185 lines
6.1 KiB
PHP
Executable File
185 lines
6.1 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use Illuminate\Http\Response as HttpResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class FilesController extends BaseApiController
|
|
{
|
|
protected array $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
|
|
|
|
public function receipt(string $name)
|
|
{
|
|
// 1) Path traversal guard
|
|
if ($name !== basename($name)) {
|
|
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
// 2) Allow-list extensions (optional but safer)
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, $this->allowedExtensions, true)) {
|
|
throw new NotFoundHttpException();
|
|
}
|
|
|
|
// 3) Build path under storage/app/uploads/receipts
|
|
$path = $this->storagePath("uploads/receipts/{$name}");
|
|
|
|
if (!is_file($path)) {
|
|
throw new NotFoundHttpException();
|
|
}
|
|
|
|
return $this->streamFile($path, $name);
|
|
}
|
|
|
|
public function reimb(string $name)
|
|
{
|
|
// 1) Path traversal guard
|
|
if ($name !== basename($name)) {
|
|
return $this->respondError('Invalid filename', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
// 2) Allow-list extensions
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
if (!in_array($ext, $this->allowedExtensions, true)) {
|
|
throw new NotFoundHttpException();
|
|
}
|
|
|
|
// 3) Build path under storage/app/uploads/reimbursements
|
|
$path = $this->storagePath("uploads/reimbursements/{$name}");
|
|
|
|
if (!is_file($path)) {
|
|
throw new NotFoundHttpException();
|
|
}
|
|
|
|
return $this->streamFile($path, $name);
|
|
}
|
|
|
|
/**
|
|
* Alias for reimb() for backward compatibility
|
|
*/
|
|
public function reimbursement(string $name)
|
|
{
|
|
return $this->reimb($name);
|
|
}
|
|
|
|
public function index()
|
|
{
|
|
$type = $this->request->getGet('type') ?? 'receipts';
|
|
$allowedTypes = ['receipts', 'reimbursements', 'checks'];
|
|
|
|
if (!in_array($type, $allowedTypes, true)) {
|
|
return $this->respondError('Invalid file type', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
try {
|
|
$path = $this->storagePath("uploads/{$type}/");
|
|
$files = [];
|
|
|
|
if (is_dir($path)) {
|
|
foreach (scandir($path) as $file) {
|
|
if ($file === '.' || $file === '..') {
|
|
continue;
|
|
}
|
|
$filePath = $path . $file;
|
|
if (!is_file($filePath)) {
|
|
continue;
|
|
}
|
|
$files[] = [
|
|
'name' => $file,
|
|
'size' => filesize($filePath),
|
|
'modified' => date('Y-m-d H:i:s', filemtime($filePath)),
|
|
];
|
|
}
|
|
}
|
|
|
|
return $this->success([
|
|
'type' => $type,
|
|
'files' => $files,
|
|
'count' => count($files),
|
|
], 'Files retrieved successfully');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Files list error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to retrieve files', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
protected function streamFile(string $path, string $name): HttpResponse
|
|
{
|
|
// 4) MIME detection (finfo is more reliable than mime_content_type)
|
|
$mime = 'application/octet-stream';
|
|
if (function_exists('finfo_open')) {
|
|
$fi = finfo_open(FILEINFO_MIME_TYPE);
|
|
if ($fi) {
|
|
$detected = finfo_file($fi, $path);
|
|
if ($detected) {
|
|
$mime = $detected;
|
|
}
|
|
finfo_close($fi);
|
|
}
|
|
} elseif (function_exists('mime_content_type')) {
|
|
$mime = mime_content_type($path) ?: $mime;
|
|
}
|
|
|
|
// 5) Caching (ETag + Last-Modified) and 304 support
|
|
$mtime = filemtime($path) ?: time();
|
|
$size = filesize($path) ?: 0;
|
|
$etag = md5($name . '|' . $mtime . '|' . $size);
|
|
|
|
$ifNoneMatch = trim($this->laravelRequest->header('If-None-Match', ''), '"');
|
|
$ifModifiedSince = $this->laravelRequest->header('If-Modified-Since');
|
|
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
|
|
|
|
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
|
|
($imsTime !== false && $imsTime >= $mtime)
|
|
) {
|
|
return response('', Response::HTTP_NOT_MODIFIED)
|
|
->header('ETag', $etag)
|
|
->header('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
|
|
}
|
|
|
|
// 6) Stream the file inline
|
|
return response()->make(file_get_contents($path), Response::HTTP_OK, [
|
|
'Content-Type' => $mime,
|
|
'X-Content-Type-Options' => 'nosniff',
|
|
'Content-Disposition' => 'inline; filename="' . $name . '"',
|
|
'Content-Length' => (string) $size,
|
|
'ETag' => $etag,
|
|
'Last-Modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
|
|
'Cache-Control' => 'public, max-age=86400',
|
|
]);
|
|
}
|
|
|
|
protected function storagePath(string $relative): string
|
|
{
|
|
return storage_path('app/' . $relative);
|
|
}
|
|
|
|
protected function isAllowedExtension(string $name): bool
|
|
{
|
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
|
return in_array($ext, $this->allowedExtensions, true);
|
|
}
|
|
|
|
protected function detectMime(string $path): string
|
|
{
|
|
if (function_exists('finfo_open')) {
|
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
if ($finfo) {
|
|
$mime = finfo_file($finfo, $path);
|
|
finfo_close($finfo);
|
|
if ($mime) {
|
|
return $mime;
|
|
}
|
|
}
|
|
} elseif (function_exists('mime_content_type')) {
|
|
$mime = mime_content_type($path);
|
|
if ($mime) {
|
|
return $mime;
|
|
}
|
|
}
|
|
return 'application/octet-stream';
|
|
}
|
|
}
|