Files
4WDCSA.co.za/docs/FUNCTIONS.md
T
twotalesanimation 81efab9cb0 Security hardening, DB connection fix, and handover documentation
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>
2026-08-09 13:56:32 +02:00

11 KiB
Raw Blame History

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 $field straight 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 token
  • validateCSRFToken($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 .php file. 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) — subscriptions
  • getEFTDetails($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.


Email

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

  1. Always use prepared statements. No string interpolation into SQL.
  2. Check permissions on every endpoint — logged in and allowed to touch this record. Several bugs have come from checking only the first.
  3. CSRF-validate every state-changing POST.
  4. Route uploads through validateFileUpload().
  5. Do not close the shared database connection.
  6. Add new pages to both routing tables (.htaccess and url()).
  7. Never echo raw user input — htmlspecialchars() on output.
  8. Keep secrets in .env, never in code.