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

18 KiB
Raw Permalink Blame History

4WDCSA Website — Handover Guide

Audience: the incoming committee, and whoever maintains this site next. Last updated: 2026-08-09

This document describes what the site does, how it is put together, how to run it day to day, and what is known to be wrong with it. It is deliberately blunt about the rough edges — you are inheriting this, so you should know.


1. What this is

The membership site for the 4x4 Club of South Africa. It handles:

  • Membership — applications, indemnity signing, payment, annual renewal
  • Bookings — club trips, driver-training courses, campsites
  • Payments — card payments via iKhokha, plus EFT with proof-of-payment upload
  • Content — a member blog, photo galleries, event pages
  • Bar tabs — an in-clubhouse till system for drinks
  • Track map — an interactive Leaflet map of the Base 4 track

It stores real personal data on members and their families. See §9 Data protection before you touch anything.


2. Technology and hosting

Thing What it is
Language PHP 8 (no framework — plain PHP with includes)
Database MySQL / MariaDB, accessed via mysqli
Web server Apache with mod_rewrite
Hosting InterWorx / SiteWorx shared hosting
Front end Bootstrap 5, jQuery 3.6, AOS, Slick — no build step
Dependencies Composer (mailjet, phpdotenv) + a hand-vendored Google API client
Email Mailjet (transactional templates)
Card payments iKhokha
Source control Git

There is no build step and no test suite. What is in the files is what runs.


3. Directory layout

/                        Web root
├── index.php            Home page
├── header.php           Shared header + nav (included by nearly every page)
├── validate_login.php   Login handler (email/password AND Google OAuth)
├── .env                 Secrets. NEVER commit. NEVER deploy over the live one.
├── .htaccess            Routing + security rules
├── .user.ini            PHP settings (session hardening, error display)
│
├── src/
│   ├── config/
│   │   ├── env.php          Loads .env
│   │   ├── connection.php   Opens the DB connection ($conn, $db)
│   │   ├── session.php      Starts + hardens the session, enforces timeout
│   │   └── functions.php    ~99 helper functions. The heart of the app.
│   ├── pages/           Views, grouped by area (auth, memberships, bookings,
│   │                    shop, gallery, events, blog, payment, other)
│   ├── processors/      Form POST handlers ("do a thing, then redirect")
│   ├── api/             JSON/AJAX endpoints
│   ├── admin/           Admin-only pages
│   ├── helpers/         notification_helper.php
│   └── logs/            Runtime logs (not web-readable)
│
├── classes/
│   └── DatabaseService.php   Prepared-statement wrapper (only lightly used)
├── components/          banner.php, insta_footer.php
├── assets/              css / js / images / sass / uploads
├── uploads/             Member uploads: blogs, proofs of payment, signatures
├── mailers/             Email templates
├── google-client/       Vendored Google API client (see §10)
├── scripts/
│   └── backup.sh        Nightly backup — see §8
└── docs/                This file, FUNCTIONS.md, migrations, feature notes

4. How a request is routed

There are two routing systems and they do not fully agree. Know this before you debug a 404.

  1. .htaccess — ~120 RewriteRule lines mapping clean URLs (/membership) onto files (src/pages/memberships/membership.php).
  2. The url() helper in functions.php — a PHP array mapping page names to file paths. Used by links inside templates.

Because url() returns file paths like /src/pages/other/about.php, and .htaccess 301-redirects any .php URL to its extensionless form, links generated by url() cost an extra redirect and expose the internal folder structure in the address bar.

If you add a page, you must add it to BOTH. Forgetting the .htaccess entry gives a 404; forgetting the url() entry gives a broken link.

Long term this should collapse to one table. See §11.


5. Roles and access control

Roles live in users.role and there are three:

Role Can do
user Normal member/visitor
admin All admin pages except superadmin-only ones
superadmin Everything, including editing the track map

Enforced by these helpers (from functions.php):

  • checkAdmin() — redirects to index.php unless admin or superadmin
  • checkSuperAdmin() — redirects unless superadmin
  • getUserRole() — returns the role string, or null if not logged in
  • checkUserSession() — redirects to login if not logged in

