From e3f3101719a4f4d12ef7cf2646b364e83e96a177 Mon Sep 17 00:00:00 2001 From: marijo Date: Tue, 23 Jun 2026 13:19:06 +0000 Subject: [PATCH] Add configurable QR code page --- .gitignore | 3 + .htaccess | 6 + README.md | 43 +++- deploy.sh | 2 + index.html | 19 -- index.php | 542 ++++++++++++++++++++++++++++++++++++++++++++++ what.example.json | 15 ++ 7 files changed, 610 insertions(+), 20 deletions(-) create mode 100644 .htaccess delete mode 100644 index.html create mode 100644 index.php create mode 100644 what.example.json diff --git a/.gitignore b/.gitignore index c317064..5974529 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ AGENTS.md + +# Local/server QR data; may contain secrets such as Wi-Fi passwords +what.json diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..2806c66 --- /dev/null +++ b/.htaccess @@ -0,0 +1,6 @@ +Options -Indexes +DirectoryIndex index.php + + + Require all denied + diff --git a/README.md b/README.md index a673ba3..b711975 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,52 @@ # qr -Static webpage intended to be hosted at: +QR-code webpage intended to be hosted at: ```text 192.168.0.226:/var/www/html/qr ``` +Open QR codes with: + +```text +http://192.168.0.226/qr?what=wifi +http://192.168.0.226/qr?what=haguest +``` + +If `what` is not defined in `what.json`, the page returns `404 Not Found`. + +## QR data + +Copy the example on the web server: + +```bash +cp what.example.json what.json +``` + +Edit `what.json` on the server. It is ignored by git and excluded from deploys because it may contain secrets like Wi-Fi passwords. + +Supported entries: + +```json +{ + "wifi": { + "type": "wifi", + "label": "Home Wi-Fi", + "ssid": "Your WiFi name", + "password": "Your WiFi password", + "security": "WPA", + "hidden": false + }, + "haguest": { + "type": "url", + "label": "HA Guest", + "url": "http://192.168.0.226/haguest" + } +} +``` + +The key name, such as `wifi` or `haguest`, is shown in the centre of the QR code. + ## Deploy ```bash diff --git a/deploy.sh b/deploy.sh index f025627..624ef1b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -7,7 +7,9 @@ DEST="${QR_DEPLOY_DEST:-/var/www/html/qr}" rsync -av --delete \ --exclude '.git/' \ --exclude 'AGENTS.md' \ + --exclude 'what.json' \ --exclude '.DS_Store' \ ./ "${HOST}:${DEST}/" echo "Deployed to ${HOST}:${DEST}" +echo "Remember to create/update ${DEST}/what.json on the server." diff --git a/index.html b/index.html deleted file mode 100644 index 2f9eaea..0000000 --- a/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - QR - - - -
-

QR

-

QR webpage placeholder.

