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

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

397 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
#!/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"  <url><loc>{u}</loc><changefreq>daily</changefreq></url>\n" for u in urls)
    body = (
        '<?xml version="1.0" encoding="UTF-8"?>\n'
        '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
        + locs
        + "</urlset>\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("/<path:repo>.git/<path:subpath>", methods=["GET", "POST"])
@app.route("/git/<path:repo>.git/<path:subpath>", 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/<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/<path:filename>")
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)