To grant someone admin there is no UI. Update the database directly:

UPDATE users SET role = 'admin' WHERE email = 'person@example.com';

Do this as part of every handover: list everyone who currently holds elevated access and remove people who have left the committee.

SELECT user_id, first_name, last_name, email, role
FROM users WHERE role IN ('admin','superadmin');

6. Membership lifecycle

This is the part most likely to generate "why can't I book?" questions.

The tables

Table Holds
users Login account, name, email, role
membership_application The application form: ID numbers, address, family, vehicles, indemnity acceptance and signature
membership_fees What was paid, when, and when membership expires
membership_links Links a second person (spouse/family) to a primary membership
membership_permissions What each linked person is allowed to do

When is someone "a member"?

getUserMemberStatus($user_id) returns true if any of these hold:

  1. They accepted the indemnity and membership_fees.payment_status = 'PAID' and today is on or before membership_end_date; or
  2. payment_status = 'PENDING RENEWAL' and today is on or before renewal_period_end (a grace period while they renew); or
  3. They are linked to somebody else's active membership.

If a member says they have paid but the site treats them as a non-member, check those three in order. The usual cause is payment_status never moving to 'PAID' because a payment webhook did not arrive (see §7).

Payment status values

PENDING (default) → AWAITING PAYMENT → PAID, with PENDING RENEWAL used during the renewal window.

Subscription pricing

calculateProrata() decides the fee:

  • March–October — full rate
  • November–February — pro-rata, charged per remaining month

Both rates come from the prices table (membership_fees and pro_rata rows), editable via Admin → Prices. The function has hardcoded fallbacks (R2600 full, R220/month) used only if those rows are missing.

Honorary members

validateHonoraryMemberName() holds a hardcoded list of names in functions.php and matches with 80% fuzzy similarity. To add or remove an honorary member you must edit that array in the code. Be aware the fuzzy match can produce false positives.


7. Payments

iKhokha — this is the live card gateway

  1. A booking or subscription creates a row in payments with status AWAITING PAYMENT.
  2. createIkhokhaPayment() asks iKhokha for a payment link and stores it.
  3. The member pays on iKhokha's site.
  4. iKhokha calls back to src/api/ikhokha_webhook.php.
  5. The webhook verifies an HMAC signature, then marks payments, bookings or membership_fees as PAID and emails confirmations.

If a payment shows as paid at iKhokha but not on the site, the webhook did not arrive or did not verify. Check progress.log in the site root. Admin → Transactions can pull history directly from iKhokha for reconciliation.

The webhook does not currently check that the amount paid matches the amount owed. It trusts the status. Worth fixing — see §11.

EFT

Members choose EFT, get a reference (addEFT() / addSubsEFT()), and upload a proof of payment (submit_pop.php, stored in uploads/pop/). Notification goes to the addresses in POP_NOTIFICATION_EMAILS. A human must then confirm the payment in Admin → EFTs. This step is manual by design.

Zero-amount bookings

