Files
vkv/app/Http/Controllers/FileController.php
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

349 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use App\Models\File;
use App\Models\Log;
use App\Models\Round;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use App\Services\Evaluation\ClaimedRunResolver;
use App\Jobs\ParseLogJob;
use App\Jobs\RecalculateClaimedRanksJob;
class FileController extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
public function __construct()
{
$this->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';
}
}