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)