#!/usr/bin/env python3 """ ╔══════════════════════════════════════════════════════════╗ ║ PaganOS Web v2 – Unified Web Platform ║ ║ One app → paganlinux.eu / git / repo / build / docs ║ ╚══════════════════════════════════════════════════════════╝ """ import os, sys, time, hashlib, functools from datetime import timedelta from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from flask import Flask, render_template, redirect, request, session, flash, jsonify, make_response, send_from_directory, abort from werkzeug.middleware.proxy_fix import ProxyFix from config.settings import SECRET_KEY, DEBUG, PORT, HOST from database import init_db from auth import get_section from i18n import inject_i18n app = Flask(__name__) app.secret_key = SECRET_KEY app.config["APPLICATION_ROOT"] = "/" app.config["TEMPLATES_AUTO_RELOAD"] = True # ProxyFix – nginx przekazuje HTTPS jako X-Forwarded-Proto app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) # ── Subdomain rewrite: git.paganlinux.eu → /git, repo → /repo itp. ── # Przepisuje PATH_INFO ZANIM Flask dopasuje URL (poziom WSGI). _orig_wsgi = app.wsgi_app _prefixes = { "git.paganlinux.eu": "/git", "repo.paganlinux.eu": "/repo", "build.paganlinux.eu": "/build", "docs.paganlinux.eu": "/docs", } _skip = frozenset({"/static/", "/login", "/register", "/logout", "/profile", "/admin", "/set-lang/", "/health", "/sources/"}) def _rewrite_wsgi(environ, start_response): host = environ.get("HTTP_HOST", "").split(":")[0] prefix = _prefixes.get(host) if prefix: path = environ.get("PATH_INFO", "") if not path.startswith(prefix) and not any(path.startswith(s) for s in _skip): environ["PATH_INFO"] = prefix + path return _orig_wsgi(environ, start_response) app.wsgi_app = _rewrite_wsgi # ── Init DB ── init_db() # ── Sprawdź obserwowane pakiety przy starcie (e-mail o nowych wersjach) ── import threading as _thr def _startup_update_check(): try: from blueprints.repo import _check_updates _check_updates() except Exception: pass _thr.Thread(target=_startup_update_check, daemon=True).start() # ── i18n context ── app.context_processor(inject_i18n) # ── Pomocnik: linki nawigacyjne zależne od domeny ── def _section_url(section_name): """Zwraca URL do sekcji – zawsze przez subdomenę.""" current = get_section() subdomains = { "git": "git.paganlinux.eu", "repo": "repo.paganlinux.eu", "build": "build.paganlinux.eu", "docs": "docs.paganlinux.eu", } # Na tej samej subdomenie → link do / if section_name == current: return "/" # Wszystko inne → subdomena domain = subdomains.get(section_name, "paganlinux.eu") return f"//{domain}/" # ── Pomocnik: dostosowuje ścieżki do bieżącej subdomeny ── def _subpath(path): """Na subdomenie usuwa prefix (np. /git/repo → /repo).""" mapping = {"git": "/git", "repo": "/repo", "build": "/build", "docs": "/docs"} prefix = mapping.get(get_section()) if prefix and path.startswith(prefix): stripped = path[len(prefix):] return stripped or "/" return path # ── Global context ── @app.context_processor def inject_globals(): return { "section": get_section(), "section_url": _section_url, "subpath": _subpath, "username": session.get("username"), "is_admin": session.get("is_admin", False), } # ── Security: rate limiter (simple in-memory) ── # ── Secure session config ── app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax', SESSION_COOKIE_SECURE=False, # True tylko jeśli HTTPS (nginx dodaje) SESSION_COOKIE_DOMAIN='.paganlinux.eu', # działa na wszystkich subdomenach PERMANENT_SESSION_LIFETIME=86400 * 7, # 7 dni ) # ── Limity parsowania formularzy (Flask 3.1+ / Werkzeug 3.1+) ── # Domyślne MAX_FORM_MEMORY_SIZE = 500 KB powodowało HTTP 413 przy zapisie # dużych receptur przez edytor plików w git (cała treść leci jako pole # formularza `content`). 64 MB z zapasem pokrywa największe PAGBUILD.yaml. # UWAGA: nie ustawiamy MAX_CONTENT_LENGTH – upload ISO (1–2 GB) to część # plikowa multipart, która nie jest objęta MAX_FORM_MEMORY_SIZE. app.config["MAX_FORM_MEMORY_SIZE"] = 64 * 1024 * 1024 # 64 MB (pola formularzy) app.config["MAX_FORM_PARTS"] = 10000 # liczba pól (np. formularze z listami) @app.errorhandler(413) def _request_too_large(e): """Czytelny komunikat zamiast surowego 413 (szczególnie dla API panelu).""" limit_mb = (app.config.get("MAX_FORM_MEMORY_SIZE") or 0) // (1024 * 1024) msg = (f"Żądanie za duże (limit {limit_mb} MB dla pól formularza). " "Zmniejsz plik lub zwiększ MAX_FORM_MEMORY_SIZE.") if limit_mb else "Żądanie za duże." if request.path.startswith("/build/api/"): return jsonify({"error": msg, "too_large": True}), 413 flash(msg, "error") return redirect(request.referrer or "/"), 303 # ── Security headers ── @app.after_request def add_security_headers(response): response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY' response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()' # CSP – pozwala na style/script z tego samego origin + unpkg CDN response.headers['Content-Security-Policy'] = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline' https://unpkg.com; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: https://api.qrserver.com; " "font-src 'self'; " "connect-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " "form-action 'self'" ) return response _rate_limits = {} def rate_limit(limit=10, window=60): """Dekorator ograniczający liczbę zapytań.""" def decorator(f): @functools.wraps(f) def wrap(*a, **kw): ip = request.remote_addr now = time.time() key = f"{ip}:{request.path}" entries = _rate_limits.get(key, []) entries = [e for e in entries if e > now - window] if len(entries) >= limit: return jsonify({"error": "Too many requests"}), 429 entries.append(now) _rate_limits[key] = entries return f(*a, **kw) return wrap return decorator # ── CSRF token ── def generate_csrf(): if "csrf_token" not in session: session["csrf_token"] = hashlib.sha256(os.urandom(32)).hexdigest() return session["csrf_token"] @app.before_request def csrf_check(): if request.method == "POST" and not request.path.startswith("/build/api/"): if ".git/" in request.path: return # skip CSRF for git HTTP backend token = request.form.get("csrf_token", "") or request.headers.get("X-CSRF-Token", "") expected = session.get("csrf_token", "") # Wymagaj tokena ZAWSZE (również gdy sesja nie ma csrf – inaczej # puste==puste przepuszcza ataki CSRF bez sesji ofiary) if (not expected or token != expected) and request.path not in ("/login", "/register"): flash("Nieprawidłowy token CSRF. Spróbuj ponownie.", "error") return redirect(request.url) app.jinja_env.globals["csrf_token"] = generate_csrf # ── SEO: robots.txt + sitemap.xml ── @app.route("/robots.txt") def robots_txt(): body = ( "User-agent: *\n" "Allow: /\n" "Disallow: /login\n" "Disallow: /register\n" "Disallow: /profile\n" "Disallow: /admin\n" "Disallow: /build/\n\n" "Sitemap: https://paganlinux.eu/sitemap.xml\n" ) return make_response(body, 200, {"Content-Type": "text/plain; charset=utf-8"}) @app.route("/sitemap.xml") def sitemap_xml(): urls = [ "https://paganlinux.eu/", "https://paganlinux.eu/about", "https://paganlinux.eu/download", "https://paganlinux.eu/status", "https://paganlinux.eu/translate", "https://paganlinux.eu/repo", "https://paganlinux.eu/git", "https://docs.paganlinux.eu/", "https://docs.paganlinux.eu/getting-started", ] locs = "".join(f" {u}daily\n" for u in urls) body = ( '\n' '\n' + locs + "\n" ) return make_response(body, 200, {"Content-Type": "application/xml; charset=utf-8"}) # ── Git HTTP backend (obsługa git clone/push przez Flask) ── import subprocess as _sp GIT_BACKEND = "/usr/lib/git-core/git-http-backend" @app.route("/.git/", methods=["GET", "POST"]) @app.route("/git/.git/", methods=["GET", "POST"]) def git_backend(repo, subpath): """Przekazuje zapytania git do git-http-backend.""" repo_path = f"/var/git/{repo}.git" if not os.path.isdir(repo_path): return "Not found", 404 env = os.environ.copy() env["GIT_PROJECT_ROOT"] = "/var/git" env["PATH_INFO"] = f"/{repo}.git/{subpath}" env["REQUEST_METHOD"] = request.method env["CONTENT_TYPE"] = request.content_type or "application/x-git-upload-pack-request" env["QUERY_STRING"] = request.query_string.decode() if request.query_string else "" env["GIT_HTTP_EXPORT_ALL"] = "1" env["GIT_CONFIG_PARAMETERS"] = "'safe.directory=*'" is_clone = "git-upload-pack" in env["QUERY_STRING"] or subpath.endswith("git-upload-pack") auth = request.headers.get("Authorization", "") if auth.startswith("Basic "): import base64 try: user_pass = base64.b64decode(auth[6:]).decode() user = user_pass.split(":")[0] env["REMOTE_USER"] = user if user else "git" except Exception: env["REMOTE_USER"] = "git" elif is_clone: env["REMOTE_USER"] = "anonymous" else: return "Unauthorized", 401, {"WWW-Authenticate": "Basic realm=\"Git\""} body = request.get_data() or b"" env["CONTENT_LENGTH"] = str(len(body)) try: proc = _sp.run([GIT_BACKEND], input=body, capture_output=True, env=env, timeout=300) except Exception as e: with open("/tmp/git_debug.log", "a") as f: f.write(f"EXCEPTION: {e}\n") return f"Git error: {e}", 500 with open("/tmp/git_debug.log", "a") as f: f.write(f"RC={proc.returncode} OUT={len(proc.stdout)} ERR={len(proc.stderr)} U={env.get('REMOTE_USER','?')}\n") if proc.stderr: f.write(f"STDERR: {proc.stderr[:500]}\n") if proc.stdout: f.write(f"STDOUT: {proc.stdout[:300]}\n") output = proc.stdout if proc.stdout else proc.stderr if not output: return f"Git backend error (rc={proc.returncode})", 500 parts = output.split(b"\r\n\r\n", 1) if len(parts) == 2: header_lines = parts[0].decode(errors="replace").split("\r\n") resp_body = parts[1] else: header_lines = [] resp_body = output status = 200 resp_headers = {} for line in header_lines: if ":" in line: k, v = line.split(":", 1) k, v = k.strip(), v.strip() if k.lower() == "status": try: status = int(v.split()[0]) except Exception: pass elif k.lower() not in ("expires", "pragma", "cache-control"): resp_headers[k] = v resp_headers["Content-Type"] = resp_headers.get("Content-Type", "application/x-git-upload-pack-result") return resp_body, status, resp_headers @app.route("/set-lang/") def set_lang(lang): next_url = request.args.get("next", "https://paganlinux.eu/") # Security: tylko względne ścieżki lub nasze domeny from urllib.parse import urlparse parsed = urlparse(next_url) allowed_hosts = {"", "paganlinux.eu", "www.paganlinux.eu", "git.paganlinux.eu", "repo.paganlinux.eu", "build.paganlinux.eu", "docs.paganlinux.eu"} if parsed.hostname and parsed.hostname not in allowed_hosts: next_url = "https://paganlinux.eu/" resp = make_response(redirect(next_url)) resp.set_cookie("lang", lang, max_age=365*86400, domain=".paganlinux.eu", secure=True, httponly=True, samesite="Lax") return resp # ── Rejestracja Blueprints ── from blueprints.main import main_bp from blueprints.git import git_bp from blueprints.repo import repo_bp from blueprints.build_panel import build_bp from blueprints.docs import docs_bp from blueprints.auth_bp import auth_bp from blueprints.profile import profile_bp from blueprints.admin import admin_bp from blueprints.submit import submit_bp from blueprints.admin_vps import admin_vps app.register_blueprint(main_bp) app.register_blueprint(git_bp, url_prefix="/git") app.register_blueprint(repo_bp, url_prefix="/repo") app.register_blueprint(build_bp, url_prefix="/build") app.register_blueprint(docs_bp, url_prefix="/docs") app.register_blueprint(auth_bp) app.register_blueprint(profile_bp) app.register_blueprint(admin_bp) app.register_blueprint(admin_vps) app.register_blueprint(submit_bp) # ── Sources – pliki źródłowe dla recipe (https://repo.paganlinux.eu/sources/) ── SOURCES_DIR = "/var/www/repo.paganlinux.eu/sources" @app.route("/sources/") def serve_source(filename): import os fp = os.path.join(SOURCES_DIR, filename) # Zabezpieczenie przed path traversal if not os.path.realpath(fp).startswith(os.path.realpath(SOURCES_DIR)): abort(404) if os.path.isfile(fp): return send_from_directory(SOURCES_DIR, filename, as_attachment=False) abort(404) # ── Subdomain routing – obsługiwane przez _SubdomainRewrite (WSGI middleware) ── # ── Error pages ── @app.errorhandler(404) def not_found(e): return render_template("404.html"), 404 # ── Health ── @app.route("/health") def health(): return jsonify({"ok": True}) # ── Markdown filter (obsługa Mermaid: ```mermaid / graph TD) ── # Wspólny renderer w markdown_render.py – fallback do surowego tekstu bez mistune. from markdown_render import render_markdown @app.template_filter("markdown") def markdown_filter(text): return render_markdown(text) # ═══════════════════════════════════════════════════════ if __name__ == "__main__": import argparse p = argparse.ArgumentParser() p.add_argument("--port", type=int, default=PORT) p.add_argument("--debug", action="store_true") args = p.parse_args() print(f" PaganOS Web v2 – http://{HOST}:{args.port}") app.run(host=HOST, port=args.port, debug=args.debug or DEBUG)