# 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: ```sql 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. > ```sql > 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 ```bash 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 ```bash # 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: ```bash 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: ```bash 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