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>
18 KiB
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 |
| 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.
.htaccess— ~120RewriteRulelines mapping clean URLs (/membership) onto files (src/pages/memberships/membership.php).- The
url()helper infunctions.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 toindex.phpunless admin or superadmincheckSuperAdmin()— redirects unless superadmingetUserRole()— returns the role string, ornullif not logged incheckUserSession()— 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:
- They accepted the indemnity and
membership_fees.payment_status = 'PAID'and today is on or beforemembership_end_date; or payment_status = 'PENDING RENEWAL'and today is on or beforerenewal_period_end(a grace period while they renew); or- 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
- A booking or subscription creates a row in
paymentswith statusAWAITING PAYMENT. createIkhokhaPayment()asks iKhokha for a payment link and stores it.- The member pays on iKhokha's site.
- iKhokha calls back to
src/api/ikhokha_webhook.php. - The webhook verifies an HMAC signature, then marks
payments,bookingsormembership_feesasPAIDand 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/andassets/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 TABLEstatements, and theDump completedmarker. 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_DAYSto change) - Exits non-zero on failure so cron reports it
Two things you must still do
- 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.
- 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:
- 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.
- 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. NotesendAdminNotification()infunctions.phpalso 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$connimmediately. 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 invendor/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.sqlis out of date. It is missing columns that later migrations added (for examplemembership_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.phpstill sits in the web root rather thansrc/processors/where the routing table expected it. It works; it is just inconsistent.
Deliberate, not bugs
.htaccessblocks.log,.env,.sql, archives and dotfiles. Do not "tidy" those rules away.uploads/.htaccessandassets/uploads/.htaccessdisable PHP execution. These are a safety net against upload bypasses — never remove them. Note.gitignorecontains a bare.htaccessrule, so these files were force-added to git; if you recreate them, usegit 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