import json, os, glob as g, re from datetime import datetime, timezone from flask import Blueprint, render_template, request, send_from_directory, jsonify, abort, session, flash from config.settings import REPO_BASE, CATEGORIES, STABLE_CATS, RECIPES_DIR from database import get_db repo_bp = Blueprint("repo", __name__) CAT_ICONS = {"core":"","desktop":"","tools":"","network":"","multimedia":"","gaming":"","office":"","other":""} def _get_packages(category="", limit=5000): pkgs = [] cats = [category] if category and category != "stable" else CATEGORIES if category == "stable": cats = STABLE_CATS for cat in cats: d = os.path.join(REPO_BASE, cat) if not os.path.isdir(d): continue rj = os.path.join(d, "repo.json") if os.path.exists(rj): try: for p in json.load(open(rj)).get("packages", []): p["size_mb"] = round(p.get("size", 0) / 1048576, 1) p["category"] = cat pkgs.append(p) except Exception: pass else: for fname in sorted(os.listdir(d)): if not (fname.endswith(".pag") or fname.endswith(".pkg.tar.xz")): continue fp = os.path.join(d, fname) base = fname[:-len(".pag")] if fname.endswith(".pag") else fname.replace(".pkg.tar.xz", "") # Inteligentne parsowanie: -- import re m = re.match(r'^(.+)-([\d][\w.-]*?)-(\d+)$', base) if m: name, version = m.group(1), m.group(2) else: name = base version = "0" pkgs.append({ "name": name, "version": version, "filename": fname, "size": os.path.getsize(fp), "size_mb": round(os.path.getsize(fp) / 1048576, 1), "arch": "x86_64", "category": cat, }) pkgs.sort(key=lambda x: x["name"]) return pkgs[:limit] def _latest_version(pkg_name): """Zwraca najnowszą wersję pakietu (najwyższą znalezioną w repo).""" best = None for p in _get_packages("", 5000): if p.get("name") == pkg_name: v = p.get("version", "") if v and (best is None or v > best): best = v return best or "" def _check_updates(): """Sprawdza obserwowane pakiety i wysyła e-mail o nowych wersjach. Wywoływane przy starcie / przez admina.""" from mail import send_email db = get_db() watches = db.execute("SELECT * FROM package_watches").fetchall() notified = 0 for w in watches: latest = _latest_version(w["pkg_name"]) if not latest or not w["last_known_version"]: continue if latest != w["last_known_version"]: # Wyślij powiadomienie user = db.execute("SELECT email FROM users WHERE username=?", (w["username"],)).fetchone() if user and user["email"]: subject = f"PaganOS: nowa wersja {w['pkg_name']} ({latest})" body = (f"Obserwowany pakiet {w['pkg_name']} ma nową wersję!\n\n" f"Wersja: {w['last_known_version']} → {latest}\n\n" f"Zobacz: https://repo.paganlinux.eu/\n") if send_email(user["email"], subject, body): notified += 1 db.execute("UPDATE package_watches SET last_known_version=? WHERE id=?", (latest, w["id"])) db.commit() db.close() return notified @repo_bp.route("/api/watch", methods=["POST"]) def watch_package(): """Obserwuj pakiet – dostaniesz e-mail o nowej wersji.""" if not session.get("username"): return jsonify({"error": "login required"}), 401 name = (request.form.get("name") or "").strip() if not name: return jsonify({"error": "missing name"}), 400 latest = _latest_version(name) db = get_db() db.execute("INSERT OR IGNORE INTO package_watches (username, pkg_name, last_known_version) VALUES (?,?,?)", (session["username"], name, latest)) db.commit() db.close() return jsonify({"ok": True, "watching": True}) @repo_bp.route("/api/unwatch", methods=["POST"]) def unwatch_package(): if not session.get("username"): return jsonify({"error": "login required"}), 401 name = (request.form.get("name") or "").strip() db = get_db() db.execute("DELETE FROM package_watches WHERE username=? AND pkg_name=?", (session["username"], name)) db.commit() db.close() return jsonify({"ok": True, "watching": False}) @repo_bp.route("/") def index(): cat = request.args.get("cat", "") search = request.args.get("q", "").lower() sort = request.args.get("sort", "name") page = request.args.get("page", 1, type=int) per_page = 30 pkgs = _get_packages(cat) if search: pkgs = [p for p in pkgs if search in p.get("name", "").lower() or search in p.get("category", "").lower() or search in str(p.get("description", "")).lower()] if sort == "size": pkgs.sort(key=lambda x: x.get("size", 0), reverse=True) elif sort == "version": pkgs.sort(key=lambda x: x.get("version", ""), reverse=True) else: pkgs.sort(key=lambda x: x.get("name", "").lower()) total_pkgs = len(pkgs) total_pages = max(1, (total_pkgs + per_page - 1) // per_page) page = min(page, total_pages) start = (page - 1) * per_page pkgs = pkgs[start:start + per_page] # Counts counts = {} for c in CATEGORIES: d = os.path.join(REPO_BASE, c) rj = os.path.join(d, "repo.json") if os.path.exists(rj): try: counts[c] = len(json.load(open(rj)).get("packages", [])) except Exception: counts[c] = len(g.glob(os.path.join(d, "*.pkg.tar.xz"))) if os.path.isdir(d) else 0 else: counts[c] = len(g.glob(os.path.join(d, "*.pkg.tar.xz"))) if os.path.isdir(d) else 0 # Sumaryczny rozmiar paczek (bajty) – z repo.json każdej kategorii total_size = 0 cat_sizes = {} for c in CATEGORIES: d = os.path.join(REPO_BASE, c) rj = os.path.join(d, "repo.json") sz = 0 if os.path.exists(rj): try: data = json.load(open(rj)) sz = sum(int(p.get("size", 0)) for p in data.get("packages", [])) except Exception: sz = sum(os.path.getsize(os.path.join(d, f)) for f in g.glob(os.path.join(d, "*.pkg.tar.xz"))) if os.path.isdir(d) else 0 elif os.path.isdir(d): sz = sum(os.path.getsize(os.path.join(d, f)) for f in g.glob(os.path.join(d, "*.pkg.tar.xz"))) cat_sizes[c] = sz total_size += sz total_size_gb = total_size / (1024.0 ** 3) return render_template("repo/index.html", packages=pkgs, categories=CATEGORIES, cat=cat, search=search, counts=counts, cat_icons=CAT_ICONS, total=sum(counts.values()), stable_cats=STABLE_CATS, page=page, total_pages=total_pages, total_pkgs=total_pkgs, sort=sort, per_page=per_page, total_size=total_size, total_size_gb=total_size_gb, cat_sizes=cat_sizes) @repo_bp.route("/download//") def download(category, filename): d = os.path.join(REPO_BASE, category) if os.path.isfile(os.path.join(d, filename)): return send_from_directory(d, filename, as_attachment=True) abort(404) @repo_bp.route("/sources/") def download_source(filename): sources_dir = "/var/www/repo.paganlinux.eu/sources" if os.path.isfile(os.path.join(sources_dir, filename)): return send_from_directory(sources_dir, filename, as_attachment=True) abort(404) @repo_bp.route("/package//") def package_detail(category, filename): """Szczegóły pakietu.""" d = os.path.join(REPO_BASE, category) fp = os.path.join(d, filename) if not os.path.isfile(fp): abort(404) # Nazwę i wersję bierzemy z repo.json (authoritative) – parsowanie nazwy # pliku jest mylące dla wersji z myślnikami (np. aalib-1.4rc5-6.pag dawało # name="aalib-1.4rc5"). repo.json ma poprawny name/version z metadata. name = version = None rj = os.path.join(d, "repo.json") if os.path.exists(rj): try: for p in json.load(open(rj)).get("packages", []): if p.get("filename") == filename: name = p.get("name") version = str(p.get("version", "")) break except Exception: pass if not name: # Fallback: parsuj nazwę pliku (format PaganOS: --.pag) base = filename[:-len(".pag")] if filename.endswith(".pag") else filename.replace(".pkg.tar.xz", "") m = re.match(r'^(.+)-([\d][\w.-]*?)-(\d+)$', base) if m: name, version = m.group(1), m.group(2) else: m = re.match(r'^(.+)-([\d][\w.-]*)$', base) name = m.group(1) if m else base version = m.group(2) if m else "?" size = os.path.getsize(fp) size_mb = round(size / 1048576, 1) # Receptura PAGBUILD.yaml w drzewie recipes – do bezpośredniego linku git # (https://git.paganlinux.eu/recipes/file?path=//PAGBUILD.yaml) recipe_path = None if name: try: for _cat in sorted(os.listdir(RECIPES_DIR)): if os.path.isfile(os.path.join(RECIPES_DIR, _cat, str(name), "PAGBUILD.yaml")): recipe_path = f"{_cat}/{name}/PAGBUILD.yaml" break except Exception: pass # URL projektu – metadata.json w .pag ma klucz "url" (nie "homepage") homepage = "" # Czytaj metadata.json z wnętrza paczki meta = {} rundeps = [] builddeps = [] try: import tarfile with tarfile.open(fp, "r:xz") as tf: if "metadata.json" in tf.getnames(): meta = json.load(tf.extractfile("metadata.json")) rundeps = meta.get("depends", []) or [] builddeps = meta.get("makedepends", []) or [] homepage = meta.get("url") or "" except Exception: pass # Historia wersji tej samej paczki w danej kategorii history = [] if os.path.isdir(d): for fname in os.listdir(d): if not (fname.endswith(".pag") or fname.endswith(".pkg.tar.xz")): continue base2 = fname[:-len(".pag")] if fname.endswith(".pag") else fname.replace(".pkg.tar.xz", "") vm = re.match(r'^' + re.escape(str(name)) + r'-([\d][\w.-]*?)-(\d+)$', base2) if not vm: continue version2 = vm.group(1) fp2 = os.path.join(d, fname) history.append({ "filename": fname, "version": version2, "size_mb": round(os.path.getsize(fp2) / 1048576, 1), "current": fname == filename, }) history.sort(key=lambda x: x["version"], reverse=True) # Czy użytkownik obserwuje ten pakiet? watching = False if session.get("username"): dbw = get_db() row = dbw.execute("SELECT 1 FROM package_watches WHERE username=? AND pkg_name=?", (session["username"], name)).fetchone() dbw.close() watching = row is not None return render_template("repo/package.html", name=name, version=version, category=category, filename=filename, size=size, size_mb=size_mb, meta=meta, deps=rundeps, builddeps=builddeps, history=history, watching=watching, homepage=homepage, recipe_path=recipe_path) @repo_bp.route("/api/packages") def api_packages(): cat = request.args.get("cat", "") q = request.args.get("q", "").lower() pkgs = _get_packages(cat, 2000) if q: pkgs = [p for p in pkgs if q in p.get("name","").lower() or q in p.get("category","").lower()] return jsonify({"packages": pkgs, "total": len(pkgs)}) @repo_bp.route("/api/delete-package", methods=["POST"]) def delete_package(): """Usuwa pakiet (.pag/.asc) i odświeża repo.json danej kategorii.""" if not session.get("is_admin"): return jsonify({"error": "admin only"}), 403 cat = (request.form.get("category") or "").strip() filename = (request.form.get("filename") or "").strip() # Walidacja: żadnych ścieżek / dziwnych nazw if not cat or not filename or "/" in filename or filename.startswith("."): return jsonify({"error": "invalid params"}), 400 fp = os.path.join(REPO_BASE, cat, filename) if not os.path.isfile(fp): return jsonify({"error": "file not found"}), 404 os.remove(fp) asc = fp + ".asc" if os.path.exists(asc): os.remove(asc) # Odśwież repo.json kategorii, żeby lista nie pokazywała usuniętego pakietu rj = os.path.join(REPO_BASE, cat, "repo.json") if os.path.exists(rj): try: data = json.load(open(rj)) pkgs = data.get("packages", []) data["packages"] = [p for p in pkgs if p.get("filename") != filename] data["count"] = len(data["packages"]) data["updated"] = datetime.now(timezone.utc).isoformat() json.dump(data, open(rj, "w"), indent=1, ensure_ascii=False) except Exception: pass flash(f"Usunięto: {filename}", "ok") return jsonify({"ok": True}) @repo_bp.route("/api/delete-all-packages", methods=["POST"]) def delete_all_packages(): """Usuwa WSZYSTKIE paczki ze wszystkich kategorii.""" if not session.get("is_admin"): return jsonify({"error": "admin only"}), 403 import glob as gmod total = 0 for cat in CATEGORIES: d = os.path.join(REPO_BASE, cat) if not os.path.isdir(d): continue for f in gmod.glob(os.path.join(d, "*.pkg.tar.xz")): os.remove(f) total += 1 # Usuń też repo.json rj = os.path.join(d, "repo.json") if os.path.exists(rj): os.remove(rj) flash(f"Usunięto {total} pakietów ze wszystkich kategorii", "ok") return jsonify({"ok": True, "deleted": total})