Files
2026-03-08 16:33:24 -04:00

305 lines
12 KiB
PHP

<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Database;
class FilesController extends Controller
{
public function receipt(string $name)
{
// 1) Path traversal guard
if ($name !== basename($name)) {
return $this->response->setStatusCode(400, 'Invalid filename');
}
// 2) Allow-list extensions (optional but safer)
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
if (!in_array($ext, $allowed, true)) {
throw PageNotFoundException::forPageNotFound();
}
// 3) Build path under writable
$path = WRITEPATH . 'uploads/receipts/' . $name;
if (!is_file($path)) {
throw PageNotFoundException::forPageNotFound();
}
// 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);
}
}
// 5) Caching (ETag + Last-Modified) and 304 support
$mtime = filemtime($path) ?: time();
$etag = md5($name . '|' . $mtime . '|' . filesize($path));
$ifNoneMatch = $this->request->getHeaderLine('If-None-Match');
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
if ($ifNoneMatch === $etag || (strtotime($ifModifiedSince) >= $mtime)) {
return $this->response
->setStatusCode(304)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
// 6) Stream the file inline
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', $mime)
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
->setHeader('Content-Length', (string) filesize($path))
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setBody(file_get_contents($path));
}
public function reimb(string $name)
{
// 1) Path traversal guard
if ($name !== basename($name)) {
return $this->response->setStatusCode(400, 'Invalid filename');
}
// 2) Allow-list extensions
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
if (!in_array($ext, $allowed, true)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
// 3) Build path under writable (REIMBURSEMENTS)
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
if (!is_file($path)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
// 4) MIME detection (prefer finfo)
$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->request->getHeaderLine('If-None-Match'), '"');
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
($imsTime !== false && $imsTime >= $mtime)
) {
return $this->response
->setStatusCode(304)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
// 6) Stream the file inline
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', $mime)
->setHeader('X-Content-Type-Options', 'nosniff')
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
->setHeader('Content-Length', (string) $size)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setBody(file_get_contents($path));
}
public function earlyDismissalSignature(string $name)
{
// 1) Path traversal guard
if ($name !== basename($name)) {
return $this->response->setStatusCode(400, 'Invalid filename');
}
// 2) Allow-list extensions
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'];
if (!in_array($ext, $allowed, true)) {
throw PageNotFoundException::forPageNotFound();
}
// 3) Build path under writable (EARLY DISMISSAL SIGNATURES)
$path = WRITEPATH . 'uploads/early_dismissal_signatures/' . $name;
if (!is_file($path)) {
throw PageNotFoundException::forPageNotFound();
}
// 4) MIME detection (prefer finfo)
$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->request->getHeaderLine('If-None-Match'), '"');
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
($imsTime !== false && $imsTime >= $mtime)
) {
return $this->response
->setStatusCode(304)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
// 6) Stream the file inline
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', $mime)
->setHeader('X-Content-Type-Options', 'nosniff')
->setHeader('Content-Disposition', 'inline; filename="' . $name . '"')
->setHeader('Content-Length', (string) $size)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setBody(file_get_contents($path));
}
public function examDraftTeacher(string $name)
{
return $this->serveExamDraftFile($name, 'drafts');
}
public function examDraftFinal(string $name)
{
return $this->serveExamDraftFile($name, 'finals');
}
private function serveExamDraftFile(string $name, string $subdir)
{
if ($name !== basename($name)) {
return $this->response->setStatusCode(400, 'Invalid filename');
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$allowed = ['doc', 'docx', 'pdf'];
if (!in_array($ext, $allowed, true)) {
throw PageNotFoundException::forPageNotFound();
}
$path = WRITEPATH . 'uploads/exams/' . trim($subdir, '/') . '/' . $name;
if (!is_file($path)) {
throw PageNotFoundException::forPageNotFound();
}
$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;
}
$mtime = filemtime($path) ?: time();
$size = filesize($path) ?: 0;
$etag = md5($name . '|' . $mtime . '|' . $size);
$ifNoneMatch = trim($this->request->getHeaderLine('If-None-Match'), '"');
$ifModifiedSince = $this->request->getHeaderLine('If-Modified-Since');
$imsTime = $ifModifiedSince ? strtotime($ifModifiedSince) : false;
if (($ifNoneMatch && $ifNoneMatch === $etag) ||
($imsTime !== false && $imsTime >= $mtime)
) {
return $this->response
->setStatusCode(304)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
$downloadName = $this->buildDraftDownloadName($name, $subdir);
return $this->response
->setStatusCode(200)
->setHeader('Content-Type', $mime)
->setHeader('X-Content-Type-Options', 'nosniff')
->setHeader('Content-Disposition', 'inline; filename="' . $downloadName . '.' . $ext . '"')
->setHeader('Content-Length', (string) $size)
->setHeader('ETag', $etag)
->setHeader('Last-Modified', gmdate('D, d M Y H:i:s', $mtime) . ' GMT')
->setHeader('Cache-Control', 'public, max-age=86400')
->setBody(file_get_contents($path));
}
private function buildDraftDownloadName(string $filename, string $subdir): string
{
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
$db = Database::connect();
$row = $db->table('exam_drafts ed')
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
->where('ed.' . $column, $filename)
->limit(1)
->get()
->getRowArray();
$classLabel = trim((string) ($row['class_section_name'] ?? 'Class' . ($row['class_section_id'] ?? '0')));
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
$parts = array_filter([
$this->slugify($classLabel),
$this->slugify($typeLabel),
$version,
]);
return implode('_', $parts);
}
private function slugify(string $value): string
{
$value = trim($value);
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
$value = trim($value, '_');
return $value === '' ? 'Exam' : mb_strtolower($value);
}
}