542 lines
17 KiB
PHP
542 lines
17 KiB
PHP
<?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>
|