Prepares the site for handover to the incoming committee. Three groups of change: security fixes, a production stability fix, and documentation. SECURITY - upload_blog_image.php accepted unauthenticated uploads with no extension or MIME validation, into a web-served directory. Anyone could upload and execute a .php file. Now requires a session, checks blog ownership, and runs through validateFileUpload() with a new blog_image type. - Added uploads/.htaccess and assets/uploads/.htaccess disabling the PHP engine, so an upload bypass still cannot execute. Force-added: .gitignore contains a bare .htaccess rule. - blog/autosave.php updated posts by blog_id alone, letting any logged-in member overwrite anyone's post. Now scoped to the author, admin override retained. It also silently reassigned authorship to the editing admin; the existing author is now preserved. - add_to_cart.php took item_price and user_id from POST, so drinks could be charged at any price to any member's tab, unauthenticated. Price and name are now looked up server-side from bar_items. Its require paths were also relative and broken. - Session cookies now httponly/secure/samesite with strict mode, set in both .htaccess and .user.ini since php_flag and .user.ini apply under different SAPIs. The 30 minute idle timeout that login set but never checked is now enforced. Google login now regenerates the session id. - Forced HTTPS. Required, because Secure cookies are never sent over plain HTTP and login would fail silently. LetsEncrypt acme-challenge is exempted above the redirect so certificate renewal keeps working. - display_errors off; .htaccess now blocks *.log, error_log, .git and dotfiles. Debug logs containing member IDs, raw webhook bodies and payment signatures were publicly fetchable. - Removed the IKHOKHA_BYPASS_SIGNATURE switch and the logging of raw webhook bodies, app IDs and signatures. Replaced a hardcoded personal Gmail recipient with the optional DEVELOPER_EMAIL variable. - Added auth to fetch_users, fetch_bar_tabs, fetch_drinks, create_bar_tab and submit_order. Converted get_tab_total to a prepared statement. - Tightened 11 mkdir(0777) calls to 0755. - Fixed the validate_login rewrite, which pointed at a nonexistent path and only worked by falling through to the generic .php rule. DATABASE STABILITY openDatabaseConnection() opened a new connection on every call across 73 call sites, and the server was hitting "Too many connections" in production. It now returns one shared handle per request, reusing the connection connection.php already opened. The 40 conn->close() calls in functions.php are removed, since closing the shared handle breaks every later caller; PHP releases it at end of request. A liveness check reconnects transparently if anything closes it. Verified no function was lost (99 before and after), every removed close was inside a braced block, and no transaction spans a helper that would open a nested one. DOCUMENTATION AND TOOLING - docs/HANDOVER.md: operator guide covering architecture, routing, roles, the membership lifecycle, payments, backups, POPIA obligations, environment variables, known issues and a handover checklist. - docs/FUNCTIONS.md: reference for the helpers in functions.php. - scripts/backup.sh: nightly database and uploads backup, written outside the web root, with rotation and verification. Dumps are written to a temp file and only promoted after passing gzip, CREATE TABLE and completion-marker checks, so a failed run can never overwrite a good backup. - .deployignore and .gitattributes added; .gitattributes pins LF on shell scripts and server config because core.autocrlf is true on Windows. - Removed leftover debris: src/api/test_log.php and .htaccess1. All 22 modified PHP files pass php -l. The backup script was tested against a stub mysqldump for healthy, truncated and failing dumps, and for rotation. Nothing was run against a live database or web server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
Function Reference
Developer reference for the helpers in src/config/functions.php (~99
functions). This covers the ones you will actually reach for. For how the site
fits together, see HANDOVER.md.
Everything here is available after:
require_once($rootPath . "/src/config/functions.php");
Database
openDatabaseConnection()
Returns a shared mysqli connection for the current request, or null if
the database is unreachable.
$conn = openDatabaseConnection();
if ($conn === null) { /* handle it */ }
Do not call
$conn->close()on the result. The handle is reused across every caller in the request; closing it breaks everything that runs after. PHP releases it automatically when the request ends.This used to open a fresh connection per call, across 73 call sites, which was exhausting the server's connection limit in production.
Most helpers below call this internally, so you rarely need it directly.
classes/DatabaseService.php
A prepared-statement wrapper available as $db after including
connection.php. Cleaner than raw mysqli, but only lightly used. Prefer it for
new code.
Methods: select(), selectOne(), insert(), update(), delete(),
execute(), count(), exists(), beginTransaction(), commit(),
rollback(), getConnection(), plus getLastError() / getLastQuery() for
debugging.
$rows = $db->select("SELECT * FROM trips WHERE published = ?", [1], "i");
Authentication and access control
| Function | Returns | Notes |
|---|---|---|
getUserRole() |
'user' / 'admin' / 'superadmin' / null |
null when logged out |
checkAdmin() |
— | Redirects to index.php unless admin/superadmin |
checkSuperAdmin() |
— | Redirects unless superadmin |
checkUserSession() |
— | Redirects to login if not logged in |
get_user_info($field) |
mixed | Reads one column from users for the logged-in user |
checkAdmin() and friends exit() after redirecting — call them at the very
top of a page, before any output.
require_once(...functions.php);
checkAdmin(); // page below is admin-only
get_user_info()interpolates$fieldstraight into the SQL. Only ever pass a hardcoded column name — never user input.
Login throttling
| Function | Purpose |
|---|---|
recordLoginAttempt($email, $success = false) |
Log an attempt |
countRecentFailedAttempts($email, $minutesBack = 15) |
Recent failures |
checkAccountLockout($email) |
['is_locked' => bool, 'minutes_remaining' => int, ...] |
lockAccount($email, $minutes = 15) |
Lock after repeated failures |
unlockAccount($email) |
Manual unlock — use this when a member phones up |
Five failures in 15 minutes triggers a 15-minute lockout.
CSRF protection
// In the form
<input type="hidden" name="csrf_token" value="<?= generateCSRFToken() ?>">
// In the processor, before doing anything else
if (!isset($_POST['csrf_token']) || !validateCSRFToken($_POST['csrf_token'])) {
http_response_code(403);
exit(json_encode(['status' => 'error', 'message' => 'Security token validation failed.']));
}
generateCSRFToken($duration = 3600)— random, expiring tokenvalidateCSRFToken($token)— single-use; validating consumes it
Tokens are single-use, so a page with several independent forms needs a token per form. If a form is submitted twice (double-click, back button) the second attempt fails validation — that is intended.
Apply this to every state-changing endpoint. Roughly 40% currently do.
Input validation
All return the cleaned value, or false if invalid.
| Function | Checks |
|---|---|
validateEmail($email, $maxLength = 254) |
Format and length |
validatePhoneNumber($phone) |
7–20 digits |
validateName($name, $min = 2, $max = 100) |
Length and characters |
validateDate($date, $format = 'Y-m-d') |
Real date in the given format |
validateAmount($amount, $min = 0, $max = 999999.99) |
Money |
validateInteger($int, $min = 0, $max = 2147483647) |
Bounded integer |
validateSAIDNumber($idNumber) |
SA ID number incl. checksum |
sanitizeTextInput($text, $maxLength = 1000) |
Strips dangerous content |
validateFileUpload($file, $fileType = 'document')
The only correct way to accept an upload. Returns
['filename' => ..., 'extension' => ..., 'mimeType' => ...] or false.
$validated = validateFileUpload($_FILES['file'], 'blog_image');
if ($validated === false) { /* reject */ }
move_uploaded_file($_FILES['file']['tmp_name'], $dir . $validated['filename']);
Types: profile_picture, blog_image, proof_of_payment, document.
It checks the upload error code, size, an extension whitelist, double
extensions (shell.php.jpg), MIME type, that images really are images, and
is_uploaded_file(). It returns a random filename — always use that rather
than the user's original name.
An endpoint that skipped this was the most serious vulnerability found in the August 2026 audit: it allowed anyone to upload and execute a
.phpfile. Do not hand-roll upload handling.
Membership
| Function | Returns |
|---|---|
getUserMemberStatus($user_id) |
true if member benefits apply — the one to use for gating |
getUserMemberStatusPending($user_id) |
true if awaiting payment |
hasAcceptedIndemnity($user_id) |
Indemnity signed? |
getMembershipType($user_id) |
Type from the latest application |
getMembershipEndDate($user_id) |
Expiry date |
isMembershipExpiringSoon($user_id) |
true within 90 days of expiry |
validateHonoraryMemberName($first, $last) |
Fuzzy match against a hardcoded list |
getUserMemberStatus() also returns true for people linked to somebody
else's active membership — that is usually what you want. See HANDOVER.md §6
for the full rules.
Linked memberships (spouse / family)
| Function | Purpose |
|---|---|
linkSecondaryUserToMembership($primary_id, $secondary_id, $relationship = 'spouse') |
Create a link + default permissions. Transactional |
getUserMembershipLink($user_id) |
['has_access' => bool, ...] for a secondary user |
getLinkedSecondaryUsers($primary_id) |
Everyone linked to a primary |
unlinkSecondaryUser($link_id, $primary_id) |
Remove a link. Verifies ownership |
Pricing and payments
| Function | Purpose |
|---|---|
calculateProrata() |
Subscription fee for right now (full Mar–Oct, pro-rata Nov–Feb) |
getPriceByDescription($description) |
One row from the prices table |
getAvailableSpaces($trip_id) |
Vehicle capacity minus bookings |
generatePaymentRef(string $type, ?int $course_trip_id, int $user_id): string |
Unique reference. $type is SUBS, COURSE, TRIP, … |
getPaymentAmount($payment_id) |
Amount for a payment |
getPaymentLinkByPaymentId($payment_id) |
Stored gateway payment link |
iKhokha
| Function | Purpose |
|---|---|
createIkhokhaPayment($payment_id, $amount, $description, $publicRef) |
Creates a payment link and records provider metadata |
getIkhokhaTransactionHistory($startDate, $endDate) |
Raw history, used by Admin → Transactions |
createPayloadToSign($urlPath, $body) / generateSignature($payload, $secret) |
HMAC signing. Used by the webhook — do not change without testing against live iKhokha callbacks |
$amount is in rands; the function converts to cents internally.
EFT
addEFT($eft_id, $booking_id, $user_id, $status, $amount, $description)addSubsEFT($eft_id, $user_id, $status, $amount, $description)— subscriptionsgetEFTDetails($eft_id),getUserIdFromEFT($eft_id),getEftDescription($eft_id)
Dead code
processPayment(), processMembershipPayment(), processPaymentTest() are
PayFast and are not called from anywhere live. processZeroPayment() is
live — it handles free bookings.
All go through Mailjet and mostly use stored templates.
| Function | Goes to |
|---|---|
sendVerificationEmail($email, $name, $token) |
New registrant |
sendPaymentConfirmation($email, $name, $description) |
Paying member |
sendInvoice($email, $name, $eft_id, $amount, $description) |
Member, after booking |
sendPOP($fullname, $eft_id, $amount, $description) |
POP_NOTIFICATION_EMAILS |
sendAdminNotification($subject, $message) |
NOTIFICATION_ADDR |
sendFinanceNotification($subject, $message) |
FINANCE_EMAIL |
sendEmail($email, $subject, $message) |
Plain text, no template |
Template IDs are hardcoded. If a template is edited in Mailjet the ID stays the same; if it is recreated, the ID changes and the code must be updated.
Tokens and URLs
encryptData($input, $salt) / decryptData($encrypted, $salt)
Used to put record IDs in URLs without exposing raw database IDs:
$token = encryptData($blog_id, $salt); // link out
$blog_id = intval(decryptData($token, $salt)); // read back
$salt comes from .env via connection.php. Changing SALT invalidates
every existing link.
This obscures IDs; it is not access control. Always still check ownership.
url($page)
Maps a page name to a path. See HANDOVER.md §4 for why this and .htaccess
must be kept in step.
Audit and logging
auditLog($user_id, $action, $resource_type = null, $resource_id = null, $details = null)
Writes to the audit table. Use for security-relevant events:
auditLog($user_id, 'BLOG_AUTOSAVE_DENIED', 'blogs', $blog_id);
$user_id may be null for pre-authentication events. Do not put secrets,
passwords or signature values in $details — admins can read this table.
progress_log($message, $context = null)
Appends to progress.log in the site root. Used heavily by the payment
webhook. Never throws, so it is safe in error paths. Blocked from web access by
.htaccess — keep it that way, it contains payment data.
Visitors
logVisitor(), getUserIP(), getClientIPAddress(), guessCountry(),
blockBlacklistedIP(). visitor_logs holds IP addresses tied to user IDs —
personal data under POPIA (HANDOVER.md §9).
Formatting helpers
| Function | Does |
|---|---|
convertDate($dateString) |
2026-03-15 → Sun, 15 Mar 2026 |
calculateDaysAndNights($start, $end) |
"3 days 2 nights" |
formatCurrency($amount) |
Rand formatting |
getFullName($user_id) |
"First Last" |
getInitialSurname($user_id) |
"F.Last" |
getEmail($user_id) / getProfilePic($user_id) |
Single lookups |
normalizeName($name) |
Lowercase, punctuation stripped — for fuzzy matching |
optimizeImage($filePath, $maxWidth = 1920, $maxHeight = 1080) |
Resize in place |
Conventions for new code
- Always use prepared statements. No string interpolation into SQL.
- Check permissions on every endpoint — logged in and allowed to touch this record. Several bugs have come from checking only the first.
- CSRF-validate every state-changing POST.
- Route uploads through
validateFileUpload(). - Do not close the shared database connection.
- Add new pages to both routing tables (
.htaccessandurl()). - Never echo raw user input —
htmlspecialchars()on output. - Keep secrets in
.env, never in code.