Files
Reticulum/RNS/Utilities/rngit/pages.py
T

2361 lines
111 KiB
Python

# 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: <mode> <type> <sha> <size>\t<name>
# 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: <additions>\t<deletions>\t<path>
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}"""