# Technical documentation — nataliehirschman

Evidence date: 2026-10-09. Source: `git@bitbucket.org:overdrive/nataliehirschman.git`, `master` at `f9248b83` (2025-10-03).

Secret values that are committed in source (database password, SMTP password, reCAPTCHA secret, encryption key) are named by file and omitted from this document.

## Repository

| Item | Value |
|---|---|
| Remote | `git@bitbucket.org:overdrive/nataliehirschman.git` |
| Default branch | `master` (`origin/HEAD`). No other remote branches |
| Tip | `f9248b83` — pipeline file commented out |
| First commit | `94bc1ad4` 2022-12-13, Jaco Brits |
| Product work | Mostly Uriel Balela, 2023-01 to 2023-07, with pipeline commits by Jaco Brits |
| 2025 deploy edits | Wessels de Wet, 2025-10-01 to 2025-10-03 |
| README | `admin.nataliehirschman.com/README.md` is the Admin Overdrive / CodeIgniter starter. No root README |

The onboarding scan listed `sonarqube_scan.bat` at the root. It is not in this revision.

## Architecture

One git repository, two runnable surfaces, plus a static export.

1. **Public site** at the repository root. Apache (or the host) serves HTML, CSS, JS, and images directly. `contact.php` and `newsletter.php` are classic PHP, not CodeIgniter.
2. **Admin** in `admin.nataliehirschman.com/`. CodeIgniter 4 front controller. Routes in `app/Config/Routes.php`. Auto-routing is enabled.
3. **Theme dump** in `admin/`. Not a second framework app. `admin/js/ajaxcalls.js` is loaded by the public HTML with a relative URL, so a deploy of the whole repo makes that script available as `/admin/js/ajaxcalls.js`.

The public browser calls the admin by absolute URL `https://admin.nataliehirschman.com`. The PHP form handlers call `https://admin.nataliehirschman.com/public/contact` and `.../public/newsletter`. That `/public/` prefix matches a host whose document root is `admin.nataliehirschman.com/` rather than `admin.nataliehirschman.com/public/`. Whether the live vhost is set up that way is **Unknown / Requires Confirmation**. CodeIgniter's own `baseURL` is `http://admin.nataliehirschman.local` (`app/Config/App.php`).

Root `.htaccess` was not present at the repository root. Rewrite rules exist only under the admin:

- `admin.nataliehirschman.com/.htaccess`
- `admin.nataliehirschman.com/public/.htaccess`

Both disable directory indexes, strip a trailing slash, redirect `www` to the bare host over **http** (the rule checks `%{HTTPS} !=on` and then writes `http://`), and send other requests to `index.php`. `ServerSignature Off`. The parent file's `RewriteBase /webadmin/` line is commented out.

## Languages and libraries

| Layer | Choice | Evidence |
|---|---|---|
| Public pages | HTML, CSS, jQuery, Bootstrap bundle, Slick, Hammer, jQuery UI, Font Awesome | `assets/`, `index.html` |
| Public form handlers | PHP, cURL | `contact.php`, `newsletter.php` |
| Admin | PHP `^8.0`, CodeIgniter `v4.6.3` locked | `composer.json`, `composer.lock` |
| Admin UI | Starter layout, Feather icons, views under `app/Views` | `layouts/`, `imageList.php` |
| Database | MySQLi | `Database.php` |
| Declared, unused in `app/` | `aws/aws-sdk-php` `^3.254`, `mpdf/mpdf` `^8.1`, `phpoffice/phpspreadsheet` `^1.25` | `composer.json`. No `Aws\`, `Mpdf`, or `PhpSpreadsheet` reference under `app/` |
| Dev | PHPUnit `^9.1`, Faker, vfsStream | `composer.json`. Example tests only |
| JS package in the theme | `admin/package.json` for the HTML theme gulp build | Not the runtime of the CI app |

Vendor code is committed under `admin.nataliehirschman.com/vendor/`. `.git-ftp-ignore` excludes that directory from git-ftp. Windows checkouts need long-path support because of Aws and PhpSpreadsheet paths.

## Application structure

```text
index.html, about.html, cv.html, contact.html, works.html,
galleries.html, email-newsletter.html, newsletter-archive.html
contact.php, newsletter.php
assets/                 public css, js, fonts, FASO-sized images
styles/                 faso.css, wyeth, shared fonts
inquire/                static enquire pages
works/                  static works indexes
workszoom/              two static zoom pages plus index that calls getImage
workszoom-all/          larger static zoom set
home/g21s/              FASO artwork-viewer scripts
admin/                  theme + js/ajaxcalls.js
admin.nataliehirschman.com/
  app/Config            routes, filters, database, email, app
  app/Controllers       Welcome, Home, Images, Articles, Upload, Api,
                        Contact, Newsletter, Users, Employee,
                        Developers/MenuManagement, Developers/CRUDGenerator
  app/Models            Api, Image, Contact, Newsletter, User, Menu,
                        Employee, Developer
  app/Filters           Authentication, Authorization, Cors
  app/Helpers           mail_helper, menu_helper, useraccess_helper
  app/Views             login, register, image list, contact list, users, layouts
  app/Database          user/menu migrations and seeds only
  public/               CI front controller, admin assets, robots.txt
  writable/             cache, logs, session, uploads
  tests/                framework examples
  env                   edited environment template (not named .env)
  spark, composer.json, composer.lock
