# Technical documentation — dthf-hr

Evidence date: 2026-10-16.

Source: `git@bitbucket.org:overdrive/dthf-hr.git`, `master` at `93552d8` (2026-06-19). Root commit `7d9727e` (2019-06-14, "Initial commit"). 53 commits. Remote branches: `master`, `Leave-integration`.

The scan copy at `~/projects/sonar_scan/repos/dthf-hr` was not on the inspection machine. A clone of `master` was used. Checkout on Windows stopped on timesheet PDFs under `documents/` whose paths exceed MAX_PATH. Those paths remain in Git. Application PHP for the screens below was present.

Passwords and usernames from `system.php` are not written here.

## What the tree is

A single PHP application using a custom framework called Nemo. There is no Composer autoload entry point for the app itself. Root scripts `include_once` the classes they need. `composer.json` only requires PDF and spreadsheet libraries.

```text
dthf-hr/
  *.php                         screens, crons, ajax, system.php
  _nemo.*.cls.php               framework
  includes/*.cls.php            domain classes
  includes/tcpdf, mpdf, ...     older PDF libraries
  layouts/                      HTML shell and jQuery
  css/, js/, images/, custom_fonts/, custom_controls/
  database/                     two SQL fragments
  documents/, PDF/, signatures/ generated or stored files
  vendor/                       Composer install, committed
  PHPExcel/, fpdi/              older libraries, still in the tree
  bitbucket-pipelines.yml
  composer.json, composer.lock
  README.md
```

`README.md` is the heading "Desmund Tutu HIV Foundation".

## Runtime

| Item | Evidence |
|---|---|
| Language | PHP. Commit `a647fe3` (2023-11-06) says "PHP 8.2 updates". `leave.api.php` comments on PHP 8.0 `curl_close` deprecation and PHP 8.5. `composer.json` has no `php` constraint |
| Database driver | `mysqli`. `mysql_*` calls in `email.read.online.php` are inside comments |
| Web server | Apache-style `.htaccess` rewrite. Document root is the application root. **Inferred** from `.htaccess` and from session paths under `DOCUMENT_ROOT` |
| Errors | `display_errors = 1`. Reporting is `E_ALL` without strict, notice, or warning (`system.php`) |
| Sessions | `session.gc_maxlifetime` 720000. `session.save_path` from the host block. `.gitignore` contains only `SESSION` |
| Tests | No PHPUnit, no test directory in the application tree |
| CI | `bitbucket-pipelines.yml` only. No test step |

`Nemo` starts a session inside `system.php` before the class constructors run. Authenticated pages then require `$_SESSION["USER"]`.

## Environments

`system.php` switches on `$_SERVER["SERVER_NAME"]`. `dthf-hr.co.za` is rewritten to `www.dthf-hr.co.za` before the switch.

| `SERVER_NAME` | How the file labels it | MySQL host | Database name | Session path suffix |
|---|---|---|---|---|
| `www.dthf-hr.co.za` | comment `LIVE` | `sql24.jnb1.host-h.net` | `dhtf_hr` | `SESSION/` under the document root |
| `dev.overdrive.co.za` | comment `QA`, property `environment = qa` | `sql6.jnb3.host-h.net` | `dthf_hr_qa_2` | `/dthf_hr/SESSION/` |
| `dthf-hr.local` | added in commit `0bf1436` ("Add database connection for local VHost") | `sql22.jnb1.host-h.net` | `dthf_hr_qa` | `/SESSION/` |
| `localhost` | no environment label | `sql22.jnb1.host-h.net` | `dthf_hr_qa` | `/DTHF/SESSION/` |
| `lamp` | local | `localhost` | `dthf_hr` | `/dthf_hr/SESSION/` |

The live database name in code is `dhtf_hr` (the letters `h` and `t` are swapped relative to the other names). Provenance: **Implemented** as that string. Whether the server database is really spelled that way is **Unknown / Requires Confirmation**.

`localhost` and `dthf-hr.local` set `SERVER_PROTOCOL` to HTTPS and HTTP respectively, then the code may overwrite `$_SERVER["SERVER_PROTOCOL"]` with that value. `$HTTP` is set to `https://` for every host, including a second assignment when the name is `localhost`.

QA credentials were changed in `ddfa1f1` (2023-11-01, "Changed QA DB details"). The values are hard-coded. They are omitted here on purpose. **Requires Confirmation** that they have been rotated, because they are in Git history.

