#!/usr/bin/env python3
"""SkyZ Files: langfristige, bei jeder Nutzung erneuerte Anmeldung.

Ausführen: sudo python3 SkyZFiles-Dauerlogin-20261011.py
Keine neuen Pakete erforderlich. Nur config/settings.py wird geändert.
"""
import ast
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import pwd
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request

ROOT = Path('/data/skyzfiles')
SERVICE = 'skyzfiles-web'
SESSION_SECONDS = 10 * 365 * 24 * 60 * 60
BEGIN = '# BEGIN SKYZFILES DAUERLOGIN V1'
END = '# END SKYZFILES DAUERLOGIN V1'
SETTINGS_BLOCK = f"""{BEGIN}
# Lange Sitzung; jede erfolgreiche Anfrage erneuert Sitzung und Cookie.
# Browser dürfen eine kürzere maximale Cookie-Laufzeit erzwingen.
SESSION_COOKIE_AGE = {SESSION_SECONDS}
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_SAVE_EVERY_REQUEST = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
{END}
"""


def build_settings(source):
    ast.parse(source)
    if source.count(BEGIN) != source.count(END) or source.count(BEGIN) > 1:
        raise RuntimeError('Unvollständiger Dauerlogin-Block; settings.py bitte prüfen.')
    if BEGIN in source:
        start = source.index(BEGIN)
        finish = source.index(END, start) + len(END)
        source = source[:start] + source[finish:]
    result = source.rstrip() + '\n\n' + SETTINGS_BLOCK
    ast.parse(result)
    return result


def run(args, **kwargs):
    return subprocess.run(args, check=True, timeout=120, **kwargs)


def service_state():
    return subprocess.check_output(
        ['systemctl', 'show', SERVICE, '-p', 'ActiveState', '--value'],
        text=True, timeout=20).strip()


def regular_file(path):
    info = path.lstat()
    if not stat.S_ISREG(info.st_mode):
        raise RuntimeError('Keine reguläre Datei: ' + str(path))
    return info


def atomic_write(path, data, uid, gid, mode):
    fd, temporary = tempfile.mkstemp(prefix='.skyz-session-', dir=path.parent)
    try:
        with os.fdopen(fd, 'wb') as handle:
            handle.write(data)
            handle.flush()
            os.fchown(handle.fileno(), uid, gid)
            os.fchmod(handle.fileno(), mode)
            os.fsync(handle.fileno())
        os.replace(temporary, path)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)


def service_python(code):
    env = os.environ.copy()
    env.update(SKYZ_DATA_DIR=str(ROOT), SKYZ_ENV_FILE=str(ROOT / 'config/app.env'),
               PYTHONDONTWRITEBYTECODE='1')
    run(['runuser', '-u', 'skyzfiles', '--', str(ROOT / 'venv/bin/python'), '-c', code],
        cwd=ROOT / 'app', env=env)


PREFLIGHT = """from config.wsgi import application
from django.conf import settings
from django.db import connection
required = ('django.contrib.sessions.middleware.SessionMiddleware',
            'django.contrib.auth.middleware.AuthenticationMiddleware',
            'django_otp.middleware.OTPMiddleware', 'vault.middleware.AccessMiddleware')
if not all(item in settings.MIDDLEWARE for item in required):
    raise RuntimeError('Abweichende Anmelde-Middleware; keine automatische Änderung.')
if settings.SESSION_ENGINE not in ('django.contrib.sessions.backends.db',
                                 'django.contrib.sessions.backends.cached_db'):
    raise RuntimeError('Dauerlogin benötigt hier serverseitige Datenbank-Sitzungen.')
with connection.cursor() as cursor:
    cursor.execute('SELECT 1 FROM django_session LIMIT 1')
print('OK: vorhandene WebApp und Sitzungsdatenbank erreichbar.')
"""

VERIFY = PREFLIGHT + f"""
from importlib import import_module
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory
assert settings.SESSION_COOKIE_AGE == {SESSION_SECONDS}, 'Sitzungsdauer'
assert settings.SESSION_EXPIRE_AT_BROWSER_CLOSE is False, 'Browser-Schließen'
assert settings.SESSION_SAVE_EVERY_REQUEST is True, 'Automatische Erneuerung'
assert settings.SESSION_COOKIE_SECURE and settings.SESSION_COOKIE_HTTPONLY
assert settings.SESSION_COOKIE_SAMESITE == 'Lax'
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
def probe(request):
    if request.session.get('_skyz_duration_probe') != 'ok':
        request.session['_skyz_duration_probe'] = 'ok'
    return HttpResponse('ok')
middleware = SessionMiddleware(probe)
factory = RequestFactory()
key = None
try:
    request = factory.get('/', secure=True)
    try:
        response = middleware(request)
    finally:
        key = request.session.session_key
    cookie = response.cookies[settings.SESSION_COOKIE_NAME]
    assert cookie['expires'] and int(cookie['max-age']) == {SESSION_SECONDS}
    assert cookie['secure'] and cookie['httponly'] and cookie['samesite'] == 'Lax'
    again = factory.get('/', secure=True,
                        HTTP_COOKIE=settings.SESSION_COOKIE_NAME + '=' + key)
    renewed = middleware(again)
    assert settings.SESSION_COOKIE_NAME in renewed.cookies, 'Cookie wurde nicht erneuert'
    assert again.session.get('_skyz_duration_probe') == 'ok'
    assert not again.session.get('_auth_user_id'), 'Prüfsitzung darf keine Anmeldung enthalten'
    assert not again.session.get('otp_device_id'), 'Prüfsitzung darf keine 2FA enthalten'
finally:
    if key:
        SessionStore(session_key=key).delete()
print('OK: persistentes HTTPS-Cookie, automatische Erneuerung und Datenbankzugriff.')
"""


