Skip to content

Instantly share code, notes, and snippets.

@wildstray
Created September 10, 2023 15:36
Show Gist options
  • Save wildstray/bd4e51ebf1da82e831bdf96495d49a61 to your computer and use it in GitHub Desktop.
Save wildstray/bd4e51ebf1da82e831bdf96495d49a61 to your computer and use it in GitHub Desktop.
Owncloud twofactor_totp 0.8.0 patch for using php-gd instead of php-imagick (BaconQrCode PR #114)
diff -urN twofactor_totp.orig/lib/Service/OtpGen.php twofactor_totp/lib/Service/OtpGen.php
--- twofactor_totp.orig/lib/Service/OtpGen.php 2023-07-29 15:23:22.000000000 +0200
+++ twofactor_totp/lib/Service/OtpGen.php 2023-09-10 12:15:29.411654688 +0200
@@ -17,9 +17,7 @@
namespace OCA\TwoFactor_Totp\Service;
-use BaconQrCode\Renderer\ImageRenderer;
-use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
-use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Renderer\GDLibRenderer;
use BaconQrCode\Writer;
use OCP\Defaults;
use OCP\IUser;
@@ -62,10 +60,7 @@
*/
public function generateOtpQR(IUser $user, string $secret) {
$data = $this->generateOtpUrl($user, $secret);
- $renderer = new ImageRenderer(
- new RendererStyle(170),
- new ImagickImageBackEnd()
- );
+ $renderer = new GDLibRenderer(280);
$writer = new Writer($renderer);
return 'data:image/png;base64,' . \base64_encode($writer->writeString($data));
}
diff -urN twofactor_totp.orig/lib/Service/OtpGen.php.bak twofactor_totp/lib/Service/OtpGen.php.bak
--- twofactor_totp.orig/lib/Service/OtpGen.php.bak 1970-01-01 01:00:00.000000000 +0100
+++ twofactor_totp/lib/Service/OtpGen.php.bak 2023-08-22 20:56:32.112281311 +0200
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * This code is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License, version 3,
+ * as published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License, version 3,
+ * along with this program. If not, see <http://www.gnu.org/licenses/>
+ *
+ */
+
+namespace OCA\TwoFactor_Totp\Service;
+
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
+use OCP\Defaults;
+use OCP\IUser;
+
+class OtpGen {
+ /** @var Defaults */
+ private $defaults;
+
+ public function __construct(Defaults $defaults) {
+ $this->defaults = $defaults;
+ }
+
+ /**
+ * Return an otpauth URI, for example "otpauth://totp/mySecretName?secret=ABCDEF&issuer=myself"
+ * @param IUser $user
+ * @param string $secret
+ * @return string the otpauth URI
+ */
+ public function generateOtpUrl(IUser $user, string $secret) {
+ $productName = $this->defaults->getName();
+ $userName = $user->getCloudId();
+
+ $secretName = \rawurlencode("$productName:$userName");
+ $issuer = \rawurlencode($productName);
+
+ return "otpauth://totp/$secretName?secret=$secret&issuer=$issuer";
+ }
+
+ /**
+ * Return a base64-encoded PNG image of the QR code containing the generated otpauth URI.
+ * The returned string will always contain the "data:image/png;base64," prefix, which is
+ * suitable to use in an "img" HTML tag
+ * ```
+ * $qr = $otpGen->genOtpQR($user, $secret);
+ * $img = "<img src=\"$qr\" />"
+ * ```
+ * @param IUser $user
+ * @param string $secret
+ * @return string a base64-encoded PNG image of the QR, with the "data:image/png;base64," prefix
+ */
+ public function generateOtpQR(IUser $user, string $secret) {
+ $data = $this->generateOtpUrl($user, $secret);
+ $renderer = new ImageRenderer(
+ new RendererStyle(170),
+ new ImagickImageBackEnd()
+ );
+ $writer = new Writer($renderer);
+ return 'data:image/png;base64,' . \base64_encode($writer->writeString($data));
+ }
+}
diff -urN twofactor_totp.orig/vendor/bacon/bacon-qr-code/README.md twofactor_totp/vendor/bacon/bacon-qr-code/README.md
--- twofactor_totp.orig/vendor/bacon/bacon-qr-code/README.md 2023-07-29 15:23:22.000000000 +0200
+++ twofactor_totp/vendor/bacon/bacon-qr-code/README.md 2023-09-10 12:28:50.029268968 +0200
@@ -37,3 +37,10 @@
- `ImagickImageBackEnd`: renders raster images using the Imagick library
- `SvgImageBackEnd`: renders SVG files using XMLWriter
- `EpsImageBackEnd`: renders EPS files
+
+### GDLib Renderer
+GD library has so many limitations, that GD support is not added as backend, but as separated rendered.
+Use `GDLibRendered` instead of `ImageRenderer`. Here are limitations:
+
+ - Does not support gradient.
+ - Does not support any curves, so you QR code is always squared.
diff -urN twofactor_totp.orig/vendor/bacon/bacon-qr-code/src/Renderer/GDLibRenderer.php twofactor_totp/vendor/bacon/bacon-qr-code/src/Renderer/GDLibRenderer.php
--- twofactor_totp.orig/vendor/bacon/bacon-qr-code/src/Renderer/GDLibRenderer.php 1970-01-01 01:00:00.000000000 +0100
+++ twofactor_totp/vendor/bacon/bacon-qr-code/src/Renderer/GDLibRenderer.php 2023-09-10 11:58:22.959542393 +0200
@@ -0,0 +1,281 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BaconQrCode\Renderer;
+
+use BaconQrCode\Encoder\ByteMatrix;
+use BaconQrCode\Encoder\MatrixUtil;
+use BaconQrCode\Encoder\QrCode;
+use BaconQrCode\Exception\InvalidArgumentException;
+use BaconQrCode\Exception\RuntimeException;
+use BaconQrCode\Renderer\Color\Alpha;
+use BaconQrCode\Renderer\Color\ColorInterface;
+use BaconQrCode\Renderer\RendererStyle\EyeFill;
+use BaconQrCode\Renderer\RendererStyle\Fill;
+use GdImage;
+
+final class GDLibRenderer implements RendererInterface
+{
+ /**
+ * @var int
+ */
+ private $size;
+
+ /**
+ * @var int
+ */
+ private $margin;
+
+ /**
+ * @var string
+ */
+ private $imageFormat;
+
+ /**
+ * @var int
+ */
+ private $compressionQuality;
+
+ /**
+ * @var Fill
+ */
+ private $fill;
+
+ /**
+ * @var GdImage
+ */
+ private $image;
+
+ /**
+ * @var array<string, int>
+ */
+ private $colors;
+
+ public function __construct(
+ int $size,
+ int $margin = 4,
+ string $imageFormat = 'png',
+ int $compressionQuality = 9,
+ ?Fill $fill = null
+ ) {
+ if (! extension_loaded('gd') || ! function_exists('gd_info')) {
+ throw new RuntimeException('You need to install the GD extension to use this back end');
+ }
+
+ $this->size = $size;
+ $this->margin = $margin;
+ $this->imageFormat = $imageFormat;
+ $this->compressionQuality = $compressionQuality;
+ $this->fill = $fill;
+ if ($this->fill === null) {
+ $this->fill = Fill::default();
+ }
+ if ($this->fill->hasGradientFill()) {
+ throw new InvalidArgumentException('GDLibRenderer does not support gradients');
+ }
+ }
+
+ /**
+ * @throws InvalidArgumentException if matrix width doesn't match height
+ */
+ public function render(QrCode $qrCode): string
+ {
+ $matrix = $qrCode->getMatrix();
+ $matrixSize = $matrix->getWidth();
+
+ if ($matrixSize !== $matrix->getHeight()) {
+ throw new InvalidArgumentException('Matrix must have the same width and height');
+ }
+
+ MatrixUtil::removePositionDetectionPatterns($matrix);
+ $this->newImage();
+ $this->draw($matrix);
+
+ return $this->renderImage();
+ }
+
+ private function newImage(): void
+ {
+ $img = imagecreatetruecolor($this->size, $this->size);
+ if ($img === false) {
+ throw new RuntimeException('Failed to create image of that size');
+ }
+
+ $this->image = $img;
+ imagealphablending($this->image, false);
+ imagesavealpha($this->image, true);
+
+
+ $bg = $this->getColor($this->fill->getBackgroundColor());
+ imagefilledrectangle($this->image, 0, 0, $this->size, $this->size, $bg);
+ imagealphablending($this->image, true);
+ }
+
+ private function draw(ByteMatrix $matrix): void
+ {
+ $matrixSize = $matrix->getWidth();
+
+ $pointsOnSide = $matrix->getWidth() + $this->margin * 2;
+ $pointInPx = $this->size / $pointsOnSide;
+
+ $this->drawEye(0, 0, $pointInPx, $this->fill->getTopLeftEyeFill());
+ $this->drawEye($matrixSize - 7, 0, $pointInPx, $this->fill->getTopRightEyeFill());
+ $this->drawEye(0, $matrixSize - 7, $pointInPx, $this->fill->getBottomLeftEyeFill());
+
+ $rows = $matrix->getArray()->toArray();
+ $color = $this->getColor($this->fill->getForegroundColor());
+ for ($y = 0; $y < $matrixSize; $y += 1) {
+ for ($x = 0; $x < $matrixSize; $x += 1) {
+ if (! $rows[$y][$x]) {
+ continue;
+ }
+
+ $points = $this->normalizePoints([
+ ($this->margin + $x) * $pointInPx, ($this->margin + $y) * $pointInPx,
+ ($this->margin + $x + 1) * $pointInPx, ($this->margin + $y) * $pointInPx,
+ ($this->margin + $x + 1) * $pointInPx, ($this->margin + $y + 1) * $pointInPx,
+ ($this->margin + $x) * $pointInPx, ($this->margin + $y + 1) * $pointInPx,
+ ]);
+ $this->imageFilledPolygon($points, $color);
+ }
+ }
+ }
+
+ private function drawEye(int $xOffset, int $yOffset, float $pointInPx, EyeFill $eyeFill): void
+ {
+ $internalColor = $this->getColor($eyeFill->inheritsInternalColor()
+ ? $this->fill->getForegroundColor()
+ : $eyeFill->getInternalColor());
+
+ $externalColor = $this->getColor($eyeFill->inheritsExternalColor()
+ ? $this->fill->getForegroundColor()
+ : $eyeFill->getExternalColor());
+
+ for ($y = 0; $y < 7; $y += 1) {
+ for ($x = 0; $x < 7; $x += 1) {
+ if ((($y === 1 || $y === 5) && $x > 0 && $x < 6) || (($x === 1 || $x === 5) && $y > 0 && $y < 6)) {
+ continue;
+ }
+
+ $points = $this->normalizePoints([
+ ($this->margin + $x + $xOffset) * $pointInPx, ($this->margin + $y + $yOffset) * $pointInPx,
+ ($this->margin + $x + $xOffset + 1) * $pointInPx, ($this->margin + $y + $yOffset) * $pointInPx,
+ ($this->margin + $x + $xOffset + 1) * $pointInPx, ($this->margin + $y + $yOffset + 1) * $pointInPx,
+ ($this->margin + $x + $xOffset) * $pointInPx, ($this->margin + $y + $yOffset + 1) * $pointInPx,
+ ]);
+
+ if ($y > 1 && $y < 5 && $x > 1 && $x < 5) {
+ $this->imageFilledPolygon($points, $internalColor);
+ } else {
+ $this->imageFilledPolygon($points, $externalColor);
+ }
+ }
+ }
+ }
+
+ /**
+ * Normalize points will trim right and bottom line by 1 pixel.
+ * Otherwise pixels of neighbors are overlapping which leads to issue with transparency and small QR codes.
+ */
+ private function normalizePoints(array $points): array
+ {
+ $maxX = $maxY = 0;
+ for ($i = 0; $i < count($points); $i += 2) {
+ // Do manual round as GD just removes decimal part
+ $points[$i] = $newX = round($points[$i]);
+ $points[$i + 1] = $newY = round($points[$i + 1]);
+
+ $maxX = max($maxX, $newX);
+ $maxY = max($maxY, $newY);
+ }
+
+ // Do trimming only if there are 4 points (8 coordinates), assumes this is square.
+
+ for ($i = 0; $i < count($points); $i += 2) {
+ $points[$i] = min($points[$i], $maxX - 1);
+ $points[$i + 1] = min($points[$i + 1], $maxY - 1);
+ }
+
+ return $points;
+ }
+
+ private function renderImage(): string
+ {
+ ob_start();
+ $quality = $this->compressionQuality;
+ switch ($this->imageFormat) {
+ case 'png':
+ if ($quality > 9 || $quality < 0) {
+ $quality = 9;
+ }
+ imagepng($this->image, null, $quality);
+ break;
+
+ case 'gif':
+ imagegif($this->image, null);
+ break;
+
+ case 'jpeg':
+ case 'jpg':
+ if ($quality > 100 || $quality < 0) {
+ $quality = 85;
+ }
+ imagejpeg($this->image, null, $quality);
+ break;
+ default:
+ ob_end_clean();
+ throw new InvalidArgumentException(
+ 'Supported image formats are jpeg, png and gif, got: ' . $this->imageFormat
+ );
+ }
+
+ imagedestroy($this->image);
+ $this->colors = [];
+ $this->image = null;
+
+ return ob_get_clean();
+ }
+
+ private function getColor(ColorInterface $color): int
+ {
+ $alpha = 100;
+
+ if ($color instanceof Alpha) {
+ $alpha = $color->getAlpha();
+ $color = $color->getBaseColor();
+ }
+
+ $rgb = $color->toRgb();
+
+ $colorKey = sprintf('%02X%02X%02X%02X', $rgb->getRed(), $rgb->getGreen(), $rgb->getBlue(), $alpha);
+
+ if (! isset($this->colors[$colorKey])) {
+ $colorId = imagecolorallocatealpha(
+ $this->image,
+ $rgb->getRed(),
+ $rgb->getGreen(),
+ $rgb->getBlue(),
+ (int)((100 - $alpha) / 100 * 127) // Alpha for GD is in range 0 (opaque) - 127 (transparent)
+ );
+
+ if ($colorId === false) {
+ throw new RuntimeException('Failed to create color: #' . $colorKey);
+ }
+
+ $this->colors[$colorKey] = $colorId;
+ }
+
+ return $this->colors[$colorKey];
+ }
+
+ private function imageFilledPolygon(array $points, int $color): bool
+ {
+ if (\PHP_VERSION_ID >= 80000) {
+ // PHP 8.0 supports this method without $num_points.
+ // And PHP 8.1 marks the other one as deprecated.
+ return imagefilledpolygon($this->image, $points, $color);
+ }
+ return imagefilledpolygon($this->image, $points, count($points) / 2, $color);
+ }
+}
diff -urN twofactor_totp.orig/vendor/bacon/bacon-qr-code/test/Integration/GDLibRenderingTest.php twofactor_totp/vendor/bacon/bacon-qr-code/test/Integration/GDLibRenderingTest.php
--- twofactor_totp.orig/vendor/bacon/bacon-qr-code/test/Integration/GDLibRenderingTest.php 1970-01-01 01:00:00.000000000 +0100
+++ twofactor_totp/vendor/bacon/bacon-qr-code/test/Integration/GDLibRenderingTest.php 2023-09-10 12:26:04.367279612 +0200
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+namespace BaconQrCodeTest\Integration;
+
+use BaconQrCode\Exception\InvalidArgumentException;
+use BaconQrCode\Exception\RuntimeException;
+use BaconQrCode\Renderer\Color\Alpha;
+use BaconQrCode\Renderer\Color\Rgb;
+use BaconQrCode\Renderer\Eye\EyeInterface;
+use BaconQrCode\Renderer\Eye\SimpleCircleEye;
+use BaconQrCode\Renderer\Eye\SquareEye;
+use BaconQrCode\Renderer\GDLibRenderer;
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\Image\GDImageBackEnd;
+use BaconQrCode\Renderer\Module\DotsModule;
+use BaconQrCode\Renderer\Module\RoundnessModule;
+use BaconQrCode\Renderer\RendererStyle\EyeFill;
+use BaconQrCode\Renderer\RendererStyle\Fill;
+use BaconQrCode\Renderer\RendererStyle\Gradient;
+use BaconQrCode\Renderer\RendererStyle\GradientType;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
+use PHPUnit\Framework\TestCase;
+use Spatie\Snapshots\MatchesSnapshots;
+
+/**
+ * @group integration
+ */
+final class GDLibRenderingTest extends TestCase
+{
+ use MatchesSnapshots;
+
+ /**
+ * @requires extension gd
+ */
+ public function testGenericQrCode(): void
+ {
+ $renderer = new GDLibRenderer(400);
+ $writer = new Writer($renderer);
+ $tempName = tempnam(sys_get_temp_dir(), 'test') . '.png';
+ $writer->writeFile('Hello World!', $tempName);
+
+ $this->assertMatchesFileSnapshot($tempName);
+ unlink($tempName);
+ }
+
+ /**
+ * @requires extension gd
+ */
+ public function testDifferentColorsQrCode(): void
+ {
+ $renderer = new GDLibRenderer(
+ 400,
+ 10,
+ 'png',
+ 9,
+ Fill::withForegroundColor(
+ new Alpha(25, new Rgb(0, 0, 0)),
+ new Rgb(0, 0, 0),
+ new EyeFill(new Rgb(220, 50, 50), new Alpha(50, new Rgb(220, 50, 50))),
+ new EyeFill(new Rgb(50, 220, 50), new Alpha(50, new Rgb(50, 220, 50))),
+ new EyeFill(new Rgb(50, 50, 220), new Alpha(50, new Rgb(50, 50, 220))),
+ )
+ );
+ $writer = new Writer($renderer);
+ $tempName = tempnam(sys_get_temp_dir(), 'test') . '.png';
+ $writer->writeFile('Hello World!', $tempName);
+
+ $this->assertMatchesFileSnapshot($tempName);
+ unlink($tempName);
+ }
+
+
+ /**
+ * @requires extension gd
+ */
+ public function testFailsOnGradient(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('GDLibRenderer does not support gradients');
+
+ new GDLibRenderer(
+ 400,
+ 10,
+ 'png',
+ 9,
+ Fill::withForegroundGradient(
+ new Alpha(25, new Rgb(0, 0, 0)),
+ new Gradient(new Rgb(255, 255, 0), new Rgb(255, 0, 255), GradientType::DIAGONAL()),
+ new EyeFill(new Rgb(220, 50, 50), new Alpha(50, new Rgb(220, 50, 50))),
+ new EyeFill(new Rgb(50, 220, 50), new Alpha(50, new Rgb(50, 220, 50))),
+ new EyeFill(new Rgb(50, 50, 220), new Alpha(50, new Rgb(50, 50, 220))),
+ )
+ );
+ }
+
+ /**
+ * @requires extension gd
+ */
+ public function testFailsOnInvalidFormat(): void
+ {
+ $renderer = new GDLibRenderer(400, 4, 'tiff');
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Supported image formats are jpeg, png and gif, got: tiff');
+
+ $writer = new Writer($renderer);
+ $tempName = tempnam(sys_get_temp_dir(), 'test') . '.png';
+ $writer->writeFile('Hello World!', $tempName);
+ }
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment