Files
alrahma_sunday_school_api/app/Http/Controllers/Api/FilesController.php
T
2026-03-05 12:29:37 -05:00

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';
}
}