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/", 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//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//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//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//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//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//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//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//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//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/) ═══ 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//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//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//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//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//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")