processZeroPayment() handles free bookings (e.g. members' free campsite nights) without going near a payment gateway.

PayFast — dead code

processPayment() and processMembershipPayment() implement PayFast, but every call site is commented out. PayFast is not in use. The functions and the PAYFAST_* environment variables can be removed once someone confirms there is no intention to switch back.


8. Backups

This is the single most important operational task. Before August 2026 there were no automated backups at all.

scripts/backup.sh dumps the database and archives member uploads.

Setup

chmod +x scripts/backup.sh
./scripts/backup.sh          # run once by hand, confirm it prints SUCCESS

Then add a nightly cron job (SiteWorx → Cron Jobs). The script deploys with the site, so it sits inside the web root — adjust html if your document root is named differently:

30 2 * * * /home/YOURUSER/html/scripts/backup.sh >> /home/YOURUSER/backups/cron.log 2>&1

Backups are written to ~/backups/ — a sibling of the web root, so they are not reachable over the web. The script itself is inside the web root but .htaccess blocks .sh files from being served.

What it does

  • Dumps the full database (--single-transaction, so the site is not locked)
  • Archives uploads/ and assets/uploads/
  • Writes to a backups/ directory outside the web root, so backups can never be downloaded by a visitor
  • Verifies every dump — gzip integrity, presence of CREATE TABLE statements, and the Dump completed marker. A dump that fails any check is discarded rather than kept, so a broken backup can never masquerade as a good one, and a failed run can never overwrite yesterday's good backup
  • Keeps 14 days and deletes older ones (KEEP_DAYS to change)
  • Exits non-zero on failure so cron reports it

Two things you must still do

  1. Copy backups off this server. A backup that only lives on the machine it is backing up is not a backup. Download them, or sync them to cloud storage.
  2. Test a restore at least once. An untested backup is a guess.

Restoring

# Database
gunzip < backups/db/DBNAME-YYYY-MM-DD_HHMM.sql.gz | mysql -u USER -p DBNAME

# Uploaded files
tar -xzf backups/files/uploads-YYYY-MM-DD_HHMM.tar.gz -C /path/to/site/

9. Data protection (POPIA)

Read this before granting anyone database access.

The site stores, for members and in some cases their families:

  • South African ID numbers (member and spouse)
  • Dates of birth (member, spouse, and up to three children)
  • Physical and postal addresses, phone numbers, occupations
  • Vehicle registrations
  • Digital indemnity signatures (images in uploads/signatures/)
  • Payment records
  • IP addresses and page visits (visitor_logs)

Children's personal information carries heightened obligations under POPIA. The committee becomes the responsible party for all of it.

Two known gaps:

  1. There is no way to delete a member's data. No account deletion, no anonymisation. The privacy policy states data is kept only for "the period of time in which users have an account with us" — the software cannot currently honour that. Either build an erasure path or amend the policy.
  2. No Information Officer is named. POPIA expects one. Designate a committee role and record it.

This is a flag, not legal advice. Take it to whoever handles club governance.


10. Environment variables

All secrets live in .env at the web root. It is not in git and must never be committed. Loaded by src/config/env.php via phpdotenv.

Required — the site breaks without these

Variable Purpose
DB_HOST DB_USER DB_PASS DB_NAME Database connection
SALT Key for encryptData() / decryptData(), used for URL tokens
HOST Base URL, used for Google OAuth redirect
MAILJET_API_KEY MAILJET_API_SECRET Sending all email
MAILJET_FROM_EMAIL MAILJET_FROM_NAME From address on all email
GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET "Sign in with Google"

Required for payments

Variable Purpose
IKHOKHA_APP_ID IKHOKHA_APP_SECRET iKhokha API credentials and webhook signature
IKHOKHA_ENDPOINT iKhokha payment-link API URL
IKHOKHA_CALLBACK_URL Where iKhokha posts payment results. Must exactly match the URL used to compute the signature, or every webhook fails
IKHOKHA_SUCCESS_URL IKHOKHA_FAILURE_URL IKHOKHA_CANCEL_URL Where the member is sent afterwards
IKHOKHA_MODE live or test
IKHOKHA_REQUESTER_URL Site URL sent to iKhokha

Notifications

Variable Purpose
ADMIN_EMAIL New-registration notifications
NOTIFICATION_ADDR General admin notifications
FINANCE_EMAIL Payment notifications
POP_NOTIFICATION_EMAILS Comma-separated list for proof-of-payment alerts
DEVELOPER_EMAIL Optional. Extra copy of payment notifications. Leave unset in normal operation

Handover action: these should point at role addresses (chair@, treasurer@) that the club controls, not at individuals' personal addresses. Committees rotate; code should not need editing when they do. Note sendAdminNotification() in functions.php also has a person's name hardcoded in it.

Unused / legacy

PAYFAST_* (dead — see §7), ENFORCE_HTTPS (never read by any code; HTTPS is now forced in .htaccess instead), APP_ENV, APP_DEBUG, APP_URL, TIMEZONE (falls back to Africa/Johannesburg).


11. Known issues

Ordered roughly by how much they matter.

Will bite you eventually

  • Two route tables (§4) that drift apart. Adding a page means editing both.
  • The webhook does not verify payment amounts (§7) — it trusts the status field from iKhokha.
  • CSRF protection covers roughly 40% of state-changing endpoints. The framework is there (generateCSRFToken() / validateCSRFToken(), correctly implemented) — it just is not applied everywhere.
  • No null-checking on database failures. Many helpers do $conn = openDatabaseConnection(); then use $conn immediately. If the database is down this produces a fatal error rather than a friendly message.

Annoying but stable

  • The Google API client is installed twice — once hand-vendored in google-client/ (~145 MB, committed to git) and once as an orphan in vendor/google/ (~231 MB, not tracked, not referenced by Composer's autoloader, apparently dead). Both autoloaders load on the login page. Consolidating onto one Composer install would remove ~376 MB and a class of future dependency conflicts.
  • docs/DB_existing schema.sql is out of date. It is missing columns that later migrations added (for example membership_fees.renewal_period_end, which the code queries). Treat the live database as the source of truth.
  • Password reset has no rate limiting, no password-strength requirement, and reveals whether an email address is registered.
  • The half-finished restructure. validate_login.php still sits in the web root rather than src/processors/ where the routing table expected it. It works; it is just inconsistent.

Deliberate, not bugs

  • .htaccess blocks .log, .env, .sql, archives and dotfiles. Do not "tidy" those rules away.
  • uploads/.htaccess and assets/uploads/.htaccess disable PHP execution. These are a safety net against upload bypasses — never remove them. Note .gitignore contains a bare .htaccess rule, so these files were force-added to git; if you recreate them, use git add -f.
  • Sessions expire after 30 minutes idle. This is intentional.

12. Deploying changes

There is no CI. Deployment is manual.

If you have SSH (check SiteWorx), use rsync — it transfers only what changed:

rsync -avz --delete --exclude-from=.deployignore ./ user@4wdcsa.co.za:~/html/

Always dry-run first (--dry-run). Excludes must cover at minimum: .git/, .env, uploads/, assets/uploads/, docs/, *.log, vendor/google/.

If you only have FTP, use lftp mirror rather than dragging files:

lftp -c "open ftp.4wdcsa.co.za; mirror -R --only-newer --delete --parallel=4 ./ /html"

Never deploy .env or uploads/. The server copies are the real ones.

After any deploy, load the site once and check the login page works — the .htaccess and .user.ini files are the two things most likely to take the whole site down if a directive is not supported by the host.


13. When something breaks

Symptom Look at
Whole site 500s after a deploy .htaccess — a directive the host rejects. Restore the previous version first, debug second
"Too many connections" Should be fixed (connections are now shared per request), but check src/logs/db_errors.log
Member paid but shows unpaid iKhokha webhook — check progress.log, then Admin → Transactions
Nobody can log in Session config. Check the site is on HTTPS — cookies are Secure and will not be sent over plain HTTP
Emails not arriving Mailjet dashboard first, then MAILJET_* values in .env
Uploads failing Directory permissions on uploads/, and upload_max_filesize in .user.ini

Logs: src/logs/db_errors.log, progress.log (payments), and the Apache error log via SiteWorx. None are web-readable, by design.


14. Handover checklist

Technical work is only part of this. The rest:

  • Transfer account ownership: hosting/InterWorx, domain registrar, Mailjet, Google Cloud (OAuth), iKhokha merchant, Instagram
  • Migrate the Git repository. It currently lives on a private server belonging to the outgoing developer. Without this the committee has no source code and no way to deploy
  • Set up backups (§8) and verify a restore works
  • Audit admin/superadmin accounts (§5) and remove departed members
  • Repoint notification emails to role addresses (§10)
  • Designate an Information Officer and resolve the deletion gap (§9)
  • Record who to call when the site breaks