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

474 lines
18 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.
# 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