Web interface
The web interface is server-rendered HTML: axum handlers fill askama templates, and a small optional script adds conveniences. This page covers:
- the routes;
- what happens to every request;
- how handlers are structured;
- how code, history and diffs are turned into pages, including syntax highlighting and Markdown.
Source files:
src/web/mod.rs: router and helpers.src/web/ctx.rs: middleware.src/web/error.rs: error handling.src/web/{account,home,repo,code,pulls}.rs: page handlers.src/web/fmt.rs: template helpers.src/highlight.rs,src/markdown.rs: content rendering.templates/,static/: templates and static assets.
Routes
All routes are defined in web::router. "Read", "write" and "admin" refer to the permission model. "User" means a signed-in user is required; anonymous visitors are redirected to /login?next=….
Site and accounts
| Method | Path | Handler | Access |
|---|---|---|---|
| GET | / |
home::index |
Anyone. Landing page when signed out, dashboard when signed in |
| GET | /explore |
home::explore |
Anyone. Up to 200 public repositories, most recently pushed first |
| GET, POST | /login |
account::login_form, account::login |
Anyone |
| GET, POST | /register |
account::register_form, account::register |
Anyone, while registration is allowed |
| POST | /logout |
account::logout |
User |
| GET, POST | /new |
repo::new_form, repo::create |
User |
| GET | /settings |
account::settings_redirect |
Redirects to /settings/profile |
| GET, POST | /settings/profile |
account::profile_form, account::update_profile |
User |
| POST | /settings/password |
account::change_password |
User |
| GET, POST | /settings/keys |
account::keys, account::add_key |
User |
| POST | /settings/keys/{id}/delete |
account::delete_key |
User (own keys only) |
| GET | /static/style.css, /static/app.js, /favicon.svg |
assets::* |
Anyone |
| GET | /{owner} |
home::profile |
Anyone. Lists the repositories the viewer may see |
Repository pages
| Method | Path | Handler | Access |
|---|---|---|---|
| GET | /{owner}/{repo} |
code::repo_home |
Read. /{owner}/{repo}.git redirects here |
| GET | /{owner}/{repo}/tree/{ref}/{path…} |
code::tree |
Read. Redirects to blob if the path is a file |
| GET | /{owner}/{repo}/blob/{ref}/{path…} |
code::blob |
Read. ?plain=1 shows Markdown as source |
| GET | /{owner}/{repo}/raw/{ref}/{path…} |
code::raw |
Read |
| GET | /{owner}/{repo}/archive/{ref}.zip or .tar.gz |
code::archive |
Read |
| GET | /{owner}/{repo}/commits |
code::commits_default |
Read. Redirects to the default branch |
| GET | /{owner}/{repo}/commits/{ref}/{path…} |
code::commits |
Read. ?page=N |
| GET | /{owner}/{repo}/commit/{sha} |
code::commit |
Read. 4–40 hex characters |
| GET | /{owner}/{repo}/branches |
repo::branches |
Read |
| POST | /{owner}/{repo}/branches/delete |
repo::delete_branch |
Write |
| GET | /{owner}/{repo}/tags |
repo::tags |
Read |
| GET | /{owner}/{repo}/compare and /compare/{base}...{head} |
pulls::compare_default, pulls::compare |
Read |
| GET, POST | /{owner}/{repo}/pulls |
pulls::list, pulls::create |
Read; creating needs a user. ?state=closed lists closed and merged |
| GET | /{owner}/{repo}/pull/{n} and /commits, /files |
pulls::conversation, pulls::commits, pulls::files |
Read |
| POST | /{owner}/{repo}/pull/{n}/comment |
pulls::comment |
User plus read |
| POST | /{owner}/{repo}/pull/{n}/state |
pulls::set_state |
Author or write |
| POST | /{owner}/{repo}/pull/{n}/edit |
pulls::edit |
Author or write |
| POST | /{owner}/{repo}/pull/{n}/merge |
pulls::merge |
Write |
| GET, POST | /{owner}/{repo}/settings |
repo::settings, repo::update_settings |
Admin |
| POST | /{owner}/{repo}/settings/collaborators and /collaborators/remove |
repo::add_collaborator, repo::remove_collaborator |
Admin |
| POST | /{owner}/{repo}/settings/delete |
repo::delete |
Admin |
Git over HTTP
/{owner}/{repo}/info/refs, /git-upload-pack and /git-receive-pack are described in Git transport.
Any other path returns the 404 page (AppError::NotFound, via the router's fallback).
Request pipeline
request ─▶ TraceLayer ─▶ ctx::middleware ─▶ route handler ─▶ ctx::middleware (response part) ─▶ client
ctx::middleware runs for every request. Before the handler, it does the following:
-
Static assets. Requests under
/static/and for/favicon.svgpass straight through. -
Cross-origin check. Every request except GET, HEAD and OPTIONS, other than git HTTP paths, must have either:
- no
Originheader; or - an
Originequal to the origin of--public-url; or - an
Originwhose host matches theHostheader.
Anything else, including
Origin: null, gets403 Cross-origin request rejected. This backs upSameSite=Laxcookies against CSRF. - no
-
Session. Hashes the session cookie and loads the user, except on git HTTP paths.
-
Flash. Decodes the flash cookie.
-
Context. Puts
Ctx { user, flash, path, session_hash }into the request extensions. Handlers receive it with theCtxextractor.
After the handler, it does the following:
- Error pages. If the response carries an
ErrorPageextension (see below), the body is replaced with the renderederror.html, keeping the status. - Flash clearing. Clears the flash cookie if one was shown and the response doesn't set a new one.
- HTML headers. On HTML responses, adds
Content-Security-Policy: default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'andCache-Control: no-store. - Global headers. On every response, adds
X-Content-Type-Options: nosniffandReferrer-Policy: strict-origin-when-cross-origin.
Handler shape
pub async fn branches(
State(state): State<AppState>,
ctx: Ctx,
Path((owner, name)): Path<(String, String)>,
) -> WebResult {
let r = load_repo(&state, &ctx, &owner, &name).await?; // async: DB lookup and access check (404 if unreadable)
let path = r.path.clone();
let default = r.repo.default_branch.clone();
let list = blocking(move || Ok(git::branches(&git::open(&path)?, &default)?)).await?; // libgit2 work off the async runtime
/* … more async DB work … */
render(&BranchesPage { ctx, r, tab: "branches", rows }) // askama template → HTML response
}
WebResultisResult<Response, AppError>.RepoCtx(fromload_repo) carries the repository row, the viewer'sAccess, the path on disk, the open pull request count and the clone URLs. Every repository template uses it.- Mutations are
POSTforms that end withredirectorredirect_flash(303 See Other, the post/redirect/get pattern). When validation fails, the handler re-renders the form with its values and an error message instead.
Errors
AppError |
Status | Shown as |
|---|---|---|
NotFound |
404 | "Page not found". Also used for repositories the viewer can't read |
Forbidden(msg) |
403 | "Access denied" with the message |
BadRequest(msg) |
400 | "Bad request" with the message |
LoginRequired(path) |
303 | Redirect to /login?next=<path> |
Internal(err) |
500 | "Something went wrong". The error is logged with its full chain |
Any error type that converts to anyhow::Error, such as sqlx::Error or git2::Error, becomes Internal through ?.
Refs and paths in URLs
URLs such as /tree/feature/login/src/main.rs don't mark where the ref ends and the path begins. Branch names can contain /. git::resolve splits the URL tail as follows:
- Split the tail on
/. For prefixes of growing length (feature, thenfeature/login, then …):- try
refs/heads/<prefix>, thenrefs/tags/<prefix>; - stop at the first one that exists and points to a commit.
- try
- If none matches, and the first segment is 4–40 hex characters that resolve to a commit, treat it as a commit SHA.
- Otherwise, return 404.
Consequences:
- The shortest matching name wins. With branches
featureandfeature/login,/tree/feature/loginmeans theloginpath on branchfeature. git normally refuses to create both of these branches together, so this is rarely a problem. - A branch shadows a tag with the same name.
- Building URLs. Always build ref and path URLs with
fmt::urlpath(or{{ crate::web::fmt::urlpath(…) }}in templates). It percent-encodes spaces,#,?and non-ASCII characters but keeps/. Usefmt::urlencodefor query-string values.
Code browsing
Repository home (code::repo_home)
- Empty repositories (no branches) render
repo/empty.html, with push instructions. - Ref shown. The page shows the default branch, or the first branch alphabetically if the default doesn't exist.
- File list. The root listing shows each entry with the last commit that changed it (see Tree below).
- README. The page renders the first file in the directory that matches, case-insensitively,
readme.md,readme.markdown,readme,readme.txtorreadme.rst, if it is under 1 MB and not binary. Markdown is rendered; other formats are shown as escaped text. - Sidebar counts.
- Commits:
git::commit_count, a full walk of history. - Branches and tags.
- Open pull requests.
- Commits:
- "Recent pushes" banner. Offers Compare & pull request for the non-default branch with the newest commit, if that commit is less than an hour old, the branch has no open pull request, and the viewer has write access.
Tree (code::tree, git::list_tree)
- Directories are listed first, then files, each group sorted case-insensitively.
- Entries are marked as directories, files, symlinks (mode
120000) or submodules (commit entries). - Last commit per entry (
git::last_commits): Openhub walks history newest first from the ref. An entry is attributed to the first commit whose entry ID differs from all of its parents'. The walk stops when every entry is found, or after 5,000 commits. Entries not found by then show no commit. - A parent-directory link and breadcrumbs are shown below the root. The README of the current directory is rendered too.
File view (code::blob)
The content is chosen in this order:
| Condition | Content |
|---|---|
Extension is .png, .jpg, .jpeg, .gif, .webp, .bmp, .ico or .svg |
<img> pointing at the raw URL |
| libgit2 says the blob is binary | "Binary file not shown", with a raw link |
Larger than 1 MB (MAX_DISPLAY_BYTES) |
"This file is too large to display", with a raw link |
.md or .markdown, without ?plain=1 |
Rendered Markdown, with relative links rewritten |
| Anything else | Highlighted source with line numbers |
- Line numbers are links to
#L<n>. - Line highlighting.
static/app.jshighlights#L10or#L10-L20. Shift-click extends the selection. - Header. Shows the size, the line count, the detected language and the last commit that touched the file.
Raw files (code::raw)
The headers depend on the file:
| File | Content-Type |
Content-Disposition |
|---|---|---|
Image (by extension, via mime_guess) |
The image's MIME type, including image/svg+xml |
inline |
| Other text | text/plain; charset=utf-8, never text/html |
inline |
| Binary | application/octet-stream |
attachment |
Every raw response also sends Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox, so even an SVG opened directly can't run script on the Openhub origin.
History (code::commits, git::log)
- Pagination: 30 commits per page, sorted by commit time.
?page=Nmoves through pages, with Newer and Older links. - Path filtering. A path filter keeps only commits whose tree entry at that path differs from every parent's. This resembles
git log -- <path>with history simplification, but doesn't follow renames. - Grouping: commits are grouped by committer date (UTC).
Commit page (code::commit)
- Shows the commit's metadata and its diff against the first parent. Merge commits therefore show what the merge brought into the first parent's line.
- A root commit is diffed against an empty tree.
- Parent links go to their commit pages.
Branches and tags
- Branches (
git::branches):- The default branch comes first, then branches ordered by newest tip commit.
- Each branch shows ahead and behind counts against the default branch (
graph_ahead_behind), its open pull request, and (for writers) a delete button. The default branch can't be deleted.
- Tags (
git::tags):- Sorted in natural order, newest version first, so
v1.10comes beforev1.9. - Each shows the tagged commit, plus the message for annotated tags.
- Sorted in natural order, newest version first, so
Diffs
git::diff_trees turns a libgit2 tree-to-tree diff into a DiffSummary. The commit page, compare page and pull request files tab all use it, and render it with templates/partials/diff.html.
- Context: three lines. Submodule changes are ignored. Renames are detected (
find_similar) and shown asold → new. - Per-file status:
added,deleted,renamed,copiedormodified, with addition and deletion counts and a five-block summary bar (FileDiff::stat_blocks). - Size caps:
- A file with more than 3,000 changed lines isn't rendered.
- After 20,000 rendered lines in total, later files aren't rendered either.
- Files skipped by these caps show a "too large" placeholder but still count toward the totals.
- Binary files show a placeholder.
- Highlighting: each hunk's lines are highlighted as one sequence, using the file's language.
Syntax highlighting
src/highlight.rs uses syntect's parser with the extended syntax set from two-face. It doesn't use a syntect theme. Instead:
- Language detection (
find_syntax): by file extension, then the whole file name (for files such asMakefileorDockerfile), then the first line (shebangs). "Plain Text" counts as unknown. - Scope mapping. Scope stacks are mapped to a small set of classes, such as
tok-kfor keywords,tok-sfor strings andtok-cfor comments (the table isSCOPE_CLASSES). Colours are the--tok-*CSS tokens instatic/style.css, so highlighting follows the light and dark themes. - Self-contained lines. Every line is rendered on its own, with all spans closed at the end of the line. Code views and diffs can then put each line in its own table row. Parser state still carries across lines, so multi-line comments and strings are coloured correctly.
- Limits:
- Input over 512 KB (
MAX_HIGHLIGHT_BYTES) is escaped but not highlighted. - If the parser fails, the rest of the file falls back to escaped text.
- Input over 512 KB (
- Output safety. All text is HTML-escaped. Only
<span class="tok-…">tags are produced.
Markdown
markdown::render(source, link_base) renders untrusted Markdown in two stages:
-
comrak renders GitHub-flavoured Markdown: tables, strikethrough, autolinks, task lists and footnotes. Raw HTML is allowed through at this stage. Fenced code blocks with a language are highlighted with
highlight::highlight_block. -
ammonia sanitises the result. On top of its defaults, it allows:
<input type checked disabled>, for task lists;width,heightandalignon images, andaligneverywhere;- the
tok-*classes on<span>.
It strips scripts, event handlers,
styleattributes andjavascript:URLs. Links getrel="noopener noreferrer nofollow".
When a LinkBase is given (READMEs and Markdown file views), relative URLs are rewritten:
In the document docs/guide.md on main |
Becomes |
|---|---|
[x](../src/lib.rs#L3) |
/owner/repo/blob/main/src/lib.rs#L3 |
 |
/owner/repo/raw/main/docs/img/logo.png |
[root](/LICENSE) |
/owner/repo/blob/main/LICENSE |
https://…, mailto:…, #anchor, //host/… |
Unchanged |
Pull request descriptions and comments are rendered without a link base.
Templates and assets
- Templates. Layouts, partials, helper calls and askama syntax are documented in
templates/CLAUDE.md. Templates compile into the binary, so a template mistake is a build error. - Static assets.
static/style.css,static/app.jsandstatic/favicon.svgare embedded withinclude_str!and served withCache-Control: public, max-age=3600.- Pages link them as
?v=<hash>, where the hash comes fromassets::version()(a hash of the CSS and JS), so browsers fetch new versions after an upgrade. - Editing these files needs a rebuild.
- JavaScript is optional.
app.jsattaches behaviour todata-*attributes;CLAUDE.mdlists the hooks. The page CSP rules out inline scripts andon*=attributes. - Theming. Light and dark themes follow
prefers-color-scheme. All colours are CSS custom properties on:root.
When you change this
- New page:
- Add the route in
web::routerand the table above. - Load repositories through
load_repo. - Use
blockingfor libgit2 calls. - Add a template that extends
base.htmlor a layout. - Check the page in light and dark themes and at 390 px width.
- Add the route in
- New top-level path segment: add it to
RESERVEDinsrc/validate.rs. - New limit or cap: add it to this page and to Security if it protects against abuse.
- New Markdown feature: check that ammonia's allow-list still strips anything that could run script, and extend the unit tests in
src/markdown.rs.