samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
138 lines· 12.1 KB ·Markdown
1# Openhub
2
3A minimal, self-hostable GitHub-style git server: push over SSH, browse code and history, and review pull requests. It is a single Rust binary backed by SQLite. User-facing documentation lives in `README.md`. How the system works (architecture, data model, access control, git transport, pull requests, web interface, security, operations) is documented in `docs/`, indexed at `docs/README.md`. This file covers working on the code.
4
5**Scope is deliberate.** Openhub is git hosting only. Do not add issues, CI, wikis, releases, forks or organisations unless asked. Pushing is SSH-only by design (HTTP is read-only), so don't add HTTP push without discussing it first.
6
7> The parent directory's `CLAUDE.md` is about Bun and JavaScript projects. Openhub has no JavaScript toolchain: `static/app.js` is hand-written and served as-is. That guidance only applies if a JS build step is ever introduced.
8
9## Commands
10
11```sh
12cargo build # also compiles every template; template errors are compile errors
13cargo run -- --data-dir ./data # web on :3000, SSH on :2222 (./data is gitignored)
14cargo test # unit tests (inline #[cfg(test)] modules)
15cargo clippy --all-targets # keep this warning-free
16cargo fmt
17scripts/smoke-test.sh [path/to/binary] # end-to-end test against a throwaway instance
18docker compose up -d # production-style run (data in a named volume)
19```
20
21- The smoke test needs `git`, `ssh`, `ssh-keygen` and `curl`. It uses ports 38080 and 38022; override them with `HTTP_PORT` and `SSH_PORT`. It creates and deletes its own temp directory.
22- When running a dev server for experiments, point `--data-dir` at a scratch directory. Don't reuse `./data`, which may hold someone's real instance.
23- The `git` executable is a runtime dependency. It serves fetches and pushes (`upload-pack`, `receive-pack`) and builds archives.
24
25## Toolchain and dependencies
26
27- Edition 2024 with `rust-version = "1.88"`. Cargo's MSRV-aware resolver then picks dependency versions that build on 1.88. Several crates' newest releases need a newer Rust, so to upgrade, bump `rust-version`, the `rust:` image tag in `Dockerfile` and the README together.
28- `Cargo.lock` is committed (this is a binary), and the Dockerfile builds with `--locked`.
29- libgit2 and SQLite are compiled from bundled C sources, so a C compiler is required. CMake and OpenSSL are not. git2 is built without default features.
30- Key crates: axum 0.8, askama 0.16, sqlx 0.8 (SQLite), git2 0.21, russh 0.63, syntect + two-face, comrak + ammonia, clap 4.
31
32## Layout
33
34| Path | What lives there |
35| --- | --- |
36| `src/main.rs` | Startup: parse config, open DB, run web and SSH servers |
37| `src/config.rs` | All flags and `OPENHUB_*` env vars (clap). Clone URL and cookie helpers |
38| `src/state.rs` | `AppState` and the permission model (`AppState::access`) |
39| `src/db.rs` | Every SQL query and row struct |
40| `migrations/` | sqlx migrations, embedded at compile time and applied at startup |
41| `src/git.rs` | libgit2 access: ref resolution, trees, history, diffs, mergeability, merging |
42| `src/sync.rs` | Bookkeeping after refs change: HEAD/default branch, open pull requests |
43| `src/ssh.rs` | SSH server: key auth, command parsing, running git, post-push hints |
44| `src/highlight.rs` | Syntax highlighting (syntect scopes mapped to `tok-*` CSS classes) |
45| `src/markdown.rs` | Markdown rendering, sanitisation and relative-link rewriting |
46| `src/validate.rs` | Usernames, repo names, emails, passwords, SSH public keys |
47| `src/icons.rs` | Inline SVG icons (Lucide, ISC). Add an icon by adding a match arm |
48| `src/web/mod.rs` | Router and helpers: `render`, `redirect`, `redirect_flash`, `blocking`, `safe_next` |
49| `src/web/ctx.rs` | Per-request middleware: session user, flash, Origin check, error pages, security headers |
50| `src/web/error.rs` | `AppError` and how it becomes a response |
51| `src/web/fmt.rs` | Helpers called from templates (`ago`, `date`, `plural`, `avatar`, `icon`, `urlpath`, …) |
52| `src/web/{account,home,repo,code,pulls}.rs` | Page handlers |
53| `src/web/git_http.rs` | Smart HTTP (clone/fetch only) |
54| `templates/` | askama templates. See `templates/CLAUDE.md` |
55| `static/` | `style.css`, `app.js`, `favicon.svg`. Compiled into the binary with `include_str!` |
56| `docs/` | System documentation (start at `docs/README.md`) and the README screenshots in `docs/screenshots/` |
57
58## How things fit together
59
60**Storage.** Bare repositories live at `<data>/repos/<id>.git` (`Config::repo_path`). They are keyed by database ID, never by name, so renames only touch the DB. Never build filesystem paths from user input.
61
62**Permissions.** `AppState::access(repo, user)` returns `Access { read, write, admin }`:
63- The owner and site admins (`users.is_admin`, the first account registered) get admin.
64- Collaborators get write.
65- Anyone gets read on a public repo.
66
67Web handlers must load repositories through `web::repo::load_repo`, which returns 404 when the user can't read the repo. Then call `RepoCtx::require_write()` or `require_admin()` as needed. Private repos must look identical to missing ones on every channel: web 404, HTTP git 404, SSH "Repository not found."
68
69**Request flow.** `ctx::middleware` runs on every request:
70- Loads the session user and flash message into a `Ctx` extension.
71- Rejects cross-origin POSTs, accepting the origin of `--public-url` or one matching the `Host` header.
72- Adds the CSP and security headers.
73- Turns any response carrying an `ErrorPage` extension into the full HTML error page.
74
75Handlers take `Ctx` as an extractor and return `WebResult` (`Result<Response, AppError>`). `ctx.require_user()?` redirects anonymous users to sign in. Use `redirect_flash` to show a message after a POST.
76
77**Blocking work.** git2 calls are synchronous. Run them inside `web::blocking(move || …)` (spawn_blocking). DB calls are async and happen outside it. The usual handler shape is: load the repo and check access (async), do the git work (blocking), render.
78
79**Pull requests.**
80- State is `open`, `closed` or `merged`.
81- `head_sha` is the last known head.
82- `base_sha` is the merge base: it is updated while the PR is open and frozen on close or merge, so the commits and diff can still be shown after the branch moves or is deleted.
83- Each PR's head is pinned at `refs/pull/<n>/head`. `receive.hideRefs=refs/pull` (set in `git::init_bare`) stops clients pushing there.
84- `sync::refresh_pulls` runs after every push and on every open-PR page view. It tracks new heads and marks PRs as merged when their head is already contained in the base (for example, merged on the command line).
85- The `pr_events` table is the conversation timeline. `author_id` is NULL for events Openhub detected itself.
86- Web merges (`git::merge`) are done in memory and update the base branch with a compare-and-swap (`reference_matching`), so a concurrent push makes the merge fail cleanly.
87
88**After a push** (SSH `receive-pack` exits 0): `sync::after_push` updates `pushed_at`, points HEAD at an existing branch (and updates `default_branch` if it had to change), then refreshes PRs. `ssh.rs` then prints the "Create a pull request" / "View pull request" hints.
89
90## Security rules
91
92- askama escapes everything by default. Use `|safe` only on HTML we generate: `fmt::icon`, `fmt::avatar`, `highlight::*` output and `markdown::render` output.
93- Any user-supplied Markdown (READMEs, PR bodies, comments) must go through `markdown::render`, which sanitises with ammonia.
94- Pages send `script-src 'self'`, so there can be no inline `<script>`, `onclick=` or `javascript:` URLs. Behaviour goes in `static/app.js` and is attached through `data-*` attributes.
95- Raw file responses (`code::raw`) keep their sandboxing CSP and serve non-image text as `text/plain`.
96- SSH runs only `git-upload-pack` and `git-receive-pack`, after `parse_command` and an access check. Never pass client-provided strings to a shell.
97- Passwords use Argon2id. Sessions store only the SHA-256 of the token.
98
99## Gotchas
100
101- **Migrations are append-only.** sqlx stores a checksum of every applied migration and refuses to start if one changes. Existing installs (including any local `./data`) have already applied `0001_initial.sql`, so add `0002_*.sql` instead of editing it.
102- **Column lists are shared constants.** Queries use runtime `sqlx::query_as` with `FromRow`, so no `DATABASE_URL` is needed at build time. When adding a column, also update `USER_COLS`, `REPO_SELECT` or `PULL_SELECT` in `db.rs` so every query returns it.
103- **git2 0.21** returns `Result<Option<&str>>` (or `Result<&str>`) from many name and message accessors, and reference iterators yield `Result`s.
104- **Ref + path URLs** (`/tree/{*rest}`, `/blob/…`, `/commits/…`, `/archive/…`) are split by `git::resolve`. It tries `refs/heads/` then `refs/tags/` for growing prefixes, then a commit SHA. Build these URLs with `fmt::urlpath`, never raw string concatenation, because branch and file names can contain spaces and `#`.
105- **russh channel handling.** `ssh.rs` deliberately drops the `Channel` object in `channel_open_session` and relies on the `data` callbacks. Keeping it unread lets russh's per-channel buffer fill and deadlock the session. Client data is forwarded to git through an unbounded queue.
106- **Static assets are embedded at compile time.** Changes to `static/` need a rebuild. Asset URLs carry a content hash (`assets::version()`), so browsers pick up changes without stale caches.
107- **Smart HTTP request size.** `git-upload-pack` requests can be large, so that route has its own 64 MB body limit. Other routes use axum's 2 MB default.
108
109## Frontend conventions
110
111- **Colours are tokens.** Colours are CSS custom properties on `:root`, redefined in the `prefers-color-scheme: dark` block. Add new colours as tokens in both places rather than raw hex inside components. Syntax colours are the `--tok-*` tokens. Two kinds of raw colour are intentional exceptions: `#fff` text on solid emphasis fills (primary/success/danger buttons, state pills, badges, avatars), and translucent white on the site header, which is dark in both themes.
112- **Reuse the existing classes before adding new ones:**
113 - Buttons: `btn`, plus `btn-primary`, `btn-success`, `btn-danger`, `btn-ghost`, `btn-sm`, `btn-icon`.
114 - Containers: `box`, `box-header`, `box-body`, `box-row`, `box-footer`.
115 - Small UI: `label`, `counter`, `branch-label`, `state-{open,closed,merged}`, `flash-{success,error,info}`, `blankslate`, `banner`.
116 - Layout pieces: `tabs` / `tab`, `dropdown` (a `<details>` element), `field`, `field-label`, `field-hint`, `input`, `form-actions`.
117- **JS hooks (`static/app.js`):**
118 - `data-copy="#input"` and `data-copy-text="…"` copy text to the clipboard.
119 - `data-confirm="…"` on a `<form>` asks for confirmation before submitting.
120 - `data-toggle="element-id"` shows or hides an element.
121 - `data-collapse` collapses a diff file.
122 - `data-filter` and `data-filter-item` filter a dropdown list.
123 - `data-autosubmit` on a `<select>` submits its form on change.
124 - `data-clone-tab` and `data-clone-pane` switch the clone URL tabs.
125 - Every page must still work without JS.
126- **Check visual changes** in light mode, dark mode and at about 390px width. The README screenshots in `docs/screenshots/` come from a dev instance at 1280px: the repo page (light), a PR conversation (light) and a PR "Files changed" tab (dark). Retake them when the UI changes noticeably.
127
128## Testing expectations
129
130- Unit tests sit next to the code they test. Pure logic such as parsing, validation, highlighting, markdown and formatting should have one.
131- `scripts/smoke-test.sh` is the end-to-end contract. When adding or changing a user-facing flow (auth, keys, push, PRs, permissions, HTTP git), extend it and run it.
132- Before finishing a change, run `cargo fmt`, `cargo clippy --all-targets`, `cargo test` and the smoke test.
133
134## Documentation
135
136- Read the relevant page in `docs/` before changing a subsystem. Each page names its source files and ends with a "When you change this" checklist.
137- When a change alters behaviour that `docs/` describes (routes, permissions, schema, limits, PR tracking, security gaps), update that page in the same change. If the docs and the code disagree, the code is right and the docs need fixing.
138- Reference code by file and function name in docs, not by line number.