Milestone 0.6: releases, Git mirror, code search, anchored API comments 3f4b0ea6eabf
Releases on tags with notes drafted from the changes since the previous release, assets stored by BLAKE3, and cached .tar.zst and .zip source archives of any state. A read-only Git mirror of the default bookmark, exported by oxy and served by git http-backend behind the hub's access checks. Code search by substring or regex with a budget and cache. API comments on proposals can anchor to any line of the projected state.
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-27 21:48 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
3f4b0ea6eabf3074989245c59133f905ada2b63e707ae18eac5082b97c700339- Depends on
- 145306044896 18e381ea88c2 19bb31ff1f5c 9ff27da517fe b64e2537d702 d23464c0ac60 de505df5cf8d
- Depended on by
- 1c52d940b4a0 041a945f55f8 93f445fa50d8 851a26a2a6d1 351cf57e5608 ba0a55bb5b3c dd327d76062d 166f6e9bf4f1 c7067c7bda56 0ee5c3fb32eb 16ee5d19d220 8a48dd830178 a7aa76ac52d7 d2c1d6a7d12e 97fc47fee9cf 076c7a7c86b1 c8684d44d549 ea14c4ec5c3f 5d90e0e045e2 7b17d570d7ee 9a777fec4ed9 ffb67a81188c d3b048b27277 eb2a3502601d c9aacc0e7dc1 acc85ac0f70b f677d6270298 39442b000e6b 17a3a84b00d1 e71846aa07b9 8a3334b7a364 d7ce41acf22c ea85398a51bc 739a763a558c ced6d3afc5cf 7974adb0be66 6978fd76a066 5533e91a2aa4 bf1ab3458db1 df940d75cd3d 10708ab8f54d a3c8288b0141 cc19697f8871 65be9f9c3074 c5f3bde466d1 9008bf6c2b8c 83d5052bf76c c79a1111dd56 78001759604c 756fe44a0f4d 34beb1a7e376 736f6813543d 42fa95540468 8d2caf122517 64caed18236a 1553bfc9ae53 07b1a5bbf481 4edcf24025cf b45384c909c1
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 62 atoms, 10 tree operations, 31 files
Browse the tree at this change
added crates/hub/migrations/0007_releases.sql +31 −0
| 1 | + | -- Releases attach notes and assets to tags (hub spec §9). Tags themselves |
|
| 2 | + | -- live in the repository; assets are content addressed under <data>/assets. |
|
| 3 | + | ||
| 4 | + | CREATE TABLE releases ( |
|
| 5 | + | id INTEGER PRIMARY KEY, |
|
| 6 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 7 | + | tag TEXT NOT NULL, |
|
| 8 | + | title TEXT NOT NULL, |
|
| 9 | + | notes TEXT NOT NULL DEFAULT '', |
|
| 10 | + | prerelease INTEGER NOT NULL DEFAULT 0, |
|
| 11 | + | author_id INTEGER REFERENCES accounts(id) ON DELETE SET NULL, |
|
| 12 | + | author TEXT NOT NULL DEFAULT '', |
|
| 13 | + | created_ms INTEGER NOT NULL, |
|
| 14 | + | UNIQUE (repo_id, tag) |
|
| 15 | + | ); |
|
| 16 | + | ||
| 17 | + | CREATE TABLE release_assets ( |
|
| 18 | + | id INTEGER PRIMARY KEY, |
|
| 19 | + | release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE, |
|
| 20 | + | name TEXT NOT NULL, |
|
| 21 | + | size INTEGER NOT NULL, |
|
| 22 | + | hash TEXT NOT NULL, |
|
| 23 | + | content_type TEXT NOT NULL DEFAULT 'application/octet-stream', |
|
| 24 | + | downloads INTEGER NOT NULL DEFAULT 0, |
|
| 25 | + | created_ms INTEGER NOT NULL, |
|
| 26 | + | UNIQUE (release_id, name) |
|
| 27 | + | ); |
|
| 28 | + | ||
| 29 | + | -- Git mirror settings per repository (hub spec §14). |
|
| 30 | + | ALTER TABLE repositories ADD COLUMN git_mirror INTEGER NOT NULL DEFAULT 0; |
|
| 31 | + | ALTER TABLE repositories ADD COLUMN git_mirror_state TEXT NOT NULL DEFAULT ''; |
added crates/hub/src/db/releases.rs +122 −0
| 1 | + | //! Releases and their assets (hub spec §9). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct Release { |
|
| 8 | + | pub id: i64, |
|
| 9 | + | pub repo_id: i64, |
|
| 10 | + | pub tag: String, |
|
| 11 | + | pub title: String, |
|
| 12 | + | pub notes: String, |
|
| 13 | + | pub prerelease: bool, |
|
| 14 | + | pub author_id: Option<i64>, |
|
| 15 | + | pub author: String, |
|
| 16 | + | pub created_ms: i64, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[derive(Clone, Debug, FromRow)] |
|
| 20 | + | pub struct Asset { |
|
| 21 | + | pub id: i64, |
|
| 22 | + | pub release_id: i64, |
|
| 23 | + | pub name: String, |
|
| 24 | + | pub size: i64, |
|
| 25 | + | pub hash: String, |
|
| 26 | + | pub content_type: String, |
|
| 27 | + | pub downloads: i64, |
|
| 28 | + | pub created_ms: i64, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub async fn list(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<Release>> { |
|
| 32 | + | sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? ORDER BY created_ms DESC, id DESC").bind(repo_id).fetch_all(pool).await |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | pub async fn get(pool: &Pool, repo_id: i64, tag: &str) -> sqlx::Result<Option<Release>> { |
|
| 36 | + | sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? AND tag = ?").bind(repo_id).bind(tag).fetch_optional(pool).await |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// The newest release that is not a pre-release: the "latest" marker. |
|
| 40 | + | pub async fn latest(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<Release>> { |
|
| 41 | + | sqlx::query_as("SELECT * FROM releases WHERE repo_id = ? AND prerelease = 0 ORDER BY created_ms DESC, id DESC LIMIT 1") |
|
| 42 | + | .bind(repo_id) |
|
| 43 | + | .fetch_optional(pool) |
|
| 44 | + | .await |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | pub async fn create( |
|
| 48 | + | pool: &Pool, |
|
| 49 | + | repo_id: i64, |
|
| 50 | + | tag: &str, |
|
| 51 | + | title: &str, |
|
| 52 | + | notes: &str, |
|
| 53 | + | prerelease: bool, |
|
| 54 | + | author: Option<(i64, &str)>, |
|
| 55 | + | ) -> sqlx::Result<Release> { |
|
| 56 | + | sqlx::query_as("INSERT INTO releases (repo_id, tag, title, notes, prerelease, author_id, author, created_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *") |
|
| 57 | + | .bind(repo_id) |
|
| 58 | + | .bind(tag) |
|
| 59 | + | .bind(title) |
|
| 60 | + | .bind(notes) |
|
| 61 | + | .bind(prerelease) |
|
| 62 | + | .bind(author.map(|a| a.0)) |
|
| 63 | + | .bind(author.map(|a| a.1).unwrap_or("")) |
|
| 64 | + | .bind(now_ms()) |
|
| 65 | + | .fetch_one(pool) |
|
| 66 | + | .await |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | pub async fn edit(pool: &Pool, id: i64, title: &str, notes: &str, prerelease: bool) -> sqlx::Result<()> { |
|
| 70 | + | sqlx::query("UPDATE releases SET title = ?, notes = ?, prerelease = ? WHERE id = ?") |
|
| 71 | + | .bind(title) |
|
| 72 | + | .bind(notes) |
|
| 73 | + | .bind(prerelease) |
|
| 74 | + | .bind(id) |
|
| 75 | + | .execute(pool) |
|
| 76 | + | .await?; |
|
| 77 | + | Ok(()) |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 81 | + | sqlx::query("DELETE FROM releases WHERE id = ?").bind(id).execute(pool).await?; |
|
| 82 | + | Ok(()) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | pub async fn assets(pool: &Pool, release_id: i64) -> sqlx::Result<Vec<Asset>> { |
|
| 86 | + | sqlx::query_as("SELECT * FROM release_assets WHERE release_id = ? ORDER BY name").bind(release_id).fetch_all(pool).await |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | pub async fn asset(pool: &Pool, release_id: i64, name: &str) -> sqlx::Result<Option<Asset>> { |
|
| 90 | + | sqlx::query_as("SELECT * FROM release_assets WHERE release_id = ? AND name = ?").bind(release_id).bind(name).fetch_optional(pool).await |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | pub async fn add_asset(pool: &Pool, release_id: i64, name: &str, size: i64, hash: &str, content_type: &str) -> sqlx::Result<Asset> { |
|
| 94 | + | sqlx::query_as( |
|
| 95 | + | "INSERT INTO release_assets (release_id, name, size, hash, content_type, created_ms) VALUES (?, ?, ?, ?, ?, ?) \ |
|
| 96 | + | ON CONFLICT (release_id, name) DO UPDATE SET size = excluded.size, hash = excluded.hash, content_type = excluded.content_type, created_ms = excluded.created_ms RETURNING *", |
|
| 97 | + | ) |
|
| 98 | + | .bind(release_id) |
|
| 99 | + | .bind(name) |
|
| 100 | + | .bind(size) |
|
| 101 | + | .bind(hash) |
|
| 102 | + | .bind(content_type) |
|
| 103 | + | .bind(now_ms()) |
|
| 104 | + | .fetch_one(pool) |
|
| 105 | + | .await |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | pub async fn delete_asset(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 109 | + | sqlx::query("DELETE FROM release_assets WHERE id = ?").bind(id).execute(pool).await?; |
|
| 110 | + | Ok(()) |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | pub async fn count_download(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| 114 | + | sqlx::query("UPDATE release_assets SET downloads = downloads + 1 WHERE id = ?").bind(id).execute(pool).await?; |
|
| 115 | + | Ok(()) |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | /// Whether any asset of any release still names this content. |
|
| 119 | + | pub async fn hash_in_use(pool: &Pool, hash: &str) -> sqlx::Result<bool> { |
|
| 120 | + | let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM release_assets WHERE hash = ?").bind(hash).fetch_one(pool).await?; |
|
| 121 | + | Ok(n > 0) |
|
| 122 | + | } |
added crates/hub/src/mirror.rs +195 −0
| 1 | + | //! The read-only Git mirror (hub spec §14): the default bookmark exported |
|
| 2 | + | //! with oxy's own `git export` into `<data>/mirrors/<owner>/<name>.git` |
|
| 3 | + | //! after every update, and served over Git's smart HTTP by `git |
|
| 4 | + | //! http-backend` behind the hub's access checks. Pushing to it is refused. |
|
| 5 | + | ||
| 6 | + | use crate::auth::{self, Access}; |
|
| 7 | + | use crate::db::{self, repos::RepoRow}; |
|
| 8 | + | use crate::repos::RepoName; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use axum::body::Bytes; |
|
| 11 | + | use axum::extract::{Path, RawQuery, State}; |
|
| 12 | + | use axum::http::{header, HeaderMap, Method, StatusCode}; |
|
| 13 | + | use axum::response::{IntoResponse, Response}; |
|
| 14 | + | use std::path::PathBuf; |
|
| 15 | + | use tokio::io::AsyncWriteExt; |
|
| 16 | + | ||
| 17 | + | pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf { |
|
| 18 | + | app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name)) |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | /// Export the default bookmark into the mirror, if the mirror is on. |
|
| 22 | + | pub async fn refresh(app: &Shared, row: &RepoRow) { |
|
| 23 | + | if !row.git_mirror { |
|
| 24 | + | return; |
|
| 25 | + | } |
|
| 26 | + | let Some(repo) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 27 | + | let dir = mirror_dir(app, &repo); |
|
| 28 | + | let bookmark = row.default_bookmark.clone(); |
|
| 29 | + | let result = app |
|
| 30 | + | .repos |
|
| 31 | + | .write(&repo, move |r, _| { |
|
| 32 | + | let mut r = oxy_core::repo::Repo::open(r.root())?; |
|
| 33 | + | r.config.bookmark = bookmark.clone(); |
|
| 34 | + | let report = r.git_export_as(&dir, &bookmark)?; |
|
| 35 | + | // Point HEAD at the exported branch so clones check it out. |
|
| 36 | + | std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?; |
|
| 37 | + | let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default(); |
|
| 38 | + | Ok((report.exported, state)) |
|
| 39 | + | }) |
|
| 40 | + | .await; |
|
| 41 | + | match result { |
|
| 42 | + | Ok((_, state)) => { |
|
| 43 | + | db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok(); |
|
| 44 | + | } |
|
| 45 | + | Err(e) => tracing::warn!("git mirror of {}: {e}", row.slug()), |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | /// Who a Git client is: HTTP Basic with a personal access token as the |
|
| 50 | + | /// password (any user name), or anonymous. |
|
| 51 | + | async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> { |
|
| 52 | + | let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) }; |
|
| 53 | + | let token = if let Some(b) = v.strip_prefix("Basic ") { |
|
| 54 | + | let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?; |
|
| 55 | + | let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?; |
|
| 56 | + | s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)? |
|
| 57 | + | } else if let Some(t) = v.strip_prefix("Bearer ") { |
|
| 58 | + | t.trim().to_string() |
|
| 59 | + | } else { |
|
| 60 | + | return Err(StatusCode::UNAUTHORIZED); |
|
| 61 | + | }; |
|
| 62 | + | let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; |
|
| 63 | + | match tok { |
|
| 64 | + | Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => { |
|
| 65 | + | db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) |
|
| 66 | + | } |
|
| 67 | + | _ => Err(StatusCode::UNAUTHORIZED), |
|
| 68 | + | } |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | fn base64_decode(s: &str) -> Option<Vec<u8>> { |
|
| 72 | + | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; |
|
| 73 | + | let mut out = Vec::new(); |
|
| 74 | + | let mut buf = 0u32; |
|
| 75 | + | let mut bits = 0; |
|
| 76 | + | for c in s.bytes().filter(|c| *c != b'=') { |
|
| 77 | + | let v = T.iter().position(|x| *x == c)? as u32; |
|
| 78 | + | buf = (buf << 6) | v; |
|
| 79 | + | bits += 6; |
|
| 80 | + | if bits >= 8 { |
|
| 81 | + | bits -= 8; |
|
| 82 | + | out.push((buf >> bits) as u8); |
|
| 83 | + | buf &= (1 << bits) - 1; |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | Some(out) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | fn challenge() -> Response { |
|
| 90 | + | (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"Oxygenhub\"")], "authentication required").into_response() |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// Every smart-HTTP path under `/{owner}/{name}.git/`. |
|
| 94 | + | pub async fn serve( |
|
| 95 | + | State(app): State<Shared>, |
|
| 96 | + | Path((owner, name_git, rest)): Path<(String, String, String)>, |
|
| 97 | + | method: Method, |
|
| 98 | + | RawQuery(query): RawQuery, |
|
| 99 | + | headers: HeaderMap, |
|
| 100 | + | body: Bytes, |
|
| 101 | + | ) -> Response { |
|
| 102 | + | let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() }; |
|
| 103 | + | let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() }; |
|
| 104 | + | let query = query.unwrap_or_default(); |
|
| 105 | + | if rest.contains("git-receive-pack") || query.contains("git-receive-pack") { |
|
| 106 | + | return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response(); |
|
| 107 | + | } |
|
| 108 | + | let row = match db::repos::get(&app.db, &owner, name).await { |
|
| 109 | + | Ok(Some(r)) if r.git_mirror => r, |
|
| 110 | + | _ => return StatusCode::NOT_FOUND.into_response(), |
|
| 111 | + | }; |
|
| 112 | + | let account = match git_caller(&app, &headers).await { |
|
| 113 | + | Ok(a) => a, |
|
| 114 | + | Err(_) => return challenge(), |
|
| 115 | + | }; |
|
| 116 | + | let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten(); |
|
| 117 | + | if !auth::roles::allows(role, Access::Read) { |
|
| 118 | + | return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() }; |
|
| 119 | + | } |
|
| 120 | + | let dir = mirror_dir(&app, &repo); |
|
| 121 | + | if !dir.join("HEAD").exists() { |
|
| 122 | + | refresh(&app, &row).await; |
|
| 123 | + | } |
|
| 124 | + | let root = app.config.data.join("mirrors"); |
|
| 125 | + | let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into()); |
|
| 126 | + | let mut cmd = tokio::process::Command::new(git); |
|
| 127 | + | cmd.arg("http-backend") |
|
| 128 | + | .env_clear() |
|
| 129 | + | .env("PATH", std::env::var("PATH").unwrap_or_default()) |
|
| 130 | + | .env("GIT_PROJECT_ROOT", &root) |
|
| 131 | + | .env("GIT_HTTP_EXPORT_ALL", "1") |
|
| 132 | + | .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}")) |
|
| 133 | + | .env("REQUEST_METHOD", method.as_str()) |
|
| 134 | + | .env("QUERY_STRING", &query) |
|
| 135 | + | .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("")) |
|
| 136 | + | .env("CONTENT_LENGTH", body.len().to_string()) |
|
| 137 | + | .env("GIT_CONFIG_NOSYSTEM", "1") |
|
| 138 | + | .env("HOME", &root) |
|
| 139 | + | .stdin(std::process::Stdio::piped()) |
|
| 140 | + | .stdout(std::process::Stdio::piped()) |
|
| 141 | + | .stderr(std::process::Stdio::piped()); |
|
| 142 | + | if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) { |
|
| 143 | + | cmd.env("HTTP_CONTENT_ENCODING", enc); |
|
| 144 | + | } |
|
| 145 | + | if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) { |
|
| 146 | + | cmd.env("GIT_PROTOCOL", p); |
|
| 147 | + | } |
|
| 148 | + | let mut child = match cmd.spawn() { |
|
| 149 | + | Ok(c) => c, |
|
| 150 | + | Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(), |
|
| 151 | + | }; |
|
| 152 | + | if let Some(mut stdin) = child.stdin.take() { |
|
| 153 | + | let _ = stdin.write_all(&body).await; |
|
| 154 | + | } |
|
| 155 | + | let out = match child.wait_with_output().await { |
|
| 156 | + | Ok(o) => o, |
|
| 157 | + | Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), |
|
| 158 | + | }; |
|
| 159 | + | cgi_response(&out.stdout) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | /// Turn CGI output (headers, blank line, body) into a response. |
|
| 163 | + | fn cgi_response(raw: &[u8]) -> Response { |
|
| 164 | + | let split = |
|
| 165 | + | raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| (i, 4)).or_else(|| raw.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2))); |
|
| 166 | + | let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() }; |
|
| 167 | + | let head = String::from_utf8_lossy(&raw[..at]); |
|
| 168 | + | let body = raw[at + sep..].to_vec(); |
|
| 169 | + | let mut status = StatusCode::OK; |
|
| 170 | + | let mut builder = Response::builder(); |
|
| 171 | + | for line in head.lines() { |
|
| 172 | + | let Some((k, v)) = line.split_once(':') else { continue }; |
|
| 173 | + | let (k, v) = (k.trim(), v.trim()); |
|
| 174 | + | if k.eq_ignore_ascii_case("status") { |
|
| 175 | + | if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) { |
|
| 176 | + | status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK); |
|
| 177 | + | } |
|
| 178 | + | } else { |
|
| 179 | + | builder = builder.header(k, v); |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + | builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()) |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | #[cfg(test)] |
|
| 186 | + | mod tests { |
|
| 187 | + | use super::*; |
|
| 188 | + | ||
| 189 | + | #[test] |
|
| 190 | + | fn base64_and_cgi() { |
|
| 191 | + | assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret"); |
|
| 192 | + | let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope"); |
|
| 193 | + | assert_eq!(r.status(), StatusCode::NOT_FOUND); |
|
| 194 | + | } |
|
| 195 | + | } |
added crates/hub/src/repos/archive.rs +115 −0
| 1 | + | //! Source archives of a state (hub spec §9): `.tar.zst` and `.zip` of the |
|
| 2 | + | //! materialised tree, cached by state hash since a state never changes. |
|
| 3 | + | ||
| 4 | + | use super::cache::Loaded; |
|
| 5 | + | use oxy_core::objects::NodeKind; |
|
| 6 | + | use oxy_core::repo::Repo; |
|
| 7 | + | use oxy_core::state::FileContent; |
|
| 8 | + | use std::io::Write; |
|
| 9 | + | use std::path::{Path, PathBuf}; |
|
| 10 | + | ||
| 11 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 12 | + | pub enum Format { |
|
| 13 | + | TarZst, |
|
| 14 | + | Zip, |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | impl Format { |
|
| 18 | + | pub fn parse(ext: &str) -> Option<Format> { |
|
| 19 | + | match ext { |
|
| 20 | + | "tar.zst" => Some(Format::TarZst), |
|
| 21 | + | "zip" => Some(Format::Zip), |
|
| 22 | + | _ => None, |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | pub fn ext(self) -> &'static str { |
|
| 27 | + | match self { |
|
| 28 | + | Format::TarZst => "tar.zst", |
|
| 29 | + | Format::Zip => "zip", |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | pub fn mime(self) -> &'static str { |
|
| 34 | + | match self { |
|
| 35 | + | Format::TarZst => "application/zstd", |
|
| 36 | + | Format::Zip => "application/zip", |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | /// One archive member: path, bytes, executable, symlink. |
|
| 42 | + | type Member = (String, Vec<u8>, bool, bool); |
|
| 43 | + | ||
| 44 | + | /// Every regular file and symlink of the state. |
|
| 45 | + | fn entries(repo: &Repo, loaded: &Loaded) -> anyhow::Result<Vec<Member>> { |
|
| 46 | + | let mut out = Vec::new(); |
|
| 47 | + | for (path, f) in &loaded.snapshot.files { |
|
| 48 | + | let bytes = match (&f.kind, &f.content) { |
|
| 49 | + | (NodeKind::Dir, _) | (_, FileContent::Dir) => continue, |
|
| 50 | + | (_, FileContent::Text { bytes, .. }) => bytes.clone(), |
|
| 51 | + | (_, FileContent::Opaque { blobs }) => match blobs.first() { |
|
| 52 | + | Some(b) => repo.read_blob(b)?, |
|
| 53 | + | None => vec![], |
|
| 54 | + | }, |
|
| 55 | + | }; |
|
| 56 | + | out.push((path.clone(), bytes, f.mode.exec, f.kind == NodeKind::Symlink)); |
|
| 57 | + | } |
|
| 58 | + | Ok(out) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// Write the archive of `loaded` under `prefix/` to `dest` (atomically). |
|
| 62 | + | pub fn build(repo: &Repo, loaded: &Loaded, prefix: &str, format: Format, dest: &Path) -> anyhow::Result<()> { |
|
| 63 | + | if let Some(dir) = dest.parent() { |
|
| 64 | + | std::fs::create_dir_all(dir)?; |
|
| 65 | + | } |
|
| 66 | + | let tmp = dest.with_extension("partial"); |
|
| 67 | + | let files = entries(repo, loaded)?; |
|
| 68 | + | match format { |
|
| 69 | + | Format::TarZst => { |
|
| 70 | + | let out = std::fs::File::create(&tmp)?; |
|
| 71 | + | let enc = zstd::Encoder::new(out, 19)?; |
|
| 72 | + | let mut tar = tar::Builder::new(enc); |
|
| 73 | + | for (path, bytes, exec, symlink) in &files { |
|
| 74 | + | let name = format!("{prefix}/{path}"); |
|
| 75 | + | let mut h = tar::Header::new_gnu(); |
|
| 76 | + | h.set_mtime(0); |
|
| 77 | + | if *symlink { |
|
| 78 | + | h.set_entry_type(tar::EntryType::Symlink); |
|
| 79 | + | h.set_size(0); |
|
| 80 | + | h.set_mode(0o777); |
|
| 81 | + | tar.append_link(&mut h, &name, String::from_utf8_lossy(bytes).as_ref())?; |
|
| 82 | + | } else { |
|
| 83 | + | h.set_size(bytes.len() as u64); |
|
| 84 | + | h.set_mode(if *exec { 0o755 } else { 0o644 }); |
|
| 85 | + | tar.append_data(&mut h, &name, bytes.as_slice())?; |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | tar.into_inner()?.finish()?.flush()?; |
|
| 89 | + | } |
|
| 90 | + | Format::Zip => { |
|
| 91 | + | let out = std::fs::File::create(&tmp)?; |
|
| 92 | + | let mut zip = zip::ZipWriter::new(out); |
|
| 93 | + | for (path, bytes, exec, symlink) in &files { |
|
| 94 | + | let name = format!("{prefix}/{path}"); |
|
| 95 | + | let opts = zip::write::SimpleFileOptions::default() |
|
| 96 | + | .compression_method(zip::CompressionMethod::Deflated) |
|
| 97 | + | .unix_permissions(if *exec { 0o755 } else { 0o644 }); |
|
| 98 | + | if *symlink { |
|
| 99 | + | zip.add_symlink(name, String::from_utf8_lossy(bytes).as_ref(), opts)?; |
|
| 100 | + | } else { |
|
| 101 | + | zip.start_file(name, opts)?; |
|
| 102 | + | zip.write_all(bytes)?; |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | zip.finish()?.flush()?; |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | std::fs::rename(&tmp, dest)?; |
|
| 109 | + | Ok(()) |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /// Where the cached archive of a state lives. |
|
| 113 | + | pub fn cache_path(data: &Path, state: &str, prefix: &str, format: Format) -> PathBuf { |
|
| 114 | + | data.join("cache").join("archives").join(format!("{state}-{prefix}.{}", format.ext())) |
|
| 115 | + | } |
added crates/hub/src/repos/search.rs +100 −0
| 1 | + | //! Code search (hub spec §11): substring or regular expression over the |
|
| 2 | + | //! text files of one state, with a time budget and a result cap. Results |
|
| 3 | + | //! are cached by state hash and query, since a state never changes. |
|
| 4 | + | ||
| 5 | + | use super::cache::Loaded; |
|
| 6 | + | use oxy_core::state::FileContent; |
|
| 7 | + | use std::collections::HashMap; |
|
| 8 | + | use std::sync::Mutex; |
|
| 9 | + | use std::time::{Duration, Instant}; |
|
| 10 | + | ||
| 11 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 12 | + | pub struct Hit { |
|
| 13 | + | pub path: String, |
|
| 14 | + | /// 1-based; 0 for a match on the path itself. |
|
| 15 | + | pub line: usize, |
|
| 16 | + | pub text: String, |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | #[derive(Clone, Debug, Default)] |
|
| 20 | + | pub struct Results { |
|
| 21 | + | pub hits: Vec<Hit>, |
|
| 22 | + | pub files: usize, |
|
| 23 | + | /// The budget or the cap stopped the search early. |
|
| 24 | + | pub truncated: bool, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | pub struct Query<'a> { |
|
| 28 | + | pub text: &'a str, |
|
| 29 | + | pub regex: bool, |
|
| 30 | + | pub case_sensitive: bool, |
|
| 31 | + | /// Only paths starting with this. |
|
| 32 | + | pub path_prefix: &'a str, |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | pub const MAX_HITS: usize = 300; |
|
| 36 | + | pub const BUDGET: Duration = Duration::from_secs(2); |
|
| 37 | + | ||
| 38 | + | pub fn run(loaded: &Loaded, q: &Query<'_>) -> Result<Results, String> { |
|
| 39 | + | let pattern = if q.regex { q.text.to_string() } else { regex::escape(q.text) }; |
|
| 40 | + | let re = regex::RegexBuilder::new(&pattern) |
|
| 41 | + | .case_insensitive(!q.case_sensitive) |
|
| 42 | + | .size_limit(1 << 20) |
|
| 43 | + | .build() |
|
| 44 | + | .map_err(|e| format!("not a valid regular expression: {e}"))?; |
|
| 45 | + | let started = Instant::now(); |
|
| 46 | + | let mut out = Results::default(); |
|
| 47 | + | for (path, f) in &loaded.snapshot.files { |
|
| 48 | + | if !path.starts_with(q.path_prefix) { |
|
| 49 | + | continue; |
|
| 50 | + | } |
|
| 51 | + | if started.elapsed() > BUDGET || out.hits.len() >= MAX_HITS { |
|
| 52 | + | out.truncated = true; |
|
| 53 | + | break; |
|
| 54 | + | } |
|
| 55 | + | let mut found = false; |
|
| 56 | + | if re.is_match(path) { |
|
| 57 | + | out.hits.push(Hit { path: path.clone(), line: 0, text: String::new() }); |
|
| 58 | + | found = true; |
|
| 59 | + | } |
|
| 60 | + | let FileContent::Text { bytes, .. } = &f.content else { continue }; |
|
| 61 | + | if bytes.len() > 1 << 20 || bytes.contains(&0) { |
|
| 62 | + | continue; |
|
| 63 | + | } |
|
| 64 | + | let text = String::from_utf8_lossy(bytes); |
|
| 65 | + | for (i, line) in text.lines().enumerate() { |
|
| 66 | + | if re.is_match(line) { |
|
| 67 | + | out.hits.push(Hit { path: path.clone(), line: i + 1, text: line.chars().take(300).collect() }); |
|
| 68 | + | found = true; |
|
| 69 | + | if out.hits.len() >= MAX_HITS { |
|
| 70 | + | out.truncated = true; |
|
| 71 | + | break; |
|
| 72 | + | } |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | if found { |
|
| 76 | + | out.files += 1; |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | Ok(out) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// A small cache of results by (state, query). |
|
| 83 | + | #[derive(Default)] |
|
| 84 | + | pub struct SearchCache { |
|
| 85 | + | inner: Mutex<HashMap<String, Results>>, |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | impl SearchCache { |
|
| 89 | + | pub fn get(&self, key: &str) -> Option<Results> { |
|
| 90 | + | self.inner.lock().unwrap().get(key).cloned() |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | pub fn put(&self, key: String, r: Results) { |
|
| 94 | + | let mut m = self.inner.lock().unwrap(); |
|
| 95 | + | if m.len() > 256 { |
|
| 96 | + | m.clear(); |
|
| 97 | + | } |
|
| 98 | + | m.insert(key, r); |
|
| 99 | + | } |
|
| 100 | + | } |
added crates/hub/src/web/releases.rs +491 −0
| 1 | + | //! Releases (hub spec §9): notes and assets on a tag, generated notes from |
|
| 2 | + | //! the changes since the previous release, source archives of any state. |
|
| 3 | + | ||
| 4 | + | use super::settings::{csrf, require}; |
|
| 5 | + | use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; |
|
| 6 | + | use crate::auth::{Access, Viewer}; |
|
| 7 | + | use crate::db::{self, accounts, releases as rdb}; |
|
| 8 | + | use crate::repos::archive::{self, Format}; |
|
| 9 | + | use crate::repos::{view, RepoName}; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use askama::Template; |
|
| 12 | + | use axum::extract::{Form, Multipart, Path, Query, State}; |
|
| 13 | + | use axum::http::header; |
|
| 14 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 15 | + | use oxy_core::hash::ChangeHash; |
|
| 16 | + | use serde::Deserialize; |
|
| 17 | + | use std::collections::HashMap; |
|
| 18 | + | ||
| 19 | + | /// A release as the pages and the API show it. |
|
| 20 | + | pub struct ReleaseView { |
|
| 21 | + | pub r: rdb::Release, |
|
| 22 | + | pub notes_html: String, |
|
| 23 | + | pub assets: Vec<rdb::Asset>, |
|
| 24 | + | pub heads: Vec<String>, |
|
| 25 | + | pub signer: Option<String>, |
|
| 26 | + | pub latest: bool, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> { |
|
| 30 | + | let list = rdb::list(&app.db, row.id).await?; |
|
| 31 | + | let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id); |
|
| 32 | + | let tags: HashMap<String, (Vec<String>, Option<String>)> = app |
|
| 33 | + | .repos |
|
| 34 | + | .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect())) |
|
| 35 | + | .await |
|
| 36 | + | .unwrap_or_default(); |
|
| 37 | + | let mut out = Vec::new(); |
|
| 38 | + | for r in list { |
|
| 39 | + | let assets = rdb::assets(&app.db, r.id).await?; |
|
| 40 | + | let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default(); |
|
| 41 | + | let notes_html = super::issues::markdown(app, row.id, &format!("/{}", row.slug()), &r.notes).await; |
|
| 42 | + | out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer }); |
|
| 43 | + | } |
|
| 44 | + | Ok(out) |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | #[derive(Template)] |
|
| 48 | + | #[template(path = "releases.html")] |
|
| 49 | + | pub struct ReleasesPage { |
|
| 50 | + | shell: Shell, |
|
| 51 | + | repo: RepoShell, |
|
| 52 | + | releases: Vec<ReleaseView>, |
|
| 53 | + | unreleased: Vec<String>, |
|
| 54 | + | one: bool, |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | pub async fn list( |
|
| 58 | + | State(app): State<Shared>, |
|
| 59 | + | viewer: Viewer, |
|
| 60 | + | Path((owner, name)): Path<(String, String)>, |
|
| 61 | + | ) -> Result<Page<ReleasesPage>, Error> { |
|
| 62 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); |
|
| 63 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 64 | + | let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 65 | + | let tags: Vec<String> = |
|
| 66 | + | app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default(); |
|
| 67 | + | let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect(); |
|
| 68 | + | Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false })) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | pub async fn show( |
|
| 72 | + | State(app): State<Shared>, |
|
| 73 | + | viewer: Viewer, |
|
| 74 | + | Path((owner, name, tag)): Path<(String, String, String)>, |
|
| 75 | + | ) -> Result<Page<ReleasesPage>, Error> { |
|
| 76 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer); |
|
| 77 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 78 | + | let releases: Vec<ReleaseView> = |
|
| 79 | + | views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect(); |
|
| 80 | + | if releases.is_empty() { |
|
| 81 | + | return Err(Error::not_found(shell)); |
|
| 82 | + | } |
|
| 83 | + | Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true })) |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | /// Notes drafted from the changes in `tag`'s state that the previous |
|
| 87 | + | /// release's state lacks: one line per change, newest first. |
|
| 88 | + | async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String { |
|
| 89 | + | let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag); |
|
| 90 | + | let tag = tag.to_string(); |
|
| 91 | + | app.repos |
|
| 92 | + | .read(repo, move |r, cache| { |
|
| 93 | + | let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) }; |
|
| 94 | + | let now = cache.load(r, &heads)?; |
|
| 95 | + | let before: Vec<ChangeHash> = match &previous { |
|
| 96 | + | Some(p) => r.tag_heads(p)?.unwrap_or_default(), |
|
| 97 | + | None => vec![], |
|
| 98 | + | }; |
|
| 99 | + | let then = cache.load(r, &before)?; |
|
| 100 | + | let mut added: Vec<_> = now |
|
| 101 | + | .state |
|
| 102 | + | .changes() |
|
| 103 | + | .filter(|(h, _)| !then.state.contains(h)) |
|
| 104 | + | .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author)) |
|
| 105 | + | .collect(); |
|
| 106 | + | added.sort_by_key(|a| std::cmp::Reverse(a.0)); |
|
| 107 | + | let mut out = match &previous { |
|
| 108 | + | Some(p) => format!("Changes since {p}:\n\n"), |
|
| 109 | + | None => "Changes:\n\n".to_string(), |
|
| 110 | + | }; |
|
| 111 | + | for (_, h, message, author) in added { |
|
| 112 | + | let who = view::author(r, &author, &message).name; |
|
| 113 | + | out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who)); |
|
| 114 | + | } |
|
| 115 | + | Ok(out) |
|
| 116 | + | }) |
|
| 117 | + | .await |
|
| 118 | + | .unwrap_or_default() |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | #[derive(Template)] |
|
| 122 | + | #[template(path = "release_new.html")] |
|
| 123 | + | pub struct NewReleasePage { |
|
| 124 | + | shell: Shell, |
|
| 125 | + | repo: RepoShell, |
|
| 126 | + | tags: Vec<String>, |
|
| 127 | + | tag: String, |
|
| 128 | + | notes: String, |
|
| 129 | + | error: Option<String>, |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | #[derive(Deserialize)] |
|
| 133 | + | pub struct NewQuery { |
|
| 134 | + | tag: Option<String>, |
|
| 135 | + | } |
|
| 136 | + | ||
| 137 | + | pub async fn new_form( |
|
| 138 | + | State(app): State<Shared>, |
|
| 139 | + | viewer: Viewer, |
|
| 140 | + | Path((owner, name)): Path<(String, String)>, |
|
| 141 | + | Query(q): Query<NewQuery>, |
|
| 142 | + | ) -> Result<Page<NewReleasePage>, Error> { |
|
| 143 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer); |
|
| 144 | + | require(&viewer, &shell)?; |
|
| 145 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 146 | + | let tags: Vec<String> = app |
|
| 147 | + | .repos |
|
| 148 | + | .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())) |
|
| 149 | + | .await |
|
| 150 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 151 | + | let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default(); |
|
| 152 | + | let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await }; |
|
| 153 | + | Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None })) |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | #[derive(Deserialize)] |
|
| 157 | + | pub struct NewForm { |
|
| 158 | + | #[serde(rename = "_csrf")] |
|
| 159 | + | csrf_token: String, |
|
| 160 | + | tag: String, |
|
| 161 | + | title: String, |
|
| 162 | + | #[serde(default)] |
|
| 163 | + | notes: String, |
|
| 164 | + | #[serde(default)] |
|
| 165 | + | prerelease: Option<String>, |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | /// Create a release for an existing tag; shared by the form and the API. |
|
| 169 | + | #[allow(clippy::too_many_arguments)] |
|
| 170 | + | pub async fn create_release( |
|
| 171 | + | app: &Shared, |
|
| 172 | + | repo: &RepoName, |
|
| 173 | + | row: &db::repos::RepoRow, |
|
| 174 | + | me: &accounts::Account, |
|
| 175 | + | tag: &str, |
|
| 176 | + | title: &str, |
|
| 177 | + | notes: Option<String>, |
|
| 178 | + | prerelease: bool, |
|
| 179 | + | ) -> Result<rdb::Release, String> { |
|
| 180 | + | let exists = app |
|
| 181 | + | .repos |
|
| 182 | + | .read(repo, { |
|
| 183 | + | let t = tag.to_string(); |
|
| 184 | + | move |r, _| Ok(r.tag_heads(&t)?.is_some()) |
|
| 185 | + | }) |
|
| 186 | + | .await |
|
| 187 | + | .map_err(|e| e.to_string())?; |
|
| 188 | + | if !exists { |
|
| 189 | + | return Err(format!("there is no tag {tag}; create it with oxy tag create and push it")); |
|
| 190 | + | } |
|
| 191 | + | if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() { |
|
| 192 | + | return Err(format!("{tag} already has a release")); |
|
| 193 | + | } |
|
| 194 | + | let notes = match notes { |
|
| 195 | + | Some(n) => n, |
|
| 196 | + | None => generated_notes(app, repo, row.id, tag).await, |
|
| 197 | + | }; |
|
| 198 | + | let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() }; |
|
| 199 | + | let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username))) |
|
| 200 | + | .await |
|
| 201 | + | .map_err(|e| e.to_string())?; |
|
| 202 | + | accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok(); |
|
| 203 | + | crate::hooks::emit( |
|
| 204 | + | app, |
|
| 205 | + | row, |
|
| 206 | + | "release.published", |
|
| 207 | + | &me.username, |
|
| 208 | + | serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }), |
|
| 209 | + | ) |
|
| 210 | + | .await; |
|
| 211 | + | crate::notify::deliver( |
|
| 212 | + | app, |
|
| 213 | + | crate::notify::Event { |
|
| 214 | + | kind: "release", |
|
| 215 | + | title: format!("{} released {title}", row.slug()), |
|
| 216 | + | url: format!("/{}/releases/tag/{tag}", row.slug()), |
|
| 217 | + | actor: me, |
|
| 218 | + | repo_id: row.id, |
|
| 219 | + | thread: None, |
|
| 220 | + | mention_text: "", |
|
| 221 | + | direct: vec![], |
|
| 222 | + | }, |
|
| 223 | + | ) |
|
| 224 | + | .await; |
|
| 225 | + | Ok(r) |
|
| 226 | + | } |
|
| 227 | + | ||
| 228 | + | pub async fn create( |
|
| 229 | + | State(app): State<Shared>, |
|
| 230 | + | viewer: Viewer, |
|
| 231 | + | Path((owner, name)): Path<(String, String)>, |
|
| 232 | + | Form(f): Form<NewForm>, |
|
| 233 | + | ) -> Result<Response, Error> { |
|
| 234 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer); |
|
| 235 | + | let me = require(&viewer, &shell)?; |
|
| 236 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 237 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 238 | + | match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await { |
|
| 239 | + | Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()), |
|
| 240 | + | Err(msg) => { |
|
| 241 | + | let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default(); |
|
| 242 | + | Ok(Page(NewReleasePage { |
|
| 243 | + | shell, |
|
| 244 | + | repo: RepoShell::new(&app, &row, "releases").with_role(role), |
|
| 245 | + | tags, |
|
| 246 | + | tag: f.tag, |
|
| 247 | + | notes: f.notes, |
|
| 248 | + | error: Some(msg), |
|
| 249 | + | }) |
|
| 250 | + | .into_response()) |
|
| 251 | + | } |
|
| 252 | + | } |
|
| 253 | + | } |
|
| 254 | + | ||
| 255 | + | #[derive(Deserialize)] |
|
| 256 | + | pub struct EditForm { |
|
| 257 | + | #[serde(rename = "_csrf")] |
|
| 258 | + | csrf_token: String, |
|
| 259 | + | #[serde(default)] |
|
| 260 | + | title: String, |
|
| 261 | + | #[serde(default)] |
|
| 262 | + | notes: String, |
|
| 263 | + | #[serde(default)] |
|
| 264 | + | prerelease: Option<String>, |
|
| 265 | + | #[serde(default)] |
|
| 266 | + | action: String, |
|
| 267 | + | #[serde(default)] |
|
| 268 | + | asset: Option<i64>, |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | pub async fn edit( |
|
| 272 | + | State(app): State<Shared>, |
|
| 273 | + | viewer: Viewer, |
|
| 274 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 275 | + | Form(f): Form<EditForm>, |
|
| 276 | + | ) -> Result<Response, Error> { |
|
| 277 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); |
|
| 278 | + | let me = require(&viewer, &shell)?; |
|
| 279 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 280 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 281 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 282 | + | let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else { |
|
| 283 | + | return Err(Error::not_found(shell)); |
|
| 284 | + | }; |
|
| 285 | + | match f.action.as_str() { |
|
| 286 | + | "delete" => { |
|
| 287 | + | let assets = rdb::assets(&app.db, r.id).await.map_err(e)?; |
|
| 288 | + | rdb::delete(&app.db, r.id).await.map_err(e)?; |
|
| 289 | + | for a in assets { |
|
| 290 | + | remove_blob_if_unused(&app, &a.hash).await; |
|
| 291 | + | } |
|
| 292 | + | accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok(); |
|
| 293 | + | return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response()); |
|
| 294 | + | } |
|
| 295 | + | "delete_asset" => { |
|
| 296 | + | if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) { |
|
| 297 | + | rdb::delete_asset(&app.db, a.id).await.map_err(e)?; |
|
| 298 | + | remove_blob_if_unused(&app, &a.hash).await; |
|
| 299 | + | } |
|
| 300 | + | } |
|
| 301 | + | _ => rdb::edit( |
|
| 302 | + | &app.db, |
|
| 303 | + | r.id, |
|
| 304 | + | if f.title.trim().is_empty() { &r.tag } else { f.title.trim() }, |
|
| 305 | + | f.notes.trim(), |
|
| 306 | + | f.prerelease.is_some(), |
|
| 307 | + | ) |
|
| 308 | + | .await |
|
| 309 | + | .map_err(e)?, |
|
| 310 | + | } |
|
| 311 | + | Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()) |
|
| 312 | + | } |
|
| 313 | + | ||
| 314 | + | fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf { |
|
| 315 | + | app.config.data.join("assets").join(&hash[..2]).join(hash) |
|
| 316 | + | } |
|
| 317 | + | ||
| 318 | + | async fn remove_blob_if_unused(app: &Shared, hash: &str) { |
|
| 319 | + | if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) { |
|
| 320 | + | tokio::fs::remove_file(blob_path(app, hash)).await.ok(); |
|
| 321 | + | } |
|
| 322 | + | } |
|
| 323 | + | ||
| 324 | + | /// Store an uploaded asset by content and attach it to a release. |
|
| 325 | + | pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> { |
|
| 326 | + | let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); |
|
| 327 | + | if name.is_empty() || name.starts_with('.') || name.len() > 200 { |
|
| 328 | + | return Err("give the asset a file name".into()); |
|
| 329 | + | } |
|
| 330 | + | if bytes.len() as u64 > app.config.limits.asset_limit.0 { |
|
| 331 | + | return Err("the asset is larger than this hub allows".into()); |
|
| 332 | + | } |
|
| 333 | + | let hash = hex::encode(blake3::hash(bytes).as_bytes()); |
|
| 334 | + | let path = blob_path(app, &hash); |
|
| 335 | + | if !path.exists() { |
|
| 336 | + | tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?; |
|
| 337 | + | let tmp = path.with_extension("partial"); |
|
| 338 | + | tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?; |
|
| 339 | + | tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?; |
|
| 340 | + | } |
|
| 341 | + | let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type }; |
|
| 342 | + | rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string()) |
|
| 343 | + | } |
|
| 344 | + | ||
| 345 | + | pub async fn upload( |
|
| 346 | + | State(app): State<Shared>, |
|
| 347 | + | viewer: Viewer, |
|
| 348 | + | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 349 | + | mut multipart: Multipart, |
|
| 350 | + | ) -> Result<Response, Error> { |
|
| 351 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); |
|
| 352 | + | require(&viewer, &shell)?; |
|
| 353 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; |
|
| 354 | + | let Some(r) = rdb::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().find(|r| r.id == id) else { |
|
| 355 | + | return Err(Error::not_found(shell)); |
|
| 356 | + | }; |
|
| 357 | + | let mut token = String::new(); |
|
| 358 | + | let mut files: Vec<(String, String, Vec<u8>)> = vec![]; |
|
| 359 | + | while let Ok(Some(field)) = multipart.next_field().await { |
|
| 360 | + | match field.name() { |
|
| 361 | + | Some("_csrf") => token = field.text().await.unwrap_or_default(), |
|
| 362 | + | Some("asset") => { |
|
| 363 | + | let fname = field.file_name().unwrap_or("").to_string(); |
|
| 364 | + | let ct = field.content_type().unwrap_or("").to_string(); |
|
| 365 | + | let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 366 | + | if !fname.is_empty() { |
|
| 367 | + | files.push((fname, ct, bytes.to_vec())); |
|
| 368 | + | } |
|
| 369 | + | } |
|
| 370 | + | _ => {} |
|
| 371 | + | } |
|
| 372 | + | } |
|
| 373 | + | csrf(&viewer, &token, &shell)?; |
|
| 374 | + | for (fname, ct, bytes) in files { |
|
| 375 | + | store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?; |
|
| 376 | + | } |
|
| 377 | + | Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()) |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | pub async fn download( |
|
| 381 | + | State(app): State<Shared>, |
|
| 382 | + | viewer: Viewer, |
|
| 383 | + | Path((owner, name, id, file)): Path<(String, String, i64, String)>, |
|
| 384 | + | ) -> Result<Response, Error> { |
|
| 385 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); |
|
| 386 | + | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 387 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 388 | + | let asset: Option<rdb::Asset> = |
|
| 389 | + | sqlx::query_as("SELECT a.* FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = ? AND r.repo_id = ?") |
|
| 390 | + | .bind(id) |
|
| 391 | + | .bind(row.id) |
|
| 392 | + | .fetch_optional(&app.db) |
|
| 393 | + | .await |
|
| 394 | + | .map_err(e)?; |
|
| 395 | + | let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) }; |
|
| 396 | + | let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?; |
|
| 397 | + | rdb::count_download(&app.db, a.id).await.ok(); |
|
| 398 | + | Ok(( |
|
| 399 | + | [ |
|
| 400 | + | (header::CONTENT_TYPE, a.content_type.clone()), |
|
| 401 | + | (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))), |
|
| 402 | + | (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), |
|
| 403 | + | (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()), |
|
| 404 | + | ], |
|
| 405 | + | bytes, |
|
| 406 | + | ) |
|
| 407 | + | .into_response()) |
|
| 408 | + | } |
|
| 409 | + | ||
| 410 | + | #[derive(Deserialize)] |
|
| 411 | + | pub struct ArchiveQuery { |
|
| 412 | + | #[serde(rename = "ref")] |
|
| 413 | + | r: String, |
|
| 414 | + | format: String, |
|
| 415 | + | } |
|
| 416 | + | ||
| 417 | + | /// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per |
|
| 418 | + | /// state and served from the cache after. |
|
| 419 | + | pub async fn source_archive( |
|
| 420 | + | State(app): State<Shared>, |
|
| 421 | + | viewer: Viewer, |
|
| 422 | + | Path((owner, name)): Path<(String, String)>, |
|
| 423 | + | Query(q): Query<ArchiveQuery>, |
|
| 424 | + | ) -> Result<Response, Error> { |
|
| 425 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer); |
|
| 426 | + | let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 427 | + | let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?; |
|
| 428 | + | let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect(); |
|
| 429 | + | let prefix = format!("{name}-{}", label.trim_matches('-')); |
|
| 430 | + | let data = app.config.data.clone(); |
|
| 431 | + | let spec = q.r.clone(); |
|
| 432 | + | let p = prefix.clone(); |
|
| 433 | + | let path = app |
|
| 434 | + | .repos |
|
| 435 | + | .read(&repo, move |r, cache| { |
|
| 436 | + | let target = view::resolve(r, cache, &spec)?; |
|
| 437 | + | let loaded = cache.load(r, &target.heads)?; |
|
| 438 | + | let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex(); |
|
| 439 | + | let dest = archive::cache_path(&data, &state, &p, format); |
|
| 440 | + | if !dest.exists() { |
|
| 441 | + | archive::build(r, &loaded, &p, format, &dest) |
|
| 442 | + | .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?; |
|
| 443 | + | } |
|
| 444 | + | Ok(dest) |
|
| 445 | + | }) |
|
| 446 | + | .await |
|
| 447 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 448 | + | let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?; |
|
| 449 | + | Ok(( |
|
| 450 | + | [ |
|
| 451 | + | (header::CONTENT_TYPE, format.mime().to_string()), |
|
| 452 | + | (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())), |
|
| 453 | + | ], |
|
| 454 | + | bytes, |
|
| 455 | + | ) |
|
| 456 | + | .into_response()) |
|
| 457 | + | } |
|
| 458 | + | ||
| 459 | + | /// API shape of a release. |
|
| 460 | + | pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release { |
|
| 461 | + | let base = format!("{}/{}", app.config.base_url(), row.slug()); |
|
| 462 | + | hub_api::Release { |
|
| 463 | + | tag: v.r.tag.clone(), |
|
| 464 | + | title: v.r.title.clone(), |
|
| 465 | + | notes: v.r.notes.clone(), |
|
| 466 | + | prerelease: v.r.prerelease, |
|
| 467 | + | latest: v.latest, |
|
| 468 | + | author: v.r.author.clone(), |
|
| 469 | + | heads: v.heads.clone(), |
|
| 470 | + | signer: v.signer.clone(), |
|
| 471 | + | assets: v |
|
| 472 | + | .assets |
|
| 473 | + | .iter() |
|
| 474 | + | .map(|a| hub_api::ReleaseAsset { |
|
| 475 | + | name: a.name.clone(), |
|
| 476 | + | size: a.size, |
|
| 477 | + | hash: a.hash.clone(), |
|
| 478 | + | content_type: a.content_type.clone(), |
|
| 479 | + | downloads: a.downloads, |
|
| 480 | + | url: format!("{base}/asset/{}/{}", a.id, a.name), |
|
| 481 | + | }) |
|
| 482 | + | .collect(), |
|
| 483 | + | archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(), |
|
| 484 | + | created_ms: v.r.created_ms, |
|
| 485 | + | url: format!("{base}/releases/tag/{}", v.r.tag), |
|
| 486 | + | } |
|
| 487 | + | } |
|
| 488 | + | ||
| 489 | + | pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> { |
|
| 490 | + | Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect()) |
|
| 491 | + | } |
added crates/hub/tests/releases.rs +327 −0
| 1 | + | //! The rest of milestone 0.6: releases with generated notes, assets and |
|
| 2 | + | //! source archives; the read-only Git mirror cloned with real git; code |
|
| 3 | + | //! search; anchored proposal comments through the API. |
|
| 4 | + | ||
| 5 | + | use axum::body::Body; |
|
| 6 | + | use axum::http::{header, Request, StatusCode}; |
|
| 7 | + | use http_body_util::BodyExt; |
|
| 8 | + | use oxy_core::repo::{IdentityScope, Repo}; |
|
| 9 | + | use oxygenhub::config::Config; |
|
| 10 | + | use oxygenhub::db; |
|
| 11 | + | use oxygenhub::repos::RepoName; |
|
| 12 | + | use oxygenhub::Shared; |
|
| 13 | + | use std::io::Read; |
|
| 14 | + | use std::path::Path; |
|
| 15 | + | use tower::ServiceExt; |
|
| 16 | + | ||
| 17 | + | fn hermetic() { |
|
| 18 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 19 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 20 | + | ONCE.call_once(|| { |
|
| 21 | + | let d = tempfile::tempdir().unwrap(); |
|
| 22 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 23 | + | DIR.set(d).ok(); |
|
| 24 | + | }); |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 28 | + | let p = root.join(rel); |
|
| 29 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 30 | + | std::fs::write(p, content).unwrap(); |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | struct Res { |
|
| 34 | + | status: StatusCode, |
|
| 35 | + | body: Vec<u8>, |
|
| 36 | + | cookie: Option<String>, |
|
| 37 | + | location: Option<String>, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | impl Res { |
|
| 41 | + | fn text(&self) -> String { |
|
| 42 | + | String::from_utf8_lossy(&self.body).into_owned() |
|
| 43 | + | } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | async fn send( |
|
| 47 | + | app: &Shared, |
|
| 48 | + | method: &str, |
|
| 49 | + | path: &str, |
|
| 50 | + | cookie: Option<&str>, |
|
| 51 | + | bearer: Option<&str>, |
|
| 52 | + | ctype: Option<&str>, |
|
| 53 | + | body: Vec<u8>, |
|
| 54 | + | ) -> Res { |
|
| 55 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 56 | + | if let Some(c) = cookie { |
|
| 57 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 58 | + | } |
|
| 59 | + | if let Some(b) = bearer { |
|
| 60 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {b}")); |
|
| 61 | + | } |
|
| 62 | + | if let Some(t) = ctype { |
|
| 63 | + | req = req.header(header::CONTENT_TYPE, t); |
|
| 64 | + | } |
|
| 65 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(Body::from(body)).unwrap()).await.unwrap(); |
|
| 66 | + | let status = res.status(); |
|
| 67 | + | let cookie = res |
|
| 68 | + | .headers() |
|
| 69 | + | .get(header::SET_COOKIE) |
|
| 70 | + | .and_then(|v| v.to_str().ok()) |
|
| 71 | + | .and_then(|v| v.split(';').next()) |
|
| 72 | + | .and_then(|kv| kv.split_once('=')) |
|
| 73 | + | .map(|(_, v)| v.to_string()); |
|
| 74 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 75 | + | let body = res.into_body().collect().await.unwrap().to_bytes().to_vec(); |
|
| 76 | + | Res { status, body, cookie, location } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | async fn form(app: &Shared, path: &str, cookie: &str, f: &str) -> Res { |
|
| 80 | + | send(app, "POST", path, Some(cookie), None, Some("application/x-www-form-urlencoded"), f.as_bytes().to_vec()).await |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | fn csrf_of(page: &str) -> String { |
|
| 84 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 85 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 89 | + | async fn releases_mirror_search_and_anchors() { |
|
| 90 | + | hermetic(); |
|
| 91 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 92 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 93 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 94 | + | config.registration.mode = "open".into(); |
|
| 95 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 96 | + | let port = listener.local_addr().unwrap().port(); |
|
| 97 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 98 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 99 | + | let router = oxygenhub::router(app.clone()); |
|
| 100 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 101 | + | let base = format!("http://127.0.0.1:{port}"); |
|
| 102 | + | let app = &app; |
|
| 103 | + | let andy = send( |
|
| 104 | + | app, |
|
| 105 | + | "POST", |
|
| 106 | + | "/register", |
|
| 107 | + | None, |
|
| 108 | + | None, |
|
| 109 | + | Some("application/x-www-form-urlencoded"), |
|
| 110 | + | b"username=andy&email=a%40example.com&password=correct-horse-battery".to_vec(), |
|
| 111 | + | ) |
|
| 112 | + | .await |
|
| 113 | + | .cookie |
|
| 114 | + | .unwrap(); |
|
| 115 | + | let repo = RepoName::parse("andy/demo").unwrap(); |
|
| 116 | + | app.repos.create(&repo, None).unwrap(); |
|
| 117 | + | db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 118 | + | ||
| 119 | + | // Two signed changes, a signed tag on the first, and a second tag later. |
|
| 120 | + | let work = tempfile::tempdir().unwrap(); |
|
| 121 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 122 | + | client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 123 | + | client.config.set("core.sign", "change").unwrap(); |
|
| 124 | + | client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap(); |
|
| 125 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 126 | + | client.save_config().unwrap(); |
|
| 127 | + | write(work.path(), "src/main.rs", b"fn main() {\n println!(\"hello flux\");\n}\n"); |
|
| 128 | + | write(work.path(), "run.sh", b"#!/bin/sh\necho run\n"); |
|
| 129 | + | std::fs::set_permissions(work.path().join("run.sh"), std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); |
|
| 130 | + | client.snapshot("t").unwrap(); |
|
| 131 | + | let c1 = client.commit("First light", &[]).unwrap(); |
|
| 132 | + | client.tag_create("v0.1", &[c1], "First release").unwrap(); |
|
| 133 | + | write(work.path(), "README.md", b"# Demo\n"); |
|
| 134 | + | client.snapshot("t").unwrap(); |
|
| 135 | + | let c2 = client.commit("Add a README", &[]).unwrap(); |
|
| 136 | + | client.tag_create("v0.2", &[c2], "Second").unwrap(); |
|
| 137 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 138 | + | ||
| 139 | + | // Releases: generated notes list the changes since the previous release. |
|
| 140 | + | let page = send(app, "GET", "/andy/demo/releases/new?tag=v0.1", Some(&andy), None, None, vec![]).await.text(); |
|
| 141 | + | assert!(page.contains("First light"), "{page}"); |
|
| 142 | + | let csrf = csrf_of(&page); |
|
| 143 | + | let r = form(app, "/andy/demo/releases/new", &andy, &format!("_csrf={csrf}&tag=v0.1&title=Zero+one¬es=First")).await; |
|
| 144 | + | assert_eq!(r.location.as_deref(), Some("/andy/demo/releases/tag/v0.1"), "{}", r.text()); |
|
| 145 | + | let (st, v) = { |
|
| 146 | + | let r = send( |
|
| 147 | + | app, |
|
| 148 | + | "POST", |
|
| 149 | + | "/api/v1/repos/andy/demo/releases", |
|
| 150 | + | None, |
|
| 151 | + | Some("site"), |
|
| 152 | + | Some("application/json"), |
|
| 153 | + | br#"{"tag":"v0.2","title":"Zero two"}"#.to_vec(), |
|
| 154 | + | ) |
|
| 155 | + | .await; |
|
| 156 | + | (r.status, serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()) |
|
| 157 | + | }; |
|
| 158 | + | assert_eq!(st, StatusCode::CREATED, "{v}"); |
|
| 159 | + | assert!( |
|
| 160 | + | v["notes"].as_str().unwrap().contains("Add a README") && !v["notes"].as_str().unwrap().contains("First light"), |
|
| 161 | + | "notes since v0.1: {v}" |
|
| 162 | + | ); |
|
| 163 | + | assert_eq!(v["latest"], true); |
|
| 164 | + | assert_eq!(v["signer"], "Andy"); |
|
| 165 | + | let r = send( |
|
| 166 | + | app, |
|
| 167 | + | "POST", |
|
| 168 | + | "/api/v1/repos/andy/demo/releases", |
|
| 169 | + | None, |
|
| 170 | + | Some("site"), |
|
| 171 | + | Some("application/json"), |
|
| 172 | + | br#"{"tag":"nope","title":"x"}"#.to_vec(), |
|
| 173 | + | ) |
|
| 174 | + | .await; |
|
| 175 | + | assert_eq!(r.status, StatusCode::BAD_REQUEST); |
|
| 176 | + | ||
| 177 | + | // An asset through the API; download it back; the page lists it. |
|
| 178 | + | let r = send( |
|
| 179 | + | app, |
|
| 180 | + | "POST", |
|
| 181 | + | "/api/v1/repos/andy/demo/releases/v0.2/assets?name=demo-linux.bin", |
|
| 182 | + | None, |
|
| 183 | + | Some("site"), |
|
| 184 | + | Some("application/octet-stream"), |
|
| 185 | + | b"\x7fELF binary".to_vec(), |
|
| 186 | + | ) |
|
| 187 | + | .await; |
|
| 188 | + | assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); |
|
| 189 | + | let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); |
|
| 190 | + | let url = v["assets"][0]["url"].as_str().unwrap().replace(&base, ""); |
|
| 191 | + | assert_eq!(v["assets"][0]["hash"], hex::encode(blake3::hash(b"\x7fELF binary").as_bytes())); |
|
| 192 | + | let got = send(app, "GET", &url, None, None, None, vec![]).await; |
|
| 193 | + | assert_eq!(got.body, b"\x7fELF binary"); |
|
| 194 | + | let list = send(app, "GET", "/andy/demo/releases", None, None, None, vec![]).await.text(); |
|
| 195 | + | assert!(list.contains("Zero two") && list.contains("demo-linux.bin") && list.contains("latest"), "{list}"); |
|
| 196 | + | ||
| 197 | + | // Source archives: tar.zst with the exec bit, and zip. |
|
| 198 | + | let tz = send(app, "GET", "/andy/demo/archive?ref=v0.1&format=tar.zst", None, None, None, vec![]).await; |
|
| 199 | + | assert_eq!(tz.status, StatusCode::OK); |
|
| 200 | + | let mut tar = tar::Archive::new(zstd::Decoder::new(tz.body.as_slice()).unwrap()); |
|
| 201 | + | let mut seen = vec![]; |
|
| 202 | + | for e in tar.entries().unwrap() { |
|
| 203 | + | let mut e = e.unwrap(); |
|
| 204 | + | let path = e.path().unwrap().to_string_lossy().into_owned(); |
|
| 205 | + | let mode = e.header().mode().unwrap(); |
|
| 206 | + | let mut s = String::new(); |
|
| 207 | + | e.read_to_string(&mut s).unwrap(); |
|
| 208 | + | seen.push((path, mode, s)); |
|
| 209 | + | } |
|
| 210 | + | assert!(seen.iter().any(|(p, _, s)| p == "demo-v0.1/src/main.rs" && s.contains("hello flux")), "{seen:?}"); |
|
| 211 | + | assert!(seen.iter().any(|(p, m, _)| p == "demo-v0.1/run.sh" && m & 0o111 != 0), "exec bit kept: {seen:?}"); |
|
| 212 | + | assert!(!seen.iter().any(|(p, _, _)| p.ends_with("README.md")), "v0.1 predates the README"); |
|
| 213 | + | let zp = send(app, "GET", "/andy/demo/archive?ref=main&format=zip", None, None, None, vec![]).await; |
|
| 214 | + | let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zp.body)).unwrap(); |
|
| 215 | + | let mut readme = String::new(); |
|
| 216 | + | zip.by_name("demo-main/README.md").unwrap().read_to_string(&mut readme).unwrap(); |
|
| 217 | + | assert_eq!(readme, "# Demo\n"); |
|
| 218 | + | ||
| 219 | + | // Code search: substring and regex, with path matches. |
|
| 220 | + | let s = send(app, "GET", "/andy/demo/search?q=flux", None, None, None, vec![]).await.text(); |
|
| 221 | + | assert!(s.contains("src/main.rs:2"), "{s}"); |
|
| 222 | + | let s = send(app, "GET", "/andy/demo/search?q=print%5Cw%2B&re=1", None, None, None, vec![]).await.text(); |
|
| 223 | + | assert!(s.contains("src/main.rs:2"), "{s}"); |
|
| 224 | + | let s = send(app, "GET", "/andy/demo/search?q=%28&re=1", None, None, None, vec![]).await.text(); |
|
| 225 | + | assert!(s.contains("not a valid regular expression"), "{s}"); |
|
| 226 | + | assert!(send(app, "GET", "/andy/demo/search?q=README", None, None, None, vec![]).await.text().contains("path matches")); |
|
| 227 | + | ||
| 228 | + | // The Git mirror: off by default, on from settings, cloned with git, push refused. |
|
| 229 | + | assert_eq!( |
|
| 230 | + | send(app, "GET", "/andy/demo.git/info/refs?service=git-upload-pack", None, None, None, vec![]).await.status, |
|
| 231 | + | StatusCode::NOT_FOUND |
|
| 232 | + | ); |
|
| 233 | + | let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text(); |
|
| 234 | + | let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await; |
|
| 235 | + | assert!(r.text().contains("mirror is on"), "{}", r.text()); |
|
| 236 | + | let clone_dir = tempfile::tempdir().unwrap(); |
|
| 237 | + | let url = format!("{base}/andy/demo.git"); |
|
| 238 | + | let target = clone_dir.path().join("demo"); |
|
| 239 | + | let out = tokio::task::spawn_blocking(move || { |
|
| 240 | + | std::process::Command::new("git") |
|
| 241 | + | .args(["clone", "-q", &url, target.to_str().unwrap()]) |
|
| 242 | + | .env("GIT_TERMINAL_PROMPT", "0") |
|
| 243 | + | .output() |
|
| 244 | + | .unwrap() |
|
| 245 | + | }) |
|
| 246 | + | .await |
|
| 247 | + | .unwrap(); |
|
| 248 | + | assert!(out.status.success(), "git clone: {}", String::from_utf8_lossy(&out.stderr)); |
|
| 249 | + | assert_eq!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap(), "# Demo\n"); |
|
| 250 | + | let log = std::process::Command::new("git") |
|
| 251 | + | .args(["-C", clone_dir.path().join("demo").to_str().unwrap(), "log", "--format=%B"]) |
|
| 252 | + | .output() |
|
| 253 | + | .unwrap(); |
|
| 254 | + | assert!(String::from_utf8_lossy(&log.stdout).contains("Add a README")); |
|
| 255 | + | // A new push refreshes the mirror. |
|
| 256 | + | write(work.path(), "README.md", b"# Demo\n\nMore.\n"); |
|
| 257 | + | client.snapshot("t").unwrap(); |
|
| 258 | + | client.commit("More README", &[]).unwrap(); |
|
| 259 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 260 | + | tokio::time::sleep(std::time::Duration::from_millis(500)).await; |
|
| 261 | + | let d = clone_dir.path().join("demo"); |
|
| 262 | + | let pull = tokio::task::spawn_blocking(move || { |
|
| 263 | + | std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "pull", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap() |
|
| 264 | + | }) |
|
| 265 | + | .await |
|
| 266 | + | .unwrap(); |
|
| 267 | + | assert!(pull.status.success(), "{}", String::from_utf8_lossy(&pull.stderr)); |
|
| 268 | + | assert!(std::fs::read_to_string(clone_dir.path().join("demo/README.md")).unwrap().contains("More.")); |
|
| 269 | + | let d = clone_dir.path().join("demo"); |
|
| 270 | + | let push = tokio::task::spawn_blocking(move || { |
|
| 271 | + | std::fs::write(d.join("x.txt"), "x").unwrap(); |
|
| 272 | + | std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "add", "x.txt"]).output().unwrap(); |
|
| 273 | + | std::process::Command::new("git") |
|
| 274 | + | .args(["-C", d.to_str().unwrap(), "-c", "user.name=x", "-c", "user.email=x@x", "commit", "-qm", "x"]) |
|
| 275 | + | .output() |
|
| 276 | + | .unwrap(); |
|
| 277 | + | std::process::Command::new("git").args(["-C", d.to_str().unwrap(), "push", "-q"]).env("GIT_TERMINAL_PROMPT", "0").output().unwrap() |
|
| 278 | + | }) |
|
| 279 | + | .await |
|
| 280 | + | .unwrap(); |
|
| 281 | + | assert!(!push.status.success(), "pushing to the mirror is refused"); |
|
| 282 | + | ||
| 283 | + | // Anchored comments through the API, at any line of the projected state. |
|
| 284 | + | client.bookmark_create("feat", None).unwrap(); |
|
| 285 | + | client.bookmark_switch("feat", false).unwrap(); |
|
| 286 | + | write(work.path(), "src/main.rs", b"fn main() {\n println!(\"hello flux\");\n println!(\"again\");\n}\n"); |
|
| 287 | + | client.snapshot("t").unwrap(); |
|
| 288 | + | client.commit("Again", &[]).unwrap(); |
|
| 289 | + | assert!(client.push_bookmark("origin", "feat", false).unwrap().iter().all(|r| r.ok)); |
|
| 290 | + | let r = send( |
|
| 291 | + | app, |
|
| 292 | + | "POST", |
|
| 293 | + | "/api/v1/repos/andy/demo/proposals", |
|
| 294 | + | None, |
|
| 295 | + | Some("site"), |
|
| 296 | + | Some("application/json"), |
|
| 297 | + | br#"{"title":"Again","target":"main","source":"feat"}"#.to_vec(), |
|
| 298 | + | ) |
|
| 299 | + | .await; |
|
| 300 | + | assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); |
|
| 301 | + | let number = serde_json::from_slice::<serde_json::Value>(&r.body).unwrap()["number"].as_i64().unwrap(); |
|
| 302 | + | let r = send( |
|
| 303 | + | app, |
|
| 304 | + | "POST", |
|
| 305 | + | &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"), |
|
| 306 | + | None, |
|
| 307 | + | Some("site"), |
|
| 308 | + | Some("application/json"), |
|
| 309 | + | br#"{"body":"why twice?","path":"src/main.rs","line":1}"#.to_vec(), |
|
| 310 | + | ) |
|
| 311 | + | .await; |
|
| 312 | + | assert_eq!(r.status, StatusCode::CREATED, "{}", r.text()); |
|
| 313 | + | let v: serde_json::Value = serde_json::from_slice(&r.body).unwrap(); |
|
| 314 | + | assert_eq!(v["path"], "src/main.rs"); |
|
| 315 | + | assert_eq!(v["anchor"]["change"], c1.to_hex(), "line 1 was written by the first change"); |
|
| 316 | + | let r = send( |
|
| 317 | + | app, |
|
| 318 | + | "POST", |
|
| 319 | + | &format!("/api/v1/repos/andy/demo/proposals/{number}/comments"), |
|
| 320 | + | None, |
|
| 321 | + | Some("site"), |
|
| 322 | + | Some("application/json"), |
|
| 323 | + | br#"{"body":"x","path":"src/main.rs","line":99}"#.to_vec(), |
|
| 324 | + | ) |
|
| 325 | + | .await; |
|
| 326 | + | assert_eq!(r.status, StatusCode::BAD_REQUEST); |
|
| 327 | + | } |
added templates/code_search.html +26 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Search {{ q }} · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "code") %}{% endblock %} |
|
| 6 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 7 | + | {% block content %} |
|
| 8 | + | <div class="head"><h1>Search code <small>on ◆ {{ r }}{% if !q.is_empty() %} · {{ results.hits.len() }} matches in {{ results.files|plural("file", "files") }}{% if results.truncated %} (stopped early; narrow the search){% endif %}{% endif %}</small></h1></div> |
|
| 9 | + | <form class="card wide" method="get" action="{{ repo.url }}/search"> |
|
| 10 | + | <div class="row"> |
|
| 11 | + | <div><input type="search" name="q" value="{{ q }}" autofocus placeholder="text, or a regular expression"></div> |
|
| 12 | + | <div><input type="text" name="path" value="{{ path }}" placeholder="under path (optional)"></div> |
|
| 13 | + | <div><button type="submit">SEARCH</button></div> |
|
| 14 | + | </div> |
|
| 15 | + | <input type="hidden" name="ref" value="{{ r }}"> |
|
| 16 | + | <div class="choice"><label><input type="checkbox" name="re" value="1"{% if regex %} checked{% endif %}> regular expression</label><label><input type="checkbox" name="case" value="1"{% if case %} checked{% endif %}> match case</label></div> |
|
| 17 | + | </form> |
|
| 18 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 19 | + | {% if !q.is_empty() && results.hits.is_empty() && error.is_none() %}<p class="empty">Nothing matches.</p>{% endif %} |
|
| 20 | + | <div class="hits"> |
|
| 21 | + | {% for h in results.hits %} |
|
| 22 | + | {% if h.line == 0 %}<p class="hit"><a href="{{ repo.url }}/file/{{ r }}/{{ h.path }}"><strong>{{ h.path }}</strong></a> <span class="small mute">path matches</span></p> |
|
| 23 | + | {% else %}<p class="hit"><a href="{{ repo.url }}/file/{{ r }}/{{ h.path }}#L{{ h.line }}"><span class="mute">{{ h.path }}:{{ h.line }}</span></a> <code>{{ h.text }}</code></p>{% endif %} |
|
| 24 | + | {% endfor %} |
|
| 25 | + | </div> |
|
| 26 | + | {% endblock %} |
added templates/release_new.html +22 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}New release · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>New release <small>notes and assets on a signed tag</small></h1></div> |
|
| 8 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 9 | + | {% if tags.is_empty() %}<p class="empty">This repository has no tags. <code>oxy tag create v1.0 -m "First release"</code> and push, then come back.</p>{% else %} |
|
| 10 | + | <form class="card wide" method="get" action="{{ repo.url }}/releases/new"> |
|
| 11 | + | <div class="row"><div><label for="tag">Tag</label><select id="tag" name="tag" onchange="this.form.submit()">{% for t in tags %}<option value="{{ t }}"{% if *t == tag %} selected{% endif %}>{{ t }}</option>{% endfor %}</select></div></div> |
|
| 12 | + | </form> |
|
| 13 | + | <form class="card wide" method="post" action="{{ repo.url }}/releases/new"> |
|
| 14 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 15 | + | <input type="hidden" name="tag" value="{{ tag }}"> |
|
| 16 | + | <label for="title">Title</label><input type="text" id="title" name="title" value="{{ tag }}"> |
|
| 17 | + | <label for="notes">Notes (drafted from the changes since the previous release)</label><textarea id="notes" name="notes" class="md" rows="14">{{ notes }}</textarea> |
|
| 18 | + | <div class="choice"><label><input type="checkbox" name="prerelease" value="1"> pre-release</label></div> |
|
| 19 | + | <button type="submit">PUBLISH</button> |
|
| 20 | + | </form> |
|
| 21 | + | {% endif %} |
|
| 22 | + | {% endblock %} |
added templates/releases.html +41 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Releases · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | + | {% block tabs %}{% call m::tabs(repo, "releases") %}{% endblock %} |
|
| 6 | + | {% block content %} |
|
| 7 | + | <div class="head"><h1>Releases <small>{{ releases.len() }}</small></h1></div> |
|
| 8 | + | {% if !one && repo.can(crate::auth::Access::Write) %} |
|
| 9 | + | <div class="bar"><a class="go small" href="{{ repo.url }}/releases/new">draft a release</a>{% if !unreleased.is_empty() %}<span class="small mute">tags without a release: {% for t in unreleased %}<a href="{{ repo.url }}/releases/new?tag={{ t }}">{{ t }}</a>{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</div> |
|
| 10 | + | {% endif %} |
|
| 11 | + | {% if releases.is_empty() %}<p class="empty">No releases yet. Tag a state with <code>oxy tag create v1.0 -m "…"</code>, push it, then draft a release here.</p>{% endif %} |
|
| 12 | + | {% for v in releases %} |
|
| 13 | + | <section class="panel release"> |
|
| 14 | + | <h2><a href="{{ repo.url }}/releases/tag/{{ v.r.tag }}">{{ v.r.title }}</a> |
|
| 15 | + | {% if v.latest %}<span class="tag head">latest</span>{% endif %}{% if v.r.prerelease %}<span class="tag warn">pre-release</span>{% endif %} |
|
| 16 | + | <span class="small mute">{{ v.r.tag }}{% if let Some(s) = v.signer %} · <span class="sig sig-valid">✔</span> signed by {{ s }}{% else %} · <span class="sig sig-unsigned">○</span> unsigned tag{% endif %} · {{ v.r.author }} · <time title="{{ v.r.created_ms|when_i }}">{{ v.r.created_ms|ago_i }}</time></span></h2> |
|
| 17 | + | {% if !v.r.notes.is_empty() %}<article class="doc">{{ v.notes_html|safe }}</article>{% endif %} |
|
| 18 | + | <h3>Assets</h3> |
|
| 19 | + | <ul class="plain"> |
|
| 20 | + | {% for a in v.assets %}<li><a href="{{ repo.url }}/asset/{{ a.id }}/{{ a.name }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.hash }}">{{ a.hash|short }}</code> · {{ a.downloads }} downloads</span> |
|
| 21 | + | {% if one && repo.can(crate::auth::Access::Write) %}<form class="tiny" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="asset" value="{{ a.id }}"><button name="action" value="delete_asset">delete</button></form>{% endif %}</li>{% endfor %} |
|
| 22 | + | <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=tar.zst">Source ({{ v.r.tag }}.tar.zst)</a></li> |
|
| 23 | + | <li><a href="{{ repo.url }}/archive?ref={{ v.r.tag }}&format=zip">Source ({{ v.r.tag }}.zip)</a></li> |
|
| 24 | + | </ul> |
|
| 25 | + | {% if one && repo.can(crate::auth::Access::Write) %} |
|
| 26 | + | <form class="card" method="post" action="{{ repo.url }}/releases/upload/{{ v.r.id }}" enctype="multipart/form-data"> |
|
| 27 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 28 | + | <label for="asset">Attach files</label><input type="file" id="asset" name="asset" multiple required> |
|
| 29 | + | <button type="submit">UPLOAD</button> |
|
| 30 | + | </form> |
|
| 31 | + | <form class="card wide" method="post" action="{{ repo.url }}/releases/edit/{{ v.r.id }}"> |
|
| 32 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 33 | + | <label for="title">Title</label><input type="text" id="title" name="title" value="{{ v.r.title }}"> |
|
| 34 | + | <label for="notes">Notes</label><textarea id="notes" name="notes" class="md" rows="8">{{ v.r.notes }}</textarea> |
|
| 35 | + | <div class="choice"><label><input type="checkbox" name="prerelease" value="1"{% if v.r.prerelease %} checked{% endif %}> pre-release</label></div> |
|
| 36 | + | <div class="choice"><button type="submit" name="action" value="save">SAVE</button> <button type="submit" name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this release? The tag stays.')">DELETE RELEASE</button></div> |
|
| 37 | + | </form> |
|
| 38 | + | {% endif %} |
|
| 39 | + | </section> |
|
| 40 | + | {% endfor %} |
|
| 41 | + | {% endblock %} |
modified Cargo.lock +83 −0
| … | 91 unchanged lines |
||
| 92 | 92 | checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" |
|
| 93 | 93 | ||
| 94 | 94 | [[package]] |
|
| 95 | + | name = "arbitrary" |
|
| 96 | + | version = "1.4.2" |
|
| 97 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 98 | + | checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" |
|
| 99 | + | dependencies = [ |
|
| 100 | + | "derive_arbitrary", |
|
| 101 | + | ] |
|
| 102 | + | ||
| 103 | + | [[package]] |
|
| 95 | 104 | name = "arc-swap" |
|
| 96 | 105 | version = "1.9.2" |
|
| 97 | 106 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 732 unchanged lines |
||
| 830 | 839 | checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" |
|
| 831 | 840 | ||
| 832 | 841 | [[package]] |
|
| 842 | + | name = "derive_arbitrary" |
|
| 843 | + | version = "1.4.2" |
|
| 844 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 845 | + | checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" |
|
| 846 | + | dependencies = [ |
|
| 847 | + | "proc-macro2", |
|
| 848 | + | "quote", |
|
| 849 | + | "syn 2.0.119", |
|
| 850 | + | ] |
|
| 851 | + | ||
| 852 | + | [[package]] |
|
| 833 | 853 | name = "digest" |
|
| 834 | 854 | version = "0.10.7" |
|
| 835 | 855 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 176 unchanged lines |
||
| 1012 | 1032 | checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" |
|
| 1013 | 1033 | ||
| 1014 | 1034 | [[package]] |
|
| 1035 | + | name = "filetime" |
|
| 1036 | + | version = "0.2.29" |
|
| 1037 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1038 | + | checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" |
|
| 1039 | + | dependencies = [ |
|
| 1040 | + | "cfg-if", |
|
| 1041 | + | "libc", |
|
| 1042 | + | ] |
|
| 1043 | + | ||
| 1044 | + | [[package]] |
|
| 1015 | 1045 | name = "find-msvc-tools" |
|
| 1016 | 1046 | version = "0.1.14" |
|
| 1017 | 1047 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 1046 unchanged lines |
||
| 2064 | 2094 | "sqlx", |
|
| 2065 | 2095 | "subtle", |
|
| 2066 | 2096 | "syntect", |
|
| 2097 | + | "tar", |
|
| 2067 | 2098 | "tempfile", |
|
| 2068 | 2099 | "thiserror 2.0.21", |
|
| 2069 | 2100 | "time", |
|
| … | 4 unchanged lines |
||
| 2074 | 2105 | "tower-http", |
|
| 2075 | 2106 | "tracing", |
|
| 2076 | 2107 | "tracing-subscriber", |
|
| 2108 | + | "zip", |
|
| 2109 | + | "zstd", |
|
| 2077 | 2110 | ] |
|
| 2078 | 2111 | ||
| 2079 | 2112 | [[package]] |
|
| … | 1245 unchanged lines |
||
| 3325 | 3358 | ] |
|
| 3326 | 3359 | ||
| 3327 | 3360 | [[package]] |
|
| 3361 | + | name = "tar" |
|
| 3362 | + | version = "0.4.46" |
|
| 3363 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3364 | + | checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" |
|
| 3365 | + | dependencies = [ |
|
| 3366 | + | "filetime", |
|
| 3367 | + | "libc", |
|
| 3368 | + | "xattr", |
|
| 3369 | + | ] |
|
| 3370 | + | ||
| 3371 | + | [[package]] |
|
| 3328 | 3372 | name = "tempfile" |
|
| 3329 | 3373 | version = "3.27.0" |
|
| 3330 | 3374 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 781 unchanged lines |
||
| 4112 | 4156 | ] |
|
| 4113 | 4157 | ||
| 4114 | 4158 | [[package]] |
|
| 4159 | + | name = "xattr" |
|
| 4160 | + | version = "1.6.1" |
|
| 4161 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4162 | + | checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" |
|
| 4163 | + | dependencies = [ |
|
| 4164 | + | "libc", |
|
| 4165 | + | "rustix", |
|
| 4166 | + | ] |
|
| 4167 | + | ||
| 4168 | + | [[package]] |
|
| 4115 | 4169 | name = "yaml-rust" |
|
| 4116 | 4170 | version = "0.4.5" |
|
| 4117 | 4171 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 116 unchanged lines |
||
| 4234 | 4288 | ] |
|
| 4235 | 4289 | ||
| 4236 | 4290 | [[package]] |
|
| 4291 | + | name = "zip" |
|
| 4292 | + | version = "2.4.2" |
|
| 4293 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4294 | + | checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" |
|
| 4295 | + | dependencies = [ |
|
| 4296 | + | "arbitrary", |
|
| 4297 | + | "crc32fast", |
|
| 4298 | + | "crossbeam-utils", |
|
| 4299 | + | "displaydoc", |
|
| 4300 | + | "flate2", |
|
| 4301 | + | "indexmap", |
|
| 4302 | + | "memchr", |
|
| 4303 | + | "thiserror 2.0.21", |
|
| 4304 | + | "zopfli", |
|
| 4305 | + | ] |
|
| 4306 | + | ||
| 4307 | + | [[package]] |
|
| 4237 | 4308 | name = "zlib-rs" |
|
| 4238 | 4309 | version = "0.6.8" |
|
| 4239 | 4310 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 6 unchanged lines |
||
| 4246 | 4317 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
|
| 4247 | 4318 | ||
| 4248 | 4319 | [[package]] |
|
| 4320 | + | name = "zopfli" |
|
| 4321 | + | version = "0.8.3" |
|
| 4322 | + | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 4323 | + | checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" |
|
| 4324 | + | dependencies = [ |
|
| 4325 | + | "bumpalo", |
|
| 4326 | + | "crc32fast", |
|
| 4327 | + | "log", |
|
| 4328 | + | "simd-adler32", |
|
| 4329 | + | ] |
|
| 4330 | + | ||
| 4331 | + | [[package]] |
|
| 4249 | 4332 | name = "zstd" |
|
| 4250 | 4333 | version = "0.13.3" |
|
| 4251 | 4334 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 23 unchanged lines |
modified Cargo.toml +4 −1
| … | 17 unchanged lines |
||
| 18 | 18 | thiserror = "2" |
|
| 19 | 19 | serde = { version = "1", features = ["derive"] } |
|
| 20 | 20 | serde_json = "1" |
|
| 21 | - | tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal", "fs"] } |
|
| 21 | + | tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal", "fs", "process", "io-util"] } |
|
| 22 | 22 | axum = { version = "0.8", features = ["macros", "form", "query", "multipart"] } |
|
| 23 | 23 | axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] } |
|
| 24 | 24 | rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } |
|
| … | 20 unchanged lines |
||
| 45 | 45 | qrcode = { version = "0.14", default-features = false, features = ["svg"] } |
|
| 46 | 46 | subtle = "2" |
|
| 47 | 47 | blake3 = "1" |
|
| 48 | + | tar = "0.4" |
|
| 49 | + | zstd = "0.13" |
|
| 50 | + | zip = { version = "2", default-features = false, features = ["deflate"] } |
|
| 48 | 51 | lettre = { version = "0.11", default-features = false, features = ["tokio1", "tokio1-rustls-tls", "smtp-transport", "builder"] } |
|
| 49 | 52 | regex = "1" |
|
| 50 | 53 | tempfile = "3" |
|
| … | 7 unchanged lines |
modified README.md +6 −4
| … | 9 unchanged lines |
||
| 10 | 10 | ## Status |
|
| 11 | 11 | ||
| 12 | 12 | Milestones 0.1 (browse), 0.2 (accounts and access), 0.3 (proposals and |
|
| 13 | - | review), 0.4 (issues and notifications) and 0.5 (API, webhooks, checks) are |
|
| 14 | - | done: pages show |
|
| 13 | + | review), 0.4 (issues and notifications), 0.5 (API, webhooks, checks) and |
|
| 14 | + | 0.6 (web edits, releases, mirror, code search) are done: pages show |
|
| 15 | 15 | trees, files, blame, history with the dependency graph, changes with diffs, |
|
| 16 | 16 | comparisons, bookmarks, tags, conflicts, the trust policy and the push log; |
|
| 17 | 17 | accounts sign in with a password and optional TOTP, create repositories and |
|
| … | 6 unchanged lines |
||
| 24 | 24 | inbox with watching, subscriptions, mentions and optional email; search |
|
| 25 | 25 | over issues, proposals, repositories and accounts; a JSON API for all of |
|
| 26 | 26 | it with scoped tokens, webhooks with signed deliveries and retries, and |
|
| 27 | - | checks on state hashes that gate landing. Web edits, releases and the Git |
|
| 28 | - | mirror follow in 0.6. |
|
| 27 | + | checks on state hashes that gate landing; editing and conflict resolution |
|
| 28 | + | in the browser, signed by the hub as your delegate; releases with assets |
|
| 29 | + | and source archives; a read-only Git mirror for Git-only tools; and code |
|
| 30 | + | search. The runner follows in 0.7. |
|
| 29 | 31 | ||
| 30 | 32 | ## Running |
|
| 31 | 33 | ||
| … | 32 unchanged lines |
modified crates/hub-api/src/lib.rs +48 −0
| … | 502 unchanged lines |
||
| 503 | 503 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 504 | 504 | pub struct NewComment { |
|
| 505 | 505 | pub body: String, |
|
| 506 | + | /// For a proposal: anchor the comment to this file's line (1-based) of |
|
| 507 | + | /// the projected state; the hub records the line's atom. |
|
| 508 | + | #[serde(default)] |
|
| 509 | + | pub path: Option<String>, |
|
| 510 | + | #[serde(default)] |
|
| 511 | + | pub line: Option<usize>, |
|
| 506 | 512 | } |
|
| 507 | 513 | ||
| 508 | 514 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| … | 42 unchanged lines |
||
| 551 | 557 | pub check: Option<Check>, |
|
| 552 | 558 | } |
|
| 553 | 559 | ||
| 560 | + | // --------------------------------------------------------------- releases |
|
| 561 | + | ||
| 562 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 563 | + | pub struct Release { |
|
| 564 | + | pub tag: String, |
|
| 565 | + | pub title: String, |
|
| 566 | + | pub notes: String, |
|
| 567 | + | pub prerelease: bool, |
|
| 568 | + | pub latest: bool, |
|
| 569 | + | pub author: String, |
|
| 570 | + | pub heads: Vec<String>, |
|
| 571 | + | pub signer: Option<String>, |
|
| 572 | + | pub assets: Vec<ReleaseAsset>, |
|
| 573 | + | pub archives: Vec<String>, |
|
| 574 | + | pub created_ms: i64, |
|
| 575 | + | pub url: String, |
|
| 576 | + | } |
|
| 577 | + | ||
| 578 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 579 | + | pub struct ReleaseAsset { |
|
| 580 | + | pub name: String, |
|
| 581 | + | pub size: i64, |
|
| 582 | + | /// BLAKE3 of the content, hex. |
|
| 583 | + | pub hash: String, |
|
| 584 | + | pub content_type: String, |
|
| 585 | + | pub downloads: i64, |
|
| 586 | + | pub url: String, |
|
| 587 | + | } |
|
| 588 | + | ||
| 589 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 590 | + | pub struct NewRelease { |
|
| 591 | + | pub tag: String, |
|
| 592 | + | pub title: String, |
|
| 593 | + | #[serde(default)] |
|
| 594 | + | pub notes: Option<String>, |
|
| 595 | + | #[serde(default)] |
|
| 596 | + | pub prerelease: bool, |
|
| 597 | + | } |
|
| 598 | + | ||
| 554 | 599 | /// Every type of the API, for schema generation. |
|
| 555 | 600 | pub fn schemas() -> Vec<(&'static str, schemars::Schema)> { |
|
| 556 | 601 | vec![ |
|
| … | 40 unchanged lines |
||
| 597 | 642 | ("IssueStateChange", schemars::schema_for!(IssueStateChange)), |
|
| 598 | 643 | ("WebhookEnvelope", schemars::schema_for!(WebhookEnvelope)), |
|
| 599 | 644 | ("ProposalEvent", schemars::schema_for!(ProposalEvent)), |
|
| 645 | + | ("Release", schemars::schema_for!(Release)), |
|
| 646 | + | ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)), |
|
| 647 | + | ("NewRelease", schemars::schema_for!(NewRelease)), |
|
| 600 | 648 | ] |
|
| 601 | 649 | } |
modified crates/hub-api/ts/index.d.ts +33 −0
| … | 316 unchanged lines |
||
| 317 | 317 | ||
| 318 | 318 | export type NewComment = { |
|
| 319 | 319 | body: string; |
|
| 320 | + | line?: number | null; |
|
| 321 | + | path?: string | null; |
|
| 320 | 322 | }; |
|
| 321 | 323 | ||
| 322 | 324 | export type NewProposal = { |
|
| … | 29 unchanged lines |
||
| 352 | 354 | number: number; |
|
| 353 | 355 | }; |
|
| 354 | 356 | ||
| 357 | + | export type ReleaseAsset = { |
|
| 358 | + | content_type: string; |
|
| 359 | + | downloads: number; |
|
| 360 | + | hash: string; |
|
| 361 | + | name: string; |
|
| 362 | + | size: number; |
|
| 363 | + | url: string; |
|
| 364 | + | }; |
|
| 365 | + | ||
| 366 | + | export type Release = { |
|
| 367 | + | archives: string[]; |
|
| 368 | + | assets: ReleaseAsset[]; |
|
| 369 | + | author: string; |
|
| 370 | + | created_ms: number; |
|
| 371 | + | heads: string[]; |
|
| 372 | + | latest: boolean; |
|
| 373 | + | notes: string; |
|
| 374 | + | prerelease: boolean; |
|
| 375 | + | signer?: string | null; |
|
| 376 | + | tag: string; |
|
| 377 | + | title: string; |
|
| 378 | + | url: string; |
|
| 379 | + | }; |
|
| 380 | + | ||
| 381 | + | export type NewRelease = { |
|
| 382 | + | notes?: string | null; |
|
| 383 | + | prerelease?: boolean; |
|
| 384 | + | tag: string; |
|
| 385 | + | title: string; |
|
| 386 | + | }; |
|
| 387 | + |
modified crates/hub/Cargo.toml +3 −0
| … | 45 unchanged lines |
||
| 46 | 46 | qrcode.workspace = true |
|
| 47 | 47 | subtle.workspace = true |
|
| 48 | 48 | blake3.workspace = true |
|
| 49 | + | tar.workspace = true |
|
| 50 | + | zstd.workspace = true |
|
| 51 | + | zip.workspace = true |
|
| 49 | 52 | lettre.workspace = true |
|
| 50 | 53 | regex.workspace = true |
|
| 51 | 54 | reqwest.workspace = true |
|
| … | 7 unchanged lines |
modified crates/hub/src/api/mod.rs +86 −1
| … | 47 unchanged lines |
||
| 48 | 48 | .route("/repos/{owner}/{name}/hooks/{id}/delete", post(delete_hook)) |
|
| 49 | 49 | .route("/repos/{owner}/{name}/checks", post(post_check)) |
|
| 50 | 50 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 51 | + | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| 52 | + | .route("/repos/{owner}/{name}/releases/{tag}/assets", post(upload_asset)) |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | async fn releases( |
|
| 56 | + | State(app): State<Shared>, |
|
| 57 | + | headers: HeaderMap, |
|
| 58 | + | Path((owner, name)): Path<(String, String)>, |
|
| 59 | + | ) -> Result<Json<Vec<Release>>, ApiError> { |
|
| 60 | + | let c = caller(&app, &headers).await?; |
|
| 61 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 62 | + | Ok(Json(crate::web::releases::api_views(&app, &repo, &row).await?)) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | async fn create_release( |
|
| 66 | + | State(app): State<Shared>, |
|
| 67 | + | headers: HeaderMap, |
|
| 68 | + | Path((owner, name)): Path<(String, String)>, |
|
| 69 | + | Json(body): Json<NewRelease>, |
|
| 70 | + | ) -> Result<(StatusCode, Json<Release>), ApiError> { |
|
| 71 | + | let c = caller(&app, &headers).await?; |
|
| 72 | + | let me = c.require("repo:write")?.clone(); |
|
| 73 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 74 | + | crate::web::releases::create_release(&app, &repo, &row, &me, &body.tag, &body.title, body.notes, body.prerelease) |
|
| 75 | + | .await |
|
| 76 | + | .map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 77 | + | let r = crate::web::releases::api_views(&app, &repo, &row) |
|
| 78 | + | .await? |
|
| 79 | + | .into_iter() |
|
| 80 | + | .find(|r| r.tag == body.tag) |
|
| 81 | + | .ok_or_else(|| not_found("no such release"))?; |
|
| 82 | + | Ok((StatusCode::CREATED, Json(r))) |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | #[derive(Deserialize)] |
|
| 86 | + | struct AssetQuery { |
|
| 87 | + | name: String, |
|
| 51 | 88 | } |
|
| 52 | 89 | ||
| 90 | + | /// `POST …/releases/{tag}/assets?name=file.tar.gz` with the file as the body. |
|
| 91 | + | async fn upload_asset( |
|
| 92 | + | State(app): State<Shared>, |
|
| 93 | + | headers: HeaderMap, |
|
| 94 | + | Path((owner, name, tag)): Path<(String, String, String)>, |
|
| 95 | + | Query(q): Query<AssetQuery>, |
|
| 96 | + | body: axum::body::Bytes, |
|
| 97 | + | ) -> Result<(StatusCode, Json<Release>), ApiError> { |
|
| 98 | + | let c = caller(&app, &headers).await?; |
|
| 99 | + | c.require("repo:write")?; |
|
| 100 | + | let (repo, row, _) = reach(&app, &c, &owner, &name, Access::Write).await?; |
|
| 101 | + | let r = db::releases::get(&app.db, row.id, &tag).await?.ok_or_else(|| not_found("no such release"))?; |
|
| 102 | + | let ct = headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("application/octet-stream").to_string(); |
|
| 103 | + | crate::web::releases::store_asset(&app, r.id, &q.name, &ct, &body).await.map_err(|m| ApiError(StatusCode::BAD_REQUEST, m))?; |
|
| 104 | + | let r = crate::web::releases::api_views(&app, &repo, &row) |
|
| 105 | + | .await? |
|
| 106 | + | .into_iter() |
|
| 107 | + | .find(|r| r.tag == tag) |
|
| 108 | + | .ok_or_else(|| not_found("no such release"))?; |
|
| 109 | + | Ok((StatusCode::CREATED, Json(r))) |
|
| 110 | + | } |
|
| 111 | + | ||
| 53 | 112 | pub struct ApiError(StatusCode, String); |
|
| 54 | 113 | ||
| 55 | 114 | impl IntoResponse for ApiError { |
|
| … | 563 unchanged lines |
||
| 619 | 678 | if body.body.trim().is_empty() { |
|
| 620 | 679 | return Err(ApiError(StatusCode::BAD_REQUEST, "an empty comment says nothing".into())); |
|
| 621 | 680 | } |
|
| 622 | - | let row_c = db::proposals::add_comment(&app.db, p.id, Some(me.id), &me.username, body.body.trim(), "", None).await?; |
|
| 681 | + | let (repo, _) = (RepoName::new(&owner, &name).ok_or_else(|| not_found("no such repository"))?, ()); |
|
| 682 | + | let anchor = match (body.path.as_deref().map(|x| x.trim_matches('/')), body.line) { |
|
| 683 | + | (Some(path), Some(line)) if !path.is_empty() => { |
|
| 684 | + | let loaded = crate::web::proposals::load(&app, &repo, p.clone()).await?; |
|
| 685 | + | let (heads, file) = (loaded.heads.clone(), path.to_string()); |
|
| 686 | + | let atom = app |
|
| 687 | + | .repos |
|
| 688 | + | .read(&repo, move |r, cache| { |
|
| 689 | + | let projected = cache.load(r, &heads)?; |
|
| 690 | + | Ok(crate::repos::proposals::atom_at(&projected, &file, line).map(|(h, i)| (h.to_hex(), i))) |
|
| 691 | + | }) |
|
| 692 | + | .await? |
|
| 693 | + | .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, format!("{path}:{line} is not a line of the projected state")))?; |
|
| 694 | + | Some((path.to_string(), atom)) |
|
| 695 | + | } |
|
| 696 | + | _ => None, |
|
| 697 | + | }; |
|
| 698 | + | let row_c = db::proposals::add_comment( |
|
| 699 | + | &app.db, |
|
| 700 | + | p.id, |
|
| 701 | + | Some(me.id), |
|
| 702 | + | &me.username, |
|
| 703 | + | body.body.trim(), |
|
| 704 | + | anchor.as_ref().map(|(path, _)| path.as_str()).unwrap_or(""), |
|
| 705 | + | anchor.as_ref().map(|(_, (h, i))| (h.as_str(), *i as i64)), |
|
| 706 | + | ) |
|
| 707 | + | .await?; |
|
| 623 | 708 | crate::notify::deliver( |
|
| 624 | 709 | &app, |
|
| 625 | 710 | crate::notify::Event { |
|
| … | 428 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 5 unchanged lines |
||
| 6 | 6 | pub mod issues; |
|
| 7 | 7 | pub mod notify; |
|
| 8 | 8 | pub mod proposals; |
|
| 9 | + | pub mod releases; |
|
| 9 | 10 | pub mod repos; |
|
| 10 | 11 | ||
| 11 | 12 | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; |
|
| … | 23 unchanged lines |
modified crates/hub/src/db/repos.rs +14 −0
| … | 13 unchanged lines |
||
| 14 | 14 | pub visibility: String, |
|
| 15 | 15 | pub created_ms: i64, |
|
| 16 | 16 | pub updated_ms: i64, |
|
| 17 | + | #[sqlx(default)] |
|
| 18 | + | pub git_mirror: bool, |
|
| 19 | + | #[sqlx(default)] |
|
| 20 | + | pub git_mirror_state: String, |
|
| 17 | 21 | } |
|
| 18 | 22 | ||
| 19 | 23 | impl RepoRow { |
|
| … | 116 unchanged lines |
||
| 136 | 140 | .fetch_all(pool) |
|
| 137 | 141 | .await |
|
| 138 | 142 | } |
|
| 143 | + | ||
| 144 | + | pub async fn set_git_mirror(pool: &Pool, id: i64, on: bool) -> sqlx::Result<()> { |
|
| 145 | + | sqlx::query("UPDATE repositories SET git_mirror = ? WHERE id = ?").bind(on).bind(id).execute(pool).await?; |
|
| 146 | + | Ok(()) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | pub async fn set_git_mirror_state(pool: &Pool, id: i64, state: &str) -> sqlx::Result<()> { |
|
| 150 | + | sqlx::query("UPDATE repositories SET git_mirror_state = ? WHERE id = ?").bind(state).bind(id).execute(pool).await?; |
|
| 151 | + | Ok(()) |
|
| 152 | + | } |
modified crates/hub/src/lib.rs +1 −0
| … | 9 unchanged lines |
||
| 10 | 10 | pub mod events; |
|
| 11 | 11 | pub mod hooks; |
|
| 12 | 12 | pub mod keys; |
|
| 13 | + | pub mod mirror; |
|
| 13 | 14 | pub mod notify; |
|
| 14 | 15 | pub mod protocol; |
|
| 15 | 16 | pub mod render; |
|
| … | 81 unchanged lines |
modified crates/hub/src/protocol.rs +3 −0
| … | 177 unchanged lines |
||
| 178 | 178 | let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()); |
|
| 179 | 179 | if let Some(app) = app { |
|
| 180 | 180 | crate::web::issues::close_from_messages(&app, &row, &messages).await; |
|
| 181 | + | if results.iter().any(|r| r.ok && r.reference == row.default_bookmark) { |
|
| 182 | + | crate::mirror::refresh(&app, &row).await; |
|
| 183 | + | } |
|
| 181 | 184 | let accepted: Vec<serde_json::Value> = results |
|
| 182 | 185 | .iter() |
|
| 183 | 186 | .filter(|r| r.ok) |
|
| … | 36 unchanged lines |
modified crates/hub/src/repos/mod.rs +4 −1
| … | 4 unchanged lines |
||
| 5 | 5 | //! the repository's lock (§16.2): pushes and other writes take the write |
|
| 6 | 6 | //! half, page reads the read half. |
|
| 7 | 7 | ||
| 8 | + | pub mod archive; |
|
| 8 | 9 | pub mod cache; |
|
| 9 | 10 | pub mod edits; |
|
| 10 | 11 | pub mod names; |
|
| 11 | 12 | pub mod proposals; |
|
| 13 | + | pub mod search; |
|
| 12 | 14 | pub mod view; |
|
| 13 | 15 | ||
| 14 | 16 | pub use cache::{Loaded, StateCache}; |
|
| … | 10 unchanged lines |
||
| 25 | 27 | root: PathBuf, |
|
| 26 | 28 | locks: Mutex<HashMap<String, Arc<RwLock<()>>>>, |
|
| 27 | 29 | cache: Arc<StateCache>, |
|
| 30 | + | pub searches: search::SearchCache, |
|
| 28 | 31 | } |
|
| 29 | 32 | ||
| 30 | 33 | #[derive(Debug, thiserror::Error)] |
|
| … | 12 unchanged lines |
||
| 43 | 46 | ||
| 44 | 47 | impl Repos { |
|
| 45 | 48 | pub fn new(root: PathBuf) -> Repos { |
|
| 46 | - | Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)) } |
|
| 49 | + | Repos { root, locks: Mutex::new(HashMap::new()), cache: Arc::new(StateCache::new(64)), searches: search::SearchCache::default() } |
|
| 47 | 50 | } |
|
| 48 | 51 | ||
| 49 | 52 | pub fn cache(&self) -> &Arc<StateCache> { |
|
| … | 111 unchanged lines |
modified crates/hub/src/web/edit.rs +1 −0
| … | 224 unchanged lines |
||
| 225 | 225 | crate::hooks::emit(&app, &row, "push", &me.account.username, serde_json::json!({ "updates": [{ "reference": done.bookmark, "old_state": done.old_state, "new_state": done.new_state, "heads": done.heads.len(), "changes": 1 }], "via": "web" })).await; |
|
| 226 | 226 | if done.bookmark == row.default_bookmark { |
|
| 227 | 227 | super::issues::close_from_messages(&app, &row, &[(done.change.to_hex(), message.clone())]).await; |
|
| 228 | + | crate::mirror::refresh(&app, &row).await; |
|
| 228 | 229 | } |
|
| 229 | 230 | accounts::audit( |
|
| 230 | 231 | &app.db, |
|
| … | 23 unchanged lines |
modified crates/hub/src/web/mod.rs +75 −0
| … | 12 unchanged lines |
||
| 13 | 13 | mod orgs; |
|
| 14 | 14 | pub mod proposals; |
|
| 15 | 15 | mod refs; |
|
| 16 | + | pub mod releases; |
|
| 16 | 17 | mod repo; |
|
| 17 | 18 | mod repo_settings; |
|
| 18 | 19 | mod session; |
|
| … | 90 unchanged lines |
||
| 109 | 110 | .route("/{owner}/{name}/edit/{r}", post(edit::save)) |
|
| 110 | 111 | .route("/{owner}/{name}/edit/{r}/{*path}", get(edit::edit_form)) |
|
| 111 | 112 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 113 | + | .route("/{owner}/{name}/releases", get(releases::list)) |
|
| 114 | + | .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create)) |
|
| 115 | + | .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show)) |
|
| 116 | + | .route("/{owner}/{name}/releases/edit/{id}", post(releases::edit)) |
|
| 117 | + | .route( |
|
| 118 | + | "/{owner}/{name}/releases/upload/{id}", |
|
| 119 | + | post(releases::upload).layer(DefaultBodyLimit::max(app.config.limits.asset_limit.0 as usize + (1 << 20))), |
|
| 120 | + | ) |
|
| 121 | + | .route("/{owner}/{name}/asset/{id}/{file}", get(releases::download)) |
|
| 122 | + | .route("/{owner}/{name}/archive", get(releases::source_archive)) |
|
| 123 | + | .route("/{owner}/{name}/search", get(repo::code_search)) |
|
| 124 | + | .route("/{owner}/{name}/info/{*rest}", any(git_info)) |
|
| 125 | + | .route("/{owner}/{name}/HEAD", get(git_head)) |
|
| 126 | + | .route("/{owner}/{name}/objects/{*rest}", get(git_objects)) |
|
| 127 | + | .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack)) |
|
| 128 | + | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 129 | + | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 112 | 130 | .route("/{owner}/{name}/issues", get(issues::list)) |
|
| 113 | 131 | .route("/{owner}/{name}/issues/new", get(issues::new_form).post(issues::create)) |
|
| 114 | 132 | .route("/{owner}/{name}/issue/{number}", get(issues::show)) |
|
| … | 15 unchanged lines |
||
| 130 | 148 | .with_state(app) |
|
| 131 | 149 | } |
|
| 132 | 150 | ||
| 151 | + | // Git's smart HTTP paths under `/{owner}/{name}.git/`, forwarded to the |
|
| 152 | + | // mirror (hub spec §14). |
|
| 153 | + | async fn git_info( |
|
| 154 | + | st: axum::extract::State<Shared>, |
|
| 155 | + | axum::extract::Path((o, n, rest)): axum::extract::Path<(String, String, String)>, |
|
| 156 | + | m: axum::http::Method, |
|
| 157 | + | q: axum::extract::RawQuery, |
|
| 158 | + | h: axum::http::HeaderMap, |
|
| 159 | + | b: axum::body::Bytes, |
|
| 160 | + | ) -> Response { |
|
| 161 | + | crate::mirror::serve(st, axum::extract::Path((o, n, format!("info/{rest}"))), m, q, h, b).await |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | async fn git_head( |
|
| 165 | + | st: axum::extract::State<Shared>, |
|
| 166 | + | axum::extract::Path((o, n)): axum::extract::Path<(String, String)>, |
|
| 167 | + | m: axum::http::Method, |
|
| 168 | + | q: axum::extract::RawQuery, |
|
| 169 | + | h: axum::http::HeaderMap, |
|
| 170 | + | b: axum::body::Bytes, |
|
| 171 | + | ) -> Response { |
|
| 172 | + | crate::mirror::serve(st, axum::extract::Path((o, n, "HEAD".into())), m, q, h, b).await |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | async fn git_objects( |
|
| 176 | + | st: axum::extract::State<Shared>, |
|
| 177 | + | axum::extract::Path((o, n, rest)): axum::extract::Path<(String, String, String)>, |
|
| 178 | + | m: axum::http::Method, |
|
| 179 | + | q: axum::extract::RawQuery, |
|
| 180 | + | h: axum::http::HeaderMap, |
|
| 181 | + | b: axum::body::Bytes, |
|
| 182 | + | ) -> Response { |
|
| 183 | + | crate::mirror::serve(st, axum::extract::Path((o, n, format!("objects/{rest}"))), m, q, h, b).await |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | async fn git_upload_pack( |
|
| 187 | + | st: axum::extract::State<Shared>, |
|
| 188 | + | axum::extract::Path((o, n)): axum::extract::Path<(String, String)>, |
|
| 189 | + | m: axum::http::Method, |
|
| 190 | + | q: axum::extract::RawQuery, |
|
| 191 | + | h: axum::http::HeaderMap, |
|
| 192 | + | b: axum::body::Bytes, |
|
| 193 | + | ) -> Response { |
|
| 194 | + | crate::mirror::serve(st, axum::extract::Path((o, n, "git-upload-pack".into())), m, q, h, b).await |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | async fn git_receive_pack( |
|
| 198 | + | st: axum::extract::State<Shared>, |
|
| 199 | + | axum::extract::Path((o, n)): axum::extract::Path<(String, String)>, |
|
| 200 | + | m: axum::http::Method, |
|
| 201 | + | q: axum::extract::RawQuery, |
|
| 202 | + | h: axum::http::HeaderMap, |
|
| 203 | + | b: axum::body::Bytes, |
|
| 204 | + | ) -> Response { |
|
| 205 | + | crate::mirror::serve(st, axum::extract::Path((o, n, "git-receive-pack".into())), m, q, h, b).await |
|
| 206 | + | } |
|
| 207 | + | ||
| 133 | 208 | /// Cross-site form posts are refused (hub spec §15.2): a browser that |
|
| 134 | 209 | /// sends `Origin` or `Sec-Fetch-Site` must name this site. Protocol and |
|
| 135 | 210 | /// API clients send neither. |
|
| … | 279 unchanged lines |
modified crates/hub/src/web/repo.rs +86 −1
| … | 17 unchanged lines |
||
| 18 | 18 | o: view::Overview, |
|
| 19 | 19 | readme_html: Option<String>, |
|
| 20 | 20 | watching: bool, |
|
| 21 | + | latest_release: Option<crate::db::releases::Release>, |
|
| 21 | 22 | } |
|
| 22 | 23 | ||
| 23 | 24 | pub async fn overview( |
|
| … | 12 unchanged lines |
||
| 36 | 37 | Some(a) => crate::db::notify::watching(&app.db, a.id, row.id).await.unwrap_or(false), |
|
| 37 | 38 | None => false, |
|
| 38 | 39 | }; |
|
| 39 | - | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching })) |
|
| 40 | + | let latest_release = crate::db::releases::latest(&app.db, row.id).await.ok().flatten(); |
|
| 41 | + | Ok(Page(OverviewPage { shell, repo: rs, o, readme_html, watching, latest_release })) |
|
| 40 | 42 | } |
|
| 41 | 43 | ||
| 42 | 44 | impl view::Overview { |
|
| … | 194 unchanged lines |
||
| 237 | 239 | } |
|
| 238 | 240 | } |
|
| 239 | 241 | } |
|
| 242 | + | ||
| 243 | + | #[derive(Deserialize)] |
|
| 244 | + | pub struct CodeQuery { |
|
| 245 | + | #[serde(default)] |
|
| 246 | + | q: String, |
|
| 247 | + | #[serde(default, rename = "ref")] |
|
| 248 | + | r: Option<String>, |
|
| 249 | + | #[serde(default)] |
|
| 250 | + | path: String, |
|
| 251 | + | #[serde(default)] |
|
| 252 | + | re: Option<String>, |
|
| 253 | + | #[serde(default)] |
|
| 254 | + | case: Option<String>, |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | #[derive(Template)] |
|
| 258 | + | #[template(path = "code_search.html")] |
|
| 259 | + | pub struct CodeSearchPage { |
|
| 260 | + | shell: Shell, |
|
| 261 | + | repo: RepoShell, |
|
| 262 | + | q: String, |
|
| 263 | + | r: String, |
|
| 264 | + | path: String, |
|
| 265 | + | regex: bool, |
|
| 266 | + | case: bool, |
|
| 267 | + | results: crate::repos::search::Results, |
|
| 268 | + | error: Option<String>, |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /// `GET /{owner}/{name}/search?q=…&ref=…&re=1&case=1&path=…` (hub spec §11). |
|
| 272 | + | pub async fn code_search( |
|
| 273 | + | State(app): State<Shared>, |
|
| 274 | + | viewer: Viewer, |
|
| 275 | + | Path((owner, name)): Path<(String, String)>, |
|
| 276 | + | Query(q): Query<CodeQuery>, |
|
| 277 | + | ) -> Result<Page<CodeSearchPage>, Error> { |
|
| 278 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/search"), &viewer); |
|
| 279 | + | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 280 | + | let r = q.r.clone().filter(|x| !x.is_empty()).unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 281 | + | let (regex, case) = (q.re.is_some(), q.case.is_some()); |
|
| 282 | + | let mut results = crate::repos::search::Results::default(); |
|
| 283 | + | let mut error = None; |
|
| 284 | + | if !q.q.trim().is_empty() { |
|
| 285 | + | let (text, spec, prefix) = (q.q.clone(), r.clone(), q.path.trim_matches('/').to_string()); |
|
| 286 | + | let app2 = app.clone(); |
|
| 287 | + | let out = app |
|
| 288 | + | .repos |
|
| 289 | + | .read(&repo, move |repo, cache| { |
|
| 290 | + | let target = view::resolve(repo, cache, &spec)?; |
|
| 291 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 292 | + | let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex(); |
|
| 293 | + | let key = format!("{state}\0{text}\0{regex}\0{case}\0{prefix}"); |
|
| 294 | + | if let Some(hit) = app2.repos.searches.get(&key) { |
|
| 295 | + | return Ok(Ok(hit)); |
|
| 296 | + | } |
|
| 297 | + | let res = crate::repos::search::run( |
|
| 298 | + | &loaded, |
|
| 299 | + | &crate::repos::search::Query { text: &text, regex, case_sensitive: case, path_prefix: &prefix }, |
|
| 300 | + | ); |
|
| 301 | + | if let Ok(r) = &res { |
|
| 302 | + | app2.repos.searches.put(key, r.clone()); |
|
| 303 | + | } |
|
| 304 | + | Ok(res) |
|
| 305 | + | }) |
|
| 306 | + | .await |
|
| 307 | + | .map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 308 | + | match out { |
|
| 309 | + | Ok(r) => results = r, |
|
| 310 | + | Err(e) => error = Some(e), |
|
| 311 | + | } |
|
| 312 | + | } |
|
| 313 | + | Ok(Page(CodeSearchPage { |
|
| 314 | + | shell, |
|
| 315 | + | repo: RepoShell::new(&app, &row, "code").with_role(role), |
|
| 316 | + | q: q.q, |
|
| 317 | + | r, |
|
| 318 | + | path: q.path, |
|
| 319 | + | regex, |
|
| 320 | + | case, |
|
| 321 | + | results, |
|
| 322 | + | error, |
|
| 323 | + | })) |
|
| 324 | + | } |
modified crates/hub/src/web/repo_settings.rs +30 −0
| … | 422 unchanged lines |
||
| 423 | 423 | db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 424 | 424 | settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await |
|
| 425 | 425 | } |
|
| 426 | + | ||
| 427 | + | #[derive(Deserialize)] |
|
| 428 | + | pub struct MirrorForm { |
|
| 429 | + | #[serde(rename = "_csrf")] |
|
| 430 | + | csrf_token: String, |
|
| 431 | + | #[serde(default)] |
|
| 432 | + | enabled: Option<String>, |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | pub async fn mirror( |
|
| 436 | + | State(app): State<Shared>, |
|
| 437 | + | viewer: Viewer, |
|
| 438 | + | Path((owner, name)): Path<(String, String)>, |
|
| 439 | + | Form(f): Form<MirrorForm>, |
|
| 440 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 441 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 442 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 443 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 444 | + | db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 445 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 446 | + | if row.git_mirror { |
|
| 447 | + | crate::mirror::refresh(&app, &row).await; |
|
| 448 | + | } |
|
| 449 | + | accounts::audit(&app.db, viewer.account(), "repo.mirror", &format!("{owner}/{name}"), if row.git_mirror { "on" } else { "off" }) |
|
| 450 | + | .await |
|
| 451 | + | .ok(); |
|
| 452 | + | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 453 | + | let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." }; |
|
| 454 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 455 | + | } |
modified docs/plan.md +14 −1
| … | 62 unchanged lines |
||
| 63 | 63 | - [x] Tests: `tests/api.rs` with a local webhook receiver that fails the first attempt. |
|
| 64 | 64 | - [ ] Organisation webhooks page (the API and delivery path handle them; no settings page yet). |
|
| 65 | 65 | ||
| 66 | + | ## 0.6 Web edits, releases, mirror, code search (2026-09-27) |
|
| 67 | + | ||
| 68 | + | - [x] Oxygen: `Repo::build_change`, `host_ref_update`, `apply_hosted`; delegates of a policy rule's identities may sign reference and tag attestations, on push and on fetch (spec §14.5, Draft 0.8g). |
|
| 69 | + | - [x] Web edits: edit, create, rename and delete files with CodeMirror 6 (bundled from npm in `web/editor`, served from `assets/vendor`); EasyMDE for Markdown fields; commit to the bookmark or propose a new one. Authored by the user's linked identity, signed by the hub key as its delegate, applied through the push checks; refused with instructions until the user delegates. |
|
| 70 | + | - [x] Browser conflict resolution: conflicted text files open with their markers; a save without markers is a resolution change depending on every side. |
|
| 71 | + | - [x] Hub landing under a policy through the delegate key. |
|
| 72 | + | - [x] Releases on tags: generated notes from the changes since the previous release, pre-releases, the latest marker, assets stored by BLAKE3 with download counts, source archives (`.tar.zst`, `.zip`) of any state built once and cached; API to list, create and upload; webhook `release.published`. |
|
| 73 | + | - [x] Read-only Git mirror of the default bookmark: exported with `oxy git export` after every update, served by `git http-backend` behind the hub's access checks (Basic auth with a token for private repositories), pushes refused. |
|
| 74 | + | - [x] Code search per repository: substring or regex, match case, path prefix, a 2 s budget and 300-hit cap, cached per state. |
|
| 75 | + | - [x] API: anchored proposal comments (`path`, `line`). |
|
| 76 | + | - [x] Tests: `tests/edits.rs`, `tests/releases.rs` (real `git clone` and `git push` against the mirror). |
|
| 77 | + | - [ ] Organisation webhooks page; import from GitHub and GitLab; trigram code index (1.x). |
|
| 78 | + | ||
| 66 | 79 | ## Later |
|
| 67 | 80 | ||
| 68 | - | - 0.6 web edits, releases, mirror · 0.7 runner · 1.0 hardening. |
|
| 81 | + | - 0.7 runner · 1.0 hardening. |
|
| 69 | 82 | ||
| 70 | 83 | ## Notes |
|
| 71 | 84 | ||
| … | 8 unchanged lines |
modified templates/overview.html +2 −0
| … | 12 unchanged lines |
||
| 13 | 13 | <span class="ref px"><a href="{{ repo.url }}/bookmarks">◆ {{ repo.default_bookmark }}</a></span> |
|
| 14 | 14 | <a class="go small" href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">{{ o.changes|plural("change", "changes") }}</a> |
|
| 15 | 15 | {% if repo.can(crate::auth::Access::Write) %}<a class="go small" href="{{ repo.url }}/newfile/{{ repo.default_bookmark }}">new file</a>{% endif %} |
|
| 16 | + | <form class="find inline-find" method="get" action="{{ repo.url }}/search"><input type="search" name="q" placeholder="Search code…"></form> |
|
| 16 | 17 | {% if let Some(c) = o.latest %}<span class="small mute">latest <a href="{{ repo.url }}/change/{{ c.hash }}">{{ c.title }}</a> · {{ c.when_ms|ago }}</span>{% endif %} |
|
| 17 | 18 | </div> |
|
| 18 | 19 | {% include "tree_table.html" %} |
|
| … | 9 unchanged lines |
||
| 28 | 29 | <div class="clone"><code>oxy clone {{ repo.clone_url }}</code> <button class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div> |
|
| 29 | 30 | <h3>Bookmarks</h3> |
|
| 30 | 31 | <ul class="plain">{% for b in o.bookmarks %}<li><a href="{{ repo.url }}/tree/{{ b.name }}">{{ b.name }}</a>{% if b.heads.len() > 1 %} <span class="tag warn">{{ b.heads.len() }} heads</span>{% endif %}{% if b.conflicted %} <span class="tag bad">conflicts</span>{% endif %}</li>{% endfor %}</ul> |
|
| 32 | + | {% if let Some(rel) = latest_release %}<h3>Latest release</h3><p><a href="{{ repo.url }}/releases/tag/{{ rel.tag }}">{{ rel.title }}</a> <span class="small mute">{{ rel.created_ms|ago_i }}</span></p>{% endif %} |
|
| 31 | 33 | <h3>Tags</h3> |
|
| 32 | 34 | {% if o.tags.is_empty() %}<p class="mute small">None.</p>{% else %}<ul class="plain">{% for t in o.tags %}<li><a href="{{ repo.url }}/tree/{{ t.name }}">{{ t.name }}</a>{% if t.signer.is_some() %} <span class="sig sig-valid" title="signed">✔</span>{% endif %}</li>{% endfor %}</ul>{% endif %} |
|
| 33 | 35 | <h3>Trust</h3> |
|
| … | 9 unchanged lines |
modified templates/repo_settings.html +7 −0
| … | 29 unchanged lines |
||
| 30 | 30 | </div> |
|
| 31 | 31 | <button type="submit">SAVE</button> |
|
| 32 | 32 | </form> |
|
| 33 | + | <form class="card wide" method="post" action="{{ repo.url }}/settings/mirror"> |
|
| 34 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 35 | + | <h2>Git mirror</h2> |
|
| 36 | + | <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if row.git_mirror %} checked{% endif %}> keep a read-only Git mirror of <strong>{{ row.default_bookmark }}</strong></label></div> |
|
| 37 | + | <p class="small mute">For tools that only speak Git (CI runners, IDEs): <code>git clone {{ shell.base_url }}{{ repo.url }}.git</code>. Private repositories take a personal access token as the password. Pushing to the mirror is refused.{% if row.git_mirror && !row.git_mirror_state.is_empty() %} Last exported state <code>{{ row.git_mirror_state|short }}</code>.{% endif %}</p> |
|
| 38 | + | <button type="submit">SAVE</button> |
|
| 39 | + | </form> |
|
| 33 | 40 | <form class="card wide" method="post" action="{{ repo.url }}/settings/checks"> |
|
| 34 | 41 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 35 | 42 | <h2>Required checks</h2> |
|
| … | 51 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 6 unchanged lines |
||
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on"{% endif %}>Proposals</a> |
|
| 8 | 8 | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on"{% endif %}>Issues</a> |
|
| 9 | 9 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on"{% endif %}>Bookmarks</a> |
|
| 10 | + | <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on"{% endif %}>Releases</a> |
|
| 10 | 11 | <a href="{{ repo.url }}/tags"{% if tab == "tags" %} class="on"{% endif %}>Tags</a> |
|
| 11 | 12 | <a href="{{ repo.url }}/conflicts"{% if tab == "conflicts" %} class="on"{% endif %}>Conflicts</a> |
|
| 12 | 13 | <a href="{{ repo.url }}/trust"{% if tab == "trust" %} class="on"{% endif %}>Trust</a> |
|
| … | 49 unchanged lines |
modified templates/style.css +5 −0
| … | 295 unchanged lines |
||
| 296 | 296 | .editor-toolbar button.mde-btn { color: var(--ink); border: 0; background: transparent; font-weight: 600; font-size: 12px; text-transform: none; letter-spacing: 0; padding: 2px 6px; width: auto; } |
|
| 297 | 297 | .editor-toolbar button.mde-btn:hover, .editor-toolbar button.active { background: var(--panel); color: var(--acc); } |
|
| 298 | 298 | .editor-preview, .editor-preview-side { background: var(--panel); color: var(--ink); } |
|
| 299 | + | ||
| 300 | + | /* code search */ |
|
| 301 | + | .inline-find { flex: 0 1 220px; } |
|
| 302 | + | .hits .hit { margin: 0; padding: 4px 8px; border-bottom: 1px solid var(--line); background: var(--panel); font-size: 13px; overflow-x: auto; white-space: nowrap; } |
|
| 303 | + | .hits .hit code { background: none; } |