Sam Jeston
History
| 1 | # Web interface |
| 2 | |
| 3 | The web interface is server-rendered HTML: axum handlers fill askama templates, and a small optional script adds conveniences. This page covers: |
| 4 | |
| 5 | - the routes; |
| 6 | - what happens to every request; |
| 7 | - how handlers are structured; |
| 8 | - how code, history and diffs are turned into pages, including syntax highlighting and Markdown. |
| 9 | |
| 10 | **Source files:** |
| 11 | - `src/web/mod.rs`: router and helpers. |
| 12 | - `src/web/ctx.rs`: middleware. |
| 13 | - `src/web/error.rs`: error handling. |
| 14 | - `src/web/{account,home,repo,code,pulls}.rs`: page handlers. |
| 15 | - `src/web/fmt.rs`: template helpers. |
| 16 | - `src/highlight.rs`, `src/markdown.rs`: content rendering. |
| 17 | - `templates/`, `static/`: templates and static assets. |
| 18 | |
| 19 | ## Routes |
| 20 | |
| 21 | All routes are defined in `web::router`. "Read", "write" and "admin" refer to the [permission model](accounts-and-access.md#permission-model). "User" means a signed-in user is required; anonymous visitors are redirected to `/login?next=…`. |
| 22 | |
| 23 | ### Site and accounts |
| 24 | |
| 25 | | Method | Path | Handler | Access | |
| 26 | | --- | --- | --- | --- | |
| 27 | | GET | `/` | `home::index` | Anyone. Landing page when signed out, dashboard when signed in | |
| 28 | | GET | `/explore` | `home::explore` | Anyone. Up to 200 public repositories, most recently pushed first | |
| 29 | | GET, POST | `/login` | `account::login_form`, `account::login` | Anyone | |
| 30 | | GET, POST | `/register` | `account::register_form`, `account::register` | Anyone, while registration is allowed | |
| 31 | | POST | `/logout` | `account::logout` | User | |
| 32 | | GET, POST | `/new` | `repo::new_form`, `repo::create` | User | |
| 33 | | GET | `/settings` | `account::settings_redirect` | Redirects to `/settings/profile` | |
| 34 | | GET, POST | `/settings/profile` | `account::profile_form`, `account::update_profile` | User | |
| 35 | | POST | `/settings/password` | `account::change_password` | User | |
| 36 | | GET, POST | `/settings/keys` | `account::keys`, `account::add_key` | User | |
| 37 | | POST | `/settings/keys/{id}/delete` | `account::delete_key` | User (own keys only) | |
| 38 | | GET | `/static/style.css`, `/static/app.js`, `/favicon.svg` | `assets::*` | Anyone | |
| 39 | | GET | `/{owner}` | `home::profile` | Anyone. Lists the repositories the viewer may see | |
| 40 | |
| 41 | ### Repository pages |
| 42 | |
| 43 | | Method | Path | Handler | Access | |
| 44 | | --- | --- | --- | --- | |
| 45 | | GET | `/{owner}/{repo}` | `code::repo_home` | Read. `/{owner}/{repo}.git` redirects here | |
| 46 | | GET | `/{owner}/{repo}/tree/{ref}/{path…}` | `code::tree` | Read. Redirects to `blob` if the path is a file | |
| 47 | | GET | `/{owner}/{repo}/blob/{ref}/{path…}` | `code::blob` | Read. `?plain=1` shows Markdown as source | |
| 48 | | GET | `/{owner}/{repo}/raw/{ref}/{path…}` | `code::raw` | Read | |
| 49 | | GET | `/{owner}/{repo}/archive/{ref}.zip` or `.tar.gz` | `code::archive` | Read | |
| 50 | | GET | `/{owner}/{repo}/commits` | `code::commits_default` | Read. Redirects to the default branch | |
| 51 | | GET | `/{owner}/{repo}/commits/{ref}/{path…}` | `code::commits` | Read. `?page=N` | |
| 52 | | GET | `/{owner}/{repo}/commit/{sha}` | `code::commit` | Read. 4–40 hex characters | |
| 53 | | GET | `/{owner}/{repo}/branches` | `repo::branches` | Read | |
| 54 | | POST | `/{owner}/{repo}/branches/delete` | `repo::delete_branch` | Write | |
| 55 | | GET | `/{owner}/{repo}/tags` | `repo::tags` | Read | |
| 56 | | GET | `/{owner}/{repo}/compare` and `/compare/{base}...{head}` | `pulls::compare_default`, `pulls::compare` | Read | |
| 57 | | GET, POST | `/{owner}/{repo}/pulls` | `pulls::list`, `pulls::create` | Read; creating needs a user. `?state=closed` lists closed and merged | |
| 58 | | GET | `/{owner}/{repo}/pull/{n}` and `/commits`, `/files` | `pulls::conversation`, `pulls::commits`, `pulls::files` | Read | |
| 59 | | POST | `/{owner}/{repo}/pull/{n}/comment` | `pulls::comment` | User plus read | |
| 60 | | POST | `/{owner}/{repo}/pull/{n}/state` | `pulls::set_state` | Author or write | |
| 61 | | POST | `/{owner}/{repo}/pull/{n}/edit` | `pulls::edit` | Author or write | |
| 62 | | POST | `/{owner}/{repo}/pull/{n}/merge` | `pulls::merge` | Write | |
| 63 | | GET, POST | `/{owner}/{repo}/settings` | `repo::settings`, `repo::update_settings` | Admin | |
| 64 | | POST | `/{owner}/{repo}/settings/collaborators` and `/collaborators/remove` | `repo::add_collaborator`, `repo::remove_collaborator` | Admin | |
| 65 | | POST | `/{owner}/{repo}/settings/delete` | `repo::delete` | Admin | |
| 66 | |
| 67 | ### Git over HTTP |
| 68 | |
| 69 | `/{owner}/{repo}/info/refs`, `/git-upload-pack` and `/git-receive-pack` are described in [Git transport](git-transport.md#smart-http-read-only). |
| 70 | |
| 71 | Any other path returns the 404 page (`AppError::NotFound`, via the router's fallback). |
| 72 | |
| 73 | ## Request pipeline |
| 74 | |
| 75 | ``` |
| 76 | request ─▶ TraceLayer ─▶ ctx::middleware ─▶ route handler ─▶ ctx::middleware (response part) ─▶ client |
| 77 | ``` |
| 78 | |
| 79 | `ctx::middleware` runs for every request. Before the handler, it does the following: |
| 80 | |
| 81 | 1. **Static assets.** Requests under `/static/` and for `/favicon.svg` pass straight through. |
| 82 | 2. **Cross-origin check.** Every request except GET, HEAD and OPTIONS, other than git HTTP paths, must have either: |
| 83 | - no `Origin` header; or |
| 84 | - an `Origin` equal to the origin of `--public-url`; or |
| 85 | - an `Origin` whose host matches the `Host` header. |
| 86 | |
| 87 | Anything else, including `Origin: null`, gets `403 Cross-origin request rejected`. This backs up `SameSite=Lax` cookies against CSRF. |
| 88 | 3. **Session.** Hashes the session cookie and loads the user, except on git HTTP paths. |
| 89 | 4. **Flash.** Decodes the flash cookie. |
| 90 | 5. **Context.** Puts `Ctx { user, flash, path, session_hash }` into the request extensions. Handlers receive it with the `Ctx` extractor. |
| 91 | |
| 92 | After the handler, it does the following: |
| 93 | |
| 94 | 1. **Error pages.** If the response carries an `ErrorPage` extension (see below), the body is replaced with the rendered `error.html`, keeping the status. |
| 95 | 2. **Flash clearing.** Clears the flash cookie if one was shown and the response doesn't set a new one. |
| 96 | 3. **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'` and `Cache-Control: no-store`. |
| 97 | 4. **Global headers.** On every response, adds `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`. |
| 98 | |
| 99 | ## Handler shape |
| 100 | |
| 101 | ```rust |
| 102 | pub async fn branches( |
| 103 | State(state): State<AppState>, |
| 104 | ctx: Ctx, |
| 105 | Path((owner, name)): Path<(String, String)>, |
| 106 | ) -> WebResult { |
| 107 | let r = load_repo(&state, &ctx, &owner, &name).await?; // async: DB lookup and access check (404 if unreadable) |
| 108 | let path = r.path.clone(); |
| 109 | let default = r.repo.default_branch.clone(); |
| 110 | let list = blocking(move || Ok(git::branches(&git::open(&path)?, &default)?)).await?; // libgit2 work off the async runtime |
| 111 | /* … more async DB work … */ |
| 112 | render(&BranchesPage { ctx, r, tab: "branches", rows }) // askama template → HTML response |
| 113 | } |
| 114 | ``` |
| 115 | |
| 116 | - `WebResult` is `Result<Response, AppError>`. |
| 117 | - `RepoCtx` (from `load_repo`) carries the repository row, the viewer's `Access`, the path on disk, the open pull request count and the clone URLs. Every repository template uses it. |
| 118 | - **Mutations** are `POST` forms that end with `redirect` or `redirect_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. |
| 119 | |
| 120 | ### Errors |
| 121 | |
| 122 | | `AppError` | Status | Shown as | |
| 123 | | --- | --- | --- | |
| 124 | | `NotFound` | 404 | "Page not found". Also used for repositories the viewer can't read | |
| 125 | | `Forbidden(msg)` | 403 | "Access denied" with the message | |
| 126 | | `BadRequest(msg)` | 400 | "Bad request" with the message | |
| 127 | | `LoginRequired(path)` | 303 | Redirect to `/login?next=<path>` | |
| 128 | | `Internal(err)` | 500 | "Something went wrong". The error is logged with its full chain | |
| 129 | |
| 130 | Any error type that converts to `anyhow::Error`, such as `sqlx::Error` or `git2::Error`, becomes `Internal` through `?`. |
| 131 | |
| 132 | ## Refs and paths in URLs |
| 133 | |
| 134 | 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: |
| 135 | |
| 136 | 1. Split the tail on `/`. For prefixes of growing length (`feature`, then `feature/login`, then …): |
| 137 | - try `refs/heads/<prefix>`, then `refs/tags/<prefix>`; |
| 138 | - stop at the first one that exists and points to a commit. |
| 139 | 2. If none matches, and the first segment is 4–40 hex characters that resolve to a commit, treat it as a commit SHA. |
| 140 | 3. Otherwise, return 404. |
| 141 | |
| 142 | Consequences: |
| 143 | |
| 144 | - **The shortest matching name wins.** With branches `feature` and `feature/login`, `/tree/feature/login` means the `login` path on branch `feature`. git normally refuses to create both of these branches together, so this is rarely a problem. |
| 145 | - **A branch shadows a tag** with the same name. |
| 146 | - **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 `/`. Use `fmt::urlencode` for query-string values. |
| 147 | |
| 148 | ## Code browsing |
| 149 | |
| 150 | ### Repository home (`code::repo_home`) |
| 151 | |
| 152 | - **Empty repositories** (no branches) render `repo/empty.html`, with push instructions. |
| 153 | - **Ref shown.** The page shows the default branch, or the first branch alphabetically if the default doesn't exist. |
| 154 | - **File list.** The root listing shows each entry with the last commit that changed it (see Tree below). |
| 155 | - **README.** The page renders the first file in the directory that matches, case-insensitively, `readme.md`, `readme.markdown`, `readme`, `readme.txt` or `readme.rst`, if it is under 1 MB and not binary. Markdown is rendered; other formats are shown as escaped text. |
| 156 | - **Sidebar counts.** |
| 157 | - Commits: `git::commit_count`, a full walk of history. |
| 158 | - Branches and tags. |
| 159 | - Open pull requests. |
| 160 | - **"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. |
| 161 | |
| 162 | ### Tree (`code::tree`, `git::list_tree`) |
| 163 | |
| 164 | - Directories are listed first, then files, each group sorted case-insensitively. |
| 165 | - Entries are marked as directories, files, symlinks (mode `120000`) or submodules (commit entries). |
| 166 | - **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. |
| 167 | - A parent-directory link and breadcrumbs are shown below the root. The README of the current directory is rendered too. |
| 168 | |
| 169 | ### File view (`code::blob`) |
| 170 | |
| 171 | The content is chosen in this order: |
| 172 | |
| 173 | | Condition | Content | |
| 174 | | --- | --- | |
| 175 | | Extension is `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`, `.ico` or `.svg` | `<img>` pointing at the raw URL | |
| 176 | | libgit2 says the blob is binary | "Binary file not shown", with a raw link | |
| 177 | | Larger than 1 MB (`MAX_DISPLAY_BYTES`) | "This file is too large to display", with a raw link | |
| 178 | | `.md` or `.markdown`, without `?plain=1` | Rendered Markdown, with relative links rewritten | |
| 179 | | Anything else | Highlighted source with line numbers | |
| 180 | |
| 181 | - **Line numbers** are links to `#L<n>`. |
| 182 | - **Line highlighting.** `static/app.js` highlights `#L10` or `#L10-L20`. Shift-click extends the selection. |
| 183 | - **Header.** Shows the size, the line count, the detected language and the last commit that touched the file. |
| 184 | |
| 185 | ### Raw files (`code::raw`) |
| 186 | |
| 187 | The headers depend on the file: |
| 188 | |
| 189 | | File | `Content-Type` | `Content-Disposition` | |
| 190 | | --- | --- | --- | |
| 191 | | Image (by extension, via `mime_guess`) | The image's MIME type, including `image/svg+xml` | `inline` | |
| 192 | | Other text | `text/plain; charset=utf-8`, never `text/html` | `inline` | |
| 193 | | Binary | `application/octet-stream` | `attachment` | |
| 194 | |
| 195 | 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. |
| 196 | |
| 197 | ### History (`code::commits`, `git::log`) |
| 198 | |
| 199 | - **Pagination:** 30 commits per page, sorted by commit time. `?page=N` moves through pages, with Newer and Older links. |
| 200 | - **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. |
| 201 | - **Grouping:** commits are grouped by committer date (UTC). |
| 202 | |
| 203 | ### Commit page (`code::commit`) |
| 204 | |
| 205 | - 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. |
| 206 | - A root commit is diffed against an empty tree. |
| 207 | - Parent links go to their commit pages. |
| 208 | |
| 209 | ### Branches and tags |
| 210 | |
| 211 | - **Branches** (`git::branches`): |
| 212 | - The default branch comes first, then branches ordered by newest tip commit. |
| 213 | - 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. |
| 214 | - **Tags** (`git::tags`): |
| 215 | - Sorted in natural order, newest version first, so `v1.10` comes before `v1.9`. |
| 216 | - Each shows the tagged commit, plus the message for annotated tags. |
| 217 | |
| 218 | ## Diffs |
| 219 | |
| 220 | `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`. |
| 221 | |
| 222 | - **Context:** three lines. Submodule changes are ignored. Renames are detected (`find_similar`) and shown as `old → new`. |
| 223 | - **Per-file status:** `added`, `deleted`, `renamed`, `copied` or `modified`, with addition and deletion counts and a five-block summary bar (`FileDiff::stat_blocks`). |
| 224 | - **Size caps:** |
| 225 | - A file with more than 3,000 changed lines isn't rendered. |
| 226 | - After 20,000 rendered lines in total, later files aren't rendered either. |
| 227 | - Files skipped by these caps show a "too large" placeholder but still count toward the totals. |
| 228 | - **Binary files** show a placeholder. |
| 229 | - **Highlighting:** each hunk's lines are highlighted as one sequence, using the file's language. |
| 230 | |
| 231 | ## Syntax highlighting |
| 232 | |
| 233 | `src/highlight.rs` uses syntect's parser with the extended syntax set from `two-face`. It doesn't use a syntect theme. Instead: |
| 234 | |
| 235 | - **Language detection** (`find_syntax`): by file extension, then the whole file name (for files such as `Makefile` or `Dockerfile`), then the first line (shebangs). "Plain Text" counts as unknown. |
| 236 | - **Scope mapping.** Scope stacks are mapped to a small set of classes, such as `tok-k` for keywords, `tok-s` for strings and `tok-c` for comments (the table is `SCOPE_CLASSES`). Colours are the `--tok-*` CSS tokens in `static/style.css`, so highlighting follows the light and dark themes. |
| 237 | - **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. |
| 238 | - **Limits:** |
| 239 | - Input over 512 KB (`MAX_HIGHLIGHT_BYTES`) is escaped but not highlighted. |
| 240 | - If the parser fails, the rest of the file falls back to escaped text. |
| 241 | - **Output safety.** All text is HTML-escaped. Only `<span class="tok-…">` tags are produced. |
| 242 | |
| 243 | ## Markdown |
| 244 | |
| 245 | `markdown::render(source, link_base)` renders untrusted Markdown in two stages: |
| 246 | |
| 247 | 1. **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`. |
| 248 | 2. **ammonia** sanitises the result. On top of its defaults, it allows: |
| 249 | - `<input type checked disabled>`, for task lists; |
| 250 | - `width`, `height` and `align` on images, and `align` everywhere; |
| 251 | - the `tok-*` classes on `<span>`. |
| 252 | |
| 253 | It strips scripts, event handlers, `style` attributes and `javascript:` URLs. Links get `rel="noopener noreferrer nofollow"`. |
| 254 | |
| 255 | When a `LinkBase` is given (READMEs and Markdown file views), relative URLs are rewritten: |
| 256 | |
| 257 | | In the document `docs/guide.md` on `main` | Becomes | |
| 258 | | --- | --- | |
| 259 | | `[x](../src/lib.rs#L3)` | `/owner/repo/blob/main/src/lib.rs#L3` | |
| 260 | | `` | `/owner/repo/raw/main/docs/img/logo.png` | |
| 261 | | `[root](/LICENSE)` | `/owner/repo/blob/main/LICENSE` | |
| 262 | | `https://…`, `mailto:…`, `#anchor`, `//host/…` | Unchanged | |
| 263 | |
| 264 | Pull request descriptions and comments are rendered without a link base. |
| 265 | |
| 266 | ## Templates and assets |
| 267 | |
| 268 | - **Templates.** Layouts, partials, helper calls and askama syntax are documented in [`templates/CLAUDE.md`](../templates/CLAUDE.md). Templates compile into the binary, so a template mistake is a build error. |
| 269 | - **Static assets.** |
| 270 | - `static/style.css`, `static/app.js` and `static/favicon.svg` are embedded with `include_str!` and served with `Cache-Control: public, max-age=3600`. |
| 271 | - Pages link them as `?v=<hash>`, where the hash comes from `assets::version()` (a hash of the CSS and JS), so browsers fetch new versions after an upgrade. |
| 272 | - Editing these files needs a rebuild. |
| 273 | - **JavaScript** is optional. `app.js` attaches behaviour to `data-*` attributes; [`CLAUDE.md`](../CLAUDE.md) lists the hooks. The page CSP rules out inline scripts and `on*=` attributes. |
| 274 | - **Theming.** Light and dark themes follow `prefers-color-scheme`. All colours are CSS custom properties on `:root`. |
| 275 | |
| 276 | ## When you change this |
| 277 | |
| 278 | - **New page:** |
| 279 | - Add the route in `web::router` and the table above. |
| 280 | - Load repositories through `load_repo`. |
| 281 | - Use `blocking` for libgit2 calls. |
| 282 | - Add a template that extends `base.html` or a layout. |
| 283 | - Check the page in light and dark themes and at 390 px width. |
| 284 | - **New top-level path segment:** add it to `RESERVED` in `src/validate.rs`. |
| 285 | - **New limit or cap:** add it to this page and to [Security](security.md) if it protects against abuse. |
| 286 | - **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`. |