Files
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

301 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](HANDOVER.md).
Everything here is available after:
```php
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.
```php
$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.
```php
$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.
```php
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
```php
// 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`.
```php
$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:
```php
$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:
```php
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.