```

`Home.php` exists. Its route is commented: `// $routes->get('home', 'Home::index');`.

## Routing

Explicit routes (`app/Config/Routes.php`):

| Method and path | Target |
|---|---|
| GET `/`, GET `test` | `Welcome::index` |
| POST `GetLogin` | `Welcome::index` |
| GET `blocked` | `Welcome::forbiddenPage` |
| GET and POST `register` | `Welcome::register`, `Welcome::registration` |
| GET `articles` | `Images::index` |
| GET `articles/{segment}` | `Images::getImageWhere` |
| POST `articles/addImage` | `Articles::addImage` |
| DELETE `articles/delete` | `Articles::delete` |
| POST `articles/update` | `Upload::updateImageupload` |
| POST `upload` | `Upload::upload` |
| GET `api/{segment}` and `api/{segment}/{segment}` | `Api::index` |
| Resource `contact` | `Contact` (index, create, show, update, delete) |
| Resource `newsletter` | `Newsletter` |
| Users and menu-management POSTs | `Users`, `Developers\MenuManagement` |
| GET `menuManagement`, GET `crudGenerator` | developer controllers |

`setAutoRoute(true)` also exposes controller methods that have no explicit route, including `Welcome::logout` and `Employee`.

`Api::index` segment switch: `about`, `works`, `contact`, `cv`. Anything else falls off the end of the method.

## Data

### Created by migrations

| Table | Columns written in the migration |
|---|---|
| `users` | `id`, `fullname`, `username`, `password`, `role`, `created_at`, `updated_at` |
| `sessions` | CodeIgniter database session table (`2022-11-20-124435_Session.php`) |
| `user_role` | role rows; seed inserts `id` 1, `role_name` Developer |
| `user_access` | `id`, `role_id`, `menu_category_id`, `menu_id`, `submenu_id` |
| `user_menu` | seed columns `menu_category`, `title`, `url`, `icon`, `parent` |
| `user_submenu` | submenu migration `2022-11-20-130307_UserSubmenu.php` |
| `user_menu_category` | seed column `menu_category` |

`2022-11-24-100652_User.php` has empty `up()` and `down()`.

### Used by models, not migrated here

| Table | Fields the PHP reads or writes | Model |
|---|---|---|
| `pages` | `id`, `page_name`, and the joined row's `page_title` | `ApiModel`, `ImageModel::getPages` |
| `images` | `id`, `title`, `category`, `size`, `medium`, `created_at`, `created_by`, `Active`, `refPage`, `imageName`, `order`, `content_type`, `body`, `image_path`, `thumbnail_path` | `ImageModel`, `ApiModel` |
| `contacts` | `id`, `from`, `email`, `phone`, `comments` | `ContactModel` |
| `newsletter` | `id`, `email`, `created_at` | `NewsletterModel` |
| `employees` | `id`, `name`, `email` | `EmployeeModel` |

`ApiModel` joins `pages.id = images.refPage`. An inner join means a page with no image row is invisible to the API.

`getWorksImage` and `checkContentPage` build SQL by concatenating request values into the query string.

## Environment configuration

Two database definitions exist.

