🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/blueprints/admin.py main

1104 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104
import os, sys, subprocess, tempfile, shutil, time
from flask import Blueprint, render_template, request, redirect, session, flash, jsonify
from database import get_db, get_setting, set_setting, set_content, delete_content
from auth import hash_password
from config.settings import GIT_DIR, PAGBUILD_SYNC, STATE_FILE

admin_bp = Blueprint("admin", __name__)

def _get_server_stats():
    """Zbiera statystyki VPS."""
    stats = {}

    # Dysk
    du = shutil.disk_usage("/")
    stats["disk_total"] = round(du.total / 1073741824, 1)  # GB
    stats["disk_used"] = round(du.used / 1073741824, 1)
    stats["disk_free"] = round(du.free / 1073741824, 1)
    stats["disk_pct"] = round(du.used / du.total * 100, 0)

    # /var/tmp osobno (tmpfs?)
    try:
        du2 = shutil.disk_usage("/var/tmp")
        stats["tmp_total"] = round(du2.total / 1073741824, 1)
        stats["tmp_used"] = round(du2.used / 1073741824, 1)
        stats["tmp_free"] = round(du2.free / 1073741824, 1)
    except:
        pass

    # RAM
    try:
        with open("/proc/meminfo") as f:
            mem = f.read()
        def _mem_val(key):
            for line in mem.splitlines():
                if line.startswith(key + ":"):
                    return int(line.split()[1]) // 1024  # MB
            return 0
        stats["ram_total"] = _mem_val("MemTotal")
        stats["ram_avail"] = _mem_val("MemAvailable")
        stats["ram_used"] = stats["ram_total"] - stats["ram_avail"]
        stats["ram_pct"] = round(stats["ram_used"] / stats["ram_total"] * 100, 0) if stats["ram_total"] else 0
        stats["swap_total"] = _mem_val("SwapTotal")
        stats["swap_free"] = _mem_val("SwapFree")
    except:
        pass

    # Uptime
    try:
        with open("/proc/uptime") as f:
            uptime_sec = float(f.read().split()[0])
            days = int(uptime_sec // 86400)
            hours = int((uptime_sec % 86400) // 3600)
            stats["uptime"] = f"{days}d {hours}h"
    except:
        stats["uptime"] = "?"

    # Load average
    try:
        with open("/proc/loadavg") as f:
            stats["load"] = f.read().split()[:3]
    except:
        stats["load"] = ["?"]

    # CPU cores
    try:
        stats["cpu_cores"] = os.cpu_count() or "?"
    except:
        stats["cpu_cores"] = "?"

    # Procesy
    try:
        p = subprocess.run(["ps", "--no-headers", "-eo", "pid,comm"], capture_output=True, text=True, timeout=5)
        stats["procs"] = len(p.stdout.splitlines())
    except:
        stats["procs"] = "?"

    return stats

def admin_required(f):
    from functools import wraps
    @wraps(f)
    def wrap(*a, **kw):
        if not session.get("is_admin"):
            flash("Wymagane uprawnienia admina", "error")
            return redirect("/")
        return f(*a, **kw)
    return wrap

@admin_bp.route("/admin/accounts")
@admin_required
def index():
    db = get_db()
    users = db.execute("SELECT * FROM users ORDER BY username").fetchall()
    repos = db.execute("SELECT * FROM repo_permissions ORDER BY repo_name").fetchall()
    user_count = db.execute("SELECT COUNT(*) FROM users").fetchone()[0]
    issue_count = db.execute("SELECT COUNT(*) FROM issues WHERE status='open'").fetchone()[0]
    db.close()
    registration_enabled = get_setting("registration_enabled", "1") == "1"
    admin_activation_required = get_setting("admin_activation_required", "0") == "1"
    git_readonly = get_setting("git_readonly", "0") == "1"

    # Lista repo z dysku
    disk_repos = []
    if os.path.exists(GIT_DIR):
        for entry in os.listdir(GIT_DIR):
            if entry.endswith(".git"):
                name = entry[:-4]
                rp = os.path.join(GIT_DIR, entry)
                vis = os.path.exists(os.path.join(rp, "git-daemon-export-ok"))
                # size
                sz = 0
                try:
                    for dp, dn, fn in os.walk(rp):
                        for f in fn:
                            sz += os.path.getsize(os.path.join(dp, f))
                except: pass
                # format size
                if sz < 1024: sz_f = f"{sz} B"
                elif sz < 1048576: sz_f = f"{sz/1024:.1f} KB"
                else: sz_f = f"{sz/1048576:.1f} MB"
                # commits
                c = subprocess.run(["git", "--git-dir", rp, "rev-list", "--count", "HEAD"],
                                   capture_output=True, text=True, timeout=5).stdout.strip()
                commits = c if c.isdigit() else "0"
                disk_repos.append({"name": name, "visible": vis, "size_fmt": sz_f, "commits": commits})

    # Statystyki builda
    try:
        js = json.load(open(STATE_FILE))
        builds = js.get("builds", [])
        last_build = builds[-1] if builds else None
        build_total = len(builds)
        build_ok = sum(1 for b in builds if b.get("status") == "ok")
        build_fail = sum(1 for b in builds if b.get("status") == "failed")
        pkg_count = js.get("packages", {}) if isinstance(js.get("packages"), dict) else {}
        pkg_count = len(pkg_count) if pkg_count else 0
    except Exception:
        last_build, build_total, build_ok, build_fail, pkg_count = None, 0, 0, 0, 0

    # Czy pagsync działa
    r = subprocess.run(["pgrep", "-f", "/opt/pagan-web-v2/pagsync"], capture_output=True)
    pagsync_running = r.returncode == 0

    try:
        from database import get_content_map
        content_modified_count = len(get_content_map())
    except Exception:
        content_modified_count = 0

    stats = _get_server_stats()
    stats["pkg_count"] = pkg_count
    stats["last_build"] = last_build
    stats["total_builds"] = build_total
    stats["success"] = build_ok
    stats["failed"] = build_fail

    return render_template("admin/index.html", users=users, repos=repos,
                           disk_repos=disk_repos, user_count=user_count,
                           issue_count=issue_count, disk_repo_count=len(disk_repos),
                           registration_enabled=registration_enabled,
                           admin_activation_required=admin_activation_required,
                           git_readonly=git_readonly,
                           stats=stats, pagsync_running=pagsync_running, nav="accounts",
                           content_modified_count=content_modified_count)


# ═══ KONTROLKI PAGSYNC ═══


def _is_ajax():
    """Prawdziwe żądanie fetch (AJAX) → odpowiedź JSON zamiast redirect."""
    return (request.headers.get("X-Requested-With") == "fetch"
            or request.headers.get("Accept", "").startswith("application/json"))

@admin_bp.route("/admin/pagsync/start", methods=["POST"])
@admin_required
def pagsync_start():
    r = subprocess.run(["pgrep", "-f", "/opt/pagan-web-v2/pagsync"], capture_output=True)
    if r.returncode == 0:
        if _is_ajax():
            return jsonify({"ok": False, "error": "pagsync już działa"})
        flash("⚠ pagsync już działa", "error")
    else:
        log = open("/var/log/pagan-sync-build.log", "a")
        subprocess.Popen([PAGBUILD_SYNC, "--once", "--missing"],
                         stdout=log, stderr=subprocess.STDOUT,
                         env={**os.environ, "PYTHONUNBUFFERED": "1"})
        time.sleep(2)
        if _is_ajax():
            return jsonify({"ok": True, "msg": "pagsync uruchomiony"})
        flash("✅ pagsync uruchomiony", "ok")
    return redirect("/admin")


@admin_bp.route("/admin/pagsync/stop", methods=["POST"])
@admin_required
def pagsync_stop():
    subprocess.run(["pkill", "-f", "/opt/pagan-web-v2/pagsync"], capture_output=True)
    time.sleep(1)
    if _is_ajax():
        return jsonify({"ok": True, "msg": "pagsync zatrzymany"})
    flash("pagsync zatrzymany", "ok")
    return redirect("/admin")


@admin_bp.route("/admin/run-check-updates", methods=["POST"])
@admin_required
def run_check_updates():
    """Uruchamia pagsync --once --check-updates (sprawdzanie nowszych wersji upstream)."""
    import threading
    def _run():
        subprocess.run([PAGBUILD_SYNC, "--once", "--check-updates"],
                       capture_output=True, text=True, timeout=3600)
    threading.Thread(target=_run, daemon=True).start()
    flash("Uruchomiono sprawdzanie nowszych wersji (pagsync --check-updates, bez --apply – nic nie zmienia)", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/install-rootfs", methods=["POST"])
@admin_required
def install_rootfs():
    """Instaluje zbudowane pakiety (stable/*.pag) do rootfs buildera."""
    import threading
    names = request.form.get("packages", "").strip()
    pkg_list = [p for p in names.replace(",", " ").split() if p]
    if not pkg_list:
        flash("Podaj nazwy pakietów (oddzielone spacją lub przecinkiem)", "error")
        return redirect("/admin")
    def _run():
        for p in pkg_list:
            subprocess.run([PAGBUILD_SYNC, "--once", "--install", p],
                           capture_output=True, text=True, timeout=600)
    threading.Thread(target=_run, daemon=True).start()
    flash(f"Instalacja do rootfs uruchomiona: {', '.join(pkg_list)}", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/content")
@admin_required
def content_editor():
    """Edytor treści całej strony (nadpisania i18n przez tabelę content)."""
    from i18n import TRANSLATIONS
    db = get_db()
    try:
        rows = db.execute("SELECT key, lang, value FROM content").fetchall()
    finally:
        db.close()
    overrides = {}
    for r in rows:
        overrides.setdefault(r["key"], {})[r["lang"]] = r["value"]

    all_keys = set(TRANSLATIONS.get("pl", {}).keys())
    all_keys |= set(TRANSLATIONS.get("en", {}).keys())
    all_keys |= set(overrides.keys())

    q = (request.args.get("q", "") or "").strip().lower()
    items = []
    for key in all_keys:
        pl_default = TRANSLATIONS.get("pl", {}).get(key, "")
        en_default = TRANSLATIONS.get("en", {}).get(key, "")
        pl_val = overrides.get(key, {}).get("pl", pl_default)
        en_val = overrides.get(key, {}).get("en", en_default)
        items.append({
            "key": key,
            "pl_default": pl_default, "en_default": en_default,
            "pl_val": pl_val, "en_val": en_val,
            "modified": key in overrides,
        })

    if q:
        items = [i for i in items if q in i["key"].lower()
                 or q in i["pl_val"].lower() or q in i["en_val"].lower()]

    def _section(key):
        return key.split("_")[0] if "_" in key else "inne"

    groups = {}
    for it in sorted(items, key=lambda x: x["key"]):
        groups.setdefault(_section(it["key"]), []).append(it)

    return render_template("admin/content.html", groups=groups, total=len(items),
                           q=q, modified_count=len(overrides), nav="content")

@admin_bp.route("/admin/content/save", methods=["POST"])
@admin_required
def content_save():
    key = (request.form.get("key", "") or "").strip()
    if not key:
        flash("Brak klucza treści", "error")
        return redirect("/admin/content")
    pl = request.form.get("pl", "")
    en = request.form.get("en", "")
    set_content(key, "pl", pl)
    set_content(key, "en", en)
    flash(f"✅ Zapisano treść: {key}", "ok")
    return redirect("/admin/content#k-" + key)


@admin_bp.route("/admin/about", methods=["GET", "POST"])
@admin_required
def about_page():
    """Edycja strony /about – treść w DB (docs/slug='about', wersje pl/en).
    Dopóki nie ma wpisu w DB – strona pokazuje statyczny szablon (fallback)."""
    db = get_db()
    rows = {r["lang"]: r for r in db.execute(
        "SELECT * FROM docs WHERE slug='about'").fetchall()}
    if request.method == "POST":
        for lang in ("pl", "en"):
            title = (request.form.get("title_" + lang) or "").strip()
            content = request.form.get("content_" + lang) or ""
            if not title:
                title = "O PaganOS" if lang == "pl" else "About PaganOS"
            # Pusty wpis, którego nie było wcześniej → pomiń (nie twórz pustych)
            if not content.strip() and lang not in rows:
                continue
            db.execute("""
                INSERT INTO docs (slug, lang, title, content, author, updated_at)
                VALUES ('about', ?, ?, ?, ?, CURRENT_TIMESTAMP)
                ON CONFLICT(slug, lang) DO UPDATE SET
                    title=excluded.title, content=excluded.content,
                    author=excluded.author, updated_at=CURRENT_TIMESTAMP
            """, (lang, title, content, session.get("username", "admin")))
        db.commit()
        db.close()
        flash("✅ Strona About zapisana", "ok")
        return redirect("/admin/about")
    pl = rows.get("pl")
    en = rows.get("en")
    db.close()
    return render_template("admin/about_edit.html", nav="about",
        pl_title=pl["title"] if pl else "O PaganOS",
        pl_content=pl["content"] if pl else "",
        en_title=en["title"] if en else "About PaganOS",
        en_content=en["content"] if en else "",
        has_pl=pl is not None, has_en=en is not None)

@admin_bp.route("/admin/content/reset/<path:key>", methods=["POST"])
@admin_required
def content_reset(key):
    delete_content(key)
    flash(f"↩ Przywrócono domyślne: {key}", "ok")
    return redirect("/admin/content")

@admin_bp.route("/admin/user/add", methods=["POST"])
@admin_required
def add_user():
    username = request.form.get("username", "").strip()
    email = request.form.get("email", "").strip()
    password = request.form.get("password", "")
    is_admin = request.form.get("is_admin") == "1"
    if not username or len(password) < 5:
        flash("Nieprawidłowe dane", "error")
        return redirect("/admin")
    db = get_db()
    try:
        db.execute("INSERT INTO users (username, password_hash, email, is_admin) VALUES (?,?,?,?)",
                   (username, hash_password(password), email or f"{username}@paganlinux.eu", int(is_admin)))
        db.commit()
        flash(f"✅ Użytkownik {username} dodany", "ok")
    except Exception as e:
        flash(f"❌ Błąd: {e}", "error")
    db.close()
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/delete", methods=["POST"])
@admin_required
def delete_user(username):
    if username == "admin":
        flash("Nie można usunąć admina", "error")
    else:
        db = get_db()
        db.execute("DELETE FROM users WHERE username=?", (username,))
        db.execute("DELETE FROM repo_permissions WHERE username=?", (username,))
        db.commit()
        db.close()
        flash(f"{username} usunięty", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/toggle-admin", methods=["POST"])
@admin_required
def toggle_admin(username):
    if username == "admin":
        flash("Admin nie może stracić uprawnień", "error")
    else:
        db = get_db()
        user = db.execute("SELECT is_admin FROM users WHERE username=?", (username,)).fetchone()
        if user:
            new = 0 if user["is_admin"] else 1
            db.execute("UPDATE users SET is_admin=? WHERE username=?", (new, username))
            db.commit()
            flash(f"✅ {username}: admin={bool(new)}", "ok")
        db.close()
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/toggle-block", methods=["POST"])
@admin_required
def toggle_block(username):
    if username == "admin":
        flash("Nie można zablokować admina", "error")
    else:
        db = get_db()
        user = db.execute("SELECT blocked FROM users WHERE username=?", (username,)).fetchone()
        if user:
            new = 0 if user["blocked"] else 1
            db.execute("UPDATE users SET blocked=? WHERE username=?", (new, username))
            db.commit()
            flash(f"{' Zablokowano' if new else ' Odblokowano'}: {username}", "ok" if new else "ok")
        db.close()
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/make-maintainer", methods=["POST"])
@admin_required
def make_maintainer(username):
    repo = request.form.get("repo", "").strip()
    if not repo:
        flash("Wybierz repozytorium", "error")
        return redirect("/admin")
    db = get_db()
    try:
        db.execute("INSERT OR REPLACE INTO repo_permissions (repo_name, username, role) VALUES (?,?,?)",
                   (repo, username, "maintainer"))
        db.commit()
        flash(f"✅ {username} → opiekun {repo}", "ok")
    except Exception as e:
        flash(f"❌ {e}", "error")
    db.close()
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/activate", methods=["POST"])
@admin_required
def activate_user(username):
    db = get_db()
    user = db.execute("SELECT verified FROM users WHERE username=?", (username,)).fetchone()
    if user:
        db.execute("UPDATE users SET verified=1, verify_token=NULL WHERE username=?", (username,))
        db.commit()
        flash(f"✅ Użytkownik {username} aktywowany", "ok")
    else:
        flash("Użytkownik nie znaleziony", "error")
    db.close()
    return redirect("/admin")

@admin_bp.route("/admin/settings", methods=["GET", "POST"])
@admin_required
def update_settings():
    if request.method == "POST":
        registration_enabled = request.form.get("registration_enabled") == "1"
        admin_activation_required = request.form.get("admin_activation_required") == "1"
        git_readonly = request.form.get("git_readonly") == "1"
        set_setting("registration_enabled", "1" if registration_enabled else "0")
        set_setting("admin_activation_required", "1" if admin_activation_required else "0")
        set_setting("git_readonly", "1" if git_readonly else "0")
        flash("✅ Ustawienia zapisane", "ok")
        return redirect("/admin/settings")
    return render_template("admin/settings.html",
                           nav="settings",
                           registration_enabled=get_setting("registration_enabled", "1") == "1",
                           admin_activation_required=get_setting("admin_activation_required", "0") == "1",
                           git_readonly=get_setting("git_readonly", "0") == "1")


# ═══ SEKCJA POBRAŃ (/download) ═══
@admin_bp.route("/admin/download")
@admin_required
def downloads_page():
    db = get_db()
    try:
        downloads = db.execute("SELECT * FROM downloads ORDER BY sort_order, id").fetchall()
    except Exception:
        downloads = []
    db.close()
    return render_template("admin/downloads.html", downloads=downloads, nav="download")

@admin_bp.route("/admin/download/add", methods=["POST"])
@admin_required
def download_add():
    icon = (request.form.get("icon") or "").strip()
    title = request.form.get("title", "").strip()
    description = request.form.get("description", "").strip()
    url = request.form.get("url", "").strip()
    button_label = request.form.get("button_label", "").strip()
    if not title:
        flash("Tytuł wymagany", "error")
        return redirect("/admin/download")
    db = get_db()
    max_order = db.execute("SELECT COALESCE(MAX(sort_order), -1) FROM downloads").fetchone()[0]
    db.execute("INSERT INTO downloads (sort_order, icon, title, description, url, button_label, enabled) VALUES (?,?,?,?,?,?,1)",
               (max_order + 1, icon, title, description, url, button_label))
    db.commit()
    db.close()
    flash(f"✅ Dodano kartę pobierania: {title}", "ok")
    return redirect("/admin/download")

@admin_bp.route("/admin/download/<int:dl_id>/edit", methods=["POST"])
@admin_required
def download_edit(dl_id):
    icon = (request.form.get("icon") or "").strip()
    title = request.form.get("title", "").strip()
    description = request.form.get("description", "").strip()
    url = request.form.get("url", "").strip()
    button_label = request.form.get("button_label", "").strip()
    enabled = 1 if request.form.get("enabled") == "1" else 0
    if not title:
        flash("Tytuł wymagany", "error")
        return redirect("/admin/download")
    db = get_db()
    db.execute("UPDATE downloads SET icon=?, title=?, description=?, url=?, button_label=?, enabled=? WHERE id=?",
               (icon, title, description, url, button_label, enabled, dl_id))
    db.commit()
    db.close()
    flash(f"✅ Zapisano: {title}", "ok")
    return redirect("/admin/download")

@admin_bp.route("/admin/download/<int:dl_id>/delete", methods=["POST"])
@admin_required
def download_delete(dl_id):
    db = get_db()
    db.execute("DELETE FROM downloads WHERE id=?", (dl_id,))
    db.commit()
    db.close()
    flash("Usunięto kartę pobierania", "ok")
    return redirect("/admin/download")

@admin_bp.route("/admin/download/<int:dl_id>/toggle", methods=["POST"])
@admin_required
def download_toggle(dl_id):
    db = get_db()
    row = db.execute("SELECT enabled FROM downloads WHERE id=?", (dl_id,)).fetchone()
    if row:
        db.execute("UPDATE downloads SET enabled=? WHERE id=?", (0 if row["enabled"] else 1, dl_id))
        db.commit()
    db.close()
    return redirect("/admin/download")

@admin_bp.route("/admin/download/<int:dl_id>/move", methods=["POST"])
@admin_required
def download_move(dl_id):
    direction = request.form.get("dir", "up")
    db = get_db()
    rows = db.execute("SELECT id, sort_order FROM downloads ORDER BY sort_order, id").fetchall()
    idx = next((i for i, r in enumerate(rows) if r["id"] == dl_id), None)
    if idx is not None:
        swap = idx - 1 if direction == "up" else idx + 1
        if 0 <= swap < len(rows):
            a, b = rows[idx], rows[swap]
            db.execute("UPDATE downloads SET sort_order=? WHERE id=?", (b["sort_order"], a["id"]))
            db.execute("UPDATE downloads SET sort_order=? WHERE id=?", (a["sort_order"], b["id"]))
            db.commit()
    db.close()
    return redirect("/admin/download")


# ═══ OBRAZY ISO (upload + link /download/iso/<wersja>) ═══
import re as _re_iso
from config.settings import ISO_DIR

@admin_bp.route("/admin/iso")
@admin_required
def iso_page():
    db = get_db()
    try:
        rows = db.execute("SELECT * FROM iso_files ORDER BY created_at DESC, id DESC").fetchall()
    except Exception:
        rows = []
    db.close()
    isos = []
    for r in rows:
        d = dict(r)
        d["link"] = f"https://paganlinux.eu/download/iso/{d['version']}"
        d["link_sha"] = f"https://paganlinux.eu/download/iso/{d['version']}.sha256"
        d["date"] = (d.get("created_at") or "")[:10]
        d["sha256_short"] = (d.get("sha256") or "")[:16]
        isos.append(d)
    return render_template("admin/iso.html", isos=isos, nav="iso", iso_dir=ISO_DIR)

@admin_bp.route("/admin/iso/upload", methods=["POST"])
@admin_required
def iso_upload():
    def resp(ok, text):
        # XHR (pasek postępu) → JSON; zwykły formularz → flash + redirect
        if request.headers.get("X-Requested-With") == "XMLHttpRequest":
            return jsonify({"ok": bool(ok), "message": text,
                            "redirect": "/admin/iso"}), (200 if ok else 400)
        flash(("✅ " if ok else "❌ ") + text, "ok" if ok else "error")
        return redirect("/admin/iso")

    version = (request.form.get("version") or "").strip()
    if not version or not _re_iso.fullmatch(r"[A-Za-z0-9._-]+", version):
        return resp(False, "Wersja wymagana – dozwolone znaki: litery, cyfry, kropka, myślnik, podkreślenie (np. 0.0.1-kde)")
    file = request.files.get("file")
    if not file or not file.filename:
        return resp(False, "Wybierz plik ISO")
    if not file.filename.lower().endswith(".iso"):
        return resp(False, "Plik musi mieć rozszerzenie .iso")

    description = (request.form.get("description") or "").strip()
    changes = (request.form.get("changes") or "").strip()

    os.makedirs(ISO_DIR, exist_ok=True)
    filename = f"Pagan-Linux-{version}.iso"
    dest = os.path.join(ISO_DIR, filename)

    # Zapis + SHA256 w strumieniu (bez trzymania całego pliku w RAM)
    import hashlib
    h = hashlib.sha256()
    size = 0
    try:
        with open(dest, "wb") as out:
            while True:
                chunk = file.stream.read(1024 * 1024)
                if not chunk:
                    break
                out.write(chunk)
                h.update(chunk)
                size += len(chunk)
    except Exception as e:
        return resp(False, f"Błąd zapisu pliku: {e}")

    sha = h.hexdigest()
    db = get_db()
    try:
        # Nadpisanie tej samej wersji zachowuje opis/zmiany, jeśli w formularzu były puste
        db.execute(
            "INSERT INTO iso_files (version, filename, size, sha256, description, changes) "
            "VALUES (?,?,?,?,?,?) "
            "ON CONFLICT(version) DO UPDATE SET "
            "filename=excluded.filename, size=excluded.size, sha256=excluded.sha256, "
            "description=CASE WHEN excluded.description<>'' THEN excluded.description "
            "                  ELSE iso_files.description END, "
            "changes=CASE WHEN excluded.changes<>'' THEN excluded.changes "
            "              ELSE iso_files.changes END, "
            "created_at=CURRENT_TIMESTAMP",
            (version, filename, size, sha, description, changes))
        db.commit()
    except Exception as e:
        db.close()
        return resp(False, f"Błąd zapisu w bazie: {e}")
    db.close()

    # Plik kontrolny .sha256 obok ISO (do weryfikacji na stronie pobierania)
    try:
        with open(dest + ".sha256", "w") as f:
            f.write(f"{sha}  {filename}\n")
    except Exception:
        pass

    link = f"https://paganlinux.eu/download/iso/{version}"
    return resp(True, f"Wgrano {filename} ({size/1048576:.0f} MB). Link: {link}")

@admin_bp.route("/admin/iso/<int:iso_id>/update", methods=["POST"])
@admin_required
def iso_update(iso_id):
    """Edycja opisu/zmian istniejącego ISO – bez ponownego wgrywania pliku."""
    description = (request.form.get("description") or "").strip()
    changes = (request.form.get("changes") or "").strip()
    db = get_db()
    try:
        cur = db.execute("UPDATE iso_files SET description=?, changes=? WHERE id=?",
                         (description, changes, iso_id))
        db.commit()
        if cur.rowcount:
            flash("✅ Zaktualizowano opis i zmiany", "ok")
        else:
            flash("❌ Nie znaleziono ISO", "error")
    except Exception as e:
        flash(f"❌ Błąd zapisu w bazie: {e}", "error")
    db.close()
    return redirect("/admin/iso")

@admin_bp.route("/admin/iso/<int:iso_id>/delete", methods=["POST"])
@admin_required
def iso_delete(iso_id):
    db = get_db()
    row = db.execute("SELECT * FROM iso_files WHERE id=?", (iso_id,)).fetchone()
    if row:
        try:
            fp = os.path.join(ISO_DIR, row["filename"])
            if os.path.exists(fp):
                os.remove(fp)
            fps = fp + ".sha256"
            if os.path.exists(fps):
                os.remove(fps)
        except Exception:
            pass
        db.execute("DELETE FROM iso_files WHERE id=?", (iso_id,))
        db.commit()
        flash(f"Usunięto ISO: {row['version']}", "ok")
    db.close()
    return redirect("/admin/iso")

@admin_bp.route("/admin/repo/add-permission", methods=["POST"])
@admin_required
def add_permission():
    repo = request.form.get("repo", "").strip()
    username = request.form.get("username", "").strip()
    role = request.form.get("role", "maintainer")
    if not repo or not username:
        flash("Repo i użytkownik wymagane", "error")
        return redirect("/admin")
    db = get_db()
    try:
        db.execute("INSERT OR REPLACE INTO repo_permissions (repo_name, username, role) VALUES (?,?,?)",
                   (repo, username, role))
        db.commit()
        flash(f"✅ {username}{repo} ({role})", "ok")
    except Exception as e:
        flash(f"❌ {e}", "error")
    db.close()
    return redirect("/admin")

@admin_bp.route("/admin/repo/remove-permission", methods=["POST"])
@admin_required
def remove_permission():
    repo = request.form.get("repo", "")
    username = request.form.get("username", "")
    db = get_db()
    db.execute("DELETE FROM repo_permissions WHERE repo_name=? AND username=?", (repo, username))
    db.commit()
    db.close()
    flash(f"{username} usunięty z {repo}", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/repo/create", methods=["POST"])
@admin_required
def create_repo():
    name = request.form.get("name", "").strip()
    if not name:
        flash("Nazwa wymagana", "error")
        return redirect("/admin")
    repo_path = os.path.join(GIT_DIR, f"{name}.git")
    if os.path.exists(repo_path):
        flash(f"Repo {name} już istnieje", "error")
    else:
        import subprocess
        os.makedirs(repo_path, exist_ok=True)
        subprocess.run(["git", "--git-dir", repo_path, "init", "--bare", "--initial-branch=main"], capture_output=True, timeout=10)
        flash(f"✅ Repo {name} utworzone", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/repo/delete", methods=["POST"])
@admin_required
def delete_repo():
    name = request.form.get("name", "").strip()
    if not name:
        flash("Nazwa wymagana", "error")
        return redirect("/admin")
    repo_path = os.path.join(GIT_DIR, f"{name}.git")
    if not os.path.exists(repo_path):
        flash(f"Repo {name} nie istnieje", "error")
    else:
        import shutil
        shutil.rmtree(repo_path)
        db = get_db()
        db.execute("DELETE FROM repo_permissions WHERE repo_name=?", (name,))
        db.execute("DELETE FROM issues WHERE repo_name=?", (name,))
        db.commit()
        db.close()
        flash(f"Repo {name} usunięte", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/repo/toggle-visibility", methods=["POST"])
@admin_required
def toggle_repo_visibility():
    name = request.form.get("name", "").strip()
    if not name:
        flash("Nazwa wymagana", "error")
        return redirect("/admin")
    repo_path = os.path.join(GIT_DIR, f"{name}.git")
    vis_file = os.path.join(repo_path, "git-daemon-export-ok")
    if os.path.exists(vis_file):
        os.remove(vis_file)
        flash(f"{name} → prywatne", "ok")
    else:
        with open(vis_file, "w") as f:
            f.write("")
        flash(f"{name} → publiczne", "ok")
    return redirect("/admin")

# ── Git commit management ──

@admin_bp.route("/admin/commits")
@admin_required
def commits():
    """Lista ostatnich commitów z recipes."""
    import subprocess
    repo_path = os.path.join(GIT_DIR, "recipes.git")
    commits_list = []
    if os.path.exists(repo_path):
        cmd = ["git", f"--git-dir={repo_path}", "--no-pager", "log", "-n", "50",
               "--pretty=format:%H|%an|%ar|%s", "--all"]
        try:
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
            for line in r.stdout.splitlines():
                parts = line.split("|", 3)
                if len(parts) == 4:
                    commits_list.append({"hash": parts[0][:8], "full_hash": parts[0],
                                         "author": parts[1], "date": parts[2], "msg": parts[3]})
        except Exception:
            pass
    return render_template("admin/commits.html", commits=commits_list, nav="commits")

@admin_bp.route("/admin/commits/edit", methods=["POST"])
@admin_required
def edit_commit_msg():
    """Edytuje opis commita (tymczasowy klon + force push)."""
    hash_val = request.form.get("hash", "")
    new_msg = request.form.get("message", "").strip()
    repo_path = os.path.join(GIT_DIR, "recipes.git")
    if not hash_val or not new_msg or not os.path.exists(repo_path):
        flash("Nieprawidłowe dane", "error")
        return redirect("/admin/commits")
    tmp = tempfile.mkdtemp(prefix="git-edit-")
    try:
        subprocess.run(["git", "clone", repo_path, tmp], capture_output=True, timeout=60)
        subprocess.run(["git", "-C", tmp, "filter-branch", "-f", "--msg-filter",
                        f"sed 's/.*/{new_msg}/'", f"{hash_val}^..{hash_val}"],
                       capture_output=True, timeout=120)
        subprocess.run(["git", "-C", tmp, "push", "--force", "origin", "main"],
                       capture_output=True, timeout=30)
        flash(f"✅ Commit {hash_val[:8]} zaktualizowany", "ok")
    except Exception as e:
        flash(f"❌ Błąd: {e}", "error")
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    return redirect("/admin/commits")

@admin_bp.route("/admin/commits/revert", methods=["POST"])
@admin_required
def revert_commit():
    """Revertuje commit (tworzy nowy commit odwracający zmiany)."""
    hash_val = request.form.get("hash", "")
    repo_path = os.path.join(GIT_DIR, "recipes.git")
    if not hash_val or not os.path.exists(repo_path):
        flash("Nieprawidłowe dane", "error")
        return redirect("/admin/commits")
    tmp = tempfile.mkdtemp(prefix="git-revert-")
    try:
        subprocess.run(["git", "clone", repo_path, tmp], capture_output=True, timeout=60)
        subprocess.run(["git", "-C", tmp, "revert", "--no-edit", hash_val],
                       capture_output=True, timeout=30)
        subprocess.run(["git", "-C", tmp, "push", "origin", "main"],
                       capture_output=True, timeout=30)
        flash(f"✅ Commit {hash_val[:8]} zrevertowany", "ok")
    except Exception as e:
        flash(f"❌ Błąd: {e}", "error")
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    return redirect("/admin/commits")

# ── Reset narzędzi systemowych ──

@admin_bp.route("/admin/reset-pagsync", methods=["POST"])
@admin_required
def reset_pagsync():
    """Resetuje stan pagsync – czyści state.json i historię buildów."""
    from config.settings import STATE_FILE
    paths_cleared = []
    if os.path.exists(STATE_FILE):
        os.remove(STATE_FILE)
        paths_cleared.append(STATE_FILE)
    flash(f"Pagsync zresetowany: {', '.join(paths_cleared) if paths_cleared else 'stan już był pusty'}", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/clean-cache", methods=["POST"])
@admin_required
def clean_cache():
    """Czyści cache źródeł builda."""
    cache_dir = "/var/cache/pagbuild"
    removed = 0
    if os.path.isdir(cache_dir):
        for item in os.listdir(cache_dir):
            item_path = os.path.join(cache_dir, item)
            try:
                if os.path.isfile(item_path):
                    os.remove(item_path)
                    removed += 1
                elif os.path.isdir(item_path):
                    shutil.rmtree(item_path)
                    removed += 1
            except Exception:
                pass
    flash(f"Wyczyściono cache builda: {removed} elementów", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/reset-build-output", methods=["POST"])
@admin_required
def reset_build_output():
    """Czyści katalog wyjściowy buildów."""
    out_dir = "/var/cache/pagbuild/output"
    removed = 0
    if os.path.isdir(out_dir):
        for item in os.listdir(out_dir):
            item_path = os.path.join(out_dir, item)
            try:
                if os.path.isfile(item_path):
                    os.remove(item_path)
                    removed += 1
                elif os.path.isdir(item_path):
                    shutil.rmtree(item_path)
                    removed += 1
            except Exception:
                pass
    flash(f"Wyczyściono output builda: {removed} plików", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/clean-source-cache", methods=["POST"])
@admin_required
def clean_source_cache():
    """Czyści cache źródeł (pliki nieużywane przez żaden przepis)."""
    try:
        result = subprocess.run([PAGBUILD_SYNC, "--clean-cache"], capture_output=True, text=True, timeout=30,
                               env={**os.environ, "PYTHONUNBUFFERED": "1"})
        flash(f"Cache źródeł wyczyszczony: {result.stdout.strip()[-100:]}", "ok")
    except Exception as e:
        flash(f"❌ Błąd czyszczenia cache: {e}", "error")
    return redirect("/admin")

@admin_bp.route("/admin/run-fix-sha", methods=["POST"])
@admin_required
def run_fix_sha():
    """Uruchamia auto-fix SHA256 w recepturach (w tle, bez limitu czasu)."""
    from config.settings import PAGBUILD_SYNC as SYNC_BIN
    import threading as _th

    def _job():
        try:
            subprocess.run([SYNC_BIN, "--fix-sha"], capture_output=True, text=True,
                           env={**os.environ, "PYTHONUNBUFFERED": "1"})
        except Exception:
            pass

    _th.Thread(target=_job, daemon=True).start()
    flash("Fix SHA uruchomiony w tle (bez limitu czasu). Wyniki trafią do recipes.git / logów.", "ok")
    return redirect("/admin")

@admin_bp.route("/admin/user/<username>/resend-verify", methods=["POST"])
@admin_required
def resend_verify(username):
    """Ponownie wysyła e-mail weryfikacyjny."""
    import hashlib as _hl
    from mail import send_email
    from config.settings import BASE_URL

    db = get_db()
    user = db.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
    if not user:
        db.close()
        flash("Użytkownik nie znaleziony", "error")
        return redirect("/admin")

    if user["verified"]:
        db.close()
        flash(f"{username} jest już zweryfikowany", "ok")
        return redirect("/admin")

    token = (user["verify_token"] if user["verify_token"] else None) or _hl.sha256(os.urandom(32)).hexdigest()
    db.execute("UPDATE users SET verify_token=? WHERE username=?", (token, username))
    db.commit()
    db.close()

    link = f"{BASE_URL}/verify/{token}"
    body = f"""Witaj {username}!

Kliknij w link poniżej, aby potwierdzić swój adres e-mail:

{link}

Pozdrawiamy,
Zespół Pagan Linux
"""
    sent = send_email(user["email"], "Potwierdzenie adresu e-mail – Pagan Linux", body)
    if sent:
        flash(f"E-mail weryfikacyjny wysłany do {user['email']}", "ok")
    else:
        flash(f"⚠ Nie udało się wysłać e-maila. Sprawdź SMTP.", "error")
    return redirect("/admin")

@admin_bp.route("/admin/commits/delete", methods=["POST"])
@admin_required
def delete_commit():
    """Usuwa commit (reset --hard + force push)."""
    hash_val = request.form.get("hash", "")
    repo_path = os.path.join(GIT_DIR, "recipes.git")
    if not hash_val or not os.path.exists(repo_path):
        flash("Nieprawidłowe dane", "error")
        return redirect("/admin/commits")
    tmp = tempfile.mkdtemp(prefix="git-delete-")
    try:
        subprocess.run(["git", "clone", repo_path, tmp], capture_output=True, timeout=60)
        subprocess.run(["git", "-C", tmp, "reset", "--hard", f"{hash_val}^"],
                       capture_output=True, timeout=30)
        subprocess.run(["git", "-C", tmp, "push", "--force", "origin", "main"],
                       capture_output=True, timeout=30)
        flash(f"Commit {hash_val[:8]} usunięty (force push)", "ok")
    except Exception as e:
        flash(f"❌ Błąd: {e}", "error")
    finally:
        shutil.rmtree(tmp, ignore_errors=True)
    return redirect("/admin/commits")


# ═══════════════════════════════════════════════════════════════
# ZGŁOSZENIA RECEPTUR (community) – pending + zatwierdzanie
# ═══════════════════════════════════════════════════════════════
import json as _json
from pathlib import Path as _Path

PENDING_DIR = _Path("/var/lib/pagan-web/pending")
RECIPES_GIT = "/var/lib/pagan-sync/recipes"
_ALLOWED_CATS = {"core", "gui", "utils"}


def _render_pagbuild_yaml(data):
    """Generuje PAGBUILD.yaml z danych zgłoszenia – styl zgodny z drzewem receptur
    (bloki | dla build/package, cytowanie wersji i URL-i)."""
    import yaml as _y
    import re as _re
    class _Dumper(_y.Dumper):
        pass
    def _str_rep(dumper, val):
        if "\n" in val:
            return dumper.represent_scalar("tag:yaml.org,2002:str", val, style="|")
        if _re.match(r"^\d+\.\d+", val) or ":" in val or val.startswith("https://") or val in ("SKIP",):
            return dumper.represent_scalar("tag:yaml.org,2002:str", val, style="'")
        return dumper.represent_scalar("tag:yaml.org,2002:str", val)
    _Dumper.add_representer(str, _str_rep)
    doc = {
        "pkgname": data["pkgname"],
        "pkgver": data["pkgver"],
        "pkgrel": int(data.get("pkgrel") or 1),
        "pkgdesc": data.get("pkgdesc", ""),
        "url": data.get("url", ""),
        "arch": "x86_64",
        "license": [x.strip() for x in (data.get("license") or "").split(",") if x.strip()] or ["CUSTOM"],
        "depends": [x.strip() for x in (data.get("depends") or "").split(",") if x.strip()],
        "makedepends": [x.strip() for x in (data.get("makedepends") or "").split(",") if x.strip()],
        "source": [x.strip() for x in (data.get("source") or "").splitlines() if x.strip()],
        "sha256sums": [x.strip() for x in (data.get("sha256sums") or "").splitlines() if x.strip()] or ["SKIP"],
        "build": data.get("build", ""),
        "package": data.get("package", ""),
    }
    return _y.dump(doc, Dumper=_Dumper, sort_keys=False, allow_unicode=True, width=120)


@admin_bp.route("/admin/recipes-pending")
@admin_required
def recipes_pending():
    items = []
    if PENDING_DIR.is_dir():
        for f in sorted(PENDING_DIR.glob("*.json"), reverse=True):
            try:
                items.append(_json.loads(f.read_text()))
            except Exception:
                pass
    return render_template("admin/recipes_pending.html", pend=items, nav="recipes")


@admin_bp.route("/admin/recipes-pending/<rid>/approve", methods=["POST"])
@admin_required
def recipes_approve(rid):
    fp = PENDING_DIR / f"{rid}.json"
    if not fp.exists():
        flash("Nie znaleziono zgłoszenia", "error")
        return redirect("/admin/recipes-pending")
    rec = _json.loads(fp.read_text())
    data = rec["data"]
    cat = data.get("category", "")
    if cat not in _ALLOWED_CATS:
        flash(f"❌ Nieprawidłowa kategoria: {cat}", "error")
        return redirect("/admin/recipes-pending")
    try:
        yaml_txt = _render_pagbuild_yaml(data)
        pkg_dir = _Path(RECIPES_GIT) / cat / data["pkgname"]
        pkg_dir.mkdir(parents=True, exist_ok=True)
        (pkg_dir / "PAGBUILD.yaml").write_text(yaml_txt)

        subprocess.run(["git", "-C", RECIPES_GIT, "add", "."],
                       capture_output=True, timeout=30)
        subprocess.run(["git", "-C", RECIPES_GIT, "commit", "-m",
                        f"community: {data['pkgname']}-{data['pkgver']} (zatwierdzone przez admina)"],
                       capture_output=True, timeout=30)
        # Repo używa gałęzi main (nie master!) – bez tego push cicho nie działał.
        push = subprocess.run(["git", "-C", RECIPES_GIT, "push", "origin", "main"],
                              capture_output=True, text=True, timeout=60)
        if push.returncode != 0:
            raise RuntimeError("git push origin main: " + (push.stderr or "")[-300:])
        fp.unlink(missing_ok=True)
        flash(f"✅ {data['pkgname']}-{data['pkgver']} zatwierdzony i dodany do recipes!", "ok")
    except Exception as e:
        flash(f"❌ Błąd zatwierdzania: {e}", "error")
    return redirect("/admin/recipes-pending")


@admin_bp.route("/admin/recipes-pending/<rid>/reject", methods=["POST"])
@admin_required
def recipes_reject(rid):
    fp = PENDING_DIR / f"{rid}.json"
    if fp.exists():
        try:
            rec = _json.loads(fp.read_text())
            name = rec.get("data", {}).get("pkgname", rid)
        except Exception:
            name = rid
        fp.unlink(missing_ok=True)
        flash(f"Zgłoszenie {name} odrzucone", "ok")
    return redirect("/admin/recipes-pending")