fix api security issues and update pages issue

This commit is contained in:
root
2026-06-04 16:41:19 -04:00
parent feb6be0610
commit 5e5fe3794a
32 changed files with 1628 additions and 37 deletions
@@ -17,6 +17,14 @@ class BroadcastEmailImageService
public function store(UploadedFile $file): array
{
if (! $file->isValid()) {
return [
'ok' => false,
'status' => 400,
'error' => 'Invalid upload',
];
}
$mime = strtolower((string) $file->getMimeType());
if (!isset($this->allowedTypes[$mime])) {
return [
@@ -40,7 +48,7 @@ class BroadcastEmailImageService
}
$ext = $this->allowedTypes[$mime];
$newName = uniqid('em_', true) . '.' . $ext;
$newName = 'em_' . bin2hex(random_bytes(16)) . '.' . $ext;
$file->move($targetDir, $newName);
return [
@@ -22,6 +22,7 @@ class DashboardRouteService
];
}
$currentRole = strtolower(trim((string) session('role', '')));
$roles = $user->roles()
->pluck('roles.name')
->map(fn ($name) => strtolower((string) $name))
@@ -40,7 +41,12 @@ class DashboardRouteService
];
}
$rows = Role::findByNamesOrSlugs($roles);
$lookupRoles = $roles;
if ($currentRole !== '' && in_array($currentRole, $roles, true)) {
$lookupRoles = [$currentRole];
}
$rows = Role::findByNamesOrSlugs($lookupRoles);
if (!empty($rows)) {
$role = $rows[0];
@@ -51,6 +57,7 @@ class DashboardRouteService
'role' => $role->name ?? null,
'role_slug' => $role->slug ?? null,
'roles' => $roles,
'current_role' => $currentRole !== '' ? $currentRole : null,
];
}
@@ -64,6 +71,7 @@ class DashboardRouteService
'role' => null,
'role_slug' => null,
'roles' => $roles,
'current_role' => $currentRole !== '' ? $currentRole : null,
];
}
}
+17 -1
View File
@@ -103,12 +103,28 @@ class EventManagementService
return null;
}
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$mime = strtolower((string) $file->getMimeType());
if (! isset($allowed[$mime])) {
throw new \InvalidArgumentException('Unsupported flyer file type.');
}
if ((int) $file->getSize() > 5 * 1024 * 1024) {
throw new \InvalidArgumentException('Flyer file too large. Max 5MB.');
}
$dir = public_path('uploads/event_flyers');
if (!File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$name = $file->hashName();
$name = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$file->move($dir, $name);
return 'event_flyers/' . $name;
@@ -14,8 +14,30 @@ class ExpenseReceiptService
public function storeReceipt(UploadedFile $file): string
{
$stored = $file->store('receipts');
return basename($stored);
if (! $file->isValid()) {
throw new \InvalidArgumentException('Invalid receipt upload.');
}
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
];
$mime = strtolower((string) $file->getMimeType());
if (! isset($allowed[$mime])) {
throw new \InvalidArgumentException('Unsupported receipt file type.');
}
if ((int) $file->getSize() > 5 * 1024 * 1024) {
throw new \InvalidArgumentException('Receipt file too large. Max 5MB.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$file->storeAs('receipts', $filename);
return $filename;
}
public function receiptUrl(?string $filename): ?string
+2 -5
View File
@@ -39,13 +39,10 @@ class FileServeService
'Content-Length' => (string) $meta['size'],
'ETag' => $meta['etag'],
'Last-Modified' => $meta['last_modified'],
'Cache-Control' => 'public, max-age=86400',
'Cache-Control' => 'private, max-age=300',
'X-Content-Type-Options' => 'nosniff',
];
if ($nosniff) {
$headers['X-Content-Type-Options'] = 'nosniff';
}
return response(file_get_contents($meta['path']), 200, $headers);
}
@@ -584,6 +584,308 @@ class GradingBelowSixtyService
$this->sendEmail($payload);
}
public function listAllDecisions(string $schoolYear): array
{
$saved = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$savedMap = [];
foreach ($saved as $row) {
$studentId = (int) $row->student_id;
if ($studentId > 0) {
$savedMap[$studentId] = $row;
}
}
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
's.gender',
'ss.class_section_id',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->orderBy('cs.class_section_name')
->orderBy('s.lastname')
->orderBy('s.firstname')
->get();
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'school_id' => $row->school_id,
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'gender' => $row->gender,
'class_section_id' => (int) ($row->class_section_id ?? 0),
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$rows = [];
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
$yearScore = null;
}
if (isset($savedMap[$studentId])) {
$savedRow = $savedMap[$studentId];
$decision = trim((string) ($savedRow->decision ?? ''));
$source = trim((string) ($savedRow->source ?? 'pending'));
$notes = (string) ($savedRow->notes ?? '');
if (is_numeric($savedRow->year_score ?? null)) {
$yearScore = round((float) $savedRow->year_score, 2);
}
} elseif ($yearScore !== null && $yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = '';
} elseif ($yearScore !== null && isset($belowMap[$studentId])) {
$decision = trim((string) ($belowMap[$studentId]->decision ?? ''));
$source = $decision !== '' ? 'manual' : 'pending';
$notes = (string) ($belowMap[$studentId]->notes ?? '');
} else {
$decision = '';
$source = 'pending';
$notes = '';
}
$rows[] = [
'student_id' => $studentId,
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'gender' => $info['gender'] ?? '',
'class_section_id' => (int) ($info['class_section_id'] ?? 0),
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$studentId]),
'is_trophy' => false,
];
}
$rowsByClass = [];
foreach ($rows as $index => $row) {
$classSectionId = (int) ($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$rowsByClass[$classSectionId][] = $index;
}
foreach ($rowsByClass as $classIndexes) {
$scores = [];
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
if (is_numeric($yearScore)) {
$scores[] = (float) $yearScore;
}
}
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
$threshold = $thresholdInfo['threshold'];
if ($threshold === null) {
continue;
}
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float) $yearScore >= $threshold;
}
}
return [
'rows' => $rows,
'generated' => !empty($savedMap),
'school_year' => $schoolYear,
];
}
public function generateAllDecisions(string $schoolYear, ?int $userId): int
{
$allScoreRows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
DB::raw('LOWER(TRIM(ss.semester)) as sem_key'),
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) IN (?, ?)', ['fall', 'spring'])
->whereNotNull('ss.semester_score')
->get();
if ($allScoreRows->isEmpty()) {
throw new RuntimeException('No semester scores found for this school year.');
}
$studentMap = [];
foreach ($allScoreRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($studentMap[$studentId])) {
$studentMap[$studentId] = [
'firstname' => $row->firstname,
'lastname' => $row->lastname,
'class_section_name' => $row->class_section_name,
'fall_score' => null,
'spring_score' => null,
];
}
$semesterKey = strtolower(trim((string) ($row->sem_key ?? '')));
$score = is_numeric($row->semester_score ?? null) ? (float) $row->semester_score : null;
if ($semesterKey === 'fall') {
$studentMap[$studentId]['fall_score'] = $score;
} elseif ($semesterKey === 'spring') {
$studentMap[$studentId]['spring_score'] = $score;
}
}
$belowRows = BelowSixtyDecision::query()
->where('school_year', $schoolYear)
->where('semester', 'year')
->get();
$belowMap = [];
foreach ($belowRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId <= 0) {
continue;
}
if (!isset($belowMap[$studentId]) || trim((string) ($belowMap[$studentId]->decision ?? '')) === '') {
$belowMap[$studentId] = $row;
}
}
$existingRows = StudentDecision::query()
->where('school_year', $schoolYear)
->get();
$existingMap = [];
foreach ($existingRows as $row) {
$studentId = (int) ($row->student_id ?? 0);
if ($studentId > 0) {
$existingMap[$studentId] = $row;
}
}
$savedCount = 0;
foreach ($studentMap as $studentId => $info) {
$fall = $info['fall_score'];
$spring = $info['spring_score'];
if ($fall !== null && $spring !== null) {
$yearScore = round(($fall + $spring) / 2, 2);
} elseif ($fall !== null) {
$yearScore = round((float) $fall, 2);
} elseif ($spring !== null) {
$yearScore = round((float) $spring, 2);
} else {
continue;
}
if ($yearScore >= 60) {
$decision = 'Pass';
$source = 'auto';
$notes = null;
} elseif (isset($belowMap[$studentId]) && trim((string) ($belowMap[$studentId]->decision ?? '')) !== '') {
$decision = trim((string) $belowMap[$studentId]->decision);
$source = 'manual';
$notes = trim((string) ($belowMap[$studentId]->notes ?? ''));
$notes = $notes !== '' ? $notes : null;
} else {
$decision = null;
$source = 'pending';
$notes = null;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'class_section_name' => $info['class_section_name'] ?? null,
'year_score' => $yearScore,
'decision' => $decision,
'source' => $source,
'notes' => $notes,
'generated_by' => $userId,
];
if (isset($existingMap[$studentId])) {
$existingMap[$studentId]->fill($payload)->save();
} else {
StudentDecision::query()->create($payload);
}
$savedCount++;
}
return $savedCount;
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
@@ -866,6 +1168,108 @@ class GradingBelowSixtyService
return $html;
}
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
$winners = $this->countScoresAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueHigherScores = array_values(array_unique(array_filter(
$sortedScores,
static fn ($score): bool => $score > $threshold
)));
sort($uniqueHigherScores);
foreach ($uniqueHigherScores as $candidate) {
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
if ($count === 1) {
return (float) $sortedScores[0];
}
$rank = ($percentile / 100) * ($count - 1);
$lowerIndex = (int) floor($rank);
$upperIndex = (int) ceil($rank);
$weight = $rank - $lowerIndex;
$lower = (float) $sortedScores[$lowerIndex];
$upper = (float) $sortedScores[$upperIndex];
return $lower + (($upper - $lower) * $weight);
}
private function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => is_numeric($score) && (float) $score >= $threshold
));
}
private function buildDecisionEmailDetailsHtml(array $semesters): string
{
if (empty($semesters)) {
@@ -387,7 +387,7 @@ class PrintRequestsPortalService
public function storeTeacherUpload(array $data, UploadedFile $file, int $teacherId): PrintRequest
{
$this->ensureStorageDirectoryExists();
$ext = $file->getClientOriginalExtension();
$ext = $this->safeExtensionForUpload($file);
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
$file->move($this->storageDirectory(), $stored);
@@ -432,7 +432,7 @@ class PrintRequestsPortalService
}
}
}
$ext = $newFile->getClientOriginalExtension();
$ext = $this->safeExtensionForUpload($newFile);
$stored = Str::uuid()->toString().($ext !== '' ? '.'.$ext : '');
$newFile->move($this->storageDirectory(), $stored);
$update['file_path'] = $stored;
@@ -445,6 +445,29 @@ class PrintRequestsPortalService
return $request->fresh();
}
private function safeExtensionForUpload(UploadedFile $file): string
{
$allowed = [
'application/pdf' => 'pdf',
'image/jpeg' => 'jpg',
'image/png' => 'png',
'text/plain' => 'txt',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
];
$mime = strtolower((string) $file->getMimeType());
if (! isset($allowed[$mime])) {
throw new \InvalidArgumentException('Unsupported upload type.');
}
if ((int) $file->getSize() > 5 * 1024 * 1024) {
throw new \InvalidArgumentException('Uploaded file too large. Max 5MB.');
}
return $allowed[$mime];
}
public function applyAdminStatus(PrintRequest $request, string $newStatus, int $adminUserId): PrintRequest
{
$current = (string) $request->status;
@@ -1081,8 +1081,6 @@ class ReportCardService
private function formatReportPDF(\FPDF $pdf, array $data): void
{
require_once base_path('app/ThirdParty/fpdf/fpdf.php');
$w = $pdf->GetPageWidth();
$pdf->SetMargins(10, 10, 10);
$bottomMarginVal = 3;
+22 -2
View File
@@ -12,13 +12,33 @@ class RoleQueryService
public function listRoles(array $filters): LengthAwarePaginator
{
$query = Role::query()
->select('roles.*')
->select([
'roles.id',
'roles.name',
'roles.slug',
'roles.description',
'roles.dashboard_route',
'roles.priority',
'roles.is_active',
'roles.created_at',
'roles.updated_at',
])
->leftJoin('user_roles', function ($join) {
$join->on('user_roles.role_id', '=', 'roles.id')
->whereNull('user_roles.deleted_at');
})
->selectRaw('COUNT(DISTINCT user_roles.user_id) AS user_count')
->groupBy('roles.id');
->groupBy([
'roles.id',
'roles.name',
'roles.slug',
'roles.description',
'roles.dashboard_route',
'roles.priority',
'roles.is_active',
'roles.created_at',
'roles.updated_at',
]);
if (!empty($filters['search'])) {
$search = trim((string) $filters['search']);
+26 -2
View File
@@ -3,6 +3,8 @@
namespace App\Services\Roles;
use App\Models\UserRole;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class RoleSwitchService
{
@@ -16,8 +18,30 @@ class RoleSwitchService
return array_values(array_filter(array_map(fn ($r) => (string) ($r['role_name'] ?? ''), $roles)));
}
public function switchRole(string $role): string
public function switchRole(int $userId, string $role): string
{
return $this->dashboardService->bestDashboardRouteFor([$role]);
$requested = strtolower(trim($role));
if ($requested === '') {
throw new NotFoundHttpException('Role not found.');
}
$roles = $this->getUserRoleNames($userId);
$matchedRole = null;
foreach ($roles as $assignedRole) {
$normalized = strtolower(trim($assignedRole));
if ($normalized === $requested) {
$matchedRole = $assignedRole;
break;
}
}
if ($matchedRole === null) {
throw new AccessDeniedHttpException('Invalid role selected.');
}
session()->put('role', $matchedRole);
return $this->dashboardService->bestDashboardRouteFor([$matchedRole]);
}
}