# Reticulum License # # Copyright (c) 2016-2026 Mark Qvist # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # - The Software shall not be used in any kind of system which includes amongst # its functions the ability to purposefully do harm to human beings. # # - The Software shall not be used, directly or indirectly, in the creation of # an artificial intelligence, machine learning or language model training # dataset, including but not limited to any use that contributes to the # training or development of such a model or algorithm. # # - The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. import os import time import threading import subprocess import urllib.parse import RNS from collections import deque from datetime import datetime from RNS.Utilities.rngit import APP_NAME from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from RNS.vendor.configobj import ConfigObj from RNS.vendor import umsgpack as mp from RNS._version import __version__ class NomadNetworkNode(): APP_NAME = "nomadnetwork" JOBS_INTERVAL = 5 PATH_INDEX = "/page/index.mu" PATH_GROUP = "/page/group.mu" PATH_REPO = "/page/repo.mu" PATH_TREE = "/page/tree.mu" PATH_BLOB = "/page/blob.mu" PATH_COMMITS = "/page/commits.mu" PATH_COMMIT = "/page/commit.mu" PATH_REFS = "/page/refs.mu" PATH_STATS = "/page/stats.mu" PATH_RELEASES = "/page/releases.mu" PATH_RELEASE = "/page/release.mu" PATH_WORK = "/page/work.mu" PATH_WORK_DOC = "/page/work_doc.mu" PATH_ARTIFACT = "/file/artifact" PATH_DOWNLOAD = "/file/download" BLOB_SIZE_LIMIT = 256 * 1024 TREE_ENTRIES_PER_PAGE = 1000 COMMITS_PER_PAGE = 100 SHOW_DIFF_BY_DEFAULT = True GIT_COMMAND_TIMEOUT = 5 MAX_RENDER_WIDTH = 100 USE_NERDFONTS = True U_ICON_SEP = "โ€ข" U_ICON_FOLDER = "๐Ÿ—€" U_ICON_FILE = "๐Ÿ—Ž" U_ICON_BRANCH = "โ‘ƒ" U_ICON_TAG = "โŒ†" U_ICON_COMMITS = "๐Ÿ–น" U_ICON_STATS = "๐Ÿ— " U_ICON_HEART = "โ™ฅ" U_ICON_PACKAGE = "โ—‡" U_ICON_WORK = "โ˜ธ" NF_ICON_SEP = "โ€ข" NF_ICON_FOLDER = "๓ฐ‰–" NF_ICON_FILE = "๏ƒถ" NF_ICON_BRANCH = "๓ฐ˜ฌ" NF_ICON_TAG = "๓ฐ“ผ" NF_ICON_COMMITS = "๓ฐ‹š" NF_ICON_STATS = "๏ˆ" NF_ICON_HEART = "๓ฐ‹‘" NF_ICON_PACKAGE = "๓ฐ—" NF_ICON_WORK = "๓ฑŒฃ" CLR_FOLDER = "`Ffe6" CLR_FILE = "`F66d" CLR_DIM = "`F666" CLR_DIM_H = "`F444" RENDERABLE_EXTS = [".md", ".mu"] RENDER_DEFAULT = [".md", ".mu"] def __init__(self, owner=None): if not owner: raise TypeError(f"Invalid owner {owner} for {self}") self._ready = False self._should_run = False self.owner = owner self.identity = owner.identity self.node_name = owner.node_name self.announce_interval = owner.announce_interval self.last_announce = 0 self.null_ident = RNS.Identity.from_bytes(bytes(64)) self.templates = {} self.templates["base"] = DEFAULT_BASE_TEMPLATE self.templates["front"] = DEFAULT_FRONT_TEMPLATE self.templates["group"] = DEFAULT_GROUP_TEMPLATE self.templates["repo"] = DEFAULT_REPO_TEMPLATE self.templates["releases"] = DEFAULT_RELEASES_TEMPLATE self.templates["release"] = DEFAULT_RELEASE_TEMPLATE self.templates["tree"] = DEFAULT_TREE_TEMPLATE self.templates["blob"] = DEFAULT_BLOB_TEMPLATE self.templates["commits"] = DEFAULT_COMMITS_TEMPLATE self.templates["commit"] = DEFAULT_COMMIT_TEMPLATE self.templates["refs"] = DEFAULT_REFS_TEMPLATE self.templates["stats"] = DEFAULT_STATS_TEMPLATE self.templates["work"] = DEFAULT_WORK_TEMPLATE self.templates["work_doc"] = DEFAULT_WORK_DOC_TEMPLATE self.templatesdir = self.owner.configdir+"/templates" self.use_nerdfonts = self.USE_NERDFONTS self.highlight_syntax = True self.highlighter = SyntaxHighlighter() self.mdc = MarkdownToMicron(max_width=self.MAX_RENDER_WIDTH, syntax_highlighter=self.highlighter) self.thanks_deque = deque(maxlen=256) if not os.path.isdir(self.templatesdir): try: os.makedirs(self.templatesdir) except Exception as e: RNS.log(f"Could not create templates directory {self.templatesdir}: {e}", RNS.LOG_ERROR) if "pages" in self.owner.config: if "unicode_icons" in self.owner.config["pages"]: if self.owner.config["pages"].as_bool("unicode_icons"): self.use_nerdfonts = False self.destination = RNS.Destination(self.identity, RNS.Destination.IN, RNS.Destination.SINGLE, self.APP_NAME, "node") self.destination.set_link_established_callback(self.remote_connected) self.destination.set_default_app_data(self.get_announce_app_data) self.register_request_handlers() RNS.log(f"Git Nomad Network Node listening on {RNS.prettyhexrep(self.destination.hash)}", RNS.LOG_NOTICE) self._should_run = True self._ready = True threading.Thread(target=self.jobs, daemon=True).start() def icon(self, name): if self.use_nerdfonts: if name == "sep": return self.NF_ICON_SEP elif name == "folder": return self.NF_ICON_FOLDER elif name == "file": return self.NF_ICON_FILE elif name == "branch": return self.NF_ICON_BRANCH elif name == "commits": return self.NF_ICON_COMMITS elif name == "tag": return self.NF_ICON_TAG elif name == "stats": return self.NF_ICON_STATS elif name == "heart": return self.NF_ICON_HEART elif name == "package": return self.NF_ICON_PACKAGE elif name == "work": return self.NF_ICON_WORK else: return "" else: if name == "sep": return self.U_ICON_SEP elif name == "folder": return self.U_ICON_FOLDER elif name == "file": return self.U_ICON_FILE elif name == "branch": return self.U_ICON_BRANCH elif name == "commits": return self.U_ICON_COMMITS elif name == "tag": return self.U_ICON_TAG elif name == "stats": return self.U_ICON_STATS elif name == "heart": return self.U_ICON_HEART elif name == "package": return self.U_ICON_PACKAGE elif name == "work": return self.U_ICON_WORK else: return "" def jobs(self): while self._should_run: time.sleep(self.JOBS_INTERVAL) try: if self.announce_interval and time.time() > self.last_announce + self.announce_interval: self.announce() except Exception as e: RNS.log(f"Error while running periodic jobs: {e}", RNS.LOG_ERROR) def get_announce_app_data(self): return self.node_name.encode("utf-8") def announce(self): RNS.log("Announcing page node destination", RNS.LOG_VERBOSE) self.last_announce = time.time() self.destination.announce() def resolve_permission(self, remote_identity, group_name, repository_name, permission): # Since the nomadnet page protocol doesn't *require* authentication, # we use null_ident in case the remote hasn't identified. if not remote_identity: remote_identity = self.null_ident return self.owner.resolve_permission(remote_identity, group_name, repository_name, permission) def register_request_handlers(self): self.destination.register_request_handler(self.PATH_INDEX, response_generator=self.serve_front_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_GROUP, response_generator=self.serve_group_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_REPO, response_generator=self.serve_repo_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_TREE, response_generator=self.serve_tree_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_BLOB, response_generator=self.serve_blob_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_COMMITS, response_generator=self.serve_commits_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_COMMIT, response_generator=self.serve_commit_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_REFS, response_generator=self.serve_refs_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_STATS, response_generator=self.serve_stats_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_RELEASES, response_generator=self.serve_releases_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_RELEASE, response_generator=self.serve_release_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_WORK, response_generator=self.serve_work_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_WORK_DOC, response_generator=self.serve_work_doc_page, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_ARTIFACT, response_generator=self.serve_artifact, allow=RNS.Destination.ALLOW_ALL) self.destination.register_request_handler(self.PATH_DOWNLOAD, response_generator=self.serve_download, allow=RNS.Destination.ALLOW_ALL) def get_template(self, template): filename = f"{template}.mu" path = os.path.join(self.templatesdir, filename) if not os.path.isfile(path): return None else: if os.access(path, os.X_OK): try: result = subprocess.run([path], stdout=subprocess.PIPE) template = result.stdout.decode("utf-8").rstrip() return template except Exception as e: RNS.log(f"Could not get dynamic template content from {path}: {e}", RNS.LOG_ERROR) return None else: try: with open(path, "rb") as fh: return fh.read().decode("utf-8").rstrip() except Exception as e: RNS.log(f"Could not get static template content from {path}: {e}", RNS.LOG_ERROR) return None def render_template(self, page_content, nav_content=None, template=None, st=None): custom_template = self.get_template(template) if template else None if custom_template: template = custom_template page_content = template.replace("{PAGE_CONTENT}", page_content) elif template and template in self.templates: template = self.templates[template] page_content = template.replace("{PAGE_CONTENT}", page_content) base_template = self.get_template("base") or self.templates["base"] base_template = base_template.replace("{PAGE_CONTENT}", page_content) base_template = base_template.replace("{NODE_NAME}", self.node_name) base_template = base_template.replace("{VERSION}", __version__) if nav_content: base_template = base_template.replace("{NAVIGATION}", nav_content) else: base_template = base_template.replace("{NAVIGATION}", "") gt = f"Generated in {RNS.prettytime(time.time()-st)}" if st else "Unknown generation time" base_template = base_template.replace("{GEN_TIME}", gt) return base_template.encode("utf-8") ############################# # Micron Generation Helpers # ############################# def m_heading(self, text, level=1): return ">" * level + text + "\n" def m_bold(self, text): return f"`!{text}`!" def m_italic(self, text): return f"`*{text}`*" def m_underline(self, text): return f"`_{text}`_" def m_color_fg(self, text, color): return f"`F{color}{text}`f" def m_divider(self, char="\u2500"): return f"-{char}\n" def m_escape(self, text): return text.replace("`", "\\`") def m_link_r(self, _label, _path, **fields): def sanitize_v(value): return urllib.parse.quote_plus(str(value).encode("utf-8")) def sanitize_label(value): return value.replace("[", "").replace("]", "").replace("`", "") field_str = "" if fields: field_parts = [] for k, v in fields.items(): field_parts.append(f"{k}={sanitize_v(v)}") field_str = "`" + "|".join(field_parts) return f"`[{sanitize_label(_label)}`:{_path}{field_str}]" def m_link(self, _label, _path, **fields): def sanitize_v(value): return urllib.parse.quote_plus(str(value).encode("utf-8")) def sanitize_label(value): return value.replace("[", "").replace("]", "").replace("`", "") field_str = "" if fields: field_parts = [] for k, v in fields.items(): field_parts.append(f"{k}={sanitize_v(v)}") field_str = "`" + "|".join(field_parts) return f"`!`[{sanitize_label(_label)}`:{_path}{field_str}]`!" def m_align(self, text, align="left"): align_tag = {"center": "c", "left": "l", "right": "r"}.get(align, "a") return f"`{align_tag}{text}`a" ######################### # Page Serving Handlers # ######################### def serve_front_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Front page request from {remote_identity}", RNS.LOG_DEBUG) content_parts = [] nav_parts = [] accessible_groups = self.get_accessible_groups(remote_identity) breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} /" nav_parts.append(breadcrumb + "\n") if not accessible_groups: content_parts.append(">>\nNo groups available\n") else: for group_name in sorted(accessible_groups.keys()): group = accessible_groups[group_name] repo_count = len(group.get("repositories", {})) repo_word = "repository" if repo_count == 1 else "repositories" link = self.m_link(f" {self.mdc.BULLET} {group_name}", self.PATH_GROUP, g=group_name) content_parts.append(f"{link} ({repo_count} {repo_word})\n") self.owner.view_succeeded(None, None, remote_identity) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="front", st=st) def serve_group_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Group page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" if not group_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {group_name}" nav_parts.append(breadcrumb + "\n") nav_content = "".join(nav_parts) accessible_repos = self.get_accessible_repositories(remote_identity, group_name) if not group_name or group_name not in self.owner.groups or not accessible_repos: content = self.m_heading("Group Not Found", 2) + "\nThe requested group was not found\n" return self.render_template(content, nav_content=nav_content, st=st) if not accessible_repos: content_parts.append("No repositories available\n") else: content_parts.append(self.m_heading(" Repositories", 1)) content_parts.append("\n") for repo_name in sorted(accessible_repos.keys()): repo = accessible_repos[repo_name] description = self.get_repository_description(repo["path"]) link = self.m_link(f" {self.mdc.BULLET} {repo_name}", self.PATH_REPO, g=group_name, r=repo_name) content_parts.append(f"{link}") if description: content_parts.append(f" - {description}\n") else: content_parts.append("\n") self.owner.view_succeeded(group_name, None, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="group", st=st) def serve_repo_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Repository page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref = data.get("var_ref", "HEAD") if data else "HEAD" thanks = True if data.get("var_thanks", "") else False if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation repo_url = f"{self.CLR_DIM}rns://{RNS.hexrep(self.owner.destination.hash, delimit=False)}/{group_name}/{repo_name}`f" breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {repo_name} {repo_url}" nav_parts.append(breadcrumb + "\n") repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Not Found", 1) + "\nThe requested repository was not found.\n" return self.render_template(content, nav_content="".join(nav_parts), st=st) description = self.get_repository_description(repo["path"]) if description: description = f"{description}\n\n" else: description = "" thanks_count = self.repository_thanks(repo["path"], add=thanks, link_id=link_id) content_parts.append(f"{description}") # Get refs information refs = self.get_repository_refs(repo["path"]) resolved_ref = self.resolve_ref(repo["path"], ref) commits_count = self.get_commit_count(repo["path"], resolved_ref) if resolved_ref else 0 branch_count = len(refs.get("heads", [])) if refs else 0 tag_count = len(refs["tags"]) if refs else 0 active_work_dir = repo["path"]+".work/active" if not os.path.isdir(active_work_dir): work_count = 0 else: work_count = len([f for f in os.listdir(active_work_dir) if f.isdigit() and os.path.isdir(os.path.join(active_work_dir, f))]) # Get releases count releases_path = f"{repo['path']}.releases" releases_count = 0 releases = self.owner.releases_list_data(releases_path) if releases: releases_count = len([r for r in releases if r.get("status") == "published"]) sep = self.icon("sep") content_parts.append(f"{self.m_link_r(self.icon('folder')+' Files', self.PATH_TREE, g=group_name, r=repo_name, ref='HEAD')} {sep} ") if releases_count: content_parts.append(f"{self.m_link_r(self.icon('package')+f' Releases ({releases_count})', self.PATH_RELEASES, g=group_name, r=repo_name)} {sep} ") content_parts.append(f"{self.m_link_r(self.icon('work')+f' Work ({work_count})', self.PATH_WORK, g=group_name, r=repo_name)} {sep} ") content_parts.append(f"{self.m_link_r(self.icon('commits')+f' Commits ({commits_count})', self.PATH_COMMITS, g=group_name, r=repo_name, ref='HEAD')} {sep} ") content_parts.append(f"{self.m_link_r(self.icon('branch')+f' Branches ({branch_count})', self.PATH_REFS, g=group_name, r=repo_name, type='heads')} {sep} ") content_parts.append(f"{self.m_link_r(self.icon('tag')+f' Tags ({tag_count})', self.PATH_REFS, g=group_name, r=repo_name, type='tags')} {sep} ") content_parts.append(f"{self.m_link_r(self.icon('heart')+f' Thanks ({thanks_count})', self.PATH_REPO, g=group_name, r=repo_name, thanks='y')}") if self.resolve_permission(remote_identity, group_name, repo_name, self.owner.PERM_STATS): content_parts.append(f" {sep} {self.m_link_r(self.icon('stats')+f' Stats', self.PATH_STATS, g=group_name, r=repo_name)}") content_parts.append("\n\n<") # Readme content readme_content, readme_is_markdown = self.get_readme_content(repo["path"]) if readme_content is not None: if not readme_content.lstrip().startswith("#") and not readme_content.lstrip().startswith(">"): content_parts.append(self.m_divider()) if readme_is_markdown: converted = self.mdc.format_block(readme_content) content_parts.append(converted) else: content_parts.append(f"\n{readme_content}\n") content_parts.append("\n") content_parts.append(self.m_divider()) else: content_parts.append(self.m_divider()) content_parts.append("\n") content_parts.append(self.m_italic("No README file found in this repository.")) content_parts.append("\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="repo", st=st) def serve_tree_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Tree page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref = data.get("var_ref", "HEAD") if data else "HEAD" tree_path = data.get("var_path", "") if data else "" tree_path = urllib.parse.unquote_plus(tree_path) page_num = 0 try: page_str = data.get("var_page", "0") if data else "0" page_num = max(0, int(page_str)) except (ValueError, TypeError): page_num = 0 repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n" return self.render_template(content, st=st) repo_path = repo["path"] # Validate ref exists resolved_ref = self.resolve_ref(repo_path, ref) if not resolved_ref: content = self.m_heading("Error", 2) + f"\n\nThe ref '{ref}' does not exist in this repository.\n" content += f"\n" + self.m_link("View All Refs", self.PATH_REFS, g=group_name, r=repo_name) + "\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb_parts = [ self.m_link("Node", self.PATH_INDEX), self.m_link(group_name, self.PATH_GROUP, g=group_name), self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name), self.m_link("files", self.PATH_TREE, g=group_name, r=repo_name) ] # Add path components to breadcrumb if tree_path: path_components = tree_path.strip("/").split("/") current_path = "" for i, component in enumerate(path_components): current_path = current_path + "/" + component if current_path else component if i == len(path_components) - 1: breadcrumb_parts.append(component) # Last component not a link else: breadcrumb_parts.append(self.m_link(component, self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=current_path)) else: breadcrumb_parts.append("") # Could be "root" or something, but a bit confusing breadcrumb = " / ".join(breadcrumb_parts) nav_parts.append(">>\n" + breadcrumb + "\n") # Get tree entries entries = self.get_tree_entries(repo_path, resolved_ref, tree_path) if entries is None: content_parts.append("Error reading directory contents.\n") elif not entries: content_parts.append("Empty directory.\n") else: i_file = self.icon("file") i_folder = self.icon("folder") def sort_key(entry): is_dir = entry["type"] in ("tree", "commit") # commit = submodule return (not is_dir, entry["name"].lower()) entries.sort(key=sort_key) # Pagination total_entries = len(entries) start_idx = page_num * self.TREE_ENTRIES_PER_PAGE end_idx = start_idx + self.TREE_ENTRIES_PER_PAGE page_entries = entries[start_idx:end_idx] content_parts.append(self.m_heading(f"Contents: {ref} ({resolved_ref[:8]})", 2)) content_parts.append("\n") if total_entries > self.TREE_ENTRIES_PER_PAGE: content_parts.append(f"{self.CLR_DIM}Showing {start_idx + 1}-{min(end_idx, total_entries)} of {total_entries} entries`f\n\n") # Parent directory link (if not at root) if tree_path: parent_path = "/".join(tree_path.rstrip("/").split("/")[:-1]) ilink = self.m_link_r(f"{i_folder}", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=parent_path) parent_link = self.m_link_r(" ../", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=parent_path) content_parts.append(f"{self.CLR_FOLDER}{ilink}`f{parent_link}\n") for entry in page_entries: entry_name = entry["name"] entry_type = entry["type"] entry_mode = entry.get("mode", "") # Directory if entry_type == "tree": subpath = tree_path + "/" + entry_name if tree_path else entry_name ilink = self.m_link_r(f"{i_folder}", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=subpath) link = self.m_link_r(f" {entry_name}/", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=subpath) content_parts.append(f"{self.CLR_FOLDER}{ilink}`f{link}\n") # Submodule elif entry_type == "commit": content_parts.append(f"{self.CLR_FOLDER}โง‰`f {entry_name} `F666(submodule)`f\n") # Symlink elif entry_type == "link": target = entry.get("link_target", "unknown") content_parts.append(f"{self.CLR_FILE}โ†ณ`f {entry_name} `F666โ†’ {self.m_escape(target)}`f\n") # File (blob) else: size_str = self.format_size(entry.get("size", 0)) subpath = tree_path + "/" + entry_name if tree_path else entry_name ilink = self.m_link_r(f"{i_file}", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=subpath) link = self.m_link_r(f" {entry_name}", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=subpath) content_parts.append(f"{self.CLR_FILE}{ilink}`f{link} `F666({size_str})`f\n") content_parts.append("\n") # Pagination controls if total_entries > self.TREE_ENTRIES_PER_PAGE: nav_links = [] if page_num > 0: nav_links.append(self.m_link("ยซ Previous", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=tree_path, page=page_num - 1)) total_pages = (total_entries + self.TREE_ENTRIES_PER_PAGE - 1) // self.TREE_ENTRIES_PER_PAGE nav_links.append(f"Page {page_num + 1} of {total_pages}") if end_idx < total_entries: nav_links.append(self.m_link("Next ยป", self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=tree_path, page=page_num + 1)) content_parts.append(" | ".join(nav_links) + "\n") if content_parts[-1] == "\n": content_parts[-1] = "" self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="tree", st=st) def serve_blob_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Blob page request from {remote_identity}", RNS.LOG_DEBUG) group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref = data.get("var_ref", "HEAD") if data else "HEAD" file_path = data.get("var_path", "") if data else "" render = data.get("var_render", "") if data else "" raw = data.get("var_raw", "") if data else "" file_path = urllib.parse.unquote_plus(file_path) render = True if render else False raw = True if raw else False repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n" return self.render_template(content, st=st) repo_path = repo["path"] # Validate ref exists resolved_ref = self.resolve_ref(repo_path, ref) if not resolved_ref: content = self.m_heading("Ref Not Found", 1) + f"\n\nThe ref '{ref}' does not exist in this repository.\n" return self.render_template(content, st=st) # Validate file path if not file_path: content = self.m_heading("Invalid Path", 1) + "\n\nNo file path specified.\n" return self.render_template(content, st=st) file_ext = os.path.splitext(file_path)[1].lower() renderable = file_ext in self.RENDERABLE_EXTS if not renderable: raw = True; render = False else: if raw: render = False elif not render and file_ext in self.RENDER_DEFAULT: render = True; raw = False content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb_parts = [ self.m_link("Node", self.PATH_INDEX), self.m_link(group_name, self.PATH_GROUP, g=group_name), self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name), self.m_link("files", self.PATH_TREE, g=group_name, r=repo_name) ] # Add path components path_components = file_path.strip("/").split("/") current_path = "" for i, component in enumerate(path_components): current_path = current_path + "/" + component if current_path else component if i == len(path_components) - 1: breadcrumb_parts.append(component) # Last component (file) not a link else: breadcrumb_parts.append(self.m_link(component, self.PATH_TREE, g=group_name, r=repo_name, ref=ref, path=current_path)) breadcrumb = " / ".join(breadcrumb_parts) nav_parts.append(">>\n" + breadcrumb + "\n") dl_link = self.m_link("Download", self.PATH_DOWNLOAD, g=group_name, r=repo_name, ref=ref, path=file_path) if not renderable: nav_parts.append(f"\n{dl_link}\n") else: sep = self.icon("sep") rnd_link = self.m_link("View rendered", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=file_path, render="y") raw_link = self.m_link("View raw", self.PATH_BLOB, g=group_name, r=repo_name, ref=ref, path=file_path, raw="y") if render: render_controls = f"Displaying Rendered {sep} {raw_link}" else: render_controls = f"Displaying Raw {sep} {rnd_link}" nav_parts.append(f"\n{render_controls} {sep} {dl_link}\n") # Get blob info blob_info = self.get_blob_info(repo_path, resolved_ref, file_path) if blob_info is None: content_parts.append("File not found at this ref.\n") else: size = blob_info.get("size", 0) is_binary = blob_info.get("is_binary", False) is_symlink = blob_info.get("is_symlink", False) symlink_target = blob_info.get("symlink_target") type_str = 'Binary' if is_binary else 'Text' size_str = RNS.prettysize(size) symlink_str = f" | Symlink โ†’ {self.m_escape(symlink_target or 'unknown')}" if is_symlink else "" content_parts.append(self.m_heading(f"{file_path} {self.CLR_DIM_H}{ref} ({resolved_ref[:8]}) {type_str}, {size_str}{symlink_str}`f\n", 2)) # Content display if is_symlink: content_parts.append(f"`*{self.m_escape(symlink_target or 'unknown')}`*\n") elif is_binary: content_parts.append("This file appears to be binary and cannot be displayed as text.\n") # TODO: Implement raw file downloads elif size > self.BLOB_SIZE_LIMIT: content_parts.append(f"This file is {RNS.prettysize(size)}, which exceeds the display limit of {RNS.prettysize(self.BLOB_SIZE_LIMIT)}.\n") else: content = self.get_blob_content(repo_path, resolved_ref, file_path) if content is not None: if renderable and render: if file_ext == ".mu": content_parts.append(f"{content.rstrip()}\n") elif file_ext == ".md": path_components = file_path.strip("/").split("/") path = "/".join(path_components[:-1])+"/" if len(path_components) > 1 else "" url_scope = f":/page/blob.mu`g={group_name}|r={repo_name}|ref={ref}|path={path}" mdc = MarkdownToMicron(max_width=self.MAX_RENDER_WIDTH, syntax_highlighter=self.highlighter, url_scope=url_scope) content_parts.append(f"{mdc.format_block(content).rstrip()}\n") else: content_parts.append(f"`=\n{content}\n`=") else: if self.highlight_syntax: highlighted = self.highlighter.highlight(content, file_path).rstrip() content_parts.append(highlighted+"\n") else: content_parts.append(f"`=\n{content}\n`=") else: content_parts.append("Error reading file content.\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="blob", st=st) def serve_commits_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Commits page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref = data.get("var_ref", "HEAD") if data else "HEAD" file_path = data.get("var_path", "") if data else "" file_path = urllib.parse.unquote_plus(file_path) page_num = 0 try: page_str = data.get("var_page", "0") if data else "0" page_num = max(0, int(page_str)) except (ValueError, TypeError): page_num = 0 repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Not Found", 1) + "\n\nThe requested repository does not exist or you do not have access to it.\n" return self.render_template(content, st=st) repo_path = repo["path"] # Validate ref exists resolved_ref = self.resolve_ref(repo_path, ref) if not resolved_ref: content = self.m_heading("Ref Not Found", 1) + f"\n\nThe ref '{ref}' does not exist in this repository.\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb_parts = [ self.m_link("Node", self.PATH_INDEX), self.m_link(group_name, self.PATH_GROUP, g=group_name), self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name), "commits" ] if file_path: breadcrumb_parts.insert(3, f"{self.m_escape(file_path)}") breadcrumb = " / ".join(breadcrumb_parts) nav_parts.append(">>\n" + breadcrumb + "\n") title_suffix = f" for {file_path}" if file_path else "" # Get commits skip = page_num * self.COMMITS_PER_PAGE commits = self.get_commits(repo_path, resolved_ref, file_path, skip, self.COMMITS_PER_PAGE) if commits is None: content_parts.append("Error reading commit history.\n") elif not commits: content_parts.append("No commits found.\n") else: content_parts.append(self.m_heading(f"Commits{title_suffix} {self.CLR_DIM_H}{ref} ({resolved_ref[:8]})`f", 2)) content_parts.append("\n") for commit in commits: short_hash = commit["hash"][:7] subject = commit["subject"] author = commit["author"] date = self.format_absolute_time(commit["timestamp"])+" - "+self.format_relative_time(commit["timestamp"]) hash_link = self.m_link(short_hash, self.PATH_COMMIT, g=group_name, r=repo_name, h=commit["hash"]) content_parts.append(f"`F66d{hash_link}`f {self.m_escape(author)} {self.CLR_DIM}{date}`f\n") content_parts.append(f"{self.m_escape(subject)}\n\n") # Pagination controls has_more = len(commits) == self.COMMITS_PER_PAGE if page_num > 0 or has_more: nav_links = [] if page_num > 0: nav_links.append(self.m_link("ยซ Newer", self.PATH_COMMITS, g=group_name, r=repo_name, ref=ref, path=file_path, page=page_num - 1)) nav_links.append(f"Page {page_num + 1}") if has_more: nav_links.append(self.m_link("Older ยป", self.PATH_COMMITS, g=group_name, r=repo_name, ref=ref, path=file_path, page=page_num + 1)) content_parts.append(" | ".join(nav_links) + "\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="commits", st=st) def serve_commit_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Commit page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" commit_hash = data.get("var_h", "") if data else "" if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, st=st) repo_path = repo["path"] # Validate commit hash if not commit_hash or len(commit_hash) < 7: content = self.m_heading("Error", 2) + "\nNo valid commit hash specified.\n" return self.render_template(content, st=st) # Resolve and validate the commit hash resolved_hash = self.resolve_ref(repo_path, commit_hash) if not resolved_hash: content = self.m_heading("Error", 2) + f"\nThe commit {commit_hash} does not exist in this repository.\n" return self.render_template(content, st=st) # Breadcrumb navigation nav_parts = [] breadcrumb = f"{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / {resolved_hash[:7]}" nav_parts.append(">>\n" + breadcrumb + "\n") nav_content = "".join(nav_parts) # Verify it's actually a commit object try: type_result = subprocess.run(["git", "cat-file", "-t", resolved_hash], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if type_result.returncode != 0 or type_result.stdout.strip() != "commit": content = self.m_heading("Error", 2) + f"\nThe hash {commit_hash} does not refer to a commit.\n" return self.render_template(content, st=st) except Exception: content = self.m_heading("Error", 2) + "\nCould not verify commit object.\n" return self.render_template(content, st=st) content_parts = [] commit_info = self.get_commit_info(repo_path, resolved_hash) if not commit_info: content_parts.append(self.m_heading("Error", 2) + "\n\nCould not retrieve commit information.\n") page_content = "".join(content_parts) return self.render_template(page_content, st=st) content_parts.append(self.m_heading(f"Commit {resolved_hash}", 2)) content_parts.append("\n") # Navigation to tree at this commit i_folder = self.icon("folder") content_parts.append(f"{self.m_link(f'{i_folder} Browse tree at this commit', self.PATH_TREE, g=group_name, r=repo_name, ref=resolved_hash)}\n\n") # Commit metadata if commit_info.get("parents"): parent_links = [] for parent_hash in commit_info["parents"]: parent_link = self.m_link(parent_hash[:7], self.PATH_COMMIT, g=group_name, r=repo_name, h=parent_hash) parent_links.append(parent_link) content_parts.append(f"Parents: {' '.join(parent_links)}\n") content_parts.append(f"Author: {self.m_escape(commit_info['author_name'])} <{self.m_escape(commit_info['author_email'])}>\n") content_parts.append(f"Date: {commit_info['author_date']}\n") if commit_info.get("committer_name") != commit_info.get("author_name"): content_parts.append(f"Commit: {self.m_escape(commit_info['committer_name'])} <{self.m_escape(commit_info['committer_email'])}>\n") content_parts.append(f"Date: {commit_info['committer_date']}\n") content_parts.append("\n") # Commit message if commit_info.get("message"): content_parts.append(self.m_escape(commit_info["message"]) + "\n") content_parts.append("\n") # Changed files if commit_info.get("files"): content_parts.append(self.m_heading("Changes", 2)) content_parts.append("\n") total_additions = sum(f.get("additions", 0) for f in commit_info["files"]) total_deletions = sum(f.get("deletions", 0) for f in commit_info["files"]) content_parts.append(f" {len(commit_info['files'])} files changed, {total_additions} insertions(+), {total_deletions} deletions(-)\n\n") for file_info in commit_info["files"]: status = file_info.get("status", "M") file_path = file_info.get("path", "unknown") additions = file_info.get("additions", 0) deletions = file_info.get("deletions", 0) status_indicators = {"A": "`F0a0A`f", # Added - green "D": "`F900D`f", # Deleted - red "M": "`Faa0M`f", # Modified - yellow "R": "`F0aaR`f" } # Renamed - cyan status_display = status_indicators.get(status, status) # File path as link to blob at this commit file_link = self.m_link(self.m_escape(file_path), self.PATH_BLOB, g=group_name, r=repo_name, ref=resolved_hash, path=file_path) stats = [] if additions > 0: stats.append(f"`F0a0+{additions}`f") if deletions > 0: stats.append(f"`F900-{deletions}`f") stats_str = " ".join(stats) if stats else "" content_parts.append(f" {status_display} {file_link} {stats_str}\n") content_parts.append("\n") if self.SHOW_DIFF_BY_DEFAULT and commit_info.get("diff"): content_parts.append(self.m_heading("Diff", 2)) content_parts.append("\n") formatted_diff = self.format_diff(commit_info["diff"]) content_parts.append(f"{formatted_diff.lstrip()}") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="commit", st=st) def serve_refs_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Refs page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref_type = data.get("var_type", "") if data else "" # "heads", "tags", or empty for both content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb = f"{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / refs" nav_parts.append(">>\n" + breadcrumb + "\n") nav_content = "".join(nav_parts) if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, nav_content=nav_content, st=st) repo_path = repo["path"] # Filtering links i_sep = self.icon("sep") filter_links = [] filter_links.append(self.m_link("All", self.PATH_REFS, g=group_name, r=repo_name)) filter_links.append(self.m_link("Branches only", self.PATH_REFS, g=group_name, r=repo_name, type="heads")) filter_links.append(self.m_link("Tags only", self.PATH_REFS, g=group_name, r=repo_name, type="tags")) content_parts.append(f" {i_sep} ".join(filter_links) + "\n\n") # Get default branch (HEAD) default_branch = None try: head_result = subprocess.run(["git", "symbolic-ref", "HEAD"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if head_result.returncode == 0: default_branch = head_result.stdout.strip().replace("refs/heads/", "") except Exception: pass show_heads = not ref_type or ref_type == "heads" show_tags = not ref_type or ref_type == "tags" refs_data = self.get_refs_info(repo_path, default_branch) if show_heads and refs_data.get("heads"): content_parts.append(self.m_heading(f"Branches ({len(refs_data['heads'])})", 2)) content_parts.append("\n") for ref_info in refs_data["heads"]: branch_name = ref_info["name"] short_hash = ref_info["short_hash"] is_default = ref_info.get("is_default", False) commit_subject = ref_info.get("commit_subject", "") # Branch name with default indicator name_display = f"`F0a0{branch_name}`f" if is_default else branch_name default_marker = " `F0a0(default)`f" if is_default else "" # Links to tree and commits at this branch tree_link = self.m_link("tree", self.PATH_TREE, g=group_name, r=repo_name, ref=branch_name) commits_link = self.m_link("commits", self.PATH_COMMITS, g=group_name, r=repo_name, ref=branch_name) content_parts.append(f"{name_display}{default_marker} [{tree_link}] [{commits_link}]\n") content_parts.append(f"{short_hash}: {self.m_escape(commit_subject)}\n\n") if show_tags and refs_data.get("tags"): content_parts.append(self.m_heading(f"Tags ({len(refs_data['tags'])})", 2)) content_parts.append("\n") for ref_info in reversed(refs_data["tags"]): tag_name = ref_info["name"] short_hash = ref_info["short_hash"] is_annotated = ref_info.get("is_annotated", False) tag_message = ref_info.get("tag_message", "") commit_subject = ref_info.get("commit_subject", "") # Tag name with annotated indicator annotated_marker = " `Faa0(annotated)`f" if is_annotated else "" # Links to tree and commits at this tag tree_link = self.m_link("tree", self.PATH_TREE, g=group_name, r=repo_name, ref=tag_name) commits_link = self.m_link("commits", self.PATH_COMMITS, g=group_name, r=repo_name, ref=tag_name) content_parts.append(f"{tag_name}{annotated_marker} {self.CLR_DIM}{short_hash}`f [{tree_link}] [{commits_link}]\n") if is_annotated and tag_message: content_parts.append(f"{self.m_escape(tag_message[:512])}\n\n") else: content_parts.append(f"{self.m_escape(commit_subject)}\n\n") # No refs found if (show_heads and not refs_data.get("heads")) and (show_tags and not refs_data.get("tags")): content_parts.append("No refs found in this repository.\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts).rstrip()+"\n" return self.render_template(page_content, nav_content=nav_content, template="refs", st=st) def serve_stats_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Statistics page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation repo_link = self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name) breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {repo_link}" nav_parts.append(breadcrumb + "\n") repo = self.get_accessible_repository(remote_identity, group_name, repo_name) stats_permission = self.resolve_permission(remote_identity, group_name, repo_name, self.owner.PERM_STATS) if not repo or not stats_permission: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, nav_content="".join(nav_parts), st=st) stats = self.owner.repository_stats(remote_identity or self.null_ident, group_name, repo_name, lookback_days=90) if not stats: content = self.m_heading("Stats Unavailable", 2) + "\nCould not retrieve statistics for this repository.\n" return self.render_template(content, nav_content="".join(nav_parts), st=st) activity_colors = { "inactive": ("`F666", "No activity"), "low": ("`F66d", "Low activity"), "moderate": ("`Faa0", "Moderate activity"), "high": ("`F0a0", "High activity") } act_color, act_label = activity_colors.get(stats["activity_level"], ("`F666", "Unknown")) content_parts.append(self.m_heading(f"Stats for {repo_name}", 2)) v_total = stats["views"]["total"] v_peak = stats["views"]["peak"] f_total = stats["fetches"]["total"] f_peak = stats["fetches"]["peak"] p_total = stats["pushes"]["total"] p_peak = stats["pushes"]["peak"] content_parts.append(f"\n`F66dViews`f : {v_total:>5} total {self.CLR_DIM}(peak: {v_peak:>3})`f\n") content_parts.append(f"`F0a0Fetches`f : {f_total:>5} total {self.CLR_DIM}(peak: {f_peak:>3})\n`f") content_parts.append(f"`Faa0Pushes`f : {p_total:>5} total {self.CLR_DIM}(peak: {p_peak:>3})\n`f") content_parts.append(f"`F0aaActivity`f : {stats['activity_score']:>5} points\n\n") content_parts.append(f"{act_color}{act_label}`f over the last {stats['actual_days']} days ({stats['date_range']})\n\n") if v_total > 0: content_parts.append(self.m_heading(f"Views", 2)) content_parts.append("\n") content_parts.append(self.render_chart(stats["views"]["daily"], stats["timeline_labels"], color="66d")) content_parts.append("\n") if f_total > 0: content_parts.append(self.m_heading(f"Fetches", 2)) content_parts.append("\n") content_parts.append(self.render_chart(stats["fetches"]["daily"], stats["timeline_labels"], color="0a0")) content_parts.append("\n") if p_total > 0: content_parts.append(self.m_heading(f"Pushes", 2)) content_parts.append("\n") content_parts.append(self.render_chart(stats["pushes"]["daily"], stats["timeline_labels"], color="aa0")) content_parts.append("\n") if stats["activity_score"] > 0: content_parts.append(self.m_heading("Combined Activity", 2)) content_parts.append("\n") content_parts.append(self.render_combined_chart(stats["views"]["daily"], stats["fetches"]["daily"], stats["pushes"]["daily"], stats["timeline_labels"])) else: content_parts.append(self.m_italic("\nNo activity recorded for this repository in the selected time period.\n\n")) page_content = "".join(content_parts) nav_content = "".join(nav_parts) return self.render_template(page_content, nav_content=nav_content, template="stats", st=st) def serve_releases_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Releases page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / releases" nav_parts.append(breadcrumb + "\n") nav_content = "".join(nav_parts) releases_path = f"{repo['path']}.releases" releases = self.owner.releases_list_data(releases_path) if not releases: content_parts.append(self.m_heading("Releases", 2)) content_parts.append("\nNo releases available for this repository.\n") page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="repo", st=st) published_releases = [r for r in releases if r.get("status") == "published"] content_parts.append(self.m_heading(f"Releases ({len(published_releases)})", 2)) content_parts.append("\n") for rel in published_releases: tag = rel.get("tag", "unknown") created_ts = rel.get("created", 0) date_str = time.strftime("%Y-%m-%d", time.localtime(created_ts)) if created_ts else "unknown" artifacts = rel.get("artifacts", 0) preview = rel.get("preview", "")[:256] if len(rel.get("preview", "")) > len(preview): preview += "โ€ฆ" link = self.m_link(tag, self.PATH_RELEASE, g=group_name, r=repo_name, t=tag) sep = self.icon("sep") artifacts_str = f"`*{artifacts} artifact{'s' if artifacts != 1 else ''}`*" content_parts.append(f"{link} {self.CLR_DIM}{date_str} {sep} {artifacts_str}`f\n") if preview: content_parts.append(f"{self.m_escape(preview)}\n") content_parts.append("\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="releases", st=st) def serve_release_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Release page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" tag = data.get("var_t", "") if data else "" if not group_name or not repo_name or not tag: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, st=st) releases_path = f"{repo['path']}.releases" if tag == "latest": releases = self.owner.releases_list_data(releases_path) if not releases: content = self.m_heading("Release Not Found", 2) + f"\nNo latest release exist.\n" return self.render_template(content, nav_content=nav_content, st=st) recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) tag = recent_releases[0]["tag"] content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / {self.m_link('releases', self.PATH_RELEASES, g=group_name, r=repo_name)} / {tag}" nav_parts.append(breadcrumb + "\n") nav_content = "".join(nav_parts) release_dir = os.path.join(releases_path, tag) if not os.path.isdir(release_dir): content = self.m_heading("Release Not Found", 2) + f"\nThe release {tag} does not exist.\n" return self.render_template(content, nav_content=nav_content, st=st) release_info = self.owner.release_data(release_dir, tag) if not release_info: content = self.m_heading("Error", 2) + "\nCould not load release data.\n" return self.render_template(content, nav_content=nav_content, st=st) # Only show published releases if release_info.get("status") != "published": content = self.m_heading("Release Not Found", 2) + f"\nThe release {tag} does not exist.\n" return self.render_template(content, nav_content=nav_content, st=st) sep = self.icon("sep") heart = self.icon("heart") created_ts = release_info.get("created", 0) ts_str = f" {sep} {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(created_ts))}" if created_ts else "" content_parts.append(self.m_heading(f"Release {tag}{ts_str}", 2)) content_parts.append("\n") # Release notes notes = release_info.get("notes", "") if notes: notes_format = release_info.get("notes_format", "text") if notes_format == "micron": content_parts.append(f"{notes}\n") elif notes_format == "markdown": content_parts.append(f"{self.mdc.format_block(notes)}\n") else: content_parts.append(f"`={notes}`=\n") content_parts.append("\n") # Artifacts artifacts = release_info.get("artifacts", []) if artifacts: content_parts.append(self.m_heading(f"Artifacts ({len(artifacts)})", 2)) content_parts.append("\n") for art in artifacts: name = art.get("name", "unknown") size = art.get("size", 0) size_str = RNS.prettysize(size) if size else "0 B" # content_parts.append(f"{self.icon('file')} {self.m_escape(name)} {self.CLR_DIM}({size_str})`f\n") lstr_1 = f"{self.icon('file')} {self.m_escape(name)}" lstr_2 = f"({size_str})" link_1 = self.m_link_r(lstr_1, self.PATH_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name) link_2 = self.m_link_r(lstr_2, self.PATH_ARTIFACT, g=group_name, r=repo_name, t=tag, a=name) content_parts.append(f"{link_1} {self.CLR_DIM}{link_2}`f\n") content_parts.append("\n") else: content_parts.append(self.m_heading("Artifacts", 2)) content_parts.append("\nNo artifacts for this release.\n\n") thanks = True if data.get("var_thanks", "") else False thanks_count = self.release_thanks(release_dir, add=thanks, link_id=link_id) content_parts.append(f"{self.m_link_r(self.icon('heart')+f' Thanks ({thanks_count})', self.PATH_RELEASE, g=group_name, r=repo_name, t=tag, thanks='y')}\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="release", st=st) def serve_work_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Work page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" scope = data.get("var_scope", "active") if data else "active" if scope not in ["active", "completed", "all"]: scope = "active" if not group_name or not repo_name: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found.\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / work" nav_parts.append(breadcrumb + "\n") nav_content = "".join(nav_parts) # Scope filter links sep = self.icon("sep") active_s = "`_" if scope == "active" else "" cmplt_s = "`_" if scope == "completed" else "" all_s = "`_" if scope == "all" else "" filter_links = [] filter_links.append(active_s+self.m_link("Active", self.PATH_WORK, g=group_name, r=repo_name, scope="active")+active_s) filter_links.append(cmplt_s+self.m_link("Completed", self.PATH_WORK, g=group_name, r=repo_name, scope="completed")+cmplt_s) filter_links.append(all_s+self.m_link("All", self.PATH_WORK, g=group_name, r=repo_name, scope="all")+all_s) content_parts.append(f" {sep} ".join(filter_links) + "\n\n") # Load work documents work_path = f"{repo['path']}.work" scopes_to_show = ["active", "completed"] if scope == "all" else [scope] for s in scopes_to_show: folder_path = os.path.join(work_path, s) docs = [] if os.path.isdir(folder_path): for entry in os.listdir(folder_path): doc_dir = os.path.join(folder_path, entry) if not os.path.isdir(doc_dir): continue try: doc_id = int(entry) root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): continue doc = self.owner._work_load_document(root_path) if not doc: continue meta = doc.get("meta", {}) comment_count = len([f for f in os.listdir(doc_dir) if f.isdigit() and os.path.isfile(os.path.join(doc_dir, f))]) docs.append({ "id": doc_id, "title": meta.get("title", "Untitled"), "created": meta.get("created", 0), "edited": meta.get("edited", 0), "author": meta.get("author", b""), "comments": comment_count }) except: continue docs.sort(key=lambda x: max(x["created"], x["edited"]), reverse=True) if not docs: content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2)+f"\n`*No {s} work documents`*\n") content_parts.append("\n") else: content_parts.append(self.m_heading(f"{s.capitalize()} ({len(docs)})", 2)) content_parts.append("\n") for doc in docs: doc_title = str(doc.get('title', 'Untitled')[:92]) if len(doc_title) < len(doc.get('title', 'Untitled')): doc_title += "โ€ฆ" title_link = self.m_link(self.icon("file")+" "+doc_title, self.PATH_WORK_DOC, g=group_name, r=repo_name, id=doc["id"], scope=s) author_str = RNS.prettyhexrep(doc["author"]) if doc["author"] else "unknown" date_str = time.strftime("%Y-%m-%d", time.localtime(doc["created"])) if doc["created"] else "" content_parts.append(f"{title_link} {self.CLR_DIM}#{doc['id']}`f\n") content_parts.append(f"{self.CLR_DIM}{date_str} by {author_str}`f\n") content_parts.append(f"{self.CLR_DIM}{doc['comments']} updates`f\n") if doc['comments'] else None content_parts.append("\n") if content_parts[-1] == "\n": content_parts[-1] = "" self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="work", st=st) def serve_work_doc_page(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Work document page request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" doc_id = data.get("var_id", "") if data else "" scope = data.get("var_scope", "active") if data else "active" if scope not in ["active", "completed", "all"]: scope = "active" if not group_name or not repo_name or not doc_id: content = self.m_heading("Error", 2) + "\nInvalid request\n" return self.render_template(content, st=st) try: doc_id = int(doc_id) except: content = self.m_heading("Error", 2) + "\nInvalid document ID\n" return self.render_template(content, st=st) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: content = self.m_heading("Error", 2) + "\nThe requested repository was not found\n" return self.render_template(content, st=st) work_path = f"{repo['path']}.work" doc_dir = os.path.join(work_path, scope, str(doc_id)) root_path = os.path.join(doc_dir, "root") if not os.path.isfile(root_path): content = self.m_heading("Not Found", 2) + "\nThe requested work document was not found\n" return self.render_template(content, st=st) doc = self.owner._work_load_document(root_path) if not doc: content = self.m_heading("Error", 2) + "\nCould not load work document\n" return self.render_template(content, st=st) content_parts = [] nav_parts = [] # Breadcrumb navigation breadcrumb = f">>\n{self.m_link('Node', self.PATH_INDEX)} / {self.m_link(group_name, self.PATH_GROUP, g=group_name)} / {self.m_link(repo_name, self.PATH_REPO, g=group_name, r=repo_name)} / {self.m_link('work', self.PATH_WORK, g=group_name, r=repo_name)} / #{doc_id}" nav_parts.append(breadcrumb + "\n") nav_content = "".join(nav_parts) doc_title = doc['meta'].get('title', 'Untitled')[:64] if len(doc_title) < len(doc['meta'].get('title', 'Untitled')): doc_title += "โ€ฆ" meta = doc.get("meta", {}) author = meta.get("author", b"") author_str = RNS.prettyhexrep(author) if author else "Unknown" created = meta.get("created", 0) edited = meta.get("edited", 0) fmt = meta.get("format", "markdown") # Document header content_parts.append(self.m_heading(f"{doc_title}", 2)) content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n") content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n") if edited and edited != created: content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n") content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n") # Document content content = doc.get("content", "").strip() if content: if fmt == "micron": content_parts.append(content) else: content_parts.append(self.mdc.format_block(content)) content_parts.append("\n") # Load and display comments comments = [] if os.path.isdir(doc_dir): for entry in os.listdir(doc_dir): if not entry.isdigit(): continue comment_path = os.path.join(doc_dir, entry) if not os.path.isfile(comment_path): continue try: comment_id = int(entry) comment = self.owner._work_load_document(comment_path) if not comment: continue cmeta = comment.get("meta", {}) comments.append({ "id": comment_id, "format": comment.get("format", "markdown"), "content": comment.get("content", ""), "created": cmeta.get("created", 0), "author": cmeta.get("author", b"") }) except: continue comments.sort(key=lambda x: x["id"]) if comments: content_parts.append("\n"+self.m_heading(f"Updates ({len(comments)})", 2)) for c in comments: fmt = c["format"] if fmt == "markdown": content = self.mdc.format_block(c["content"]) else: content = c["content"] cauthor_str = RNS.prettyhexrep(c["author"]) if c["author"] else "Unknown" cdate = time.strftime("%Y-%m-%d %H:%M", time.localtime(c["created"])) if c["created"] else "unknown" content_parts.append(f"\n{self.CLR_DIM}#{c['id']} by {cauthor_str} on {cdate}`f\n") content_parts.append(f"{content}\n") self.owner.view_succeeded(group_name, repo_name, remote_identity) page_content = "".join(content_parts) return self.render_template(page_content, nav_content=nav_content, template="work_doc", st=st) def serve_artifact(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"Artifact file request from {remote_identity}", RNS.LOG_DEBUG) if not data: data = {} group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" tag = data.get("var_t", "") if data else "" artifact = data.get("var_a", "") if data else "" artifact = urllib.parse.unquote_plus(artifact) if "/" in artifact: return None if not group_name or not repo_name or not tag or not artifact: None repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: RNS.log(f"Repository not found or no access for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None releases_path = f"{repo['path']}.releases" if tag == "latest": releases = self.owner.releases_list_data(releases_path) if not releases: return None recent_releases = sorted(releases, key=lambda x: x['created'], reverse=True) tag = recent_releases[0]["tag"] release_dir = os.path.join(releases_path, tag) artifacts_dir = os.path.join(release_dir, "artifacts") artifact_path = os.path.join(artifacts_dir, artifact) release_info = self.owner.release_data(release_dir, tag) if not release_info: RNS.log(f"Could not resolve release info for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None if release_info.get("status") != "published": RNS.log(f"Attempt to fetch unpublished artifact {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None if not os.path.isdir(release_dir): RNS.log(f"Release directory not found for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None if not os.path.isdir(artifacts_dir): RNS.log(f"Artifacts directory not found for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None if not os.path.isfile(artifact_path): RNS.log(f"Artifacts file not found for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_WARNING) return None RNS.log(f"Artifact file resolved for artifact request {group_name}/{repo_name}/{tag}/{artifact}", RNS.LOG_DEBUG) return [open(artifact_path, "rb"), {"name": artifact.encode("utf-8")}] def serve_download(self, path, data, request_id, link_id, remote_identity, requested_at): st = time.time() RNS.log(f"File download request from {remote_identity}", RNS.LOG_DEBUG) group_name = data.get("var_g", "") if data else "" repo_name = data.get("var_r", "") if data else "" ref = data.get("var_ref", "HEAD") if data else "HEAD" file_path = data.get("var_path", "") if data else "" file_path = urllib.parse.unquote_plus(file_path) file_name = os.path.basename(file_path) repo = self.get_accessible_repository(remote_identity, group_name, repo_name) if not repo: RNS.log(f"Repository not found or no access for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING) return None repo_path = repo["path"] resolved_ref = self.resolve_ref(repo_path, ref) if not resolved_ref: RNS.log(f"Ref not found for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING) return None if not file_path: RNS.log(f"No file path for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING) return None blob_info = self.get_blob_info(repo_path, resolved_ref, file_path) if blob_info is None: RNS.log(f"File not found at ref for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING) return None else: stream = self.get_blob_stream(repo_path, resolved_ref, file_path) if stream is not None: return [stream, {"name": file_name.encode("utf-8")}] else: RNS.log(f"Could not resolve blob stream for download request {group_name}/{repo_name}/{ref}/{file_path}", RNS.LOG_WARNING) return None return None ####################### # Git Data Extraction # ####################### def get_repository_description(self, repo_path): try: # Try git config first result = subprocess.run(["git", "config", "--get", "repository.description"], cwd=repo_path, capture_output=True, text=True, check=False) if result.returncode == 0 and result.stdout.strip(): return result.stdout.strip() # Fall back to description file desc_path = f"{repo_path}.description" if os.path.isfile(desc_path): with open(desc_path, "r") as f: desc = f.read().strip() if desc: return desc except Exception as e: RNS.log(f"Error getting repository description: {e}", RNS.LOG_DEBUG) return None def get_repository_refs(self, repo_path): refs = {"heads": [], "tags": []} try: # Get all refs with their hashes result = subprocess.run(["git", "for-each-ref", "--format", "%(objectname) %(refname) %(refname:short)", "refs/heads", "refs/tags"], cwd=repo_path, capture_output=True, text=True, check=False) if result.returncode != 0: return refs for line in result.stdout.strip().split("\n"): if not line.strip(): continue parts = line.split(" ", 2) if len(parts) >= 2: full_hash = parts[0] ref_name = parts[1] short_name = parts[2] if len(parts) > 2 else ref_name short_hash = full_hash[:7] ref_info = { "name": short_name, "hash": full_hash, "short_hash": short_hash } if ref_name.startswith("refs/heads/"): refs["heads"].append(ref_info) elif ref_name.startswith("refs/tags/"): refs["tags"].append(ref_info) except Exception as e: RNS.log(f"Error getting repository refs: {e}", RNS.LOG_DEBUG) return refs def resolve_ref(self, repo_path, ref): try: result = subprocess.run(["git", "rev-parse", "--verify", ref], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode == 0: hash_val = result.stdout.strip() # Validate it's a 40-char hex string if len(hash_val) == 40 and all(c in "0123456789abcdef" for c in hash_val.lower()): return hash_val.lower() except subprocess.TimeoutExpired: RNS.log(f"Timeout resolving ref '{ref}'", RNS.LOG_WARNING) except Exception as e: RNS.log(f"Error resolving ref: {e}", RNS.LOG_WARNING) return None def get_tree_entries(self, repo_path, ref, path): entries = [] tree_path = path.strip("/") if path else "" try: # Use git ls-tree to list directory contents ls_tree_path = f"{ref}:{tree_path}" if tree_path else ref result = subprocess.run(["git", "ls-tree", "-l", ls_tree_path], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode != 0: # Check if it's actually a tree cat_result = subprocess.run(["git", "cat-file", "-t", ls_tree_path], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if cat_result.returncode != 0 or cat_result.stdout.strip() != "tree": return None # Not a valid tree return [] # Valid but empty tree for line in result.stdout.strip().split("\n"): if not line.strip(): continue # Parse ls-tree output: \t # Format: 100644 blob abc123 1234\tfilename parts = line.split("\t", 1) if len(parts) != 2: continue meta_part = parts[0] name = parts[1] meta_parts = meta_part.split() if len(meta_parts) < 3: continue mode = meta_parts[0] obj_type = meta_parts[1] # sha = meta_parts[2] size = 0 if len(meta_parts) >= 4: try: size = int(meta_parts[3]) except ValueError: size = 0 entry = { "name": name, "type": obj_type, # blob, tree, or commit "mode": mode, "size": size, "link_target": None } # Detect symlinks (mode 120000) and get symlink target if mode == "120000": entry["type"] = "link" try: symlink_result = subprocess.run(["git", "show", f"{ref}:{tree_path}/{name}" if tree_path else f"{ref}:{name}"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if symlink_result.returncode == 0: entry["link_target"] = symlink_result.stdout.strip() except Exception: pass entries.append(entry) except subprocess.TimeoutExpired: RNS.log(f"Timeout listing tree contents", RNS.LOG_WARNING) return None except Exception as e: RNS.log(f"Error getting tree entries: {e}", RNS.LOG_WARNING) return None return entries def get_blob_info(self, repo_path, ref, path): file_path = path.strip("/") try: # Get object info result = subprocess.run(["git", "cat-file", "-s", f"{ref}:{file_path}"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode != 0: return None size = int(result.stdout.strip()) # Check if it's a symlink via ls-tree parent_dir = "/".join(file_path.split("/")[:-1]) filename = file_path.split("/")[-1] ls_tree_path = f"{ref}:{parent_dir}" if parent_dir else ref result = subprocess.run(["git", "ls-tree", ls_tree_path], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) is_symlink = False if result.returncode == 0: for line in result.stdout.strip().split("\n"): if f"\t{filename}" in line and line.startswith("120000"): is_symlink = True break # Get symlink target if applicable symlink_target = None if is_symlink: content_result = subprocess.run(["git", "show", f"{ref}:{file_path}"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if content_result.returncode == 0: symlink_target = content_result.stdout.strip() # Binary detection using git diff --numstat is_binary = False if not is_symlink: # Try to get a sample of the file to check for null bytes sample_result = subprocess.run(["git", "show", f"{ref}:{file_path}"], cwd=repo_path, capture_output=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if sample_result.returncode == 0: # Check for null bytes in first 8KB sample = sample_result.stdout[:8192] if b'\x00' in sample: is_binary = True else: # Also check using git diff approach for text encoding issues diff_result = subprocess.run(["git", "diff", "--numstat", "--no-index", "--", "/dev/null", f"{ref}:{file_path}"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) # If git says it's binary, the line will start with "-" "-" if diff_result.returncode == 1: # git diff returns 1 for differences first_line = diff_result.stdout.strip().split("\n")[0] if diff_result.stdout else "" if first_line.startswith("-"): is_binary = True return { "size": size, "is_binary": is_binary, "is_symlink": is_symlink, "symlink_target": symlink_target } except subprocess.TimeoutExpired: RNS.log(f"Timeout getting blob info", RNS.LOG_DEBUG) return None except Exception as e: RNS.log(f"Error getting blob info: {e}", RNS.LOG_DEBUG) return None def get_blob_content(self, repo_path, ref, path): file_path = path.strip("/") try: result = subprocess.run(["git", "show", f"{ref}:{file_path}"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode == 0: return result.stdout except subprocess.TimeoutExpired: RNS.log(f"Timeout getting blob content", RNS.LOG_WARNING) except Exception as e: RNS.log(f"Error getting blob content: {e}", RNS.LOG_WARNING) return None def get_blob_stream(self, repo_path, ref, path): file_path = path.strip("/") try: proc = subprocess.Popen(["git", "show", f"{ref}:{file_path}"], cwd=repo_path, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return proc.stdout except subprocess.TimeoutExpired: RNS.log(f"Timeout getting blob content handle", RNS.LOG_WARNING) except Exception as e: RNS.log(f"Error getting blob content handle: {e}", RNS.LOG_WARNING) return None def get_refs_info(self, repo_path, default_branch=None): refs = {"heads": [], "tags": []} try: # Get all refs with their hashes and commit info # Format: objectname refname refname:short subject result = subprocess.run(["git", "for-each-ref", "--format=%(objectname)|%(refname)|%(refname:short)|%(subject)", "refs/heads", "refs/tags"], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode != 0: return refs for line in result.stdout.strip().split("\n"): if not line.strip(): continue parts = line.split("|", 3) if len(parts) < 3: continue full_hash = parts[0] ref_name = parts[1] short_name = parts[2] subject = parts[3] if len(parts) > 3 else "" short_hash = full_hash[:7] ref_info = { "name": short_name, "hash": full_hash, "short_hash": short_hash, "commit_subject": subject, "is_default": short_name == default_branch, "is_annotated": False, "tag_message": None } if ref_name.startswith("refs/heads/"): refs["heads"].append(ref_info) elif ref_name.startswith("refs/tags/"): # Check if it's an annotated tag try: # Get the object type this tag points to tag_result = subprocess.run(["git", "for-each-ref", "--format=%(objecttype)|%(contents:subject)", ref_name], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if tag_result.returncode == 0: tag_parts = tag_result.stdout.strip().split("|", 1) if len(tag_parts) >= 2: obj_type = tag_parts[0] if obj_type == "tag": ref_info["is_annotated"] = True ref_info["tag_message"] = tag_parts[1] except Exception: pass refs["tags"].append(ref_info) except subprocess.TimeoutExpired: RNS.log(f"Timeout getting refs info", RNS.LOG_WARNING) except Exception as e: RNS.log(f"Error getting refs info: {e}", RNS.LOG_WARNING) return refs def get_commit_count(self, repo_path, ref): try: result = subprocess.run(["git", "rev-list", "--count", ref], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode == 0: return int(result.stdout.strip()) except subprocess.TimeoutExpired: RNS.log(f"Timeout counting commits for ref '{ref}'", RNS.LOG_WARNING) except Exception as e: RNS.log(f"Error counting commits: {e}", RNS.LOG_WARNING) return 0 def get_commits(self, repo_path, ref, file_path, skip, limit): commits = [] try: sep = "|_SEP_|" cmd = ["git", "log", f"--format=%H{sep}%s{sep}%an{sep}%ae{sep}%at", "--skip", str(skip), "-n", str(limit), ref] if file_path: cmd.extend(["--", file_path]) result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode != 0: return None for line in result.stdout.strip().split("\n"): if not line.strip(): continue parts = line.split(sep, 4) if len(parts) >= 5: commits.append({ "hash": parts[0], "subject": parts[1], "author": parts[2], "author_email": parts[3], "timestamp": int(parts[4]) }) except subprocess.TimeoutExpired: RNS.log(f"Timeout getting commits", RNS.LOG_DEBUG) return None except Exception as e: RNS.log(f"Error getting commits: {e}", RNS.LOG_DEBUG) return None return commits def get_commit_info(self, repo_path, commit_hash): try: # Get commit metadata format_str = "%P%n%an%n%ae%n%aI%n%cn%n%ce%n%cI%n%B" result = subprocess.run(["git", "show", "--no-patch", "--format=" + format_str, commit_hash], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if result.returncode != 0: return None lines = result.stdout.split("\n") if len(lines) < 7: return None # Parse parents (space-separated hashes on first line) parents = lines[0].strip().split() if lines[0].strip() else [] info = { "parents": parents, "author_name": lines[1], "author_email": lines[2], "author_date": lines[3], "committer_name": lines[4], "committer_email": lines[5], "committer_date": lines[6], "message": "\n".join(lines[7:]).strip(), "files": [], "diff": None } # Get file change statistics stats_result = subprocess.run(["git", "diff-tree", "--numstat", "-r", commit_hash], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if stats_result.returncode == 0: for line in stats_result.stdout.strip().split("\n"): if not line.strip(): continue # Parse numstat output: \t\t parts = line.split("\t") if len(parts) >= 3: additions = parts[0] deletions = parts[1] file_path = parts[2] # Detect status based on additions/deletions pattern status = "M" # Modified by default if additions == "-" and deletions == "-": status = "R" # Renamed (binary or unmerged) additions = "0" deletions = "0" # Check for added/deleted via --diff-filter would require second call # Instead, we'll use a simpler approach: check if file exists in parent if info["parents"]: parent = info["parents"][0] # Check if file exists in parent check_result = subprocess.run(["git", "cat-file", "-e", f"{parent}:{file_path}"], cwd=repo_path, capture_output=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if check_result.returncode != 0: status = "A" # Added (didn't exist in parent) else: # Check if file exists in current commit check_current = subprocess.run(["git", "cat-file", "-e", f"{commit_hash}:{file_path}"], cwd=repo_path, capture_output=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if check_current.returncode != 0: status = "D" # Deleted (doesn't exist in current) try: add_count = int(additions) if additions != "-" else 0 del_count = int(deletions) if deletions != "-" else 0 except ValueError: add_count = 0 del_count = 0 info["files"].append({ "path": file_path, "status": status, "additions": add_count, "deletions": del_count }) # Get diff if enabled if self.SHOW_DIFF_BY_DEFAULT: diff_result = subprocess.run(["git", "show", "--format=", commit_hash], cwd=repo_path, capture_output=True, text=True, timeout=self.GIT_COMMAND_TIMEOUT, check=False) if diff_result.returncode == 0: info["diff"] = diff_result.stdout return info except subprocess.TimeoutExpired: RNS.log(f"Timeout getting commit info", RNS.LOG_WARNING) return None except Exception as e: RNS.log(f"Error getting commit info: {e}", RNS.LOG_WARNING) return None def get_readme_content(self, repo_path): readme_names = [ ("README.mu", False), ("Readme.mu", False), ("readme.mu", False), ("README", False), ("readme", False), ("README.md", True), ("readme.md", True), ("README.rst", False), ("README.txt", False), ("readme.rst", False), ("readme.txt", False) ] for readme_name, is_markdown in readme_names: try: result = subprocess.run(["git", "show", f"HEAD:{readme_name}"], cwd=repo_path, capture_output=True, text=True, check=False) if result.returncode == 0: return result.stdout, is_markdown except Exception: continue return None, False ################### # Utility Methods # ################### def format_size(self, size_bytes): return RNS.prettysize(size_bytes) def format_absolute_time(self, timestamp): dt = datetime.fromtimestamp(timestamp) return dt.strftime('%Y-%m-%d %H:%M:%S') def format_relative_time(self, timestamp): now = time.time() diff = now - timestamp if diff < 60: return "just now" elif diff < 3600: minutes = int(diff / 60) return f"{minutes} minute{'s' if minutes != 1 else ''} ago" elif diff < 86400: hours = int(diff / 3600) return f"{hours} hour{'s' if hours != 1 else ''} ago" elif diff < 604800: days = int(diff / 86400) return f"{days} day{'s' if days != 1 else ''} ago" elif diff < 2592000: weeks = int(diff / 604800) return f"{weeks} week{'s' if weeks != 1 else ''} ago" elif diff < 31536000: months = int(diff / 2592000) return f"{months} month{'s' if months != 1 else ''} ago" else: years = int(diff / 31536000) return f"{years} year{'s' if years != 1 else ''} ago" def format_diff(self, diff_text: str) -> str: lines = diff_text.split("\n") formatted_lines = [] for line in lines: if line.startswith("+"): if line.startswith("+++"): formatted_lines.append(self.m_escape(line)) else: formatted_lines.append(f"`F0a0{self.m_escape(line)}`f") elif line.startswith("-"): if line.startswith("---"): formatted_lines.append(self.m_escape(f"\\{line}")) else: formatted_lines.append(f"`F900{self.m_escape(line)}`f") elif line.startswith("@@"): formatted_lines.append(f"`F0aa{self.m_escape(line)}`f") elif line.startswith("diff ") or line.startswith("index ") or line.startswith("new file") or line.startswith("deleted file"): if line.startswith("diff --git a"): formatted_lines.append("") formatted_lines.append(f"`F666{self.m_escape(line)}`f") else: formatted_lines.append(self.m_escape(line)) return "\n".join(formatted_lines) def repository_thanks(self, repo_path, add=False, link_id=None): if add: thanks_hash = RNS.Identity.full_hash(link_id+repo_path.encode("utf-8")) if thanks_hash in self.thanks_deque: add = False else: self.thanks_deque.append(thanks_hash) try: thanks_path = f"{repo_path}.thanks" if not os.path.isfile(thanks_path): thanks_count = 1 if add else 0 with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count})) else: with open(thanks_path, "rb") as fh: thanks_data = mp.unpackb(fh.read()) if "count" in thanks_data: thanks_count = thanks_data["count"] else: raise ValueError("Invalid data in thanks file") if add: thanks_count += 1 with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count})) return thanks_count except Exception as e: RNS.log(f"Error while processing repository thanks for {group_name}/{repo_name}: {e}", RNS.LOG_ERROR) return 0 def release_thanks(self, release_path, add=False, link_id=None): if add: thanks_hash = RNS.Identity.full_hash(link_id+release_path.encode("utf-8")) if thanks_hash in self.thanks_deque: add = False else: self.thanks_deque.append(thanks_hash) try: thanks_path = f"{release_path}/THANKS" if not os.path.isfile(thanks_path): thanks_count = 1 if add else 0 with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count})) else: with open(thanks_path, "rb") as fh: thanks_data = mp.unpackb(fh.read()) if "count" in thanks_data: thanks_count = thanks_data["count"] else: raise ValueError("Invalid data in thanks file") if add: thanks_count += 1 with open(thanks_path, "wb") as fh: fh.write(mp.packb({"count": thanks_count})) return thanks_count except Exception as e: RNS.log(f"Error while processing release thanks for {group_name}/{repo_name}: {e}", RNS.LOG_ERROR) return 0 ################### # Stats Renderers # ################### def render_chart(self, data, labels, color="666", height=10): if not data or all(d == 0 for d in data): return "No data available\n" max_val = max(data) if max(data) > 0 else 1 num_points = len(data) hsep = "" indent = "" bar_width = 1 chart_lines = [] chart_lines.append(f"{indent}`F{color}Peak: {max_val}`f\n") for row in range(height, 0, -1): threshold = (row - 1) / height * max_val row_line = f"{indent}โ”‚" for val in data: if val > threshold: if row >= height * 0.875: row_line += f"`F{color}{'โ–ˆ'*bar_width}`f{hsep}" elif row >= height * 0.625: row_line += f"`F{color}{'โ–“'*bar_width}`f{hsep}" elif row >= height * 0.375: row_line += f"`F{color}{'โ–’'*bar_width}`f{hsep}" else: row_line += f"`F{color}{'โ–‘'*bar_width}`f{hsep}" else: row_line += f"{' '*bar_width}{hsep}" row_line += "\n" chart_lines.append(row_line) hsj = "โ”ด"*len(hsep) bottom_border = "โ””" + hsj.join(["โ”€" * bar_width] * num_points) + "โ”˜" chart_lines.append(indent + bottom_border + "\n") chart_width = len(bottom_border) first_label = f"{labels[0][:12]:<12}" final_label = f"{labels[-1][:12]:>12}" middle_space = chart_width-len(first_label)-len(final_label) label_line = f"{indent}`F666{first_label}`f" label_line += " " * middle_space label_line += f"`F666{final_label}`f\n" chart_lines.append(label_line) return "".join(chart_lines) # TODO: This is a weird idea, really. Probably redo it to something else. def render_combined_chart(self, views, fetches, pushes, labels, height=4): if not views or not labels: return "No data available\n" all_data = [v + f + p for v, f, p in zip(views, fetches, pushes)] max_val = max(all_data) if all(all_data) > 0 else 1 num_points = len(views) hsep = "" indent = "" bar_width = 1 lines = [] lines.append(f"{indent}`F66dโ–ˆโ–ˆ`f Views `F0a0โ–ˆโ–ˆ`f Fetches `Faa0โ–ˆโ–ˆ`f Pushes\n\n") for row in range(height, 0, -1): threshold = (row - 1) / height * max_val row_line = f"{indent}โ”‚" for i in range(num_points): v, f, p = views[i], fetches[i], pushes[i] total = v + f + p if total > threshold: # Determine which "layer" this row represents # Priority: Pushes > fetches > views for display if p > 0 and threshold < (v + f + p) and threshold >= (v + f): row_line += f"`Faa0{'โ–ˆ'*bar_width}`f{hsep}" elif f > 0 and threshold < (v + f) and threshold >= v: row_line += f"`F0a0{'โ–“'*bar_width}`f{hsep}" elif v > 0 and threshold < v: row_line += f"`F66d{'โ–‘'*bar_width}`f{hsep}" else: # Mixed or partial, show dominant if p >= f and p >= v: row_line += f"`Faa0{'โ–’'*bar_width}`f{hsep}" elif f >= v: row_line += f"`F0a0{'โ–’'*bar_width}`f{hsep}" else: row_line += f"`F66d{'โ–’'*bar_width}`f{hsep}" else: row_line += f"{' '*bar_width}{hsep}" row_line += "\n" lines.append(row_line) hsj = "โ”ด"*len(hsep) bottom_border = "โ””" + hsj.join(["โ”€" * bar_width] * num_points) + "โ”˜" lines.append(indent + bottom_border + "\n") chart_width = len(bottom_border) first_label = f"{labels[0][:12]:<12}" final_label = f"{labels[-1][:12]:>12}" middle_space = chart_width-len(first_label)-len(final_label) label_line = f"{indent}`F666{first_label}`f" label_line += " " * middle_space label_line += f"`F666{final_label}`f\n" lines.append(label_line) return "".join(lines) ####################### # Connection Handlers # ####################### def remote_connected(self, link): RNS.log(f"Peer connected to {self.destination}", RNS.LOG_DEBUG) link.set_remote_identified_callback(self.remote_identified) link.set_link_closed_callback(self.remote_disconnected) def remote_disconnected(self, link): RNS.log(f"Peer disconnected from {self.destination}", RNS.LOG_DEBUG) def remote_identified(self, link, identity): RNS.log(f"Peer identified as {link.get_remote_identity()} on {link}", RNS.LOG_DEBUG) ###################### # Permission Control # ###################### def get_accessible_groups(self, remote_identity): accessible_groups = {} for group_name, group_data in self.owner.groups.items(): accessible_repos = self.get_accessible_repositories(remote_identity, group_name) if accessible_repos: accessible_groups[group_name] = { "path": group_data["path"], "repositories": accessible_repos } return accessible_groups def get_accessible_repositories(self, remote_identity, group_name): if group_name not in self.owner.groups: return {} group_data = self.owner.groups[group_name] all_repos = group_data.get("repositories", {}) accessible_repos = {} for repo_name, repo_data in all_repos.items(): if self.resolve_permission(remote_identity, group_name, repo_name, self.owner.PERM_READ): accessible_repos[repo_name] = repo_data return accessible_repos def get_accessible_repository(self, remote_identity, group_name, repo_name): if group_name not in self.owner.groups: return None group_data = self.owner.groups[group_name] all_repos = group_data.get("repositories", {}) if repo_name not in all_repos: return None if self.resolve_permission(remote_identity, group_name, repo_name, self.owner.PERM_READ): return all_repos[repo_name] return None # Global base template DEFAULT_BASE_TEMPLATE = """#!c=0 > {NODE_NAME} {NAVIGATION} {PAGE_CONTENT} < - `a`F666`[Served by rngit {VERSION}`:/page/index.mu] - {GEN_TIME}`f""" # Front page template DEFAULT_FRONT_TEMPLATE = """> Groups {PAGE_CONTENT}""" # Repositories page template DEFAULT_GROUP_TEMPLATE = """{PAGE_CONTENT}""" # Repository page template DEFAULT_REPO_TEMPLATE = """{PAGE_CONTENT}""" # Repository page template DEFAULT_RELEASES_TEMPLATE = """{PAGE_CONTENT}""" # Repository page template DEFAULT_RELEASE_TEMPLATE = """{PAGE_CONTENT}""" # Tree page template DEFAULT_TREE_TEMPLATE = """{PAGE_CONTENT}""" # Blob page template DEFAULT_BLOB_TEMPLATE = """{PAGE_CONTENT}""" # Commits page template DEFAULT_COMMITS_TEMPLATE = """{PAGE_CONTENT}""" # Commit page template DEFAULT_COMMIT_TEMPLATE = """{PAGE_CONTENT}""" # Refs page template DEFAULT_REFS_TEMPLATE = """{PAGE_CONTENT}""" # Stats page template DEFAULT_STATS_TEMPLATE = """{PAGE_CONTENT}""" # Work page templates DEFAULT_WORK_TEMPLATE = """{PAGE_CONTENT}""" DEFAULT_WORK_DOC_TEMPLATE = """{PAGE_CONTENT}""" # Fallback template FALLBACK_TEMPLATE = """{PAGE_CONTENT}"""