**`app/Config/Database.php` (what CI uses unless `.env` overrides it)**

- Host `sql11.cpt4.host-h.net`
- Database `natalgyyqk_db1`
- User `natalgyyqk_1`
- Driver MySQLi, port 3306, charset `utf8`, collation `utf8_general_ci`
- Password is in this file. Omitted here.
- `DBDebug` is true when `ENVIRONMENT` is not `production`.

**Committed `admin.nataliehirschman.com/env`**

- `CI_ENVIRONMENT = development`
- `database.default.hostname = localhost`
- `database.default.database = nataliehirscham` (spelled that way in the file)
- `database.default.username = root`
- `database.default.password` empty
- Session driver `CodeIgniter\Session\Handlers\DatabaseHandler`, cookie `ci_session`, save path `sessions`, match IP true, regenerate-destroy true, time-to-update 1440
- `encryption.key` is set. Omitted here.

CodeIgniter loads `.env`, not `env`. The starter README says to copy `env` to `.env`. `.rsync-exclude` lists `.env*`, which does not name the extensionless `env` file. A git-ftp deploy would upload `env` as a normal file and would not, by itself, create `.env`. If production has no `.env`, `Database.php` is the connection that runs. **Unknown / Requires Confirmation** which file the server actually loads.

`App.php` session settings differ from `env`: file handler, expiration 7200 seconds, match IP false, regenerate-destroy false, update every 300 seconds. `.env` wins only when those keys are present and the file is loaded.

`baseURL` is `http://admin.nataliehirschman.local`. `indexPage` is `index.php`. `forceGlobalSecureRequests` is false. `CSPEnabled` is false. `allowedHostnames` is empty.

## Authentication and sessions

- Filter `isLoggedIn` → `App\Filters\Authentication`. Missing `isLoggedIn` redirects to `/`.
- Filter `isGranted` → `App\Filters\Authorization`. Uses `MenuModel::getMenuByUrl` and `UserModel::checkUserAccess`.
- Filter `cors` runs first on every request.
- Login session keys: `username`, `role`, `isLoggedIn`.
- Passwords: `password_hash(..., PASSWORD_DEFAULT)` on create, `password_verify` on login.
- Seed user and password: see business rules. Do not treat the seed as proof of a live account.
- Public registration sets `inputRole` to `1`.
- Logout: `Welcome::logout` destroys the session. No explicit route; auto-route only.

Exceptions are listed in `app/Config/Filters.php`. `newsletter` is not among them. `upload` and `contact` are.

## Integrations

| Integration | Where | Notes |
|---|---|---|
| Admin JSON API | `Api` controller | CORS open. No API key |
| reCAPTCHA v3 | Site key in `contact.html` and `email-newsletter.html` (`6Lfl3o0kAAAAAJInGAszJz6LD67rJuWqIwdqAc0O`). Secret in `contact.php` and `newsletter.php` (omitted) | Verify URL `https://www.google.com/recaptcha/api/siteverify` |
| Matomo | Inline in public HTML | `//analytics.medmin.co.za/`, site `3`, domains `*.nataliehirschman.com` |
| SMTP | `app/Config/Email.php` | Host `smtp.googlemail.com`, port `587`, user `nataliehirschmanweb@gmail.com`, protocol `smtp`. Password omitted. `mail_helper.php` `sendMail` |
| MySQL | `Database.php` | Host above. Not SQLite except the PHPUnit `tests` group (`:memory:`) |
| FASO | Static HTML | `fineartstudioonline.com`, admin id `33778`, `images.fasosites.com` URLs left in the hero markup |
| FTP | Historical pipeline | `git ftp push` to `ftp://$FTP_HOST/public_html` using `$FTP_USERNAME` and `$FTP_PASSWORD` |

`Contact::create` does not call `sendMail`. The helper is the only application mail entry point found.

## Deployment and infrastructure

Current `bitbucket-pipelines.yml` has the `image:` line and the `pipelines:` block commented out (commit `f9248b83`, 2025-10-03). A push to `master` does not run those steps.

Immediately before that, the active step was:

- image `python:3.9` (also commented in the final edit)
- `apt-get` install `zip`, `git`, `git-ftp`
- `git ftp push` to `ftp://$FTP_HOST/public_html`
- vendor excluded by `.git-ftp-ignore` (`admin.nataliehirschman.com/vendor/` and `vendor/**`)
- `git ftp init` left commented, with a note to init once

