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