Initial commit

This commit is contained in:
Zdeněk Burda
2026-01-09 21:26:40 +01:00
parent e83aec6dca
commit 41e3ce6f25
404 changed files with 61250 additions and 28 deletions

View File

@@ -0,0 +1,348 @@
<?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';
}
}