//! Web UI: server-rendered HTML pages for browsing repositories. //! //! All HTML is generated from Rust using `format!()`. No build step, //! no static assets, no framework. Highlight.js loaded from CDN for //! syntax highlighting in the file viewer. use crate::{ api, diff, presentation::{self, Action, Hint, NegotiatedRepresentation}, store, }; use pulldown_cmark::{html, CowStr, Event, Options, Parser, Tag}; use worker::*; mod commit; mod home; mod log; mod search; mod settings; mod tree_blob; pub(crate) use commit::page_commit; pub(crate) use commit::{page_commit_markdown, page_diff_markdown}; pub(crate) use home::{page_home, page_home_markdown}; pub(crate) use log::{page_log, page_log_markdown}; pub(crate) use search::{page_search, page_search_markdown}; pub(crate) use settings::{page_settings, page_settings_markdown}; pub(crate) use tree_blob::{page_blob, page_blob_markdown, page_tree, page_tree_markdown}; type Url = worker::Url; // --------------------------------------------------------------------------- // Layout: shared HTML shell // --------------------------------------------------------------------------- pub(crate) fn layout( title: &str, owner: &str, repo_name: &str, default_branch: &str, actor_name: Option<&str>, content: &str, ) -> String { let is_owner = actor_name == Some(owner); let global_auth = match actor_name { Some(name) => format!( r#"{n}Sign out"#, n = html_escape(name), ), None => format!( r#"Sign in"#, o = html_escape(owner), r = html_escape(repo_name), ), }; let repo_settings_link = if is_owner { format!( r#"Settings"#, html_escape(owner), html_escape(repo_name) ) } else { String::new() }; format!( r#" {title} - {owner}/{repo_name} - ripgit
{content}
"#, title = html_escape(title), owner = html_escape(owner), repo_name = html_escape(repo_name), default_branch = html_escape(default_branch), global_auth = global_auth, repo_settings_link = repo_settings_link, content = content, CSS = CSS, ) } const CSS: &str = r#" * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 14px; color: #1f2328; background: #fff; line-height: 1.5; } a { color: #0969da; text-decoration: none; } a:hover { text-decoration: underline; } /* ── Global nav (identity only) ─────────────────────────────── */ .global-nav { display: flex; align-items: center; justify-content: space-between; padding: 0 24px; height: 44px; } header { border-bottom: 1px solid #d1d9e0; } .logo { font-weight: 700; font-size: 15px; color: #1f2328; } .global-auth { display: flex; align-items: center; gap: 10px; font-size: 13px; } /* ── Repo bar (context + actions) ───────────────────────────── */ .repo-bar-wrap { border-top: 1px solid #d1d9e0; background: #f6f8fa; } .repo-bar { display: flex; align-items: center; gap: 16px; max-width: 1200px; margin: 0 auto; padding: 0 24px; height: 40px; } .repo-crumb { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } .repo-search { flex: 0 0 auto; } .repo-tabs { margin-left: auto; display: flex; align-items: center; gap: 16px; flex-shrink: 0; } .repo-tabs a { font-size: 13px; color: #656d76; } .repo-tabs a:hover { color: #1f2328; text-decoration: none; } .sep { color: #656d76; } .owner-name { font-weight: 600; color: #1f2328; font-size: 13px; } .repo-name { font-weight: 600; color: #1f2328; font-size: 13px; } main { max-width: 1200px; margin: 0 auto; padding: 24px; } h1 { font-size: 20px; margin-bottom: 16px; } h2 { font-size: 16px; margin-bottom: 12px; } /* File tree */ .tree-table { width: 100%; border-collapse: collapse; } .tree-table td { padding: 6px 12px; border-top: 1px solid #d1d9e0; } .tree-table tr:first-child td { border-top: none; } .tree-icon { width: 20px; color: #656d76; } .tree-name { } .tree-msg { color: #656d76; text-align: right; } /* Commit list */ .commit-list { list-style: none; } .commit-item { padding: 8px 0; border-bottom: 1px solid #d1d9e0; display: flex; align-items: baseline; gap: 12px; } .commit-msg { flex: 1; } .commit-msg a { color: #1f2328; } .commit-hash { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; color: #0969da; background: #ddf4ff; padding: 2px 6px; border-radius: 4px; } .commit-time { color: #656d76; font-size: 12px; white-space: nowrap; } .commit-author { color: #656d76; font-size: 12px; } /* File viewer */ .file-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px 6px 0 0; } .raw-btn { font-size: 12px; padding: 2px 8px; border: 1px solid #d1d9e0; border-radius: 4px; color: #1f2328; background: #fff; text-decoration: none; } .raw-btn:hover { background: #f6f8fa; text-decoration: none; } .nav-signin { color: #1f2328; border: 1px solid #d1d9e0; border-radius: 4px; padding: 3px 10px; text-decoration: none; background: #fff; } .nav-signin:hover { background: #f6f8fa; } .nav-user { font-weight: 600; color: #1f2328; text-decoration: none; } .nav-signout { color: #656d76; text-decoration: none; } .empty-repo { padding: 24px; background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px; } .empty-repo h2 { margin-bottom: 12px; } .empty-repo-msg { color: #656d76; } .push-cmd { background: #fff; border: 1px solid #d1d9e0; border-radius: 6px; padding: 14px 16px; font-size: 13px; margin: 12px 0; overflow-x: auto; line-height: 1.6; } .push-note { font-size: 13px; color: #656d76; margin-top: 8px; } .push-note a { color: #0969da; } .settings-section { border: 1px solid #d1d9e0; border-radius: 6px; padding: 20px 24px; margin-bottom: 20px; } .settings-section h2 { font-size: 16px; margin-bottom: 10px; } .settings-hint { font-size: 13px; color: #656d76; margin-bottom: 14px; } .settings-danger { border-color: #cf222e; } .settings-danger h2 { color: #cf222e; } .stats-grid { display: flex; gap: 16px; flex-wrap: wrap; } .stat-box { background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px; padding: 12px 20px; min-width: 100px; text-align: center; } .stat-val { font-size: 22px; font-weight: 600; } .stat-lbl { font-size: 12px; color: #656d76; margin-top: 2px; } .action-row { display: flex; flex-direction: column; gap: 10px; } .action-row form { display: flex; align-items: center; gap: 12px; } .action-hint { font-size: 13px; color: #656d76; } .btn-action { background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px; padding: 5px 14px; font-size: 13px; cursor: pointer; } .btn-action:hover { background: #eaeef2; } .btn-danger-action { background: #cf222e; color: #fff; border: none; border-radius: 6px; padding: 5px 14px; font-size: 13px; cursor: pointer; } .btn-danger-action:hover { background: #a40e26; } .inline-form { display: flex; gap: 8px; align-items: center; } .branch-input { border: 1px solid #d1d9e0; border-radius: 6px; padding: 5px 10px; font-size: 13px; font-family: ui-monospace, monospace; width: 280px; } .danger-confirm { width: 320px; } .branch-input:focus { outline: none; border-color: #0969da; } .file-content { border: 1px solid #d1d9e0; border-top: none; border-radius: 0 0 6px 6px; overflow-x: auto; } .file-content pre { margin: 0; padding: 12px; font-size: 13px; line-height: 1.45; } .file-content code { font-family: ui-monospace, SFMono-Regular, monospace; } .line-numbers { display: inline-block; text-align: right; padding-right: 12px; margin-right: 12px; border-right: 1px solid #d1d9e0; color: #656d76; user-select: none; min-width: 40px; } /* Diff */ .diff-file { margin-bottom: 16px; border: 1px solid #d1d9e0; border-radius: 6px; overflow: hidden; } .diff-file-header { padding: 8px 12px; background: #f6f8fa; border-bottom: 1px solid #d1d9e0; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; display: flex; align-items: center; gap: 8px; } .diff-status { font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 3px; text-transform: uppercase; } .diff-status.added { background: #dafbe1; color: #116329; } .diff-status.deleted { background: #ffebe9; color: #82071e; } .diff-status.modified { background: #ddf4ff; color: #0550ae; } .diff-hunk-header { padding: 4px 12px; background: #ddf4ff; color: #656d76; font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; border-top: 1px solid #d1d9e0; } .diff-table { width: 100%; border-collapse: collapse; font-size: 13px; } .diff-table td { padding: 0 12px; font-family: ui-monospace, SFMono-Regular, monospace; white-space: pre-wrap; word-break: break-all; vertical-align: top; line-height: 20px; } .diff-ln { width: 1%; min-width: 40px; text-align: right; color: #656d76; user-select: none; padding: 0 8px; } .diff-line-add { background: #dafbe1; } .diff-line-add .diff-ln { background: #ccffd8; } .diff-line-del { background: #ffebe9; } .diff-line-del .diff-ln { background: #ffd7d5; } .diff-line-ctx { background: #fff; } /* Stats */ .diff-stats { margin-bottom: 16px; padding: 12px; background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 13px; } .stat-add { color: #116329; font-weight: 600; } .stat-del { color: #82071e; font-weight: 600; } /* Search */ .search-form { display: flex; gap: 8px; margin-bottom: 16px; } .search-form input[type="text"] { flex: 1; padding: 8px 12px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 14px; } .search-form button { padding: 8px 16px; background: #2da44e; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; } .search-result { padding: 12px; border: 1px solid #d1d9e0; border-radius: 6px; margin-bottom: 8px; } .search-result-path { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; margin-bottom: 4px; } .search-result-snippet { font-size: 13px; color: #656d76; white-space: pre-wrap; } /* Breadcrumb */ .breadcrumb { margin-bottom: 16px; font-size: 16px; } .breadcrumb a { font-weight: 600; } /* README */ .readme-box { margin-top: 24px; border: 1px solid #d1d9e0; border-radius: 6px; } .readme-header { padding: 8px 12px; background: #f6f8fa; border-bottom: 1px solid #d1d9e0; font-weight: 600; font-size: 13px; } .readme-body { padding: 16px 24px; font-size: 14px; line-height: 1.6; } .readme-body pre { background: #f6f8fa; padding: 12px; border-radius: 6px; overflow-x: auto; } /* Pagination */ .pagination { display: flex; gap: 8px; margin-top: 16px; } .pagination a, .pagination span { padding: 6px 12px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 13px; } .pagination span { color: #656d76; } /* Branch selector */ .branch-selector { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 16px; } .branch-selector select { padding: 5px 8px; border: 1px solid #d1d9e0; border-radius: 6px; background: #f6f8fa; font-size: 13px; font-weight: 600; cursor: pointer; } .branch-selector .branch-label { font-size: 13px; color: #656d76; } /* Line numbers (highlightjs-line-numbers.js overrides) */ .hljs-ln-numbers { text-align: right; padding-right: 12px !important; border-right: 1px solid #d1d9e0; color: #656d76; user-select: none; min-width: 40px; vertical-align: top; } .hljs-ln-code { padding-left: 12px !important; } /* Markdown */ .readme-body h1 { font-size: 24px; margin: 16px 0 8px; padding-bottom: 4px; border-bottom: 1px solid #d1d9e0; } .readme-body h2 { font-size: 20px; margin: 14px 0 6px; padding-bottom: 4px; border-bottom: 1px solid #d1d9e0; } .readme-body h3 { font-size: 16px; margin: 12px 0 4px; } .readme-body p { margin: 8px 0; } .readme-body ul, .readme-body ol { margin: 8px 0; padding-left: 24px; } .readme-body li { margin: 2px 0; } .readme-body code { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; background: #eff1f3; padding: 2px 6px; border-radius: 4px; } .readme-body pre code { background: none; padding: 0; } .readme-body a { color: #0969da; } .readme-body img { max-width: 100%; } .readme-body hr { border: none; border-top: 1px solid #d1d9e0; margin: 16px 0; } .readme-body blockquote { border-left: 3px solid #d1d9e0; padding-left: 12px; color: #656d76; margin: 8px 0; } /* Nav search */ .repo-search { position: relative; } .repo-search input { padding: 4px 10px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 13px; width: 220px; background: #fff; color: #1f2328; } .repo-search input:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.1); } /* Keep .nav-search as an alias so the JS selector still works */ .nav-search { position: relative; } .nav-search-results { position: absolute; top: calc(100% + 6px); right: 0; width: 460px; background: #fff; border: 1px solid #d1d9e0; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); z-index: 100; overflow: hidden; max-height: 460px; overflow-y: auto; } .nsr-item { display: block; padding: 8px 12px; border-bottom: 1px solid #f0f2f4; text-decoration: none; color: #1f2328; } .nsr-item:hover { background: #f6f8fa; text-decoration: none; } .nsr-path { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; color: #0969da; } .nsr-snippet { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 12px; color: #656d76; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; } .nsr-empty { padding: 16px 12px; color: #656d76; font-size: 13px; text-align: center; } .nsr-all { display: block; padding: 8px 12px; font-size: 13px; color: #0969da; text-align: center; border-top: 1px solid #d1d9e0; background: #f6f8fa; text-decoration: none; } .nsr-all:hover { background: #eef1f5; text-decoration: none; } /* ── Issues & PRs ───────────────────────────────────────────── */ .issue-list-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .issue-tabs { display: flex; gap: 0; border: 1px solid #d1d9e0; border-radius: 6px; overflow: hidden; margin-bottom: 0; } .issue-tab { padding: 6px 16px; font-size: 13px; color: #656d76; background: #f6f8fa; border-right: 1px solid #d1d9e0; text-decoration: none; } .issue-tab:last-child { border-right: none; } .issue-tab.active { background: #fff; color: #1f2328; font-weight: 600; } .issue-tab:hover { background: #eaeef2; text-decoration: none; } .issue-list { border: 1px solid #d1d9e0; border-radius: 6px; overflow: hidden; margin-top: 8px; } .issue-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 16px; border-bottom: 1px solid #d1d9e0; } .issue-item:last-child { border-bottom: none; } .issue-item:hover { background: #f6f8fa; } .issue-state-icon { font-size: 14px; margin-top: 2px; flex-shrink: 0; } .issue-state-icon.open { color: #1a7f37; } .issue-state-icon.closed { color: #656d76; } .issue-state-icon.merged { color: #8250df; } .issue-item-main { flex: 1; min-width: 0; } .issue-item-title { font-weight: 600; color: #1f2328; word-break: break-word; } .issue-item-title:hover { color: #0969da; text-decoration: none; } .issue-item-meta { font-size: 12px; color: #656d76; margin-top: 2px; } .pr-branch-pair { font-size: 12px; color: #656d76; margin-left: 8px; } .pr-branch-pair code { font-size: 12px; background: #f6f8fa; padding: 1px 5px; border-radius: 3px; border: 1px solid #d1d9e0; } .issue-empty { padding: 24px; color: #656d76; text-align: center; } .issue-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; } .issue-badge.open { background: #dafbe1; color: #1a7f37; } .issue-badge.closed { background: #f0f2f4; color: #656d76; } .issue-badge.merged { background: #eee4ff; color: #8250df; } .issue-detail-header { margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #d1d9e0; } .issue-title-row h1 { font-size: 22px; margin-bottom: 8px; } .issue-number-heading { color: #656d76; font-weight: 400; } .issue-meta-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } .issue-meta-text { font-size: 13px; color: #656d76; } .issue-body-wrap { border: 1px solid #d1d9e0; border-radius: 6px; padding: 16px 20px; margin-bottom: 20px; } .issue-body { line-height: 1.6; } .issue-no-description { color: #656d76; font-style: italic; font-size: 14px; } .comment { border: 1px solid #d1d9e0; border-radius: 6px; margin-bottom: 12px; overflow: hidden; } .comment-header { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #f6f8fa; border-bottom: 1px solid #d1d9e0; font-size: 13px; } .comment-time { color: #656d76; font-size: 12px; } .comment-body { padding: 12px 16px; font-size: 14px; line-height: 1.6; } .comment-form-wrap { border: 1px solid #d1d9e0; border-radius: 6px; margin-top: 16px; overflow: hidden; } .comment-header-row { padding: 8px 14px; background: #f6f8fa; border-bottom: 1px solid #d1d9e0; font-size: 13px; font-weight: 600; } .comment-form { padding: 12px 16px; } .comment-textarea { width: 100%; padding: 8px 12px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 14px; font-family: inherit; resize: vertical; } .comment-textarea:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.1); } .comment-form-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } .comment-thread { margin: 16px 0; } .pr-diff-section { margin: 20px 0; } .pr-diff-section h2 { font-size: 16px; margin-bottom: 10px; } .pr-diff-stats { display: flex; gap: 12px; align-items: center; padding: 8px 14px; background: #f6f8fa; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 13px; margin-bottom: 12px; } .pr-merge-box { padding: 16px; background: #dafbe1; border: 1px solid #aef0be; border-radius: 6px; margin-bottom: 12px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .pr-merged-box { padding: 12px 16px; background: #eee4ff; border: 1px solid #d8b4fe; border-radius: 6px; margin-bottom: 12px; font-size: 13px; } .pr-merge-hint { font-size: 12px; color: #1a7f37; margin: 0; } .pr-branch-info { display: flex; align-items: center; gap: 8px; } .branch-tag { background: #f6f8fa; border: 1px solid #d1d9e0; padding: 2px 8px; border-radius: 4px; font-size: 13px; } .pr-branch-missing { color: #cf222e; font-size: 14px; } .btn-primary { padding: 6px 16px; background: #2da44e; color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block; } .btn-primary:hover { background: #2c974b; color: #fff; text-decoration: none; } .btn-merge { padding: 8px 20px; background: #8250df; color: #fff; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; font-weight: 600; } .btn-merge:hover { background: #6e40c9; } .new-issue-form { max-width: 760px; } .form-group { margin-bottom: 16px; } .form-label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 6px; } .form-hint { font-weight: 400; color: #656d76; } .form-input { width: 100%; padding: 8px 12px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 14px; font-family: inherit; } .form-input:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.1); } .form-textarea { width: 100%; padding: 8px 12px; border: 1px solid #d1d9e0; border-radius: 6px; font-size: 14px; font-family: inherit; resize: vertical; } .form-textarea:focus { outline: none; border-color: #0969da; box-shadow: 0 0 0 3px rgba(9,105,218,0.1); } .form-actions { display: flex; gap: 8px; align-items: center; } .form-branch-row { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; } .form-branch-row .arrow { font-size: 18px; color: #656d76; padding-bottom: 6px; } "#; // --------------------------------------------------------------------------- // Page: Owner profile (/:owner/) // Handled at the Worker level since there is no per-owner DO. // --------------------------------------------------------------------------- pub fn page_owner_profile( owner: &str, actor_name: Option<&str>, url: &Url, repos: &[String], ) -> Result { let is_owner = actor_name == Some(owner); let host = url.host_str().unwrap_or("your-worker.dev"); let scheme = url.scheme(); // Repo list (shown to everyone, empty state differs for owner vs visitor) let repo_list = if repos.is_empty() { if is_owner { format!( r#"

