middleware('auth:sanctum')->only(['delete']); } /** * Vrátí seznam nahraných souborů (metadata) pro zobrazení v UI. * Výstupem je JSON kolekce záznamů bez interního pole "path". */ public function index(Request $request): JsonResponse { $perPage = (int) $request->get('per_page', 100); $files = File::query() ->select([ 'id', 'filename', 'mimetype', 'filesize', 'hash', 'uploaded_by', 'created_at', ]) ->orderByDesc('created_at') ->paginate($perPage); return response()->json($files); } /** * Vrátí metadata konkrétního souboru jako JSON. * Path k fyzickému souboru se z bezpečnostních důvodů nevrací. * * @param \App\Models\File $file * @return \Illuminate\Http\JsonResponse */ public function show(File $file): JsonResponse { // schválně nevracím path, je to interní implementační detail return response()->json([ 'id' => $file->id, 'filename' => $file->filename, 'mimetype' => $file->mimetype, 'filesize' => $file->filesize, 'hash' => $file->hash, 'uploaded_by' => $file->uploaded_by, 'created_at' => $file->created_at, 'updated_at' => $file->updated_at, ]); } /** * Vrátí soubor ke stažení (HTTP download) s Content-Disposition: attachment. * Pokud soubor fyzicky neexistuje, vrátí HTTP 404. * * @param \App\Models\File $file * @return \Symfony\Component\HttpFoundation\StreamedResponse */ public function download(File $file): StreamedResponse { if (! Storage::exists($file->path)) { abort(404); } return Storage::download( $file->path, $this->buildDownloadName($file), ['Content-Type' => $file->mimetype] ); } /** * Vrátí obsah souboru v HTTP odpovědi (např. pro náhled nebo další zpracování). * Content-Type je převzat z uloženého mimetype v DB. * Pokud soubor neexistuje, vrátí HTTP 404. * * @param \App\Models\File $file * @return \Illuminate\Http\Response */ public function content(File $file): Response { $content = $this->getFileContent($file); return response($content, 200) ->header('Content-Type', $file->mimetype); } /** * Interní helper pro načtení obsahu souboru pro interní použití v PHP kódu. * Při neexistenci souboru by měl konzistentně signalizovat chybu * stejně jako download()/content() – buď abort(404), nebo doménovou výjimkou. * * @param \App\Models\File $file * @return string binární obsah souboru * * @throws \RuntimeException pokud soubor neexistuje (aktuální stav) */ protected function getFileContent(File $file): string { if (! Storage::exists($file->path)) { throw new \RuntimeException('File not found.'); } return Storage::get($file->path); } /** * Přijme nahraný soubor z HTTP requestu, uloží ho na disk pod UUID názvem * do dvouúrovňové adresářové struktury (první znak / první dva znaky UUID), * spočítá hash obsahu a zapíše metadata do tabulky files. * * Vrací JSON s metadaty nově vytvořeného záznamu. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\JsonResponse */ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'file' => ['required', 'file', 'max:10240'], 'round_id' => ['required', 'integer', 'exists:rounds,id'], ]); /** @var \Illuminate\Http\UploadedFile $uploaded */ $uploaded = $validated['file']; $roundId = (int) $validated['round_id']; $round = Round::find($roundId); if (! $round) { return response()->json([ 'message' => 'Kolo nebylo nalezeno.', ], 404); } if (! auth()->check()) { $deadline = $round->logs_deadline; if (! $deadline || now()->greaterThan($deadline)) { return response()->json([ 'message' => 'Termín pro nahrání logu již vypršel.', ], 403); } } $hash = hash_file('sha256', $uploaded->getRealPath()); // pokus o načtení PCall z EDI pro případnou náhradu existujícího logu $pcall = $this->extractPcallFromUploaded($uploaded); // ověř existenci v DB $existing = File::where('hash', $hash)->first(); if ($existing) { return response()->json([ 'message' => 'Duplicitní soubor již existuje.', ], 409); } // pokud existuje log se stejnou PCall v daném kole, ale jiným hashem, nahraď ho novým if ($pcall) { $existingLog = Log::with('file') ->where('round_id', $roundId) ->whereRaw('UPPER(pcall) = ?', [mb_strtoupper($pcall)]) ->first(); if ($existingLog && $existingLog->file && $existingLog->file->hash === $hash) { return response()->json([ 'message' => 'Duplicitní soubor již existuje.', ], 409); } if ($existingLog) { $this->deleteLogWithFile($existingLog); } } $uuid = (string) Str::uuid(); $extension = $uploaded->getClientOriginalExtension(); $storedFilename = $uuid . ($extension ? '.' . $extension : ''); $level1 = substr($uuid, 0, 1); $level2 = substr($uuid, 0, 2); $directory = "uploads/{$level1}/{$level2}"; if (! Storage::exists($directory)) { Storage::makeDirectory($directory); } $storedPath = $uploaded->storeAs($directory, $storedFilename); try { $file = File::create([ 'path' => $storedPath, 'filename' => $uploaded->getClientOriginalName(), 'mimetype' => $uploaded->getMimeType() ?? 'application/octet-stream', 'filesize' => $uploaded->getSize(), 'hash' => $hash, 'uploaded_by' => auth()->check() ? (string) auth()->id() : null, ]); } catch (QueryException $e) { // hash už mezitím někdo vložil return response()->json([ 'message' => 'Duplicitní soubor již existuje.', ], 409); } $log = Log::create([ 'round_id' => $roundId, 'file_id' => $file->id, 'ip_address' => $request->ip(), ]); // Předej parsování do asynchronní pipeline (ParseLogJob), // aby logiku bylo možné volat jednotně z evaluace. $claimedRun = ClaimedRunResolver::forRound($roundId); ParseLogJob::dispatch($claimedRun->id, $log->id)->onQueue('evaluation'); RecalculateClaimedRanksJob::dispatch($claimedRun->id) ->delay(now()->addSeconds(10)) ->onQueue('evaluation'); return response()->json($file, 201); } /** * Smaže fyzický soubor z disku a odpovídající metadata z DB. * Pokud soubor neexistuje, vrací 404 (pouze pokud nechceš tichý success). * * @param \App\Models\File $file * @return \Illuminate\Http\JsonResponse */ public function destroy(File $file): JsonResponse { $this->authorize('delete', $file); if (Storage::exists($file->path)) { Storage::delete($file->path); } $file->delete(); return response()->json(null, 204); } /** * Zkusí vytáhnout PCall z nahraného EDI souboru (bez plného parsování). */ protected function extractPcallFromUploaded(\Illuminate\Http\UploadedFile $uploaded): ?string { $contents = @file($uploaded->getRealPath(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); if (! $contents) { return null; } foreach ($contents as $line) { $trimmed = trim((string) $line); if (stripos($trimmed, 'PCALL=') === 0) { return trim(substr($trimmed, 6)); } } return null; } /** * Smaže log, jeho QSO a výsledky, navázaný soubor a fyzický obsah. */ protected function deleteLogWithFile(Log $log): void { $file = $log->file; $filePath = $file?->path; DB::transaction(function () use ($log, $file) { $log->logResults()->delete(); $log->qsos()->delete(); $log->delete(); if ($file) { $file->delete(); } }); if ($filePath && Storage::exists($filePath)) { Storage::delete($filePath); } } /** * Vytvoří název souboru pro download ve formátu XXCALLSIGN_HASH.edi */ protected function buildDownloadName(File $file): string { $log = Log::where('file_id', $file->id)->first(); if (! $log) { return $file->filename; } $pcall = strtoupper(trim($log->pcall ?? '')); if ($pcall === '') { return $file->filename; } $psect = strtoupper(trim($log->psect ?? '')); $tokens = preg_split('/[\s;,_-]+/', $psect) ?: []; $hasCheck = in_array('CHECK', $tokens, true) || $psect === 'CHECK'; $sixHour = ($log->sixhr_category ?? false) || in_array('6H', $tokens, true); $isSO = in_array('SO', $tokens, true) || in_array('SOLO', $tokens, true); $isMO = in_array('MO', $tokens, true) || in_array('MULTI', $tokens, true); $prefix = ''; if (! $hasCheck) { if ($sixHour && $isSO) { $prefix = '61'; } elseif ($sixHour && $isMO) { $prefix = '62'; } elseif ($isSO) { $prefix = '01'; } elseif ($isMO) { $prefix = '02'; } } $hashPart = strtoupper(substr(hash('crc32', $file->hash ?? (string) $file->id), 0, 8)); return ($prefix ? $prefix : '') . $pcall . '_' . $hashPart . '.edi'; } }