Add configurable QR code page

This commit is contained in:
marijo 2026-06-23 13:19:06 +00:00
parent c4afd3c253
commit e3f3101719
7 changed files with 610 additions and 20 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
AGENTS.md
# Local/server QR data; may contain secrets such as Wi-Fi passwords
what.json

6
.htaccess Normal file
View file

@ -0,0 +1,6 @@
Options -Indexes
DirectoryIndex index.php
<FilesMatch "^(what\.json|AGENTS\.md|deploy\.sh)$">
Require all denied
</FilesMatch>

View file

@ -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

View file

@ -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."

View file

@ -1,19 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; }
main { text-align: center; padding: 2rem; }
</style>
</head>
<body>
<main>
<h1>QR</h1>
<p>QR webpage placeholder.</p>
</main>
</body>
</html>

542
index.php Normal file
View file

@ -0,0 +1,542 @@
<?php
declare(strict_types=1);
const DATA_FILE = __DIR__ . '/what.json';
function not_found(): never
{
http_response_code(404);
header('Content-Type: text/plain; charset=utf-8');
echo "404 Not Found\n";
exit;
}
function fail(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message . "\n";
exit;
}
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function wifi_escape(string $value): string
{
return strtr($value, [
'\\' => '\\\\',
';' => '\\;',
',' => '\\,',
':' => '\\:',
'"' => '\\"',
]);
}
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<int,int> */
private static array $gfExp = [];
/** @var array<int,int> */
private static array $gfLog = [];
private int $version;
private int $size;
/** @var array<int,array<int,bool>> */
private array $modules;
/** @var array<int,array<int,bool>> */
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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $fullSize . ' ' . $fullSize . '" role="img" aria-label="QR code">';
$svg .= '<rect width="100%" height="100%" rx="2" fill="#fff"/>';
$svg .= '<g fill="#111">';
for ($y = 0; $y < $this->size; $y++) {
for ($x = 0; $x < $this->size; $x++) {
if ($this->modules[$y][$x]) {
$svg .= '<rect x="' . ($x + $border) . '" y="' . ($y + $border) . '" width="1" height="1" rx="0.18"/>';
}
}
}
$svg .= '</g>';
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 .= '<rect x="' . $x . '" y="' . $y . '" width="' . $badgeWidth . '" height="' . $badgeHeight . '" rx="' . ($badgeHeight / 2) . '" fill="#fff" stroke="#111" stroke-width="0.45"/>';
$svg .= '<text x="' . ($fullSize / 2) . '" y="' . ($fullSize / 2) . '" fill="#111" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="' . $fontSize . '" font-weight="800" text-anchor="middle" dominant-baseline="central">' . $safeText . '</text>';
}
$svg .= '</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<int,int> $bytes @return array<int,int> */
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<int,int> $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<int,int> $data @return array<int,int> */
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<int,int> */
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<int,int> $data @param array<int,int> $divisor @return array<int,int> */
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<int,int> $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');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= h($title) ?> QR</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5f5f5; color: #111; }
main { width: min(34rem, calc(100vw - 2rem)); text-align: center; padding: 1rem; }
.card { background: #fff; border-radius: 1.5rem; box-shadow: 0 1rem 3rem rgb(0 0 0 / 12%); padding: clamp(1.25rem, 5vw, 2.5rem); }
h1 { margin: 0 0 .5rem; font-size: clamp(1.75rem, 8vw, 3rem); }
p { margin: .5rem 0 1.5rem; overflow-wrap: anywhere; }
a { color: inherit; }
.qr { margin: auto; max-width: 24rem; }
.qr svg { display: block; width: 100%; height: auto; }
@media (prefers-color-scheme: dark) {
body { background: #111; color: #f5f5f5; }
.card { background: #1d1d1d; }
}
</style>
</head>
<body>
<main>
<section class="card">
<h1><?= h($title) ?></h1>
<p><?php if ($link !== null): ?><a href="<?= h($link) ?>"><?= h($subtitle) ?></a><?php else: ?><?= h($subtitle) ?><?php endif; ?></p>
<div class="qr"><?= $qr->toSvg($what) ?></div>
</section>
</main>
</body>
</html>

15
what.example.json Normal file
View file

@ -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"
}
}