🔒 Repository is read-only – file editing is disabled.
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)