-
- - diff --git a/index.php b/index.php new file mode 100644 index 0000000..a7a5b37 --- /dev/null +++ b/index.php @@ -0,0 +1,542 @@ + '\\\\', + ';' => '\\;', + ',' => '\\,', + ':' => '\\:', + '"' => '\\"', + ]); +} + +function qr_entry_to_payload(string $key, mixed $entry): ?array +{ + if (is_string($entry)) { + return [ + 'payload' => $entry, + 'title' => $key, + 'subtitle' => 'Scan this QR code.', + 'link' => filter_var($entry, FILTER_VALIDATE_URL) ? $entry : null, + ]; + } + + if (!is_array($entry)) { + return null; + } + + $type = strtolower((string)($entry['type'] ?? '')); + $label = (string)($entry['label'] ?? $key); + + if ($type === 'wifi' || isset($entry['ssid'])) { + $ssid = (string)($entry['ssid'] ?? ''); + if ($ssid === '') { + return null; + } + + $security = strtoupper((string)($entry['security'] ?? $entry['encryption'] ?? 'WPA')); + if (in_array($security, ['', 'NONE', 'OPEN', 'NOPASS'], true)) { + $security = 'nopass'; + } + + $hidden = !empty($entry['hidden']) ? 'true' : 'false'; + $payload = 'WIFI:T:' . wifi_escape($security) . ';S:' . wifi_escape($ssid) . ';'; + if ($security !== 'nopass') { + $payload .= 'P:' . wifi_escape((string)($entry['password'] ?? '')) . ';'; + } + $payload .= 'H:' . $hidden . ';;'; + + return [ + 'payload' => $payload, + 'title' => $label, + 'subtitle' => 'Scan to connect to ' . $ssid, + 'link' => null, + ]; + } + + $payload = $entry['url'] ?? $entry['text'] ?? $entry['payload'] ?? null; + if (!is_string($payload) || $payload === '') { + return null; + } + + return [ + 'payload' => $payload, + 'title' => $label, + 'subtitle' => filter_var($payload, FILTER_VALIDATE_URL) ? $payload : 'Scan this QR code.', + 'link' => filter_var($payload, FILTER_VALIDATE_URL) ? $payload : null, + ]; +} + +final class QrCode +{ + private const ECC_CODEWORDS_PER_BLOCK = [1 => 10, 2 => 16, 3 => 26, 4 => 18, 5 => 24, 6 => 16, 7 => 18, 8 => 22, 9 => 22, 10 => 26]; + private const DATA_CODEWORDS_PER_BLOCK = [ + 1 => [16], + 2 => [28], + 3 => [44], + 4 => [32, 32], + 5 => [43, 43], + 6 => [27, 27, 27, 27], + 7 => [31, 31, 31, 31], + 8 => [38, 38, 39, 39], + 9 => [36, 36, 36, 37, 37], + 10 => [43, 43, 43, 43, 44], + ]; + private const ALIGNMENT_POSITIONS = [ + 1 => [], + 2 => [6, 18], + 3 => [6, 22], + 4 => [6, 26], + 5 => [6, 30], + 6 => [6, 34], + 7 => [6, 22, 38], + 8 => [6, 24, 42], + 9 => [6, 26, 46], + 10 => [6, 28, 50], + ]; + + /** @var array */ + private static array $gfExp = []; + /** @var array */ + private static array $gfLog = []; + + private int $version; + private int $size; + /** @var array> */ + private array $modules; + /** @var array> */ + private array $isFunction; + + private function __construct(int $version) + { + $this->version = $version; + $this->size = 21 + 4 * ($version - 1); + $row = array_fill(0, $this->size, false); + $this->modules = array_fill(0, $this->size, $row); + $this->isFunction = array_fill(0, $this->size, $row); + } + + public static function fromText(string $text): self + { + $bytes = array_values(unpack('C*', $text) ?: []); + $version = self::chooseVersion(count($bytes)); + $qr = new self($version); + $qr->drawFunctionPatterns(); + $dataCodewords = $qr->makeDataCodewords($bytes); + $allCodewords = self::addEccAndInterleave($version, $dataCodewords); + $qr->drawCodewords($allCodewords, 0); + $qr->drawFormatBits(0); + return $qr; + } + + public function toSvg(string $centerText = '', int $border = 4): string + { + $fullSize = $this->size + $border * 2; + $svg = ''; + $svg .= ''; + $svg .= ''; + for ($y = 0; $y < $this->size; $y++) { + for ($x = 0; $x < $this->size; $x++) { + if ($this->modules[$y][$x]) { + $svg .= ''; + } + } + } + $svg .= ''; + + if ($centerText !== '') { + $safeText = htmlspecialchars($centerText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $chars = max(1, strlen($centerText)); + $badgeWidth = min($this->size * 0.48, max($this->size * 0.25, $chars * 1.15 + 4)); + $badgeHeight = max(4.8, min(6.2, $this->size * 0.18)); + $x = ($fullSize - $badgeWidth) / 2; + $y = ($fullSize - $badgeHeight) / 2; + $fontSize = min(3.2, max(2.1, ($badgeWidth - 2) / ($chars * 0.62))); + $svg .= ''; + $svg .= '' . $safeText . ''; + } + + $svg .= ''; + return $svg; + } + + private static function chooseVersion(int $byteLength): int + { + for ($version = 1; $version <= 10; $version++) { + $countBits = $version <= 9 ? 8 : 16; + $capacityBits = array_sum(self::DATA_CODEWORDS_PER_BLOCK[$version]) * 8; + if (4 + $countBits + $byteLength * 8 <= $capacityBits) { + return $version; + } + } + fail(413, 'QR payload is too long.'); + } + + /** @param array $bytes @return array */ + private function makeDataCodewords(array $bytes): array + { + $capacityBits = array_sum(self::DATA_CODEWORDS_PER_BLOCK[$this->version]) * 8; + $bits = []; + self::appendBits($bits, 0b0100, 4); // Byte mode + self::appendBits($bits, count($bytes), $this->version <= 9 ? 8 : 16); + foreach ($bytes as $byte) { + self::appendBits($bits, $byte, 8); + } + + $terminator = min(4, $capacityBits - count($bits)); + for ($i = 0; $i < $terminator; $i++) { + $bits[] = 0; + } + while (count($bits) % 8 !== 0) { + $bits[] = 0; + } + + $result = []; + for ($i = 0; $i < count($bits); $i += 8) { + $byte = 0; + for ($j = 0; $j < 8; $j++) { + $byte = ($byte << 1) | $bits[$i + $j]; + } + $result[] = $byte; + } + + $pad = [0xEC, 0x11]; + for ($i = 0; count($result) < intdiv($capacityBits, 8); $i++) { + $result[] = $pad[$i % 2]; + } + return $result; + } + + /** @param array $bits */ + private static function appendBits(array &$bits, int $value, int $length): void + { + for ($i = $length - 1; $i >= 0; $i--) { + $bits[] = ($value >> $i) & 1; + } + } + + /** @param array $data @return array */ + private static function addEccAndInterleave(int $version, array $data): array + { + $blockLengths = self::DATA_CODEWORDS_PER_BLOCK[$version]; + $eccLength = self::ECC_CODEWORDS_PER_BLOCK[$version]; + $divisor = self::reedSolomonDivisor($eccLength); + $blocks = []; + $offset = 0; + foreach ($blockLengths as $length) { + $blockData = array_slice($data, $offset, $length); + $offset += $length; + $blocks[] = ['data' => $blockData, 'ecc' => self::reedSolomonRemainder($blockData, $divisor)]; + } + + $result = []; + $maxData = max($blockLengths); + for ($i = 0; $i < $maxData; $i++) { + foreach ($blocks as $block) { + if ($i < count($block['data'])) { + $result[] = $block['data'][$i]; + } + } + } + for ($i = 0; $i < $eccLength; $i++) { + foreach ($blocks as $block) { + $result[] = $block['ecc'][$i]; + } + } + return $result; + } + + /** @return array */ + private static function reedSolomonDivisor(int $degree): array + { + self::initGalois(); + $result = array_fill(0, $degree, 0); + $result[$degree - 1] = 1; + $root = 1; + for ($i = 0; $i < $degree; $i++) { + for ($j = 0; $j < $degree; $j++) { + $result[$j] = self::gfMultiply($result[$j], $root); + if ($j + 1 < $degree) { + $result[$j] ^= $result[$j + 1]; + } + } + $root = self::gfMultiply($root, 0x02); + } + return $result; + } + + /** @param array $data @param array $divisor @return array */ + private static function reedSolomonRemainder(array $data, array $divisor): array + { + $result = array_fill(0, count($divisor), 0); + foreach ($data as $byte) { + $factor = $byte ^ $result[0]; + array_shift($result); + $result[] = 0; + foreach ($divisor as $i => $coefficient) { + $result[$i] ^= self::gfMultiply($coefficient, $factor); + } + } + return $result; + } + + private static function initGalois(): void + { + if (self::$gfExp !== []) { + return; + } + $x = 1; + for ($i = 0; $i < 255; $i++) { + self::$gfExp[$i] = $x; + self::$gfLog[$x] = $i; + $x <<= 1; + if (($x & 0x100) !== 0) { + $x ^= 0x11D; + } + } + for ($i = 255; $i < 512; $i++) { + self::$gfExp[$i] = self::$gfExp[$i - 255]; + } + } + + private static function gfMultiply(int $x, int $y): int + { + if ($x === 0 || $y === 0) { + return 0; + } + return self::$gfExp[self::$gfLog[$x] + self::$gfLog[$y]]; + } + + private function drawFunctionPatterns(): void + { + $this->drawFinderPattern(3, 3); + $this->drawFinderPattern($this->size - 4, 3); + $this->drawFinderPattern(3, $this->size - 4); + + for ($i = 0; $i < $this->size; $i++) { + if (!$this->isFunction[6][$i]) { + $this->setFunctionModule($i, 6, $i % 2 === 0); + } + if (!$this->isFunction[$i][6]) { + $this->setFunctionModule(6, $i, $i % 2 === 0); + } + } + + foreach (self::ALIGNMENT_POSITIONS[$this->version] as $x) { + foreach (self::ALIGNMENT_POSITIONS[$this->version] as $y) { + if (!$this->isFunction[$y][$x]) { + $this->drawAlignmentPattern($x, $y); + } + } + } + + $this->drawFormatBits(0); + if ($this->version >= 7) { + $this->drawVersionBits(); + } + } + + private function drawFinderPattern(int $cx, int $cy): void + { + for ($dy = -4; $dy <= 4; $dy++) { + for ($dx = -4; $dx <= 4; $dx++) { + $x = $cx + $dx; + $y = $cy + $dy; + if ($x < 0 || $x >= $this->size || $y < 0 || $y >= $this->size) { + continue; + } + $dist = max(abs($dx), abs($dy)); + $this->setFunctionModule($x, $y, $dist !== 2 && $dist !== 4); + } + } + } + + private function drawAlignmentPattern(int $cx, int $cy): void + { + for ($dy = -2; $dy <= 2; $dy++) { + for ($dx = -2; $dx <= 2; $dx++) { + $this->setFunctionModule($cx + $dx, $cy + $dy, max(abs($dx), abs($dy)) !== 1); + } + } + } + + private function drawFormatBits(int $mask): void + { + $data = $mask; // Medium ECC has format bits 00. + $rem = $data; + for ($i = 0; $i < 10; $i++) { + $rem = ($rem << 1) ^ ((($rem >> 9) & 1) * 0x537); + } + $bits = (($data << 10) | ($rem & 0x3FF)) ^ 0x5412; + + for ($i = 0; $i <= 5; $i++) { + $this->setFunctionModule(8, $i, (($bits >> $i) & 1) !== 0); + } + $this->setFunctionModule(8, 7, (($bits >> 6) & 1) !== 0); + $this->setFunctionModule(8, 8, (($bits >> 7) & 1) !== 0); + $this->setFunctionModule(7, 8, (($bits >> 8) & 1) !== 0); + for ($i = 9; $i < 15; $i++) { + $this->setFunctionModule(14 - $i, 8, (($bits >> $i) & 1) !== 0); + } + + for ($i = 0; $i < 8; $i++) { + $this->setFunctionModule($this->size - 1 - $i, 8, (($bits >> $i) & 1) !== 0); + } + for ($i = 8; $i < 15; $i++) { + $this->setFunctionModule(8, $this->size - 15 + $i, (($bits >> $i) & 1) !== 0); + } + $this->setFunctionModule(8, $this->size - 8, true); + } + + private function drawVersionBits(): void + { + $rem = $this->version; + for ($i = 0; $i < 12; $i++) { + $rem = ($rem << 1) ^ ((($rem >> 11) & 1) * 0x1F25); + } + $bits = ($this->version << 12) | ($rem & 0xFFF); + for ($i = 0; $i < 18; $i++) { + $bit = (($bits >> $i) & 1) !== 0; + $a = $this->size - 11 + ($i % 3); + $b = intdiv($i, 3); + $this->setFunctionModule($a, $b, $bit); + $this->setFunctionModule($b, $a, $bit); + } + } + + /** @param array $codewords */ + private function drawCodewords(array $codewords, int $mask): void + { + $bits = []; + foreach ($codewords as $byte) { + self::appendBits($bits, $byte, 8); + } + + $i = 0; + $upward = true; + for ($right = $this->size - 1; $right >= 1; $right -= 2) { + if ($right === 6) { + $right = 5; + } + for ($vert = 0; $vert < $this->size; $vert++) { + $y = $upward ? $this->size - 1 - $vert : $vert; + for ($j = 0; $j < 2; $j++) { + $x = $right - $j; + if ($this->isFunction[$y][$x]) { + continue; + } + $bit = $i < count($bits) && $bits[$i] === 1; + $i++; + if ($this->maskBit($mask, $x, $y)) { + $bit = !$bit; + } + $this->modules[$y][$x] = $bit; + } + } + $upward = !$upward; + } + } + + private function maskBit(int $mask, int $x, int $y): bool + { + return match ($mask) { + 0 => (($x + $y) % 2) === 0, + default => false, + }; + } + + private function setFunctionModule(int $x, int $y, bool $dark): void + { + $this->modules[$y][$x] = $dark; + $this->isFunction[$y][$x] = true; + } +} + +$what = (string)($_GET['what'] ?? ''); +if ($what === '') { + fail(400, 'Missing required query parameter: what'); +} +if (!preg_match('/^[A-Za-z0-9_.-]+$/', $what)) { + not_found(); +} +if (!is_readable(DATA_FILE)) { + not_found(); +} + +$config = json_decode((string)file_get_contents(DATA_FILE), true); +if (!is_array($config)) { + fail(500, 'Invalid what.json'); +} +if (!array_key_exists($what, $config)) { + not_found(); +} + +$entry = qr_entry_to_payload($what, $config[$what]); +if ($entry === null) { + not_found(); +} + +$qr = QrCode::fromText($entry['payload']); +$title = (string)$entry['title']; +$subtitle = (string)$entry['subtitle']; +$link = is_string($entry['link']) ? $entry['link'] : null; + +header('Content-Type: text/html; charset=utf-8'); +?> + + + + + + <?= h($title) ?> QR + + + +
+
+

+

+
toSvg($what) ?>
+
+
+ + diff --git a/what.example.json b/what.example.json new file mode 100644 index 0000000..087510b --- /dev/null +++ b/what.example.json @@ -0,0 +1,15 @@ +{ + "wifi": { + "type": "wifi", + "label": "Home Wi-Fi", + "ssid": "Your WiFi name", + "password": "Your WiFi password", + "security": "WPA", + "hidden": false + }, + "haguest": { + "type": "url", + "label": "HA Guest", + "url": "http://192.168.0.226/haguest" + } +}