Compare commits

..

1 Commits

Author SHA1 Message Date
root b32fb853f6 merge prod to main 2026-05-16 13:44:12 -04:00
9670 changed files with 1003758 additions and 4134 deletions
Vendored
BIN
View File
Binary file not shown.
+180
View File
@@ -0,0 +1,180 @@
#--------------------------------------------------------------------
# Example Environment Configuration file
#
# This file can be used as a starting point for your own
# custom .env files, and contains most of the possible settings
# available in a default install.
#
# By default, all of the settings are commented out. If you want
# to override the setting, you must un-comment it by removing the '#'
# at the beginning of the line.
#--------------------------------------------------------------------
#--------------------------------------------------------------------
# ENVIRONMENT
#--------------------------------------------------------------------
CI_ENVIRONMENT = development
# Which profile to use if none is supplied to sendEmail()
# Options: default, communication, payment, ...
MAIL_PROFILE_DEFAULT=default
# ===== DEFAULT (system) =====
# ---- Legacy fallbacks (kept for compatibility) ----
SMTP_HOST = smtp.gmail.com
SMTP_USER = alrahma.sunday.school@gmail.com
SMTP_PASS = "psnp emdq dykw ypul"
SMTP_PORT = 587
SMTP_ENCRYPTION = tls # was SSL; set to ssl to match 587
# Reply-To for all mail profiles
MAIL_DEFAULT_REPLY_TO=alrahma.isgl@gmail.com
MAIL_DEFAULT_REPLY_TO_NAME="Al Rahma Sunday School"
MAIL_COMMUNICATION_REPLY_TO=alrahma.isgl@gmail.com
MAIL_COMMUNICATION_REPLY_TO_NAME="School Communications"
MAIL_PAYMENT_REPLY_TO=alrahma.isgl@gmail.com
MAIL_PAYMENT_REPLY_TO_NAME="School Payments"
MAIL_DEFAULT_REPLY_TO="alrahma.isgl@gmail.com"
# Optional sender directory you already use elsewhere
MAIL_SENDERS="{\"general\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma No-Reply\"},\
\"registration\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Register Office\"},\
\"notifications\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Notifications\"},\
\"finance\":{\"email\":\"alrahma.isgl@gmail.com\",\"name\":\"Al Rahma Finance Office\"}}"
# Principal notification recipient for TimeOff requests (comma-separated allowed)
PRINCIPAL_EMAIL="principal@alrahmaisgl.org"
#--------------------------------------------------------------------
# APP
#--------------------------------------------------------------------
# If you have trouble with `.`, you could also use `_`.
app_baseURL = 'http://localhost:8080/'
# app.forceGlobalSecureRequests = true
# app.CSPEnabled = false
#--------------------------------------------------------------------
# DATABASE
#--------------------------------------------------------------------
database.default.hostname = 127.0.0.1
database.default.database = school
database.default.username = root
database.default.password = root
database.default.DBDriver = MySQLi
database.default.DBPrefix =
database.default.port = 3306
#--------------------------------------------------------------------
# CONTENT SECURITY POLICY
#--------------------------------------------------------------------
# contentsecuritypolicy.reportOnly = false
# contentsecuritypolicy.defaultSrc = 'none'
# contentsecuritypolicy.scriptSrc = 'self'
# contentsecuritypolicy.styleSrc = 'self'
# contentsecuritypolicy.imageSrc = 'self'
# contentsecuritypolicy.baseURI = null
# contentsecuritypolicy.childSrc = null
# contentsecuritypolicy.connectSrc = 'self'
# contentsecuritypolicy.fontSrc = null
# contentsecuritypolicy.formAction = null
# contentsecuritypolicy.frameAncestors = null
# contentsecuritypolicy.frameSrc = null
# contentsecuritypolicy.mediaSrc = null
# contentsecuritypolicy.objectSrc = null
# contentsecuritypolicy.pluginTypes = null
# contentsecuritypolicy.reportURI = null
# contentsecuritypolicy.sandbox = false
# contentsecuritypolicy.upgradeInsecureRequests = false
# contentsecuritypolicy.styleNonceTag = '{csp-style-nonce}'
# contentsecuritypolicy.scriptNonceTag = '{csp-script-nonce}'
# contentsecuritypolicy.autoNonce = true
#--------------------------------------------------------------------
# COOKIE
#--------------------------------------------------------------------
# cookie.prefix = ''
# cookie.expires = 0
# cookie.path = '/'
# cookie.domain = ''
# cookie.secure = false
# cookie.httponly = false
# cookie.samesite = 'Lax'
# cookie.raw = false
#--------------------------------------------------------------------
# ENCRYPTION
#--------------------------------------------------------------------
# encryption.key =
# encryption.driver = OpenSSL
# encryption.blockSize = 16
# encryption.digest = SHA512
#--------------------------------------------------------------------
# HONEYPOT
#--------------------------------------------------------------------
# honeypot.hidden = 'true'
# honeypot.label = 'Fill This Field'
# honeypot.name = 'honeypot'
# honeypot.template = '<label>{label}</label><input type="text" name="{name}" value=""/>'
# honeypot.container = '<div style="display:none">{template}</div>'
#--------------------------------------------------------------------
# SECURITY
#--------------------------------------------------------------------
# security.csrfProtection = 'cookie'
# security.tokenRandomize = false
# security.tokenName = 'csrf_token_name'
# security.headerName = 'X-CSRF-TOKEN'
# security.cookieName = 'csrf_cookie_name'
# security.expires = 7200
# security.regenerate = true
# security.redirect = false
# security.samesite = 'Lax'
#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------
# session.driver = 'CodeIgniter\Session\Handlers\FileHandler'
# session.cookieName = 'ci_session'
# session.expiration = 7200
# session.savePath = null
# session.matchIP = false
# session.timeToUpdate = 300
# session.regenerateDestroy = false
#--------------------------------------------------------------------
# LOGGER
#--------------------------------------------------------------------
# logger.threshold = 4
#--------------------------------------------------------------------
# CURLRequest
#--------------------------------------------------------------------
# curlrequest.shareOptions = false
mail.protocol = smtp
mail.SMTPHost = ${SMTP_HOST}
mail.SMTPUser = ${SMTP_USER}
mail.SMTPPass = ${SMTP_PASS}
mail.SMTPPort = ${SMTP_PORT}
mail.SMTPCrypto = tls
mail.fromEmail = ${SMTP_USER}
mail.fromName = "Al Rahma Sunday School"
mail.mailType = text
mail.charset = UTF-8
-54
View File
@@ -1,54 +0,0 @@
.env
.env.*
!.env.example
/vendor/
/node_modules/
/writable/cache/*
/writable/debugbar/*
/writable/logs/*
/writable/session/*
/writable/uploads/*
/writable/tmp/*
/writable/*.log
!/writable/.gitkeep
!/writable/cache/.gitkeep
!/writable/debugbar/.gitkeep
!/writable/logs/.gitkeep
!/writable/session/.gitkeep
!/writable/uploads/.gitkeep
!/writable/tmp/.gitkeep
/public/build/
/public/hot/
/public/uploads/**
!/public/uploads/**/index.html
!/public/uploads/.gitkeep
/uploads/**
!/uploads/**/index.html
!/uploads/.gitkeep
docker-compose.override.yml
docker-compose.local.yml
*.log
.swp
.swo
*~
.tmp
.DS_Store
Thumbs.db
.idea/
.vscode/
.codex/
/coverage/
/build/
/builds
/phpunit.xml.cache
/.phpunit.result.cache
/writable/reports/*
/writable/test.txt
BIN
View File
Binary file not shown.
View File
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
+3 -3
View File
@@ -36,13 +36,13 @@ class SyncPaypalPayments extends BaseCommand
$this->invoiceModel = new InvoiceModel(); $this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel(); $this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel(); $this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
} }
public function run(array $params) public function run(array $params)
{ {
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$dryRun = CLI::getOption('dry-run'); $dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only'); $reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE'); $mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+1 -6
View File
@@ -24,8 +24,6 @@ class Filters extends BaseConfig
'honeypot' => Honeypot::class, 'honeypot' => Honeypot::class,
'invalidchars' => InvalidChars::class, 'invalidchars' => InvalidChars::class,
'secureheaders' => SecureHeaders::class, 'secureheaders' => SecureHeaders::class,
'sanitizeinput' => \App\Filters\SanitizeInputFilter::class,
'apiratelimit' => \App\Filters\ApiRateLimitFilter::class,
'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter 'auth' => \App\Filters\AuthFilter::class, // Define the alias for your auth filter
'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication 'apiAuth' => \App\Filters\ApiAuthFilter::class, // JWT-based API authentication
'cleanupScheduler' => \App\Filters\CleanupScheduler::class, 'cleanupScheduler' => \App\Filters\CleanupScheduler::class,
@@ -44,8 +42,6 @@ class Filters extends BaseConfig
public array $globals = [ public array $globals = [
'before' => [ 'before' => [
'timezone', 'timezone',
'sanitizeinput',
'invalidchars',
'csrf' => ['except' => [ 'csrf' => ['except' => [
// Webhooks / integrations // Webhooks / integrations
'api/paypal-webhook', 'api/paypal-webhook',
@@ -84,7 +80,6 @@ class Filters extends BaseConfig
]], ]],
], ],
'after' => [ 'after' => [
'secureheaders',
'toolbar', 'toolbar',
'cleanupScheduler' => ['except' => ['cleanup/*']], 'cleanupScheduler' => ['except' => ['cleanup/*']],
], ],
@@ -117,7 +112,7 @@ class Filters extends BaseConfig
* @var array<string, array<string, list<string>>> * @var array<string, array<string, list<string>>>
*/ */
public array $filters = [ public array $filters = [
'apiratelimit' => ['before' => ['api/*', 'index.php/api/*']],
]; ];
} }
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
-11
View File
@@ -174,16 +174,6 @@ $routes->group('administrator/subject-curriculum', ['filter' => 'auth:update_cur
$routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1'); $routes->post('delete/(:num)', 'View\SubjectCurriculumController::delete/$1');
}); });
$routes->get('administrator/trophy', 'View\TrophyController::index', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/winners', 'View\TrophyController::winners', ['filter' => 'auth:admin']);
$routes->get('administrator/trophy/final', 'View\TrophyController::final', ['filter' => 'auth:admin']);
// Certificates
$routes->get('administrator/certificates', 'View\CertificateController::index', ['filter' => 'auth:admin']);
$routes->post('administrator/certificates/generate', 'View\CertificateController::generate', ['filter' => 'auth:admin']);
$routes->get('administrator/certificates/log', 'View\CertificateController::auditLog', ['filter' => 'auth:admin']);
$routes->get('verify/(:segment)', 'View\CertificateController::verify/$1');
/* /*
@@ -545,7 +535,6 @@ $routes->post('payment/event_charges', 'View\EventController::eventUpdate');
// Parent event participation // Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow'); $routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1'); $routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1'); $routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1'); $routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+1 -1
View File
@@ -36,7 +36,7 @@ class Services extends BaseService
/** /**
* Override CI Email service to enforce a global Reply-To. * Override CI Email service to enforce a global Reply-To.
*/ */
public static function email(?array $config = null, bool $getShared = true) public static function email(array $config = null, bool $getShared = true)
{ {
if ($getShared) { if ($getShared) {
return static::getSharedInstance('email', $config); return static::getSharedInstance('email', $config);
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
View File
+31 -51
View File
@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Models\LoginActivityModel; use App\Models\LoginActivityModel;
use App\Models\UserModel; use App\Models\UserModel;
use App\Models\UserRoleModel; use App\Models\UserRoleModel;
use CodeIgniter\Controller;
use CodeIgniter\Events\Events; use CodeIgniter\Events\Events;
use App\Models\IpAttemptModel; use App\Models\IpAttemptModel;
use App\Models\PasswordResetModel; use App\Models\PasswordResetModel;
@@ -18,7 +19,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
require_once APPPATH . 'Helpers/jwt_helper.php'; require_once APPPATH . 'Helpers/jwt_helper.php';
class AuthController extends BaseController class AuthController extends Controller
{ {
protected $configModel; protected $configModel;
protected $userModel; protected $userModel;
@@ -82,23 +83,9 @@ class AuthController extends BaseController
log_message('info', 'Processing login form submission.'); log_message('info', 'Processing login form submission.');
$redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? '')); $redirectTo = $this->sanitizeRedirectTarget((string) ($this->request->getPost('redirect_to') ?? $this->request->getGet('redirect_to') ?? ''));
$validator = \Config\Services::validation();
$requestData = sanitize_request_value($this->request->getPost(['email', 'password']));
$validator->setRules([
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validator->run($requestData)) {
return redirect()
->back()
->with('error', 'Please enter a valid email and password.')
->withInput();
}
// Step 1: Get email, password, and IP from the request // Step 1: Get email, password, and IP from the request
$email = strtolower((string) $requestData['email']); $email = $this->request->getPost('email');
$password = (string) $requestData['password']; $password = $this->request->getPost('password');
$ip = $this->request->getIPAddress(); $ip = $this->request->getIPAddress();
log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email); log_message('info', 'Login attempt from IP: ' . $ip . ' for email: ' . $email);
@@ -152,29 +139,26 @@ class AuthController extends BaseController
// JSON API: POST /api/login // JSON API: POST /api/login
public function apiLogin() public function apiLogin()
{ {
$requestData = sanitize_request_value($this->request->getJSON(true) ?: [ $requestData = $this->request->getJSON(true);
'email' => $this->request->getPost('email'), if (!$requestData) {
'password' => $this->request->getPost('password'), // fallback to form vars
]); $requestData = [
'email' => $this->request->getPost('email'),
$validation = \Config\Services::validation(); 'password' => $this->request->getPost('password'),
$validation->setRules([ ];
'email' => 'required|valid_email|max_length[254]',
'password' => 'required|max_length[255]',
]);
if (! $validation->run($requestData)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => false,
'message' => 'Validation failed.',
'errors' => $validation->getErrors(),
]);
} }
$email = strtolower((string) ($requestData['email'] ?? '')); $email = $requestData['email'] ?? '';
$password = (string) ($requestData['password'] ?? ''); $password = $requestData['password'] ?? '';
$ip = $this->request->getIPAddress(); $ip = $this->request->getIPAddress();
if (!$email || !$password) {
return $this->response->setStatusCode(400)->setJSON([
'status' => false,
'message' => 'Email and password are required.'
]);
}
if ($this->isIpBlocked($ip)) { if ($this->isIpBlocked($ip)) {
return $this->response->setStatusCode(429)->setJSON([ return $this->response->setStatusCode(429)->setJSON([
'status' => false, 'status' => false,
@@ -236,15 +220,7 @@ class AuthController extends BaseController
'exp' => $exp, 'exp' => $exp,
]; ];
try { $secret = env('JWT_SECRET', 'change-me-in-env');
$secret = require_env('JWT_SECRET');
} catch (\RuntimeException $e) {
return $this->response->setStatusCode(500)->setJSON([
'status' => false,
'message' => 'JWT configuration is missing.',
]);
}
$token = jwt_encode($payload, $secret, 'HS256'); $token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setJSON([ return $this->response->setJSON([
@@ -264,14 +240,18 @@ class AuthController extends BaseController
*/ */
public function apiRegister() public function apiRegister()
{ {
$requestData = sanitize_request_value($this->request->getJSON(true) ?: $this->request->getPost()); $requestData = $this->request->getJSON(true);
if (!$requestData) {
// fallback to form vars
$requestData = $this->request->getPost();
}
// Basic validation // Basic validation
$rules = [ $rules = [
'firstname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]", 'firstname' => 'required|min_length[2]|max_length[30]',
'lastname' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[2]|max_length[30]", 'lastname' => 'required|min_length[2]|max_length[30]',
'email' => 'required|valid_email|max_length[254]|is_unique[users.email]', 'email' => 'required|valid_email|is_unique[users.email]',
'password' => 'required|min_length[8]|max_length[255]', 'password' => 'required|min_length[8]',
'cellphone' => 'required|min_length[10]|max_length[20]', 'cellphone' => 'required|min_length[10]|max_length[20]',
]; ];
@@ -352,7 +332,7 @@ class AuthController extends BaseController
'exp' => $exp, 'exp' => $exp,
]; ];
$secret = require_env('JWT_SECRET'); $secret = env('JWT_SECRET', 'change-me-in-env');
$token = jwt_encode($payload, $secret, 'HS256'); $token = jwt_encode($payload, $secret, 'HS256');
return $this->response->setStatusCode(201)->setJSON([ return $this->response->setStatusCode(201)->setJSON([
+2 -17
View File
@@ -36,7 +36,7 @@ abstract class BaseController extends Controller
* *
* @var list<string> * @var list<string>
*/ */
protected $helpers = ['security']; protected $helpers = [];
/** @var ApiClient */ /** @var ApiClient */
protected ApiClient $api; protected ApiClient $api;
@@ -73,21 +73,6 @@ abstract class BaseController extends Controller
// Assuming the user role is stored in the session // Assuming the user role is stored in the session
return session()->get('role'); return session()->get('role');
} }
protected function validated(array $rules, ?array $data = null): array
{
$validation = service('validation');
$payload = $data ?? ($this->request->getJSON(true) ?: $this->request->getPost());
$payload = sanitize_request_value($payload);
$validation->setRules($rules);
if (! $validation->run($payload)) {
throw new \InvalidArgumentException(json_encode($validation->getErrors(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: 'Validation failed');
}
return $payload;
}
} }
?> ?>
View File
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
+3 -10
View File
@@ -20,22 +20,15 @@ class ProofreadController extends ResourceController
} }
// Accept form-urlencoded payload to play nicely with CSRF protection // Accept form-urlencoded payload to play nicely with CSRF protection
$validation = service('validation'); $text = (string) ($this->request->getPost('text') ?? '');
$payload = sanitize_request_value($this->request->getPost(['text'])); if ($text === '' || mb_strlen($text) > 20000) {
$validation->setRules([
'text' => 'required|min_length[1]|max_length[20000]',
]);
if (! $validation->run($payload)) {
return $this->respond([ return $this->respond([
'ok' => false, 'ok' => false,
'error' => 'Invalid text payload.', 'error' => 'Invalid text (empty or too long).',
'csrfHash' => csrf_hash(), 'csrfHash' => csrf_hash(),
], 422); ], 422);
} }
$text = (string) $payload['text'];
$client = \Config\Services::curlrequest(['timeout' => 10]); $client = \Config\Services::curlrequest(['timeout' => 10]);
try { try {
View File
View File
View File
View File
View File
View File
View File
View File
View File
@@ -1,398 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\CertificateRecordModel;
class CertificateController extends BaseController
{
protected $classSectionModel;
protected $configModel;
protected $certRecordModel;
protected $schoolYear;
public function __construct()
{
$this->classSectionModel = new ClassSectionModel();
$this->configModel = new ConfigurationModel();
$this->certRecordModel = new CertificateRecordModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
// ─── Admin UI ──────────────────────────────────────────────────────────────
public function index()
{
$db = \Config\Database::connect();
$classSectionId = $this->request->getGet('class_section_id');
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$classSections = $db->table('classSection cs')
->select('cs.class_section_id, cs.class_section_name')
->join('student_class sc', 'sc.class_section_id = cs.class_section_id')
->join('students s', 's.id = sc.student_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->groupBy('cs.class_section_id, cs.class_section_name')
->having('COUNT(s.id) >', 1)
->orderBy('cs.class_section_name', 'ASC')
->get()->getResultArray();
$students = [];
if ($classSectionId) {
$students = $db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
}
return view('admin/certificates/index', [
'classSections' => $classSections,
'students' => $students,
'selectedClassId' => $classSectionId,
'schoolYear' => $schoolYear,
'certDate' => date('m/d/Y'),
]);
}
public function auditLog()
{
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$records = $this->certRecordModel->getAuditLog($schoolYear);
$yearSummary = $this->certRecordModel->yearSummary();
return view('admin/certificates/audit_log', [
'records' => $records,
'schoolYear' => $schoolYear,
'yearSummary' => $yearSummary,
]);
}
// ─── Public verification page ──────────────────────────────────────────────
public function verify(string $certNumber)
{
$record = \Config\Database::connect()
->table('certificate_records cr')
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
->join('users u', 'u.id = cr.issued_by', 'left')
->where('cr.certificate_number', strtoupper($certNumber))
->get()->getRowArray();
return view('certificates/verify', ['record' => $record ?: null]);
}
// ─── PDF generation ────────────────────────────────────────────────────────
public function generate()
{
$studentIds = $this->request->getPost('student_ids') ?? [];
$certDate = trim($this->request->getPost('cert_date') ?? date('m/d/Y'));
$classSectionId = $this->request->getPost('class_section_id');
$schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear;
if (empty($studentIds)) {
return redirect()->to('administrator/certificates')->with('error', 'Please select at least one student.');
}
$studentIds = array_filter(array_map('intval', $studentIds));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
if (empty($studentIds)) {
return redirect()->to('administrator/certificates')->with('error', 'Invalid student selection.');
}
$db = \Config\Database::connect();
$students = [];
foreach ($studentIds as $id) {
$row = null;
if ($classSectionId) {
$row = $db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, cs.class_section_name AS grade')
->join('students s', 's.id = sc.student_id')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('s.id', $id)
->get()->getRowArray();
}
if (!$row) {
$s = $db->table('students')
->select('id, firstname, lastname, registration_grade AS grade')
->where('id', $id)
->get()->getRowArray();
$row = $s ?: null;
}
if ($row) {
$students[] = $row;
}
}
if (empty($students)) {
return redirect()->to('administrator/certificates')->with('error', 'No valid students found.');
}
$issuedBy = session()->get('user_id');
$issuedAt = date('Y-m-d H:i:s');
$certDateDb = $this->parseCertDate($certDate);
foreach ($students as &$student) {
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
$this->certRecordModel->insert([
'certificate_number' => $certNumber,
'student_id' => $student['id'],
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $this->formatGrade($student['grade'] ?? ''),
'cert_date' => $certDateDb,
'school_year' => $schoolYear,
'class_section_id' => $classSectionId ?: null,
'issued_by' => $issuedBy,
'issued_at' => $issuedAt,
]);
$student['cert_number'] = $certNumber;
}
unset($student);
$pdfData = $this->buildPdf($students, $certDate);
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setBody($pdfData);
}
// ─── Helpers ───────────────────────────────────────────────────────────────
private function parseCertDate(string $certDate): ?string
{
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $m)) {
return $m[3] . '-' . $m[1] . '-' . $m[2];
}
if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate)) {
return $certDate;
}
return null;
}
private function formatGrade(string $raw): string
{
$clean = trim($raw);
$lower = strtolower($clean);
if (preg_match('/^\d+$/', $clean) && (int)$clean >= 1 && (int)$clean <= 9) {
return 'Grade ' . $clean;
}
if ($lower === 'youth') {
return 'Youth';
}
if ($lower === 'kg') {
return 'Kindergarten';
}
return $clean;
}
/**
* Generates a QR code PNG for $url and returns its temporary file path.
* Caller must unlink() the file when done.
*/
private function makeQrTempFile(string $url): ?string
{
try {
$result = \Endroid\QrCode\Builder\Builder::create()
->writer(new \Endroid\QrCode\Writer\PngWriter())
->data($url)
->encoding(new \Endroid\QrCode\Encoding\Encoding('UTF-8'))
->size(300)
->margin(2)
->build();
$tmp = tempnam(sys_get_temp_dir(), 'cert_qr_') . '.png';
$result->saveToFile($tmp);
return $tmp;
} catch (\Throwable $e) {
log_message('error', 'Certificate QR generation failed: ' . $e->getMessage());
return null;
}
}
// ─── PDF rendering ─────────────────────────────────────────────────────────
private function buildPdf(array $students, string $certDate): string
{
$fontDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'fonts' . DIRECTORY_SEPARATOR;
$imgDir = FCPATH . 'assets' . DIRECTORY_SEPARATOR . 'certificates' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
$edwardianFont = \TCPDF_FONTS::addTTFfont($fontDir . 'Edwardian Script ITC Regular.ttf', 'TrueTypeUnicode', '', 32);
$garamondBold = \TCPDF_FONTS::addTTFfont($fontDir . 'Garamond Bold.ttf', 'TrueTypeUnicode', '', 32);
$ebGaramond = \TCPDF_FONTS::addTTFfont($fontDir . 'EBGaramond-Regular.ttf', 'TrueTypeUnicode', '', 32);
$pdf = new \TCPDF('L', 'pt', 'A4', true, 'UTF-8', false);
$pdf->SetCreator('Al Rahma Sunday School');
$pdf->SetTitle('Student Certificates');
$pdf->SetMargins(0, 0, 0, true);
$pdf->SetAutoPageBreak(false, 0);
$pdf->setPrintHeader(false);
$pdf->setPrintFooter(false);
$pdf->SetHeaderMargin(0);
$pdf->SetFooterMargin(0);
$W = $pdf->getPageWidth();
$H = $pdf->getPageHeight();
$tmpFiles = [];
foreach ($students as $student) {
$pdf->AddPage();
$name = $student['firstname'] . ' ' . $student['lastname'];
$grade = $this->formatGrade($student['grade'] ?? '');
$certNumber = $student['cert_number'] ?? '';
// Build verification URL and generate QR temp file
$verifyUrl = base_url('verify/' . rawurlencode($certNumber));
$qrFile = $certNumber !== '' ? $this->makeQrTempFile($verifyUrl) : null;
if ($qrFile) {
$tmpFiles[] = $qrFile;
}
$this->drawCertificate(
$pdf, $W, $H,
$name, $grade, $certDate, $certNumber,
$qrFile,
$imgDir, $edwardianFont, $garamondBold, $ebGaramond
);
}
$output = $pdf->Output('', 'S');
foreach ($tmpFiles as $f) {
@unlink($f);
}
return $output;
}
/**
* Coordinate mapping: iTextSharp origin bottom-left → TCPDF origin top-left.
* y_tcpdf ≈ H y_iTextSharp
*/
private function drawCertificate(
\TCPDF $pdf,
float $W,
float $H,
string $name,
string $grade,
string $certDate,
string $certNumber,
?string $qrFile,
string $imgDir,
string $edwardianFont,
string $garamondBold,
string $ebGaramond
): void {
// ── Images
$pdf->Image($imgDir . 'title.png', 126, 0, 600);
$pdf->Image($imgDir . 'background.png', 280, 176, 291, 340);
$pdf->Image($imgDir . 'signature.png', 140, 399, 90, 90);
// ── QR code — bottom-right corner: 1.5 cm from bottom, 2.5 cm from right
$qrSize = 42; // pt
if ($qrFile !== null && file_exists($qrFile)) {
$qrX = $W - 70.87 - $qrSize; // 2.5 cm from right edge
$qrY = $H - 42.52 - $qrSize; // 1.5 cm from bottom edge
$pdf->Image($qrFile, $qrX, $qrY, $qrSize, $qrSize);
}
// ── "Presented to:"
$pdf->SetFont('times', 'B', 24);
$pdf->SetTextColor(0, 0, 0);
$pdf->SetXY(0, 151);
$pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C');
// ── Student name (bold via fill + stroke)
$pdf->SetFont($edwardianFont, '', 38);
$pdf->SetDrawColor(0, 0, 0);
$pdf->setTextRenderingMode(0.4, true, false);
$pdf->SetXY(0, 219);
$pdf->Cell($W, 38, $name, 0, 0, 'C');
$pdf->setTextRenderingMode(0, true, false);
// ── Line under name
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 243);
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
// ── Description
$pdf->SetFont($ebGaramond, '', 20);
$pdf->SetXY(0, 273);
$pdf->Cell($W, 20, 'for successfully completing the requirements of', 0, 0, 'C');
// ── Grade
$pdf->SetFont($garamondBold, '', 20);
$pdf->SetXY(0, 310);
$pdf->Cell($W, 20, $grade, 0, 0, 'C');
// ── "at"
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 343);
$pdf->Cell($W, 20, 'at', 0, 0, 'C');
// ── School name
$pdf->SetFont($garamondBold, '', 20);
$pdf->SetXY(0, 375);
$pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C');
// ── Date (gradient script)
$this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453);
// ── Date underline + label
$pdf->SetFont('times', '', 20);
$pdf->SetXY(0, 463);
$pdf->Cell(742, 20, '_________________', 0, 0, 'R');
$pdf->SetXY(650, 492);
$pdf->Cell(80, 20, 'Date', 0, 0, 'C');
// ── Signature underline + label
$pdf->SetXY(106, 463);
$pdf->Cell(200, 20, '_________________', 0, 0, 'L');
$pdf->SetXY(106, 492);
$pdf->Cell(168, 20, 'Signature', 0, 0, 'C');
// ── Certificate number — 1.4 cm from bottom, 2.5 cm from left
if ($certNumber !== '') {
$pdf->SetFont('helvetica', '', 8);
$pdf->SetTextColor(150, 150, 150);
$pdf->SetXY(70.87, $H - 39.68);
$pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L');
$pdf->SetTextColor(0, 0, 0);
}
}
private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void
{
$pdf->SetFont($fontName, '', $fontSize);
for ($i = 0; $i < 6; $i++) {
$pdf->setAlpha(($i + 1) * 25 / 255);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x + $i / 5, $y + $i / 5, $text);
}
$pdf->setAlpha(1);
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x, $y, $text);
}
}
View File
View File
View File
View File
View File
View File
+9 -9
View File
@@ -24,10 +24,10 @@ class ContactController extends BaseController
// Define validation rules // Define validation rules
$validation->setRules([ $validation->setRules([
'name' => "required|regex_match[/^[\\p{L}\\s'\\-]+$/u]|min_length[3]|max_length[100]", 'name' => 'required|min_length[3]',
'email' => 'required|valid_email|max_length[254]', 'email' => 'required|valid_email',
'subject' => 'required|min_length[3]|max_length[150]', 'subject' => 'required|min_length[3]',
'message' => 'required|min_length[10]|max_length[5000]' 'message' => 'required|min_length[10]'
]); ]);
if (!$validation->withRequest($this->request)->run()) { if (!$validation->withRequest($this->request)->run()) {
@@ -37,10 +37,10 @@ class ContactController extends BaseController
} }
// Process form data // Process form data
$name = esc((string) $this->request->getPost('name')); $name = $this->request->getPost('name');
$email = esc(strtolower((string) $this->request->getPost('email'))); $email = strtolower($this->request->getPost('email'));
$subject = esc((string) $this->request->getPost('subject')); $subject = $this->request->getPost('subject');
$message = nl2br(esc((string) $this->request->getPost('message'))); $message = $this->request->getPost('message');
// Initialize the EmailController // Initialize the EmailController
$emailController = new \App\Controllers\View\EmailController(); $emailController = new \App\Controllers\View\EmailController();
@@ -63,4 +63,4 @@ class ContactController extends BaseController
return redirect()->to('/parent/contact'); return redirect()->to('/parent/contact');
} }
} }
View File
View File

Some files were not shown because too many files have changed in this diff Show More