If `mysqli_connect` fails, the script prints a critical error and does not build `$xdb`. On success it loads every `sysSettings` row into `$SystemSettings`.

Header code prints the database name next to the brand when `SERVER_NAME` is `lamp`.

## Framework

| Class file | Responsibility |
|---|---|
| `_nemo.basic.cls.php` | Page shell used by login. No user session required |
| `_nemo.cls.php` | Authenticated page. Loads security, sort, filters, last-visited, toolbar, paging. Renders `layouts/layout.back.incl.php` |
| `_nemo.menu.cls.php` | Menu from `sysMenuLevel1` / `sysMenuLevel2` / `sysSecurity` |
| `_nemo.list.cls.php`, `_nemo.details.cls.php` | List and detail screens |
| `_nemo.database.cls.php`, `_nemo.database2.cls.php` | Table gateway. `NemoDatabase` save/list/query. Domain code uses both `$xdb` and `new NemoDatabase` |
| `_nemo.password.cls.php` | `sysPassword` hashes. `hash` is described in a comment as the hash of protocol plus salt plus password |
| `_nemo.email.cls.php` | MIME email, templates, attachments, optional save |
| `_nemo.report.cls.php` | Report helper used by the daily crons |
| `_framework/_nemo.audit.cls.php` | Included by timesheet, blind-capture, and public-holiday classes |

PHP 8 work (comments "Wessels" and "Rachel", commits in October 2023) declared dynamic properties on these classes and quoted previously bare array keys.

## Domain data

The repository does not contain a full schema. Tables below are ones the current PHP reads or writes.

| Table | Used for |
|---|---|
| `sysUser`, `sysSecurityGroup`, `sysSecurity` | Accounts and rights |
| `sysMenuLevel1`, `sysMenuLevel2` | Menu |
| `sysSettings` | Runtime configuration, including SMTP and fiscal start |
| `sysPassword` | Password hashes and reset tokens |
| `sysLogin` | Login success and failure rows |
| `sysCronJob` | Queued jobs. Hourly script reads `strStatus = 'New'` |
| `tblCompany`, `tblCompanyStaff` | Companies and per-company staff codes |
| `tblStaff` | People. `strEmployeeNumber` added by `database/tblstaff0260107.sql` |
| `tblProject` | Projects. `refCompanyID`, `blnActive` |
| `tblProjectStaffAllocation` | Period percentage and status |
| `logProjectStaffAllocation` | Copy of allocations written at month end |
| `tblCompanyControlCheck` | Per company and period. `strPeriod`, `dtMonthEnd`, `blnMonthEnd`, `dblCostIT` |
| `tblCompanyCostIT` | IT units for the unit-cost division |
| `tblCompanyJournal`, `tblJournal` | Journals. A comment shows `strGLCode VARCHAR(9)` added to `tblCompanyJournal` |
| `tblTimesheet` | Control version per company, staff, period |
| `tblTimesheetBlind` | Captured signed version |
| `tblLeave` | Leave applications. DDL in `database/tblLeave.sql` |
| `tblApiToken` | Defined in `saveToken`. Not called by the pull |
| `hrPublicHoliday` | `dtEffectiveDate` for the calendar |
| `tblEmail` | Sent mail. Online read updates `strStatus` |

`tblProjectStaffAllocation.strStatus` is an enum. The application discovers the members from `information_schema` and `eval`s the column type after replacing `enum` with `array`. The PHP comparisons use `Initial`, `Approved`, `Final`, and `Adjusted`.

`getProjectStaffAllocationStatusArray` changes the connection's selected database to `information_schema` and then back.

## Integrations

### PaySpace / YourHCM

| Item | Value in code |
|---|---|
| Token URL | `https://identity.yourhcm.com/connect/token` |
| Token body | `client_id`, `client_secret`, `scope=api.full_access` |
| Leave URL | `https://api.payspace.com/odata/v2.0/{companyId}/EmployeeLeaveApplication/{year}/{month}` |
| Company id | `access` response `group_companies[0].companies[0].company_id` |
| Credentials | Globals `$CLIENT_ID` and `$CLIENT_SECRET`. Not assigned in the repository |
| Caller | `cron.employee.leave.php` |
| TLS | `CURLOPT_SSL_VERIFYPEER` true on the token request |

The leave GET uses `file_get_contents` with an `http` stream context and an `Authorization` header. Failure handling on that GET is not the same as the token `curl` error handling.

### Mail

