🔒 Repository is read-only – file editing is disabled.
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")