#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GEO-SAILING Lizenzserver (nur Python-Standardbibliothek, Python 3.8+).

Einmalig:   python3 lizenzserver.py init
Key machen: python3 lizenzserver.py neu "Anna"
Starten:    python3 lizenzserver.py start --port 8000
Hilfe:      python3 lizenzserver.py hilfe

Die geschuetzten Seiten liegen im Ordner "geschuetzt" und werden nur an Geraete
ausgeliefert, die einen gueltigen Key aktiviert haben. Ein Key wird beim ersten
Gebrauch an das Geraet gebunden (Standard: 1 Geraet pro Key).
"""
import sys, os, re, json, sqlite3, secrets, hashlib, time, argparse, ipaddress
import urllib.request
from collections import deque
from datetime import datetime, timedelta
from http.cookies import SimpleCookie
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
import rechtliches                              # Impressum, Datenschutz, Haftungsausschluss (oeffentlich, Angaben im Admin-Bereich)

BASE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(BASE, 'daten')
DB = os.path.join(DATA, 'lizenzen.db')
LOG = os.path.join(DATA, 'zugriffe.log')
CFG = os.path.join(DATA, 'config.json')
PAGES_DIR = os.path.join(BASE, 'geschuetzt')
PUBLIC_DIR = os.path.join(BASE, 'public')
PAGES = ['index', 'vorbereitung', 'sprechfunk', 'einweisung', 'manoever', 'logbuch']
ADMIN_PAGE = 'admin'                            # nur fuer die Admin-Lizenz (ID aus config.json "admin_license", Standard 3)
ADMIN_DEFAULT_ID = 3
QR_LICENSE = 3                                    # NUR diese Lizenz darf per QR-Code (Einmal-Token aus dem Admin-Panel) ein weiteres Geraet bekommen
MUSTER_LICENSES = (3, 21)                         # nur beim ersten Anlegen der Spalte 'muster': diese Lizenzen bekommen die Muster-Toerns schon vorab (danach per Haken im Admin-Panel)
QR_MINUTES = 10                                   # so lange gilt ein QR-Code (config.json "qr_minuten")
LIC_MARK = b'<!--LIZENZ-INFO-->'                 # Platzhalter in index.html: Restlaufzeit des Keys (Demo-Sekunden bzw. Ablaufdatum)
ADMIN_MARK = b'<!--ADMIN-KACHEL-->'             # Platzhalter in index.html, wird nur fuer den Admin durch die Kachel ersetzt
ADMIN_TILE = '''<a class="big" href="admin.html" style="--c:var(--gust)">
      <svg class="ico" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="22" cy="32" r="9"/><path d="M31 32h25M46 32v8M54 32v6"/></svg>
      <div>
        <div class="eyebrow">Nur für dich · Admin</div>
        <h2>Lizenzen</h2>
        <p>Keys erzeugen, sperren, zurücksetzen und das Zugriffsprotokoll ansehen.</p>
      </div>
      <span class="go">Öffnen <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></span>
    </a>'''.encode('utf-8')
ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'   # ohne I, O, 0, 1
DEV_RE = re.compile(r'^[A-Za-z0-9_-]{32,128}$')
MAX_FAILS, FAIL_WINDOW = 10, 600                # 10 Fehlversuche je 10 Minuten und IP
DEMO_DEFAULT_MIN = 30                          # Demo-Keys: Minuten ab erster Nutzung (config.json "demo_minuten")
COOKIE = 'gg_dev'
COOKIE_AGE = 400 * 86400                       # Der Browser merkt sich das Geraet bis zu 400 Tage (wird bei jeder Nutzung erneuert)
MIME = {'.json': 'application/json', '.js': 'text/javascript', '.png': 'image/png', '.ico': 'image/x-icon', '.svg': 'image/svg+xml'}

SHELL = r'''<!doctype html>
<html lang="de"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>GEO-SAILING</title>
<link rel="manifest" href="manifest.json"><meta name="theme-color" content="#0a6573">
<meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="GEO-SAILING">
<link rel="icon" href="icon-192.png"><link rel="apple-touch-icon" href="icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#e9f0ef;--surface:#f9fcfb;--ink:#12252b;--muted:#55696f;--line:#cbd9d9;--accent:#0a6573;--accent-ink:#fff;--err:#a8201a;--water:#0a8575;--air:#c0560e;--wind:#3d4b94;--ok:#1f6f4a;--glow:rgba(10,101,115,.13);--shadow:0 14px 40px rgba(10,60,70,.14);
  --fd:"Bricolage Grotesque","Segoe UI",system-ui,sans-serif;--fb:"IBM Plex Sans","Segoe UI",system-ui,sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#0b171b;--surface:#112228;--ink:#e3eeee;--muted:#94aaae;--line:#23393f;--accent:#4fc0cf;--accent-ink:#072228;--err:#f58a82;--water:#3fc7b3;--air:#f08a4b;--wind:#8d9cf0;--ok:#55c58f;--glow:rgba(79,192,207,.14);--shadow:0 14px 40px rgba(0,0,0,.45);color-scheme:dark}}
*{box-sizing:border-box}html{height:100%}body{margin:0;min-height:100%}
body{background:radial-gradient(900px 520px at 12% -8%,var(--glow),transparent 70%),radial-gradient(700px 480px at 100% 105%,var(--glow),transparent 70%);background-color:var(--bg);background-repeat:no-repeat;background-attachment:fixed;color:var(--ink);font:15px/1.5 var(--fb);display:flex;flex-direction:column;align-items:center;padding:max(22px,env(safe-area-inset-top,0px)) max(18px,env(safe-area-inset-right,0px)) max(16px,env(safe-area-inset-bottom,0px)) max(18px,env(safe-area-inset-left,0px))}
html:not(.ready) .wrap{display:none}
#ld{color:var(--muted);font-size:14px;margin:auto}html.ready #ld{display:none}
.wrap{margin:auto 0;width:100%;max-width:960px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);grid-template-areas:"head form" "feat form";column-gap:56px;row-gap:26px;align-items:start}
.head{grid-area:head;display:flex;flex-direction:column;gap:14px}
.brand{display:flex;align-items:center;gap:12px}
.brand img{width:46px;height:46px;border-radius:12px;box-shadow:0 4px 14px rgba(10,60,70,.22)}
.brand span{font:600 12px var(--fb);letter-spacing:.14em;text-transform:uppercase;color:var(--accent)}
h1{margin:0;font:700 clamp(36px,6.4vw,56px)/1.02 var(--fd);letter-spacing:-.025em;text-wrap:balance}
.slogan{margin:0;font:500 clamp(18px,2.6vw,22px)/1.35 var(--fd);color:var(--muted);max-width:26ch;text-wrap:balance}
.feat{grid-area:feat;list-style:none;margin:0;padding:0;display:grid;gap:10px}
.feat li{position:relative;display:flex;align-items:center;gap:14px;padding:12px 16px 12px 12px;background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--c);border-radius:12px}
.feat .ic{flex:none;width:46px;height:46px;border-radius:12px;display:grid;place-items:center;color:var(--c);background:var(--c);background:color-mix(in srgb,var(--c) 13%,transparent)}
.feat .ic svg{width:28px;height:28px}
.feat b{display:block;font:700 17px/1.2 var(--fd);letter-spacing:-.01em}
.feat span{display:block;margin-top:2px;font-size:12.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--muted)}
form{grid-area:form;position:relative;background:var(--surface);border:1px solid var(--line);border-top:5px solid var(--accent);border-radius:16px;padding:26px 24px 20px;display:flex;flex-direction:column;gap:12px;box-shadow:var(--shadow)}
h2{margin:0;font:700 26px/1.1 var(--fd);letter-spacing:-.02em}p{margin:0;color:var(--muted);font-size:14px}
label{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
input{font:500 17px var(--fb);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:13px 12px;width:100%;text-transform:uppercase;letter-spacing:.06em;outline:0;transition:border-color .15s,box-shadow .15s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--glow)}
button{font:600 16px var(--fb);color:var(--accent-ink);background:var(--accent);border:0;border-radius:10px;padding:13px;cursor:pointer;transition:filter .15s,transform .1s}button:hover{filter:brightness(1.07)}button:active{transform:translateY(1px)}button[disabled]{opacity:.6}
.err{color:var(--err);font-size:14px;min-height:20px}
.lg{margin-top:18px;text-align:center;font-size:13px;color:var(--muted)}.lg a{color:inherit;margin:0 6px}.lg a:hover{color:var(--accent)}
.req{align-self:center;font-size:13px;color:var(--muted);text-decoration:underline;padding:6px 8px}.req:hover{color:var(--accent)}
@media (max-width:760px){.wrap{grid-template-columns:minmax(0,1fr);grid-template-areas:"head" "form" "feat";row-gap:22px;max-width:440px}.slogan{max-width:none}}
</style></head><body>
<main class="wrap">
<header class="head">
<div class="brand"><img src="icon-192.png" alt="" width="46" height="46"><span>Segeln · Ostsee</span></div>
<h1>GEO-SAILING</h1>
<p class="slogan">Dein treuer Begleiter für den nächsten Ostseeurlaub</p>
</header>
<ul class="feat">
<li style="--c:var(--water)"><div class="ic"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 10h30a6 6 0 0 1 6 6v38H20a6 6 0 0 1-6-6z"/><path d="M14 48a6 6 0 0 1 6-6h30"/><path d="M24 20h18M24 28h12"/></svg></div><div><b>Logbuch</b></div></li>
<li style="--c:var(--accent)"><div class="ic"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="32" cy="32" r="24"/><path d="M32 12v6M32 46v6M12 32h6M46 32h6"/><path d="M40 24l-6 14-10 2 6-14z" fill="currentColor" fill-opacity=".15"/></svg></div><div><b>Törn-Briefing</b></div></li>
<li style="--c:var(--wind)"><div class="ic"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="18" y="26" width="28" height="30" rx="5"/><path d="M26 26L38 8M26 36h12M26 44h12M26 50h6"/><path d="M48 14a12 12 0 0 1 0 16M54 8a20 20 0 0 1 0 28" opacity=".6"/></svg></div><div><b>Funk-Skript</b></div></li>
<li style="--c:var(--ok)"><div class="ic"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="14" y="10" width="36" height="46" rx="5"/><path d="M24 8h16v8H24z"/><path d="M22 28l4 4 7-8M22 42l4 4 7-8M38 30h6M38 44h6"/></svg></div><div><b>Crew-Einweisung</b></div></li>
<li style="--c:var(--air)"><div class="ic"><svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M32 8v34"/><path d="M32 12l16 26H32z" fill="currentColor" fill-opacity=".15"/><path d="M28 20L16 38h12"/><path d="M10 46h44l-6 9H16z"/></svg></div><div><b>Manöver</b></div></li>
</ul>
<form id="f" autocomplete="off">
<h2>Anmelden</h2><p>Zugang nur mit persönlichem Key.</p>
<input id="k" type="text" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" autocapitalize="characters" spellcheck="false" required>
<div class="err" id="e" role="alert"></div>
<button type="submit" id="b">Aktivieren</button>
<a class="req" id="r" href="#">Zugang anfragen</a>
</form>
</main>
<!--RECHT--><div class="lg"><a href="impressum.html">Impressum</a> · <a href="datenschutz.html">Datenschutz</a> · <a href="haftungsausschluss.html">Haftungsausschluss</a></div><!--/RECHT-->
<p id="ld">Lade …</p>
<noscript><p>Diese Seite braucht JavaScript.</p></noscript>
<script src="offline.js"></script>
<script>
(function () {
  var NAME = '__NAME__', DK = 'ostsee-dev', OUT = 'ostsee-out';
  var GG = window.GG || null, note = '', offMsg = '';
  var f = document.getElementById('f'), k = document.getElementById('k'), e = document.getElementById('e'), b = document.getElementById('b');
  var R = document.getElementById('r');
  function mailto(subj, lines) { return 'mailto:niklas.gleinig@googlemail.com?subject=' + encodeURIComponent(subj) + '&body=' + encodeURIComponent(lines.join('\r\n')); }
  R.href = mailto('Zugangsanfrage GEO-SAILING', ['Hallo Niklas,', '', 'ich möchte gerne Zugang zu GEO-SAILING bekommen. Bitte schicke mir einen Key.', '', 'Mein Name: ', 'Wofür ich die App nutzen möchte (z. B. Törn, Zeitraum): ', '', 'Viele Grüße']);
  function ls(a, key, v) { try { if (a === 'g') return localStorage.getItem(key); if (a === 's') localStorage.setItem(key, v); else localStorage.removeItem(key); } catch (x) {} return null; }
  function ready() { document.documentElement.classList.add('ready'); setTimeout(function () { k.focus(); }, 50); }
  function dev() {
    var d = ls('g', DK);
    if (!d || !/^[A-Za-z0-9_-]{32,128}$/.test(d)) {
      var a = new Uint8Array(32); crypto.getRandomValues(a);
      d = Array.prototype.map.call(a, function (x) { return ('0' + x.toString(16)).slice(-2); }).join(''); ls('s', DK, d);
    }
    return d;
  }
  function post(url, o, ms) {
    var ctl = (ms && window.AbortController) ? new AbortController() : null, t = ctl ? setTimeout(function () { ctl.abort(); }, ms) : 0;
    return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o), cache: 'no-store', signal: ctl ? ctl.signal : undefined })
      .then(function (r) { clearTimeout(t); return r.text().then(function (tx) { return { s: r.status, t: tx, d: r.headers.get('X-Dev'), x: r.headers.get('X-Exp'), o: r.headers.get('X-Offline-Days') }; }); },
            function (x) { clearTimeout(t); throw x; });
  }
  function show(html, ts) { if (GG) html = GG.inject(html, ts || 0); document.open(); document.write(html); document.close(); }
  function net(ms) {
    return post('api/page', { name: NAME, dev: ls('g', DK) || '' }, ms).then(function (r) {
      if (r.s === 200) {
        if (r.d && /^[A-Za-z0-9_-]{32,128}$/.test(r.d)) ls('s', DK, r.d);
        if (GG) {
          GG.touch(r.x, r.o);
          if (GG.cacheable(NAME)) return GG.save(NAME, r.t).then(function () { show(r.t, 0); return 'ok'; });
        }
        show(r.t, 0); return 'ok';
      }
      if (r.s === 403 || r.s === 429) {
        var j = {}; try { j = JSON.parse(r.t); } catch (x) {}
        if (j.expired) note = j.error;
        if (j.denied && GG) GG.clear();
        return 'denied';
      }
      return 'error';
    });
  }
  /* Offline-Modus an: Seite direkt vom Gerät. Sonst zuerst der Server; ist er nicht erreichbar, die Kopie vom Gerät (solange die Lizenz-Frist läuft). */
  function load() {
    if (!GG || !GG.cacheable(NAME)) return net(0);
    return GG.get(NAME).then(function (c) {
      var usable = !!c && GG.valid(), expiredMsg = 'Die Offline-Zeit ist abgelaufen. Bitte kurz mit dem Internet verbinden, damit die Lizenz geprüft wird.';
      if (GG.on() && usable) { show(c.html, c.ts); return 'ok'; }
      offMsg = '';
      if (GG.on()) offMsg = c ? expiredMsg : 'Offline-Modus: Diese Seite ist noch nicht auf dem Gerät gespeichert. Bitte einmal mit Internet verbinden.';
      return net(usable ? 4000 : 15000).catch(function (x) {
        if (usable) { show(c.html, c.ts); return 'ok'; }
        if (c && !offMsg) offMsg = expiredMsg;
        throw x;
      });
    });
  }
  function fail(x) { e.textContent = x; b.disabled = false; }
  function auto(n) {
    load().then(function (st) {
      if (st === 'ok') return;
      if (st === 'denied') { ready(); if (note) { fail(note); R.textContent = 'Verlängerung anfragen'; R.href = mailto('Key verlängern für GEO-SAILING', ['Hallo Niklas,', '', 'mein Key für GEO-SAILING ist abgelaufen. Könntest du ihn bitte verlängern oder mir einen neuen Key schicken?', '', 'Mein Name: ', '', 'Viele Grüße']); } return; }
      throw new Error('x');
    }).catch(function () {
      if (n > 0 && !offMsg) setTimeout(function () { auto(n - 1); }, 1500);
      else { ready(); fail(offMsg || 'Server gerade nicht erreichbar. Bitte Seite neu laden.'); }
    });
  }
  /* QR-Code-Link (…/#key=XXXX-XXXX-XXXX-XXXX): Key übernehmen, aus der Adresszeile entfernen und automatisch aktivieren. */
  var hk = (/[#&]key=([A-Za-z0-9-]{16,24})/.exec(location.hash) || [])[1] || '';
  var hq = (/[#&]qr=([A-Za-z0-9_-]{20,64})/.exec(location.hash) || [])[1] || '';   /* Einmal-QR-Code aus dem Admin-Panel (weiteres Gerät) */
  if (hk || hq) { try { history.replaceState(null, '', location.pathname + location.search); } catch (x) {} }
  if (hq) {
    ready(); k.removeAttribute('required'); k.value = ''; e.textContent = 'Gerät wird per QR-Code angemeldet …'; b.disabled = true;
    post('api/redeem', { qr: hq, dev: dev() }).then(function (r) {
      var j = {}; try { j = JSON.parse(r.t); } catch (x) {}
      if (r.s === 200 && j.ok) {
        if (j.dev) ls('s', DK, j.dev); ls('r', OUT);
        return (GG ? GG.clear() : Promise.resolve()).then(load).then(function (st) { if (st !== 'ok') fail('Anmeldung fehlgeschlagen.'); });
      }
      fail(j.error || ('Fehler beim Aktivieren (HTTP ' + r.s + ').')); k.setAttribute('required', '');
    }).catch(function () { fail('Server nicht erreichbar. Bitte später erneut versuchen.'); k.setAttribute('required', ''); });
  }
  else if (hk) { ready(); k.value = hk; e.textContent = 'Key aus QR-Code wird aktiviert …'; setTimeout(function () { f.dispatchEvent(new Event('submit', { cancelable: true })); }, 0); }
  else if (!ls('g', OUT)) auto(3); else ready();
  f.addEventListener('submit', function (ev) {
    ev.preventDefault(); e.textContent = ''; b.disabled = true;
    post('api/redeem', { key: k.value, dev: dev() }).then(function (r) {
      var j = {}; try { j = JSON.parse(r.t); } catch (x) {}
      if (r.s === 200 && j.ok) {
        if (j.dev) ls('s', DK, j.dev); ls('r', OUT);
        return (GG ? GG.clear() : Promise.resolve()).then(load).then(function (st) { if (st !== 'ok') fail('Anmeldung fehlgeschlagen.'); });
      }
      fail(j.error || ('Fehler beim Aktivieren (HTTP ' + r.s + '). Server-Einstellungen prüfen.')); k.select();
    }).catch(function () { fail('Server nicht erreichbar. Bitte später erneut versuchen.'); });
  });
})();
</script></body></html>
'''

# ---------------------------------------------------------------- Hilfsfunktionen
def now(): return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
def sha(s): return hashlib.sha256(s.encode('utf-8')).hexdigest()
def norm_key(k): return re.sub(r'[^A-Z0-9]', '', (k or '').upper())
_MIGRIERT = False
IP_TABLE = '''CREATE TABLE IF NOT EXISTS ip_log(license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, ip TEXT NOT NULL,
        first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, hits INTEGER NOT NULL DEFAULT 1, PRIMARY KEY(license_id, ip))'''
LOG_RE = re.compile(r'^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) \| (\S+)\s*\| (?:AKTIVIERT|LOGIN|PRUEFUNG|SEITE \S+)\s*\| .*\(#(\d+)\)\s*$')
def backfill_ips(c):
    """Beim ersten Anlegen der IP-Tabelle: bisherige erfolgreiche Zugriffe aus zugriffe.log uebernehmen."""
    try:
        ids = set(r[0] for r in c.execute('SELECT id FROM licenses'))
        acc = {}
        with open(LOG, encoding='utf-8', errors='replace') as f:
            for line in f:
                m = LOG_RE.match(line.rstrip('\n'))
                if not m or int(m.group(3)) not in ids: continue
                k = (int(m.group(3)), m.group(2)); a = acc.get(k)
                if a: a[1] = m.group(1); a[2] += 1
                else: acc[k] = [m.group(1), m.group(1), 1]
        for (lid, ip), (fs, ls, n) in acc.items():
            c.execute('INSERT OR IGNORE INTO ip_log(license_id,ip,first_seen,last_seen,hits) VALUES(?,?,?,?,?)', (lid, ip, fs, ls, n))
    except OSError: pass
def ensure_schema(c):
    """Aeltere Datenbank ergaenzen: Ablaufdatum, Demo-Spalten, IP-Verlauf je Key."""
    cols = [r[1] for r in c.execute('PRAGMA table_info(licenses)')]
    if not cols: return
    for name, typ in (('expires', 'TEXT'), ('demo_min', 'INTEGER'), ('demo_until', 'TEXT')):
        if name not in cols: c.execute('ALTER TABLE licenses ADD COLUMN %s %s' % (name, typ))
    if 'muster' not in cols:   # Haken "Muster-Toerns anlegen" je Lizenz; fuer 3 und 21 vorbelegt
        c.execute('ALTER TABLE licenses ADD COLUMN muster INTEGER NOT NULL DEFAULT 0')
        c.execute('UPDATE licenses SET muster=1 WHERE id IN (%s)' % ','.join(str(int(i)) for i in MUSTER_LICENSES))
    if not c.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='ip_log'").fetchone():
        c.execute(IP_TABLE); backfill_ips(c)
    c.execute('CREATE TABLE IF NOT EXISTS logbuch(license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, eid TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(license_id, eid))')
    # Zusaetzliche Admins: Lizenzen mit Zugriff auf die Admin-Seite (der Haupt-Admin steht weiter in config.json "admin_license")
    c.execute('CREATE TABLE IF NOT EXISTS admins(license_id INTEGER PRIMARY KEY REFERENCES licenses(id) ON DELETE CASCADE)')
    # Geteilte Toerns: lb_share = Freigabe-Code eines Toerns (gehoert dem Besitzer), lb_member = Lizenzen, die beigetreten sind
    c.execute('CREATE TABLE IF NOT EXISTS lb_share(code TEXT PRIMARY KEY, owner INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, tid TEXT NOT NULL, UNIQUE(owner, tid))')
    c.execute('CREATE TABLE IF NOT EXISTS lb_member(code TEXT NOT NULL REFERENCES lb_share(code) ON DELETE CASCADE, lid INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, joined TEXT, PRIMARY KEY(code, lid))')
    # Wachfuehrer eines geteilten Toerns (Lizenz, die gerade die Wache hat)
    c.execute('CREATE TABLE IF NOT EXISTS lb_wache(code TEXT PRIMARY KEY REFERENCES lb_share(code) ON DELETE CASCADE, lid INTEGER NOT NULL, t REAL)')
    # Von Hand hinzugefuegte Wachfuehrer (Personen ohne eigene Lizenz bzw. ohne Freigabe); gid wird vom Geraet vergeben, in der Crew steht die Person als -gid
    c.execute('CREATE TABLE IF NOT EXISTS lb_gast(code TEXT NOT NULL REFERENCES lb_share(code) ON DELETE CASCADE, gid INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY(code, gid))')
    c.execute('CREATE TABLE IF NOT EXISTS qr_tokens(token_hash TEXT PRIMARY KEY, license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, created REAL NOT NULL)')
    c.execute('''CREATE TABLE IF NOT EXISTS ip_geo(ip TEXT PRIMARY KEY, ok INTEGER NOT NULL, lat REAL, lon REAL, city TEXT, country TEXT, org TEXT, ts REAL NOT NULL)''')
    # Ein Geraet darf mit mehreren Keys angemeldet sein: alte Tabelle hatte dev_hash allein als UNIQUE, jetzt gilt UNIQUE(license_id, dev_hash).
    old = any(len(c.execute('PRAGMA index_info(%s)' % r[1]).fetchall()) == 1 and c.execute('PRAGMA index_info(%s)' % r[1]).fetchone()[2] == 'dev_hash'
              for r in c.execute('PRAGMA index_list(devices)') if r[2])
    if old:
        c.executescript('''
          CREATE TABLE devices_neu(id INTEGER PRIMARY KEY AUTOINCREMENT, license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
            dev_hash TEXT NOT NULL, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, ip TEXT, UNIQUE(license_id, dev_hash));
          INSERT INTO devices_neu(id,license_id,dev_hash,first_seen,last_seen,ip) SELECT id,license_id,dev_hash,first_seen,last_seen,ip FROM devices;
          DROP TABLE devices; ALTER TABLE devices_neu RENAME TO devices;''')
    c.commit()
def db():
    global _MIGRIERT
    c = sqlite3.connect(DB, timeout=15); c.row_factory = sqlite3.Row
    c.execute('PRAGMA foreign_keys=ON')
    if not _MIGRIERT: ensure_schema(c); _MIGRIERT = True
    return c
def touch_ip(c, lid, ip):
    """Merkt sich, von welcher IP-Adresse aus ein Key benutzt wurde (erstmals, zuletzt, Anzahl)."""
    t = now()
    if c.execute('UPDATE ip_log SET last_seen=?, hits=hits+1 WHERE license_id=? AND ip=?', (t, lid, ip)).rowcount == 0:
        c.execute('INSERT INTO ip_log(license_id,ip,first_seen,last_seen,hits) VALUES(?,?,?,?,1)', (lid, ip, t, t))
# ---------------------------------------------------------------- IP-Standorte (nur fuer die Admin-Karte)
GEO_URL_DEFAULT = 'https://ipwho.is/{ip}'       # in config.json aenderbar: "geo_url" (mit {ip}), "geo": false schaltet die Abfrage ab
GEO_OK_TTL = 30 * 86400                         # gefundene Standorte 30 Tage merken
GEO_FAIL_TTL = 3600                             # Fehlschlaege nach 1 Stunde erneut versuchen
GEO_PER_CALL = 12                               # so viele neue Abfragen pro Aufruf, der Rest folgt im naechsten
def ip_valid(ip):
    try: ipaddress.ip_address(ip); return True
    except Exception: return False
def ip_private(ip):
    try:
        a = ipaddress.ip_address(ip)
        return a.is_private or a.is_loopback or a.is_link_local or a.is_reserved or a.is_multicast
    except Exception: return True
def geo_fetch(ip):
    """Fragt einen Standort-Dienst ab (Standard ipwho.is, HTTPS). Gibt dict(lat,lon,city,country,org) oder None zurueck."""
    url = str(cfg().get('geo_url') or GEO_URL_DEFAULT).replace('{ip}', ip)
    try:
        rq = urllib.request.Request(url, headers={'User-Agent': 'geo-gleinig-admin/1.0', 'Accept': 'application/json'})
        with urllib.request.urlopen(rq, timeout=4) as r: d = json.loads(r.read(200000).decode('utf-8', 'replace'))
        if d.get('success') is False or d.get('status') not in (None, 'success'): return None
        lat = d.get('latitude', d.get('lat')); lon = d.get('longitude', d.get('lon', d.get('lng')))
        lat = float(lat); lon = float(lon)
        if not (-90 <= lat <= 90 and -180 <= lon <= 180): return None
        conn = d.get('connection') if isinstance(d.get('connection'), dict) else {}
        return {'lat': lat, 'lon': lon, 'city': str(d.get('city') or '')[:60], 'country': str(d.get('country') or d.get('country_name') or '')[:60],
                'org': str(conn.get('isp') or conn.get('org') or d.get('isp') or d.get('org') or '')[:80]}
    except Exception:
        return None
def geo_lookup(c, ips):
    """Standorte fuer IPs aus dem Zwischenspeicher, fehlende werden (begrenzt) nachgeladen. Rueckgabe: ({ip: info}, noch_offen)."""
    out, todo, t = {}, [], time.time()
    for ip in ips:
        if not ip_valid(ip): out[ip] = {'ok': False, 'why': 'ungueltig'}; continue
        if ip_private(ip): out[ip] = {'ok': False, 'why': 'privat'}; continue
        r = c.execute('SELECT * FROM ip_geo WHERE ip=?', (ip,)).fetchone()
        if r and t - r['ts'] < (GEO_OK_TTL if r['ok'] else GEO_FAIL_TTL):
            out[ip] = {'ok': True, 'lat': r['lat'], 'lon': r['lon'], 'city': r['city'], 'country': r['country'], 'org': r['org']} if r['ok'] else {'ok': False, 'why': 'unbekannt'}
        else: todo.append(ip)
    pending = 0
    if cfg().get('geo', True) is False:
        for ip in todo: out[ip] = {'ok': False, 'why': 'aus'}
        return out, 0
    for ip in todo[:GEO_PER_CALL]:
        g = geo_fetch(ip)
        if g:
            c.execute('INSERT OR REPLACE INTO ip_geo(ip,ok,lat,lon,city,country,org,ts) VALUES(?,1,?,?,?,?,?,?)', (ip, g['lat'], g['lon'], g['city'], g['country'], g['org'], t))
            g['ok'] = True; out[ip] = g
        else:
            c.execute('INSERT OR REPLACE INTO ip_geo(ip,ok,lat,lon,city,country,org,ts) VALUES(?,0,NULL,NULL,NULL,NULL,NULL,?)', (ip, t))
            out[ip] = {'ok': False, 'why': 'unbekannt'}
    c.commit()
    pending = len(todo) - GEO_PER_CALL
    return out, max(0, pending)
def unassigned_ips(limit_lines=200000):
    """IPs aus dem Zugriffsprotokoll, die zu keinem Key gehoeren: abgelehnte Keys, Aufrufe ohne Anmeldung, zu viele Fehlversuche."""
    ev_ok = ('KEIN-ZUGRIFF', 'ABGELEHNT', 'GESPERRT-ZU-OFT')
    acc = {}
    try:
        with open(LOG, encoding='utf-8', errors='replace') as f: lines = deque((x.rstrip('\n') for x in f), maxlen=limit_lines)
    except OSError: return []
    for line in lines:
        p = [x.strip() for x in line.split('|', 3)]
        if len(p) < 3: continue
        ts, ip, ev = p[0], p[1], p[2]; who = p[3] if len(p) > 3 else ''
        if ev not in ev_ok or re.search(r'\(#\d+\)', who) or not ip_valid(ip): continue
        a = acc.get(ip)
        if not a: a = acc[ip] = {'ip': ip, 'first_seen': ts, 'last_seen': ts, 'hits': 0, 'events': {}}
        a['last_seen'] = ts; a['hits'] += 1; a['events'][ev] = a['events'].get(ev, 0) + 1
    return sorted(acc.values(), key=lambda x: x['last_seen'], reverse=True)[:300]
def demo_minutes():
    try: return max(1, min(1440, int(cfg().get('demo_minuten', DEMO_DEFAULT_MIN))))
    except Exception: return DEMO_DEFAULT_MIN
def demo_expired(lic): return bool(lic['demo_until']) and lic['demo_until'] < now()
def demo_msg(): return 'Dieser Demo-Key ist abgelaufen. Bitte frage einen vollständigen Key an.'
def start_demo(c, lic):
    """Demo-Key: Die Laufzeit beginnt mit der ersten erfolgreichen Nutzung."""
    if lic['demo_min'] and not lic['demo_until']:
        c.execute('UPDATE licenses SET demo_until=? WHERE id=? AND demo_until IS NULL', ((datetime.now() + timedelta(minutes=lic['demo_min'])).strftime('%Y-%m-%d %H:%M:%S'), lic['id']))
def today(): return datetime.now().strftime('%Y-%m-%d')
def parse_date(v):
    """'' / None -> None (unbegrenzt); 'YYYY-MM-DD' -> dasselbe; sonst ValueError. Der Key gilt bis einschliesslich dieses Tages."""
    if v is None or str(v).strip() == '' or str(v).strip().lower() in ('aus', 'nie', 'unbegrenzt'): return None
    v = str(v).strip()
    d = datetime.strptime(v, '%Y-%m-%d')
    if d.year > 2100: raise ValueError('Jahr zu gross')
    return v
def is_expired(exp): return bool(exp) and exp < today()
def de_date(v): return '%s.%s.%s' % (v[8:10], v[5:7], v[:4])
def expired_msg(exp): return 'Dein Key ist am %s abgelaufen. Bitte frage einen neuen bzw. verlängerten Key an.' % de_date(exp)
def cfg():
    try:
        with open(CFG, encoding='utf-8') as f: return json.load(f)
    except Exception: return {'host': '127.0.0.1', 'port': 8000, 'proxy': False}
def qr_minutes():
    try: return max(1, min(60, int(cfg().get('qr_minuten', QR_MINUTES))))
    except Exception: return QR_MINUTES
def offline_days():
    """Wie viele Tage die Seiten nach der letzten erfolgreichen Lizenzpruefung offline nutzbar sind (config.json \"offline_tage\", Standard 14, 0 = aus)."""
    try: return max(0, min(365, int(cfg().get('offline_tage', 14))))
    except Exception: return 14
def is_admin(c, lid):
    """Haupt-Admin (config.json) oder Lizenz, der im Admin-Dialog Admin-Rechte gegeben wurden."""
    if lid == admin_id(): return True
    try: return c.execute('SELECT 1 FROM admins WHERE license_id=?', (lid,)).fetchone() is not None
    except sqlite3.Error: return False
def admin_id():
    try: return int(cfg().get('admin_license', ADMIN_DEFAULT_ID))
    except Exception: return ADMIN_DEFAULT_ID
def log(ip, event, who=''):
    try:
        with open(LOG, 'a', encoding='utf-8') as f: f.write('%s | %-15s | %-18s | %s\n' % (now(), ip, event, who))
    except Exception: pass
def need_init():
    if not os.path.exists(DB):
        print('Noch nicht eingerichtet. Bitte zuerst:  python3 lizenzserver.py init'); sys.exit(1)

# ---------------------------------------------------------------- Befehle
def cmd_init(a):
    os.makedirs(DATA, exist_ok=True); os.makedirs(PAGES_DIR, exist_ok=True); os.makedirs(PUBLIC_DIR, exist_ok=True)
    c = sqlite3.connect(DB)
    c.executescript('''
      CREATE TABLE IF NOT EXISTS licenses(id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL, key_hash TEXT UNIQUE NOT NULL,
        created TEXT NOT NULL, max_devices INTEGER NOT NULL DEFAULT 1, revoked INTEGER NOT NULL DEFAULT 0, expires TEXT, demo_min INTEGER, demo_until TEXT);
      CREATE TABLE IF NOT EXISTS devices(id INTEGER PRIMARY KEY AUTOINCREMENT, license_id INTEGER NOT NULL REFERENCES licenses(id) ON DELETE CASCADE,
        dev_hash TEXT NOT NULL, first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, ip TEXT, UNIQUE(license_id, dev_hash));
      CREATE TABLE IF NOT EXISTS fails(ip TEXT NOT NULL, ts REAL NOT NULL);
    ''')
    c.execute(IP_TABLE); c.commit(); c.close()
    if not os.path.exists(CFG):
        with open(CFG, 'w', encoding='utf-8') as f: json.dump({'host': '127.0.0.1', 'port': 8000, 'proxy': False}, f, indent=1)
    try: os.chmod(DATA, 0o700)
    except Exception: pass
    print('Datenbank angelegt: ' + DB)
    print('Naechste Schritte:\n  python3 lizenzserver.py neu "Name"      (Key erzeugen)\n  python3 lizenzserver.py start           (Server starten)')

def cmd_neu(a):
    need_init(); c = db(); out = []
    try:
        exp = parse_date(a.bis) if a.bis else (None if not a.tage else (datetime.now() + timedelta(days=a.tage)).strftime('%Y-%m-%d'))
    except ValueError: print('Datum bitte als JJJJ-MM-TT, z.B. 2027-06-30'); sys.exit(1)
    dm = demo_minutes() if a.demo else None
    if dm: exp = None
    for i in range(a.anzahl):
        key = '-'.join(''.join(secrets.choice(ALPHABET) for _ in range(4)) for _ in range(4))
        lab = a.name if a.anzahl == 1 else '%s %d' % (a.name, i + 1)
        c.execute('INSERT INTO licenses(label,key_hash,created,max_devices,expires,demo_min) VALUES(?,?,?,?,?,?)', (lab, sha(norm_key(key)), now(), a.geraete, exp, dm))
        out.append((c.execute('SELECT last_insert_rowid()').fetchone()[0], lab, key))
    c.commit(); c.close()
    print('Der Key wird nur jetzt angezeigt (gespeichert ist nur ein Hash). Bitte direkt weitergeben:\n')
    for i, lab, key in out: print('  #%-3d %-20s %s   (%d Geraet%s, %s)' % (i, lab, key, a.geraete, '' if a.geraete == 1 else 'e', ('Demo: %d Min. ab erster Nutzung' % dm) if dm else (('gueltig bis ' + exp) if exp else 'unbegrenzt')))

def cmd_liste(a):
    need_init(); c = db()
    rows = c.execute('''SELECT l.*, (SELECT COUNT(*) FROM devices d WHERE d.license_id=l.id) AS n,
                        (SELECT MAX(last_seen) FROM devices d WHERE d.license_id=l.id) AS ls FROM licenses l ORDER BY l.id''').fetchall()
    if not rows: print('Noch keine Keys. Erzeugen mit:  python3 lizenzserver.py neu "Name"'); return
    print('%-4s %-20s %-10s %-8s %-19s %-11s %s' % ('ID', 'Name', 'Status', 'Geraete', 'Zuletzt gesehen', 'Gueltig bis', 'Angelegt'))
    for r in rows:
        st = 'gesperrt' if r['revoked'] else ('abgelaufen' if (is_expired(r['expires']) or demo_expired(r)) else ('benutzt' if r['n'] else 'frei'))
        gb = ('Demo ' + (r['demo_until'][11:16] if r['demo_until'] else '%dmin' % r['demo_min'])) if r['demo_min'] else (r['expires'] or '-')
        print('%-4d %-20s %-10s %-8s %-19s %-11s %s' % (r['id'], r['label'][:20], st, '%d/%d' % (r['n'], r['max_devices']), r['ls'] or '-', gb, r['created']))

def _lic(c, id_):
    r = c.execute('SELECT * FROM licenses WHERE id=?', (id_,)).fetchone()
    if not r: print('Keine Lizenz mit ID %s.' % id_); sys.exit(1)
    return r

def cmd_sperren(a):
    need_init(); c = db(); r = _lic(c, a.id); c.execute('UPDATE licenses SET revoked=1 WHERE id=?', (a.id,)); c.commit()
    print('Gesperrt: #%d %s (Geraete verlieren sofort den Zugang)' % (r['id'], r['label']))
def cmd_entsperren(a):
    need_init(); c = db(); r = _lic(c, a.id); c.execute('UPDATE licenses SET revoked=0 WHERE id=?', (a.id,)); c.commit()
    print('Entsperrt: #%d %s' % (r['id'], r['label']))
def cmd_reset(a):
    need_init(); c = db(); r = _lic(c, a.id); c.execute('DELETE FROM devices WHERE license_id=?', (a.id,)); c.commit()
    print('Geraetebindung geloescht: #%d %s. Der Key kann einmal neu aktiviert werden (z.B. fuer ein neues Handy).' % (r['id'], r['label']))
def cmd_ablauf(a):
    need_init(); c = db(); r = _lic(c, a.id)
    try: exp = parse_date(a.datum)
    except ValueError: print('Datum bitte als JJJJ-MM-TT (z.B. 2027-06-30) oder "aus" fuer unbegrenzt.'); sys.exit(1)
    c.execute('UPDATE licenses SET expires=? WHERE id=?', (exp, a.id)); c.commit()
    print('#%d %s: %s' % (r['id'], r['label'], ('gueltig bis einschliesslich ' + exp) if exp else 'unbegrenzt gueltig'))
def cmd_geraete(a):
    need_init(); c = db(); r = _lic(c, a.id)
    if not 1 <= a.anzahl <= 100: print('Anzahl bitte 1 bis 100.'); sys.exit(1)
    c.execute('UPDATE licenses SET max_devices=? WHERE id=?', (a.anzahl, a.id)); c.commit()
    print('#%d %s: bis zu %d Geraet%s erlaubt.' % (r['id'], r['label'], a.anzahl, '' if a.anzahl == 1 else 'e'))
def cmd_ips(a):
    need_init(); c = db(); r = _lic(c, a.id)
    rows = c.execute('SELECT * FROM ip_log WHERE license_id=? ORDER BY last_seen DESC', (a.id,)).fetchall()
    print('IP-Adressen fuer #%d %s:' % (r['id'], r['label']))
    if not rows: print('  noch keine.'); return
    print('  %-45s %-19s %-19s %s' % ('IP', 'Zuletzt', 'Erstmals', 'Aufrufe'))
    for x in rows: print('  %-45s %-19s %-19s %d' % (x['ip'], x['last_seen'], x['first_seen'], x['hits']))
def cmd_loeschen(a):
    need_init(); c = db(); r = _lic(c, a.id); c.execute('DELETE FROM licenses WHERE id=?', (a.id,)); c.commit()
    print('Geloescht: #%d %s' % (r['id'], r['label']))
def cmd_log(a):
    if not os.path.exists(LOG): print('Noch keine Eintraege.'); return
    with open(LOG, encoding='utf-8') as f: lines = f.readlines()
    sys.stdout.write(''.join(lines[-a.n:]))

# ---------------------------------------------------------------- Webserver
def seed_muster(c, lid, label):
    """Demo-Key: legt beim ersten Aufruf des Logbuchs die Muster-Toerns aus daten/muster-toerns.json an (Platzhalter {EIGNER} = Name der Lizenz).
    Die Marke '_muster' verhindert, dass geloeschte Muster wiederkommen."""
    try:
        if c.execute("SELECT 1 FROM logbuch WHERE license_id=? AND eid='_muster'", (lid,)).fetchone(): return
        with open(os.path.join(DATA, 'muster-toerns.json'), encoding='utf-8') as f: txt = f.read()
        rows = json.loads(txt.replace('{EIGNER}', json.dumps(label or 'Demo', ensure_ascii=False)[1:-1]))
        for v in rows:
            if isinstance(v, dict) and isinstance(v.get('id'), str):
                c.execute('INSERT OR IGNORE INTO logbuch(license_id, eid, data) VALUES(?,?,?)', (lid, v['id'], json.dumps(v, ensure_ascii=False)))
        c.execute("INSERT OR IGNORE INTO logbuch(license_id, eid, data) VALUES(?,?,?)", (lid, '_muster', '{}'))
        c.commit()
    except (OSError, ValueError, sqlite3.Error): pass



class H(BaseHTTPRequestHandler):
    server_version = 'GeoSailing'
    sys_version = ''
    def log_message(self, *a): pass
    def ip(self):
        if self.server.proxy:
            # Der Proxy (Apache/nginx) haengt die echte Client-IP hinten an. Ein vom Besucher
            # mitgeschickter Wert steht davor und wird bewusst ignoriert (kein Faelschen der IP).
            x = (self.headers.get('X-Forwarded-For') or '').split(',')[-1].strip()
            if x: return x[:45]
        return self.client_address[0]
    def send(self, code, body, ctype='text/html; charset=utf-8', extra=None):
        if isinstance(body, str): body = body.encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', ctype); self.send_header('Content-Length', str(len(body)))
        self.send_header('Cache-Control', 'no-store'); self.send_header('X-Content-Type-Options', 'nosniff')
        self.send_header('Referrer-Policy', 'no-referrer')
        for k, v in (extra or {}).items(): self.send_header(k, v)
        self.end_headers()
        if self.command != 'HEAD': self.wfile.write(body)
    def cookie_dev(self):
        try:
            ck = SimpleCookie(self.headers.get('Cookie') or '')
            v = ck[COOKIE].value if COOKIE in ck else ''
        except Exception: return ''
        return v if DEV_RE.match(v or '') else ''
    def cookie_hdr(self, tok):
        sec = '; Secure' if (self.headers.get('X-Forwarded-Proto') or '').lower() == 'https' else ''
        return {'Set-Cookie': '%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax%s' % (COOKIE, tok, COOKIE_AGE, sec)}
    def jerr(self, code, msg): self.send(code, json.dumps({'error': msg}), 'application/json')
    def jden(self, msg, **extra):   # 403 mit denied-Merker: nur dann loescht das Geraet seine Offline-Kopien
        d = {'error': msg, 'denied': True}; d.update(extra); self.send(403, json.dumps(d), 'application/json')
    def do_HEAD(self): self.do_GET()
    def do_GET(self):
        try: self._get()
        except Exception as e: self.crash(e)
    def crash(self, e):
        import traceback; traceback.print_exc(file=sys.stderr); sys.stderr.flush()
        if isinstance(e, sqlite3.OperationalError):
            msg = 'Datenbank nicht beschreibbar (Dateirechte auf dem Server prüfen: lizenzserver.py pruefen).'
        else:
            msg = 'Serverfehler. Details im Server-Protokoll (journalctl -u geo-gleinig -n 30).'
        try: self.jerr(500, msg)
        except Exception: pass
    def _get(self):
        p = self.path.split('?', 1)[0].split('#', 1)[0]
        name = os.path.basename(p) or 'index.html'
        if p.endswith('/') or p == '': name = 'index.html'
        if name.endswith('.html') and name[:-5] in rechtliches.RENDER and rechtliches.active(DATA):   # Rechtstexte: bewusst ohne Key abrufbar
            return self.send(200, rechtliches.render(name[:-5], DATA, cfg().get('geo', True) is not False))
        if name.endswith('.html') and (name[:-5] in PAGES or name[:-5] == ADMIN_PAGE):
            sh = SHELL.replace('__NAME__', name[:-5])
            if not rechtliches.active(DATA): sh = re.sub(r'<!--RECHT-->.*?<!--/RECHT-->', '', sh, flags=re.S)   # Rechtstexte abgeschaltet: auch die Links weg
            return self.send(200, sh)
        fp = os.path.join(PUBLIC_DIR, name)
        if name and re.match(r'^[A-Za-z0-9._-]+$', name) and os.path.isfile(fp):
            with open(fp, 'rb') as f: data = f.read()
            return self.send(200, data, MIME.get(os.path.splitext(name)[1].lower(), 'application/octet-stream'))
        self.send(404, 'Nicht gefunden', 'text/plain; charset=utf-8')
    def body(self, lim=4096):
        try:
            if 'chunked' in (self.headers.get('Transfer-Encoding') or '').lower():
                raw = b''
                while True:
                    ln = int((self.rfile.readline(64).strip() or b'0').split(b';')[0], 16)
                    if ln == 0:
                        self.rfile.readline(); break
                    raw += self.rfile.read(ln); self.rfile.readline()
                    if len(raw) > lim: return None
            else:
                n = int(self.headers.get('Content-Length') or 0)
                if n <= 0 or n > lim: return None
                raw = self.rfile.read(n)
            return json.loads(raw.decode('utf-8'))
        except Exception: return None
    def do_POST(self):
        try: self._post()
        except Exception as e: self.crash(e)
    def _post(self):
        p = self.path.split('?', 1)[0]
        if p.endswith('/api/redeem'): return self.redeem()
        if p.endswith('/api/page'): return self.page()
        if p.endswith('/api/check'): return self.check()
        if p.endswith('/api/admin'): return self.admin()
        if p.endswith('/api/logbuch'): return self.logbuch()
        self.send(404, 'Nicht gefunden', 'text/plain; charset=utf-8')
    def limited(self, c, ip):
        c.execute('DELETE FROM fails WHERE ts<?', (time.time() - FAIL_WINDOW,))
        return c.execute('SELECT COUNT(*) FROM fails WHERE ip=?', (ip,)).fetchone()[0] >= MAX_FAILS
    def redeem(self):
        ip = self.ip(); j = self.body(); c = db()
        try:
            if self.limited(c, ip):
                c.commit(); log(ip, 'GESPERRT-ZU-OFT'); return self.jerr(429, 'Zu viele Fehlversuche. Bitte in 10 Minuten erneut versuchen.')
            dev = (j or {}).get('dev', ''); key = norm_key((j or {}).get('key', ''))
            qr = (j or {}).get('qr')
            if qr is not None: return self.redeem_qr(c, ip, dev, qr)
            if not isinstance(dev, str) or not DEV_RE.match(dev) or len(key) != 16:
                c.execute('INSERT INTO fails VALUES(?,?)', (ip, time.time())); c.commit(); log(ip, 'ABGELEHNT', 'Format'); return self.jerr(403, 'Key ungültig.')
            lic = c.execute('SELECT * FROM licenses WHERE key_hash=?', (sha(key),)).fetchone()
            if not lic or lic['revoked']:
                c.execute('INSERT INTO fails VALUES(?,?)', (ip, time.time())); c.commit(); log(ip, 'ABGELEHNT', 'unbekannt/gesperrt'); return self.jerr(403, 'Key ungültig oder gesperrt.')
            if is_expired(lic['expires']):
                log(ip, 'ABGELEHNT', '%s (#%d): abgelaufen' % (lic['label'], lic['id'])); return self.jerr(403, expired_msg(lic['expires']))
            if demo_expired(lic):
                log(ip, 'ABGELEHNT', '%s (#%d): Demo abgelaufen' % (lic['label'], lic['id'])); return self.jerr(403, demo_msg())
            ck = self.cookie_dev()
            if ck:
                same = c.execute('SELECT * FROM devices WHERE dev_hash=? AND license_id=?', (sha(ck), lic['id'])).fetchone()
                if same:   # Dieses Geraet ist schon mit genau diesem Key aktiviert (Browser hat sich das gemerkt)
                    c.execute('UPDATE devices SET last_seen=?, ip=? WHERE id=?', (now(), ip, same['id'])); touch_ip(c, lic['id'], ip); start_demo(c, lic); c.commit()
                    log(ip, 'LOGIN', '%s (#%d)' % (lic['label'], lic['id']))
                    return self.send(200, json.dumps({'ok': True, 'dev': ck}), 'application/json', self.cookie_hdr(ck))
            dh = sha(dev); d = c.execute('SELECT * FROM devices WHERE dev_hash=? AND license_id=?', (dh, lic['id'])).fetchone()   # ein Geraet darf mehrere Keys haben
            if not d:
                n = c.execute('SELECT COUNT(*) FROM devices WHERE license_id=?', (lic['id'],)).fetchone()[0]
                if n >= lic['max_devices']:
                    c.execute('INSERT INTO fails VALUES(?,?)', (ip, time.time())); c.commit()
                    log(ip, 'ABGELEHNT', '%s (#%d): schon benutzt' % (lic['label'], lic['id'])); return self.jerr(403, 'Dieser Key ist schon auf einem anderen Gerät aktiviert.')
                c.execute('INSERT INTO devices(license_id,dev_hash,first_seen,last_seen,ip) VALUES(?,?,?,?,?)', (lic['id'], dh, now(), now(), ip))
                log(ip, 'AKTIVIERT', '%s (#%d)' % (lic['label'], lic['id']))
            else:
                c.execute('UPDATE devices SET last_seen=?, ip=? WHERE id=?', (now(), ip, d['id'])); log(ip, 'LOGIN', '%s (#%d)' % (lic['label'], lic['id']))
            touch_ip(c, lic['id'], ip); start_demo(c, lic)
            c.commit(); self.send(200, json.dumps({'ok': True, 'dev': dev}), 'application/json', self.cookie_hdr(dev))
        finally: c.close()
    def redeem_qr(self, c, ip, dev, qr):
        """Einmal-QR-Code aus dem Admin-Panel: bindet dieses Geraet an die Lizenz QR_LICENSE, auch wenn deren Geraete-Limit erreicht ist.
        Der Token gilt nur kurz, nur einmal und nur fuer diese eine Lizenz."""
        def bad(why):
            c.execute('INSERT INTO fails VALUES(?,?)', (ip, time.time())); c.commit(); log(ip, 'ABGELEHNT', 'QR: ' + why)
            return self.jerr(403, 'Dieser QR-Code ist ungültig oder abgelaufen. Bitte einen neuen erzeugen lassen.')
        if not isinstance(dev, str) or not DEV_RE.match(dev) or not isinstance(qr, str) or not re.match(r'^[A-Za-z0-9_-]{20,64}$', qr): return bad('Format')
        c.execute('DELETE FROM qr_tokens WHERE created<?', (time.time() - qr_minutes() * 60,))
        t = c.execute('SELECT * FROM qr_tokens WHERE token_hash=?', (sha(qr),)).fetchone()
        if not t or t['license_id'] != QR_LICENSE: return bad('unbekannt/abgelaufen')
        lic = c.execute('SELECT * FROM licenses WHERE id=?', (QR_LICENSE,)).fetchone()
        if not lic or lic['revoked'] or is_expired(lic['expires']) or demo_expired(lic): return bad('Lizenz nicht gueltig')
        c.execute('DELETE FROM qr_tokens WHERE token_hash=?', (t['token_hash'],))   # Einmal-Token: sofort verbraucht
        dh = sha(dev); d = c.execute('SELECT * FROM devices WHERE dev_hash=? AND license_id=?', (dh, lic['id'])).fetchone()
        if d: c.execute('UPDATE devices SET last_seen=?, ip=? WHERE id=?', (now(), ip, d['id']))
        else: c.execute('INSERT INTO devices(license_id,dev_hash,first_seen,last_seen,ip) VALUES(?,?,?,?,?)', (lic['id'], dh, now(), now(), ip))
        touch_ip(c, lic['id'], ip); c.commit()
        log(ip, 'AKTIVIERT', '%s (#%d) per QR-Code' % (lic['label'], lic['id']))
        self.send(200, json.dumps({'ok': True, 'dev': dev}), 'application/json', self.cookie_hdr(dev))
    def who(self, c, dev):
        """Welche Lizenz gehoert zu diesem Geraet? Gibt (Zeile, benutztes Token) oder (None, None) zurueck."""
        cands = []
        if isinstance(dev, str) and DEV_RE.match(dev): cands.append(dev)
        ck = self.cookie_dev()
        if ck and ck not in cands: cands.append(ck)
        for cand in cands:
            r = c.execute('''SELECT d.id AS did, l.id AS lid, l.label, l.expires AS exp, l.demo_min AS dmin, l.demo_until AS dunt FROM devices d JOIN licenses l ON l.id=d.license_id
                             WHERE d.dev_hash=? AND l.revoked=0 AND (l.expires IS NULL OR l.expires>=?) AND (l.demo_until IS NULL OR l.demo_until>=?)
                             ORDER BY d.last_seen DESC, d.id DESC''', (sha(cand), today(), now())).fetchone()   # mehrere Keys: der zuletzt eingegebene bzw. benutzte gilt
            if r: return r, cand
        return None, None
    def expired_for(self, c, dev):
        """Hinweistext, falls das Geraet zu einem (nicht gesperrten) abgelaufenen Key oder abgelaufenen Demo-Key gehoert, sonst None."""
        cands = [x for x in (dev, self.cookie_dev()) if isinstance(x, str) and DEV_RE.match(x)]
        for cand in cands:
            r = c.execute('''SELECT l.expires, l.demo_until FROM devices d JOIN licenses l ON l.id=d.license_id
                             WHERE d.dev_hash=? AND l.revoked=0 AND ((l.expires IS NOT NULL AND l.expires<?) OR (l.demo_until IS NOT NULL AND l.demo_until<?))''', (sha(cand), today(), now())).fetchone()
            if r: return expired_msg(r['expires']) if is_expired(r['expires']) else demo_msg()
        return None
    def check(self):
        """Lizenzpruefung ohne Seiteninhalt: wird vom Geraet aufgerufen, sobald es nach Offline-Nutzung wieder Netz hat."""
        ip = self.ip(); j = self.body() or {}; dev = j.get('dev', ''); c = db()
        try:
            r, used = self.who(c, dev)
            if not r:
                exp = self.expired_for(c, dev)
                if exp:
                    log(ip, 'ABGELAUFEN', 'pruefung'); return self.jden(exp, expired=True)
                log(ip, 'KEIN-ZUGRIFF', 'pruefung'); return self.jden('Nicht angemeldet.')
            c.execute('UPDATE devices SET last_seen=?, ip=? WHERE id=?', (now(), ip, r['did'])); touch_ip(c, r['lid'], ip); c.commit()
            log(ip, 'PRUEFUNG', '%s (#%d)' % (r['label'], r['lid']))
            self.send(200, json.dumps({'ok': True, 'exp': r['exp'] or '', 'days': 0 if r['dmin'] else offline_days()}), 'application/json', self.cookie_hdr(used))
        finally: c.close()
    def page(self):
        ip = self.ip(); j = self.body() or {}; name = j.get('name'); dev = j.get('dev', '')
        if name not in PAGES and name != ADMIN_PAGE: return self.jerr(403, 'Nicht angemeldet.')
        c = db()
        try:
            r, used = self.who(c, dev)
            if not r:
                exp = self.expired_for(c, dev)
                if exp:
                    log(ip, 'ABGELAUFEN', name)
                    return self.jden(exp, expired=True)
                log(ip, 'KEIN-ZUGRIFF', name); return self.jden('Nicht angemeldet.')
            adm = is_admin(c, r['lid'])
            if name == ADMIN_PAGE and not adm:
                log(ip, 'KEIN-ZUGRIFF', 'admin: %s (#%d)' % (r['label'], r['lid'])); return self.jerr(403, 'Nicht angemeldet.')
            c.execute('UPDATE devices SET last_seen=?, ip=? WHERE id=?', (now(), ip, r['did'])); touch_ip(c, r['lid'], ip); c.commit()
        finally: c.close()
        fp = os.path.join(PAGES_DIR, name + '.html')
        try:
            with open(fp, 'rb') as f: data = f.read()
        except OSError: return self.jerr(500, 'Seite fehlt auf dem Server.')
        if name == 'index':   # Admin-Kachel gibt es nur im HTML, das an die Admin-Lizenz geht
            data = data.replace(ADMIN_MARK, ADMIN_TILE if adm else b'')
            info = {}
            if r['dmin'] and r['dunt']:
                try: info['demo'] = max(0, int((datetime.strptime(r['dunt'], '%Y-%m-%d %H:%M:%S') - datetime.now()).total_seconds()))
                except ValueError: pass
            elif r['exp']: info['exp'] = r['exp']
            # Spenden-Knopf im Dashboard: nicht bei Demo-Keys, nicht bei Lizenz Nr. 21 und nicht bei Lizenzen namens "Papa" (auch "Papa 1", "Papa 2" ...)
            if not r['dmin'] and r['lid'] != 21 and re.sub(r'\s*\d+$', '', (r['label'] or '').strip()).lower() != 'papa': info['spende'] = True
            data = data.replace(LIC_MARK, ('<script>window.GG_LIC=' + json.dumps(info) + ';</script>').encode('utf-8'))
        i = data.rfind(b'</body>')   # Links zu Impressum, Datenschutz und Haftungsausschluss auf jeder Seite
        if i >= 0 and rechtliches.active(DATA): data = data[:i] + rechtliches.FOOTER.encode('utf-8') + data[i:]
        log(ip, 'SEITE ' + name, '%s (#%d)' % (r['label'], r['lid']))
        hdr = self.cookie_hdr(used); hdr['X-Dev'] = used; hdr['X-Exp'] = r['exp'] or ''; hdr['X-Offline-Days'] = '0' if r['dmin'] else str(offline_days())   # Demo-Keys: nie offline nutzbar   # Token auch an die Seite melden, damit Browser-Speicher und Cookie sich gegenseitig auffuellen
        self.send(200, data, 'text/html; charset=utf-8', hdr)

    def logbuch(self):
        """Logbuch je Lizenz: jede Anfrage wird einzeln geprueft, Zeilen gehoeren immer nur zur Lizenz des Geraets.
        Geteilte Toerns: Jeder schreibt nur in seine eigenen Zeilen; beim Lesen (list) werden die Eintraege der anderen Teilnehmer
        eines gemeinsamen Toerns dazugegeben. Den Toern selbst (Abschliessen, Loeschen) aendert nur der Besitzer, denn sein Datensatz liegt nur bei ihm."""
        j = self.body() or {}; op = j.get('op'); c = db()
        try:
            r, used = self.who(c, j.get('dev', ''))
            if not r: return self.jerr(403, 'Nicht angemeldet.')
            lid = r['lid']
            def out(o): self.send(200, json.dumps(o), 'application/json', self.cookie_hdr(used))
            eid = j.get('eid'); d = j.get('data'); code = j.get('code')
            labels = {}
            def label(i):
                if i not in labels:
                    x = c.execute('SELECT label FROM licenses WHERE id=?', (i,)).fetchone(); labels[i] = x['label'] if x else '?'
                return labels[i]
            def tour_of(sh):
                row = c.execute('SELECT data FROM logbuch WHERE license_id=? AND eid=?', (sh['owner'], sh['tid'])).fetchone()
                if not row: return None
                try: t = json.loads(row['data'])
                except ValueError: return None
                return t if isinstance(t, dict) else None
            def share_of(cd):
                if not (isinstance(cd, str) and re.match(r'^[A-Za-z0-9]{10,40}$', cd)): return None
                return c.execute('SELECT code, owner, tid FROM lb_share WHERE code=?', (cd,)).fetchone()
            if op in ('save', 'del') and isinstance(eid, str) and re.match(r'^s\d+-[A-Za-z0-9_-]{1,40}$', eid):
                # Besitzer eines gemeinsamen Toerns bearbeitet/loescht Eintraege der Teilnehmer (Zeile liegt in deren Daten, Id: s<Lizenz>-<Eintrag>)
                p, pe = int(eid[1:].split('-', 1)[0]), eid.split('-', 1)[1]
                row = c.execute('SELECT data FROM logbuch WHERE license_id=? AND eid=?', (p, pe)).fetchone()
                try: old = json.loads(row['data']) if row else None
                except ValueError: old = None
                if not isinstance(old, dict) or old.get('k') == 't' or not isinstance(old.get('tid'), str): return self.jerr(400, 'Ungueltig.')
                if not c.execute('SELECT 1 FROM lb_share s JOIN lb_member m ON m.code=s.code WHERE s.owner=? AND s.tid=? AND m.lid=?', (lid, old['tid'], p)).fetchone():
                    return self.jerr(400, 'Keine Berechtigung.')
                if op == 'del':
                    c.execute('DELETE FROM logbuch WHERE license_id=? AND eid=?', (p, pe)); c.commit(); return out({'ok': True})
                if not isinstance(d, dict): return self.jerr(400, 'Ungueltig.')
                for k in [k for k in d if str(k).startswith('_')]: del d[k]
                d['id'] = pe; d['tid'] = old['tid']; d.pop('k', None)
                txt = json.dumps(d, ensure_ascii=False)
                if len(txt.encode('utf-8')) > 3500: return self.jerr(400, 'Eintrag zu gross.')
                c.execute('UPDATE logbuch SET data=? WHERE license_id=? AND eid=?', (txt, p, pe)); c.commit(); return out({'ok': True})
            if op == 'list':
                mf = c.execute('SELECT muster FROM licenses WHERE id=?', (lid,)).fetchone()
                if r['dmin'] or (mf and mf['muster']): seed_muster(c, lid, r['label'])   # Demo-Keys und Lizenzen mit gesetztem Haken bekommen einmalig die Muster-Toerns zum Ansehen
                ent, toern = [], {}
                for row in c.execute('SELECT eid, data FROM logbuch WHERE license_id=?', (lid,)):
                    try: v = json.loads(row['data'])
                    except ValueError: continue
                    if row['eid'] == '_toern': toern = v
                    elif row['eid'] == '_muster': continue
                    else: ent.append(v)
                shares = c.execute('SELECT code, owner, tid FROM lb_share WHERE owner=? OR code IN (SELECT code FROM lb_member WHERE lid=?)', (lid, lid)).fetchall()
                for sh in shares:
                    members = [m['lid'] for m in c.execute('SELECT lid FROM lb_member WHERE code=?', (sh['code'],))]
                    crew = [{'i': p, 'n': label(p)} for p in [sh['owner']] + members]
                    crew += [{'i': -g['gid'], 'n': g['name'], 'g': 1} for g in c.execute('SELECT gid, name FROM lb_gast WHERE code=? ORDER BY rowid', (sh['code'],))]
                    wr = c.execute('SELECT lid, t FROM lb_wache WHERE code=?', (sh['code'],)).fetchone()
                    wache = {'i': wr['lid'] if wr and wr['lid'] in [x['i'] for x in crew] else sh['owner'], 't': wr['t'] if wr else None}
                    if sh['owner'] != lid:
                        t = tour_of(sh)
                        if not t: continue
                        t['_by'] = label(sh['owner']); t['sh'] = sh['code']; t['_crew'] = crew; t['_wache'] = wache; ent.append(t)
                    else:
                        for e in ent:
                            if isinstance(e, dict) and e.get('k') == 't' and e.get('id') == sh['tid']:
                                if members: e['_with'] = [label(m) for m in members]
                                e['_crew'] = crew; e['_wache'] = wache
                    for p in [sh['owner']] + members:
                        if p == lid: continue
                        for row in c.execute('SELECT eid, data FROM logbuch WHERE license_id=? AND data LIKE ?', (p, '%"tid": "' + sh['tid'] + '"%')):
                            try: v = json.loads(row['data'])
                            except ValueError: continue
                            if not isinstance(v, dict) or v.get('k') == 't' or v.get('tid') != sh['tid']: continue
                            v['id'] = ('s%d-%s' % (p, row['eid']))[:60]; v['_by'] = label(p); ent.append(v)
                return out({'lid': lid, 'entries': ent, 'toern': toern})
            if op == 'save':
                if not (isinstance(eid, str) and re.match(r'^[A-Za-z0-9_-]{1,40}$', eid) and isinstance(d, dict)) or eid == '_toern': return self.jerr(400, 'Ungueltig.')
                for k in [k for k in d if str(k).startswith('_')]: del d[k]   # Anzeige-Felder (z.B. _by) gehoeren nie in die Datenbank
                d['id'] = eid
                if d.get('k') == 't':
                    sh = d.get('sh')
                    if sh is not None:   # Freigabe-Code des Toerns: wird mit dem Toern angelegt, gehoert dem Besitzer
                        if not (isinstance(sh, str) and re.match(r'^[A-Za-z0-9]{10,40}$', sh)): return self.jerr(400, 'Ungueltig.')
                        x = c.execute('SELECT owner, tid FROM lb_share WHERE code=?', (sh,)).fetchone()
                        if x and (x['owner'] != lid or x['tid'] != eid): return self.jerr(400, 'Ungueltig.')
                        if not x: c.execute('INSERT OR IGNORE INTO lb_share(code, owner, tid) VALUES(?,?,?)', (sh, lid, eid))
                elif isinstance(d.get('tid'), str):   # Teilnehmer duerfen nur in offene Toerns schreiben (Eintraege von vor dem Abschliessen werden noch angenommen)
                    x = c.execute('SELECT s.code, s.owner, s.tid FROM lb_share s JOIN lb_member m ON m.code=s.code WHERE s.tid=? AND m.lid=?', (d['tid'], lid)).fetchone()
                    if x:
                        t = tour_of(x)
                        if not t: return self.jerr(400, 'Dieser Toern existiert nicht mehr.')
                        cl = t.get('c')
                        if cl and not (isinstance(d.get('t'), (int, float)) and d['t'] <= cl): return self.jerr(400, 'Dieser Toern ist abgeschlossen.')
                txt = json.dumps(d, ensure_ascii=False)
                if len(txt.encode('utf-8')) > 3500: return self.jerr(400, 'Eintrag zu gross.')
                if not c.execute('SELECT 1 FROM logbuch WHERE license_id=? AND eid=?', (lid, eid)).fetchone() and c.execute('SELECT COUNT(*) FROM logbuch WHERE license_id=?', (lid,)).fetchone()[0] >= 5000:
                    return self.jerr(400, 'Logbuch voll (5000 Eintraege).')
                c.execute('INSERT OR REPLACE INTO logbuch(license_id, eid, data) VALUES(?,?,?)', (lid, eid, txt)); c.commit(); return out({'ok': True})
            if op == 'del':
                if not isinstance(eid, str): return self.jerr(400, 'Ungueltig.')
                c.execute('DELETE FROM logbuch WHERE license_id=? AND eid=?', (lid, eid))
                c.execute('DELETE FROM lb_share WHERE owner=? AND tid=?', (lid, eid))   # Besitzer loescht den Toern: Freigabe und Beitritte enden
                c.commit(); return out({'ok': True})
            if op == 'wache':   # Wachfuehrer wechseln: Besitzer und Teilnehmer des Toerns, nur solange der Toern offen ist
                sh = share_of(code); who = j.get('who')
                if not sh or not isinstance(who, int) or isinstance(who, bool): return self.jerr(400, 'Ungueltig.')
                mem = [m['lid'] for m in c.execute('SELECT lid FROM lb_member WHERE code=?', (sh['code'],))]
                if lid != sh['owner'] and lid not in mem: return self.jerr(400, 'Keine Berechtigung.')
                if who < 0:   # von Hand hinzugefuegte Person
                    if not c.execute('SELECT 1 FROM lb_gast WHERE code=? AND gid=?', (sh['code'], -who)).fetchone(): return self.jerr(400, 'Diese Person ist nicht im Toern.')
                elif who != sh['owner'] and who not in mem: return self.jerr(400, 'Diese Person ist nicht im Toern.')
                t = tour_of(sh)
                if not t or t.get('c'): return self.jerr(400, 'Dieser Toern ist abgeschlossen.')
                c.execute('INSERT OR REPLACE INTO lb_wache(code, lid, t) VALUES(?,?,?)', (sh['code'], who, time.time() * 1000)); c.commit(); return out({'ok': True})
            if op in ('gast', 'gdel'):   # Wachfuehrer von Hand hinzufuegen/entfernen: jeder im Toern (Besitzer und Teilnehmer), nur solange der Toern offen ist
                sh = share_of(code); gid = j.get('gid')
                if not sh or not isinstance(gid, int) or isinstance(gid, bool) or not (0 < gid < 2 ** 53): return self.jerr(400, 'Ungueltig.')
                if lid != sh['owner'] and not c.execute('SELECT 1 FROM lb_member WHERE code=? AND lid=?', (sh['code'], lid)).fetchone(): return self.jerr(400, 'Keine Berechtigung.')
                t = tour_of(sh)
                if not t or t.get('c'): return self.jerr(400, 'Dieser Toern ist abgeschlossen.')
                if op == 'gdel':
                    c.execute('DELETE FROM lb_gast WHERE code=? AND gid=?', (sh['code'], gid))
                    c.execute('DELETE FROM lb_wache WHERE code=? AND lid=?', (sh['code'], -gid))   # hatte die Person die Wache, faellt sie an den Besitzer zurueck
                    c.commit(); return out({'ok': True})
                name = re.sub(r'\s+', ' ', str(j.get('name') or '')).strip()[:40]
                if not name: return self.jerr(400, 'Bitte einen Namen angeben.')
                if not c.execute('SELECT 1 FROM lb_gast WHERE code=? AND gid=?', (sh['code'], gid)).fetchone() and c.execute('SELECT COUNT(*) FROM lb_gast WHERE code=?', (sh['code'],)).fetchone()[0] >= 20:
                    return self.jerr(400, 'Es sind schon 20 Personen von Hand hinzugefuegt.')
                c.execute('INSERT OR REPLACE INTO lb_gast(code, gid, name) VALUES(?,?,?)', (sh['code'], gid, name)); c.commit(); return out({'ok': True})
            if op in ('peek', 'join', 'leave'):
                sh = share_of(code)
                if op == 'leave':
                    if sh: c.execute('DELETE FROM lb_member WHERE code=? AND lid=?', (sh['code'], lid)); c.commit()
                    return out({'ok': True})
                if not sh: return self.jerr(400, 'Dieser Code ist ungueltig oder der Toern wurde geloescht.')
                if sh['owner'] == lid: return self.jerr(400, 'Das ist dein eigener Toern.')
                t = tour_of(sh)
                if not t: return self.jerr(400, 'Dieser Toern existiert nicht mehr.')
                if op == 'peek': return out({'n': str(t.get('n') or '')[:100], 'v': str(t.get('v') or '')[:100], 'z': str(t.get('z') or '')[:100], 'by': label(sh['owner']), 'closed': bool(t.get('c'))})
                if c.execute('SELECT COUNT(*) FROM lb_member WHERE code=?', (sh['code'],)).fetchone()[0] >= 20 and not c.execute('SELECT 1 FROM lb_member WHERE code=? AND lid=?', (sh['code'], lid)).fetchone():
                    return self.jerr(400, 'Dieser Toern hat schon 20 Teilnehmer.')
                c.execute('INSERT OR IGNORE INTO lb_member(code, lid, joined) VALUES(?,?,?)', (sh['code'], lid, now())); c.commit()
                log(self.ip(), 'LOGBUCH-BEITRITT', '%s (#%d) -> %s' % (r['label'], lid, label(sh['owner'])))
                return out({'ok': True})
            if op == 'toern':
                if not isinstance(d, dict): return self.jerr(400, 'Ungueltig.')
                t = dict((k, str(d.get(k) or '')[:100]) for k in ('tn', 'tv', 'tz'))
                c.execute('INSERT OR REPLACE INTO logbuch(license_id, eid, data) VALUES(?,?,?)', (lid, '_toern', json.dumps(t, ensure_ascii=False))); c.commit(); return out({'ok': True})
            self.jerr(400, 'Unbekannte Aktion.')
        finally: c.close()

    def admin(self):
        ip = self.ip(); j = self.body(40000) or {}; act = j.get('action'); c = db()
        try:
            r, used = self.who(c, j.get('dev', ''))
            if not r or not is_admin(c, r['lid']):
                log(ip, 'KEIN-ZUGRIFF', 'admin-api'); return self.jerr(403, 'Nicht angemeldet.')
            me = admin_id(); who = '%s (#%d)' % (r['label'], r['lid'])
            def out(o): self.send(200, json.dumps(o), 'application/json', self.cookie_hdr(used))
            def lic_id():
                try: i = int(j.get('id'))
                except Exception: i = 0
                row = c.execute('SELECT * FROM licenses WHERE id=?', (i,)).fetchone()
                if not row: self.jerr(404, 'Key nicht gefunden.'); return None
                return row
            if act == 'list':
                rows = c.execute('''SELECT l.id, l.label, l.created, l.max_devices, l.revoked, l.expires, l.demo_min, l.demo_until,
                    (SELECT COUNT(*) FROM devices d WHERE d.license_id=l.id) AS n,
                    (SELECT MAX(last_seen) FROM devices d WHERE d.license_id=l.id) AS last_seen,
                    (SELECT ip FROM devices d WHERE d.license_id=l.id ORDER BY last_seen DESC LIMIT 1) AS ip
                    FROM licenses l ORDER BY l.id''').fetchall()
                return out({'ok': True, 'me': me, 'today': today(), 'now': now(), 'licenses': [dict(x) for x in rows]})
            if act == 'detail':
                row = lic_id()
                if row is None: return
                ips = c.execute('SELECT ip, first_seen, last_seen, hits FROM ip_log WHERE license_id=? ORDER BY last_seen DESC', (row['id'],)).fetchall()
                devs = c.execute('SELECT id, first_seen, last_seen, ip FROM devices WHERE license_id=? ORDER BY last_seen DESC', (row['id'],)).fetchall()
                return out({'ok': True, 'now': now(), 'license': {'id': row['id'], 'label': row['label'], 'created': row['created'], 'max_devices': row['max_devices'], 'revoked': row['revoked'],
                            'expires': row['expires'], 'demo_min': row['demo_min'], 'demo_until': row['demo_until']},
                            'ips': [dict(x) for x in ips], 'devices': [dict(x) for x in devs],
                            'admin': is_admin(c, row['id']), 'admin_main': row['id'] == me, 'can_grant': r['lid'] == me, 'qr': row['id'] == QR_LICENSE, 'muster': bool(row['muster'])})
            if act == 'admin_set':   # Admin-Rechte geben/entziehen: nur der Haupt-Admin, nicht fuer Demo-Keys und nicht fuer den Haupt-Admin selbst
                if r['lid'] != me: return self.jerr(403, 'Nur der Haupt-Admin darf Admin-Rechte vergeben.')
                row = lic_id()
                if row is None: return
                if row['id'] == me: return self.jerr(400, 'Der Haupt-Admin behält seine Rechte (config.json).')
                want = j.get('admin') is True
                if want and row['demo_min']: return self.jerr(400, 'Demo-Keys können keine Admin-Rechte bekommen.')
                if want: c.execute('INSERT OR IGNORE INTO admins(license_id) VALUES(?)', (row['id'],))
                else: c.execute('DELETE FROM admins WHERE license_id=?', (row['id'],))
                c.commit()
                log(ip, 'ADMIN admin-rechte', '#%d %s: %s von %s' % (row['id'], row['label'], 'gegeben' if want else 'entzogen', who))
                return out({'ok': True, 'admin': want})
            if act == 'muster_set':   # Haken "Muster-Toerns anlegen": an = werden angelegt (sofort, auch wieder nach dem Loeschen), aus = es kommen keine neuen; vorhandene Eintraege bleiben
                row = lic_id()
                if row is None: return
                want = j.get('muster') is True
                c.execute('UPDATE licenses SET muster=? WHERE id=?', (1 if want else 0, row['id']))
                if want:
                    c.execute("DELETE FROM logbuch WHERE license_id=? AND eid='_muster'", (row['id'],)); c.commit()
                    seed_muster(c, row['id'], row['label'])
                c.commit()
                log(ip, 'ADMIN muster', '#%d %s: %s von %s' % (row['id'], row['label'], 'an' if want else 'aus', who))
                return out({'ok': True, 'muster': want})
            if act == 'qr_neu':   # QR-Code fuer ein weiteres Geraet: nur Lizenz QR_LICENSE, immer nur ein gueltiger Code, Einmalgebrauch
                row = c.execute('SELECT * FROM licenses WHERE id=?', (QR_LICENSE,)).fetchone()
                if not row: return self.jerr(404, 'Lizenz Nr. %d existiert nicht.' % QR_LICENSE)
                if row['revoked'] or is_expired(row['expires']) or demo_expired(row): return self.jerr(400, 'Lizenz Nr. %d ist gesperrt oder abgelaufen.' % QR_LICENSE)
                tok = secrets.token_urlsafe(24)
                c.execute('DELETE FROM qr_tokens'); c.execute('INSERT INTO qr_tokens VALUES(?,?,?)', (sha(tok), QR_LICENSE, time.time())); c.commit()
                log(ip, 'ADMIN qr-code', '#%d %s: QR-Code erzeugt (%d Min. gueltig) von %s' % (row['id'], row['label'], qr_minutes(), who))
                return out({'ok': True, 'token': tok, 'minutes': qr_minutes()})
            if act == 'geraet_ab':   # ein einzelnes Geraet abmelden: nur dessen Bindung wird geloescht, der Key bleibt gueltig und der Platz wird wieder frei
                row = lic_id()
                if row is None: return
                try: did = int(j.get('device'))
                except Exception: return self.jerr(400, 'Ungültiges Gerät.')
                d = c.execute('SELECT * FROM devices WHERE id=? AND license_id=?', (did, row['id'])).fetchone()
                if not d: return self.jerr(404, 'Gerät nicht gefunden (evtl. schon abgemeldet).')
                if d['id'] == r['did']: return self.jerr(400, 'Das ist das Gerät, mit dem du gerade angemeldet bist. Es lässt sich hier nicht abmelden (sonst sperrst du dich selbst aus).')
                c.execute('DELETE FROM devices WHERE id=?', (d['id'],)); c.commit()
                n = c.execute('SELECT COUNT(*) FROM devices WHERE license_id=?', (row['id'],)).fetchone()[0]
                log(ip, 'ADMIN geraet-abmelden', '#%d %s: Gerät #%d (zuletzt %s, IP %s) von %s' % (row['id'], row['label'], d['id'], d['last_seen'], d['ip'] or '-', who))
                return out({'ok': True, 'used': n})
            if act == 'geo':
                ips = [str(x)[:45] for x in (j.get('ips') or []) if isinstance(x, str)][:60]
                g, pend = geo_lookup(c, list(dict.fromkeys(ips)))
                return out({'ok': True, 'geo': g, 'pending': pend})
            if act == 'recht_get':
                v = rechtliches.load(DATA)
                return out({'ok': True, 'aktiv': v.get('_aktiv', True), 'groups': rechtliches.spec(v), 'missing': rechtliches.missing(v), 'stand': v.get('_stand', ''),
                            'geo': cfg().get('geo', True) is not False})
            if act == 'recht_aktiv':
                on = bool(j.get('aktiv'))
                try: rechtliches.set_active(DATA, on)
                except OSError: return self.jerr(500, 'Datei daten/rechtliches.json nicht beschreibbar (Dateirechte prüfen: lizenzserver.py pruefen).')
                log(ip, 'ADMIN rechtliches', ('eingeschaltet' if on else 'ausgeschaltet') + ' von %s' % who)
                return out({'ok': True, 'aktiv': on})
            if act == 'recht_set':
                vals = j.get('values')
                if not isinstance(vals, dict): return self.jerr(400, 'Ungültige Eingabe.')
                try: d = rechtliches.save(DATA, vals)
                except OSError: return self.jerr(500, 'Datei daten/rechtliches.json nicht beschreibbar (Dateirechte prüfen: lizenzserver.py pruefen).')
                log(ip, 'ADMIN rechtliches', 'gespeichert von %s' % who)
                return out({'ok': True, 'missing': rechtliches.missing(d), 'stand': d.get('_stand', '')})
            if act == 'fremd':
                return out({'ok': True, 'ips': unassigned_ips()})
            if act == 'geraete':
                row = lic_id()
                if row is None: return
                try: ger = int(j.get('geraete'))
                except Exception: return self.jerr(400, 'Ungültige Zahl.')
                if not 1 <= ger <= 100: return self.jerr(400, 'Geräte pro Key: 1–100.')
                c.execute('UPDATE licenses SET max_devices=? WHERE id=?', (ger, row['id'])); c.commit()
                n = c.execute('SELECT COUNT(*) FROM devices WHERE license_id=?', (row['id'],)).fetchone()[0]
                log(ip, 'ADMIN geraete', '#%d %s: %d -> %d von %s' % (row['id'], row['label'], row['max_devices'], ger, who))
                return out({'ok': True, 'max_devices': ger, 'used': n})
            if act == 'log':
                try: n = max(1, min(500, int(j.get('n', 100))))
                except Exception: n = 100
                lines = []
                tags = [str(x).strip().upper() for x in (j.get('tags') or []) if isinstance(x, str) and str(x).strip()][:20]
                q = str(j.get('q') or '').strip().lower()[:60]
                fip = str(j.get('ip') or '').strip()[:45]
                def want(line):
                    if fip and (line.split('|', 2)[1].strip() if line.count('|') > 1 else '') != fip: return False
                    if q and q not in line.lower(): return False
                    if not tags: return True
                    p = line.split('|', 3)
                    ev = p[2].strip().upper() if len(p) > 2 else ''
                    return any(ev.startswith(t) for t in tags)
                try:
                    with open(LOG, encoding='utf-8', errors='replace') as f: lines = list(deque((x.rstrip('\n') for x in f if want(x)), maxlen=n))
                except OSError: pass
                return out({'ok': True, 'lines': lines[::-1]})
            if act == 'neu':
                name = re.sub(r'[\x00-\x1f|]', ' ', str(j.get('name', ''))).strip()[:40]
                try: anz = int(j.get('anzahl', 1)); ger = int(j.get('geraete', 1))
                except Exception: return self.jerr(400, 'Ungültige Zahl.')
                if not name: return self.jerr(400, 'Bitte einen Namen eingeben.')
                if not 1 <= anz <= 50 or not 1 <= ger <= 100: return self.jerr(400, 'Anzahl 1–50, Geräte 1–100.')
                try: exp = parse_date(j.get('expires'))
                except Exception: return self.jerr(400, 'Ungültiges Datum.')
                dm = demo_minutes() if j.get('demo') else None
                if dm: exp = None
                keys = []
                for i in range(anz):
                    key = '-'.join(''.join(secrets.choice(ALPHABET) for _ in range(4)) for _ in range(4))
                    lab = name if anz == 1 else '%s %d' % (name, i + 1)
                    cur = c.execute('INSERT INTO licenses(label,key_hash,created,max_devices,expires,demo_min) VALUES(?,?,?,?,?,?)', (lab, sha(norm_key(key)), now(), ger, exp, dm))
                    keys.append({'id': cur.lastrowid, 'label': lab, 'key': key, 'devices': ger, 'expires': exp, 'demo_min': dm})
                c.commit(); log(ip, 'ADMIN neu', '%d Key(s) "%s" %s von %s' % (anz, name, ('Demo %d Min.' % dm) if dm else ('bis ' + (exp or 'unbegrenzt')), who))
                return out({'ok': True, 'keys': keys})
            if act == 'umbenennen':
                row = lic_id()
                if row is None: return
                name = re.sub(r'[\x00-\x1f|]', ' ', str(j.get('name', ''))).strip()[:40]
                if not name: return self.jerr(400, 'Bitte einen Namen eingeben.')
                c.execute('UPDATE licenses SET label=? WHERE id=?', (name, row['id'])); c.commit()
                log(ip, 'ADMIN umbenennen', '#%d %s -> %s von %s' % (row['id'], row['label'], name, who))
                return out({'ok': True, 'label': name})
            if act == 'ablauf':
                row = lic_id()
                if row is None: return
                if row['id'] in (me, r['lid']): return self.jerr(400, 'Beim Admin-Key lässt sich kein Ablaufdatum setzen (sonst sperrst du dich selbst aus). Das geht nur per Kommandozeile.')
                try: exp = parse_date(j.get('expires'))
                except Exception: return self.jerr(400, 'Ungültiges Datum.')
                c.execute('UPDATE licenses SET expires=? WHERE id=?', (exp, row['id'])); c.commit()
                log(ip, 'ADMIN ablauf', '#%d %s: %s von %s' % (row['id'], row['label'], exp or 'unbegrenzt', who))
                return out({'ok': True})
            if act in ('sperren', 'entsperren', 'reset', 'loeschen'):
                row = lic_id()
                if row is None: return
                if row['id'] in (me, r['lid']) and act != 'entsperren':
                    return self.jerr(400, 'Der Admin-Key kann hier nicht gesperrt, zurückgesetzt oder gelöscht werden (sonst sperrst du dich selbst aus). Das geht nur per Kommandozeile.')
                if act == 'sperren': c.execute('UPDATE licenses SET revoked=1 WHERE id=?', (row['id'],))
                elif act == 'entsperren': c.execute('UPDATE licenses SET revoked=0 WHERE id=?', (row['id'],))
                elif act == 'reset': c.execute('DELETE FROM devices WHERE license_id=?', (row['id'],))
                else: c.execute('DELETE FROM licenses WHERE id=?', (row['id'],))
                c.commit(); log(ip, 'ADMIN ' + act, '#%d %s von %s' % (row['id'], row['label'], who))
                return out({'ok': True})
            self.jerr(400, 'Unbekannte Aktion (%s). Läuft auf dem Server die aktuelle lizenzserver.py? Datei ersetzen und danach: sudo systemctl restart geo-gleinig' % str(act)[:20])
        finally: c.close()

def cmd_pruefen(a):
    import getpass
    ok = True
    def line(good, text, hint=''):
        nonlocal ok
        if not good: ok = False
        print(('  OK      ' if good else '  FEHLER  ') + text + ((' -> ' + hint) if (hint and not good) else ''))
    fix = 'sudo chown -R www-data:www-data ' + BASE
    print('Pruefung als Benutzer: ' + getpass.getuser() + '   (der Dienst laeuft als www-data: besser mit "sudo -u www-data python3 lizenzserver.py pruefen")')
    line(os.path.isdir(DATA), 'Ordner daten vorhanden', 'zuerst: lizenzserver.py init')
    line(os.access(DATA, os.W_OK), 'Ordner daten beschreibbar', fix)
    line(os.path.isfile(DB), 'Datenbank vorhanden', 'zuerst: lizenzserver.py init')
    if os.path.isfile(DB):
        line(os.access(DB, os.W_OK), 'Datenbank-Datei beschreibbar', fix)
        try:
            c = db(); c.execute('INSERT INTO fails VALUES(?,?)', ('pruefung', 0)); c.execute("DELETE FROM fails WHERE ip='pruefung'"); c.commit(); c.close()
            line(True, 'Schreibtest in der Datenbank')
        except Exception as e:
            line(False, 'Schreibtest in der Datenbank (%s)' % e, fix)
    try:
        open(LOG, 'a').close(); line(True, 'Protokolldatei beschreibbar')
    except Exception as e:
        line(False, 'Protokolldatei (%s)' % e, fix)
    for n in PAGES + [ADMIN_PAGE]:
        f = os.path.join(PAGES_DIR, n + '.html')
        line(os.path.isfile(f) and os.access(f, os.R_OK), 'Seite geschuetzt/%s.html lesbar' % n, 'Datei fehlt oder Rechte: ' + fix)
    for n in ('manifest.json', 'sw.js', 'icon-192.png'):
        line(os.path.isfile(os.path.join(PUBLIC_DIR, n)), 'public/' + n + ' vorhanden', 'ZIP komplett entpackt?')
    print('\n' + ('Alles in Ordnung.' if ok else 'Es gibt Fehler (siehe oben). Nach der Korrektur: sudo systemctl restart geo-gleinig'))

def cmd_start(a):
    need_init(); cf = cfg()
    host = a.host or cf.get('host', '127.0.0.1'); port = a.port or cf.get('port', 8000); proxy = a.proxy or cf.get('proxy', False)
    s = ThreadingHTTPServer((host, port), H); s.proxy = proxy
    print('Lizenzserver laeuft auf http://%s:%d  (Strg+C beendet)%s' % (host, port, '  [Proxy-IP aktiv]' if proxy else ''))
    try: s.serve_forever()
    except KeyboardInterrupt: print('\nBeendet.')

def cmd_hilfe(a):
    print(__doc__ + '''
Befehle:
  init                           Datenbank anlegen (einmalig)
  neu "Name" [--geraete N] [--anzahl M] [--bis JJJJ-MM-TT | --tage N]   neue Keys erzeugen (Standard 1 Geraet, unbegrenzt)
  neu "Name" --demo              Demo-Key: nur 30 Min. (config "demo_minuten") ab der ersten Nutzung
  geraete ID N                   erlaubte Geraete eines Keys aendern
  ips ID                         alle IP-Adressen, mit denen dieser Key benutzt wurde
  ablauf ID JJJJ-MM-TT|aus       Ablaufdatum setzen (gilt bis einschliesslich) / entfernen
  liste                          alle Keys mit Status
  sperren ID | entsperren ID     Key sperren / wieder freigeben
  reset ID                       Geraetebindung loeschen (neues Handy)
  loeschen ID                    Key endgueltig loeschen
  pruefen                        Rechte und Dateien pruefen (bei Fehlern)
  log [N]                        letzte N Zeilen des Zugriffsprotokolls (daten/zugriffe.log)
  start [--port P] [--host H] [--proxy]   Server starten''')

def main():
    ap = argparse.ArgumentParser(add_help=False); sp = ap.add_subparsers(dest='cmd')
    sp.add_parser('init').set_defaults(f=cmd_init)
    p = sp.add_parser('neu'); p.add_argument('name'); p.add_argument('--geraete', type=int, default=1); p.add_argument('--anzahl', type=int, default=1); p.add_argument('--bis'); p.add_argument('--tage', type=int); p.add_argument('--demo', action='store_true'); p.set_defaults(f=cmd_neu)
    sp.add_parser('liste').set_defaults(f=cmd_liste)
    p = sp.add_parser('ablauf'); p.add_argument('id', type=int); p.add_argument('datum'); p.set_defaults(f=cmd_ablauf)
    p = sp.add_parser('geraete'); p.add_argument('id', type=int); p.add_argument('anzahl', type=int); p.set_defaults(f=cmd_geraete)
    p = sp.add_parser('ips'); p.add_argument('id', type=int); p.set_defaults(f=cmd_ips)
    for n, fn in (('sperren', cmd_sperren), ('entsperren', cmd_entsperren), ('reset', cmd_reset), ('loeschen', cmd_loeschen)):
        p = sp.add_parser(n); p.add_argument('id', type=int); p.set_defaults(f=fn)
    p = sp.add_parser('log'); p.add_argument('n', type=int, nargs='?', default=50); p.set_defaults(f=cmd_log)
    p = sp.add_parser('start'); p.add_argument('--port', type=int); p.add_argument('--host'); p.add_argument('--proxy', action='store_true'); p.set_defaults(f=cmd_start)
    sp.add_parser('pruefen').set_defaults(f=cmd_pruefen)
    sp.add_parser('hilfe').set_defaults(f=cmd_hilfe)
    a = ap.parse_args()
    if not getattr(a, 'f', None): cmd_hilfe(a); return
    a.f(a)

if __name__ == '__main__': main()