`NemoEmail` builds headers with optional `mb_encode_mimeheader` when `sysSettings` `EnableMimeEncoding` is set. Callers pass `SMTP Send As` and sometimes `SMTP BCC`. The transport function is `Send()` in `_nemo.email.cls.php`. The SMTP host is a database setting, not a constant.

Templates are loaded by name, including `Reset Password`, and the cron payload field `strCronJobType`.

### FTP deploy

`bitbucket-pipelines.yml`:

- Image `python:3.5.7`.
- Branch `master`: `apt-get` install `zip`, `git`, `git-ftp`, then `git ftp init` to `ftp://$FTP_HOST_LIVE`.
- Branch `development`: the same with `FTP_*_QA`.
- `git ftp push` is commented on both branches.
- There is no PHP lint, Composer install, or test.

Provenance: **Implemented** as that YAML. Whether pipelines are enabled, and whether production is updated by `git ftp init` or by some other copy, is **Unknown / Requires Confirmation**.

## Scheduled scripts

No crontab is in the repository. A comment says the host already had a maximum of 10 cron jobs.

| File | What the live code does |
|---|---|
| `cron.hourly.php` | Selects up to 40 `sysCronJob` rows with `strStatus = 'New'`, newest `dtCreated` first. The active case `generateTimesheet` builds a PDF, emails it, and marks the row `Processed`. A commented copy of the query filters `dtCreated` to 2020-11-23. A 20190610 comment says the limit of 40 was added because the job fell over after about 48 rows |
| `cron.daily.php` | Outstanding timesheet reminders and outstanding report. Reminder days are month-end plus 8, 11, 15, and 20 |
| `cron.daily.outstanding.timesheets.php` | Same task names. A 5th-of-month roll references variables that are only set in comments |
| `cron.daily.outstanding.reports.php` | Report variant |
| `cron.daily.send.outstanding.timesheets.php` | Send variant |
| `cron.daily.initialize.timesheets.php` | On the 4th, if the previous period has `blnMonthEnd = 1`, calls `rollME_TSC` and sets `dtMonthEnd` to today. Header says this was moved |
| `cron.employee.leave.php` | PaySpace pull for the current year and month |

`set_time_limit(3000)` and `memory_limit` 128M are set on the daily scripts.

## PDF and Excel

| Library | Where | Role now |
|---|---|---|
| `mpdf/mpdf` ^8.1 | `vendor/`, `composer.json` | Current Composer dependency |
| `phpoffice/phpspreadsheet` ^1.29 | `vendor/` | Staff-cost and finance-levy imports since `af32517` |
| TCPDF | `includes/tcpdf` | Still in the tree |
| mPDF 2019 trees | `includes/mpdf`, `includes/mpdf_20190305` | Still in the tree. Comment records a function-redeclare clash with FPDI |
| FPDF / FPDI | `layouts/fpdf.php`, `fpdi/` | Encryption helper calls are commented |
| PHPExcel | `PHPExcel/` | Superseded for imports. Directory remains |

Timesheet PDFs are written under `PDF/`. Historical PDFs are committed under `documents/`. Filenames contain staff names and project titles. That is personal data in Git.

## Security notes from the code

These are observations, not a pentest.

- Production and QA database passwords are in `system.php` and therefore in Git history.
- `info.php` is `phpinfo()` with no authentication.
- `email.read.online.php` updates a row by `UID` with no session.
- `ajaxfunctions.php` `SendResetEmail` interpolates the email into SQL.
- `leave.api.php` builds insert SQL with `addslashes` and also interpolates the bearer-token company id into the URL.
- `CheckSecurityLock` tells the user the account is suspended and sends mail, but does not save `blnLogin`.
- `Action=RedirectKnowmore` in `login.php` loads a user by email and then continues into the login action with the password taken from the row.
- `display_errors` is enabled for every environment, including the live host name.
- Several cron and class files contain personal email addresses in comments. They are not repeated here.
- `.gitignore` does not ignore `documents/`, `vendor/`, or `system.php`.

## Local inspection limits

- `multica repo checkout` refused `dthf-hr` because the repo is not configured as a workspace resource. The tree was cloned read-only with Git over HTTPS.
- Bitbucket MCP `getRepository` returned HTTP 401, so repository settings (default reviewers, pipeline enabled flag) were not read from the API.
- Windows could not create some `documents/*.pdf` paths. Code and SQL files used for this documentation were readable.
- The live database was not queried. Enum members, security-group labels, and `sysSettings` values are therefore unknown beyond what PHP contains.
