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
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
@@ -6,6 +6,8 @@ use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Roles\RoleSwitchRequest;
use App\Services\Roles\RoleSwitchService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpFoundation\Response;
class RoleSwitcherController extends BaseApiController
@@ -40,7 +42,13 @@ class RoleSwitcherController extends BaseApiController
}
$role = (string) $request->validated()['role'];
$route = $this->switchService->switchRole($role);
try {
$route = $this->switchService->switchRole($guard, $role);
} catch (AccessDeniedHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_FORBIDDEN);
} catch (NotFoundHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
}
return $this->success([
'role' => $role,
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Grading;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Grading\BelowSixtyEmailRequest;
use App\Http\Requests\Grading\AllDecisionsRequest;
use App\Http\Requests\Grading\BelowSixtyDecisionDetailsRequest;
use App\Http\Requests\Grading\BelowSixtyDecisionListRequest;
use App\Http\Requests\Grading\BelowSixtyDecisionSaveRequest;
@@ -13,6 +14,7 @@ use App\Http\Requests\Grading\BelowSixtyMeetingSaveRequest;
use App\Http\Requests\Grading\BelowSixtySendRequest;
use App\Http\Requests\Grading\BelowSixtyStatusRequest;
use App\Http\Requests\Grading\BelowSixtyListRequest;
use App\Http\Requests\Grading\GenerateAllDecisionsRequest;
use App\Http\Requests\Grading\GradingLockAllRequest;
use App\Http\Requests\Grading\GradingLockRequest;
use App\Http\Requests\Grading\GradingOverviewRequest;
@@ -316,6 +318,27 @@ class GradingController extends BaseApiController
return response()->json(['ok' => true] + $data);
}
public function allDecisions(AllDecisionsRequest $request): JsonResponse
{
$payload = $request->validated();
$data = $this->belowSixtyService->listAllDecisions((string) $payload['school_year']);
return response()->json(['ok' => true] + $data);
}
public function generateAllDecisions(GenerateAllDecisionsRequest $request): JsonResponse
{
$userId = $this->authenticatedUserIdOrUnauthorized();
if ($userId instanceof JsonResponse) {
return $userId;
}
$payload = $request->validated();
$count = $this->belowSixtyService->generateAllDecisions((string) $payload['school_year'], $userId);
return response()->json(['ok' => true, 'saved_count' => $count]);
}
public function belowSixtyDecisions(BelowSixtyDecisionListRequest $request): JsonResponse
{
$payload = $request->validated();
@@ -47,7 +47,7 @@ class PrintRequestsController extends BaseApiController
}
$validator = Validator::make($request->all(), [
'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'],
'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'page_selection' => ['nullable', 'string', 'regex:/^\s*\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*\s*$/'],
'num_copies' => ['required', 'integer', 'min:1'],
'required_by' => ['required', 'date'],
@@ -97,7 +97,7 @@ class PrintRequestsController extends BaseApiController
];
if ($request->hasFile('file') && $request->file('file')->isValid()) {
$rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'];
$rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
}
$validator = Validator::make($request->all(), $rules);
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Requests\Roles\RoleSwitchRequest;
use App\Services\Roles\RoleSwitchService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpFoundation\Response;
class RoleSwitcherController extends BaseApiController
@@ -33,8 +35,19 @@ class RoleSwitcherController extends BaseApiController
public function switch(RoleSwitchRequest $request): JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return $this->error('Please log in first.', Response::HTTP_UNAUTHORIZED);
}
$role = (string) $request->validated()['role'];
$route = $this->switchService->switchRole($role);
try {
$route = $this->switchService->switchRole($userId, $role);
} catch (AccessDeniedHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_FORBIDDEN);
} catch (NotFoundHttpException $e) {
return $this->error($e->getMessage(), Response::HTTP_NOT_FOUND);
}
return $this->success([
'role' => $role,
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
/** @var Response $response */
$response = $next($request);
$headers = $response->headers;
$headers->set('X-Content-Type-Options', 'nosniff');
$headers->set('X-Frame-Options', 'DENY');
$headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if ($request->isSecure()) {
$headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Grading;
use App\Http\Requests\ApiFormRequest;
class AllDecisionsRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'school_year' => ['required', 'string', 'max:50'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Grading;
use App\Http\Requests\ApiFormRequest;
class GenerateAllDecisionsRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'school_year' => ['required', 'string', 'max:50'],
];
}
}
+2
View File
@@ -90,6 +90,8 @@ class User extends Authenticatable implements JWTSubject
{
return DB::table('user_roles')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->whereNull('user_roles.deleted_at')
->where('roles.is_active', 1)
->where('user_roles.user_id', $this->id)
->pluck('roles.name')
->unique()
@@ -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]);
}
}
+2
View File
@@ -15,6 +15,8 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
$middleware->alias([
// JWT auth
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+200
View File
@@ -0,0 +1,200 @@
{
"summary": {
"assessment_date": "2026-06-04",
"reviewer": "OpenAI GPT-5.5 Thinking",
"application": "school_api",
"framework_observed": "Laravel 12 (not CodeIgniter 4)",
"total_findings": 12,
"fixed_findings": 9,
"open_findings": 3,
"overall_risk_level": "Medium after static fixes; High until real secrets are rotated and dynamic testing/dependency audit are completed",
"verification_commands": [
"php -l changed PHP files: passed",
"composer audit: blocked - composer command not found",
"php artisan route:list: blocked - missing PHP mbstring extension (mb_split undefined)",
"static secret scan: original app/JWT/SMTP/PayPal secrets removed from edited .env/test.sql"
]
},
"findings": [
{
"id": "SEC-001",
"severity": "Critical",
"category": "Secrets / Environment",
"location": ".env; test.sql",
"status": "Fixed",
"finding": "The archive shipped active-looking application, JWT, SMTP, database and PayPal credentials, plus production debug/local settings.",
"risk": "Credential exposure can enable account takeover, forged JWTs, SMTP abuse, payment-provider abuse, and easier exploitation through debug traces.",
"evidence": "Static secret scan found APP_KEY, JWT_SECRET, Gmail app password, DB root/root, and PayPal client/secret values.",
"remediation": "Redacted shipped secrets to placeholders, changed production posture, enabled encrypted/secure session settings, and redacted PayPal credentials in test.sql.",
"before": "APP_DEBUG=true; MAIL_DEFAULT_PASS contained a Gmail app password; JWT_SECRET contained a live-looking HMAC secret; test.sql included PayPal secrets.",
"after": "APP_DEBUG=false; secrets use CHANGE_ME placeholders; SESSION_ENCRYPT=true; SESSION_SECURE_COOKIE=true; PayPal values redacted.",
"verification": "Repeated static secret scan. Remaining matches are placeholders or framework config keys; no original Gmail/JWT/PayPal strings remained.",
"result": "Fixed in source archive. Real production credentials must still be rotated outside this codebase."
},
{
"id": "SEC-002",
"severity": "High",
"category": "CSRF / Session State Change",
"location": "routes/web.php logout route",
"status": "Fixed",
"finding": "Browser-session logout used GET, a state-changing action vulnerable to CSRF-style forced logout and link/preload triggering.",
"risk": "An attacker could cause authenticated users to lose sessions through an image/link/preload request. Not catastrophic, but still sloppy. The web needed one less foot-gun.",
"evidence": "Route::get('logout', ...) was registered under the web middleware group.",
"remediation": "Changed logout to POST so Laravel web CSRF protection applies.",
"before": "Route::get('logout', [AuthSessionController::class, 'logout'])",
"after": "Route::post('logout', [AuthSessionController::class, 'logout'])",
"verification": "PHP lint passed for routes/web.php; diff confirms GET was removed for the logout route.",
"result": "Fixed. Frontend logout calls must use POST."
},
{
"id": "SEC-003",
"severity": "High",
"category": "Brute Force / Abuse Controls",
"location": "routes/api.php public auth, registration, invite, contact routes",
"status": "Fixed",
"finding": "Several public mutation endpoints had no route-level throttle: login/register aliases, captcha, contact, and authorized-user password setup.",
"risk": "Attackers could automate credential stuffing, registration abuse, contact spam, and invite/password token guessing at higher volume than necessary.",
"evidence": "Public POST/GET routes were registered without throttle middleware.",
"remediation": "Added route-level throttles: login 10/min, register 5/min, captcha 30/min, invite GET 20/min, password setup POST 10/min, contact 5/min.",
"before": "Route::post('login', ...); Route::post('register', ...); Route::post('contact', ...);",
"after": "Routes now include middleware('throttle:...') on public auth/contact/invite mutation paths.",
"verification": "PHP lint passed for routes/api.php; route snippets confirm throttle middleware is attached.",
"result": "Fixed statically. Live rate-limit behavior still needs HTTP retesting after deployment."
},
{
"id": "SEC-004",
"severity": "High",
"category": "File Upload Security",
"location": "app/Services/Events/EventManagementService.php",
"status": "Fixed",
"finding": "Event flyer upload moved files into public storage without explicit MIME allowlist or file-size enforcement in the service.",
"risk": "Untrusted files could be stored in a web-accessible directory. Extension games are ancient, stupid, and still work when services trust uploads too much.",
"evidence": "storeFlyer() used hashName() and move() without validating MIME or size inside the service.",
"remediation": "Added service-level MIME allowlist for JPEG, PNG, WEBP, PDF; max 5MB; cryptographically random server-side filename using the validated MIME extension.",
"before": "$name = $file->hashName(); $file->move($dir, $name);",
"after": "MIME map validation, size check, random_bytes filename, controlled extension.",
"verification": "PHP lint passed for EventManagementService.php; static diff confirms validation was added before move().",
"result": "Fixed."
},
{
"id": "SEC-005",
"severity": "High",
"category": "File Upload Security",
"location": "app/Services/Expenses/ExpenseReceiptService.php",
"status": "Fixed",
"finding": "Receipt upload stored files without service-level validation of validity, MIME type, or size.",
"risk": "Malformed or unexpected files could be accepted and later served/downloaded, increasing malware and content-sniffing risk.",
"evidence": "storeReceipt() directly called $file->store('receipts') and returned basename.",
"remediation": "Added isValid(), MIME allowlist, max 5MB, random server-side filename, and controlled extension.",
"before": "$stored = $file->store('receipts');",
"after": "Validated MIME and size, then storeAs() with bin2hex(random_bytes(16)).",
"verification": "PHP lint passed for ExpenseReceiptService.php; static diff confirms checks and randomized naming.",
"result": "Fixed."
},
{
"id": "SEC-006",
"severity": "Medium",
"category": "File Upload Security",
"location": "app/Services/BroadcastEmail/BroadcastEmailImageService.php",
"status": "Fixed",
"finding": "Broadcast email image upload used uniqid() for public filenames and lacked an explicit isValid() check.",
"risk": "uniqid() is predictable enough to make public file discovery easier. Tiny risk, but still a classic example of software pretending timestamps are secrets.",
"evidence": "$newName = uniqid('em_', true) . '.' . $ext; no isValid() guard.",
"remediation": "Added isValid() handling and replaced uniqid() with bin2hex(random_bytes(16)).",
"before": "uniqid('em_', true)",
"after": "'em_' . bin2hex(random_bytes(16))",
"verification": "PHP lint passed for BroadcastEmailImageService.php; diff confirms change.",
"result": "Fixed."
},
{
"id": "SEC-007",
"severity": "Medium",
"category": "File Upload Security",
"location": "PrintRequestsController; PrintRequestsPortalService",
"status": "Fixed",
"finding": "Print request uploads had controller extension/MIME validation but service naming still trusted the client original extension.",
"risk": "Client-controlled extensions can create misleading stored files and weaken downstream serving assumptions.",
"evidence": "Portal service used getClientOriginalExtension() to build stored filenames.",
"remediation": "Added safeExtensionForUpload() that maps detected MIME to controlled extensions; added mimetypes rules to controller validation.",
"before": "$ext = $file->getClientOriginalExtension();",
"after": "$ext = $this->safeExtensionForUpload($file); plus mimetypes validation.",
"verification": "PHP lint passed for controller and service; diff confirms service no longer trusts client extensions.",
"result": "Fixed."
},
{
"id": "SEC-008",
"severity": "Medium",
"category": "HTTP Security Headers / Content Sniffing",
"location": "bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php",
"status": "Fixed",
"finding": "The app lacked a global security header middleware and some uploaded/private file responses were cacheable as public unless nosniff was manually requested.",
"risk": "Missing headers increases clickjacking/content-sniffing exposure. Public caching of private uploaded files can leak sensitive school documents through shared caches.",
"evidence": "No global header middleware; FileServeService used Cache-Control public, max-age=86400 and conditional nosniff.",
"remediation": "Added global SecurityHeaders middleware; added nosniff, frame denial, referrer policy, permissions policy, HSTS over HTTPS; changed file serving to private max-age=300 and nosniff always.",
"before": "Cache-Control: public, max-age=86400; conditional X-Content-Type-Options.",
"after": "Cache-Control: private, max-age=300; X-Content-Type-Options: nosniff; global headers middleware.",
"verification": "PHP lint passed for middleware, bootstrap, and FileServeService; static diff confirms middleware registration.",
"result": "Fixed statically. Header behavior should be confirmed by HTTP response tests in deployed environment."
},
{
"id": "SEC-009",
"severity": "Medium",
"category": "SQL Injection Hardening",
"location": "app/Models/User.php",
"status": "Fixed",
"finding": "A school_year value was manually quoted and interpolated into a raw EXISTS SQL string.",
"risk": "Manual quoting is fragile. It was not obviously exploitable because PDO quote was used, but parameterized query builder logic is safer and auditable.",
"evidence": "$escYear = DB::getPdo()->quote($schoolYear); ... tc.school_year = {$escYear}",
"remediation": "Replaced raw interpolation with whereExists(), whereColumn(), and bound where('tc.school_year', $schoolYear).",
"before": "tc.school_year = {$escYear}",
"after": "->where('tc.school_year', $schoolYear)",
"verification": "PHP lint passed for User.php; static diff confirms interpolation removed.",
"result": "Fixed."
},
{
"id": "SEC-010",
"severity": "Medium",
"category": "Dependency Audit",
"location": "composer.lock / environment",
"status": "Open",
"finding": "Dependency audit could not be completed in this sandbox because composer is not installed.",
"risk": "Known vulnerable packages may remain. Laravel, JWT, PDF, and mail libraries should be audited before deployment because humans keep shipping libraries faster than they patch them.",
"evidence": "composer audit returned: bash: composer: command not found.",
"remediation": "Not fixed here. Run composer audit in CI and fail builds on high/critical advisories.",
"before": "No verified dependency advisory baseline from this run.",
"after": "Open remediation item documented.",
"verification": "Attempted composer audit; blocked by missing composer binary.",
"result": "Open."
},
{
"id": "SEC-011",
"severity": "Medium",
"category": "Dynamic Security Testing",
"location": "Running application / OWASP ZAP",
"status": "Open",
"finding": "OWASP ZAP and manual HTTP abuse testing were not executed because no running target with required PHP extensions/database was available in the sandbox.",
"risk": "Static review can miss runtime authorization, CORS, CSRF token, redirect, and response-header issues.",
"evidence": "php artisan route:list failed because the PHP runtime lacks mbstring: undefined function mb_split().",
"remediation": "Install required PHP extensions, configure a test database, boot the app, then run authenticated and unauthenticated ZAP baseline plus manual endpoint abuse tests.",
"before": "No dynamic scan baseline.",
"after": "Open remediation item documented with blocker.",
"verification": "Artisan route listing attempted; blocked by missing mbstring extension.",
"result": "Open."
},
{
"id": "SEC-012",
"severity": "Low",
"category": "Framework Scope Accuracy",
"location": "security-verification-plan.md",
"status": "Open",
"finding": "The uploaded plan says CodeIgniter 4, but the application is Laravel 12.",
"risk": "Wrong-framework checklists miss framework-specific controls. This is how audits become theater with better fonts.",
"evidence": "composer.json name/type and dependencies identify laravel/framework ^12.0; routes, middleware, FormRequests, and artisan confirm Laravel structure.",
"remediation": "Documented scope mismatch. Future audits should use a Laravel-specific checklist.",
"before": "CodeIgniter 4 plan wording.",
"after": "This report applies the plan categories to Laravel controls.",
"verification": "Static framework identification completed from composer.json and project structure.",
"result": "Open documentation correction."
}
]
}
+303
View File
@@ -0,0 +1,303 @@
# Security Fix Report - school_api
## Executive Summary
- **Assessment Date:** 2026-06-04
- **Reviewer:** OpenAI GPT-5.5 Thinking
- **Application:** school_api
- **Framework Observed:** Laravel 12 (not CodeIgniter 4)
- **Total Findings:** 12
- **Fixed Findings:** 9
- **Open Findings:** 3
- **Overall Risk Level:** Medium after static fixes; High until real secrets are rotated and dynamic testing/dependency audit are completed
The uploaded verification plan was applied by category, but the codebase is Laravel 12 rather than CodeIgniter 4. Fixed items are documented with before/after evidence and verification notes. Dynamic testing and dependency advisory scanning remain open because the sandbox lacks Composer and the PHP mbstring extension required to boot Artisan.
## Vulnerability Inventory
| ID | Severity | Category | Location | Status |
|---|---:|---|---|---|
| SEC-001 | Critical | Secrets / Environment | .env; test.sql | Fixed |
| SEC-002 | High | CSRF / Session State Change | routes/web.php logout route | Fixed |
| SEC-003 | High | Brute Force / Abuse Controls | routes/api.php public auth, registration, invite, contact routes | Fixed |
| SEC-004 | High | File Upload Security | app/Services/Events/EventManagementService.php | Fixed |
| SEC-005 | High | File Upload Security | app/Services/Expenses/ExpenseReceiptService.php | Fixed |
| SEC-006 | Medium | File Upload Security | app/Services/BroadcastEmail/BroadcastEmailImageService.php | Fixed |
| SEC-007 | Medium | File Upload Security | PrintRequestsController; PrintRequestsPortalService | Fixed |
| SEC-008 | Medium | HTTP Security Headers / Content Sniffing | bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php | Fixed |
| SEC-009 | Medium | SQL Injection Hardening | app/Models/User.php | Fixed |
| SEC-010 | Medium | Dependency Audit | composer.lock / environment | Open |
| SEC-011 | Medium | Dynamic Security Testing | Running application / OWASP ZAP | Open |
| SEC-012 | Low | Framework Scope Accuracy | security-verification-plan.md | Open |
## Detailed Remediation Record
### SEC-001 - Critical - Secrets / Environment
**Finding:** The archive shipped active-looking application, JWT, SMTP, database and PayPal credentials, plus production debug/local settings.
**Risk:** Credential exposure can enable account takeover, forged JWTs, SMTP abuse, payment-provider abuse, and easier exploitation through debug traces.
**Location:** .env; test.sql
**Evidence:** Static secret scan found APP_KEY, JWT_SECRET, Gmail app password, DB root/root, and PayPal client/secret values.
**Remediation:** Redacted shipped secrets to placeholders, changed production posture, enabled encrypted/secure session settings, and redacted PayPal credentials in test.sql.
**Before:** APP_DEBUG=true; MAIL_DEFAULT_PASS contained a Gmail app password; JWT_SECRET contained a live-looking HMAC secret; test.sql included PayPal secrets.
**After:** APP_DEBUG=false; secrets use CHANGE_ME placeholders; SESSION_ENCRYPT=true; SESSION_SECURE_COOKIE=true; PayPal values redacted.
**Verification:** Repeated static secret scan. Remaining matches are placeholders or framework config keys; no original Gmail/JWT/PayPal strings remained.
**Result:** Fixed in source archive. Real production credentials must still be rotated outside this codebase.
### SEC-002 - High - CSRF / Session State Change
**Finding:** Browser-session logout used GET, a state-changing action vulnerable to CSRF-style forced logout and link/preload triggering.
**Risk:** An attacker could cause authenticated users to lose sessions through an image/link/preload request. Not catastrophic, but still sloppy. The web needed one less foot-gun.
**Location:** routes/web.php logout route
**Evidence:** Route::get('logout', ...) was registered under the web middleware group.
**Remediation:** Changed logout to POST so Laravel web CSRF protection applies.
**Before:** Route::get('logout', [AuthSessionController::class, 'logout'])
**After:** Route::post('logout', [AuthSessionController::class, 'logout'])
**Verification:** PHP lint passed for routes/web.php; diff confirms GET was removed for the logout route.
**Result:** Fixed. Frontend logout calls must use POST.
### SEC-003 - High - Brute Force / Abuse Controls
**Finding:** Several public mutation endpoints had no route-level throttle: login/register aliases, captcha, contact, and authorized-user password setup.
**Risk:** Attackers could automate credential stuffing, registration abuse, contact spam, and invite/password token guessing at higher volume than necessary.
**Location:** routes/api.php public auth, registration, invite, contact routes
**Evidence:** Public POST/GET routes were registered without throttle middleware.
**Remediation:** Added route-level throttles: login 10/min, register 5/min, captcha 30/min, invite GET 20/min, password setup POST 10/min, contact 5/min.
**Before:** Route::post('login', ...); Route::post('register', ...); Route::post('contact', ...);
**After:** Routes now include middleware('throttle:...') on public auth/contact/invite mutation paths.
**Verification:** PHP lint passed for routes/api.php; route snippets confirm throttle middleware is attached.
**Result:** Fixed statically. Live rate-limit behavior still needs HTTP retesting after deployment.
### SEC-004 - High - File Upload Security
**Finding:** Event flyer upload moved files into public storage without explicit MIME allowlist or file-size enforcement in the service.
**Risk:** Untrusted files could be stored in a web-accessible directory. Extension games are ancient, stupid, and still work when services trust uploads too much.
**Location:** app/Services/Events/EventManagementService.php
**Evidence:** storeFlyer() used hashName() and move() without validating MIME or size inside the service.
**Remediation:** Added service-level MIME allowlist for JPEG, PNG, WEBP, PDF; max 5MB; cryptographically random server-side filename using the validated MIME extension.
**Before:** $name = $file->hashName(); $file->move($dir, $name);
**After:** MIME map validation, size check, random_bytes filename, controlled extension.
**Verification:** PHP lint passed for EventManagementService.php; static diff confirms validation was added before move().
**Result:** Fixed.
### SEC-005 - High - File Upload Security
**Finding:** Receipt upload stored files without service-level validation of validity, MIME type, or size.
**Risk:** Malformed or unexpected files could be accepted and later served/downloaded, increasing malware and content-sniffing risk.
**Location:** app/Services/Expenses/ExpenseReceiptService.php
**Evidence:** storeReceipt() directly called $file->store('receipts') and returned basename.
**Remediation:** Added isValid(), MIME allowlist, max 5MB, random server-side filename, and controlled extension.
**Before:** $stored = $file->store('receipts');
**After:** Validated MIME and size, then storeAs() with bin2hex(random_bytes(16)).
**Verification:** PHP lint passed for ExpenseReceiptService.php; static diff confirms checks and randomized naming.
**Result:** Fixed.
### SEC-006 - Medium - File Upload Security
**Finding:** Broadcast email image upload used uniqid() for public filenames and lacked an explicit isValid() check.
**Risk:** uniqid() is predictable enough to make public file discovery easier. Tiny risk, but still a classic example of software pretending timestamps are secrets.
**Location:** app/Services/BroadcastEmail/BroadcastEmailImageService.php
**Evidence:** $newName = uniqid('em_', true) . '.' . $ext; no isValid() guard.
**Remediation:** Added isValid() handling and replaced uniqid() with bin2hex(random_bytes(16)).
**Before:** uniqid('em_', true)
**After:** 'em_' . bin2hex(random_bytes(16))
**Verification:** PHP lint passed for BroadcastEmailImageService.php; diff confirms change.
**Result:** Fixed.
### SEC-007 - Medium - File Upload Security
**Finding:** Print request uploads had controller extension/MIME validation but service naming still trusted the client original extension.
**Risk:** Client-controlled extensions can create misleading stored files and weaken downstream serving assumptions.
**Location:** PrintRequestsController; PrintRequestsPortalService
**Evidence:** Portal service used getClientOriginalExtension() to build stored filenames.
**Remediation:** Added safeExtensionForUpload() that maps detected MIME to controlled extensions; added mimetypes rules to controller validation.
**Before:** $ext = $file->getClientOriginalExtension();
**After:** $ext = $this->safeExtensionForUpload($file); plus mimetypes validation.
**Verification:** PHP lint passed for controller and service; diff confirms service no longer trusts client extensions.
**Result:** Fixed.
### SEC-008 - Medium - HTTP Security Headers / Content Sniffing
**Finding:** The app lacked a global security header middleware and some uploaded/private file responses were cacheable as public unless nosniff was manually requested.
**Risk:** Missing headers increases clickjacking/content-sniffing exposure. Public caching of private uploaded files can leak sensitive school documents through shared caches.
**Location:** bootstrap/app.php; app/Http/Middleware/SecurityHeaders.php; FileServeService.php
**Evidence:** No global header middleware; FileServeService used Cache-Control public, max-age=86400 and conditional nosniff.
**Remediation:** Added global SecurityHeaders middleware; added nosniff, frame denial, referrer policy, permissions policy, HSTS over HTTPS; changed file serving to private max-age=300 and nosniff always.
**Before:** Cache-Control: public, max-age=86400; conditional X-Content-Type-Options.
**After:** Cache-Control: private, max-age=300; X-Content-Type-Options: nosniff; global headers middleware.
**Verification:** PHP lint passed for middleware, bootstrap, and FileServeService; static diff confirms middleware registration.
**Result:** Fixed statically. Header behavior should be confirmed by HTTP response tests in deployed environment.
### SEC-009 - Medium - SQL Injection Hardening
**Finding:** A school_year value was manually quoted and interpolated into a raw EXISTS SQL string.
**Risk:** Manual quoting is fragile. It was not obviously exploitable because PDO quote was used, but parameterized query builder logic is safer and auditable.
**Location:** app/Models/User.php
**Evidence:** $escYear = DB::getPdo()->quote($schoolYear); ... tc.school_year = {$escYear}
**Remediation:** Replaced raw interpolation with whereExists(), whereColumn(), and bound where('tc.school_year', $schoolYear).
**Before:** tc.school_year = {$escYear}
**After:** ->where('tc.school_year', $schoolYear)
**Verification:** PHP lint passed for User.php; static diff confirms interpolation removed.
**Result:** Fixed.
### SEC-010 - Medium - Dependency Audit
**Finding:** Dependency audit could not be completed in this sandbox because composer is not installed.
**Risk:** Known vulnerable packages may remain. Laravel, JWT, PDF, and mail libraries should be audited before deployment because humans keep shipping libraries faster than they patch them.
**Location:** composer.lock / environment
**Evidence:** composer audit returned: bash: composer: command not found.
**Remediation:** Not fixed here. Run composer audit in CI and fail builds on high/critical advisories.
**Before:** No verified dependency advisory baseline from this run.
**After:** Open remediation item documented.
**Verification:** Attempted composer audit; blocked by missing composer binary.
**Result:** Open.
### SEC-011 - Medium - Dynamic Security Testing
**Finding:** OWASP ZAP and manual HTTP abuse testing were not executed because no running target with required PHP extensions/database was available in the sandbox.
**Risk:** Static review can miss runtime authorization, CORS, CSRF token, redirect, and response-header issues.
**Location:** Running application / OWASP ZAP
**Evidence:** php artisan route:list failed because the PHP runtime lacks mbstring: undefined function mb_split().
**Remediation:** Install required PHP extensions, configure a test database, boot the app, then run authenticated and unauthenticated ZAP baseline plus manual endpoint abuse tests.
**Before:** No dynamic scan baseline.
**After:** Open remediation item documented with blocker.
**Verification:** Artisan route listing attempted; blocked by missing mbstring extension.
**Result:** Open.
### SEC-012 - Low - Framework Scope Accuracy
**Finding:** The uploaded plan says CodeIgniter 4, but the application is Laravel 12.
**Risk:** Wrong-framework checklists miss framework-specific controls. This is how audits become theater with better fonts.
**Location:** security-verification-plan.md
**Evidence:** composer.json name/type and dependencies identify laravel/framework ^12.0; routes, middleware, FormRequests, and artisan confirm Laravel structure.
**Remediation:** Documented scope mismatch. Future audits should use a Laravel-specific checklist.
**Before:** CodeIgniter 4 plan wording.
**After:** This report applies the plan categories to Laravel controls.
**Verification:** Static framework identification completed from composer.json and project structure.
**Result:** Open documentation correction.
## Verification Evidence
- php -l changed PHP files: passed
- composer audit: blocked - composer command not found
- php artisan route:list: blocked - missing PHP mbstring extension (mb_split undefined)
- static secret scan: original app/JWT/SMTP/PayPal secrets removed from edited .env/test.sql
## Changed Source Files
- .env
- test.sql
- routes/web.php
- routes/api.php
- bootstrap/app.php
- app/Http/Middleware/SecurityHeaders.php
- app/Services/Files/FileServeService.php
- app/Services/Events/EventManagementService.php
- app/Services/Expenses/ExpenseReceiptService.php
- app/Services/BroadcastEmail/BroadcastEmailImageService.php
- app/Services/PrintRequests/PrintRequestsPortalService.php
- app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php
- app/Models/User.php
## Deployment Notes
- Rotate the exposed Gmail app password, JWT secret, Laravel APP_KEY, database password, and PayPal credentials. Redaction in the repository does not invalidate credentials already leaked in the original ZIP. That part requires action with the providers, not wishful thinking.
- Regenerate APP_KEY and JWT_SECRET in each environment. Do not commit .env files with real values.
- Run composer audit in CI and install required PHP extensions, including mbstring, before dynamic tests.
- Re-test all changed public auth/contact/invite routes for expected 429 behavior and frontend compatibility.
@@ -0,0 +1,85 @@
# CodeIgniter 4 Security Verification and Remediation Plan
## Objective
Perform a comprehensive security assessment of the CodeIgniter 4 application, identify vulnerabilities, implement fixes, verify remediation, and produce a complete audit trail of all findings and corrections.
## Mandatory Requirement
Every vulnerability that is fixed must be documented immediately in the remediation report.
No fix may be marked complete without evidence of verification and retesting.
## Scope
- CodeIgniter 4 configuration
- Controllers
- Models
- Views
- Routes
- Filters
- Authentication and Authorization
- API endpoints
- Session handling
- File uploads
- Composer dependencies
## Steps
1. Environment and Debug Configuration Review
2. CSRF Protection Verification
3. XSS Protection Review
4. SQL Injection Review
5. Input Validation Review
6. Authentication Security Review
7. Authorization Review
8. Route and Filter Review
9. File Upload Security Review
10. Session and Cookie Security Review
11. Dependency Audit
12. Secret Scanning
13. Automated Security Scan (OWASP ZAP)
14. Manual Abuse Testing
15. Generate Security Fix Report
## Security Fix Report Requirements
### Executive Summary
- Assessment date
- Reviewer
- Total findings
- Fixed findings
- Open findings
- Overall risk level
### Vulnerability Inventory
- ID
- Severity
- Category
- Location
- Status
### Detailed Remediation Record
- Finding
- Risk
- Location
- Evidence
- Remediation
- Before/After code
- Verification
- Result
### Deliverables
- security-verification-plan.md
- security-fix-report.md
- security-fix-report.pdf
- security-findings.json
## Success Criteria
An independent reviewer must be able to:
- Reproduce findings
- Verify fixes
- Audit changes
- Review remaining risks
- Approve or reject deployment based on evidence
+362
View File
@@ -0,0 +1,362 @@
--- .env
--- school_api_orig/school_api/.env 2026-06-04 07:50:20.000000000 +0000
+++ school_api_work/school_api/.env 2026-06-04 17:35:04.127545553 +0000
@@ -1,9 +1,9 @@
APP_NAME=Alrahma_API
-APP_ENV=local
-APP_KEY=base64:RfIZKqgXC9seghl2Jqs2MgLh1X1Z7APRsnhUK8CgWx8=
-APP_DEBUG=true
+APP_ENV=production
+APP_KEY=base64:CHANGE_ME_GENERATE_WITH_php_artisan_key_generate
+APP_DEBUG=false
APP_TIMEZONE=America/New_York
-APP_URL=http://localhost:8080
+APP_URL=https://example.com
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -22,18 +22,21 @@
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=school_api
-DB_USERNAME=root
-DB_PASSWORD=root
-APP_URL=http://127.0.0.1:8000
+DB_USERNAME=school_api_user
+DB_PASSWORD=CHANGE_ME_DB_PASSWORD
+APP_URL=https://example.com
# ----------------------------
# SESSION
# ----------------------------
SESSION_DRIVER=file
SESSION_LIFETIME=120
-SESSION_ENCRYPT=false
+SESSION_ENCRYPT=true
SESSION_PATH=/
SESSION_DOMAIN=null
+SESSION_SECURE_COOKIE=true
+SESSION_HTTP_ONLY=true
+SESSION_SAME_SITE=strict
# ----------------------------
# CACHE & QUEUE
@@ -56,10 +59,10 @@
MAIL_PROFILE_DEFAULT=default
MAIL_DEFAULT_HOST=smtp.gmail.com
MAIL_DEFAULT_PORT=587
-MAIL_DEFAULT_USER=alrahma.sunday.school@gmail.com
-MAIL_DEFAULT_PASS="psnp emdq dykw ypul"
+MAIL_DEFAULT_USER=CHANGE_ME_SMTP_USER
+MAIL_DEFAULT_PASS=CHANGE_ME_SMTP_PASSWORD
MAIL_DEFAULT_ENCRYPTION=tls
-MAIL_DEFAULT_FROM_EMAIL="alrahma.sunday.school@gmail.com"
+MAIL_DEFAULT_FROM_EMAIL="no-reply@example.com"
MAIL_DEFAULT_FROM_NAME="Alrahma API"
MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com"
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
@@ -102,7 +105,7 @@
# ----------------------------
# JWT
# ----------------------------
-JWT_SECRET=Uj8rGnYcXMgeRc5qCIn9Wn03tYo1pCsBz1Biou8T9zWtaNxBYi3P4NP5vuFiXHmd
+JWT_SECRET=CHANGE_ME_GENERATE_WITH_php_artisan_jwt_secret
JWT_ALGO=HS256
JWT_TTL=60
JWT_REFRESH_TTL=20160
--- routes/web.php
--- school_api_orig/school_api/routes/web.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/routes/web.php 2026-06-04 17:35:04.129001398 +0000
@@ -61,7 +61,7 @@
Route::middleware('web')->group(function () {
Route::get('login', [AuthSessionController::class, 'loginMask'])->name('login');
Route::post('user/login', [AuthSessionController::class, 'login'])->name('auth.session.login');
- Route::get('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
+ Route::post('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
Route::get('select-role', [AuthSessionController::class, 'selectRole'])->name('auth.session.select-role');
Route::post('set-role', [AuthSessionController::class, 'setRole'])->name('auth.session.set-role');
--- routes/api.php
--- school_api_orig/school_api/routes/api.php 2026-06-04 17:05:37.000000000 +0000
+++ school_api_work/school_api/routes/api.php 2026-06-04 17:35:04.130570862 +0000
@@ -126,8 +126,8 @@
use App\Http\Controllers\Api\System\AccessDeniedController;
use App\Http\Controllers\Api\Certificates\CertificateController;
// Public auth aliases without the `/v1` prefix.
-Route::post('login', [AuthController::class, 'login']);
-Route::post('register', [RegisterController::class, 'store']);
+Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
+Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
/*
| LanguageTool proxy for legacy and current clients.
@@ -160,12 +160,15 @@
});
Route::get('confirm_authorized_user', [AuthorizedUserInviteController::class, 'confirm'])
+ ->middleware('throttle:20,1')
->name('api.invite.confirm');
Route::get('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'setPasswordForm'])
->whereNumber('authorizedUserId')
+ ->middleware('throttle:20,1')
->name('api.invite.set-password-form');
Route::post('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'savePassword'])
->whereNumber('authorizedUserId')
+ ->middleware('throttle:10,1')
->name('api.invite.set-password');
/*
@@ -184,13 +187,13 @@
Route::prefix('v1')->group(function () {
// Public auth aliases without the `/auth` prefix.
- Route::post('login', [AuthController::class, 'login']);
- Route::post('register', [RegisterController::class, 'store']);
+ Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
+ Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
Route::prefix('auth')->group(function () {
- Route::get('register/captcha', [RegisterController::class, 'captcha']);
- Route::post('register', [RegisterController::class, 'store']);
- Route::post('login', [AuthController::class, 'login']);
+ Route::get('register/captcha', [RegisterController::class, 'captcha'])->middleware('throttle:30,1');
+ Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
+ Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
Route::post('refresh', [AuthController::class, 'refresh'])->middleware('jwt.auth');
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:api');
Route::get('me', [AuthController::class, 'me'])->middleware('auth:api');
@@ -208,7 +211,7 @@
Route::post('contact', [PageController::class, 'submitContact']);
});
- Route::post('contact', [SupportContactController::class, 'send']);
+ Route::post('contact', [SupportContactController::class, 'send'])->middleware('throttle:5,1');
/*
| Badge scan kiosk (public, throttled). Logs: authenticated staff.
--- bootstrap/app.php
--- school_api_orig/school_api/bootstrap/app.php 2026-05-29 03:38:30.000000000 +0000
+++ school_api_work/school_api/bootstrap/app.php 2026-06-04 17:35:04.128252803 +0000
@@ -15,6 +15,8 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
+ $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
+
$middleware->alias([
// JWT auth
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
--- app/Http/Middleware/SecurityHeaders.php
--- app/Services/Files/FileServeService.php
--- school_api_orig/school_api/app/Services/Files/FileServeService.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/app/Services/Files/FileServeService.php 2026-06-04 17:35:04.131136361 +0000
@@ -39,13 +39,10 @@
'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);
}
--- app/Services/Events/EventManagementService.php
--- school_api_orig/school_api/app/Services/Events/EventManagementService.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/app/Services/Events/EventManagementService.php 2026-06-04 17:35:04.131625457 +0000
@@ -103,12 +103,28 @@
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;
--- app/Services/Expenses/ExpenseReceiptService.php
--- school_api_orig/school_api/app/Services/Expenses/ExpenseReceiptService.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/app/Services/Expenses/ExpenseReceiptService.php 2026-06-04 17:35:04.132035612 +0000
@@ -14,8 +14,30 @@
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
--- app/Services/BroadcastEmail/BroadcastEmailImageService.php
--- school_api_orig/school_api/app/Services/BroadcastEmail/BroadcastEmailImageService.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/app/Services/BroadcastEmail/BroadcastEmailImageService.php 2026-06-04 17:35:04.132596226 +0000
@@ -17,6 +17,14 @@
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 @@
}
$ext = $this->allowedTypes[$mime];
- $newName = uniqid('em_', true) . '.' . $ext;
+ $newName = 'em_' . bin2hex(random_bytes(16)) . '.' . $ext;
$file->move($targetDir, $newName);
return [
--- app/Services/PrintRequests/PrintRequestsPortalService.php
--- school_api_orig/school_api/app/Services/PrintRequests/PrintRequestsPortalService.php 2026-06-04 06:36:56.000000000 +0000
+++ school_api_work/school_api/app/Services/PrintRequests/PrintRequestsPortalService.php 2026-06-04 17:35:04.133321294 +0000
@@ -387,7 +387,7 @@
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 @@
}
}
}
- $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 @@
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;
--- app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php
--- school_api_orig/school_api/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php 2026-05-29 00:28:37.000000000 +0000
+++ school_api_work/school_api/app/Http/Controllers/Api/PrintRequests/PrintRequestsController.php 2026-06-04 17:35:04.133827253 +0000
@@ -47,7 +47,7 @@
}
$validator = Validator::make($request->all(), [
- 'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'],
+ 'file' => ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
'page_selection' => ['nullable', 'string', 'regex:/^\s*\d+(?:\s*-\s*\d+)?(?:\s*,\s*\d+(?:\s*-\s*\d+)?)*\s*$/'],
'num_copies' => ['required', 'integer', 'min:1'],
'required_by' => ['required', 'date'],
@@ -97,7 +97,7 @@
];
if ($request->hasFile('file') && $request->file('file')->isValid()) {
- $rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt'];
+ $rules['file'] = ['required', 'file', 'max:5120', 'mimes:pdf,jpg,jpeg,png,doc,docx,txt', 'mimetypes:application/pdf,image/jpeg,image/png,text/plain,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
}
$validator = Validator::make($request->all(), $rules);
--- app/Models/User.php
--- test.sql
--- school_api_orig/school_api/test.sql 2026-05-29 00:27:51.000000000 +0000
+++ school_api_work/school_api/test.sql 2026-06-04 17:35:52.923658533 +0000
@@ -479,15 +479,15 @@
(29, 'total_semester2_days', '13'),
(30, 'delete_email_notify', 'support@alrahmaisgl.org, melabidi@alrahmaisgl.org'),
(31, 'fall_semester_start', '2025-09-21'),
-(32, 'sandbox_secret_paypal', 'Ar0Br3ZhBukQwwdSCxIcSveiu70-Ekt-qqF0nSyYNr3eRAYQKcWTTlhCAKncZiYXmBoPeo4IceV6FjT'),
+(32, 'sandbox_secret_paypal', 'CHANGE_ME_SANDBOX_SECRET_PAYPAL'),
(33, 'sandbox_verifylink_paypal', 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature'),
(34, 'sandbox_tokenurl_paypal', 'https://api-m.sandbox.paypal.com/v1/oauth2/token'),
-(35, 'sandbox_clientid_paypal', 'AYsiOekZUvrjgx9C5c554ZeSQ4W6yd0ZX-OHE93_D0fWoa4YXrMmroEeLiAjjdCKkELH8EVZR_yGMLPS'),
-(36, 'sandbox_webhookid_paypal', '0EU27043LC060650D'),
+(35, 'sandbox_clientid_paypal', 'CHANGE_ME_SANDBOX_CLIENTID_PAYPAL'),
+(36, 'sandbox_webhookid_paypal', 'CHANGE_ME_SANDBOX_WEBHOOKID_PAYPAL'),
(37, 'production_verifylink_paypal', 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature'),
-(38, 'production_webhookid_paypal', '0H5876426L0092829'),
-(39, 'production_secret_paypal', 'KKvmE-0cvwx8rEHnEcFaAvn1Rlc3cy07IGIjqGGPR8EqxABbmPodM_JrjRQq2SF2903A4q0DBjjUWai'),
-(40, 'production_clientid_paypal', 'AQhCgmypaitgLgretMqNrh_7mEG5fAmwCECxCxevuLDQfsQVwECFyQOU50byzPzFrwd0cVVD8r2lujIB'),
+(38, 'production_webhookid_paypal', 'CHANGE_ME_PRODUCTION_WEBHOOKID_PAYPAL'),
+(39, 'production_secret_paypal', 'CHANGE_ME_PRODUCTION_SECRET_PAYPAL'),
+(40, 'production_clientid_paypal', 'CHANGE_ME_PRODUCTION_CLIENTID_PAYPAL'),
(41, 'production_tokenurl_paypal', 'https://api-m.paypal.com/v1/oauth2/token'),
(42, 'comment_reviewer', 'administrator, principal'),
(43, 'paypal_mode', 'sandbox'),
+13 -8
View File
@@ -126,8 +126,8 @@ use App\Http\Controllers\Api\Staff\TimeOffNotificationController;
use App\Http\Controllers\Api\System\AccessDeniedController;
use App\Http\Controllers\Api\Certificates\CertificateController;
// Public auth aliases without the `/v1` prefix.
Route::post('login', [AuthController::class, 'login']);
Route::post('register', [RegisterController::class, 'store']);
Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
/*
| LanguageTool proxy for legacy and current clients.
@@ -160,12 +160,15 @@ Route::prefix('winners/competitions')->group(function () {
});
Route::get('confirm_authorized_user', [AuthorizedUserInviteController::class, 'confirm'])
->middleware('throttle:20,1')
->name('api.invite.confirm');
Route::get('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'setPasswordForm'])
->whereNumber('authorizedUserId')
->middleware('throttle:20,1')
->name('api.invite.set-password-form');
Route::post('set_authorized_user_password/{authorizedUserId}', [AuthorizedUserInviteController::class, 'savePassword'])
->whereNumber('authorizedUserId')
->middleware('throttle:10,1')
->name('api.invite.set-password');
/*
@@ -184,13 +187,13 @@ Route::permanentRedirect('administrator/attendance-templates', '/api/v1/administ
Route::prefix('v1')->group(function () {
// Public auth aliases without the `/auth` prefix.
Route::post('login', [AuthController::class, 'login']);
Route::post('register', [RegisterController::class, 'store']);
Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
Route::prefix('auth')->group(function () {
Route::get('register/captcha', [RegisterController::class, 'captcha']);
Route::post('register', [RegisterController::class, 'store']);
Route::post('login', [AuthController::class, 'login']);
Route::get('register/captcha', [RegisterController::class, 'captcha'])->middleware('throttle:30,1');
Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:5,1');
Route::post('login', [AuthController::class, 'login'])->middleware('throttle:10,1');
Route::post('refresh', [AuthController::class, 'refresh'])->middleware('jwt.auth');
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:api');
Route::get('me', [AuthController::class, 'me'])->middleware('auth:api');
@@ -208,7 +211,7 @@ Route::prefix('v1')->group(function () {
Route::post('contact', [PageController::class, 'submitContact']);
});
Route::post('contact', [SupportContactController::class, 'send']);
Route::post('contact', [SupportContactController::class, 'send'])->middleware('throttle:5,1');
/*
| Badge scan kiosk (public, throttled). Logs: authenticated staff.
@@ -1151,6 +1154,8 @@ Route::prefix('v1')->group(function () {
Route::prefix('grading')->group(function () {
Route::get('overview', [GradingApiController::class, 'overview']);
Route::get('decisions', [GradingApiController::class, 'allDecisions']);
Route::post('decisions/generate', [GradingApiController::class, 'generateAllDecisions']);
Route::get('scores/{type}/{classSectionId}/{studentId}', [GradingApiController::class, 'show']);
Route::put('scores', [GradingApiController::class, 'update']);
+1 -1
View File
@@ -61,7 +61,7 @@ HTML;
Route::middleware('web')->group(function () {
Route::get('login', [AuthSessionController::class, 'loginMask'])->name('login');
Route::post('user/login', [AuthSessionController::class, 'login'])->name('auth.session.login');
Route::get('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
Route::post('logout', [AuthSessionController::class, 'logout'])->name('auth.session.logout');
Route::get('select-role', [AuthSessionController::class, 'selectRole'])->name('auth.session.select-role');
Route::post('set-role', [AuthSessionController::class, 'setRole'])->name('auth.session.set-role');
+6 -6
View File
@@ -479,15 +479,15 @@ INSERT INTO `configuration` (`id`, `config_key`, `config_value`) VALUES
(29, 'total_semester2_days', '13'),
(30, 'delete_email_notify', 'support@alrahmaisgl.org, melabidi@alrahmaisgl.org'),
(31, 'fall_semester_start', '2025-09-21'),
(32, 'sandbox_secret_paypal', 'Ar0Br3ZhBukQwwdSCxIcSveiu70-Ekt-qqF0nSyYNr3eRAYQKcWTTlhCAKncZiYXmBoPeo4IceV6FjT'),
(32, 'sandbox_secret_paypal', 'CHANGE_ME_SANDBOX_SECRET_PAYPAL'),
(33, 'sandbox_verifylink_paypal', 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature'),
(34, 'sandbox_tokenurl_paypal', 'https://api-m.sandbox.paypal.com/v1/oauth2/token'),
(35, 'sandbox_clientid_paypal', 'AYsiOekZUvrjgx9C5c554ZeSQ4W6yd0ZX-OHE93_D0fWoa4YXrMmroEeLiAjjdCKkELH8EVZR_yGMLPS'),
(36, 'sandbox_webhookid_paypal', '0EU27043LC060650D'),
(35, 'sandbox_clientid_paypal', 'CHANGE_ME_SANDBOX_CLIENTID_PAYPAL'),
(36, 'sandbox_webhookid_paypal', 'CHANGE_ME_SANDBOX_WEBHOOKID_PAYPAL'),
(37, 'production_verifylink_paypal', 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature'),
(38, 'production_webhookid_paypal', '0H5876426L0092829'),
(39, 'production_secret_paypal', 'KKvmE-0cvwx8rEHnEcFaAvn1Rlc3cy07IGIjqGGPR8EqxABbmPodM_JrjRQq2SF2903A4q0DBjjUWai'),
(40, 'production_clientid_paypal', 'AQhCgmypaitgLgretMqNrh_7mEG5fAmwCECxCxevuLDQfsQVwECFyQOU50byzPzFrwd0cVVD8r2lujIB'),
(38, 'production_webhookid_paypal', 'CHANGE_ME_PRODUCTION_WEBHOOKID_PAYPAL'),
(39, 'production_secret_paypal', 'CHANGE_ME_PRODUCTION_SECRET_PAYPAL'),
(40, 'production_clientid_paypal', 'CHANGE_ME_PRODUCTION_CLIENTID_PAYPAL'),
(41, 'production_tokenurl_paypal', 'https://api-m.paypal.com/v1/oauth2/token'),
(42, 'comment_reviewer', 'administrator, principal'),
(43, 'paypal_mode', 'sandbox'),