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

pagan-web/app.py main

546 linii Raw ← Powrót
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
#!/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"
GIT_DIR = "/var/git"

import base64 as _b64
import secrets as _secrets


def _git_repo_dir(repo):
    """Zwraca ścieżkę repo albo None, jeśli wykracza poza GIT_DIR (path traversal)."""
    root = os.path.realpath(GIT_DIR)
    path = os.path.realpath(os.path.join(root, f"{repo}.git"))
    if path != root and not path.startswith(root + os.sep):
        return None
    return path


def _git_credentials():
    """Dekoduje nagłówek HTTP Basic. Fail-closed: None przy jakimkolwiek błędzie."""
    scheme, _, payload = request.headers.get("Authorization", "").partition(" ")
    if scheme.lower() != "basic" or not payload:
        return None
    try:
        raw = _b64.b64decode(payload, validate=True).decode("utf-8")
    except Exception:
        return None
    username, sep, secret = raw.partition(":")
    if not sep or not username:
        return None
    return username, secret


def _git_authenticate(username, secret):
    """Weryfikuje HASŁO albo GIT_TOKEN. Zwraca wiersz użytkownika albo None.

    PO CO token: konta z 2FA nie mogą podać hasła w git — `pag_pat_...` z profilu
    jest właśnie do tego. Wcześniej hasło nie było sprawdzane W OGÓLE.
    """
    from database import get_db
    from auth import verify_password
    db = None
    try:
        db = get_db()
        row = db.execute(
            "SELECT username, password_hash, git_token, is_admin, blocked, verified FROM users WHERE username=?",
            (username,),
        ).fetchone()
    except Exception:
        return None
    finally:
        if db is not None:
            try:
                db.close()
            except Exception:
                pass
    if row is None or row["blocked"]:
        return None
    token = row["git_token"] or ""
    if token and _secrets.compare_digest(secret.encode(), token.encode()):
        return row
    if verify_password(secret, row["password_hash"]):
        return row
    return None


def _git_may_access(user_row, repo):
    """Czy użytkownik ma prawo do repo (admin zawsze; inaczej repo_permissions)."""
    if user_row is None:
        return False
    if user_row["is_admin"]:
        return True
    from auth import can_edit_repo
    return can_edit_repo(user_row["username"], repo)


def _git_401():
    return "Unauthorized", 401, {"WWW-Authenticate": 'Basic realm="Git"'}


def _git_403():
    return "Forbidden", 403


# ── Throttling prób logowania do gita (ochrona przed zgadywaniem hasła) ──
# Web-login ma `_check_brute_force`; git nie miał żadnego — a to jest właśnie
# endpoint, który bywa atakowany. Blokujemy po (IP + login) na okno czasowe.
_GIT_FAILS = {}
_GIT_FAILS_LOCK = _thr.Lock()
_GIT_MAX_FAILS = 10
_GIT_FAIL_WINDOW = 900  # sekundy (15 min)
_MAX_TRACKED = 20000


def _git_client_ip():
    # nginx ustawia X-Real-IP; ProxyFix nie mapuje remote_addr (x_for=0),
    # więc bierzemy nagłówek jawnie — tak samo robi blueprints/auth_bp.py.
    return request.headers.get("X-Real-IP", request.remote_addr) or "?"


def _git_rate_limited(key):
    now = time.time()
    with _GIT_FAILS_LOCK:
        hits = [t for t in _GIT_FAILS.get(key, []) if t > now - _GIT_FAIL_WINDOW]
        _GIT_FAILS[key] = hits
        return len(hits) >= _GIT_MAX_FAILS


def _git_record_fail(key):
    now = time.time()
    with _GIT_FAILS_LOCK:
        _GIT_FAILS.setdefault(key, []).append(now)
        # Okazjonalne czyszczenie, żeby słownik nie rósł w nieskończoność.
        if len(_GIT_FAILS) > _MAX_TRACKED:
            cutoff = now - _GIT_FAIL_WINDOW
            for k in [k for k, v in _GIT_FAILS.items() if not [t for t in v if t > cutoff]]:
                _GIT_FAILS.pop(k, None)


def _git_clear_fails(key):
    with _GIT_FAILS_LOCK:
        _GIT_FAILS.pop(key, None)


@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 = _git_repo_dir(repo)
    if repo_path is None or not os.path.isdir(repo_path):
        return "Not found", 404

    env = os.environ.copy()
    env["GIT_PROJECT_ROOT"] = GIT_DIR
    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 ""
    # EXPORT_ALL=1 jest potrzebne, by git-http-backend obsłużył TAKŻE repo prywatne
    # (dla uprawnionych). Widoczność egzekwujemy sami poniżej, na podstawie markera
    # `git-daemon-export-ok` — inaczej repo prywatne byłoby publicznie klonowalne.
    env["GIT_HTTP_EXPORT_ALL"] = "1"
    env["GIT_CONFIG_PARAMETERS"] = "'safe.directory=*'"

    # `git-receive-pack` = zapis (push); jego reklama (`info/refs?service=...`)
    # też wymaga logowania — dzięki temu klient git wie, że ma się uwierzytelnić.
    is_push = "git-receive-pack" in env["QUERY_STRING"] or subpath.endswith("git-receive-pack")
    is_public = os.path.exists(os.path.join(repo_path, "git-daemon-export-ok"))

    creds = _git_credentials()
    rl_key = None
    if creds is None:
        user_row = None
    else:
        # Throttling: po serii nieudanych prób blokujemy (IP + login) na okno czasowe.
        rl_key = f"{_git_client_ip()}:{creds[0]}"
        if _git_rate_limited(rl_key):
            return "Too Many Requests", 429, {"Retry-After": str(_GIT_FAIL_WINDOW)}
        user_row = _git_authenticate(creds[0], creds[1])
        # Podano dane, ale są BŁĘDNE → zawsze 401; nigdy nie udawaj anonima ani
        # nie przepuszczaj dalej (to była właśnie luka: sprawdzano tylko obecność).
        if user_row is None:
            _git_record_fail(rl_key)
            return _git_401()
        _git_clear_fails(rl_key)

    if is_push:
        # ZAPIS: poprawne uwierzytelnienie ORAZ uprawnienie do tego repo.
        if user_row is None:
            return _git_401()
        # Konta bez potwierdzonego e-maila nie pushują (limit jest pułapką na boty).
        if not user_row["verified"]:
            return "Forbidden: account not verified", 403
        if not _git_may_access(user_row, repo):
            return _git_403()
    elif not is_public:
        # ODCZYT repo prywatnego: tylko uprawnieni.
        if user_row is None:
            return _git_401()
        if not _git_may_access(user_row, repo):
            return _git_403()

    # REMOTE_USER ustawiamy WYŁĄCZNIE ze zweryfikowanego źródła (wcześniej brało się
    # to wprost z nagłówka klienta → można było podszyć się pod `admin`).
    env["REMOTE_USER"] = user_row["username"] if user_row is not None else "anonymous"

    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 (tabele GFM + 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)