PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function absUrlFor(array $params = []): string {
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$scheme = $https ? 'https' : 'https';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
$base = $scheme . '://' . $host . ($dir ? $dir : '');
$qs = http_build_query($params);
return $base . '/' . basename(__FILE__) . ($qs ? ('?' . $qs) : '');
}
function clientExternalIp(array $trustedCidrs = []): string {
$cf = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
if ($cf && filter_var($cf, FILTER_VALIDATE_IP)) return $cf;
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
$remote = $_SERVER['REMOTE_ADDR'] ?? '';
if (!$xff) return $remote ?: 'UNKNOWN';
// XFF may be a list "client, proxy1, proxy2"
$parts = array_map('trim', explode(',', $xff));
// Walk from leftmost; pick first that is not in a trusted range
foreach ($parts as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP)) continue;
if (!ipInCidrs($ip, $trustedCidrs)) {
return $ip;
}
}
// Fallback: last hop or REMOTE_ADDR
return $parts[0] ?: ($remote ?: 'UNKNOWN');
}
function ipInCidrs(string $ip, array $cidrs): bool {
foreach ($cidrs as $cidr) {
[$subnet, $mask] = array_pad(explode('/', $cidr, 2), 2, null);
if (!$subnet || $mask === null) continue;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
&& filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ipLong = ip2long($ip);
$subLong = ip2long($subnet);
$maskLong = -1 << (32 - (int)$mask);
if (($ipLong & $maskLong) === ($subLong & $maskLong)) return true;
}
}
return false;
}
function salutationFromName(string $fullName): string {
$name = trim(preg_replace('/\s+/', ' ', $fullName));
if ($name === '') return 'there';
$name = preg_replace(
'/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/i',
'',
$name
);
$tokens = preg_split('/\s+/', $name);
if (!$tokens) return 'there';
$titles = [
'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'
];
$i = 0;
while ($i < count($tokens) && in_array(strtolower(rtrim($tokens[$i], '.')), $titles, true)) $i++;
while ($i < count($tokens) && preg_match('/^[A-Za-z]\.?$/', $tokens[$i])) $i++;
return $i < count($tokens) ? $tokens[$i] : 'there';
}
// ---------------------------------------------------------------------
// DB adapters (tweak SQL/columns to your schema)
// ---------------------------------------------------------------------
function fetchLoaFromDb(PDO $pdo, string $clientId, array $drgCandidates): array
{
// If no candidates (bad/missing drg), force empty result
if (!$drgCandidates) return [];
// Build a dynamic IN() that’s safe
$ph = [];
$params = [];
foreach ($drgCandidates as $i => $c) {
$ph[] = ':d' . $i;
$params[':d' . $i] = $c;
}
$in = implode(',', $ph);
// Pull what we need (adjust/extend columns if you add more later)
// $sql = "SELECT * FROM details WHERE drg IN ($in) ORDER BY id DESC LIMIT 1";
$sql = "
SELECT d.*, a.*
FROM details d
LEFT JOIN addresses a ON d.drg = a.drg
WHERE d.drg IN ($in)
ORDER BY d.id DESC
LIMIT 1
";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$job = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
// Compose a clean display name (prefer joint_name, else First Last)
$name = trim((string)($job['joint_name'] ?? ''));
if ($name === '') {
$first = trim((string)($job['firstname'] ?? ''));
$last = trim((string)($job['lastname'] ?? ''));
$name = trim($first . ' ' . $last);
}
// Minimal LOA body built from available fields
$loaHtml = composeLoaHtmlFromJob($job);
// Brand defaults from config
$company = $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design';
$logoUrl = $GLOBALS['cfg']['dark_logo'] ?? '';
// Prepared date: today
$prepared = date('F j, Y');
return [
'client_id' => $clientId, // canonical Job # like "3043"
'client_name' => $name,
'client_email' => (string)($job['client_email'] ?? ''),
'client_phone' => (string)($job['client_mobile'] ?? ''),
'client_address' => (string)($job['billing_address'] ?? ''),
'dev_name' => $GLOBALS['cfg']['dev_name'] ?? 'Modulos Design',
'dev_email' => $GLOBALS['cfg']['dev_email'] ?? 'drafting@modulosdesign.com.au',
'dev_phone' => $GLOBALS['cfg']['dev_phone'] ?? '',
'dev_address' => $GLOBALS['cfg']['dev_address'] ?? '',
'building_surveyor' => $GLOBALS['cfg']['building_surveyor'] ?? '',
'company' => $company,
'logo_url' => $logoUrl,
'prepared_date' => $prepared,
'loa_html' => $loaHtml,
'client_signature_png'=> $job['signature'] ?? null,
'client_signed_at' => $job['loa_signed'] ?? null,
];
}
/**
* Compose the LOA body from job fields.
*/
function composeLoaHtmlFromJob(array $j): string
{
$esc = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
$firstname = $esc($j['firstname'] ?? '');
$lastname = $esc($j['lastname'] ?? '');
// Prefer joint_name; if empty, show "First Last"
$jointRaw = trim((string)($j['joint_name'] ?? ''));
if ($jointRaw === '') {
$jointRaw = trim(($j['firstname'] ?? '') . ' ' . ($j['lastname'] ?? ''));
}
$joint_name = $esc($jointRaw);
// Postal address: use postal_address if you have it; else billing_address
$postal_address = $esc($j['postal_address'] ?? ($j['billing_address'] ?? ''));
$client_mobile = $esc($j['client_mobile'] ?? '');
$client_email = $esc($j['client_email'] ?? '');
// Site address: use site_address if present; else fall back to locality
$site_address = (string)($j['site_address'] ?? '');
if ($site_address === '' && !empty($j['locality'])) {
$site_address = (string)$j['locality'];
}
$site_address = $esc($site_address);
$property_id = $esc($j['property_id'] ?? '');
$title_id = $esc($j['title_id'] ?? '');
$details = trim((string)($j['design_style'] ?? ''));
$detailsHtml = $details !== '' ? nl2br($esc($details)) : '';
$building_surveyor = $GLOBALS['cfg']['building_surveyor'] ?? '';
$dev_name = $GLOBALS['cfg']['dev_name'] ?? '-';
$dev_email = $GLOBALS['cfg']['dev_email'] ?? '-';
$dev_phone = $GLOBALS['cfg']['dev_phone'] ?? '-';
$dev_address = $GLOBALS['cfg']['dev_address'] ?? '-';
return <<
{$firstname} {$lastname}
{$joint_name}
{$postal_address}
Project Details
Title Owner/s Name/s
{$joint_name}
Title Owner/s Contact Details
Postal Address: {$postal_address}
Ph: {$client_mobile}
Email: {$client_email}
Project Address
{$site_address}
PID: {$property_id}
Volume/Folio: {$title_id}
Project Description
{$detailsHtml}
Contact Name and Phone Number
{$dev_name}
{$dev_company}
{$dev_address}
{$dev_phone}
{$dev_email}
We the undersigned, hereby authorise the above representative from Modulos Design to act as our lawful agent to sign and apply for all necessary certificates and permits on our behalf for the above project.
HTML;
}
/**
* Save the client’s signature + metadata back to DB.
*/
function saveLoaSignatureToDb(PDO $pdo, string $clientId, string $signatureDataUri, string $clientIp, string $clientTz, string $signedAtIso): void
{
// Only write the columns you actually have: signature + loa_signed
$sql = "
UPDATE details
SET signature = :sig,
loa_signed = :signed
WHERE CAST(drg AS CHAR) = :drg1
OR CAST(drg AS CHAR) = CONCAT(:drg2, '.0')
OR CAST(drg AS CHAR) = CONCAT(:drg3, '.00')
";
$pdo->prepare($sql)->execute([
':sig' => $signatureDataUri,
':signed'=> $signedAtIso,
':drg1' => $clientId,
':drg2' => $clientId,
':drg3' => $clientId,
]);
}
// ---------------------------------------------------------------------
// Templating for on-screen unsigned view
// ---------------------------------------------------------------------
function headerWeb(string $title, string $clientId, string $prepared, string $logoUrl): string
{
$safeT = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
$safeJ = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
$safeD = htmlspecialchars($prepared, ENT_QUOTES, 'UTF-8');
$safeL = $logoUrl
? ' '
: '';
$base = absUrlFor();
return <<
{$safeJ} – {$safeT}
HTML;
}
function footerWeb(): string
{
return <<
HTML;
}
// ---------------------------------------------------------------------
// PDF compilation
// ---------------------------------------------------------------------
function buildSignedPdfHtml(
string $clientId,
string $preparedDate,
string $company,
string $loaHtml,
string $devSigHtml,
string $clientSigHtml
): string {
$safeJob = htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
$safeDate = htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8');
$safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
return <<
{$safeJob} – Signed LOA
HTML;
}
// ---------------------------------------------------------------------
// Email builder (14px everywhere, square button). Link points to ?download=signed
// ---------------------------------------------------------------------
function buildSignedLoaEmail(
string $absoluteDownloadUrl,
string $clientId,
string $clientName,
string $preparedDate,
string $company,
string $logoUrl
): array {
$first = salutationFromName($clientName ?: '');
$fSafe = htmlspecialchars($first, ENT_QUOTES, 'UTF-8');
$safeUrl= htmlspecialchars($absoluteDownloadUrl, ENT_QUOTES, 'UTF-8');
$safeCo = htmlspecialchars($company, ENT_QUOTES, 'UTF-8');
$safeJob= htmlspecialchars($clientId, ENT_QUOTES, 'UTF-8');
$prep = $preparedDate
? " (prepared " . htmlspecialchars($preparedDate, ENT_QUOTES, 'UTF-8') . ")"
: '';
$logo = $logoUrl
? '
'
: '';
$subject = "{$safeJob} – LOA signed";
$html = <<
Thank you for signing your Letter of Acceptance — here’s your copy and access link.
Hello {$fSafe},
Thank you for signing the Letter of Acceptance{$prep}. A PDF copy is attached, and you can view or download it anytime using the link below:
View LOA
If the button doesn’t work, copy and paste this link into your browser:
{$safeUrl}
Kind regards, {$safeCo}
This is an automated message. Please reply to this email if you have any questions.
HTML;
$alt = "Hello {$first},\n\nThank you for signing the Letter of Acceptance{$prep}.\nView/download: {$absoluteDownloadUrl}\n\nKind regards,\n{$company}";
return [$subject, $html, $alt];
}
// ---------------------------------------------------------------------
// Dev and Client signature blocks
// ---------------------------------------------------------------------
function devSignatureHtml(string $sigUrlOrData, ?string $timestamp = null, ?string $ip = null, ?string $devName = null): string
{
$img = $sigUrlOrData
? ' '
: '';
$name = $devName
? '' . htmlspecialchars($devName, ENT_QUOTES, 'UTF-8') . '
'
: '';
$meta = '';
if ($timestamp || $ip) {
$meta .= '';
if ($timestamp) $meta .= '
Signed on: ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '
';
if ($ip) $meta .= '
IP address: ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '
';
$meta .= '
';
}
return $name . $img . $meta;
}
function clientSignatureHtml(string $sigDataUri, ?string $timestamp = null, ?string $ip = null, ?string $clientName = null): string
{
$img = ' ';
$name = $clientName
? '' . htmlspecialchars($clientName, ENT_QUOTES, 'UTF-8') . '
'
: '';
$meta = '';
if ($timestamp || $ip) {
$meta .= '';
if ($timestamp) $meta .= '
Signed on: ' . htmlspecialchars($timestamp, ENT_QUOTES, 'UTF-8') . '
';
if ($ip) $meta .= '
Client IP: ' . htmlspecialchars($ip, ENT_QUOTES, 'UTF-8') . '
';
$meta .= '
';
}
return $name . $img . $meta;
}
// ---------------------------------------------------------------------
// Mail sender
// ---------------------------------------------------------------------
function sendSignedLoaEmails(array $cfg, string $fromAddress, array $row, string $pdfBinary): void
{
$clientEmail = trim((string)($row['client_email'] ?? ''));
$devEmail = trim((string)($row['dev_email'] ?? ''));
$clientEmail = filter_var($clientEmail, FILTER_VALIDATE_EMAIL) ?: '';
$devEmail = filter_var($devEmail, FILTER_VALIDATE_EMAIL) ?: '';
if (!$clientEmail && !$devEmail) return;
$company = $row['company'] ?? ($cfg['dev_name'] ?? 'Modulos Design');
$logoUrl = $row['logo_url'] ?? ($cfg['dark_logo'] ?? '');
$clientId = (string)($row['client_id'] ?? '');
$clientName= (string)($row['client_name'] ?? '');
$prepared = (string)($row['prepared_date'] ?? date('F j, Y'));
$dlUrl = absUrlFor(['drg' => $clientId, 'download' => 'signed']);
[$subjC, $htmlC, $altC] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
[$subjD, $htmlD, $altD] = buildSignedLoaEmail($dlUrl, $clientId, $clientName, $prepared, $company, $logoUrl);
$subjD = $clientId . ' – LOA signed!';
$targets = [];
if ($clientEmail) {
$targets[] = [
'to' => $clientEmail,
'subject' => $subjC,
'html' => $htmlC,
'alt' => $altC,
'replyTo' => $devEmail ?: null,
];
}
if ($devEmail) {
// add "Signed by" note
$signedBy = htmlspecialchars($clientEmail ?: 'unknown', ENT_QUOTES, 'UTF-8');
$inject = 'Signed by: ' . $signedBy . ' ';
$htmlD = str_replace('', ' ' . $inject . '', $htmlD);
$targets[] = [
'to' => $devEmail,
'subject' => $subjD,
'html' => $htmlD,
'alt' => $altD . "\n\nSigned by: " . ($clientEmail ?: 'unknown'),
'replyTo' => $clientEmail ?: null,
];
}
foreach ($targets as $t) {
$mail = new PHPMailer(true);
$mail->SMTPDebug = SMTP::DEBUG_OFF;
$mail->isSMTP();
$mail->Host = $cfg['smtp_host'] ?? '';
$mail->SMTPAuth = true;
$mail->Username = $cfg['smtp_username'] ?? '';
$mail->Password = $cfg['smtp_password'] ?? '';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
$mail->Port = $cfg['smtp_port'] ?? 465;
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
$mail->setFrom($cfg['from_address'] ?? $fromAddress ?? 'drafting@modulosdesign.com.au', $company);
if (!empty($t['replyTo'])) $mail->addReplyTo($t['replyTo']);
$mail->addAddress($t['to']);
$mail->isHTML(true);
$mail->Subject = $t['subject'];
$mail->Body = $t['html'];
if (!empty($t['alt'])) $mail->AltBody = $t['alt'];
// Optional BCC + attachment
$mail->addBCC('drafting@modulosdesign.com.au');
// Attach the PDF from memory
$mail->addStringAttachment($pdfBinary, $clientId . '_loa_signed.pdf', 'base64', 'application/pdf');
try {
$mail->send();
} catch (Exception $e) {
error_log("LOA email send error to {$t['to']}: {$mail->ErrorInfo}");
}
}
}
// ---------------------------------------------------------------------
// MAIN FLOW
// ---------------------------------------------------------------------
$pdo = getPdoSafe($cfg);
// $clientId = isset($_GET['clientid']) && preg_match('/^\d{1,10}$/', $_GET['clientid']) ? $_GET['clientid'] : 'unknown';
$drgRaw = $_GET['drg'] ?? $_GET['clientid'] ?? '';
[$clientId, $drgCandidates] = normalizeDrg($drgRaw); // $clientId becomes your Job # string (e.g. "3043")
$trusted = isset($cfg['trusted_proxies']) && is_array($cfg['trusted_proxies']) ? $cfg['trusted_proxies'] : [];
$row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
// Developer signature image (URL or data:) from config
$devSigUrl = '';
if (!empty($cfg['dev_signature'])) {
$devSigUrl = (string)$cfg['dev_signature']; // can be data: or absolute URL
}
// ---------------------------------------------------------------------
// GET ?download=signed -> stream PDF of latest signed LOA from DB
// ---------------------------------------------------------------------
if (($_GET['download'] ?? '') === 'signed') {
$filename = $clientId . '-LOA-Signed.pdf';
$pdfPath = CONTRACT_DIR . '/' . $filename;
// No file yet, build from DB
if (empty($row['client_signature_png'])) {
http_response_code(404);
exit('No signed LOA on file.');
}
$clientSig = clientSignatureHtml(
$row['client_signature_png'],
!empty($row['client_signed_at']) ? date('F j, Y \a\t g:i:s A T', strtotime($row['client_signed_at'])) : null,
$row['client_ip'] ?? null,
$row['client_name'] ?? null
);
$devSig = devSignatureHtml($devSigUrl, null, null, $row['dev_name'] ?? null);
$pdfHtml = buildSignedPdfHtml(
$clientId,
(string)$row['prepared_date'],
(string)$row['company'],
(string)$row['loa_html'],
$devSig,
$clientSig
);
$opts = new Options();
$opts->set('defaultFont', 'Helvetica');
$opts->set('isRemoteEnabled', true);
$dompdf = new Dompdf($opts);
$dompdf->loadHtml($pdfHtml, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
$dompdf->setBasePath('https://' . $host . $dir);
$dompdf->render();
$pdf = $dompdf->output();
// Save then stream
@file_put_contents($pdfPath, $pdf, LOCK_EX);
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $filename . '"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
}
// ---------------------------------------------------------------------
// POST (client signing) -> save signature, email, stream PDF
// ---------------------------------------------------------------------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['client_signature'])) {
$sig = (string)$_POST['client_signature'];
if (!str_starts_with($sig, 'data:image/png;base64,')) {
http_response_code(400);
exit('Invalid signature format');
}
if (strlen($sig) > 2 * 1024 * 1024) { // 2MB limit for safety
http_response_code(413);
exit('Signature too large');
}
$clientTz = (string)($_POST['client_tz'] ?? '');
$signedAtIso = (function ($clientTz) {
try {
if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
$tz = new DateTimeZone($clientTz);
$dt = new DateTime('now', $tz);
return $dt->format(DateTime::ATOM);
}
} catch (\Throwable $e) {}
return gmdate('c');
})($clientTz);
$clientIp = clientExternalIp($trusted);
// Persist signature to DB
saveLoaSignatureToDb($pdo, $clientId, $sig, $clientIp, $clientTz, $signedAtIso);
// Re-hydrate row with saved fields (optional)
$row = fetchLoaFromDb($pdo, $clientId, $drgCandidates);
// Build signature blocks
$clientSig = clientSignatureHtml(
$sig,
date('F j, Y \a\t g:i:s A T', strtotime($signedAtIso)),
$clientIp,
$row['client_name'] ?? null
);
$devSignedAt = date('F j, Y \a\t g:i:s A T');
$devIp = $_SERVER['SERVER_ADDR'] ?? 'UNKNOWN';
$devSig = devSignatureHtml($devSigUrl, $devSignedAt, $devIp, $row['dev_name'] ?? null);
// Compile PDF HTML
$pdfHtml = buildSignedPdfHtml(
$clientId,
(string)$row['prepared_date'],
(string)$row['company'],
(string)$row['loa_html'],
$devSig,
$clientSig
);
// Render PDF
$opts = new Options();
$opts->set('defaultFont', 'Helvetica');
$opts->set('isRemoteEnabled', true);
$dompdf = new Dompdf($opts);
$dompdf->loadHtml($pdfHtml, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\') . '/';
$dompdf->setBasePath('https://' . $host . $dir);
$dompdf->render();
$pdf = $dompdf->output();
// Email PDFs
$from = $cfg['from_address'] ?? 'drafting@modulosdesign.com.au';
sendSignedLoaEmails($cfg, $from, $row, $pdf);
// Stream to browser
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $clientId . '-LOA-Signed.pdf"');
header('Content-Length: ' . strlen($pdf));
echo $pdf;
exit;
}
// ---------------------------------------------------------------------
// GET (no signature yet) -> show unsigned page with signature pad
// ---------------------------------------------------------------------
if (!headers_sent()) {
header('Content-Type: text/html; charset=UTF-8');
}
echo headerWeb('Letter of Authority (Unsigned)', $clientId, (string)$row['prepared_date'], (string)$row['logo_url']);
// LOA BODY (from DB)
echo '';
echo $row['loa_html']; // Assume this is trusted HTML from your system. If not, sanitize.
echo '
';
// Signature UI (no dev signature displayed here)
$clientEmailSafe = htmlspecialchars((string)($row['client_email'] ?? ''), ENT_QUOTES, 'UTF-8');
$csrfSafe = $csrf;
?>
= footerWeb(); ?>