349 lines
11 KiB
PHP
349 lines
11 KiB
PHP
<?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';
|
||
}
|
||
}
|