def wait_ready():
    class NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, *args):
            return None
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
    deadline = time.monotonic() + 20
    while time.monotonic() < deadline:
        request = urllib.request.Request('http://127.0.0.1:8097/account/login/',
                    headers={'Host': 'files.skyz.pet', 'X-Forwarded-Proto': 'https'})
        try:
            with opener.open(request, timeout=2) as response:
                if response.status == 200 and service_state() == 'active':
                    return
        except (urllib.error.URLError, OSError):
            pass
        time.sleep(0.5)
    raise RuntimeError('WebApp antwortet nach dem Neustart nicht auf die Anmeldeseite.')


def main():
    if len(sys.argv) > 1:
        raise RuntimeError('Aufruf: sudo python3 ' + Path(__file__).name)
    if os.geteuid() != 0:
        raise RuntimeError('Bitte mit sudo python3 ausführen.')
    pwd.getpwnam('skyzfiles')
    for path in (ROOT, ROOT / 'app', ROOT / 'app/config', ROOT / 'config'):
        if path.is_symlink() or not path.is_dir():
            raise RuntimeError('Unerwarteter Anwendungsordner: ' + str(path))
    regular_file(ROOT / 'app/manage.py')
    regular_file(ROOT / 'state/db.sqlite3')
    settings_file = ROOT / 'app/config/settings.py'
    original_info = regular_file(settings_file)
    original = settings_file.read_bytes()
    patched = build_settings(original.decode('utf-8')).encode('utf-8')
    user = subprocess.check_output(
        ['systemctl', 'show', SERVICE, '-p', 'User', '--value'],
        text=True, timeout=20).strip()
    if user != 'skyzfiles':
        raise RuntimeError('Abweichender Web-Dienstbenutzer: ' + user)
    print('Prüfe WebApp als Dienstbenutzer skyzfiles ...', flush=True)
    try:
        service_python(PREFLIGHT)
    except subprocess.CalledProcessError as exc:
        raise RuntimeError('WebApp startet bereits vor diesem Update nicht. '
                           'Bei PermissionError zuerst das Rechte-Reparatur-Skript ausführen.') from exc

    backup = ROOT / 'config' / ('session-backup-' + datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S-%f'))
    backup.mkdir(mode=0o700)
    atomic_write(backup / 'settings.py', original, 0, 0, 0o600)
    metadata = {'path': str(settings_file), 'uid': original_info.st_uid,
                'gid': original_info.st_gid, 'mode': stat.S_IMODE(original_info.st_mode)}
    atomic_write(backup / 'metadata.json', json.dumps(metadata, indent=2).encode('utf-8'), 0, 0, 0o600)
    initial_state = service_state()
    stopped = changed = False
    try:
        print('Webdienst kurz stoppen und Dauerlogin aktivieren ...', flush=True)
        run(['systemctl', 'stop', SERVICE])
        stopped = True
        if settings_file.read_bytes() != original:
            raise RuntimeError('settings.py wurde zwischenzeitlich geändert; bitte erneut ausführen.')
        if patched != original:
            atomic_write(settings_file, patched, 0, 0, 0o644)
            changed = True
        service_python(VERIFY)
        run(['systemctl', 'reset-failed', SERVICE])
        run(['systemctl', 'start', SERVICE])
        wait_ready()
    except BaseException:
        if stopped:
            subprocess.run(['systemctl', 'stop', SERVICE], check=False, timeout=30)
        if changed:
            atomic_write(settings_file, original, original_info.st_uid, original_info.st_gid,
                         stat.S_IMODE(original_info.st_mode))
        if stopped and initial_state in ('active', 'activating', 'reloading'):
            subprocess.run(['systemctl', 'reset-failed', SERVICE], check=False, timeout=30)
            subprocess.run(['systemctl', 'start', SERVICE], check=False, timeout=30)
        print('Update nicht abgeschlossen; vorherige Einstellungen wiederhergestellt.', file=sys.stderr)
        print('Sicherung: ' + str(backup), file=sys.stderr)
        raise
    print('\nDauerlogin installiert. Webdienst und Anmeldeseite antworten lokal.')
    print('Sitzung: 10 Jahre, bei jeder Anfrage erneuert; bleibt beim Browser-Schließen erhalten.')
    print('Bestehende, noch gültige Anmeldung: Seite neu laden. Sonst einmal neu anmelden.')
    print('Abmelden und Passwort-Sicherheitsprüfungen bleiben aktiv. Cookies müssen gespeichert bleiben.')
    print('Sicherung: ' + str(backup))


if __name__ == '__main__':
    try:
        main()
    except (Exception, KeyboardInterrupt) as exc:
        print('FEHLER: ' + (str(exc) or 'Abgebrochen'), file=sys.stderr)
        sys.exit(1)
