samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
286 lines· 17.5 KB ·Markdown
1# Web interface
2
3The 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
21All 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
71Any other path returns the 404 page (`AppError::NotFound`, via the router's fallback).
72
73## Request pipeline
74
75```
76request ─▶ 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
811. **Static assets.** Requests under `/static/` and for `/favicon.svg` pass straight through.
822. **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.
883. **Session.** Hashes the session cookie and loads the user, except on git HTTP paths.
894. **Flash.** Decodes the flash cookie.
905. **Context.** Puts `Ctx { user, flash, path, session_hash }` into the request extensions. Handlers receive it with the `Ctx` extractor.
91
92After the handler, it does the following:
93
941. **Error pages.** If the response carries an `ErrorPage` extension (see below), the body is replaced with the rendered `error.html`, keeping the status.
952. **Flash clearing.** Clears the flash cookie if one was shown and the response doesn't set a new one.
963. **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`.
974. **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
102pub 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
130Any 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
134URLs 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
1361. 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.
1392. If none matches, and the first segment is 4–40 hex characters that resolve to a commit, treat it as a commit SHA.
1403. Otherwise, return 404.
141
142Consequences:
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
171The 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
187The 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
195Every 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
2471. **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`.
2482. **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
255When 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| `![logo](img/logo.png)` | `/owner/repo/raw/main/docs/img/logo.png` |
261| `[root](/LICENSE)` | `/owner/repo/blob/main/LICENSE` |
262| `https://…`, `mailto:…`, `#anchor`, `//host/…` | Unchanged |
263
264Pull 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`.