commit 918ca9cb2c77dca2a0a69ee834e49ee14c7728c9 Author: Lorenz Stechauner <lorenz.stechauner@necronda.net> Date: Sat May 3 16:15:32 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..541957b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +credentials.* +!*.sample.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..61cc7b8 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ + +# Elwig synchronization backend + +https://sync.elwig.at/ diff --git a/www/.data/.gitkeep b/www/.data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/www/.php/auth.inc b/www/.php/auth.inc new file mode 100644 index 0000000..8de6c1b --- /dev/null +++ b/www/.php/auth.inc @@ -0,0 +1,22 @@ +<?php +require "credentials.inc"; + +function http_401_unauthorized(): void { + header('Status: 401'); + header('WWW-Authenticate: Basic realm="Elwig"'); + header('Content-Type: text/plain; charset=UTF-8'); + header('Content-Length: 17'); + exit("401 Unauthorized\n"); +} + +function authenticate(string $client): void { + global $CREDENTIALS; + $credentials = $CREDENTIALS[$client]; + if (!array_key_exists('PHP_AUTH_USER', $_SERVER) || + !array_key_exists('PHP_AUTH_PW', $_SERVER) || + !array_key_exists($_SERVER['PHP_AUTH_USER'], $credentials) || + $_SERVER['PHP_AUTH_PW'] !== $credentials[$_SERVER['PHP_AUTH_USER']]) + { + http_401_unauthorized(); + } +} diff --git a/www/.php/credentials.sample.inc b/www/.php/credentials.sample.inc new file mode 100644 index 0000000..b84458d --- /dev/null +++ b/www/.php/credentials.sample.inc @@ -0,0 +1,8 @@ +<?php +global $CREDENTIALS; + +$CREDENTIALS = [ + 'name' => [ + 'username' => 'password', + ], +]; diff --git a/www/.php/format.inc b/www/.php/format.inc new file mode 100644 index 0000000..f808ed0 --- /dev/null +++ b/www/.php/format.inc @@ -0,0 +1,50 @@ +<?php + +date_default_timezone_set('Europe/Vienna'); + +function get_fmt(): string { + $fmt = _get_fmt(); + if ($fmt === 'ascii') { + header('Status: 303'); + header('Location: ?format=text'); + header('Content-Length: 14'); + exit("303 See Other\n"); + } else if ($fmt !== 'json' && $fmt !== 'html' && $fmt !== 'text') { + header('Status: 300'); + header('Content-Type: text/html; charset=UTF-8'); + header('Content-Length: 162'); + echo "<!DOCTYPE html><html><head></head><body>\n<a href='?format=html'>HTML</a><br/>\n<a href='?format=json'>JSON</a><br/>\n<a href='?format=text'>Text</a>\n</body></html>\n"; + exit(); + } + return $fmt; +} + +function _get_fmt(): string { + if (!empty($_GET['format'])) return $_GET['format']; + + $fmts = []; + foreach (explode(',', $_SERVER['HTTP_ACCEPT']) as $acc) { + $acc = explode(';', trim($acc)); + $q = 1; + if (sizeof($acc) > 1) { + $qv = trim($acc[1]); + if (str_starts_with($qv, 'q=')) { + $q = (double)substr($qv, 2); + } + } + $fmts[trim($acc[0])] = $q; + } + arsort($fmts, SORT_NUMERIC); + array_filter($fmts, function($k) { + return str_contains($k, '/json') || $k === 'text/html' || $k === 'text/plain' || str_contains($k, 'text/*'); + }); + + $type = sizeof($fmts) > 0 ? array_key_first($fmts) : null; + if (str_contains($type, '/json')) { + return 'json'; + } else if ($type === 'text/plain') { + return 'text'; + } else { + return 'html'; + } +} diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..985b599 Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/index.php b/www/index.php new file mode 100644 index 0000000..bddbf60 --- /dev/null +++ b/www/index.php @@ -0,0 +1,276 @@ +<?php +require ".php/format.inc"; +require ".php/auth.inc"; +require ".php/credentials.inc"; +global $CREDENTIALS; + +$clients = array_keys($CREDENTIALS); + +$format = get_fmt(); + +function get_zip_meta($path): array { + $meta = 'null'; + $fp = fopen($path, "rb"); + + $zipHdr1 = fread($fp, 30); + if (strlen($zipHdr1) !== 30 || !str_starts_with($zipHdr1, "PK\x03\x04") || + $zipHdr1[8] !== "\x00" || $zipHdr1[9] !== "\x00" || $zipHdr1[26] !== "\x07" || $zipHdr1[27] !== "\x00") + { + fclose($fp); + return array($meta, null, null); + } + + $extraFieldLen1 = unpack("v", substr($zipHdr1, 28, 2))[1]; + $name1 = fread($fp, 7); + fseek($fp, $extraFieldLen1, SEEK_CUR); + $data1 = fread($fp, unpack("V", substr($zipHdr1, 18, 4))[1]); + if ($name1 !== "version" || !str_starts_with($data1, "elwig:")) { + fclose($fp); + return array($meta, null, null); + } + $version = (int)substr($data1, 6); + + $zipHdr2 = fread($fp, 30); + if (strlen($zipHdr2) !== 30 || !str_starts_with($zipHdr2, "PK\x03\x04") || + $zipHdr2[8] !== "\x00" || $zipHdr2[9] !== "\x00" || $zipHdr2[26] !== "\x09" || $zipHdr2[27] !== "\x00") + { + fclose($fp); + return array($meta, $version, null); + } + + $extraFieldLen2 = unpack("v", substr($zipHdr2, 28, 2))[1]; + $name2 = fread($fp, 9); + fseek($fp, $extraFieldLen2, SEEK_CUR); + if ($name2 !== "meta.json") { + fclose($fp); + return array($meta, $version, null); + } + + $meta = fread($fp, unpack("V", substr($zipHdr2, 18, 4))[1]); + $files = "{"; + $first = true; + while (!feof($fp)) { + $zipHdr3 = fread($fp, 30); + if (strlen($zipHdr3) !== 30 || !str_starts_with($zipHdr3, "PK\x03\x04")) + continue; + $compSize = unpack("V", substr($zipHdr3, 18, 4))[1]; + $uncompSize = unpack("V", substr($zipHdr3, 22, 4))[1]; + $name = fread($fp, unpack("v", substr($zipHdr3, 26, 2))[1]); + $crc = unpack("V", substr($zipHdr3, 14, 4))[1]; + fseek($fp, $compSize, SEEK_CUR); + $hex = substr("00000000" . dechex($crc), -8); + if (!$first) $files .= ", "; + $files .= "\"$name\": {\"compressed_size\": $compSize, \"uncompressed_size\": $uncompSize, \"crc32\": \"$hex\"}"; + $first = false; + } + $files .= "}"; + + fclose($fp); + return array($meta, $version, $files); +} + +$path = $_SERVER['PATH_INFO']; +if ($path == '') { + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + header('Status: 405'); + header('Allow: GET'); + if ($format === 'text') { + header('Content-Type: text/plain; charset=UTF-8'); + header('Content-Length: 23'); + echo "405 Method Not Allowed\n"; + } else if ($format === 'json') { + header('Content-Type: application/json; charset=UTF-8'); + header('Content-Length: 48'); + echo "{\"errors\": [{\"message\": \"Method not allowed\"}]}\n"; + } else { + header('Content-Type: text/html; charset=UTF-8'); + header('Content-Length: 0'); + } + exit(); + } + + if ($format === 'text') { + header('Content-Type: text/plain; charset=UTF-8'); + foreach ($clients as $c) + echo "$c\n"; + } else if ($format === 'json') { + header('Content-Type: application/json; charset=UTF-8'); + echo "{\"data\": ["; + $first = true; + foreach ($clients as $c) { + if (!$first) echo ","; + echo "\n {\"name\": \"$c\"}"; + $first = false; + } + echo "\n]}\n"; + } else if ($format === 'html') { + header('Content-Type: application/xhtml+xml; charset=UTF-8'); + echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"; + ?> + <html xmlns="http://www.w3.org/1999/xhtml" lang="de"> + <head> + <meta charset="UTF-8"/> + <title>Mandanten - Elwig</title> + <meta name="description" content="Elektronische Winzergenossenschaftsverwaltung"/> + <link rel="icon" href="/favicon.ico" sizes="16x16 20x20 24x24 30x30 32x32 36x36 40x40 48x48 60x60 64x64 72x72 80x80 96x96 128x128 256x256"/> + <link rel="stylesheet" href="/res/style.css"/> + <meta name="viewport" content="width=device-width,initial-scale=1.0"/> + </head> + <body> + <main> + <section> + <h3>Mandanten</h3> + <p class="center"><a href="https://elwig.at/">Homepage</a></p> + <table> + <thead><tr><th>Name</th></tr></thead> + <tbody> + <?php foreach ($clients as $c) { + echo " <tr><td><a href='$c'>$c</a></td></tr>\n"; + } ?> + </tbody> + </table> + <p class="center"><a href="?format=json">JSON-Format</a></p> + </section> + </main> + </body> + </html> + <?php } + exit(); +} + +foreach ($clients as $c) { + if ($path !== "/$c" && !str_starts_with($path, "/$c/")) + continue; + + header('Content-Type: text/plain; charset=UTF-8'); + + authenticate($c); + if ($path === "/$c") { + header('Status: 308'); + header("Location: $c/"); + header('Content-Length: 23'); + exit("308 Permanent Redirect\n"); + } else if ($path === "/$c/") { + if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + header("Status: 405"); + header("Allow: GET"); + header('Content-Length: 23'); + exit("405 Method Not Allowed\n"); + } + + header('Content-Type: application/json; charset=UTF-8'); + echo "{\"data\": [\n"; + $first = true; + foreach (scandir(".data/$c/") as $file) { + if (str_starts_with($file, ".") || str_ends_with($file, ".php") || str_ends_with($file, ".inc")) continue; + if (!$first) echo ",\n"; + $path = ".data/$c/$file"; + $size = filesize($path); + $url = "https://sync.elwig.at/$c/$file"; + $mod = date(DATE_ATOM, filemtime($path)); + $cre = date(DATE_ATOM, filectime($path)); + $datetime = "null"; + $zwstid = "null"; + if (str_ends_with($file, ".elwig.zip") && substr_count($file, "_") === 2) { + $parts = explode("_", substr($file, 0, -10)); + $time = str_replace("-", ":", $parts[1]); + $dt = DateTime::createFromFormat("Y-m-d H:i:s", "$parts[0] $time"); + $datetime = '"' . $dt->format(DateTimeInterface::RFC3339) . '"'; + $zwstid = "\"$parts[2]\""; + } + list($meta, $version, $files) = get_zip_meta($path); + $files ??= "null"; + $version ??= "null"; + echo " {\"name\": \"$file\", \"timestamp\": $datetime, \"zwstid\": $zwstid, \"meta\": $meta, \"files\": $files, " . + "\"version\": $version, \"url\": \"$url\", \"size\": $size, \"created\": \"$cre\", \"modified\": \"$mod\"}"; + $first = false; + } + echo "\n]}\n"; + exit(); + } + + $file = substr($path, strlen("/$c/")); + $path = ".data/$c/$file"; + if (!preg_match_all('/[A-Za-z0-9_.-]+/', $file) && !($file === '*' && $_SERVER['REQUEST_METHOD'] === 'DELETE')) { + header('Status: 400'); + header('Content-Length: 16'); + exit("400 Bad Request\n"); + } else if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $size = filesize($path); + if ($size === false) { + header('Status: 404'); + header('Content-Length: 14'); + exit("404 Not Found\n"); + } + $type = mime_content_type($path); + header("Content-Type: $type"); + header("Content-Disposition: attachment; filename=\"$file\""); + header("Content-Length: $size"); + readfile($path); + } else if ($_SERVER['REQUEST_METHOD'] === 'PUT') { + $upload = fopen("php://input", "r"); + $fp = fopen("/tmp/upload-$file", "wb+"); + if (!$upload || !$fp) { + fclose($fp); + fclose($upload); + header('Status: 500'); + header('Content-Length: 26'); + exit("500 Internal Server Error\n"); + } + + while ($data = fread($upload, 4096)) fwrite($fp, $data); + fclose($fp); + fclose($upload); + + if (!rename("/tmp/upload-$file", $path)) { + header('Status: 500'); + header('Content-Length: 26'); + exit("500 Internal Server Error\n"); + } + + header("Status: 201"); + header('Content-Length: 12'); + exit("201 Created\n"); + } else if ($_SERVER['REQUEST_METHOD'] === 'DELETE') { + if ($file === '*') { + foreach (scandir(".data/$c/") as $f) { + if (str_starts_with($f, ".") || str_ends_with($f, ".php") || str_ends_with($f, ".inc")) continue; + if (unlink(".data/$c/$f") === false) { + header("Status: 500"); + exit("500 Internal Server Error\n"); + } + echo "Deleted $f\n"; + } + } else if (!is_file($path)) { + header("Status: 404"); + header("Content-Length: 14"); + exit("404 Not Found\n"); + } else if (unlink($path) === false) { + header("Status: 500"); + exit("500 Internal Server Error\n"); + } + exit("200 OK\n"); + } else { + header("Status: 405"); + header("Allow: GET, PUT, DELETE"); + header("Content-Length: 23"); + exit("405 Method Not Allowed\n"); + } + + exit(); +} + +header("Status: 404"); +if ($format === 'text') { + header('Content-Type: text/plain; charset=UTF-8'); + header('Content-Length: 14'); + echo "404 Not Found\n"; +} else if ($format === 'json') { + header('Content-Type: application/json; charset=UTF-8'); + header('Content-Length: 39'); + echo "{\"errors\": [{\"message\": \"Not found\"}]}\n"; +} else { + header('Content-Type: text/html; charset=UTF-8'); + header('Content-Length: 0'); +} +exit(); diff --git a/www/res/style.css b/www/res/style.css new file mode 100644 index 0000000..a07ea01 --- /dev/null +++ b/www/res/style.css @@ -0,0 +1,161 @@ + +/**** Shared ****/ + +* { + box-sizing: border-box; +} + +:root { + font-family: 'Arial', sans-serif; + scroll-behavior: smooth; + --main-color: #A040C0; + --light-color: #D098E0; + --accent-color: #C0F080; + --bg-color: #EBEFE7; + --blur-color: #60804020; + --blur: 16px; +} + +body { + margin: 0; + position: relative; +} + +body.header-footer { + min-height: calc(100vh + 8em); + padding: 3em 0 8em 0; +} + +header { + position: fixed; + top: 0; + width: 100%; + height: 3em; + box-shadow: 0 0 0.5em #00000060; + background-color: #FFFFFF; + z-index: 10; +} + +header img { + height: 2.5em; + margin: 0.25em; +} + +nav { + height: 100%; + display: flex; + justify-content: space-between; +} + +nav > *:first-child, +nav > *:last-child { + flex: 100px 1 1; +} + +nav > *:last-child { + text-align: right; +} + +nav ul { + display: flex; + list-style-type: none; + margin: 0; + padding: 0; + justify-content: center; + align-items: center; + height: 100%; +} + +nav li { + flex: 100px 1 1; +} + +nav li a { + text-decoration: none; + padding: 1em 2em; + color: #808080; +} + +nav li a:hover, +nav li.active a{ + color: var(--main-color); +} + +nav a.flag { + text-decoration: none; +} + +nav a.flag div { + display: inline-block; + font-size: 1.25em; + width: 1em; + margin: 0.75rem 1rem; +} + +nav a.flag[href='/de/'] div::before, +nav a.flag[href='/en/']:hover div::before, +nav a.flag[href='/en/']:focus div::before { + content: '\1F1EC\1F1E7'; /* GB */ +} + +nav a.flag[href='/en/'] div::before, +nav a.flag[href='/de/']:hover div::before, +nav a.flag[href='/de/']:focus div::before { + content: '\1F1E6\1F1F9'; /* AT */ +} + +footer { + height: 8em; + padding: 1em; + width: 100%; + box-shadow: 0 0 0.5rem #00000060; + background-color: #404040; + color: #FFFFFF; + display: flex; + justify-content: center; + gap: 2em; + position: absolute; + bottom: 0; +} + +footer img { + height: 4em; + width: 4em; +} + +footer a { + align-self: center; +} + +@media screen and (max-width: 800px) { + footer { + flex-direction: column; + height: unset; + align-items: center; + gap: 1em; + padding-top: 3em; + } + footer > *:not(:first-child) { + width: 300px; + } +} + +p a, +table a { + color: var(--main-color); +} + +main section h3 { + text-align: center; + font-size: 2em; +} + +.center { + text-align: center; +} + +main section p, +main section table { + max-width: 1000px; + margin: 1em auto; +} diff --git a/www/robots.txt b/www/robots.txt new file mode 100644 index 0000000..44c5fee --- /dev/null +++ b/www/robots.txt @@ -0,0 +1,3 @@ +# robots.txt for sync.elwig.at +User-Agent: * +Disallow: /