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

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

346 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
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 = ['<?xml version="1.0" encoding="UTF-8"?>',
             '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
    for u in urls:
        lines.append(f'  <url><loc>{base}{u}</loc></url>')
    lines.append('</urlset>')
    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/<path:version>")
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/<wersja>.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)