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 = '';
+ 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
+
+
+
+
+
+
+
+
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"
+ }
+}