Working vertical
Browse files| @@ -0,0 +1,12 @@ | ||
| 1 | +# The Dockerfile copies only what it needs; this keeps the build context small | |
| 2 | +# and keeps local data and secrets out of it. | |
| 3 | +target | |
| 4 | +data | |
| 5 | +.git | |
| 6 | +.env | |
| 7 | +docs | |
| 8 | +scripts | |
| 9 | +*.db | |
| 10 | +*.db-shm | |
| 11 | +*.db-wal | |
| 12 | +ssh_host_*_key |
| @@ -0,0 +1,18 @@ | ||
| 1 | +# Build output | |
| 2 | +/target | |
| 3 | + | |
| 4 | +# Default runtime data directory (database, repositories, SSH host key) | |
| 5 | +/data | |
| 6 | + | |
| 7 | +# Runtime data if --data-dir points elsewhere inside the checkout | |
| 8 | +*.db | |
| 9 | +*.db-shm | |
| 10 | +*.db-wal | |
| 11 | +ssh_host_*_key | |
| 12 | +*.git/ | |
| 13 | + | |
| 14 | +# Local deployment settings (read automatically by docker compose) | |
| 15 | +.env | |
| 16 | + | |
| 17 | +# OS files | |
| 18 | +.DS_Store |
| @@ -0,0 +1,138 @@ | ||
| 1 | +# Openhub | |
| 2 | + | |
| 3 | +A 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 | |
| 12 | +cargo build # also compiles every template; template errors are compile errors | |
| 13 | +cargo run -- --data-dir ./data # web on :3000, SSH on :2222 (./data is gitignored) | |
| 14 | +cargo test # unit tests (inline #[cfg(test)] modules) | |
| 15 | +cargo clippy --all-targets # keep this warning-free | |
| 16 | +cargo fmt | |
| 17 | +scripts/smoke-test.sh [path/to/binary] # end-to-end test against a throwaway instance | |
| 18 | +docker 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 | + | |
| 67 | +Web 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 | + | |
| 75 | +Handlers 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. |
Large diff not rendered (4247 additions, 0 deletions).
| @@ -0,0 +1,42 @@ | ||
| 1 | +[package] | |
| 2 | +name = "openhub" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2024" | |
| 5 | +rust-version = "1.88" | |
| 6 | +description = "A minimal, self-hostable git hosting server" | |
| 7 | +license = "MIT" | |
| 8 | + | |
| 9 | +[dependencies] | |
| 10 | +ammonia = "4" | |
| 11 | +anyhow = "1" | |
| 12 | +argon2 = "0.5" | |
| 13 | +askama = "0.16" | |
| 14 | +axum = { version = "0.8", features = ["macros"] } | |
| 15 | +axum-extra = { version = "0.12", features = ["cookie"] } | |
| 16 | +base64 = "0.22" | |
| 17 | +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } | |
| 18 | +clap = { version = "4", features = ["derive", "env"] } | |
| 19 | +cookie = "0.18" | |
| 20 | +comrak = { version = "0.55", default-features = false } | |
| 21 | +flate2 = "1" | |
| 22 | +futures-util = "0.3" | |
| 23 | +git2 = { version = "0.21", default-features = false } | |
| 24 | +hex = "0.4" | |
| 25 | +mime_guess = "2" | |
| 26 | +rand = "0.8" | |
| 27 | +russh = "0.63" | |
| 28 | +serde = { version = "1", features = ["derive"] } | |
| 29 | +sha2 = "0.10" | |
| 30 | +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] } | |
| 31 | +syntect = { version = "5", default-features = false, features = ["default-themes", "html", "parsing", "regex-fancy"] } | |
| 32 | +two-face = { version = "0.5", default-features = false, features = ["syntect-fancy"] } | |
| 33 | +thiserror = "2" | |
| 34 | +tokio = { version = "1", features = ["full"] } | |
| 35 | +tokio-util = { version = "0.7", features = ["io"] } | |
| 36 | +tower-http = { version = "0.6", features = ["trace"] } | |
| 37 | +tracing = "0.1" | |
| 38 | +tracing-subscriber = { version = "0.3", features = ["env-filter"] } | |
| 39 | + | |
| 40 | +[profile.release] | |
| 41 | +lto = "thin" | |
| 42 | +codegen-units = 4 |
| @@ -0,0 +1,26 @@ | ||
| 1 | +# syntax=docker/dockerfile:1 | |
| 2 | + | |
| 3 | +FROM rust:1.88-bookworm AS build | |
| 4 | +WORKDIR /src | |
| 5 | +COPY Cargo.toml Cargo.lock ./ | |
| 6 | +COPY migrations migrations | |
| 7 | +COPY src src | |
| 8 | +COPY static static | |
| 9 | +COPY templates templates | |
| 10 | +RUN cargo build --release --locked | |
| 11 | + | |
| 12 | +FROM debian:bookworm-slim | |
| 13 | +# git serves fetches and pushes; everything else is in the binary. | |
| 14 | +RUN apt-get update \ | |
| 15 | + && apt-get install -y --no-install-recommends git ca-certificates \ | |
| 16 | + && rm -rf /var/lib/apt/lists/* \ | |
| 17 | + && useradd --system --home-dir /data --shell /usr/sbin/nologin openhub \ | |
| 18 | + && mkdir /data && chown openhub:openhub /data | |
| 19 | +COPY --from=build /src/target/release/openhub /usr/local/bin/openhub | |
| 20 | +USER openhub | |
| 21 | +ENV OPENHUB_DATA_DIR=/data \ | |
| 22 | + OPENHUB_HTTP_ADDR=0.0.0.0:3000 \ | |
| 23 | + OPENHUB_SSH_ADDR=0.0.0.0:2222 | |
| 24 | +VOLUME /data | |
| 25 | +EXPOSE 3000 2222 | |
| 26 | +ENTRYPOINT ["openhub"] |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | MIT License |
| 2 | 2 | |
| 3 | -Copyright (c) 2026 Sam Jeston | |
| 3 | +Copyright (c) 2026 Openhub contributors | |
| 4 | 4 | |
| 5 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy |
| 6 | 6 | of this software and associated documentation files (the "Software"), to deal |
| @@ -0,0 +1,150 @@ | ||
| 1 | +# Openhub | |
| 2 | + | |
| 3 | +Openhub is a minimal, self-hostable git server with a familiar web interface. Push over SSH, browse code and history, and review changes with pull requests. That is the whole product: no issue tracker, no CI, no wiki. | |
| 4 | + | |
| 5 | +It ships as a single Rust binary with SQLite for storage, so a whole installation is one executable plus one data directory. | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | +## Features | |
| 10 | + | |
| 11 | +- **Git over SSH**: a built-in SSH server authenticates `git push` and `git fetch` with the public keys users add in their settings. No system `sshd` configuration is needed. | |
| 12 | +- **Read-only git over HTTP**: anonymous clones of public repositories, and password-authenticated clones of private ones. | |
| 13 | +- **Code browsing**: file trees with the latest commit for each entry, syntax-highlighted files with linkable line numbers, rendered Markdown READMEs, raw files, and ZIP/tar.gz downloads. | |
| 14 | +- **History**: paginated commit logs (per branch, tag or path), commit pages with diffs, branches with ahead/behind counts, and tags. | |
| 15 | +- **Pull requests**: compare branches, open a pull request, discuss it in Markdown comments, and review the commits and the diff. Merge with a merge commit, a squash or a rebase, then delete the branch. Pull requests merged with a plain `git push` are detected automatically. | |
| 16 | +- **Accounts and access**: public and private repositories, plus collaborators with write access. The first account created is the site administrator. | |
| 17 | +- **Comfortable to use**: light and dark themes, works on phone-sized screens, and needs no JavaScript for core features. | |
| 18 | + | |
| 19 | +<table> | |
| 20 | + <tr> | |
| 21 | + <td><img src="docs/screenshots/pull-request.png" alt="Pull request conversation"></td> | |
| 22 | + <td><img src="docs/screenshots/diff-dark.png" alt="Diff view in the dark theme"></td> | |
| 23 | + </tr> | |
| 24 | +</table> | |
| 25 | + | |
| 26 | +## Quick start | |
| 27 | + | |
| 28 | +### With Docker | |
| 29 | + | |
| 30 | +```sh | |
| 31 | +docker compose up -d | |
| 32 | +``` | |
| 33 | + | |
| 34 | +Open <http://localhost:3000> and create an account; the first account becomes the administrator. Before deploying, edit `OPENHUB_PUBLIC_URL` in `docker-compose.yml` to the URL people will use. | |
| 35 | + | |
| 36 | +### From source | |
| 37 | + | |
| 38 | +You need Rust 1.88 or newer, a C compiler (to build the bundled libgit2 and SQLite), and `git` installed at runtime. | |
| 39 | + | |
| 40 | +```sh | |
| 41 | +cargo build --release | |
| 42 | +./target/release/openhub --public-url http://localhost:3000 | |
| 43 | +``` | |
| 44 | + | |
| 45 | +By default the web interface listens on port `3000` and the SSH server on port `2222`. Everything is stored in `./data`. | |
| 46 | + | |
| 47 | +## Pushing your first repository | |
| 48 | + | |
| 49 | +1. Sign in and open **Settings → SSH keys**. Paste your public key, for example the contents of `~/.ssh/id_ed25519.pub`. | |
| 50 | +2. Check that the server accepts the key: | |
| 51 | + ```sh | |
| 52 | + ssh -T -p 2222 git@your-host | |
| 53 | + # Hi alice! You've successfully authenticated, but Openhub does not provide shell access. | |
| 54 | + ``` | |
| 55 | +3. Create a repository with **New**, then push to it: | |
| 56 | + ```sh | |
| 57 | + git remote add origin ssh://git@your-host:2222/alice/project.git | |
| 58 | + git push -u origin main | |
| 59 | + ``` | |
| 60 | + | |
| 61 | +When you push a branch other than the default branch, the server prints a link for opening a pull request. Once a pull request exists, the link points to it instead. | |
| 62 | + | |
| 63 | +## Configuration | |
| 64 | + | |
| 65 | +Each option can be set as a command-line flag or as an environment variable. | |
| 66 | + | |
| 67 | +| Flag | Environment variable | Default | Description | | |
| 68 | +| --- | --- | --- | --- | | |
| 69 | +| `--data-dir` | `OPENHUB_DATA_DIR` | `./data` | Holds the database, repositories and SSH host key | | |
| 70 | +| `--http-addr` | `OPENHUB_HTTP_ADDR` | `0.0.0.0:3000` | Listen address for the web interface | | |
| 71 | +| `--ssh-addr` | `OPENHUB_SSH_ADDR` | `0.0.0.0:2222` | Listen address for the SSH server | | |
| 72 | +| `--public-url` | `OPENHUB_PUBLIC_URL` | `http://localhost:3000` | External URL of the web interface, used for links and clone URLs | | |
| 73 | +| `--ssh-host` | `OPENHUB_SSH_HOST` | host of the public URL | Host shown in SSH clone URLs | | |
| 74 | +| `--ssh-port` | `OPENHUB_SSH_PORT` | the SSH listen port | Port shown in SSH clone URLs | | |
| 75 | +| `--disable-registration` | `OPENHUB_DISABLE_REGISTRATION` | `false` | Turns off sign-ups (the first account can still be created) | | |
| 76 | +| `--git-bin` | `OPENHUB_GIT_BIN` | `git` | Path to the `git` executable | | |
| 77 | + | |
| 78 | +Logging is controlled with `RUST_LOG`, for example `RUST_LOG=openhub=debug`. | |
| 79 | + | |
| 80 | +## Deployment notes | |
| 81 | + | |
| 82 | +- **HTTPS**: put Openhub behind a reverse proxy such as Caddy or nginx and set `OPENHUB_PUBLIC_URL=https://git.example.com`. Session cookies are marked `Secure` whenever the public URL uses HTTPS. SSH traffic goes straight to the SSH port, not through the proxy. | |
| 83 | +- **Public URL must match**: form submissions are only accepted from the origin in `OPENHUB_PUBLIC_URL`, or from one matching the request's `Host` header. If sign-in or other forms fail with "Cross-origin request rejected", check that the public URL matches exactly what appears in the browser's address bar, including scheme and port. | |
| 84 | +- **Using port 22 for SSH**: forward port 22 to Openhub's SSH port (for example `"22:2222"` in Docker) and set `OPENHUB_SSH_PORT=22`. Clone URLs then take the short form `[email protected]:alice/project.git`. If the host already runs its own `sshd` on port 22, keep Openhub on another port. | |
| 85 | +- **Host key**: the SSH host key is created on first start at `<data-dir>/ssh_host_ed25519_key`. Keep it with your backups so clients don't get host-key warnings after a restore. | |
| 86 | +- **Backups**: back up the whole data directory. To copy the database while the server is running, use `sqlite3 openhub.db ".backup backup.db"`; otherwise stop the server first. | |
| 87 | +- **Registration**: once everyone has an account, set `OPENHUB_DISABLE_REGISTRATION=true`. | |
| 88 | + | |
| 89 | +## How it works | |
| 90 | + | |
| 91 | +``` | |
| 92 | +browser ──▶ web UI (axum + askama) ──┐ | |
| 93 | +git (HTTP) ──▶ smart HTTP, read-only ──┼──▶ bare repositories (libgit2 + git) | |
| 94 | +git (SSH) ──▶ SSH server (russh) ──┘ | |
| 95 | + │ | |
| 96 | + └──▶ SQLite: users, SSH keys, repositories, pull requests | |
| 97 | +``` | |
| 98 | + | |
| 99 | +- Repositories are stored as bare git repositories named by their database ID (`repos/42.git`), so renaming a repository only changes the database. | |
| 100 | +- Fetches and pushes run the standard `git upload-pack` and `git receive-pack`, so protocol support (including protocol v2) matches the installed git. | |
| 101 | +- After each push Openhub corrects `HEAD` if the default branch is missing, tracks the new tips of open pull requests, and marks pull requests whose commits reached the base branch as merged. | |
| 102 | +- Each pull request's head commit is kept at `refs/pull/<n>/head`, so its history survives after the branch is deleted. You can fetch it with `git fetch origin pull/<n>/head`. These refs cannot be pushed to. | |
| 103 | +- Web merges happen in memory with libgit2. The base branch is only updated if it hasn't moved since the merge started. | |
| 104 | + | |
| 105 | +### Source layout | |
| 106 | + | |
| 107 | +| Path | Purpose | | |
| 108 | +| --- | --- | | |
| 109 | +| `src/main.rs` | Startup: configuration, database, web and SSH servers | | |
| 110 | +| `src/ssh.rs` | SSH server, key authentication, git command dispatch | | |
| 111 | +| `src/git.rs` | Repository access: refs, trees, history, diffs, merging | | |
| 112 | +| `src/sync.rs` | Post-push bookkeeping for HEAD and pull requests | | |
| 113 | +| `src/db.rs`, `migrations/` | SQLite schema and queries | | |
| 114 | +| `src/web/` | HTTP handlers: accounts, code browsing, pull requests, smart HTTP | | |
| 115 | +| `src/highlight.rs`, `src/markdown.rs` | Syntax highlighting and sanitised Markdown | | |
| 116 | +| `templates/`, `static/` | HTML templates, stylesheet and script (compiled into the binary) | | |
| 117 | + | |
| 118 | +## Security | |
| 119 | + | |
| 120 | +- Passwords are hashed with Argon2id. Session tokens are random, and only their SHA-256 hashes are stored. | |
| 121 | +- Cookies are `HttpOnly` and `SameSite=Lax`, and cross-origin form posts are rejected. | |
| 122 | +- Pages are served with a strict Content-Security-Policy, and no inline scripts are used. | |
| 123 | +- Markdown (READMEs, pull requests and comments) is sanitised with `ammonia`. Raw files are served with a sandboxing CSP so they can never run script on the Openhub origin. | |
| 124 | +- The SSH server accepts only public-key authentication and runs only `git-upload-pack` and `git-receive-pack`, for repositories the key's owner can access. Shell sessions are refused. | |
| 125 | +- Private repositories return 404 to anyone without access. | |
| 126 | + | |
| 127 | +Openhub doesn't yet rate-limit sign-in attempts. If the server is exposed to the internet, add rate limiting at your reverse proxy. [docs/security.md](docs/security.md) lists the known gaps and hardening advice, including what to change before running an instance with open registration. | |
| 128 | + | |
| 129 | +## Development | |
| 130 | + | |
| 131 | +```sh | |
| 132 | +cargo test # unit tests | |
| 133 | +cargo clippy --all-targets # lints (kept warning-free) | |
| 134 | +scripts/smoke-test.sh # end-to-end: push, browse, pull requests, merges, permissions | |
| 135 | +cargo run -- --data-dir ./data | |
| 136 | +``` | |
| 137 | + | |
| 138 | +The smoke test starts a throwaway instance on ports 38080 and 38022 (override with `HTTP_PORT` and `SSH_PORT`) and drives it with `curl`, `ssh`, `ssh-keygen` and `git`. | |
| 139 | + | |
| 140 | +How the system works, including the architecture, data model, permissions, git transport, pull request tracking, security model and operations, is documented in [`docs/`](docs/README.md). Coding conventions and known pitfalls for contributors are in [`CLAUDE.md`](CLAUDE.md), with template-specific notes in [`templates/CLAUDE.md`](templates/CLAUDE.md). | |
| 141 | + | |
| 142 | +## Out of scope | |
| 143 | + | |
| 144 | +Openhub deliberately covers only git hosting. It has no issues, CI, wikis, releases, forks, organisations or line-level review comments. | |
| 145 | + | |
| 146 | +## License | |
| 147 | + | |
| 148 | +MIT. See [LICENSE](LICENSE). | |
| 149 | + | |
| 150 | +Icons are from [Lucide](https://lucide.dev) (ISC License). Syntax definitions come from [syntect](https://github.com/trishume/syntect) and [two-face](https://github.com/CosmicHorrorDev/two-face). |
| @@ -0,0 +1,21 @@ | ||
| 1 | +services: | |
| 2 | + openhub: | |
| 3 | + build: . | |
| 4 | + image: openhub:latest | |
| 5 | + restart: unless-stopped | |
| 6 | + ports: | |
| 7 | + - "3000:3000" # web interface and read-only git over HTTP | |
| 8 | + - "2222:2222" # git over SSH | |
| 9 | + environment: | |
| 10 | + # The URL people use to reach the web interface (used for links and clone URLs). | |
| 11 | + OPENHUB_PUBLIC_URL: "http://localhost:3000" | |
| 12 | + # Host and port shown in SSH clone URLs, if they differ from the above. | |
| 13 | + # OPENHUB_SSH_HOST: "git.example.com" | |
| 14 | + # OPENHUB_SSH_PORT: "2222" | |
| 15 | + # Turn off sign-ups once your accounts exist. | |
| 16 | + # OPENHUB_DISABLE_REGISTRATION: "true" | |
| 17 | + volumes: | |
| 18 | + - openhub-data:/data | |
| 19 | + | |
| 20 | +volumes: | |
| 21 | + openhub-data: |
| @@ -0,0 +1,53 @@ | ||
| 1 | +# Openhub documentation | |
| 2 | + | |
| 3 | +These pages explain how Openhub works inside: its components, the data they keep and the rules they follow. They are written for two readers: | |
| 4 | + | |
| 5 | +- **People** running, reviewing or extending Openhub. | |
| 6 | +- **Coding agents** that need accurate context before changing the system. | |
| 7 | + | |
| 8 | +Related documents: | |
| 9 | + | |
| 10 | +- The [project README](../README.md) covers installing, configuring and using Openhub. | |
| 11 | +- [`CLAUDE.md`](../CLAUDE.md) and [`templates/CLAUDE.md`](../templates/CLAUDE.md) cover coding conventions and pitfalls. | |
| 12 | + | |
| 13 | +These pages fill the gap between those: they explain the mechanics, so you can change the system without first reading every source file. | |
| 14 | + | |
| 15 | +## Contents | |
| 16 | + | |
| 17 | +| Document | Read it to learn | | |
| 18 | +| --- | --- | | |
| 19 | +| [Architecture](architecture.md) | What runs in the process, how requests flow through it, and where files live on disk | | |
| 20 | +| [Data model](data-model.md) | The SQLite tables, the bare repositories, and the invariants that tie them together | | |
| 21 | +| [Accounts and access](accounts-and-access.md) | How sign-up, sessions and SSH keys work, and who may read, push to or administer a repository | | |
| 22 | +| [Git transport](git-transport.md) | How `git push`, `fetch` and `clone` are served over SSH and HTTP, and how archives are built | | |
| 23 | +| [Pull requests](pull-requests.md) | The pull request lifecycle, how heads and merge bases are tracked, and the three merge methods | | |
| 24 | +| [Web interface](web-interface.md) | Routes, the request pipeline, code browsing, diffs, syntax highlighting and Markdown | | |
| 25 | +| [Security](security.md) | The threat model, the protections in place, known gaps and hardening advice | | |
| 26 | +| [Operations](operations.md) | How to deploy, back up, upgrade, administer and troubleshoot an instance | | |
| 27 | + | |
| 28 | +## Quick lookup | |
| 29 | + | |
| 30 | +| Question | Where the answer is | | |
| 31 | +| --- | --- | | |
| 32 | +| Who may do what to a repository? | [Accounts and access → Permission model](accounts-and-access.md#permission-model) (`AppState::access` in `src/state.rs`) | | |
| 33 | +| What happens when someone pushes? | [Git transport → After a push](git-transport.md#after-a-push) (`sync::after_push` in `src/sync.rs`) | | |
| 34 | +| How does Openhub notice a pull request was merged on the command line? | [Pull requests → Tracking open pull requests](pull-requests.md#tracking-open-pull-requests) | | |
| 35 | +| How does a web merge avoid racing a concurrent push? | [Pull requests → Merging in the web interface](pull-requests.md#merging-in-the-web-interface) | | |
| 36 | +| How does `/tree/feature/login/src` know where the branch name ends? | [Web interface → Refs and paths in URLs](web-interface.md#refs-and-paths-in-urls) | | |
| 37 | +| Where is a repository stored on disk? | [Data model → Repositories on disk](data-model.md#repositories-on-disk) | | |
| 38 | +| Which work uses libgit2, and which uses the `git` program? | [Architecture → libgit2 and the git executable](architecture.md#libgit2-and-the-git-executable) | | |
| 39 | +| How do I add a database column? | [Data model → Changing the schema](data-model.md#changing-the-schema) | | |
| 40 | +| Is it safe to leave sign-ups open? | [Security → Known gaps](security.md#known-gaps) | | |
| 41 | +| How do I make someone an administrator? | [Operations → Administration without a UI](operations.md#administration-without-a-ui) | | |
| 42 | +| Why does a form say "Cross-origin request rejected"? | [Operations → Troubleshooting](operations.md#troubleshooting) | | |
| 43 | + | |
| 44 | +## Conventions in these pages | |
| 45 | + | |
| 46 | +- Code is referenced by file and function name (for example `git::merge` in `src/git.rs`), not by line number, so references survive edits. | |
| 47 | +- "Repository" means a hosted repository. "Bare repository" means its directory on disk. | |
| 48 | +- Most pages end with a **When you change this** list naming the other places that must change with it. | |
| 49 | +- `docs/screenshots/` holds the images embedded in the project README. [`CLAUDE.md`](../CLAUDE.md) explains when and how to retake them. | |
| 50 | + | |
| 51 | +## Keeping these docs accurate | |
| 52 | + | |
| 53 | +These pages describe behaviour that the code implements. When a change alters something described here, update the page in the same change. If a page and the code disagree, the code is right and the page is a bug. |
| @@ -0,0 +1,146 @@ | ||
| 1 | +# Accounts and access | |
| 2 | + | |
| 3 | +This page explains how people get accounts, how they prove who they are on each channel (browser, SSH and HTTP git), and what they can then do with each repository. | |
| 4 | + | |
| 5 | +**Source files:** | |
| 6 | +- `src/web/account.rs`: sign-up, sign-in, sessions, profile, password and SSH keys. | |
| 7 | +- `src/web/ctx.rs`: session lookup and cookies. | |
| 8 | +- `src/state.rs`: the permission model. | |
| 9 | +- `src/validate.rs`: input rules. | |
| 10 | +- `src/ssh.rs`: SSH authentication. | |
| 11 | +- `src/web/git_http.rs`: HTTP Basic authentication. | |
| 12 | + | |
| 13 | +## Accounts | |
| 14 | + | |
| 15 | +### Registration | |
| 16 | + | |
| 17 | +`GET`/`POST /register` (`account::register_form`, `account::register`). | |
| 18 | + | |
| 19 | +- **When it's allowed.** Registration is open unless `--disable-registration` (`OPENHUB_DISABLE_REGISTRATION=true`) is set. Even then, the first account can always be created, so a new instance can't lock itself out. `account::can_register` holds the rule, and the landing and sign-in pages use it to show or hide the sign-up link. | |
| 20 | +- **The first account becomes the site administrator** (`users.is_admin = 1`). Register it before the server is reachable by anyone else. | |
| 21 | +- **Validation** (`src/validate.rs`): | |
| 22 | + | |
| 23 | + | Field | Rule | | |
| 24 | + | --- | --- | | |
| 25 | + | Username | 1–39 ASCII letters, digits or hyphens. Can't start or end with a hyphen or contain `--`. Not reserved. Unique, case-insensitively | | |
| 26 | + | Email | Up to 254 characters, no whitespace, a non-empty local part, and a domain containing `.`. Unique, case-insensitively. Not verified by email | | |
| 27 | + | Password | At least 8 characters and at most 256 bytes | | |
| 28 | + | |
| 29 | + Reserved usernames are names that would clash with top-level routes: `admin`, `api`, `assets`, `explore`, `favicon.svg`, `git`, `help`, `login`, `logout`, `new`, `register`, `root`, `settings`, `signin`, `signup`, `static`, `system`. Adding a top-level route means adding its first path segment to `RESERVED`. | |
| 30 | + | |
| 31 | +- **Afterwards**, the new user is signed in and sent to **Settings → SSH keys** with a welcome message. | |
| 32 | + | |
| 33 | +### Signing in | |
| 34 | + | |
| 35 | +`POST /login` (`account::login`): | |
| 36 | + | |
| 37 | +- The login field accepts a username or an email (`db::user_by_login`). | |
| 38 | +- A password is verified with Argon2id even when the account doesn't exist, against `DUMMY_HASH`. That way, response timing doesn't reveal which accounts exist. The error message is always "Incorrect username or password." | |
| 39 | +- `next` takes the user back to the page that required sign-in. `web::safe_next` only accepts same-site paths: it must start with `/`, but not `//` or `/\`. | |
| 40 | +- There is no rate limiting, lockout, two-factor authentication or password reset (see [Security](security.md#known-gaps)). | |
| 41 | + | |
| 42 | +### Passwords | |
| 43 | + | |
| 44 | +- Hashed with `argon2` 0.5 defaults (Argon2id, 19 MiB of memory, 2 iterations) and a random salt, inside `spawn_blocking`. | |
| 45 | +- Changing the password (`POST /settings/password`) requires the current password. It signs out every other session of that user and keeps the current one. | |
| 46 | + | |
| 47 | +### Sessions | |
| 48 | + | |
| 49 | +| Property | Value | | |
| 50 | +| --- | --- | | |
| 51 | +| Cookie | `openhub_session`, `HttpOnly`, `SameSite=Lax`, `Path=/`, 30-day `Max-Age`. `Secure` when `--public-url` starts with `https://` | | |
| 52 | +| Token | 32 random bytes from the OS RNG, base64url-encoded | | |
| 53 | +| Storage | Only `sha256(token)` is stored, in `sessions.token_hash`, so a database leak doesn't expose usable cookies | | |
| 54 | +| Lifetime | Fixed 30 days from sign-in. There's no sliding renewal | | |
| 55 | +| Validation | `ctx::middleware` hashes the cookie and calls `db::user_by_session`, which ignores expired rows. It skips this for git HTTP paths | | |
| 56 | +| Sign-out | `POST /logout` deletes the session row and clears the cookie | | |
| 57 | + | |
| 58 | +### Flash messages | |
| 59 | + | |
| 60 | +One-off messages shown after a redirect, such as "Profile updated.", are set by `web::redirect_flash`. They are stored in the `openhub_flash` cookie as base64url `kind\nmessage`, where kind is `success`, `error` or `info`. The middleware decodes the cookie into `Ctx.flash` and clears it on the next response, unless that response sets a new one. | |
| 61 | + | |
| 62 | +### Profile | |
| 63 | + | |
| 64 | +`/settings/profile` edits the display name (up to 100 characters), email and bio (up to 500 characters). Avatars are generated from the username, as a coloured initial (`fmt::avatar`). No images are uploaded or fetched. | |
| 65 | + | |
| 66 | +## SSH keys | |
| 67 | + | |
| 68 | +SSH keys are the only credential that can push. | |
| 69 | + | |
| 70 | +**Adding a key** (`POST /settings/keys`, `account::add_key` and `validate::ssh_public_key`): | |
| 71 | +1. The input must be a single line. Input containing `PRIVATE KEY` is rejected, with a hint to paste the `.pub` file instead. | |
| 72 | +2. The line is parsed as an OpenSSH public key by the `ssh-key` crate. | |
| 73 | +3. The stored copy is normalised: re-encoded with the comment removed. The comment becomes the default title. | |
| 74 | +4. The SHA-256 fingerprint must not already be registered, by this user or anyone else. Because a key belongs to at most one account, the key alone identifies the user during SSH authentication. | |
| 75 | + | |
| 76 | +**Deleting a key** (`POST /settings/keys/{id}/delete`) only works for the user's own keys. Anything else returns 404. | |
| 77 | + | |
| 78 | +**Usage tracking.** `last_used_at` is updated each time the key authenticates, and shown on the keys page. | |
| 79 | + | |
| 80 | +**Key types.** Ed25519 is recommended and is what the smoke test uses. Other algorithms that both the `ssh-key` parser and russh support also work. | |
| 81 | + | |
| 82 | +## Identity on each channel | |
| 83 | + | |
| 84 | +| Channel | How the user is identified | Code | | |
| 85 | +| --- | --- | --- | | |
| 86 | +| Browser | Session cookie | `ctx::middleware` → `Ctx.user` | | |
| 87 | +| SSH (`git push`, `fetch`, `clone`) | Public key fingerprint → `ssh_keys` → `users`. The SSH username, conventionally `git`, is ignored | `SshSession::auth_publickey` | | |
| 88 | +| HTTP git, public repository | Anonymous. Credentials aren't requested | `git_http::authorize` | | |
| 89 | +| HTTP git, private repository | HTTP Basic auth: a username or email, plus the account password, checked with Argon2id on every request | `git_http::authorize` | | |
| 90 | +| HTTP push | Always refused (403) | `git_http::receive_pack`, `info_refs` | | |
| 91 | + | |
| 92 | +## Permission model | |
| 93 | + | |
| 94 | +All permission checks go through `AppState::access(repo, user)`, which returns `Access { read, write, admin }`: | |
| 95 | + | |
| 96 | +| Who | `read` | `write` | `admin` | | |
| 97 | +| --- | --- | --- | --- | | |
| 98 | +| Site administrator (`users.is_admin`) | ✓ | ✓ | ✓ | | |
| 99 | +| Repository owner | ✓ | ✓ | ✓ | | |
| 100 | +| Collaborator | ✓ | ✓ | | | |
| 101 | +| Any other signed-in user | public repositories only | | | | |
| 102 | +| Anonymous visitor | public repositories only | | | | |
| 103 | + | |
| 104 | +### What each level allows | |
| 105 | + | |
| 106 | +| Action | Needs | Enforced in | | |
| 107 | +| --- | --- | --- | | |
| 108 | +| View code, history, branches, tags and pull requests; download archives and raw files | `read` | `repo::load_repo` (every repository page) | | |
| 109 | +| Clone and fetch over SSH or HTTP | `read` | `SshSession::exec_request`, `git_http::authorize` | | |
| 110 | +| Push over SSH (create, update or delete branches and tags) | `write` | `SshSession::exec_request` | | |
| 111 | +| Open a pull request, or comment on one | Signed in, plus `read` | `pulls::create`, `pulls::comment` | | |
| 112 | +| Edit a pull request's title or body, close it or reopen it | The pull request's author, or `write` | `pulls::edit`, `pulls::close_pull`, `pulls::set_state` | | |
| 113 | +| Merge a pull request | `write` | `pulls::merge` (`RepoCtx::require_write`) | | |
| 114 | +| Delete a branch in the web interface (not the default branch) | `write` | `repo::delete_branch` | | |
| 115 | +| Change the name, description, visibility or default branch | `admin` | `repo::update_settings` (`RepoCtx::require_admin`) | | |
| 116 | +| Add or remove collaborators | `admin` | `repo::add_collaborator`, `repo::remove_collaborator` | | |
| 117 | +| Delete the repository | `admin`, plus typing `owner/name` to confirm | `repo::delete` | | |
| 118 | +| Create a repository | Signed in | `repo::create` (always owned by the creator) | | |
| 119 | + | |
| 120 | +Notes: | |
| 121 | + | |
| 122 | +- **Opening a pull request needs only `read`,** but both branches must already exist in the repository, and only people with `write` can push branches. So on a public repository, any signed-in user can open a pull request between existing branches and comment on it. They can't change any code. | |
| 123 | +- **Collaborators can't be granted read-only access.** The only level is write. | |
| 124 | +- **There are no organisations or teams.** Every repository belongs to one user. | |
| 125 | +- **The site administrator has no special pages.** Their power is `admin` on every repository. Promoting or demoting users, or removing accounts, is done in SQL (see [Operations](operations.md#administration-without-a-ui)). | |
| 126 | + | |
| 127 | +### Private repositories look like missing ones | |
| 128 | + | |
| 129 | +A user without `read` should be unable to tell a private repository from one that doesn't exist: | |
| 130 | + | |
| 131 | +| Channel | Response when the repository is private and the user lacks access | | |
| 132 | +| --- | --- | | |
| 133 | +| Web | 404 page (`load_repo` returns `AppError::NotFound` before any other check) | | |
| 134 | +| SSH | `ERROR: Repository not found.`, the same as for a missing repository | | |
| 135 | +| HTTP git, with credentials | 404 `Repository not found` | | |
| 136 | +| HTTP git, without credentials | **401** with a Basic challenge, while a missing repository gets 404. This is a known gap, listed under [Security → Known gaps](security.md#known-gaps) | | |
| 137 | +| Profile and dashboard lists | Private repositories are only listed for the owner, collaborators and site administrators (`db::list_owner_repos`) | | |
| 138 | +| Explore and landing pages | Public repositories only | | |
| 139 | + | |
| 140 | +New handlers must load repositories through `repo::load_repo`, which applies this rule, then call `require_write()` or `require_admin()` as needed. | |
| 141 | + | |
| 142 | +## When you change this | |
| 143 | + | |
| 144 | +- **New top-level route** (`/something`): add `something` to `RESERVED` in `src/validate.rs`. An existing user with that name would shadow the route, or the route would shadow the user, so check the database. | |
| 145 | +- **New permission level**, such as read-only collaborators: change `Access`, `AppState::access`, the collaborators table (via a migration), the settings page, `db::list_owner_repos` and `db::list_member_repos`, then update both tables above. | |
| 146 | +- **New credential type**, such as tokens: add it to the identity table, keep pushes on SSH unless a decision is made otherwise, and extend `scripts/smoke-test.sh`. |
| @@ -0,0 +1,160 @@ | ||
| 1 | +# Architecture | |
| 2 | + | |
| 3 | +Openhub is one Rust process. It serves the web interface and git over HTTP on one port, and git over SSH on a second port. State is kept in two places: | |
| 4 | + | |
| 5 | +- A SQLite database, for accounts, keys, repository metadata and pull requests. | |
| 6 | +- A directory of bare git repositories, for all code and history. | |
| 7 | + | |
| 8 | +There are no background workers, queues or caches. Everything happens in response to a web request or an SSH session. | |
| 9 | + | |
| 10 | +## Components | |
| 11 | + | |
| 12 | +``` | |
| 13 | + browser git over HTTP git over SSH | |
| 14 | + │ │ │ | |
| 15 | + ▼ ▼ ▼ | |
| 16 | + ┌───────────────────────────┐ ┌───────────────────────────┐ | |
| 17 | + │ Web server (axum) │ │ SSH server (russh) │ | |
| 18 | + │ src/web/ │ │ src/ssh.rs │ | |
| 19 | + └─────────────┬─────────────┘ └─────────────┬─────────────┘ | |
| 20 | + │ │ | |
| 21 | + └───────────────┬───────────────┘ | |
| 22 | + ▼ | |
| 23 | + ┌───────────────────────────────────────────────────────────┐ | |
| 24 | + │ Shared core │ | |
| 25 | + │ AppState (src/state.rs): Config, SQLite pool, access() │ | |
| 26 | + │ libgit2, in process (src/git.rs, src/sync.rs) │ | |
| 27 | + │ git child processes: upload-pack, receive-pack, archive │ | |
| 28 | + └─────────────┬───────────────────────────────┬─────────────┘ | |
| 29 | + ▼ ▼ | |
| 30 | + <data>/openhub.db <data>/repos/<id>.git | |
| 31 | + SQLite database bare repositories | |
| 32 | +``` | |
| 33 | + | |
| 34 | +| Module | Responsibility | | |
| 35 | +| --- | --- | | |
| 36 | +| `src/main.rs` | Startup and shutdown | | |
| 37 | +| `src/config.rs` | `Config`: every command-line flag and `OPENHUB_*` variable, plus path and clone-URL helpers | | |
| 38 | +| `src/state.rs` | `AppState`, shared by both servers, and the permission model `AppState::access` | | |
| 39 | +| `src/db.rs` | The connection pool, migrations, row types and every SQL query | | |
| 40 | +| `src/git.rs` | Every libgit2 operation: refs, trees, blobs, history, diffs, mergeability, merging | | |
| 41 | +| `src/sync.rs` | Keeps the database in step with refs after they change | | |
| 42 | +| `src/ssh.rs` | The SSH server: key authentication, command parsing, running git, push hints | | |
| 43 | +| `src/web/` | The HTTP router, middleware and page handlers, plus smart HTTP and static assets | | |
| 44 | +| `src/highlight.rs` | Syntax highlighting to `tok-*` CSS classes | | |
| 45 | +| `src/markdown.rs` | Markdown to sanitised HTML, with relative-link rewriting | | |
| 46 | +| `src/validate.rs` | Rules for usernames, repository names, emails, passwords and SSH public keys | | |
| 47 | +| `src/icons.rs` | Inline SVG icons | | |
| 48 | +| `templates/` | askama HTML templates, compiled into the binary | | |
| 49 | +| `static/` | `style.css`, `app.js` and `favicon.svg`, embedded into the binary with `include_str!` | | |
| 50 | +| `migrations/` | SQL migrations, embedded and applied at startup | | |
| 51 | + | |
| 52 | +## Startup | |
| 53 | + | |
| 54 | +`main` in `src/main.rs`: | |
| 55 | + | |
| 56 | +1. Starts logging. The level comes from `RUST_LOG` and defaults to `openhub=info`. | |
| 57 | +2. Parses `Config` from flags and environment variables, then calls `Config::normalize`, which strips trailing slashes from the public URL. | |
| 58 | +3. Creates `<data>/repos` if it is missing. | |
| 59 | +4. Runs `git --version`. If `git` can't be run, startup fails. | |
| 60 | +5. Opens the database with `db::connect`: | |
| 61 | + - Creates the database file if needed. | |
| 62 | + - Turns on WAL mode, `synchronous=NORMAL` and foreign keys, with a 5-second busy timeout and a pool of up to 8 connections. | |
| 63 | + - Applies any pending migrations. | |
| 64 | +6. Builds `AppState` and runs `web::serve` and `ssh::serve` together with `tokio::select!`. | |
| 65 | + | |
| 66 | +If either server returns an error, the process exits. Ctrl-C shuts it down. On its first start, the SSH server generates `<data>/ssh_host_ed25519_key` (see [Git transport](git-transport.md#host-key)). | |
| 67 | + | |
| 68 | +## Data directory | |
| 69 | + | |
| 70 | +``` | |
| 71 | +<data-dir>/ default ./data, or /data in Docker | |
| 72 | +├── openhub.db SQLite database (plus openhub.db-wal and openhub.db-shm while running) | |
| 73 | +├── ssh_host_ed25519_key SSH host private key (mode 0600) | |
| 74 | +└── repos/ | |
| 75 | + ├── 1.git/ bare repository for repositories.id = 1 | |
| 76 | + └── 2.git/ | |
| 77 | +``` | |
| 78 | + | |
| 79 | +A whole installation is this directory plus the binary. Back it up as a unit (see [Operations](operations.md#backups)). | |
| 80 | + | |
| 81 | +## libgit2 and the git executable | |
| 82 | + | |
| 83 | +Openhub uses two git implementations, each for the jobs it handles best: | |
| 84 | + | |
| 85 | +| Work | Implementation | Where | | |
| 86 | +| --- | --- | --- | | |
| 87 | +| Serving fetches and clones (SSH and HTTP) | `git upload-pack`, run as a child process | `src/ssh.rs`, `src/web/git_http.rs` | | |
| 88 | +| Receiving pushes (SSH only) | `git receive-pack`, run as a child process | `src/ssh.rs` | | |
| 89 | +| ZIP and tar.gz downloads | `git archive`, run as a child process | `code::archive` in `src/web/code.rs` | | |
| 90 | +| Browsing trees, files, history, branches and tags | libgit2, in process | `src/git.rs` | | |
| 91 | +| Diffs, mergeability checks and web merges | libgit2, in process | `src/git.rs` | | |
| 92 | +| Creating repositories and initial commits | libgit2, in process | `git::init_bare`, `git::initial_commit` | | |
| 93 | +| Updating refs (`HEAD`, `refs/pull/*`, deleting branches) | libgit2, in process | `src/git.rs`, `src/sync.rs` | | |
| 94 | + | |
| 95 | +The wire protocol is left to the installed `git`, so protocol v2, shallow clones and future protocol changes come from the system package. Everything Openhub needs to inspect or compute is done with libgit2, which avoids parsing command output. | |
| 96 | + | |
| 97 | +libgit2 is compiled into the binary from bundled C sources (`git2` crate, default features off, so no OpenSSL or libssh2). The `git` executable is a runtime dependency, configurable with `--git-bin`. | |
| 98 | + | |
| 99 | +## Concurrency model | |
| 100 | + | |
| 101 | +- **Runtime.** Openhub runs on a multi-threaded tokio runtime (`#[tokio::main]`). | |
| 102 | +- **libgit2 calls are synchronous.** Web handlers run them inside `web::blocking`, which wraps `spawn_blocking`, so they never stall the async workers. A `git2::Repository` is opened per request and never shared between threads. | |
| 103 | +- **Database access is async** through the sqlx pool. Handlers normally: | |
| 104 | + 1. Load the repository and check access (async). | |
| 105 | + 2. Do the git work (blocking). | |
| 106 | + 3. Render the page. | |
| 107 | +- **Each SSH connection** is its own russh handler (`SshSession`). Each git child process gets tasks that copy data between the SSH channel and the process's stdin, stdout and stderr. | |
| 108 | +- **Child processes** are started with `tokio::process::Command` and `kill_on_drop(true)`, so a dropped connection kills its git process. | |
| 109 | +- **Concurrent ref updates** are serialised by git's own ref locking. Web merges use a compare-and-swap ref update, so a merge racing a push fails cleanly instead of overwriting it (see [Pull requests](pull-requests.md#merging-in-the-web-interface)). | |
| 110 | + | |
| 111 | +## How the main flows work | |
| 112 | + | |
| 113 | +### Viewing a file in the browser | |
| 114 | + | |
| 115 | +1. The axum router matches `/{owner}/{repo}/blob/{*rest}` and calls `code::blob`. | |
| 116 | +2. `ctx::middleware` has already loaded the signed-in user from the session cookie into `Ctx`. | |
| 117 | +3. `repo::load_repo` looks up the repository by owner and name and calls `AppState::access`. If the user can't read it, the handler returns 404. | |
| 118 | +4. Inside `web::blocking`: | |
| 119 | + - `git::resolve` splits `rest` into a ref and a path. | |
| 120 | + - `git::read_blob` reads the file. | |
| 121 | + - `highlight::highlight_lines` or `markdown::render` turns it into HTML. | |
| 122 | +5. The askama template renders. The middleware adds the security headers. | |
| 123 | + | |
| 124 | +### Pushing over SSH | |
| 125 | + | |
| 126 | +1. russh authenticates the client key against `ssh_keys.fingerprint`. | |
| 127 | +2. The client runs `git-receive-pack '/alice/project.git'`. `parse_command` extracts the service, owner and name. | |
| 128 | +3. Openhub looks up the repository and checks write access. | |
| 129 | +4. It records the branch tips, then spawns `git receive-pack <data>/repos/<id>.git` and relays data between the client and the process. | |
| 130 | +5. When the process exits successfully: | |
| 131 | + - `sync::after_push` updates `pushed_at`, fixes `HEAD` and refreshes open pull requests. | |
| 132 | + - Push hints are printed as `remote:` lines. | |
| 133 | + | |
| 134 | +### Merging a pull request in the browser | |
| 135 | + | |
| 136 | +1. `pulls::merge` checks write access and re-checks mergeability. | |
| 137 | +2. `git::merge` builds the new commits in memory with libgit2. | |
| 138 | +3. It moves the base branch with `reference_matching`, which only succeeds if the branch still points where it did when the merge started. | |
| 139 | +4. The database records the merge, and `sync::refresh_pulls` re-evaluates the repository's other open pull requests. | |
| 140 | + | |
| 141 | +## Design decisions | |
| 142 | + | |
| 143 | +| Decision | Why | | |
| 144 | +| --- | --- | | |
| 145 | +| A single binary with SQLite | Openhub targets small, self-hosted installations. One process and one data directory are easy to run, back up and move. It isn't designed for horizontal scaling. | | |
| 146 | +| A built-in SSH server | No system `sshd`, `authorized_keys` management or `git` Unix user is needed. Keys are managed in the web interface. | | |
| 147 | +| Pushes over SSH only | Key-based auth is the one credential for writing. HTTP is read-only, so passwords can never push. | | |
| 148 | +| Bare repositories named by database ID | Renaming a repository or user never touches the filesystem, and no filesystem path is ever built from user input. | | |
| 149 | +| `refs/pull/<n>/head` | A pull request's commits stay reachable, and its diff can still be shown, after its branch is deleted. | | |
| 150 | +| No background jobs | State is reconciled when it is next needed: after a push, and on pull request page views. There's nothing to schedule or monitor. | | |
| 151 | +| Server-rendered HTML, with optional JavaScript | Every page works without JavaScript. `static/app.js` only adds conveniences such as copy buttons and line highlighting. | | |
| 152 | + | |
| 153 | +## Scope | |
| 154 | + | |
| 155 | +Openhub is git hosting only: repositories, code browsing, history, SSH keys, collaborators and pull requests. Issues, CI, wikis, releases, forks, organisations, webhooks, line-level review comments and HTTP push are out of scope. Discuss any of these before adding them. | |
| 156 | + | |
| 157 | +## When you change this | |
| 158 | + | |
| 159 | +- **New module:** add it to the layout table in [`CLAUDE.md`](../CLAUDE.md) and to the components table above. | |
| 160 | +- **New runtime dependency** (another external program, say): add it to `check_git`-style startup checks, the `Dockerfile` runtime stage and the README requirements. |
| @@ -0,0 +1,206 @@ | ||
| 1 | +# Data model | |
| 2 | + | |
| 3 | +Openhub keeps two kinds of state: | |
| 4 | + | |
| 5 | +| Store | Holds | Source of truth for | | |
| 6 | +| --- | --- | --- | | |
| 7 | +| SQLite (`<data>/openhub.db`) | Users, sessions, SSH keys, repository metadata, collaborators, pull requests, pull request events | Who exists, who may do what, and pull request state | | |
| 8 | +| Bare repositories (`<data>/repos/<id>.git`) | Every commit, tree, blob, branch and tag, plus Openhub's `refs/pull/<n>/head` refs | All code and history | | |
| 9 | + | |
| 10 | +The two are linked by the repository ID and by branch names and commit SHAs stored in the database. [Invariants](#invariants) lists the rules that keep them consistent. | |
| 11 | + | |
| 12 | +**Source files:** | |
| 13 | +- `migrations/0001_initial.sql` holds the schema. | |
| 14 | +- `src/db.rs` holds the connection setup, row structs and every query. | |
| 15 | +- `src/git.rs` holds the repository operations. | |
| 16 | + | |
| 17 | +## Database connection | |
| 18 | + | |
| 19 | +`db::connect` opens the database with these settings: | |
| 20 | + | |
| 21 | +| Setting | Value | Why | | |
| 22 | +| --- | --- | --- | | |
| 23 | +| `create_if_missing` | true | A fresh data directory needs no setup | | |
| 24 | +| `journal_mode` | WAL | Readers don't block the writer | | |
| 25 | +| `synchronous` | NORMAL | Safe with WAL and faster than FULL | | |
| 26 | +| `foreign_keys` | on | Cascading deletes and referential integrity | | |
| 27 | +| `busy_timeout` | 5 s | Waits out short write locks instead of failing | | |
| 28 | +| Pool size | 8 connections | Plenty for a single small server | | |
| 29 | + | |
| 30 | +Pending migrations are applied before the servers start. | |
| 31 | + | |
| 32 | +## Conventions | |
| 33 | + | |
| 34 | +- **Timestamps** are Unix seconds (`INTEGER`) from `db::now()`. They are shown in UTC. | |
| 35 | +- **Booleans** are `INTEGER` 0 or 1, mapped to `bool` by sqlx. | |
| 36 | +- **Names compare case-insensitively.** `users.username`, `users.email` and `repositories.name` use `COLLATE NOCASE`. Lookups use the column's collation, so `/Alice/Project` finds `alice/project`. Uniqueness is case-insensitive too. | |
| 37 | +- **Row structs** (`User`, `Repo`, `SshKey`, `PullRequest`, `PrEvent`) derive `FromRow`. Queries use runtime `sqlx::query_as`, so building needs no database. | |
| 38 | +- **Shared column lists.** `USER_COLS`, `REPO_SELECT` and `PULL_SELECT` are constants in `db.rs`. Every query for that struct uses them, so each one returns the same columns. | |
| 39 | + | |
| 40 | +## Tables | |
| 41 | + | |
| 42 | +### `users` | |
| 43 | + | |
| 44 | +| Column | Type | Notes | | |
| 45 | +| --- | --- | --- | | |
| 46 | +| `id` | INTEGER PK | | | |
| 47 | +| `username` | TEXT, unique, NOCASE | Validated by `validate::username`. Appears in URLs (`/{owner}`) | | |
| 48 | +| `email` | TEXT, unique, NOCASE | Used for signing in and as the committer email for web merges | | |
| 49 | +| `display_name` | TEXT | Optional, up to 100 characters. `User::name()` falls back to the username | | |
| 50 | +| `bio` | TEXT | Optional, up to 500 characters | | |
| 51 | +| `password_hash` | TEXT | Argon2id in PHC string format (`$argon2id$v=19$…`) | | |
| 52 | +| `is_admin` | INTEGER | Site administrator. Set for the first account created | | |
| 53 | +| `created_at` | INTEGER | | | |
| 54 | + | |
| 55 | +### `sessions` | |
| 56 | + | |
| 57 | +| Column | Type | Notes | | |
| 58 | +| --- | --- | --- | | |
| 59 | +| `token_hash` | TEXT PK | Hex SHA-256 of the cookie token. The token itself is never stored | | |
| 60 | +| `user_id` | INTEGER, FK, cascade | | | |
| 61 | +| `created_at`, `expires_at` | INTEGER | `expires_at` is 30 days after sign-in. Expired rows are purged at each sign-in | | |
| 62 | + | |
| 63 | +### `ssh_keys` | |
| 64 | + | |
| 65 | +| Column | Type | Notes | | |
| 66 | +| --- | --- | --- | | |
| 67 | +| `id` | INTEGER PK | | | |
| 68 | +| `user_id` | INTEGER, FK, cascade | | | |
| 69 | +| `title` | TEXT | Defaults to the key comment, or failing that the key type | | |
| 70 | +| `fingerprint` | TEXT, unique | `SHA256:…`. Unique across all users, so a key identifies exactly one account | | |
| 71 | +| `public_key` | TEXT | Normalised OpenSSH line with the comment removed | | |
| 72 | +| `key_type` | TEXT | For example `ssh-ed25519` | | |
| 73 | +| `created_at` | INTEGER | | | |
| 74 | +| `last_used_at` | INTEGER, nullable | Updated on every successful SSH authentication | | |
| 75 | + | |
| 76 | +### `repositories` | |
| 77 | + | |
| 78 | +| Column | Type | Notes | | |
| 79 | +| --- | --- | --- | | |
| 80 | +| `id` | INTEGER PK | Also names the directory on disk: `repos/<id>.git` | | |
| 81 | +| `owner_id` | INTEGER, FK, cascade | | | |
| 82 | +| `name` | TEXT, NOCASE | Validated by `validate::repo_name`. `UNIQUE (owner_id, name)` | | |
| 83 | +| `description` | TEXT | Up to 350 characters | | |
| 84 | +| `is_private` | INTEGER | | | |
| 85 | +| `default_branch` | TEXT | Defaults to `main`. Kept in step with the bare repository's `HEAD` | | |
| 86 | +| `created_at`, `updated_at` | INTEGER | `updated_at` changes when settings are saved, and with `pushed_at` | | |
| 87 | +| `pushed_at` | INTEGER, nullable | Set after each successful push and each web merge. Orders repository lists | | |
| 88 | + | |
| 89 | +### `collaborators` | |
| 90 | + | |
| 91 | +| Column | Type | Notes | | |
| 92 | +| --- | --- | --- | | |
| 93 | +| `repo_id` | INTEGER, FK, cascade | Primary key together with `user_id` | | |
| 94 | +| `user_id` | INTEGER, FK, cascade | | | |
| 95 | +| `created_at` | INTEGER | | | |
| 96 | + | |
| 97 | +A row grants write access (push, merge, delete branches). There is only one collaborator level. | |
| 98 | + | |
| 99 | +### `pull_requests` | |
| 100 | + | |
| 101 | +| Column | Type | Notes | | |
| 102 | +| --- | --- | --- | | |
| 103 | +| `id` | INTEGER PK | Internal ID, used by `pr_events` | | |
| 104 | +| `repo_id` | INTEGER, FK, cascade | | | |
| 105 | +| `number` | INTEGER | Per-repository number shown in URLs. `MAX(number) + 1` inside a transaction. `UNIQUE (repo_id, number)` | | |
| 106 | +| `title`, `body` | TEXT | Title up to 256 characters. The body is Markdown | | |
| 107 | +| `author_id` | INTEGER, FK | No cascade (see [Deleting rows](#deleting-rows)) | | |
| 108 | +| `base_branch`, `head_branch` | TEXT | Branch names in the same repository. There are no forks | | |
| 109 | +| `state` | TEXT | `open`, `closed` or `merged` | | |
| 110 | +| `head_sha` | TEXT | Last known tip of the head branch, mirrored in `refs/pull/<number>/head` | | |
| 111 | +| `base_sha` | TEXT | Merge base of base and head. Tracked while open, frozen on close or merge | | |
| 112 | +| `merge_commit_sha` | TEXT, nullable | New tip of the base branch after a web merge. NULL when the merge was detected after a push | | |
| 113 | +| `merged_by` | INTEGER, FK, nullable | NULL when Openhub detected the merge on a page view | | |
| 114 | +| `merged_at`, `closed_at` | INTEGER, nullable | `closed_at` is also set on merge | | |
| 115 | +| `created_at`, `updated_at` | INTEGER | `updated_at` changes on every event, edit and head update | | |
| 116 | + | |
| 117 | +[Pull requests](pull-requests.md) explains how these columns change over a pull request's life. | |
| 118 | + | |
| 119 | +### `pr_events` | |
| 120 | + | |
| 121 | +The conversation timeline: comments, plus records of state changes. | |
| 122 | + | |
| 123 | +| Column | Type | Notes | | |
| 124 | +| --- | --- | --- | | |
| 125 | +| `id` | INTEGER PK | Used for `#event-<id>` anchors | | |
| 126 | +| `pr_id` | INTEGER, FK, cascade | | | |
| 127 | +| `author_id` | INTEGER, FK, nullable | NULL for events Openhub detected itself | | |
| 128 | +| `kind` | TEXT | `comment`, `closed`, `reopened`, `merged` or `branch_deleted` | | |
| 129 | +| `body` | TEXT | Markdown for `comment`, the branch name for `branch_deleted`, empty otherwise | | |
| 130 | +| `created_at` | INTEGER | | | |
| 131 | + | |
| 132 | +The pull request's own description is stored in `pull_requests.body`, not as an event. | |
| 133 | + | |
| 134 | +## Relationships | |
| 135 | + | |
| 136 | +``` | |
| 137 | +users ─┬─< sessions (cascade) | |
| 138 | + ├─< ssh_keys (cascade) | |
| 139 | + ├─< repositories ─┬─< collaborators >─ users (cascade both ways) | |
| 140 | + │ └─< pull_requests ─< pr_events (cascade) | |
| 141 | + ├─< pull_requests.author_id, .merged_by (no cascade) | |
| 142 | + └─< pr_events.author_id (no cascade) | |
| 143 | +``` | |
| 144 | + | |
| 145 | +### Deleting rows | |
| 146 | + | |
| 147 | +- **Deleting a repository** (`db::delete_repo`) cascades to its collaborators, pull requests and events. The handler then removes `repos/<id>.git`. If that fails, it logs a warning and the directory is left behind. | |
| 148 | +- **Deleting a user** isn't supported in the interface. Because foreign keys are enforced, `DELETE FROM users` fails for anyone who authored a pull request or event, or merged a pull request, in a repository they don't own. A future "delete user" feature must reassign or nullify those references first. For pull requests, `author_id` is `NOT NULL`, so that needs a placeholder user or a migration. | |
| 149 | + | |
| 150 | +## Repositories on disk | |
| 151 | + | |
| 152 | +Each repository is a bare git repository at `Config::repo_path(id)`, which is `<data>/repos/<id>.git`. File paths are never built from owner or repository names. | |
| 153 | + | |
| 154 | +`git::init_bare` creates it when a repository is created: | |
| 155 | +- `HEAD` points to `refs/heads/main`. | |
| 156 | +- `receive.hideRefs` is set to `refs/pull`. | |
| 157 | +- If the creator ticked "Add a README", `git::initial_commit` writes a single commit containing `README.md`. | |
| 158 | + | |
| 159 | +| Ref | Written by | Notes | | |
| 160 | +| --- | --- | --- | | |
| 161 | +| `HEAD` | `git init`, `git::ensure_head`, `git::set_head` | Symbolic ref to the default branch | | |
| 162 | +| `refs/heads/*` | Pushes, web merges, `git::delete_branch` | | | |
| 163 | +| `refs/tags/*` | Pushes | Tags can't be created in the web interface | | |
| 164 | +| `refs/pull/<n>/head` | `git::set_pull_ref` (create, refresh, merge, reopen) | Hidden from `receive-pack`, so pushes to it are rejected. Fetchable with `git fetch origin pull/<n>/head` | | |
| 165 | + | |
| 166 | +The objects that web merges create (merge, squash and rebased commits) are written to the same object database. If the final ref update fails, they are left unreferenced, and a later `git gc` removes them. Openhub never runs `git gc` itself (see [Operations](operations.md#repository-maintenance)). | |
| 167 | + | |
| 168 | +## Invariants | |
| 169 | + | |
| 170 | +These must stay true. Code that changes either store must preserve them. | |
| 171 | + | |
| 172 | +1. **Every `repositories` row has a directory.** `repo::create` inserts the row, then initialises `repos/<id>.git`. If initialisation fails, it deletes the row and the directory. | |
| 173 | +2. **IDs are never reused for a different directory.** SQLite `INTEGER PRIMARY KEY` can reuse the highest ID after a delete. Deleting a repository removes its directory, so a reused ID starts from an empty path. If directory removal failed, a later repository could pick up the stale directory. Remove leftovers by hand if the log warns about it. | |
| 174 | +3. **`default_branch` names `HEAD`'s target whenever that branch exists.** | |
| 175 | + - The settings page moves `HEAD` with `git::set_head` before saving. | |
| 176 | + - After a push, `sync::after_push` calls `git::ensure_head`. If the default branch is missing, `HEAD` moves to `main`, `master` or the first branch alphabetically, and the column is updated to match. | |
| 177 | +4. **Each pull request's `refs/pull/<n>/head` points at its `head_sha`.** Every code path that writes `head_sha` also calls `git::set_pull_ref`. | |
| 178 | +5. **At most one open pull request exists per (repository, base, head)**, enforced by checks in `pulls::create` and in reopening (`pulls::set_state`). There is no database constraint, so new code paths must check too. | |
| 179 | +6. **Closed and merged pull requests have a frozen `base_sha` and `head_sha`**, so their commits and diff render the same forever (see [Pull requests](pull-requests.md#which-commits-a-pull-request-shows)). | |
| 180 | + | |
| 181 | +## Changing the schema | |
| 182 | + | |
| 183 | +1. **Add a new file** such as `migrations/0002_add_repo_topics.sql`. Never edit an existing migration: sqlx stores a checksum of each applied migration, and the server refuses to start if one changes. | |
| 184 | +2. **Update the column list constant** (`USER_COLS`, `REPO_SELECT` or `PULL_SELECT`) and the row struct in `src/db.rs`. | |
| 185 | +3. **Add query functions** to `src/db.rs`. Keep SQL out of handlers. | |
| 186 | +4. **Prefer `ALTER TABLE … ADD COLUMN` with a default**, so existing rows stay valid. SQLite can't drop or alter constraints in place, so changing one needs the create-copy-rename pattern inside the migration. | |
| 187 | +5. **Run the smoke test against a fresh data directory, and against a copy of an existing one,** to check the migration applies to both. | |
| 188 | + | |
| 189 | +Migrations only go forward. Rolling back means restoring a backup taken before the upgrade. | |
| 190 | + | |
| 191 | +## Inspecting the database | |
| 192 | + | |
| 193 | +```sh | |
| 194 | +sqlite3 -readonly data/openhub.db | |
| 195 | +sqlite> .tables | |
| 196 | +sqlite> SELECT id, username, is_admin FROM users; | |
| 197 | +sqlite> SELECT r.id, u.username || '/' || r.name, r.is_private FROM repositories r JOIN users u ON u.id = r.owner_id; | |
| 198 | +sqlite> SELECT number, state, base_branch, head_branch, substr(head_sha, 1, 7) FROM pull_requests WHERE repo_id = 1; | |
| 199 | +``` | |
| 200 | + | |
| 201 | +[Operations](operations.md#administration-without-a-ui) has recipes for changing data by hand. | |
| 202 | + | |
| 203 | +## When you change this | |
| 204 | + | |
| 205 | +- **New table or column:** update this page, the migration, `src/db.rs`, and any template that should show it. | |
| 206 | +- **New ref namespace:** add it to the refs table above. If clients must not push to it, add it to `receive.hideRefs` in `git::configure`. Existing repositories only got the setting when they were created, so they need a migration step at startup. |
| @@ -0,0 +1,192 @@ | ||
| 1 | +# Git transport | |
| 2 | + | |
| 3 | +This page explains how git clients talk to Openhub. Pushes are accepted only over SSH, by a built-in SSH server. Clones and fetches work over SSH and over read-only smart HTTP. In every case the git protocol itself is handled by the installed `git` program, and Openhub decides who may run it against which repository. | |
| 4 | + | |
| 5 | +**Source files:** | |
| 6 | +- `src/ssh.rs`: the SSH server. | |
| 7 | +- `src/web/git_http.rs`: smart HTTP. | |
| 8 | +- `src/sync.rs`: bookkeeping after a push. | |
| 9 | +- `src/web/code.rs` (`archive`): downloads. | |
| 10 | +- `src/config.rs`: clone URLs. | |
| 11 | + | |
| 12 | +## Channels at a glance | |
| 13 | + | |
| 14 | +| Channel | Operations | Authentication | Runs | | |
| 15 | +| --- | --- | --- | --- | | |
| 16 | +| SSH (`--ssh-addr`, default port 2222) | clone, fetch, push | SSH public key | `git upload-pack` or `git receive-pack` | | |
| 17 | +| Smart HTTP (`--http-addr`, default port 3000) | clone, fetch | None for public repositories; Basic auth for private ones | `git upload-pack --stateless-rpc` | | |
| 18 | +| HTTP archive download | ZIP or tar.gz of a branch, tag or commit | Browser session, or anonymous for public repositories | `git archive` | | |
| 19 | + | |
| 20 | +## Clone URLs | |
| 21 | + | |
| 22 | +`Config` builds the URLs shown in the **Clone** dropdown, in push hints and in the documentation pages: | |
| 23 | + | |
| 24 | +| URL | Built by | Example | | |
| 25 | +| --- | --- | --- | | |
| 26 | +| SSH, port 22 | `ssh_clone_url` | `[email protected]:alice/project.git` | | |
| 27 | +| SSH, any other port | `ssh_clone_url` | `ssh://[email protected]:2222/alice/project.git` | | |
| 28 | +| HTTP | `http_clone_url` | `https://git.example.com/alice/project.git` | | |
| 29 | +| SSH test command | `ssh_test_command` | `ssh -T -p 2222 [email protected]` | | |
| 30 | + | |
| 31 | +The SSH host is `--ssh-host`, or failing that the host of `--public-url`. The SSH port is `--ssh-port`, or failing that the port of `--ssh-addr`. Set both when Openhub runs behind port forwarding, for example container port 2222 published as host port 22. | |
| 32 | + | |
| 33 | +## SSH server | |
| 34 | + | |
| 35 | +The server is built on russh (`ssh::serve`) and listens on `--ssh-addr`. | |
| 36 | + | |
| 37 | +### Host key | |
| 38 | + | |
| 39 | +On first start, Openhub generates an Ed25519 host key at `<data>/ssh_host_ed25519_key`, in OpenSSH format with mode `0600`, and reuses it on every later start. Its SHA-256 fingerprint is logged at startup so users can check it. If the key is lost, every client sees a "host key changed" warning, so keep it in backups. | |
| 40 | + | |
| 41 | +### Server settings | |
| 42 | + | |
| 43 | +| Setting | Value | | |
| 44 | +| --- | --- | | |
| 45 | +| Version string | `SSH-2.0-Openhub` | | |
| 46 | +| Authentication methods | `publickey` only. No passwords or keyboard-interactive | | |
| 47 | +| Delay after a rejected authentication | 300 ms (none for the first "none" probe) | | |
| 48 | +| Inactivity timeout | 1 hour | | |
| 49 | +| Keepalive interval | 60 s | | |
| 50 | + | |
| 51 | +### Authentication | |
| 52 | + | |
| 53 | +1. **`auth_publickey_offered`.** The client offers a public key, and Openhub accepts the offer only if its fingerprint is in `ssh_keys`. Clients therefore only sign with keys that will work, and move on to the next key otherwise. | |
| 54 | +2. **`auth_publickey`.** russh has verified the client's signature. Openhub loads the owning user, updates `last_used_at` and logs the sign-in with the peer address. | |
| 55 | + | |
| 56 | +The SSH username is ignored. Anything works, but clone URLs use `git`. | |
| 57 | + | |
| 58 | +### Channel requests | |
| 59 | + | |
| 60 | +| Request | Response | | |
| 61 | +| --- | --- | | |
| 62 | +| `env GIT_PROTOCOL=…` | Stored and passed to git, which enables protocol v2. Other variables are refused | | |
| 63 | +| `pty-req` | Refused | | |
| 64 | +| `shell` (for example `ssh -T`) | Prints `Hi <user>! You've successfully authenticated, but Openhub does not provide shell access.` and exits with status 1 | | |
| 65 | +| `subsystem` (for example `sftp`) | Refused | | |
| 66 | +| `exec` | Parsed as a git command. See below | | |
| 67 | + | |
| 68 | +### Command parsing | |
| 69 | + | |
| 70 | +`parse_command` accepts exactly these forms, which are what git clients send: | |
| 71 | + | |
| 72 | +``` | |
| 73 | +git-upload-pack '<path>' | |
| 74 | +git-receive-pack '<path>' | |
| 75 | +git upload-pack '<path>' | |
| 76 | +git receive-pack '<path>' | |
| 77 | +``` | |
| 78 | + | |
| 79 | +It then processes the path: | |
| 80 | + | |
| 81 | +1. Removes surrounding single or double quotes. | |
| 82 | +2. Rejects the path if it still contains `'`, `"`, `\` or a NUL byte. | |
| 83 | +3. Trims `/` from both ends, then removes a trailing `.git`. | |
| 84 | +4. Requires exactly `owner/name`. | |
| 85 | + | |
| 86 | +Anything else, including `git-upload-archive` (that is, `git archive --remote`), gets `ERROR: Openhub only supports git fetch and push over SSH.` and exit status 1. | |
| 87 | + | |
| 88 | +### Authorisation and execution | |
| 89 | + | |
| 90 | +`SshSession::exec_request` does the following: | |
| 91 | + | |
| 92 | +1. Looks up the repository with `db::repo_by_name` and calls `AppState::access`. | |
| 93 | +2. Checks permissions: | |
| 94 | + - No read access, or no such repository: `ERROR: Repository not found.` | |
| 95 | + - `receive-pack` without write access: `ERROR: You do not have permission to push to owner/name.` | |
| 96 | +3. For `receive-pack`, records every branch tip (`branch_tips`), so push hints can tell what changed. | |
| 97 | +4. Spawns `<git-bin> upload-pack|receive-pack <data>/repos/<id>.git`: | |
| 98 | + - The path comes from the database ID, never from the client's string. | |
| 99 | + - No shell is involved. | |
| 100 | + - stdin, stdout and stderr are piped. | |
| 101 | + - `kill_on_drop` is set. | |
| 102 | + - `GIT_PROTOCOL` is passed through when the client sent it. | |
| 103 | +5. Relays data: | |
| 104 | + | |
| 105 | + ``` | |
| 106 | + client ──SSH data──▶ SshSession::data ──unbounded mpsc──▶ task ──▶ git stdin | |
| 107 | + client ◀─SSH data─── handle.data ◀── forward() ◀───────────── git stdout | |
| 108 | + client ◀─ext. data── handle.extended_data(1) ◀── forward() ◀────── git stderr | |
| 109 | + client EOF ─────────▶ channel_eof drops the sender ──────────────▶ git stdin closed | |
| 110 | + ``` | |
| 111 | + | |
| 112 | +6. After git exits, sends git's exit status, then EOF, then closes the channel. | |
| 113 | + | |
| 114 | +**Why the `Channel` object is dropped.** `channel_open_session` drops russh's `Channel` handle on purpose and receives data through the `data` callback instead. Holding the handle without reading from it fills russh's per-channel buffer and deadlocks the session. The trade-off is that the queue to git's stdin is unbounded (see [Security](security.md#known-gaps)). | |
| 115 | + | |
| 116 | +### Protecting `refs/pull` | |
| 117 | + | |
| 118 | +Every repository is created with `receive.hideRefs=refs/pull`. `git receive-pack` then neither advertises `refs/pull/*` nor accepts updates to it: a push there fails with `deny updating a hidden ref`. `upload-pack` still advertises these refs, so clients can fetch them. | |
| 119 | + | |
| 120 | +## After a push | |
| 121 | + | |
| 122 | +When `receive-pack` exits with status 0, two things happen before the exit status is sent: | |
| 123 | + | |
| 124 | +1. **`sync::after_push`** runs: | |
| 125 | + - `db::touch_pushed` sets `pushed_at`. | |
| 126 | + - `git::ensure_head` makes sure `HEAD` names an existing branch. It prefers the current default branch, then `main`, then `master`, then the first branch alphabetically. If it had to pick a different branch, `repositories.default_branch` is updated. That's how a repository's first push of a non-`main` branch becomes its default. | |
| 127 | + - `sync::refresh_pulls` re-evaluates every open pull request in the repository. It records new heads and marks pull requests as merged when their head is now in the base (see [Pull requests](pull-requests.md#tracking-open-pull-requests)). | |
| 128 | +2. **Push hints** (`push_hints`): for each non-default branch whose tip changed in this push, Openhub prints one of the following on stderr. The client shows them prefixed with `remote:`. | |
| 129 | + | |
| 130 | + ``` | |
| 131 | + Create a pull request for 'feature' on Openhub by visiting: | |
| 132 | + https://git.example.com/alice/project/compare/main...feature | |
| 133 | + ``` | |
| 134 | + | |
| 135 | + ``` | |
| 136 | + View pull request for 'feature': | |
| 137 | + https://git.example.com/alice/project/pull/3 | |
| 138 | + ``` | |
| 139 | + | |
| 140 | + Deleted branches and the default branch get no hint. | |
| 141 | + | |
| 142 | +Errors in `after_push` are logged and don't fail the push, which by then has already been accepted. | |
| 143 | + | |
| 144 | +## Smart HTTP (read-only) | |
| 145 | + | |
| 146 | +Routes, with `{repo}` accepting an optional `.git` suffix: | |
| 147 | + | |
| 148 | +| Request | Result | | |
| 149 | +| --- | --- | | |
| 150 | +| `GET /{owner}/{repo}/info/refs?service=git-upload-pack` | Ref advertisement from `git upload-pack --stateless-rpc --advertise-refs`. Unless protocol v2 was requested, it's prefixed with the `# service=git-upload-pack` pkt-line and a flush packet | | |
| 151 | +| `POST /{owner}/{repo}/git-upload-pack` | Request body (gzip decoded if `Content-Encoding: gzip`) piped to `git upload-pack --stateless-rpc`; the output is streamed back | | |
| 152 | +| `GET …/info/refs?service=git-receive-pack` | 403: "Pushing over HTTP is not supported. Add an SSH key in your Openhub settings and push over SSH." | | |
| 153 | +| `POST …/git-receive-pack` | 403 | | |
| 154 | +| `GET …/info/refs` with no `service` (dumb HTTP) | 403: only the smart protocol is supported | | |
| 155 | + | |
| 156 | +Details: | |
| 157 | + | |
| 158 | +- **Authorisation** (`git_http::authorize`): | |
| 159 | + - A missing repository gets 404. | |
| 160 | + - A public repository needs no credentials. | |
| 161 | + - A private repository needs Basic auth. Missing or wrong credentials get 401 with `WWW-Authenticate: Basic realm="Openhub"`. Valid credentials without read access get 404. | |
| 162 | +- **Protocol v2.** The `Git-Protocol` header is passed through as `GIT_PROTOCOL` when it contains only `[A-Za-z0-9=:.]`. | |
| 163 | +- **Body limit.** The `git-upload-pack` route accepts request bodies up to 64 MB (`GIT_BODY_LIMIT` in `src/web/mod.rs`). Negotiation for large repositories lists many commits. Other routes keep axum's 2 MB default. | |
| 164 | +- **Middleware.** These paths skip the session lookup and the cross-origin check, because git clients send neither cookies nor `Origin` (see `ctx::is_git_request`). | |
| 165 | +- **Errors.** git's stderr is discarded. Failures are logged and returned as 500 with a short message. | |
| 166 | + | |
| 167 | +## Archive downloads | |
| 168 | + | |
| 169 | +`GET /{owner}/{repo}/archive/{ref}.zip` or `.tar.gz` (`code::archive`): | |
| 170 | + | |
| 171 | +1. Checks read access with `load_repo`. | |
| 172 | +2. Resolves `{ref}` with `git::resolve`, which accepts a branch, tag or commit SHA. The ref must not include a path. | |
| 173 | +3. Runs `git --git-dir <path> archive --format=zip|tar.gz --prefix=<repo>-<ref>/ <commit-sha>` and streams stdout to the client as `attachment; filename="<repo>-<ref>.<ext>"`. Any `/` in the ref becomes `-`. | |
| 174 | + | |
| 175 | +`git archive` honours the repository's own `.gitattributes`, including `export-ignore` and `export-subst`. See [Security](security.md) for why that matters. | |
| 176 | + | |
| 177 | +## Protocol features | |
| 178 | + | |
| 179 | +The installed git provides the protocol support: | |
| 180 | +- Protocol v0, v1 and v2. | |
| 181 | +- Shallow clones (`--depth`). | |
| 182 | +- Side-band progress. | |
| 183 | +- Atomic pushes. | |
| 184 | + | |
| 185 | +Openhub doesn't set `uploadpack.allowFilter`, so partial clone filters (`--filter=blob:none`) are ignored by the server. It doesn't install hooks, and nothing in the protocol can write a repository's config or hooks. Git LFS isn't supported. | |
| 186 | + | |
| 187 | +## When you change this | |
| 188 | + | |
| 189 | +- **New SSH command:** add it to `Service` and `parse_command`, together with its unit test. Decide which `Access` level it needs. Extend the smoke test. | |
| 190 | +- **New post-push behaviour:** put it in `sync::after_push`, so web merges and pushes share it where it applies. | |
| 191 | +- **New git configuration for hosted repositories:** add it to `git::configure`. Existing repositories don't pick it up automatically, so add a startup step if they need it. | |
| 192 | +- **Changes to HTTP authentication:** keep private repositories indistinguishable from missing ones, and keep pushes on SSH unless that is decided otherwise. |
| @@ -0,0 +1,149 @@ | ||
| 1 | +# Operations | |
| 2 | + | |
| 3 | +This page covers running an Openhub instance day to day: first start, reverse proxies, backups, upgrades, administration without a UI, and troubleshooting. The [project README](../README.md) has installation steps and the full [configuration table](../README.md#configuration). | |
| 4 | + | |
| 5 | +## First start checklist | |
| 6 | + | |
| 7 | +1. **Set `OPENHUB_PUBLIC_URL`** to the exact URL people will use, such as `https://git.example.com`. Links, HTTP clone URLs, push hints, the cross-origin check and the cookie `Secure` flag all depend on it. | |
| 8 | +2. **Set the SSH host and port** with `OPENHUB_SSH_HOST` and `OPENHUB_SSH_PORT` if clients reach SSH on a different host or port from the one Openhub listens on. Docker port mapping is the usual reason. | |
| 9 | +3. **Start the server and register the administrator account straight away.** The first account becomes site administrator (see [Security](security.md#known-gaps)). | |
| 10 | +4. **Add an SSH key** in **Settings → SSH keys**, then check it with the command shown there (`ssh -T -p 2222 git@host`). | |
| 11 | +5. **Set `OPENHUB_DISABLE_REGISTRATION=true`** once everyone who needs an account has one, unless you intend to run an open instance. | |
| 12 | +6. **Note the SSH host key fingerprint** from the startup log (`SSH host key fingerprint: SHA256:…`) so users can verify it on first connect. | |
| 13 | + | |
| 14 | +## Logs | |
| 15 | + | |
| 16 | +Openhub logs to stdout through `tracing`. Set the level with `RUST_LOG`: | |
| 17 | + | |
| 18 | +| `RUST_LOG` | Shows | | |
| 19 | +| --- | --- | | |
| 20 | +| unset (`openhub=info`) | Startup, SSH sign-ins, git commands run over SSH, and errors | | |
| 21 | +| `openhub=debug` | Plus SSH session errors | | |
| 22 | +| `openhub=info,tower_http=debug` | Plus one line for every HTTP request and response | | |
| 23 | + | |
| 24 | +Internal errors (HTTP 500) are logged with their full error chain. The user only sees a generic message. | |
| 25 | + | |
| 26 | +## Reverse proxy | |
| 27 | + | |
| 28 | +Openhub speaks plain HTTP. Put a TLS-terminating proxy in front of it for the web port only; SSH goes straight to the SSH port. | |
| 29 | + | |
| 30 | +**Caddy:** | |
| 31 | + | |
| 32 | +``` | |
| 33 | +git.example.com { | |
| 34 | + reverse_proxy 127.0.0.1:3000 | |
| 35 | +} | |
| 36 | +``` | |
| 37 | + | |
| 38 | +**nginx:** | |
| 39 | + | |
| 40 | +```nginx | |
| 41 | +server { | |
| 42 | + listen 443 ssl; | |
| 43 | + server_name git.example.com; | |
| 44 | + # ssl_certificate … | |
| 45 | + | |
| 46 | + client_max_body_size 64m; # git fetch negotiation for large repositories | |
| 47 | + location / { | |
| 48 | + proxy_pass http://127.0.0.1:3000; | |
| 49 | + proxy_set_header Host $host; | |
| 50 | + proxy_buffering off; # stream clones and archive downloads | |
| 51 | + } | |
| 52 | +} | |
| 53 | +``` | |
| 54 | + | |
| 55 | +Then set `OPENHUB_PUBLIC_URL=https://git.example.com`. The cross-origin check accepts form posts whose `Origin` matches either the public URL or the `Host` header, so proxies that rewrite `Host` still work. | |
| 56 | + | |
| 57 | +If you rate-limit at the proxy (recommended, see [Security](security.md#hardening-advice)), cover `POST /login`, `POST /register` and `/*/*/info/refs`. | |
| 58 | + | |
| 59 | +## Backups | |
| 60 | + | |
| 61 | +Everything lives in the data directory (see [Architecture](architecture.md#data-directory)): | |
| 62 | + | |
| 63 | +| Item | How to back it up | | |
| 64 | +| --- | --- | | |
| 65 | +| `openhub.db` | While running: `sqlite3 openhub.db ".backup backup.db"`, which takes a consistent snapshot. When stopped: copy the file. Don't copy a live database file without its `-wal` file | | |
| 66 | +| `repos/` | Copying bare repositories while a push is in progress can capture a half-written state. Stop the server, use a filesystem snapshot (ZFS, Btrfs or LVM), or accept that the latest push may need repeating | | |
| 67 | +| `ssh_host_ed25519_key` | Copy it. Without it, every client warns about a changed host key after a restore | | |
| 68 | + | |
| 69 | +**To restore:** stop Openhub, put all three items back in one data directory, and start it again. The database and `repos/` must come from the same point in time. A repository row without its directory, or a directory without its row, causes errors (see [Data model → Invariants](data-model.md#invariants)). | |
| 70 | + | |
| 71 | +## Upgrades | |
| 72 | + | |
| 73 | +1. **Back up** the data directory. | |
| 74 | +2. **Build or pull the new version.** From source, run `git pull && cargo build --release`. With Docker, run `docker compose build && docker compose up -d`. | |
| 75 | +3. **Start it.** Pending database migrations run automatically at startup, before the servers listen. | |
| 76 | + | |
| 77 | +Migrations only go forward. To roll back, restore the backup taken in step 1 and run the old version. | |
| 78 | + | |
| 79 | +## Repository maintenance | |
| 80 | + | |
| 81 | +Openhub never runs `git gc`. `git receive-pack` runs `git gc --auto` after pushes by default, which keeps most repositories tidy. To compact a repository by hand, or to clean up objects left by failed web merges: | |
| 82 | + | |
| 83 | +```sh | |
| 84 | +git --git-dir data/repos/<id>.git gc | |
| 85 | +``` | |
| 86 | + | |
| 87 | +To map a repository to its ID: | |
| 88 | + | |
| 89 | +```sh | |
| 90 | +sqlite3 -readonly data/openhub.db \ | |
| 91 | + "SELECT r.id FROM repositories r JOIN users u ON u.id = r.owner_id WHERE u.username = 'alice' AND r.name = 'project';" | |
| 92 | +``` | |
| 93 | + | |
| 94 | +Run maintenance as the same user that runs Openhub, so file ownership stays correct. In Docker: | |
| 95 | + | |
| 96 | +```sh | |
| 97 | +docker compose exec openhub git --git-dir /data/repos/<id>.git gc | |
| 98 | +``` | |
| 99 | + | |
| 100 | +## Administration without a UI | |
| 101 | + | |
| 102 | +Openhub has no admin pages. Do these tasks with `sqlite3` against `openhub.db`, after taking a backup. Changes take effect immediately, and the server doesn't need restarting. | |
| 103 | + | |
| 104 | +```sql | |
| 105 | +-- Make someone a site administrator (or set to 0 to remove) | |
| 106 | +UPDATE users SET is_admin = 1 WHERE username = 'bob'; | |
| 107 | + | |
| 108 | +-- Sign a user out everywhere | |
| 109 | +DELETE FROM sessions WHERE user_id = (SELECT id FROM users WHERE username = 'bob'); | |
| 110 | + | |
| 111 | +-- Revoke all of a user's SSH keys (stops them pushing) | |
| 112 | +DELETE FROM ssh_keys WHERE user_id = (SELECT id FROM users WHERE username = 'bob'); | |
| 113 | + | |
| 114 | +-- Transfer a repository to another user (the new owner must not already have a repository with that name) | |
| 115 | +UPDATE repositories SET owner_id = (SELECT id FROM users WHERE username = 'carol') | |
| 116 | + WHERE id = 42; | |
| 117 | +``` | |
| 118 | + | |
| 119 | +**Resetting a forgotten password.** There's no reset flow. You can set a new hash made by any Argon2id tool that writes the standard PHC string format. For example, with the `argon2` command-line tool (Debian package `argon2`): | |
| 120 | + | |
| 121 | +```sh | |
| 122 | +printf '%s' 'new-password' | argon2 "$(openssl rand -hex 16)" -id -e | |
| 123 | +# → $argon2id$v=19$m=4096,t=3,p=1$… | |
| 124 | +``` | |
| 125 | + | |
| 126 | +```sql | |
| 127 | +UPDATE users SET password_hash = '$argon2id$v=19$…' WHERE username = 'bob'; | |
| 128 | +``` | |
| 129 | + | |
| 130 | +**Deleting a user.** `DELETE FROM users …` removes their sessions, keys, repositories and collaborations, through cascades. It fails if they authored or merged pull requests, or wrote pull request events, in other users' repositories (see [Data model → Deleting rows](data-model.md#deleting-rows)). Deleting their rows also doesn't remove their repositories' directories under `repos/`. Delete each repository in the web interface first, while signed in as an administrator. | |
| 131 | + | |
| 132 | +## Troubleshooting | |
| 133 | + | |
| 134 | +| Symptom | Likely cause | Fix | | |
| 135 | +| --- | --- | --- | | |
| 136 | +| Forms fail with **"Cross-origin request rejected"** | The browser's origin matches neither `OPENHUB_PUBLIC_URL` nor the `Host` header the server receives | Set the public URL to exactly what's in the address bar, including scheme and port | | |
| 137 | +| Startup fails with **"could not run `git`"** | git isn't installed or isn't on `PATH` | Install git, or set `OPENHUB_GIT_BIN` | | |
| 138 | +| Startup fails with a **migration checksum or "previously applied but has been modified"** error | An already-applied file in `migrations/` was edited | Restore the original file and put the change in a new migration | | |
| 139 | +| **"database is locked"** errors | Another process held a write lock for more than 5 seconds, often a `sqlite3` shell with an open transaction | Close the other connection. Open shells with `-readonly` for inspection | | |
| 140 | +| `git push` says **"Permission denied (publickey)"** | The key isn't registered, or ssh offered a different key | Add the key in Settings. Test with `ssh -i ~/.ssh/key -o IdentitiesOnly=yes -T -p 2222 git@host` | | |
| 141 | +| `git push` says **"ERROR: Repository not found."** | The path is wrong, or the account can't read the repository | Check `owner/name` in the remote URL, and that the key belongs to the expected account (`ssh -T` greets you by username) | | |
| 142 | +| `git push` says **"You do not have permission to push to …"** | The account can read but isn't the owner or a collaborator | The owner adds the account under repository **Settings → Collaborators** | | |
| 143 | +| ssh warns **"REMOTE HOST IDENTIFICATION HAS CHANGED"** | `ssh_host_ed25519_key` was lost or replaced | Restore the key from backup. If it really is gone, users remove the old entry with `ssh-keygen -R '[host]:2222'` | | |
| 144 | +| Push to `refs/pull/…` fails with **"deny updating a hidden ref"** | Expected: Openhub manages those refs | Push to a branch instead | | |
| 145 | +| `git push` over HTTP fails with **403** | HTTP is read-only by design | Use the SSH remote URL | | |
| 146 | +| Clone URLs show **localhost** or the wrong port | `OPENHUB_PUBLIC_URL`, `OPENHUB_SSH_HOST` or `OPENHUB_SSH_PORT` isn't set | Set them and restart | | |
| 147 | +| Large HTTP clones fail with **413** | A proxy limits request bodies below the 64 MB Openhub allows for fetch negotiation | Raise the proxy limit (`client_max_body_size` in nginx) | | |
| 148 | +| A repository page shows **"Something went wrong"** | An internal error, such as a missing `repos/<id>.git` directory | Check the log for `internal error:`. Restore the directory from backup, or delete the repository | | |
| 149 | +| A new repository unexpectedly shows **old content** | A previous repository with the same ID left its directory behind (deletion logs `could not remove`) | Delete the new repository, remove the stale `repos/<id>.git`, then create it again | |
| @@ -0,0 +1,206 @@ | ||
| 1 | +# Pull requests | |
| 2 | + | |
| 3 | +A pull request proposes merging one branch (the **head**) into another (the **base**) of the same repository. Openhub has no forks, so both branches always live in one bare repository. This page covers how pull requests are created, tracked as branches move, displayed, merged and closed. | |
| 4 | + | |
| 5 | +**Source files:** | |
| 6 | +- `src/web/pulls.rs`: handlers. | |
| 7 | +- `src/sync.rs`: tracking. | |
| 8 | +- `src/git.rs`: `mergeability`, `merge`, `commits_between`, `range_diff`, `set_pull_ref`. | |
| 9 | +- `src/db.rs`: pull request and event queries. | |
| 10 | +- `templates/repo/{compare,pulls,pull}.html`: pages. | |
| 11 | + | |
| 12 | +## Lifecycle | |
| 13 | + | |
| 14 | +``` | |
| 15 | + create (compare page) | |
| 16 | + │ | |
| 17 | + ▼ | |
| 18 | + ┌──────────────▶ open ───────────────┐ | |
| 19 | + │ │ │ merged in the web interface, or the | |
| 20 | + │ reopen │ close │ head reached the base through a push | |
| 21 | + │ ▼ ▼ | |
| 22 | + └────────────── closed merged (final) | |
| 23 | +``` | |
| 24 | + | |
| 25 | +| State | Range shown | Can change to | | |
| 26 | +| --- | --- | --- | | |
| 27 | +| `open` | Live: merge base of the current tips → head tip | `closed` (author or writer), `merged` (writer merges, or detected) | | |
| 28 | +| `closed` | Frozen at close: `base_sha..head_sha` | `open` (author or writer, if both branches still exist) | | |
| 29 | +| `merged` | Frozen at merge: `base_sha..head_sha` | Nothing; this state is final | | |
| 30 | + | |
| 31 | +### Key columns | |
| 32 | + | |
| 33 | +| Column | Meaning | | |
| 34 | +| --- | --- | | |
| 35 | +| `head_sha` | Last known tip of the head branch, mirrored in `refs/pull/<n>/head` | | |
| 36 | +| `base_sha` | The merge base of base and head. Despite the name, it is not the base branch's tip | | |
| 37 | +| `merge_commit_sha` | For web merges, the new tip of the base branch: the merge commit, the squash commit or the last rebased commit | | |
| 38 | + | |
| 39 | +## Comparing and creating | |
| 40 | + | |
| 41 | +**The compare page** is `GET /{owner}/{repo}/compare/{base}...{head}` (`pulls::compare`). `/compare?base=…&head=…` redirects there, and `/compare/{head}` uses the default branch as the base. It shows, in order: | |
| 42 | + | |
| 43 | +1. A notice instead of results when no head is chosen, a branch doesn't exist, base and head are the same, the histories are unrelated, or the head has nothing new. | |
| 44 | +2. **Commits:** `git::commits_between(base_tip, head_tip)`, the commits on the head that aren't on the base, oldest first. | |
| 45 | +3. **Mergeability**, from `git::mergeability`. | |
| 46 | +4. **Diff:** `git::range_diff(merge_base, head_tip)`. This is a three-dot diff: only the head's changes since it diverged, not changes made on the base since then. | |
| 47 | +5. **A form** whose title defaults to the single commit's summary when there is exactly one commit, or else the branch name made readable (`feature/add-login` becomes `Feature/add login`). | |
| 48 | +6. **A link** to the existing open pull request for this base and head, instead of the form, when there is one. | |
| 49 | + | |
| 50 | +**Creating** (`POST /{owner}/{repo}/pulls`, `pulls::create`) requires: | |
| 51 | +- a signed-in user with read access; | |
| 52 | +- a non-empty title, cut to 256 characters; | |
| 53 | +- both branches to exist; | |
| 54 | +- base and head to differ; | |
| 55 | +- the head not to be already contained in the base; | |
| 56 | +- the two histories to share a merge base. | |
| 57 | + | |
| 58 | +If an open pull request already exists for the same base and head, the request redirects to it instead. | |
| 59 | + | |
| 60 | +On success, Openhub: | |
| 61 | +1. Assigns the next number for this repository: `MAX(number) + 1`, inside a transaction. | |
| 62 | +2. Stores `head_sha` (the head tip) and `base_sha` (the merge base). | |
| 63 | +3. Writes `refs/pull/<n>/head`. | |
| 64 | + | |
| 65 | +## Tracking open pull requests | |
| 66 | + | |
| 67 | +Pushes can move either branch at any time, so `sync::refresh_pulls(state, repo_id, actor)` reconciles every open pull request in a repository. It runs: | |
| 68 | + | |
| 69 | +| Trigger | `actor` recorded for detected merges | | |
| 70 | +| --- | --- | | |
| 71 | +| After every successful SSH push (`sync::after_push`) | The pusher | | |
| 72 | +| After a web merge (`pulls::merge`), to update the repository's other pull requests | The user who merged | | |
| 73 | +| On every view of an open pull request's conversation, commits or files tab (`pulls::load_pull`) | None, so the timeline reads "This pull request was merged" | | |
| 74 | + | |
| 75 | +For each open pull request: | |
| 76 | + | |
| 77 | +1. **The head branch no longer exists:** nothing changes. The page says the head branch no longer exists and still shows the commits from the stored head. | |
| 78 | +2. **The head tip moved:** `refs/pull/<n>/head` is moved to the new tip. | |
| 79 | +3. **The head tip is now an ancestor of, or equal to, the base tip:** the pull request is marked `merged`. This catches merges done on the command line and fast-forward pushes. In the database: | |
| 80 | + - `merged_by` is set to `actor`. | |
| 81 | + - `merge_commit_sha` is set to NULL. | |
| 82 | + - `base_sha` keeps the stored merge base (or is computed if missing). | |
| 83 | + - `head_sha` is set to the head tip. | |
| 84 | + - A `merged` event is added. | |
| 85 | +4. **Otherwise:** if the head tip or the merge base changed, `head_sha` and `base_sha` are updated. | |
| 86 | + | |
| 87 | +The git work runs in one `spawn_blocking` call, and the database writes follow it. | |
| 88 | + | |
| 89 | +## Which commits a pull request shows | |
| 90 | + | |
| 91 | +`pulls::pull_range` decides the commit range for every pull request tab: | |
| 92 | + | |
| 93 | +- **Open:** `(merge_base(base tip, head tip), head tip)`, computed live. If the head branch is gone, it falls back to the stored `head_sha`, or `refs/pull/<n>/head`. If the base branch is gone, it falls back to the stored `base_sha`. | |
| 94 | +- **Closed or merged:** `(base_sha, head_sha)`, exactly as frozen. This is why `base_sha` stores the merge base rather than the base tip: after a merge, the base tip contains the head, so a live range would be empty. | |
| 95 | + | |
| 96 | +From that range, `pulls::pull_data` derives: | |
| 97 | +- the commits (`commits_between`); | |
| 98 | +- the file, addition and deletion counts (`diff_stats`); | |
| 99 | +- the rendered diff (`range_diff`, on the files tab only); | |
| 100 | +- for open pull requests with both branches present, the mergeability. | |
| 101 | + | |
| 102 | +## Mergeability | |
| 103 | + | |
| 104 | +`git::mergeability(repo, base_tip, head_tip)` is computed on every view of an open pull request. It isn't cached. | |
| 105 | + | |
| 106 | +| Result | Meaning | Page shows | | |
| 107 | +| --- | --- | --- | | |
| 108 | +| `UpToDate` | The head is already contained in the base | (Normally detected as merged first) | | |
| 109 | +| `Conflicts` | An in-memory three-way merge (`merge_commits`) has conflicts | Conflict notice with command-line instructions | | |
| 110 | +| `Clean` | The merge succeeds | Merge controls for users with write access | | |
| 111 | + | |
| 112 | +## Merging in the web interface | |
| 113 | + | |
| 114 | +`POST /{owner}/{repo}/pull/{n}/merge` with `method=merge|squash|rebase` (`pulls::merge` → `git::merge`). It requires `write`. | |
| 115 | + | |
| 116 | +1. Re-reads both branch tips and re-checks mergeability. If there are conflicts, or nothing to merge, it shows an error message and changes nothing. | |
| 117 | +2. Builds the result in memory. The base branch isn't touched yet. | |
| 118 | + | |
| 119 | + | Method | Result | Author | Committer | Message | | |
| 120 | + | --- | --- | --- | --- | --- | | |
| 121 | + | `merge` | One commit with parents `[base tip, head tip]`, tree from the three-way merge | Merging user | Merging user | `Merge pull request #N from <head>`, a blank line, then the title | | |
| 122 | + | `squash` | One commit with parent `[base tip]`, tree from the three-way merge | Author of the head tip commit | Merging user | `<title> (#N)`, plus a `* <summary>` line per commit when there's more than one | | |
| 123 | + | `rebase` | Each non-merge commit in `base..head` cherry-picked in order onto the base tip | Original author | Merging user | Original message | | |
| 124 | + | |
| 125 | + About rebasing: | |
| 126 | + - Merge commits in the range are skipped, as `git rebase` does. | |
| 127 | + - A commit that becomes empty is dropped. | |
| 128 | + - A conflict in any commit fails the whole merge with `Commit abc1234 cannot be rebased cleanly`. | |
| 129 | + | |
| 130 | + The merging user's identity is their display name (or username) and account email, with the current time. Commits aren't signed. No hooks run. | |
| 131 | +3. Moves the base branch with `repo.reference_matching(refs/heads/<base>, new_tip, force, expected = base tip from step 1)`. This is a compare-and-swap: if anyone pushed to the base since step 1, the update fails with "The base branch changed while merging; please try again". The objects already written stay unreferenced and are harmless. | |
| 132 | +4. Points `refs/pull/<n>/head` at the head tip that was merged. | |
| 133 | +5. Records the merge in the database: | |
| 134 | + - `mark_merged` sets the state, `merged_by`, `merge_commit_sha = new_tip`, `head_sha` and `base_sha = merge base`. | |
| 135 | + - A `merged` event is added. | |
| 136 | + - `pushed_at` is updated. | |
| 137 | + - `refresh_pulls` runs for the rest of the repository. | |
| 138 | + | |
| 139 | +The head branch is kept. After a merge or close, the page offers **Delete branch** when all of these hold: | |
| 140 | +- the user has write access; | |
| 141 | +- the branch still exists; | |
| 142 | +- it isn't the default branch; | |
| 143 | +- no other open pull request uses it as its head. | |
| 144 | + | |
| 145 | +Deleting it records a `branch_deleted` event. | |
| 146 | + | |
| 147 | +## Closing, reopening and editing | |
| 148 | + | |
| 149 | +- **Close:** use the **Close pull request** button beside the comment box. Any text in the box is posted as a comment first. Alternatively, `POST …/state` with `action=close`. | |
| 150 | + - Allowed for the author, or anyone with write access. | |
| 151 | + - Freezes `head_sha` and `base_sha` to the current `pull_range` and records a `closed` event. | |
| 152 | +- **Reopen** (`POST …/state` with `action=reopen`): | |
| 153 | + - Allowed for the author, or anyone with write access. | |
| 154 | + - Requires both branches to exist, and no other open pull request for the same base and head. | |
| 155 | + - Recomputes `head_sha` and the merge base, moves `refs/pull/<n>/head` and records a `reopened` event. | |
| 156 | + - Merged pull requests can't be reopened. | |
| 157 | +- **Edit** (`POST …/edit`): the author, or anyone with write access, can change the title and description. | |
| 158 | + | |
| 159 | +## Conversation | |
| 160 | + | |
| 161 | +The description (`pull_requests.body`) and comments (`pr_events` rows with `kind = 'comment'`) are Markdown, rendered with `markdown::render` without a link base. Relative links there aren't rewritten. | |
| 162 | + | |
| 163 | +Any signed-in user with read access can comment. Comments can't be edited or deleted. | |
| 164 | + | |
| 165 | +| Event `kind` | Created by | Shown as | | |
| 166 | +| --- | --- | --- | | |
| 167 | +| `comment` | `pulls::comment` | Comment box | | |
| 168 | +| `closed` | `pulls::close_pull` | "*user* closed this" | | |
| 169 | +| `reopened` | `pulls::set_state` | "*user* reopened this" | | |
| 170 | +| `merged` | `pulls::merge`, `sync::refresh_pulls` | "*user* merged commit abc1234 into main", or "This pull request was merged" when there's no author | | |
| 171 | +| `branch_deleted` | `repo::delete_branch` (when called with `pr`) | "*user* deleted the *branch* branch" | | |
| 172 | + | |
| 173 | +## Where pull requests appear | |
| 174 | + | |
| 175 | +- **Repository → Pull requests:** a list with open and closed tabs. The closed tab includes merged pull requests. The repository tab bar shows the open count. | |
| 176 | +- **Dashboard:** up to 20 open pull requests that the user authored, or that are in repositories they own or collaborate on. | |
| 177 | +- **Branches page:** a link from each branch to its open pull request. | |
| 178 | +- **Repository home:** a banner offering **Compare & pull request** when a non-default branch got a commit within the last hour, has no open pull request, and the viewer has write access. | |
| 179 | +- **Push output:** the "Create a pull request" or "View pull request" hints (see [Git transport](git-transport.md#after-a-push)). | |
| 180 | + | |
| 181 | +## Checking out a pull request locally | |
| 182 | + | |
| 183 | +```sh | |
| 184 | +git fetch origin pull/<n>/head:pr-<n> | |
| 185 | +git checkout pr-<n> | |
| 186 | +``` | |
| 187 | + | |
| 188 | +The sidebar of every pull request shows these commands. | |
| 189 | + | |
| 190 | +## Edge cases | |
| 191 | + | |
| 192 | +| Situation | Behaviour | | |
| 193 | +| --- | --- | | |
| 194 | +| Head branch force-pushed | The next refresh records the new head. `refs/pull/<n>/head` moves, so the old commits become unreferenced unless another ref holds them | | |
| 195 | +| Head branch deleted while open | The pull request stays open and shows its last known commits. It can't be merged until the branch is pushed again | | |
| 196 | +| Head branch renamed | Seen as a deletion. The pull request doesn't follow the new name | | |
| 197 | +| Base branch deleted | Can't be merged. The page says the base branch no longer exists | | |
| 198 | +| Head merged into base by a plain `git push` | Detected after that push and marked merged, with the pusher recorded | | |
| 199 | +| Base and head merged locally, but the merge result never pushed | Nothing changes until the base branch on the server contains the head | | |
| 200 | +| Two pull requests share a head branch with different bases | Allowed. Each is tracked independently | | |
| 201 | + | |
| 202 | +## When you change this | |
| 203 | + | |
| 204 | +- **New pull request state or event kind:** update the state table and event table above, the `-- …` comments in the migration (as a new migration if the schema changes), `templates/repo/pull.html` and the smoke test. | |
| 205 | +- **New merge method:** add it to `git::MergeMethod`, implement it in `git::merge` (keeping the compare-and-swap ref update) and add an option to the merge form. | |
| 206 | +- **Anything that writes `head_sha`** must also call `git::set_pull_ref`. |
Binary file not shown.
Binary file not shown.
Binary file not shown.
| @@ -0,0 +1,120 @@ | ||
| 1 | +# Security | |
| 2 | + | |
| 3 | +This page describes what Openhub protects against, how, and where it is still exposed. It is meant for anyone deciding how to deploy Openhub, and for anyone changing code that handles untrusted input. | |
| 4 | + | |
| 5 | +## Threat model | |
| 6 | + | |
| 7 | +**Trusted:** | |
| 8 | +- The operator, who has shell access and owns the data directory. | |
| 9 | +- The site administrator, who has admin rights on every repository. | |
| 10 | +- Repository owners and collaborators, within their own repositories. | |
| 11 | + | |
| 12 | +**Untrusted:** | |
| 13 | +- Anonymous visitors. | |
| 14 | +- Signed-in users acting on repositories they don't belong to. | |
| 15 | +- When registration is open, anyone on the internet, who can create an account and push arbitrary repositories of their own. | |
| 16 | + | |
| 17 | +**Untrusted input enters through:** | |
| 18 | +- **HTTP requests:** forms, URLs, headers and smart HTTP bodies. | |
| 19 | +- **SSH sessions:** authentication attempts, exec commands and pack data. | |
| 20 | +- **Repository content.** Every object pushed by a user is later parsed by Openhub (libgit2, syntax highlighting, Markdown) and by `git` (`upload-pack`, `receive-pack`, `archive`). | |
| 21 | +- **Markdown and text** in profiles, repository descriptions, pull requests and comments. | |
| 22 | + | |
| 23 | +## Protections in place | |
| 24 | + | |
| 25 | +| Area | Protection | Where | | |
| 26 | +| --- | --- | --- | | |
| 27 | +| Passwords | Argon2id with a random salt per password. Verification runs even for unknown accounts, so timing doesn't reveal which accounts exist | `src/web/account.rs` | | |
| 28 | +| Sessions | 256-bit random tokens. Only their SHA-256 is stored. `HttpOnly` and `SameSite=Lax` cookies, `Secure` under HTTPS. Other sessions are revoked when the password changes | `src/web/ctx.rs`, `src/web/account.rs` | | |
| 29 | +| CSRF | `SameSite=Lax`, plus an `Origin` check on every non-GET request outside git HTTP | `ctx::cross_origin` | | |
| 30 | +| Open redirects | `next` and `return_to` must be same-site paths | `web::safe_next` | | |
| 31 | +| XSS in pages | askama escapes every value. `\|safe` is only used on HTML generated by Openhub. A strict CSP (`script-src 'self'`, no inline script, `object-src 'none'`, `base-uri 'self'`) applies | templates, `ctx::middleware` | | |
| 32 | +| XSS through Markdown | Raw HTML is sanitised by ammonia after rendering. Links get `rel="noopener noreferrer nofollow"` | `src/markdown.rs` | | |
| 33 | +| XSS through hosted files | Raw files are served as `text/plain` (or as an image, or as an attachment), with `nosniff` and `CSP: default-src 'none'; sandbox` | `code::raw` | | |
| 34 | +| Clickjacking | `frame-ancestors 'none'` on HTML pages | `ctx::middleware` | | |
| 35 | +| SSH command execution | Only `git-upload-pack` and `git-receive-pack` are accepted, through a strict parser that rejects quotes, backslashes and NUL bytes. git is spawned directly with no shell. The repository path comes from the database ID | `src/ssh.rs` | | |
| 36 | +| SSH access | Public-key authentication only. A 300 ms delay after each rejection. No shell, pty or subsystems | `src/ssh.rs` | | |
| 37 | +| Path traversal | No filesystem path is built from user input. Repository paths are `repos/<id>.git`. File paths inside a repository are looked up in git trees, not on disk | `Config::repo_path`, `src/git.rs` | | |
| 38 | +| Private repository privacy | Unreadable repositories return 404 on the web and "Repository not found" over SSH | `repo::load_repo`, `ssh.rs` | | |
| 39 | +| Writing to repositories | Only SSH `receive-pack` (with write access) and web merges and branch deletions (with write access) change refs. Pushes can't write repository config or hooks, and no hooks are installed | `src/ssh.rs`, `src/web/pulls.rs`, `src/web/repo.rs` | | |
| 40 | +| Openhub-managed refs | `receive.hideRefs=refs/pull` stops clients rewriting pull request heads | `git::configure` | | |
| 41 | +| Merge races | The base branch is updated with a compare-and-swap, so a merge can't overwrite a concurrent push | `git::merge` | | |
| 42 | +| Pasted private keys | The key form rejects input that contains `PRIVATE KEY` | `validate::ssh_public_key` | | |
| 43 | +| Rendering cost | Highlighting stops at 512 KB. Files over 1 MB aren't displayed. Diffs stop at 3,000 lines per file and 20,000 in total. The last-commit search stops at 5,000 commits. Request bodies are limited to 2 MB (64 MB for `git-upload-pack`) | `src/highlight.rs`, `src/web/code.rs`, `src/git.rs`, `src/web/mod.rs` | | |
| 44 | +| Container | The Docker image runs as an unprivileged `openhub` user, which owns only `/data` | `Dockerfile` | | |
| 45 | + | |
| 46 | +## Known gaps | |
| 47 | + | |
| 48 | +The protections above suit an instance whose users are trusted. The gaps below matter most when registration is open, or when the server is reachable from the internet. | |
| 49 | + | |
| 50 | +### Abuse and resource exhaustion | |
| 51 | + | |
| 52 | +1. **Registration is open by default, and the first account becomes administrator.** Anyone who finds the URL can create an account, along with unlimited public and private repositories. If the server is reachable before the operator registers, a stranger can take the administrator account. | |
| 53 | +2. **There is no rate limiting** on sign-in, sign-up, SSH authentication or HTTP Basic authentication. Each password check costs about 19 MB of memory and noticeable CPU. Sign-in runs one even for unknown accounts, and private HTTP clones run one per request. A flood of attempts can exhaust memory or CPU without needing an account. | |
| 54 | +3. **There are no quotas.** Repository count, repository size and push size are all unlimited. | |
| 55 | +4. **git child processes have no timeouts or resource limits.** A slow or huge push or fetch holds a process for as long as the client keeps the connection open, up to the 1-hour SSH inactivity timeout. | |
| 56 | +5. **The SSH input queue is unbounded.** Data from the client is queued in memory faster than git can read it (see [Git transport](git-transport.md#authorisation-and-execution)), so a fast client can grow server memory. | |
| 57 | +6. **gzip request bodies to `git-upload-pack` are decompressed without a size limit.** The 64 MB limit applies to the compressed body only. | |
| 58 | +7. **Some page loads are expensive for large repositories.** The commit count on the repository home walks the full history. Pull request pages compute mergeability with an in-memory merge on every view. Pathological input can make syntax highlighting slow, within the 512 KB cap. | |
| 59 | + | |
| 60 | +### Code execution | |
| 61 | + | |
| 62 | +Openhub's own code is Rust, so it is memory-safe. The real exposure is C code parsing repository data that users control: | |
| 63 | + | |
| 64 | +8. **libgit2 runs inside the server process.** It parses pushed objects to show trees, diffs and merges. An attacker controls those objects, and can trigger merges of crafted content in their own repository. A memory-corruption bug in libgit2 (there have been such CVEs, for example CVE-2024-24577) would compromise the whole process: every repository, the password hashes and the SSH host key. Keep the `git2`/`libgit2-sys` dependency up to date. | |
| 65 | +9. **The `git` program parses untrusted packs** in `receive-pack` and `upload-pack`, and runs with the same filesystem access as Openhub. Keep the system `git` patched. | |
| 66 | +10. **`git archive` honours a repository's own `.gitattributes`, including `export-subst`.** That setting feeds attacker-controlled format strings into git's pretty-format engine, the route used by CVE-2022-41903 (heap overflow in `git archive`). A patched git closes that bug. The feature remains attack surface. | |
| 67 | +11. **`receive.fsckObjects` isn't enabled,** so malformed objects are accepted. Openhub can then store and serve objects crafted to attack the git *clients* of people who clone them, such as past `.gitmodules` and symlink exploits. Enabling it rejects some older real-world repositories with harmless defects. That's why it's off, but the trade-off is different for a public instance. | |
| 68 | + | |
| 69 | +### Privacy and moderation | |
| 70 | + | |
| 71 | +12. **An anonymous HTTP clone can tell a private repository from a missing one.** The private one gets 401, the missing one 404 (see [Accounts and access](accounts-and-access.md#private-repositories-look-like-missing-ones)). The web interface and SSH don't leak this. | |
| 72 | +13. **Images in Markdown can load from any HTTPS host** (`img-src https:`), so a README can show the IP addresses and user agents of its readers to a third party. | |
| 73 | +14. **There are no moderation tools.** There's no admin list of users, no way to disable or delete accounts in the interface, and comments can't be edited or deleted. An administrator can delete any repository. | |
| 74 | +15. **There's no email verification, password reset, two-factor authentication or session list.** Sessions aren't bound to an IP address or client. | |
| 75 | + | |
| 76 | +## Hardening advice | |
| 77 | + | |
| 78 | +### Private or team instance (registration off, trusted users) | |
| 79 | + | |
| 80 | +1. **Register the administrator account first**, before the server is reachable from anywhere else. | |
| 81 | +2. **Then set `OPENHUB_DISABLE_REGISTRATION=true`.** Create later accounts by briefly re-enabling registration, or by having the administrator register them. | |
| 82 | +3. **Serve over HTTPS** behind a reverse proxy, and set `OPENHUB_PUBLIC_URL` to the `https://` URL. That also marks cookies `Secure`. | |
| 83 | +4. **Add rate limiting at the proxy** for `POST /login`, `POST /register` and `/*/*/info/refs`. | |
| 84 | +5. **Keep git, the base image and Rust dependencies updated:** use `apt upgrade` or rebuild the image, run `cargo update`, and rebuild. | |
| 85 | +6. **Harden the container:** | |
| 86 | + ```yaml | |
| 87 | + services: | |
| 88 | + openhub: | |
| 89 | + read_only: true | |
| 90 | + cap_drop: [ALL] | |
| 91 | + security_opt: ["no-new-privileges:true"] | |
| 92 | + tmpfs: [/tmp] | |
| 93 | + mem_limit: 1g | |
| 94 | + pids_limit: 256 | |
| 95 | + ``` | |
| 96 | + | |
| 97 | +### Public instance with open registration | |
| 98 | + | |
| 99 | +Do everything above. In addition, before opening sign-ups, Openhub needs changes that don't exist yet. Roughly in priority order: | |
| 100 | + | |
| 101 | +1. **Control who can sign up:** invite-only registration, or administrator approval. Add moderation: list, disable and delete users and content. | |
| 102 | +2. **Add limits:** | |
| 103 | + - A push size limit (`receive.maxInputSize`). | |
| 104 | + - Per-user repository count and disk quotas. | |
| 105 | + - Rate limits on authentication and pushes inside Openhub. | |
| 106 | + - A cap on concurrent password hashing. | |
| 107 | + - A bounded SSH input queue, with timeouts on git processes. | |
| 108 | + - A size limit on decompressed request bodies. | |
| 109 | +3. **Enable `receive.fsckObjects`,** with `receive.fsck.<msg-id>=ignore` exceptions for common harmless defects. | |
| 110 | +4. **Sandbox git child processes.** Use Landlock, seccomp, bubblewrap or nsjail so each process sees only its own repository (read-only for fetches and archives), has no network, and runs under CPU, memory and time limits. Alternatively, build archives with libgit2 so `export-subst` never runs. | |
| 111 | +5. **Move libgit2 work into a separate sandboxed worker process.** This is the largest change, and the only one that contains a libgit2 memory-safety bug. | |
| 112 | +6. **Return 401 for missing repositories over HTTP** (as GitHub does), so private and missing repositories look the same. | |
| 113 | +7. **Proxy external Markdown images**, or limit them to the Openhub origin. | |
| 114 | + | |
| 115 | +## When you change this | |
| 116 | + | |
| 117 | +- **Any new handler that reads repository content** must go through `load_repo` (web) or `AppState::access` (other channels), and must keep private repositories indistinguishable from missing ones. | |
| 118 | +- **Any new HTML built in Rust and marked `|safe`** must escape every user-controlled value. Add a unit test showing that `<script>` comes out escaped. | |
| 119 | +- **Any new external process** must be spawned without a shell, with arguments that come from validated or database-derived values, and with `kill_on_drop(true)`. | |
| 120 | +- **When a gap above is fixed,** move it to the protections table. |
| @@ -0,0 +1,286 @@ | ||
| 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`. |
| @@ -0,0 +1,90 @@ | ||
| 1 | +CREATE TABLE users ( | |
| 2 | + id INTEGER PRIMARY KEY, | |
| 3 | + username TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 4 | + email TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 5 | + display_name TEXT NOT NULL DEFAULT '', | |
| 6 | + bio TEXT NOT NULL DEFAULT '', | |
| 7 | + password_hash TEXT NOT NULL, | |
| 8 | + is_admin INTEGER NOT NULL DEFAULT 0, | |
| 9 | + created_at INTEGER NOT NULL | |
| 10 | +); | |
| 11 | + | |
| 12 | +CREATE TABLE sessions ( | |
| 13 | + token_hash TEXT PRIMARY KEY, | |
| 14 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 15 | + created_at INTEGER NOT NULL, | |
| 16 | + expires_at INTEGER NOT NULL | |
| 17 | +); | |
| 18 | +CREATE INDEX sessions_user ON sessions(user_id); | |
| 19 | + | |
| 20 | +CREATE TABLE ssh_keys ( | |
| 21 | + id INTEGER PRIMARY KEY, | |
| 22 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 23 | + title TEXT NOT NULL, | |
| 24 | + fingerprint TEXT NOT NULL UNIQUE, | |
| 25 | + public_key TEXT NOT NULL, | |
| 26 | + key_type TEXT NOT NULL, | |
| 27 | + created_at INTEGER NOT NULL, | |
| 28 | + last_used_at INTEGER | |
| 29 | +); | |
| 30 | +CREATE INDEX ssh_keys_user ON ssh_keys(user_id); | |
| 31 | + | |
| 32 | +-- Bare repositories live on disk at <data>/repos/<id>.git, so renames only touch this table. | |
| 33 | +CREATE TABLE repositories ( | |
| 34 | + id INTEGER PRIMARY KEY, | |
| 35 | + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 36 | + name TEXT NOT NULL COLLATE NOCASE, | |
| 37 | + description TEXT NOT NULL DEFAULT '', | |
| 38 | + is_private INTEGER NOT NULL DEFAULT 0, | |
| 39 | + default_branch TEXT NOT NULL DEFAULT 'main', | |
| 40 | + created_at INTEGER NOT NULL, | |
| 41 | + updated_at INTEGER NOT NULL, | |
| 42 | + pushed_at INTEGER, | |
| 43 | + UNIQUE (owner_id, name) | |
| 44 | +); | |
| 45 | + | |
| 46 | +CREATE TABLE collaborators ( | |
| 47 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 48 | + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 49 | + created_at INTEGER NOT NULL, | |
| 50 | + PRIMARY KEY (repo_id, user_id) | |
| 51 | +); | |
| 52 | +CREATE INDEX collaborators_user ON collaborators(user_id); | |
| 53 | + | |
| 54 | +CREATE TABLE pull_requests ( | |
| 55 | + id INTEGER PRIMARY KEY, | |
| 56 | + repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, | |
| 57 | + number INTEGER NOT NULL, | |
| 58 | + title TEXT NOT NULL, | |
| 59 | + body TEXT NOT NULL DEFAULT '', | |
| 60 | + author_id INTEGER NOT NULL REFERENCES users(id), | |
| 61 | + base_branch TEXT NOT NULL, | |
| 62 | + head_branch TEXT NOT NULL, | |
| 63 | + -- 'open' | 'closed' | 'merged' | |
| 64 | + state TEXT NOT NULL DEFAULT 'open', | |
| 65 | + -- Last known tip of the head branch (also kept at refs/pull/<number>/head). | |
| 66 | + head_sha TEXT, | |
| 67 | + -- Merge base of head and base. Tracked while open, frozen once closed or merged. | |
| 68 | + base_sha TEXT, | |
| 69 | + merge_commit_sha TEXT, | |
| 70 | + merged_by INTEGER REFERENCES users(id), | |
| 71 | + merged_at INTEGER, | |
| 72 | + closed_at INTEGER, | |
| 73 | + created_at INTEGER NOT NULL, | |
| 74 | + updated_at INTEGER NOT NULL, | |
| 75 | + UNIQUE (repo_id, number) | |
| 76 | +); | |
| 77 | +CREATE INDEX pull_requests_repo_state ON pull_requests(repo_id, state); | |
| 78 | + | |
| 79 | +-- Conversation timeline: comments plus state changes. | |
| 80 | +CREATE TABLE pr_events ( | |
| 81 | + id INTEGER PRIMARY KEY, | |
| 82 | + pr_id INTEGER NOT NULL REFERENCES pull_requests(id) ON DELETE CASCADE, | |
| 83 | + -- NULL when Openhub detected the change itself (e.g. merged by a plain push). | |
| 84 | + author_id INTEGER REFERENCES users(id), | |
| 85 | + -- 'comment' | 'closed' | 'reopened' | 'merged' | 'branch_deleted' | |
| 86 | + kind TEXT NOT NULL, | |
| 87 | + body TEXT NOT NULL DEFAULT '', | |
| 88 | + created_at INTEGER NOT NULL | |
| 89 | +); | |
| 90 | +CREATE INDEX pr_events_pr ON pr_events(pr_id); |
| @@ -0,0 +1,151 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# End-to-end smoke test: starts a throwaway Openhub instance and exercises | |
| 3 | +# sign up, SSH keys, pushing, browsing, pull requests, merging and permissions. | |
| 4 | +# | |
| 5 | +# Usage: scripts/smoke-test.sh [path/to/openhub-binary] | |
| 6 | +set -euo pipefail | |
| 7 | + | |
| 8 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 9 | +BIN="${1:-$ROOT/target/debug/openhub}" | |
| 10 | +HTTP_PORT="${HTTP_PORT:-38080}" | |
| 11 | +SSH_PORT="${SSH_PORT:-38022}" | |
| 12 | +BASE="http://127.0.0.1:$HTTP_PORT" | |
| 13 | + | |
| 14 | +if [[ ! -x "$BIN" ]]; then | |
| 15 | + (cd "$ROOT" && cargo build --quiet) | |
| 16 | +fi | |
| 17 | + | |
| 18 | +WORK="$(mktemp -d)" | |
| 19 | +SERVER_PID="" | |
| 20 | +cleanup() { | |
| 21 | + [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true | |
| 22 | + rm -rf "$WORK" | |
| 23 | +} | |
| 24 | +trap cleanup EXIT | |
| 25 | + | |
| 26 | +pass=0 | |
| 27 | +ok() { pass=$((pass + 1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } | |
| 28 | +fail() { | |
| 29 | + printf ' \033[31m✗ %s\033[0m\n' "$1" | |
| 30 | + echo "--- server log ---" | |
| 31 | + tail -n 30 "$WORK/server.log" || true | |
| 32 | + exit 1 | |
| 33 | +} | |
| 34 | +expect_status() { # expect_status <code> <description> <curl args...> | |
| 35 | + local want="$1" desc="$2" | |
| 36 | + shift 2 | |
| 37 | + local got | |
| 38 | + got="$(curl -s -o /dev/null -w '%{http_code}' "$@")" | |
| 39 | + [[ "$got" == "$want" ]] && ok "$desc" || fail "$desc (expected $want, got $got)" | |
| 40 | +} | |
| 41 | +expect_body() { # expect_body <pattern> <description> <curl args...> | |
| 42 | + local pattern="$1" desc="$2" | |
| 43 | + shift 2 | |
| 44 | + curl -s "$@" | grep -q -- "$pattern" && ok "$desc" || fail "$desc (missing '$pattern')" | |
| 45 | +} | |
| 46 | + | |
| 47 | +echo "Starting Openhub in $WORK" | |
| 48 | +"$BIN" --data-dir "$WORK/data" --http-addr "127.0.0.1:$HTTP_PORT" --ssh-addr "127.0.0.1:$SSH_PORT" \ | |
| 49 | + --public-url "$BASE" >"$WORK/server.log" 2>&1 & | |
| 50 | +SERVER_PID=$! | |
| 51 | +for _ in $(seq 1 50); do | |
| 52 | + curl -s -o /dev/null "$BASE/" && break | |
| 53 | + sleep 0.2 | |
| 54 | +done | |
| 55 | +curl -s -o /dev/null "$BASE/" || fail "server did not start" | |
| 56 | + | |
| 57 | +ssh-keygen -q -t ed25519 -N "" -C alice@test -f "$WORK/alice_key" | |
| 58 | +ssh-keygen -q -t ed25519 -N "" -C bob@test -f "$WORK/bob_key" | |
| 59 | +ssh_cmd() { echo "ssh -i $WORK/$1_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"; } | |
| 60 | +A_COOKIES="$WORK/alice.cookies" | |
| 61 | +B_COOKIES="$WORK/bob.cookies" | |
| 62 | +REMOTE="ssh://[email protected]:$SSH_PORT" | |
| 63 | + | |
| 64 | +echo "Accounts and keys" | |
| 65 | +expect_status 303 "register alice (first user)" -c "$A_COOKIES" -X POST \ | |
| 66 | + -d "username=alice&[email protected]&password=correct-horse" "$BASE/register" | |
| 67 | +expect_status 303 "register bob" -c "$B_COOKIES" -X POST \ | |
| 68 | + -d "username=bob&[email protected]&password=correct-horse" "$BASE/register" | |
| 69 | +expect_status 303 "alice adds SSH key" -b "$A_COOKIES" -X POST \ | |
| 70 | + --data-urlencode "key=$(cat "$WORK/alice_key.pub")" "$BASE/settings/keys" | |
| 71 | +expect_status 303 "bob adds SSH key" -b "$B_COOKIES" -X POST \ | |
| 72 | + --data-urlencode "key=$(cat "$WORK/bob_key.pub")" "$BASE/settings/keys" | |
| 73 | +expect_body "already in use" "the same key cannot be added twice" -b "$B_COOKIES" -X POST \ | |
| 74 | + --data-urlencode "key=$(cat "$WORK/alice_key.pub")" "$BASE/settings/keys" | |
| 75 | +expect_body "Incorrect username or password" "wrong password is rejected" -X POST \ | |
| 76 | + -d "login=alice&password=nope" "$BASE/login" | |
| 77 | +greeting="$($(ssh_cmd alice) -T -p "$SSH_PORT" [email protected] 2>&1 || true)" | |
| 78 | +[[ "$greeting" == *"Hi alice!"* ]] && ok "ssh -T greets alice" || fail "ssh -T greeting" | |
| 79 | + | |
| 80 | +echo "Repositories and pushing" | |
| 81 | +expect_status 303 "create public repo" -b "$A_COOKIES" -X POST -d "name=demo&visibility=public" "$BASE/new" | |
| 82 | +expect_status 303 "create private repo with README" -b "$A_COOKIES" -X POST \ | |
| 83 | + -d "name=secret&visibility=private&readme=1" "$BASE/new" | |
| 84 | +git init -q -b main "$WORK/demo" | |
| 85 | +cd "$WORK/demo" | |
| 86 | +git config user.name "Alice" && git config user.email [email protected] | |
| 87 | +printf '# Demo\n\nHello from **Openhub**.\n' >README.md | |
| 88 | +mkdir -p src && printf 'fn main() {\n println!("hi");\n}\n' >src/main.rs | |
| 89 | +git add -A && git commit -qm "Initial commit" | |
| 90 | +git remote add origin "$REMOTE/alice/demo.git" | |
| 91 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git push -q -u origin main 2>/dev/null && ok "push over SSH" || fail "push over SSH" | |
| 92 | + | |
| 93 | +expect_body "Hello from <strong>Openhub</strong>" "README renders on repo page" "$BASE/alice/demo" | |
| 94 | +expect_body 'tok-k' "source files are syntax highlighted" "$BASE/alice/demo/blob/main/src/main.rs" | |
| 95 | +expect_body "Initial commit" "commit history lists the commit" "$BASE/alice/demo/commits/main" | |
| 96 | +expect_status 200 "raw file download" "$BASE/alice/demo/raw/main/README.md" | |
| 97 | +expect_status 200 "zip archive download" "$BASE/alice/demo/archive/main.zip" | |
| 98 | + | |
| 99 | +echo "Pull requests" | |
| 100 | +git checkout -q -b feature | |
| 101 | +printf 'pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n' >src/lib.rs | |
| 102 | +git add -A && git commit -qm "Add library" | |
| 103 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git push -u origin feature 2>"$WORK/push.txt" >/dev/null | |
| 104 | +grep -q "compare/main...feature" "$WORK/push.txt" && ok "push suggests opening a pull request" || fail "push hint" | |
| 105 | +expect_status 303 "open pull request" -b "$A_COOKIES" -X POST \ | |
| 106 | + -d "base=main&head=feature&title=Add+library&body=Adds+add()" "$BASE/alice/demo/pulls" | |
| 107 | +expect_body "no conflicts" "pull request is mergeable" -b "$A_COOKIES" "$BASE/alice/demo/pull/1" | |
| 108 | +expect_body "src/lib.rs" "files changed tab shows the diff" "$BASE/alice/demo/pull/1/files" | |
| 109 | +expect_status 303 "bob comments" -b "$B_COOKIES" -X POST -d "body=Nice+work" "$BASE/alice/demo/pull/1/comment" | |
| 110 | +expect_status 403 "bob cannot merge without write access" -b "$B_COOKIES" -X POST -d "method=merge" \ | |
| 111 | + "$BASE/alice/demo/pull/1/merge" | |
| 112 | +expect_status 303 "alice merges" -b "$A_COOKIES" -X POST -d "method=merge" "$BASE/alice/demo/pull/1/merge" | |
| 113 | +expect_body "successfully merged" "pull request shows as merged" -b "$A_COOKIES" "$BASE/alice/demo/pull/1" | |
| 114 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git fetch -q origin | |
| 115 | +git log --format=%s origin/main | grep -q "Merge pull request #1" && ok "merge commit landed on main" || fail "merge commit" | |
| 116 | + | |
| 117 | +git checkout -q -b cli origin/main | |
| 118 | +echo "x" >cli.txt && git add -A && git commit -qm "CLI change" | |
| 119 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git push -q -u origin cli 2>/dev/null | |
| 120 | +expect_status 303 "open second pull request" -b "$A_COOKIES" -X POST -d "base=main&head=cli&title=CLI" "$BASE/alice/demo/pulls" | |
| 121 | +git checkout -q -B main origin/main && git merge -q --no-ff cli -m "Merge cli locally" | |
| 122 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git push -q origin main 2>/dev/null | |
| 123 | +expect_body "state-merged" "pull request merged on the command line is detected" -b "$A_COOKIES" "$BASE/alice/demo/pull/2" | |
| 124 | + | |
| 125 | +echo "Permissions" | |
| 126 | +expect_status 404 "private repo hidden from anonymous users" "$BASE/alice/secret" | |
| 127 | +expect_status 404 "private repo hidden from non-collaborators" -b "$B_COOKIES" "$BASE/alice/secret" | |
| 128 | +GIT_SSH_COMMAND="$(ssh_cmd bob)" git clone -q "$REMOTE/alice/secret.git" "$WORK/nope" 2>/dev/null && | |
| 129 | + fail "bob cloned a private repo" || ok "bob cannot clone private repo over SSH" | |
| 130 | +cd "$WORK/demo" | |
| 131 | +GIT_SSH_COMMAND="$(ssh_cmd bob)" git push -q origin HEAD:refs/heads/bob 2>/dev/null && | |
| 132 | + fail "bob pushed without access" || ok "bob cannot push to alice/demo" | |
| 133 | +expect_status 303 "alice adds bob as collaborator" -b "$A_COOKIES" -X POST -d "username=bob" \ | |
| 134 | + "$BASE/alice/secret/settings/collaborators" | |
| 135 | +GIT_SSH_COMMAND="$(ssh_cmd bob)" git clone -q "$REMOTE/alice/secret.git" "$WORK/secret" 2>/dev/null && | |
| 136 | + ok "collaborator can clone private repo" || fail "collaborator clone" | |
| 137 | +GIT_SSH_COMMAND="$(ssh_cmd alice)" git push -q origin HEAD:refs/pull/1/head 2>/dev/null && | |
| 138 | + fail "pushed to refs/pull" || ok "refs/pull/* cannot be pushed to" | |
| 139 | + | |
| 140 | +echo "Git over HTTP" | |
| 141 | +git clone -q "$BASE/alice/demo.git" "$WORK/http-clone" && ok "anonymous HTTP clone of public repo" || fail "HTTP clone" | |
| 142 | +GIT_TERMINAL_PROMPT=0 git clone -q "$BASE/alice/secret.git" "$WORK/http-secret" 2>/dev/null && | |
| 143 | + fail "anonymous HTTP clone of private repo" || ok "private repo requires authentication over HTTP" | |
| 144 | +git clone -q "http://bob:[email protected]:$HTTP_PORT/alice/secret.git" "$WORK/http-secret2" && | |
| 145 | + ok "collaborator HTTP clone with password" || fail "authenticated HTTP clone" | |
| 146 | +cd "$WORK/http-clone" | |
| 147 | +git -c user.name=x -c user.email=x@x commit -q --allow-empty -m "nope" | |
| 148 | +git push -q origin main 2>/dev/null && fail "push over HTTP" || ok "push over HTTP is refused" | |
| 149 | + | |
| 150 | +echo | |
| 151 | +printf '\033[32mAll %d checks passed.\033[0m\n' "$pass" |
| @@ -0,0 +1,118 @@ | ||
| 1 | +use std::net::SocketAddr; | |
| 2 | +use std::path::PathBuf; | |
| 3 | + | |
| 4 | +use clap::Parser; | |
| 5 | + | |
| 6 | +/// Openhub: a minimal, self-hostable git server. | |
| 7 | +#[derive(Parser, Debug, Clone)] | |
| 8 | +#[command(name = "openhub", version, about)] | |
| 9 | +pub struct Config { | |
| 10 | + /// Directory holding the database, bare repositories and the SSH host key. | |
| 11 | + #[arg(long, env = "OPENHUB_DATA_DIR", default_value = "./data")] | |
| 12 | + pub data_dir: PathBuf, | |
| 13 | + | |
| 14 | + /// Address the web interface (and git over HTTP) listens on. | |
| 15 | + #[arg(long, env = "OPENHUB_HTTP_ADDR", default_value = "0.0.0.0:3000")] | |
| 16 | + pub http_addr: SocketAddr, | |
| 17 | + | |
| 18 | + /// Address the built-in SSH server listens on. | |
| 19 | + #[arg(long, env = "OPENHUB_SSH_ADDR", default_value = "0.0.0.0:2222")] | |
| 20 | + pub ssh_addr: SocketAddr, | |
| 21 | + | |
| 22 | + /// Public base URL of the web interface, used for links and HTTP clone URLs. | |
| 23 | + #[arg( | |
| 24 | + long, | |
| 25 | + env = "OPENHUB_PUBLIC_URL", | |
| 26 | + default_value = "http://localhost:3000" | |
| 27 | + )] | |
| 28 | + pub public_url: String, | |
| 29 | + | |
| 30 | + /// Host name shown in SSH clone URLs. Defaults to the host of the public URL. | |
| 31 | + #[arg(long, env = "OPENHUB_SSH_HOST")] | |
| 32 | + pub ssh_host: Option<String>, | |
| 33 | + | |
| 34 | + /// Port shown in SSH clone URLs. Defaults to the SSH listen port. | |
| 35 | + #[arg(long, env = "OPENHUB_SSH_PORT")] | |
| 36 | + pub ssh_port: Option<u16>, | |
| 37 | + | |
| 38 | + /// Disable self-service sign up. The first account can always be created. | |
| 39 | + #[arg(long, env = "OPENHUB_DISABLE_REGISTRATION")] | |
| 40 | + pub disable_registration: bool, | |
| 41 | + | |
| 42 | + /// Path to the git executable used to serve fetches and pushes. | |
| 43 | + #[arg(long, env = "OPENHUB_GIT_BIN", default_value = "git")] | |
| 44 | + pub git_bin: String, | |
| 45 | +} | |
| 46 | + | |
| 47 | +impl Config { | |
| 48 | + pub fn normalize(&mut self) { | |
| 49 | + while self.public_url.ends_with('/') { | |
| 50 | + self.public_url.pop(); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + pub fn db_path(&self) -> PathBuf { | |
| 55 | + self.data_dir.join("openhub.db") | |
| 56 | + } | |
| 57 | + | |
| 58 | + pub fn repos_dir(&self) -> PathBuf { | |
| 59 | + self.data_dir.join("repos") | |
| 60 | + } | |
| 61 | + | |
| 62 | + pub fn host_key_path(&self) -> PathBuf { | |
| 63 | + self.data_dir.join("ssh_host_ed25519_key") | |
| 64 | + } | |
| 65 | + | |
| 66 | + pub fn repo_path(&self, repo_id: i64) -> PathBuf { | |
| 67 | + self.repos_dir().join(format!("{repo_id}.git")) | |
| 68 | + } | |
| 69 | + | |
| 70 | + pub fn secure_cookies(&self) -> bool { | |
| 71 | + self.public_url.starts_with("https://") | |
| 72 | + } | |
| 73 | + | |
| 74 | + fn public_host(&self) -> String { | |
| 75 | + let rest = self | |
| 76 | + .public_url | |
| 77 | + .split_once("://") | |
| 78 | + .map(|(_, r)| r) | |
| 79 | + .unwrap_or(&self.public_url); | |
| 80 | + let authority = rest.split('/').next().unwrap_or(rest); | |
| 81 | + // Strip a port, taking care not to break bracketed IPv6 literals. | |
| 82 | + match authority.rsplit_once(':') { | |
| 83 | + Some((host, port)) if !host.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => { | |
| 84 | + host.to_string() | |
| 85 | + } | |
| 86 | + _ => authority.to_string(), | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + pub fn ssh_display_host(&self) -> String { | |
| 91 | + self.ssh_host.clone().unwrap_or_else(|| self.public_host()) | |
| 92 | + } | |
| 93 | + | |
| 94 | + pub fn ssh_display_port(&self) -> u16 { | |
| 95 | + self.ssh_port.unwrap_or(self.ssh_addr.port()) | |
| 96 | + } | |
| 97 | + | |
| 98 | + pub fn ssh_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 99 | + let host = self.ssh_display_host(); | |
| 100 | + match self.ssh_display_port() { | |
| 101 | + 22 => format!("git@{host}:{owner}/{repo}.git"), | |
| 102 | + port => format!("ssh://git@{host}:{port}/{owner}/{repo}.git"), | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + pub fn http_clone_url(&self, owner: &str, repo: &str) -> String { | |
| 107 | + format!("{}/{owner}/{repo}.git", self.public_url) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /// The command users can run to check their SSH key is accepted. | |
| 111 | + pub fn ssh_test_command(&self) -> String { | |
| 112 | + let host = self.ssh_display_host(); | |
| 113 | + match self.ssh_display_port() { | |
| 114 | + 22 => format!("ssh -T git@{host}"), | |
| 115 | + port => format!("ssh -T -p {port} git@{host}"), | |
| 116 | + } | |
| 117 | + } | |
| 118 | +} |
| @@ -0,0 +1,768 @@ | ||
| 1 | +//! SQLite persistence: connection setup, row types and queries. | |
| 2 | + | |
| 3 | +use std::path::Path; | |
| 4 | +use std::str::FromStr; | |
| 5 | +use std::time::Duration; | |
| 6 | + | |
| 7 | +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; | |
| 8 | +use sqlx::{FromRow, SqlitePool}; | |
| 9 | + | |
| 10 | +pub type Db = SqlitePool; | |
| 11 | +type Result<T> = std::result::Result<T, sqlx::Error>; | |
| 12 | + | |
| 13 | +pub fn now() -> i64 { | |
| 14 | + std::time::SystemTime::now() | |
| 15 | + .duration_since(std::time::UNIX_EPOCH) | |
| 16 | + .map(|d| d.as_secs() as i64) | |
| 17 | + .unwrap_or(0) | |
| 18 | +} | |
| 19 | + | |
| 20 | +pub async fn connect(path: &Path) -> anyhow::Result<Db> { | |
| 21 | + let opts = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))? | |
| 22 | + .create_if_missing(true) | |
| 23 | + .journal_mode(SqliteJournalMode::Wal) | |
| 24 | + .synchronous(SqliteSynchronous::Normal) | |
| 25 | + .foreign_keys(true) | |
| 26 | + .busy_timeout(Duration::from_secs(5)); | |
| 27 | + let pool = SqlitePoolOptions::new() | |
| 28 | + .max_connections(8) | |
| 29 | + .connect_with(opts) | |
| 30 | + .await?; | |
| 31 | + sqlx::migrate!("./migrations").run(&pool).await?; | |
| 32 | + Ok(pool) | |
| 33 | +} | |
| 34 | + | |
| 35 | +// --------------------------------------------------------------------------- | |
| 36 | +// Users & sessions | |
| 37 | +// --------------------------------------------------------------------------- | |
| 38 | + | |
| 39 | +#[derive(Debug, Clone, FromRow)] | |
| 40 | +pub struct User { | |
| 41 | + pub id: i64, | |
| 42 | + pub username: String, | |
| 43 | + pub email: String, | |
| 44 | + pub display_name: String, | |
| 45 | + pub bio: String, | |
| 46 | + pub password_hash: String, | |
| 47 | + pub is_admin: bool, | |
| 48 | + pub created_at: i64, | |
| 49 | +} | |
| 50 | + | |
| 51 | +impl User { | |
| 52 | + /// Display name if set, otherwise the username. | |
| 53 | + pub fn name(&self) -> &str { | |
| 54 | + if self.display_name.trim().is_empty() { | |
| 55 | + &self.username | |
| 56 | + } else { | |
| 57 | + &self.display_name | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +const USER_COLS: &str = | |
| 63 | + "id, username, email, display_name, bio, password_hash, is_admin, created_at"; | |
| 64 | + | |
| 65 | +pub async fn count_users(db: &Db) -> Result<i64> { | |
| 66 | + sqlx::query_scalar("SELECT COUNT(*) FROM users") | |
| 67 | + .fetch_one(db) | |
| 68 | + .await | |
| 69 | +} | |
| 70 | + | |
| 71 | +pub async fn create_user( | |
| 72 | + db: &Db, | |
| 73 | + username: &str, | |
| 74 | + email: &str, | |
| 75 | + password_hash: &str, | |
| 76 | + is_admin: bool, | |
| 77 | +) -> Result<i64> { | |
| 78 | + let res = sqlx::query( | |
| 79 | + "INSERT INTO users (username, email, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?, ?)", | |
| 80 | + ) | |
| 81 | + .bind(username) | |
| 82 | + .bind(email) | |
| 83 | + .bind(password_hash) | |
| 84 | + .bind(is_admin) | |
| 85 | + .bind(now()) | |
| 86 | + .execute(db) | |
| 87 | + .await?; | |
| 88 | + Ok(res.last_insert_rowid()) | |
| 89 | +} | |
| 90 | + | |
| 91 | +pub async fn user_by_id(db: &Db, id: i64) -> Result<Option<User>> { | |
| 92 | + sqlx::query_as(&format!("SELECT {USER_COLS} FROM users WHERE id = ?")) | |
| 93 | + .bind(id) | |
| 94 | + .fetch_optional(db) | |
| 95 | + .await | |
| 96 | +} | |
| 97 | + | |
| 98 | +pub async fn user_by_username(db: &Db, username: &str) -> Result<Option<User>> { | |
| 99 | + sqlx::query_as(&format!("SELECT {USER_COLS} FROM users WHERE username = ?")) | |
| 100 | + .bind(username) | |
| 101 | + .fetch_optional(db) | |
| 102 | + .await | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub async fn user_by_login(db: &Db, login: &str) -> Result<Option<User>> { | |
| 106 | + sqlx::query_as(&format!( | |
| 107 | + "SELECT {USER_COLS} FROM users WHERE username = ?1 OR email = ?1" | |
| 108 | + )) | |
| 109 | + .bind(login) | |
| 110 | + .fetch_optional(db) | |
| 111 | + .await | |
| 112 | +} | |
| 113 | + | |
| 114 | +pub async fn email_taken(db: &Db, email: &str, except_user: i64) -> Result<bool> { | |
| 115 | + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = ? AND id != ?") | |
| 116 | + .bind(email) | |
| 117 | + .bind(except_user) | |
| 118 | + .fetch_one(db) | |
| 119 | + .await?; | |
| 120 | + Ok(n > 0) | |
| 121 | +} | |
| 122 | + | |
| 123 | +pub async fn update_profile( | |
| 124 | + db: &Db, | |
| 125 | + id: i64, | |
| 126 | + display_name: &str, | |
| 127 | + email: &str, | |
| 128 | + bio: &str, | |
| 129 | +) -> Result<()> { | |
| 130 | + sqlx::query("UPDATE users SET display_name = ?, email = ?, bio = ? WHERE id = ?") | |
| 131 | + .bind(display_name) | |
| 132 | + .bind(email) | |
| 133 | + .bind(bio) | |
| 134 | + .bind(id) | |
| 135 | + .execute(db) | |
| 136 | + .await?; | |
| 137 | + Ok(()) | |
| 138 | +} | |
| 139 | + | |
| 140 | +pub async fn update_password(db: &Db, id: i64, password_hash: &str) -> Result<()> { | |
| 141 | + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?") | |
| 142 | + .bind(password_hash) | |
| 143 | + .bind(id) | |
| 144 | + .execute(db) | |
| 145 | + .await?; | |
| 146 | + Ok(()) | |
| 147 | +} | |
| 148 | + | |
| 149 | +pub async fn create_session(db: &Db, token_hash: &str, user_id: i64, ttl_secs: i64) -> Result<()> { | |
| 150 | + let now = now(); | |
| 151 | + sqlx::query( | |
| 152 | + "INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", | |
| 153 | + ) | |
| 154 | + .bind(token_hash) | |
| 155 | + .bind(user_id) | |
| 156 | + .bind(now) | |
| 157 | + .bind(now + ttl_secs) | |
| 158 | + .execute(db) | |
| 159 | + .await?; | |
| 160 | + Ok(()) | |
| 161 | +} | |
| 162 | + | |
| 163 | +pub async fn user_by_session(db: &Db, token_hash: &str) -> Result<Option<User>> { | |
| 164 | + sqlx::query_as(&format!( | |
| 165 | + "SELECT {} FROM users u JOIN sessions s ON s.user_id = u.id \ | |
| 166 | + WHERE s.token_hash = ? AND s.expires_at > ?", | |
| 167 | + USER_COLS | |
| 168 | + .split(", ") | |
| 169 | + .map(|c| format!("u.{c}")) | |
| 170 | + .collect::<Vec<_>>() | |
| 171 | + .join(", ") | |
| 172 | + )) | |
| 173 | + .bind(token_hash) | |
| 174 | + .bind(now()) | |
| 175 | + .fetch_optional(db) | |
| 176 | + .await | |
| 177 | +} | |
| 178 | + | |
| 179 | +pub async fn delete_session(db: &Db, token_hash: &str) -> Result<()> { | |
| 180 | + sqlx::query("DELETE FROM sessions WHERE token_hash = ?") | |
| 181 | + .bind(token_hash) | |
| 182 | + .execute(db) | |
| 183 | + .await?; | |
| 184 | + Ok(()) | |
| 185 | +} | |
| 186 | + | |
| 187 | +pub async fn delete_other_sessions(db: &Db, user_id: i64, keep_hash: &str) -> Result<()> { | |
| 188 | + sqlx::query("DELETE FROM sessions WHERE user_id = ? AND token_hash != ?") | |
| 189 | + .bind(user_id) | |
| 190 | + .bind(keep_hash) | |
| 191 | + .execute(db) | |
| 192 | + .await?; | |
| 193 | + Ok(()) | |
| 194 | +} | |
| 195 | + | |
| 196 | +pub async fn purge_expired_sessions(db: &Db) -> Result<()> { | |
| 197 | + sqlx::query("DELETE FROM sessions WHERE expires_at <= ?") | |
| 198 | + .bind(now()) | |
| 199 | + .execute(db) | |
| 200 | + .await?; | |
| 201 | + Ok(()) | |
| 202 | +} | |
| 203 | + | |
| 204 | +// --------------------------------------------------------------------------- | |
| 205 | +// SSH keys | |
| 206 | +// --------------------------------------------------------------------------- | |
| 207 | + | |
| 208 | +#[derive(Debug, Clone, FromRow)] | |
| 209 | +pub struct SshKey { | |
| 210 | + pub id: i64, | |
| 211 | + pub title: String, | |
| 212 | + pub fingerprint: String, | |
| 213 | + pub key_type: String, | |
| 214 | + pub created_at: i64, | |
| 215 | + pub last_used_at: Option<i64>, | |
| 216 | +} | |
| 217 | + | |
| 218 | +pub async fn list_keys(db: &Db, user_id: i64) -> Result<Vec<SshKey>> { | |
| 219 | + sqlx::query_as( | |
| 220 | + "SELECT id, title, fingerprint, key_type, created_at, last_used_at FROM ssh_keys \ | |
| 221 | + WHERE user_id = ? ORDER BY created_at", | |
| 222 | + ) | |
| 223 | + .bind(user_id) | |
| 224 | + .fetch_all(db) | |
| 225 | + .await | |
| 226 | +} | |
| 227 | + | |
| 228 | +pub async fn fingerprint_exists(db: &Db, fingerprint: &str) -> Result<bool> { | |
| 229 | + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ssh_keys WHERE fingerprint = ?") | |
| 230 | + .bind(fingerprint) | |
| 231 | + .fetch_one(db) | |
| 232 | + .await?; | |
| 233 | + Ok(n > 0) | |
| 234 | +} | |
| 235 | + | |
| 236 | +pub async fn add_key( | |
| 237 | + db: &Db, | |
| 238 | + user_id: i64, | |
| 239 | + title: &str, | |
| 240 | + fingerprint: &str, | |
| 241 | + public_key: &str, | |
| 242 | + key_type: &str, | |
| 243 | +) -> Result<()> { | |
| 244 | + sqlx::query( | |
| 245 | + "INSERT INTO ssh_keys (user_id, title, fingerprint, public_key, key_type, created_at) \ | |
| 246 | + VALUES (?, ?, ?, ?, ?, ?)", | |
| 247 | + ) | |
| 248 | + .bind(user_id) | |
| 249 | + .bind(title) | |
| 250 | + .bind(fingerprint) | |
| 251 | + .bind(public_key) | |
| 252 | + .bind(key_type) | |
| 253 | + .bind(now()) | |
| 254 | + .execute(db) | |
| 255 | + .await?; | |
| 256 | + Ok(()) | |
| 257 | +} | |
| 258 | + | |
| 259 | +pub async fn delete_key(db: &Db, id: i64, user_id: i64) -> Result<bool> { | |
| 260 | + let res = sqlx::query("DELETE FROM ssh_keys WHERE id = ? AND user_id = ?") | |
| 261 | + .bind(id) | |
| 262 | + .bind(user_id) | |
| 263 | + .execute(db) | |
| 264 | + .await?; | |
| 265 | + Ok(res.rows_affected() > 0) | |
| 266 | +} | |
| 267 | + | |
| 268 | +/// Finds the owner of a public key by its SHA256 fingerprint. | |
| 269 | +pub async fn user_by_key_fingerprint(db: &Db, fingerprint: &str) -> Result<Option<(User, i64)>> { | |
| 270 | + let Some((user_id, key_id)): Option<(i64, i64)> = | |
| 271 | + sqlx::query_as("SELECT user_id, id FROM ssh_keys WHERE fingerprint = ?") | |
| 272 | + .bind(fingerprint) | |
| 273 | + .fetch_optional(db) | |
| 274 | + .await? | |
| 275 | + else { | |
| 276 | + return Ok(None); | |
| 277 | + }; | |
| 278 | + Ok(user_by_id(db, user_id).await?.map(|u| (u, key_id))) | |
| 279 | +} | |
| 280 | + | |
| 281 | +pub async fn touch_key(db: &Db, key_id: i64) -> Result<()> { | |
| 282 | + sqlx::query("UPDATE ssh_keys SET last_used_at = ? WHERE id = ?") | |
| 283 | + .bind(now()) | |
| 284 | + .bind(key_id) | |
| 285 | + .execute(db) | |
| 286 | + .await?; | |
| 287 | + Ok(()) | |
| 288 | +} | |
| 289 | + | |
| 290 | +// --------------------------------------------------------------------------- | |
| 291 | +// Repositories | |
| 292 | +// --------------------------------------------------------------------------- | |
| 293 | + | |
| 294 | +#[derive(Debug, Clone, FromRow)] | |
| 295 | +pub struct Repo { | |
| 296 | + pub id: i64, | |
| 297 | + pub owner_id: i64, | |
| 298 | + pub owner: String, | |
| 299 | + pub name: String, | |
| 300 | + pub description: String, | |
| 301 | + pub is_private: bool, | |
| 302 | + pub default_branch: String, | |
| 303 | + pub created_at: i64, | |
| 304 | + pub pushed_at: Option<i64>, | |
| 305 | +} | |
| 306 | + | |
| 307 | +const REPO_SELECT: &str = "SELECT r.id, r.owner_id, u.username AS owner, r.name, r.description, \ | |
| 308 | + r.is_private, r.default_branch, r.created_at, r.pushed_at \ | |
| 309 | + FROM repositories r JOIN users u ON u.id = r.owner_id"; | |
| 310 | + | |
| 311 | +pub async fn create_repo( | |
| 312 | + db: &Db, | |
| 313 | + owner_id: i64, | |
| 314 | + name: &str, | |
| 315 | + description: &str, | |
| 316 | + is_private: bool, | |
| 317 | + default_branch: &str, | |
| 318 | +) -> Result<i64> { | |
| 319 | + let now = now(); | |
| 320 | + let res = sqlx::query( | |
| 321 | + "INSERT INTO repositories (owner_id, name, description, is_private, default_branch, created_at, updated_at) \ | |
| 322 | + VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 323 | + ) | |
| 324 | + .bind(owner_id) | |
| 325 | + .bind(name) | |
| 326 | + .bind(description) | |
| 327 | + .bind(is_private) | |
| 328 | + .bind(default_branch) | |
| 329 | + .bind(now) | |
| 330 | + .bind(now) | |
| 331 | + .execute(db) | |
| 332 | + .await?; | |
| 333 | + Ok(res.last_insert_rowid()) | |
| 334 | +} | |
| 335 | + | |
| 336 | +pub async fn repo_by_name(db: &Db, owner: &str, name: &str) -> Result<Option<Repo>> { | |
| 337 | + sqlx::query_as(&format!( | |
| 338 | + "{REPO_SELECT} WHERE u.username = ? AND r.name = ?" | |
| 339 | + )) | |
| 340 | + .bind(owner) | |
| 341 | + .bind(name) | |
| 342 | + .fetch_optional(db) | |
| 343 | + .await | |
| 344 | +} | |
| 345 | + | |
| 346 | +pub async fn repo_name_taken(db: &Db, owner_id: i64, name: &str, except: i64) -> Result<bool> { | |
| 347 | + let n: i64 = sqlx::query_scalar( | |
| 348 | + "SELECT COUNT(*) FROM repositories WHERE owner_id = ? AND name = ? AND id != ?", | |
| 349 | + ) | |
| 350 | + .bind(owner_id) | |
| 351 | + .bind(name) | |
| 352 | + .bind(except) | |
| 353 | + .fetch_one(db) | |
| 354 | + .await?; | |
| 355 | + Ok(n > 0) | |
| 356 | +} | |
| 357 | + | |
| 358 | +pub async fn list_public_repos(db: &Db, limit: i64) -> Result<Vec<Repo>> { | |
| 359 | + sqlx::query_as(&format!( | |
| 360 | + "{REPO_SELECT} WHERE r.is_private = 0 ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ?" | |
| 361 | + )) | |
| 362 | + .bind(limit) | |
| 363 | + .fetch_all(db) | |
| 364 | + .await | |
| 365 | +} | |
| 366 | + | |
| 367 | +/// Repositories owned by `owner_id` that `viewer` is allowed to see. | |
| 368 | +pub async fn list_owner_repos(db: &Db, owner_id: i64, viewer: Option<&User>) -> Result<Vec<Repo>> { | |
| 369 | + let (viewer_id, admin) = viewer.map(|v| (v.id, v.is_admin)).unwrap_or((-1, false)); | |
| 370 | + sqlx::query_as(&format!( | |
| 371 | + "{REPO_SELECT} WHERE r.owner_id = ?1 AND (r.is_private = 0 OR ?2 OR r.owner_id = ?3 \ | |
| 372 | + OR EXISTS (SELECT 1 FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?3)) \ | |
| 373 | + ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC" | |
| 374 | + )) | |
| 375 | + .bind(owner_id) | |
| 376 | + .bind(admin) | |
| 377 | + .bind(viewer_id) | |
| 378 | + .fetch_all(db) | |
| 379 | + .await | |
| 380 | +} | |
| 381 | + | |
| 382 | +/// Repositories a user owns or collaborates on. | |
| 383 | +pub async fn list_member_repos(db: &Db, user_id: i64) -> Result<Vec<Repo>> { | |
| 384 | + sqlx::query_as(&format!( | |
| 385 | + "{REPO_SELECT} WHERE r.owner_id = ?1 \ | |
| 386 | + OR EXISTS (SELECT 1 FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?1) \ | |
| 387 | + ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC" | |
| 388 | + )) | |
| 389 | + .bind(user_id) | |
| 390 | + .fetch_all(db) | |
| 391 | + .await | |
| 392 | +} | |
| 393 | + | |
| 394 | +pub async fn update_repo( | |
| 395 | + db: &Db, | |
| 396 | + id: i64, | |
| 397 | + name: &str, | |
| 398 | + description: &str, | |
| 399 | + is_private: bool, | |
| 400 | + default_branch: &str, | |
| 401 | +) -> Result<()> { | |
| 402 | + sqlx::query( | |
| 403 | + "UPDATE repositories SET name = ?, description = ?, is_private = ?, default_branch = ?, updated_at = ? WHERE id = ?", | |
| 404 | + ) | |
| 405 | + .bind(name) | |
| 406 | + .bind(description) | |
| 407 | + .bind(is_private) | |
| 408 | + .bind(default_branch) | |
| 409 | + .bind(now()) | |
| 410 | + .bind(id) | |
| 411 | + .execute(db) | |
| 412 | + .await?; | |
| 413 | + Ok(()) | |
| 414 | +} | |
| 415 | + | |
| 416 | +pub async fn set_default_branch(db: &Db, id: i64, branch: &str) -> Result<()> { | |
| 417 | + sqlx::query("UPDATE repositories SET default_branch = ? WHERE id = ?") | |
| 418 | + .bind(branch) | |
| 419 | + .bind(id) | |
| 420 | + .execute(db) | |
| 421 | + .await?; | |
| 422 | + Ok(()) | |
| 423 | +} | |
| 424 | + | |
| 425 | +pub async fn touch_pushed(db: &Db, id: i64) -> Result<()> { | |
| 426 | + let now = now(); | |
| 427 | + sqlx::query("UPDATE repositories SET pushed_at = ?, updated_at = ? WHERE id = ?") | |
| 428 | + .bind(now) | |
| 429 | + .bind(now) | |
| 430 | + .bind(id) | |
| 431 | + .execute(db) | |
| 432 | + .await?; | |
| 433 | + Ok(()) | |
| 434 | +} | |
| 435 | + | |
| 436 | +pub async fn delete_repo(db: &Db, id: i64) -> Result<()> { | |
| 437 | + sqlx::query("DELETE FROM repositories WHERE id = ?") | |
| 438 | + .bind(id) | |
| 439 | + .execute(db) | |
| 440 | + .await?; | |
| 441 | + Ok(()) | |
| 442 | +} | |
| 443 | + | |
| 444 | +pub async fn is_collaborator(db: &Db, repo_id: i64, user_id: i64) -> Result<bool> { | |
| 445 | + let n: i64 = | |
| 446 | + sqlx::query_scalar("SELECT COUNT(*) FROM collaborators WHERE repo_id = ? AND user_id = ?") | |
| 447 | + .bind(repo_id) | |
| 448 | + .bind(user_id) | |
| 449 | + .fetch_one(db) | |
| 450 | + .await?; | |
| 451 | + Ok(n > 0) | |
| 452 | +} | |
| 453 | + | |
| 454 | +pub async fn list_collaborators(db: &Db, repo_id: i64) -> Result<Vec<User>> { | |
| 455 | + sqlx::query_as( | |
| 456 | + "SELECT u.id, u.username, u.email, u.display_name, u.bio, u.password_hash, u.is_admin, u.created_at \ | |
| 457 | + FROM users u JOIN collaborators c ON c.user_id = u.id WHERE c.repo_id = ? ORDER BY u.username", | |
| 458 | + ) | |
| 459 | + .bind(repo_id) | |
| 460 | + .fetch_all(db) | |
| 461 | + .await | |
| 462 | +} | |
| 463 | + | |
| 464 | +pub async fn add_collaborator(db: &Db, repo_id: i64, user_id: i64) -> Result<()> { | |
| 465 | + sqlx::query( | |
| 466 | + "INSERT OR IGNORE INTO collaborators (repo_id, user_id, created_at) VALUES (?, ?, ?)", | |
| 467 | + ) | |
| 468 | + .bind(repo_id) | |
| 469 | + .bind(user_id) | |
| 470 | + .bind(now()) | |
| 471 | + .execute(db) | |
| 472 | + .await?; | |
| 473 | + Ok(()) | |
| 474 | +} | |
| 475 | + | |
| 476 | +pub async fn remove_collaborator(db: &Db, repo_id: i64, user_id: i64) -> Result<()> { | |
| 477 | + sqlx::query("DELETE FROM collaborators WHERE repo_id = ? AND user_id = ?") | |
| 478 | + .bind(repo_id) | |
| 479 | + .bind(user_id) | |
| 480 | + .execute(db) | |
| 481 | + .await?; | |
| 482 | + Ok(()) | |
| 483 | +} | |
| 484 | + | |
| 485 | +// --------------------------------------------------------------------------- | |
| 486 | +// Pull requests | |
| 487 | +// --------------------------------------------------------------------------- | |
| 488 | + | |
| 489 | +#[derive(Debug, Clone, FromRow)] | |
| 490 | +pub struct PullRequest { | |
| 491 | + pub id: i64, | |
| 492 | + pub repo_id: i64, | |
| 493 | + pub number: i64, | |
| 494 | + pub title: String, | |
| 495 | + pub body: String, | |
| 496 | + pub author_id: i64, | |
| 497 | + pub author: String, | |
| 498 | + pub base_branch: String, | |
| 499 | + pub head_branch: String, | |
| 500 | + pub state: String, | |
| 501 | + pub head_sha: Option<String>, | |
| 502 | + pub base_sha: Option<String>, | |
| 503 | + pub merge_commit_sha: Option<String>, | |
| 504 | + pub merged_by: Option<String>, | |
| 505 | + pub merged_at: Option<i64>, | |
| 506 | + pub closed_at: Option<i64>, | |
| 507 | + pub created_at: i64, | |
| 508 | + pub comments: i64, | |
| 509 | +} | |
| 510 | + | |
| 511 | +impl PullRequest { | |
| 512 | + pub fn is_open(&self) -> bool { | |
| 513 | + self.state == "open" | |
| 514 | + } | |
| 515 | +} | |
| 516 | + | |
| 517 | +const PULL_SELECT: &str = "SELECT p.id, p.repo_id, p.number, p.title, p.body, p.author_id, \ | |
| 518 | + a.username AS author, p.base_branch, p.head_branch, p.state, p.head_sha, p.base_sha, \ | |
| 519 | + p.merge_commit_sha, m.username AS merged_by, p.merged_at, p.closed_at, p.created_at, \ | |
| 520 | + (SELECT COUNT(*) FROM pr_events e WHERE e.pr_id = p.id AND e.kind = 'comment') AS comments \ | |
| 521 | + FROM pull_requests p JOIN users a ON a.id = p.author_id LEFT JOIN users m ON m.id = p.merged_by"; | |
| 522 | + | |
| 523 | +#[allow(clippy::too_many_arguments)] | |
| 524 | +pub async fn create_pull( | |
| 525 | + db: &Db, | |
| 526 | + repo_id: i64, | |
| 527 | + author_id: i64, | |
| 528 | + title: &str, | |
| 529 | + body: &str, | |
| 530 | + base: &str, | |
| 531 | + head: &str, | |
| 532 | + head_sha: &str, | |
| 533 | + base_sha: &str, | |
| 534 | +) -> Result<i64> { | |
| 535 | + let mut tx = db.begin().await?; | |
| 536 | + let number: i64 = sqlx::query_scalar( | |
| 537 | + "SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repo_id = ?", | |
| 538 | + ) | |
| 539 | + .bind(repo_id) | |
| 540 | + .fetch_one(&mut *tx) | |
| 541 | + .await?; | |
| 542 | + let now = now(); | |
| 543 | + sqlx::query( | |
| 544 | + "INSERT INTO pull_requests (repo_id, number, title, body, author_id, base_branch, head_branch, head_sha, base_sha, created_at, updated_at) \ | |
| 545 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 546 | + ) | |
| 547 | + .bind(repo_id) | |
| 548 | + .bind(number) | |
| 549 | + .bind(title) | |
| 550 | + .bind(body) | |
| 551 | + .bind(author_id) | |
| 552 | + .bind(base) | |
| 553 | + .bind(head) | |
| 554 | + .bind(head_sha) | |
| 555 | + .bind(base_sha) | |
| 556 | + .bind(now) | |
| 557 | + .bind(now) | |
| 558 | + .execute(&mut *tx) | |
| 559 | + .await?; | |
| 560 | + tx.commit().await?; | |
| 561 | + Ok(number) | |
| 562 | +} | |
| 563 | + | |
| 564 | +pub async fn pull_by_number(db: &Db, repo_id: i64, number: i64) -> Result<Option<PullRequest>> { | |
| 565 | + sqlx::query_as(&format!( | |
| 566 | + "{PULL_SELECT} WHERE p.repo_id = ? AND p.number = ?" | |
| 567 | + )) | |
| 568 | + .bind(repo_id) | |
| 569 | + .bind(number) | |
| 570 | + .fetch_optional(db) | |
| 571 | + .await | |
| 572 | +} | |
| 573 | + | |
| 574 | +/// `open = true` lists open pull requests, otherwise closed and merged ones. | |
| 575 | +pub async fn list_pulls(db: &Db, repo_id: i64, open: bool) -> Result<Vec<PullRequest>> { | |
| 576 | + let cond = if open { | |
| 577 | + "p.state = 'open'" | |
| 578 | + } else { | |
| 579 | + "p.state != 'open'" | |
| 580 | + }; | |
| 581 | + sqlx::query_as(&format!( | |
| 582 | + "{PULL_SELECT} WHERE p.repo_id = ? AND {cond} ORDER BY p.number DESC" | |
| 583 | + )) | |
| 584 | + .bind(repo_id) | |
| 585 | + .fetch_all(db) | |
| 586 | + .await | |
| 587 | +} | |
| 588 | + | |
| 589 | +pub async fn count_pulls(db: &Db, repo_id: i64) -> Result<(i64, i64)> { | |
| 590 | + sqlx::query_as( | |
| 591 | + "SELECT COALESCE(SUM(state = 'open'), 0), COALESCE(SUM(state != 'open'), 0) \ | |
| 592 | + FROM pull_requests WHERE repo_id = ?", | |
| 593 | + ) | |
| 594 | + .bind(repo_id) | |
| 595 | + .fetch_one(db) | |
| 596 | + .await | |
| 597 | +} | |
| 598 | + | |
| 599 | +pub async fn find_open_pull(db: &Db, repo_id: i64, base: &str, head: &str) -> Result<Option<i64>> { | |
| 600 | + sqlx::query_scalar( | |
| 601 | + "SELECT number FROM pull_requests WHERE repo_id = ? AND base_branch = ? AND head_branch = ? AND state = 'open'", | |
| 602 | + ) | |
| 603 | + .bind(repo_id) | |
| 604 | + .bind(base) | |
| 605 | + .bind(head) | |
| 606 | + .fetch_optional(db) | |
| 607 | + .await | |
| 608 | +} | |
| 609 | + | |
| 610 | +pub async fn find_open_pull_by_head(db: &Db, repo_id: i64, head: &str) -> Result<Option<i64>> { | |
| 611 | + sqlx::query_scalar( | |
| 612 | + "SELECT number FROM pull_requests WHERE repo_id = ? AND head_branch = ? AND state = 'open'", | |
| 613 | + ) | |
| 614 | + .bind(repo_id) | |
| 615 | + .bind(head) | |
| 616 | + .fetch_optional(db) | |
| 617 | + .await | |
| 618 | +} | |
| 619 | + | |
| 620 | +/// Open pull requests in repositories the user can push to, or that they authored. | |
| 621 | +pub async fn dashboard_pulls(db: &Db, user_id: i64) -> Result<Vec<(PullRequest, String, String)>> { | |
| 622 | + let pulls: Vec<PullRequest> = sqlx::query_as(&format!( | |
| 623 | + "{PULL_SELECT} JOIN repositories r ON r.id = p.repo_id WHERE p.state = 'open' AND \ | |
| 624 | + (p.author_id = ?1 OR r.owner_id = ?1 OR EXISTS \ | |
| 625 | + (SELECT 1 FROM collaborators c WHERE c.repo_id = r.id AND c.user_id = ?1)) \ | |
| 626 | + ORDER BY p.updated_at DESC LIMIT 20" | |
| 627 | + )) | |
| 628 | + .bind(user_id) | |
| 629 | + .fetch_all(db) | |
| 630 | + .await?; | |
| 631 | + let mut out = Vec::with_capacity(pulls.len()); | |
| 632 | + for p in pulls { | |
| 633 | + let (owner, name): (String, String) = sqlx::query_as( | |
| 634 | + "SELECT u.username, r.name FROM repositories r JOIN users u ON u.id = r.owner_id WHERE r.id = ?", | |
| 635 | + ) | |
| 636 | + .bind(p.repo_id) | |
| 637 | + .fetch_one(db) | |
| 638 | + .await?; | |
| 639 | + out.push((p, owner, name)); | |
| 640 | + } | |
| 641 | + Ok(out) | |
| 642 | +} | |
| 643 | + | |
| 644 | +pub async fn update_pull_head( | |
| 645 | + db: &Db, | |
| 646 | + id: i64, | |
| 647 | + head_sha: &str, | |
| 648 | + base_sha: Option<&str>, | |
| 649 | +) -> Result<()> { | |
| 650 | + sqlx::query("UPDATE pull_requests SET head_sha = ?, base_sha = ?, updated_at = ? WHERE id = ?") | |
| 651 | + .bind(head_sha) | |
| 652 | + .bind(base_sha) | |
| 653 | + .bind(now()) | |
| 654 | + .bind(id) | |
| 655 | + .execute(db) | |
| 656 | + .await?; | |
| 657 | + Ok(()) | |
| 658 | +} | |
| 659 | + | |
| 660 | +pub async fn update_pull_text(db: &Db, id: i64, title: &str, body: &str) -> Result<()> { | |
| 661 | + sqlx::query("UPDATE pull_requests SET title = ?, body = ?, updated_at = ? WHERE id = ?") | |
| 662 | + .bind(title) | |
| 663 | + .bind(body) | |
| 664 | + .bind(now()) | |
| 665 | + .bind(id) | |
| 666 | + .execute(db) | |
| 667 | + .await?; | |
| 668 | + Ok(()) | |
| 669 | +} | |
| 670 | + | |
| 671 | +pub async fn mark_merged( | |
| 672 | + db: &Db, | |
| 673 | + id: i64, | |
| 674 | + merged_by: Option<i64>, | |
| 675 | + merge_commit_sha: Option<&str>, | |
| 676 | + head_sha: &str, | |
| 677 | + base_sha: &str, | |
| 678 | +) -> Result<()> { | |
| 679 | + let now = now(); | |
| 680 | + sqlx::query( | |
| 681 | + "UPDATE pull_requests SET state = 'merged', merged_by = ?, merged_at = ?, closed_at = ?, \ | |
| 682 | + merge_commit_sha = ?, head_sha = ?, base_sha = ?, updated_at = ? WHERE id = ?", | |
| 683 | + ) | |
| 684 | + .bind(merged_by) | |
| 685 | + .bind(now) | |
| 686 | + .bind(now) | |
| 687 | + .bind(merge_commit_sha) | |
| 688 | + .bind(head_sha) | |
| 689 | + .bind(base_sha) | |
| 690 | + .bind(now) | |
| 691 | + .bind(id) | |
| 692 | + .execute(db) | |
| 693 | + .await?; | |
| 694 | + Ok(()) | |
| 695 | +} | |
| 696 | + | |
| 697 | +pub async fn mark_closed(db: &Db, id: i64, head_sha: &str, base_sha: &str) -> Result<()> { | |
| 698 | + let now = now(); | |
| 699 | + sqlx::query( | |
| 700 | + "UPDATE pull_requests SET state = 'closed', closed_at = ?, head_sha = ?, base_sha = ?, updated_at = ? WHERE id = ?", | |
| 701 | + ) | |
| 702 | + .bind(now) | |
| 703 | + .bind(head_sha) | |
| 704 | + .bind(base_sha) | |
| 705 | + .bind(now) | |
| 706 | + .bind(id) | |
| 707 | + .execute(db) | |
| 708 | + .await?; | |
| 709 | + Ok(()) | |
| 710 | +} | |
| 711 | + | |
| 712 | +pub async fn mark_reopened(db: &Db, id: i64, head_sha: &str, base_sha: &str) -> Result<()> { | |
| 713 | + sqlx::query( | |
| 714 | + "UPDATE pull_requests SET state = 'open', closed_at = NULL, head_sha = ?, base_sha = ?, updated_at = ? WHERE id = ?", | |
| 715 | + ) | |
| 716 | + .bind(head_sha) | |
| 717 | + .bind(base_sha) | |
| 718 | + .bind(now()) | |
| 719 | + .bind(id) | |
| 720 | + .execute(db) | |
| 721 | + .await?; | |
| 722 | + Ok(()) | |
| 723 | +} | |
| 724 | + | |
| 725 | +#[derive(Debug, Clone, FromRow)] | |
| 726 | +pub struct PrEvent { | |
| 727 | + pub id: i64, | |
| 728 | + pub author: String, | |
| 729 | + pub kind: String, | |
| 730 | + pub body: String, | |
| 731 | + pub created_at: i64, | |
| 732 | +} | |
| 733 | + | |
| 734 | +pub async fn add_event( | |
| 735 | + db: &Db, | |
| 736 | + pr_id: i64, | |
| 737 | + author_id: Option<i64>, | |
| 738 | + kind: &str, | |
| 739 | + body: &str, | |
| 740 | +) -> Result<()> { | |
| 741 | + let now = now(); | |
| 742 | + sqlx::query( | |
| 743 | + "INSERT INTO pr_events (pr_id, author_id, kind, body, created_at) VALUES (?, ?, ?, ?, ?)", | |
| 744 | + ) | |
| 745 | + .bind(pr_id) | |
| 746 | + .bind(author_id) | |
| 747 | + .bind(kind) | |
| 748 | + .bind(body) | |
| 749 | + .bind(now) | |
| 750 | + .execute(db) | |
| 751 | + .await?; | |
| 752 | + sqlx::query("UPDATE pull_requests SET updated_at = ? WHERE id = ?") | |
| 753 | + .bind(now) | |
| 754 | + .bind(pr_id) | |
| 755 | + .execute(db) | |
| 756 | + .await?; | |
| 757 | + Ok(()) | |
| 758 | +} | |
| 759 | + | |
| 760 | +pub async fn list_events(db: &Db, pr_id: i64) -> Result<Vec<PrEvent>> { | |
| 761 | + sqlx::query_as( | |
| 762 | + "SELECT e.id, COALESCE(u.username, '') AS author, e.kind, e.body, e.created_at FROM pr_events e \ | |
| 763 | + LEFT JOIN users u ON u.id = e.author_id WHERE e.pr_id = ? ORDER BY e.created_at, e.id", | |
| 764 | + ) | |
| 765 | + .bind(pr_id) | |
| 766 | + .fetch_all(db) | |
| 767 | + .await | |
| 768 | +} |
| @@ -0,0 +1,1023 @@ | ||
| 1 | +//! Read and write access to bare repositories via libgit2. | |
| 2 | +//! | |
| 3 | +//! Everything here is synchronous; web handlers call into it from | |
| 4 | +//! `spawn_blocking`. | |
| 5 | + | |
| 6 | +use std::collections::{HashMap, HashSet}; | |
| 7 | +use std::path::Path; | |
| 8 | + | |
| 9 | +use anyhow::{Context, Result, bail}; | |
| 10 | +use git2::{ | |
| 11 | + BranchType, Commit, DiffFindOptions, DiffOptions, ObjectType, Oid, Patch, Repository, | |
| 12 | + RepositoryInitOptions, Signature, Sort, Tree, | |
| 13 | +}; | |
| 14 | + | |
| 15 | +use crate::highlight; | |
| 16 | + | |
| 17 | +// --------------------------------------------------------------------------- | |
| 18 | +// Repository lifecycle | |
| 19 | +// --------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +pub fn init_bare(path: &Path, default_branch: &str) -> Result<Repository> { | |
| 22 | + let mut opts = RepositoryInitOptions::new(); | |
| 23 | + opts.bare(true).initial_head(default_branch); | |
| 24 | + let repo = Repository::init_opts(path, &opts) | |
| 25 | + .with_context(|| format!("initializing repository at {}", path.display()))?; | |
| 26 | + configure(&repo)?; | |
| 27 | + Ok(repo) | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// Server-side configuration applied to every hosted repository. | |
| 31 | +fn configure(repo: &Repository) -> Result<()> { | |
| 32 | + let mut cfg = repo.config()?; | |
| 33 | + // refs/pull/* is managed by Openhub; clients may fetch but never push to it. | |
| 34 | + cfg.set_str("receive.hideRefs", "refs/pull")?; | |
| 35 | + Ok(()) | |
| 36 | +} | |
| 37 | + | |
| 38 | +pub fn open(path: &Path) -> Result<Repository> { | |
| 39 | + Repository::open_bare(path).with_context(|| format!("opening repository {}", path.display())) | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// Creates the first commit of an empty repository with the given files. | |
| 43 | +pub fn initial_commit( | |
| 44 | + repo: &Repository, | |
| 45 | + branch: &str, | |
| 46 | + files: &[(&str, &[u8])], | |
| 47 | + sig: &Signature, | |
| 48 | +) -> Result<Oid> { | |
| 49 | + let mut builder = repo.treebuilder(None)?; | |
| 50 | + for (name, content) in files { | |
| 51 | + let blob = repo.blob(content)?; | |
| 52 | + builder.insert(name, blob, 0o100644)?; | |
| 53 | + } | |
| 54 | + let tree = repo.find_tree(builder.write()?)?; | |
| 55 | + let oid = repo.commit( | |
| 56 | + Some(&format!("refs/heads/{branch}")), | |
| 57 | + sig, | |
| 58 | + sig, | |
| 59 | + "Initial commit", | |
| 60 | + &tree, | |
| 61 | + &[], | |
| 62 | + )?; | |
| 63 | + Ok(oid) | |
| 64 | +} | |
| 65 | + | |
| 66 | +pub fn is_empty(repo: &Repository) -> bool { | |
| 67 | + repo.branches(Some(BranchType::Local)) | |
| 68 | + .map(|mut b| b.next().is_none()) | |
| 69 | + .unwrap_or(true) | |
| 70 | +} | |
| 71 | + | |
| 72 | +pub fn branch_exists(repo: &Repository, name: &str) -> bool { | |
| 73 | + repo.find_branch(name, BranchType::Local).is_ok() | |
| 74 | +} | |
| 75 | + | |
| 76 | +pub fn branch_tip(repo: &Repository, name: &str) -> Option<Oid> { | |
| 77 | + repo.find_branch(name, BranchType::Local) | |
| 78 | + .ok() | |
| 79 | + .and_then(|b| b.get().target()) | |
| 80 | +} | |
| 81 | + | |
| 82 | +pub fn set_head(repo: &Repository, branch: &str) -> Result<()> { | |
| 83 | + repo.set_head(&format!("refs/heads/{branch}"))?; | |
| 84 | + Ok(()) | |
| 85 | +} | |
| 86 | + | |
| 87 | +/// Makes sure HEAD points at an existing branch, preferring `preferred`. | |
| 88 | +/// Returns the branch HEAD points at afterwards, or `None` if the repository | |
| 89 | +/// has no branches yet. | |
| 90 | +pub fn ensure_head(repo: &Repository, preferred: &str) -> Result<Option<String>> { | |
| 91 | + let names = branch_names(repo)?; | |
| 92 | + if names.is_empty() { | |
| 93 | + return Ok(None); | |
| 94 | + } | |
| 95 | + let pick = [preferred, "main", "master"] | |
| 96 | + .into_iter() | |
| 97 | + .find(|c| names.iter().any(|n| n == c)) | |
| 98 | + .map(str::to_string) | |
| 99 | + .unwrap_or_else(|| names[0].clone()); | |
| 100 | + let current = repo | |
| 101 | + .find_reference("HEAD") | |
| 102 | + .ok() | |
| 103 | + .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_string)); | |
| 104 | + if current.as_deref() != Some(&format!("refs/heads/{pick}")) { | |
| 105 | + set_head(repo, &pick)?; | |
| 106 | + } | |
| 107 | + Ok(Some(pick)) | |
| 108 | +} | |
| 109 | + | |
| 110 | +pub fn delete_branch(repo: &Repository, name: &str) -> Result<()> { | |
| 111 | + let mut branch = repo.find_branch(name, BranchType::Local)?; | |
| 112 | + branch.delete()?; | |
| 113 | + Ok(()) | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// Points `refs/pull/<number>/head` at the given commit so the pull request's | |
| 117 | +/// history survives even if its branch is deleted. | |
| 118 | +pub fn set_pull_ref(repo: &Repository, number: i64, oid: Oid) -> Result<()> { | |
| 119 | + repo.reference( | |
| 120 | + &format!("refs/pull/{number}/head"), | |
| 121 | + oid, | |
| 122 | + true, | |
| 123 | + "openhub: update pull request head", | |
| 124 | + )?; | |
| 125 | + Ok(()) | |
| 126 | +} | |
| 127 | + | |
| 128 | +pub fn pull_ref(repo: &Repository, number: i64) -> Option<Oid> { | |
| 129 | + repo.refname_to_id(&format!("refs/pull/{number}/head")).ok() | |
| 130 | +} | |
| 131 | + | |
| 132 | +// --------------------------------------------------------------------------- | |
| 133 | +// Commits and refs | |
| 134 | +// --------------------------------------------------------------------------- | |
| 135 | + | |
| 136 | +#[derive(Debug, Clone)] | |
| 137 | +pub struct CommitInfo { | |
| 138 | + pub id: String, | |
| 139 | + pub short_id: String, | |
| 140 | + pub summary: String, | |
| 141 | + pub body: String, | |
| 142 | + pub author_name: String, | |
| 143 | + pub author_email: String, | |
| 144 | + pub author_time: i64, | |
| 145 | + pub committer_name: String, | |
| 146 | + pub committer_time: i64, | |
| 147 | + pub parents: Vec<String>, | |
| 148 | +} | |
| 149 | + | |
| 150 | +impl CommitInfo { | |
| 151 | + pub fn from_commit(c: &Commit) -> Self { | |
| 152 | + let message = String::from_utf8_lossy(c.message_bytes()).to_string(); | |
| 153 | + let mut parts = message.splitn(2, '\n'); | |
| 154 | + let summary = parts.next().unwrap_or("").trim().to_string(); | |
| 155 | + let body = parts.next().unwrap_or("").trim().to_string(); | |
| 156 | + let author = c.author(); | |
| 157 | + let committer = c.committer(); | |
| 158 | + let id = c.id().to_string(); | |
| 159 | + CommitInfo { | |
| 160 | + short_id: id[..7].to_string(), | |
| 161 | + id, | |
| 162 | + summary, | |
| 163 | + body, | |
| 164 | + author_name: String::from_utf8_lossy(author.name_bytes()).to_string(), | |
| 165 | + author_email: String::from_utf8_lossy(author.email_bytes()).to_string(), | |
| 166 | + author_time: author.when().seconds(), | |
| 167 | + committer_name: String::from_utf8_lossy(committer.name_bytes()).to_string(), | |
| 168 | + committer_time: committer.when().seconds(), | |
| 169 | + parents: c.parent_ids().map(|p| p.to_string()).collect(), | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 173 | + pub fn short_parents(&self) -> Vec<(String, String)> { | |
| 174 | + self.parents | |
| 175 | + .iter() | |
| 176 | + .map(|p| (p.clone(), p[..7].to_string())) | |
| 177 | + .collect() | |
| 178 | + } | |
| 179 | +} | |
| 180 | + | |
| 181 | +pub fn commit_info(repo: &Repository, oid: Oid) -> Result<CommitInfo> { | |
| 182 | + Ok(CommitInfo::from_commit(&repo.find_commit(oid)?)) | |
| 183 | +} | |
| 184 | + | |
| 185 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 186 | +pub enum RefKind { | |
| 187 | + Branch, | |
| 188 | + Tag, | |
| 189 | + Commit, | |
| 190 | +} | |
| 191 | + | |
| 192 | +/// A ref (branch, tag or commit) plus a path inside its tree, parsed from a | |
| 193 | +/// URL tail such as `feature/login/src/main.rs`. | |
| 194 | +#[derive(Debug, Clone)] | |
| 195 | +pub struct Resolved { | |
| 196 | + pub name: String, | |
| 197 | + pub kind: RefKind, | |
| 198 | + pub commit: Oid, | |
| 199 | + pub path: String, | |
| 200 | +} | |
| 201 | + | |
| 202 | +pub fn resolve(repo: &Repository, rest: &str) -> Option<Resolved> { | |
| 203 | + let rest = rest.trim_matches('/'); | |
| 204 | + if rest.is_empty() { | |
| 205 | + return None; | |
| 206 | + } | |
| 207 | + let segs: Vec<&str> = rest.split('/').collect(); | |
| 208 | + for i in 1..=segs.len() { | |
| 209 | + let candidate = segs[..i].join("/"); | |
| 210 | + let path = segs[i..].join("/"); | |
| 211 | + for (prefix, kind) in [ | |
| 212 | + ("refs/heads/", RefKind::Branch), | |
| 213 | + ("refs/tags/", RefKind::Tag), | |
| 214 | + ] { | |
| 215 | + if let Ok(r) = repo.find_reference(&format!("{prefix}{candidate}")) { | |
| 216 | + if let Ok(c) = r.peel_to_commit() { | |
| 217 | + return Some(Resolved { | |
| 218 | + name: candidate, | |
| 219 | + kind, | |
| 220 | + commit: c.id(), | |
| 221 | + path, | |
| 222 | + }); | |
| 223 | + } | |
| 224 | + } | |
| 225 | + } | |
| 226 | + } | |
| 227 | + let first = segs[0]; | |
| 228 | + if (4..=40).contains(&first.len()) && first.chars().all(|c| c.is_ascii_hexdigit()) { | |
| 229 | + if let Ok(c) = repo.revparse_single(first).and_then(|o| o.peel_to_commit()) { | |
| 230 | + return Some(Resolved { | |
| 231 | + name: first.to_string(), | |
| 232 | + kind: RefKind::Commit, | |
| 233 | + commit: c.id(), | |
| 234 | + path: segs[1..].join("/"), | |
| 235 | + }); | |
| 236 | + } | |
| 237 | + } | |
| 238 | + None | |
| 239 | +} | |
| 240 | + | |
| 241 | +pub fn branch_names(repo: &Repository) -> Result<Vec<String>> { | |
| 242 | + let mut names = Vec::new(); | |
| 243 | + for b in repo.branches(Some(BranchType::Local))? { | |
| 244 | + let (b, _) = b?; | |
| 245 | + if let Some(name) = b.name()? { | |
| 246 | + names.push(name.to_string()); | |
| 247 | + } | |
| 248 | + } | |
| 249 | + names.sort(); | |
| 250 | + Ok(names) | |
| 251 | +} | |
| 252 | + | |
| 253 | +pub fn tag_names(repo: &Repository) -> Result<Vec<String>> { | |
| 254 | + let mut names: Vec<String> = repo | |
| 255 | + .tag_names(None)? | |
| 256 | + .iter() | |
| 257 | + .filter_map(|t| t.ok().flatten().map(String::from)) | |
| 258 | + .collect(); | |
| 259 | + names.sort_by(|a, b| natural_cmp(b, a)); | |
| 260 | + Ok(names) | |
| 261 | +} | |
| 262 | + | |
| 263 | +/// Orders version-like names so that `v1.10` sorts after `v1.9`. | |
| 264 | +fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { | |
| 265 | + fn chunks(s: &str) -> Vec<(bool, String)> { | |
| 266 | + let mut out: Vec<(bool, String)> = Vec::new(); | |
| 267 | + for c in s.chars() { | |
| 268 | + let digit = c.is_ascii_digit(); | |
| 269 | + match out.last_mut() { | |
| 270 | + Some((d, buf)) if *d == digit => buf.push(c), | |
| 271 | + _ => out.push((digit, c.to_string())), | |
| 272 | + } | |
| 273 | + } | |
| 274 | + out | |
| 275 | + } | |
| 276 | + let (ca, cb) = (chunks(a), chunks(b)); | |
| 277 | + for (x, y) in ca.iter().zip(cb.iter()) { | |
| 278 | + let ord = if x.0 && y.0 { | |
| 279 | + let (nx, ny) = (x.1.trim_start_matches('0'), y.1.trim_start_matches('0')); | |
| 280 | + nx.len().cmp(&ny.len()).then_with(|| nx.cmp(ny)) | |
| 281 | + } else { | |
| 282 | + x.1.cmp(&y.1) | |
| 283 | + }; | |
| 284 | + if ord.is_ne() { | |
| 285 | + return ord; | |
| 286 | + } | |
| 287 | + } | |
| 288 | + ca.len().cmp(&cb.len()) | |
| 289 | +} | |
| 290 | + | |
| 291 | +#[derive(Debug, Clone)] | |
| 292 | +pub struct BranchInfo { | |
| 293 | + pub name: String, | |
| 294 | + pub commit: CommitInfo, | |
| 295 | + pub ahead: usize, | |
| 296 | + pub behind: usize, | |
| 297 | +} | |
| 298 | + | |
| 299 | +/// Branches sorted by most recent commit, with ahead/behind counts relative | |
| 300 | +/// to `default_branch`. | |
| 301 | +pub fn branches(repo: &Repository, default_branch: &str) -> Result<Vec<BranchInfo>> { | |
| 302 | + let default_tip = branch_tip(repo, default_branch); | |
| 303 | + let mut out = Vec::new(); | |
| 304 | + for b in repo.branches(Some(BranchType::Local))? { | |
| 305 | + let (b, _) = b?; | |
| 306 | + let Some(name) = b.name()?.map(str::to_string) else { | |
| 307 | + continue; | |
| 308 | + }; | |
| 309 | + let Ok(commit) = b.get().peel_to_commit() else { | |
| 310 | + continue; | |
| 311 | + }; | |
| 312 | + let (ahead, behind) = match default_tip { | |
| 313 | + Some(base) if name != default_branch => { | |
| 314 | + repo.graph_ahead_behind(commit.id(), base).unwrap_or((0, 0)) | |
| 315 | + } | |
| 316 | + _ => (0, 0), | |
| 317 | + }; | |
| 318 | + out.push(BranchInfo { | |
| 319 | + name, | |
| 320 | + commit: CommitInfo::from_commit(&commit), | |
| 321 | + ahead, | |
| 322 | + behind, | |
| 323 | + }); | |
| 324 | + } | |
| 325 | + out.sort_by(|a, b| b.commit.committer_time.cmp(&a.commit.committer_time)); | |
| 326 | + Ok(out) | |
| 327 | +} | |
| 328 | + | |
| 329 | +#[derive(Debug, Clone)] | |
| 330 | +pub struct TagInfo { | |
| 331 | + pub name: String, | |
| 332 | + pub commit: CommitInfo, | |
| 333 | + pub message: Option<String>, | |
| 334 | +} | |
| 335 | + | |
| 336 | +pub fn tags(repo: &Repository) -> Result<Vec<TagInfo>> { | |
| 337 | + let mut out = Vec::new(); | |
| 338 | + for name in tag_names(repo)? { | |
| 339 | + let Ok(r) = repo.find_reference(&format!("refs/tags/{name}")) else { | |
| 340 | + continue; | |
| 341 | + }; | |
| 342 | + let message = r | |
| 343 | + .peel(ObjectType::Tag) | |
| 344 | + .ok() | |
| 345 | + .and_then(|o| o.into_tag().ok()) | |
| 346 | + .and_then(|t| t.message().ok().flatten().map(|m| m.trim().to_string())) | |
| 347 | + .filter(|m| !m.is_empty()); | |
| 348 | + let Ok(commit) = r.peel_to_commit() else { | |
| 349 | + continue; | |
| 350 | + }; | |
| 351 | + out.push(TagInfo { | |
| 352 | + name, | |
| 353 | + commit: CommitInfo::from_commit(&commit), | |
| 354 | + message, | |
| 355 | + }); | |
| 356 | + } | |
| 357 | + Ok(out) | |
| 358 | +} | |
| 359 | + | |
| 360 | +fn entry_id_at(commit: &Commit, path: &str) -> Option<Oid> { | |
| 361 | + let tree = commit.tree().ok()?; | |
| 362 | + if path.is_empty() { | |
| 363 | + return Some(tree.id()); | |
| 364 | + } | |
| 365 | + tree.get_path(Path::new(path)).ok().map(|e| e.id()) | |
| 366 | +} | |
| 367 | + | |
| 368 | +/// True if `commit` changed `path` relative to all of its parents. | |
| 369 | +fn touches_path(commit: &Commit, path: &str) -> bool { | |
| 370 | + let mine = entry_id_at(commit, path); | |
| 371 | + if commit.parent_count() == 0 { | |
| 372 | + return mine.is_some(); | |
| 373 | + } | |
| 374 | + commit.parents().all(|p| entry_id_at(&p, path) != mine) | |
| 375 | +} | |
| 376 | + | |
| 377 | +/// A page of history starting at `start`, optionally limited to commits | |
| 378 | +/// touching `path`. Returns the commits and whether more exist. | |
| 379 | +pub fn log( | |
| 380 | + repo: &Repository, | |
| 381 | + start: Oid, | |
| 382 | + path: Option<&str>, | |
| 383 | + skip: usize, | |
| 384 | + limit: usize, | |
| 385 | +) -> Result<(Vec<CommitInfo>, bool)> { | |
| 386 | + let mut walk = repo.revwalk()?; | |
| 387 | + walk.set_sorting(Sort::TIME)?; | |
| 388 | + walk.push(start)?; | |
| 389 | + let mut out = Vec::new(); | |
| 390 | + let mut seen = 0usize; | |
| 391 | + for oid in walk { | |
| 392 | + let commit = repo.find_commit(oid?)?; | |
| 393 | + if let Some(p) = path.filter(|p| !p.is_empty()) { | |
| 394 | + if !touches_path(&commit, p) { | |
| 395 | + continue; | |
| 396 | + } | |
| 397 | + } | |
| 398 | + if seen >= skip + limit { | |
| 399 | + return Ok((out, true)); | |
| 400 | + } | |
| 401 | + if seen >= skip { | |
| 402 | + out.push(CommitInfo::from_commit(&commit)); | |
| 403 | + } | |
| 404 | + seen += 1; | |
| 405 | + } | |
| 406 | + Ok((out, false)) | |
| 407 | +} | |
| 408 | + | |
| 409 | +pub fn commit_count(repo: &Repository, start: Oid) -> Result<usize> { | |
| 410 | + let mut walk = repo.revwalk()?; | |
| 411 | + walk.push(start)?; | |
| 412 | + Ok(walk.count()) | |
| 413 | +} | |
| 414 | + | |
| 415 | +/// Commits reachable from `head` but not from `base`, oldest first. | |
| 416 | +pub fn commits_between(repo: &Repository, base: Oid, head: Oid) -> Result<Vec<CommitInfo>> { | |
| 417 | + let mut walk = repo.revwalk()?; | |
| 418 | + walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; | |
| 419 | + walk.push(head)?; | |
| 420 | + walk.hide(base)?; | |
| 421 | + let mut out = Vec::new(); | |
| 422 | + for oid in walk { | |
| 423 | + out.push(CommitInfo::from_commit(&repo.find_commit(oid?)?)); | |
| 424 | + } | |
| 425 | + Ok(out) | |
| 426 | +} | |
| 427 | + | |
| 428 | +pub fn merge_base(repo: &Repository, a: Oid, b: Oid) -> Option<Oid> { | |
| 429 | + repo.merge_base(a, b).ok() | |
| 430 | +} | |
| 431 | + | |
| 432 | +pub fn is_ancestor(repo: &Repository, ancestor: Oid, of: Oid) -> bool { | |
| 433 | + ancestor == of || repo.graph_descendant_of(of, ancestor).unwrap_or(false) | |
| 434 | +} | |
| 435 | + | |
| 436 | +// --------------------------------------------------------------------------- | |
| 437 | +// Trees and blobs | |
| 438 | +// --------------------------------------------------------------------------- | |
| 439 | + | |
| 440 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 441 | +pub enum EntryKind { | |
| 442 | + Dir, | |
| 443 | + File, | |
| 444 | + Symlink, | |
| 445 | + Submodule, | |
| 446 | +} | |
| 447 | + | |
| 448 | +#[derive(Debug, Clone)] | |
| 449 | +pub struct TreeEntry { | |
| 450 | + pub name: String, | |
| 451 | + pub path: String, | |
| 452 | + pub kind: EntryKind, | |
| 453 | + pub last_commit: Option<CommitInfo>, | |
| 454 | +} | |
| 455 | + | |
| 456 | +impl TreeEntry { | |
| 457 | + pub fn is_dir(&self) -> bool { | |
| 458 | + self.kind == EntryKind::Dir | |
| 459 | + } | |
| 460 | + pub fn is_submodule(&self) -> bool { | |
| 461 | + self.kind == EntryKind::Submodule | |
| 462 | + } | |
| 463 | +} | |
| 464 | + | |
| 465 | +pub fn path_type(repo: &Repository, commit: Oid, path: &str) -> Result<Option<ObjectType>> { | |
| 466 | + let tree = repo.find_commit(commit)?.tree()?; | |
| 467 | + if path.is_empty() { | |
| 468 | + return Ok(Some(ObjectType::Tree)); | |
| 469 | + } | |
| 470 | + Ok(tree.get_path(Path::new(path)).ok().and_then(|e| e.kind())) | |
| 471 | +} | |
| 472 | + | |
| 473 | +fn subtree<'r>(repo: &'r Repository, commit: &Commit<'r>, path: &str) -> Result<Option<Tree<'r>>> { | |
| 474 | + let tree = commit.tree()?; | |
| 475 | + if path.is_empty() { | |
| 476 | + return Ok(Some(tree)); | |
| 477 | + } | |
| 478 | + let Ok(entry) = tree.get_path(Path::new(path)) else { | |
| 479 | + return Ok(None); | |
| 480 | + }; | |
| 481 | + if entry.kind() != Some(ObjectType::Tree) { | |
| 482 | + return Ok(None); | |
| 483 | + } | |
| 484 | + Ok(Some(repo.find_tree(entry.id())?)) | |
| 485 | +} | |
| 486 | + | |
| 487 | +/// Lists a directory, directories first, each with the last commit that touched it. | |
| 488 | +pub fn list_tree(repo: &Repository, commit_id: Oid, path: &str) -> Result<Option<Vec<TreeEntry>>> { | |
| 489 | + let commit = repo.find_commit(commit_id)?; | |
| 490 | + let Some(tree) = subtree(repo, &commit, path)? else { | |
| 491 | + return Ok(None); | |
| 492 | + }; | |
| 493 | + let mut entries: Vec<TreeEntry> = tree | |
| 494 | + .iter() | |
| 495 | + .map(|e| { | |
| 496 | + let name = String::from_utf8_lossy(e.name_bytes()).to_string(); | |
| 497 | + let kind = match (e.kind(), e.filemode()) { | |
| 498 | + (Some(ObjectType::Tree), _) => EntryKind::Dir, | |
| 499 | + (Some(ObjectType::Commit), _) => EntryKind::Submodule, | |
| 500 | + (_, 0o120000) => EntryKind::Symlink, | |
| 501 | + _ => EntryKind::File, | |
| 502 | + }; | |
| 503 | + TreeEntry { | |
| 504 | + path: if path.is_empty() { | |
| 505 | + name.clone() | |
| 506 | + } else { | |
| 507 | + format!("{path}/{name}") | |
| 508 | + }, | |
| 509 | + name, | |
| 510 | + kind, | |
| 511 | + last_commit: None, | |
| 512 | + } | |
| 513 | + }) | |
| 514 | + .collect(); | |
| 515 | + entries.sort_by(|a, b| { | |
| 516 | + b.is_dir() | |
| 517 | + .cmp(&a.is_dir()) | |
| 518 | + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) | |
| 519 | + }); | |
| 520 | + | |
| 521 | + let mut last = last_commits(repo, commit_id, path, &entries)?; | |
| 522 | + for e in &mut entries { | |
| 523 | + e.last_commit = last.remove(&e.name); | |
| 524 | + } | |
| 525 | + Ok(Some(entries)) | |
| 526 | +} | |
| 527 | + | |
| 528 | +/// Finds the most recent commit that changed each entry of a directory. | |
| 529 | +/// Bounded so very long histories cannot stall a page load. | |
| 530 | +fn last_commits( | |
| 531 | + repo: &Repository, | |
| 532 | + start: Oid, | |
| 533 | + dir: &str, | |
| 534 | + entries: &[TreeEntry], | |
| 535 | +) -> Result<HashMap<String, CommitInfo>> { | |
| 536 | + const MAX_WALK: usize = 5000; | |
| 537 | + let mut remaining: HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect(); | |
| 538 | + let mut found = HashMap::new(); | |
| 539 | + let mut walk = repo.revwalk()?; | |
| 540 | + walk.set_sorting(Sort::TIME)?; | |
| 541 | + walk.push(start)?; | |
| 542 | + | |
| 543 | + let entry_ids = |c: &Commit| -> HashMap<String, Oid> { | |
| 544 | + subtree(repo, c, dir) | |
| 545 | + .ok() | |
| 546 | + .flatten() | |
| 547 | + .map(|t| { | |
| 548 | + t.iter() | |
| 549 | + .map(|e| (String::from_utf8_lossy(e.name_bytes()).to_string(), e.id())) | |
| 550 | + .collect() | |
| 551 | + }) | |
| 552 | + .unwrap_or_default() | |
| 553 | + }; | |
| 554 | + | |
| 555 | + for oid in walk.take(MAX_WALK) { | |
| 556 | + if remaining.is_empty() { | |
| 557 | + break; | |
| 558 | + } | |
| 559 | + let commit = repo.find_commit(oid?)?; | |
| 560 | + let mine = entry_ids(&commit); | |
| 561 | + let parents: Vec<HashMap<String, Oid>> = commit.parents().map(|p| entry_ids(&p)).collect(); | |
| 562 | + let changed: Vec<&str> = remaining | |
| 563 | + .iter() | |
| 564 | + .copied() | |
| 565 | + .filter(|name| { | |
| 566 | + let id = mine.get(*name); | |
| 567 | + id.is_some() && parents.iter().all(|p| p.get(*name) != id) | |
| 568 | + }) | |
| 569 | + .collect(); | |
| 570 | + if changed.is_empty() { | |
| 571 | + continue; | |
| 572 | + } | |
| 573 | + let info = CommitInfo::from_commit(&commit); | |
| 574 | + for name in changed { | |
| 575 | + remaining.remove(name); | |
| 576 | + found.insert(name.to_string(), info.clone()); | |
| 577 | + } | |
| 578 | + } | |
| 579 | + Ok(found) | |
| 580 | +} | |
| 581 | + | |
| 582 | +pub struct Blob { | |
| 583 | + pub data: Vec<u8>, | |
| 584 | + pub size: usize, | |
| 585 | + pub is_binary: bool, | |
| 586 | +} | |
| 587 | + | |
| 588 | +pub fn read_blob(repo: &Repository, commit: Oid, path: &str) -> Result<Option<Blob>> { | |
| 589 | + let tree = repo.find_commit(commit)?.tree()?; | |
| 590 | + let Ok(entry) = tree.get_path(Path::new(path)) else { | |
| 591 | + return Ok(None); | |
| 592 | + }; | |
| 593 | + if entry.kind() != Some(ObjectType::Blob) { | |
| 594 | + return Ok(None); | |
| 595 | + } | |
| 596 | + let blob = repo.find_blob(entry.id())?; | |
| 597 | + Ok(Some(Blob { | |
| 598 | + size: blob.size(), | |
| 599 | + is_binary: blob.is_binary(), | |
| 600 | + data: blob.content().to_vec(), | |
| 601 | + })) | |
| 602 | +} | |
| 603 | + | |
| 604 | +/// Finds a README in the given directory, returning its file name and contents. | |
| 605 | +pub fn find_readme(repo: &Repository, commit: Oid, dir: &str) -> Result<Option<(String, String)>> { | |
| 606 | + let c = repo.find_commit(commit)?; | |
| 607 | + let Some(tree) = subtree(repo, &c, dir)? else { | |
| 608 | + return Ok(None); | |
| 609 | + }; | |
| 610 | + let candidates = [ | |
| 611 | + "readme.md", | |
| 612 | + "readme.markdown", | |
| 613 | + "readme", | |
| 614 | + "readme.txt", | |
| 615 | + "readme.rst", | |
| 616 | + ]; | |
| 617 | + let found = candidates.iter().find_map(|cand| { | |
| 618 | + tree.iter().find(|e| { | |
| 619 | + e.kind() == Some(ObjectType::Blob) | |
| 620 | + && e.name().is_ok_and(|n| n.eq_ignore_ascii_case(cand)) | |
| 621 | + }) | |
| 622 | + }); | |
| 623 | + let Some(entry) = found else { return Ok(None) }; | |
| 624 | + let blob = repo.find_blob(entry.id())?; | |
| 625 | + if blob.is_binary() || blob.size() > 1024 * 1024 { | |
| 626 | + return Ok(None); | |
| 627 | + } | |
| 628 | + Ok(Some(( | |
| 629 | + entry.name().unwrap_or("README").to_string(), | |
| 630 | + String::from_utf8_lossy(blob.content()).to_string(), | |
| 631 | + ))) | |
| 632 | +} | |
| 633 | + | |
| 634 | +// --------------------------------------------------------------------------- | |
| 635 | +// Diffs | |
| 636 | +// --------------------------------------------------------------------------- | |
| 637 | + | |
| 638 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 639 | +pub enum LineKind { | |
| 640 | + Add, | |
| 641 | + Del, | |
| 642 | + Context, | |
| 643 | +} | |
| 644 | + | |
| 645 | +#[derive(Debug, Clone)] | |
| 646 | +pub struct DiffLine { | |
| 647 | + pub kind: LineKind, | |
| 648 | + pub old_no: Option<u32>, | |
| 649 | + pub new_no: Option<u32>, | |
| 650 | + pub html: String, | |
| 651 | +} | |
| 652 | + | |
| 653 | +impl DiffLine { | |
| 654 | + pub fn class(&self) -> &'static str { | |
| 655 | + match self.kind { | |
| 656 | + LineKind::Add => "add", | |
| 657 | + LineKind::Del => "del", | |
| 658 | + LineKind::Context => "ctx", | |
| 659 | + } | |
| 660 | + } | |
| 661 | + pub fn marker(&self) -> &'static str { | |
| 662 | + match self.kind { | |
| 663 | + LineKind::Add => "+", | |
| 664 | + LineKind::Del => "-", | |
| 665 | + LineKind::Context => " ", | |
| 666 | + } | |
| 667 | + } | |
| 668 | +} | |
| 669 | + | |
| 670 | +#[derive(Debug, Clone)] | |
| 671 | +pub struct Hunk { | |
| 672 | + pub header: String, | |
| 673 | + pub lines: Vec<DiffLine>, | |
| 674 | +} | |
| 675 | + | |
| 676 | +#[derive(Debug, Clone)] | |
| 677 | +pub struct FileDiff { | |
| 678 | + pub index: usize, | |
| 679 | + pub old_path: String, | |
| 680 | + pub new_path: String, | |
| 681 | + pub status: &'static str, | |
| 682 | + pub additions: usize, | |
| 683 | + pub deletions: usize, | |
| 684 | + pub binary: bool, | |
| 685 | + pub too_large: bool, | |
| 686 | + pub hunks: Vec<Hunk>, | |
| 687 | +} | |
| 688 | + | |
| 689 | +impl FileDiff { | |
| 690 | + pub fn display_path(&self) -> String { | |
| 691 | + if self.status == "renamed" && self.old_path != self.new_path { | |
| 692 | + format!("{} → {}", self.old_path, self.new_path) | |
| 693 | + } else { | |
| 694 | + self.new_path.clone() | |
| 695 | + } | |
| 696 | + } | |
| 697 | + /// Five-square summary bar like `+++--`. | |
| 698 | + pub fn stat_blocks(&self) -> Vec<&'static str> { | |
| 699 | + let total = self.additions + self.deletions; | |
| 700 | + if total == 0 { | |
| 701 | + return vec!["neutral"; 5]; | |
| 702 | + } | |
| 703 | + let share = |n: usize| { | |
| 704 | + let blocks = ((n as f64 / total as f64) * 5.0).round() as usize; | |
| 705 | + if n > 0 { blocks.max(1) } else { 0 } | |
| 706 | + }; | |
| 707 | + let (mut adds, mut dels) = (share(self.additions), share(self.deletions)); | |
| 708 | + while adds + dels > 5 { | |
| 709 | + if adds > dels { adds -= 1 } else { dels -= 1 } | |
| 710 | + } | |
| 711 | + let mut v = vec!["add"; adds]; | |
| 712 | + v.extend(std::iter::repeat_n("del", dels)); | |
| 713 | + v.resize(5, "neutral"); | |
| 714 | + v | |
| 715 | + } | |
| 716 | +} | |
| 717 | + | |
| 718 | +#[derive(Debug, Clone, Default)] | |
| 719 | +pub struct DiffSummary { | |
| 720 | + pub files: Vec<FileDiff>, | |
| 721 | + pub additions: usize, | |
| 722 | + pub deletions: usize, | |
| 723 | +} | |
| 724 | + | |
| 725 | +const MAX_FILE_LINES: usize = 3000; | |
| 726 | +const MAX_TOTAL_LINES: usize = 20000; | |
| 727 | + | |
| 728 | +pub fn diff_trees( | |
| 729 | + repo: &Repository, | |
| 730 | + old: Option<&Tree>, | |
| 731 | + new: Option<&Tree>, | |
| 732 | +) -> Result<DiffSummary> { | |
| 733 | + let mut opts = DiffOptions::new(); | |
| 734 | + opts.context_lines(3).ignore_submodules(true); | |
| 735 | + let mut diff = repo.diff_tree_to_tree(old, new, Some(&mut opts))?; | |
| 736 | + let mut find = DiffFindOptions::new(); | |
| 737 | + find.renames(true); | |
| 738 | + diff.find_similar(Some(&mut find))?; | |
| 739 | + | |
| 740 | + let mut summary = DiffSummary::default(); | |
| 741 | + let mut rendered_lines = 0usize; | |
| 742 | + for idx in 0..diff.deltas().len() { | |
| 743 | + let Some(patch) = Patch::from_diff(&diff, idx)? else { | |
| 744 | + continue; | |
| 745 | + }; | |
| 746 | + let delta = patch.delta(); | |
| 747 | + let status = match delta.status() { | |
| 748 | + git2::Delta::Added => "added", | |
| 749 | + git2::Delta::Deleted => "deleted", | |
| 750 | + git2::Delta::Renamed => "renamed", | |
| 751 | + git2::Delta::Copied => "copied", | |
| 752 | + _ => "modified", | |
| 753 | + }; | |
| 754 | + let path_of = |f: git2::DiffFile| { | |
| 755 | + f.path() | |
| 756 | + .map(|p| p.to_string_lossy().to_string()) | |
| 757 | + .unwrap_or_default() | |
| 758 | + }; | |
| 759 | + let old_path = path_of(delta.old_file()); | |
| 760 | + let new_path = path_of(delta.new_file()); | |
| 761 | + let binary = delta.flags().is_binary(); | |
| 762 | + let (_, additions, deletions) = patch.line_stats()?; | |
| 763 | + let too_large = additions + deletions > MAX_FILE_LINES | |
| 764 | + || rendered_lines + additions + deletions > MAX_TOTAL_LINES; | |
| 765 | + | |
| 766 | + let mut hunks = Vec::new(); | |
| 767 | + if !binary && !too_large { | |
| 768 | + let lang_path = if new_path.is_empty() { | |
| 769 | + &old_path | |
| 770 | + } else { | |
| 771 | + &new_path | |
| 772 | + }; | |
| 773 | + for h in 0..patch.num_hunks() { | |
| 774 | + let (hunk, n) = patch.hunk(h)?; | |
| 775 | + let mut raw = Vec::with_capacity(n); | |
| 776 | + for l in 0..n { | |
| 777 | + let line = patch.line_in_hunk(h, l)?; | |
| 778 | + let kind = match line.origin() { | |
| 779 | + '+' => LineKind::Add, | |
| 780 | + '-' => LineKind::Del, | |
| 781 | + ' ' => LineKind::Context, | |
| 782 | + _ => continue, // "\ No newline at end of file" markers | |
| 783 | + }; | |
| 784 | + let text = String::from_utf8_lossy(line.content()); | |
| 785 | + let text = text.trim_end_matches(['\n', '\r']).to_string(); | |
| 786 | + raw.push((kind, line.old_lineno(), line.new_lineno(), text)); | |
| 787 | + } | |
| 788 | + let texts: Vec<&str> = raw.iter().map(|r| r.3.as_str()).collect(); | |
| 789 | + let htmls = highlight::highlight_lines(lang_path, &texts); | |
| 790 | + let lines = raw | |
| 791 | + .into_iter() | |
| 792 | + .zip(htmls) | |
| 793 | + .map(|((kind, old_no, new_no, _), html)| DiffLine { | |
| 794 | + kind, | |
| 795 | + old_no, | |
| 796 | + new_no, | |
| 797 | + html, | |
| 798 | + }) | |
| 799 | + .collect(); | |
| 800 | + hunks.push(Hunk { | |
| 801 | + header: String::from_utf8_lossy(hunk.header()) | |
| 802 | + .trim_end() | |
| 803 | + .to_string(), | |
| 804 | + lines, | |
| 805 | + }); | |
| 806 | + } | |
| 807 | + rendered_lines += additions + deletions; | |
| 808 | + } | |
| 809 | + summary.additions += additions; | |
| 810 | + summary.deletions += deletions; | |
| 811 | + summary.files.push(FileDiff { | |
| 812 | + index: summary.files.len(), | |
| 813 | + old_path, | |
| 814 | + new_path, | |
| 815 | + status, | |
| 816 | + additions, | |
| 817 | + deletions, | |
| 818 | + binary, | |
| 819 | + too_large, | |
| 820 | + hunks, | |
| 821 | + }); | |
| 822 | + } | |
| 823 | + Ok(summary) | |
| 824 | +} | |
| 825 | + | |
| 826 | +/// Diff introduced by a commit, against its first parent. | |
| 827 | +pub fn commit_diff(repo: &Repository, oid: Oid) -> Result<DiffSummary> { | |
| 828 | + let commit = repo.find_commit(oid)?; | |
| 829 | + let parent_tree = match commit.parent(0) { | |
| 830 | + Ok(p) => Some(p.tree()?), | |
| 831 | + Err(_) => None, | |
| 832 | + }; | |
| 833 | + diff_trees(repo, parent_tree.as_ref(), Some(&commit.tree()?)) | |
| 834 | +} | |
| 835 | + | |
| 836 | +/// Files changed, additions and deletions between two commits, without | |
| 837 | +/// rendering the patch. | |
| 838 | +pub fn diff_stats(repo: &Repository, base: Oid, head: Oid) -> Result<(usize, usize, usize)> { | |
| 839 | + let base_tree = repo.find_commit(base)?.tree()?; | |
| 840 | + let head_tree = repo.find_commit(head)?.tree()?; | |
| 841 | + let mut diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)?; | |
| 842 | + diff.find_similar(Some(DiffFindOptions::new().renames(true)))?; | |
| 843 | + let stats = diff.stats()?; | |
| 844 | + Ok((stats.files_changed(), stats.insertions(), stats.deletions())) | |
| 845 | +} | |
| 846 | + | |
| 847 | +/// Diff between two commits (`base..head` as trees). | |
| 848 | +pub fn range_diff(repo: &Repository, base: Oid, head: Oid) -> Result<DiffSummary> { | |
| 849 | + let base_tree = repo.find_commit(base)?.tree()?; | |
| 850 | + let head_tree = repo.find_commit(head)?.tree()?; | |
| 851 | + diff_trees(repo, Some(&base_tree), Some(&head_tree)) | |
| 852 | +} | |
| 853 | + | |
| 854 | +// --------------------------------------------------------------------------- | |
| 855 | +// Merging | |
| 856 | +// --------------------------------------------------------------------------- | |
| 857 | + | |
| 858 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 859 | +pub enum Mergeability { | |
| 860 | + /// Can be merged without conflicts. | |
| 861 | + Clean, | |
| 862 | + /// A three-way merge produces conflicts. | |
| 863 | + Conflicts, | |
| 864 | + /// The head has no commits that are not already in the base. | |
| 865 | + UpToDate, | |
| 866 | +} | |
| 867 | + | |
| 868 | +pub fn mergeability(repo: &Repository, base: Oid, head: Oid) -> Result<Mergeability> { | |
| 869 | + if is_ancestor(repo, head, base) { | |
| 870 | + return Ok(Mergeability::UpToDate); | |
| 871 | + } | |
| 872 | + let ours = repo.find_commit(base)?; | |
| 873 | + let theirs = repo.find_commit(head)?; | |
| 874 | + let index = repo.merge_commits(&ours, &theirs, None)?; | |
| 875 | + Ok(if index.has_conflicts() { | |
| 876 | + Mergeability::Conflicts | |
| 877 | + } else { | |
| 878 | + Mergeability::Clean | |
| 879 | + }) | |
| 880 | +} | |
| 881 | + | |
| 882 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 883 | +pub enum MergeMethod { | |
| 884 | + Merge, | |
| 885 | + Squash, | |
| 886 | + Rebase, | |
| 887 | +} | |
| 888 | + | |
| 889 | +impl MergeMethod { | |
| 890 | + pub fn parse(s: &str) -> Option<Self> { | |
| 891 | + match s { | |
| 892 | + "merge" => Some(Self::Merge), | |
| 893 | + "squash" => Some(Self::Squash), | |
| 894 | + "rebase" => Some(Self::Rebase), | |
| 895 | + _ => None, | |
| 896 | + } | |
| 897 | + } | |
| 898 | +} | |
| 899 | + | |
| 900 | +pub struct MergeRequest<'a> { | |
| 901 | + pub base_branch: &'a str, | |
| 902 | + pub base: Oid, | |
| 903 | + pub head: Oid, | |
| 904 | + pub method: MergeMethod, | |
| 905 | + pub message: &'a str, | |
| 906 | + pub committer: &'a Signature<'a>, | |
| 907 | +} | |
| 908 | + | |
| 909 | +/// Merges `head` into the base branch and returns the new tip of the base. | |
| 910 | +/// Fails without changing anything if the base branch moved or the merge conflicts. | |
| 911 | +pub fn merge(repo: &Repository, req: &MergeRequest) -> Result<Oid> { | |
| 912 | + let base_commit = repo.find_commit(req.base)?; | |
| 913 | + let head_commit = repo.find_commit(req.head)?; | |
| 914 | + | |
| 915 | + let new_tip = match req.method { | |
| 916 | + MergeMethod::Merge | MergeMethod::Squash => { | |
| 917 | + let mut index = repo.merge_commits(&base_commit, &head_commit, None)?; | |
| 918 | + if index.has_conflicts() { | |
| 919 | + bail!("This branch has conflicts that must be resolved"); | |
| 920 | + } | |
| 921 | + let tree = repo.find_tree(index.write_tree_to(repo)?)?; | |
| 922 | + let parents: Vec<&Commit> = if req.method == MergeMethod::Merge { | |
| 923 | + vec![&base_commit, &head_commit] | |
| 924 | + } else { | |
| 925 | + vec![&base_commit] | |
| 926 | + }; | |
| 927 | + let author = if req.method == MergeMethod::Squash { | |
| 928 | + head_commit.author().to_owned() | |
| 929 | + } else { | |
| 930 | + req.committer.to_owned() | |
| 931 | + }; | |
| 932 | + repo.commit(None, &author, req.committer, req.message, &tree, &parents)? | |
| 933 | + } | |
| 934 | + MergeMethod::Rebase => { | |
| 935 | + let mut walk = repo.revwalk()?; | |
| 936 | + walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; | |
| 937 | + walk.push(req.head)?; | |
| 938 | + walk.hide(req.base)?; | |
| 939 | + let mut onto = base_commit; | |
| 940 | + for oid in walk { | |
| 941 | + let commit = repo.find_commit(oid?)?; | |
| 942 | + if commit.parent_count() > 1 { | |
| 943 | + continue; // merge commits are dropped, as with `git rebase` | |
| 944 | + } | |
| 945 | + let mut index = repo.cherrypick_commit(&commit, &onto, 0, None)?; | |
| 946 | + if index.has_conflicts() { | |
| 947 | + bail!( | |
| 948 | + "Commit {} cannot be rebased cleanly", | |
| 949 | + &commit.id().to_string()[..7] | |
| 950 | + ); | |
| 951 | + } | |
| 952 | + let tree_id = index.write_tree_to(repo)?; | |
| 953 | + if tree_id == onto.tree_id() { | |
| 954 | + continue; // became empty | |
| 955 | + } | |
| 956 | + let tree = repo.find_tree(tree_id)?; | |
| 957 | + let message = String::from_utf8_lossy(commit.message_raw_bytes()).to_string(); | |
| 958 | + let new = repo.commit( | |
| 959 | + None, | |
| 960 | + &commit.author(), | |
| 961 | + req.committer, | |
| 962 | + &message, | |
| 963 | + &tree, | |
| 964 | + &[&onto], | |
| 965 | + )?; | |
| 966 | + onto = repo.find_commit(new)?; | |
| 967 | + } | |
| 968 | + onto.id() | |
| 969 | + } | |
| 970 | + }; | |
| 971 | + | |
| 972 | + repo.reference_matching( | |
| 973 | + &format!("refs/heads/{}", req.base_branch), | |
| 974 | + new_tip, | |
| 975 | + true, | |
| 976 | + req.base, | |
| 977 | + "openhub: merge pull request", | |
| 978 | + ) | |
| 979 | + .context("The base branch changed while merging; please try again")?; | |
| 980 | + Ok(new_tip) | |
| 981 | +} | |
| 982 | + | |
| 983 | +#[cfg(test)] | |
| 984 | +mod tests { | |
| 985 | + use super::*; | |
| 986 | + | |
| 987 | + #[test] | |
| 988 | + fn natural_ordering() { | |
| 989 | + let mut v = vec!["v1.9", "v1.10", "v1.2", "v0.9"]; | |
| 990 | + v.sort_by(|a, b| natural_cmp(a, b)); | |
| 991 | + assert_eq!(v, vec!["v0.9", "v1.2", "v1.9", "v1.10"]); | |
| 992 | + } | |
| 993 | + | |
| 994 | + #[test] | |
| 995 | + fn stat_blocks_sum_to_five() { | |
| 996 | + for (a, d) in [ | |
| 997 | + (0, 0), | |
| 998 | + (1, 0), | |
| 999 | + (0, 1), | |
| 1000 | + (1, 1), | |
| 1001 | + (10, 1), | |
| 1002 | + (1, 10), | |
| 1003 | + (3, 2), | |
| 1004 | + (100, 99), | |
| 1005 | + ] { | |
| 1006 | + let f = FileDiff { | |
| 1007 | + index: 0, | |
| 1008 | + old_path: String::new(), | |
| 1009 | + new_path: String::new(), | |
| 1010 | + status: "modified", | |
| 1011 | + additions: a, | |
| 1012 | + deletions: d, | |
| 1013 | + binary: false, | |
| 1014 | + too_large: false, | |
| 1015 | + hunks: vec![], | |
| 1016 | + }; | |
| 1017 | + let b = f.stat_blocks(); | |
| 1018 | + assert_eq!(b.len(), 5, "{a}/{d}"); | |
| 1019 | + assert_eq!(b.contains(&"add"), a > 0, "{a}/{d}"); | |
| 1020 | + assert_eq!(b.contains(&"del"), d > 0, "{a}/{d}"); | |
| 1021 | + } | |
| 1022 | + } | |
| 1023 | +} |
| @@ -0,0 +1,256 @@ | ||
| 1 | +//! Server-side syntax highlighting. | |
| 2 | +//! | |
| 3 | +//! Scopes from syntect grammars are collapsed into a small set of token | |
| 4 | +//! classes (`tok-k`, `tok-s`, ...) so colours live in our stylesheet and | |
| 5 | +//! follow the light/dark theme. Each line is rendered self-contained, which | |
| 6 | +//! lets the code view and diffs place lines in separate table rows. | |
| 7 | + | |
| 8 | +use std::collections::HashMap; | |
| 9 | +use std::fmt::Write; | |
| 10 | +use std::sync::LazyLock; | |
| 11 | + | |
| 12 | +use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxReference, SyntaxSet}; | |
| 13 | + | |
| 14 | +static SYNTAXES: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_newlines); | |
| 15 | + | |
| 16 | +/// Files larger than this are shown without highlighting. | |
| 17 | +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; | |
| 18 | + | |
| 19 | +const SCOPE_CLASSES: &[(&str, &str)] = &[ | |
| 20 | + ("comment", "c"), | |
| 21 | + ("punctuation.definition.comment", "c"), | |
| 22 | + ("string.regexp", "sr"), | |
| 23 | + ("constant.character.escape", "e"), | |
| 24 | + ("string", "s"), | |
| 25 | + ("markup.raw", "s"), | |
| 26 | + ("constant.numeric", "n"), | |
| 27 | + ("constant", "kc"), | |
| 28 | + ("variable.language", "kc"), | |
| 29 | + ("support.constant", "kc"), | |
| 30 | + ("keyword.operator", "o"), | |
| 31 | + ("keyword", "k"), | |
| 32 | + ("storage", "k"), | |
| 33 | + ("entity.name.function", "f"), | |
| 34 | + ("support.function", "f"), | |
| 35 | + ("variable.function", "f"), | |
| 36 | + ("entity.name.tag", "tag"), | |
| 37 | + ("entity.other.attribute-name", "attr"), | |
| 38 | + ("support.type.property-name", "attr"), | |
| 39 | + ("entity.name.section", "h"), | |
| 40 | + ("markup.heading", "h"), | |
| 41 | + ("entity.name", "t"), | |
| 42 | + ("entity.other.inherited-class", "t"), | |
| 43 | + ("support.type", "t"), | |
| 44 | + ("support.class", "t"), | |
| 45 | + ("meta.annotation", "m"), | |
| 46 | + ("meta.attribute", "m"), | |
| 47 | + ("variable.parameter", "v"), | |
| 48 | + ("markup.bold", "b"), | |
| 49 | + ("markup.italic", "i"), | |
| 50 | + ("markup.inserted", "ins"), | |
| 51 | + ("markup.deleted", "del"), | |
| 52 | + ("markup.underline.link", "link"), | |
| 53 | +]; | |
| 54 | + | |
| 55 | +fn class_for_scope(scope: Scope) -> Option<&'static str> { | |
| 56 | + let name = scope.build_string(); | |
| 57 | + SCOPE_CLASSES | |
| 58 | + .iter() | |
| 59 | + .find(|(prefix, _)| { | |
| 60 | + name == *prefix || (name.starts_with(prefix) && name.as_bytes()[prefix.len()] == b'.') | |
| 61 | + }) | |
| 62 | + .map(|(_, class)| *class) | |
| 63 | +} | |
| 64 | + | |
| 65 | +pub fn escape(s: &str) -> String { | |
| 66 | + let mut out = String::with_capacity(s.len()); | |
| 67 | + escape_into(&mut out, s); | |
| 68 | + out | |
| 69 | +} | |
| 70 | + | |
| 71 | +fn escape_into(out: &mut String, s: &str) { | |
| 72 | + for c in s.chars() { | |
| 73 | + match c { | |
| 74 | + '&' => out.push_str("&"), | |
| 75 | + '<' => out.push_str("<"), | |
| 76 | + '>' => out.push_str(">"), | |
| 77 | + '"' => out.push_str("""), | |
| 78 | + '\'' => out.push_str("'"), | |
| 79 | + _ => out.push(c), | |
| 80 | + } | |
| 81 | + } | |
| 82 | +} | |
| 83 | + | |
| 84 | +pub fn find_syntax(path: &str, first_line: &str) -> Option<&'static SyntaxReference> { | |
| 85 | + let ss = &*SYNTAXES; | |
| 86 | + let file_name = path.rsplit('/').next().unwrap_or(path); | |
| 87 | + let ext = file_name.rsplit_once('.').map(|(_, e)| e); | |
| 88 | + ext.and_then(|e| ss.find_syntax_by_extension(e)) | |
| 89 | + .or_else(|| ss.find_syntax_by_extension(file_name)) | |
| 90 | + .or_else(|| ss.find_syntax_by_first_line(first_line)) | |
| 91 | + .filter(|s| s.name != "Plain Text") | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// Human readable language name for a file, if known. | |
| 95 | +pub fn language_name(path: &str, first_line: &str) -> Option<&'static str> { | |
| 96 | + find_syntax(path, first_line).map(|s| s.name.as_str()) | |
| 97 | +} | |
| 98 | + | |
| 99 | +struct LineHighlighter { | |
| 100 | + state: ParseState, | |
| 101 | + stack: ScopeStack, | |
| 102 | + cache: HashMap<Scope, Option<&'static str>>, | |
| 103 | + failed: bool, | |
| 104 | +} | |
| 105 | + | |
| 106 | +impl LineHighlighter { | |
| 107 | + fn new(syntax: &SyntaxReference) -> Self { | |
| 108 | + LineHighlighter { | |
| 109 | + state: ParseState::new(syntax), | |
| 110 | + stack: ScopeStack::new(), | |
| 111 | + cache: HashMap::new(), | |
| 112 | + failed: false, | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + fn current_class(&mut self) -> Option<&'static str> { | |
| 117 | + for scope in self.stack.as_slice().iter().rev() { | |
| 118 | + let class = *self | |
| 119 | + .cache | |
| 120 | + .entry(*scope) | |
| 121 | + .or_insert_with(|| class_for_scope(*scope)); | |
| 122 | + if class.is_some() { | |
| 123 | + return class; | |
| 124 | + } | |
| 125 | + } | |
| 126 | + None | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Highlights one line (without its trailing newline). | |
| 130 | + fn line(&mut self, text: &str) -> String { | |
| 131 | + if self.failed { | |
| 132 | + return escape(text); | |
| 133 | + } | |
| 134 | + let with_newline = format!("{text}\n"); | |
| 135 | + let ops = match self.state.parse_line(&with_newline, &SYNTAXES) { | |
| 136 | + Ok(ops) => ops, | |
| 137 | + Err(_) => { | |
| 138 | + self.failed = true; | |
| 139 | + return escape(text); | |
| 140 | + } | |
| 141 | + }; | |
| 142 | + let mut out = String::with_capacity(text.len() * 2); | |
| 143 | + let mut pending = String::new(); | |
| 144 | + let mut pending_class: Option<&'static str> = None; | |
| 145 | + let mut cursor = 0; | |
| 146 | + | |
| 147 | + let flush = |out: &mut String, pending: &mut String, class: Option<&'static str>| { | |
| 148 | + if pending.is_empty() { | |
| 149 | + return; | |
| 150 | + } | |
| 151 | + match class { | |
| 152 | + Some(c) => { | |
| 153 | + let _ = write!(out, r#"<span class="tok-{c}">"#); | |
| 154 | + escape_into(out, pending); | |
| 155 | + out.push_str("</span>"); | |
| 156 | + } | |
| 157 | + None => escape_into(out, pending), | |
| 158 | + } | |
| 159 | + pending.clear(); | |
| 160 | + }; | |
| 161 | + | |
| 162 | + for (pos, op) in ops { | |
| 163 | + let pos = pos.min(text.len()); | |
| 164 | + if pos > cursor { | |
| 165 | + let class = self.current_class(); | |
| 166 | + if class != pending_class { | |
| 167 | + flush(&mut out, &mut pending, pending_class); | |
| 168 | + pending_class = class; | |
| 169 | + } | |
| 170 | + pending.push_str(&text[cursor..pos]); | |
| 171 | + cursor = pos; | |
| 172 | + } | |
| 173 | + if self.stack.apply(&op).is_err() { | |
| 174 | + self.failed = true; | |
| 175 | + break; | |
| 176 | + } | |
| 177 | + } | |
| 178 | + if cursor < text.len() { | |
| 179 | + let class = if self.failed { | |
| 180 | + None | |
| 181 | + } else { | |
| 182 | + self.current_class() | |
| 183 | + }; | |
| 184 | + if class != pending_class { | |
| 185 | + flush(&mut out, &mut pending, pending_class); | |
| 186 | + pending_class = class; | |
| 187 | + } | |
| 188 | + pending.push_str(&text[cursor..]); | |
| 189 | + } | |
| 190 | + flush(&mut out, &mut pending, pending_class); | |
| 191 | + out | |
| 192 | + } | |
| 193 | +} | |
| 194 | + | |
| 195 | +/// Highlights a sequence of lines from the file at `path`, returning one | |
| 196 | +/// HTML fragment per input line. | |
| 197 | +pub fn highlight_lines(path: &str, lines: &[&str]) -> Vec<String> { | |
| 198 | + let total: usize = lines.iter().map(|l| l.len()).sum(); | |
| 199 | + let syntax = (total <= MAX_HIGHLIGHT_BYTES) | |
| 200 | + .then(|| find_syntax(path, lines.first().copied().unwrap_or(""))) | |
| 201 | + .flatten(); | |
| 202 | + match syntax { | |
| 203 | + Some(syntax) => { | |
| 204 | + let mut h = LineHighlighter::new(syntax); | |
| 205 | + lines.iter().map(|l| h.line(l)).collect() | |
| 206 | + } | |
| 207 | + None => lines.iter().map(|l| escape(l)).collect(), | |
| 208 | + } | |
| 209 | +} | |
| 210 | + | |
| 211 | +/// Highlights a code block in a known language (used for Markdown fences). | |
| 212 | +pub fn highlight_block(lang: &str, code: &str) -> Option<String> { | |
| 213 | + let syntax = SYNTAXES | |
| 214 | + .find_syntax_by_token(lang) | |
| 215 | + .filter(|s| s.name != "Plain Text")?; | |
| 216 | + if code.len() > MAX_HIGHLIGHT_BYTES { | |
| 217 | + return None; | |
| 218 | + } | |
| 219 | + let mut h = LineHighlighter::new(syntax); | |
| 220 | + let lines: Vec<String> = code.lines().map(|l| h.line(l)).collect(); | |
| 221 | + Some(lines.join("\n")) | |
| 222 | +} | |
| 223 | + | |
| 224 | +#[cfg(test)] | |
| 225 | +mod tests { | |
| 226 | + use super::*; | |
| 227 | + | |
| 228 | + #[test] | |
| 229 | + fn highlights_rust_keywords_and_strings() { | |
| 230 | + let out = highlight_lines("main.rs", &["fn main() {", " let s = \"<hi>\";", "}"]); | |
| 231 | + assert_eq!(out.len(), 3); | |
| 232 | + assert!( | |
| 233 | + out[0].contains(r#"<span class="tok-k">fn</span>"#), | |
| 234 | + "{}", | |
| 235 | + out[0] | |
| 236 | + ); | |
| 237 | + assert!(out[1].contains("<hi>"), "{}", out[1]); | |
| 238 | + assert!(out[1].contains("tok-s"), "{}", out[1]); | |
| 239 | + } | |
| 240 | + | |
| 241 | + #[test] | |
| 242 | + fn unknown_files_are_escaped() { | |
| 243 | + let out = highlight_lines("notes.unknownext", &["<b>&</b>"]); | |
| 244 | + assert_eq!(out, vec!["<b>&</b>"]); | |
| 245 | + } | |
| 246 | + | |
| 247 | + #[test] | |
| 248 | + fn multiline_comment_classes_every_line() { | |
| 249 | + let out = highlight_lines("a.c", &["/* one", "two */ int x;"]); | |
| 250 | + assert!( | |
| 251 | + out[1].starts_with(r#"<span class="tok-c">two */</span>"#), | |
| 252 | + "{}", | |
| 253 | + out[1] | |
| 254 | + ); | |
| 255 | + } | |
| 256 | +} |
| @@ -0,0 +1,104 @@ | ||
| 1 | +// Icons from Lucide (https://lucide.dev), ISC License. | |
| 2 | +// Copyright (c) Lucide Contributors 2022; portions (c) Cole Bemis 2013-2022 (Feather, MIT). | |
| 3 | + | |
| 4 | +/// Returns the inner SVG markup for a named icon. | |
| 5 | +fn body(name: &str) -> &'static str { | |
| 6 | + match name { | |
| 7 | + "arrow-right" => r#"<path d="M5 12h14" /><path d="m12 5 7 7-7 7" />"#, | |
| 8 | + "book-marked" => { | |
| 9 | + r#"<path d="M10 2v8l3-3 3 3V2" /><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" />"# | |
| 10 | + } | |
| 11 | + "check" => r#"<path d="M20 6 9 17l-5-5" />"#, | |
| 12 | + "chevron-down" => r#"<path d="m6 9 6 6 6-6" />"#, | |
| 13 | + "circle-alert" => { | |
| 14 | + r#"<circle cx="12" cy="12" r="10" /><line x1="12" x2="12" y1="8" y2="12" /><line x1="12" x2="12.01" y1="16" y2="16" />"# | |
| 15 | + } | |
| 16 | + "circle-check" => r#"<circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" />"#, | |
| 17 | + "clock" => r#"<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />"#, | |
| 18 | + "code" => r#"<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />"#, | |
| 19 | + "copy" => { | |
| 20 | + r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />"# | |
| 21 | + } | |
| 22 | + "download" => { | |
| 23 | + r#"<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" />"# | |
| 24 | + } | |
| 25 | + "file-code" => { | |
| 26 | + r#"<path d="M10 12.5 8 15l2 2.5" /><path d="m14 12.5 2 2.5-2 2.5" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />"# | |
| 27 | + } | |
| 28 | + "file-text" => { | |
| 29 | + r#"<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />"# | |
| 30 | + } | |
| 31 | + "file" => { | |
| 32 | + r#"<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />"# | |
| 33 | + } | |
| 34 | + "folder" => { | |
| 35 | + r#"<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />"# | |
| 36 | + } | |
| 37 | + "git-branch" => { | |
| 38 | + r#"<line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" />"# | |
| 39 | + } | |
| 40 | + "git-commit-horizontal" => { | |
| 41 | + r#"<circle cx="12" cy="12" r="3" /><line x1="3" x2="9" y1="12" y2="12" /><line x1="15" x2="21" y1="12" y2="12" />"# | |
| 42 | + } | |
| 43 | + "git-compare" => { | |
| 44 | + r#"<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M13 6h3a2 2 0 0 1 2 2v7" /><path d="M11 18H8a2 2 0 0 1-2-2V9" />"# | |
| 45 | + } | |
| 46 | + "git-merge" => { | |
| 47 | + r#"<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M6 21V9a9 9 0 0 0 9 9" />"# | |
| 48 | + } | |
| 49 | + "git-pull-request-closed" => { | |
| 50 | + r#"<circle cx="6" cy="6" r="3" /><path d="M6 9v12" /><path d="m21 3-6 6" /><path d="m21 9-6-6" /><path d="M18 11.5V15" /><circle cx="18" cy="18" r="3" />"# | |
| 51 | + } | |
| 52 | + "git-pull-request" => { | |
| 53 | + r#"<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" x2="6" y1="9" y2="21" />"# | |
| 54 | + } | |
| 55 | + "globe" => { | |
| 56 | + r#"<circle cx="12" cy="12" r="10" /><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /><path d="M2 12h20" />"# | |
| 57 | + } | |
| 58 | + "history" => { | |
| 59 | + r#"<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l4 2" />"# | |
| 60 | + } | |
| 61 | + "key-round" => { | |
| 62 | + r#"<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /><circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />"# | |
| 63 | + } | |
| 64 | + "lock" => { | |
| 65 | + r#"<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />"# | |
| 66 | + } | |
| 67 | + "log-out" => { | |
| 68 | + r#"<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" x2="9" y1="12" y2="12" />"# | |
| 69 | + } | |
| 70 | + "message-square" => { | |
| 71 | + r#"<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />"# | |
| 72 | + } | |
| 73 | + "plus" => r#"<path d="M5 12h14" /><path d="M12 5v14" />"#, | |
| 74 | + "search" => r#"<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />"#, | |
| 75 | + "settings" => { | |
| 76 | + r#"<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /><circle cx="12" cy="12" r="3" />"# | |
| 77 | + } | |
| 78 | + "tag" => { | |
| 79 | + r#"<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />"# | |
| 80 | + } | |
| 81 | + "terminal" => { | |
| 82 | + r#"<polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" />"# | |
| 83 | + } | |
| 84 | + "trash-2" => { | |
| 85 | + r#"<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /><line x1="10" x2="10" y1="11" y2="17" /><line x1="14" x2="14" y1="11" y2="17" />"# | |
| 86 | + } | |
| 87 | + "user" => { | |
| 88 | + r#"<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />"# | |
| 89 | + } | |
| 90 | + "users" => { | |
| 91 | + r#"<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />"# | |
| 92 | + } | |
| 93 | + "x" => r#"<path d="M18 6 6 18" /><path d="m6 6 12 12" />"#, | |
| 94 | + _ => "", | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Renders a named icon as an inline SVG element. | |
| 99 | +pub fn icon(name: &str) -> String { | |
| 100 | + format!( | |
| 101 | + r#"<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{}</svg>"#, | |
| 102 | + body(name) | |
| 103 | + ) | |
| 104 | +} |
| @@ -0,0 +1,57 @@ | ||
| 1 | +mod config; | |
| 2 | +mod db; | |
| 3 | +mod git; | |
| 4 | +mod highlight; | |
| 5 | +mod icons; | |
| 6 | +mod markdown; | |
| 7 | +mod ssh; | |
| 8 | +mod state; | |
| 9 | +mod sync; | |
| 10 | +mod validate; | |
| 11 | +mod web; | |
| 12 | + | |
| 13 | +use anyhow::Context; | |
| 14 | +use clap::Parser; | |
| 15 | +use tracing_subscriber::EnvFilter; | |
| 16 | + | |
| 17 | +use crate::config::Config; | |
| 18 | +use crate::state::AppState; | |
| 19 | + | |
| 20 | +#[tokio::main] | |
| 21 | +async fn main() -> anyhow::Result<()> { | |
| 22 | + tracing_subscriber::fmt() | |
| 23 | + .with_env_filter( | |
| 24 | + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("openhub=info")), | |
| 25 | + ) | |
| 26 | + .init(); | |
| 27 | + | |
| 28 | + let mut config = Config::parse(); | |
| 29 | + config.normalize(); | |
| 30 | + std::fs::create_dir_all(config.repos_dir()) | |
| 31 | + .with_context(|| format!("creating data directory {}", config.data_dir.display()))?; | |
| 32 | + check_git(&config.git_bin)?; | |
| 33 | + | |
| 34 | + let db = db::connect(&config.db_path()).await?; | |
| 35 | + let state = AppState::new(config, db); | |
| 36 | + | |
| 37 | + tokio::select! { | |
| 38 | + res = web::serve(state.clone()) => res.context("web server stopped"), | |
| 39 | + res = ssh::serve(state.clone()) => res.context("SSH server stopped"), | |
| 40 | + _ = tokio::signal::ctrl_c() => { | |
| 41 | + tracing::info!("shutting down"); | |
| 42 | + Ok(()) | |
| 43 | + } | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// Fetches and pushes are served by the system git, so make sure it exists. | |
| 48 | +fn check_git(git_bin: &str) -> anyhow::Result<()> { | |
| 49 | + let output = std::process::Command::new(git_bin) | |
| 50 | + .arg("--version") | |
| 51 | + .output() | |
| 52 | + .with_context(|| { | |
| 53 | + format!("could not run `{git_bin}`; Openhub requires git to be installed") | |
| 54 | + })?; | |
| 55 | + tracing::info!("using {}", String::from_utf8_lossy(&output.stdout).trim()); | |
| 56 | + Ok(()) | |
| 57 | +} |
| @@ -0,0 +1,179 @@ | ||
| 1 | +//! GitHub-flavoured Markdown rendering with HTML sanitisation. | |
| 2 | + | |
| 3 | +use std::borrow::Cow; | |
| 4 | +use std::collections::HashMap; | |
| 5 | +use std::fmt; | |
| 6 | + | |
| 7 | +use comrak::adapters::SyntaxHighlighterAdapter; | |
| 8 | +use comrak::options::Plugins; | |
| 9 | +use comrak::{Options, markdown_to_html_with_plugins}; | |
| 10 | + | |
| 11 | +use crate::highlight; | |
| 12 | + | |
| 13 | +/// Where relative links and images inside a rendered document should point. | |
| 14 | +#[derive(Debug, Clone)] | |
| 15 | +pub struct LinkBase { | |
| 16 | + /// e.g. `/alice/project/blob/main` | |
| 17 | + pub blob_root: String, | |
| 18 | + /// e.g. `/alice/project/raw/main` | |
| 19 | + pub raw_root: String, | |
| 20 | + /// Directory of the document inside the repository, e.g. `docs`. | |
| 21 | + pub dir: String, | |
| 22 | +} | |
| 23 | + | |
| 24 | +struct Highlighter; | |
| 25 | + | |
| 26 | +impl SyntaxHighlighterAdapter for Highlighter { | |
| 27 | + fn write_highlighted( | |
| 28 | + &self, | |
| 29 | + output: &mut dyn fmt::Write, | |
| 30 | + lang: Option<&str>, | |
| 31 | + code: &str, | |
| 32 | + ) -> fmt::Result { | |
| 33 | + let html = lang | |
| 34 | + .and_then(|l| highlight::highlight_block(l, code)) | |
| 35 | + .unwrap_or_else(|| highlight::escape(code)); | |
| 36 | + output.write_str(&html) | |
| 37 | + } | |
| 38 | + | |
| 39 | + fn write_pre_tag( | |
| 40 | + &self, | |
| 41 | + output: &mut dyn fmt::Write, | |
| 42 | + _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 43 | + ) -> fmt::Result { | |
| 44 | + output.write_str("<pre>") | |
| 45 | + } | |
| 46 | + | |
| 47 | + fn write_code_tag( | |
| 48 | + &self, | |
| 49 | + output: &mut dyn fmt::Write, | |
| 50 | + _attributes: HashMap<&'static str, Cow<'_, str>>, | |
| 51 | + ) -> fmt::Result { | |
| 52 | + output.write_str("<code>") | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +const TOKEN_CLASSES: &[&str] = &[ | |
| 57 | + "tok-c", "tok-sr", "tok-e", "tok-s", "tok-n", "tok-kc", "tok-o", "tok-k", "tok-f", "tok-tag", | |
| 58 | + "tok-attr", "tok-h", "tok-t", "tok-m", "tok-v", "tok-b", "tok-i", "tok-ins", "tok-del", | |
| 59 | + "tok-link", | |
| 60 | +]; | |
| 61 | + | |
| 62 | +/// Renders untrusted Markdown to sanitised HTML. | |
| 63 | +pub fn render(source: &str, links: Option<&LinkBase>) -> String { | |
| 64 | + let mut options = Options::default(); | |
| 65 | + options.extension.strikethrough = true; | |
| 66 | + options.extension.table = true; | |
| 67 | + options.extension.autolink = true; | |
| 68 | + options.extension.tasklist = true; | |
| 69 | + options.extension.footnotes = true; | |
| 70 | + options.render.r#unsafe = true; // raw HTML is allowed through, then sanitised below | |
| 71 | + | |
| 72 | + let highlighter = Highlighter; | |
| 73 | + let mut plugins = Plugins::default(); | |
| 74 | + plugins.render.codefence_syntax_highlighter = Some(&highlighter); | |
| 75 | + let html = markdown_to_html_with_plugins(source, &options, &plugins); | |
| 76 | + | |
| 77 | + let mut builder = ammonia::Builder::default(); | |
| 78 | + builder | |
| 79 | + .add_tags(["input"]) | |
| 80 | + .add_tag_attributes("input", ["type", "checked", "disabled"]) | |
| 81 | + .add_tag_attributes("img", ["width", "height", "align"]) | |
| 82 | + .add_generic_attributes(["align"]) | |
| 83 | + .add_allowed_classes("span", TOKEN_CLASSES) | |
| 84 | + .link_rel(Some("noopener noreferrer nofollow")) | |
| 85 | + .url_relative(ammonia::UrlRelative::PassThrough); | |
| 86 | + if let Some(base) = links.cloned() { | |
| 87 | + builder.attribute_filter( | |
| 88 | + move |element, attribute, value| match (element, attribute) { | |
| 89 | + ("a", "href") => Some(rewrite_relative(value, &base.blob_root, &base.dir)), | |
| 90 | + ("img", "src") => Some(rewrite_relative(value, &base.raw_root, &base.dir)), | |
| 91 | + _ => Some(Cow::Borrowed(value)), | |
| 92 | + }, | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + builder.clean(&html).to_string() | |
| 96 | +} | |
| 97 | + | |
| 98 | +fn rewrite_relative<'u>(value: &'u str, root: &str, dir: &str) -> Cow<'u, str> { | |
| 99 | + let is_absolute = value.starts_with('#') | |
| 100 | + || value.starts_with("//") | |
| 101 | + || value | |
| 102 | + .split_once(':') | |
| 103 | + .is_some_and(|(scheme, _)| !scheme.contains('/') && !scheme.is_empty()); | |
| 104 | + if is_absolute || value.is_empty() { | |
| 105 | + return Cow::Borrowed(value); | |
| 106 | + } | |
| 107 | + let (path, suffix) = match value.find(['?', '#']) { | |
| 108 | + Some(i) => value.split_at(i), | |
| 109 | + None => (value, ""), | |
| 110 | + }; | |
| 111 | + let joined = if let Some(rooted) = path.strip_prefix('/') { | |
| 112 | + rooted.to_string() | |
| 113 | + } else if dir.is_empty() { | |
| 114 | + path.to_string() | |
| 115 | + } else { | |
| 116 | + format!("{dir}/{path}") | |
| 117 | + }; | |
| 118 | + let mut parts: Vec<&str> = Vec::new(); | |
| 119 | + for seg in joined.split('/') { | |
| 120 | + match seg { | |
| 121 | + "" | "." => {} | |
| 122 | + ".." => { | |
| 123 | + parts.pop(); | |
| 124 | + } | |
| 125 | + s => parts.push(s), | |
| 126 | + } | |
| 127 | + } | |
| 128 | + Cow::Owned(format!("{root}/{}{suffix}", parts.join("/"))) | |
| 129 | +} | |
| 130 | + | |
| 131 | +#[cfg(test)] | |
| 132 | +mod tests { | |
| 133 | + use super::*; | |
| 134 | + | |
| 135 | + #[test] | |
| 136 | + fn strips_scripts() { | |
| 137 | + let out = render( | |
| 138 | + "hello <script>alert(1)</script> <img src=x onerror=alert(1)>", | |
| 139 | + None, | |
| 140 | + ); | |
| 141 | + assert!(!out.contains("<script")); | |
| 142 | + assert!(!out.contains("onerror")); | |
| 143 | + } | |
| 144 | + | |
| 145 | + #[test] | |
| 146 | + fn rewrites_relative_links() { | |
| 147 | + let base = LinkBase { | |
| 148 | + blob_root: "/a/r/blob/main".into(), | |
| 149 | + raw_root: "/a/r/raw/main".into(), | |
| 150 | + dir: "docs".into(), | |
| 151 | + }; | |
| 152 | + let out = render( | |
| 153 | + "[x](../src/lib.rs#L3)  [y](https://e.com) [z](#top)", | |
| 154 | + Some(&base), | |
| 155 | + ); | |
| 156 | + assert!( | |
| 157 | + out.contains(r#"href="/a/r/blob/main/src/lib.rs#L3""#), | |
| 158 | + "{out}" | |
| 159 | + ); | |
| 160 | + assert!( | |
| 161 | + out.contains(r#"src="/a/r/raw/main/docs/img/logo.png""#), | |
| 162 | + "{out}" | |
| 163 | + ); | |
| 164 | + assert!(out.contains(r#"href="https://e.com""#), "{out}"); | |
| 165 | + assert!(out.contains(r##"href="#top""##), "{out}"); | |
| 166 | + } | |
| 167 | + | |
| 168 | + #[test] | |
| 169 | + fn highlights_fenced_code() { | |
| 170 | + let out = render("```rust\nfn main() {}\n```", None); | |
| 171 | + assert!(out.contains(r#"<span class="tok-k">fn</span>"#), "{out}"); | |
| 172 | + } | |
| 173 | + | |
| 174 | + #[test] | |
| 175 | + fn renders_task_lists() { | |
| 176 | + let out = render("- [x] done\n- [ ] todo", None); | |
| 177 | + assert!(out.contains("checkbox"), "{out}"); | |
| 178 | + } | |
| 179 | +} |
| @@ -0,0 +1,532 @@ | ||
| 1 | +//! Built-in SSH server for `git push` / `git fetch`. | |
| 2 | +//! | |
| 3 | +//! Users authenticate with the public keys registered in their settings. | |
| 4 | +//! The only commands accepted are `git-upload-pack` (fetch) and | |
| 5 | +//! `git-receive-pack` (push); each is mapped to a hosted repository after | |
| 6 | +//! checking permissions and run with the system `git`. | |
| 7 | + | |
| 8 | +use std::collections::HashMap; | |
| 9 | +use std::net::SocketAddr; | |
| 10 | +use std::path::Path; | |
| 11 | +use std::process::Stdio; | |
| 12 | +use std::sync::Arc; | |
| 13 | +use std::time::Duration; | |
| 14 | + | |
| 15 | +use anyhow::{Context, Result}; | |
| 16 | +use rand::RngCore; | |
| 17 | +use russh::keys::ssh_key::private::Ed25519Keypair; | |
| 18 | +use russh::keys::ssh_key::{HashAlg, LineEnding, PrivateKey, PublicKey}; | |
| 19 | +use russh::server::{self, Auth, Msg, Server as _, Session}; | |
| 20 | +use russh::{Channel, ChannelId, MethodKind, MethodSet, Pty, SshId}; | |
| 21 | +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; | |
| 22 | +use tokio::process::Command; | |
| 23 | +use tokio::sync::mpsc; | |
| 24 | + | |
| 25 | +use crate::db::{self, Repo, User}; | |
| 26 | +use crate::git; | |
| 27 | +use crate::state::AppState; | |
| 28 | +use crate::sync; | |
| 29 | + | |
| 30 | +pub async fn serve(state: AppState) -> Result<()> { | |
| 31 | + let key = load_or_create_host_key(&state.config.host_key_path())?; | |
| 32 | + tracing::info!( | |
| 33 | + "SSH host key fingerprint: {}", | |
| 34 | + key.public_key().fingerprint(HashAlg::Sha256) | |
| 35 | + ); | |
| 36 | + let mut methods = MethodSet::empty(); | |
| 37 | + methods.push(MethodKind::PublicKey); | |
| 38 | + let config = Arc::new(server::Config { | |
| 39 | + server_id: SshId::Standard("SSH-2.0-Openhub".into()), | |
| 40 | + methods, | |
| 41 | + auth_rejection_time: Duration::from_millis(300), | |
| 42 | + auth_rejection_time_initial: Some(Duration::ZERO), | |
| 43 | + keys: vec![key], | |
| 44 | + inactivity_timeout: Some(Duration::from_secs(3600)), | |
| 45 | + keepalive_interval: Some(Duration::from_secs(60)), | |
| 46 | + nodelay: true, | |
| 47 | + ..Default::default() | |
| 48 | + }); | |
| 49 | + let addr = state.config.ssh_addr; | |
| 50 | + tracing::info!("SSH server listening on {addr}"); | |
| 51 | + let mut server = SshServer { state }; | |
| 52 | + server.run_on_address(config, addr).await?; | |
| 53 | + Ok(()) | |
| 54 | +} | |
| 55 | + | |
| 56 | +fn load_or_create_host_key(path: &Path) -> Result<PrivateKey> { | |
| 57 | + if path.exists() { | |
| 58 | + return PrivateKey::read_openssh_file(path) | |
| 59 | + .with_context(|| format!("reading SSH host key {}", path.display())); | |
| 60 | + } | |
| 61 | + let mut seed = [0u8; 32]; | |
| 62 | + rand::rngs::OsRng.fill_bytes(&mut seed); | |
| 63 | + let key = PrivateKey::from(Ed25519Keypair::from_seed(&seed)); | |
| 64 | + let pem = key.to_openssh(LineEnding::LF)?; | |
| 65 | + let mut opts = std::fs::OpenOptions::new(); | |
| 66 | + opts.write(true).create_new(true); | |
| 67 | + #[cfg(unix)] | |
| 68 | + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); | |
| 69 | + std::io::Write::write_all(&mut opts.open(path)?, pem.as_bytes())?; | |
| 70 | + tracing::info!("generated new SSH host key at {}", path.display()); | |
| 71 | + Ok(key) | |
| 72 | +} | |
| 73 | + | |
| 74 | +struct SshServer { | |
| 75 | + state: AppState, | |
| 76 | +} | |
| 77 | + | |
| 78 | +impl server::Server for SshServer { | |
| 79 | + type Handler = SshSession; | |
| 80 | + | |
| 81 | + fn new_client(&mut self, peer: Option<SocketAddr>) -> SshSession { | |
| 82 | + SshSession { | |
| 83 | + state: self.state.clone(), | |
| 84 | + peer, | |
| 85 | + user: None, | |
| 86 | + channels: HashMap::new(), | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + fn handle_session_error(&mut self, error: anyhow::Error) { | |
| 91 | + tracing::debug!("ssh session ended with error: {error:#}"); | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +#[derive(Default)] | |
| 96 | +struct ChannelState { | |
| 97 | + /// Value of GIT_PROTOCOL sent by the client (enables protocol v2). | |
| 98 | + protocol: Option<String>, | |
| 99 | + /// Feeds client data to the running git process. Dropping it closes stdin. | |
| 100 | + stdin: Option<mpsc::UnboundedSender<Vec<u8>>>, | |
| 101 | +} | |
| 102 | + | |
| 103 | +struct SshSession { | |
| 104 | + state: AppState, | |
| 105 | + peer: Option<SocketAddr>, | |
| 106 | + user: Option<User>, | |
| 107 | + channels: HashMap<ChannelId, ChannelState>, | |
| 108 | +} | |
| 109 | + | |
| 110 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 111 | +enum Service { | |
| 112 | + UploadPack, | |
| 113 | + ReceivePack, | |
| 114 | +} | |
| 115 | + | |
| 116 | +impl Service { | |
| 117 | + fn subcommand(self) -> &'static str { | |
| 118 | + match self { | |
| 119 | + Service::UploadPack => "upload-pack", | |
| 120 | + Service::ReceivePack => "receive-pack", | |
| 121 | + } | |
| 122 | + } | |
| 123 | +} | |
| 124 | + | |
| 125 | +/// Parses `git-upload-pack '/owner/repo.git'` and similar into the service | |
| 126 | +/// and the (owner, repo) pair. | |
| 127 | +fn parse_command(command: &str) -> Option<(Service, String, String)> { | |
| 128 | + let command = command.trim(); | |
| 129 | + let (verb, rest) = command.split_once(' ')?; | |
| 130 | + let (service, arg) = match verb { | |
| 131 | + "git-upload-pack" => (Service::UploadPack, rest), | |
| 132 | + "git-receive-pack" => (Service::ReceivePack, rest), | |
| 133 | + "git" => { | |
| 134 | + let (sub, arg) = rest.trim_start().split_once(' ')?; | |
| 135 | + match sub { | |
| 136 | + "upload-pack" => (Service::UploadPack, arg), | |
| 137 | + "receive-pack" => (Service::ReceivePack, arg), | |
| 138 | + _ => return None, | |
| 139 | + } | |
| 140 | + } | |
| 141 | + _ => return None, | |
| 142 | + }; | |
| 143 | + let arg = arg.trim(); | |
| 144 | + let unquoted = arg | |
| 145 | + .strip_prefix('\'') | |
| 146 | + .and_then(|a| a.strip_suffix('\'')) | |
| 147 | + .or_else(|| arg.strip_prefix('"').and_then(|a| a.strip_suffix('"'))) | |
| 148 | + .unwrap_or(arg); | |
| 149 | + if unquoted.contains(['\'', '"', '\\', '\0']) { | |
| 150 | + return None; | |
| 151 | + } | |
| 152 | + let path = unquoted.trim_matches('/'); | |
| 153 | + let path = path.strip_suffix(".git").unwrap_or(path); | |
| 154 | + let (owner, name) = path.split_once('/')?; | |
| 155 | + if owner.is_empty() || name.is_empty() || name.contains('/') { | |
| 156 | + return None; | |
| 157 | + } | |
| 158 | + Some((service, owner.to_string(), name.to_string())) | |
| 159 | +} | |
| 160 | + | |
| 161 | +fn branch_tips(path: &Path) -> HashMap<String, String> { | |
| 162 | + let Ok(repo) = git::open(path) else { | |
| 163 | + return HashMap::new(); | |
| 164 | + }; | |
| 165 | + git::branch_names(&repo) | |
| 166 | + .unwrap_or_default() | |
| 167 | + .into_iter() | |
| 168 | + .filter_map(|b| git::branch_tip(&repo, &b).map(|t| (b, t.to_string()))) | |
| 169 | + .collect() | |
| 170 | +} | |
| 171 | + | |
| 172 | +async fn forward<R, F, Fut>(mut reader: R, mut send: F) | |
| 173 | +where | |
| 174 | + R: AsyncRead + Unpin, | |
| 175 | + F: FnMut(Vec<u8>) -> Fut, | |
| 176 | + Fut: std::future::Future<Output = bool>, | |
| 177 | +{ | |
| 178 | + let mut buf = vec![0u8; 32 * 1024]; | |
| 179 | + loop { | |
| 180 | + match reader.read(&mut buf).await { | |
| 181 | + Ok(0) | Err(_) => break, | |
| 182 | + Ok(n) => { | |
| 183 | + if !send(buf[..n].to_vec()).await { | |
| 184 | + break; | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + } | |
| 189 | +} | |
| 190 | + | |
| 191 | +impl SshSession { | |
| 192 | + fn reject(session: &mut Session, channel: ChannelId, message: &str) -> Result<()> { | |
| 193 | + session.channel_success(channel)?; | |
| 194 | + session.extended_data(channel, 1, format!("ERROR: {message}\n").into_bytes())?; | |
| 195 | + session.exit_status_request(channel, 1)?; | |
| 196 | + session.eof(channel)?; | |
| 197 | + session.close(channel)?; | |
| 198 | + Ok(()) | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +/// Lines shown after a push, pointing at pull requests for pushed branches. | |
| 203 | +async fn push_hints( | |
| 204 | + state: &AppState, | |
| 205 | + repo: &Repo, | |
| 206 | + before: &HashMap<String, String>, | |
| 207 | + after: &HashMap<String, String>, | |
| 208 | +) -> Vec<String> { | |
| 209 | + let base = format!("{}/{}/{}", state.config.public_url, repo.owner, repo.name); | |
| 210 | + let default = db::repo_by_name(&state.db, &repo.owner, &repo.name) | |
| 211 | + .await | |
| 212 | + .ok() | |
| 213 | + .flatten() | |
| 214 | + .map(|r| r.default_branch) | |
| 215 | + .unwrap_or_else(|| repo.default_branch.clone()); | |
| 216 | + let open = db::list_pulls(&state.db, repo.id, true) | |
| 217 | + .await | |
| 218 | + .unwrap_or_default(); | |
| 219 | + let mut lines = Vec::new(); | |
| 220 | + let mut updated: Vec<&String> = after | |
| 221 | + .iter() | |
| 222 | + .filter(|(b, tip)| before.get(*b) != Some(*tip) && **b != default) | |
| 223 | + .map(|(b, _)| b) | |
| 224 | + .collect(); | |
| 225 | + updated.sort(); | |
| 226 | + for branch in updated { | |
| 227 | + lines.push(String::new()); | |
| 228 | + match open.iter().find(|p| &p.head_branch == branch) { | |
| 229 | + Some(pr) => { | |
| 230 | + lines.push(format!("View pull request for '{branch}':")); | |
| 231 | + lines.push(format!(" {base}/pull/{}", pr.number)); | |
| 232 | + } | |
| 233 | + None => { | |
| 234 | + lines.push(format!( | |
| 235 | + "Create a pull request for '{branch}' on Openhub by visiting:" | |
| 236 | + )); | |
| 237 | + lines.push(format!( | |
| 238 | + " {base}/compare/{}...{}", | |
| 239 | + crate::web::fmt::urlpath(&default), | |
| 240 | + crate::web::fmt::urlpath(branch) | |
| 241 | + )); | |
| 242 | + } | |
| 243 | + } | |
| 244 | + } | |
| 245 | + if !lines.is_empty() { | |
| 246 | + lines.push(String::new()); | |
| 247 | + } | |
| 248 | + lines | |
| 249 | +} | |
| 250 | + | |
| 251 | +impl server::Handler for SshSession { | |
| 252 | + type Error = anyhow::Error; | |
| 253 | + | |
| 254 | + async fn auth_publickey_offered(&mut self, _user: &str, key: &PublicKey) -> Result<Auth> { | |
| 255 | + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 256 | + Ok( | |
| 257 | + if db::fingerprint_exists(&self.state.db, &fingerprint).await? { | |
| 258 | + Auth::Accept | |
| 259 | + } else { | |
| 260 | + Auth::reject() | |
| 261 | + }, | |
| 262 | + ) | |
| 263 | + } | |
| 264 | + | |
| 265 | + async fn auth_publickey(&mut self, _user: &str, key: &PublicKey) -> Result<Auth> { | |
| 266 | + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); | |
| 267 | + match db::user_by_key_fingerprint(&self.state.db, &fingerprint).await? { | |
| 268 | + Some((user, key_id)) => { | |
| 269 | + let _ = db::touch_key(&self.state.db, key_id).await; | |
| 270 | + tracing::info!( | |
| 271 | + "ssh: {} authenticated with key {fingerprint} from {:?}", | |
| 272 | + user.username, | |
| 273 | + self.peer | |
| 274 | + ); | |
| 275 | + self.user = Some(user); | |
| 276 | + Ok(Auth::Accept) | |
| 277 | + } | |
| 278 | + None => Ok(Auth::reject()), | |
| 279 | + } | |
| 280 | + } | |
| 281 | + | |
| 282 | + async fn channel_open_session( | |
| 283 | + &mut self, | |
| 284 | + channel: Channel<Msg>, | |
| 285 | + reply: server::ChannelOpenHandle, | |
| 286 | + _session: &mut Session, | |
| 287 | + ) -> Result<()> { | |
| 288 | + self.channels.insert(channel.id(), ChannelState::default()); | |
| 289 | + // Data arrives through the `data` callback; dropping the channel | |
| 290 | + // handle keeps russh from also buffering it there. | |
| 291 | + drop(channel); | |
| 292 | + reply.accept().await; | |
| 293 | + Ok(()) | |
| 294 | + } | |
| 295 | + | |
| 296 | + async fn env_request( | |
| 297 | + &mut self, | |
| 298 | + channel: ChannelId, | |
| 299 | + name: &str, | |
| 300 | + value: &str, | |
| 301 | + session: &mut Session, | |
| 302 | + ) -> Result<()> { | |
| 303 | + if name == "GIT_PROTOCOL" { | |
| 304 | + if let Some(state) = self.channels.get_mut(&channel) { | |
| 305 | + state.protocol = Some(value.to_string()); | |
| 306 | + } | |
| 307 | + session.channel_success(channel)?; | |
| 308 | + } else { | |
| 309 | + session.channel_failure(channel)?; | |
| 310 | + } | |
| 311 | + Ok(()) | |
| 312 | + } | |
| 313 | + | |
| 314 | + async fn pty_request( | |
| 315 | + &mut self, | |
| 316 | + channel: ChannelId, | |
| 317 | + _term: &str, | |
| 318 | + _col_width: u32, | |
| 319 | + _row_height: u32, | |
| 320 | + _pix_width: u32, | |
| 321 | + _pix_height: u32, | |
| 322 | + _modes: &[(Pty, u32)], | |
| 323 | + session: &mut Session, | |
| 324 | + ) -> Result<()> { | |
| 325 | + session.channel_failure(channel)?; | |
| 326 | + Ok(()) | |
| 327 | + } | |
| 328 | + | |
| 329 | + async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<()> { | |
| 330 | + let name = self | |
| 331 | + .user | |
| 332 | + .as_ref() | |
| 333 | + .map(|u| u.username.as_str()) | |
| 334 | + .unwrap_or("there"); | |
| 335 | + session.channel_success(channel)?; | |
| 336 | + let msg = format!( | |
| 337 | + "Hi {name}! You've successfully authenticated, but Openhub does not provide shell access.\r\n" | |
| 338 | + ); | |
| 339 | + session.extended_data(channel, 1, msg.into_bytes())?; | |
| 340 | + session.exit_status_request(channel, 1)?; | |
| 341 | + session.eof(channel)?; | |
| 342 | + session.close(channel)?; | |
| 343 | + Ok(()) | |
| 344 | + } | |
| 345 | + | |
| 346 | + async fn subsystem_request( | |
| 347 | + &mut self, | |
| 348 | + channel: ChannelId, | |
| 349 | + _name: &str, | |
| 350 | + session: &mut Session, | |
| 351 | + ) -> Result<()> { | |
| 352 | + session.channel_failure(channel)?; | |
| 353 | + Ok(()) | |
| 354 | + } | |
| 355 | + | |
| 356 | + async fn exec_request( | |
| 357 | + &mut self, | |
| 358 | + channel: ChannelId, | |
| 359 | + data: &[u8], | |
| 360 | + session: &mut Session, | |
| 361 | + ) -> Result<()> { | |
| 362 | + let Some(user) = self.user.clone() else { | |
| 363 | + return Self::reject(session, channel, "Not authenticated."); | |
| 364 | + }; | |
| 365 | + let command = String::from_utf8_lossy(data).to_string(); | |
| 366 | + let Some((service, owner, name)) = parse_command(&command) else { | |
| 367 | + tracing::info!( | |
| 368 | + "ssh: {} sent unsupported command {command:?}", | |
| 369 | + user.username | |
| 370 | + ); | |
| 371 | + return Self::reject( | |
| 372 | + session, | |
| 373 | + channel, | |
| 374 | + "Openhub only supports git fetch and push over SSH.", | |
| 375 | + ); | |
| 376 | + }; | |
| 377 | + let repo = db::repo_by_name(&self.state.db, &owner, &name).await?; | |
| 378 | + let access = match &repo { | |
| 379 | + Some(r) => self.state.access(r, Some(&user)).await?, | |
| 380 | + None => Default::default(), | |
| 381 | + }; | |
| 382 | + let Some(repo) = repo.filter(|_| access.read) else { | |
| 383 | + return Self::reject(session, channel, "Repository not found."); | |
| 384 | + }; | |
| 385 | + if service == Service::ReceivePack && !access.write { | |
| 386 | + let msg = format!( | |
| 387 | + "You do not have permission to push to {}/{}.", | |
| 388 | + repo.owner, repo.name | |
| 389 | + ); | |
| 390 | + return Self::reject(session, channel, &msg); | |
| 391 | + } | |
| 392 | + | |
| 393 | + let path = self.state.config.repo_path(repo.id); | |
| 394 | + let before = if service == Service::ReceivePack { | |
| 395 | + let p = path.clone(); | |
| 396 | + tokio::task::spawn_blocking(move || branch_tips(&p)).await? | |
| 397 | + } else { | |
| 398 | + HashMap::new() | |
| 399 | + }; | |
| 400 | + | |
| 401 | + let mut cmd = Command::new(&self.state.config.git_bin); | |
| 402 | + cmd.arg(service.subcommand()) | |
| 403 | + .arg(&path) | |
| 404 | + .stdin(Stdio::piped()) | |
| 405 | + .stdout(Stdio::piped()) | |
| 406 | + .stderr(Stdio::piped()) | |
| 407 | + .kill_on_drop(true); | |
| 408 | + if let Some(protocol) = self.channels.get(&channel).and_then(|c| c.protocol.clone()) { | |
| 409 | + cmd.env("GIT_PROTOCOL", protocol); | |
| 410 | + } | |
| 411 | + let mut child = match cmd.spawn() { | |
| 412 | + Ok(c) => c, | |
| 413 | + Err(e) => { | |
| 414 | + tracing::error!("could not start git: {e}"); | |
| 415 | + return Self::reject(session, channel, "Internal server error."); | |
| 416 | + } | |
| 417 | + }; | |
| 418 | + tracing::info!( | |
| 419 | + "ssh: {} running {} on {}/{}", | |
| 420 | + user.username, | |
| 421 | + service.subcommand(), | |
| 422 | + repo.owner, | |
| 423 | + repo.name | |
| 424 | + ); | |
| 425 | + session.channel_success(channel)?; | |
| 426 | + | |
| 427 | + let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>(); | |
| 428 | + self.channels.entry(channel).or_default().stdin = Some(tx); | |
| 429 | + let mut stdin = child.stdin.take().context("git stdin")?; | |
| 430 | + let stdout = child.stdout.take().context("git stdout")?; | |
| 431 | + let stderr = child.stderr.take().context("git stderr")?; | |
| 432 | + | |
| 433 | + tokio::spawn(async move { | |
| 434 | + while let Some(buf) = rx.recv().await { | |
| 435 | + if stdin.write_all(&buf).await.is_err() { | |
| 436 | + break; | |
| 437 | + } | |
| 438 | + } | |
| 439 | + }); | |
| 440 | + | |
| 441 | + let handle = session.handle(); | |
| 442 | + let state = self.state.clone(); | |
| 443 | + tokio::spawn(async move { | |
| 444 | + let out = forward(stdout, |buf| { | |
| 445 | + let h = handle.clone(); | |
| 446 | + async move { h.data(channel, buf).await.is_ok() } | |
| 447 | + }); | |
| 448 | + let err = forward(stderr, |buf| { | |
| 449 | + let h = handle.clone(); | |
| 450 | + async move { h.extended_data(channel, 1, buf).await.is_ok() } | |
| 451 | + }); | |
| 452 | + tokio::join!(out, err); | |
| 453 | + let code = match child.wait().await { | |
| 454 | + Ok(status) => status.code().unwrap_or(1), | |
| 455 | + Err(_) => 1, | |
| 456 | + }; | |
| 457 | + if service == Service::ReceivePack && code == 0 { | |
| 458 | + if let Err(e) = sync::after_push(&state, &repo, user.id).await { | |
| 459 | + tracing::error!( | |
| 460 | + "post-push sync failed for {}/{}: {e:#}", | |
| 461 | + repo.owner, | |
| 462 | + repo.name | |
| 463 | + ); | |
| 464 | + } | |
| 465 | + let p = path.clone(); | |
| 466 | + let after = tokio::task::spawn_blocking(move || branch_tips(&p)) | |
| 467 | + .await | |
| 468 | + .unwrap_or_default(); | |
| 469 | + for line in push_hints(&state, &repo, &before, &after).await { | |
| 470 | + let text = format!("remote: {line}\n"); | |
| 471 | + let _ = handle.extended_data(channel, 1, text.into_bytes()).await; | |
| 472 | + } | |
| 473 | + } | |
| 474 | + let _ = handle.exit_status_request(channel, code as u32).await; | |
| 475 | + let _ = handle.eof(channel).await; | |
| 476 | + let _ = handle.close(channel).await; | |
| 477 | + }); | |
| 478 | + Ok(()) | |
| 479 | + } | |
| 480 | + | |
| 481 | + async fn data( | |
| 482 | + &mut self, | |
| 483 | + channel: ChannelId, | |
| 484 | + data: &[u8], | |
| 485 | + _session: &mut Session, | |
| 486 | + ) -> Result<()> { | |
| 487 | + if let Some(tx) = self.channels.get(&channel).and_then(|c| c.stdin.as_ref()) { | |
| 488 | + let _ = tx.send(data.to_vec()); | |
| 489 | + } | |
| 490 | + Ok(()) | |
| 491 | + } | |
| 492 | + | |
| 493 | + async fn channel_eof(&mut self, channel: ChannelId, _session: &mut Session) -> Result<()> { | |
| 494 | + if let Some(state) = self.channels.get_mut(&channel) { | |
| 495 | + state.stdin = None; | |
| 496 | + } | |
| 497 | + Ok(()) | |
| 498 | + } | |
| 499 | + | |
| 500 | + async fn channel_close(&mut self, channel: ChannelId, _session: &mut Session) -> Result<()> { | |
| 501 | + self.channels.remove(&channel); | |
| 502 | + Ok(()) | |
| 503 | + } | |
| 504 | +} | |
| 505 | + | |
| 506 | +#[cfg(test)] | |
| 507 | +mod tests { | |
| 508 | + use super::*; | |
| 509 | + | |
| 510 | + #[test] | |
| 511 | + fn parses_git_commands() { | |
| 512 | + assert_eq!( | |
| 513 | + parse_command("git-upload-pack '/alice/project.git'"), | |
| 514 | + Some((Service::UploadPack, "alice".into(), "project".into())) | |
| 515 | + ); | |
| 516 | + assert_eq!( | |
| 517 | + parse_command("git-receive-pack 'alice/project.git'"), | |
| 518 | + Some((Service::ReceivePack, "alice".into(), "project".into())) | |
| 519 | + ); | |
| 520 | + assert_eq!( | |
| 521 | + parse_command("git upload-pack 'alice/project'"), | |
| 522 | + Some((Service::UploadPack, "alice".into(), "project".into())) | |
| 523 | + ); | |
| 524 | + assert_eq!( | |
| 525 | + parse_command("git-upload-archive 'alice/project.git'"), | |
| 526 | + None | |
| 527 | + ); | |
| 528 | + assert_eq!(parse_command("rm -rf /"), None); | |
| 529 | + assert_eq!(parse_command("git-upload-pack 'a/b/c.git'"), None); | |
| 530 | + assert_eq!(parse_command("git-upload-pack 'a/b'; ls'"), None); | |
| 531 | + } | |
| 532 | +} |
| @@ -0,0 +1,60 @@ | ||
| 1 | +use std::ops::Deref; | |
| 2 | +use std::sync::Arc; | |
| 3 | + | |
| 4 | +use crate::config::Config; | |
| 5 | +use crate::db::{self, Db, Repo, User}; | |
| 6 | + | |
| 7 | +pub struct Inner { | |
| 8 | + pub config: Config, | |
| 9 | + pub db: Db, | |
| 10 | +} | |
| 11 | + | |
| 12 | +/// Shared application state for the web and SSH servers. | |
| 13 | +#[derive(Clone)] | |
| 14 | +pub struct AppState(Arc<Inner>); | |
| 15 | + | |
| 16 | +impl AppState { | |
| 17 | + pub fn new(config: Config, db: Db) -> Self { | |
| 18 | + AppState(Arc::new(Inner { config, db })) | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl Deref for AppState { | |
| 23 | + type Target = Inner; | |
| 24 | + fn deref(&self) -> &Inner { | |
| 25 | + &self.0 | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// What a user may do with a repository. | |
| 30 | +#[derive(Debug, Clone, Copy, Default)] | |
| 31 | +pub struct Access { | |
| 32 | + pub read: bool, | |
| 33 | + pub write: bool, | |
| 34 | + pub admin: bool, | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl AppState { | |
| 38 | + pub async fn access(&self, repo: &Repo, user: Option<&User>) -> Result<Access, sqlx::Error> { | |
| 39 | + let Some(user) = user else { | |
| 40 | + return Ok(Access { | |
| 41 | + read: !repo.is_private, | |
| 42 | + write: false, | |
| 43 | + admin: false, | |
| 44 | + }); | |
| 45 | + }; | |
| 46 | + if user.is_admin || user.id == repo.owner_id { | |
| 47 | + return Ok(Access { | |
| 48 | + read: true, | |
| 49 | + write: true, | |
| 50 | + admin: true, | |
| 51 | + }); | |
| 52 | + } | |
| 53 | + let collaborator = db::is_collaborator(&self.db, repo.id, user.id).await?; | |
| 54 | + Ok(Access { | |
| 55 | + read: collaborator || !repo.is_private, | |
| 56 | + write: collaborator, | |
| 57 | + admin: false, | |
| 58 | + }) | |
| 59 | + } | |
| 60 | +} |
| @@ -0,0 +1,112 @@ | ||
| 1 | +//! Keeps database state in step with repository refs after they change, | |
| 2 | +//! whether through a push or a merge in the web interface. | |
| 3 | + | |
| 4 | +use anyhow::Result; | |
| 5 | +use git2::Oid; | |
| 6 | + | |
| 7 | +use crate::db::{self, Repo}; | |
| 8 | +use crate::git; | |
| 9 | +use crate::state::AppState; | |
| 10 | + | |
| 11 | +enum Change { | |
| 12 | + /// The head branch moved or the merge base changed. | |
| 13 | + Updated { | |
| 14 | + id: i64, | |
| 15 | + head: Oid, | |
| 16 | + merge_base: Option<Oid>, | |
| 17 | + }, | |
| 18 | + /// The head is now contained in the base, e.g. merged on the command line. | |
| 19 | + Merged { | |
| 20 | + id: i64, | |
| 21 | + head: Oid, | |
| 22 | + merge_base: String, | |
| 23 | + }, | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// Called after a successful push. | |
| 27 | +pub async fn after_push(state: &AppState, repo: &Repo, pusher: i64) -> Result<()> { | |
| 28 | + db::touch_pushed(&state.db, repo.id).await?; | |
| 29 | + let path = state.config.repo_path(repo.id); | |
| 30 | + let preferred = repo.default_branch.clone(); | |
| 31 | + let head = | |
| 32 | + tokio::task::spawn_blocking(move || git::ensure_head(&git::open(&path)?, &preferred)) | |
| 33 | + .await??; | |
| 34 | + if let Some(head) = head.filter(|h| *h != repo.default_branch) { | |
| 35 | + db::set_default_branch(&state.db, repo.id, &head).await?; | |
| 36 | + } | |
| 37 | + refresh_pulls(state, repo.id, Some(pusher)).await | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// Re-evaluates every open pull request of a repository. | |
| 41 | +pub async fn refresh_pulls(state: &AppState, repo_id: i64, actor: Option<i64>) -> Result<()> { | |
| 42 | + let pulls = db::list_pulls(&state.db, repo_id, true).await?; | |
| 43 | + if pulls.is_empty() { | |
| 44 | + return Ok(()); | |
| 45 | + } | |
| 46 | + let path = state.config.repo_path(repo_id); | |
| 47 | + let changes = tokio::task::spawn_blocking(move || -> Result<Vec<Change>> { | |
| 48 | + let repo = git::open(&path)?; | |
| 49 | + let mut changes = Vec::new(); | |
| 50 | + for p in pulls { | |
| 51 | + // A deleted head branch leaves the pull request as it was. | |
| 52 | + let Some(head) = git::branch_tip(&repo, &p.head_branch) else { | |
| 53 | + continue; | |
| 54 | + }; | |
| 55 | + let head_changed = p.head_sha.as_deref() != Some(head.to_string().as_str()); | |
| 56 | + if head_changed { | |
| 57 | + git::set_pull_ref(&repo, p.number, head)?; | |
| 58 | + } | |
| 59 | + let base = git::branch_tip(&repo, &p.base_branch); | |
| 60 | + match base { | |
| 61 | + Some(base) if git::is_ancestor(&repo, head, base) => { | |
| 62 | + let merge_base = p | |
| 63 | + .base_sha | |
| 64 | + .clone() | |
| 65 | + .or_else(|| git::merge_base(&repo, head, base).map(|o| o.to_string())) | |
| 66 | + .unwrap_or_else(|| head.to_string()); | |
| 67 | + changes.push(Change::Merged { | |
| 68 | + id: p.id, | |
| 69 | + head, | |
| 70 | + merge_base, | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + _ => { | |
| 74 | + let merge_base = base.and_then(|b| git::merge_base(&repo, b, head)); | |
| 75 | + let base_changed = | |
| 76 | + merge_base.map(|m| m.to_string()) != p.base_sha && merge_base.is_some(); | |
| 77 | + if head_changed || base_changed { | |
| 78 | + changes.push(Change::Updated { | |
| 79 | + id: p.id, | |
| 80 | + head, | |
| 81 | + merge_base, | |
| 82 | + }); | |
| 83 | + } | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + Ok(changes) | |
| 88 | + }) | |
| 89 | + .await??; | |
| 90 | + | |
| 91 | + for change in changes { | |
| 92 | + match change { | |
| 93 | + Change::Updated { | |
| 94 | + id, | |
| 95 | + head, | |
| 96 | + merge_base, | |
| 97 | + } => { | |
| 98 | + let mb = merge_base.map(|m| m.to_string()); | |
| 99 | + db::update_pull_head(&state.db, id, &head.to_string(), mb.as_deref()).await?; | |
| 100 | + } | |
| 101 | + Change::Merged { | |
| 102 | + id, | |
| 103 | + head, | |
| 104 | + merge_base, | |
| 105 | + } => { | |
| 106 | + db::mark_merged(&state.db, id, actor, None, &head.to_string(), &merge_base).await?; | |
| 107 | + db::add_event(&state.db, id, actor, "merged", "").await?; | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + Ok(()) | |
| 112 | +} |
| @@ -0,0 +1,145 @@ | ||
| 1 | +//! Input validation for names and keys. | |
| 2 | + | |
| 3 | +use russh::keys::ssh_key::{HashAlg, PublicKey}; | |
| 4 | + | |
| 5 | +/// Usernames that would collide with top-level routes. | |
| 6 | +const RESERVED: &[&str] = &[ | |
| 7 | + "admin", | |
| 8 | + "api", | |
| 9 | + "assets", | |
| 10 | + "explore", | |
| 11 | + "favicon.svg", | |
| 12 | + "git", | |
| 13 | + "help", | |
| 14 | + "login", | |
| 15 | + "logout", | |
| 16 | + "new", | |
| 17 | + "register", | |
| 18 | + "root", | |
| 19 | + "settings", | |
| 20 | + "signin", | |
| 21 | + "signup", | |
| 22 | + "static", | |
| 23 | + "system", | |
| 24 | +]; | |
| 25 | + | |
| 26 | +pub fn username(name: &str) -> Result<(), &'static str> { | |
| 27 | + if name.is_empty() || name.len() > 39 { | |
| 28 | + return Err("Username must be between 1 and 39 characters."); | |
| 29 | + } | |
| 30 | + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { | |
| 31 | + return Err("Username may only contain letters, numbers and hyphens."); | |
| 32 | + } | |
| 33 | + if name.starts_with('-') || name.ends_with('-') || name.contains("--") { | |
| 34 | + return Err("Username cannot start or end with a hyphen or contain consecutive hyphens."); | |
| 35 | + } | |
| 36 | + if RESERVED.contains(&name.to_ascii_lowercase().as_str()) { | |
| 37 | + return Err("That username is reserved."); | |
| 38 | + } | |
| 39 | + Ok(()) | |
| 40 | +} | |
| 41 | + | |
| 42 | +pub fn repo_name(name: &str) -> Result<(), &'static str> { | |
| 43 | + if name.is_empty() || name.len() > 100 { | |
| 44 | + return Err("Repository name must be between 1 and 100 characters."); | |
| 45 | + } | |
| 46 | + if !name | |
| 47 | + .chars() | |
| 48 | + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) | |
| 49 | + { | |
| 50 | + return Err("Repository name may only contain letters, numbers, '-', '_' and '.'."); | |
| 51 | + } | |
| 52 | + if name.starts_with('.') || name.to_ascii_lowercase().ends_with(".git") { | |
| 53 | + return Err("Repository name cannot start with '.' or end with '.git'."); | |
| 54 | + } | |
| 55 | + Ok(()) | |
| 56 | +} | |
| 57 | + | |
| 58 | +pub fn email(email: &str) -> Result<(), &'static str> { | |
| 59 | + let ok = email.len() <= 254 | |
| 60 | ||
| 61 | + .split_once('@') | |
| 62 | + .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.')) | |
| 63 | + && !email.chars().any(char::is_whitespace); | |
| 64 | + if ok { | |
| 65 | + Ok(()) | |
| 66 | + } else { | |
| 67 | + Err("Please enter a valid email address.") | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +pub fn password(password: &str) -> Result<(), &'static str> { | |
| 72 | + if password.chars().count() < 8 { | |
| 73 | + return Err("Password must be at least 8 characters."); | |
| 74 | + } | |
| 75 | + if password.len() > 256 { | |
| 76 | + return Err("Password is too long."); | |
| 77 | + } | |
| 78 | + Ok(()) | |
| 79 | +} | |
| 80 | + | |
| 81 | +pub struct ParsedKey { | |
| 82 | + pub normalized: String, | |
| 83 | + pub fingerprint: String, | |
| 84 | + pub key_type: String, | |
| 85 | + pub comment: String, | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Parses an OpenSSH public key line, e.g. `ssh-ed25519 AAAA... me@laptop`. | |
| 89 | +pub fn ssh_public_key(input: &str) -> Result<ParsedKey, &'static str> { | |
| 90 | + let line = input.trim(); | |
| 91 | + if line.contains('\n') { | |
| 92 | + return Err("Please paste a single public key."); | |
| 93 | + } | |
| 94 | + if line.contains("PRIVATE KEY") { | |
| 95 | + return Err("That looks like a private key. Paste the contents of the .pub file instead."); | |
| 96 | + } | |
| 97 | + let key = PublicKey::from_openssh(line) | |
| 98 | + .map_err(|_| "Key is invalid. Paste an OpenSSH public key such as the contents of ~/.ssh/id_ed25519.pub.")?; | |
| 99 | + let comment = key.comment().as_str_lossy().to_string(); | |
| 100 | + let mut bare = key.clone(); | |
| 101 | + bare.set_comment(""); | |
| 102 | + let normalized = bare.to_openssh().map_err(|_| "Key could not be encoded.")?; | |
| 103 | + Ok(ParsedKey { | |
| 104 | + normalized: normalized.trim().to_string(), | |
| 105 | + fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), | |
| 106 | + key_type: key.algorithm().as_str().to_string(), | |
| 107 | + comment, | |
| 108 | + }) | |
| 109 | +} | |
| 110 | + | |
| 111 | +#[cfg(test)] | |
| 112 | +mod tests { | |
| 113 | + use super::*; | |
| 114 | + | |
| 115 | + #[test] | |
| 116 | + fn usernames() { | |
| 117 | + assert!(username("alice").is_ok()); | |
| 118 | + assert!(username("a-b-c").is_ok()); | |
| 119 | + assert!(username("-alice").is_err()); | |
| 120 | + assert!(username("al--ice").is_err()); | |
| 121 | + assert!(username("settings").is_err()); | |
| 122 | + assert!(username("ali ce").is_err()); | |
| 123 | + } | |
| 124 | + | |
| 125 | + #[test] | |
| 126 | + fn repo_names() { | |
| 127 | + assert!(repo_name("openhub").is_ok()); | |
| 128 | + assert!(repo_name("my_repo.rs").is_ok()); | |
| 129 | + assert!(repo_name("..").is_err()); | |
| 130 | + assert!(repo_name("x.git").is_err()); | |
| 131 | + assert!(repo_name("a/b").is_err()); | |
| 132 | + } | |
| 133 | + | |
| 134 | + #[test] | |
| 135 | + fn parses_ed25519_key() { | |
| 136 | + let k = ssh_public_key( | |
| 137 | + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMys5dTzxVmfRH761T646Bpe3nX9Hx86atVkOotoyxWQ me@host", | |
| 138 | + ) | |
| 139 | + .unwrap(); | |
| 140 | + assert!(k.fingerprint.starts_with("SHA256:")); | |
| 141 | + assert_eq!(k.key_type, "ssh-ed25519"); | |
| 142 | + assert_eq!(k.comment, "me@host"); | |
| 143 | + assert!(ssh_public_key("not a key").is_err()); | |
| 144 | + } | |
| 145 | +} |
| @@ -0,0 +1,430 @@ | ||
| 1 | +//! Sign up, sign in, profile settings and SSH key management. | |
| 2 | + | |
| 3 | +use std::sync::LazyLock; | |
| 4 | + | |
| 5 | +use argon2::Argon2; | |
| 6 | +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; | |
| 7 | +use askama::Template; | |
| 8 | +use axum::Form; | |
| 9 | +use axum::extract::{Path, Query, State}; | |
| 10 | +use serde::Deserialize; | |
| 11 | + | |
| 12 | +use super::ctx::{self, Ctx, SESSION_TTL_SECS}; | |
| 13 | +use super::{AppError, WebResult, redirect, redirect_flash, render, safe_next, with_cookie}; | |
| 14 | +use crate::db::{self, SshKey}; | |
| 15 | +use crate::state::AppState; | |
| 16 | +use crate::validate; | |
| 17 | + | |
| 18 | +/// Verified against when an account does not exist, to keep timing uniform. | |
| 19 | +static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| { | |
| 20 | + let salt = SaltString::generate(&mut rand::rngs::OsRng); | |
| 21 | + Argon2::default() | |
| 22 | + .hash_password(b"openhub-dummy-password", &salt) | |
| 23 | + .map(|h| h.to_string()) | |
| 24 | + .unwrap_or_default() | |
| 25 | +}); | |
| 26 | + | |
| 27 | +pub async fn hash_password(password: String) -> Result<String, AppError> { | |
| 28 | + tokio::task::spawn_blocking(move || { | |
| 29 | + let salt = SaltString::generate(&mut rand::rngs::OsRng); | |
| 30 | + Argon2::default() | |
| 31 | + .hash_password(password.as_bytes(), &salt) | |
| 32 | + .map(|h| h.to_string()) | |
| 33 | + .map_err(|e| AppError::Internal(anyhow::anyhow!("hashing password: {e}"))) | |
| 34 | + }) | |
| 35 | + .await? | |
| 36 | +} | |
| 37 | + | |
| 38 | +pub async fn verify_password(password: String, hash: String) -> bool { | |
| 39 | + tokio::task::spawn_blocking(move || { | |
| 40 | + PasswordHash::new(&hash) | |
| 41 | + .map(|parsed| { | |
| 42 | + Argon2::default() | |
| 43 | + .verify_password(password.as_bytes(), &parsed) | |
| 44 | + .is_ok() | |
| 45 | + }) | |
| 46 | + .unwrap_or(false) | |
| 47 | + }) | |
| 48 | + .await | |
| 49 | + .unwrap_or(false) | |
| 50 | +} | |
| 51 | + | |
| 52 | +async fn start_session(state: &AppState, user_id: i64, to: &str) -> WebResult { | |
| 53 | + let token = ctx::new_token(); | |
| 54 | + db::create_session( | |
| 55 | + &state.db, | |
| 56 | + &ctx::hash_token(&token), | |
| 57 | + user_id, | |
| 58 | + SESSION_TTL_SECS, | |
| 59 | + ) | |
| 60 | + .await?; | |
| 61 | + let _ = db::purge_expired_sessions(&state.db).await; | |
| 62 | + Ok(with_cookie( | |
| 63 | + redirect(to), | |
| 64 | + ctx::session_cookie(token, state.config.secure_cookies()), | |
| 65 | + )) | |
| 66 | +} | |
| 67 | + | |
| 68 | +// --------------------------------------------------------------------------- | |
| 69 | +// Sign in / sign up | |
| 70 | +// --------------------------------------------------------------------------- | |
| 71 | + | |
| 72 | +#[derive(Template)] | |
| 73 | +#[template(path = "login.html")] | |
| 74 | +struct LoginPage { | |
| 75 | + ctx: Ctx, | |
| 76 | + login: String, | |
| 77 | + next: String, | |
| 78 | + error: Option<String>, | |
| 79 | + can_register: bool, | |
| 80 | +} | |
| 81 | + | |
| 82 | +#[derive(Deserialize)] | |
| 83 | +pub struct NextQuery { | |
| 84 | + next: Option<String>, | |
| 85 | +} | |
| 86 | + | |
| 87 | +async fn can_register(state: &AppState) -> Result<bool, AppError> { | |
| 88 | + Ok(!state.config.disable_registration || db::count_users(&state.db).await? == 0) | |
| 89 | +} | |
| 90 | + | |
| 91 | +pub async fn login_form( | |
| 92 | + State(state): State<AppState>, | |
| 93 | + ctx: Ctx, | |
| 94 | + Query(q): Query<NextQuery>, | |
| 95 | +) -> WebResult { | |
| 96 | + if ctx.user.is_some() { | |
| 97 | + return Ok(redirect(&safe_next(q.next.as_deref()))); | |
| 98 | + } | |
| 99 | + render(&LoginPage { | |
| 100 | + ctx, | |
| 101 | + login: String::new(), | |
| 102 | + next: safe_next(q.next.as_deref()), | |
| 103 | + error: None, | |
| 104 | + can_register: can_register(&state).await?, | |
| 105 | + }) | |
| 106 | +} | |
| 107 | + | |
| 108 | +#[derive(Deserialize)] | |
| 109 | +pub struct LoginForm { | |
| 110 | + login: String, | |
| 111 | + password: String, | |
| 112 | + next: Option<String>, | |
| 113 | +} | |
| 114 | + | |
| 115 | +pub async fn login(State(state): State<AppState>, ctx: Ctx, Form(f): Form<LoginForm>) -> WebResult { | |
| 116 | + let next = safe_next(f.next.as_deref()); | |
| 117 | + let user = db::user_by_login(&state.db, f.login.trim()).await?; | |
| 118 | + // Always run a verification so response timing does not reveal whether the account exists. | |
| 119 | + let hash = user | |
| 120 | + .as_ref() | |
| 121 | + .map(|u| u.password_hash.clone()) | |
| 122 | + .unwrap_or_else(|| DUMMY_HASH.clone()); | |
| 123 | + let ok = verify_password(f.password, hash).await; | |
| 124 | + match user { | |
| 125 | + Some(u) if ok => start_session(&state, u.id, &next).await, | |
| 126 | + _ => render(&LoginPage { | |
| 127 | + ctx, | |
| 128 | + login: f.login, | |
| 129 | + next, | |
| 130 | + error: Some("Incorrect username or password.".into()), | |
| 131 | + can_register: can_register(&state).await?, | |
| 132 | + }), | |
| 133 | + } | |
| 134 | +} | |
| 135 | + | |
| 136 | +#[derive(Template)] | |
| 137 | +#[template(path = "register.html")] | |
| 138 | +struct RegisterPage { | |
| 139 | + ctx: Ctx, | |
| 140 | + username: String, | |
| 141 | + email: String, | |
| 142 | + error: Option<String>, | |
| 143 | + allowed: bool, | |
| 144 | + first_user: bool, | |
| 145 | +} | |
| 146 | + | |
| 147 | +pub async fn register_form(State(state): State<AppState>, ctx: Ctx) -> WebResult { | |
| 148 | + if ctx.user.is_some() { | |
| 149 | + return Ok(redirect("/")); | |
| 150 | + } | |
| 151 | + let users = db::count_users(&state.db).await?; | |
| 152 | + render(&RegisterPage { | |
| 153 | + ctx, | |
| 154 | + username: String::new(), | |
| 155 | + email: String::new(), | |
| 156 | + error: None, | |
| 157 | + allowed: can_register(&state).await?, | |
| 158 | + first_user: users == 0, | |
| 159 | + }) | |
| 160 | +} | |
| 161 | + | |
| 162 | +#[derive(Deserialize)] | |
| 163 | +pub struct RegisterForm { | |
| 164 | + username: String, | |
| 165 | + email: String, | |
| 166 | + password: String, | |
| 167 | +} | |
| 168 | + | |
| 169 | +pub async fn register( | |
| 170 | + State(state): State<AppState>, | |
| 171 | + ctx: Ctx, | |
| 172 | + Form(f): Form<RegisterForm>, | |
| 173 | +) -> WebResult { | |
| 174 | + let users = db::count_users(&state.db).await?; | |
| 175 | + if !can_register(&state).await? { | |
| 176 | + return Err(AppError::Forbidden( | |
| 177 | + "Registration is disabled on this server.".into(), | |
| 178 | + )); | |
| 179 | + } | |
| 180 | + let username = f.username.trim().to_string(); | |
| 181 | + let email = f.email.trim().to_string(); | |
| 182 | + let mut error = validate::username(&username) | |
| 183 | + .and(validate::email(&email)) | |
| 184 | + .and(validate::password(&f.password)) | |
| 185 | + .err() | |
| 186 | + .map(str::to_string); | |
| 187 | + if error.is_none() && db::user_by_username(&state.db, &username).await?.is_some() { | |
| 188 | + error = Some("That username is already taken.".into()); | |
| 189 | + } | |
| 190 | + if error.is_none() && db::email_taken(&state.db, &email, -1).await? { | |
| 191 | + error = Some("An account with that email already exists.".into()); | |
| 192 | + } | |
| 193 | + if let Some(error) = error { | |
| 194 | + return render(&RegisterPage { | |
| 195 | + ctx, | |
| 196 | + username, | |
| 197 | + email, | |
| 198 | + error: Some(error), | |
| 199 | + allowed: true, | |
| 200 | + first_user: users == 0, | |
| 201 | + }); | |
| 202 | + } | |
| 203 | + let hash = hash_password(f.password).await?; | |
| 204 | + let id = db::create_user(&state.db, &username, &email, &hash, users == 0).await?; | |
| 205 | + let resp = start_session(&state, id, "/settings/keys").await?; | |
| 206 | + Ok(with_cookie( | |
| 207 | + resp, | |
| 208 | + ctx::flash_cookie( | |
| 209 | + "success", | |
| 210 | + "Welcome to Openhub! Add an SSH key so you can push code.", | |
| 211 | + ), | |
| 212 | + )) | |
| 213 | +} | |
| 214 | + | |
| 215 | +pub async fn logout(State(state): State<AppState>, ctx: Ctx) -> WebResult { | |
| 216 | + if let Some(hash) = &ctx.session_hash { | |
| 217 | + db::delete_session(&state.db, hash).await?; | |
| 218 | + } | |
| 219 | + Ok(with_cookie(redirect("/"), ctx::clear_session_cookie())) | |
| 220 | +} | |
| 221 | + | |
| 222 | +// --------------------------------------------------------------------------- | |
| 223 | +// Settings | |
| 224 | +// --------------------------------------------------------------------------- | |
| 225 | + | |
| 226 | +pub async fn settings_redirect() -> WebResult { | |
| 227 | + Ok(redirect("/settings/profile")) | |
| 228 | +} | |
| 229 | + | |
| 230 | +#[derive(Template)] | |
| 231 | +#[template(path = "settings/profile.html")] | |
| 232 | +struct ProfilePage { | |
| 233 | + ctx: Ctx, | |
| 234 | + tab: &'static str, | |
| 235 | + display_name: String, | |
| 236 | + email: String, | |
| 237 | + bio: String, | |
| 238 | + error: Option<String>, | |
| 239 | + password_error: Option<String>, | |
| 240 | +} | |
| 241 | + | |
| 242 | +pub async fn profile_form(ctx: Ctx) -> WebResult { | |
| 243 | + let user = ctx.require_user()?.clone(); | |
| 244 | + render(&ProfilePage { | |
| 245 | + ctx, | |
| 246 | + tab: "profile", | |
| 247 | + display_name: user.display_name, | |
| 248 | + email: user.email, | |
| 249 | + bio: user.bio, | |
| 250 | + error: None, | |
| 251 | + password_error: None, | |
| 252 | + }) | |
| 253 | +} | |
| 254 | + | |
| 255 | +#[derive(Deserialize)] | |
| 256 | +pub struct ProfileForm { | |
| 257 | + #[serde(default)] | |
| 258 | + display_name: String, | |
| 259 | + email: String, | |
| 260 | + #[serde(default)] | |
| 261 | + bio: String, | |
| 262 | +} | |
| 263 | + | |
| 264 | +pub async fn update_profile( | |
| 265 | + State(state): State<AppState>, | |
| 266 | + ctx: Ctx, | |
| 267 | + Form(f): Form<ProfileForm>, | |
| 268 | +) -> WebResult { | |
| 269 | + let user = ctx.require_user()?.clone(); | |
| 270 | + let display_name = f.display_name.trim().chars().take(100).collect::<String>(); | |
| 271 | + let bio = f.bio.trim().chars().take(500).collect::<String>(); | |
| 272 | + let email = f.email.trim().to_string(); | |
| 273 | + let mut error = validate::email(&email).err().map(str::to_string); | |
| 274 | + if error.is_none() && db::email_taken(&state.db, &email, user.id).await? { | |
| 275 | + error = Some("That email is used by another account.".into()); | |
| 276 | + } | |
| 277 | + if let Some(error) = error { | |
| 278 | + return render(&ProfilePage { | |
| 279 | + ctx, | |
| 280 | + tab: "profile", | |
| 281 | + display_name, | |
| 282 | + email, | |
| 283 | + bio, | |
| 284 | + error: Some(error), | |
| 285 | + password_error: None, | |
| 286 | + }); | |
| 287 | + } | |
| 288 | + db::update_profile(&state.db, user.id, &display_name, &email, &bio).await?; | |
| 289 | + Ok(redirect_flash( | |
| 290 | + "/settings/profile", | |
| 291 | + "success", | |
| 292 | + "Profile updated.", | |
| 293 | + )) | |
| 294 | +} | |
| 295 | + | |
| 296 | +#[derive(Deserialize)] | |
| 297 | +pub struct PasswordForm { | |
| 298 | + current: String, | |
| 299 | + new: String, | |
| 300 | + confirm: String, | |
| 301 | +} | |
| 302 | + | |
| 303 | +pub async fn change_password( | |
| 304 | + State(state): State<AppState>, | |
| 305 | + ctx: Ctx, | |
| 306 | + Form(f): Form<PasswordForm>, | |
| 307 | +) -> WebResult { | |
| 308 | + let user = ctx.require_user()?.clone(); | |
| 309 | + let mut error = None; | |
| 310 | + if !verify_password(f.current, user.password_hash.clone()).await { | |
| 311 | + error = Some("Current password is incorrect.".to_string()); | |
| 312 | + } else if let Err(e) = validate::password(&f.new) { | |
| 313 | + error = Some(e.to_string()); | |
| 314 | + } else if f.new != f.confirm { | |
| 315 | + error = Some("New passwords do not match.".to_string()); | |
| 316 | + } | |
| 317 | + if let Some(error) = error { | |
| 318 | + return render(&ProfilePage { | |
| 319 | + ctx, | |
| 320 | + tab: "profile", | |
| 321 | + display_name: user.display_name, | |
| 322 | + email: user.email, | |
| 323 | + bio: user.bio, | |
| 324 | + error: None, | |
| 325 | + password_error: Some(error), | |
| 326 | + }); | |
| 327 | + } | |
| 328 | + let hash = hash_password(f.new).await?; | |
| 329 | + db::update_password(&state.db, user.id, &hash).await?; | |
| 330 | + if let Some(keep) = &ctx.session_hash { | |
| 331 | + db::delete_other_sessions(&state.db, user.id, keep).await?; | |
| 332 | + } | |
| 333 | + Ok(redirect_flash( | |
| 334 | + "/settings/profile", | |
| 335 | + "success", | |
| 336 | + "Password changed. Other sessions have been signed out.", | |
| 337 | + )) | |
| 338 | +} | |
| 339 | + | |
| 340 | +#[derive(Template)] | |
| 341 | +#[template(path = "settings/keys.html")] | |
| 342 | +struct KeysPage { | |
| 343 | + ctx: Ctx, | |
| 344 | + tab: &'static str, | |
| 345 | + keys: Vec<SshKey>, | |
| 346 | + test_command: String, | |
| 347 | + title: String, | |
| 348 | + key: String, | |
| 349 | + error: Option<String>, | |
| 350 | +} | |
| 351 | + | |
| 352 | +async fn keys_page( | |
| 353 | + state: &AppState, | |
| 354 | + ctx: Ctx, | |
| 355 | + title: String, | |
| 356 | + key: String, | |
| 357 | + error: Option<String>, | |
| 358 | +) -> WebResult { | |
| 359 | + let user = ctx.require_user()?; | |
| 360 | + let keys = db::list_keys(&state.db, user.id).await?; | |
| 361 | + render(&KeysPage { | |
| 362 | + ctx, | |
| 363 | + tab: "keys", | |
| 364 | + keys, | |
| 365 | + test_command: state.config.ssh_test_command(), | |
| 366 | + title, | |
| 367 | + key, | |
| 368 | + error, | |
| 369 | + }) | |
| 370 | +} | |
| 371 | + | |
| 372 | +pub async fn keys(State(state): State<AppState>, ctx: Ctx) -> WebResult { | |
| 373 | + keys_page(&state, ctx, String::new(), String::new(), None).await | |
| 374 | +} | |
| 375 | + | |
| 376 | +#[derive(Deserialize)] | |
| 377 | +pub struct KeyForm { | |
| 378 | + #[serde(default)] | |
| 379 | + title: String, | |
| 380 | + key: String, | |
| 381 | +} | |
| 382 | + | |
| 383 | +pub async fn add_key(State(state): State<AppState>, ctx: Ctx, Form(f): Form<KeyForm>) -> WebResult { | |
| 384 | + let user = ctx.require_user()?.clone(); | |
| 385 | + let parsed = match validate::ssh_public_key(&f.key) { | |
| 386 | + Ok(p) => p, | |
| 387 | + Err(e) => return keys_page(&state, ctx, f.title, f.key, Some(e.into())).await, | |
| 388 | + }; | |
| 389 | + if db::fingerprint_exists(&state.db, &parsed.fingerprint).await? { | |
| 390 | + return keys_page( | |
| 391 | + &state, | |
| 392 | + ctx, | |
| 393 | + f.title, | |
| 394 | + f.key, | |
| 395 | + Some("This key is already in use by an account.".into()), | |
| 396 | + ) | |
| 397 | + .await; | |
| 398 | + } | |
| 399 | + let title = match f.title.trim() { | |
| 400 | + "" if !parsed.comment.is_empty() => parsed.comment.clone(), | |
| 401 | + "" => parsed.key_type.clone(), | |
| 402 | + t => t.chars().take(100).collect(), | |
| 403 | + }; | |
| 404 | + db::add_key( | |
| 405 | + &state.db, | |
| 406 | + user.id, | |
| 407 | + &title, | |
| 408 | + &parsed.fingerprint, | |
| 409 | + &parsed.normalized, | |
| 410 | + &parsed.key_type, | |
| 411 | + ) | |
| 412 | + .await?; | |
| 413 | + Ok(redirect_flash( | |
| 414 | + "/settings/keys", | |
| 415 | + "success", | |
| 416 | + &format!("SSH key \u{201c}{title}\u{201d} added."), | |
| 417 | + )) | |
| 418 | +} | |
| 419 | + | |
| 420 | +pub async fn delete_key(State(state): State<AppState>, ctx: Ctx, Path(id): Path<i64>) -> WebResult { | |
| 421 | + let user = ctx.require_user()?; | |
| 422 | + if !db::delete_key(&state.db, id, user.id).await? { | |
| 423 | + return Err(AppError::NotFound); | |
| 424 | + } | |
| 425 | + Ok(redirect_flash( | |
| 426 | + "/settings/keys", | |
| 427 | + "success", | |
| 428 | + "SSH key deleted.", | |
| 429 | + )) | |
| 430 | +} |
| @@ -0,0 +1,54 @@ | ||
| 1 | +//! Static assets compiled into the binary. | |
| 2 | + | |
| 3 | +use axum::http::header; | |
| 4 | +use axum::response::IntoResponse; | |
| 5 | + | |
| 6 | +const STYLE: &str = include_str!("../../static/style.css"); | |
| 7 | +const SCRIPT: &str = include_str!("../../static/app.js"); | |
| 8 | +const FAVICON: &str = include_str!("../../static/favicon.svg"); | |
| 9 | + | |
| 10 | +const CACHE: &str = "public, max-age=3600"; | |
| 11 | + | |
| 12 | +pub async fn stylesheet() -> impl IntoResponse { | |
| 13 | + ( | |
| 14 | + [ | |
| 15 | + (header::CONTENT_TYPE, "text/css; charset=utf-8"), | |
| 16 | + (header::CACHE_CONTROL, CACHE), | |
| 17 | + ], | |
| 18 | + STYLE, | |
| 19 | + ) | |
| 20 | +} | |
| 21 | + | |
| 22 | +pub async fn script() -> impl IntoResponse { | |
| 23 | + ( | |
| 24 | + [ | |
| 25 | + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), | |
| 26 | + (header::CACHE_CONTROL, CACHE), | |
| 27 | + ], | |
| 28 | + SCRIPT, | |
| 29 | + ) | |
| 30 | +} | |
| 31 | + | |
| 32 | +pub async fn favicon() -> impl IntoResponse { | |
| 33 | + ( | |
| 34 | + [ | |
| 35 | + (header::CONTENT_TYPE, "image/svg+xml"), | |
| 36 | + (header::CACHE_CONTROL, CACHE), | |
| 37 | + ], | |
| 38 | + FAVICON, | |
| 39 | + ) | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// Short content hash appended to asset URLs so browsers pick up new versions. | |
| 43 | +pub fn version() -> &'static str { | |
| 44 | + use sha2::{Digest, Sha256}; | |
| 45 | + use std::sync::LazyLock; | |
| 46 | + static VERSION: LazyLock<String> = LazyLock::new(|| { | |
| 47 | + let digest = Sha256::new() | |
| 48 | + .chain_update(STYLE) | |
| 49 | + .chain_update(SCRIPT) | |
| 50 | + .finalize(); | |
| 51 | + hex::encode(&digest[..4]) | |
| 52 | + }); | |
| 53 | + &VERSION | |
| 54 | +} |
| @@ -0,0 +1,747 @@ | ||
| 1 | +//! Browsing code: repository home, trees, files, raw blobs and history. | |
| 2 | + | |
| 3 | +use std::process::Stdio; | |
| 4 | + | |
| 5 | +use askama::Template; | |
| 6 | +use axum::body::Body; | |
| 7 | +use axum::extract::{Path, Query, State}; | |
| 8 | +use axum::http::{HeaderValue, header}; | |
| 9 | +use axum::response::IntoResponse; | |
| 10 | +use git2::{BranchType, ObjectType, Oid, Repository}; | |
| 11 | +use serde::Deserialize; | |
| 12 | +use tokio::process::Command; | |
| 13 | +use tokio_util::io::ReaderStream; | |
| 14 | + | |
| 15 | +use super::fmt::urlpath; | |
| 16 | +use super::repo::{RepoCtx, load_repo}; | |
| 17 | +use super::{AppError, Ctx, WebResult, blocking, redirect, render}; | |
| 18 | +use crate::db; | |
| 19 | +use crate::git::{self, CommitInfo, DiffSummary, RefKind, Resolved, TreeEntry}; | |
| 20 | +use crate::highlight; | |
| 21 | +use crate::markdown::{self, LinkBase}; | |
| 22 | +use crate::state::AppState; | |
| 23 | + | |
| 24 | +const COMMITS_PER_PAGE: usize = 30; | |
| 25 | +const MAX_DISPLAY_BYTES: usize = 1024 * 1024; | |
| 26 | + | |
| 27 | +// --------------------------------------------------------------------------- | |
| 28 | +// Shared view pieces | |
| 29 | +// --------------------------------------------------------------------------- | |
| 30 | + | |
| 31 | +pub struct RefLink { | |
| 32 | + pub name: String, | |
| 33 | + pub href: String, | |
| 34 | + pub selected: bool, | |
| 35 | +} | |
| 36 | + | |
| 37 | +/// The branch/tag switcher shown above code listings. | |
| 38 | +pub struct RefSelector { | |
| 39 | + pub current: String, | |
| 40 | + pub kind: RefKind, | |
| 41 | + pub branches: Vec<RefLink>, | |
| 42 | + pub tags: Vec<RefLink>, | |
| 43 | +} | |
| 44 | + | |
| 45 | +impl RefSelector { | |
| 46 | + /// `view` is `tree`, `blob` or `commits`; `path` is kept when switching. | |
| 47 | + fn build(repo: &Repository, base: &str, view: &str, current: &Resolved, path: &str) -> Self { | |
| 48 | + let link = |name: &str, selected: bool| { | |
| 49 | + let mut href = format!("{base}/{view}/{}", urlpath(name)); | |
| 50 | + if !path.is_empty() { | |
| 51 | + href.push('/'); | |
| 52 | + href.push_str(&urlpath(path)); | |
| 53 | + } | |
| 54 | + RefLink { | |
| 55 | + name: name.to_string(), | |
| 56 | + href, | |
| 57 | + selected, | |
| 58 | + } | |
| 59 | + }; | |
| 60 | + let branches = git::branch_names(repo) | |
| 61 | + .unwrap_or_default() | |
| 62 | + .into_iter() | |
| 63 | + .map(|b| link(&b, current.kind == RefKind::Branch && current.name == b)) | |
| 64 | + .collect(); | |
| 65 | + let tags = git::tag_names(repo) | |
| 66 | + .unwrap_or_default() | |
| 67 | + .into_iter() | |
| 68 | + .map(|t| link(&t, current.kind == RefKind::Tag && current.name == t)) | |
| 69 | + .collect(); | |
| 70 | + let current_label = match current.kind { | |
| 71 | + RefKind::Commit => current.name.chars().take(7).collect(), | |
| 72 | + _ => current.name.clone(), | |
| 73 | + }; | |
| 74 | + RefSelector { | |
| 75 | + current: current_label, | |
| 76 | + kind: current.kind, | |
| 77 | + branches, | |
| 78 | + tags, | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + pub fn icon(&self) -> &'static str { | |
| 83 | + match self.kind { | |
| 84 | + RefKind::Tag => "tag", | |
| 85 | + _ => "git-branch", | |
| 86 | + } | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +pub struct Crumb { | |
| 91 | + pub name: String, | |
| 92 | + pub href: String, | |
| 93 | +} | |
| 94 | + | |
| 95 | +fn breadcrumbs(base: &str, refname: &str, path: &str) -> Vec<Crumb> { | |
| 96 | + let mut crumbs = Vec::new(); | |
| 97 | + let mut acc = String::new(); | |
| 98 | + for seg in path.split('/').filter(|s| !s.is_empty()) { | |
| 99 | + if !acc.is_empty() { | |
| 100 | + acc.push('/'); | |
| 101 | + } | |
| 102 | + acc.push_str(seg); | |
| 103 | + crumbs.push(Crumb { | |
| 104 | + name: seg.to_string(), | |
| 105 | + href: format!("{base}/tree/{}/{}", urlpath(refname), urlpath(&acc)), | |
| 106 | + }); | |
| 107 | + } | |
| 108 | + crumbs | |
| 109 | +} | |
| 110 | + | |
| 111 | +pub struct EntryRow { | |
| 112 | + pub entry: TreeEntry, | |
| 113 | + pub href: String, | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// Resolves `{ref}/{path}` or returns 404. | |
| 117 | +fn resolve(repo: &Repository, rest: &str) -> Result<Resolved, AppError> { | |
| 118 | + git::resolve(repo, rest).ok_or(AppError::NotFound) | |
| 119 | +} | |
| 120 | + | |
| 121 | +fn link_base(base: &str, refname: &str, dir: &str) -> LinkBase { | |
| 122 | + LinkBase { | |
| 123 | + blob_root: format!("{base}/blob/{}", urlpath(refname)), | |
| 124 | + raw_root: format!("{base}/raw/{}", urlpath(refname)), | |
| 125 | + dir: dir.to_string(), | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +// --------------------------------------------------------------------------- | |
| 130 | +// Repository home and trees | |
| 131 | +// --------------------------------------------------------------------------- | |
| 132 | + | |
| 133 | +pub struct Readme { | |
| 134 | + pub name: String, | |
| 135 | + pub html: String, | |
| 136 | + pub is_markdown: bool, | |
| 137 | +} | |
| 138 | + | |
| 139 | +pub struct TreeView { | |
| 140 | + pub refsel: RefSelector, | |
| 141 | + pub refname: String, | |
| 142 | + pub path: String, | |
| 143 | + pub crumbs: Vec<Crumb>, | |
| 144 | + pub entries: Vec<EntryRow>, | |
| 145 | + pub parent_href: Option<String>, | |
| 146 | + pub latest: Option<CommitInfo>, | |
| 147 | + pub commit_count: usize, | |
| 148 | + pub branch_count: usize, | |
| 149 | + pub tag_count: usize, | |
| 150 | + pub readme: Option<Readme>, | |
| 151 | + pub history_href: String, | |
| 152 | + pub recent_branch: Option<(String, i64)>, | |
| 153 | +} | |
| 154 | + | |
| 155 | +#[derive(Template)] | |
| 156 | +#[template(path = "repo/code.html")] | |
| 157 | +struct CodePage { | |
| 158 | + ctx: Ctx, | |
| 159 | + r: RepoCtx, | |
| 160 | + tab: &'static str, | |
| 161 | + view: TreeView, | |
| 162 | + is_root: bool, | |
| 163 | +} | |
| 164 | + | |
| 165 | +#[derive(Template)] | |
| 166 | +#[template(path = "repo/empty.html")] | |
| 167 | +struct EmptyPage { | |
| 168 | + ctx: Ctx, | |
| 169 | + r: RepoCtx, | |
| 170 | + tab: &'static str, | |
| 171 | +} | |
| 172 | + | |
| 173 | +fn build_tree_view( | |
| 174 | + repo: &Repository, | |
| 175 | + base: &str, | |
| 176 | + resolved: &Resolved, | |
| 177 | + with_stats: bool, | |
| 178 | + default_branch: &str, | |
| 179 | +) -> Result<Option<TreeView>, AppError> { | |
| 180 | + let path = resolved.path.trim_matches('/').to_string(); | |
| 181 | + let Some(entries) = git::list_tree(repo, resolved.commit, &path)? else { | |
| 182 | + return Ok(None); | |
| 183 | + }; | |
| 184 | + let refname = resolved.name.clone(); | |
| 185 | + let entries = entries | |
| 186 | + .into_iter() | |
| 187 | + .map(|e| { | |
| 188 | + let view = if e.is_dir() { "tree" } else { "blob" }; | |
| 189 | + let href = format!("{base}/{view}/{}/{}", urlpath(&refname), urlpath(&e.path)); | |
| 190 | + EntryRow { entry: e, href } | |
| 191 | + }) | |
| 192 | + .collect(); | |
| 193 | + let latest = if path.is_empty() { | |
| 194 | + Some(git::commit_info(repo, resolved.commit)?) | |
| 195 | + } else { | |
| 196 | + git::log(repo, resolved.commit, Some(&path), 0, 1)? | |
| 197 | + .0 | |
| 198 | + .into_iter() | |
| 199 | + .next() | |
| 200 | + }; | |
| 201 | + let readme = git::find_readme(repo, resolved.commit, &path)?.map(|(name, text)| { | |
| 202 | + let lower = name.to_ascii_lowercase(); | |
| 203 | + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); | |
| 204 | + let html = if is_markdown { | |
| 205 | + markdown::render(&text, Some(&link_base(base, &refname, &path))) | |
| 206 | + } else { | |
| 207 | + highlight::escape(&text) | |
| 208 | + }; | |
| 209 | + Readme { | |
| 210 | + name, | |
| 211 | + html, | |
| 212 | + is_markdown, | |
| 213 | + } | |
| 214 | + }); | |
| 215 | + let parent_href = (!path.is_empty()).then(|| match path.rsplit_once('/') { | |
| 216 | + Some((parent, _)) => format!("{base}/tree/{}/{}", urlpath(&refname), urlpath(parent)), | |
| 217 | + None => format!("{base}/tree/{}", urlpath(&refname)), | |
| 218 | + }); | |
| 219 | + let (commit_count, branch_count, tag_count) = if with_stats { | |
| 220 | + ( | |
| 221 | + git::commit_count(repo, resolved.commit)?, | |
| 222 | + git::branch_names(repo)?.len(), | |
| 223 | + git::tag_names(repo)?.len(), | |
| 224 | + ) | |
| 225 | + } else { | |
| 226 | + (0, 0, 0) | |
| 227 | + }; | |
| 228 | + let recent_branch = if with_stats { | |
| 229 | + recently_pushed_branch(repo, default_branch) | |
| 230 | + } else { | |
| 231 | + None | |
| 232 | + }; | |
| 233 | + let mut history_href = format!("{base}/commits/{}", urlpath(&refname)); | |
| 234 | + if !path.is_empty() { | |
| 235 | + history_href.push('/'); | |
| 236 | + history_href.push_str(&urlpath(&path)); | |
| 237 | + } | |
| 238 | + Ok(Some(TreeView { | |
| 239 | + refsel: RefSelector::build(repo, base, "tree", resolved, &path), | |
| 240 | + crumbs: breadcrumbs(base, &refname, &path), | |
| 241 | + refname, | |
| 242 | + path, | |
| 243 | + entries, | |
| 244 | + parent_href, | |
| 245 | + latest, | |
| 246 | + commit_count, | |
| 247 | + branch_count, | |
| 248 | + tag_count, | |
| 249 | + readme, | |
| 250 | + history_href, | |
| 251 | + recent_branch, | |
| 252 | + })) | |
| 253 | +} | |
| 254 | + | |
| 255 | +/// The most recently updated non-default branch, if it changed in the last hour. | |
| 256 | +fn recently_pushed_branch(repo: &Repository, default_branch: &str) -> Option<(String, i64)> { | |
| 257 | + let cutoff = db::now() - 3600; | |
| 258 | + repo.branches(Some(BranchType::Local)) | |
| 259 | + .ok()? | |
| 260 | + .flatten() | |
| 261 | + .filter_map(|(b, _)| { | |
| 262 | + let name = b.name().ok()??.to_string(); | |
| 263 | + let time = b.get().peel_to_commit().ok()?.committer().when().seconds(); | |
| 264 | + (name != default_branch && time > cutoff).then_some((name, time)) | |
| 265 | + }) | |
| 266 | + .max_by_key(|(_, t)| *t) | |
| 267 | +} | |
| 268 | + | |
| 269 | +pub async fn repo_home( | |
| 270 | + State(state): State<AppState>, | |
| 271 | + ctx: Ctx, | |
| 272 | + Path((owner, name)): Path<(String, String)>, | |
| 273 | +) -> WebResult { | |
| 274 | + if let Some(stripped) = name.strip_suffix(".git") { | |
| 275 | + return Ok(redirect(&format!("/{owner}/{stripped}"))); | |
| 276 | + } | |
| 277 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 278 | + let (path, base, default) = (r.path.clone(), r.base(), r.repo.default_branch.clone()); | |
| 279 | + let view = blocking(move || { | |
| 280 | + let repo = git::open(&path)?; | |
| 281 | + if git::is_empty(&repo) { | |
| 282 | + return Ok(None); | |
| 283 | + } | |
| 284 | + let head = git::resolve(&repo, &default).or_else(|| { | |
| 285 | + let branch = git::branch_names(&repo).ok()?.into_iter().next()?; | |
| 286 | + git::resolve(&repo, &branch) | |
| 287 | + }); | |
| 288 | + let Some(resolved) = head else { | |
| 289 | + return Ok(None); | |
| 290 | + }; | |
| 291 | + build_tree_view(&repo, &base, &resolved, true, &default) | |
| 292 | + }) | |
| 293 | + .await?; | |
| 294 | + let Some(mut view) = view else { | |
| 295 | + return render(&EmptyPage { | |
| 296 | + ctx, | |
| 297 | + r, | |
| 298 | + tab: "code", | |
| 299 | + }); | |
| 300 | + }; | |
| 301 | + // Only suggest opening a pull request to people who can push, and only once. | |
| 302 | + if let Some((branch, _)) = &view.recent_branch { | |
| 303 | + let has_pr = db::list_pulls(&state.db, r.repo.id, true) | |
| 304 | + .await? | |
| 305 | + .iter() | |
| 306 | + .any(|p| &p.head_branch == branch); | |
| 307 | + if has_pr || !r.access.write { | |
| 308 | + view.recent_branch = None; | |
| 309 | + } | |
| 310 | + } | |
| 311 | + render(&CodePage { | |
| 312 | + ctx, | |
| 313 | + r, | |
| 314 | + tab: "code", | |
| 315 | + view, | |
| 316 | + is_root: true, | |
| 317 | + }) | |
| 318 | +} | |
| 319 | + | |
| 320 | +pub async fn tree( | |
| 321 | + State(state): State<AppState>, | |
| 322 | + ctx: Ctx, | |
| 323 | + Path((owner, name, rest)): Path<(String, String, String)>, | |
| 324 | +) -> WebResult { | |
| 325 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 326 | + let (path, base, default) = (r.path.clone(), r.base(), r.repo.default_branch.clone()); | |
| 327 | + let view = blocking(move || { | |
| 328 | + let repo = git::open(&path)?; | |
| 329 | + let resolved = resolve(&repo, &rest)?; | |
| 330 | + if let Some(ObjectType::Blob) = git::path_type(&repo, resolved.commit, &resolved.path)? { | |
| 331 | + return Ok(Err(format!("{base}/blob/{rest}"))); | |
| 332 | + } | |
| 333 | + let is_root = resolved.path.is_empty(); | |
| 334 | + Ok(Ok(( | |
| 335 | + build_tree_view(&repo, &base, &resolved, is_root, &default)?, | |
| 336 | + is_root, | |
| 337 | + ))) | |
| 338 | + }) | |
| 339 | + .await?; | |
| 340 | + match view { | |
| 341 | + Err(blob_url) => Ok(redirect(&urlpath(&blob_url))), | |
| 342 | + Ok((Some(view), is_root)) => render(&CodePage { | |
| 343 | + ctx, | |
| 344 | + r, | |
| 345 | + tab: "code", | |
| 346 | + view, | |
| 347 | + is_root, | |
| 348 | + }), | |
| 349 | + Ok((None, _)) => Err(AppError::NotFound), | |
| 350 | + } | |
| 351 | +} | |
| 352 | + | |
| 353 | +// --------------------------------------------------------------------------- | |
| 354 | +// Files | |
| 355 | +// --------------------------------------------------------------------------- | |
| 356 | + | |
| 357 | +pub enum BlobContent { | |
| 358 | + Code { lines: Vec<String> }, | |
| 359 | + Markdown { html: String }, | |
| 360 | + Image { src: String }, | |
| 361 | + Binary, | |
| 362 | + TooLarge, | |
| 363 | +} | |
| 364 | + | |
| 365 | +pub struct BlobView { | |
| 366 | + pub refsel: RefSelector, | |
| 367 | + pub refname: String, | |
| 368 | + pub path: String, | |
| 369 | + pub file_name: String, | |
| 370 | + pub crumbs: Vec<Crumb>, | |
| 371 | + pub size: usize, | |
| 372 | + pub line_count: usize, | |
| 373 | + pub language: Option<&'static str>, | |
| 374 | + pub content: BlobContent, | |
| 375 | + pub latest: Option<CommitInfo>, | |
| 376 | + pub raw_href: String, | |
| 377 | + pub history_href: String, | |
| 378 | + pub is_markdown: bool, | |
| 379 | + pub plain: bool, | |
| 380 | +} | |
| 381 | + | |
| 382 | +#[derive(Template)] | |
| 383 | +#[template(path = "repo/blob.html")] | |
| 384 | +struct BlobPage { | |
| 385 | + ctx: Ctx, | |
| 386 | + r: RepoCtx, | |
| 387 | + tab: &'static str, | |
| 388 | + view: BlobView, | |
| 389 | +} | |
| 390 | + | |
| 391 | +#[derive(Deserialize)] | |
| 392 | +pub struct BlobQuery { | |
| 393 | + plain: Option<String>, | |
| 394 | +} | |
| 395 | + | |
| 396 | +fn is_image(path: &str) -> bool { | |
| 397 | + let lower = path.to_ascii_lowercase(); | |
| 398 | + [ | |
| 399 | + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg", | |
| 400 | + ] | |
| 401 | + .iter() | |
| 402 | + .any(|ext| lower.ends_with(ext)) | |
| 403 | +} | |
| 404 | + | |
| 405 | +pub async fn blob( | |
| 406 | + State(state): State<AppState>, | |
| 407 | + ctx: Ctx, | |
| 408 | + Path((owner, name, rest)): Path<(String, String, String)>, | |
| 409 | + Query(q): Query<BlobQuery>, | |
| 410 | +) -> WebResult { | |
| 411 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 412 | + let plain = q.plain.is_some_and(|p| p == "1"); | |
| 413 | + let (path, base) = (r.path.clone(), r.base()); | |
| 414 | + let view = blocking(move || { | |
| 415 | + let repo = git::open(&path)?; | |
| 416 | + let resolved = resolve(&repo, &rest)?; | |
| 417 | + let file_path = resolved.path.clone(); | |
| 418 | + if file_path.is_empty() { | |
| 419 | + return Ok(Err(format!("{base}/tree/{}", resolved.name))); | |
| 420 | + } | |
| 421 | + match git::path_type(&repo, resolved.commit, &file_path)? { | |
| 422 | + Some(ObjectType::Tree) => return Ok(Err(format!("{base}/tree/{rest}"))), | |
| 423 | + Some(ObjectType::Blob) => {} | |
| 424 | + _ => return Err(AppError::NotFound), | |
| 425 | + } | |
| 426 | + let blob = git::read_blob(&repo, resolved.commit, &file_path)?.ok_or(AppError::NotFound)?; | |
| 427 | + let refname = resolved.name.clone(); | |
| 428 | + let raw_href = format!("{base}/raw/{}/{}", urlpath(&refname), urlpath(&file_path)); | |
| 429 | + let lower = file_path.to_ascii_lowercase(); | |
| 430 | + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); | |
| 431 | + let dir = file_path.rsplit_once('/').map(|(d, _)| d).unwrap_or(""); | |
| 432 | + | |
| 433 | + let mut line_count = 0; | |
| 434 | + let mut language = None; | |
| 435 | + let content = if is_image(&file_path) { | |
| 436 | + BlobContent::Image { | |
| 437 | + src: raw_href.clone(), | |
| 438 | + } | |
| 439 | + } else if blob.is_binary { | |
| 440 | + BlobContent::Binary | |
| 441 | + } else if blob.size > MAX_DISPLAY_BYTES { | |
| 442 | + BlobContent::TooLarge | |
| 443 | + } else { | |
| 444 | + let text = String::from_utf8_lossy(&blob.data); | |
| 445 | + let lines: Vec<&str> = text.lines().collect(); | |
| 446 | + line_count = lines.len(); | |
| 447 | + language = highlight::language_name(&file_path, lines.first().copied().unwrap_or("")); | |
| 448 | + if is_markdown && !plain { | |
| 449 | + BlobContent::Markdown { | |
| 450 | + html: markdown::render(&text, Some(&link_base(&base, &refname, dir))), | |
| 451 | + } | |
| 452 | + } else { | |
| 453 | + BlobContent::Code { | |
| 454 | + lines: highlight::highlight_lines(&file_path, &lines), | |
| 455 | + } | |
| 456 | + } | |
| 457 | + }; | |
| 458 | + let latest = git::log(&repo, resolved.commit, Some(&file_path), 0, 1)? | |
| 459 | + .0 | |
| 460 | + .into_iter() | |
| 461 | + .next(); | |
| 462 | + Ok(Ok(BlobView { | |
| 463 | + refsel: RefSelector::build(&repo, &base, "blob", &resolved, &file_path), | |
| 464 | + crumbs: breadcrumbs(&base, &refname, dir), | |
| 465 | + file_name: file_path | |
| 466 | + .rsplit('/') | |
| 467 | + .next() | |
| 468 | + .unwrap_or(&file_path) | |
| 469 | + .to_string(), | |
| 470 | + history_href: format!( | |
| 471 | + "{base}/commits/{}/{}", | |
| 472 | + urlpath(&refname), | |
| 473 | + urlpath(&file_path) | |
| 474 | + ), | |
| 475 | + refname, | |
| 476 | + path: file_path, | |
| 477 | + size: blob.size, | |
| 478 | + line_count, | |
| 479 | + language, | |
| 480 | + content, | |
| 481 | + latest, | |
| 482 | + raw_href, | |
| 483 | + is_markdown, | |
| 484 | + plain, | |
| 485 | + })) | |
| 486 | + }) | |
| 487 | + .await?; | |
| 488 | + match view { | |
| 489 | + Ok(view) => render(&BlobPage { | |
| 490 | + ctx, | |
| 491 | + r, | |
| 492 | + tab: "code", | |
| 493 | + view, | |
| 494 | + }), | |
| 495 | + Err(url) => Ok(redirect(&urlpath(&url))), | |
| 496 | + } | |
| 497 | +} | |
| 498 | + | |
| 499 | +pub async fn raw( | |
| 500 | + State(state): State<AppState>, | |
| 501 | + ctx: Ctx, | |
| 502 | + Path((owner, name, rest)): Path<(String, String, String)>, | |
| 503 | +) -> WebResult { | |
| 504 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 505 | + let path = r.path.clone(); | |
| 506 | + let (file_path, blob) = blocking(move || { | |
| 507 | + let repo = git::open(&path)?; | |
| 508 | + let resolved = resolve(&repo, &rest)?; | |
| 509 | + let blob = | |
| 510 | + git::read_blob(&repo, resolved.commit, &resolved.path)?.ok_or(AppError::NotFound)?; | |
| 511 | + Ok((resolved.path, blob)) | |
| 512 | + }) | |
| 513 | + .await?; | |
| 514 | + | |
| 515 | + let mime = mime_guess::from_path(&file_path).first_or_octet_stream(); | |
| 516 | + let content_type = if mime.type_() == "image" { | |
| 517 | + mime.essence_str().to_string() | |
| 518 | + } else if !blob.is_binary { | |
| 519 | + "text/plain; charset=utf-8".to_string() | |
| 520 | + } else { | |
| 521 | + "application/octet-stream".to_string() | |
| 522 | + }; | |
| 523 | + let file_name = file_path | |
| 524 | + .rsplit('/') | |
| 525 | + .next() | |
| 526 | + .unwrap_or("file") | |
| 527 | + .replace('"', ""); | |
| 528 | + let disposition = if content_type == "application/octet-stream" { | |
| 529 | + format!("attachment; filename=\"{file_name}\"") | |
| 530 | + } else { | |
| 531 | + format!("inline; filename=\"{file_name}\"") | |
| 532 | + }; | |
| 533 | + let mut resp = blob.data.into_response(); | |
| 534 | + let headers = resp.headers_mut(); | |
| 535 | + if let Ok(v) = HeaderValue::from_str(&content_type) { | |
| 536 | + headers.insert(header::CONTENT_TYPE, v); | |
| 537 | + } | |
| 538 | + if let Ok(v) = HeaderValue::from_str(&disposition) { | |
| 539 | + headers.insert(header::CONTENT_DISPOSITION, v); | |
| 540 | + } | |
| 541 | + // User content is never allowed to run script on this origin. | |
| 542 | + headers.insert( | |
| 543 | + header::CONTENT_SECURITY_POLICY, | |
| 544 | + HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; sandbox"), | |
| 545 | + ); | |
| 546 | + Ok(resp) | |
| 547 | +} | |
| 548 | + | |
| 549 | +// --------------------------------------------------------------------------- | |
| 550 | +// History | |
| 551 | +// --------------------------------------------------------------------------- | |
| 552 | + | |
| 553 | +pub struct CommitGroup { | |
| 554 | + pub date: String, | |
| 555 | + pub commits: Vec<CommitInfo>, | |
| 556 | +} | |
| 557 | + | |
| 558 | +pub struct CommitsView { | |
| 559 | + pub refsel: RefSelector, | |
| 560 | + pub refname: String, | |
| 561 | + pub path: String, | |
| 562 | + pub groups: Vec<CommitGroup>, | |
| 563 | + pub newer_href: Option<String>, | |
| 564 | + pub older_href: Option<String>, | |
| 565 | +} | |
| 566 | + | |
| 567 | +#[derive(Template)] | |
| 568 | +#[template(path = "repo/commits.html")] | |
| 569 | +struct CommitsPage { | |
| 570 | + ctx: Ctx, | |
| 571 | + r: RepoCtx, | |
| 572 | + tab: &'static str, | |
| 573 | + view: CommitsView, | |
| 574 | +} | |
| 575 | + | |
| 576 | +#[derive(Deserialize)] | |
| 577 | +pub struct PageQuery { | |
| 578 | + page: Option<usize>, | |
| 579 | +} | |
| 580 | + | |
| 581 | +pub async fn commits_default( | |
| 582 | + State(state): State<AppState>, | |
| 583 | + ctx: Ctx, | |
| 584 | + Path((owner, name)): Path<(String, String)>, | |
| 585 | +) -> WebResult { | |
| 586 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 587 | + Ok(redirect(&format!( | |
| 588 | + "{}/commits/{}", | |
| 589 | + r.base(), | |
| 590 | + urlpath(&r.repo.default_branch) | |
| 591 | + ))) | |
| 592 | +} | |
| 593 | + | |
| 594 | +pub async fn commits( | |
| 595 | + State(state): State<AppState>, | |
| 596 | + ctx: Ctx, | |
| 597 | + Path((owner, name, rest)): Path<(String, String, String)>, | |
| 598 | + Query(q): Query<PageQuery>, | |
| 599 | +) -> WebResult { | |
| 600 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 601 | + let page = q.page.unwrap_or(1).max(1); | |
| 602 | + let (path, base) = (r.path.clone(), r.base()); | |
| 603 | + let view = blocking(move || { | |
| 604 | + let repo = git::open(&path)?; | |
| 605 | + let resolved = resolve(&repo, &rest)?; | |
| 606 | + let file_path = resolved.path.trim_matches('/').to_string(); | |
| 607 | + let (list, more) = git::log( | |
| 608 | + &repo, | |
| 609 | + resolved.commit, | |
| 610 | + Some(&file_path), | |
| 611 | + (page - 1) * COMMITS_PER_PAGE, | |
| 612 | + COMMITS_PER_PAGE, | |
| 613 | + )?; | |
| 614 | + let mut groups: Vec<CommitGroup> = Vec::new(); | |
| 615 | + for c in list { | |
| 616 | + let date = super::fmt::date(c.committer_time); | |
| 617 | + match groups.last_mut() { | |
| 618 | + Some(g) if g.date == date => g.commits.push(c), | |
| 619 | + _ => groups.push(CommitGroup { | |
| 620 | + date, | |
| 621 | + commits: vec![c], | |
| 622 | + }), | |
| 623 | + } | |
| 624 | + } | |
| 625 | + let mut here = format!("{base}/commits/{}", urlpath(&resolved.name)); | |
| 626 | + if !file_path.is_empty() { | |
| 627 | + here.push('/'); | |
| 628 | + here.push_str(&urlpath(&file_path)); | |
| 629 | + } | |
| 630 | + Ok(CommitsView { | |
| 631 | + refsel: RefSelector::build(&repo, &base, "commits", &resolved, &file_path), | |
| 632 | + refname: resolved.name.clone(), | |
| 633 | + path: file_path, | |
| 634 | + groups, | |
| 635 | + newer_href: (page > 1).then(|| format!("{here}?page={}", page - 1)), | |
| 636 | + older_href: more.then(|| format!("{here}?page={}", page + 1)), | |
| 637 | + }) | |
| 638 | + }) | |
| 639 | + .await?; | |
| 640 | + render(&CommitsPage { | |
| 641 | + ctx, | |
| 642 | + r, | |
| 643 | + tab: "commits", | |
| 644 | + view, | |
| 645 | + }) | |
| 646 | +} | |
| 647 | + | |
| 648 | +#[derive(Template)] | |
| 649 | +#[template(path = "repo/commit.html")] | |
| 650 | +struct CommitPage { | |
| 651 | + ctx: Ctx, | |
| 652 | + r: RepoCtx, | |
| 653 | + tab: &'static str, | |
| 654 | + commit: CommitInfo, | |
| 655 | + diff: DiffSummary, | |
| 656 | +} | |
| 657 | + | |
| 658 | +pub async fn commit( | |
| 659 | + State(state): State<AppState>, | |
| 660 | + ctx: Ctx, | |
| 661 | + Path((owner, name, sha)): Path<(String, String, String)>, | |
| 662 | +) -> WebResult { | |
| 663 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 664 | + if !sha.chars().all(|c| c.is_ascii_hexdigit()) || sha.len() < 4 || sha.len() > 40 { | |
| 665 | + return Err(AppError::NotFound); | |
| 666 | + } | |
| 667 | + let path = r.path.clone(); | |
| 668 | + let (commit, diff) = blocking(move || { | |
| 669 | + let repo = git::open(&path)?; | |
| 670 | + let oid: Oid = repo | |
| 671 | + .revparse_single(&sha) | |
| 672 | + .and_then(|o| o.peel_to_commit()) | |
| 673 | + .map_err(|_| AppError::NotFound)? | |
| 674 | + .id(); | |
| 675 | + Ok((git::commit_info(&repo, oid)?, git::commit_diff(&repo, oid)?)) | |
| 676 | + }) | |
| 677 | + .await?; | |
| 678 | + render(&CommitPage { | |
| 679 | + ctx, | |
| 680 | + r, | |
| 681 | + tab: "commits", | |
| 682 | + commit, | |
| 683 | + diff, | |
| 684 | + }) | |
| 685 | +} | |
| 686 | + | |
| 687 | +// --------------------------------------------------------------------------- | |
| 688 | +// Archives | |
| 689 | +// --------------------------------------------------------------------------- | |
| 690 | + | |
| 691 | +/// Streams `git archive` output for `{ref}.zip` or `{ref}.tar.gz`. | |
| 692 | +pub async fn archive( | |
| 693 | + State(state): State<AppState>, | |
| 694 | + ctx: Ctx, | |
| 695 | + Path((owner, name, file)): Path<(String, String, String)>, | |
| 696 | +) -> WebResult { | |
| 697 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 698 | + let (refname, format, content_type) = if let Some(r) = file.strip_suffix(".zip") { | |
| 699 | + (r.to_string(), "zip", "application/zip") | |
| 700 | + } else if let Some(r) = file.strip_suffix(".tar.gz") { | |
| 701 | + (r.to_string(), "tar.gz", "application/gzip") | |
| 702 | + } else { | |
| 703 | + return Err(AppError::NotFound); | |
| 704 | + }; | |
| 705 | + let path = r.path.clone(); | |
| 706 | + let lookup = refname.clone(); | |
| 707 | + let commit = blocking(move || { | |
| 708 | + let repo = git::open(&path)?; | |
| 709 | + let resolved = resolve(&repo, &lookup)?; | |
| 710 | + if !resolved.path.is_empty() { | |
| 711 | + return Err(AppError::NotFound); | |
| 712 | + } | |
| 713 | + Ok(resolved.commit) | |
| 714 | + }) | |
| 715 | + .await?; | |
| 716 | + | |
| 717 | + let stem = format!("{}-{}", r.repo.name, refname.replace('/', "-")); | |
| 718 | + let mut child = Command::new(&state.config.git_bin) | |
| 719 | + .arg("--git-dir") | |
| 720 | + .arg(&r.path) | |
| 721 | + .arg("archive") | |
| 722 | + .arg(format!("--format={format}")) | |
| 723 | + .arg(format!("--prefix={stem}/")) | |
| 724 | + .arg(commit.to_string()) | |
| 725 | + .stdin(Stdio::null()) | |
| 726 | + .stdout(Stdio::piped()) | |
| 727 | + .stderr(Stdio::null()) | |
| 728 | + .kill_on_drop(true) | |
| 729 | + .spawn()?; | |
| 730 | + let stdout = child | |
| 731 | + .stdout | |
| 732 | + .take() | |
| 733 | + .ok_or_else(|| anyhow::anyhow!("git archive stdout"))?; | |
| 734 | + tokio::spawn(async move { | |
| 735 | + let _ = child.wait().await; | |
| 736 | + }); | |
| 737 | + let mut resp = Body::from_stream(ReaderStream::new(stdout)).into_response(); | |
| 738 | + let headers = resp.headers_mut(); | |
| 739 | + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); | |
| 740 | + if let Ok(v) = HeaderValue::from_str(&format!( | |
| 741 | + "attachment; filename=\"{}.{format}\"", | |
| 742 | + stem.replace('"', "") | |
| 743 | + )) { | |
| 744 | + headers.insert(header::CONTENT_DISPOSITION, v); | |
| 745 | + } | |
| 746 | + Ok(resp) | |
| 747 | +} |
| @@ -0,0 +1,292 @@ | ||
| 1 | +//! Per-request context: the signed-in user, flash messages, CSRF protection | |
| 2 | +//! and error page rendering. | |
| 3 | + | |
| 4 | +use std::sync::Arc; | |
| 5 | + | |
| 6 | +use askama::Template; | |
| 7 | +use axum::extract::{FromRequestParts, Request, State}; | |
| 8 | +use axum::http::header::{self, HeaderValue}; | |
| 9 | +use axum::http::request::Parts; | |
| 10 | +use axum::http::{Method, StatusCode}; | |
| 11 | +use axum::middleware::Next; | |
| 12 | +use axum::response::{Html, IntoResponse, Response}; | |
| 13 | +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | |
| 14 | +use base64::Engine; | |
| 15 | +use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 16 | +use rand::RngCore; | |
| 17 | +use sha2::{Digest, Sha256}; | |
| 18 | + | |
| 19 | +use super::error::{AppError, ErrorPage}; | |
| 20 | +use crate::db::{self, User}; | |
| 21 | +use crate::state::AppState; | |
| 22 | + | |
| 23 | +pub const SESSION_COOKIE: &str = "openhub_session"; | |
| 24 | +const FLASH_COOKIE: &str = "openhub_flash"; | |
| 25 | +pub const SESSION_TTL_SECS: i64 = 30 * 24 * 3600; | |
| 26 | + | |
| 27 | +#[derive(Debug, Clone)] | |
| 28 | +pub struct Flash { | |
| 29 | + pub kind: String, | |
| 30 | + pub message: String, | |
| 31 | +} | |
| 32 | + | |
| 33 | +/// Available to every handler and template. | |
| 34 | +#[derive(Debug, Clone)] | |
| 35 | +pub struct Ctx { | |
| 36 | + pub user: Option<Arc<User>>, | |
| 37 | + pub flash: Option<Flash>, | |
| 38 | + pub path: String, | |
| 39 | + pub session_hash: Option<String>, | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Ctx { | |
| 43 | + pub fn require_user(&self) -> Result<&User, AppError> { | |
| 44 | + self.user | |
| 45 | + .as_deref() | |
| 46 | + .ok_or_else(|| AppError::LoginRequired(self.path.clone())) | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +impl<S: Send + Sync> FromRequestParts<S> for Ctx { | |
| 51 | + type Rejection = AppError; | |
| 52 | + | |
| 53 | + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { | |
| 54 | + parts | |
| 55 | + .extensions | |
| 56 | + .get::<Ctx>() | |
| 57 | + .cloned() | |
| 58 | + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("request context missing"))) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +pub fn hash_token(token: &str) -> String { | |
| 63 | + hex::encode(Sha256::digest(token.as_bytes())) | |
| 64 | +} | |
| 65 | + | |
| 66 | +pub fn new_token() -> String { | |
| 67 | + let mut bytes = [0u8; 32]; | |
| 68 | + rand::rngs::OsRng.fill_bytes(&mut bytes); | |
| 69 | + URL_SAFE_NO_PAD.encode(bytes) | |
| 70 | +} | |
| 71 | + | |
| 72 | +pub fn session_cookie(token: String, secure: bool) -> Cookie<'static> { | |
| 73 | + Cookie::build((SESSION_COOKIE, token)) | |
| 74 | + .path("/") | |
| 75 | + .http_only(true) | |
| 76 | + .secure(secure) | |
| 77 | + .same_site(SameSite::Lax) | |
| 78 | + .max_age(time_duration(SESSION_TTL_SECS)) | |
| 79 | + .build() | |
| 80 | +} | |
| 81 | + | |
| 82 | +pub fn clear_session_cookie() -> Cookie<'static> { | |
| 83 | + Cookie::build((SESSION_COOKIE, "")) | |
| 84 | + .path("/") | |
| 85 | + .max_age(time_duration(0)) | |
| 86 | + .build() | |
| 87 | +} | |
| 88 | + | |
| 89 | +fn time_duration(secs: i64) -> cookie::time::Duration { | |
| 90 | + cookie::time::Duration::seconds(secs) | |
| 91 | +} | |
| 92 | + | |
| 93 | +pub fn flash_cookie(kind: &str, message: &str) -> Cookie<'static> { | |
| 94 | + let value = URL_SAFE_NO_PAD.encode(format!("{kind}\n{message}")); | |
| 95 | + Cookie::build((FLASH_COOKIE, value)) | |
| 96 | + .path("/") | |
| 97 | + .http_only(true) | |
| 98 | + .same_site(SameSite::Lax) | |
| 99 | + .build() | |
| 100 | +} | |
| 101 | + | |
| 102 | +fn decode_flash(value: &str) -> Option<Flash> { | |
| 103 | + let bytes = URL_SAFE_NO_PAD.decode(value).ok()?; | |
| 104 | + let text = String::from_utf8(bytes).ok()?; | |
| 105 | + let (kind, message) = text.split_once('\n')?; | |
| 106 | + let kind = match kind { | |
| 107 | + "success" | "error" | "info" => kind.to_string(), | |
| 108 | + _ => return None, | |
| 109 | + }; | |
| 110 | + Some(Flash { | |
| 111 | + kind, | |
| 112 | + message: message.to_string(), | |
| 113 | + }) | |
| 114 | +} | |
| 115 | + | |
| 116 | +fn is_git_request(path: &str) -> bool { | |
| 117 | + path.ends_with("/info/refs") | |
| 118 | + || path.ends_with("/git-upload-pack") | |
| 119 | + || path.ends_with("/git-receive-pack") | |
| 120 | +} | |
| 121 | + | |
| 122 | +/// Rejects cross-site form posts. SameSite=Lax cookies already prevent | |
| 123 | +/// authenticated cross-site requests in modern browsers; this is a second | |
| 124 | +/// line of defence based on the Origin header. Requests are accepted when the | |
| 125 | +/// origin matches either the configured public URL or the Host header (reverse | |
| 126 | +/// proxies do not always preserve the latter). | |
| 127 | +fn cross_origin(req: &Request, public_url: &str) -> bool { | |
| 128 | + let Some(origin) = req | |
| 129 | + .headers() | |
| 130 | + .get(header::ORIGIN) | |
| 131 | + .and_then(|v| v.to_str().ok()) | |
| 132 | + else { | |
| 133 | + return false; | |
| 134 | + }; | |
| 135 | + if origin.eq_ignore_ascii_case(public_url) { | |
| 136 | + return false; | |
| 137 | + } | |
| 138 | + let host = req | |
| 139 | + .headers() | |
| 140 | + .get(header::HOST) | |
| 141 | + .and_then(|v| v.to_str().ok()) | |
| 142 | + .unwrap_or(""); | |
| 143 | + let origin_host = origin.split_once("://").map(|(_, h)| h).unwrap_or(origin); | |
| 144 | + origin == "null" || !origin_host.eq_ignore_ascii_case(host) | |
| 145 | +} | |
| 146 | + | |
| 147 | +/// `https://git.example.com/sub` → `https://git.example.com` | |
| 148 | +fn origin_of(url: &str) -> &str { | |
| 149 | + let after_scheme = url.find("://").map(|i| i + 3).unwrap_or(0); | |
| 150 | + match url[after_scheme..].find('/') { | |
| 151 | + Some(i) => &url[..after_scheme + i], | |
| 152 | + None => url, | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +#[derive(Template)] | |
| 157 | +#[template(path = "error.html")] | |
| 158 | +struct ErrorTemplate { | |
| 159 | + ctx: Ctx, | |
| 160 | + title: &'static str, | |
| 161 | + message: String, | |
| 162 | + status: u16, | |
| 163 | +} | |
| 164 | + | |
| 165 | +pub async fn middleware(State(state): State<AppState>, mut req: Request, next: Next) -> Response { | |
| 166 | + let path = req.uri().path().to_string(); | |
| 167 | + if path.starts_with("/static/") || path == "/favicon.svg" { | |
| 168 | + return next.run(req).await; | |
| 169 | + } | |
| 170 | + let git = is_git_request(&path); | |
| 171 | + if !git | |
| 172 | + && !matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) | |
| 173 | + && cross_origin(&req, origin_of(&state.config.public_url)) | |
| 174 | + { | |
| 175 | + return (StatusCode::FORBIDDEN, "Cross-origin request rejected").into_response(); | |
| 176 | + } | |
| 177 | + | |
| 178 | + let jar = CookieJar::from_headers(req.headers()); | |
| 179 | + let mut user = None; | |
| 180 | + let mut session_hash = None; | |
| 181 | + if !git { | |
| 182 | + if let Some(cookie) = jar.get(SESSION_COOKIE) { | |
| 183 | + let hash = hash_token(cookie.value()); | |
| 184 | + match db::user_by_session(&state.db, &hash).await { | |
| 185 | + Ok(Some(u)) => { | |
| 186 | + user = Some(Arc::new(u)); | |
| 187 | + session_hash = Some(hash); | |
| 188 | + } | |
| 189 | + Ok(None) => {} | |
| 190 | + Err(e) => tracing::error!("session lookup failed: {e}"), | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + let flash = jar.get(FLASH_COOKIE).and_then(|c| decode_flash(c.value())); | |
| 195 | + let had_flash = jar.get(FLASH_COOKIE).is_some(); | |
| 196 | + | |
| 197 | + let ctx = Ctx { | |
| 198 | + user, | |
| 199 | + flash, | |
| 200 | + path, | |
| 201 | + session_hash, | |
| 202 | + }; | |
| 203 | + req.extensions_mut().insert(ctx.clone()); | |
| 204 | + let mut resp = next.run(req).await; | |
| 205 | + | |
| 206 | + if let Some(page) = resp.extensions_mut().remove::<ErrorPage>() { | |
| 207 | + let tpl = ErrorTemplate { | |
| 208 | + ctx: ctx.clone(), | |
| 209 | + title: page.title, | |
| 210 | + message: page.message, | |
| 211 | + status: page.status.as_u16(), | |
| 212 | + }; | |
| 213 | + let body = tpl.render().unwrap_or_else(|_| page.title.to_string()); | |
| 214 | + resp = (page.status, Html(body)).into_response(); | |
| 215 | + } | |
| 216 | + | |
| 217 | + let headers = resp.headers_mut(); | |
| 218 | + // Consume the flash message unless this response sets a new one. | |
| 219 | + let sets_flash = headers | |
| 220 | + .get_all(header::SET_COOKIE) | |
| 221 | + .iter() | |
| 222 | + .any(|v| v.to_str().is_ok_and(|s| s.starts_with(FLASH_COOKIE))); | |
| 223 | + if had_flash && !sets_flash { | |
| 224 | + let clear = Cookie::build((FLASH_COOKIE, "")) | |
| 225 | + .path("/") | |
| 226 | + .max_age(time_duration(0)) | |
| 227 | + .build(); | |
| 228 | + if let Ok(v) = HeaderValue::from_str(&clear.to_string()) { | |
| 229 | + headers.append(header::SET_COOKIE, v); | |
| 230 | + } | |
| 231 | + } | |
| 232 | + let is_html = headers | |
| 233 | + .get(header::CONTENT_TYPE) | |
| 234 | + .and_then(|v| v.to_str().ok()) | |
| 235 | + .is_some_and(|v| v.starts_with("text/html")); | |
| 236 | + if is_html { | |
| 237 | + headers.insert( | |
| 238 | + header::CONTENT_SECURITY_POLICY, | |
| 239 | + HeaderValue::from_static( | |
| 240 | + "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; \ | |
| 241 | + script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'", | |
| 242 | + ), | |
| 243 | + ); | |
| 244 | + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); | |
| 245 | + } | |
| 246 | + headers.insert( | |
| 247 | + "x-content-type-options", | |
| 248 | + HeaderValue::from_static("nosniff"), | |
| 249 | + ); | |
| 250 | + headers.insert( | |
| 251 | + header::REFERRER_POLICY, | |
| 252 | + HeaderValue::from_static("strict-origin-when-cross-origin"), | |
| 253 | + ); | |
| 254 | + resp | |
| 255 | +} | |
| 256 | + | |
| 257 | +#[cfg(test)] | |
| 258 | +mod tests { | |
| 259 | + use super::*; | |
| 260 | + | |
| 261 | + fn post(origin: Option<&str>, host: &str) -> Request { | |
| 262 | + let mut builder = Request::builder() | |
| 263 | + .method("POST") | |
| 264 | + .uri("/x") | |
| 265 | + .header(header::HOST, host); | |
| 266 | + if let Some(o) = origin { | |
| 267 | + builder = builder.header(header::ORIGIN, o); | |
| 268 | + } | |
| 269 | + builder.body(axum::body::Body::empty()).unwrap() | |
| 270 | + } | |
| 271 | + | |
| 272 | + #[test] | |
| 273 | + fn origin_checks() { | |
| 274 | + let public = origin_of("https://git.example.com/"); | |
| 275 | + assert_eq!(public, "https://git.example.com"); | |
| 276 | + assert!(!cross_origin(&post(None, "localhost:3000"), public)); | |
| 277 | + assert!(!cross_origin( | |
| 278 | + &post(Some("http://localhost:3000"), "localhost:3000"), | |
| 279 | + public | |
| 280 | + )); | |
| 281 | + // Behind a proxy that rewrites Host. | |
| 282 | + assert!(!cross_origin( | |
| 283 | + &post(Some("https://git.example.com"), "127.0.0.1:3000"), | |
| 284 | + public | |
| 285 | + )); | |
| 286 | + assert!(cross_origin( | |
| 287 | + &post(Some("https://evil.example"), "localhost:3000"), | |
| 288 | + public | |
| 289 | + )); | |
| 290 | + assert!(cross_origin(&post(Some("null"), "localhost:3000"), public)); | |
| 291 | + } | |
| 292 | +} |
| @@ -0,0 +1,62 @@ | ||
| 1 | +use axum::http::StatusCode; | |
| 2 | +use axum::response::{IntoResponse, Redirect, Response}; | |
| 3 | + | |
| 4 | +/// Errors returned by page handlers. The context middleware turns these into | |
| 5 | +/// full HTML error pages. | |
| 6 | +#[derive(Debug)] | |
| 7 | +pub enum AppError { | |
| 8 | + NotFound, | |
| 9 | + Forbidden(String), | |
| 10 | + BadRequest(String), | |
| 11 | + /// Redirects to the sign-in page, returning to the given path afterwards. | |
| 12 | + LoginRequired(String), | |
| 13 | + Internal(anyhow::Error), | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl<E: Into<anyhow::Error>> From<E> for AppError { | |
| 17 | + fn from(e: E) -> Self { | |
| 18 | + AppError::Internal(e.into()) | |
| 19 | + } | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// Marker attached to error responses so the middleware can render them. | |
| 23 | +#[derive(Debug, Clone)] | |
| 24 | +pub struct ErrorPage { | |
| 25 | + pub status: StatusCode, | |
| 26 | + pub title: &'static str, | |
| 27 | + pub message: String, | |
| 28 | +} | |
| 29 | + | |
| 30 | +impl IntoResponse for AppError { | |
| 31 | + fn into_response(self) -> Response { | |
| 32 | + let (status, title, message) = match self { | |
| 33 | + AppError::LoginRequired(next) => { | |
| 34 | + let target = format!("/login?next={}", super::fmt::urlencode(&next)); | |
| 35 | + return Redirect::to(&target).into_response(); | |
| 36 | + } | |
| 37 | + AppError::NotFound => ( | |
| 38 | + StatusCode::NOT_FOUND, | |
| 39 | + "Page not found", | |
| 40 | + "The page you are looking for does not exist, or you do not have access to it." | |
| 41 | + .to_string(), | |
| 42 | + ), | |
| 43 | + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "Access denied", msg), | |
| 44 | + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "Bad request", msg), | |
| 45 | + AppError::Internal(err) => { | |
| 46 | + tracing::error!("internal error: {err:#}"); | |
| 47 | + ( | |
| 48 | + StatusCode::INTERNAL_SERVER_ERROR, | |
| 49 | + "Something went wrong", | |
| 50 | + "An unexpected error occurred. Please try again.".to_string(), | |
| 51 | + ) | |
| 52 | + } | |
| 53 | + }; | |
| 54 | + let mut resp = (status, format!("{title}: {message}")).into_response(); | |
| 55 | + resp.extensions_mut().insert(ErrorPage { | |
| 56 | + status, | |
| 57 | + title, | |
| 58 | + message, | |
| 59 | + }); | |
| 60 | + resp | |
| 61 | + } | |
| 62 | +} |
| @@ -0,0 +1,173 @@ | ||
| 1 | +//! Small formatting helpers called from templates. | |
| 2 | + | |
| 3 | +use chrono::{DateTime, Utc}; | |
| 4 | + | |
| 5 | +use crate::db::now; | |
| 6 | + | |
| 7 | +/// Lets helpers accept numbers by value or by (nested) reference, since | |
| 8 | +/// templates pass fields by reference but method results by value. | |
| 9 | +pub trait Num<T: Copy> { | |
| 10 | + fn value(&self) -> T; | |
| 11 | +} | |
| 12 | + | |
| 13 | +impl Num<i64> for i64 { | |
| 14 | + fn value(&self) -> i64 { | |
| 15 | + *self | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +impl Num<usize> for usize { | |
| 20 | + fn value(&self) -> usize { | |
| 21 | + *self | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +impl<T: Copy, N: Num<T> + ?Sized> Num<T> for &N { | |
| 26 | + fn value(&self) -> T { | |
| 27 | + (**self).value() | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +pub fn icon(name: &str) -> String { | |
| 32 | + crate::icons::icon(name) | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// "3 days ago" style relative time. | |
| 36 | +pub fn ago(ts: impl Num<i64>) -> String { | |
| 37 | + let delta = now() - ts.value(); | |
| 38 | + if delta < 45 { | |
| 39 | + return "just now".into(); | |
| 40 | + } | |
| 41 | + let (n, unit) = match delta { | |
| 42 | + d if d < 3600 => ((d + 30) / 60, "minute"), | |
| 43 | + d if d < 86400 => ((d + 1800) / 3600, "hour"), | |
| 44 | + d if d < 30 * 86400 => ((d + 43200) / 86400, "day"), | |
| 45 | + d if d < 365 * 86400 => (d / (30 * 86400), "month"), | |
| 46 | + d => (d / (365 * 86400), "year"), | |
| 47 | + }; | |
| 48 | + let n = n.max(1); | |
| 49 | + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) | |
| 50 | +} | |
| 51 | + | |
| 52 | +fn datetime(ts: i64) -> DateTime<Utc> { | |
| 53 | + DateTime::from_timestamp(ts, 0).unwrap_or_default() | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// "Oct 10, 2026" | |
| 57 | +pub fn date(ts: impl Num<i64>) -> String { | |
| 58 | + datetime(ts.value()).format("%b %-d, %Y").to_string() | |
| 59 | +} | |
| 60 | + | |
| 61 | +/// "Oct 10, 2026, 14:05 UTC" | |
| 62 | +pub fn datetime_full(ts: impl Num<i64>) -> String { | |
| 63 | + datetime(ts.value()) | |
| 64 | + .format("%b %-d, %Y, %H:%M UTC") | |
| 65 | + .to_string() | |
| 66 | +} | |
| 67 | + | |
| 68 | +pub fn short_sha(sha: &str) -> &str { | |
| 69 | + sha.get(..7).unwrap_or(sha) | |
| 70 | +} | |
| 71 | + | |
| 72 | +pub fn bytes(n: impl Num<usize>) -> String { | |
| 73 | + let n = n.value() as f64; | |
| 74 | + if n < 1024.0 { | |
| 75 | + format!("{n} Bytes") | |
| 76 | + } else if n < 1024.0 * 1024.0 { | |
| 77 | + format!("{:.1} KB", n / 1024.0) | |
| 78 | + } else { | |
| 79 | + format!("{:.1} MB", n / (1024.0 * 1024.0)) | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +pub fn plural(n: impl Num<usize>, word: &str) -> String { | |
| 84 | + let n = n.value(); | |
| 85 | + if n == 1 { | |
| 86 | + format!("1 {word}") | |
| 87 | + } else if word.ends_with("ch") || word.ends_with('s') || word.ends_with('x') { | |
| 88 | + format!("{n} {word}es") | |
| 89 | + } else { | |
| 90 | + format!("{n} {word}s") | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// A coloured initial badge used in place of profile pictures. | |
| 95 | +pub fn avatar(name: &str, size: u32) -> String { | |
| 96 | + let hue = name | |
| 97 | + .bytes() | |
| 98 | + .fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32)) | |
| 99 | + % 360; | |
| 100 | + let initial = name | |
| 101 | + .chars() | |
| 102 | + .find(|c| c.is_alphanumeric()) | |
| 103 | + .map(|c| c.to_uppercase().to_string()) | |
| 104 | + .unwrap_or_else(|| "?".into()); | |
| 105 | + let font = (size as f32 * 0.45).round() as u32; | |
| 106 | + format!( | |
| 107 | + r#"<span class="avatar" style="--hue:{hue};width:{size}px;height:{size}px;font-size:{font}px" aria-hidden="true">{}</span>"#, | |
| 108 | + crate::highlight::escape(&initial) | |
| 109 | + ) | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Percent-encodes a value for use in a query string. | |
| 113 | +pub fn urlencode(s: &str) -> String { | |
| 114 | + let mut out = String::with_capacity(s.len()); | |
| 115 | + for b in s.bytes() { | |
| 116 | + match b { | |
| 117 | + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 118 | + out.push(b as char) | |
| 119 | + } | |
| 120 | + _ => out.push_str(&format!("%{b:02X}")), | |
| 121 | + } | |
| 122 | + } | |
| 123 | + out | |
| 124 | +} | |
| 125 | + | |
| 126 | +/// Percent-encodes a repository path or ref for use in a URL path, keeping `/`. | |
| 127 | +pub fn urlpath(s: &str) -> String { | |
| 128 | + let mut out = String::with_capacity(s.len()); | |
| 129 | + for b in s.bytes() { | |
| 130 | + match b { | |
| 131 | + b'A'..=b'Z' | |
| 132 | + | b'a'..=b'z' | |
| 133 | + | b'0'..=b'9' | |
| 134 | + | b'-' | |
| 135 | + | b'_' | |
| 136 | + | b'.' | |
| 137 | + | b'~' | |
| 138 | + | b'/' | |
| 139 | + | b'@' | |
| 140 | + | b'+' | |
| 141 | + | b'!' | |
| 142 | + | b'$' | |
| 143 | + | b'(' | |
| 144 | + | b')' | |
| 145 | + | b',' | |
| 146 | + | b';' | |
| 147 | + | b'=' | |
| 148 | + | b':' => out.push(b as char), | |
| 149 | + _ => out.push_str(&format!("%{b:02X}")), | |
| 150 | + } | |
| 151 | + } | |
| 152 | + out | |
| 153 | +} | |
| 154 | + | |
| 155 | +#[cfg(test)] | |
| 156 | +mod tests { | |
| 157 | + use super::*; | |
| 158 | + | |
| 159 | + #[test] | |
| 160 | + fn relative_times() { | |
| 161 | + let n = now(); | |
| 162 | + assert_eq!(ago(n), "just now"); | |
| 163 | + assert_eq!(ago(n - 60), "1 minute ago"); | |
| 164 | + assert_eq!(ago(n - 2 * 3600), "2 hours ago"); | |
| 165 | + assert_eq!(ago(n - 3 * 86400), "3 days ago"); | |
| 166 | + } | |
| 167 | + | |
| 168 | + #[test] | |
| 169 | + fn url_encoding() { | |
| 170 | + assert_eq!(urlpath("src/a b#c.rs"), "src/a%20b%23c.rs"); | |
| 171 | + assert_eq!(urlencode("/a/b?c"), "%2Fa%2Fb%3Fc"); | |
| 172 | + } | |
| 173 | +} |
| @@ -0,0 +1,238 @@ | ||
| 1 | +//! Git "smart HTTP" protocol for cloning and fetching. | |
| 2 | +//! | |
| 3 | +//! Public repositories can be cloned anonymously; private ones accept HTTP | |
| 4 | +//! basic auth with the account password. Pushing is only offered over SSH, | |
| 5 | +//! where access is controlled by SSH keys. | |
| 6 | + | |
| 7 | +use std::io::Read; | |
| 8 | +use std::process::Stdio; | |
| 9 | + | |
| 10 | +use axum::body::{Body, Bytes}; | |
| 11 | +use axum::extract::{Path, Query, State}; | |
| 12 | +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; | |
| 13 | +use axum::response::{IntoResponse, Response}; | |
| 14 | +use base64::Engine; | |
| 15 | +use base64::engine::general_purpose::STANDARD; | |
| 16 | +use serde::Deserialize; | |
| 17 | +use tokio::io::AsyncWriteExt; | |
| 18 | +use tokio::process::Command; | |
| 19 | +use tokio_util::io::ReaderStream; | |
| 20 | + | |
| 21 | +use super::account::verify_password; | |
| 22 | +use crate::db::{self, Repo}; | |
| 23 | +use crate::state::AppState; | |
| 24 | + | |
| 25 | +fn plain(status: StatusCode, message: &str) -> Response { | |
| 26 | + (status, format!("{message}\n")).into_response() | |
| 27 | +} | |
| 28 | + | |
| 29 | +fn unauthorized() -> Response { | |
| 30 | + let mut resp = plain(StatusCode::UNAUTHORIZED, "Authentication required"); | |
| 31 | + resp.headers_mut().insert( | |
| 32 | + header::WWW_AUTHENTICATE, | |
| 33 | + HeaderValue::from_static("Basic realm=\"Openhub\""), | |
| 34 | + ); | |
| 35 | + resp | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Resolves the repository and checks read access, returning an HTTP error | |
| 39 | +/// response (401 or 404) when the request may not proceed. | |
| 40 | +async fn authorize( | |
| 41 | + state: &AppState, | |
| 42 | + headers: &HeaderMap, | |
| 43 | + owner: &str, | |
| 44 | + name: &str, | |
| 45 | +) -> Result<Repo, Response> { | |
| 46 | + let name = name.strip_suffix(".git").unwrap_or(name); | |
| 47 | + let not_found = || plain(StatusCode::NOT_FOUND, "Repository not found"); | |
| 48 | + let repo = match db::repo_by_name(&state.db, owner, name).await { | |
| 49 | + Ok(Some(r)) => r, | |
| 50 | + Ok(None) => return Err(not_found()), | |
| 51 | + Err(e) => { | |
| 52 | + tracing::error!("repository lookup failed: {e}"); | |
| 53 | + return Err(plain(StatusCode::INTERNAL_SERVER_ERROR, "Internal error")); | |
| 54 | + } | |
| 55 | + }; | |
| 56 | + if !repo.is_private { | |
| 57 | + return Ok(repo); | |
| 58 | + } | |
| 59 | + let Some((login, password)) = basic_auth(headers) else { | |
| 60 | + return Err(unauthorized()); | |
| 61 | + }; | |
| 62 | + let user = match db::user_by_login(&state.db, &login).await { | |
| 63 | + Ok(Some(u)) => u, | |
| 64 | + _ => return Err(unauthorized()), | |
| 65 | + }; | |
| 66 | + if !verify_password(password, user.password_hash.clone()).await { | |
| 67 | + return Err(unauthorized()); | |
| 68 | + } | |
| 69 | + match state.access(&repo, Some(&user)).await { | |
| 70 | + Ok(a) if a.read => Ok(repo), | |
| 71 | + _ => Err(not_found()), | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> { | |
| 76 | + let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; | |
| 77 | + let encoded = value.strip_prefix("Basic ")?; | |
| 78 | + let decoded = String::from_utf8(STANDARD.decode(encoded.trim()).ok()?).ok()?; | |
| 79 | + let (user, pass) = decoded.split_once(':')?; | |
| 80 | + Some((user.to_string(), pass.to_string())) | |
| 81 | +} | |
| 82 | + | |
| 83 | +fn protocol_v2(headers: &HeaderMap) -> Option<String> { | |
| 84 | + headers | |
| 85 | + .get("git-protocol") | |
| 86 | + .and_then(|v| v.to_str().ok()) | |
| 87 | + .filter(|v| { | |
| 88 | + v.chars() | |
| 89 | + .all(|c| c.is_ascii_alphanumeric() || "=:.".contains(c)) | |
| 90 | + }) | |
| 91 | + .map(str::to_string) | |
| 92 | +} | |
| 93 | + | |
| 94 | +fn pkt_line(s: &str) -> Vec<u8> { | |
| 95 | + let mut out = format!("{:04x}", s.len() + 4).into_bytes(); | |
| 96 | + out.extend_from_slice(s.as_bytes()); | |
| 97 | + out | |
| 98 | +} | |
| 99 | + | |
| 100 | +#[derive(Deserialize)] | |
| 101 | +pub struct InfoRefsQuery { | |
| 102 | + service: Option<String>, | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub async fn info_refs( | |
| 106 | + State(state): State<AppState>, | |
| 107 | + Path((owner, name)): Path<(String, String)>, | |
| 108 | + Query(q): Query<InfoRefsQuery>, | |
| 109 | + headers: HeaderMap, | |
| 110 | +) -> Response { | |
| 111 | + match q.service.as_deref() { | |
| 112 | + Some("git-upload-pack") => {} | |
| 113 | + Some("git-receive-pack") => { | |
| 114 | + return plain( | |
| 115 | + StatusCode::FORBIDDEN, | |
| 116 | + "Pushing over HTTP is not supported. Add an SSH key in your Openhub settings and push over SSH.", | |
| 117 | + ); | |
| 118 | + } | |
| 119 | + _ => { | |
| 120 | + return plain( | |
| 121 | + StatusCode::FORBIDDEN, | |
| 122 | + "Only the smart HTTP protocol is supported", | |
| 123 | + ); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + let repo = match authorize(&state, &headers, &owner, &name).await { | |
| 127 | + Ok(r) => r, | |
| 128 | + Err(resp) => return resp, | |
| 129 | + }; | |
| 130 | + let path = state.config.repo_path(repo.id); | |
| 131 | + let protocol = protocol_v2(&headers); | |
| 132 | + let mut cmd = Command::new(&state.config.git_bin); | |
| 133 | + cmd.args(["upload-pack", "--stateless-rpc", "--advertise-refs"]) | |
| 134 | + .arg(&path) | |
| 135 | + .stdin(Stdio::null()) | |
| 136 | + .stderr(Stdio::null()); | |
| 137 | + if let Some(p) = &protocol { | |
| 138 | + cmd.env("GIT_PROTOCOL", p); | |
| 139 | + } | |
| 140 | + let output = match cmd.output().await { | |
| 141 | + Ok(o) if o.status.success() => o.stdout, | |
| 142 | + Ok(o) => { | |
| 143 | + tracing::error!("git upload-pack --advertise-refs failed: {}", o.status); | |
| 144 | + return plain(StatusCode::INTERNAL_SERVER_ERROR, "git failed"); | |
| 145 | + } | |
| 146 | + Err(e) => { | |
| 147 | + tracing::error!("could not run git: {e}"); | |
| 148 | + return plain(StatusCode::INTERNAL_SERVER_ERROR, "git is not available"); | |
| 149 | + } | |
| 150 | + }; | |
| 151 | + let mut body = Vec::with_capacity(output.len() + 64); | |
| 152 | + // Protocol v2 responses start directly with the capability advertisement. | |
| 153 | + if !protocol.as_deref().is_some_and(|p| p.contains("version=2")) { | |
| 154 | + body.extend(pkt_line("# service=git-upload-pack\n")); | |
| 155 | + body.extend_from_slice(b"0000"); | |
| 156 | + } | |
| 157 | + body.extend(output); | |
| 158 | + ( | |
| 159 | + [ | |
| 160 | + ( | |
| 161 | + header::CONTENT_TYPE, | |
| 162 | + "application/x-git-upload-pack-advertisement", | |
| 163 | + ), | |
| 164 | + (header::CACHE_CONTROL, "no-cache"), | |
| 165 | + ], | |
| 166 | + body, | |
| 167 | + ) | |
| 168 | + .into_response() | |
| 169 | +} | |
| 170 | + | |
| 171 | +pub async fn upload_pack( | |
| 172 | + State(state): State<AppState>, | |
| 173 | + Path((owner, name)): Path<(String, String)>, | |
| 174 | + headers: HeaderMap, | |
| 175 | + body: Bytes, | |
| 176 | +) -> Response { | |
| 177 | + let repo = match authorize(&state, &headers, &owner, &name).await { | |
| 178 | + Ok(r) => r, | |
| 179 | + Err(resp) => return resp, | |
| 180 | + }; | |
| 181 | + let gzipped = headers | |
| 182 | + .get(header::CONTENT_ENCODING) | |
| 183 | + .and_then(|v| v.to_str().ok()) | |
| 184 | + .is_some_and(|v| v.contains("gzip")); | |
| 185 | + let input = if gzipped { | |
| 186 | + let mut out = Vec::new(); | |
| 187 | + if flate2::read::GzDecoder::new(&body[..]) | |
| 188 | + .read_to_end(&mut out) | |
| 189 | + .is_err() | |
| 190 | + { | |
| 191 | + return plain(StatusCode::BAD_REQUEST, "Invalid gzip body"); | |
| 192 | + } | |
| 193 | + out | |
| 194 | + } else { | |
| 195 | + body.to_vec() | |
| 196 | + }; | |
| 197 | + | |
| 198 | + let path = state.config.repo_path(repo.id); | |
| 199 | + let mut cmd = Command::new(&state.config.git_bin); | |
| 200 | + cmd.args(["upload-pack", "--stateless-rpc"]) | |
| 201 | + .arg(&path) | |
| 202 | + .stdin(Stdio::piped()) | |
| 203 | + .stdout(Stdio::piped()) | |
| 204 | + .stderr(Stdio::null()) | |
| 205 | + .kill_on_drop(true); | |
| 206 | + if let Some(p) = protocol_v2(&headers) { | |
| 207 | + cmd.env("GIT_PROTOCOL", p); | |
| 208 | + } | |
| 209 | + let mut child = match cmd.spawn() { | |
| 210 | + Ok(c) => c, | |
| 211 | + Err(e) => { | |
| 212 | + tracing::error!("could not run git: {e}"); | |
| 213 | + return plain(StatusCode::INTERNAL_SERVER_ERROR, "git is not available"); | |
| 214 | + } | |
| 215 | + }; | |
| 216 | + let mut stdin = child.stdin.take().expect("piped stdin"); | |
| 217 | + let stdout = child.stdout.take().expect("piped stdout"); | |
| 218 | + tokio::spawn(async move { | |
| 219 | + let _ = stdin.write_all(&input).await; | |
| 220 | + drop(stdin); | |
| 221 | + let _ = child.wait().await; | |
| 222 | + }); | |
| 223 | + ( | |
| 224 | + [ | |
| 225 | + (header::CONTENT_TYPE, "application/x-git-upload-pack-result"), | |
| 226 | + (header::CACHE_CONTROL, "no-cache"), | |
| 227 | + ], | |
| 228 | + Body::from_stream(ReaderStream::new(stdout)), | |
| 229 | + ) | |
| 230 | + .into_response() | |
| 231 | +} | |
| 232 | + | |
| 233 | +pub async fn receive_pack() -> Response { | |
| 234 | + plain( | |
| 235 | + StatusCode::FORBIDDEN, | |
| 236 | + "Pushing over HTTP is not supported. Push over SSH instead.", | |
| 237 | + ) | |
| 238 | +} |
| @@ -0,0 +1,94 @@ | ||
| 1 | +//! Dashboard, explore and user profile pages. | |
| 2 | + | |
| 3 | +use askama::Template; | |
| 4 | +use axum::extract::{Path, State}; | |
| 5 | + | |
| 6 | +use super::{AppError, Ctx, WebResult, render}; | |
| 7 | +use crate::db::{self, PullRequest, Repo, User}; | |
| 8 | +use crate::state::AppState; | |
| 9 | + | |
| 10 | +#[derive(Template)] | |
| 11 | +#[template(path = "dashboard.html")] | |
| 12 | +struct DashboardPage { | |
| 13 | + ctx: Ctx, | |
| 14 | + repos: Vec<Repo>, | |
| 15 | + pulls: Vec<(PullRequest, String, String)>, | |
| 16 | + recent: Vec<Repo>, | |
| 17 | +} | |
| 18 | + | |
| 19 | +#[derive(Template)] | |
| 20 | +#[template(path = "landing.html")] | |
| 21 | +struct LandingPage { | |
| 22 | + ctx: Ctx, | |
| 23 | + repos: Vec<Repo>, | |
| 24 | + can_register: bool, | |
| 25 | +} | |
| 26 | + | |
| 27 | +pub async fn index(State(state): State<AppState>, ctx: Ctx) -> WebResult { | |
| 28 | + match ctx.user.clone() { | |
| 29 | + Some(user) => { | |
| 30 | + let repos = db::list_member_repos(&state.db, user.id).await?; | |
| 31 | + let pulls = db::dashboard_pulls(&state.db, user.id).await?; | |
| 32 | + let recent = db::list_public_repos(&state.db, 10) | |
| 33 | + .await? | |
| 34 | + .into_iter() | |
| 35 | + .filter(|r| r.owner_id != user.id) | |
| 36 | + .collect(); | |
| 37 | + render(&DashboardPage { | |
| 38 | + ctx, | |
| 39 | + repos, | |
| 40 | + pulls, | |
| 41 | + recent, | |
| 42 | + }) | |
| 43 | + } | |
| 44 | + None => { | |
| 45 | + let repos = db::list_public_repos(&state.db, 12).await?; | |
| 46 | + let can_register = | |
| 47 | + !state.config.disable_registration || db::count_users(&state.db).await? == 0; | |
| 48 | + render(&LandingPage { | |
| 49 | + ctx, | |
| 50 | + repos, | |
| 51 | + can_register, | |
| 52 | + }) | |
| 53 | + } | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +#[derive(Template)] | |
| 58 | +#[template(path = "explore.html")] | |
| 59 | +struct ExplorePage { | |
| 60 | + ctx: Ctx, | |
| 61 | + repos: Vec<Repo>, | |
| 62 | +} | |
| 63 | + | |
| 64 | +pub async fn explore(State(state): State<AppState>, ctx: Ctx) -> WebResult { | |
| 65 | + let repos = db::list_public_repos(&state.db, 200).await?; | |
| 66 | + render(&ExplorePage { ctx, repos }) | |
| 67 | +} | |
| 68 | + | |
| 69 | +#[derive(Template)] | |
| 70 | +#[template(path = "profile.html")] | |
| 71 | +struct ProfilePage { | |
| 72 | + ctx: Ctx, | |
| 73 | + profile: User, | |
| 74 | + repos: Vec<Repo>, | |
| 75 | + is_self: bool, | |
| 76 | +} | |
| 77 | + | |
| 78 | +pub async fn profile( | |
| 79 | + State(state): State<AppState>, | |
| 80 | + ctx: Ctx, | |
| 81 | + Path(owner): Path<String>, | |
| 82 | +) -> WebResult { | |
| 83 | + let profile = db::user_by_username(&state.db, &owner) | |
| 84 | + .await? | |
| 85 | + .ok_or(AppError::NotFound)?; | |
| 86 | + let repos = db::list_owner_repos(&state.db, profile.id, ctx.user.as_deref()).await?; | |
| 87 | + let is_self = ctx.user.as_ref().is_some_and(|u| u.id == profile.id); | |
| 88 | + render(&ProfilePage { | |
| 89 | + ctx, | |
| 90 | + profile, | |
| 91 | + repos, | |
| 92 | + is_self, | |
| 93 | + }) | |
| 94 | +} |
| @@ -0,0 +1,165 @@ | ||
| 1 | +mod account; | |
| 2 | +pub mod assets; | |
| 3 | +mod code; | |
| 4 | +pub mod ctx; | |
| 5 | +pub mod error; | |
| 6 | +pub mod fmt; | |
| 7 | +mod git_http; | |
| 8 | +mod home; | |
| 9 | +mod pulls; | |
| 10 | +mod repo; | |
| 11 | + | |
| 12 | +use askama::Template; | |
| 13 | +use axum::Router; | |
| 14 | +use axum::extract::DefaultBodyLimit; | |
| 15 | +use axum::http::{HeaderValue, StatusCode, header}; | |
| 16 | +use axum::response::{Html, IntoResponse, Response}; | |
| 17 | +use axum::routing::{get, post}; | |
| 18 | +use axum_extra::extract::cookie::Cookie; | |
| 19 | +use tower_http::trace::TraceLayer; | |
| 20 | + | |
| 21 | +use crate::state::AppState; | |
| 22 | +pub use ctx::Ctx; | |
| 23 | +pub use error::AppError; | |
| 24 | + | |
| 25 | +pub type WebResult = Result<Response, AppError>; | |
| 26 | + | |
| 27 | +/// Fetch negotiation requests list every commit the client already has. | |
| 28 | +const GIT_BODY_LIMIT: usize = 64 * 1024 * 1024; | |
| 29 | + | |
| 30 | +pub fn router(state: AppState) -> Router { | |
| 31 | + Router::new() | |
| 32 | + .route("/", get(home::index)) | |
| 33 | + .route("/explore", get(home::explore)) | |
| 34 | + .route("/login", get(account::login_form).post(account::login)) | |
| 35 | + .route( | |
| 36 | + "/register", | |
| 37 | + get(account::register_form).post(account::register), | |
| 38 | + ) | |
| 39 | + .route("/logout", post(account::logout)) | |
| 40 | + .route("/new", get(repo::new_form).post(repo::create)) | |
| 41 | + .route("/settings", get(account::settings_redirect)) | |
| 42 | + .route( | |
| 43 | + "/settings/profile", | |
| 44 | + get(account::profile_form).post(account::update_profile), | |
| 45 | + ) | |
| 46 | + .route("/settings/password", post(account::change_password)) | |
| 47 | + .route("/settings/keys", get(account::keys).post(account::add_key)) | |
| 48 | + .route("/settings/keys/{id}/delete", post(account::delete_key)) | |
| 49 | + .route("/static/style.css", get(assets::stylesheet)) | |
| 50 | + .route("/static/app.js", get(assets::script)) | |
| 51 | + .route("/favicon.svg", get(assets::favicon)) | |
| 52 | + .route("/{owner}", get(home::profile)) | |
| 53 | + .route("/{owner}/{repo}", get(code::repo_home)) | |
| 54 | + .route("/{owner}/{repo}/tree/{*rest}", get(code::tree)) | |
| 55 | + .route("/{owner}/{repo}/blob/{*rest}", get(code::blob)) | |
| 56 | + .route("/{owner}/{repo}/raw/{*rest}", get(code::raw)) | |
| 57 | + .route("/{owner}/{repo}/archive/{*file}", get(code::archive)) | |
| 58 | + .route("/{owner}/{repo}/commits", get(code::commits_default)) | |
| 59 | + .route("/{owner}/{repo}/commits/{*rest}", get(code::commits)) | |
| 60 | + .route("/{owner}/{repo}/commit/{sha}", get(code::commit)) | |
| 61 | + .route("/{owner}/{repo}/branches", get(repo::branches)) | |
| 62 | + .route("/{owner}/{repo}/branches/delete", post(repo::delete_branch)) | |
| 63 | + .route("/{owner}/{repo}/tags", get(repo::tags)) | |
| 64 | + .route("/{owner}/{repo}/compare", get(pulls::compare_default)) | |
| 65 | + .route("/{owner}/{repo}/compare/{*spec}", get(pulls::compare)) | |
| 66 | + .route( | |
| 67 | + "/{owner}/{repo}/pulls", | |
| 68 | + get(pulls::list).post(pulls::create), | |
| 69 | + ) | |
| 70 | + .route("/{owner}/{repo}/pull/{number}", get(pulls::conversation)) | |
| 71 | + .route("/{owner}/{repo}/pull/{number}/commits", get(pulls::commits)) | |
| 72 | + .route("/{owner}/{repo}/pull/{number}/files", get(pulls::files)) | |
| 73 | + .route( | |
| 74 | + "/{owner}/{repo}/pull/{number}/comment", | |
| 75 | + post(pulls::comment), | |
| 76 | + ) | |
| 77 | + .route("/{owner}/{repo}/pull/{number}/merge", post(pulls::merge)) | |
| 78 | + .route( | |
| 79 | + "/{owner}/{repo}/pull/{number}/state", | |
| 80 | + post(pulls::set_state), | |
| 81 | + ) | |
| 82 | + .route("/{owner}/{repo}/pull/{number}/edit", post(pulls::edit)) | |
| 83 | + .route( | |
| 84 | + "/{owner}/{repo}/settings", | |
| 85 | + get(repo::settings).post(repo::update_settings), | |
| 86 | + ) | |
| 87 | + .route( | |
| 88 | + "/{owner}/{repo}/settings/collaborators", | |
| 89 | + post(repo::add_collaborator), | |
| 90 | + ) | |
| 91 | + .route( | |
| 92 | + "/{owner}/{repo}/settings/collaborators/remove", | |
| 93 | + post(repo::remove_collaborator), | |
| 94 | + ) | |
| 95 | + .route("/{owner}/{repo}/settings/delete", post(repo::delete)) | |
| 96 | + .route("/{owner}/{repo}/info/refs", get(git_http::info_refs)) | |
| 97 | + .route( | |
| 98 | + "/{owner}/{repo}/git-upload-pack", | |
| 99 | + post(git_http::upload_pack).layer(DefaultBodyLimit::max(GIT_BODY_LIMIT)), | |
| 100 | + ) | |
| 101 | + .route( | |
| 102 | + "/{owner}/{repo}/git-receive-pack", | |
| 103 | + post(git_http::receive_pack), | |
| 104 | + ) | |
| 105 | + .fallback(|| async { AppError::NotFound }) | |
| 106 | + .layer(axum::middleware::from_fn_with_state( | |
| 107 | + state.clone(), | |
| 108 | + ctx::middleware, | |
| 109 | + )) | |
| 110 | + .layer(TraceLayer::new_for_http()) | |
| 111 | + .with_state(state) | |
| 112 | +} | |
| 113 | + | |
| 114 | +pub async fn serve(state: AppState) -> anyhow::Result<()> { | |
| 115 | + let addr = state.config.http_addr; | |
| 116 | + let listener = tokio::net::TcpListener::bind(addr).await?; | |
| 117 | + tracing::info!("web interface listening on http://{addr}"); | |
| 118 | + axum::serve(listener, router(state)).await?; | |
| 119 | + Ok(()) | |
| 120 | +} | |
| 121 | + | |
| 122 | +pub fn render<T: Template>(template: &T) -> WebResult { | |
| 123 | + Ok(Html(template.render()?).into_response()) | |
| 124 | +} | |
| 125 | + | |
| 126 | +pub fn redirect(to: &str) -> Response { | |
| 127 | + let mut resp = StatusCode::SEE_OTHER.into_response(); | |
| 128 | + if let Ok(v) = HeaderValue::from_str(to) { | |
| 129 | + resp.headers_mut().insert(header::LOCATION, v); | |
| 130 | + } | |
| 131 | + resp | |
| 132 | +} | |
| 133 | + | |
| 134 | +pub fn with_cookie(mut resp: Response, cookie: Cookie<'_>) -> Response { | |
| 135 | + if let Ok(v) = HeaderValue::from_str(&cookie.to_string()) { | |
| 136 | + resp.headers_mut().append(header::SET_COOKIE, v); | |
| 137 | + } | |
| 138 | + resp | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// Redirect that shows a one-off message on the next page. | |
| 142 | +pub fn redirect_flash(to: &str, kind: &str, message: &str) -> Response { | |
| 143 | + with_cookie(redirect(to), ctx::flash_cookie(kind, message)) | |
| 144 | +} | |
| 145 | + | |
| 146 | +/// Runs blocking repository work off the async runtime. | |
| 147 | +pub async fn blocking<T, F>(f: F) -> Result<T, AppError> | |
| 148 | +where | |
| 149 | + T: Send + 'static, | |
| 150 | + F: FnOnce() -> Result<T, AppError> + Send + 'static, | |
| 151 | +{ | |
| 152 | + tokio::task::spawn_blocking(f) | |
| 153 | + .await | |
| 154 | + .map_err(|e| AppError::Internal(e.into()))? | |
| 155 | +} | |
| 156 | + | |
| 157 | +/// Returns `next` if it is a safe same-site path, otherwise `/`. | |
| 158 | +pub fn safe_next(next: Option<&str>) -> String { | |
| 159 | + match next { | |
| 160 | + Some(n) if n.starts_with('/') && !n.starts_with("//") && !n.starts_with("/\\") => { | |
| 161 | + n.to_string() | |
| 162 | + } | |
| 163 | + _ => "/".to_string(), | |
| 164 | + } | |
| 165 | +} |
| @@ -0,0 +1,703 @@ | ||
| 1 | +//! Pull requests: compare, open, discuss, review changes, merge and close. | |
| 2 | + | |
| 3 | +use askama::Template; | |
| 4 | +use axum::Form; | |
| 5 | +use axum::extract::{Path, Query, State}; | |
| 6 | +use git2::{Oid, Repository}; | |
| 7 | +use serde::Deserialize; | |
| 8 | + | |
| 9 | +use super::fmt::urlpath; | |
| 10 | +use super::repo::{RepoCtx, load_repo}; | |
| 11 | +use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render}; | |
| 12 | +use crate::db::{self, PrEvent, PullRequest}; | |
| 13 | +use crate::git::{self, CommitInfo, DiffSummary, MergeMethod, MergeRequest, Mergeability}; | |
| 14 | +use crate::markdown; | |
| 15 | +use crate::state::AppState; | |
| 16 | +use crate::sync; | |
| 17 | + | |
| 18 | +// --------------------------------------------------------------------------- | |
| 19 | +// Compare | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +pub struct CompareView { | |
| 23 | + pub base: String, | |
| 24 | + pub head: String, | |
| 25 | + pub branches: Vec<String>, | |
| 26 | + pub commits: Vec<CommitInfo>, | |
| 27 | + pub diff: Option<DiffSummary>, | |
| 28 | + pub mergeability: Option<Mergeability>, | |
| 29 | + pub existing: Option<i64>, | |
| 30 | + pub title: String, | |
| 31 | + pub notice: Option<String>, | |
| 32 | +} | |
| 33 | + | |
| 34 | +impl CompareView { | |
| 35 | + pub fn can_open(&self) -> bool { | |
| 36 | + self.existing.is_none() && !self.commits.is_empty() | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +#[derive(Template)] | |
| 41 | +#[template(path = "repo/compare.html")] | |
| 42 | +struct ComparePage { | |
| 43 | + ctx: Ctx, | |
| 44 | + r: RepoCtx, | |
| 45 | + tab: &'static str, | |
| 46 | + view: CompareView, | |
| 47 | +} | |
| 48 | + | |
| 49 | +#[derive(Deserialize)] | |
| 50 | +pub struct CompareQuery { | |
| 51 | + base: Option<String>, | |
| 52 | + head: Option<String>, | |
| 53 | +} | |
| 54 | + | |
| 55 | +pub async fn compare_default( | |
| 56 | + State(state): State<AppState>, | |
| 57 | + ctx: Ctx, | |
| 58 | + Path((owner, name)): Path<(String, String)>, | |
| 59 | + Query(q): Query<CompareQuery>, | |
| 60 | +) -> WebResult { | |
| 61 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 62 | + let base = q.base.unwrap_or_else(|| r.repo.default_branch.clone()); | |
| 63 | + match q.head.filter(|h| !h.is_empty()) { | |
| 64 | + Some(head) => Ok(redirect(&format!( | |
| 65 | + "{}/compare/{}...{}", | |
| 66 | + r.base(), | |
| 67 | + urlpath(&base), | |
| 68 | + urlpath(&head) | |
| 69 | + ))), | |
| 70 | + None => compare_page(&state, ctx, r, base, String::new()).await, | |
| 71 | + } | |
| 72 | +} | |
| 73 | + | |
| 74 | +pub async fn compare( | |
| 75 | + State(state): State<AppState>, | |
| 76 | + ctx: Ctx, | |
| 77 | + Path((owner, name, spec)): Path<(String, String, String)>, | |
| 78 | +) -> WebResult { | |
| 79 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 80 | + let (base, head) = match spec.split_once("...") { | |
| 81 | + Some((b, h)) => (b.to_string(), h.to_string()), | |
| 82 | + None => (r.repo.default_branch.clone(), spec.clone()), | |
| 83 | + }; | |
| 84 | + compare_page(&state, ctx, r, base, head).await | |
| 85 | +} | |
| 86 | + | |
| 87 | +async fn compare_page( | |
| 88 | + state: &AppState, | |
| 89 | + ctx: Ctx, | |
| 90 | + r: RepoCtx, | |
| 91 | + base: String, | |
| 92 | + head: String, | |
| 93 | +) -> WebResult { | |
| 94 | + let existing = if head.is_empty() { | |
| 95 | + None | |
| 96 | + } else { | |
| 97 | + db::find_open_pull(&state.db, r.repo.id, &base, &head).await? | |
| 98 | + }; | |
| 99 | + let path = r.path.clone(); | |
| 100 | + let view = blocking(move || { | |
| 101 | + let repo = git::open(&path)?; | |
| 102 | + let branches = git::branch_names(&repo)?; | |
| 103 | + let mut view = CompareView { | |
| 104 | + base: base.clone(), | |
| 105 | + head: head.clone(), | |
| 106 | + branches, | |
| 107 | + commits: Vec::new(), | |
| 108 | + diff: None, | |
| 109 | + mergeability: None, | |
| 110 | + existing, | |
| 111 | + title: String::new(), | |
| 112 | + notice: None, | |
| 113 | + }; | |
| 114 | + if head.is_empty() { | |
| 115 | + view.notice = Some("Choose a branch to compare against the base.".into()); | |
| 116 | + return Ok(view); | |
| 117 | + } | |
| 118 | + let (Some(base_tip), Some(head_tip)) = | |
| 119 | + (git::branch_tip(&repo, &base), git::branch_tip(&repo, &head)) | |
| 120 | + else { | |
| 121 | + view.notice = Some("One of the selected branches does not exist.".into()); | |
| 122 | + return Ok(view); | |
| 123 | + }; | |
| 124 | + if base == head { | |
| 125 | + view.notice = Some("Choose two different branches to see what changed.".into()); | |
| 126 | + return Ok(view); | |
| 127 | + } | |
| 128 | + let Some(merge_base) = git::merge_base(&repo, base_tip, head_tip) else { | |
| 129 | + view.notice = Some(format!( | |
| 130 | + "{base} and {head} have entirely different histories." | |
| 131 | + )); | |
| 132 | + return Ok(view); | |
| 133 | + }; | |
| 134 | + view.commits = git::commits_between(&repo, base_tip, head_tip)?; | |
| 135 | + if view.commits.is_empty() { | |
| 136 | + view.notice = Some(format!( | |
| 137 | + "{base} is up to date with all commits from {head}." | |
| 138 | + )); | |
| 139 | + return Ok(view); | |
| 140 | + } | |
| 141 | + view.title = match view.commits.as_slice() { | |
| 142 | + [only] => only.summary.clone(), | |
| 143 | + _ => humanize_branch(&head), | |
| 144 | + }; | |
| 145 | + view.mergeability = Some(git::mergeability(&repo, base_tip, head_tip)?); | |
| 146 | + view.diff = Some(git::range_diff(&repo, merge_base, head_tip)?); | |
| 147 | + Ok(view) | |
| 148 | + }) | |
| 149 | + .await?; | |
| 150 | + render(&ComparePage { | |
| 151 | + ctx, | |
| 152 | + r, | |
| 153 | + tab: "pulls", | |
| 154 | + view, | |
| 155 | + }) | |
| 156 | +} | |
| 157 | + | |
| 158 | +/// `feature/add-login` → `Feature/add login` | |
| 159 | +fn humanize_branch(branch: &str) -> String { | |
| 160 | + let s = branch.replace(['-', '_'], " "); | |
| 161 | + let mut chars = s.chars(); | |
| 162 | + match chars.next() { | |
| 163 | + Some(first) => first.to_uppercase().chain(chars).collect(), | |
| 164 | + None => s, | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[derive(Deserialize)] | |
| 169 | +pub struct CreateForm { | |
| 170 | + base: String, | |
| 171 | + head: String, | |
| 172 | + title: String, | |
| 173 | + #[serde(default)] | |
| 174 | + body: String, | |
| 175 | +} | |
| 176 | + | |
| 177 | +pub async fn create( | |
| 178 | + State(state): State<AppState>, | |
| 179 | + ctx: Ctx, | |
| 180 | + Path((owner, name)): Path<(String, String)>, | |
| 181 | + Form(f): Form<CreateForm>, | |
| 182 | +) -> WebResult { | |
| 183 | + let user = ctx.require_user()?.clone(); | |
| 184 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 185 | + let title = f.title.trim().chars().take(256).collect::<String>(); | |
| 186 | + if title.is_empty() { | |
| 187 | + return Err(AppError::BadRequest("A pull request needs a title.".into())); | |
| 188 | + } | |
| 189 | + if let Some(number) = db::find_open_pull(&state.db, r.repo.id, &f.base, &f.head).await? { | |
| 190 | + return Ok(redirect(&format!("{}/pull/{number}", r.base()))); | |
| 191 | + } | |
| 192 | + let path = r.path.clone(); | |
| 193 | + let (base, head) = (f.base.clone(), f.head.clone()); | |
| 194 | + let (head_tip, merge_base) = blocking(move || { | |
| 195 | + let repo = git::open(&path)?; | |
| 196 | + let (Some(base_tip), Some(head_tip)) = | |
| 197 | + (git::branch_tip(&repo, &base), git::branch_tip(&repo, &head)) | |
| 198 | + else { | |
| 199 | + return Err(AppError::BadRequest("Both branches must exist.".into())); | |
| 200 | + }; | |
| 201 | + if base == head || git::is_ancestor(&repo, head_tip, base_tip) { | |
| 202 | + return Err(AppError::BadRequest( | |
| 203 | + "There is nothing to merge between these branches.".into(), | |
| 204 | + )); | |
| 205 | + } | |
| 206 | + let merge_base = git::merge_base(&repo, base_tip, head_tip).ok_or_else(|| { | |
| 207 | + AppError::BadRequest("These branches have entirely different histories.".into()) | |
| 208 | + })?; | |
| 209 | + Ok((head_tip, merge_base)) | |
| 210 | + }) | |
| 211 | + .await?; | |
| 212 | + let number = db::create_pull( | |
| 213 | + &state.db, | |
| 214 | + r.repo.id, | |
| 215 | + user.id, | |
| 216 | + &title, | |
| 217 | + f.body.trim(), | |
| 218 | + &f.base, | |
| 219 | + &f.head, | |
| 220 | + &head_tip.to_string(), | |
| 221 | + &merge_base.to_string(), | |
| 222 | + ) | |
| 223 | + .await?; | |
| 224 | + let path = r.path.clone(); | |
| 225 | + blocking(move || Ok(git::set_pull_ref(&git::open(&path)?, number, head_tip)?)).await?; | |
| 226 | + Ok(redirect(&format!("{}/pull/{number}", r.base()))) | |
| 227 | +} | |
| 228 | + | |
| 229 | +// --------------------------------------------------------------------------- | |
| 230 | +// List | |
| 231 | +// --------------------------------------------------------------------------- | |
| 232 | + | |
| 233 | +#[derive(Template)] | |
| 234 | +#[template(path = "repo/pulls.html")] | |
| 235 | +struct PullsPage { | |
| 236 | + ctx: Ctx, | |
| 237 | + r: RepoCtx, | |
| 238 | + tab: &'static str, | |
| 239 | + pulls: Vec<PullRequest>, | |
| 240 | + showing_open: bool, | |
| 241 | + open_count: i64, | |
| 242 | + closed_count: i64, | |
| 243 | +} | |
| 244 | + | |
| 245 | +#[derive(Deserialize)] | |
| 246 | +pub struct ListQuery { | |
| 247 | + state: Option<String>, | |
| 248 | +} | |
| 249 | + | |
| 250 | +pub async fn list( | |
| 251 | + State(state): State<AppState>, | |
| 252 | + ctx: Ctx, | |
| 253 | + Path((owner, name)): Path<(String, String)>, | |
| 254 | + Query(q): Query<ListQuery>, | |
| 255 | +) -> WebResult { | |
| 256 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 257 | + let showing_open = q.state.as_deref() != Some("closed"); | |
| 258 | + let pulls = db::list_pulls(&state.db, r.repo.id, showing_open).await?; | |
| 259 | + let (open_count, closed_count) = db::count_pulls(&state.db, r.repo.id).await?; | |
| 260 | + render(&PullsPage { | |
| 261 | + ctx, | |
| 262 | + r, | |
| 263 | + tab: "pulls", | |
| 264 | + pulls, | |
| 265 | + showing_open, | |
| 266 | + open_count, | |
| 267 | + closed_count, | |
| 268 | + }) | |
| 269 | +} | |
| 270 | + | |
| 271 | +// --------------------------------------------------------------------------- | |
| 272 | +// Pull request pages | |
| 273 | +// --------------------------------------------------------------------------- | |
| 274 | + | |
| 275 | +/// Everything derived from git about a pull request. | |
| 276 | +pub struct PullData { | |
| 277 | + pub head_exists: bool, | |
| 278 | + pub base_exists: bool, | |
| 279 | + pub commits: Vec<CommitInfo>, | |
| 280 | + pub mergeability: Option<Mergeability>, | |
| 281 | + pub files: usize, | |
| 282 | + pub additions: usize, | |
| 283 | + pub deletions: usize, | |
| 284 | + pub diff: Option<DiffSummary>, | |
| 285 | +} | |
| 286 | + | |
| 287 | +fn parse_oid(s: Option<&str>) -> Option<Oid> { | |
| 288 | + s.and_then(|s| Oid::from_str(s).ok()) | |
| 289 | +} | |
| 290 | + | |
| 291 | +/// The commit range a pull request covers: (merge base, head). | |
| 292 | +fn pull_range(repo: &Repository, pr: &PullRequest) -> (Option<Oid>, Option<Oid>) { | |
| 293 | + let stored_head = parse_oid(pr.head_sha.as_deref()).or_else(|| git::pull_ref(repo, pr.number)); | |
| 294 | + if !pr.is_open() { | |
| 295 | + return (parse_oid(pr.base_sha.as_deref()), stored_head); | |
| 296 | + } | |
| 297 | + let head = git::branch_tip(repo, &pr.head_branch).or(stored_head); | |
| 298 | + let base = git::branch_tip(repo, &pr.base_branch); | |
| 299 | + match (base, head) { | |
| 300 | + (Some(b), Some(h)) => (git::merge_base(repo, b, h), Some(h)), | |
| 301 | + _ => (parse_oid(pr.base_sha.as_deref()), head), | |
| 302 | + } | |
| 303 | +} | |
| 304 | + | |
| 305 | +fn pull_data(repo: &Repository, pr: &PullRequest, with_diff: bool) -> Result<PullData, AppError> { | |
| 306 | + let head_tip = git::branch_tip(repo, &pr.head_branch); | |
| 307 | + let base_tip = git::branch_tip(repo, &pr.base_branch); | |
| 308 | + let (from, to) = pull_range(repo, pr); | |
| 309 | + let mut data = PullData { | |
| 310 | + head_exists: head_tip.is_some(), | |
| 311 | + base_exists: base_tip.is_some(), | |
| 312 | + commits: Vec::new(), | |
| 313 | + mergeability: None, | |
| 314 | + files: 0, | |
| 315 | + additions: 0, | |
| 316 | + deletions: 0, | |
| 317 | + diff: None, | |
| 318 | + }; | |
| 319 | + if let (Some(from), Some(to)) = (from, to) { | |
| 320 | + data.commits = git::commits_between(repo, from, to)?; | |
| 321 | + (data.files, data.additions, data.deletions) = git::diff_stats(repo, from, to)?; | |
| 322 | + if with_diff { | |
| 323 | + data.diff = Some(git::range_diff(repo, from, to)?); | |
| 324 | + } | |
| 325 | + } | |
| 326 | + if let (true, Some(base), Some(head)) = (pr.is_open(), base_tip, head_tip) { | |
| 327 | + data.mergeability = Some(git::mergeability(repo, base, head)?); | |
| 328 | + } | |
| 329 | + Ok(data) | |
| 330 | +} | |
| 331 | + | |
| 332 | +async fn load_pull( | |
| 333 | + state: &AppState, | |
| 334 | + r: &RepoCtx, | |
| 335 | + number: i64, | |
| 336 | + with_diff: bool, | |
| 337 | +) -> Result<(PullRequest, PullData), AppError> { | |
| 338 | + let mut pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 339 | + .await? | |
| 340 | + .ok_or(AppError::NotFound)?; | |
| 341 | + if pr.is_open() { | |
| 342 | + // Pick up pushes and merges that happened since the last sync. | |
| 343 | + sync::refresh_pulls(state, r.repo.id, None).await?; | |
| 344 | + pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 345 | + .await? | |
| 346 | + .ok_or(AppError::NotFound)?; | |
| 347 | + } | |
| 348 | + let path = r.path.clone(); | |
| 349 | + let pr_clone = pr.clone(); | |
| 350 | + let data = blocking(move || pull_data(&git::open(&path)?, &pr_clone, with_diff)).await?; | |
| 351 | + Ok((pr, data)) | |
| 352 | +} | |
| 353 | + | |
| 354 | +pub struct TimelineItem { | |
| 355 | + pub event: PrEvent, | |
| 356 | + pub html: String, | |
| 357 | +} | |
| 358 | + | |
| 359 | +#[derive(Template)] | |
| 360 | +#[template(path = "repo/pull.html")] | |
| 361 | +struct PullPage { | |
| 362 | + ctx: Ctx, | |
| 363 | + r: RepoCtx, | |
| 364 | + tab: &'static str, | |
| 365 | + pr: PullRequest, | |
| 366 | + data: PullData, | |
| 367 | + pull_tab: &'static str, | |
| 368 | + body_html: String, | |
| 369 | + timeline: Vec<TimelineItem>, | |
| 370 | + can_close: bool, | |
| 371 | + can_delete_branch: bool, | |
| 372 | +} | |
| 373 | + | |
| 374 | +async fn pull_page( | |
| 375 | + state: &AppState, | |
| 376 | + ctx: Ctx, | |
| 377 | + owner: &str, | |
| 378 | + name: &str, | |
| 379 | + number: i64, | |
| 380 | + pull_tab: &'static str, | |
| 381 | +) -> WebResult { | |
| 382 | + let r = load_repo(state, &ctx, owner, name).await?; | |
| 383 | + let (pr, data) = load_pull(state, &r, number, pull_tab == "files").await?; | |
| 384 | + let user = ctx.user.as_deref(); | |
| 385 | + let can_close = user.is_some_and(|u| u.id == pr.author_id) || r.access.write; | |
| 386 | + let can_delete_branch = r.access.write | |
| 387 | + && !pr.is_open() | |
| 388 | + && data.head_exists | |
| 389 | + && pr.head_branch != r.repo.default_branch | |
| 390 | + && db::find_open_pull_by_head(&state.db, r.repo.id, &pr.head_branch) | |
| 391 | + .await? | |
| 392 | + .is_none(); | |
| 393 | + let timeline = if pull_tab == "conversation" { | |
| 394 | + db::list_events(&state.db, pr.id) | |
| 395 | + .await? | |
| 396 | + .into_iter() | |
| 397 | + .map(|event| TimelineItem { | |
| 398 | + html: if event.kind == "comment" { | |
| 399 | + markdown::render(&event.body, None) | |
| 400 | + } else { | |
| 401 | + String::new() | |
| 402 | + }, | |
| 403 | + event, | |
| 404 | + }) | |
| 405 | + .collect() | |
| 406 | + } else { | |
| 407 | + Vec::new() | |
| 408 | + }; | |
| 409 | + let body_html = markdown::render(&pr.body, None); | |
| 410 | + render(&PullPage { | |
| 411 | + ctx, | |
| 412 | + r, | |
| 413 | + tab: "pulls", | |
| 414 | + pr, | |
| 415 | + data, | |
| 416 | + pull_tab, | |
| 417 | + body_html, | |
| 418 | + timeline, | |
| 419 | + can_close, | |
| 420 | + can_delete_branch, | |
| 421 | + }) | |
| 422 | +} | |
| 423 | + | |
| 424 | +pub async fn conversation( | |
| 425 | + State(state): State<AppState>, | |
| 426 | + ctx: Ctx, | |
| 427 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 428 | +) -> WebResult { | |
| 429 | + pull_page(&state, ctx, &owner, &name, number, "conversation").await | |
| 430 | +} | |
| 431 | + | |
| 432 | +pub async fn commits( | |
| 433 | + State(state): State<AppState>, | |
| 434 | + ctx: Ctx, | |
| 435 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 436 | +) -> WebResult { | |
| 437 | + pull_page(&state, ctx, &owner, &name, number, "commits").await | |
| 438 | +} | |
| 439 | + | |
| 440 | +pub async fn files( | |
| 441 | + State(state): State<AppState>, | |
| 442 | + ctx: Ctx, | |
| 443 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 444 | +) -> WebResult { | |
| 445 | + pull_page(&state, ctx, &owner, &name, number, "files").await | |
| 446 | +} | |
| 447 | + | |
| 448 | +// --------------------------------------------------------------------------- | |
| 449 | +// Actions | |
| 450 | +// --------------------------------------------------------------------------- | |
| 451 | + | |
| 452 | +#[derive(Deserialize)] | |
| 453 | +pub struct CommentForm { | |
| 454 | + #[serde(default)] | |
| 455 | + body: String, | |
| 456 | + close: Option<String>, | |
| 457 | +} | |
| 458 | + | |
| 459 | +pub async fn comment( | |
| 460 | + State(state): State<AppState>, | |
| 461 | + ctx: Ctx, | |
| 462 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 463 | + Form(f): Form<CommentForm>, | |
| 464 | +) -> WebResult { | |
| 465 | + let user = ctx.require_user()?.clone(); | |
| 466 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 467 | + let pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 468 | + .await? | |
| 469 | + .ok_or(AppError::NotFound)?; | |
| 470 | + let body = f.body.trim(); | |
| 471 | + if !body.is_empty() { | |
| 472 | + db::add_event(&state.db, pr.id, Some(user.id), "comment", body).await?; | |
| 473 | + } | |
| 474 | + let url = format!("{}/pull/{number}", r.base()); | |
| 475 | + if f.close.is_some() && pr.is_open() { | |
| 476 | + return close_pull(&state, &r, &pr, user.id, &url).await; | |
| 477 | + } | |
| 478 | + if body.is_empty() { | |
| 479 | + return Ok(redirect_flash(&url, "error", "Comment cannot be empty.")); | |
| 480 | + } | |
| 481 | + Ok(redirect(&url)) | |
| 482 | +} | |
| 483 | + | |
| 484 | +async fn close_pull( | |
| 485 | + state: &AppState, | |
| 486 | + r: &RepoCtx, | |
| 487 | + pr: &PullRequest, | |
| 488 | + user_id: i64, | |
| 489 | + url: &str, | |
| 490 | +) -> WebResult { | |
| 491 | + if !(r.access.write || pr.author_id == user_id) { | |
| 492 | + return Err(AppError::Forbidden( | |
| 493 | + "Only the author or a collaborator can close this.".into(), | |
| 494 | + )); | |
| 495 | + } | |
| 496 | + let path = r.path.clone(); | |
| 497 | + let pr_clone = pr.clone(); | |
| 498 | + let (base, head) = blocking(move || Ok(pull_range(&git::open(&path)?, &pr_clone))).await?; | |
| 499 | + let head = head.map(|h| h.to_string()).unwrap_or_default(); | |
| 500 | + let base = base.map(|b| b.to_string()).unwrap_or_default(); | |
| 501 | + db::mark_closed(&state.db, pr.id, &head, &base).await?; | |
| 502 | + db::add_event(&state.db, pr.id, Some(user_id), "closed", "").await?; | |
| 503 | + Ok(redirect(url)) | |
| 504 | +} | |
| 505 | + | |
| 506 | +#[derive(Deserialize)] | |
| 507 | +pub struct StateForm { | |
| 508 | + action: String, | |
| 509 | +} | |
| 510 | + | |
| 511 | +pub async fn set_state( | |
| 512 | + State(state): State<AppState>, | |
| 513 | + ctx: Ctx, | |
| 514 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 515 | + Form(f): Form<StateForm>, | |
| 516 | +) -> WebResult { | |
| 517 | + let user = ctx.require_user()?.clone(); | |
| 518 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 519 | + let pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 520 | + .await? | |
| 521 | + .ok_or(AppError::NotFound)?; | |
| 522 | + let url = format!("{}/pull/{number}", r.base()); | |
| 523 | + match f.action.as_str() { | |
| 524 | + "close" if pr.is_open() => close_pull(&state, &r, &pr, user.id, &url).await, | |
| 525 | + "reopen" if pr.state == "closed" => { | |
| 526 | + if !(r.access.write || pr.author_id == user.id) { | |
| 527 | + return Err(AppError::Forbidden( | |
| 528 | + "Only the author or a collaborator can reopen this.".into(), | |
| 529 | + )); | |
| 530 | + } | |
| 531 | + if db::find_open_pull(&state.db, r.repo.id, &pr.base_branch, &pr.head_branch) | |
| 532 | + .await? | |
| 533 | + .is_some() | |
| 534 | + { | |
| 535 | + return Ok(redirect_flash( | |
| 536 | + &url, | |
| 537 | + "error", | |
| 538 | + "Another open pull request already exists for these branches.", | |
| 539 | + )); | |
| 540 | + } | |
| 541 | + let path = r.path.clone(); | |
| 542 | + let (base_branch, head_branch, n) = | |
| 543 | + (pr.base_branch.clone(), pr.head_branch.clone(), pr.number); | |
| 544 | + let tips = blocking(move || { | |
| 545 | + let repo = git::open(&path)?; | |
| 546 | + let (Some(base), Some(head)) = ( | |
| 547 | + git::branch_tip(&repo, &base_branch), | |
| 548 | + git::branch_tip(&repo, &head_branch), | |
| 549 | + ) else { | |
| 550 | + return Ok(None); | |
| 551 | + }; | |
| 552 | + git::set_pull_ref(&repo, n, head)?; | |
| 553 | + Ok(git::merge_base(&repo, base, head).map(|mb| (head, mb))) | |
| 554 | + }) | |
| 555 | + .await?; | |
| 556 | + let Some((head, merge_base)) = tips else { | |
| 557 | + return Ok(redirect_flash( | |
| 558 | + &url, | |
| 559 | + "error", | |
| 560 | + "This pull request cannot be reopened because a branch no longer exists.", | |
| 561 | + )); | |
| 562 | + }; | |
| 563 | + db::mark_reopened(&state.db, pr.id, &head.to_string(), &merge_base.to_string()).await?; | |
| 564 | + db::add_event(&state.db, pr.id, Some(user.id), "reopened", "").await?; | |
| 565 | + Ok(redirect(&url)) | |
| 566 | + } | |
| 567 | + _ => Ok(redirect(&url)), | |
| 568 | + } | |
| 569 | +} | |
| 570 | + | |
| 571 | +#[derive(Deserialize)] | |
| 572 | +pub struct MergeForm { | |
| 573 | + method: String, | |
| 574 | +} | |
| 575 | + | |
| 576 | +pub async fn merge( | |
| 577 | + State(state): State<AppState>, | |
| 578 | + ctx: Ctx, | |
| 579 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 580 | + Form(f): Form<MergeForm>, | |
| 581 | +) -> WebResult { | |
| 582 | + let user = ctx.require_user()?.clone(); | |
| 583 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 584 | + r.require_write()?; | |
| 585 | + let pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 586 | + .await? | |
| 587 | + .ok_or(AppError::NotFound)?; | |
| 588 | + let url = format!("{}/pull/{number}", r.base()); | |
| 589 | + if !pr.is_open() { | |
| 590 | + return Ok(redirect(&url)); | |
| 591 | + } | |
| 592 | + let method = MergeMethod::parse(&f.method) | |
| 593 | + .ok_or_else(|| AppError::BadRequest("Unknown merge method.".into()))?; | |
| 594 | + | |
| 595 | + let path = r.path.clone(); | |
| 596 | + let pr_clone = pr.clone(); | |
| 597 | + let (committer_name, committer_email) = (user.name().to_string(), user.email.clone()); | |
| 598 | + let result = blocking(move || { | |
| 599 | + let repo = git::open(&path)?; | |
| 600 | + let (Some(base), Some(head)) = ( | |
| 601 | + git::branch_tip(&repo, &pr_clone.base_branch), | |
| 602 | + git::branch_tip(&repo, &pr_clone.head_branch), | |
| 603 | + ) else { | |
| 604 | + return Ok(Err("Both branches must exist to merge.".to_string())); | |
| 605 | + }; | |
| 606 | + match git::mergeability(&repo, base, head)? { | |
| 607 | + Mergeability::Clean => {} | |
| 608 | + Mergeability::Conflicts => { | |
| 609 | + return Ok(Err( | |
| 610 | + "This branch has conflicts that must be resolved first.".into(), | |
| 611 | + )); | |
| 612 | + } | |
| 613 | + Mergeability::UpToDate => return Ok(Err("There is nothing to merge.".into())), | |
| 614 | + } | |
| 615 | + let merge_base = git::merge_base(&repo, base, head) | |
| 616 | + .ok_or_else(|| AppError::BadRequest("Branches have unrelated histories.".into()))?; | |
| 617 | + let message = match method { | |
| 618 | + MergeMethod::Merge => format!( | |
| 619 | + "Merge pull request #{} from {}\n\n{}", | |
| 620 | + pr_clone.number, pr_clone.head_branch, pr_clone.title | |
| 621 | + ), | |
| 622 | + MergeMethod::Squash => { | |
| 623 | + let mut m = format!("{} (#{})\n", pr_clone.title, pr_clone.number); | |
| 624 | + let commits = git::commits_between(&repo, base, head)?; | |
| 625 | + if commits.len() > 1 { | |
| 626 | + m.push('\n'); | |
| 627 | + for c in commits { | |
| 628 | + m.push_str(&format!("* {}\n", c.summary)); | |
| 629 | + } | |
| 630 | + } | |
| 631 | + m | |
| 632 | + } | |
| 633 | + MergeMethod::Rebase => String::new(), | |
| 634 | + }; | |
| 635 | + let sig = git2::Signature::now(&committer_name, &committer_email)?; | |
| 636 | + let new_tip = match git::merge( | |
| 637 | + &repo, | |
| 638 | + &MergeRequest { | |
| 639 | + base_branch: &pr_clone.base_branch, | |
| 640 | + base, | |
| 641 | + head, | |
| 642 | + method, | |
| 643 | + message: &message, | |
| 644 | + committer: &sig, | |
| 645 | + }, | |
| 646 | + ) { | |
| 647 | + Ok(tip) => tip, | |
| 648 | + Err(e) => return Ok(Err(e.to_string())), | |
| 649 | + }; | |
| 650 | + git::set_pull_ref(&repo, pr_clone.number, head)?; | |
| 651 | + Ok(Ok((new_tip, head, merge_base))) | |
| 652 | + }) | |
| 653 | + .await?; | |
| 654 | + | |
| 655 | + let (new_tip, head, merge_base) = match result { | |
| 656 | + Ok(v) => v, | |
| 657 | + Err(msg) => return Ok(redirect_flash(&url, "error", &msg)), | |
| 658 | + }; | |
| 659 | + db::mark_merged( | |
| 660 | + &state.db, | |
| 661 | + pr.id, | |
| 662 | + Some(user.id), | |
| 663 | + Some(&new_tip.to_string()), | |
| 664 | + &head.to_string(), | |
| 665 | + &merge_base.to_string(), | |
| 666 | + ) | |
| 667 | + .await?; | |
| 668 | + db::add_event(&state.db, pr.id, Some(user.id), "merged", "").await?; | |
| 669 | + db::touch_pushed(&state.db, r.repo.id).await?; | |
| 670 | + sync::refresh_pulls(&state, r.repo.id, Some(user.id)).await?; | |
| 671 | + Ok(redirect_flash(&url, "success", "Pull request merged.")) | |
| 672 | +} | |
| 673 | + | |
| 674 | +#[derive(Deserialize)] | |
| 675 | +pub struct EditForm { | |
| 676 | + title: String, | |
| 677 | + #[serde(default)] | |
| 678 | + body: String, | |
| 679 | +} | |
| 680 | + | |
| 681 | +pub async fn edit( | |
| 682 | + State(state): State<AppState>, | |
| 683 | + ctx: Ctx, | |
| 684 | + Path((owner, name, number)): Path<(String, String, i64)>, | |
| 685 | + Form(f): Form<EditForm>, | |
| 686 | +) -> WebResult { | |
| 687 | + let user = ctx.require_user()?.clone(); | |
| 688 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 689 | + let pr = db::pull_by_number(&state.db, r.repo.id, number) | |
| 690 | + .await? | |
| 691 | + .ok_or(AppError::NotFound)?; | |
| 692 | + if !(r.access.write || pr.author_id == user.id) { | |
| 693 | + return Err(AppError::Forbidden( | |
| 694 | + "Only the author or a collaborator can edit this.".into(), | |
| 695 | + )); | |
| 696 | + } | |
| 697 | + let title = f.title.trim().chars().take(256).collect::<String>(); | |
| 698 | + if title.is_empty() { | |
| 699 | + return Err(AppError::BadRequest("A pull request needs a title.".into())); | |
| 700 | + } | |
| 701 | + db::update_pull_text(&state.db, pr.id, &title, f.body.trim()).await?; | |
| 702 | + Ok(redirect(&format!("{}/pull/{number}", r.base()))) | |
| 703 | +} |
| @@ -0,0 +1,490 @@ | ||
| 1 | +//! Repository loading, creation, settings, branches and tags. | |
| 2 | + | |
| 3 | +use std::collections::HashMap; | |
| 4 | +use std::path::PathBuf; | |
| 5 | + | |
| 6 | +use askama::Template; | |
| 7 | +use axum::Form; | |
| 8 | +use axum::extract::{Path, State}; | |
| 9 | +use serde::Deserialize; | |
| 10 | + | |
| 11 | +use super::{AppError, Ctx, WebResult, blocking, redirect, redirect_flash, render, safe_next}; | |
| 12 | +use crate::db::{self, Repo, User}; | |
| 13 | +use crate::git::{self, BranchInfo, TagInfo}; | |
| 14 | +use crate::state::{Access, AppState}; | |
| 15 | +use crate::validate; | |
| 16 | + | |
| 17 | +/// A repository the current user is allowed to read, with their permissions. | |
| 18 | +#[derive(Debug, Clone)] | |
| 19 | +pub struct RepoCtx { | |
| 20 | + pub repo: Repo, | |
| 21 | + pub access: Access, | |
| 22 | + pub path: PathBuf, | |
| 23 | + pub open_pulls: i64, | |
| 24 | + pub ssh_url: String, | |
| 25 | + pub http_url: String, | |
| 26 | +} | |
| 27 | + | |
| 28 | +impl RepoCtx { | |
| 29 | + /// URL prefix of the repository, e.g. `/alice/project`. | |
| 30 | + pub fn base(&self) -> String { | |
| 31 | + format!("/{}/{}", self.repo.owner, self.repo.name) | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub fn pull_url(&self, number: &i64) -> String { | |
| 35 | + format!("{}/pull/{number}", self.base()) | |
| 36 | + } | |
| 37 | + | |
| 38 | + pub fn require_write(&self) -> Result<(), AppError> { | |
| 39 | + if self.access.write { | |
| 40 | + Ok(()) | |
| 41 | + } else { | |
| 42 | + Err(AppError::Forbidden( | |
| 43 | + "You need write access to this repository to do that.".into(), | |
| 44 | + )) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + pub fn require_admin(&self) -> Result<(), AppError> { | |
| 49 | + if self.access.admin { | |
| 50 | + Ok(()) | |
| 51 | + } else { | |
| 52 | + Err(AppError::Forbidden( | |
| 53 | + "Only the repository owner can do that.".into(), | |
| 54 | + )) | |
| 55 | + } | |
| 56 | + } | |
| 57 | +} | |
| 58 | + | |
| 59 | +pub async fn load_repo( | |
| 60 | + state: &AppState, | |
| 61 | + ctx: &Ctx, | |
| 62 | + owner: &str, | |
| 63 | + name: &str, | |
| 64 | +) -> Result<RepoCtx, AppError> { | |
| 65 | + let repo = db::repo_by_name(&state.db, owner, name) | |
| 66 | + .await? | |
| 67 | + .ok_or(AppError::NotFound)?; | |
| 68 | + let access = state.access(&repo, ctx.user.as_deref()).await?; | |
| 69 | + if !access.read { | |
| 70 | + // Private repositories are indistinguishable from missing ones. | |
| 71 | + return Err(AppError::NotFound); | |
| 72 | + } | |
| 73 | + let (open_pulls, _) = db::count_pulls(&state.db, repo.id).await?; | |
| 74 | + Ok(RepoCtx { | |
| 75 | + path: state.config.repo_path(repo.id), | |
| 76 | + ssh_url: state.config.ssh_clone_url(&repo.owner, &repo.name), | |
| 77 | + http_url: state.config.http_clone_url(&repo.owner, &repo.name), | |
| 78 | + repo, | |
| 79 | + access, | |
| 80 | + open_pulls, | |
| 81 | + }) | |
| 82 | +} | |
| 83 | + | |
| 84 | +// --------------------------------------------------------------------------- | |
| 85 | +// New repository | |
| 86 | +// --------------------------------------------------------------------------- | |
| 87 | + | |
| 88 | +#[derive(Template)] | |
| 89 | +#[template(path = "new_repo.html")] | |
| 90 | +struct NewRepoPage { | |
| 91 | + ctx: Ctx, | |
| 92 | + name: String, | |
| 93 | + description: String, | |
| 94 | + private: bool, | |
| 95 | + readme: bool, | |
| 96 | + error: Option<String>, | |
| 97 | +} | |
| 98 | + | |
| 99 | +pub async fn new_form(ctx: Ctx) -> WebResult { | |
| 100 | + ctx.require_user()?; | |
| 101 | + render(&NewRepoPage { | |
| 102 | + ctx, | |
| 103 | + name: String::new(), | |
| 104 | + description: String::new(), | |
| 105 | + private: false, | |
| 106 | + readme: false, | |
| 107 | + error: None, | |
| 108 | + }) | |
| 109 | +} | |
| 110 | + | |
| 111 | +#[derive(Deserialize)] | |
| 112 | +pub struct NewRepoForm { | |
| 113 | + name: String, | |
| 114 | + #[serde(default)] | |
| 115 | + description: String, | |
| 116 | + visibility: String, | |
| 117 | + readme: Option<String>, | |
| 118 | +} | |
| 119 | + | |
| 120 | +pub async fn create( | |
| 121 | + State(state): State<AppState>, | |
| 122 | + ctx: Ctx, | |
| 123 | + Form(f): Form<NewRepoForm>, | |
| 124 | +) -> WebResult { | |
| 125 | + let user = ctx.require_user()?.clone(); | |
| 126 | + let name = f.name.trim().to_string(); | |
| 127 | + let description = f.description.trim().chars().take(350).collect::<String>(); | |
| 128 | + let private = f.visibility == "private"; | |
| 129 | + let readme = f.readme.is_some(); | |
| 130 | + | |
| 131 | + let mut error = validate::repo_name(&name).err().map(str::to_string); | |
| 132 | + if error.is_none() && db::repo_name_taken(&state.db, user.id, &name, -1).await? { | |
| 133 | + error = Some(format!( | |
| 134 | + "You already have a repository named \u{201c}{name}\u{201d}." | |
| 135 | + )); | |
| 136 | + } | |
| 137 | + if let Some(error) = error { | |
| 138 | + return render(&NewRepoPage { | |
| 139 | + ctx, | |
| 140 | + name, | |
| 141 | + description, | |
| 142 | + private, | |
| 143 | + readme, | |
| 144 | + error: Some(error), | |
| 145 | + }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + let id = db::create_repo(&state.db, user.id, &name, &description, private, "main").await?; | |
| 149 | + let path = state.config.repo_path(id); | |
| 150 | + let (repo_name, desc) = (name.clone(), description.clone()); | |
| 151 | + let init = blocking(move || { | |
| 152 | + let repo = git::init_bare(&path, "main")?; | |
| 153 | + if readme { | |
| 154 | + let mut content = format!("# {repo_name}\n"); | |
| 155 | + if !desc.is_empty() { | |
| 156 | + content.push_str(&format!("\n{desc}\n")); | |
| 157 | + } | |
| 158 | + let sig = git2::Signature::now(user.name(), &user.email)?; | |
| 159 | + git::initial_commit(&repo, "main", &[("README.md", content.as_bytes())], &sig)?; | |
| 160 | + } | |
| 161 | + Ok(()) | |
| 162 | + }) | |
| 163 | + .await; | |
| 164 | + if let Err(e) = init { | |
| 165 | + db::delete_repo(&state.db, id).await?; | |
| 166 | + let _ = std::fs::remove_dir_all(state.config.repo_path(id)); | |
| 167 | + return Err(e); | |
| 168 | + } | |
| 169 | + Ok(redirect(&format!( | |
| 170 | + "/{}/{}", | |
| 171 | + ctx.require_user()?.username, | |
| 172 | + name | |
| 173 | + ))) | |
| 174 | +} | |
| 175 | + | |
| 176 | +// --------------------------------------------------------------------------- | |
| 177 | +// Branches and tags | |
| 178 | +// --------------------------------------------------------------------------- | |
| 179 | + | |
| 180 | +pub struct BranchRow { | |
| 181 | + pub branch: BranchInfo, | |
| 182 | + pub pull: Option<i64>, | |
| 183 | + pub is_default: bool, | |
| 184 | +} | |
| 185 | + | |
| 186 | +#[derive(Template)] | |
| 187 | +#[template(path = "repo/branches.html")] | |
| 188 | +struct BranchesPage { | |
| 189 | + ctx: Ctx, | |
| 190 | + r: RepoCtx, | |
| 191 | + tab: &'static str, | |
| 192 | + rows: Vec<BranchRow>, | |
| 193 | +} | |
| 194 | + | |
| 195 | +pub async fn branches( | |
| 196 | + State(state): State<AppState>, | |
| 197 | + ctx: Ctx, | |
| 198 | + Path((owner, name)): Path<(String, String)>, | |
| 199 | +) -> WebResult { | |
| 200 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 201 | + let path = r.path.clone(); | |
| 202 | + let default = r.repo.default_branch.clone(); | |
| 203 | + let list = blocking(move || Ok(git::branches(&git::open(&path)?, &default)?)).await?; | |
| 204 | + let pulls: HashMap<String, i64> = db::list_pulls(&state.db, r.repo.id, true) | |
| 205 | + .await? | |
| 206 | + .into_iter() | |
| 207 | + .map(|p| (p.head_branch, p.number)) | |
| 208 | + .collect(); | |
| 209 | + let mut rows: Vec<BranchRow> = list | |
| 210 | + .into_iter() | |
| 211 | + .map(|b| BranchRow { | |
| 212 | + pull: pulls.get(&b.name).copied(), | |
| 213 | + is_default: b.name == r.repo.default_branch, | |
| 214 | + branch: b, | |
| 215 | + }) | |
| 216 | + .collect(); | |
| 217 | + rows.sort_by_key(|row| !row.is_default); | |
| 218 | + render(&BranchesPage { | |
| 219 | + ctx, | |
| 220 | + r, | |
| 221 | + tab: "branches", | |
| 222 | + rows, | |
| 223 | + }) | |
| 224 | +} | |
| 225 | + | |
| 226 | +#[derive(Deserialize)] | |
| 227 | +pub struct DeleteBranchForm { | |
| 228 | + branch: String, | |
| 229 | + return_to: Option<String>, | |
| 230 | + pr: Option<i64>, | |
| 231 | +} | |
| 232 | + | |
| 233 | +pub async fn delete_branch( | |
| 234 | + State(state): State<AppState>, | |
| 235 | + ctx: Ctx, | |
| 236 | + Path((owner, name)): Path<(String, String)>, | |
| 237 | + Form(f): Form<DeleteBranchForm>, | |
| 238 | +) -> WebResult { | |
| 239 | + let user = ctx.require_user()?.clone(); | |
| 240 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 241 | + r.require_write()?; | |
| 242 | + if f.branch == r.repo.default_branch { | |
| 243 | + return Err(AppError::BadRequest( | |
| 244 | + "The default branch cannot be deleted.".into(), | |
| 245 | + )); | |
| 246 | + } | |
| 247 | + let path = r.path.clone(); | |
| 248 | + let branch = f.branch.clone(); | |
| 249 | + blocking(move || { | |
| 250 | + let repo = git::open(&path)?; | |
| 251 | + if !git::branch_exists(&repo, &branch) { | |
| 252 | + return Err(AppError::NotFound); | |
| 253 | + } | |
| 254 | + git::delete_branch(&repo, &branch)?; | |
| 255 | + Ok(()) | |
| 256 | + }) | |
| 257 | + .await?; | |
| 258 | + if let Some(number) = f.pr { | |
| 259 | + if let Some(pr) = db::pull_by_number(&state.db, r.repo.id, number).await? { | |
| 260 | + if pr.head_branch == f.branch { | |
| 261 | + db::add_event(&state.db, pr.id, Some(user.id), "branch_deleted", &f.branch).await?; | |
| 262 | + } | |
| 263 | + } | |
| 264 | + } | |
| 265 | + let to = f | |
| 266 | + .return_to | |
| 267 | + .as_deref() | |
| 268 | + .map(|t| safe_next(Some(t))) | |
| 269 | + .unwrap_or_else(|| format!("{}/branches", r.base())); | |
| 270 | + Ok(redirect_flash( | |
| 271 | + &to, | |
| 272 | + "success", | |
| 273 | + &format!("Deleted branch {}.", f.branch), | |
| 274 | + )) | |
| 275 | +} | |
| 276 | + | |
| 277 | +#[derive(Template)] | |
| 278 | +#[template(path = "repo/tags.html")] | |
| 279 | +struct TagsPage { | |
| 280 | + ctx: Ctx, | |
| 281 | + r: RepoCtx, | |
| 282 | + tab: &'static str, | |
| 283 | + tags: Vec<TagInfo>, | |
| 284 | +} | |
| 285 | + | |
| 286 | +pub async fn tags( | |
| 287 | + State(state): State<AppState>, | |
| 288 | + ctx: Ctx, | |
| 289 | + Path((owner, name)): Path<(String, String)>, | |
| 290 | +) -> WebResult { | |
| 291 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 292 | + let path = r.path.clone(); | |
| 293 | + let tags = blocking(move || Ok(git::tags(&git::open(&path)?)?)).await?; | |
| 294 | + render(&TagsPage { | |
| 295 | + ctx, | |
| 296 | + r, | |
| 297 | + tab: "code", | |
| 298 | + tags, | |
| 299 | + }) | |
| 300 | +} | |
| 301 | + | |
| 302 | +// --------------------------------------------------------------------------- | |
| 303 | +// Settings | |
| 304 | +// --------------------------------------------------------------------------- | |
| 305 | + | |
| 306 | +#[derive(Template)] | |
| 307 | +#[template(path = "repo/settings.html")] | |
| 308 | +struct SettingsPage { | |
| 309 | + ctx: Ctx, | |
| 310 | + r: RepoCtx, | |
| 311 | + tab: &'static str, | |
| 312 | + branches: Vec<String>, | |
| 313 | + collaborators: Vec<User>, | |
| 314 | + error: Option<String>, | |
| 315 | + collab_error: Option<String>, | |
| 316 | +} | |
| 317 | + | |
| 318 | +async fn settings_page( | |
| 319 | + state: &AppState, | |
| 320 | + ctx: Ctx, | |
| 321 | + r: RepoCtx, | |
| 322 | + error: Option<String>, | |
| 323 | + collab_error: Option<String>, | |
| 324 | +) -> WebResult { | |
| 325 | + r.require_admin()?; | |
| 326 | + let path = r.path.clone(); | |
| 327 | + let branches = blocking(move || Ok(git::branch_names(&git::open(&path)?)?)).await?; | |
| 328 | + let collaborators = db::list_collaborators(&state.db, r.repo.id).await?; | |
| 329 | + render(&SettingsPage { | |
| 330 | + ctx, | |
| 331 | + r, | |
| 332 | + tab: "settings", | |
| 333 | + branches, | |
| 334 | + collaborators, | |
| 335 | + error, | |
| 336 | + collab_error, | |
| 337 | + }) | |
| 338 | +} | |
| 339 | + | |
| 340 | +pub async fn settings( | |
| 341 | + State(state): State<AppState>, | |
| 342 | + ctx: Ctx, | |
| 343 | + Path((owner, name)): Path<(String, String)>, | |
| 344 | +) -> WebResult { | |
| 345 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 346 | + settings_page(&state, ctx, r, None, None).await | |
| 347 | +} | |
| 348 | + | |
| 349 | +#[derive(Deserialize)] | |
| 350 | +pub struct SettingsForm { | |
| 351 | + name: String, | |
| 352 | + #[serde(default)] | |
| 353 | + description: String, | |
| 354 | + visibility: String, | |
| 355 | + default_branch: Option<String>, | |
| 356 | +} | |
| 357 | + | |
| 358 | +pub async fn update_settings( | |
| 359 | + State(state): State<AppState>, | |
| 360 | + ctx: Ctx, | |
| 361 | + Path((owner, name)): Path<(String, String)>, | |
| 362 | + Form(f): Form<SettingsForm>, | |
| 363 | +) -> WebResult { | |
| 364 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 365 | + r.require_admin()?; | |
| 366 | + let new_name = f.name.trim().to_string(); | |
| 367 | + let description = f.description.trim().chars().take(350).collect::<String>(); | |
| 368 | + let mut error = validate::repo_name(&new_name).err().map(str::to_string); | |
| 369 | + if error.is_none() | |
| 370 | + && db::repo_name_taken(&state.db, r.repo.owner_id, &new_name, r.repo.id).await? | |
| 371 | + { | |
| 372 | + error = Some("A repository with that name already exists.".into()); | |
| 373 | + } | |
| 374 | + if let Some(error) = error { | |
| 375 | + return settings_page(&state, ctx, r, Some(error), None).await; | |
| 376 | + } | |
| 377 | + | |
| 378 | + let mut default_branch = r.repo.default_branch.clone(); | |
| 379 | + if let Some(branch) = f.default_branch.filter(|b| *b != default_branch) { | |
| 380 | + let path = r.path.clone(); | |
| 381 | + let b = branch.clone(); | |
| 382 | + blocking(move || { | |
| 383 | + let repo = git::open(&path)?; | |
| 384 | + if !git::branch_exists(&repo, &b) { | |
| 385 | + return Err(AppError::BadRequest("That branch does not exist.".into())); | |
| 386 | + } | |
| 387 | + git::set_head(&repo, &b)?; | |
| 388 | + Ok(()) | |
| 389 | + }) | |
| 390 | + .await?; | |
| 391 | + default_branch = branch; | |
| 392 | + } | |
| 393 | + db::update_repo( | |
| 394 | + &state.db, | |
| 395 | + r.repo.id, | |
| 396 | + &new_name, | |
| 397 | + &description, | |
| 398 | + f.visibility == "private", | |
| 399 | + &default_branch, | |
| 400 | + ) | |
| 401 | + .await?; | |
| 402 | + Ok(redirect_flash( | |
| 403 | + &format!("/{}/{}/settings", r.repo.owner, new_name), | |
| 404 | + "success", | |
| 405 | + "Repository settings saved.", | |
| 406 | + )) | |
| 407 | +} | |
| 408 | + | |
| 409 | +#[derive(Deserialize)] | |
| 410 | +pub struct CollaboratorForm { | |
| 411 | + username: String, | |
| 412 | +} | |
| 413 | + | |
| 414 | +pub async fn add_collaborator( | |
| 415 | + State(state): State<AppState>, | |
| 416 | + ctx: Ctx, | |
| 417 | + Path((owner, name)): Path<(String, String)>, | |
| 418 | + Form(f): Form<CollaboratorForm>, | |
| 419 | +) -> WebResult { | |
| 420 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 421 | + r.require_admin()?; | |
| 422 | + let username = f.username.trim(); | |
| 423 | + let Some(user) = db::user_by_username(&state.db, username).await? else { | |
| 424 | + let msg = format!("No user named \u{201c}{username}\u{201d} exists."); | |
| 425 | + return settings_page(&state, ctx, r, None, Some(msg)).await; | |
| 426 | + }; | |
| 427 | + if user.id == r.repo.owner_id { | |
| 428 | + let msg = "The owner already has full access.".to_string(); | |
| 429 | + return settings_page(&state, ctx, r, None, Some(msg)).await; | |
| 430 | + } | |
| 431 | + db::add_collaborator(&state.db, r.repo.id, user.id).await?; | |
| 432 | + Ok(redirect_flash( | |
| 433 | + &format!("{}/settings", r.base()), | |
| 434 | + "success", | |
| 435 | + &format!("{} can now push to this repository.", user.username), | |
| 436 | + )) | |
| 437 | +} | |
| 438 | + | |
| 439 | +pub async fn remove_collaborator( | |
| 440 | + State(state): State<AppState>, | |
| 441 | + ctx: Ctx, | |
| 442 | + Path((owner, name)): Path<(String, String)>, | |
| 443 | + Form(f): Form<CollaboratorForm>, | |
| 444 | +) -> WebResult { | |
| 445 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 446 | + r.require_admin()?; | |
| 447 | + if let Some(user) = db::user_by_username(&state.db, &f.username).await? { | |
| 448 | + db::remove_collaborator(&state.db, r.repo.id, user.id).await?; | |
| 449 | + } | |
| 450 | + Ok(redirect_flash( | |
| 451 | + &format!("{}/settings", r.base()), | |
| 452 | + "success", | |
| 453 | + "Collaborator removed.", | |
| 454 | + )) | |
| 455 | +} | |
| 456 | + | |
| 457 | +#[derive(Deserialize)] | |
| 458 | +pub struct DeleteForm { | |
| 459 | + confirm: String, | |
| 460 | +} | |
| 461 | + | |
| 462 | +pub async fn delete( | |
| 463 | + State(state): State<AppState>, | |
| 464 | + ctx: Ctx, | |
| 465 | + Path((owner, name)): Path<(String, String)>, | |
| 466 | + Form(f): Form<DeleteForm>, | |
| 467 | +) -> WebResult { | |
| 468 | + let r = load_repo(&state, &ctx, &owner, &name).await?; | |
| 469 | + r.require_admin()?; | |
| 470 | + let full_name = format!("{}/{}", r.repo.owner, r.repo.name); | |
| 471 | + if f.confirm.trim() != full_name { | |
| 472 | + return settings_page( | |
| 473 | + &state, | |
| 474 | + ctx, | |
| 475 | + r, | |
| 476 | + Some(format!("Type {full_name} to confirm deletion.")), | |
| 477 | + None, | |
| 478 | + ) | |
| 479 | + .await; | |
| 480 | + } | |
| 481 | + db::delete_repo(&state.db, r.repo.id).await?; | |
| 482 | + if let Err(e) = tokio::fs::remove_dir_all(&r.path).await { | |
| 483 | + tracing::warn!("could not remove {}: {e}", r.path.display()); | |
| 484 | + } | |
| 485 | + Ok(redirect_flash( | |
| 486 | + "/", | |
| 487 | + "success", | |
| 488 | + &format!("Deleted {full_name}."), | |
| 489 | + )) | |
| 490 | +} |
| @@ -0,0 +1,141 @@ | ||
| 1 | +// Small progressive enhancements. Every page works without this script. | |
| 2 | +(() => { | |
| 3 | + "use strict"; | |
| 4 | + | |
| 5 | + const copyText = async (text, button) => { | |
| 6 | + try { | |
| 7 | + await navigator.clipboard.writeText(text); | |
| 8 | + } catch { | |
| 9 | + const area = document.createElement("textarea"); | |
| 10 | + area.value = text; | |
| 11 | + document.body.appendChild(area); | |
| 12 | + area.select(); | |
| 13 | + document.execCommand("copy"); | |
| 14 | + area.remove(); | |
| 15 | + } | |
| 16 | + const original = button.innerHTML; | |
| 17 | + button.innerHTML = | |
| 18 | + '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>'; | |
| 19 | + button.classList.add("copied"); | |
| 20 | + setTimeout(() => { | |
| 21 | + button.innerHTML = original; | |
| 22 | + button.classList.remove("copied"); | |
| 23 | + }, 1500); | |
| 24 | + }; | |
| 25 | + | |
| 26 | + document.addEventListener("click", (event) => { | |
| 27 | + const target = event.target.closest("[data-copy], [data-copy-text]"); | |
| 28 | + if (target) { | |
| 29 | + event.preventDefault(); | |
| 30 | + const text = target.dataset.copyText ?? document.querySelector(target.dataset.copy)?.value ?? ""; | |
| 31 | + copyText(text, target); | |
| 32 | + return; | |
| 33 | + } | |
| 34 | + | |
| 35 | + const toggle = event.target.closest("[data-toggle]"); | |
| 36 | + if (toggle) { | |
| 37 | + const el = document.getElementById(toggle.dataset.toggle); | |
| 38 | + if (el) { | |
| 39 | + el.hidden = !el.hidden; | |
| 40 | + if (!el.hidden) el.querySelector("input, textarea")?.focus(); | |
| 41 | + } | |
| 42 | + return; | |
| 43 | + } | |
| 44 | + | |
| 45 | + const collapse = event.target.closest("[data-collapse]"); | |
| 46 | + if (collapse) { | |
| 47 | + collapse.closest(".diff-file")?.classList.toggle("collapsed"); | |
| 48 | + return; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const cloneTab = event.target.closest("[data-clone-tab]"); | |
| 52 | + if (cloneTab) { | |
| 53 | + const panel = cloneTab.closest(".clone-panel"); | |
| 54 | + panel.querySelectorAll("[data-clone-tab]").forEach((t) => t.classList.toggle("selected", t === cloneTab)); | |
| 55 | + panel.querySelectorAll("[data-clone-pane]").forEach((p) => { | |
| 56 | + p.hidden = p.dataset.clonePane !== cloneTab.dataset.cloneTab; | |
| 57 | + }); | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + | |
| 61 | + // Close open dropdowns when clicking elsewhere. | |
| 62 | + document.querySelectorAll("details.dropdown[open]").forEach((d) => { | |
| 63 | + if (!d.contains(event.target)) d.removeAttribute("open"); | |
| 64 | + }); | |
| 65 | + }); | |
| 66 | + | |
| 67 | + // Only one dropdown open at a time; focus the filter when opening. | |
| 68 | + document.querySelectorAll("details.dropdown").forEach((details) => { | |
| 69 | + details.addEventListener("toggle", () => { | |
| 70 | + if (!details.open) return; | |
| 71 | + document.querySelectorAll("details.dropdown[open]").forEach((other) => { | |
| 72 | + if (other !== details) other.removeAttribute("open"); | |
| 73 | + }); | |
| 74 | + details.querySelector("[data-filter]")?.focus(); | |
| 75 | + }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + document.addEventListener("keydown", (event) => { | |
| 79 | + if (event.key === "Escape") { | |
| 80 | + document.querySelectorAll("details.dropdown[open]").forEach((d) => { | |
| 81 | + d.removeAttribute("open"); | |
| 82 | + d.querySelector("summary")?.focus(); | |
| 83 | + }); | |
| 84 | + } | |
| 85 | + }); | |
| 86 | + | |
| 87 | + document.addEventListener("input", (event) => { | |
| 88 | + const filter = event.target.closest("[data-filter]"); | |
| 89 | + if (!filter) return; | |
| 90 | + const query = filter.value.trim().toLowerCase(); | |
| 91 | + filter | |
| 92 | + .closest(".dropdown-menu") | |
| 93 | + .querySelectorAll("[data-filter-item]") | |
| 94 | + .forEach((item) => { | |
| 95 | + item.hidden = query !== "" && !item.textContent.toLowerCase().includes(query); | |
| 96 | + }); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + document.addEventListener("change", (event) => { | |
| 100 | + const select = event.target.closest("select[data-autosubmit]"); | |
| 101 | + if (select) select.form.submit(); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + document.addEventListener("submit", (event) => { | |
| 105 | + const message = event.target.dataset.confirm; | |
| 106 | + if (message && !window.confirm(message)) event.preventDefault(); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + // Highlight #L10 or #L10-L20 in file views. | |
| 110 | + const highlightLines = () => { | |
| 111 | + document.querySelectorAll("tr.highlighted").forEach((row) => row.classList.remove("highlighted")); | |
| 112 | + const match = location.hash.match(/^#L(\d+)(?:-L(\d+))?$/); | |
| 113 | + if (!match) return; | |
| 114 | + const start = Number(match[1]); | |
| 115 | + const end = Number(match[2] || match[1]); | |
| 116 | + for (let n = Math.min(start, end); n <= Math.max(start, end); n++) { | |
| 117 | + document.getElementById(`L${n}`)?.classList.add("highlighted"); | |
| 118 | + } | |
| 119 | + }; | |
| 120 | + | |
| 121 | + // Shift-click a line number to select a range. | |
| 122 | + let anchorLine = null; | |
| 123 | + document.addEventListener("click", (event) => { | |
| 124 | + const link = event.target.closest(".code-view .num a"); | |
| 125 | + if (!link) return; | |
| 126 | + const line = Number(link.getAttribute("href").slice(2)); | |
| 127 | + if (event.shiftKey && anchorLine) { | |
| 128 | + event.preventDefault(); | |
| 129 | + const [a, b] = [Math.min(anchorLine, line), Math.max(anchorLine, line)]; | |
| 130 | + history.replaceState(null, "", `#L${a}-L${b}`); | |
| 131 | + highlightLines(); | |
| 132 | + } else { | |
| 133 | + anchorLine = line; | |
| 134 | + } | |
| 135 | + }); | |
| 136 | + | |
| 137 | + window.addEventListener("hashchange", highlightLines); | |
| 138 | + highlightLines(); | |
| 139 | + const first = document.querySelector("tr.highlighted"); | |
| 140 | + if (first) first.scrollIntoView({ block: "center" }); | |
| 141 | +})(); |
| @@ -0,0 +1 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#2563eb"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="9" r="3"/><circle cx="11" cy="23" r="3"/><circle cx="22" cy="12" r="3"/><path d="M11 12v8M22 15c0 4-4 5-8.5 6.5"/></g></svg> |
| @@ -0,0 +1,865 @@ | ||
| 1 | +/* Openhub stylesheet. Colours are tokens so the dark theme only redefines variables. */ | |
| 2 | + | |
| 3 | +:root { | |
| 4 | + --bg: #ffffff; | |
| 5 | + --bg-subtle: #f6f8fa; | |
| 6 | + --bg-muted: #eef1f4; | |
| 7 | + --bg-hover: #f3f4f6; | |
| 8 | + --border: #d1d9e0; | |
| 9 | + --border-muted: #e4e8ec; | |
| 10 | + --text: #1f2328; | |
| 11 | + --text-muted: #59636e; | |
| 12 | + --text-subtle: #818b98; | |
| 13 | + --accent: #2563eb; | |
| 14 | + --accent-hover: #1d4ed8; | |
| 15 | + --accent-subtle: #eef4ff; | |
| 16 | + --accent-border: #bcd0fb; | |
| 17 | + --focus: rgba(37, 99, 235, 0.35); | |
| 18 | + --success: #1a7f37; | |
| 19 | + --success-emphasis: #1f883d; | |
| 20 | + --success-emphasis-hover: #1c7a37; | |
| 21 | + --success-subtle: #dafbe1; | |
| 22 | + --success-border: rgba(26, 127, 55, 0.35); | |
| 23 | + --danger: #cf222e; | |
| 24 | + --danger-emphasis: #d1242f; | |
| 25 | + --danger-subtle: #ffebe9; | |
| 26 | + --danger-border: rgba(207, 34, 46, 0.35); | |
| 27 | + --merged: #8250df; | |
| 28 | + --merged-subtle: #fbefff; | |
| 29 | + --attention-subtle: #fff8c5; | |
| 30 | + --attention-border: #e6d58c; | |
| 31 | + --header-bg: #0f172a; | |
| 32 | + --header-text: #e2e8f0; | |
| 33 | + --header-muted: #94a3b8; | |
| 34 | + --btn-bg: #f6f8fa; | |
| 35 | + --btn-hover: #eef1f4; | |
| 36 | + --btn-border: #d1d9e0; | |
| 37 | + --shadow-sm: 0 1px 0 rgba(31, 35, 40, 0.04); | |
| 38 | + --shadow-md: 0 8px 24px rgba(66, 74, 83, 0.12), 0 1px 3px rgba(31, 35, 40, 0.08); | |
| 39 | + --diff-add-bg: #e6ffec; | |
| 40 | + --diff-add-num: #ccffd8; | |
| 41 | + --diff-del-bg: #ffebe9; | |
| 42 | + --diff-del-num: #ffd7d5; | |
| 43 | + --diff-hunk-bg: #eef4ff; | |
| 44 | + --line-highlight: #fff8c5; | |
| 45 | + --folder: #54aeff; | |
| 46 | + --tok-comment: #6e7781; | |
| 47 | + --tok-string: #0a3069; | |
| 48 | + --tok-number: #0550ae; | |
| 49 | + --tok-constant: #0550ae; | |
| 50 | + --tok-keyword: #cf222e; | |
| 51 | + --tok-function: #8250df; | |
| 52 | + --tok-type: #953800; | |
| 53 | + --tok-tag: #116329; | |
| 54 | + --tok-attr: #0550ae; | |
| 55 | + --tok-meta: #6639ba; | |
| 56 | + --radius: 6px; | |
| 57 | + --radius-lg: 10px; | |
| 58 | + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; | |
| 59 | + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; | |
| 60 | + color-scheme: light; | |
| 61 | +} | |
| 62 | + | |
| 63 | +@media (prefers-color-scheme: dark) { | |
| 64 | + :root { | |
| 65 | + --bg: #0d1117; | |
| 66 | + --bg-subtle: #151b23; | |
| 67 | + --bg-muted: #212830; | |
| 68 | + --bg-hover: #1b2129; | |
| 69 | + --border: #3d444d; | |
| 70 | + --border-muted: #2a313a; | |
| 71 | + --text: #e6edf3; | |
| 72 | + --text-muted: #9198a1; | |
| 73 | + --text-subtle: #6e7681; | |
| 74 | + --accent: #4493f8; | |
| 75 | + --accent-hover: #6cabff; | |
| 76 | + --accent-subtle: rgba(56, 139, 253, 0.12); | |
| 77 | + --accent-border: rgba(56, 139, 253, 0.4); | |
| 78 | + --focus: rgba(68, 147, 248, 0.45); | |
| 79 | + --success: #3fb950; | |
| 80 | + --success-emphasis: #238636; | |
| 81 | + --success-emphasis-hover: #2ea043; | |
| 82 | + --success-subtle: rgba(46, 160, 67, 0.15); | |
| 83 | + --success-border: rgba(46, 160, 67, 0.4); | |
| 84 | + --danger: #f85149; | |
| 85 | + --danger-emphasis: #da3633; | |
| 86 | + --danger-subtle: rgba(248, 81, 73, 0.12); | |
| 87 | + --danger-border: rgba(248, 81, 73, 0.4); | |
| 88 | + --merged: #ab7df8; | |
| 89 | + --merged-subtle: rgba(171, 125, 248, 0.15); | |
| 90 | + --attention-subtle: rgba(187, 128, 9, 0.15); | |
| 91 | + --attention-border: rgba(187, 128, 9, 0.4); | |
| 92 | + --header-bg: #010409; | |
| 93 | + --header-text: #e6edf3; | |
| 94 | + --header-muted: #9198a1; | |
| 95 | + --btn-bg: #212830; | |
| 96 | + --btn-hover: #262c36; | |
| 97 | + --btn-border: #3d444d; | |
| 98 | + --shadow-sm: none; | |
| 99 | + --shadow-md: 0 8px 24px rgba(1, 4, 9, 0.75), 0 0 0 1px #3d444d; | |
| 100 | + --diff-add-bg: rgba(46, 160, 67, 0.15); | |
| 101 | + --diff-add-num: rgba(63, 185, 80, 0.3); | |
| 102 | + --diff-del-bg: rgba(248, 81, 73, 0.1); | |
| 103 | + --diff-del-num: rgba(248, 81, 73, 0.3); | |
| 104 | + --diff-hunk-bg: rgba(56, 139, 253, 0.1); | |
| 105 | + --line-highlight: rgba(187, 128, 9, 0.2); | |
| 106 | + --folder: #4493f8; | |
| 107 | + --tok-comment: #9198a1; | |
| 108 | + --tok-string: #a5d6ff; | |
| 109 | + --tok-number: #79c0ff; | |
| 110 | + --tok-constant: #79c0ff; | |
| 111 | + --tok-keyword: #ff7b72; | |
| 112 | + --tok-function: #d2a8ff; | |
| 113 | + --tok-type: #ffa657; | |
| 114 | + --tok-tag: #7ee787; | |
| 115 | + --tok-attr: #79c0ff; | |
| 116 | + --tok-meta: #d2a8ff; | |
| 117 | + color-scheme: dark; | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +/* ---------- Base ---------- */ | |
| 122 | + | |
| 123 | +*, *::before, *::after { box-sizing: border-box; } | |
| 124 | + | |
| 125 | +html { -webkit-text-size-adjust: 100%; } | |
| 126 | + | |
| 127 | +body { | |
| 128 | + margin: 0; | |
| 129 | + background: var(--bg); | |
| 130 | + color: var(--text); | |
| 131 | + font-family: var(--font); | |
| 132 | + font-size: 14px; | |
| 133 | + line-height: 1.5; | |
| 134 | + min-height: 100vh; | |
| 135 | + display: flex; | |
| 136 | + flex-direction: column; | |
| 137 | +} | |
| 138 | + | |
| 139 | +a { color: var(--accent); text-decoration: none; } | |
| 140 | +a:hover { text-decoration: underline; } | |
| 141 | + | |
| 142 | +h1, h2, h3, h4 { margin: 0; font-weight: 600; line-height: 1.25; } | |
| 143 | +h1 { font-size: 24px; } | |
| 144 | +h2 { font-size: 20px; } | |
| 145 | +h3 { font-size: 16px; } | |
| 146 | +p { margin: 0 0 12px; } | |
| 147 | + | |
| 148 | +code, pre, kbd, .text-mono { font-family: var(--mono); } | |
| 149 | +code { font-size: 85%; padding: .2em .4em; background: var(--bg-muted); border-radius: 4px; } | |
| 150 | + | |
| 151 | +button { font: inherit; } | |
| 152 | + | |
| 153 | +.icon { width: 16px; height: 16px; flex-shrink: 0; vertical-align: text-bottom; } | |
| 154 | + | |
| 155 | +.container { width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 24px; } | |
| 156 | + | |
| 157 | +.page { flex: 1; padding: 24px 0 48px; } | |
| 158 | + | |
| 159 | +.text-muted { color: var(--text-muted); } | |
| 160 | +.text-small { font-size: 12px; } | |
| 161 | +.text-success { color: var(--success); } | |
| 162 | +.text-danger { color: var(--danger); } | |
| 163 | +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } | |
| 164 | +.dot { color: var(--text-subtle); margin: 0 4px; } | |
| 165 | +.push-right { margin-left: auto; } | |
| 166 | +[hidden] { display: none !important; } | |
| 167 | + | |
| 168 | +/* ---------- Header & footer ---------- */ | |
| 169 | + | |
| 170 | +.site-header { background: var(--header-bg); color: var(--header-text); } | |
| 171 | +.site-header-inner { | |
| 172 | + max-width: 1280px; margin: 0 auto; padding: 0 24px; height: 60px; | |
| 173 | + display: flex; align-items: center; gap: 20px; | |
| 174 | +} | |
| 175 | +.brand { display: inline-flex; align-items: center; gap: 10px; color: #fff; font-weight: 600; font-size: 17px; letter-spacing: -0.01em; } | |
| 176 | +.brand:hover { text-decoration: none; color: #fff; } | |
| 177 | +.brand-mark { width: 30px; height: 30px; color: #93b4ff; } | |
| 178 | +.site-nav { display: flex; gap: 4px; } | |
| 179 | +.site-nav a { color: var(--header-text); padding: 6px 10px; border-radius: var(--radius); font-weight: 500; } | |
| 180 | +.site-nav a:hover { background: rgba(255, 255, 255, 0.08); text-decoration: none; } | |
| 181 | +.site-header-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } | |
| 182 | +.header-link { color: var(--header-text); font-weight: 500; } | |
| 183 | +.header-link:hover { color: #fff; } | |
| 184 | +.header-button { | |
| 185 | + display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; | |
| 186 | + color: var(--header-text); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: var(--radius); font-weight: 500; | |
| 187 | +} | |
| 188 | +.header-button:hover { border-color: rgba(255, 255, 255, 0.45); text-decoration: none; color: #fff; } | |
| 189 | +.header-avatar { display: inline-flex; align-items: center; gap: 4px; color: var(--header-muted); cursor: pointer; } | |
| 190 | +.header-avatar:hover { color: #fff; } | |
| 191 | +.header-avatar .icon { width: 14px; height: 14px; } | |
| 192 | + | |
| 193 | +.site-footer { border-top: 1px solid var(--border-muted); padding: 24px 0 32px; color: var(--text-muted); font-size: 12px; } | |
| 194 | +.site-footer .container { display: flex; align-items: center; flex-wrap: wrap; } | |
| 195 | +.site-footer a { color: var(--text-muted); } | |
| 196 | + | |
| 197 | +/* ---------- Buttons ---------- */ | |
| 198 | + | |
| 199 | +.btn { | |
| 200 | + display: inline-flex; align-items: center; justify-content: center; gap: 6px; | |
| 201 | + padding: 5px 14px; min-height: 32px; | |
| 202 | + font-size: 14px; font-weight: 500; line-height: 20px; white-space: nowrap; | |
| 203 | + color: var(--text); background: var(--btn-bg); border: 1px solid var(--btn-border); | |
| 204 | + border-radius: var(--radius); box-shadow: var(--shadow-sm); cursor: pointer; user-select: none; | |
| 205 | + transition: background-color .12s, border-color .12s; | |
| 206 | +} | |
| 207 | +.btn:hover { background: var(--btn-hover); text-decoration: none; } | |
| 208 | +.btn:focus-visible, .input:focus-visible, summary:focus-visible, .clone-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } | |
| 209 | +.btn.selected { background: var(--bg-muted); border-color: var(--border); font-weight: 600; } | |
| 210 | +.btn.disabled, .btn:disabled { color: var(--text-subtle); cursor: default; pointer-events: none; opacity: .7; } | |
| 211 | +.btn-sm { padding: 3px 10px; min-height: 28px; font-size: 12px; } | |
| 212 | +.btn-lg { padding: 9px 20px; font-size: 16px; } | |
| 213 | +.btn-block { display: flex; width: 100%; } | |
| 214 | +.btn-icon { padding-left: 7px; padding-right: 7px; } | |
| 215 | +.btn-mono { font-family: var(--mono); font-weight: 500; } | |
| 216 | +.btn-primary { color: #fff; background: var(--accent); border-color: rgba(0, 0, 0, .12); } | |
| 217 | +.btn-primary:hover { background: var(--accent-hover); } | |
| 218 | +.btn-success { color: #fff; background: var(--success-emphasis); border-color: rgba(0, 0, 0, .12); } | |
| 219 | +.btn-success:hover { background: var(--success-emphasis-hover); } | |
| 220 | +.btn-danger { color: var(--danger); } | |
| 221 | +.btn-danger:hover { color: #fff; background: var(--danger-emphasis); border-color: var(--danger-emphasis); } | |
| 222 | +.btn-ghost { background: transparent; border-color: transparent; box-shadow: none; color: var(--text-muted); } | |
| 223 | +.btn-ghost:hover { background: var(--bg-muted); color: var(--text); } | |
| 224 | +.btn-group { display: inline-flex; } | |
| 225 | +.btn-group > .btn { border-radius: 0; } | |
| 226 | +.btn-group > .btn + .btn { margin-left: -1px; } | |
| 227 | +.btn-group > .btn:first-child { border-radius: var(--radius) 0 0 var(--radius); } | |
| 228 | +.btn-group > .btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; } | |
| 229 | + | |
| 230 | +/* ---------- Forms ---------- */ | |
| 231 | + | |
| 232 | +.input { | |
| 233 | + display: block; width: 100%; padding: 5px 12px; min-height: 32px; | |
| 234 | + font: inherit; font-size: 14px; line-height: 20px; color: var(--text); | |
| 235 | + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); | |
| 236 | + box-shadow: inset 0 1px 0 rgba(208, 215, 222, 0.2); | |
| 237 | +} | |
| 238 | +.input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); } | |
| 239 | +.input::placeholder { color: var(--text-subtle); } | |
| 240 | +.input:disabled { background: var(--bg-subtle); color: var(--text-muted); } | |
| 241 | +.input-sm { padding: 3px 8px; min-height: 28px; font-size: 13px; } | |
| 242 | +.input-mono { font-family: var(--mono); font-size: 13px; } | |
| 243 | +textarea.input { resize: vertical; min-height: 80px; } | |
| 244 | +select.input { width: auto; padding-right: 28px; } | |
| 245 | +.input-static { display: flex; align-items: center; gap: 6px; background: var(--bg-subtle); font-weight: 500; } | |
| 246 | + | |
| 247 | +.form { display: flex; flex-direction: column; gap: 16px; } | |
| 248 | +.field { display: flex; flex-direction: column; gap: 6px; margin: 0; border: 0; padding: 0; min-width: 0; } | |
| 249 | +.field-label { font-weight: 600; font-size: 14px; } | |
| 250 | +.field-hint { font-size: 12px; color: var(--text-muted); } | |
| 251 | +.field-row { display: flex; align-items: flex-end; gap: 8px; } | |
| 252 | +.field-row-sep { font-size: 20px; color: var(--text-muted); padding-bottom: 2px; } | |
| 253 | +.field-grow { flex: 1; } | |
| 254 | +.form-actions { display: flex; gap: 8px; align-items: center; } | |
| 255 | +.form-actions-end { justify-content: flex-end; } | |
| 256 | +.inline-form { display: flex; gap: 8px; margin-top: 12px; } | |
| 257 | +.inline-form .input { max-width: 320px; } | |
| 258 | + | |
| 259 | +.input-group { display: flex; } | |
| 260 | +.input-group .input { border-radius: var(--radius) 0 0 var(--radius); min-width: 0; } | |
| 261 | +.input-group .btn { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; box-shadow: none; } | |
| 262 | +.input-group-label { | |
| 263 | + display: flex; align-items: center; padding: 0 12px; font-size: 12px; font-weight: 600; | |
| 264 | + background: var(--bg-subtle); border: 1px solid var(--border); border-right: 0; border-radius: var(--radius) 0 0 var(--radius); | |
| 265 | +} | |
| 266 | +.input-group-label + .input { border-radius: 0; } | |
| 267 | + | |
| 268 | +.radio-cards { gap: 8px; } | |
| 269 | +.radio-card, .checkbox { | |
| 270 | + display: flex; align-items: flex-start; gap: 10px; padding: 12px 14px; | |
| 271 | + border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; | |
| 272 | +} | |
| 273 | +.radio-card:has(input:checked) { border-color: var(--accent); background: var(--accent-subtle); } | |
| 274 | +.radio-card input, .checkbox input { margin: 3px 0 0; accent-color: var(--accent); } | |
| 275 | +.radio-card .icon { margin-top: 2px; color: var(--text-muted); } | |
| 276 | +.radio-card > span, .checkbox > span { display: flex; flex-direction: column; gap: 2px; } | |
| 277 | +.radio-card .text-muted, .checkbox .text-muted { font-size: 12px; } | |
| 278 | +.checkbox { border-style: dashed; } | |
| 279 | + | |
| 280 | +/* ---------- Boxes, labels, counters ---------- */ | |
| 281 | + | |
| 282 | +.box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); } | |
| 283 | +.box + .box { margin-top: 16px; } | |
| 284 | +.box-header { | |
| 285 | + padding: 12px 16px; background: var(--bg-subtle); border-bottom: 1px solid var(--border); | |
| 286 | + border-radius: var(--radius) var(--radius) 0 0; | |
| 287 | +} | |
| 288 | +.box-body { padding: 16px; } | |
| 289 | +.box-footer { padding: 12px 16px; background: var(--bg-subtle); border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); } | |
| 290 | +.box-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--border-muted); } | |
| 291 | +.box-row:first-child { border-top: 0; } | |
| 292 | +.box-row:hover { background: var(--bg-subtle); } | |
| 293 | + | |
| 294 | +.label { | |
| 295 | + display: inline-flex; align-items: center; padding: 0 7px; height: 20px; | |
| 296 | + font-size: 12px; font-weight: 500; line-height: 18px; color: var(--text-muted); | |
| 297 | + border: 1px solid var(--border); border-radius: 2em; white-space: nowrap; vertical-align: middle; | |
| 298 | +} | |
| 299 | +.label-added { color: var(--success); border-color: currentColor; } | |
| 300 | +.label-deleted { color: var(--danger); border-color: currentColor; } | |
| 301 | +.label-renamed, .label-copied { color: var(--merged); border-color: currentColor; } | |
| 302 | + | |
| 303 | +.counter { | |
| 304 | + display: inline-block; min-width: 20px; padding: 0 6px; font-size: 12px; font-weight: 500; line-height: 18px; | |
| 305 | + text-align: center; color: var(--text); background: var(--bg-muted); border-radius: 2em; | |
| 306 | +} | |
| 307 | + | |
| 308 | +.branch-label { | |
| 309 | + display: inline-block; padding: 0 6px; font-family: var(--mono); font-size: 12px; line-height: 20px; | |
| 310 | + color: var(--accent); background: var(--accent-subtle); border-radius: var(--radius); max-width: 100%; | |
| 311 | + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; | |
| 312 | +} | |
| 313 | + | |
| 314 | +.avatar { | |
| 315 | + display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; | |
| 316 | + border-radius: 50%; color: #fff; font-weight: 600; line-height: 1; vertical-align: middle; | |
| 317 | + background: hsl(var(--hue) 52% 46%); user-select: none; | |
| 318 | +} | |
| 319 | + | |
| 320 | +.flash { | |
| 321 | + display: flex; align-items: flex-start; gap: 10px; padding: 12px 16px; margin-bottom: 16px; | |
| 322 | + border: 1px solid var(--accent-border); background: var(--accent-subtle); border-radius: var(--radius); | |
| 323 | +} | |
| 324 | +.flash .icon { margin-top: 2px; color: var(--accent); } | |
| 325 | +.flash-success { border-color: var(--success-border); background: var(--success-subtle); } | |
| 326 | +.flash-success .icon { color: var(--success); } | |
| 327 | +.flash-error { border-color: var(--danger-border); background: var(--danger-subtle); } | |
| 328 | +.flash-error .icon { color: var(--danger); } | |
| 329 | + | |
| 330 | +.banner { | |
| 331 | + display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; | |
| 332 | + padding: 10px 16px; margin-bottom: 16px; background: var(--attention-subtle); | |
| 333 | + border: 1px solid var(--attention-border); border-radius: var(--radius); | |
| 334 | +} | |
| 335 | +.banner .icon { color: var(--text-muted); } | |
| 336 | + | |
| 337 | +.blankslate { padding: 48px 24px; text-align: center; color: var(--text-muted); } | |
| 338 | +.blankslate > .icon { width: 28px; height: 28px; margin-bottom: 12px; color: var(--text-subtle); } | |
| 339 | +.blankslate h3 { color: var(--text); margin-bottom: 6px; font-size: 18px; } | |
| 340 | +.blankslate p { max-width: 520px; margin: 0 auto 12px; } | |
| 341 | +.blankslate-sm { padding: 28px 16px; } | |
| 342 | +.blankslate-sm h3 { font-size: 16px; } | |
| 343 | +.blankslate-page { padding: 96px 24px; } | |
| 344 | +.blankslate-page h1 { color: var(--text); margin-bottom: 8px; } | |
| 345 | +.blankslate-code { font-size: 64px; font-weight: 700; color: var(--border); line-height: 1; margin-bottom: 16px; letter-spacing: -0.04em; } | |
| 346 | +.blankslate-actions { display: flex; gap: 8px; justify-content: center; margin-top: 20px; } | |
| 347 | + | |
| 348 | +.command { | |
| 349 | + margin: 8px 0 16px; padding: 12px 16px; font-size: 12px; line-height: 1.6; | |
| 350 | + background: var(--bg-subtle); border: 1px solid var(--border-muted); border-radius: var(--radius); | |
| 351 | + overflow-x: auto; white-space: pre; | |
| 352 | +} | |
| 353 | +.command-sm { padding: 8px 10px; font-size: 11px; margin: 0; } | |
| 354 | + | |
| 355 | +.hint { margin: 8px 0 0; font-size: 12px; color: var(--text-muted); } | |
| 356 | + | |
| 357 | +/* ---------- Dropdowns ---------- */ | |
| 358 | + | |
| 359 | +.dropdown { position: relative; display: inline-block; } | |
| 360 | +.dropdown > summary { list-style: none; } | |
| 361 | +.dropdown > summary::-webkit-details-marker { display: none; } | |
| 362 | +.dropdown-menu { | |
| 363 | + position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; min-width: 200px; | |
| 364 | + padding: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-lg); | |
| 365 | + box-shadow: var(--shadow-md); color: var(--text); | |
| 366 | +} | |
| 367 | +.dropdown-right .dropdown-menu { left: auto; right: 0; } | |
| 368 | +.dropdown-wide { width: 320px; } | |
| 369 | +.dropdown-header { padding: 6px 14px 8px; font-size: 12px; color: var(--text-muted); border-bottom: 1px solid var(--border-muted); margin-bottom: 6px; } | |
| 370 | +.dropdown-header strong { color: var(--text); } | |
| 371 | +.dropdown-section { padding: 8px 14px 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--text-subtle); } | |
| 372 | +.dropdown-item { | |
| 373 | + display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 14px; | |
| 374 | + color: var(--text); background: none; border: 0; text-align: left; cursor: pointer; font-size: 14px; | |
| 375 | +} | |
| 376 | +.dropdown-item:hover { background: var(--bg-hover); text-decoration: none; } | |
| 377 | +.dropdown-item .icon { color: var(--text-muted); } | |
| 378 | +.dropdown-item.selected { font-weight: 600; } | |
| 379 | +.dropdown-divider { height: 1px; margin: 6px 0; background: var(--border-muted); } | |
| 380 | +.dropdown-filter { padding: 0 10px 6px; } | |
| 381 | +.dropdown-scroll { max-height: 320px; overflow-y: auto; } | |
| 382 | +.icon-spacer { display: inline-block; width: 16px; flex-shrink: 0; } | |
| 383 | + | |
| 384 | +/* ---------- Page headings ---------- */ | |
| 385 | + | |
| 386 | +.page-header { margin-bottom: 24px; } | |
| 387 | +.page-header h1 { margin-bottom: 4px; } | |
| 388 | +.page-subheader { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } | |
| 389 | +.page-subheader h2 { font-size: 20px; } | |
| 390 | +.page-subheader-stacked { flex-direction: column; align-items: flex-start; gap: 4px; } | |
| 391 | +.page-subheader-stacked p { margin: 0; } | |
| 392 | +.section { margin-top: 32px; } | |
| 393 | +.section:first-child { margin-top: 0; } | |
| 394 | +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } | |
| 395 | +.section-header h2 { font-size: 16px; display: flex; align-items: center; gap: 8px; } | |
| 396 | +.section-header a { display: inline-flex; align-items: center; gap: 4px; font-size: 13px; } | |
| 397 | +.narrow { max-width: 680px; margin: 0 auto; } | |
| 398 | +.narrow-wide { max-width: 860px; } | |
| 399 | + | |
| 400 | +/* ---------- Landing & lists ---------- */ | |
| 401 | + | |
| 402 | +.hero { padding: 56px 0 40px; max-width: 720px; } | |
| 403 | +.hero h1 { font-size: 44px; letter-spacing: -0.03em; margin-bottom: 16px; } | |
| 404 | +.hero-lead { font-size: 18px; color: var(--text-muted); margin-bottom: 24px; } | |
| 405 | +.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; } | |
| 406 | + | |
| 407 | +.repo-list { list-style: none; margin: 0; padding: 0; } | |
| 408 | +.repo-item { padding: 16px; border-top: 1px solid var(--border-muted); } | |
| 409 | +.repo-item:first-child { border-top: 0; } | |
| 410 | +.repo-item h3 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 16px; } | |
| 411 | +.repo-item h3 a { font-weight: 600; } | |
| 412 | +.repo-item h3 .text-muted { font-weight: 400; } | |
| 413 | +.repo-item-desc { margin: 6px 0 0; color: var(--text-muted); } | |
| 414 | +.repo-item-meta { margin-top: 8px; font-size: 12px; color: var(--text-muted); } | |
| 415 | + | |
| 416 | +.issue-row { align-items: flex-start; } | |
| 417 | +.issue-icon { padding-top: 2px; } | |
| 418 | +.issue-main { flex: 1; min-width: 0; } | |
| 419 | +.issue-title { color: var(--text); font-weight: 600; font-size: 15px; margin-right: 6px; } | |
| 420 | +.issue-title:hover { color: var(--accent); } | |
| 421 | +.issue-meta { margin-top: 4px; font-size: 12px; color: var(--text-muted); display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; } | |
| 422 | +.issue-comments { display: inline-flex; align-items: center; gap: 4px; color: var(--text-muted); font-size: 12px; } | |
| 423 | +.branch-pair { display: inline-flex; align-items: center; gap: 4px; } | |
| 424 | +.state-color-open { color: var(--success); } | |
| 425 | +.state-color-closed { color: var(--danger); } | |
| 426 | +.state-color-merged { color: var(--merged); } | |
| 427 | + | |
| 428 | +/* ---------- Dashboard & profile ---------- */ | |
| 429 | + | |
| 430 | +.layout-dashboard { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 32px; } | |
| 431 | +.sidebar-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } | |
| 432 | +.sidebar-header h2 { font-size: 14px; } | |
| 433 | +.sidebar-repos { list-style: none; padding: 0; margin: 0; } | |
| 434 | +.sidebar-repos a { display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin: 0 -8px; color: var(--text); border-radius: var(--radius); } | |
| 435 | +.sidebar-repos a:hover { background: var(--bg-hover); text-decoration: none; } | |
| 436 | +.sidebar-repos .icon { color: var(--text-muted); } | |
| 437 | +.welcome-box { margin-bottom: 32px; background: linear-gradient(135deg, var(--accent-subtle), var(--bg) 60%); } | |
| 438 | +.welcome-box h2 { margin-bottom: 4px; } | |
| 439 | +.steps { margin: 12px 0 0; padding-left: 20px; } | |
| 440 | +.steps li { margin: 6px 0; } | |
| 441 | + | |
| 442 | +.layout-profile { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 40px; } | |
| 443 | +.profile-card .avatar { width: 100% !important; height: auto !important; aspect-ratio: 1; max-width: 260px; font-size: 96px !important; margin-bottom: 16px; } | |
| 444 | +.profile-name { font-size: 24px; } | |
| 445 | +.profile-login { font-size: 20px; font-weight: 300; color: var(--text-muted); } | |
| 446 | +.profile-bio { margin: 12px 0; } | |
| 447 | +.profile-card .btn-block { margin: 16px 0; } | |
| 448 | +.profile-meta { display: flex; align-items: center; gap: 6px; margin-top: 12px; color: var(--text-muted); font-size: 13px; } | |
| 449 | + | |
| 450 | +/* ---------- Auth ---------- */ | |
| 451 | + | |
| 452 | +.auth-page .container { display: flex; justify-content: center; } | |
| 453 | +.auth-card { width: 100%; max-width: 380px; padding-top: 32px; } | |
| 454 | +.auth-card h1 { font-size: 24px; font-weight: 400; text-align: center; margin-bottom: 20px; letter-spacing: -0.01em; } | |
| 455 | +.auth-card .box-body { background: var(--bg-subtle); } | |
| 456 | +.auth-alt { margin-top: 16px; padding: 16px; text-align: center; border: 1px solid var(--border); border-radius: var(--radius); } | |
| 457 | + | |
| 458 | +/* ---------- Settings ---------- */ | |
| 459 | + | |
| 460 | +.settings-header { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; } | |
| 461 | +.settings-header h1 { font-size: 20px; } | |
| 462 | +.layout-settings { display: grid; grid-template-columns: 240px minmax(0, 1fr); gap: 32px; } | |
| 463 | +.side-nav { display: flex; flex-direction: column; gap: 2px; } | |
| 464 | +.side-nav-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; color: var(--text); border-radius: var(--radius); position: relative; } | |
| 465 | +.side-nav-item .icon { color: var(--text-muted); } | |
| 466 | +.side-nav-item:hover { background: var(--bg-hover); text-decoration: none; } | |
| 467 | +.side-nav-item.selected { background: var(--bg-muted); font-weight: 600; } | |
| 468 | +.side-nav-item.selected::before { content: ""; position: absolute; left: -8px; top: 6px; bottom: 6px; width: 4px; border-radius: 4px; background: var(--accent); } | |
| 469 | +.settings-main { max-width: 760px; } | |
| 470 | +.settings-section { margin-bottom: 40px; } | |
| 471 | +.settings-title { font-size: 20px; font-weight: 400; padding-bottom: 8px; margin-bottom: 16px; border-bottom: 1px solid var(--border-muted); } | |
| 472 | +.key-row { align-items: flex-start; } | |
| 473 | +.key-icon { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 4px 8px; color: var(--text-muted); } | |
| 474 | +.key-icon .icon { width: 28px; height: 28px; } | |
| 475 | +.key-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; } | |
| 476 | +.key-fingerprint { display: block; padding: 0; background: none; color: var(--text-muted); font-size: 12px; word-break: break-all; } | |
| 477 | +.help-box p { margin-bottom: 4px; } | |
| 478 | +.help-box p + .input-group { margin-top: 4px; } | |
| 479 | +.collaborator-row { gap: 10px; } | |
| 480 | +.danger-zone { border-color: var(--danger); } | |
| 481 | +.danger-zone h3 { margin-bottom: 4px; font-size: 15px; } | |
| 482 | + | |
| 483 | +/* ---------- Repository header ---------- */ | |
| 484 | + | |
| 485 | +.repo-page { padding-top: 24px; } | |
| 486 | +.repo-header { background: var(--bg-subtle); border-bottom: 1px solid var(--border); padding-top: 20px; } | |
| 487 | +.repo-title { display: flex; align-items: center; gap: 6px; font-size: 20px; flex-wrap: wrap; margin-bottom: 16px; } | |
| 488 | +.repo-icon { display: inline-flex; color: var(--text-muted); } | |
| 489 | +.repo-owner { font-weight: 400; } | |
| 490 | +.repo-sep { color: var(--text-muted); } | |
| 491 | +.repo-name { font-weight: 600; } | |
| 492 | +.repo-title .label { margin-left: 4px; } | |
| 493 | +.repo-description { font-size: 16px; color: var(--text-muted); } | |
| 494 | + | |
| 495 | +.tabs { display: flex; gap: 4px; overflow-x: auto; scrollbar-width: none; position: relative; z-index: 0; } | |
| 496 | +.tabs::-webkit-scrollbar { display: none; } | |
| 497 | +.tab { | |
| 498 | + display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px 10px; | |
| 499 | + color: var(--text); white-space: nowrap; border-bottom: 2px solid transparent; position: relative; | |
| 500 | +} | |
| 501 | +.tab .icon { color: var(--text-muted); } | |
| 502 | +.tab:hover { text-decoration: none; } | |
| 503 | +.tab:hover::after { content: ""; position: absolute; inset: 4px 0 6px; background: var(--bg-hover); border-radius: var(--radius); z-index: -1; } | |
| 504 | +.tab.selected { font-weight: 600; border-bottom-color: var(--accent); } | |
| 505 | +.tab.selected .icon { color: var(--text); } | |
| 506 | +.tabs-sub { border-bottom: 1px solid var(--border); margin-bottom: 24px; align-items: center; } | |
| 507 | +.tabs-extra { margin-left: auto; font-family: var(--mono); font-size: 12px; padding-left: 12px; white-space: nowrap; } | |
| 508 | + | |
| 509 | +/* ---------- Code browser ---------- */ | |
| 510 | + | |
| 511 | +.layout-repo { display: grid; grid-template-columns: minmax(0, 1fr) 296px; gap: 24px; } | |
| 512 | +.layout-full { display: block; } | |
| 513 | +.code-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } | |
| 514 | +.toolbar-link { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 14px; } | |
| 515 | +.toolbar-link strong { color: var(--text); } | |
| 516 | +.toolbar-link:hover { color: var(--accent); text-decoration: none; } | |
| 517 | +.toolbar-link:hover strong { color: var(--accent); } | |
| 518 | +.toolbar-spacer { flex: 1; } | |
| 519 | +.toolbar-text { color: var(--text-muted); } | |
| 520 | + | |
| 521 | +.ref-selector .ref-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; font-weight: 600; } | |
| 522 | +.ref-selector summary .icon:first-child { color: var(--text-muted); } | |
| 523 | + | |
| 524 | +.breadcrumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 2px; font-size: 16px; min-width: 0; word-break: break-all; } | |
| 525 | +.crumb-sep { color: var(--text-muted); margin: 0 4px; } | |
| 526 | + | |
| 527 | +.latest-commit { display: flex; align-items: center; gap: 8px; font-size: 14px; min-width: 0; } | |
| 528 | +.latest-author { white-space: nowrap; } | |
| 529 | +.latest-message { color: var(--text-muted); flex: 1; } | |
| 530 | +.latest-message:hover { color: var(--accent); } | |
| 531 | +.latest-meta { display: inline-flex; align-items: center; color: var(--text-muted); font-size: 12px; white-space: nowrap; } | |
| 532 | +.history-link { display: inline-flex; align-items: center; gap: 4px; color: var(--text); font-size: 13px; white-space: nowrap; margin-left: 8px; } | |
| 533 | +.history-link .icon { color: var(--text-muted); } | |
| 534 | +.latest-commit-box { margin-bottom: 16px; } | |
| 535 | +.latest-commit-box .box-header { border-bottom: 0; border-radius: var(--radius); } | |
| 536 | + | |
| 537 | +.files { width: 100%; border-collapse: collapse; table-layout: fixed; } | |
| 538 | +.files td { padding: 8px 16px; border-top: 1px solid var(--border-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 539 | +.files tr:first-child td { border-top: 0; } | |
| 540 | +.files tr:hover td { background: var(--bg-subtle); } | |
| 541 | +.file-icon { width: 44px; padding-right: 0 !important; color: var(--text-muted); } | |
| 542 | +.file-icon.is-dir { color: var(--folder); } | |
| 543 | +.file-icon.is-dir .icon { fill: currentColor; fill-opacity: .25; } | |
| 544 | +.file-name { width: 32%; } | |
| 545 | +.file-name a { color: var(--text); } | |
| 546 | +.file-name a:hover { color: var(--accent); } | |
| 547 | +.file-message a { color: var(--text-muted); } | |
| 548 | +.file-message a:hover { color: var(--accent); } | |
| 549 | +.file-age { width: 130px; text-align: right; color: var(--text-muted); } | |
| 550 | + | |
| 551 | +.readme { margin-top: 24px; } | |
| 552 | +.readme-header { display: flex; align-items: center; gap: 8px; background: var(--bg); position: sticky; top: 0; z-index: 2; } | |
| 553 | +.readme-header .icon { color: var(--text-muted); } | |
| 554 | +.readme .markdown-body { padding: 24px 32px 32px; } | |
| 555 | +.readme-plain { margin: 0; padding: 16px; white-space: pre-wrap; font-size: 13px; } | |
| 556 | + | |
| 557 | +.repo-sidebar h2 { font-size: 16px; margin-bottom: 12px; } | |
| 558 | +.about-description { font-size: 15px; } | |
| 559 | +.about-list { list-style: none; padding: 0; margin: 0 0 16px; } | |
| 560 | +.about-list li { margin: 8px 0; } | |
| 561 | +.about-list a { display: inline-flex; align-items: center; gap: 8px; color: var(--text-muted); } | |
| 562 | +.about-list a:hover { color: var(--accent); text-decoration: none; } | |
| 563 | +.repo-sidebar > a { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); } | |
| 564 | + | |
| 565 | +.clone-dropdown .dropdown-menu { padding: 0 0 6px; } | |
| 566 | +.clone-dropdown .dropdown-divider { margin-top: 0; } | |
| 567 | +.clone-panel { padding: 12px 16px 14px; } | |
| 568 | +.clone-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--border-muted); margin: 0 -16px 12px; padding: 0 16px; } | |
| 569 | +.clone-tab { padding: 4px 0 8px; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--text-muted); cursor: pointer; font-weight: 600; font-size: 13px; } | |
| 570 | +.clone-tab.selected { color: var(--text); border-bottom-color: var(--accent); } | |
| 571 | + | |
| 572 | +.quick-setup .box-header { display: flex; flex-direction: column; gap: 2px; } | |
| 573 | +.quick-setup .box-header h2 { font-size: 16px; } | |
| 574 | +.quick-setup h3 { font-size: 14px; margin: 24px 0 8px; } | |
| 575 | + | |
| 576 | +/* ---------- File view ---------- */ | |
| 577 | + | |
| 578 | +.blob-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 8px 16px; } | |
| 579 | +.blob-info { display: flex; align-items: center; color: var(--text-muted); font-size: 12px; font-family: var(--mono); } | |
| 580 | +.blob-actions { display: flex; align-items: center; gap: 8px; } | |
| 581 | +.blob-box .markdown-body { padding: 24px 32px 32px; } | |
| 582 | +.blob-image { padding: 24px; text-align: center; background-image: conic-gradient(var(--bg-subtle) 25%, var(--bg) 0 50%, var(--bg-subtle) 0 75%, var(--bg) 0); background-size: 16px 16px; } | |
| 583 | +.blob-image img { max-width: 100%; } | |
| 584 | +.blob-note { padding: 40px 16px; text-align: center; color: var(--text-muted); } | |
| 585 | + | |
| 586 | +.code-scroll, .diff-scroll { overflow-x: auto; } | |
| 587 | +.code-view, .diff-table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; line-height: 20px; tab-size: 4; } | |
| 588 | +.code-view td, .diff-table td { padding: 0 10px; vertical-align: top; } | |
| 589 | +.code-view .num, .diff-table .num { | |
| 590 | + width: 1%; min-width: 50px; text-align: right; color: var(--text-subtle); user-select: none; white-space: nowrap; | |
| 591 | +} | |
| 592 | +.code-view .num a { color: inherit; display: block; } | |
| 593 | +.code-view .num a:hover { color: var(--text); text-decoration: none; } | |
| 594 | +.code-view .code, .diff-table .code { white-space: pre; color: var(--text); } | |
| 595 | +.code-view tr:first-child td { padding-top: 8px; } | |
| 596 | +.code-view tr:last-child td { padding-bottom: 8px; } | |
| 597 | +.code-view tr.highlighted td { background: var(--line-highlight); } | |
| 598 | + | |
| 599 | +/* ---------- Commits ---------- */ | |
| 600 | + | |
| 601 | +.commit-group { margin-bottom: 24px; } | |
| 602 | +.commit-group-title { display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 500; color: var(--text-muted); margin-bottom: 8px; } | |
| 603 | +.commit-row { justify-content: space-between; } | |
| 604 | +.commit-main { min-width: 0; flex: 1; } | |
| 605 | +.commit-title { color: var(--text); font-weight: 600; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 606 | +.commit-title:hover { color: var(--accent); } | |
| 607 | +.commit-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; color: var(--text-muted); } | |
| 608 | +.commit-meta strong { color: var(--text); font-weight: 600; } | |
| 609 | +.commit-actions { display: flex; align-items: center; gap: 4px; } | |
| 610 | +.commit-row-compact { gap: 8px; padding-top: 8px; padding-bottom: 8px; } | |
| 611 | +.commit-row-compact .commit-title { flex: 1; font-weight: 400; } | |
| 612 | +.commit-row-author { white-space: nowrap; } | |
| 613 | +.pagination { display: flex; justify-content: center; gap: 0; margin-top: 16px; } | |
| 614 | +.pagination .btn:first-child { border-radius: var(--radius) 0 0 var(--radius); } | |
| 615 | +.pagination .btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; margin-left: -1px; } | |
| 616 | + | |
| 617 | +.commit-header { margin-bottom: 24px; } | |
| 618 | +.commit-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } | |
| 619 | +.commit-summary { font-size: 20px; word-break: break-word; } | |
| 620 | +.commit-body { margin: 12px 0 0; white-space: pre-wrap; font-size: 13px; color: var(--text-muted); } | |
| 621 | +.commit-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 13px; } | |
| 622 | +.commit-author { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; color: var(--text-muted); } | |
| 623 | +.commit-author strong { color: var(--text); } | |
| 624 | +.commit-ids { display: flex; gap: 16px; color: var(--text-muted); flex-wrap: wrap; align-items: center; } | |
| 625 | +.commit-ids > span { display: inline-flex; align-items: center; gap: 4px; } | |
| 626 | +.commit-ids .text-mono { color: var(--text); font-size: 12px; } | |
| 627 | + | |
| 628 | +/* ---------- Diffs ---------- */ | |
| 629 | + | |
| 630 | +.diff-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; } | |
| 631 | +.diff-mini-stat { font-family: var(--mono); font-size: 11px; white-space: nowrap; } | |
| 632 | +.diff-file { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 16px; } | |
| 633 | +.diff-file-header { | |
| 634 | + display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 6px; position: sticky; top: 0; z-index: 3; | |
| 635 | + background: var(--bg-subtle); border-bottom: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; | |
| 636 | +} | |
| 637 | +.diff-file.collapsed .diff-file-header { border-bottom: 0; border-radius: var(--radius); } | |
| 638 | +.diff-file.collapsed .diff-body { display: none; } | |
| 639 | +.diff-file.collapsed .diff-toggle .icon { transform: rotate(-90deg); } | |
| 640 | +.diff-toggle { display: inline-flex; padding: 4px; background: none; border: 0; border-radius: var(--radius); color: var(--text-muted); cursor: pointer; } | |
| 641 | +.diff-toggle:hover { background: var(--bg-muted); color: var(--text); } | |
| 642 | +.diff-counts { font-family: var(--mono); font-size: 12px; white-space: nowrap; } | |
| 643 | +.diff-blocks { display: inline-flex; gap: 1px; } | |
| 644 | +.block { width: 8px; height: 8px; border-radius: 1px; background: var(--border); } | |
| 645 | +.block-add { background: var(--success-emphasis); } | |
| 646 | +.block-del { background: var(--danger-emphasis); } | |
| 647 | +.diff-path { font-family: var(--mono); font-size: 12px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 648 | +.diff-body { border-radius: 0 0 var(--radius) var(--radius); overflow: hidden; } | |
| 649 | +.diff-note { padding: 24px 16px; text-align: center; color: var(--text-muted); } | |
| 650 | +.diff-table .num { min-width: 44px; } | |
| 651 | +.diff-table .num + .num { border-right: 1px solid var(--border-muted); } | |
| 652 | +.diff-line.add td { background: var(--diff-add-bg); } | |
| 653 | +.diff-line.add .num { background: var(--diff-add-num); } | |
| 654 | +.diff-line.del td { background: var(--diff-del-bg); } | |
| 655 | +.diff-line.del .num { background: var(--diff-del-num); } | |
| 656 | +.diff-line .marker { display: inline-block; width: 14px; margin-left: -4px; color: var(--text-muted); user-select: none; } | |
| 657 | +.diff-hunk td { background: var(--diff-hunk-bg); color: var(--text-muted); padding-top: 4px; padding-bottom: 4px; } | |
| 658 | +.diff-hunk .code { color: var(--text-muted); } | |
| 659 | + | |
| 660 | +/* ---------- Branches & tags ---------- */ | |
| 661 | + | |
| 662 | +.branch-row { gap: 16px; } | |
| 663 | +.branch-main { flex: 1; min-width: 0; } | |
| 664 | +.branch-name { font-family: var(--mono); font-size: 13px; font-weight: 600; padding: 2px 6px; background: var(--accent-subtle); border-radius: var(--radius); margin-right: 6px; } | |
| 665 | +.branch-meta { margin-top: 6px; color: var(--text-muted); font-size: 12px; } | |
| 666 | +.branch-actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; width: 200px; flex-shrink: 0; } | |
| 667 | +.branch-header { background: var(--bg-subtle); border-radius: var(--radius) var(--radius) 0 0; font-size: 12px; font-weight: 600; color: var(--text-muted); padding-top: 8px; padding-bottom: 8px; } | |
| 668 | +.branch-header:hover { background: var(--bg-subtle); } | |
| 669 | +.branch-actions form { margin: 0; } | |
| 670 | +.ahead-behind { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; width: 90px; font-size: 12px; color: var(--text-muted); } | |
| 671 | +.ab-behind { text-align: right; padding-right: 6px; } | |
| 672 | +.ab-ahead { padding-left: 6px; } | |
| 673 | +.ab-divider { height: 16px; background: var(--border); } | |
| 674 | +.tag-row { align-items: flex-start; } | |
| 675 | +.tag-main { flex: 1; min-width: 0; } | |
| 676 | +.tag-name { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 15px; color: var(--text); } | |
| 677 | +.tag-message { margin: 4px 0; color: var(--text-muted); white-space: pre-wrap; } | |
| 678 | + | |
| 679 | +/* ---------- Compare & pull requests ---------- */ | |
| 680 | + | |
| 681 | +.compare-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 16px; background: var(--bg-subtle); } | |
| 682 | +.compare-icon { color: var(--text-muted); display: inline-flex; } | |
| 683 | +.compare-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); } | |
| 684 | +.compare-select select { max-width: 220px; } | |
| 685 | +.compare-arrow { color: var(--text-muted); } | |
| 686 | +.merge-hint { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; } | |
| 687 | +.compare-commits { margin-bottom: 24px; } | |
| 688 | +.compare-summary { display: flex; gap: 32px; justify-content: center; padding: 12px; margin: 16px 0; color: var(--text-muted); background: var(--bg-subtle); } | |
| 689 | +.compare-summary span { display: inline-flex; align-items: center; gap: 6px; } | |
| 690 | +.compare-summary strong { color: var(--text); } | |
| 691 | +.new-pull { margin-bottom: 16px; } | |
| 692 | +.new-pull-body { display: flex; gap: 16px; padding: 16px; } | |
| 693 | +.new-pull-fields { flex: 1; display: flex; flex-direction: column; gap: 12px; min-width: 0; } | |
| 694 | +.new-pull-fields .form-actions { justify-content: flex-end; } | |
| 695 | + | |
| 696 | +.pull-header { margin-bottom: 16px; } | |
| 697 | +.pull-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } | |
| 698 | +.pull-title { font-size: 28px; font-weight: 400; word-break: break-word; letter-spacing: -0.01em; } | |
| 699 | +.pull-title .text-muted { font-weight: 300; } | |
| 700 | +.edit-pull { margin-top: 12px; } | |
| 701 | +.pull-meta { display: flex; align-items: center; gap: 10px; margin-top: 10px; color: var(--text-muted); flex-wrap: wrap; } | |
| 702 | +.pull-meta strong { color: var(--text); } | |
| 703 | +.state { | |
| 704 | + display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 2em; | |
| 705 | + font-weight: 500; color: #fff; white-space: nowrap; | |
| 706 | +} | |
| 707 | +.state-open { background: var(--success-emphasis); } | |
| 708 | +.state-closed { background: var(--danger-emphasis); } | |
| 709 | +.state-merged { background: var(--merged); } | |
| 710 | + | |
| 711 | +.layout-pull { display: grid; grid-template-columns: minmax(0, 1fr) 256px; gap: 32px; } | |
| 712 | +.timeline { position: relative; z-index: 0; display: flex; flex-direction: column; gap: 0; } | |
| 713 | +.timeline-comment { display: flex; gap: 16px; position: relative; padding-bottom: 20px; } | |
| 714 | +.timeline-comment:not(.comment-form-row)::before { | |
| 715 | + content: ""; position: absolute; left: 76px; top: 0; bottom: 0; width: 2px; background: var(--border-muted); z-index: -1; | |
| 716 | +} | |
| 717 | +.timeline-avatar { flex-shrink: 0; width: 40px; } | |
| 718 | +.comment { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); position: relative; } | |
| 719 | +.comment::before { | |
| 720 | + content: ""; position: absolute; top: 12px; left: -8px; width: 14px; height: 14px; background: var(--bg-subtle); | |
| 721 | + border-left: 1px solid var(--border); border-bottom: 1px solid var(--border); transform: rotate(45deg); | |
| 722 | +} | |
| 723 | +.comment-header { | |
| 724 | + display: flex; align-items: center; gap: 6px; padding: 8px 16px; background: var(--bg-subtle); | |
| 725 | + border-bottom: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; flex-wrap: wrap; position: relative; | |
| 726 | +} | |
| 727 | +.comment-role { margin-left: auto; } | |
| 728 | +.comment-body { padding: 16px; } | |
| 729 | +.comment-form-body { padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 12px; } | |
| 730 | +.timeline-event { display: flex; align-items: center; gap: 12px; padding: 0 0 20px 60px; color: var(--text-muted); position: relative; } | |
| 731 | +.timeline-event::before { content: ""; position: absolute; left: 76px; top: 0; bottom: 0; width: 2px; background: var(--border-muted); z-index: -1; } | |
| 732 | +.timeline-event strong { color: var(--text); } | |
| 733 | +.event-badge { | |
| 734 | + display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; | |
| 735 | + border-radius: 50%; background: var(--bg-muted); color: var(--text-muted); border: 2px solid var(--bg); | |
| 736 | +} | |
| 737 | +.badge-merged { background: var(--merged); color: #fff; } | |
| 738 | +.badge-closed { background: var(--danger-emphasis); color: #fff; } | |
| 739 | +.badge-open { background: var(--success-emphasis); color: #fff; } | |
| 740 | + | |
| 741 | +.merge-section { padding-top: 4px; } | |
| 742 | +.merge-avatar { | |
| 743 | + width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius); | |
| 744 | + color: #fff; background: var(--text-subtle); | |
| 745 | +} | |
| 746 | +.merge-avatar.state-open { background: var(--success-emphasis); } | |
| 747 | +.merge-avatar.state-merged { background: var(--merged); } | |
| 748 | +.merge-avatar.state-closed { background: var(--danger-emphasis); } | |
| 749 | +.merge-avatar .icon { width: 20px; height: 20px; } | |
| 750 | +.merge-box { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; background: var(--bg); } | |
| 751 | +.merge-box-ready { border-color: var(--success-emphasis); } | |
| 752 | +.merge-box-merged { border-color: var(--merged); } | |
| 753 | +.merge-status { display: flex; align-items: center; gap: 12px; } | |
| 754 | +.merge-status > div { flex: 1; } | |
| 755 | +.merge-status-icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 50%; color: #fff; flex-shrink: 0; } | |
| 756 | +.status-success { background: var(--success-emphasis); } | |
| 757 | +.status-danger { background: var(--danger-emphasis); } | |
| 758 | +.status-neutral { background: var(--text-subtle); } | |
| 759 | +.merge-form { display: flex; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-muted); flex-wrap: wrap; } | |
| 760 | +.merge-note { margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--border-muted); } | |
| 761 | +.merge-instructions { margin-bottom: 0; } | |
| 762 | +.reopen-form { display: flex; justify-content: flex-end; margin-top: -8px; } | |
| 763 | + | |
| 764 | +.pull-sidebar { font-size: 13px; } | |
| 765 | +.sidebar-item { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border-muted); } | |
| 766 | +.sidebar-item h3 { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; } | |
| 767 | +.sidebar-item p { margin: 0; } | |
| 768 | + | |
| 769 | +/* ---------- Markdown ---------- */ | |
| 770 | + | |
| 771 | +.markdown-body { font-size: 15px; line-height: 1.6; word-wrap: break-word; } | |
| 772 | +.markdown-body > *:first-child { margin-top: 0 !important; } | |
| 773 | +.markdown-body > *:last-child { margin-bottom: 0 !important; } | |
| 774 | +.markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre, .markdown-body table, .markdown-body details { margin: 0 0 16px; } | |
| 775 | +.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { margin: 24px 0 16px; font-weight: 600; line-height: 1.25; } | |
| 776 | +.markdown-body h1 { font-size: 2em; padding-bottom: .3em; border-bottom: 1px solid var(--border-muted); } | |
| 777 | +.markdown-body h2 { font-size: 1.5em; padding-bottom: .3em; border-bottom: 1px solid var(--border-muted); } | |
| 778 | +.markdown-body h3 { font-size: 1.25em; } | |
| 779 | +.markdown-body h4 { font-size: 1em; } | |
| 780 | +.markdown-body ul, .markdown-body ol { padding-left: 2em; } | |
| 781 | +.markdown-body li + li { margin-top: .25em; } | |
| 782 | +.markdown-body li > ul, .markdown-body li > ol { margin: .25em 0 0; } | |
| 783 | +.markdown-body blockquote { padding: 0 1em; color: var(--text-muted); border-left: .25em solid var(--border); } | |
| 784 | +.markdown-body code { font-size: 85%; } | |
| 785 | +.markdown-body pre { padding: 16px; overflow: auto; font-size: 85%; line-height: 1.45; background: var(--bg-subtle); border-radius: var(--radius); } | |
| 786 | +.markdown-body pre code { padding: 0; background: none; font-size: 100%; white-space: pre; } | |
| 787 | +.markdown-body table { border-collapse: collapse; display: block; width: max-content; max-width: 100%; overflow: auto; } | |
| 788 | +.markdown-body th, .markdown-body td { padding: 6px 13px; border: 1px solid var(--border); } | |
| 789 | +.markdown-body th { font-weight: 600; background: var(--bg-subtle); } | |
| 790 | +.markdown-body tr:nth-child(2n) td { background: var(--bg-subtle); } | |
| 791 | +.markdown-body img { max-width: 100%; box-sizing: content-box; } | |
| 792 | +.markdown-body hr { height: 4px; margin: 24px 0; background: var(--border-muted); border: 0; border-radius: 2px; } | |
| 793 | +.markdown-body input[type="checkbox"] { margin: 0 .35em 0 -1.4em; vertical-align: middle; } | |
| 794 | +.markdown-body li:has(> input[type="checkbox"]) { list-style: none; } | |
| 795 | +.markdown-body a { text-decoration: underline; text-underline-offset: .2em; } | |
| 796 | + | |
| 797 | +/* ---------- Syntax tokens ---------- */ | |
| 798 | + | |
| 799 | +.tok-c { color: var(--tok-comment); font-style: italic; } | |
| 800 | +.tok-s, .tok-sr { color: var(--tok-string); } | |
| 801 | +.tok-e { color: var(--tok-string); font-weight: 600; } | |
| 802 | +.tok-n { color: var(--tok-number); } | |
| 803 | +.tok-kc { color: var(--tok-constant); } | |
| 804 | +.tok-k, .tok-o { color: var(--tok-keyword); } | |
| 805 | +.tok-f { color: var(--tok-function); } | |
| 806 | +.tok-t, .tok-v { color: var(--tok-type); } | |
| 807 | +.tok-tag { color: var(--tok-tag); } | |
| 808 | +.tok-attr { color: var(--tok-attr); } | |
| 809 | +.tok-h { color: var(--tok-constant); font-weight: 600; } | |
| 810 | +.tok-m { color: var(--tok-meta); } | |
| 811 | +.tok-b { font-weight: 600; } | |
| 812 | +.tok-i { font-style: italic; } | |
| 813 | +.tok-ins { color: var(--success); } | |
| 814 | +.tok-del { color: var(--danger); } | |
| 815 | +.tok-link { color: var(--accent); text-decoration: underline; } | |
| 816 | + | |
| 817 | +/* ---------- Responsive ---------- */ | |
| 818 | + | |
| 819 | +@media (max-width: 1012px) { | |
| 820 | + .layout-repo, .layout-pull { grid-template-columns: minmax(0, 1fr); } | |
| 821 | + .repo-sidebar { order: -1; padding-bottom: 16px; border-bottom: 1px solid var(--border-muted); } | |
| 822 | + .repo-sidebar .about-list { display: flex; flex-wrap: wrap; gap: 4px 16px; } | |
| 823 | + .repo-sidebar .about-list li { margin: 0; } | |
| 824 | + .pull-sidebar { display: none; } | |
| 825 | +} | |
| 826 | + | |
| 827 | +@media (max-width: 768px) { | |
| 828 | + .container, .site-header-inner { padding-left: 16px; padding-right: 16px; } | |
| 829 | + .site-header-inner { gap: 12px; } | |
| 830 | + .hide-sm { display: none; } | |
| 831 | + .header-button { padding: 5px 8px; } | |
| 832 | + .layout-dashboard, .layout-profile, .layout-settings { grid-template-columns: minmax(0, 1fr); gap: 24px; } | |
| 833 | + .profile-card { display: grid; grid-template-columns: 72px 1fr; gap: 0 16px; align-items: center; } | |
| 834 | + .profile-card .avatar { width: 72px !important; height: 72px !important; font-size: 32px !important; margin: 0; grid-row: span 2; } | |
| 835 | + .profile-card .profile-bio, .profile-card .btn-block, .profile-card .profile-meta { grid-column: 1 / -1; } | |
| 836 | + .side-nav { flex-direction: row; overflow-x: auto; } | |
| 837 | + .side-nav-item.selected::before { display: none; } | |
| 838 | + .hero h1 { font-size: 32px; } | |
| 839 | + .file-message { display: none; } | |
| 840 | + .files td { padding: 8px 10px; } | |
| 841 | + .file-icon { width: 36px; } | |
| 842 | + .file-name { width: auto; } | |
| 843 | + .file-age { width: 120px; } | |
| 844 | + .branch-actions { width: auto; } | |
| 845 | + .latest-message, .latest-meta .dot, .latest-meta time { display: none; } | |
| 846 | + .latest-author { flex: 1; } | |
| 847 | + .readme .markdown-body, .blob-box .markdown-body { padding: 16px; } | |
| 848 | + .timeline-comment:not(.comment-form-row)::before, .timeline-event::before { left: 20px; } | |
| 849 | + .timeline-avatar { display: none; } | |
| 850 | + .timeline-comment:not(.comment-form-row)::before { display: none; } | |
| 851 | + .comment::before { display: none; } | |
| 852 | + .timeline-event { padding-left: 4px; } | |
| 853 | + .merge-section .timeline-avatar { display: none; } | |
| 854 | + .branch-row { flex-wrap: wrap; } | |
| 855 | + .ahead-behind { display: none; } | |
| 856 | + .commit-header-top { flex-direction: column; } | |
| 857 | + .new-pull-body > .avatar { display: none; } | |
| 858 | + .pull-title { font-size: 22px; } | |
| 859 | + .dropdown-wide { width: min(320px, calc(100vw - 32px)); } | |
| 860 | + .compare-select select { max-width: 160px; } | |
| 861 | + .inline-form { flex-direction: column; } | |
| 862 | + .inline-form .input { max-width: none; } | |
| 863 | + .field-row { flex-direction: column; align-items: stretch; } | |
| 864 | + .field-row-sep { display: none; } | |
| 865 | +} |
| @@ -0,0 +1,62 @@ | ||
| 1 | +# Templates (askama 0.16) | |
| 2 | + | |
| 3 | +Templates are compiled into the binary at build time, so `cargo build` reports template errors as Rust type errors pointing at the `#[derive(Template)]` struct. Each page's struct sits next to its handler in `src/web/*.rs`. | |
| 4 | + | |
| 5 | +## Layouts | |
| 6 | + | |
| 7 | +``` | |
| 8 | +base.html blocks: title, page_class, subheader, content | |
| 9 | +├── layout_repo.html repository header and tabs; blocks: page_title, content | |
| 10 | +└── layout_settings.html account settings side nav; block: settings | |
| 11 | +``` | |
| 12 | + | |
| 13 | +- Every page struct needs `ctx: Ctx`, because `base.html` reads `ctx.user`, `ctx.flash` and `ctx.path`. | |
| 14 | +- Repo pages also need `r: RepoCtx` and `tab: &'static str`. `tab` is one of `code`, `commits`, `branches`, `pulls` or `settings` and selects the active tab. | |
| 15 | +- Settings pages need `tab` (`profile` or `keys`). | |
| 16 | +- In repo pages, `page_title` is a prefix ending in ` · `, e.g. `{% block page_title %}Branches · {% endblock %}`. | |
| 17 | +- **Never create a file named `base.html` in a subdirectory.** askama resolves `{% extends %}` relative to the current template's directory first, so `repo/*.html` extending `"base.html"` would pick up `repo/base.html` and fail with a cyclic dependency. That's why section layouts are named `layout_*.html` and kept at the root. | |
| 18 | + | |
| 19 | +## Partials (`{% include %}`, which share the including template's scope) | |
| 20 | + | |
| 21 | +| Partial | Variables it expects in scope | | |
| 22 | +| --- | --- | | |
| 23 | +| `partials/diff.html` | `diff: DiffSummary`, `file_ref` (commit SHA or URL-encoded ref to link "View file" at), `base` (repo URL) | | |
| 24 | +| `partials/commit_list.html` | `groups: Vec<CommitGroup>`, `base` | | |
| 25 | +| `partials/refsel.html` | `refsel: RefSelector` | | |
| 26 | +| `partials/clone.html` | `r: RepoCtx`, `view` with a `refname` field (used for archive links) | | |
| 27 | +| `partials/repo_list.html` | `repos: Vec<Repo>` | | |
| 28 | + | |
| 29 | +Bind them with `{% let %}` before including, e.g. `{% let file_ref = commit.id.clone() %}`, or `{% let repos = recent %}` to reuse the repo list. | |
| 30 | + | |
| 31 | +## Calling Rust from templates | |
| 32 | + | |
| 33 | +Helpers live in `src/web/fmt.rs` and are called by full path: | |
| 34 | + | |
| 35 | +```jinja | |
| 36 | +{{ crate::web::fmt::icon("git-branch")|safe }} {# names: match arms in src/icons.rs #} | |
| 37 | +{{ crate::web::fmt::avatar(user.username, 20)|safe }} | |
| 38 | +<time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time> | |
| 39 | +{{ crate::web::fmt::plural(view.commit_count, "commit") }} {# "1 commit", "3 commits", "2 branches" #} | |
| 40 | +{{ base }}/tree/{{ crate::web::fmt::urlpath(view.refname) }}/{{ crate::web::fmt::urlpath(entry.path) }} | |
| 41 | +/login?next={{ crate::web::fmt::urlencode(ctx.path) }} | |
| 42 | +``` | |
| 43 | + | |
| 44 | +- askama passes struct fields by reference (`&c.committer_time`) but method-call results and literals by value. Numeric helpers therefore take `impl fmt::Num<i64>` / `impl fmt::Num<usize>`, which accepts both. Follow that pattern for new numeric helpers. String helpers take `&str`. | |
| 45 | +- Methods on view structs are fine too (`r.base()`, `r.pull_url(pr.number)`, `f.display_path()`, `l.class()`). | |
| 46 | +- Use `urlpath` for ref names and file paths in URL paths (keeps `/`, encodes spaces, `#` and so on). Use `urlencode` for query-string values. | |
| 47 | +- Only mark output `|safe` when it's HTML we generate: `icon`, `avatar`, highlighted lines, and `markdown::render` output. Never put `|safe` on user-provided strings. | |
| 48 | + | |
| 49 | +## Syntax that works here | |
| 50 | + | |
| 51 | +- `{% if let Some(x) = opt %}…{% endif %}`, `{% else if %}`, `{% for (a, b) in pairs %}`, `loop.index`, `loop.last`. | |
| 52 | +- `{% match value %}{% when crate::web::code::BlobContent::Code with { lines } %}…{% endmatch %}`. Name enum variants by full path. | |
| 53 | +- Comparing an `Option<enum>`: `data.mergeability == Some(crate::git::Mergeability::Clean)`. | |
| 54 | +- Comparing a loop item against a field: `{% if *b == view.base %}`. | |
| 55 | + | |
| 56 | +## Markup conventions | |
| 57 | + | |
| 58 | +- There is no inline JavaScript (the CSP forbids it). Use the `data-*` hooks from `static/app.js`, which are listed in the root `CLAUDE.md`. Destructive forms get `data-confirm="…"`. | |
| 59 | +- Dropdowns are `<details class="dropdown">` with a `<summary>` and a `.dropdown-menu`, so they work without JS. | |
| 60 | +- Show empty states with `<div class="blankslate">` (an icon, an `<h3>` and a `<p>`). | |
| 61 | +- Show errors on re-rendered forms with `<div class="flash flash-error">`. For messages after a redirect, use `redirect_flash` in the handler instead. | |
| 62 | +- Forms POST to same-origin paths, and every page must still work with JS disabled. |
| @@ -0,0 +1,66 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | +<head> | |
| 4 | + <meta charset="utf-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + <title>{% block title %}Openhub{% endblock %}</title> | |
| 7 | + <link rel="icon" href="/favicon.svg" type="image/svg+xml"> | |
| 8 | + <link rel="stylesheet" href="/static/style.css?v={{ crate::web::assets::version() }}"> | |
| 9 | + <script src="/static/app.js?v={{ crate::web::assets::version() }}" defer></script> | |
| 10 | +</head> | |
| 11 | +<body> | |
| 12 | + <header class="site-header"> | |
| 13 | + <div class="site-header-inner"> | |
| 14 | + <a class="brand" href="/"> | |
| 15 | + <svg class="brand-mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8" fill="currentColor" opacity=".14"/><circle cx="11" cy="9" r="3" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="11" cy="23" r="3" fill="none" stroke="currentColor" stroke-width="2.2"/><circle cx="22" cy="12" r="3" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M11 12v8M22 15c0 4-4 5-8.5 6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg> | |
| 16 | + <span>Openhub</span> | |
| 17 | + </a> | |
| 18 | + <nav class="site-nav"> | |
| 19 | + <a href="/explore">Explore</a> | |
| 20 | + </nav> | |
| 21 | + <div class="site-header-right"> | |
| 22 | + {% if let Some(user) = ctx.user %} | |
| 23 | + <a class="header-button" href="/new" title="New repository">{{ crate::web::fmt::icon("plus")|safe }}<span class="hide-sm">New</span></a> | |
| 24 | + <details class="dropdown dropdown-right"> | |
| 25 | + <summary class="header-avatar" aria-label="Account menu">{{ crate::web::fmt::avatar(user.username, 26)|safe }}{{ crate::web::fmt::icon("chevron-down")|safe }}</summary> | |
| 26 | + <div class="dropdown-menu"> | |
| 27 | + <div class="dropdown-header">Signed in as <strong>{{ user.username }}</strong></div> | |
| 28 | + <a class="dropdown-item" href="/{{ user.username }}">{{ crate::web::fmt::icon("user")|safe }} Your profile</a> | |
| 29 | + <a class="dropdown-item" href="/settings/keys">{{ crate::web::fmt::icon("key-round")|safe }} SSH keys</a> | |
| 30 | + <a class="dropdown-item" href="/settings/profile">{{ crate::web::fmt::icon("settings")|safe }} Settings</a> | |
| 31 | + <div class="dropdown-divider"></div> | |
| 32 | + <form method="post" action="/logout"> | |
| 33 | + <button type="submit" class="dropdown-item">{{ crate::web::fmt::icon("log-out")|safe }} Sign out</button> | |
| 34 | + </form> | |
| 35 | + </div> | |
| 36 | + </details> | |
| 37 | + {% else %} | |
| 38 | + <a class="header-link" href="/login?next={{ crate::web::fmt::urlencode(ctx.path) }}">Sign in</a> | |
| 39 | + <a class="header-button" href="/register">Sign up</a> | |
| 40 | + {% endif %} | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + </header> | |
| 44 | + {% block subheader %}{% endblock %} | |
| 45 | + <main class="page {% block page_class %}{% endblock %}"> | |
| 46 | + <div class="container"> | |
| 47 | + {% if let Some(flash) = ctx.flash %} | |
| 48 | + <div class="flash flash-{{ flash.kind }}" role="status"> | |
| 49 | + {% if flash.kind == "error" %}{{ crate::web::fmt::icon("circle-alert")|safe }}{% else %}{{ crate::web::fmt::icon("circle-check")|safe }}{% endif %} | |
| 50 | + <span>{{ flash.message }}</span> | |
| 51 | + </div> | |
| 52 | + {% endif %} | |
| 53 | + {% block content %}{% endblock %} | |
| 54 | + </div> | |
| 55 | + </main> | |
| 56 | + <footer class="site-footer"> | |
| 57 | + <div class="container"> | |
| 58 | + <span>Openhub</span> | |
| 59 | + <span class="dot">·</span> | |
| 60 | + <span>Self-hosted git</span> | |
| 61 | + <span class="dot">·</span> | |
| 62 | + <a href="/explore">Explore repositories</a> | |
| 63 | + </div> | |
| 64 | + </footer> | |
| 65 | +</body> | |
| 66 | +</html> |
| @@ -0,0 +1,72 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Dashboard · Openhub{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +<div class="layout-dashboard"> | |
| 5 | + <aside class="dashboard-sidebar"> | |
| 6 | + <div class="sidebar-header"> | |
| 7 | + <h2>Your repositories</h2> | |
| 8 | + <a class="btn btn-sm btn-primary" href="/new">{{ crate::web::fmt::icon("plus")|safe }} New</a> | |
| 9 | + </div> | |
| 10 | + {% if repos.is_empty() %} | |
| 11 | + <p class="text-muted text-small">You don't have any repositories yet.</p> | |
| 12 | + {% else %} | |
| 13 | + <ul class="sidebar-repos"> | |
| 14 | + {% for repo in repos %} | |
| 15 | + <li> | |
| 16 | + <a href="/{{ repo.owner }}/{{ repo.name }}"> | |
| 17 | + {% if repo.is_private %}{{ crate::web::fmt::icon("lock")|safe }}{% else %}{{ crate::web::fmt::icon("book-marked")|safe }}{% endif %} | |
| 18 | + <span class="truncate">{{ repo.owner }}/<strong>{{ repo.name }}</strong></span> | |
| 19 | + </a> | |
| 20 | + </li> | |
| 21 | + {% endfor %} | |
| 22 | + </ul> | |
| 23 | + {% endif %} | |
| 24 | + </aside> | |
| 25 | + <div class="dashboard-main"> | |
| 26 | + {% if repos.is_empty() %} | |
| 27 | + <div class="box welcome-box"> | |
| 28 | + <div class="box-body"> | |
| 29 | + <h2>Welcome to Openhub{% if let Some(u) = ctx.user %}, {{ u.name() }}{% endif %}</h2> | |
| 30 | + <p class="text-muted">Get set up in two steps.</p> | |
| 31 | + <ol class="steps"> | |
| 32 | + <li><a href="/settings/keys">Add an SSH key</a> so you can push from the command line.</li> | |
| 33 | + <li><a href="/new">Create a repository</a> and push your code to it.</li> | |
| 34 | + </ol> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + {% endif %} | |
| 38 | + <section class="section"> | |
| 39 | + <div class="section-header"><h2>Open pull requests</h2></div> | |
| 40 | + {% if pulls.is_empty() %} | |
| 41 | + <div class="blankslate blankslate-sm box"> | |
| 42 | + {{ crate::web::fmt::icon("git-pull-request")|safe }} | |
| 43 | + <p>No open pull requests in your repositories.</p> | |
| 44 | + </div> | |
| 45 | + {% else %} | |
| 46 | + <div class="box"> | |
| 47 | + {% for (pr, owner, name) in pulls %} | |
| 48 | + <div class="box-row issue-row"> | |
| 49 | + <span class="issue-icon state-color-open">{{ crate::web::fmt::icon("git-pull-request")|safe }}</span> | |
| 50 | + <div class="issue-main"> | |
| 51 | + <a class="text-muted" href="/{{ owner }}/{{ name }}">{{ owner }}/{{ name }}</a> | |
| 52 | + <a class="issue-title" href="/{{ owner }}/{{ name }}/pull/{{ pr.number }}">{{ pr.title }}</a> | |
| 53 | + <div class="issue-meta">#{{ pr.number }} opened <time title="{{ crate::web::fmt::datetime_full(pr.created_at) }}">{{ crate::web::fmt::ago(pr.created_at) }}</time> by {{ pr.author }}</div> | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + {% endfor %} | |
| 57 | + </div> | |
| 58 | + {% endif %} | |
| 59 | + </section> | |
| 60 | + {% if !recent.is_empty() %} | |
| 61 | + <section class="section"> | |
| 62 | + <div class="section-header"> | |
| 63 | + <h2>Recently updated on this server</h2> | |
| 64 | + <a href="/explore">Explore {{ crate::web::fmt::icon("arrow-right")|safe }}</a> | |
| 65 | + </div> | |
| 66 | + {% let repos = recent %} | |
| 67 | + <div class="box">{% include "partials/repo_list.html" %}</div> | |
| 68 | + </section> | |
| 69 | + {% endif %} | |
| 70 | + </div> | |
| 71 | +</div> | |
| 72 | +{% endblock %} |
| @@ -0,0 +1,13 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ title }} · Openhub{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +<div class="blankslate blankslate-page"> | |
| 5 | + <div class="blankslate-code">{{ status }}</div> | |
| 6 | + <h1>{{ title }}</h1> | |
| 7 | + <p>{{ message }}</p> | |
| 8 | + <div class="blankslate-actions"> | |
| 9 | + <a class="btn" href="/">Go to the homepage</a> | |
| 10 | + {% if ctx.user.is_none() && status == 404 %}<a class="btn btn-primary" href="/login?next={{ crate::web::fmt::urlencode(ctx.path) }}">Sign in</a>{% endif %} | |
| 11 | + </div> | |
| 12 | +</div> | |
| 13 | +{% endblock %} |
| @@ -0,0 +1,17 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Explore · Openhub{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +<div class="page-header"> | |
| 5 | + <h1>Explore</h1> | |
| 6 | + <p class="text-muted">Public repositories on this server, most recently updated first.</p> | |
| 7 | +</div> | |
| 8 | +{% if repos.is_empty() %} | |
| 9 | +<div class="blankslate"> | |
| 10 | + {{ crate::web::fmt::icon("globe")|safe }} | |
| 11 | + <h3>Nothing to explore yet</h3> | |
| 12 | + <p>When someone creates a public repository, it will show up here.</p> | |
| 13 | +</div> | |
| 14 | +{% else %} | |
| 15 | +<div class="box">{% include "partials/repo_list.html" %}</div> | |
| 16 | +{% endif %} | |
| 17 | +{% endblock %} |
| @@ -0,0 +1,26 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block content %} | |
| 3 | +<section class="hero"> | |
| 4 | + <h1>Git hosting you run yourself.</h1> | |
| 5 | + <p class="hero-lead">Openhub is a small, self-hosted home for your repositories. Push over SSH, browse code and history, and review changes with pull requests.</p> | |
| 6 | + <div class="hero-actions"> | |
| 7 | + {% if can_register %}<a class="btn btn-primary btn-lg" href="/register">Create an account</a>{% endif %} | |
| 8 | + <a class="btn btn-lg" href="/login">Sign in</a> | |
| 9 | + </div> | |
| 10 | +</section> | |
| 11 | +<section class="section"> | |
| 12 | + <div class="section-header"> | |
| 13 | + <h2>Public repositories</h2> | |
| 14 | + <a href="/explore">View all {{ crate::web::fmt::icon("arrow-right")|safe }}</a> | |
| 15 | + </div> | |
| 16 | + {% if repos.is_empty() %} | |
| 17 | + <div class="blankslate"> | |
| 18 | + {{ crate::web::fmt::icon("book-marked")|safe }} | |
| 19 | + <h3>No public repositories yet</h3> | |
| 20 | + <p>Repositories made public on this server will appear here.</p> | |
| 21 | + </div> | |
| 22 | + {% else %} | |
| 23 | + <div class="box">{% include "partials/repo_list.html" %}</div> | |
| 24 | + {% endif %} | |
| 25 | +</section> | |
| 26 | +{% endblock %} |
| @@ -0,0 +1,26 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{% block page_title %}{% endblock %}{{ r.repo.owner }}/{{ r.repo.name }} · Openhub{% endblock %} | |
| 3 | +{% block page_class %}repo-page{% endblock %} | |
| 4 | +{% block subheader %} | |
| 5 | +{% let base = r.base() %} | |
| 6 | +<div class="repo-header"> | |
| 7 | + <div class="container"> | |
| 8 | + <div class="repo-title"> | |
| 9 | + <span class="repo-icon">{% if r.repo.is_private %}{{ crate::web::fmt::icon("lock")|safe }}{% else %}{{ crate::web::fmt::icon("book-marked")|safe }}{% endif %}</span> | |
| 10 | + <a class="repo-owner" href="/{{ r.repo.owner }}">{{ r.repo.owner }}</a> | |
| 11 | + <span class="repo-sep">/</span> | |
| 12 | + <a class="repo-name" href="{{ base }}">{{ r.repo.name }}</a> | |
| 13 | + <span class="label">{% if r.repo.is_private %}Private{% else %}Public{% endif %}</span> | |
| 14 | + </div> | |
| 15 | + <nav class="tabs" aria-label="Repository"> | |
| 16 | + <a class="tab{% if tab == "code" %} selected{% endif %}" href="{{ base }}">{{ crate::web::fmt::icon("code")|safe }} Code</a> | |
| 17 | + <a class="tab{% if tab == "commits" %} selected{% endif %}" href="{{ base }}/commits">{{ crate::web::fmt::icon("history")|safe }} Commits</a> | |
| 18 | + <a class="tab{% if tab == "branches" %} selected{% endif %}" href="{{ base }}/branches">{{ crate::web::fmt::icon("git-branch")|safe }} Branches</a> | |
| 19 | + <a class="tab{% if tab == "pulls" %} selected{% endif %}" href="{{ base }}/pulls">{{ crate::web::fmt::icon("git-pull-request")|safe }} Pull requests{% if r.open_pulls > 0 %} <span class="counter">{{ r.open_pulls }}</span>{% endif %}</a> | |
| 20 | + {% if r.access.admin %} | |
| 21 | + <a class="tab{% if tab == "settings" %} selected{% endif %}" href="{{ base }}/settings">{{ crate::web::fmt::icon("settings")|safe }} Settings</a> | |
| 22 | + {% endif %} | |
| 23 | + </nav> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | +{% endblock %} |
| @@ -0,0 +1,20 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block content %} | |
| 3 | +<div class="settings-header"> | |
| 4 | + {% if let Some(u) = ctx.user %}{{ crate::web::fmt::avatar(u.username, 40)|safe }} | |
| 5 | + <div> | |
| 6 | + <h1>{{ u.name() }}</h1> | |
| 7 | + <div class="text-muted">Your personal account</div> | |
| 8 | + </div> | |
| 9 | + {% endif %} | |
| 10 | +</div> | |
| 11 | +<div class="layout-settings"> | |
| 12 | + <nav class="side-nav" aria-label="Settings"> | |
| 13 | + <a class="side-nav-item{% if tab == "profile" %} selected{% endif %}" href="/settings/profile">{{ crate::web::fmt::icon("user")|safe }} Profile & password</a> | |
| 14 | + <a class="side-nav-item{% if tab == "keys" %} selected{% endif %}" href="/settings/keys">{{ crate::web::fmt::icon("key-round")|safe }} SSH keys</a> | |
| 15 | + </nav> | |
| 16 | + <div class="settings-main"> | |
| 17 | + {% block settings %}{% endblock %} | |
| 18 | + </div> | |
| 19 | +</div> | |
| 20 | +{% endblock %} |
| @@ -0,0 +1,24 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Sign in · Openhub{% endblock %} | |
| 3 | +{% block page_class %}auth-page{% endblock %} | |
| 4 | +{% block content %} | |
| 5 | +<div class="auth-card"> | |
| 6 | + <h1>Sign in to Openhub</h1> | |
| 7 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 8 | + <form method="post" action="/login" class="box box-body form"> | |
| 9 | + <input type="hidden" name="next" value="{{ next }}"> | |
| 10 | + <label class="field"> | |
| 11 | + <span class="field-label">Username or email</span> | |
| 12 | + <input class="input" type="text" name="login" value="{{ login }}" autocomplete="username" autocapitalize="off" autofocus required> | |
| 13 | + </label> | |
| 14 | + <label class="field"> | |
| 15 | + <span class="field-label">Password</span> | |
| 16 | + <input class="input" type="password" name="password" autocomplete="current-password" required> | |
| 17 | + </label> | |
| 18 | + <button class="btn btn-primary btn-block" type="submit">Sign in</button> | |
| 19 | + </form> | |
| 20 | + {% if can_register %} | |
| 21 | + <div class="auth-alt">New here? <a href="/register">Create an account</a>.</div> | |
| 22 | + {% endif %} | |
| 23 | +</div> | |
| 24 | +{% endblock %} |
| @@ -0,0 +1,47 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}New repository · Openhub{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +<div class="narrow"> | |
| 5 | + <div class="page-header"> | |
| 6 | + <h1>Create a new repository</h1> | |
| 7 | + <p class="text-muted">A repository contains all your project's files and their revision history.</p> | |
| 8 | + </div> | |
| 9 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 10 | + <form method="post" action="/new" class="form"> | |
| 11 | + <div class="field-row"> | |
| 12 | + <div class="field"> | |
| 13 | + <span class="field-label">Owner</span> | |
| 14 | + <div class="input input-static">{% if let Some(u) = ctx.user %}{{ crate::web::fmt::avatar(u.username, 18)|safe }} {{ u.username }}{% endif %}</div> | |
| 15 | + </div> | |
| 16 | + <span class="field-row-sep">/</span> | |
| 17 | + <label class="field field-grow"> | |
| 18 | + <span class="field-label">Repository name</span> | |
| 19 | + <input class="input" type="text" name="name" value="{{ name }}" maxlength="100" autocapitalize="off" autofocus required> | |
| 20 | + </label> | |
| 21 | + </div> | |
| 22 | + <label class="field"> | |
| 23 | + <span class="field-label">Description <span class="text-muted">(optional)</span></span> | |
| 24 | + <input class="input" type="text" name="description" value="{{ description }}" maxlength="350"> | |
| 25 | + </label> | |
| 26 | + <fieldset class="field radio-cards"> | |
| 27 | + <label class="radio-card"> | |
| 28 | + <input type="radio" name="visibility" value="public"{% if !private %} checked{% endif %}> | |
| 29 | + {{ crate::web::fmt::icon("book-marked")|safe }} | |
| 30 | + <span><strong>Public</strong><span class="text-muted">Anyone who can reach this server can see and clone this repository.</span></span> | |
| 31 | + </label> | |
| 32 | + <label class="radio-card"> | |
| 33 | + <input type="radio" name="visibility" value="private"{% if private %} checked{% endif %}> | |
| 34 | + {{ crate::web::fmt::icon("lock")|safe }} | |
| 35 | + <span><strong>Private</strong><span class="text-muted">Only you and collaborators you add can see this repository.</span></span> | |
| 36 | + </label> | |
| 37 | + </fieldset> | |
| 38 | + <label class="checkbox"> | |
| 39 | + <input type="checkbox" name="readme" value="1"{% if readme %} checked{% endif %}> | |
| 40 | + <span><strong>Add a README file</strong><span class="text-muted">Start with an initial commit so you can clone the repository right away.</span></span> | |
| 41 | + </label> | |
| 42 | + <div class="form-actions"> | |
| 43 | + <button class="btn btn-primary" type="submit">Create repository</button> | |
| 44 | + </div> | |
| 45 | + </form> | |
| 46 | +</div> | |
| 47 | +{% endblock %} |
| @@ -0,0 +1,28 @@ | ||
| 1 | +<details class="dropdown dropdown-right clone-dropdown"> | |
| 2 | + <summary class="btn btn-primary">{{ crate::web::fmt::icon("code")|safe }} Clone {{ crate::web::fmt::icon("chevron-down")|safe }}</summary> | |
| 3 | + <div class="dropdown-menu dropdown-wide"> | |
| 4 | + <div class="clone-panel"> | |
| 5 | + <div class="clone-tabs" role="tablist"> | |
| 6 | + <button type="button" class="clone-tab selected" data-clone-tab="ssh">SSH</button> | |
| 7 | + <button type="button" class="clone-tab" data-clone-tab="https">HTTPS</button> | |
| 8 | + </div> | |
| 9 | + <div class="clone-pane" data-clone-pane="ssh"> | |
| 10 | + <div class="input-group"> | |
| 11 | + <input class="input input-mono" type="text" readonly value="{{ r.ssh_url }}" id="clone-ssh" aria-label="SSH clone URL"> | |
| 12 | + <button type="button" class="btn" data-copy="#clone-ssh" title="Copy">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 13 | + </div> | |
| 14 | + <p class="hint">Use a password-protected SSH key. <a href="/settings/keys">Manage keys</a></p> | |
| 15 | + </div> | |
| 16 | + <div class="clone-pane" data-clone-pane="https" hidden> | |
| 17 | + <div class="input-group"> | |
| 18 | + <input class="input input-mono" type="text" readonly value="{{ r.http_url }}" id="clone-https" aria-label="HTTPS clone URL"> | |
| 19 | + <button type="button" class="btn" data-copy="#clone-https" title="Copy">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 20 | + </div> | |
| 21 | + <p class="hint">HTTPS is read-only. Push over SSH.</p> | |
| 22 | + </div> | |
| 23 | + </div> | |
| 24 | + <div class="dropdown-divider"></div> | |
| 25 | + <a class="dropdown-item" href="{{ r.base() }}/archive/{{ crate::web::fmt::urlpath(view.refname) }}.zip">{{ crate::web::fmt::icon("download")|safe }} Download ZIP</a> | |
| 26 | + <a class="dropdown-item" href="{{ r.base() }}/archive/{{ crate::web::fmt::urlpath(view.refname) }}.tar.gz">{{ crate::web::fmt::icon("download")|safe }} Download tar.gz</a> | |
| 27 | + </div> | |
| 28 | +</details> |
| @@ -0,0 +1,28 @@ | ||
| 1 | +{# Expects `groups` (Vec<CommitGroup>) and `base`. #} | |
| 2 | +<div class="commit-groups"> | |
| 3 | + {% for g in groups %} | |
| 4 | + <div class="commit-group"> | |
| 5 | + <h3 class="commit-group-title">{{ crate::web::fmt::icon("git-commit-horizontal")|safe }} Commits on {{ g.date }}</h3> | |
| 6 | + <div class="box"> | |
| 7 | + {% for c in g.commits %} | |
| 8 | + <div class="box-row commit-row"> | |
| 9 | + <div class="commit-main"> | |
| 10 | + <a class="commit-title" href="{{ base }}/commit/{{ c.id }}">{{ c.summary }}</a> | |
| 11 | + <div class="commit-meta"> | |
| 12 | + {{ crate::web::fmt::avatar(c.author_name, 18)|safe }} | |
| 13 | + <strong>{{ c.author_name }}</strong> committed <time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time> | |
| 14 | + </div> | |
| 15 | + </div> | |
| 16 | + <div class="commit-actions"> | |
| 17 | + <div class="btn-group"> | |
| 18 | + <a class="btn btn-sm btn-mono" href="{{ base }}/commit/{{ c.id }}">{{ c.short_id }}</a> | |
| 19 | + <button type="button" class="btn btn-sm" data-copy-text="{{ c.id }}" title="Copy full SHA">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 20 | + </div> | |
| 21 | + <a class="btn btn-sm btn-ghost" href="{{ base }}/tree/{{ c.id }}" title="Browse the repository at this point">{{ crate::web::fmt::icon("code")|safe }}</a> | |
| 22 | + </div> | |
| 23 | + </div> | |
| 24 | + {% endfor %} | |
| 25 | + </div> | |
| 26 | + </div> | |
| 27 | + {% endfor %} | |
| 28 | +</div> |
| @@ -0,0 +1,53 @@ | ||
| 1 | +{# Expects `diff` (DiffSummary), `file_ref` (commit to link files at) and `base` (repo URL). #} | |
| 2 | +<div class="diff-toolbar"> | |
| 3 | + <span>Showing <strong>{{ crate::web::fmt::plural(diff.files.len(), "changed file") }}</strong> with <strong class="text-success">{{ crate::web::fmt::plural(diff.additions, "addition") }}</strong> and <strong class="text-danger">{{ crate::web::fmt::plural(diff.deletions, "deletion") }}</strong>.</span> | |
| 4 | + {% if diff.files.len() > 1 %} | |
| 5 | + <details class="dropdown dropdown-right"> | |
| 6 | + <summary class="btn btn-sm">{{ crate::web::fmt::icon("file")|safe }} Jump to file {{ crate::web::fmt::icon("chevron-down")|safe }}</summary> | |
| 7 | + <div class="dropdown-menu dropdown-wide"> | |
| 8 | + <div class="dropdown-scroll"> | |
| 9 | + {% for f in diff.files %} | |
| 10 | + <a class="dropdown-item" href="#diff-{{ f.index }}"><span class="diff-mini-stat"><span class="text-success">+{{ f.additions }}</span> <span class="text-danger">−{{ f.deletions }}</span></span><span class="truncate">{{ f.display_path() }}</span></a> | |
| 11 | + {% endfor %} | |
| 12 | + </div> | |
| 13 | + </div> | |
| 14 | + </details> | |
| 15 | + {% endif %} | |
| 16 | +</div> | |
| 17 | +{% for f in diff.files %} | |
| 18 | +<div class="diff-file" id="diff-{{ f.index }}"> | |
| 19 | + <div class="diff-file-header"> | |
| 20 | + <button type="button" class="diff-toggle" data-collapse aria-label="Collapse file">{{ crate::web::fmt::icon("chevron-down")|safe }}</button> | |
| 21 | + <span class="diff-counts"><span class="text-success">+{{ f.additions }}</span> <span class="text-danger">−{{ f.deletions }}</span></span> | |
| 22 | + <span class="diff-blocks" aria-hidden="true">{% for b in f.stat_blocks() %}<span class="block block-{{ b }}"></span>{% endfor %}</span> | |
| 23 | + <span class="diff-path">{{ f.display_path() }}</span> | |
| 24 | + {% if f.status != "modified" %}<span class="label label-{{ f.status }}">{{ f.status }}</span>{% endif %} | |
| 25 | + {% if f.status != "deleted" %} | |
| 26 | + <a class="btn btn-sm btn-ghost diff-view" href="{{ base }}/blob/{{ file_ref }}/{{ crate::web::fmt::urlpath(f.new_path) }}">View file</a> | |
| 27 | + {% endif %} | |
| 28 | + </div> | |
| 29 | + <div class="diff-body"> | |
| 30 | + {% if f.binary %} | |
| 31 | + <div class="diff-note">Binary file not shown.</div> | |
| 32 | + {% else if f.too_large %} | |
| 33 | + <div class="diff-note">Large diff not rendered ({{ f.additions }} additions, {{ f.deletions }} deletions).</div> | |
| 34 | + {% else if f.hunks.is_empty() %} | |
| 35 | + <div class="diff-note">{% if f.status == "renamed" %}File renamed without changes.{% else %}No content changes.{% endif %}</div> | |
| 36 | + {% else %} | |
| 37 | + <div class="diff-scroll"> | |
| 38 | + <table class="diff-table"> | |
| 39 | + {% for h in f.hunks %} | |
| 40 | + <tr class="diff-hunk"><td class="num"></td><td class="num"></td><td class="code">{{ h.header }}</td></tr> | |
| 41 | + {% for l in h.lines %} | |
| 42 | + <tr class="diff-line {{ l.class() }}"><td class="num">{% if let Some(n) = l.old_no %}{{ n }}{% endif %}</td><td class="num">{% if let Some(n) = l.new_no %}{{ n }}{% endif %}</td><td class="code"><span class="marker">{{ l.marker() }}</span>{{ l.html|safe }}</td></tr> | |
| 43 | + {% endfor %} | |
| 44 | + {% endfor %} | |
| 45 | + </table> | |
| 46 | + </div> | |
| 47 | + {% endif %} | |
| 48 | + </div> | |
| 49 | +</div> | |
| 50 | +{% endfor %} | |
| 51 | +{% if diff.files.is_empty() %} | |
| 52 | +<div class="blankslate blankslate-sm"><p>No file changes.</p></div> | |
| 53 | +{% endif %} |
| @@ -0,0 +1,19 @@ | ||
| 1 | +<details class="dropdown ref-selector"> | |
| 2 | + <summary class="btn" title="Switch branches or tags">{{ crate::web::fmt::icon(refsel.icon())|safe }}<span class="ref-name">{{ refsel.current }}</span>{{ crate::web::fmt::icon("chevron-down")|safe }}</summary> | |
| 3 | + <div class="dropdown-menu dropdown-wide"> | |
| 4 | + <div class="dropdown-header">Switch branches/tags</div> | |
| 5 | + <div class="dropdown-filter"><input class="input input-sm" type="search" placeholder="Filter branches and tags" data-filter aria-label="Filter"></div> | |
| 6 | + <div class="dropdown-scroll"> | |
| 7 | + <div class="dropdown-section">Branches</div> | |
| 8 | + {% for b in refsel.branches %} | |
| 9 | + <a class="dropdown-item{% if b.selected %} selected{% endif %}" href="{{ b.href }}" data-filter-item>{% if b.selected %}{{ crate::web::fmt::icon("check")|safe }}{% else %}<span class="icon-spacer"></span>{% endif %}<span class="truncate">{{ b.name }}</span></a> | |
| 10 | + {% endfor %} | |
| 11 | + {% if !refsel.tags.is_empty() %} | |
| 12 | + <div class="dropdown-section">Tags</div> | |
| 13 | + {% for t in refsel.tags %} | |
| 14 | + <a class="dropdown-item{% if t.selected %} selected{% endif %}" href="{{ t.href }}" data-filter-item>{% if t.selected %}{{ crate::web::fmt::icon("check")|safe }}{% else %}<span class="icon-spacer"></span>{% endif %}<span class="truncate">{{ t.name }}</span></a> | |
| 15 | + {% endfor %} | |
| 16 | + {% endif %} | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | +</details> |
| @@ -0,0 +1,21 @@ | ||
| 1 | +{# Expects `repos` (Vec<Repo>). #} | |
| 2 | +<ul class="repo-list"> | |
| 3 | + {% for repo in repos %} | |
| 4 | + <li class="repo-item"> | |
| 5 | + <div class="repo-item-main"> | |
| 6 | + <h3> | |
| 7 | + <a href="/{{ repo.owner }}/{{ repo.name }}"><span class="text-muted">{{ repo.owner }} /</span> {{ repo.name }}</a> | |
| 8 | + <span class="label">{% if repo.is_private %}Private{% else %}Public{% endif %}</span> | |
| 9 | + </h3> | |
| 10 | + {% if !repo.description.is_empty() %}<p class="repo-item-desc">{{ repo.description }}</p>{% endif %} | |
| 11 | + <div class="repo-item-meta"> | |
| 12 | + {% if let Some(pushed) = repo.pushed_at %} | |
| 13 | + Updated <time title="{{ crate::web::fmt::datetime_full(pushed) }}">{{ crate::web::fmt::ago(pushed) }}</time> | |
| 14 | + {% else %} | |
| 15 | + Created <time title="{{ crate::web::fmt::datetime_full(repo.created_at) }}">{{ crate::web::fmt::ago(repo.created_at) }}</time> | |
| 16 | + {% endif %} | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + </li> | |
| 20 | + {% endfor %} | |
| 21 | +</ul> |
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}{{ profile.username }} · Openhub{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +<div class="layout-profile"> | |
| 5 | + <aside class="profile-card"> | |
| 6 | + {{ crate::web::fmt::avatar(profile.username, 200)|safe }} | |
| 7 | + <h1 class="profile-name">{{ profile.name() }}</h1> | |
| 8 | + {% if profile.name() != profile.username %}<div class="profile-login">{{ profile.username }}</div>{% endif %} | |
| 9 | + {% if !profile.bio.is_empty() %}<p class="profile-bio">{{ profile.bio }}</p>{% endif %} | |
| 10 | + {% if is_self %}<a class="btn btn-block" href="/settings/profile">Edit profile</a>{% endif %} | |
| 11 | + <div class="profile-meta">{{ crate::web::fmt::icon("clock")|safe }} Joined {{ crate::web::fmt::date(profile.created_at) }}</div> | |
| 12 | + </aside> | |
| 13 | + <div class="profile-main"> | |
| 14 | + <div class="section-header"> | |
| 15 | + <h2>Repositories <span class="counter">{{ repos.len() }}</span></h2> | |
| 16 | + {% if is_self %}<a class="btn btn-sm btn-primary" href="/new">{{ crate::web::fmt::icon("plus")|safe }} New</a>{% endif %} | |
| 17 | + </div> | |
| 18 | + {% if repos.is_empty() %} | |
| 19 | + <div class="blankslate"> | |
| 20 | + {{ crate::web::fmt::icon("book-marked")|safe }} | |
| 21 | + <h3>{% if is_self %}You don't have any repositories yet{% else %}{{ profile.username }} doesn't have any public repositories yet{% endif %}</h3> | |
| 22 | + </div> | |
| 23 | + {% else %} | |
| 24 | + <div class="box">{% include "partials/repo_list.html" %}</div> | |
| 25 | + {% endif %} | |
| 26 | + </div> | |
| 27 | +</div> | |
| 28 | +{% endblock %} |
| @@ -0,0 +1,38 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}Sign up · Openhub{% endblock %} | |
| 3 | +{% block page_class %}auth-page{% endblock %} | |
| 4 | +{% block content %} | |
| 5 | +<div class="auth-card"> | |
| 6 | + <h1>Create your account</h1> | |
| 7 | + {% if !allowed %} | |
| 8 | + <div class="box box-body"> | |
| 9 | + <p>Registration is disabled on this server. Ask an administrator to create an account for you.</p> | |
| 10 | + <a class="btn btn-block" href="/login">Sign in instead</a> | |
| 11 | + </div> | |
| 12 | + {% else %} | |
| 13 | + {% if first_user %} | |
| 14 | + <div class="flash flash-info">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>This is the first account on this server, so it will be the administrator.</span></div> | |
| 15 | + {% endif %} | |
| 16 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 17 | + <form method="post" action="/register" class="box box-body form"> | |
| 18 | + <label class="field"> | |
| 19 | + <span class="field-label">Username</span> | |
| 20 | + <input class="input" type="text" name="username" value="{{ username }}" autocomplete="username" autocapitalize="off" maxlength="39" pattern="[A-Za-z0-9\-]+" autofocus required> | |
| 21 | + <span class="field-hint">Letters, numbers and single hyphens.</span> | |
| 22 | + </label> | |
| 23 | + <label class="field"> | |
| 24 | + <span class="field-label">Email</span> | |
| 25 | + <input class="input" type="email" name="email" value="{{ email }}" autocomplete="email" required> | |
| 26 | + <span class="field-hint">Used for merge commits you create on the web.</span> | |
| 27 | + </label> | |
| 28 | + <label class="field"> | |
| 29 | + <span class="field-label">Password</span> | |
| 30 | + <input class="input" type="password" name="password" autocomplete="new-password" minlength="8" required> | |
| 31 | + <span class="field-hint">At least 8 characters.</span> | |
| 32 | + </label> | |
| 33 | + <button class="btn btn-primary btn-block" type="submit">Create account</button> | |
| 34 | + </form> | |
| 35 | + {% endif %} | |
| 36 | + <div class="auth-alt">Already have an account? <a href="/login">Sign in</a>.</div> | |
| 37 | +</div> | |
| 38 | +{% endblock %} |
| @@ -0,0 +1,69 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}{{ view.path }} at {{ view.refname }} · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="code-toolbar"> | |
| 6 | + {% let refsel = view.refsel %} | |
| 7 | + {% include "partials/refsel.html" %} | |
| 8 | + <nav class="breadcrumbs" aria-label="Path"> | |
| 9 | + <a href="{{ base }}/tree/{{ crate::web::fmt::urlpath(view.refname) }}"><strong>{{ r.repo.name }}</strong></a> | |
| 10 | + {% for c in view.crumbs %}<span class="crumb-sep">/</span><a href="{{ c.href }}">{{ c.name }}</a>{% endfor %} | |
| 11 | + <span class="crumb-sep">/</span><strong>{{ view.file_name }}</strong> | |
| 12 | + <button type="button" class="btn btn-sm btn-ghost btn-icon" data-copy-text="{{ view.path }}" title="Copy path">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 13 | + </nav> | |
| 14 | +</div> | |
| 15 | +{% if let Some(c) = view.latest %} | |
| 16 | +<div class="box latest-commit-box"> | |
| 17 | + <div class="box-header latest-commit"> | |
| 18 | + {{ crate::web::fmt::avatar(c.author_name, 22)|safe }} | |
| 19 | + <strong class="latest-author">{{ c.author_name }}</strong> | |
| 20 | + <a class="latest-message truncate" href="{{ base }}/commit/{{ c.id }}">{{ c.summary }}</a> | |
| 21 | + <span class="latest-meta"> | |
| 22 | + <a class="text-mono text-muted" href="{{ base }}/commit/{{ c.id }}">{{ c.short_id }}</a> | |
| 23 | + <span class="dot">·</span> | |
| 24 | + <time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time> | |
| 25 | + </span> | |
| 26 | + <a class="history-link" href="{{ view.history_href }}">{{ crate::web::fmt::icon("history")|safe }} History</a> | |
| 27 | + </div> | |
| 28 | +</div> | |
| 29 | +{% endif %} | |
| 30 | +<div class="box blob-box"> | |
| 31 | + <div class="box-header blob-header"> | |
| 32 | + <div class="blob-info"> | |
| 33 | + {% if view.line_count > 0 %}<span>{{ crate::web::fmt::plural(view.line_count, "line") }}</span><span class="dot">·</span>{% endif %} | |
| 34 | + <span>{{ crate::web::fmt::bytes(view.size) }}</span> | |
| 35 | + {% if let Some(lang) = view.language %}<span class="dot">·</span><span>{{ lang }}</span>{% endif %} | |
| 36 | + </div> | |
| 37 | + <div class="blob-actions"> | |
| 38 | + {% if view.is_markdown %} | |
| 39 | + <div class="btn-group"> | |
| 40 | + <a class="btn btn-sm{% if !view.plain %} selected{% endif %}" href="?">Preview</a> | |
| 41 | + <a class="btn btn-sm{% if view.plain %} selected{% endif %}" href="?plain=1">Code</a> | |
| 42 | + </div> | |
| 43 | + {% endif %} | |
| 44 | + <a class="btn btn-sm" href="{{ view.raw_href }}">Raw</a> | |
| 45 | + <a class="btn btn-sm btn-icon" href="{{ view.raw_href }}" download="{{ view.file_name }}" title="Download">{{ crate::web::fmt::icon("download")|safe }}</a> | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + {% match view.content %} | |
| 49 | + {% when crate::web::code::BlobContent::Code with { lines } %} | |
| 50 | + <div class="code-scroll"> | |
| 51 | + <table class="code-view" data-lines> | |
| 52 | + <tbody> | |
| 53 | + {% for line in lines %} | |
| 54 | + <tr id="L{{ loop.index }}"><td class="num"><a href="#L{{ loop.index }}">{{ loop.index }}</a></td><td class="code">{{ line|safe }}</td></tr> | |
| 55 | + {% endfor %} | |
| 56 | + </tbody> | |
| 57 | + </table> | |
| 58 | + </div> | |
| 59 | + {% when crate::web::code::BlobContent::Markdown with { html } %} | |
| 60 | + <article class="markdown-body">{{ html|safe }}</article> | |
| 61 | + {% when crate::web::code::BlobContent::Image with { src } %} | |
| 62 | + <div class="blob-image"><img src="{{ src }}" alt="{{ view.file_name }}"></div> | |
| 63 | + {% when crate::web::code::BlobContent::Binary %} | |
| 64 | + <div class="blob-note">Binary file not shown. <a href="{{ view.raw_href }}">View raw</a></div> | |
| 65 | + {% when crate::web::code::BlobContent::TooLarge %} | |
| 66 | + <div class="blob-note">This file is too large to display. <a href="{{ view.raw_href }}">View raw</a></div> | |
| 67 | + {% endmatch %} | |
| 68 | +</div> | |
| 69 | +{% endblock %} |
| @@ -0,0 +1,59 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Branches · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="page-subheader"> | |
| 6 | + <h2>Branches</h2> | |
| 7 | + <a class="btn btn-sm" href="{{ base }}/tags">{{ crate::web::fmt::icon("tag")|safe }} Tags</a> | |
| 8 | +</div> | |
| 9 | +{% if rows.is_empty() %} | |
| 10 | +<div class="blankslate"> | |
| 11 | + {{ crate::web::fmt::icon("git-branch")|safe }} | |
| 12 | + <h3>No branches yet</h3> | |
| 13 | + <p>Push to this repository to create a branch.</p> | |
| 14 | +</div> | |
| 15 | +{% else %} | |
| 16 | +<div class="box"> | |
| 17 | + <div class="box-row branch-row branch-header"> | |
| 18 | + <div class="branch-main">Branch</div> | |
| 19 | + {% if rows.len() > 1 %} | |
| 20 | + <div class="ahead-behind" title="Commits behind and ahead of {{ r.repo.default_branch }}"><span class="ab-behind">Behind</span><span class="ab-divider"></span><span class="ab-ahead">Ahead</span></div> | |
| 21 | + {% endif %} | |
| 22 | + <div class="branch-actions"></div> | |
| 23 | + </div> | |
| 24 | + {% for row in rows %} | |
| 25 | + <div class="box-row branch-row"> | |
| 26 | + <div class="branch-main"> | |
| 27 | + <a class="branch-name" href="{{ base }}/tree/{{ crate::web::fmt::urlpath(row.branch.name) }}">{{ row.branch.name }}</a> | |
| 28 | + {% if row.is_default %}<span class="label">Default</span>{% endif %} | |
| 29 | + <div class="branch-meta"> | |
| 30 | + Updated <time title="{{ crate::web::fmt::datetime_full(row.branch.commit.committer_time) }}">{{ crate::web::fmt::ago(row.branch.commit.committer_time) }}</time> by {{ row.branch.commit.author_name }} | |
| 31 | + </div> | |
| 32 | + </div> | |
| 33 | + {% if row.is_default %} | |
| 34 | + <div class="ahead-behind" aria-hidden="true"></div> | |
| 35 | + {% else %} | |
| 36 | + <div class="ahead-behind" title="{{ row.branch.behind }} commits behind, {{ row.branch.ahead }} commits ahead of {{ r.repo.default_branch }}"> | |
| 37 | + <span class="ab-behind">{{ row.branch.behind }}</span> | |
| 38 | + <span class="ab-divider"></span> | |
| 39 | + <span class="ab-ahead">{{ row.branch.ahead }}</span> | |
| 40 | + </div> | |
| 41 | + {% endif %} | |
| 42 | + <div class="branch-actions"> | |
| 43 | + {% if let Some(n) = row.pull %} | |
| 44 | + <a class="btn btn-sm" href="{{ base }}/pull/{{ n }}"><span class="state-color-open">{{ crate::web::fmt::icon("git-pull-request")|safe }}</span> #{{ n }}</a> | |
| 45 | + {% else if !row.is_default && row.branch.ahead > 0 %} | |
| 46 | + <a class="btn btn-sm" href="{{ base }}/compare/{{ crate::web::fmt::urlpath(r.repo.default_branch) }}...{{ crate::web::fmt::urlpath(row.branch.name) }}">New pull request</a> | |
| 47 | + {% endif %} | |
| 48 | + {% if r.access.write && !row.is_default %} | |
| 49 | + <form method="post" action="{{ base }}/branches/delete" data-confirm="Delete the branch “{{ row.branch.name }}”? This cannot be undone."> | |
| 50 | + <input type="hidden" name="branch" value="{{ row.branch.name }}"> | |
| 51 | + <button class="btn btn-sm btn-danger btn-icon" type="submit" title="Delete branch">{{ crate::web::fmt::icon("trash-2")|safe }}</button> | |
| 52 | + </form> | |
| 53 | + {% endif %} | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + {% endfor %} | |
| 57 | +</div> | |
| 58 | +{% endif %} | |
| 59 | +{% endblock %} |
| @@ -0,0 +1,88 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}{% if !view.path.is_empty() %}{{ view.path }} at {{ view.refname }} · {% endif %}{% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +{% if let Some((branch, pushed)) = view.recent_branch %} | |
| 6 | +<div class="banner"> | |
| 7 | + <span>{{ crate::web::fmt::icon("git-branch")|safe }} <strong>{{ branch }}</strong> had recent pushes <time>{{ crate::web::fmt::ago(pushed) }}</time></span> | |
| 8 | + <a class="btn btn-sm btn-success" href="{{ base }}/compare/{{ crate::web::fmt::urlpath(r.repo.default_branch) }}...{{ crate::web::fmt::urlpath(branch) }}">Compare & pull request</a> | |
| 9 | +</div> | |
| 10 | +{% endif %} | |
| 11 | +<div class="{% if is_root %}layout-repo{% else %}layout-full{% endif %}"> | |
| 12 | + <div class="repo-main"> | |
| 13 | + <div class="code-toolbar"> | |
| 14 | + {% let refsel = view.refsel %} | |
| 15 | + {% include "partials/refsel.html" %} | |
| 16 | + {% if is_root %} | |
| 17 | + <a class="toolbar-link" href="{{ base }}/branches">{{ crate::web::fmt::icon("git-branch")|safe }} <strong>{{ view.branch_count }}</strong> {% if view.branch_count == 1 %}branch{% else %}branches{% endif %}</a> | |
| 18 | + <a class="toolbar-link" href="{{ base }}/tags">{{ crate::web::fmt::icon("tag")|safe }} <strong>{{ view.tag_count }}</strong> {% if view.tag_count == 1 %}tag{% else %}tags{% endif %}</a> | |
| 19 | + {% else %} | |
| 20 | + <nav class="breadcrumbs" aria-label="Path"> | |
| 21 | + <a href="{{ base }}/tree/{{ crate::web::fmt::urlpath(view.refname) }}"><strong>{{ r.repo.name }}</strong></a> | |
| 22 | + {% for c in view.crumbs %}<span class="crumb-sep">/</span>{% if loop.last %}<strong>{{ c.name }}</strong>{% else %}<a href="{{ c.href }}">{{ c.name }}</a>{% endif %}{% endfor %} | |
| 23 | + </nav> | |
| 24 | + {% endif %} | |
| 25 | + <span class="toolbar-spacer"></span> | |
| 26 | + {% if is_root %}{% include "partials/clone.html" %}{% endif %} | |
| 27 | + </div> | |
| 28 | + <div class="box file-browser"> | |
| 29 | + {% if let Some(c) = view.latest %} | |
| 30 | + <div class="box-header latest-commit"> | |
| 31 | + {{ crate::web::fmt::avatar(c.author_name, 22)|safe }} | |
| 32 | + <strong class="latest-author">{{ c.author_name }}</strong> | |
| 33 | + <a class="latest-message truncate" href="{{ base }}/commit/{{ c.id }}" title="{{ c.summary }}">{{ c.summary }}</a> | |
| 34 | + <span class="latest-meta"> | |
| 35 | + <a class="text-mono text-muted" href="{{ base }}/commit/{{ c.id }}">{{ c.short_id }}</a> | |
| 36 | + <span class="dot">·</span> | |
| 37 | + <time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time> | |
| 38 | + </span> | |
| 39 | + <a class="history-link" href="{{ view.history_href }}">{{ crate::web::fmt::icon("history")|safe }}{% if is_root %} <strong>{{ view.commit_count }}</strong> {% if view.commit_count == 1 %}commit{% else %}commits{% endif %}{% else %} History{% endif %}</a> | |
| 40 | + </div> | |
| 41 | + {% endif %} | |
| 42 | + <table class="files"> | |
| 43 | + <tbody> | |
| 44 | + {% if let Some(up) = view.parent_href %} | |
| 45 | + <tr><td class="file-icon"></td><td class="file-name" colspan="3"><a href="{{ up }}" title="Up one level">..</a></td></tr> | |
| 46 | + {% endif %} | |
| 47 | + {% for row in view.entries %} | |
| 48 | + <tr> | |
| 49 | + <td class="file-icon{% if row.entry.is_dir() %} is-dir{% endif %}">{% if row.entry.is_dir() %}{{ crate::web::fmt::icon("folder")|safe }}{% else if row.entry.is_submodule() %}{{ crate::web::fmt::icon("folder")|safe }}{% else %}{{ crate::web::fmt::icon("file")|safe }}{% endif %}</td> | |
| 50 | + <td class="file-name">{% if row.entry.is_submodule() %}<span title="Submodule">{{ row.entry.name }}</span>{% else %}<a href="{{ row.href }}">{{ row.entry.name }}</a>{% endif %}</td> | |
| 51 | + <td class="file-message">{% if let Some(c) = row.entry.last_commit %}<a href="{{ base }}/commit/{{ c.id }}" title="{{ c.summary }}">{{ c.summary }}</a>{% endif %}</td> | |
| 52 | + <td class="file-age">{% if let Some(c) = row.entry.last_commit %}<time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time>{% endif %}</td> | |
| 53 | + </tr> | |
| 54 | + {% endfor %} | |
| 55 | + </tbody> | |
| 56 | + </table> | |
| 57 | + </div> | |
| 58 | + {% if let Some(readme) = view.readme %} | |
| 59 | + <div class="box readme" id="readme"> | |
| 60 | + <div class="box-header readme-header">{{ crate::web::fmt::icon("book-marked")|safe }} <strong>{{ readme.name }}</strong></div> | |
| 61 | + {% if readme.is_markdown %} | |
| 62 | + <article class="markdown-body">{{ readme.html|safe }}</article> | |
| 63 | + {% else %} | |
| 64 | + <pre class="readme-plain">{{ readme.html|safe }}</pre> | |
| 65 | + {% endif %} | |
| 66 | + </div> | |
| 67 | + {% endif %} | |
| 68 | + </div> | |
| 69 | + {% if is_root %} | |
| 70 | + <aside class="repo-sidebar"> | |
| 71 | + <h2>About</h2> | |
| 72 | + {% if r.repo.description.is_empty() %} | |
| 73 | + <p class="text-muted"><em>No description provided.</em></p> | |
| 74 | + {% else %} | |
| 75 | + <p class="about-description">{{ r.repo.description }}</p> | |
| 76 | + {% endif %} | |
| 77 | + <ul class="about-list"> | |
| 78 | + {% if view.readme.is_some() %}<li><a href="#readme">{{ crate::web::fmt::icon("book-marked")|safe }} Readme</a></li>{% endif %} | |
| 79 | + <li><a href="{{ base }}/commits">{{ crate::web::fmt::icon("history")|safe }} {{ crate::web::fmt::plural(view.commit_count, "commit") }}</a></li> | |
| 80 | + <li><a href="{{ base }}/branches">{{ crate::web::fmt::icon("git-branch")|safe }} {{ crate::web::fmt::plural(view.branch_count, "branch") }}</a></li> | |
| 81 | + <li><a href="{{ base }}/tags">{{ crate::web::fmt::icon("tag")|safe }} {{ crate::web::fmt::plural(view.tag_count, "tag") }}</a></li> | |
| 82 | + <li><a href="{{ base }}/pulls">{{ crate::web::fmt::icon("git-pull-request")|safe }} {{ r.open_pulls }} open pull {% if r.open_pulls == 1 %}request{% else %}requests{% endif %}</a></li> | |
| 83 | + </ul> | |
| 84 | + {% if r.access.admin %}<a class="text-small" href="{{ base }}/settings">{{ crate::web::fmt::icon("settings")|safe }} Edit repository details</a>{% endif %} | |
| 85 | + </aside> | |
| 86 | + {% endif %} | |
| 87 | +</div> | |
| 88 | +{% endblock %} |
| @@ -0,0 +1,35 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}{{ commit.summary }} · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="box commit-header"> | |
| 6 | + <div class="box-body"> | |
| 7 | + <div class="commit-header-top"> | |
| 8 | + <h1 class="commit-summary">{{ commit.summary }}</h1> | |
| 9 | + <a class="btn btn-sm" href="{{ base }}/tree/{{ commit.id }}">{{ crate::web::fmt::icon("code")|safe }} Browse files</a> | |
| 10 | + </div> | |
| 11 | + {% if !commit.body.is_empty() %}<pre class="commit-body">{{ commit.body }}</pre>{% endif %} | |
| 12 | + </div> | |
| 13 | + <div class="box-footer commit-footer"> | |
| 14 | + <div class="commit-author"> | |
| 15 | + {{ crate::web::fmt::avatar(commit.author_name, 20)|safe }} | |
| 16 | + <strong title="{{ commit.author_email }}">{{ commit.author_name }}</strong> | |
| 17 | + {% if commit.committer_name != commit.author_name %}authored <time title="{{ crate::web::fmt::datetime_full(commit.author_time) }}">{{ crate::web::fmt::ago(commit.author_time) }}</time> and <strong>{{ commit.committer_name }}</strong> committed{% else %}committed{% endif %} | |
| 18 | + <time title="{{ crate::web::fmt::datetime_full(commit.committer_time) }}">{{ crate::web::fmt::ago(commit.committer_time) }}</time> | |
| 19 | + </div> | |
| 20 | + <div class="commit-ids"> | |
| 21 | + {% let parents = commit.short_parents() %} | |
| 22 | + {% if !parents.is_empty() %} | |
| 23 | + <span>{% if parents.len() == 1 %}1 parent{% else %}{{ parents.len() }} parents{% endif %} | |
| 24 | + {% for (full, short) in parents %}<a class="text-mono" href="{{ base }}/commit/{{ full }}">{{ short }}</a>{% if !loop.last %} + {% endif %}{% endfor %} | |
| 25 | + </span> | |
| 26 | + {% endif %} | |
| 27 | + <span>commit <span class="text-mono">{{ commit.id }}</span> | |
| 28 | + <button type="button" class="btn btn-sm btn-ghost btn-icon" data-copy-text="{{ commit.id }}" title="Copy full SHA">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 29 | + </span> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | +</div> | |
| 33 | +{% let file_ref = commit.id.clone() %} | |
| 34 | +{% include "partials/diff.html" %} | |
| 35 | +{% endblock %} |
| @@ -0,0 +1,28 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Commits · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="code-toolbar"> | |
| 6 | + {% let refsel = view.refsel %} | |
| 7 | + {% include "partials/refsel.html" %} | |
| 8 | + {% if !view.path.is_empty() %} | |
| 9 | + <span class="toolbar-text">History for <a href="{{ base }}/blob/{{ crate::web::fmt::urlpath(view.refname) }}/{{ crate::web::fmt::urlpath(view.path) }}"><strong class="text-mono">{{ view.path }}</strong></a></span> | |
| 10 | + {% endif %} | |
| 11 | +</div> | |
| 12 | +{% if view.groups.is_empty() %} | |
| 13 | +<div class="blankslate"> | |
| 14 | + {{ crate::web::fmt::icon("history")|safe }} | |
| 15 | + <h3>No commits found</h3> | |
| 16 | + <p>There is no history for this path on {{ view.refname }}.</p> | |
| 17 | +</div> | |
| 18 | +{% else %} | |
| 19 | +{% let groups = view.groups %} | |
| 20 | +{% include "partials/commit_list.html" %} | |
| 21 | +{% endif %} | |
| 22 | +{% if view.newer_href.is_some() || view.older_href.is_some() %} | |
| 23 | +<nav class="pagination"> | |
| 24 | + {% if let Some(h) = view.newer_href %}<a class="btn" href="{{ h }}">Newer</a>{% else %}<span class="btn disabled">Newer</span>{% endif %} | |
| 25 | + {% if let Some(h) = view.older_href %}<a class="btn" href="{{ h }}">Older</a>{% else %}<span class="btn disabled">Older</span>{% endif %} | |
| 26 | +</nav> | |
| 27 | +{% endif %} | |
| 28 | +{% endblock %} |
| @@ -0,0 +1,92 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Compare · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="page-subheader page-subheader-stacked"> | |
| 6 | + <h2>Compare changes</h2> | |
| 7 | + <p class="text-muted">Choose two branches to see what has changed or to open a pull request.</p> | |
| 8 | +</div> | |
| 9 | +<form class="compare-bar box" method="get" action="{{ base }}/compare"> | |
| 10 | + <span class="compare-icon">{{ crate::web::fmt::icon("git-compare")|safe }}</span> | |
| 11 | + <label class="compare-select"><span>base:</span> | |
| 12 | + <select class="input input-sm" name="base" data-autosubmit> | |
| 13 | + {% for b in view.branches %}<option value="{{ b }}"{% if *b == view.base %} selected{% endif %}>{{ b }}</option>{% endfor %} | |
| 14 | + </select> | |
| 15 | + </label> | |
| 16 | + <span class="compare-arrow">←</span> | |
| 17 | + <label class="compare-select"><span>compare:</span> | |
| 18 | + <select class="input input-sm" name="head" data-autosubmit> | |
| 19 | + <option value="">choose a branch…</option> | |
| 20 | + {% for b in view.branches %}<option value="{{ b }}"{% if *b == view.head %} selected{% endif %}>{{ b }}</option>{% endfor %} | |
| 21 | + </select> | |
| 22 | + </label> | |
| 23 | + <noscript><button class="btn btn-sm" type="submit">Compare</button></noscript> | |
| 24 | + {% if let Some(m) = view.mergeability %} | |
| 25 | + {% match m %} | |
| 26 | + {% when crate::git::Mergeability::Clean %}<span class="merge-hint text-success">{{ crate::web::fmt::icon("check")|safe }} <strong>Able to merge.</strong> These branches can be automatically merged.</span> | |
| 27 | + {% when crate::git::Mergeability::Conflicts %}<span class="merge-hint text-danger">{{ crate::web::fmt::icon("x")|safe }} <strong>Can't automatically merge.</strong> You can still open the pull request.</span> | |
| 28 | + {% when crate::git::Mergeability::UpToDate %} | |
| 29 | + {% endmatch %} | |
| 30 | + {% endif %} | |
| 31 | +</form> | |
| 32 | + | |
| 33 | +{% if let Some(notice) = view.notice %} | |
| 34 | +<div class="blankslate box"> | |
| 35 | + {{ crate::web::fmt::icon("git-compare")|safe }} | |
| 36 | + <h3>{{ notice }}</h3> | |
| 37 | +</div> | |
| 38 | +{% else %} | |
| 39 | + {% if let Some(n) = view.existing %} | |
| 40 | + <div class="banner"> | |
| 41 | + <span>{{ crate::web::fmt::icon("git-pull-request")|safe }} There is already an open pull request for these branches.</span> | |
| 42 | + <a class="btn btn-sm" href="{{ base }}/pull/{{ n }}">View pull request #{{ n }}</a> | |
| 43 | + </div> | |
| 44 | + {% else if view.can_open() %} | |
| 45 | + {% if ctx.user.is_some() %} | |
| 46 | + <form method="post" action="{{ base }}/pulls" class="box new-pull"> | |
| 47 | + <div class="new-pull-body"> | |
| 48 | + {% if let Some(u) = ctx.user %}{{ crate::web::fmt::avatar(u.username, 40)|safe }}{% endif %} | |
| 49 | + <div class="new-pull-fields"> | |
| 50 | + <input type="hidden" name="base" value="{{ view.base }}"> | |
| 51 | + <input type="hidden" name="head" value="{{ view.head }}"> | |
| 52 | + <label class="field"> | |
| 53 | + <span class="field-label">Title</span> | |
| 54 | + <input class="input" type="text" name="title" value="{{ view.title }}" maxlength="256" required> | |
| 55 | + </label> | |
| 56 | + <label class="field"> | |
| 57 | + <span class="field-label">Description</span> | |
| 58 | + <textarea class="input" name="body" rows="8" placeholder="Describe your changes. Markdown is supported."></textarea> | |
| 59 | + </label> | |
| 60 | + <div class="form-actions"><button class="btn btn-success" type="submit">Create pull request</button></div> | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + </form> | |
| 64 | + {% else %} | |
| 65 | + <div class="banner"><span><a href="/login?next={{ crate::web::fmt::urlencode(ctx.path) }}">Sign in</a> to open a pull request.</span></div> | |
| 66 | + {% endif %} | |
| 67 | + {% endif %} | |
| 68 | + | |
| 69 | + <div class="compare-summary box"> | |
| 70 | + <span>{{ crate::web::fmt::icon("git-commit-horizontal")|safe }} <strong>{{ crate::web::fmt::plural(view.commits.len(), "commit") }}</strong></span> | |
| 71 | + {% if let Some(d) = view.diff %} | |
| 72 | + <span>{{ crate::web::fmt::icon("file-text")|safe }} <strong>{{ crate::web::fmt::plural(d.files.len(), "file") }} changed</strong></span> | |
| 73 | + {% endif %} | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div class="box compare-commits"> | |
| 77 | + {% for c in view.commits %} | |
| 78 | + <div class="box-row commit-row commit-row-compact"> | |
| 79 | + {{ crate::web::fmt::avatar(c.author_name, 20)|safe }} | |
| 80 | + <span class="text-muted text-small commit-row-author">{{ c.author_name }}</span> | |
| 81 | + <a class="commit-title truncate" href="{{ base }}/commit/{{ c.id }}">{{ c.summary }}</a> | |
| 82 | + <a class="text-mono text-muted text-small" href="{{ base }}/commit/{{ c.id }}">{{ c.short_id }}</a> | |
| 83 | + </div> | |
| 84 | + {% endfor %} | |
| 85 | + </div> | |
| 86 | + | |
| 87 | + {% if let Some(diff) = view.diff %} | |
| 88 | + {% let file_ref = crate::web::fmt::urlpath(view.head) %} | |
| 89 | + {% include "partials/diff.html" %} | |
| 90 | + {% endif %} | |
| 91 | +{% endif %} | |
| 92 | +{% endblock %} |
| @@ -0,0 +1,34 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block content %} | |
| 3 | +{% if !r.repo.description.is_empty() %}<p class="repo-description">{{ r.repo.description }}</p>{% endif %} | |
| 4 | +<div class="box quick-setup"> | |
| 5 | + <div class="box-header"> | |
| 6 | + <h2>Quick setup</h2> | |
| 7 | + <span class="text-muted">This repository is empty. Push some code to get started.</span> | |
| 8 | + </div> | |
| 9 | + <div class="box-body"> | |
| 10 | + <div class="input-group"> | |
| 11 | + <span class="input-group-label">SSH</span> | |
| 12 | + <input class="input input-mono" type="text" readonly value="{{ r.ssh_url }}" id="empty-ssh" aria-label="SSH clone URL"> | |
| 13 | + <button type="button" class="btn" data-copy="#empty-ssh" title="Copy">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 14 | + </div> | |
| 15 | + {% if r.access.write %} | |
| 16 | + <h3>…or create a new repository on the command line</h3> | |
| 17 | + <pre class="command">echo "# {{ r.repo.name }}" >> README.md | |
| 18 | +git init | |
| 19 | +git add README.md | |
| 20 | +git commit -m "first commit" | |
| 21 | +git branch -M {{ r.repo.default_branch }} | |
| 22 | +git remote add origin {{ r.ssh_url }} | |
| 23 | +git push -u origin {{ r.repo.default_branch }}</pre> | |
| 24 | + <h3>…or push an existing repository from the command line</h3> | |
| 25 | + <pre class="command">git remote add origin {{ r.ssh_url }} | |
| 26 | +git branch -M {{ r.repo.default_branch }} | |
| 27 | +git push -u origin {{ r.repo.default_branch }}</pre> | |
| 28 | + <p class="text-muted text-small">Pushing requires an SSH key on your account. <a href="/settings/keys">Manage SSH keys</a>.</p> | |
| 29 | + {% else %} | |
| 30 | + <p class="text-muted">Nothing has been pushed to this repository yet.</p> | |
| 31 | + {% endif %} | |
| 32 | + </div> | |
| 33 | +</div> | |
| 34 | +{% endblock %} |
| @@ -0,0 +1,256 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}{{ pr.title }} · Pull request #{{ pr.number }} · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +{% let purl = r.pull_url(pr.number) %} | |
| 6 | +<div class="pull-header"> | |
| 7 | + <div class="pull-title-row"> | |
| 8 | + <h1 class="pull-title">{{ pr.title }} <span class="text-muted">#{{ pr.number }}</span></h1> | |
| 9 | + {% if can_close %}<button type="button" class="btn btn-sm" data-toggle="edit-pull">Edit</button>{% endif %} | |
| 10 | + </div> | |
| 11 | + {% if can_close %} | |
| 12 | + <form id="edit-pull" class="box box-body form edit-pull" method="post" action="{{ purl }}/edit" hidden> | |
| 13 | + <label class="field"><span class="field-label">Title</span><input class="input" type="text" name="title" value="{{ pr.title }}" maxlength="256" required></label> | |
| 14 | + <label class="field"><span class="field-label">Description</span><textarea class="input" name="body" rows="6">{{ pr.body }}</textarea></label> | |
| 15 | + <div class="form-actions"> | |
| 16 | + <button class="btn btn-primary" type="submit">Save</button> | |
| 17 | + <button class="btn" type="button" data-toggle="edit-pull">Cancel</button> | |
| 18 | + </div> | |
| 19 | + </form> | |
| 20 | + {% endif %} | |
| 21 | + <div class="pull-meta"> | |
| 22 | + {% if pr.state == "merged" %} | |
| 23 | + <span class="state state-merged">{{ crate::web::fmt::icon("git-merge")|safe }} Merged</span> | |
| 24 | + <span><strong>{{ pr.merged_by.clone().unwrap_or(pr.author.clone()) }}</strong> merged {{ crate::web::fmt::plural(data.commits.len(), "commit") }} into <span class="branch-label">{{ pr.base_branch }}</span> from <span class="branch-label">{{ pr.head_branch }}</span>{% if let Some(t) = pr.merged_at %} <time title="{{ crate::web::fmt::datetime_full(t) }}">{{ crate::web::fmt::ago(t) }}</time>{% endif %}</span> | |
| 25 | + {% else if pr.state == "closed" %} | |
| 26 | + <span class="state state-closed">{{ crate::web::fmt::icon("git-pull-request-closed")|safe }} Closed</span> | |
| 27 | + <span><strong>{{ pr.author }}</strong> wanted to merge {{ crate::web::fmt::plural(data.commits.len(), "commit") }} into <span class="branch-label">{{ pr.base_branch }}</span> from <span class="branch-label">{{ pr.head_branch }}</span></span> | |
| 28 | + {% else %} | |
| 29 | + <span class="state state-open">{{ crate::web::fmt::icon("git-pull-request")|safe }} Open</span> | |
| 30 | + <span><strong>{{ pr.author }}</strong> wants to merge {{ crate::web::fmt::plural(data.commits.len(), "commit") }} into <span class="branch-label">{{ pr.base_branch }}</span> from <span class="branch-label">{{ pr.head_branch }}</span></span> | |
| 31 | + {% endif %} | |
| 32 | + </div> | |
| 33 | +</div> | |
| 34 | + | |
| 35 | +<nav class="tabs tabs-sub" aria-label="Pull request"> | |
| 36 | + <a class="tab{% if pull_tab == "conversation" %} selected{% endif %}" href="{{ purl }}">{{ crate::web::fmt::icon("message-square")|safe }} Conversation <span class="counter">{{ pr.comments }}</span></a> | |
| 37 | + <a class="tab{% if pull_tab == "commits" %} selected{% endif %}" href="{{ purl }}/commits">{{ crate::web::fmt::icon("git-commit-horizontal")|safe }} Commits <span class="counter">{{ data.commits.len() }}</span></a> | |
| 38 | + <a class="tab{% if pull_tab == "files" %} selected{% endif %}" href="{{ purl }}/files">{{ crate::web::fmt::icon("file-text")|safe }} Files changed <span class="counter">{{ data.files }}</span></a> | |
| 39 | + <span class="tabs-extra"><span class="text-success">+{{ data.additions }}</span> <span class="text-danger">−{{ data.deletions }}</span></span> | |
| 40 | +</nav> | |
| 41 | + | |
| 42 | +{% if pull_tab == "conversation" %} | |
| 43 | +<div class="layout-pull"> | |
| 44 | + <div class="timeline"> | |
| 45 | + <div class="timeline-comment"> | |
| 46 | + <div class="timeline-avatar">{{ crate::web::fmt::avatar(pr.author, 40)|safe }}</div> | |
| 47 | + <div class="comment"> | |
| 48 | + <div class="comment-header"> | |
| 49 | + <strong>{{ pr.author }}</strong> <span class="text-muted">opened this <time title="{{ crate::web::fmt::datetime_full(pr.created_at) }}">{{ crate::web::fmt::ago(pr.created_at) }}</time></span> | |
| 50 | + <span class="label comment-role">Author</span> | |
| 51 | + </div> | |
| 52 | + <div class="comment-body markdown-body"> | |
| 53 | + {% if pr.body.is_empty() %}<p class="text-muted"><em>No description provided.</em></p>{% else %}{{ body_html|safe }}{% endif %} | |
| 54 | + </div> | |
| 55 | + </div> | |
| 56 | + </div> | |
| 57 | + | |
| 58 | + {% for item in timeline %} | |
| 59 | + {% let e = item.event %} | |
| 60 | + {% if e.kind == "comment" %} | |
| 61 | + <div class="timeline-comment" id="event-{{ e.id }}"> | |
| 62 | + <div class="timeline-avatar">{{ crate::web::fmt::avatar(e.author, 40)|safe }}</div> | |
| 63 | + <div class="comment"> | |
| 64 | + <div class="comment-header"> | |
| 65 | + <strong>{{ e.author }}</strong> <span class="text-muted">commented <a class="text-muted" href="#event-{{ e.id }}"><time title="{{ crate::web::fmt::datetime_full(e.created_at) }}">{{ crate::web::fmt::ago(e.created_at) }}</time></a></span> | |
| 66 | + {% if e.author == pr.author %}<span class="label comment-role">Author</span>{% endif %} | |
| 67 | + </div> | |
| 68 | + <div class="comment-body markdown-body">{{ item.html|safe }}</div> | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + {% else %} | |
| 72 | + <div class="timeline-event"> | |
| 73 | + {% if e.kind == "merged" %} | |
| 74 | + <span class="event-badge badge-merged">{{ crate::web::fmt::icon("git-merge")|safe }}</span> | |
| 75 | + <span>{% if e.author.is_empty() %}This pull request was <strong>merged</strong>{% else %}<strong>{{ e.author }}</strong> merged{% endif %} | |
| 76 | + {% if let Some(sha) = pr.merge_commit_sha %}commit <a class="text-mono" href="{{ base }}/commit/{{ sha }}">{{ crate::web::fmt::short_sha(sha) }}</a>{% endif %} | |
| 77 | + into <span class="branch-label">{{ pr.base_branch }}</span> | |
| 78 | + {% else if e.kind == "closed" %} | |
| 79 | + <span class="event-badge badge-closed">{{ crate::web::fmt::icon("git-pull-request-closed")|safe }}</span> | |
| 80 | + <span><strong>{{ e.author }}</strong> closed this | |
| 81 | + {% else if e.kind == "reopened" %} | |
| 82 | + <span class="event-badge badge-open">{{ crate::web::fmt::icon("git-pull-request")|safe }}</span> | |
| 83 | + <span><strong>{{ e.author }}</strong> reopened this | |
| 84 | + {% else if e.kind == "branch_deleted" %} | |
| 85 | + <span class="event-badge">{{ crate::web::fmt::icon("git-branch")|safe }}</span> | |
| 86 | + <span><strong>{{ e.author }}</strong> deleted the <span class="branch-label">{{ e.body }}</span> branch | |
| 87 | + {% else %} | |
| 88 | + <span class="event-badge">{{ crate::web::fmt::icon("clock")|safe }}</span> | |
| 89 | + <span>{{ e.kind }} | |
| 90 | + {% endif %} | |
| 91 | + <time class="text-muted" title="{{ crate::web::fmt::datetime_full(e.created_at) }}">{{ crate::web::fmt::ago(e.created_at) }}</time></span> | |
| 92 | + </div> | |
| 93 | + {% endif %} | |
| 94 | + {% endfor %} | |
| 95 | + | |
| 96 | + {% if pr.state == "open" %} | |
| 97 | + <div class="timeline-comment merge-section"> | |
| 98 | + <div class="timeline-avatar merge-avatar state-{% if data.mergeability == Some(crate::git::Mergeability::Clean) %}open{% else %}neutral{% endif %}">{{ crate::web::fmt::icon("git-merge")|safe }}</div> | |
| 99 | + <div class="merge-box{% if data.mergeability == Some(crate::git::Mergeability::Clean) %} merge-box-ready{% endif %}"> | |
| 100 | + {% if !data.head_exists %} | |
| 101 | + <div class="merge-status"> | |
| 102 | + <span class="merge-status-icon status-neutral">{{ crate::web::fmt::icon("git-branch")|safe }}</span> | |
| 103 | + <div><strong>The head branch {{ pr.head_branch }} no longer exists.</strong><div class="text-muted">Push the branch again or close this pull request.</div></div> | |
| 104 | + </div> | |
| 105 | + {% else if !data.base_exists %} | |
| 106 | + <div class="merge-status"> | |
| 107 | + <span class="merge-status-icon status-neutral">{{ crate::web::fmt::icon("git-branch")|safe }}</span> | |
| 108 | + <div><strong>The base branch {{ pr.base_branch }} no longer exists.</strong></div> | |
| 109 | + </div> | |
| 110 | + {% else if data.mergeability == Some(crate::git::Mergeability::Conflicts) %} | |
| 111 | + <div class="merge-status"> | |
| 112 | + <span class="merge-status-icon status-danger">{{ crate::web::fmt::icon("x")|safe }}</span> | |
| 113 | + <div><strong>This branch has conflicts that must be resolved</strong><div class="text-muted">Resolve them locally and push the result:</div></div> | |
| 114 | + </div> | |
| 115 | + <pre class="command merge-instructions">git fetch origin | |
| 116 | +git checkout {{ pr.head_branch }} | |
| 117 | +git merge origin/{{ pr.base_branch }} | |
| 118 | +# fix conflicts, then | |
| 119 | +git commit | |
| 120 | +git push origin {{ pr.head_branch }}</pre> | |
| 121 | + {% else %} | |
| 122 | + <div class="merge-status"> | |
| 123 | + <span class="merge-status-icon status-success">{{ crate::web::fmt::icon("check")|safe }}</span> | |
| 124 | + <div><strong>This branch has no conflicts with the base branch</strong><div class="text-muted">Merging can be performed automatically.</div></div> | |
| 125 | + </div> | |
| 126 | + {% if r.access.write %} | |
| 127 | + <form class="merge-form" method="post" action="{{ purl }}/merge"> | |
| 128 | + <select class="input input-sm" name="method" aria-label="Merge method"> | |
| 129 | + <option value="merge">Create a merge commit</option> | |
| 130 | + <option value="squash">Squash and merge</option> | |
| 131 | + <option value="rebase">Rebase and merge</option> | |
| 132 | + </select> | |
| 133 | + <button class="btn btn-success" type="submit">{{ crate::web::fmt::icon("git-merge")|safe }} Merge pull request</button> | |
| 134 | + </form> | |
| 135 | + {% else %} | |
| 136 | + <p class="merge-note text-muted">Only people with write access to this repository can merge pull requests.</p> | |
| 137 | + {% endif %} | |
| 138 | + {% endif %} | |
| 139 | + </div> | |
| 140 | + </div> | |
| 141 | + {% else if pr.state == "merged" %} | |
| 142 | + <div class="timeline-comment merge-section"> | |
| 143 | + <div class="timeline-avatar merge-avatar state-merged">{{ crate::web::fmt::icon("git-merge")|safe }}</div> | |
| 144 | + <div class="merge-box merge-box-merged"> | |
| 145 | + <div class="merge-status"> | |
| 146 | + <div><strong>Pull request successfully merged and closed</strong><div class="text-muted">{% if can_delete_branch %}The <span class="branch-label">{{ pr.head_branch }}</span> branch can be safely deleted.{% else %}You're all set.{% endif %}</div></div> | |
| 147 | + {% if can_delete_branch %} | |
| 148 | + <form method="post" action="{{ base }}/branches/delete"> | |
| 149 | + <input type="hidden" name="branch" value="{{ pr.head_branch }}"> | |
| 150 | + <input type="hidden" name="return_to" value="{{ purl }}"> | |
| 151 | + <input type="hidden" name="pr" value="{{ pr.number }}"> | |
| 152 | + <button class="btn btn-sm" type="submit">Delete branch</button> | |
| 153 | + </form> | |
| 154 | + {% endif %} | |
| 155 | + </div> | |
| 156 | + </div> | |
| 157 | + </div> | |
| 158 | + {% else %} | |
| 159 | + <div class="timeline-comment merge-section"> | |
| 160 | + <div class="timeline-avatar merge-avatar state-closed">{{ crate::web::fmt::icon("git-pull-request-closed")|safe }}</div> | |
| 161 | + <div class="merge-box"> | |
| 162 | + <div class="merge-status"> | |
| 163 | + <div><strong>Closed with unmerged commits</strong><div class="text-muted">This pull request is closed, but the <span class="branch-label">{{ pr.head_branch }}</span> branch has unmerged commits.</div></div> | |
| 164 | + {% if can_delete_branch %} | |
| 165 | + <form method="post" action="{{ base }}/branches/delete" data-confirm="Delete the branch “{{ pr.head_branch }}”? Its commits are not merged."> | |
| 166 | + <input type="hidden" name="branch" value="{{ pr.head_branch }}"> | |
| 167 | + <input type="hidden" name="return_to" value="{{ purl }}"> | |
| 168 | + <input type="hidden" name="pr" value="{{ pr.number }}"> | |
| 169 | + <button class="btn btn-sm btn-danger" type="submit">Delete branch</button> | |
| 170 | + </form> | |
| 171 | + {% endif %} | |
| 172 | + </div> | |
| 173 | + </div> | |
| 174 | + </div> | |
| 175 | + {% endif %} | |
| 176 | + | |
| 177 | + {% if let Some(u) = ctx.user %} | |
| 178 | + <div class="timeline-comment comment-form-row"> | |
| 179 | + <div class="timeline-avatar">{{ crate::web::fmt::avatar(u.username, 40)|safe }}</div> | |
| 180 | + <form class="comment comment-form" method="post" action="{{ purl }}/comment"> | |
| 181 | + <div class="comment-header"><strong>Add a comment</strong></div> | |
| 182 | + <div class="comment-form-body"> | |
| 183 | + <textarea class="input" name="body" rows="5" placeholder="Leave a comment. Markdown is supported."></textarea> | |
| 184 | + <div class="form-actions form-actions-end"> | |
| 185 | + {% if pr.state == "open" && can_close %} | |
| 186 | + <button class="btn" type="submit" name="close" value="1">{{ crate::web::fmt::icon("git-pull-request-closed")|safe }} Close pull request</button> | |
| 187 | + {% endif %} | |
| 188 | + <button class="btn btn-primary" type="submit">Comment</button> | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + </form> | |
| 192 | + </div> | |
| 193 | + {% if pr.state == "closed" && can_close %} | |
| 194 | + <form class="reopen-form" method="post" action="{{ purl }}/state"> | |
| 195 | + <input type="hidden" name="action" value="reopen"> | |
| 196 | + <button class="btn" type="submit">Reopen pull request</button> | |
| 197 | + </form> | |
| 198 | + {% endif %} | |
| 199 | + {% else %} | |
| 200 | + <div class="banner"><span><a href="/login?next={{ crate::web::fmt::urlencode(ctx.path) }}">Sign in</a> to join the conversation.</span></div> | |
| 201 | + {% endif %} | |
| 202 | + </div> | |
| 203 | + | |
| 204 | + <aside class="pull-sidebar"> | |
| 205 | + <div class="sidebar-item"> | |
| 206 | + <h3>Branches</h3> | |
| 207 | + <p><span class="branch-label">{{ pr.base_branch }}</span> ← <span class="branch-label">{{ pr.head_branch }}</span></p> | |
| 208 | + </div> | |
| 209 | + <div class="sidebar-item"> | |
| 210 | + <h3>Changes</h3> | |
| 211 | + <p>{{ crate::web::fmt::plural(data.commits.len(), "commit") }}, {{ crate::web::fmt::plural(data.files, "file") }}<br> | |
| 212 | + <span class="text-success">+{{ data.additions }}</span> <span class="text-danger">−{{ data.deletions }}</span></p> | |
| 213 | + </div> | |
| 214 | + <div class="sidebar-item"> | |
| 215 | + <h3>Checkout locally</h3> | |
| 216 | + <pre class="command command-sm">git fetch origin pull/{{ pr.number }}/head:pr-{{ pr.number }} | |
| 217 | +git checkout pr-{{ pr.number }}</pre> | |
| 218 | + </div> | |
| 219 | + </aside> | |
| 220 | +</div> | |
| 221 | + | |
| 222 | +{% else if pull_tab == "commits" %} | |
| 223 | +{% if data.commits.is_empty() %} | |
| 224 | +<div class="blankslate box"><p>No commits to show.</p></div> | |
| 225 | +{% else %} | |
| 226 | +<div class="box"> | |
| 227 | + {% for c in data.commits %} | |
| 228 | + <div class="box-row commit-row"> | |
| 229 | + <div class="commit-main"> | |
| 230 | + <a class="commit-title" href="{{ base }}/commit/{{ c.id }}">{{ c.summary }}</a> | |
| 231 | + <div class="commit-meta"> | |
| 232 | + {{ crate::web::fmt::avatar(c.author_name, 18)|safe }} | |
| 233 | + <strong>{{ c.author_name }}</strong> committed <time title="{{ crate::web::fmt::datetime_full(c.committer_time) }}">{{ crate::web::fmt::ago(c.committer_time) }}</time> | |
| 234 | + </div> | |
| 235 | + </div> | |
| 236 | + <div class="commit-actions"> | |
| 237 | + <div class="btn-group"> | |
| 238 | + <a class="btn btn-sm btn-mono" href="{{ base }}/commit/{{ c.id }}">{{ c.short_id }}</a> | |
| 239 | + <button type="button" class="btn btn-sm" data-copy-text="{{ c.id }}" title="Copy full SHA">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 240 | + </div> | |
| 241 | + <a class="btn btn-sm btn-ghost" href="{{ base }}/tree/{{ c.id }}" title="Browse the repository at this point">{{ crate::web::fmt::icon("code")|safe }}</a> | |
| 242 | + </div> | |
| 243 | + </div> | |
| 244 | + {% endfor %} | |
| 245 | +</div> | |
| 246 | +{% endif %} | |
| 247 | + | |
| 248 | +{% else %} | |
| 249 | +{% if let Some(diff) = data.diff %} | |
| 250 | +{% let file_ref = pr.head_sha.clone().unwrap_or(crate::web::fmt::urlpath(pr.head_branch)) %} | |
| 251 | +{% include "partials/diff.html" %} | |
| 252 | +{% else %} | |
| 253 | +<div class="blankslate box"><p>The changes for this pull request are no longer available.</p></div> | |
| 254 | +{% endif %} | |
| 255 | +{% endif %} | |
| 256 | +{% endblock %} |
| @@ -0,0 +1,45 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Pull requests · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="page-subheader"> | |
| 6 | + <div class="btn-group"> | |
| 7 | + <a class="btn btn-sm{% if showing_open %} selected{% endif %}" href="{{ base }}/pulls">{{ crate::web::fmt::icon("git-pull-request")|safe }} {{ open_count }} Open</a> | |
| 8 | + <a class="btn btn-sm{% if !showing_open %} selected{% endif %}" href="{{ base }}/pulls?state=closed">{{ crate::web::fmt::icon("check")|safe }} {{ closed_count }} Closed</a> | |
| 9 | + </div> | |
| 10 | + {% if ctx.user.is_some() %}<a class="btn btn-sm btn-primary" href="{{ base }}/compare">New pull request</a>{% endif %} | |
| 11 | +</div> | |
| 12 | +{% if pulls.is_empty() %} | |
| 13 | +<div class="blankslate box"> | |
| 14 | + {{ crate::web::fmt::icon("git-pull-request")|safe }} | |
| 15 | + <h3>{% if showing_open %}No open pull requests{% else %}No closed pull requests{% endif %}</h3> | |
| 16 | + <p>Pull requests let you propose, discuss and review changes before merging them into a branch.</p> | |
| 17 | + {% if showing_open && ctx.user.is_some() %}<a class="btn btn-primary" href="{{ base }}/compare">New pull request</a>{% endif %} | |
| 18 | +</div> | |
| 19 | +{% else %} | |
| 20 | +<div class="box"> | |
| 21 | + {% for pr in pulls %} | |
| 22 | + <div class="box-row issue-row"> | |
| 23 | + <span class="issue-icon state-color-{{ pr.state }}">{% if pr.state == "merged" %}{{ crate::web::fmt::icon("git-merge")|safe }}{% else if pr.state == "closed" %}{{ crate::web::fmt::icon("git-pull-request-closed")|safe }}{% else %}{{ crate::web::fmt::icon("git-pull-request")|safe }}{% endif %}</span> | |
| 24 | + <div class="issue-main"> | |
| 25 | + <a class="issue-title" href="{{ base }}/pull/{{ pr.number }}">{{ pr.title }}</a> | |
| 26 | + <div class="issue-meta"> | |
| 27 | + #{{ pr.number }} | |
| 28 | + {% if pr.state == "merged" %} | |
| 29 | + by {{ pr.author }} was merged {% if let Some(t) = pr.merged_at %}<time title="{{ crate::web::fmt::datetime_full(t) }}">{{ crate::web::fmt::ago(t) }}</time>{% endif %} | |
| 30 | + {% else if pr.state == "closed" %} | |
| 31 | + by {{ pr.author }} was closed {% if let Some(t) = pr.closed_at %}<time title="{{ crate::web::fmt::datetime_full(t) }}">{{ crate::web::fmt::ago(t) }}</time>{% endif %} | |
| 32 | + {% else %} | |
| 33 | + opened <time title="{{ crate::web::fmt::datetime_full(pr.created_at) }}">{{ crate::web::fmt::ago(pr.created_at) }}</time> by {{ pr.author }} | |
| 34 | + {% endif %} | |
| 35 | + <span class="branch-pair"><span class="branch-label">{{ pr.base_branch }}</span> ← <span class="branch-label">{{ pr.head_branch }}</span></span> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + {% if pr.comments > 0 %} | |
| 39 | + <a class="issue-comments" href="{{ base }}/pull/{{ pr.number }}">{{ crate::web::fmt::icon("message-square")|safe }} {{ pr.comments }}</a> | |
| 40 | + {% endif %} | |
| 41 | + </div> | |
| 42 | + {% endfor %} | |
| 43 | +</div> | |
| 44 | +{% endif %} | |
| 45 | +{% endblock %} |
| @@ -0,0 +1,91 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Settings · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="narrow-wide"> | |
| 6 | + <section class="settings-section"> | |
| 7 | + <h2 class="settings-title">General</h2> | |
| 8 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 9 | + <form method="post" action="{{ base }}/settings" class="form"> | |
| 10 | + <label class="field"> | |
| 11 | + <span class="field-label">Repository name</span> | |
| 12 | + <input class="input" type="text" name="name" value="{{ r.repo.name }}" maxlength="100" required> | |
| 13 | + <span class="field-hint">Renaming changes the URL. Update your remotes with <code>git remote set-url origin <new url></code>.</span> | |
| 14 | + </label> | |
| 15 | + <label class="field"> | |
| 16 | + <span class="field-label">Description</span> | |
| 17 | + <input class="input" type="text" name="description" value="{{ r.repo.description }}" maxlength="350"> | |
| 18 | + </label> | |
| 19 | + <label class="field"> | |
| 20 | + <span class="field-label">Default branch</span> | |
| 21 | + {% if branches.is_empty() %} | |
| 22 | + <input class="input" type="text" value="{{ r.repo.default_branch }}" disabled> | |
| 23 | + <span class="field-hint">Push a branch to choose a different default.</span> | |
| 24 | + {% else %} | |
| 25 | + <select class="input" name="default_branch"> | |
| 26 | + {% for b in branches %}<option value="{{ b }}"{% if *b == r.repo.default_branch %} selected{% endif %}>{{ b }}</option>{% endfor %} | |
| 27 | + </select> | |
| 28 | + <span class="field-hint">The branch shown on the repository page and used as the default base for pull requests.</span> | |
| 29 | + {% endif %} | |
| 30 | + </label> | |
| 31 | + <fieldset class="field radio-cards"> | |
| 32 | + <legend class="field-label">Visibility</legend> | |
| 33 | + <label class="radio-card"> | |
| 34 | + <input type="radio" name="visibility" value="public"{% if !r.repo.is_private %} checked{% endif %}> | |
| 35 | + {{ crate::web::fmt::icon("book-marked")|safe }} | |
| 36 | + <span><strong>Public</strong><span class="text-muted">Anyone who can reach this server can see and clone it.</span></span> | |
| 37 | + </label> | |
| 38 | + <label class="radio-card"> | |
| 39 | + <input type="radio" name="visibility" value="private"{% if r.repo.is_private %} checked{% endif %}> | |
| 40 | + {{ crate::web::fmt::icon("lock")|safe }} | |
| 41 | + <span><strong>Private</strong><span class="text-muted">Only you and collaborators can see it.</span></span> | |
| 42 | + </label> | |
| 43 | + </fieldset> | |
| 44 | + <div class="form-actions"><button class="btn btn-primary" type="submit">Save changes</button></div> | |
| 45 | + </form> | |
| 46 | + </section> | |
| 47 | + | |
| 48 | + <section class="settings-section"> | |
| 49 | + <h2 class="settings-title">Collaborators</h2> | |
| 50 | + <p class="text-muted">Collaborators can push branches, merge pull requests and see this repository if it is private.</p> | |
| 51 | + {% if let Some(e) = collab_error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 52 | + <div class="box"> | |
| 53 | + <div class="box-row collaborator-row"> | |
| 54 | + {{ crate::web::fmt::avatar(r.repo.owner, 24)|safe }} | |
| 55 | + <a href="/{{ r.repo.owner }}"><strong>{{ r.repo.owner }}</strong></a> | |
| 56 | + <span class="label">Owner</span> | |
| 57 | + </div> | |
| 58 | + {% for c in collaborators %} | |
| 59 | + <div class="box-row collaborator-row"> | |
| 60 | + {{ crate::web::fmt::avatar(c.username, 24)|safe }} | |
| 61 | + <a href="/{{ c.username }}"><strong>{{ c.username }}</strong></a> | |
| 62 | + <span class="label">Write</span> | |
| 63 | + <form method="post" action="{{ base }}/settings/collaborators/remove" class="push-right" data-confirm="Remove {{ c.username }} as a collaborator?"> | |
| 64 | + <input type="hidden" name="username" value="{{ c.username }}"> | |
| 65 | + <button class="btn btn-sm btn-danger" type="submit">Remove</button> | |
| 66 | + </form> | |
| 67 | + </div> | |
| 68 | + {% endfor %} | |
| 69 | + </div> | |
| 70 | + <form method="post" action="{{ base }}/settings/collaborators" class="inline-form"> | |
| 71 | + <input class="input" type="text" name="username" placeholder="Username" required autocapitalize="off" aria-label="Username"> | |
| 72 | + <button class="btn" type="submit">{{ crate::web::fmt::icon("users")|safe }} Add collaborator</button> | |
| 73 | + </form> | |
| 74 | + </section> | |
| 75 | + | |
| 76 | + <section class="settings-section"> | |
| 77 | + <h2 class="settings-title text-danger">Danger zone</h2> | |
| 78 | + <div class="box danger-zone"> | |
| 79 | + <div class="box-body"> | |
| 80 | + <h3>Delete this repository</h3> | |
| 81 | + <p class="text-muted">This permanently deletes the repository, its history and all pull requests. It cannot be undone.</p> | |
| 82 | + <form method="post" action="{{ base }}/settings/delete" class="inline-form"> | |
| 83 | + <input class="input" type="text" name="confirm" placeholder="{{ r.repo.owner }}/{{ r.repo.name }}" required autocomplete="off" aria-label="Type the repository name to confirm"> | |
| 84 | + <button class="btn btn-danger" type="submit">Delete repository</button> | |
| 85 | + </form> | |
| 86 | + <p class="field-hint">Type <strong>{{ r.repo.owner }}/{{ r.repo.name }}</strong> to confirm.</p> | |
| 87 | + </div> | |
| 88 | + </div> | |
| 89 | + </section> | |
| 90 | +</div> | |
| 91 | +{% endblock %} |
| @@ -0,0 +1,35 @@ | ||
| 1 | +{% extends "layout_repo.html" %} | |
| 2 | +{% block page_title %}Tags · {% endblock %} | |
| 3 | +{% block content %} | |
| 4 | +{% let base = r.base() %} | |
| 5 | +<div class="page-subheader"> | |
| 6 | + <h2>Tags</h2> | |
| 7 | + <a class="btn btn-sm" href="{{ base }}/branches">{{ crate::web::fmt::icon("git-branch")|safe }} Branches</a> | |
| 8 | +</div> | |
| 9 | +{% if tags.is_empty() %} | |
| 10 | +<div class="blankslate"> | |
| 11 | + {{ crate::web::fmt::icon("tag")|safe }} | |
| 12 | + <h3>No tags yet</h3> | |
| 13 | + <p>Tags mark specific points in history, such as releases. Create one with <code>git tag v1.0.0 && git push --tags</code>.</p> | |
| 14 | +</div> | |
| 15 | +{% else %} | |
| 16 | +<div class="box"> | |
| 17 | + {% for t in tags %} | |
| 18 | + <div class="box-row tag-row"> | |
| 19 | + <div class="tag-main"> | |
| 20 | + <a class="tag-name" href="{{ base }}/tree/{{ crate::web::fmt::urlpath(t.name) }}">{{ crate::web::fmt::icon("tag")|safe }} {{ t.name }}</a> | |
| 21 | + {% if let Some(m) = t.message %}<p class="tag-message">{{ m }}</p>{% endif %} | |
| 22 | + <div class="text-muted text-small"> | |
| 23 | + <time title="{{ crate::web::fmt::datetime_full(t.commit.committer_time) }}">{{ crate::web::fmt::date(t.commit.committer_time) }}</time> | |
| 24 | + <span class="dot">·</span> | |
| 25 | + <a class="text-mono" href="{{ base }}/commit/{{ t.commit.id }}">{{ t.commit.short_id }}</a> | |
| 26 | + <span class="dot">·</span> | |
| 27 | + {{ t.commit.summary }} | |
| 28 | + </div> | |
| 29 | + </div> | |
| 30 | + <a class="btn btn-sm btn-ghost" href="{{ base }}/tree/{{ crate::web::fmt::urlpath(t.name) }}">{{ crate::web::fmt::icon("code")|safe }} Browse</a> | |
| 31 | + </div> | |
| 32 | + {% endfor %} | |
| 33 | +</div> | |
| 34 | +{% endif %} | |
| 35 | +{% endblock %} |
| @@ -0,0 +1,63 @@ | ||
| 1 | +{% extends "layout_settings.html" %} | |
| 2 | +{% block title %}SSH keys · Openhub{% endblock %} | |
| 3 | +{% block settings %} | |
| 4 | +<section class="settings-section"> | |
| 5 | + <h2 class="settings-title">SSH keys</h2> | |
| 6 | + <p class="text-muted">These keys can push to and pull from every repository you have access to. Remove any key you no longer recognise.</p> | |
| 7 | + {% if keys.is_empty() %} | |
| 8 | + <div class="blankslate blankslate-sm box"> | |
| 9 | + {{ crate::web::fmt::icon("key-round")|safe }} | |
| 10 | + <h3>No SSH keys yet</h3> | |
| 11 | + <p>Add a key below to start pushing over SSH.</p> | |
| 12 | + </div> | |
| 13 | + {% else %} | |
| 14 | + <div class="box"> | |
| 15 | + {% for k in keys %} | |
| 16 | + <div class="box-row key-row"> | |
| 17 | + <div class="key-icon">{{ crate::web::fmt::icon("key-round")|safe }}<span class="label">SSH</span></div> | |
| 18 | + <div class="key-main"> | |
| 19 | + <strong>{{ k.title }}</strong> | |
| 20 | + <code class="key-fingerprint">{{ k.fingerprint }}</code> | |
| 21 | + <div class="text-muted text-small"> | |
| 22 | + {{ k.key_type }} · Added {{ crate::web::fmt::date(k.created_at) }} · | |
| 23 | + {% if let Some(used) = k.last_used_at %}Last used <time title="{{ crate::web::fmt::datetime_full(used) }}">{{ crate::web::fmt::ago(used) }}</time>{% else %}Never used{% endif %} | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + <form method="post" action="/settings/keys/{{ k.id }}/delete" data-confirm="Delete the SSH key “{{ k.title }}”? Anything using it will no longer be able to access your repositories."> | |
| 27 | + <button class="btn btn-sm btn-danger" type="submit">Delete</button> | |
| 28 | + </form> | |
| 29 | + </div> | |
| 30 | + {% endfor %} | |
| 31 | + </div> | |
| 32 | + {% endif %} | |
| 33 | +</section> | |
| 34 | +<section class="settings-section"> | |
| 35 | + <h2 class="settings-title">Add a new SSH key</h2> | |
| 36 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 37 | + <form method="post" action="/settings/keys" class="form"> | |
| 38 | + <label class="field"> | |
| 39 | + <span class="field-label">Title</span> | |
| 40 | + <input class="input" type="text" name="title" value="{{ title }}" maxlength="100" placeholder="e.g. Work laptop"> | |
| 41 | + <span class="field-hint">Defaults to the key's comment.</span> | |
| 42 | + </label> | |
| 43 | + <label class="field"> | |
| 44 | + <span class="field-label">Key</span> | |
| 45 | + <textarea class="input input-mono" name="key" rows="5" required placeholder="Begins with 'ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', ...">{{ key }}</textarea> | |
| 46 | + </label> | |
| 47 | + <div class="form-actions"><button class="btn btn-primary" type="submit">Add SSH key</button></div> | |
| 48 | + </form> | |
| 49 | +</section> | |
| 50 | +<section class="settings-section"> | |
| 51 | + <h2 class="settings-title">Getting started</h2> | |
| 52 | + <div class="box box-body help-box"> | |
| 53 | + <p>Generate a key if you don't have one, then paste the contents of the <code>.pub</code> file above:</p> | |
| 54 | + <pre class="command">ssh-keygen -t ed25519 -C "[email protected]" | |
| 55 | +cat ~/.ssh/id_ed25519.pub</pre> | |
| 56 | + <p>Once added, check that the server accepts it:</p> | |
| 57 | + <div class="input-group"> | |
| 58 | + <input class="input input-mono" type="text" readonly value="{{ test_command }}" id="ssh-test" aria-label="SSH test command"> | |
| 59 | + <button type="button" class="btn" data-copy="#ssh-test" title="Copy">{{ crate::web::fmt::icon("copy")|safe }}</button> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | +</section> | |
| 63 | +{% endblock %} |
| @@ -0,0 +1,44 @@ | ||
| 1 | +{% extends "layout_settings.html" %} | |
| 2 | +{% block title %}Profile settings · Openhub{% endblock %} | |
| 3 | +{% block settings %} | |
| 4 | +<section class="settings-section"> | |
| 5 | + <h2 class="settings-title">Public profile</h2> | |
| 6 | + {% if let Some(e) = error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 7 | + <form method="post" action="/settings/profile" class="form"> | |
| 8 | + <label class="field"> | |
| 9 | + <span class="field-label">Display name</span> | |
| 10 | + <input class="input" type="text" name="display_name" value="{{ display_name }}" maxlength="100"> | |
| 11 | + <span class="field-hint">Shown on your profile instead of your username.</span> | |
| 12 | + </label> | |
| 13 | + <label class="field"> | |
| 14 | + <span class="field-label">Email</span> | |
| 15 | + <input class="input" type="email" name="email" value="{{ email }}" required> | |
| 16 | + <span class="field-hint">Used as the author email for merges you perform on the web.</span> | |
| 17 | + </label> | |
| 18 | + <label class="field"> | |
| 19 | + <span class="field-label">Bio</span> | |
| 20 | + <textarea class="input" name="bio" rows="3" maxlength="500">{{ bio }}</textarea> | |
| 21 | + </label> | |
| 22 | + <div class="form-actions"><button class="btn btn-primary" type="submit">Update profile</button></div> | |
| 23 | + </form> | |
| 24 | +</section> | |
| 25 | +<section class="settings-section"> | |
| 26 | + <h2 class="settings-title">Change password</h2> | |
| 27 | + {% if let Some(e) = password_error %}<div class="flash flash-error">{{ crate::web::fmt::icon("circle-alert")|safe }}<span>{{ e }}</span></div>{% endif %} | |
| 28 | + <form method="post" action="/settings/password" class="form"> | |
| 29 | + <label class="field"> | |
| 30 | + <span class="field-label">Current password</span> | |
| 31 | + <input class="input" type="password" name="current" autocomplete="current-password" required> | |
| 32 | + </label> | |
| 33 | + <label class="field"> | |
| 34 | + <span class="field-label">New password</span> | |
| 35 | + <input class="input" type="password" name="new" autocomplete="new-password" minlength="8" required> | |
| 36 | + </label> | |
| 37 | + <label class="field"> | |
| 38 | + <span class="field-label">Confirm new password</span> | |
| 39 | + <input class="input" type="password" name="confirm" autocomplete="new-password" minlength="8" required> | |
| 40 | + </label> | |
| 41 | + <div class="form-actions"><button class="btn" type="submit">Change password</button></div> | |
| 42 | + </form> | |
| 43 | +</section> | |
| 44 | +{% endblock %} |