429 lines
16 KiB
PHP
429 lines
16 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();
|
|
}
|
|
|
|
if (!$this->canAccessExamDraftFile($name, $subdir)) {
|
|
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
|
}
|
|
|
|
$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
|
|
{
|
|
$db = Database::connect();
|
|
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
|
$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);
|
|
}
|
|
|
|
private function resolveExamDraftFileColumn($db): string
|
|
{
|
|
try {
|
|
$fields = $db->getFieldNames('exam_drafts');
|
|
if (in_array('teacher_file', $fields, true)) {
|
|
return 'teacher_file';
|
|
}
|
|
if (in_array('author_file', $fields, true)) {
|
|
return 'author_file';
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'FilesController::resolveExamDraftFileColumn error: ' . $e->getMessage());
|
|
}
|
|
return 'teacher_file';
|
|
}
|
|
|
|
private function resolveExamDraftAuthorIdColumn($db): string
|
|
{
|
|
try {
|
|
$fields = $db->getFieldNames('exam_drafts');
|
|
if (in_array('teacher_id', $fields, true)) {
|
|
return 'teacher_id';
|
|
}
|
|
if (in_array('author_id', $fields, true)) {
|
|
return 'author_id';
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'FilesController::resolveExamDraftAuthorIdColumn error: ' . $e->getMessage());
|
|
}
|
|
|
|
return 'teacher_id';
|
|
}
|
|
|
|
private function canAccessExamDraftFile(string $filename, string $subdir): bool
|
|
{
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
if ($userId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$role = strtolower((string) (session()->get('role') ?? ''));
|
|
if (in_array($role, ['admin', 'administrator', 'principal'], true)) {
|
|
return true;
|
|
}
|
|
|
|
$db = Database::connect();
|
|
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
|
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
|
|
|
|
$draft = $db->table('exam_drafts ed')
|
|
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
|
|
->where('ed.' . $fileColumn, $filename)
|
|
->limit(1)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
if (empty($draft)) {
|
|
return false;
|
|
}
|
|
|
|
$authorId = (int) ($draft['draft_author_id'] ?? 0);
|
|
if ($authorId > 0 && $authorId === $userId) {
|
|
return true;
|
|
}
|
|
|
|
$classSectionId = (int) ($draft['class_section_id'] ?? 0);
|
|
if ($classSectionId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$status = strtolower(trim((string) ($draft['status'] ?? '')));
|
|
if ($subdir === 'drafts' && $status === 'draft') {
|
|
return false;
|
|
}
|
|
|
|
$currentSchoolYear = trim((string) (session()->get('school_year') ?? ''));
|
|
$currentSemester = trim((string) (session()->get('semester') ?? ''));
|
|
$draftSchoolYear = trim((string) ($draft['school_year'] ?? ''));
|
|
$draftSemester = trim((string) ($draft['semester'] ?? ''));
|
|
|
|
$hasCurrentAssignment = $db->table('teacher_class')
|
|
->select('id')
|
|
->where('teacher_id', $userId)
|
|
->where('class_section_id', $classSectionId);
|
|
|
|
if ($currentSchoolYear !== '') {
|
|
$hasCurrentAssignment->where('school_year', $currentSchoolYear);
|
|
}
|
|
|
|
$hasCurrentAssignment = $hasCurrentAssignment->limit(1)->countAllResults() > 0;
|
|
|
|
if ($hasCurrentAssignment) {
|
|
if ($subdir === 'finals') {
|
|
return true;
|
|
}
|
|
|
|
return $draftSchoolYear === '' || $currentSchoolYear === '' || $draftSchoolYear === $currentSchoolYear;
|
|
}
|
|
|
|
$assignmentQuery = $db->table('teacher_class')
|
|
->select('id')
|
|
->where('teacher_id', $userId)
|
|
->where('class_section_id', $classSectionId);
|
|
|
|
if ($draftSchoolYear !== '') {
|
|
$assignmentQuery->where('school_year', $draftSchoolYear);
|
|
}
|
|
|
|
$hasDraftYearAssignment = $assignmentQuery->limit(1)->countAllResults() > 0;
|
|
if (!$hasDraftYearAssignment) {
|
|
return false;
|
|
}
|
|
|
|
if ($subdir === 'finals') {
|
|
return true;
|
|
}
|
|
|
|
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
|
}
|
|
}
|