from flask import Blueprint, render_template, jsonify, request import os, json, glob as gmod, subprocess from config.settings import STATE_FILE, GIT_DIR main_bp = Blueprint("main", __name__) def _recent_recipe_changes(limit=10): """Ostatnie commity w recipes.git (repo receptur).""" repo = os.path.join(GIT_DIR, "recipes.git") if not os.path.isdir(repo): return [] try: out = subprocess.run( ["git", f"--git-dir={repo}", "--no-pager", "log", "-n", str(limit), "--pretty=format:%H|%an|%ar|%s"], capture_output=True, text=True, timeout=10).stdout except Exception: return [] changes = [] for line in out.strip().splitlines(): if not line: continue parts = line.split("|", 3) if len(parts) == 4: changes.append({"hash": parts[0], "author": parts[1], "when": parts[2], "subject": parts[3]}) return changes def _live_build_data(): """Dane dla strony /status – stan buildów + ostatnie zmiany w recipes.""" s = {} if os.path.exists(STATE_FILE): try: s = json.load(open(STATE_FILE)) except Exception: pass builds = s.get("builds", []) ok = sum(1 for b in builds if b.get("status") == "ok") fail = sum(1 for b in builds if b.get("status") == "failed") return { "total": len(builds), "ok": ok, "fail": fail, "current": s.get("current_build"), "last_sync": s.get("last_sync", ""), "last_build": builds[-1] if builds else None, "changes": _recent_recipe_changes(), } @main_bp.route("/api/status-live") def status_live(): """JSON dla strony /status – odświeżany przez JS.""" return jsonify(_live_build_data()) def _server_status_payload(): """Kondycja serwera: metryki hosta, usługi i dostępność podstron. Wszystko lokalnie – bez zewnętrznych serwisów (otwarte i darmowe).""" import time as _t out = {"server": {}, "checks": []} # ── metryki hosta ── try: with open("/proc/uptime") as f: up = float(f.read().split()[0]) d, rem = int(up // 86400), up % 86400 h, rem = int(rem // 3600), rem % 3600 m = int(rem // 60) out["server"]["uptime"] = f"{d}d {h}h {m}m" except Exception: out["server"]["uptime"] = "?" try: with open("/proc/loadavg") as f: out["server"]["load"] = f.read().split()[:3] except Exception: out["server"]["load"] = ["?"] try: mem = {} with open("/proc/meminfo") as f: for ln in f: if ln.startswith(("MemTotal", "MemAvailable")): k, v = ln.split(":")[0], int(ln.split()[1]) mem[k] = v total = mem.get("MemTotal", 1) used = total - mem.get("MemAvailable", total) out["server"]["ram_pct"] = round(100.0 * used / total, 0) out["server"]["ram_total_gb"] = round(total / 1048576, 1) except Exception: out["server"]["ram_pct"] = 0 out["server"]["ram_total_gb"] = 0 try: st = os.statvfs("/") total_b = st.f_blocks * st.f_frsize free_b = st.f_bavail * st.f_frsize used_b = total_b - free_b out["server"]["disk_pct"] = round(100.0 * used_b / total_b, 0) out["server"]["disk_free_gb"] = round(free_b / 1073741824, 1) except Exception: out["server"]["disk_pct"] = 0 out["server"]["disk_free_gb"] = 0 # ── usługi (ps) ── procs = "" try: r = subprocess.run(["ps", "-eo", "comm,args"], capture_output=True, text=True, timeout=5) procs = r.stdout except Exception: pass services = [] services.append({"name": "Nginx", "ok": "nginx" in procs}) services.append({"name": "Anubis", "ok": "anubis" in procs}) services.append({"name": "Panel web", "ok": "/opt/pagan-web-v2/app.py" in procs}) services.append({"name": "Builder", "ok": ("pagsync" in procs or "pagbuild" in procs)}) out["server"]["services"] = services # ── dostępność podstron (równolegle, krótki timeout) ── endpoints = [ {"name": "paganlinux.eu", "url": "https://paganlinux.eu/"}, {"name": "repo.paganlinux.eu", "url": "https://repo.paganlinux.eu/"}, {"name": "repo.json", "url": "https://repo.paganlinux.eu/stable/repo.json"}, {"name": "git.paganlinux.eu", "url": "https://git.paganlinux.eu/"}, {"name": "docs.paganlinux.eu", "url": "https://docs.paganlinux.eu/"}, {"name": "build.paganlinux.eu", "url": "https://build.paganlinux.eu/build/"}, ] def _check(ep): import urllib.request as _ur t0 = _t.time() try: req = _ur.Request(ep["url"], headers={"User-Agent": "PaganOS-status/1.0"}) with _ur.urlopen(req, timeout=7) as resp: resp.read(32768) code = resp.status except Exception as e: code = getattr(e, "code", None) or 0 return {"name": ep["name"], "url": ep["url"], "ok": (0 < code < 400) or code in (401, 403), "code": code, "ms": int((_t.time() - t0) * 1000)} try: from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=6) as ex: out["checks"] = list(ex.map(_check, endpoints)) except Exception: out["checks"] = [{"name": e["name"], "url": e["url"], "ok": False, "code": 0, "ms": 0} for e in endpoints] return out @main_bp.route("/api/server-status") def api_server_status(): """JSON kondycji serwera dla strony /status.""" return jsonify(_server_status_payload()) def _count_packages(): """Liczy rzeczywistą liczbę pakietów w repo.""" from config.settings import REPO_BASE, CATEGORIES total = 0 cats_count = {} for cat in CATEGORIES: d = os.path.join(REPO_BASE, cat) if not os.path.isdir(d): continue # Preferuj repo.json (szybciej) rj = os.path.join(d, "repo.json") if os.path.exists(rj): try: n = len(json.load(open(rj)).get("packages", [])) cats_count[cat] = n total += n continue except Exception: pass # Fallback: licz pliki .pkg.tar.xz n = len(gmod.glob(os.path.join(d, "*.pkg.tar.xz"))) cats_count[cat] = n total += n return total, cats_count def _read_build_status(): if not os.path.exists(STATE_FILE): return {"last_sync": "", "current_build": None, "last_build": None, "total_builds": 0, "success": 0, "failed": 0} try: state = json.load(open(STATE_FILE)) builds = state.get("builds", []) current_build = state.get("current_build") last_build = builds[-1] if builds else None return { "last_sync": state.get("last_sync", ""), "current_build": current_build, "last_build": last_build, "total_builds": len(builds), "success": sum(1 for b in builds if b.get("status") == "ok"), "failed": sum(1 for b in builds if b.get("status") == "failed"), } except Exception: return {"last_sync": "", "current_build": None, "last_build": None, "total_builds": 0, "success": 0, "failed": 0} @main_bp.route("/") def index(): pkg_count, cats = _count_packages() build_status = _read_build_status() return render_template("main/index.html", pkg_count=pkg_count, pkg_cats=cats, build_status=build_status, recent_changes=_recent_recipe_changes(8)) @main_bp.route("/sitemap.xml") def sitemap(): from flask import Response base = "https://paganlinux.eu" urls = ["/", "/download", "/about", "/status", "/repo", "/docs", "/submit-recipe"] lines = ['', ''] for u in urls: lines.append(f' {base}{u}') lines.append('') return Response("\n".join(lines), mimetype="application/xml") @main_bp.route("/robots.txt") def robots(): from flask import Response body = "User-agent: *\nAllow: /\nSitemap: https://paganlinux.eu/sitemap.xml\n" return Response(body, mimetype="text/plain") @main_bp.route("/cookies") def cookies_page(): """Polityka cookies – link z belki cookies na każdej stronie.""" return render_template("main/cookies.html") @main_bp.route("/status") def status_page(): return render_template("main/status.html", data=_live_build_data()) @main_bp.route("/download") def download(): from database import get_db db = get_db() try: downloads = db.execute("SELECT * FROM downloads WHERE enabled=1 ORDER BY sort_order, id").fetchall() except Exception: downloads = [] 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"/download/iso/{d['version']}" d["link_sha"] = f"/download/iso/{d['version']}.sha256" d["size_mb"] = round(d["size"] / 1048576) if d.get("size") else 0 d["date"] = (d.get("created_at") or "")[:10] d["sha256_short"] = (d.get("sha256") or "")[:12] isos.append(d) return render_template("main/download.html", downloads=downloads, isos=isos) @main_bp.route("/download/iso/") def download_iso(version): """Serwuje plik ISO dla wersji (np. /download/iso/0.0.1-kde).""" from flask import send_from_directory, abort from config.settings import ISO_DIR from database import get_db import re if not re.fullmatch(r"[A-Za-z0-9._-]+", version): abort(404) db = get_db() try: row = db.execute("SELECT * FROM iso_files WHERE version=?", (version,)).fetchone() except Exception: row = None db.close() if row and os.path.isfile(os.path.join(ISO_DIR, row["filename"])): return send_from_directory(ISO_DIR, row["filename"], as_attachment=True, download_name=row["filename"]) # Plik kontrolny sumy: /download/iso/.sha256 if version.endswith(".sha256"): base = version[:-7] if base and re.fullmatch(r"[A-Za-z0-9._-]+", base): db = get_db() try: row2 = db.execute("SELECT * FROM iso_files WHERE version=?", (base,)).fetchone() except Exception: row2 = None db.close() side = os.path.join(ISO_DIR, row2["filename"] + ".sha256") if row2 else "" if row2 and os.path.isfile(side): return send_from_directory(ISO_DIR, row2["filename"] + ".sha256", as_attachment=True, download_name=row2["filename"] + ".sha256") # Fallback: bezpośrednia nazwa pliku (np. /download/iso/Pagan-Linux-0.0.1-kde.iso) if version.lower().endswith(".iso") and os.path.isfile(os.path.join(ISO_DIR, version)): return send_from_directory(ISO_DIR, version, as_attachment=True, download_name=version) abort(404) @main_bp.route("/about") def about(): """Strona About – treść z DB (docs/slug='about'), fallback: statyczny szablon. Edycja: /admin/about (tylko admin). Dopóki nie ma wpisu w DB pokazuje się dawny templates/main/about.html, więc zmiana jest bezpieczna.""" from database import get_db from i18n import get_lang req_lang = (request.args.get("lang") or "").strip() cur = req_lang or get_lang() db = get_db() doc = db.execute("SELECT * FROM docs WHERE slug='about' AND lang=?", (cur,)).fetchone() if doc is None and cur != "pl": # Brak wybranego języka – pokaż polski (domyślny), o ile istnieje doc = db.execute("SELECT * FROM docs WHERE slug='about' AND lang='pl'").fetchone() other = db.execute("SELECT lang FROM docs WHERE slug='about' AND lang!=?", (doc["lang"] if doc else "-",)).fetchall() db.close() if not doc: return render_template("main/about.html") return render_template("main/about_doc.html", doc=doc, other_langs=[r["lang"] for r in other]) @main_bp.route("/translate") def translate_page(): from i18n import TRANSLATIONS en_keys = set(TRANSLATIONS.get("en", {}).keys()) pl_keys = set(TRANSLATIONS.get("pl", {}).keys()) all_keys = en_keys | pl_keys missing_pl = sorted(en_keys - pl_keys) missing_en = sorted(pl_keys - en_keys) total = len(all_keys) done_pl = total - len(missing_pl) done_en = total - len(missing_en) pct_pl = round(done_pl / total * 100) if total else 100 pct_en = round(done_en / total * 100) if total else 100 sections = { "nav": ["git", "repo", "build", "docs", "add_pkg", "login_btn", "logout_btn", "register", "profile", "admin"], "hero": ["hero_title", "hero_subtitle", "download_iso", "browse_repo", "browse_docs", "pkg_count", "build_count", "ok_count", "fail_count"], "features": ["why_title", "feat_from_scratch", "feat_from_scratch_desc", "feat_pkg_mgr", "feat_pkg_mgr_desc", "feat_toolchain", "feat_toolchain_desc", "feat_security", "feat_security_desc"], "repo_section": ["repo_all", "repo_search", "repo_name_header", "repo_version", "repo_desc", "repo_no_results", "size", "category"], "footer": ["footer_about", "footer_links", "footer_dl", "footer_repo", "footer_source", "footer_docs", "footer_community", "footer_suggest", "footer_about_link", "footer_copy"], "auth": ["login_title", "username_placeholder", "password_placeholder", "remember_me", "login_submit", "create_account", "forgot_password"], } return render_template("main/translate.html", total=total, done_pl=done_pl, done_en=done_en, pct_pl=pct_pl, pct_en=pct_en, missing_pl=missing_pl, missing_en=missing_en, sections=sections, tr=TRANSLATIONS)