The rsync revision the same week (`c896fbd4`, then replaced) synced the repo to `$FTP_USERNAME@$FTP_HOST:/public_html/` with `--delete`, excluding tests, writable cache/logs/session/debugbar, `.git/`, `.env*`, `phpunit.xml*`, `README.md`, `composer.lock`, `composer.json`, the pipeline file, and `.rsync-exclude`. `.rsync-exclude` still lists those patterns but the active file at the tip is git-ftp, and that step is commented out.

Sonar project key: `overdrive_nataliehirschman_AYZhdsHLtKPJNQrmj1Pe` in `sonar-project.properties`. The Sonar pipe is commented and names `SONAR_HOST_URL` and `SONAR_TOKEN`.

No Docker, no queue worker, no cron, no scheduler, and no background job class were found under `app/`.

Hosting shape **inferred** from the FTP path and the host-h.net database host: shared hosting with the site in `public_html`, admin as a sibling directory or vhost. The exact vhost map is **Unknown / Requires Confirmation**.

## Security notes (from the code, not a pentest)

- Database password, SMTP password, reCAPTCHA secret, and an encryption key are in git. They are not repeated here. Human review should decide rotation and history cleanup.
- Users seeder contains a known password for `jaco@overdrive.co.za`.
- Public registration creates role `1`.
- `upload` and the whole `contact` resource skip authentication.
- CSRF filter is off. CORS origin is `*`.
- API queries concatenate the works id into SQL.
- Upload validation is bypassed.
- `www` rewrite targets `http://`, not `https`.
- `contact.php` / `newsletter.php` do not check that cURL or JSON decode succeeded before reading `$response->success`.
- Directory listing is disabled only where the admin `.htaccess` applies. The public root has no `.htaccess` in git.
- `public/robots.txt` allows all crawlers. Root `robots.txt` was removed.

## Development and testing

Starter README commands:

- copy `env` to `.env`
- `php spark db:create`
- `php spark migrate`
- `php spark db:seed users` (this runs `Users`, which also calls `UserAccess`, `UserMenu`, `UserMenuCategory`, and `UserRole`)
- `php spark key:generate`
- `php spark serve`

Migrations will not create `pages`, `images`, `contacts`, or `newsletter`. Those tables have to already exist, or the API and forms fail. No SQL dump is in the repository.

Tests: `tests/unit/HealthTest.php`, `tests/session/ExampleSessionTest.php`, `tests/database/ExampleDatabaseTest.php`. `phpunit.xml.dist` is the framework file. No feature test covers contact, works, or login.

`writable/` is the CI writable tree (cache, logs, session, uploads).

## File map

| Concern | File |
|---|---|
| Public API client | `admin/js/ajaxcalls.js` |
| Contact hand-off | `contact.php`, `app/Controllers/Contact.php` |
| Newsletter hand-off | `newsletter.php`, `app/Controllers/Newsletter.php` |
| Read API | `app/Controllers/Api.php`, `app/Models/ApiModel.php` |
| Image admin | `app/Controllers/Images.php`, `app/Controllers/Upload.php`, `app/Views/images/imageList.php` |
| Auth | `app/Controllers/Welcome.php`, `app/Filters/Authentication.php`, `app/Filters/Authorization.php` |
| Filter exceptions | `app/Config/Filters.php` |
| Routes | `app/Config/Routes.php` |
| Mail | `app/Helpers/mail_helper.php`, `app/Config/Email.php` |
| Deploy | `bitbucket-pipelines.yml`, `.git-ftp-ignore`, `.rsync-exclude` |

## Unknown / requires confirmation

- Live document roots for the public host and `admin.nataliehirschman.com`, and whether `/public/contact` is the path that hits `Contact::create`.
- Whether a server `.env` overrides `Database.php` and the session driver.
- Whether the commented pipeline means production is frozen at the last successful FTP push.
- Column types for `pages`, `images`, `contacts`, `newsletter`, and `employees`.
- Which menu rows exist in production beyond the seed (articles and contacts are not seeded).
- Whether the seed user was imported.
- Case sensitivity of `content_type` (`Image` in the form, `image` in the filter query).
- Intended medium label for the option value `Carole`.
