contract.php 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235
  1. <?php
  2. /* ##########################
  3. [client_email]
  4. [dev_email]
  5. [dev_signature]
  6. [dev_ip]
  7. [dev_timestamp]
  8. [dev_timestamp_offset]
  9. [dev_name]
  10. [client_name]
  11. ################################### */
  12. //error_reporting(E_ERROR | E_PARSE);
  13. error_reporting(E_ALL);
  14. ini_set("display_errors", 0);
  15. ini_set("log_errors", 1);
  16. date_default_timezone_set("Australia/Hobart");
  17. ini_set("default_charset", "UTF-8");
  18. mb_internal_encoding("UTF-8");
  19. require_once __DIR__ . "/Parsedown.php";
  20. require_once __DIR__ . "/dompdf/autoload.inc.php";
  21. use PHPMailer\PHPMailer\PHPMailer;
  22. use PHPMailer\PHPMailer\SMTP;
  23. use PHPMailer\PHPMailer\Exception;
  24. require_once "../internal/phpmailer/src/Exception.php";
  25. require_once "../internal/phpmailer/src/PHPMailer.php";
  26. require_once "../internal/phpmailer/src/SMTP.php";
  27. // at the top, before any output
  28. session_start();
  29. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  30. $ok = isset($_POST["csrf"], $_SESSION["csrf"]) && hash_equals($_SESSION["csrf"], $_POST["csrf"]);
  31. if (!$ok) {
  32. http_response_code(403);
  33. exit("Invalid CSRF token");
  34. }
  35. }
  36. if (empty($_SESSION["csrf"])) {
  37. $_SESSION["csrf"] = bin2hex(random_bytes(32));
  38. }
  39. /* ------------------------------- CSRF (same) ---------------------------------- */
  40. $cfg = @include __DIR__ . "/config.php";
  41. $cfg = is_array($cfg) ? $cfg : [];
  42. $csrf = htmlspecialchars($_SESSION["csrf"] ?? "", ENT_QUOTES, "UTF-8");
  43. // Behavior flags
  44. $redirectToSigned = true; // keep this true
  45. $allowSelfDelete = (bool)($cfg['self_delete'] ?? false); // default off
  46. $fromAddress = $cfg["from_address"] ?? "drafting@modulosdesign.com.au";
  47. $clientIdRaw = $_GET['clientid'] ?? '';
  48. $clientId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', $clientIdRaw) ? $clientIdRaw : 'unknown';
  49. $devName = $cfg['dev_name'] ?? 'Modulos Design';
  50. $devEmail = $cfg["dev_email"] ?? 'drafting@modulosdesign.com.au';
  51. $devEmail = preg_replace('/[\r\n]+/', "", $devEmail);
  52. $devSig = $cfg["dev_signature"] ?? "";
  53. $devPhone = $cfg['dev_phone'] ?? '';
  54. $devAddress = $cfg['dev_address'] ?? '';
  55. $clientName = $_GET['client_name'] ?? '';
  56. $clientEmail = $_POST['client_email'] ?? $_GET['client_email'] ?? '';
  57. $clientEmail = trim(preg_replace('/[\r\n]+/', '', (string)$clientEmail)); // strip CRLF + trim
  58. $clientPhone = $_GET['client_phone'] ?? '';
  59. $clientAddress = $_GET['client_address'] ?? '';
  60. // 🔧 Fallback to front matter if still empty
  61. if ($clientEmail === '') {
  62. $meta = parseFrontMatterForId($clientId);
  63. $clientEmailFromMd = '';
  64. if (is_array($meta)) {
  65. // prefer nested client.email, but accept client_email if present
  66. $clientEmailFromMd = (string)(
  67. getByPath($meta, 'client.email', '') ?:
  68. ($meta['client']['email'] ?? '') ?:
  69. ($meta['client_email'] ?? '')
  70. );
  71. }
  72. if ($clientEmailFromMd !== '') {
  73. $clientEmail = trim(preg_replace('/[\r\n]+/', '', $clientEmailFromMd));
  74. }
  75. }
  76. if (is_string($devSig) && $devSig !== '') {
  77. // Can be a data: URL or a normal URL; both are fine
  78. $DEV_SIGNATURE = '<img id="dev_signature" src="' . htmlspecialchars($devSig, ENT_QUOTES, 'UTF-8') . '" style="max-height: 117px;padding: 10px;" alt="Client Relations Signature">';
  79. } elseif (!empty($devSigPath) && is_file($devSigPath)) {
  80. // Fallback: original file-path logic
  81. $abs = realpath($devSigPath) ?: $devSigPath;
  82. $prefix = rtrim($_SERVER["DOCUMENT_ROOT"] ?? "", "/");
  83. $devSigUrl = str_replace($prefix, "", $abs);
  84. if ($devSigUrl === $abs) {
  85. // fallback if the file is outside docroot
  86. $devSigUrl = $abs;
  87. }
  88. $DEV_SIGNATURE = '<img id="dev_signature" src="' . htmlspecialchars($devSigUrl, ENT_QUOTES, "UTF-8") . '" style="max-height: 117px;padding: 10px;" alt="Client Relations Signature">';
  89. }
  90. function loadContractHtml(?string $clientId, array $overrides = []): string {
  91. $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
  92. $path = __DIR__ . '/contracts/' . $safeId . '.md';
  93. if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
  94. $md = file_get_contents($path);
  95. // 1) Split front matter and body
  96. $vars = [];
  97. $body = $md;
  98. if (preg_match('/^\s*---\s*\n(.*?)\n---\s*\n(.*)$/s', $md, $m)) {
  99. $front = $m[1];
  100. $body = $m[2];
  101. $vars = parseFrontMatter($front);
  102. }
  103. // 2) Defaults available to every document
  104. $base = [
  105. 'dev' => [
  106. 'name' => $GLOBALS['devName'] ?? 'Modulos Design',
  107. 'email' => $GLOBALS['devEmail'] ?? 'drafting@modulosdesign.com.au',
  108. 'phone' => $GLOBALS['devPhone'] ?? '',
  109. 'address' => $GLOBALS['devAddress'] ?? '',
  110. ],
  111. 'client' => [
  112. 'name' => $GLOBALS['clientName'] ?? '',
  113. 'email' => $GLOBALS['clientEmail'] ?? '',
  114. 'phone' => $GLOBALS['clientPhone'] ?? '',
  115. 'address' => $GLOBALS['clientAddress'] ?? '',
  116. ],
  117. 'today' => date('F j, Y'),
  118. ];
  119. // 3) Merge: URL or POST overrides > front matter > base
  120. $merged = array_replace_recursive($base, $vars, $overrides);
  121. // 4) Also allow flat GET overrides like client_name=...
  122. foreach (['client_name' => 'client.name', 'client_email' => 'client.email', 'client_phone' => 'client.phone'] as $q => $pathKey) {
  123. if (isset($_GET[$q]) && $_GET[$q] !== '') {
  124. setByPath($merged, $pathKey, (string)$_GET[$q]);
  125. }
  126. }
  127. // 5) Replace [path.to.value] placeholders in the Markdown body
  128. $body = preg_replace_callback('/\[([a-zA-Z0-9_.-]+)\]/', function ($m) use ($merged) {
  129. $val = getByPath($merged, $m[1]);
  130. return is_scalar($val) ? (string)$val : '';
  131. }, $body);
  132. // 6) Convert to HTML
  133. $Parsedown = new Parsedown();
  134. $Parsedown->setSafeMode(true);
  135. return $Parsedown->text($body);
  136. }
  137. function parseFrontMatter(string $text): array {
  138. // If the PECL yaml extension is available, use it
  139. if (function_exists('yaml_parse')) {
  140. $arr = @yaml_parse($text);
  141. return is_array($arr) ? $arr : [];
  142. }
  143. // Minimal indentation-aware parser for nested maps and simple lists
  144. $lines = preg_split('/\R/', rtrim($text));
  145. $root = [];
  146. $stack = [ ['indent' => -1, 'ref' => &$root] ];
  147. foreach ($lines as $raw) {
  148. if ($raw === '') continue;
  149. $trimmed = ltrim($raw, ' ');
  150. if ($trimmed === '' || $trimmed[0] === '#') continue;
  151. $indent = strlen($raw) - strlen($trimmed);
  152. // climb up to the correct parent by indent
  153. while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) {
  154. array_pop($stack);
  155. }
  156. $parent =& $stack[array_key_last($stack)]['ref'];
  157. // List item
  158. if (preg_match('/^-\s*(.*)$/', $trimmed, $m)) {
  159. $val = $m[1];
  160. if (!is_array($parent)) $parent = [];
  161. if ($val === '') {
  162. $parent[] = [];
  163. $stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
  164. } else {
  165. $parent[] = _fm_trim_quotes($val);
  166. }
  167. continue;
  168. }
  169. // Key: value or Key:
  170. if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trimmed, $m)) {
  171. $key = $m[1];
  172. $val = $m[2];
  173. if ($val === '') {
  174. if (!isset($parent[$key]) || !is_array($parent[$key])) {
  175. $parent[$key] = [];
  176. }
  177. $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
  178. } else {
  179. $parent[$key] = _fm_trim_quotes($val);
  180. }
  181. }
  182. }
  183. return $root;
  184. }
  185. function parseFrontMatterForId(?string $clientId): array {
  186. $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? (string)$clientId : 'default';
  187. $path = __DIR__ . '/contracts/' . $safeId . '.md';
  188. if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
  189. $md = @file_get_contents($path);
  190. if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
  191. return parseFrontMatter($m[1]);
  192. }
  193. return [];
  194. }
  195. function getPreparedDateFromMd(string $clientId): string {
  196. $safe = preg_match('/^[A-Za-z0-9_-]{1,64}$/', $clientId) ? $clientId : 'default';
  197. $path = __DIR__ . '/contracts/' . $safe . '.md';
  198. if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
  199. $md = file_get_contents($path);
  200. if (preg_match('/^\s*---\s*\n(.*?)\n---/s', $md, $m)) {
  201. $meta = parseFrontMatter($m[1]);
  202. $prepared = getByPath($meta, 'dates.prepared', '');
  203. return is_string($prepared) ? $prepared : '';
  204. }
  205. return '';
  206. }
  207. function _fm_trim_quotes(string $v): string {
  208. $v = trim($v);
  209. if ($v !== '' && $v[0] === "'" && substr($v, -1) === "'") return stripslashes(substr($v, 1, -1));
  210. if ($v !== '' && $v[0] === '"' && substr($v, -1) === '"') return stripslashes(substr($v, 1, -1));
  211. return $v;
  212. }
  213. function getByPath(array $arr, string $path, $default = '') {
  214. $keys = explode('.', $path);
  215. foreach ($keys as $k) {
  216. if ($k === '') continue;
  217. if (is_array($arr) && array_key_exists($k, $arr)) {
  218. $arr = $arr[$k];
  219. } else {
  220. return $default;
  221. }
  222. }
  223. return $arr;
  224. }
  225. function setByPath(array &$arr, string $path, $value): void {
  226. $keys = explode('.', $path);
  227. $ref =& $arr;
  228. foreach ($keys as $k) {
  229. if ($k === '') continue;
  230. if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = [];
  231. $ref =& $ref[$k];
  232. }
  233. $ref = $value;
  234. }
  235. // Gets the current file URL and replaces the .php extension with .html
  236. function getHtmlUrl(string $htmlName): string {
  237. $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
  238. $scheme = $https ? 'https' : 'http';
  239. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  240. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
  241. return $scheme . '://' . $host . ($dir ? $dir : '') . '/' . $htmlName;
  242. }
  243. function getClientIp(): string {
  244. // 2) X-Forwarded-For: "client, proxy1, proxy2"
  245. if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
  246. $parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
  247. foreach ($parts as $ip) {
  248. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
  249. return $ip; // first public address
  250. }
  251. }
  252. // if nothing public, take the first valid one
  253. foreach ($parts as $ip) {
  254. if (filter_var($ip, FILTER_VALIDATE_IP)) {
  255. return $ip;
  256. }
  257. }
  258. }
  259. // 3) X-Real-IP
  260. if (!empty($_SERVER['HTTP_X_REAL_IP']) &&
  261. filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) {
  262. return $_SERVER['HTTP_X_REAL_IP'];
  263. }
  264. // 4) Fallback
  265. if (!empty($_SERVER['REMOTE_ADDR']) &&
  266. filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) {
  267. return $_SERVER['REMOTE_ADDR'];
  268. }
  269. return 'UNKNOWN';
  270. }
  271. // Build overrides only when values are non-empty
  272. $ov = ['client' => [], 'dev' => []];
  273. if ($clientName !== '') $ov['client']['name'] = $clientName;
  274. if ($clientEmail !== '') $ov['client']['email'] = $clientEmail;
  275. if ($clientPhone !== '') $ov['client']['phone'] = $clientPhone;
  276. if ($clientAddress!== '') $ov['client']['address'] = $clientAddress;
  277. if ($devName !== '') $ov['dev']['name'] = $devName;
  278. if ($devEmail !== '') $ov['dev']['email'] = $devEmail;
  279. if ($devPhone !== '') $ov['dev']['phone'] = $devPhone;
  280. if ($devAddress !== '') $ov['dev']['address'] = $devAddress;
  281. $CONTRACT_HTML = loadContractHtml($_GET['clientid'] ?? null, $ov);
  282. $CLIENT_SIGNATURE = $_POST["client_signature"] ?? null;
  283. if ( is_string($CLIENT_SIGNATURE) && str_starts_with($CLIENT_SIGNATURE, "data:image/png;base64,") ) {
  284. // Size guard, rejects very large data URIs
  285. if (strlen($CLIENT_SIGNATURE) > 2 * 1024 * 1024) {
  286. http_response_code(413);
  287. exit("Signature too large");
  288. }
  289. $CLIENT_SIGNATURE = '<img id="hk" src="' . htmlspecialchars($CLIENT_SIGNATURE, ENT_QUOTES, "UTF-8") . '">';
  290. } else {
  291. $CLIENT_SIGNATURE = null;
  292. }
  293. /* -------------------------------------------------------------------------- */
  294. /* SECURITY AND ACCESS */
  295. /* -------------------------------------------------------------------------- */
  296. // Optional config
  297. $cfg = @include __DIR__ . '/config.php';
  298. $cfg = is_array($cfg) ? $cfg : [];
  299. // Optional admin creds for Basic Auth or old token scheme
  300. $ADMIN_USER = $cfg['admin_user'] ?? ($ADMIN_USER ?? '');
  301. $ADMIN_PASS = $cfg['admin_pass'] ?? ($ADMIN_PASS ?? '');
  302. $SECRET_KEY = $cfg['admin_secret'] ?? ($SECRET_KEY ?? ''); // old global secret, if you still use it
  303. // Front matter helpers reused from your client file
  304. if (!function_exists('_fm_trim_quotes')) {
  305. function _fm_trim_quotes(string $v) {
  306. $v = trim($v);
  307. if ($v === '') return '';
  308. $q = $v[0];
  309. if (($q === '"' || $q === "'") && substr($v, -1) === $q) return substr($v, 1, -1);
  310. return $v;
  311. }
  312. }
  313. if (!function_exists('parseFrontMatter')) {
  314. function parseFrontMatter(string $text): array {
  315. if (function_exists('yaml_parse')) {
  316. $arr = @yaml_parse($text);
  317. return is_array($arr) ? $arr : [];
  318. }
  319. $lines = preg_split('/\R/', rtrim($text));
  320. $root = [];
  321. $stack = [ ['indent' => -1, 'ref' => &$root] ];
  322. foreach ($lines as $raw) {
  323. if ($raw === '') continue;
  324. $trim = ltrim($raw, ' ');
  325. if ($trim === '' || $trim[0] === '#') continue;
  326. $indent = strlen($raw) - strlen($trim);
  327. while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]['indent']) array_pop($stack);
  328. $parent =& $stack[array_key_last($stack)]['ref'];
  329. if (preg_match('/^-\s*(.*)$/', $trim, $m)) {
  330. $val = $m[1];
  331. if (!is_array($parent)) $parent = [];
  332. if ($val === '') {
  333. $parent[] = [];
  334. $stack[] = ['indent' => $indent, 'ref' => &$parent[array_key_last($parent)]];
  335. } else {
  336. $parent[] = _fm_trim_quotes($val);
  337. }
  338. continue;
  339. }
  340. if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trim, $m)) {
  341. $key = $m[1];
  342. $val = $m[2];
  343. if ($val === '') {
  344. if (!isset($parent[$key]) || !is_array($parent[$key])) $parent[$key] = [];
  345. $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]];
  346. } else {
  347. $parent[$key] = _fm_trim_quotes($val);
  348. }
  349. }
  350. }
  351. return $root;
  352. }
  353. }
  354. if (!function_exists('parseFrontMatterForId')) {
  355. function parseFrontMatterForId(?string $clientId): array {
  356. $safeId = preg_match('/^[A-Za-z0-9_-]{1,64}$/', (string)$clientId) ? $clientId : 'default';
  357. $path = __DIR__ . '/contracts/' . $safeId . '.md';
  358. if (!is_file($path)) $path = __DIR__ . '/contracts/default.md';
  359. $md = @file_get_contents($path);
  360. if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
  361. return parseFrontMatter($m[1]);
  362. }
  363. return [];
  364. }
  365. }
  366. function fm_admin_secret_for(string $clientId): string {
  367. $fm = parseFrontMatterForId($clientId);
  368. // support either nested admin.secret or a flat admin_secret
  369. if (!empty($fm['admin']) && is_array($fm['admin']) && !empty($fm['admin']['secret'])) {
  370. return (string)$fm['admin']['secret'];
  371. }
  372. if (!empty($fm['admin_secret'])) return (string)$fm['admin_secret'];
  373. return '';
  374. }
  375. /**
  376. * Accept both link formats:
  377. * 1) New style: contract.php?clientid=3043&token=HMAC_SHA256(clientid, fm_admin_secret)
  378. * 2) Old style: contract.php?token=HMAC_SHA256(ADMIN_USER|ADMIN_PASS|expires, SECRET_KEY)&expires=UNIX
  379. */
  380. function access_allowed_by_token(): bool {
  381. global $ADMIN_USER, $ADMIN_PASS, $SECRET_KEY;
  382. $clientId = $_GET['clientid'] ?? '';
  383. $token = $_GET['token'] ?? '';
  384. $expires = isset($_GET['expires']) ? (int)$_GET['expires'] : 0;
  385. if ($token === '') return false;
  386. // Old scheme with expiry and global secret
  387. if ($expires > 0 && $ADMIN_USER !== '' && $ADMIN_PASS !== '' && $SECRET_KEY !== '') {
  388. if ($expires < time()) return false;
  389. $expected = hash_hmac('sha256', $ADMIN_USER . '|' . $ADMIN_PASS . '|' . $expires, $SECRET_KEY);
  390. if (hash_equals($expected, $token)) return true;
  391. }
  392. // New scheme with per-client secret in front matter
  393. if ($clientId !== '') {
  394. $secret = fm_admin_secret_for($clientId);
  395. if ($secret !== '') {
  396. $expected2 = hash_hmac('sha256', $clientId, $secret);
  397. if (hash_equals($expected2, $token)) return true;
  398. }
  399. }
  400. return false;
  401. }
  402. function require_admin_auth(): void {
  403. global $ADMIN_USER, $ADMIN_PASS;
  404. // Allow valid token to bypass auth for clients
  405. if (access_allowed_by_token()) return;
  406. // If admin creds are not configured, block
  407. if ($ADMIN_USER === '' || $ADMIN_PASS === '') {
  408. http_response_code(401);
  409. echo 'Auth required';
  410. exit;
  411. }
  412. // Basic Auth for admins
  413. if (!isset($_SERVER['PHP_AUTH_USER'])) {
  414. header('WWW-Authenticate: Basic realm="Contracts Admin"');
  415. header('HTTP/1.0 401 Unauthorized');
  416. echo 'Auth required';
  417. exit;
  418. }
  419. if ($_SERVER['PHP_AUTH_USER'] !== $ADMIN_USER || ($_SERVER['PHP_AUTH_PW'] ?? '') !== $ADMIN_PASS) {
  420. header('WWW-Authenticate: Basic realm="Contracts Admin"');
  421. header('HTTP/1.0 401 Unauthorized');
  422. echo 'Invalid credentials';
  423. exit;
  424. }
  425. }
  426. require_admin_auth();
  427. /** The HTML code (and some PHP) is kept in PHP variables like $CONTRACT_HTML, $FOOTER, $CONTRACT_SIGNED_PHP, and $CLIENT_DATE_IP_COMPILED. **/
  428. function headerWithTitle(
  429. string $title,
  430. ?string $clientId = null,
  431. ?string $preparedDate = null,
  432. string $context = 'web' // 'web' or 'pdf'
  433. ): string {
  434. $safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
  435. $safeJob = htmlspecialchars((string)$clientId, ENT_QUOTES, 'UTF-8');
  436. $safePreparedDate = htmlspecialchars((string)$preparedDate, ENT_QUOTES, 'UTF-8');
  437. $baseHref = htmlspecialchars(
  438. ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http')
  439. . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost')
  440. . rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/',
  441. ENT_QUOTES,
  442. 'UTF-8'
  443. );
  444. // CSS includes differ by context
  445. $cssLinks = $context === 'web'
  446. ? <<<HTML
  447. <link rel="preconnect" href="https://cdn.jsdelivr.net">
  448. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
  449. <link href="../internal/css/blueprint.css" rel="stylesheet">
  450. <link href="../internal/css/print.css" rel="stylesheet" media="print">
  451. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
  452. <link href="style.css" rel="stylesheet">
  453. HTML
  454. : <<<HTML
  455. <!-- Minimal CSS for PDF -->
  456. <link href="../internal/css/blueprint.css" rel="stylesheet">
  457. <link href="style.css" rel="stylesheet">
  458. <style>
  459. @page { margin: 5mm 10mm 10mm 10mm; }
  460. body { background:#fff;}
  461. .container { max-width: 780px; margin: 0 auto; font-size:0.7rem; }
  462. .shadow-sm { box-shadow: none !important; }
  463. .rounded-3 { border-radius: 0 !important; }
  464. .bg-white { background: #fff !important; }
  465. .d-print-none, .noprint { display: none !important; }
  466. .img-logo { max-height: 40px; }
  467. .page-header { display: table; width: 100%; table-layout: fixed; }
  468. .page-header > .col-4 { display: table-cell; width: 33.333%; vertical-align: middle; padding: 0 8px; }
  469. .page-header .text-start { text-align: left; }
  470. .page-header .text-center { text-align: center; }
  471. .page-header .text-end { text-align: right; }
  472. .compiled-signatures { display: table; width: 100%; table-layout: fixed; margin-top: 1rem; }
  473. .compiled-signatures .compiled-signature { display: table-cell; width: 35%; vertical-align: bottom; padding: 0 8px; }
  474. .compiled-signatures img { max-width: 100%; height: auto; }
  475. </style>
  476. HTML;
  477. // No JS in PDF
  478. $jsLinks = $context === 'web'
  479. ? <<<HTML
  480. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
  481. HTML
  482. : '';
  483. // Navbar only for web
  484. $nav = $context === 'web'
  485. ? <<<HTML
  486. <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
  487. <div class="container-fluid">
  488. <a class="navbar-brand brown-light" href="#">
  489. <img src="../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top" >
  490. Modulos Design
  491. </a>
  492. </div>
  493. </nav>
  494. HTML
  495. : '';
  496. return <<<HTML
  497. <!doctype html>
  498. <html lang="en">
  499. <head>
  500. <meta charset="utf-8">
  501. <title>{$safeJob} - {$safeTitle}</title>
  502. <meta name="viewport" content="width=device-width, initial-scale=1">
  503. <meta name="robots" content="noindex">
  504. <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
  505. <base href="{$baseHref}">
  506. {$cssLinks}
  507. {$jsLinks}
  508. </head>
  509. <body>
  510. {$nav}
  511. <main class="container my-4">
  512. <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
  513. <div class="row align-items-center page-header">
  514. <div class="col-12 col-md-6 text-start">
  515. <img class="img-fluid pt-2 img-logo" src="../internal/images/blueprint-full-logo-medium.png" height="100" alt="Modulos Design">
  516. </div>
  517. <div class="col-12 col-md-6 text-end pt-3">
  518. <h3 class="fw-bold mb-1" style="color:#373a3c;">Job: {$safeJob}</h3>
  519. <h4 class="mb-1"><span class="fw-bold text-secondary">{$safePreparedDate}</span></h4>
  520. </div>
  521. </div>
  522. HTML;
  523. }
  524. function footerFor(string $context = 'web'): string {
  525. $extra = $context === 'web'
  526. ? <<<HTML
  527. <script>
  528. function printContract(){ window.print(); }
  529. </script>
  530. HTML
  531. : ''; // no JS for PDF
  532. return <<<HTML
  533. </div> <!-- /.rounded-3 -->
  534. </main>
  535. {$extra}
  536. </body>
  537. </html>
  538. HTML;
  539. }
  540. if ($CLIENT_SIGNATURE == null) {
  541. /** ⌛ Waiting for Client to sign: include signature elements and javascript **/
  542. // If a signed file already exists for this client, send them there
  543. if ($redirectToSigned) {
  544. $pattern = __DIR__ . "/{$clientId}_signed_contract*.html";
  545. $matches = glob($pattern);
  546. if ($matches) {
  547. usort($matches, fn($a, $b) => filemtime($b) <=> filemtime($a));
  548. $latest = basename($matches[0]);
  549. header("Location: " . $latest . "#hk", true, 302);
  550. exit;
  551. }
  552. }
  553. if (!headers_sent()) {
  554. header('Content-Type: text/html; charset=UTF-8');
  555. }
  556. $preparedDate = $_GET['prepared'] ?? getPreparedDateFromMd($clientId) ?: date('F j, Y');
  557. $HEADER = headerWithTitle(
  558. 'Unsigned Contract',
  559. $clientId, // show this as “Job: …”
  560. $preparedDate, // show this as the date
  561. 'web'
  562. );
  563. $clientEmailSafe = htmlspecialchars($clientEmail, ENT_QUOTES, 'UTF-8');
  564. $FOOTER = <<<HTML
  565. <div id="ui-unsigned">
  566. <form method="post" class="noprint" id="signature_form">
  567. <div id="signature-container">
  568. <div id="canvas-container">
  569. <canvas id="signature-pad" class="signature-pad" width="188" height="58.66"></canvas>
  570. </div>
  571. </div>
  572. <div class="animate slide">
  573. <div id="signature-controls" class="d-flex gap-2 justify-content-center mt-3">
  574. <button id="reset" type="button" class="btn btn-warning rounded-0">Clear</button>
  575. <button data-bs-toggle="modal" data-bs-target="#modal-qr" type="button" class="btn btn-secondary rounded-0">Sign on mobile</button>
  576. <button id="confirm" type="submit" class="btn btn-success rounded-0" disabled>Sign</button>
  577. </div>
  578. </div>
  579. <div class="flow" style="max-width: 330px; margin-inline-start: auto;">
  580. <h3 class="margin-top loading-signed hidden | animate slide" style="color: var(--clr-green-500); font-weight: 700;">Saving contract…</h3>
  581. <small class="loading-signed hidden | animate slide delay-16"
  582. style="font-weight: 600; color: var(--clr-blue-700);">
  583. This shouldn't take more than a minute.
  584. </small>
  585. </div>
  586. <input type="hidden" name="client_email" value="{$clientEmailSafe}">
  587. <input type="hidden" name="csrf" value="{$csrf}">
  588. <input type="hidden" id="client_signature" name="client_signature" />
  589. <input type="hidden" name="client_tz" value="">
  590. </form>
  591. <div class="modal fade" tabindex="-1" id="modal-qr" aria-labelledby="modal-qrLabel" aria-hidden="true">
  592. <div class="modal-dialog modal-dialog-centered">
  593. <div class="modal-content">
  594. <div class="modal-body qr-code-container">
  595. <button id="close-modal-qr" type="button" class="btn-close" data-bs-dismiss="modal-qr" aria-label="Close"></button>
  596. <canvas id="qr-code"></canvas>
  597. </div>
  598. </div>
  599. </div>
  600. </div>
  601. </div>
  602. </div>
  603. </main>
  604. <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
  605. <script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
  606. <script id="contract_script_unsigned" type="module">
  607. signature("#signature-pad")
  608. function signature(selector) {
  609. if (!document.querySelector(selector)) return
  610. const canvas = document.querySelector(selector)
  611. // https://github.com/szimek/signature_pad#options
  612. const clientSignaturePad = new SignaturePad(canvas, {
  613. penColor: "hsl(200, 100%, 30%)",
  614. minDistance: 2,
  615. })
  616. resizeCanvas()
  617. if (localStorage.getItem("client_signature")) {
  618. document.querySelector("#confirm").disabled = false
  619. // document.querySelector("#reset").disabled = false
  620. }
  621. // event listeners
  622. // save signature to localStorage on change
  623. clientSignaturePad.addEventListener("afterUpdateStroke", () => {
  624. let data = clientSignaturePad.toDataURL("image/png")
  625. document.querySelector("#client_signature").value = data
  626. localStorage.setItem("client_signature", data)
  627. // ! probably remove these:
  628. document.querySelector("#confirm").disabled = false
  629. // document.querySelector("#reset").disabled = false
  630. })
  631. // button to reset signature
  632. document.querySelector("#reset")?.addEventListener("click", (e) => {
  633. clientSignaturePad.clear()
  634. localStorage.removeItem("client_signature")
  635. document.querySelector("#client_signature").value = null
  636. document.querySelector("#confirm").disabled = true
  637. // document.querySelector("#reset").disabled = true
  638. })
  639. // form submit
  640. document.querySelector("#signature_form").addEventListener("submit", (e) => {
  641. // e.preventDefault();
  642. e.target.querySelectorAll(".loading-signed").forEach((el) => {
  643. el.classList.remove("hidden")
  644. })
  645. e.target.querySelector("#canvas-container").classList.add("just-signed")
  646. let otherElements = document.querySelectorAll("#content > *:not(#ui-unsigned, #dev_signature)")
  647. otherElements.forEach(element => {
  648. // element.style.cssText = `opacity: .5;`
  649. element.style.opacity = "0.5"
  650. })
  651. })
  652. window.onresize = resizeCanvas
  653. // needed for retina displays
  654. function resizeCanvas() {
  655. const ratio = Math.max(window.devicePixelRatio || 1, 1)
  656. canvas.width = canvas.offsetWidth * ratio
  657. canvas.height = canvas.offsetHeight * ratio
  658. canvas.getContext("2d").scale(ratio, ratio)
  659. let data = localStorage.getItem("client_signature");
  660. if (data) {
  661. clientSignaturePad.fromDataURL(data)
  662. // disableResetButtonIfSignatureIsEmpty(data)
  663. document.querySelector("#client_signature").value = data
  664. }
  665. }
  666. }
  667. </script>
  668. <script>
  669. (function () {
  670. const modal = document.getElementById('modal-qr')
  671. const btnOpen = document.getElementById('show-modal-qr')
  672. const btnClose = document.getElementById('close-modal-qr')
  673. const canvas = document.getElementById('qr-code')
  674. if (canvas && window.QRious) {
  675. new QRious({
  676. element: canvas,
  677. value: window.location.href,
  678. foreground: 'hsl(200, 30%, 20%)',
  679. padding: 0,
  680. size: 500
  681. })
  682. }
  683. if (btnOpen && modal) {
  684. btnOpen.addEventListener('click', function (e) {
  685. e.preventDefault()
  686. try {
  687. if (typeof modal.showModal === 'function') {
  688. modal.showModal()
  689. } else {
  690. // very old browser fallback
  691. modal.setAttribute('open', '')
  692. }
  693. } catch (err) {
  694. modal.setAttribute('open', '')
  695. }
  696. })
  697. }
  698. btnClose?.addEventListener('click', function () {
  699. try {
  700. if (modal.open) modal.close()
  701. else modal.removeAttribute('open')
  702. } catch (e) {
  703. modal.removeAttribute('open')
  704. }
  705. })
  706. // click outside to close
  707. modal?.addEventListener('click', function (e) {
  708. const r = modal.getBoundingClientRect()
  709. const inside = e.clientY >= r.top && e.clientY <= r.bottom && e.clientX >= r.left && e.clientX <= r.right
  710. if (!inside) {
  711. try { modal.close() } catch (err) { modal.removeAttribute('open') }
  712. }
  713. })
  714. })()
  715. </script>
  716. <script>
  717. document.addEventListener('DOMContentLoaded', function () {
  718. try {
  719. var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''
  720. var tzField = document.querySelector('input[name="client_tz"]')
  721. if (tzField) tzField.value = tz
  722. } catch (e) {}
  723. })
  724. </script>
  725. </body>
  726. </html>
  727. HTML;
  728. echo $HEADER;
  729. echo $CONTRACT_HTML;
  730. //echo $DEV_SIGNATURE;
  731. echo $FOOTER;
  732. } else {
  733. /** Contract was just signed: put $CLIENT_SIGNATURE and the other parts in the .html file **/
  734. // Build dev signature meta
  735. $devTimestamp = date('F j, Y \a\t g:i:s A T');
  736. $devIP = $_SERVER['SERVER_ADDR'] ?? 'UNKNOWN';
  737. $DEV_SIGNATURE .=
  738. '<div class="date-ip">' .
  739. '<strong>Signed on:</strong> ' . htmlspecialchars($devTimestamp, ENT_QUOTES, 'UTF-8') . '<br>' .
  740. '</div>';
  741. // Client signed date and IP
  742. $clientTz = $_POST['client_tz'] ?? '';
  743. if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
  744. $tz = new DateTimeZone($clientTz);
  745. $clientDate = (new DateTime('now', $tz))->format('F j, Y \a\t g:i:s A T');
  746. } else {
  747. $clientDate = gmdate('F j, Y \a\t g:i:s A \G\M\T');
  748. }
  749. //$clientIp = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN';
  750. $clientIp = getClientIp();
  751. $CLIENT_SIGNATURE .=
  752. '<div id="date-ip" class="date-ip">' .
  753. '<strong>Signed on:</strong> ' . htmlspecialchars($clientDate, ENT_QUOTES, 'UTF-8') . '<br>' .
  754. '<strong>Client IP:</strong> ' . htmlspecialchars($clientIp, ENT_QUOTES, 'UTF-8') .
  755. '</div>';
  756. // Optional names above signatures (prefer MD front-matter, then querystring/config)
  757. $meta = parseFrontMatterForId($_GET['clientid'] ?? null);
  758. // try client.name in the MD front-matter
  759. $clientNameFromMd = '';
  760. if (is_array($meta)) {
  761. $clientNameFromMd = (string) (getByPath($meta, 'client.name', '') ?: ($meta['client']['name'] ?? ''));
  762. }
  763. $clientNameResolved = $clientName !== '' ? $clientName : $clientNameFromMd;
  764. // likewise for dev.name (in case you ever move it to the MD)
  765. $devNameFromMd = '';
  766. if (is_array($meta)) {
  767. $devNameFromMd = (string) (getByPath($meta, 'dev.name', '') ?: ($meta['dev']['name'] ?? ''));
  768. }
  769. $devNameResolved = $devName !== '' ? $devName : $devNameFromMd;
  770. if ($devNameResolved !== '') {
  771. $DEV_SIGNATURE = '<strong>' . htmlspecialchars($devNameResolved, ENT_QUOTES, 'UTF-8') . '</strong>' . $DEV_SIGNATURE;
  772. }
  773. if ($clientNameResolved !== '') {
  774. $CLIENT_SIGNATURE = '<strong>' . htmlspecialchars($clientNameResolved, ENT_QUOTES, 'UTF-8') . '</strong>' . $CLIENT_SIGNATURE;
  775. }
  776. $preparedDate = getPreparedDateFromMd($clientId) ?: $clientDate;
  777. // Assemble final HTML
  778. $HEADER = headerWithTitle(
  779. 'Signed Contract',
  780. $clientId,
  781. $preparedDate // you already computed this above
  782. );
  783. $CONTRACT_HTML = loadContractHtml($_GET['clientid'] ?? null);
  784. $compiled = <<<HTML
  785. <div class="row compiled-signatures align-items-end">
  786. <div class="col compiled-signature">{$DEV_SIGNATURE}</div>
  787. <div class="col compiled-signature">{$CLIENT_SIGNATURE}</div>
  788. </div>
  789. <br>
  790. <div class="row download-pdf d-print-none">
  791. <a href="contracts/{$clientId}_signed_contract.pdf" download class="btn btn-light rounded-0" id="downloadpdf">Download PDF</a>
  792. </div>
  793. HTML;
  794. $closing = <<<HTML
  795. </div>
  796. </main>
  797. <script>
  798. function printContract(){ window.print(); }
  799. </script>
  800. </body>
  801. </html>
  802. HTML;
  803. // Build a unique filename like 3043_signed_contract_20250812-184501.html
  804. $timestamp = date('Ymd-His');
  805. //$htmlName = "{$clientId}_signed_contract_{$timestamp}.html";
  806. $htmlName = "{$clientId}_signed_contract.html";
  807. if (file_exists($htmlName)) {
  808. $htmlName = "{$clientId}_signed_contract_" . date('Ymd-His') . ".html";
  809. }
  810. //$output = $HEADER . $CONTRACT_HTML . $compiled . $closing;
  811. // 1) WEB HTML to save
  812. $preparedDate = getByPath(parseFrontMatterForId($_GET['clientid'] ?? null), 'dates.prepared', date('F j, Y'));
  813. $headerWeb = headerWithTitle("{$clientId} - Signed Contract", $clientId, $preparedDate, 'web');
  814. $footerWeb = footerFor('web');
  815. $outputWeb = $headerWeb . $CONTRACT_HTML . $compiled . $footerWeb;
  816. file_put_contents($htmlName, $outputWeb);
  817. // 2) PDF HTML (lean) to render
  818. $headerPdf = headerWithTitle("{$clientId} - Signed Contract", $clientId, $preparedDate, 'pdf');
  819. $footerPdf = footerFor('pdf');
  820. $outputPdf = $headerPdf . $CONTRACT_HTML . $compiled . $footerPdf;
  821. // Save HTML file
  822. file_put_contents($htmlName, $outputWeb);
  823. $options = new \Dompdf\Options();
  824. $options->set('defaultFont', 'Helvetica');
  825. $options->set('isRemoteEnabled', true);
  826. $dompdf = new \Dompdf\Dompdf($options);
  827. $dompdf->loadHtml($outputPdf, 'UTF-8');
  828. $dompdf->setPaper('A4', 'portrait');
  829. // Helpful for resolving relative paths in CSS/images:
  830. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  831. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
  832. $dompdf->setBasePath('https://' . $host . $dir);
  833. $dompdf->render();
  834. $pdfPath = 'contracts/' . $clientId . '_signed_contract.pdf';
  835. file_put_contents($pdfPath, $dompdf->output());
  836. //error_log("$clientId - Finished creating html and pdf, Create email next.\r\n", 3, "error_log");
  837. error_log("$clientId - About to call sendEmails with clientEmail='{$clientEmail}' devEmail='{$devEmail}'\r\n", 3, "error_log");
  838. // Now send emails (and attach PDF)
  839. sendEmails($clientEmail, $devEmail, $fromAddress, $htmlName, $clientId, $pdfPath);
  840. //error_log("$clientId - Finished creating email. Redirect to HTML NOW.\r\n", 3, "error_log");
  841. // Redirect last
  842. header('Location: ' . $htmlName . '#hk', true, 303);
  843. exit;
  844. }
  845. // Function to email notifications; gets called when Client signs
  846. function sendEmails(
  847. string $clientEmail,
  848. string $devEmail,
  849. string $fromAddress,
  850. string $htmlName,
  851. string $clientId,
  852. string $pdfPath = ''
  853. ): void {
  854. global $cfg;
  855. // 1) Clean + validate addresses
  856. $clientEmail = preg_replace('/[\r\n]+/', '', $clientEmail);
  857. $devEmail = preg_replace('/[\r\n]+/', '', $devEmail);
  858. $clientEmail = filter_var($clientEmail, FILTER_VALIDATE_EMAIL) ?: '';
  859. $devEmail = filter_var($devEmail, FILTER_VALIDATE_EMAIL) ?: '';
  860. if (!$clientEmail && !$devEmail) return;
  861. // 2) Inputs for the email template
  862. $viewUrl = htmlspecialchars(getHtmlUrl($htmlName), ENT_QUOTES, 'UTF-8');
  863. $company = $cfg['dev_name'] ?? 'Modulos Design';
  864. $preparedDate = getPreparedDateFromMd($clientId) ?: date('F j, Y');
  865. // Try to greet the client by name (from front-matter)
  866. $meta = parseFrontMatterForId($clientId);
  867. $clientNameFromMd = is_array($meta) ? (string)(getByPath($meta, 'client.name', '')) : '';
  868. $clientCompanyFromMd = is_array($meta) ? (string)(getByPath($meta, 'client.company', '')) : $clientNameFromMd;
  869. $clientNameSafe = $clientNameFromMd;
  870. // Build a simple target list
  871. $targets = [];
  872. if ($clientEmail) $targets[] = ['to' => $clientEmail, 'kind' => 'client'];
  873. if ($devEmail) $targets[] = ['to' => $devEmail, 'kind' => 'dev'];
  874. foreach ($targets as $t) {
  875. // 3) Make the mailer
  876. $mail = new PHPMailer(true);
  877. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  878. $mail->isSMTP();
  879. $mail->Host = $cfg['smtp_host'] ?? '';
  880. $mail->SMTPAuth = true;
  881. $mail->Username = $cfg['smtp_username'] ?? '';
  882. $mail->Password = $cfg['smtp_password'] ?? '';
  883. $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; // 465/SSL
  884. $mail->Port = $cfg['smtp_port'] ?? 465;
  885. $mail->CharSet = 'UTF-8';
  886. $mail->Encoding = 'base64';
  887. $mail->setFrom($fromAddress, $company);
  888. // sensible reply-to
  889. if ($t['kind'] === 'client' && $devEmail) $mail->addReplyTo($devEmail);
  890. if ($t['kind'] === 'dev' && $clientEmail) $mail->addReplyTo($clientEmail);
  891. $mail->addAddress($t['to']);
  892. $mail->isHTML(true);
  893. // 4) Embed the logo **on this exact $mail** and get the <img> tag
  894. $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $company, 200);
  895. $safeSignature = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $company, 100);
  896. // 5) Build the body with correct argument order
  897. [$subject, $html, $alt] = buildSignedContractEmail(
  898. $logoHtml, // <- first param is the HTML for the CID <img>
  899. $viewUrl,
  900. $clientId,
  901. $clientNameSafe,
  902. $preparedDate,
  903. $company,
  904. $safeSignature
  905. );
  906. // Developer copy tweaks
  907. if ($t['kind'] === 'dev') {
  908. $subject = $clientId . ' ' . $clientCompanyFromMd . ' – Contract has been signed';
  909. $signedBy = htmlspecialchars($clientEmail ?: 'unknown', ENT_QUOTES, 'UTF-8');
  910. $inject = '<tr><td style="padding:4px 24px 0;font-size:14px;color:#444;">Signed by: ' . $signedBy . '</td></tr>';
  911. // try to insert after the first <tr> block; fallback to simple boundary replace
  912. $html = preg_replace('/(<tr>\s*<td[^>]*>.*?<\/td>\s*<\/tr>)/s', '$1' . $inject, $html, 1)
  913. ?: str_replace('</tr><tr>', '</tr>' . $inject . '<tr>', $html);
  914. $alt .= "\n\nSigned by: " . ($clientEmail ?: 'unknown');
  915. }
  916. $mail->Subject = $subject;
  917. $mail->Body = $html;
  918. if (!empty($alt)) $mail->AltBody = $alt;
  919. // Optional BCC + attachment
  920. $mail->addBCC('drafting@modulosdesign.com.au');
  921. if ($pdfPath !== '' && is_file($pdfPath)) {
  922. $mail->addAttachment($pdfPath, basename($pdfPath));
  923. }
  924. try {
  925. $mail->send();
  926. } catch (Exception $e) {
  927. error_log("Mailer error to {$t['to']}: {$mail->ErrorInfo}\n", 3, "error_log");
  928. }
  929. }
  930. }
  931. function salutationFromName(string $fullName): string {
  932. // Normalize whitespace (incl. non-breaking space) and collapse runs
  933. $name = str_replace("\xC2\xA0", ' ', $fullName); // NBSP → space
  934. $name = trim(preg_replace('/\s+/u', ' ', $name));
  935. if ($name === '') return 'there';
  936. // Strip common trailing suffixes like ", MD", ", PhD", "Jr.", etc.
  937. $name = preg_replace(
  938. '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu',
  939. '',
  940. $name
  941. );
  942. // Remove one or more leading honorifics (with optional dot), incl. unicode spaces
  943. $honorifics = '(mr|mrs|ms|miss|mx|dr|prof|sir|dame|lord|lady|hon|rev|fr|father|pastor|rabbi|imam|capt|cpt|gen|col|maj|sgt|officer|chief|coach|pres|sen|rep)';
  944. $name = preg_replace('/^(?:' . $honorifics . ')\.?[\s\x{00A0}]+/iu', '', $name);
  945. while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) {
  946. $name = preg_replace('/^' . $honorifics . '(\.?)[\s\x{00A0}]+/iu', '', $name, 1);
  947. }
  948. // First non-initial token becomes the salutation
  949. $tokens = preg_split('/[\s\x{00A0}]+/u', $name);
  950. if (!$tokens) return 'there';
  951. foreach ($tokens as $tok) {
  952. $t = rtrim($tok, '.'); // drop trailing dot from initials
  953. if (!preg_match('/^[A-Za-z]\.?$/u', $t)) // skip single-letter initials
  954. return $t;
  955. }
  956. return $tokens[0] ?: 'there';
  957. }
  958. function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 140): string {
  959. if ($dataUrl === '') return '';
  960. // Handle minor whitespace/newlines in config values
  961. $dataUrl = trim($dataUrl);
  962. $prefix = 'data:image/png;base64,';
  963. if (stripos($dataUrl, $prefix) !== 0) return '';
  964. $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
  965. if ($bin === false || $bin === '') return '';
  966. // Deterministic CID so forwards/replies still render
  967. $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos';
  968. // This is the crucial bit you commented out
  969. $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png');
  970. return '<img src="cid:' . $cid . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
  971. }
  972. function buildSignedContractEmail(
  973. string $logoHtml,
  974. string $viewUrl,
  975. string $clientId,
  976. string $clientName = '',
  977. string $preparedDate = '',
  978. string $company = 'Modulos Design',
  979. string $safeSignature
  980. ): array {
  981. $firstName = salutationFromName($clientName);
  982. $firstNameSafe = htmlspecialchars($firstName, ENT_QUOTES, 'UTF-8');
  983. $safeUrl = htmlspecialchars($viewUrl, ENT_QUOTES, 'UTF-8');
  984. $safeCompany = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
  985. $safeJob = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
  986. $safePrepared = htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8');
  987. $preparedPart = $preparedDate ? " (prepared {$safePrepared})" : '';
  988. $subject = "{$safeJob} – Copy of Signed Contract";
  989. $html = <<<HTML
  990. <!-- Preheader stays hidden at 0px -->
  991. <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
  992. Thank you for signing your contract — here’s your copy and access link.
  993. </div>
  994. <div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
  995. <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600"
  996. style="width:600px;max-width:100%;background:#ffffff;border-radius:8px;overflow:hidden;
  997. font-size:14px;line-height:1.6;font-family:Arial,Helvetica,sans-serif;">
  998. <tr>
  999. <td style="font-size:14px;line-height:1.6;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
  1000. <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;line-height:1.6;">
  1001. <tr>
  1002. <td style="font-size:14px;line-height:1.6;">$logoHtml</td>
  1003. <td align="right" style="font-weight:700;font-size:14px;line-height:1.6;">Job #$safeJob</td>
  1004. </tr>
  1005. </table>
  1006. </td>
  1007. </tr>
  1008. <tr>
  1009. <td style="padding:28px 24px 8px;line-height:1.6;color:#635A4A;font-size:14px;">
  1010. <div style="font-size:14px;margin-bottom:8px;line-height:1.6;">Hello {$firstNameSafe},</div>
  1011. <div style="font-size:14px;line-height:1.6;">
  1012. Thank you for signing the contract{$preparedPart}. A copy is attached for your records,
  1013. and you can view or download it anytime using the link below:
  1014. </div>
  1015. </td>
  1016. </tr>
  1017. <tr>
  1018. <td align="center" style="padding:20px 24px 8px;font-size:14px;line-height:1.6;">
  1019. <!--[if mso]>
  1020. <v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
  1021. href="$safeUrl"
  1022. style="height:42px;v-text-anchor:middle;width:240px;"
  1023. stroked="f" fillcolor="#635A4A">
  1024. <w:anchorlock/>
  1025. <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;">View Contract</center>
  1026. </v:rect>
  1027. <![endif]-->
  1028. <!--[if !mso]><!-- -->
  1029. <a href="$safeUrl"
  1030. style="background:#635A4A;border-radius:0;display:inline-block;padding:12px 24px;color:#ffffff;
  1031. text-decoration:none;font-weight:700;font-size:14px;line-height:1.6;mso-hide:all"
  1032. target="_blank" rel="noopener">View Contract</a>
  1033. <!--<![endif]-->
  1034. </td>
  1035. </tr>
  1036. <tr>
  1037. <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
  1038. <div style="font-size:14px;line-height:1.6;">
  1039. If the button doesn’t work, copy and paste this link into your browser:<br>
  1040. <span style="word-break:break-all;color:#635A4A;font-size:14px;line-height:1.6;">$safeUrl</span>
  1041. </div>
  1042. <div style="font-size:14px;line-height:1.6;margin-top:18px;">
  1043. Thanks again — we’re excited to be working with you and looking forward to getting started.<br><br>
  1044. <b>Kind Regards,</b><br><br>$safeSignature<br>Benjamin Harris<br>Modulos Design<br>0402 984 082 | drafting@modulosdesign.com.au
  1045. </div>
  1046. </td>
  1047. </tr>
  1048. <tr>
  1049. <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:14px;line-height:1.6;">
  1050. This is an automated message. Please reply to this email if you have any questions.
  1051. </td>
  1052. </tr>
  1053. </table>
  1054. </div>
  1055. HTML;
  1056. $alt = "Hello {$firstName},\n\nThe contract has been signed{$preparedPart}.\n\nView/download: {$viewUrl}\n\nThanks,\n{$company}";
  1057. return [$subject, $html, $alt];
  1058. }
  1059. ?>