';
} elseif (!empty($devSigPath) && is_file($devSigPath)) {
// Fallback: original file-path logic
$abs = realpath($devSigPath) ?: $devSigPath;
$prefix = rtrim($_SERVER["DOCUMENT_ROOT"] ?? "", "/");
$devSigUrl = str_replace($prefix, "", $abs);
if ($devSigUrl === $abs) {
// fallback if the file is outside docroot
$devSigUrl = $abs;
}
$DEV_SIGNATURE = '';
}
function loadContractHtml(?string $clientId, array $overrides = []): string {
$safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
$path = __DIR__ . '/contracts/' . $safeId . '.md';
if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
$md = file_get_contents($path);
// 1) Split front matter and body
$vars = [];
$body = $md;
if (preg_match('/^\s*---\s*\n(.*?)\n---\s*\n(.*)$/s', $md, $m)) {
$front = $m[1];
$body = $m[2];
$vars = parseFrontMatter($front);
}
// 2) Defaults available to every document
$base = [
'dev' => [
'name' => $GLOBALS['devName'] ?? 'Modulos Design',
'email' => $GLOBALS['devEmail'] ?? 'drafting@modulosdesign.com.au',
'phone' => $GLOBALS['devPhone'] ?? '',
'address' => $GLOBALS['devAddress'] ?? '',
],
'client' => [
'name' => $GLOBALS['clientName'] ?? '',
'email' => $GLOBALS['clientEmail'] ?? '',
'phone' => $GLOBALS['clientPhone'] ?? '',
'address' => $GLOBALS['clientAddress'] ?? '',
],
'today' => date('F j, Y'),
];
// 3) Merge: URL or POST overrides > front matter > base
$merged = array_replace_recursive($base, $vars, $overrides);
// 4) Also allow flat GET overrides like client_name=...
foreach (['client_name' => 'client.name', 'client_email' => 'client.email', 'client_phone' => 'client.phone'] as $q => $pathKey) {
if (isset($_GET[$q]) && $_GET[$q] !== '') {
setByPath($merged, $pathKey, (string)$_GET[$q]);
}
}
// 5) Replace [path.to.value] placeholders in the Markdown body
$body = preg_replace_callback('/\[([a-zA-Z0-9_.-]+)\]/', function ($m) use ($merged) {
$val = getByPath($merged, $m[1]);
return is_scalar($val) ? (string)$val : '';
}, $body);
// 6) Convert to HTML
$Parsedown = new Parsedown();
$Parsedown->setSafeMode(true);
return $Parsedown->text($body);
}
function parseFrontMatter(string $text): array {
// If the PECL yaml extension is available, use it
if (function_exists('yaml_parse')) {
$arr = @yaml_parse($text);
return is_array($arr) ? $arr : [];
}
// Minimal indentation-aware parser for nested maps and simple lists
$lines = preg_split('/\R/', rtrim($text));
$root = [];
$stack = [ ['indent' => -1, 'ref' => &$root] ];
foreach ($lines as $raw) {
if ($raw === '') continue;
$trimmed = ltrim($raw, ' ');
if ($trimmed === '' || $trimmed[0] === '#') continue;
$indent = strlen($raw) - strlen($trimmed);
// climb up to the correct parent by indent
while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) {
array_pop($stack);
}
$parent =& $stack[array_key_last($stack)]['ref'];
// List item
if (preg_match('/^-\s*(.*)$/', $trimmed, $m)) {
$val = $m[1];
if (!is_array($parent)) $parent = [];
if ($val === '') {
$parent[] = [];
$stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
} else {
$parent[] = _fm_trim_quotes($val);
}
continue;
}
// Key: value or Key:
if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trimmed, $m)) {
$key = $m[1];
$val = $m[2];
if ($val === '') {
if (!isset($parent[$key]) || !is_array($parent[$key])) {
$parent[$key] = [];
}
$stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
} else {
$parent[$key] = _fm_trim_quotes($val);
}
}
}
return $root;
}
function parseFrontMatterForId(?string $clientId): array {
$safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
$path = __DIR__ . '/contracts/' . $safeId . '.md';
if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
$md = @file_get_contents($path);
if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
return parseFrontMatter($m[1]);
}
return [];
}
function getPreparedDateFromMd(string $clientId): string {
$safe = preg_match('/^[A-Za-z0-9_-]{1,64}$/', $clientId) ? $clientId : 'default';
$path = __DIR__ . '/contracts/' . $safe . '.md';
if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
$md = file_get_contents($path);
if (preg_match('/^\s*---\s*\n(.*?)\n---/s', $md, $m)) {
$meta = parseFrontMatter($m[1]);
$prepared = getByPath($meta, 'dates.prepared', '');
return is_string($prepared) ? $prepared : '';
}
return '';
}
function _fm_trim_quotes(string $v): string {
$v = trim($v);
if ($v !== '' && $v[0] === "'" && substr($v, -1) === "'") return stripslashes(substr($v, 1, -1));
if ($v !== '' && $v[0] === '"' && substr($v, -1) === '"') return stripslashes(substr($v, 1, -1));
return $v;
}
function getByPath(array $arr, string $path, $default = '') {
$keys = explode('.', $path);
foreach ($keys as $k) {
if ($k === '') continue;
if (is_array($arr) && array_key_exists($k, $arr)) {
$arr = $arr[$k];
} else {
return $default;
}
}
return $arr;
}
function setByPath(array &$arr, string $path, $value): void {
$keys = explode('.', $path);
$ref =& $arr;
foreach ($keys as $k) {
if ($k === '') continue;
if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = [];
$ref =& $ref[$k];
}
$ref = $value;
}
// Gets the current file URL and replaces the .php extension with .html
function getHtmlUrl(string $htmlName): string {
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$scheme = $https ? 'https' : 'https';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
return $scheme . '://' . $host . ($dir ? $dir : '') . '/' . $htmlName;
}
function getClientIp(): string {
// 2) X-Forwarded-For: "client, proxy1, proxy2"
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
foreach ($parts as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return $ip; // first public address
}
}
// if nothing public, take the first valid one
foreach ($parts as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
return $ip;
}
}
}
// 3) X-Real-IP
if (!empty($_SERVER['HTTP_X_REAL_IP']) &&
filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
return $_SERVER['HTTP_X_REAL_IP'];
}
// 4) Fallback
if (!empty($_SERVER['REMOTE_ADDR']) &&
filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) {
return $_SERVER['REMOTE_ADDR'];
}
return 'UNKNOWN';
}
// Build overrides only when values are non-empty
$ov = ['client' => [], 'dev' => []];
if ($clientName !== '') $ov['client']['name'] = $clientName;
if ($clientEmail !== '') $ov['client']['email'] = $clientEmail;
if ($clientPhone !== '') $ov['client']['phone'] = $clientPhone;
if ($clientAddress!== '') $ov['client']['address'] = $clientAddress;
if ($devName !== '') $ov['dev']['name'] = $devName;
if ($devEmail !== '') $ov['dev']['email'] = $devEmail;
if ($devPhone !== '') $ov['dev']['phone'] = $devPhone;
if ($devAddress !== '') $ov['dev']['address'] = $devAddress;
$CONTRACT_HTML = loadContractHtml($_GET['clientid'] ?? null, $ov);
$CLIENT_SIGNATURE = $_POST["client_signature"] ?? null;
if ( is_string($CLIENT_SIGNATURE) && str_starts_with($CLIENT_SIGNATURE, "data:image/png;base64,") ) {
// Size guard, rejects very large data URIs
if (strlen($CLIENT_SIGNATURE) > 2 * 1024 * 1024) {
http_response_code(413);
exit("Signature too large");
}
$CLIENT_SIGNATURE = '
';
} else {
$CLIENT_SIGNATURE = null;
}
/* -------------------------------------------------------------------------- */
/* SECURITY AND ACCESS */
/* -------------------------------------------------------------------------- */
// Optional config
$cfg = @include __DIR__ . '/config.php';
$cfg = is_array($cfg) ? $cfg : [];
// Optional admin creds for Basic Auth or old token scheme
$ADMIN_USER = $cfg['admin_user'] ?? ($ADMIN_USER ?? '');
$ADMIN_PASS = $cfg['admin_pass'] ?? ($ADMIN_PASS ?? '');
$SECRET_KEY = $cfg['admin_secret'] ?? ($SECRET_KEY ?? ''); // old global secret, if you still use it
// Front matter helpers reused from your client file
if (!function_exists('_fm_trim_quotes')) {
function _fm_trim_quotes(string $v) {
$v = trim($v);
if ($v === '') return '';
$q = $v[0];
if (($q === '"' || $q === "'") && substr($v, -1) === $q) return substr($v, 1, -1);
return $v;
}
}
if (!function_exists('parseFrontMatter')) {
function parseFrontMatter(string $text): array {
if (function_exists('yaml_parse')) {
$arr = @yaml_parse($text);
return is_array($arr) ? $arr : [];
}
$lines = preg_split('/\R/', rtrim($text));
$root = [];
$stack = [ ['indent' => -1, 'ref' => &$root] ];
foreach ($lines as $raw) {
if ($raw === '') continue;
$trim = ltrim($raw, ' ');
if ($trim === '' || $trim[0] === '#') continue;
$indent = strlen($raw) - strlen($trim);
while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) array_pop($stack);
$parent =& $stack[array_key_last($stack)]['ref'];
if (preg_match('/^-\s*(.*)$/', $trim, $m)) {
$val = $m[1];
if (!is_array($parent)) $parent = [];
if ($val === '') {
$parent[] = [];
$stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
} else {
$parent[] = _fm_trim_quotes($val);
}
continue;
}
if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trim, $m)) {
$key = $m[1];
$val = $m[2];
if ($val === '') {
if (!isset($parent[$key]) || !is_array($parent[$key])) $parent[$key] = [];
$stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
} else {
$parent[$key] = _fm_trim_quotes($val);
}
}
}
return $root;
}
}
if (!function_exists('parseFrontMatterForId')) {
function parseFrontMatterForId(?string $clientId): array {
$safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? $clientId : 'default';
$path = __DIR__ . '/contracts/' . $safeId . '.md';
if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
$md = @file_get_contents($path);
if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
return parseFrontMatter($m[1]);
}
return [];
}
}
function fm_admin_secret_for(string $clientId): string {
$fm = parseFrontMatterForId($clientId);
// support either nested admin.secret or a flat admin_secret
if (!empty($fm['admin']) && is_array($fm['admin']) && !empty($fm['admin']['secret'])) {
return (string)$fm['admin']['secret'];
}
if (!empty($fm['admin_secret'])) return (string)$fm['admin_secret'];
return '';
}
/**
* Accept both link formats:
* 1) New style: contract.php?clientid=3043&token=HMAC_SHA256(clientid, fm_admin_secret)
* 2) Old style: contract.php?token=HMAC_SHA256(ADMIN_USER|ADMIN_PASS|expires, SECRET_KEY)&expires=UNIX
*/
function access_allowed_by_token(): bool {
global $ADMIN_USER, $ADMIN_PASS, $SECRET_KEY;
$clientId = $_GET['clientid'] ?? '';
$token = $_GET['token'] ?? '';
$expires = isset($_GET['expires']) ? (int)$_GET['expires'] : 0;
if ($token === '') return false;
// Old scheme with expiry and global secret
if ($expires > 0 && $ADMIN_USER !== '' && $ADMIN_PASS !== '' && $SECRET_KEY !== '') {
if ($expires < time()) return false;
$expected = hash_hmac('sha256', $ADMIN_USER . '|' . $ADMIN_PASS . '|' . $expires, $SECRET_KEY);
if (hash_equals($expected, $token)) return true;
}
// New scheme with per-client secret in front matter
if ($clientId !== '') {
$secret = fm_admin_secret_for($clientId);
if ($secret !== '') {
$expected2 = hash_hmac('sha256', $clientId, $secret);
if (hash_equals($expected2, $token)) return true;
}
}
return false;
}
function require_admin_auth(): void {
global $ADMIN_USER, $ADMIN_PASS;
// Allow valid token to bypass auth for clients
if (access_allowed_by_token()) return;
// If admin creds are not configured, block
if ($ADMIN_USER === '' || $ADMIN_PASS === '') {
http_response_code(401);
echo 'Auth required';
exit;
}
// Basic Auth for admins
if (!isset($_SERVER['PHP_AUTH_USER'])) {
header('WWW-Authenticate: Basic realm="Contracts Admin"');
header('HTTP/1.0 401 Unauthorized');
echo 'Auth required';
exit;
}
if ($_SERVER['PHP_AUTH_USER'] !== $ADMIN_USER || ($_SERVER['PHP_AUTH_PW'] ?? '') !== $ADMIN_PASS) {
header('WWW-Authenticate: Basic realm="Contracts Admin"');
header('HTTP/1.0 401 Unauthorized');
echo 'Invalid credentials';
exit;
}
}
require_admin_auth();
/** The HTML code (and some PHP) is kept in PHP variables like $CONTRACT_HTML, $FOOTER, $CONTRACT_SIGNED_PHP, and $CLIENT_DATE_IP_COMPILED. **/
function headerWithTitle(
string $title,
?string $clientId = null,
?string $preparedDate = null,
string $context = 'web' // 'web' or 'pdf'
): string {
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$safeJob = htmlspecialchars((string)$clientId, ENT_QUOTES, 'UTF-8');
$safePreparedDate = htmlspecialchars((string)$preparedDate, ENT_QUOTES, 'UTF-8');
$baseHref = htmlspecialchars(
((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'https')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
. rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/',
ENT_QUOTES,
'UTF-8'
);
// CSS includes differ by context
$cssLinks = $context === 'web'
? <<
HTML
: <<
HTML;
// No JS in PDF
$jsLinks = $context === 'web'
? <<
HTML
: '';
// Navbar only for web
$nav = $context === 'web'
? <<