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: /