1
0
mirror of git://f0xx.org/ac/ac-platform-php synced 2026-07-29 00:57:39 +03:00

Add branded QR renderer with centered app logo overlay.

High error correction qrencode plus GD logo plate for 2FA and short-link QR PNGs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 14:21:10 +02:00
parent dbe0a83636
commit 4f4a2f9ea0
3 changed files with 121 additions and 0 deletions

BIN
assets/ac_qr_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

120
src/QrBrandedRenderer.php Normal file
View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
/** Branded QR PNG: high error correction + centered app logo with white border. */
final class QrBrandedRenderer {
private const LOGO_FRACTION = 0.22;
private const BORDER_FRACTION = 0.045;
private function __construct() {
}
public static function renderPng(string $text, int $size = 256): ?string {
$text = trim($text);
if ($text === '') {
return null;
}
$raw = self::rawQrPng($text, $size);
if ($raw === null) {
return null;
}
$branded = self::overlayLogo($raw);
return $branded ?? $raw;
}
public static function dataUri(string $text, int $size = 256): ?string {
$png = self::renderPng($text, $size);
if ($png === null) {
return null;
}
return 'data:image/png;base64,' . base64_encode($png);
}
/** @return list<string> */
private static function logoCandidates(): array {
return [
__DIR__ . '/../public/assets/ac_qr_logo.png',
__DIR__ . '/../assets/ac_qr_logo.png',
];
}
private static function logoPath(): ?string {
foreach (self::logoCandidates() as $path) {
if (is_readable($path)) {
return $path;
}
}
return null;
}
private static function rawQrPng(string $text, int $size): ?string {
$qrencode = trim((string) shell_exec('command -v qrencode 2>/dev/null') ?? '');
if ($qrencode === '') {
return null;
}
$size = max(128, min(512, $size));
$moduleSize = max(3, (int) round($size / 33));
$cmd = sprintf(
'%s -l H -t PNG -s %d -m 2 -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 overlayLogo(string $pngBytes): ?string {
if (!extension_loaded('gd')) {
return null;
}
$logoPath = self::logoPath();
if ($logoPath === null) {
return null;
}
$qr = @imagecreatefromstring($pngBytes);
if ($qr === false) {
return null;
}
$logo = @imagecreatefrompng($logoPath);
if ($logo === false) {
imagedestroy($qr);
return null;
}
$qrW = imagesx($qr);
$qrH = imagesy($qr);
if ($qrW < 32 || $qrH < 32) {
imagedestroy($qr);
imagedestroy($logo);
return null;
}
$logoBox = (int) round(min($qrW, $qrH) * self::LOGO_FRACTION);
$border = max(2, (int) round(min($qrW, $qrH) * self::BORDER_FRACTION));
$plate = $logoBox + 2 * $border;
$plateX = (int) floor(($qrW - $plate) / 2);
$plateY = (int) floor(($qrH - $plate) / 2);
$white = imagecolorallocate($qr, 255, 255, 255);
imagefilledrectangle($qr, $plateX, $plateY, $plateX + $plate - 1, $plateY + $plate - 1, $white);
$srcW = imagesx($logo);
$srcH = imagesy($logo);
$dstX = $plateX + $border;
$dstY = $plateY + $border;
imagecopyresampled($qr, $logo, $dstX, $dstY, 0, 0, $logoBox, $logoBox, $srcW, $srcH);
ob_start();
imagepng($qr);
$out = ob_get_clean();
imagedestroy($qr);
imagedestroy($logo);
if (!is_string($out) || strlen($out) < 8) {
return null;
}
return $out;
}
}

View File

@@ -76,6 +76,7 @@ require_once __DIR__ . '/WireGuardTrafficStats.php';
require_once __DIR__ . '/RsshSessionProvisioner.php';
require_once __DIR__ . '/RsshBastionProvisioner.php';
require_once __DIR__ . '/AuthTwoFactorPage.php';
require_once __DIR__ . '/QrBrandedRenderer.php';
require_once __DIR__ . '/AnalyticsHead.php';
function cfg(string $key, $default = null) {