mirror of
git://f0xx.org/ac/ac-deploy
synced 2026-07-29 04:19:00 +03:00
cluster0: QR verify email attachment, libqrencode-tools, src=qr analytics
ShortLinksRepository mail pack + renderQrPng; cluster APK includes libqrencode-tools; deploy-ac-url-shortener ensures QR CLI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -71,4 +71,4 @@ CAST03_PUBLIC=c3.acl0.f0xx.org
|
|||||||
EXPECTED_CRASHES_TABLES=16
|
EXPECTED_CRASHES_TABLES=16
|
||||||
EXPECTED_URL_SHORTENER_TABLES=4
|
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"
|
||||||
|
|||||||
@@ -6,35 +6,82 @@ final class AuthMailer {
|
|||||||
private function __construct() {
|
private function __construct() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function send(string $to, string $subject, string $bodyText): bool {
|
/**
|
||||||
|
* @param list<array{name:string,data:string,type:string}> $attachments
|
||||||
|
*/
|
||||||
|
public static function send(string $to, string $subject, string $bodyText, array $attachments = []): bool {
|
||||||
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
$from = (string) cfg('mail.from', 'Android Cast <noreply@apps.f0xx.org>');
|
||||||
$replyTo = (string) cfg('mail.reply_to', '');
|
$replyTo = (string) cfg('mail.reply_to', '');
|
||||||
$transport = strtolower((string) cfg('mail.transport', 'smtp'));
|
$transport = strtolower((string) cfg('mail.transport', 'smtp'));
|
||||||
if ($transport === 'sendmail') {
|
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 {
|
public static function sendVerifyEmail(string $to, string $verifyUrl): bool {
|
||||||
$subject = 'Verify your Android Cast account';
|
$subject = 'Verify your Android Cast account';
|
||||||
$link = class_exists('ShortLinksRepository', false)
|
$shortUrl = $verifyUrl;
|
||||||
? ShortLinksRepository::shortenForOutbound($verifyUrl, 86400)
|
$qrScan = $verifyUrl;
|
||||||
: $verifyUrl;
|
$qrPng = null;
|
||||||
$body = "Open this link to verify your email (valid 24h):\n\n" . $link . "\n";
|
if (class_exists('ShortLinksRepository', false)) {
|
||||||
return self::send($to, $subject, $body);
|
$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<array{name:string,data:string,type:string}> $attachments
|
||||||
|
*/
|
||||||
|
private static function sendMail(
|
||||||
|
string $to,
|
||||||
|
string $subject,
|
||||||
|
string $body,
|
||||||
|
string $from,
|
||||||
|
string $replyTo,
|
||||||
|
array $attachments
|
||||||
|
): bool {
|
||||||
$headers = "From: {$from}\r\n";
|
$headers = "From: {$from}\r\n";
|
||||||
if ($replyTo !== '') {
|
if ($replyTo !== '') {
|
||||||
$headers .= "Reply-To: {$replyTo}\r\n";
|
$headers .= "Reply-To: {$replyTo}\r\n";
|
||||||
}
|
}
|
||||||
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
if ($attachments === []) {
|
||||||
return @mail($to, $subject, $body, $headers);
|
$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<array{name:string,data:string,type:string}> $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');
|
$host = (string) cfg('mail.smtp.host', '127.0.0.1');
|
||||||
$port = (int) cfg('mail.smtp.port', 587);
|
$port = (int) cfg('mail.smtp.port', 587);
|
||||||
$enc = strtolower((string) cfg('mail.smtp.encryption', 'tls'));
|
$enc = strtolower((string) cfg('mail.smtp.encryption', 'tls'));
|
||||||
@@ -46,7 +93,7 @@ final class AuthMailer {
|
|||||||
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
|
$fp = @stream_socket_client($remote . ':' . $port, $errno, $errstr, 15);
|
||||||
if ($fp === false) {
|
if ($fp === false) {
|
||||||
error_log('AuthMailer SMTP connect failed: ' . $errstr);
|
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);
|
stream_set_timeout($fp, 15);
|
||||||
if (!self::smtpExpect($fp, [220])) {
|
if (!self::smtpExpect($fp, [220])) {
|
||||||
@@ -108,11 +155,19 @@ final class AuthMailer {
|
|||||||
fclose($fp);
|
fclose($fp);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
$mime = $attachments === [] ? null : self::buildMime($body, $attachments);
|
||||||
$msg = "From: {$from}\r\n";
|
$msg = "From: {$from}\r\n";
|
||||||
if ($replyTo !== '') {
|
if ($replyTo !== '') {
|
||||||
$msg .= "Reply-To: {$replyTo}\r\n";
|
$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);
|
fwrite($fp, $msg);
|
||||||
if (!self::smtpExpect($fp, [250])) {
|
if (!self::smtpExpect($fp, [250])) {
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
@@ -123,6 +178,30 @@ final class AuthMailer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{name:string,data:string,type:string}> $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<int> $codes */
|
/** @param resource $fp @param list<int> $codes */
|
||||||
private static function smtpExpect($fp, array $codes): bool {
|
private static function smtpExpect($fp, array $codes): bool {
|
||||||
$line = '';
|
$line = '';
|
||||||
|
|||||||
@@ -481,20 +481,70 @@ final class ShortLinksRepository {
|
|||||||
|
|
||||||
/** Outbound mail/SMS — shorten our URLs; fall back to long URL on failure. */
|
/** Outbound mail/SMS — shorten our URLs; fall back to long URL on failure. */
|
||||||
public static function shortenForOutbound(string $longUrl, int $ttlSeconds = 86400): string {
|
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);
|
$longUrl = trim($longUrl);
|
||||||
|
$fallback = ['short_url' => $longUrl, 'qr_scan_url' => $longUrl, 'qr_png' => null];
|
||||||
if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
if ($longUrl === '' || !UrlShortenerDatabase::enabled() || !UrlShortenerDatabase::ping()) {
|
||||||
return $longUrl;
|
return $fallback;
|
||||||
}
|
}
|
||||||
$bearerId = self::resolveOutboundBearerId();
|
$bearerId = self::resolveOutboundBearerId();
|
||||||
if ($bearerId <= 0) {
|
if ($bearerId <= 0) {
|
||||||
return $longUrl;
|
return $fallback;
|
||||||
}
|
}
|
||||||
$mint = self::createLink($bearerId, $longUrl, $ttlSeconds);
|
$mint = self::createLink($bearerId, $longUrl, $ttlSeconds);
|
||||||
if (empty($mint['ok']) || empty($mint['short_url'])) {
|
if (empty($mint['ok']) || empty($mint['short_url'])) {
|
||||||
error_log('ShortLinksRepository: outbound shorten failed');
|
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 {
|
private static function resolveOutboundBearerId(): int {
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ else
|
|||||||
git clone --branch "$BRANCH" --depth 1 "$ORIGIN" "$URL_ROOT"
|
git clone --branch "$BRANCH" --depth 1 "$ORIGIN" "$URL_ROOT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
apk add --no-cache libqrencode libqrencode-tools >/dev/null 2>&1 || true
|
||||||
|
|
||||||
chown -R nginx:nginx "$URL_ROOT" 2>/dev/null || 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)"
|
log "deploy-ac-url-shortener_ok $(host_short) $(git -C "$URL_ROOT" rev-parse --short HEAD)"
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ $ok = AuthMailer::sendVerifyEmail($to, $long);
|
|||||||
echo $ok ? "mail_ok" : "mail_fail";
|
echo $ok ? "mail_ok" : "mail_fail";
|
||||||
' 2>&1)" || _out="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
|
case "$_out" in
|
||||||
mail_ok) exit 0 ;;
|
mail_ok) exit 0 ;;
|
||||||
*) exit 1 ;;
|
*) exit 1 ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user