diff --git a/sim/cluster0/cluster.env b/sim/cluster0/cluster.env index efbcfc6..06f5e07 100644 --- a/sim/cluster0/cluster.env +++ b/sim/cluster0/cluster.env @@ -71,4 +71,4 @@ CAST03_PUBLIC=c3.acl0.f0xx.org EXPECTED_CRASHES_TABLES=16 EXPECTED_URL_SHORTENER_TABLES=4 -APK_PACKAGES_BASE="nginx php83 php83-fpm php83-cli php83-session php83-pdo php83-pdo_mysql php83-pdo_sqlite php83-sqlite3 php83-mbstring php83-json php83-curl php83-openssl php83-xml php83-zip php83-phar php83-opcache mariadb-client git rsync curl bash ca-certificates sqlite wireguard-tools nfs-utils openssh-client" +APK_PACKAGES_BASE="nginx php83 php83-fpm php83-cli php83-session php83-pdo php83-pdo_mysql php83-pdo_sqlite php83-sqlite3 php83-mbstring php83-json php83-curl php83-openssl php83-xml php83-zip php83-phar php83-opcache mariadb-client git rsync curl bash ca-certificates sqlite wireguard-tools nfs-utils openssh-client libqrencode libqrencode-tools" diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php index eb9e6cf..d4bf3fd 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php +++ b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php @@ -6,35 +6,82 @@ final class AuthMailer { private function __construct() { } - public static function send(string $to, string $subject, string $bodyText): bool { + /** + * @param list $attachments + */ + public static function send(string $to, string $subject, string $bodyText, array $attachments = []): bool { $from = (string) cfg('mail.from', 'Android Cast '); $replyTo = (string) cfg('mail.reply_to', ''); $transport = strtolower((string) cfg('mail.transport', 'smtp')); if ($transport === 'sendmail') { - return self::sendMail($to, $subject, $bodyText, $from, $replyTo); + return self::sendMail($to, $subject, $bodyText, $from, $replyTo, $attachments); } - return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo); + return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments); } public static function sendVerifyEmail(string $to, string $verifyUrl): bool { $subject = 'Verify your Android Cast account'; - $link = class_exists('ShortLinksRepository', false) - ? ShortLinksRepository::shortenForOutbound($verifyUrl, 86400) - : $verifyUrl; - $body = "Open this link to verify your email (valid 24h):\n\n" . $link . "\n"; - return self::send($to, $subject, $body); + $shortUrl = $verifyUrl; + $qrScan = $verifyUrl; + $qrPng = null; + if (class_exists('ShortLinksRepository', false)) { + $pack = ShortLinksRepository::shortenForOutboundMail($verifyUrl, 86400); + $shortUrl = (string) ($pack['short_url'] ?? $verifyUrl); + $qrScan = (string) ($pack['qr_scan_url'] ?? $shortUrl); + $qrPng = $pack['qr_png'] ?? null; + } + $body = "Open this link to verify your email (valid 24h):\n\n" + . $shortUrl . "\n\n"; + if ($qrPng !== null && $qrPng !== '') { + $body .= "Or scan the attached QR code on your phone (opens the same short link).\n"; + } + $attachments = []; + if ($qrPng !== null && $qrPng !== '') { + $attachments[] = [ + 'name' => 'verify-email-qr.png', + 'data' => $qrPng, + 'type' => 'image/png', + ]; + } + return self::send($to, $subject, $body, $attachments); } - private static function sendMail(string $to, string $subject, string $body, string $from, string $replyTo): bool { + /** + * @param list $attachments + */ + private static function sendMail( + string $to, + string $subject, + string $body, + string $from, + string $replyTo, + array $attachments + ): bool { $headers = "From: {$from}\r\n"; if ($replyTo !== '') { $headers .= "Reply-To: {$replyTo}\r\n"; } - $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; - return @mail($to, $subject, $body, $headers); + if ($attachments === []) { + $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; + return @mail($to, $subject, $body, $headers); + } + $built = self::buildMime($body, $attachments); + $headers .= "MIME-Version: 1.0\r\n"; + $headers .= 'Content-Type: multipart/mixed; boundary="' . $built['boundary'] . "\"\r\n"; + return @mail($to, $subject, $built['body'], $headers); } - private static function sendSmtp(string $to, string $subject, string $body, string $from, string $replyTo): bool { + /** + * @param list $attachments + */ + private static function sendSmtp( + string $to, + string $subject, + string $body, + string $from, + string $replyTo, + array $attachments + ): bool { $host = (string) cfg('mail.smtp.host', '127.0.0.1'); $port = (int) cfg('mail.smtp.port', 587); $enc = strtolower((string) cfg('mail.smtp.encryption', 'tls')); @@ -46,7 +93,7 @@ final class AuthMailer { $fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15); if ($fp === false) { error_log('AuthMailer SMTP connect failed: ' . $errstr); - return self::sendMail($to, $subject, $body, $from, $replyTo); + return self::sendMail($to, $subject, $body, $from, $replyTo, $attachments); } stream_set_timeout($fp, 15); if (!self::smtpExpect($fp, [220])) { @@ -108,11 +155,19 @@ final class AuthMailer { fclose($fp); return false; } + $mime = $attachments === [] ? null : self::buildMime($body, $attachments); $msg = "From: {$from}\r\n"; if ($replyTo !== '') { $msg .= "Reply-To: {$replyTo}\r\n"; } - $msg .= "To: {$to}\r\nSubject: {$subject}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n"; + $msg .= "To: {$to}\r\nSubject: {$subject}\r\n"; + if ($mime === null) { + $msg .= "Content-Type: text/plain; charset=UTF-8\r\n\r\n{$body}\r\n.\r\n"; + } else { + $msg .= "MIME-Version: 1.0\r\n"; + $msg .= 'Content-Type: multipart/mixed; boundary="' . $mime['boundary'] . "\"\r\n\r\n"; + $msg .= $mime['body'] . "\r\n.\r\n"; + } fwrite($fp, $msg); if (!self::smtpExpect($fp, [250])) { fclose($fp); @@ -123,6 +178,30 @@ final class AuthMailer { return true; } + /** + * @param list $attachments + * @return array{boundary:string,body:string} + */ + private static function buildMime(string $textBody, array $attachments): array { + $boundary = 'ac_' . bin2hex(random_bytes(12)); + $out = "--{$boundary}\r\n"; + $out .= "Content-Type: text/plain; charset=UTF-8\r\n"; + $out .= "Content-Transfer-Encoding: 8bit\r\n\r\n"; + $out .= $textBody . "\r\n"; + foreach ($attachments as $att) { + $name = (string) ($att['name'] ?? 'attachment.bin'); + $type = (string) ($att['type'] ?? 'application/octet-stream'); + $data = (string) ($att['data'] ?? ''); + $out .= "--{$boundary}\r\n"; + $out .= "Content-Type: {$type}; name=\"{$name}\"\r\n"; + $out .= "Content-Transfer-Encoding: base64\r\n"; + $out .= "Content-Disposition: attachment; filename=\"{$name}\"\r\n\r\n"; + $out .= chunk_split(base64_encode($data), 76, "\r\n"); + } + $out .= "--{$boundary}--\r\n"; + return ['boundary' => $boundary, 'body' => $out]; + } + /** @param resource $fp @param list $codes */ private static function smtpExpect($fp, array $codes): bool { $line = ''; diff --git a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php index 039b109..6553e13 100644 --- a/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php +++ b/sim/cluster0/lab-seeds/backend/src/ShortLinksRepository.php @@ -481,20 +481,70 @@ final class ShortLinksRepository { /** Outbound mail/SMS — shorten our URLs; fall back to long URL on failure. */ public static function shortenForOutbound(string $longUrl, int $ttlSeconds = 86400): string { + return self::shortenForOutboundMail($longUrl, $ttlSeconds)['short_url']; + } + + /** + * Short link + QR payload for outbound mail. + * + * @return array{short_url:string,qr_scan_url:string,qr_png:?string} + */ + public static function shortenForOutboundMail(string $longUrl, int $ttlSeconds = 86400): array { $longUrl = trim($longUrl); + $fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_png' => null]; if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) { - return $longUrl; + return $fallback; } $bearerId = self::resolveOutboundBearerId(); if ($bearerId <= 0) { - return $longUrl; + return $fallback; } $mint = self::createLink($bearerId, $longUrl, $ttlSeconds); if (empty($mint['ok']) || empty($mint['short_url'])) { error_log('ShortLinksRepository: outbound shorten failed'); - return $longUrl; + return $fallback; } - return (string) $mint['short_url']; + $shortUrl = (string) $mint['short_url']; + $qrScan = self::qrScanUrl($shortUrl); + return [ + 'short_url' => $shortUrl, + 'qr_scan_url' => $qrScan, + 'qr_png' => self::renderQrPng($qrScan), + ]; + } + + /** Short URL with src=qr for QR encoding and phone scan analytics. */ + public static function qrScanUrl(string $shortUrl): string { + return self::appendQueryParam($shortUrl, 'src', 'qr'); + } + + public static function appendQueryParam(string $url, string $key, string $value): string { + $sep = str_contains($url, '?') ? '&' : '?'; + return $url . $sep . rawurlencode($key) . '=' . rawurlencode($value); + } + + public static function renderQrPng(string $text, int $size = 256): ?string { + $text = trim($text); + if ($text === '') { + return null; + } + $qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? ''); + if ($qrencode === '') { + return null; + } + $size = max(128, min(512, $size)); + $moduleSize = max(2, (int) round($size / 40)); + $cmd = sprintf( + '%s -t PNG -s %d -m 1 -o - %s 2>/dev/null', + escapeshellcmd($qrencode), + $moduleSize, + escapeshellarg($text) + ); + $out = shell_exec($cmd); + if (!is_string($out) || strlen($out) < 8) { + return null; + } + return $out; } private static function resolveOutboundBearerId(): int { diff --git a/sim/cluster0/scripts/deploy-ac-url-shortener.sh b/sim/cluster0/scripts/deploy-ac-url-shortener.sh index 52d4d05..d0bfcd4 100755 --- a/sim/cluster0/scripts/deploy-ac-url-shortener.sh +++ b/sim/cluster0/scripts/deploy-ac-url-shortener.sh @@ -24,5 +24,7 @@ else git clone --branch "$BRANCH" --depth 1 "$ORIGIN" "$URL_ROOT" fi +apk add --no-cache libqrencode libqrencode-tools >/dev/null 2>&1 || true + chown -R nginx:nginx "$URL_ROOT" 2>/dev/null || true log "deploy-ac-url-shortener_ok $(host_short) $(git -C "$URL_ROOT" rev-parse --short HEAD)" diff --git a/sim/cluster0/scripts/verify-mail-lab.sh b/sim/cluster0/scripts/verify-mail-lab.sh index 2e7df8b..aabe696 100755 --- a/sim/cluster0/scripts/verify-mail-lab.sh +++ b/sim/cluster0/scripts/verify-mail-lab.sh @@ -28,7 +28,10 @@ $ok = AuthMailer::sendVerifyEmail($to, $long); echo $ok ? "mail_ok" : "mail_fail"; ' 2>&1)" || _out="mail_fail" -log "$_out to=$TO long=$_long" +log "$_out to=$TO short=$(echo "$_long" | sed 's/token=.*/token=…/')" +if echo "$_out" | grep -q mail_ok; then + log "hint: expect s.f0xx.org short link + verify-email-qr.png attachment (QR encodes …&src=qr)" +fi case "$_out" in mail_ok) exit 0 ;; *) exit 1 ;;