Create your first repo

Repos are created on first push. Pick any name:

cd my-project
git init
git add .
git commit -m "initial commit"
git remote add origin {scheme}://{owner}:TOKEN@{host}/{owner}/my-project
git push origin main

Get a TOKEN from Settings → Access Tokens.

"#, owner = html_escape(owner), scheme = scheme, host = host, ) } else { r#"

No public repositories.

"#.to_string() } } else { let mut items = String::new(); for repo in repos { items.push_str(&format!( r#"
  • {repo}
  • "#, owner = html_escape(owner), repo = html_escape(repo), )); } let mut html = format!(r#""#, items); if is_owner { html.push_str(&format!( r#"
    Push a new repo
    cd my-project
    git init && git add . && git commit -m "initial commit"
    git remote add origin {scheme}://{owner}:TOKEN@{host}/{owner}/my-project
    git push origin main

    Get a TOKEN from Settings.

    "#, owner = html_escape(owner), scheme = scheme, host = host, )); } html }; let body = format!( r#"

    {owner}

    {settings_link}
    {repo_list}
    "#, owner = html_escape(owner), settings_link = if is_owner { r#"Settings"#.to_string() } else { String::new() }, repo_list = repo_list, ); let html = format!( r#" {owner} — ripgit
    ripgit / {owner}
    {body} "#, owner = html_escape(owner), body = body, ); let mut resp = Response::from_bytes(html.into_bytes())?; resp.headers_mut() .set("Content-Type", "text/html; charset=utf-8")?; Ok(resp) } pub fn page_owner_profile_markdown( owner: &str, actor_name: Option<&str>, url: &Url, repos: &[String], selection: &NegotiatedRepresentation, ) -> Result { let is_owner = actor_name == Some(owner); let host = url.host_str().unwrap_or("your-worker.dev"); let scheme = url.scheme(); let mut markdown = format!("# {}\n\nRepositories: `{}`\n", owner, repos.len()); if repos.is_empty() { if is_owner { markdown.push_str("\nNo repositories yet. Repositories are created on first push.\n"); markdown.push_str("\n## First Push\n\n```bash\n"); markdown.push_str("cd my-project\n"); markdown.push_str("git init\n"); markdown.push_str("git add .\n"); markdown.push_str("git commit -m \"initial commit\"\n"); markdown.push_str(&format!( "git remote add origin {}://{}:TOKEN@{}/{}/my-project\n", scheme, owner, host, owner )); markdown.push_str("git push origin main\n"); markdown.push_str("```\n"); } else { markdown.push_str("\nNo public repositories.\n"); } } else { markdown.push_str("\n## Repositories (GET paths)\n"); for repo in repos { markdown.push_str(&format!("- `{}` - `/{}/{}`\n", repo, owner, repo)); } if is_owner { markdown.push_str("\n## Push a New Repository\n\n```bash\n"); markdown.push_str("cd my-project\n"); markdown.push_str("git init\n"); markdown.push_str("git add .\n"); markdown.push_str("git commit -m \"initial commit\"\n"); markdown.push_str(&format!( "git remote add origin {}://{}:TOKEN@{}/{}/my-project\n", scheme, owner, host, owner )); markdown.push_str("git push origin main\n"); markdown.push_str("```\n"); } } let mut actions = vec![Action::get( format!("/{}/", owner), "reload this owner profile", )]; if is_owner { actions.push(Action::get("/settings", "open account settings")); } let hints = vec![ presentation::text_navigation_hint(*selection), Hint::new("Repository paths listed above are GET routes under this owner."), Hint::new( "Repositories are created on first push; there is no separate create-repo endpoint.", ), ]; markdown.push_str(&presentation::render_actions_section(&actions)); markdown.push_str(&presentation::render_hints_section(&hints)); presentation::markdown_response(&markdown, selection) } // --------------------------------------------------------------------------- // Raw file serving // --------------------------------------------------------------------------- pub fn serve_raw(sql: &SqlStorage, ref_name: &str, path: &str) -> Result { if path.is_empty() { return Response::error("path required", 400); } let commit_hash = match api::resolve_ref(sql, ref_name)? { Some(h) => h, None => return Response::error("ref not found", 404), }; let blob_hash = match resolve_path_to_blob(sql, &commit_hash, path) { Ok(h) => h, Err(_) => return Response::error("Not Found", 404), }; let content = load_blob(sql, &blob_hash)?; let filename = path.rsplit('/').next().unwrap_or(path); let content_type = raw_content_type(filename, &content); let mut resp = Response::from_bytes(content)?; resp.headers_mut().set("Content-Type", content_type)?; // Prevent the browser from sniffing or executing the content resp.headers_mut() .set("X-Content-Type-Options", "nosniff")?; resp.headers_mut() .set("Content-Security-Policy", "default-src 'none'")?; Ok(resp) } /// Pick a safe Content-Type for raw file serving. /// Text files are served as text/plain (never text/html) so the browser /// displays them rather than executing anything. Images get their proper type /// so they can be embedded/viewed directly. Everything else that contains /// null bytes is treated as binary. fn raw_content_type(filename: &str, content: &[u8]) -> &'static str { let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); match ext.as_str() { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "gif" => "image/gif", "webp" => "image/webp", "ico" => "image/x-icon", "pdf" => "application/pdf", // SVG/HTML/XML/JSON: always text/plain — never execute in browser _ => { let is_binary = !content.is_empty() && content[..content.len().min(8192)].contains(&0); if is_binary { "application/octet-stream" } else { "text/plain; charset=utf-8" } } } } struct CommitListItem { hash: String, short_hash: String, subject: String, author: String, relative_time: String, } fn summarize_commits(commits: Vec) -> Vec { commits .into_iter() .map(|commit| CommitListItem { short_hash: commit.hash[..7.min(commit.hash.len())].to_string(), subject: first_line(&commit.message), author: commit.author, relative_time: format_time(commit.commit_time), hash: commit.hash, }) .collect() } // --------------------------------------------------------------------------- // Diff rendering // --------------------------------------------------------------------------- pub(crate) fn render_file_diff(file: &diff::FileDiff) -> String { let status_class = match file.status { diff::DiffStatus::Added => "added", diff::DiffStatus::Deleted => "deleted", diff::DiffStatus::Modified => "modified", }; let status_label = match file.status { diff::DiffStatus::Added => "A", diff::DiffStatus::Deleted => "D", diff::DiffStatus::Modified => "M", }; let mut html = String::new(); html.push_str(r#"
    "#); html.push_str(&format!( r#"
    {label} {path}
    "#, cls = status_class, label = status_label, path = html_escape(&file.path), )); if let Some(hunks) = &file.hunks { for hunk in hunks { // Check for binary marker if hunk.lines.len() == 1 && hunk.lines[0].tag == "binary" { html.push_str(r#"
    Binary files differ
    "#); continue; } html.push_str(&format!( r#"
    @@ -{},{} +{},{} @@
    "#, hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count, )); html.push_str(r#""#); let mut old_ln = hunk.old_start; let mut new_ln = hunk.new_start; for line in &hunk.lines { let (class, prefix, oln, nln) = match line.tag { "add" => { let n = new_ln; new_ln += 1; ("diff-line-add", "+", String::new(), n.to_string()) } "delete" => { let o = old_ln; old_ln += 1; ("diff-line-del", "-", o.to_string(), String::new()) } _ => { let o = old_ln; let n = new_ln; old_ln += 1; new_ln += 1; ("diff-line-ctx", " ", o.to_string(), n.to_string()) } }; // Strip trailing newline for display let content = line.content.trim_end_matches('\n'); html.push_str(&format!( r#""#, cls = class, oln = oln, nln = nln, prefix = prefix, content = html_escape(content), )); } html.push_str("
    {oln}{nln}{prefix}{content}
    "); } } html.push_str("
    "); html } // --------------------------------------------------------------------------- // Commit list rendering (shared by home + log) // --------------------------------------------------------------------------- fn render_commit_list( commits: &[CommitListItem], owner: &str, repo_name: &str, show_author: bool, ) -> String { let mut html = String::new(); html.push_str(r#"
      "#); for commit in commits { let commit_path = format!("/{}/{}/commit/{}", owner, repo_name, commit.hash); html.push_str(&format!( r#"
    • {short} {msg} {author} {time}
    • "#, commit_path = html_escape(&commit_path), short = html_escape(&commit.short_hash), msg = html_escape(&commit.subject), author = if !show_author || commit.author.is_empty() { String::new() } else { format!( r#"{}"#, html_escape(&commit.author) ) }, time = html_escape(&commit.relative_time), )); } html.push_str("
    "); html } // --------------------------------------------------------------------------- // Data loading helpers // --------------------------------------------------------------------------- struct SortedEntry { name: String, hash: String, is_tree: bool, } fn load_sorted_tree(sql: &SqlStorage, tree_hash: &str) -> Result> { let entries = store::load_tree_from_db(sql, tree_hash)?; let mut dirs: Vec = Vec::new(); let mut files: Vec = Vec::new(); for e in entries { let se = SortedEntry { name: e.name, hash: e.hash, is_tree: e.mode == 0o040000, }; if se.is_tree { dirs.push(se); } else { files.push(se); } } dirs.sort_by(|a, b| a.name.cmp(&b.name)); files.sort_by(|a, b| a.name.cmp(&b.name)); dirs.append(&mut files); Ok(dirs) } fn load_tree_for_commit(sql: &SqlStorage, commit_hash: &str) -> Result { #[derive(serde::Deserialize)] struct Row { tree_hash: String, } let rows: Vec = sql .exec( "SELECT tree_hash FROM commits WHERE hash = ?", vec![SqlStorageValue::from(commit_hash.to_string())], )? .to_array()?; rows.into_iter() .next() .map(|r| r.tree_hash) .ok_or_else(|| Error::RustError(format!("commit not found: {}", commit_hash))) } fn resolve_path_to_tree(sql: &SqlStorage, commit_hash: &str, path: &str) -> Result { let root = load_tree_for_commit(sql, commit_hash)?; if path.is_empty() { return Ok(root); } let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); let mut current = root; for segment in &segments { let entry = find_tree_entry(sql, ¤t, segment)?; match entry { Some((hash, mode)) if mode == 0o040000 => { current = hash; } Some(_) => { return Err(Error::RustError(format!( "'{}' is not a directory", segment ))); } None => { return Err(Error::RustError(format!("'{}' not found in tree", segment))); } } } Ok(current) } fn resolve_path_to_blob(sql: &SqlStorage, commit_hash: &str, path: &str) -> Result { let root = load_tree_for_commit(sql, commit_hash)?; let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); if segments.is_empty() { return Err(Error::RustError("empty path".into())); } let mut current_tree = root; for (i, segment) in segments.iter().enumerate() { let entry = find_tree_entry(sql, ¤t_tree, segment)?; match entry { Some((hash, mode)) => { if i < segments.len() - 1 { if mode != 0o040000 { return Err(Error::RustError(format!( "'{}' is not a directory", segment ))); } current_tree = hash; } else { // Last segment — should be a blob return Ok(hash); } } None => { return Err(Error::RustError(format!("'{}' not found", segment))); } } } Err(Error::RustError("path resolution failed".into())) } fn find_tree_entry(sql: &SqlStorage, tree_hash: &str, name: &str) -> Result> { #[derive(serde::Deserialize)] struct Row { entry_hash: String, mode: i64, } let rows: Vec = sql .exec( "SELECT entry_hash, mode FROM trees WHERE tree_hash = ? AND name = ?", vec![ SqlStorageValue::from(tree_hash.to_string()), SqlStorageValue::from(name.to_string()), ], )? .to_array()?; Ok(rows .into_iter() .next() .map(|r| (r.entry_hash, r.mode as u32))) } fn load_blob(sql: &SqlStorage, blob_hash: &str) -> Result> { #[derive(serde::Deserialize)] struct BlobInfo { group_id: i64, version_in_group: i64, } let rows: Vec = sql .exec( "SELECT group_id, version_in_group FROM blobs WHERE blob_hash = ?", vec![SqlStorageValue::from(blob_hash.to_string())], )? .to_array()?; match rows.into_iter().next() { Some(info) => store::reconstruct_blob(sql, info.group_id, info.version_in_group), None => Err(Error::RustError(format!("blob not found: {}", blob_hash))), } } struct CommitMeta { hash: String, author: String, author_email: String, commit_time: i64, message: String, } fn load_commit_meta(sql: &SqlStorage, hash: &str) -> Result { #[derive(serde::Deserialize)] struct Row { author: String, author_email: String, commit_time: i64, message: String, } let rows: Vec = sql .exec( "SELECT author, author_email, commit_time, message FROM commits WHERE hash = ?", vec![SqlStorageValue::from(hash.to_string())], )? .to_array()?; let row = rows .into_iter() .next() .ok_or_else(|| Error::RustError(format!("commit not found: {}", hash)))?; Ok(CommitMeta { hash: hash.to_string(), author: row.author, author_email: row.author_email, commit_time: row.commit_time, message: row.message, }) } fn walk_commits(sql: &SqlStorage, head: &str, limit: i64, offset: i64) -> Result> { let mut result = Vec::new(); let mut current = Some(head.to_string()); let mut skipped = 0i64; while let Some(hash) = current { if result.len() as i64 >= limit { break; } let meta = match load_commit_meta_opt(sql, &hash)? { Some(m) => m, None => break, }; // Get first parent for next iteration #[derive(serde::Deserialize)] struct ParentRow { parent_hash: String, } let parents: Vec = sql .exec( "SELECT parent_hash FROM commit_parents WHERE commit_hash = ? ORDER BY ordinal ASC LIMIT 1", vec![SqlStorageValue::from(hash.clone())], )? .to_array()?; current = parents.into_iter().next().map(|p| p.parent_hash); if skipped < offset { skipped += 1; continue; } result.push(meta); } Ok(result) } fn load_commit_meta_opt(sql: &SqlStorage, hash: &str) -> Result> { #[derive(serde::Deserialize)] struct Row { author: String, author_email: String, commit_time: i64, message: String, } let rows: Vec = sql .exec( "SELECT author, author_email, commit_time, message FROM commits WHERE hash = ?", vec![SqlStorageValue::from(hash.to_string())], )? .to_array()?; Ok(rows.into_iter().next().map(|row| CommitMeta { hash: hash.to_string(), author: row.author, author_email: row.author_email, commit_time: row.commit_time, message: row.message, })) } pub(crate) fn resolve_default_branch(sql: &SqlStorage) -> Result<(String, Option)> { // Try main first, then the first ref we find if let Some(hash) = api::resolve_ref(sql, "main")? { return Ok(("main".to_string(), Some(hash))); } if let Some(hash) = api::resolve_ref(sql, "master")? { return Ok(("master".to_string(), Some(hash))); } // Fall back to first branch #[derive(serde::Deserialize)] struct Row { name: String, commit_hash: String, } let rows: Vec = sql .exec( "SELECT name, commit_hash FROM refs WHERE name LIKE 'refs/heads/%' LIMIT 1", None, )? .to_array()?; match rows.into_iter().next() { Some(r) => { let branch = r .name .strip_prefix("refs/heads/") .unwrap_or(&r.name) .to_string(); Ok((branch, Some(r.commit_hash))) } None => Ok(("main".to_string(), None)), } } fn load_branches(sql: &SqlStorage) -> Result> { #[derive(serde::Deserialize)] struct Row { name: String, } let rows: Vec = sql .exec( "SELECT name FROM refs WHERE name LIKE 'refs/heads/%' ORDER BY name", None, )? .to_array()?; Ok(rows .into_iter() .map(|r| { r.name .strip_prefix("refs/heads/") .unwrap_or(&r.name) .to_string() }) .collect()) } // --------------------------------------------------------------------------- // Markdown renderer // --------------------------------------------------------------------------- pub(crate) fn render_markdown(text: &str) -> String { render_markdown_with_context(text, None) } pub(crate) fn render_repo_markdown( sql: &SqlStorage, text: &str, owner: &str, repo_name: &str, ref_name: &str, commit_hash: &str, source_path: &str, ) -> String { let context = RepoMarkdownContext { sql, owner, repo_name, ref_name, commit_hash, source_path, }; render_markdown_with_context(text, Some(&context)) } struct RepoMarkdownContext<'a> { sql: &'a SqlStorage, owner: &'a str, repo_name: &'a str, ref_name: &'a str, commit_hash: &'a str, source_path: &'a str, } enum RepoMarkdownTargetKind { Tree, Blob, } fn render_markdown_with_context(text: &str, context: Option<&RepoMarkdownContext<'_>>) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); options.insert(Options::ENABLE_FOOTNOTES); options.insert(Options::ENABLE_STRIKETHROUGH); options.insert(Options::ENABLE_TASKLISTS); options.insert(Options::ENABLE_SMART_PUNCTUATION); let parser = Parser::new_ext(text, options).map(|event| sanitize_markdown_event(event, context)); let mut output = String::new(); html::push_html(&mut output, parser); output } fn sanitize_markdown_event<'a>( event: Event<'a>, context: Option<&RepoMarkdownContext<'_>>, ) -> Event<'a> { match event { Event::Start(tag) => Event::Start(sanitize_markdown_tag(tag, context)), Event::Html(raw) | Event::InlineHtml(raw) => Event::Text(raw), _ => event, } } fn sanitize_markdown_tag<'a>(tag: Tag<'a>, context: Option<&RepoMarkdownContext<'_>>) -> Tag<'a> { match tag { Tag::Link { link_type, dest_url, title, id, } => Tag::Link { link_type, dest_url: sanitize_markdown_url(rewrite_repo_markdown_url(dest_url, false, context)), title, id, }, Tag::Image { link_type, dest_url, title, id, } => Tag::Image { link_type, dest_url: sanitize_markdown_url(rewrite_repo_markdown_url(dest_url, true, context)), title, id, }, _ => tag, } } fn rewrite_repo_markdown_url<'a>( url: CowStr<'a>, is_image: bool, context: Option<&RepoMarkdownContext<'_>>, ) -> CowStr<'a> { let Some(context) = context else { return url; }; let raw = url.as_ref().trim(); if !is_repo_relative_markdown_url(raw) { return url; } let (path_part, suffix) = split_markdown_destination(raw); let normalized = normalize_repo_relative_path(context.source_path, path_part); if normalized.is_empty() { return CowStr::from(format!( "/{}/{}/?ref={}{}", context.owner, context.repo_name, context.ref_name, suffix )); } let base = if is_image { format!( "/{}/{}/raw/{}/{}", context.owner, context.repo_name, context.ref_name, normalized ) } else { match repo_markdown_target_kind(context.sql, context.commit_hash, &normalized) { Ok(Some(RepoMarkdownTargetKind::Tree)) => format!( "/{}/{}/tree/{}/{}", context.owner, context.repo_name, context.ref_name, normalized ), Ok(Some(RepoMarkdownTargetKind::Blob)) | Ok(None) | Err(_) => format!( "/{}/{}/blob/{}/{}", context.owner, context.repo_name, context.ref_name, normalized ), } }; CowStr::from(format!("{}{}", base, suffix)) } fn is_repo_relative_markdown_url(url: &str) -> bool { let trimmed = url.trim(); if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('/') || trimmed.starts_with('?') { return false; } let Some((scheme, _)) = trimmed.split_once(':') else { return true; }; !scheme .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) } fn split_markdown_destination(url: &str) -> (&str, &str) { for (idx, ch) in url.char_indices() { if matches!(ch, '?' | '#') { return (&url[..idx], &url[idx..]); } } (url, "") } fn normalize_repo_relative_path(source_path: &str, target: &str) -> String { let base_dir = source_path .rsplit_once('/') .map(|(dir, _)| dir) .unwrap_or(""); let mut segments: Vec<&str> = if target.starts_with('/') || base_dir.is_empty() { Vec::new() } else { base_dir .split('/') .filter(|segment| !segment.is_empty()) .collect() }; for segment in target.split('/') { match segment { "" | "." => {} ".." => { segments.pop(); } _ => segments.push(segment), } } segments.join("/") } fn repo_markdown_target_kind( sql: &SqlStorage, commit_hash: &str, path: &str, ) -> Result> { if path.is_empty() { return Ok(Some(RepoMarkdownTargetKind::Tree)); } let mut current_tree = load_tree_for_commit(sql, commit_hash)?; let segments: Vec<&str> = path .split('/') .filter(|segment| !segment.is_empty()) .collect(); for (idx, segment) in segments.iter().enumerate() { let Some((hash, mode)) = find_tree_entry(sql, ¤t_tree, segment)? else { return Ok(None); }; let is_last = idx == segments.len() - 1; if is_last { return Ok(Some(if mode == 0o040000 { RepoMarkdownTargetKind::Tree } else { RepoMarkdownTargetKind::Blob })); } if mode != 0o040000 { return Ok(None); } current_tree = hash; } Ok(None) } fn sanitize_markdown_url(url: CowStr<'_>) -> CowStr<'_> { if is_safe_markdown_url(url.as_ref()) { url } else { CowStr::from("#") } } fn is_safe_markdown_url(url: &str) -> bool { let trimmed = url.trim(); if trimmed.is_empty() { return true; } if trimmed.starts_with('#') || trimmed.starts_with('/') || trimmed.starts_with("./") || trimmed.starts_with("../") || trimmed.starts_with('?') { return true; } let Some((scheme, _)) = trimmed.split_once(':') else { return true; }; if !scheme .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) { return true; } matches!( scheme.to_ascii_lowercase().as_str(), "http" | "https" | "mailto" ) } #[cfg(test)] mod markdown_tests { use super::{ is_repo_relative_markdown_url, normalize_repo_relative_path, render_markdown, split_markdown_destination, }; #[test] fn render_markdown_preserves_unicode() { let html = render_markdown("café 😅 — 你好"); assert!(html.contains("café 😅 — 你好")); } #[test] fn render_markdown_supports_common_gfm_features() { let html = render_markdown( "| a | b |\n| - | - |\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n\n```rust\nfn main() {}\n```", ); assert!(html.contains("")); assert!(html.contains("checkbox")); assert!(html.contains("language-rust")); } #[test] fn render_markdown_escapes_raw_html_and_unsafe_links() { let html = render_markdown("\n\n[bad](javascript:alert(1))"); assert!(!html.contains("