import os, subprocess, io, json, re, time from datetime import datetime from flask import Blueprint, render_template, request, redirect, flash, abort, session from config.settings import GIT_DIR from database import get_db, get_setting git_bp = Blueprint("git", __name__) # ── Search repo packages ── def _search_packages(query): """Szuka pakietów w repo.json lub po plikach .pkg.tar.xz.""" results = [] from config.settings import REPO_BASE import glob as gmod q = query.lower() cats = ["core", "desktop", "tools", "network", "multimedia", "gaming", "office", "other"] for cat in cats: d = os.path.join(REPO_BASE, cat) if not os.path.isdir(d): continue # Najpierw repo.json rj = os.path.join(d, "repo.json") found = False if os.path.exists(rj): try: pkgs = json.load(open(rj)).get("packages", []) for p in pkgs: name = p.get("name", "") if q in name.lower(): results.append({ "name": name, "version": p.get("version",""), "category": cat, "filename": p.get("filename", ""), "size": p.get("size", 0), }) found = True except Exception: pass # Fallback: szukaj po nazwach plików .pkg.tar.xz if not found or not os.path.exists(rj): for f in sorted(gmod.glob(os.path.join(d, "*.pkg.tar.xz"))): fname = os.path.basename(f).replace(".pkg.tar.xz", "") if q in fname.lower(): results.append({ "name": fname.rsplit("-", 1)[0] if "-" in fname else fname, "version": fname.rsplit("-", 1)[1] if "-" in fname else "", "category": cat, "filename": os.path.basename(f), "size": os.path.getsize(f), }) results.sort(key=lambda x: x["name"]) return results[:50] # ── Helpers ── def _run_git(repo_path, args, timeout=30): try: r = subprocess.run( ["git", f"--git-dir={repo_path}"] + [str(a) for a in args], capture_output=True, text=True, timeout=timeout ) return r.stdout if r.returncode == 0 else "" except Exception: return "" def _get_branch(repo_path): for b in ("main", "master"): if _run_git(repo_path, ["rev-parse", "--verify", b]).strip(): return b # Fallback: sprawdź HEAD head = _run_git(repo_path, ["symbolic-ref", "--short", "HEAD"]).strip() return head if head else "main" def _get_repos(for_user=None): repos = [] if not os.path.exists(GIT_DIR): return repos is_admin = for_user and session.get("is_admin") for entry in sorted(os.listdir(GIT_DIR)): full = os.path.join(GIT_DIR, entry) git_path = full if entry.endswith(".git") else os.path.join(full, ".git") if os.path.exists(os.path.join(git_path, "HEAD")): name = entry[:-4] if entry.endswith(".git") else entry # Visibility vis_file = os.path.join(git_path, "git-daemon-export-ok") visible = os.path.exists(vis_file) # Filtruj prywatne repo – tylko admin i uprawnieni widzą if not visible and not is_admin: if for_user: db = get_db() row = db.execute("SELECT 1 FROM repo_permissions WHERE repo_name=? AND username=?", (name, for_user)).fetchone() db.close() if not row: continue else: continue desc = "Brak opisu" desc_file = os.path.join(git_path, "description") if os.path.exists(desc_file): with open(desc_file) as f: d = f.read().strip() if d and "Unnamed" not in d: desc = d size = _get_dir_size(git_path) branches = _run_git(git_path, ["branch"]).strip() branch_count = len([l for l in branches.split("\n") if l]) if branches else 0 last_commit = _run_git(git_path, ["log", "-1", "--format=%ar"]).strip() repos.append({"name": name, "path": git_path, "desc": desc, "visible": visible, "size": size, "branch_count": branch_count, "last_commit": last_commit}) return repos def _get_dir_size(path): total = 0 try: for dirpath, dirnames, filenames in os.walk(path): for f in filenames: fp = os.path.join(dirpath, f) if os.path.isfile(fp): total += os.path.getsize(fp) except Exception: pass return total def _format_size(size): for unit in ["B", "KB", "MB", "GB"]: if size < 1024: return f"{size:.1f} {unit}" if unit != "B" else f"{size} B" size /= 1024 return f"{size:.1f} TB" def _can_edit(username, repo_name): if not username: return False db = get_db() row = db.execute("SELECT 1 FROM repo_permissions WHERE repo_name=? AND username=?", (repo_name, username)).fetchone() db.close() return row is not None or session.get("is_admin") def _git_readonly(): """Globalny tryb tylko do odczytu dla gita (ustawiany w panelu admina).""" try: return get_setting("git_readonly", "0") == "1" except Exception: return False def _repo_readonly(repo_name): """Per-repo tryb tylko do odczytu (plik-marker w bare repo, jak git-daemon-export-ok).""" return os.path.exists(os.path.join(GIT_DIR, f"{repo_name}.git", "pag-readonly")) def _can_modify(repo_name): """Czy bieżący użytkownik może modyfikować pliki repo przez web. Blokuje, gdy włączony jest globalny tryb read-only LUB tryb per-repo. Admini zachowują dostęp, żeby móc zdjąć blokadę i zarządzać repo. """ if not session.get("is_admin"): if _git_readonly() or _repo_readonly(repo_name): return False return _can_edit(session.get("username"), repo_name) def _readonly_blocked(repo_name): """Odczytowy skrót: pokazuje komunikat i zwraca redirect, gdy zapis zabroniony.""" if not _can_modify(repo_name): if not session.get("is_admin") and _repo_readonly(repo_name): flash("🔒 Repozytorium jest w trybie tylko do odczytu – edycja wyłączona", "error") elif _git_readonly() and not session.get("is_admin"): flash("🔒 Repozytorium w trybie tylko do odczytu – edycja jest wyłączona", "error") else: flash("Brak uprawnień do edycji tego repo", "error") return redirect(f"/git/{repo_name}") return None def _check_repo_access(repo_name): """Sprawdza czy użytkownik ma dostęp do repo. Zwraca repo_path lub abort 404.""" # Path traversal protection if ".." in repo_name or "/" in repo_name or "\\" in repo_name: abort(404) repo_path = os.path.join(GIT_DIR, f"{repo_name}.git") if not os.path.exists(repo_path): abort(404) vis_file = os.path.join(repo_path, "git-daemon-export-ok") if not os.path.exists(vis_file): username = session.get("username") if not _can_edit(username, repo_name): flash("Repozytorium prywatne – brak dostępu", "error") return None return repo_path def _get_git_env(username): email = session.get("email", f"{username}@paganlinux.eu") return {"GIT_AUTHOR_NAME": username, "GIT_AUTHOR_EMAIL": email, "GIT_COMMITTER_NAME": username, "GIT_COMMITTER_EMAIL": email} # ── Pomocniki: upload plików / obrazy / świeże (bezcommitowe) repo ── _IMAGE_MIME = { "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif", "webp": "image/webp", "svg": "image/svg+xml", "bmp": "image/bmp", "avif": "image/avif", "ico": "image/x-icon", } _TEXT_EXTS = {"txt", "md", "markdown", "py", "sh", "c", "h", "cpp", "hpp", "rs", "go", "js", "ts", "json", "yaml", "yml", "toml", "ini", "conf", "css", "html", "xml", "svg", "patch", "diff", "license", "gitignore", "cfg", "service", "desktop", "1", "5", "po", "pot", "am", "ac", "m4", "mk", "cmake"} def _file_mime(path): ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" if ext in _IMAGE_MIME: return _IMAGE_MIME[ext] if ext in _TEXT_EXTS: return "text/plain; charset=utf-8" return "application/octet-stream" def _is_image_path(path): ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" return ext in _IMAGE_MIME def _repo_has_commits(repo_path): return bool(_run_git(repo_path, ["rev-parse", "--verify", "--quiet", "HEAD"]).strip()) def _prepare_worktree(repo_path, work_tree, branch): """Checkout gałęzi do katalogu roboczego; dla repo bez commitów – orphan branch.""" env = {**os.environ, **(_get_git_env(session["username"]) if session.get("username") else {})} if _repo_has_commits(repo_path): # Zsynchronizuj indeks bare repo z galezia PRZED checkoutem. Bez tego # nieswiezy index (np. po skasowaniu receptur) jest materializowany do # work-tree, a kolejny `git add -A` wskrzesza usuniete pliki. subprocess.run(["git", f"--git-dir={repo_path}", "read-tree", branch], capture_output=True, text=True, timeout=30, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", "-f", branch, "."], capture_output=True, text=True, timeout=30, env=env) else: subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", "--orphan", branch], capture_output=True, text=True, timeout=30, env=env) def _commit_and_push(repo_path, work_tree, branch, message): """git add -A + commit + (próba) push – commit zapisuje się wprost w repo.""" env = {**os.environ, **(_get_git_env(session["username"]) if session.get("username") else {})} subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", "-A"], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", message], capture_output=True, text=True, timeout=15, env=env) subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch], capture_output=True, text=True, timeout=15, env=env) # ── Release (tag + tarball w /sources/) ── SOURCES_DIR = "/var/www/repo.paganlinux.eu/sources" SOURCES_URL = "https://repo.paganlinux.eu/sources" def _get_releases(repo_name, repo_path, limit=10): """Zwraca release'y repozytorium (tagi, dla których istnieje tarball w /sources/).""" releases = [] tags = _run_git(repo_path, ["tag", "--sort=-creatordate"]).strip() if not tags: return releases for tag in tags.split("\n")[:limit]: tag = tag.strip() if not tag: continue version = tag[1:] if tag.startswith("v") else tag # Sprawdź czy tarball release'a jest w /sources/ (.tar.gz lub .tar.xz) for ext in (".tar.gz", ".tar.xz", ".tar.bz2"): fname = f"{repo_name}-{version}{ext}" fpath = os.path.join(SOURCES_DIR, fname) if os.path.isfile(fpath): releases.append({ "tag": tag, "version": version, "filename": fname, "url": f"{SOURCES_URL}/{fname}", "size_fmt": _format_size(os.path.getsize(fpath)), }) break return releases # ── Ostatnie commity – JEDNO wywołanie git log zamiast N× `git log -1 -- path` ── def _last_commit_map(repo_path, branch): """Zwraca mapę 'ścieżka -> uniksowy timestamp' ostatniego commita. Dla katalogów z setkami plików stare podejście (osobne `git log -1 -- path` dla każdego pliku) uruchamiało setki procesów git i trwało sekundy. Ten wariant robi to w JEDNYM przebiegu: `git log --name-only` wypisuje commity od najnowszego; pierwsze pojawienie się ścieżki = jej ostatni commit. """ out = _run_git(repo_path, ["--no-pager", "log", "--pretty=format:\x01%ct", "--name-only", branch]) last = {} cur = 0 for line in out.split("\n"): if line.startswith("\x01"): try: cur = int(line[1:].strip()) except ValueError: cur = 0 elif line.strip(): p = line.strip() if p not in last: last[p] = cur return last def _rel_time(ts): """Czas względny po polsku (np. '3 godz. temu').""" if not ts: return "" d = max(0, time.time() - ts) if d < 60: return "przed chwilą" if d < 3600: return f"{int(d // 60)} min temu" if d < 86400: return f"{int(d // 3600)} godz. temu" if d < 86400 * 30: return f"{int(d // 86400)} dni temu" if d < 86400 * 365: return f"{int(d // (86400 * 30))} mies. temu" return f"{int(d // (86400 * 365))} lat temu" def _last_commit_for(item, commit_map): """Ostatni commit pliku/katalogu z gotowej mapy (0 = brak danych).""" if item["type"] == "dir": prefix = item["path"] + "/" return max((t for p, t in commit_map.items() if p.startswith(prefix)), default=0) return commit_map.get(item["path"], 0) # ── Routes ── @git_bp.route("/") def index(): counts = {} commit_counts = {} db = get_db() username = session.get("username") repos = _get_repos(for_user=username) for repo in repos: counts[repo["name"]] = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=? AND status='open'", (repo["name"],)).fetchone()[0] c = _run_git(repo["path"], ["rev-list", "--count", "HEAD"]).strip() commit_counts[repo["name"]] = c if c and c.isdigit() else "0" db.close() total_commits = sum(int(c) for c in commit_counts.values() if str(c).isdigit()) total_size = sum(r.get("size", 0) for r in repos) public_count = sum(1 for r in repos if r.get("visible")) private_count = len(repos) - public_count for r in repos: r["commits"] = commit_counts.get(r["name"], "0") r["open_issues"] = counts.get(r["name"], 0) r["size_fmt"] = _format_size(r.get("size", 0)) # Activity feed: ostatnie 15 commitów ze wszystkich repo activity = [] for r in _get_repos(for_user=username): log = _run_git(r["path"], ["--no-pager", "log", "-n", "5", "--pretty=format:%h|%an|%ar|%s", "--all"]) for line in log.splitlines()[:5]: p = line.split("|", 3) if len(p) == 4: activity.append({"repo": r["name"], "hash": p[0], "author": p[1], "date": p[2], "msg": p[3][:80]}) activity.sort(key=lambda x: x["date"], reverse=False) activity = activity[-20:] return render_template("git/index.html", repos=repos, issue_counts=counts, repo_count=len(repos), total_commits=total_commits, is_admin=session.get("is_admin"), total_size=_format_size(total_size), public_count=public_count, private_count=private_count, activity=activity) @git_bp.route("/help") def help_page(): return render_template("git/help.html") @git_bp.route("/") @git_bp.route("//tree/") def view_repo(repo_name, tree_path=""): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") branch = request.args.get("branch") or _get_branch(repo_path) tree_ref = f"{branch}:{tree_path}" if tree_path else branch tree_out = _run_git(repo_path, ["ls-tree", "-l", tree_ref]) items = [] if tree_out: for line in tree_out.splitlines(): parts = line.split(maxsplit=4) if len(parts) >= 5: obj_type = parts[1] obj_size = parts[3] if parts[3] != "-" else "" name = parts[4].split("\t")[-1] if "\t" in parts[4] else parts[4] full_path = f"{tree_path}/{name}".strip("/") items.append({"name": name, "type": "dir" if obj_type == "tree" else "file", "path": full_path, "size": obj_size}) # Ostatnie commity – JEDNO wywołanie git log zamiast osobnego # `git log -1` dla każdego pliku (setki procesów git na duże katalogi). commit_map = _last_commit_map(repo_path, branch) for item in items: ts = _last_commit_for(item, commit_map) item["ts"] = ts item["last_commit"] = _rel_time(ts) items.sort(key=lambda x: (x["type"] != "dir", x["name"].lower())) dir_count = sum(1 for i in items if i["type"] == "dir") file_count = len(items) - dir_count # Paginacja commitów page = request.args.get("page", 1, type=int) per_page = 15 total_commits = int(_run_git(repo_path, ["rev-list", "--count", branch]).strip() or 0) skip = (page - 1) * per_page log = _run_git(repo_path, ["log", "-n", str(per_page), "--skip", str(skip), "--pretty=format:%h|%an|%ar|%s", branch]) commits = [] for line in log.splitlines(): p = line.split("|", 3) if len(p) == 4: commits.append({"hash": p[0], "author": p[1], "date": p[2], "msg": p[3]}) total_pages = max(1, (total_commits + per_page - 1) // per_page) crumbs = [] if tree_path: acc = "" for part in tree_path.split("/"): acc = f"{acc}/{part}".strip("/") crumbs.append({"name": part, "path": acc}) # Statystyki repo branch_count = len([l for l in _run_git(repo_path, ["branch"]).split("\n") if l.strip()]) contrib_count = len(_run_git(repo_path, ["shortlog", "-s", "--all"]).splitlines()) can = _can_modify(repo_name) db = get_db() issue_count = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=?", (repo_name,)).fetchone()[0] db.close() # Release'y – tagi z tarballem w /sources/ releases = _get_releases(repo_name, repo_path) # README – wybór języka: README.md (PL) / README-EN.md (EN) # Priorytet: ?readme=pl|en → język interfejsu (cookie/Accept-Language). # Gdy w repo jest tylko jeden wariant, pokazujemy go (i brak przełącznika). readme_html = "" readme_lang = "" readme_has = {"pl": False, "en": False} if not tree_path: _readme_names = { "pl": ("README.md", "README.pl.md", "README-PL.md", "Readme.md", "readme.md", "README"), "en": ("README-EN.md", "README.en.md", "README-en.md", "README.EN.md", "README-en_US.md"), } def _has_readme(spec): return bool(_run_git(repo_path, ["cat-file", "-t", spec]).strip()) for _lg, _names in _readme_names.items(): readme_has[_lg] = any(_has_readme(f"{branch}:{n}") for n in _names) _want = (request.args.get("readme") or "").strip().lower() if _want not in ("pl", "en"): try: from i18n import get_lang _want = "en" if get_lang() == "en" else "pl" except Exception: _want = "pl" for _lg in (_want, "en" if _want == "pl" else "pl"): if not readme_has.get(_lg): continue for _n in _readme_names[_lg]: rcontent = _run_git(repo_path, ["show", f"{branch}:{_n}"]) if rcontent: from markdown_render import render_markdown readme_html = ( '
' + render_markdown(rcontent, escape=True, plugins=['strikethrough', 'task_lists', 'url']) + '
' ) readme_lang = _lg break if readme_html: break def _fmt_size(s): try: s = int(s) for u in ["B", "KB", "MB"]: if s < 1024: return f"{s} {u}" if u == "B" else f"{s/1024:.1f} {u}" s /= 1024 return f"{s/1024:.1f} GB" except: return s return render_template("git/repo.html", repo_name=repo_name, items=items, commits=commits, branch=branch, tree_path=tree_path, breadcrumbs=crumbs, can_edit=can, issue_count=issue_count, releases=releases, git_readonly=_git_readonly(), repo_readonly=_repo_readonly(repo_name), readme_html=readme_html, readme_lang=readme_lang, readme_has=readme_has, page=page, total_pages=total_pages, total_commits=total_commits, branch_count=branch_count, contrib_count=contrib_count, fmt_size=_fmt_size, dir_count=dir_count, file_count=file_count) @git_bp.route("//file") def view_file(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") file_path = request.args.get("path", "") if not file_path: abort(404) branch = request.args.get("branch") or _get_branch(repo_path) is_markdown = file_path.lower().endswith((".md", ".markdown")) is_image = _is_image_path(file_path) content_html = "" line_count = 0 content = "" if is_image: # Binarki/obrazy – bez podświetlania; podgląd w szablonie pass else: content = _run_git(repo_path, ["show", f"{branch}:{file_path}"]) line_count = len(content.splitlines()) if content else 0 if is_markdown: from markdown_render import render_markdown content_html = render_markdown(content or "(pusty)", escape=True, plugins=['strikethrough', 'task_lists', 'url']) content_html = f'
{content_html}
' else: from pygments import highlight from pygments.lexers import get_lexer_for_filename, TextLexer from pygments.formatters import HtmlFormatter try: lexer = get_lexer_for_filename(file_path) except Exception: lexer = TextLexer() content_html = highlight(content or "(pusty)", lexer, HtmlFormatter(style="monokai")) return render_template("git/file.html", repo_name=repo_name, file_path=file_path, content_html=content_html, content_raw=content or "", branch=branch, can_edit=_can_modify(repo_name), git_readonly=_git_readonly(), repo_readonly=_repo_readonly(repo_name), is_markdown=is_markdown, is_image=is_image, line_count=line_count) @git_bp.route("//raw") def raw_file(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") file_path = request.args.get("path", "") if not file_path: abort(404) branch = request.args.get("branch") or _get_branch(repo_path) from flask import Response # Obrazy/binarki – oryginalne bajty + właściwy mimetype (git show bez text=True) try: r = subprocess.run(["git", f"--git-dir={repo_path}", "show", f"{branch}:{file_path}"], capture_output=True, timeout=20) data = r.stdout if r.returncode == 0 else b"" except Exception: data = b"" mime = _file_mime(file_path) return Response(data, mimetype=mime) @git_bp.route("//edit", methods=["GET", "POST"]) def edit_file(repo_name): if not session.get("username"): return redirect("/login?next=" + request.url) repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") blocked = _readonly_blocked(repo_name) if blocked: return blocked file_path = request.args.get("path", "") if not file_path: abort(404) if request.method == "POST": content = request.form.get("content", "") msg = request.form.get("message", f"Edit {file_path}") branch = request.args.get("branch") or _get_branch(repo_path) # Write to temp, commit via git import tempfile tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".tmp", delete=False) tmp.write(content) tmp.close() env = {**os.environ, **_get_git_env(session["username"])} work_tree = tempfile.mkdtemp(prefix="git-edit-") subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."], capture_output=True, text=True, timeout=30, env=env) dest = os.path.join(work_tree, file_path) os.makedirs(os.path.dirname(dest), exist_ok=True) with open(dest, "w") as f: f.write(content) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", file_path], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", msg], capture_output=True, text=True, timeout=10, env=env) # Push do bara (work-tree commit wymaga pushu przez główną gałąź) subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch], capture_output=True, text=True, timeout=15, env=env) import shutil os.unlink(tmp.name) shutil.rmtree(work_tree, ignore_errors=True) flash(f"✅ Zapisano: {file_path}", "ok") return redirect(f"/git/{repo_name}/file?path={file_path}&branch={branch}") branch = request.args.get("branch") or _get_branch(repo_path) content = _run_git(repo_path, ["show", f"{branch}:{file_path}"]) return render_template("git/edit.html", repo_name=repo_name, file_path=file_path, content=content, branch=branch) @git_bp.route("//new", methods=["GET", "POST"]) def new_file(repo_name): if not session.get("username"): return redirect("/login?next=" + request.url) repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") blocked = _readonly_blocked(repo_name) if blocked: return blocked if request.method == "POST": file_path = request.form.get("path", "").strip() content = request.form.get("content", "") msg = request.form.get("message", f"Add {file_path}") branch = _get_branch(repo_path) if ".." in file_path.split("/") or file_path.startswith("/") or "\\" in file_path: flash("❌ Nieprawidłowa ścieżka", "error") return redirect(f"/git/{repo_name}/new") import tempfile, shutil env = {**os.environ, **_get_git_env(session["username"])} work_tree = tempfile.mkdtemp(prefix="git-new-") _prepare_worktree(repo_path, work_tree, branch) dest = os.path.join(work_tree, file_path) os.makedirs(os.path.dirname(dest), exist_ok=True) with open(dest, "w") as f: f.write(content) _commit_and_push(repo_path, work_tree, branch, msg) shutil.rmtree(work_tree, ignore_errors=True) flash(f"✅ Dodano: {file_path}", "ok") return redirect(f"/git/{repo_name}") return render_template("git/new.html", repo_name=repo_name) @git_bp.route("//upload", methods=["GET", "POST"]) def upload_files(repo_name): """Upload plików (w tym obrazów) do repozytorium przez przeglądarkę.""" if not session.get("username"): return redirect("/login?next=" + request.url) repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") blocked = _readonly_blocked(repo_name) if blocked: return blocked dir_path = (request.args.get("dir") or request.form.get("dir") or "").strip().strip("/") if dir_path: parts = dir_path.split("/") if any(p in ("", ".", "..") or "\\" in p for p in parts): flash("❌ Nieprawidłowy katalog docelowy", "error") return redirect(f"/git/{repo_name}") if request.method == "POST": files = request.files.getlist("files") files = [f for f in files if f and f.filename] user_msg = (request.form.get("message") or "").strip() back = f"/git/{repo_name}/upload" + (f"?dir={dir_path}" if dir_path else "") if not files: flash("❌ Wybierz co najmniej jeden plik", "error") return redirect(back) if len(files) > 25: flash("❌ Maksymalnie 25 plików na raz", "error") return redirect(back) branch = _get_branch(repo_path) import tempfile, shutil env = {**os.environ, **_get_git_env(session["username"])} work_tree = tempfile.mkdtemp(prefix="git-up-") _prepare_worktree(repo_path, work_tree, branch) base = os.path.join(work_tree, dir_path) if dir_path else work_tree os.makedirs(base, exist_ok=True) added, skipped = [], [] for f in files: name = (f.filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip() if not name or name in (".", "..") or name == ".git": skipped.append((f.filename, "niedozwolona nazwa")) continue dest = os.path.join(base, name) if os.path.exists(dest): skipped.append((name, "plik już istnieje w repo")) continue try: f.save(dest) size = os.path.getsize(dest) if size == 0: os.remove(dest) skipped.append((name, "pusty plik")) continue if size > 50 * 1024 * 1024: os.remove(dest) skipped.append((name, "plik większy niż 50 MB")) continue added.append(name) except Exception as e: skipped.append((name, f"błąd zapisu: {e}")) if added: if user_msg: msg = user_msg else: msg = f"Add {len(added)} file(s): " + ", ".join(added[:6]) + ("…" if len(added) > 6 else "") _commit_and_push(repo_path, work_tree, branch, msg) flash(f"✅ Wgrano {len(added)} plik(ów): " + ", ".join(added), "ok") if skipped: flash("⚠ Pominięto: " + "; ".join(f"{n} ({why})" for n, why in skipped), "error") shutil.rmtree(work_tree, ignore_errors=True) dest_dir = f"/git/{repo_name}/tree/{dir_path}" if dir_path else f"/git/{repo_name}" return redirect(dest_dir) return render_template("git/upload.html", repo_name=repo_name, dir_path=dir_path) @git_bp.route("//delete", methods=["POST"]) def delete_item(repo_name): if not session.get("username"): return redirect("/login") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") blocked = _readonly_blocked(repo_name) if blocked: return blocked file_path = request.form.get("path", "") if not file_path: abort(400) branch = _get_branch(repo_path) import tempfile env = {**os.environ, **_get_git_env(session["username"])} work_tree = tempfile.mkdtemp(prefix="git-del-") subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."], capture_output=True, text=True, timeout=30, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "rm", "-rf", file_path], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", f"Delete {file_path}"], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch], capture_output=True, text=True, timeout=15, env=env) import shutil shutil.rmtree(work_tree, ignore_errors=True) flash(f"Usunięto: {file_path}", "ok") return redirect(f"/git/{repo_name}") @git_bp.route("//rename", methods=["GET", "POST"]) def rename_item(repo_name): """Zmiana nazwy pliku lub katalogu (git mv + commit + push).""" if not session.get("username"): return redirect("/login?next=" + request.url) repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") blocked = _readonly_blocked(repo_name) if blocked: return blocked old_path = (request.args.get("path") or request.form.get("old_path", "")).strip().strip("/") if not old_path or ".." in old_path.split("/") or old_path.startswith("/"): abort(400) if request.method == "POST": new_path = request.form.get("new_path", "").strip().strip("/") if not new_path or new_path == old_path: flash("❌ Podaj nową nazwę (inną niż obecna)", "error") return redirect(f"/git/{repo_name}/rename?path={old_path}") if ".." in new_path.split("/") or new_path.startswith("/"): flash("❌ Nieprawidłowa ścieżka docelowa", "error") return redirect(f"/git/{repo_name}/rename?path={old_path}") branch = _get_branch(repo_path) import tempfile, shutil env = {**os.environ, **_get_git_env(session["username"])} work_tree = tempfile.mkdtemp(prefix="git-mv-") subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "checkout", branch, "."], capture_output=True, text=True, timeout=30, env=env) src = os.path.join(work_tree, old_path) dst = os.path.join(work_tree, new_path) if not os.path.exists(src): flash("❌ Nie znaleziono: " + old_path, "error") shutil.rmtree(work_tree, ignore_errors=True) return redirect(f"/git/{repo_name}") if os.path.exists(dst): flash("❌ Już istnieje: " + new_path, "error") shutil.rmtree(work_tree, ignore_errors=True) return redirect(f"/git/{repo_name}/rename?path={old_path}") os.makedirs(os.path.dirname(dst), exist_ok=True) os.rename(src, dst) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "add", "-A"], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", f"--work-tree={work_tree}", "commit", "-m", f"Rename {old_path} -> {new_path}"], capture_output=True, text=True, timeout=10, env=env) subprocess.run(["git", f"--git-dir={repo_path}", "push", "origin", branch], capture_output=True, text=True, timeout=15, env=env) shutil.rmtree(work_tree, ignore_errors=True) flash(f"✅ Zmieniono nazwę: {old_path} → {new_path}", "ok") return redirect(f"/git/{repo_name}") branch = request.args.get("branch") or _get_branch(repo_path) return render_template("git/rename.html", repo_name=repo_name, old_path=old_path, branch=branch) @git_bp.route("//branches") def view_branches(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") out = _run_git(repo_path, ["branch", "-a"]) branches = [] for line in out.splitlines(): name = line.strip().lstrip("* ") is_remote = name.startswith("remotes/") if is_remote: name = name.replace("remotes/origin/", "") if "HEAD" in name or not name: continue if name not in [b["name"] for b in branches]: branches.append({"name": name, "is_default": name in ("main", "master"), "is_remote": is_remote}) default_branch = _get_branch(repo_path) return render_template("git/branches.html", repo_name=repo_name, branches=branches, default_branch=default_branch) @git_bp.route("//branches/set-default", methods=["POST"]) def set_default_branch(repo_name): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect(f"/git/{repo_name}") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") branch = request.form.get("branch", "") if not branch: abort(400) _run_git(repo_path, ["symbolic-ref", "HEAD", f"refs/heads/{branch}"]) flash(f"✅ Domyślna gałąź: {branch}", "ok") return redirect(f"/git/{repo_name}/branches") @git_bp.route("//branches/delete", methods=["POST"]) def delete_branch(repo_name): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect(f"/git/{repo_name}") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") branch = request.form.get("branch", "") if not branch: abort(400) default = _get_branch(repo_path) if branch == default: flash("❌ Nie można usunąć domyślnej gałęzi", "error") return redirect(f"/git/{repo_name}/branches") _run_git(repo_path, ["branch", "-D", branch]) _run_git(repo_path, ["push", "origin", "--delete", branch]) flash(f"Usunięto gałąź: {branch}", "ok") return redirect(f"/git/{repo_name}/branches") @git_bp.route("/search") def global_search(): query = request.args.get("q", "").strip() if len(query) < 2: return redirect("/git") results = [] # Szukaj w kodzie (git grep na bare repo) for repo in _get_repos(for_user=session.get("username")): git_dir = repo["path"] # Sprawdź czy repo ma commity if not _run_git(git_dir, ["rev-parse", "HEAD"], timeout=5).strip(): continue out = _run_git(git_dir, ["--no-pager", "grep", "-n", "-i", "-I", "--cached", query, "HEAD"], timeout=30) if not out: # Fallback: spróbuj bez --cached out = _run_git(git_dir, ["--no-pager", "grep", "-n", "-i", "-I", query, "HEAD"], timeout=30) for line in out.splitlines(): line = line.strip() # Remove HEAD: prefix if line.startswith("HEAD:"): line = line[len("HEAD:"):] if ":" in line: parts = line.split(":", 2) if len(parts) >= 2: results.append({ "type": "code", "repo": repo["name"], "file": parts[0], "line": parts[1] if len(parts) > 1 else "", "text": parts[2][:200] if len(parts) > 2 else "", }) # Szukaj w pakietach (repo) pkgs = _search_packages(query) return render_template("git/search.html", query=query, results=results[:100], packages=pkgs, total=len(results) + len(pkgs)) # ── Issues ── @git_bp.route("//issues") def issues(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") db = get_db() status = request.args.get("status", "open") rows = db.execute("SELECT * FROM issues WHERE repo_name=? AND status=? ORDER BY created_at DESC", (repo_name, status)).fetchall() db.close() return render_template("git/issues.html", repo_name=repo_name, issues=rows, status=status) @git_bp.route("//issues/new", methods=["GET", "POST"]) def new_issue(repo_name): if not session.get("username"): return redirect("/login") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") if request.method == "POST": title = request.form.get("title", "").strip() desc = request.form.get("description", "").strip() if not title: flash("Tytuł wymagany", "error") else: db = get_db() db.execute("INSERT INTO issues (repo_name, title, description, author) VALUES (?,?,?,?)", (repo_name, title, desc, session["username"])) db.commit() db.close() flash("Issue utworzony", "ok") return redirect(f"/git/{repo_name}/issues") return render_template("git/issue_new.html", repo_name=repo_name) @git_bp.route("//issues/") def view_issue(repo_name, issue_id): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") db = get_db() issue = db.execute("SELECT * FROM issues WHERE id=? AND repo_name=?", (issue_id, repo_name)).fetchone() if not issue: abort(404) replies = db.execute("SELECT * FROM issue_replies WHERE issue_id=? ORDER BY created_at", (issue_id,)).fetchall() db.close() return render_template("git/issue.html", repo_name=repo_name, issue=issue, replies=replies) @git_bp.route("//issues//reply", methods=["POST"]) def reply_issue(repo_name, issue_id): if not session.get("username"): return redirect("/login") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") content = request.form.get("content", "").strip() if content: db = get_db() db.execute("INSERT INTO issue_replies (issue_id, content, author) VALUES (?,?,?)", (issue_id, content, session["username"])) db.commit() db.close() return redirect(f"/git/{repo_name}/issues/{issue_id}") @git_bp.route("//issues//toggle") def toggle_issue(repo_name, issue_id): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") db = get_db() issue = db.execute("SELECT * FROM issues WHERE id=?", (issue_id,)).fetchone() if issue: new_status = "closed" if issue["status"] == "open" else "open" db.execute("UPDATE issues SET status=? WHERE id=?", (new_status, issue_id)) db.commit() db.close() return redirect(f"/git/{repo_name}/issues") # ── Commit detail + diff ── @git_bp.route("//commit/") def view_commit(repo_name, commit_hash): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") import re as _re # Pobierz diff i statystyki osobno diff = _run_git(repo_path, ["--no-pager", "show", "--format=", "-p", commit_hash]) if not diff: diff = _run_git(repo_path, ["--no-pager", "show", "--format=fuller", "-p", commit_hash]) if not diff: flash("Commit nie istnieje", "error") return redirect(f"/git/{repo_name}") # Statystyki z git diff --stat stat_out = _run_git(repo_path, ["--no-pager", "diff", "--stat", f"{commit_hash}~1", commit_hash]) if not stat_out: stat_out = _run_git(repo_path, ["--no-pager", "show", "--stat", "--format=", commit_hash]) files_changed = 0 insertions = 0 deletions = 0 stat_line = "" if stat_out: for line in stat_out.splitlines(): line_s = line.strip() if "files changed" in line_s and "insertion" in line_s: stat_line = line_s m = _re.search(r'(\d+)\s+files?\s+changed[,\s]*(\d+)\s+insertion[^\d]*(\d+)\s+deletion', line_s) if m: files_changed, insertions, deletions = int(m.group(1)), int(m.group(2)), int(m.group(3)) else: m2 = _re.search(r'(\d+)\s+files?\s+changed[,\s]*(\d+)\s+insertion', line_s) if m2: files_changed, insertions = int(m2.group(1)), int(m2.group(2)) # Parsuj diff na struktury z numerami linii hunks = [] # [{header, lines: [{type, old_ln, new_ln, text}]}] current_hunk = None old_ln, new_ln = 0, 0 for line in diff.splitlines(): if line.startswith("diff ") or line.startswith("index "): continue # pomijamy nagłówki plików if line.startswith("---") or line.startswith("+++"): continue if line.startswith("@@"): # Parsuj @@ -old,count +new,count @@ m = _re.match(r'@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@', line) if m: old_ln = int(m.group(1)) new_ln = int(m.group(3)) current_hunk = {"header": line, "lines": []} hunks.append(current_hunk) elif current_hunk is not None: if line.startswith("\\"): continue # \ No newline at end of file if line.startswith("+"): current_hunk["lines"].append({"type": "add", "old": "", "new": str(new_ln), "text": line}) new_ln += 1 elif line.startswith("-"): current_hunk["lines"].append({"type": "del", "old": str(old_ln), "new": "", "text": line}) old_ln += 1 else: current_hunk["lines"].append({"type": "ctx", "old": str(old_ln), "new": str(new_ln), "text": line}) old_ln += 1 new_ln += 1 return render_template("git/commit.html", repo_name=repo_name, commit_hash=commit_hash, diff_raw=diff, hunks=hunks, stat_line=stat_line, files_changed=files_changed, insertions=insertions, deletions=deletions) # ── Contributors ── @git_bp.route("//contributors") def contributors(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") out = _run_git(repo_path, ["shortlog", "-sne", "--all"]) contribs = [] for line in out.splitlines(): line = line.strip() if not line: continue parts = line.split("\t", 1) if len(parts) == 2: count = parts[0].strip() name_email = parts[1].strip() contribs.append({"count": int(count), "name": name_email}) return render_template("git/contributors.html", repo_name=repo_name, contributors=contribs, total=sum(c["count"] for c in contribs)) # ── Download archive ── @git_bp.route("//archive") def download_archive(repo_name): repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") branch = request.args.get("branch") or _get_branch(repo_path) fmt = request.args.get("format", "tar.gz") import tempfile, time tmp = os.path.join(tempfile.gettempdir(), f"{repo_name}-{branch}.{fmt}") subprocess.run( ["git", f"--git-dir={repo_path}", "archive", f"--format={fmt}", f"--output={tmp}", f"--prefix={repo_name}-{branch}/", branch], capture_output=True, timeout=30 ) if os.path.exists(tmp): from flask import send_file return send_file(tmp, as_attachment=True, download_name=f"{repo_name}-{branch}.{fmt}", mimetype="application/gzip" if fmt == "tar.gz" else "application/zip") flash("Nie udało się wygenerować archiwum", "error") return redirect(f"/git/{repo_name}") # ── Create Release (tag + tarball) ── @git_bp.route("//release", methods=["GET", "POST"]) def create_release(repo_name): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect(f"/git/{repo_name}") repo_path = _check_repo_access(repo_name) if repo_path is None: return redirect("/git") if request.method == "POST": version = request.form.get("version", "").strip().lstrip("v") if not version: flash("Wersja wymagana", "error") return redirect(f"/git/{repo_name}/release") branch = _get_branch(repo_path) tag_name = f"v{version}" # Sprawdź czy tag już istnieje existing = _run_git(repo_path, ["tag", "-l", tag_name]).strip() if existing: flash(f"Tag {tag_name} już istnieje", "error") return redirect(f"/git/{repo_name}/release") # Stwórz tag _run_git(repo_path, ["tag", "-a", tag_name, "-m", f"Release {tag_name}", branch]) # Stwórz tarball z tego taga import io from flask import send_file tarball_name = f"{repo_name}-{version}.tar.gz" tarball_path = os.path.join("/tmp", tarball_name) subprocess.run( ["git", f"--git-dir={repo_path}", "archive", "--format=tar.gz", f"--output={tarball_path}", f"--prefix={repo_name}-{version}/", tag_name], capture_output=True, timeout=30 ) if os.path.exists(tarball_path): # Kopiuj do sources sources_dir = "/var/www/repo.paganlinux.eu/sources" os.makedirs(sources_dir, exist_ok=True) import shutil shutil.copy2(tarball_path, os.path.join(sources_dir, tarball_name)) os.unlink(tarball_path) size = os.path.getsize(os.path.join(sources_dir, tarball_name)) flash(f"✅ Release {tag_name} utworzony! Tarball: {tarball_name} ({round(size/1024)} KB)", "ok") else: flash(f"⚠ Tag {tag_name} utworzony, ale tarball się nie udał", "error") return redirect(f"/git/{repo_name}") # GET – formularz branch = _get_branch(repo_path) last_tag = _run_git(repo_path, ["describe", "--tags", "--abbrev=0"]).strip() commit_count = _run_git(repo_path, ["rev-list", "--count", "HEAD"]).strip() or "0" return render_template("git/release.html", repo_name=repo_name, branch=branch, last_tag=last_tag, commit_count=commit_count) # ── New repo (admin) ── @git_bp.route("/new-repo", methods=["GET", "POST"]) def new_repo(): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect("/git") if request.method == "POST": name = request.form.get("name", "").strip() desc = request.form.get("description", "").strip() if not name: flash("Nazwa wymagana", "error") else: repo_path = os.path.join(GIT_DIR, f"{name}.git") os.makedirs(repo_path, exist_ok=True) subprocess.run(["git", "--git-dir", repo_path, "init", "--bare", "--initial-branch=main"], capture_output=True, timeout=10) if desc: with open(os.path.join(repo_path, "description"), "w") as f: f.write(desc) # Give admin access db = get_db() db.execute("INSERT OR IGNORE INTO repo_permissions (repo_name, username, role) VALUES (?,?,?)", (name, session["username"], "owner")) db.commit() db.close() flash(f"✅ Repo {name} utworzone", "ok") return redirect(f"/git/{name}") return render_template("git/new_repo.html") @git_bp.route("//settings", methods=["GET", "POST"]) def repo_settings(repo_name): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect(f"/git/{repo_name}") repo_path = os.path.join(GIT_DIR, f"{repo_name}.git") if not os.path.exists(repo_path): abort(404) if request.method == "POST": action = request.form.get("action", "") if action == "save_desc": desc = request.form.get("description", "").strip() with open(os.path.join(repo_path, "description"), "w") as f: f.write(desc or "Unnamed repository") flash("✅ Opis zapisany", "ok") elif action == "toggle_visibility": vis_file = os.path.join(repo_path, "git-daemon-export-ok") if os.path.exists(vis_file): os.remove(vis_file) flash("Repo ustawione jako PRYWATNE", "ok") else: with open(vis_file, "w") as f: f.write("") flash("Repo ustawione jako PUBLICZNE", "ok") elif action == "toggle_readonly": ro_file = os.path.join(repo_path, "pag-readonly") if os.path.exists(ro_file): os.remove(ro_file) flash("Repo ustawione jako EDYTOWALNE (read-write)", "ok") else: with open(ro_file, "w") as f: f.write("") flash("Repo ustawione jako TYLKO DO ODCZYTU", "ok") return redirect(f"/git/{repo_name}/settings") desc = "" desc_file = os.path.join(repo_path, "description") if os.path.exists(desc_file): with open(desc_file) as f: desc = f.read().strip() visible = os.path.exists(os.path.join(repo_path, "git-daemon-export-ok")) readonly = os.path.exists(os.path.join(repo_path, "pag-readonly")) count = _run_git(repo_path, ["rev-list", "--count", "HEAD"]).strip() or "0" size = _format_size(_get_dir_size(repo_path)) branches = _run_git(repo_path, ["branch"]).strip() branch_count = len([l for l in branches.split("\n") if l]) if branches else 0 last_commit = _run_git(repo_path, ["log", "-1", "--format=%ar"]).strip() or "nigdy" default_branch = _get_branch(repo_path) # Ostatnie tagi tags = _run_git(repo_path, ["tag", "--sort=-creatordate"]).strip() tag_list = [t for t in tags.split("\n")[:10] if t] if tags else [] db = get_db() maintainers = db.execute("SELECT username, role FROM repo_permissions WHERE repo_name=?", (repo_name,)).fetchall() issue_count = db.execute("SELECT COUNT(*) FROM issues WHERE repo_name=?", (repo_name,)).fetchone()[0] db.close() return render_template("git/settings.html", repo_name=repo_name, description=desc, commit_count=count, size=size, branch_count=branch_count, last_commit=last_commit, default_branch=default_branch, visible=visible, tags=tag_list, maintainers=maintainers, issue_count=issue_count, readonly=readonly) @git_bp.route("//delete-repo", methods=["POST"]) def delete_repo(repo_name): if not session.get("is_admin"): flash("Tylko admin", "error") return redirect("/git") import shutil repo_path = os.path.join(GIT_DIR, f"{repo_name}.git") if os.path.exists(repo_path): shutil.rmtree(repo_path) db = get_db() db.execute("DELETE FROM repo_permissions WHERE repo_name=?", (repo_name,)) db.execute("DELETE FROM issues WHERE repo_name=?", (repo_name,)) db.commit() db.close() flash(f"Repo {repo_name} usunięte", "ok") return redirect("/git")