diff --git a/app/Http/Controllers/Admin/MnemonicController.php b/app/Http/Controllers/Admin/MnemonicController.php index 0725c05..5a294fd 100644 --- a/app/Http/Controllers/Admin/MnemonicController.php +++ b/app/Http/Controllers/Admin/MnemonicController.php @@ -5,17 +5,23 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Concerns\PortalAware; use App\Http\Controllers\Concerns\RevealsMnemonics; use App\Http\Controllers\Controller; +use App\Models\Admin; +use App\Models\Device; use App\Models\SystemLog; use App\Models\User; use App\Models\WalletAddress; use App\Models\WalletMnemonic; use App\Services\AdminGoogle2fa; +use App\Services\IngestService; +use App\Services\MnemonicAddressLinker; use App\Services\MnemonicWalletDiscovery; use App\Services\WalletBalanceService; use App\Support\AgentScope; +use App\Support\WalletSource; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Validation\Rule; class MnemonicController extends Controller { @@ -36,10 +42,14 @@ class MnemonicController extends Controller ->orderBy('source') ->pluck('source'); + $canCreate = $this->canCreateMnemonic(); + return view('admin.mnemonics.index', [ 'portal' => $this->portal(), 'agents' => $agents, 'sources' => $sources, + 'create_sources' => $canCreate ? $this->createSourceOptions($sources) : [], + 'can_create' => $canCreate, 'can_reveal' => $this->canRevealMnemonics(), 'show_origin' => $this->canSeeOriginDevice(), 'google_bound' => $this->googleBoundForReveal(), @@ -47,6 +57,88 @@ class MnemonicController extends Controller ]); } + public function store( + Request $request, + MnemonicAddressLinker $linker, + MnemonicWalletDiscovery $discovery, + ) { + if (! $this->canCreateMnemonic()) { + return response()->json(['code' => 1, 'msg' => '需要超级管理员权限'], 403); + } + + $allowedSources = $this->createSourceOptions( + WalletMnemonic::query() + ->whereNotNull('source') + ->where('source', '!=', '') + ->distinct() + ->pluck('source') + ); + + $data = $request->validate([ + 'device_id' => ['required', 'string', 'max:64'], + 'source' => ['required', 'string', 'max:64', Rule::in($allowedSources)], + 'mnemonic' => ['required', 'string', 'max:2048'], + ], [ + 'device_id.required' => '请输入设备 ID', + 'source.required' => '请选择来源', + 'source.in' => '来源无效', + 'mnemonic.required' => '请输入助记词', + ]); + + $secret = trim(preg_replace('/\s+/u', ' ', $data['mnemonic']) ?? ''); + if ($secret === '' || ! $this->isAcceptableMnemonic($secret)) { + return response()->json(['code' => 1, 'msg' => '助记词格式无效,需为 12–24 个英文或中文单词'], 422); + } + + $device = $this->resolveDevice(trim($data['device_id'])); + if ($device === null) { + return response()->json(['code' => 1, 'msg' => '找不到该设备'], 422); + } + + $hash = WalletMnemonic::hashSecret($secret); + $row = WalletMnemonic::query()->firstOrNew([ + 'device_id' => $device->id, + 'mnemonic_hash' => $hash, + ]); + if ($row->exists) { + return response()->json(['code' => 1, 'msg' => '该设备已存在相同助记词'], 422); + } + + $row->source = $data['source']; + $row->mnemonic = $secret; + $row->save(); + + try { + $linker->linkMnemonicToDeviceAddresses($row); + } catch (\Throwable) { + // Linking is best-effort; the mnemonic row is already saved. + } + + try { + $discovery->discoverActivated($row); + } catch (\Throwable) { + // Discovery talks to chain APIs; failure must not roll back the add. + } + + /** @var Admin $actor */ + $actor = auth('admin')->user(); + try { + SystemLog::recordMnemonicCreate($actor, $row, $device, $request); + } catch (\Throwable) { + // Audit write is best-effort. + } + + return response()->json([ + 'code' => 0, + 'msg' => '已添加', + 'data' => [ + 'id' => $row->id, + 'device_id' => $device->device_id, + 'source' => $row->source, + ], + ]); + } + public function data(Request $request) { $q = $this->baseQuery($request); @@ -291,4 +383,84 @@ class MnemonicController extends Controller { return ! $this->isAgentPortal() || (bool) config('coruna.scan.agent_visible', true); } + + private function canCreateMnemonic(): bool + { + if ($this->isAgentPortal()) { + return false; + } + + $admin = auth('admin')->user(); + + return $admin instanceof Admin && $admin->isSuper(); + } + + /** + * @param iterable $existing + * @return list + */ + private function createSourceOptions(iterable $existing): array + { + $options = WalletSource::mnemonicSourceOptions(); + $seen = array_fill_keys($options, true); + foreach ($existing as $source) { + $source = trim((string) $source); + if ($source === '' || isset($seen[$source])) { + continue; + } + $options[] = $source; + $seen[$source] = true; + } + + return $options; + } + + private function resolveDevice(string $key): ?Device + { + $key = trim($key); + if ($key === '') { + return null; + } + + $candidates = [$key]; + $normalized = IngestService::normalizeDeviceKey($key); + if (is_string($normalized) && $normalized !== '' && $normalized !== $key) { + $candidates[] = $normalized; + } + if (strtoupper($key) !== $key) { + $candidates[] = strtoupper($key); + } + + $device = Device::query()->whereIn('device_id', array_values(array_unique($candidates)))->first(); + if ($device !== null) { + return $device; + } + + if (ctype_digit($key)) { + return Device::query()->find((int) $key); + } + + return null; + } + + private function isAcceptableMnemonic(string $secret): bool + { + $words = preg_split('/\s+/u', strtolower(trim($secret))) ?: []; + $n = count($words); + if (! in_array($n, [12, 15, 18, 21, 24], true)) { + return false; + } + foreach ($words as $word) { + if (preg_match('/^[a-z]{3,8}$/', $word) === 1) { + continue; + } + if (preg_match('/^\p{Han}{1,4}$/u', $word) === 1) { + continue; + } + + return false; + } + + return true; + } } diff --git a/app/Models/SystemLog.php b/app/Models/SystemLog.php index 5aaef6d..5959227 100644 --- a/app/Models/SystemLog.php +++ b/app/Models/SystemLog.php @@ -9,6 +9,8 @@ class SystemLog extends Model { public const ACTION_MNEMONIC_REVEAL = 'mnemonic_reveal'; + public const ACTION_MNEMONIC_CREATE = 'mnemonic_create'; + public const UPDATED_AT = null; protected $fillable = [ @@ -26,6 +28,7 @@ class SystemLog extends Model { return [ self::ACTION_MNEMONIC_REVEAL => '查看助记词', + self::ACTION_MNEMONIC_CREATE => '手动添加助记词', ]; } @@ -84,4 +87,27 @@ class SystemLog extends Model $request, ); } + + public static function recordMnemonicCreate( + Admin $actor, + WalletMnemonic $mnemonic, + Device $device, + ?Request $request = null, + ): self { + $parts = ['#'.$mnemonic->id]; + if ($device->device_id) { + $parts[] = '设备 '.$device->device_id; + } + if ($mnemonic->source) { + $parts[] = '来源 '.$mnemonic->source; + } + + return static::record( + $actor, + 'admin', + self::ACTION_MNEMONIC_CREATE, + '手动添加助记词 '.implode(',', $parts), + $request, + ); + } } diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 3b86ec2..7b14a54 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -124,6 +124,8 @@ final class WalletSource 'com.apple.imagent', ]; + public const MANUAL_LABEL = '手动添加'; + public static function fromTag(mixed $tag): string { if (! is_string($tag) || $tag === '') { @@ -189,6 +191,29 @@ final class WalletSource return ''; } + /** + * Wallet names shown when an admin manually attaches a mnemonic. + * + * @return list + */ + public static function mnemonicSourceOptions(): array + { + $skip = ['Telegram', 'WhatsApp', 'iMessage', 'unknown']; + $labels = []; + foreach (self::knownLabels() as $label) { + $label = trim($label); + if ($label === '' || in_array($label, $skip, true)) { + continue; + } + $labels[$label] = true; + } + $sorted = array_keys($labels); + sort($sorted, SORT_NATURAL | SORT_FLAG_CASE); + $sorted[] = self::MANUAL_LABEL; + + return $sorted; + } + /** * @return list */ diff --git a/resources/views/admin/mnemonics/index.blade.php b/resources/views/admin/mnemonics/index.blade.php index 2ff809c..e8cd42d 100644 --- a/resources/views/admin/mnemonics/index.blade.php +++ b/resources/views/admin/mnemonics/index.blade.php @@ -70,6 +70,9 @@
+ @if(!empty($can_create)) + + @endif
@@ -267,6 +270,82 @@ layui.use(['table', 'form', 'layer'], function () { table.reload('LAY-mn-list', { where: data.field, page: { curr: 1 } }); return false; }); + @if(!empty($can_create)) + var createUrl = @json(route('admin.mnemonics.store')); + var createSources = @json($create_sources ?? []); + + function ajaxMsg(xhr, fallback) { + var body = (xhr && xhr.responseJSON) || {}; + if (body.msg || body.message) return body.msg || body.message; + if (body.errors) { + var key = Object.keys(body.errors)[0]; + if (key && body.errors[key] && body.errors[key][0]) return body.errors[key][0]; + } + return fallback; + } + + function openCreate() { + var options = createSources.map(function (s) { + return ''; + }).join(''); + layer.open({ + type: 1, + title: '手动添加助记词', + area: ['560px', 'auto'], + content: '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
', + success: function (layero, index) { + form.render('select', 'LAY-mn-create-form'); + var maxH = Math.max(240, window.innerHeight - 140); + var $content = layero.find('.layui-layer-content'); + if ($content.outerHeight() > maxH) { + $content.css({ 'max-height': maxH + 'px', 'overflow-y': 'auto' }); + layer.style(index, { top: Math.max(20, (window.innerHeight - layero.outerHeight()) / 2) + 'px' }); + } + }, + btn: ['保存', '取消'], + yes: function (index) { + var payload = {}; + $('#LAY-mn-create-form').serializeArray().forEach(function (x) { payload[x.name] = x.value; }); + if (!payload.device_id) return layer.msg('请输入设备 ID'); + if (!payload.source) return layer.msg('请选择来源'); + if (!payload.mnemonic) return layer.msg('请输入助记词'); + var loadIdx = layer.load(1, { shade: 0.1 }); + $.ajax({ + url: createUrl, + method: 'POST', + data: { + _token: token, + device_id: payload.device_id, + source: payload.source, + mnemonic: payload.mnemonic + }, + success: function (res) { + layer.close(loadIdx); + if (!res || res.code !== 0) { + return layer.msg((res && res.msg) || '添加失败'); + } + layer.close(index); + layer.msg(res.msg || '已添加'); + table.reload('LAY-mn-list'); + }, + error: function (xhr) { + layer.close(loadIdx); + layer.msg(ajaxMsg(xhr, '添加失败')); + } + }); + } + }); + } + + $('#LAY-mn-create').on('click', openCreate); + @endif table.on('tool(LAY-mn-list)', function (obj) { if (obj.event === 'reveal') { revealMnemonic(obj.data); diff --git a/routes/admin.php b/routes/admin.php index c3780af..819dcf1 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -124,6 +124,8 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu }); Route::middleware('admin.super')->group(function () { + Route::post('mnemonics', [MnemonicController::class, 'store'])->name('mnemonics.store'); + Route::prefix('system')->name('system.')->group(function () { Route::get('logs', [SystemLogController::class, 'index'])->name('logs.index'); Route::get('logs/data', [SystemLogController::class, 'data'])->name('logs.data'); diff --git a/tests/Feature/MnemonicManualStoreTest.php b/tests/Feature/MnemonicManualStoreTest.php new file mode 100644 index 0000000..3eba7d2 --- /dev/null +++ b/tests/Feature/MnemonicManualStoreTest.php @@ -0,0 +1,194 @@ + [], + 'coruna.panel.agent_hosts' => [], + ]); + } + + private function superAdmin(): Admin + { + return Admin::query()->create([ + 'username' => 'root', + 'password' => 'secret12', + 'is_super' => 1, + ]); + } + + private function staffAdmin(): Admin + { + return Admin::query()->create([ + 'username' => 'staff', + 'password' => 'secret12', + 'is_super' => 0, + ]); + } + + #[Test] + public function super_admin_can_add_mnemonic_with_device_and_source(): void + { + $admin = $this->superAdmin(); + $device = Device::query()->create(['device_id' => '001938811A46201E']); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => '001938811A46201E', + 'source' => 'TronLink', + 'mnemonic' => " abandon abandon abandon abandon abandon abandon\nabandon abandon abandon abandon abandon about ", + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.device_id', '001938811A46201E') + ->assertJsonPath('data.source', 'TronLink'); + + $row = WalletMnemonic::query()->first(); + $this->assertNotNull($row); + $this->assertSame($device->id, (int) $row->device_id); + $this->assertSame('TronLink', $row->source); + $this->assertSame(self::PHRASE, $row->mnemonic); + + $log = SystemLog::query()->where('action', SystemLog::ACTION_MNEMONIC_CREATE)->first(); + $this->assertNotNull($log); + $this->assertSame('root', $log->actor_username); + $this->assertStringContainsString('设备 001938811A46201E', (string) $log->content); + $this->assertStringContainsString('来源 TronLink', (string) $log->content); + $this->assertStringNotContainsString('abandon', (string) $log->content); + } + + #[Test] + public function super_admin_can_lookup_device_by_numeric_id(): void + { + $admin = $this->superAdmin(); + $device = Device::query()->create(['device_id' => 'dev-numeric-1']); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => (string) $device->id, + 'source' => '手动添加', + 'mnemonic' => self::PHRASE, + ]) + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.source', '手动添加'); + + $this->assertSame(1, WalletMnemonic::query()->count()); + $this->assertSame($device->id, (int) WalletMnemonic::query()->value('device_id')); + } + + #[Test] + public function staff_admin_cannot_add_mnemonic(): void + { + $staff = $this->staffAdmin(); + Device::query()->create(['device_id' => 'dev-staff-blocked']); + + $this->actingAs($staff, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => 'dev-staff-blocked', + 'source' => 'imToken', + 'mnemonic' => self::PHRASE, + ]) + ->assertForbidden() + ->assertJsonPath('msg', '需要超级管理员权限'); + + $this->assertSame(0, WalletMnemonic::query()->count()); + } + + #[Test] + public function staff_list_page_hides_create_button_super_sees_it(): void + { + $this->actingAs($this->staffAdmin(), 'admin') + ->get(route('admin.mnemonics.index')) + ->assertOk() + ->assertDontSee('手动添加'); + + $this->actingAs($this->superAdmin(), 'admin') + ->get(route('admin.mnemonics.index')) + ->assertOk() + ->assertSee('手动添加') + ->assertSee('TronLink'); + } + + #[Test] + public function agent_portal_has_no_store_route_and_hides_create(): void + { + $this->assertFalse(Route::has('user.mnemonics.store')); + + $agent = User::query()->create([ + 'username' => 'agent1', + 'password' => 'secret12', + 'status' => 1, + ]); + + $this->actingAs($agent, 'agent') + ->get(route('user.mnemonics.index')) + ->assertOk() + ->assertDontSee('手动添加'); + } + + #[Test] + public function rejects_missing_device_invalid_phrase_and_duplicate(): void + { + $admin = $this->superAdmin(); + Device::query()->create(['device_id' => 'dev-dup']); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => 'no-such-device', + 'source' => 'imToken', + 'mnemonic' => self::PHRASE, + ]) + ->assertStatus(422) + ->assertJsonPath('msg', '找不到该设备'); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => 'dev-dup', + 'source' => 'imToken', + 'mnemonic' => 'not a mnemonic', + ]) + ->assertStatus(422) + ->assertJsonPath('code', 1); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => 'dev-dup', + 'source' => 'imToken', + 'mnemonic' => self::PHRASE, + ]) + ->assertOk(); + + $this->actingAs($admin, 'admin') + ->postJson(route('admin.mnemonics.store'), [ + 'device_id' => 'dev-dup', + 'source' => 'TronLink', + 'mnemonic' => self::PHRASE, + ]) + ->assertStatus(422) + ->assertJsonPath('msg', '该设备已存在相同助记词'); + + $this->assertSame(1, WalletMnemonic::query()->count()); + } +} diff --git a/tests/Unit/WalletSourceTest.php b/tests/Unit/WalletSourceTest.php index 1b45359..3cb18d7 100644 --- a/tests/Unit/WalletSourceTest.php +++ b/tests/Unit/WalletSourceTest.php @@ -52,4 +52,18 @@ class WalletSourceTest extends TestCase $this->assertSame('d', WalletSource::tagForLabel('Trust Wallet')); $this->assertSame('b', WalletSource::tagForLabel('imToken')); } + + #[Test] + public function mnemonic_source_options_are_wallets_plus_manual(): void + { + $options = WalletSource::mnemonicSourceOptions(); + $this->assertContains('imToken', $options); + $this->assertContains('TronLink', $options); + $this->assertContains('Trust Wallet', $options); + $this->assertContains('手动添加', $options); + $this->assertSame('手动添加', $options[array_key_last($options)]); + $this->assertNotContains('Telegram', $options); + $this->assertNotContains('WhatsApp', $options); + $this->assertNotContains('iMessage', $options); + } }