Drops: per-user gists, each a small hidden Oxygen repository (#279) 166f6e9bf4f1
A drop is one or more files kept as an Oxygen repository of its own,
named drops/ID under its owner (24 random hex digits, unique on the hub):
its page is /{owner}/drops/{id} and `oxy clone` takes that address, the
wire protocol answering at /{owner}/drops/{id}/oxy/v1/. `drops` is now a
reserved owner and repository name. repositories gains kind ('repo' or
'drop') and title (migration 0087, SQLite and Postgres).
- Never among repositories: db::repos::list, list_by_owner, forks_of and
visible_repo_ids leave drops out (profiles, dashboard, explore, search,
GET /api/v1/repos, backups, Diffusion, the Flow scheduler); upkeep uses
list_with_drops (gc, history scan, check, admin repo list, Admin ›
Storage, which labels them). The API's /repos/... is 404 for a drop,
and a drop's repository pages are read-only.
- Visibility: public, or secret (stored private, listed only to its owner
and site admins, readable by anyone with the address on the web, API
and protocol; /log hides a secret drop's name).
- Pages: /drops, /{owner}/drops, /drops/new, the drop page with files
rendered, edit and delete; history, changes, raw and tree reuse the
repository pages with the drop's own bar. hub.js adds/removes file rows.
- Changes from the web and the API are made by the hub as the owner's
delegate (web edits' mechanism), after push protection's rules; drop
pushes are push-protected with a drop-specific refusal. Flow, webhooks
and issue closing never run for drops. Only the owner edits; the owner
or a site admin deletes (exclusive lock, trash, row).
- API: GET/POST /drops, GET /users/{name}/drops, GET/PATCH/DELETE
/drops/{id}; hub_api Drop, DropFile, NewDrop, DropEdit (ts regenerated).
- oh drop create|list|view|delete; oh understands a drop clone's remote.
- Spec §4.5, oh.md, README, changelog. Tests: tests/drops.rs, oh parse
and CLI tests.
Closes #279
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 45a6a4c1e0c5
- When
- · 2026-10-20 15:39 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
166f6e9bf4f134de4e0de65a2d4453aa82c04cbcff14f3854bb7e8a7ee361182- Depends on
- 07b1a5bbf481 0ee5c3fb32eb 101740454617 119fd17d213f 145306044896 16ee5d19d220 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 1e8a6e8bb91b 20b8fe85888b 2a0c09c09716 3b1bc27fe805 3f4b0ea6eabf 42fa95540468 5533e91a2aa4 5c6e92fd8d71 5d90e0e045e2 6ae41081ccd3 756fe44a0f4d 757195ad7a5d 7f283f8e8b97 85ec74fd82d8 8a3334b7a364 8aa5ef384ef1 8fb35df7d66e 9008bf6c2b8c 92574625ee9a 92a797b20912 93be9ce4653e 965b950f9b96 99ca93d60bca a7aa76ac52d7 a7e6e8e6094c b5668108487f b64e2537d702 bf1ab3458db1 c9aacc0e7dc1 cb7b3cd45dc0 ced6d3afc5cf d0fbea2eb46e d23464c0ac60 d7d182467382 df71c7892d27 e8fa53694f68 e97500b70f9a e9bfea8e26db ea55fb62f919 f677d6270298 ffb67a81188c
- Depended on by
- 1c52d940b4a0 b7e1be264c0a 041a945f55f8 04f9c984439e fac6853c4e46 1ec29691b67a 15d3f6f7871b 84e3c09656b2 5a9a6e9a15bc b951c58734e8 9bbca80f777a 8501fd5815b9 ba0a55bb5b3c 8ac2aa4ca6d1 ca0c443c30cf c419455a82dd adbc38e7ea09 9041db4e4d92 8bf3010760d7 a549ddd5941f d366306dae7d
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- 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
- 108 atoms, 9 tree operations, 43 files
Browse the tree at this change
added crates/hub/migrations-pg/0087_drops.sql +8 −0
| 1 | + | -- Drops (oxygenhub#279): a drop is a small repository of its own, named |
|
| 2 | + | -- `drops/ID` under its owner, that no repository list shows; `kind` tells |
|
| 3 | + | -- it from a repository and `title` is its name on the Drops page (the |
|
| 4 | + | -- description column holds its description). A secret drop is private to |
|
| 5 | + | -- every list and readable by anyone with its address. |
|
| 6 | + | ALTER TABLE repositories ADD COLUMN kind TEXT NOT NULL DEFAULT 'repo'; |
|
| 7 | + | ALTER TABLE repositories ADD COLUMN title TEXT NOT NULL DEFAULT ''; |
|
| 8 | + | CREATE INDEX repositories_kind_owner ON repositories(kind, owner); |
added crates/hub/migrations/0087_drops.sql +8 −0
| 1 | + | -- Drops (oxygenhub#279): a drop is a small repository of its own, named |
|
| 2 | + | -- `drops/ID` under its owner, that no repository list shows; `kind` tells |
|
| 3 | + | -- it from a repository and `title` is its name on the Drops page (the |
|
| 4 | + | -- description column holds its description). A secret drop is private to |
|
| 5 | + | -- every list and readable by anyone with its address. |
|
| 6 | + | ALTER TABLE repositories ADD COLUMN kind TEXT NOT NULL DEFAULT 'repo'; |
|
| 7 | + | ALTER TABLE repositories ADD COLUMN title TEXT NOT NULL DEFAULT ''; |
|
| 8 | + | CREATE INDEX repositories_kind_owner ON repositories(kind, owner); |
added crates/hub/src/api/drops.rs +169 −0
| 1 | + | //! Drops in the API (oxygenhub#279): `GET`/`POST /drops` (yours; making |
|
| 2 | + | //! one), `GET /users/{name}/drops` (someone's public ones), |
|
| 3 | + | //! `GET`/`PATCH`/`DELETE /drops/{id}`. Reading a drop needs nothing but |
|
| 4 | + | //! its id; changing one needs `repo:write` and to be its owner (or a site |
|
| 5 | + | //! administrator, who may also delete it). |
|
| 6 | + | ||
| 7 | + | use super::{caller, not_found, ApiError}; |
|
| 8 | + | use crate::db; |
|
| 9 | + | use crate::drops::{self, DropError, DropFile, NewFile}; |
|
| 10 | + | use crate::Shared; |
|
| 11 | + | use axum::extract::{Path, State}; |
|
| 12 | + | use axum::http::{HeaderMap, StatusCode}; |
|
| 13 | + | use axum::Json; |
|
| 14 | + | use hub_api::{Drop, DropEdit, NewDrop}; |
|
| 15 | + | ||
| 16 | + | impl From<DropError> for ApiError { |
|
| 17 | + | fn from(e: DropError) -> ApiError { |
|
| 18 | + | match e { |
|
| 19 | + | DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m), |
|
| 20 | + | DropError::Repo(e) => e.into(), |
|
| 21 | + | DropError::Db(e) => e.into(), |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | /// The API's shape of a drop, with its files' content when `content`. |
|
| 27 | + | fn shape(app: &Shared, row: &db::repos::RepoRow, files: &[DropFile], content: bool) -> Drop { |
|
| 28 | + | let base = app.config.base_url(); |
|
| 29 | + | let url = drops::url_of(row); |
|
| 30 | + | Drop { |
|
| 31 | + | id: drops::id_of(row).to_string(), |
|
| 32 | + | owner: row.owner.clone(), |
|
| 33 | + | title: row.title.clone(), |
|
| 34 | + | description: row.description.clone(), |
|
| 35 | + | visibility: drops::visibility_label(row).to_string(), |
|
| 36 | + | url: format!("{base}{url}"), |
|
| 37 | + | clone_url: format!("{base}{url}"), |
|
| 38 | + | created_ms: row.created_ms, |
|
| 39 | + | updated_ms: row.updated_ms, |
|
| 40 | + | files: files |
|
| 41 | + | .iter() |
|
| 42 | + | .map(|f| hub_api::DropFile { |
|
| 43 | + | name: f.path.clone(), |
|
| 44 | + | size: f.size, |
|
| 45 | + | content: if content { f.text.clone() } else { None }, |
|
| 46 | + | raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path), |
|
| 47 | + | }) |
|
| 48 | + | .collect(), |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | async fn listed(app: &Shared, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> { |
|
| 53 | + | let mut out = Vec::with_capacity(rows.len()); |
|
| 54 | + | for row in rows { |
|
| 55 | + | let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default(); |
|
| 56 | + | out.push(shape(app, &row, &files, false)); |
|
| 57 | + | } |
|
| 58 | + | Ok(out) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// `GET /drops`: the caller's drops, secret ones too. |
|
| 62 | + | pub(super) async fn mine(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Drop>>, ApiError> { |
|
| 63 | + | let c = caller(&app, &headers).await?; |
|
| 64 | + | let me = c.require("repo:read")?.clone(); |
|
| 65 | + | let rows = db::repos::drops_of(&app.db, &me.username).await?; |
|
| 66 | + | Ok(Json(listed(&app, rows).await?)) |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// `GET /users/{name}/drops`: someone's public drops (all of them to |
|
| 70 | + | /// themselves and to site administrators). |
|
| 71 | + | pub(super) async fn of_user(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Drop>>, ApiError> { |
|
| 72 | + | let c = caller(&app, &headers).await?; |
|
| 73 | + | let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?; |
|
| 74 | + | let rows: Vec<_> = |
|
| 75 | + | db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect(); |
|
| 76 | + | Ok(Json(listed(&app, rows).await?)) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | /// `POST /drops`. |
|
| 80 | + | pub(super) async fn create( |
|
| 81 | + | State(app): State<Shared>, |
|
| 82 | + | headers: HeaderMap, |
|
| 83 | + | Json(body): Json<NewDrop>, |
|
| 84 | + | ) -> Result<(StatusCode, Json<Drop>), ApiError> { |
|
| 85 | + | let c = caller(&app, &headers).await?; |
|
| 86 | + | let me = c.require("repo:write")?.clone(); |
|
| 87 | + | let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect(); |
|
| 88 | + | let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?; |
|
| 89 | + | let (_, files) = drops::files(&app, &row, "").await?; |
|
| 90 | + | Ok((StatusCode::CREATED, Json(shape(&app, &row, &files, true)))) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> { |
|
| 94 | + | if !crate::repos::names::valid_drop_id(id) { |
|
| 95 | + | return Err(not_found("no such drop")); |
|
| 96 | + | } |
|
| 97 | + | db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop")) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | /// `GET /drops/{id}`: anyone with the id, its files' content included. |
|
| 101 | + | pub(super) async fn view(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<Drop>, ApiError> { |
|
| 102 | + | let c = caller(&app, &headers).await?; |
|
| 103 | + | let row = row_of(&app, &id).await?; |
|
| 104 | + | if c.account.as_ref().is_some_and(|a| a.blocked()) { |
|
| 105 | + | return Err(not_found("no such drop")); |
|
| 106 | + | } |
|
| 107 | + | let (_, files) = drops::files(&app, &row, "").await?; |
|
| 108 | + | Ok(Json(shape(&app, &row, &files, true))) |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | /// The drop, when the caller may change it (its owner) or, `deleting`, |
|
| 112 | + | /// delete it (its owner or a site administrator); else 403. |
|
| 113 | + | async fn writable( |
|
| 114 | + | app: &Shared, |
|
| 115 | + | headers: &HeaderMap, |
|
| 116 | + | id: &str, |
|
| 117 | + | deleting: bool, |
|
| 118 | + | ) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> { |
|
| 119 | + | let c = caller(app, headers).await?; |
|
| 120 | + | let me = c.require("repo:write")?.clone(); |
|
| 121 | + | let row = row_of(app, id).await?; |
|
| 122 | + | let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) }; |
|
| 123 | + | if !may { |
|
| 124 | + | return Err(super::forbidden(if deleting { |
|
| 125 | + | "only the drop's owner or a site administrator may delete it" |
|
| 126 | + | } else { |
|
| 127 | + | "only the drop's owner may change it" |
|
| 128 | + | })); |
|
| 129 | + | } |
|
| 130 | + | Ok((me, row)) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | /// `PATCH /drops/{id}`. |
|
| 134 | + | pub(super) async fn edit( |
|
| 135 | + | State(app): State<Shared>, |
|
| 136 | + | headers: HeaderMap, |
|
| 137 | + | Path(id): Path<String>, |
|
| 138 | + | Json(body): Json<DropEdit>, |
|
| 139 | + | ) -> Result<Json<Drop>, ApiError> { |
|
| 140 | + | let (me, row) = writable(&app, &headers, &id, false).await?; |
|
| 141 | + | let mut seen = Vec::new(); |
|
| 142 | + | let mut files = Vec::with_capacity(body.files.len()); |
|
| 143 | + | for f in body.files { |
|
| 144 | + | let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?; |
|
| 145 | + | if seen.contains(&name) { |
|
| 146 | + | return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice."))); |
|
| 147 | + | } |
|
| 148 | + | seen.push(name.clone()); |
|
| 149 | + | files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) }); |
|
| 150 | + | } |
|
| 151 | + | let update = drops::Update { |
|
| 152 | + | title: body.title.unwrap_or_else(|| row.title.clone()), |
|
| 153 | + | description: body.description.unwrap_or_else(|| row.description.clone()), |
|
| 154 | + | secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public), |
|
| 155 | + | files, |
|
| 156 | + | message: body.message.unwrap_or_default(), |
|
| 157 | + | }; |
|
| 158 | + | drops::update(&app, &me, &row, update).await?; |
|
| 159 | + | let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?; |
|
| 160 | + | let (_, files) = drops::files(&app, &row, "").await?; |
|
| 161 | + | Ok(Json(shape(&app, &row, &files, true))) |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | /// `DELETE /drops/{id}`. |
|
| 165 | + | pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> { |
|
| 166 | + | let (me, row) = writable(&app, &headers, &id, true).await?; |
|
| 167 | + | drops::delete(&app, &me, &row).await?; |
|
| 168 | + | Ok(StatusCode::NO_CONTENT) |
|
| 169 | + | } |
added crates/hub/src/drops.rs +418 −0
| 1 | + | //! Drops (oxygenhub#279): small snippets of one or more files, as gists |
|
| 2 | + | //! are, each its own Oxygen repository. |
|
| 3 | + | //! |
|
| 4 | + | //! A drop is a repository named `drops/ID` under its owner, with |
|
| 5 | + | //! `repositories.kind = 'drop'`: stored, locked, cloned and pushed as any |
|
| 6 | + | //! repository is (`oxy clone https://HUB/OWNER/drops/ID`), but never |
|
| 7 | + | //! listed among repositories. Its id is 24 random hex digits, so a secret |
|
| 8 | + | //! drop's address cannot be guessed; a secret drop is `private` to every |
|
| 9 | + | //! list and readable by anyone who has the address (`auth::roles`). The |
|
| 10 | + | //! hub makes its changes as the owner's delegate, as web edits are made, |
|
| 11 | + | //! after the same push protection scan a push gets. |
|
| 12 | + | ||
| 13 | + | use crate::db::{self, accounts::Account, repos::RepoRow}; |
|
| 14 | + | use crate::repos::edits::{self, Signer}; |
|
| 15 | + | use crate::repos::{RepoError, RepoName}; |
|
| 16 | + | use crate::Shared; |
|
| 17 | + | use hub_api::Visibility; |
|
| 18 | + | use oxy_core::objects::NodeKind; |
|
| 19 | + | use oxy_core::repo::FileEdit; |
|
| 20 | + | use oxy_core::state::FileContent; |
|
| 21 | + | ||
| 22 | + | /// Most files a drop takes from the web or the API in one go. |
|
| 23 | + | pub const MAX_FILES: usize = 50; |
|
| 24 | + | /// Largest file the web or the API takes, in bytes. |
|
| 25 | + | pub const MAX_FILE_BYTES: usize = 1 << 20; |
|
| 26 | + | /// Longest title and description. |
|
| 27 | + | pub const MAX_TITLE: usize = 200; |
|
| 28 | + | pub const MAX_DESCRIPTION: usize = 2000; |
|
| 29 | + | /// Most text a drop page shows of one file; past it, the raw link. |
|
| 30 | + | const SHOWN_BYTES: u64 = 512 * 1024; |
|
| 31 | + | ||
| 32 | + | /// Why a drop was not made or changed. |
|
| 33 | + | #[derive(Debug)] |
|
| 34 | + | pub enum DropError { |
|
| 35 | + | /// What the person can fix, said to them. |
|
| 36 | + | Bad(String), |
|
| 37 | + | Repo(RepoError), |
|
| 38 | + | Db(sqlx::Error), |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl std::fmt::Display for DropError { |
|
| 42 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 43 | + | match self { |
|
| 44 | + | DropError::Bad(m) => f.write_str(m), |
|
| 45 | + | DropError::Repo(e) => write!(f, "{e}"), |
|
| 46 | + | DropError::Db(e) => write!(f, "{e}"), |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | impl From<sqlx::Error> for DropError { |
|
| 52 | + | fn from(e: sqlx::Error) -> DropError { |
|
| 53 | + | DropError::Db(e) |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | impl From<RepoError> for DropError { |
|
| 58 | + | fn from(e: RepoError) -> DropError { |
|
| 59 | + | DropError::Repo(e) |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | fn bad(msg: impl Into<String>) -> DropError { |
|
| 64 | + | DropError::Bad(msg.into()) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | /// `public` or `secret`, as people and the API say it. |
|
| 68 | + | pub fn visibility_label(row: &RepoRow) -> &'static str { |
|
| 69 | + | if row.visibility() == Visibility::Public { |
|
| 70 | + | "public" |
|
| 71 | + | } else { |
|
| 72 | + | "secret" |
|
| 73 | + | } |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | fn visibility(secret: bool) -> Visibility { |
|
| 77 | + | if secret { |
|
| 78 | + | Visibility::Private |
|
| 79 | + | } else { |
|
| 80 | + | Visibility::Public |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | /// The drop's id, from its row. |
|
| 85 | + | pub fn id_of(row: &RepoRow) -> &str { |
|
| 86 | + | row.name.split_once('/').map(|(_, id)| id).unwrap_or(&row.name) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// `/{owner}/drops/{id}`. |
|
| 90 | + | pub fn url_of(row: &RepoRow) -> String { |
|
| 91 | + | format!("/{}/{}", row.owner, row.name) |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | /// The title a page shows: its own, else its first file's name, else its id. |
|
| 95 | + | pub fn shown_title(row: &RepoRow, files: &[DropFile]) -> String { |
|
| 96 | + | if !row.title.is_empty() { |
|
| 97 | + | row.title.clone() |
|
| 98 | + | } else if let Some(f) = files.first() { |
|
| 99 | + | f.path.clone() |
|
| 100 | + | } else { |
|
| 101 | + | id_of(row).to_string() |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// One of a drop's files. |
|
| 106 | + | #[derive(Clone, Debug)] |
|
| 107 | + | pub struct DropFile { |
|
| 108 | + | pub path: String, |
|
| 109 | + | pub size: u64, |
|
| 110 | + | /// The text, when the file is text and not too large to show. |
|
| 111 | + | pub text: Option<String>, |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | /// A file as given from the web or the API: a name and its content. |
|
| 115 | + | #[derive(Clone, Debug)] |
|
| 116 | + | pub struct NewFile { |
|
| 117 | + | pub name: String, |
|
| 118 | + | pub content: String, |
|
| 119 | + | } |
|
| 120 | + | ||
| 121 | + | /// A file name: a path of plain parts, at most 255 bytes. |
|
| 122 | + | pub fn check_name(name: &str) -> Result<String, String> { |
|
| 123 | + | let name = name.trim().trim_matches('/'); |
|
| 124 | + | if name.is_empty() { |
|
| 125 | + | return Err("Every file needs a name.".into()); |
|
| 126 | + | } |
|
| 127 | + | if name.len() > 255 { |
|
| 128 | + | return Err(format!("{name:.40}…: a file name is at most 255 bytes.")); |
|
| 129 | + | } |
|
| 130 | + | if name.chars().any(|c| c.is_control() || c == '\\') |
|
| 131 | + | || name.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == oxy_core::repo::OXY_DIR || p.trim() != p) |
|
| 132 | + | { |
|
| 133 | + | return Err(format!("{name:?} is not a file name: use letters, digits and punctuation, with / between folders.")); |
|
| 134 | + | } |
|
| 135 | + | Ok(name.to_string()) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | /// Files from the web or the API, checked: named, not too many or too |
|
| 139 | + | /// large, no name twice, and nothing push protection refuses. |
|
| 140 | + | pub fn check_files(files: &[NewFile], allow_empty: bool) -> Result<Vec<NewFile>, String> { |
|
| 141 | + | if files.is_empty() && !allow_empty { |
|
| 142 | + | return Err("A drop needs at least one file.".into()); |
|
| 143 | + | } |
|
| 144 | + | if files.len() > MAX_FILES { |
|
| 145 | + | return Err(format!("A drop takes at most {MAX_FILES} files at a time.")); |
|
| 146 | + | } |
|
| 147 | + | let mut out: Vec<NewFile> = Vec::with_capacity(files.len()); |
|
| 148 | + | for f in files { |
|
| 149 | + | let name = check_name(&f.name)?; |
|
| 150 | + | if f.content.len() > MAX_FILE_BYTES { |
|
| 151 | + | return Err(format!("{name} is larger than {} KiB; push larger files with oxy.", MAX_FILE_BYTES / 1024)); |
|
| 152 | + | } |
|
| 153 | + | if out.iter().any(|o| o.name == name) { |
|
| 154 | + | return Err(format!("{name} is named twice.")); |
|
| 155 | + | } |
|
| 156 | + | // Line endings as the browser's editor shows them. |
|
| 157 | + | out.push(NewFile { name, content: f.content.replace("\r\n", "\n") }); |
|
| 158 | + | } |
|
| 159 | + | scan(&out)?; |
|
| 160 | + | Ok(out) |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | /// Push protection (#268) over what the hub is asked to commit: the same |
|
| 164 | + | /// rules a push gets, so a drop made on the web cannot carry what a push |
|
| 165 | + | /// would be refused for. |
|
| 166 | + | pub fn scan(files: &[NewFile]) -> Result<(), String> { |
|
| 167 | + | let rules = crate::push_protection::rules(); |
|
| 168 | + | let mut found = Vec::new(); |
|
| 169 | + | for f in files { |
|
| 170 | + | for h in crate::push_protection::scan_text(rules, f.content.as_bytes()) { |
|
| 171 | + | let rule = &rules.rules[h.rule]; |
|
| 172 | + | found.push(format!("{}:{}: {} (fingerprint {})", f.name, h.line, rule.description, h.fingerprint)); |
|
| 173 | + | } |
|
| 174 | + | } |
|
| 175 | + | if found.is_empty() { |
|
| 176 | + | return Ok(()); |
|
| 177 | + | } |
|
| 178 | + | Err(format!( |
|
| 179 | + | "Push protection refused this: {} like {}, and nothing was saved. {} Take {} out (read credentials from the environment or a secret store), and if {} real, revoke or rotate {}.", |
|
| 180 | + | if found.len() == 1 { "a value looks".to_string() } else { format!("{} values look", found.len()) }, |
|
| 181 | + | if found.len() == 1 { "a secret" } else { "secrets" }, |
|
| 182 | + | found.join("; "), |
|
| 183 | + | if found.len() == 1 { "it" } else { "them" }, |
|
| 184 | + | if found.len() == 1 { "it is" } else { "one is" }, |
|
| 185 | + | if found.len() == 1 { "it" } else { "it as well" }, |
|
| 186 | + | )) |
|
| 187 | + | } |
|
| 188 | + | ||
| 189 | + | fn check_text(title: &str, description: &str) -> Result<(), String> { |
|
| 190 | + | if title.chars().count() > MAX_TITLE { |
|
| 191 | + | return Err(format!("A title is at most {MAX_TITLE} characters.")); |
|
| 192 | + | } |
|
| 193 | + | if description.chars().count() > MAX_DESCRIPTION { |
|
| 194 | + | return Err(format!("A description is at most {MAX_DESCRIPTION} characters.")); |
|
| 195 | + | } |
|
| 196 | + | Ok(()) |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | /// The owner's signer: their identity that delegates to the hub's key. |
|
| 200 | + | async fn signer(app: &Shared, account: &Account) -> Result<Signer, DropError> { |
|
| 201 | + | crate::web::edit::signer_for(app, account.id).await.map_err(DropError::Bad) |
|
| 202 | + | } |
|
| 203 | + | ||
| 204 | + | /// Make a drop for `account`: a new repository with the files as its first |
|
| 205 | + | /// change on `main`, authored by the account's identity and signed by the |
|
| 206 | + | /// hub as its delegate. Nothing is left behind when that fails. |
|
| 207 | + | pub async fn create( |
|
| 208 | + | app: &Shared, |
|
| 209 | + | account: &Account, |
|
| 210 | + | title: &str, |
|
| 211 | + | description: &str, |
|
| 212 | + | secret: bool, |
|
| 213 | + | files: &[NewFile], |
|
| 214 | + | ) -> Result<RepoRow, DropError> { |
|
| 215 | + | if account.blocked() { |
|
| 216 | + | return Err(bad("This account cannot make drops.")); |
|
| 217 | + | } |
|
| 218 | + | let (title, description) = (title.trim(), description.trim()); |
|
| 219 | + | check_text(title, description).map_err(DropError::Bad)?; |
|
| 220 | + | let files = check_files(files, false).map_err(DropError::Bad)?; |
|
| 221 | + | let signer = signer(app, account).await?; |
|
| 222 | + | // Ids are unique across the hub, so `oh drop view ID` needs no owner. |
|
| 223 | + | let mut found = None; |
|
| 224 | + | for _ in 0..8 { |
|
| 225 | + | let id = crate::auth::random_hex(12); |
|
| 226 | + | let Some(name) = RepoName::drop(&account.username, &id) else { break }; |
|
| 227 | + | if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) { |
|
| 228 | + | found = Some(name); |
|
| 229 | + | break; |
|
| 230 | + | } |
|
| 231 | + | } |
|
| 232 | + | let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) }; |
|
| 233 | + | let mut repo_id = [0u8; 16]; |
|
| 234 | + | rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut repo_id); |
|
| 235 | + | app.repos.create(&name, Some(repo_id))?; |
|
| 236 | + | let id = name.drop_id().unwrap_or_default().to_string(); |
|
| 237 | + | let row = match db::repos::create_drop(&app.db, &account.username, &id, title, description, visibility(secret)).await { |
|
| 238 | + | Ok(row) => row, |
|
| 239 | + | Err(e) => { |
|
| 240 | + | let _ = std::fs::remove_dir_all(app.repos.dir(&name)); |
|
| 241 | + | return Err(e.into()); |
|
| 242 | + | } |
|
| 243 | + | }; |
|
| 244 | + | let edits: Vec<FileEdit> = |
|
| 245 | + | files.iter().map(|f| FileEdit { path: f.name.clone(), content: Some(f.content.clone().into_bytes()) }).collect(); |
|
| 246 | + | let message = if title.is_empty() { "Create the drop".to_string() } else { format!("Create {title}") }; |
|
| 247 | + | let b = row.default_bookmark.clone(); |
|
| 248 | + | match edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await { |
|
| 249 | + | Ok(done) => { |
|
| 250 | + | recorded(app, account, &row, &done, "drop.created", visibility_label(&row)).await; |
|
| 251 | + | Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) |
|
| 252 | + | } |
|
| 253 | + | Err(e) => { |
|
| 254 | + | db::repos::delete(&app.db, row.id).await.ok(); |
|
| 255 | + | let _ = std::fs::remove_dir_all(app.repos.dir(&name)); |
|
| 256 | + | Err(DropError::Bad(format!("The drop was not made: {e}"))) |
|
| 257 | + | } |
|
| 258 | + | } |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /// What a change to a drop's text or files is. |
|
| 262 | + | pub struct Update { |
|
| 263 | + | pub title: String, |
|
| 264 | + | pub description: String, |
|
| 265 | + | pub secret: bool, |
|
| 266 | + | /// Files to write or (content `None`) remove; others are kept. |
|
| 267 | + | pub files: Vec<FileEdit>, |
|
| 268 | + | pub message: String, |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | /// Change a drop: its files in one change made as the owner's delegate, |
|
| 272 | + | /// when any changed, and its title, description and visibility. |
|
| 273 | + | pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> { |
|
| 274 | + | let (title, description) = (u.title.trim(), u.description.trim()); |
|
| 275 | + | check_text(title, description).map_err(DropError::Bad)?; |
|
| 276 | + | if !u.files.is_empty() { |
|
| 277 | + | if u.files.len() > MAX_FILES { |
|
| 278 | + | return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time."))); |
|
| 279 | + | } |
|
| 280 | + | let written: Vec<NewFile> = u |
|
| 281 | + | .files |
|
| 282 | + | .iter() |
|
| 283 | + | .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() })) |
|
| 284 | + | .collect(); |
|
| 285 | + | for f in &written { |
|
| 286 | + | check_name(&f.name).map_err(DropError::Bad)?; |
|
| 287 | + | if f.content.len() > MAX_FILE_BYTES { |
|
| 288 | + | return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024))); |
|
| 289 | + | } |
|
| 290 | + | } |
|
| 291 | + | scan(&written).map_err(DropError::Bad)?; |
|
| 292 | + | let signer = signer(app, account).await?; |
|
| 293 | + | let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; |
|
| 294 | + | let message = if u.message.trim().is_empty() { |
|
| 295 | + | format!("Edit {}", if title.is_empty() { id_of(row) } else { title }) |
|
| 296 | + | } else { |
|
| 297 | + | u.message.trim().to_string() |
|
| 298 | + | }; |
|
| 299 | + | // Removing every file would leave an empty drop: delete it instead. |
|
| 300 | + | if u.files.iter().any(|e| e.content.is_none()) { |
|
| 301 | + | let (_, current) = files(app, row, "").await?; |
|
| 302 | + | let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count(); |
|
| 303 | + | if left == 0 && written.is_empty() { |
|
| 304 | + | return Err(bad("A drop keeps at least one file; delete the drop instead.")); |
|
| 305 | + | } |
|
| 306 | + | } |
|
| 307 | + | let b = row.default_bookmark.clone(); |
|
| 308 | + | let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer) |
|
| 309 | + | .await |
|
| 310 | + | .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?; |
|
| 311 | + | recorded(app, account, row, &done, "drop.edited", "").await; |
|
| 312 | + | } |
|
| 313 | + | db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?; |
|
| 314 | + | if visibility(u.secret) != row.visibility() { |
|
| 315 | + | db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok(); |
|
| 316 | + | } |
|
| 317 | + | Ok(()) |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | /// The push log and the audit log for a change the hub made to a drop. |
|
| 321 | + | async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) { |
|
| 322 | + | let outcome = db::repos::PushOutcome { |
|
| 323 | + | reference: done.bookmark.clone(), |
|
| 324 | + | ok: true, |
|
| 325 | + | reason: "web edit".into(), |
|
| 326 | + | old_state: done.old_state.clone(), |
|
| 327 | + | new_state: done.new_state.clone(), |
|
| 328 | + | heads: done.heads.len() as i64, |
|
| 329 | + | changes: 1, |
|
| 330 | + | }; |
|
| 331 | + | db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok(); |
|
| 332 | + | let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) }; |
|
| 333 | + | db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok(); |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | /// Delete a drop: its directory to the trash and its row, once nothing |
|
| 337 | + | /// reads or writes it (#249), audited. |
|
| 338 | + | pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> { |
|
| 339 | + | let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; |
|
| 340 | + | let _guard = app.repos.exclusive_guard(&name).await; |
|
| 341 | + | match app.repos.trash(&name) { |
|
| 342 | + | Ok(_) | Err(RepoError::NotFound) => {} |
|
| 343 | + | Err(e) => return Err(e.into()), |
|
| 344 | + | } |
|
| 345 | + | db::repos::delete(&app.db, row.id).await?; |
|
| 346 | + | db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok(); |
|
| 347 | + | Ok(()) |
|
| 348 | + | } |
|
| 349 | + | ||
| 350 | + | /// The drop's files at `spec` (its default bookmark when empty): every |
|
| 351 | + | /// file, with its text when it is text and small enough to show. |
|
| 352 | + | pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec<DropFile>), RepoError> { |
|
| 353 | + | let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?; |
|
| 354 | + | let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() }; |
|
| 355 | + | app.repos |
|
| 356 | + | .read(&name, move |repo, cache| { |
|
| 357 | + | let target = crate::repos::view::resolve(repo, cache, &spec)?; |
|
| 358 | + | let loaded = cache.load(repo, &target.heads)?; |
|
| 359 | + | let mut out = Vec::new(); |
|
| 360 | + | for (path, f) in &loaded.snapshot.files { |
|
| 361 | + | let bytes = match (&f.kind, &f.content) { |
|
| 362 | + | (NodeKind::Dir, _) | (_, FileContent::Dir) => continue, |
|
| 363 | + | (_, FileContent::Text { bytes, .. }) => bytes.clone(), |
|
| 364 | + | (_, FileContent::Opaque { blobs }) => match blobs.first() { |
|
| 365 | + | Some(b) => repo.read_blob(b)?, |
|
| 366 | + | None => Vec::new(), |
|
| 367 | + | }, |
|
| 368 | + | }; |
|
| 369 | + | let size = bytes.len() as u64; |
|
| 370 | + | let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None }; |
|
| 371 | + | out.push(DropFile { path: path.clone(), size, text }); |
|
| 372 | + | } |
|
| 373 | + | Ok((target, out)) |
|
| 374 | + | }) |
|
| 375 | + | .await |
|
| 376 | + | } |
|
| 377 | + | ||
| 378 | + | /// The drop `id` of `owner`, or none. |
|
| 379 | + | pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result<Option<RepoRow>> { |
|
| 380 | + | let Some(name) = RepoName::drop(owner, id) else { return Ok(None) }; |
|
| 381 | + | Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop())) |
|
| 382 | + | } |
|
| 383 | + | ||
| 384 | + | /// Whether `account` may change the drop: its owner alone. |
|
| 385 | + | pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool { |
|
| 386 | + | account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner)) |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /// Whether `account` may delete the drop: its owner, or a site |
|
| 390 | + | /// administrator. |
|
| 391 | + | pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool { |
|
| 392 | + | may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin) |
|
| 393 | + | } |
|
| 394 | + | ||
| 395 | + | /// Whether the drop is listed to `account`: public ones to everyone, secret |
|
| 396 | + | /// ones to their owner and site administrators only. |
|
| 397 | + | pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool { |
|
| 398 | + | row.visibility() == Visibility::Public || may_delete(account, row) |
|
| 399 | + | } |
|
| 400 | + | ||
| 401 | + | /// The edits that turn `current` text files into `wanted`: files gone are |
|
| 402 | + | /// removed, new or changed ones written. Files that are not text (pushed |
|
| 403 | + | /// with oxy) are left alone. |
|
| 404 | + | pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec<FileEdit> { |
|
| 405 | + | let mut out = Vec::new(); |
|
| 406 | + | for c in current.iter().filter(|c| c.text.is_some()) { |
|
| 407 | + | if !wanted.iter().any(|w| w.name == c.path) { |
|
| 408 | + | out.push(FileEdit { path: c.path.clone(), content: None }); |
|
| 409 | + | } |
|
| 410 | + | } |
|
| 411 | + | for w in wanted { |
|
| 412 | + | let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str())); |
|
| 413 | + | if !same { |
|
| 414 | + | out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) }); |
|
| 415 | + | } |
|
| 416 | + | } |
|
| 417 | + | out |
|
| 418 | + | } |
added crates/hub/src/web/drops.rs +477 −0
| 1 | + | //! Drops' pages (oxygenhub#279): `/{owner}/drops` lists a person's drops |
|
| 2 | + | //! (all of them to themselves, the public ones to everyone else), `/drops` |
|
| 3 | + | //! goes to your own, `/drops/new` makes one, and `/{owner}/drops/{id}` |
|
| 4 | + | //! shows one with its files rendered, with Edit and Delete for its owner. |
|
| 5 | + | //! A drop's history, changes and raw files are the repository pages under |
|
| 6 | + | //! `/{owner}/drops/{id}/…`, with the drop's bar in place of the tabs. |
|
| 7 | + | ||
| 8 | + | use super::settings::{csrf, require}; |
|
| 9 | + | use super::{filters, Error, Page, RepoShell, Shell}; |
|
| 10 | + | use crate::auth::{Access, Viewer}; |
|
| 11 | + | use crate::db; |
|
| 12 | + | use crate::drops::{self, DropFile, NewFile}; |
|
| 13 | + | use crate::render::{highlight, markdown}; |
|
| 14 | + | use crate::Shared; |
|
| 15 | + | use askama::Template; |
|
| 16 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 17 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 18 | + | use serde::Deserialize; |
|
| 19 | + | ||
| 20 | + | /// One drop on a Drops page. |
|
| 21 | + | pub struct DropLine { |
|
| 22 | + | pub url: String, |
|
| 23 | + | pub title: String, |
|
| 24 | + | pub description: String, |
|
| 25 | + | pub secret: bool, |
|
| 26 | + | pub files: Vec<String>, |
|
| 27 | + | pub updated_ms: i64, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | #[derive(Template)] |
|
| 31 | + | #[template(path = "drops.html")] |
|
| 32 | + | pub struct DropsPage { |
|
| 33 | + | shell: Shell, |
|
| 34 | + | owner: String, |
|
| 35 | + | is_me: bool, |
|
| 36 | + | drops: Vec<DropLine>, |
|
| 37 | + | notice: Option<String>, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | /// One file as the drop page shows it. |
|
| 41 | + | pub struct ShownFile { |
|
| 42 | + | pub path: String, |
|
| 43 | + | pub anchor: String, |
|
| 44 | + | pub size: u64, |
|
| 45 | + | pub language: Option<&'static str>, |
|
| 46 | + | /// Highlighted lines, as markup. |
|
| 47 | + | pub lines: Vec<String>, |
|
| 48 | + | pub markdown: Option<String>, |
|
| 49 | + | /// Why the file is not shown (binary, too large). |
|
| 50 | + | pub note: Option<String>, |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | #[derive(Template)] |
|
| 54 | + | #[template(path = "drop.html")] |
|
| 55 | + | pub struct DropPage { |
|
| 56 | + | shell: Shell, |
|
| 57 | + | repo: RepoShell, |
|
| 58 | + | title: String, |
|
| 59 | + | description: String, |
|
| 60 | + | secret: bool, |
|
| 61 | + | files: Vec<ShownFile>, |
|
| 62 | + | can_edit: bool, |
|
| 63 | + | can_delete: bool, |
|
| 64 | + | clone_url: String, |
|
| 65 | + | /// What the files are shown at, and whether that is the default bookmark. |
|
| 66 | + | target: String, |
|
| 67 | + | at_default: bool, |
|
| 68 | + | created_ms: i64, |
|
| 69 | + | updated_ms: i64, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | #[derive(Template)] |
|
| 73 | + | #[template(path = "drop_form.html")] |
|
| 74 | + | pub struct DropForm { |
|
| 75 | + | shell: Shell, |
|
| 76 | + | editing: bool, |
|
| 77 | + | action: String, |
|
| 78 | + | cancel: String, |
|
| 79 | + | title: String, |
|
| 80 | + | description: String, |
|
| 81 | + | secret: bool, |
|
| 82 | + | files: Vec<(String, String)>, |
|
| 83 | + | /// Files the form does not edit: not text, or too large. |
|
| 84 | + | kept: Vec<String>, |
|
| 85 | + | message: String, |
|
| 86 | + | delegation: Option<String>, |
|
| 87 | + | error: Option<String>, |
|
| 88 | + | } |
|
| 89 | + | ||
| 90 | + | /// `GET /drops`: your own Drops page. |
|
| 91 | + | pub async fn mine(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> { |
|
| 92 | + | let shell = Shell::new(&app, "/drops", &viewer); |
|
| 93 | + | let me = require(&viewer, &shell)?; |
|
| 94 | + | Ok(Redirect::to(&format!("/{}/drops", me.account.username)).into_response()) |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #[derive(Deserialize, Default)] |
|
| 98 | + | pub struct ListQuery { |
|
| 99 | + | #[serde(default)] |
|
| 100 | + | deleted: Option<String>, |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// `GET /{owner}/drops`. |
|
| 104 | + | pub async fn list( |
|
| 105 | + | State(app): State<Shared>, |
|
| 106 | + | viewer: Viewer, |
|
| 107 | + | Path(owner): Path<String>, |
|
| 108 | + | Query(q): Query<ListQuery>, |
|
| 109 | + | ) -> Result<Page<DropsPage>, Error> { |
|
| 110 | + | let shell = Shell::new(&app, &format!("/{owner}/drops"), &viewer); |
|
| 111 | + | let db_err = |e| Error::from_db(shell.clone(), e); |
|
| 112 | + | let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? else { |
|
| 113 | + | return Err(Error::not_found(shell)); |
|
| 114 | + | }; |
|
| 115 | + | let rows = db::repos::drops_of(&app.db, &account.username).await.map_err(db_err)?; |
|
| 116 | + | let mut lines = Vec::new(); |
|
| 117 | + | for row in rows.into_iter().filter(|r| drops::listed_to(viewer.account(), r)) { |
|
| 118 | + | let files = drops::files(&app, &row, "").await.map(|(_, f)| f).unwrap_or_default(); |
|
| 119 | + | lines.push(DropLine { |
|
| 120 | + | url: drops::url_of(&row), |
|
| 121 | + | title: drops::shown_title(&row, &files), |
|
| 122 | + | description: row.description.clone(), |
|
| 123 | + | secret: row.visibility() != hub_api::Visibility::Public, |
|
| 124 | + | files: files.into_iter().map(|f| f.path).collect(), |
|
| 125 | + | updated_ms: row.updated_ms, |
|
| 126 | + | }); |
|
| 127 | + | } |
|
| 128 | + | let is_me = viewer.account().is_some_and(|a| a.id == account.id); |
|
| 129 | + | let notice = q.deleted.map(|_| "The drop was deleted.".to_string()); |
|
| 130 | + | Ok(Page(DropsPage { shell, owner: account.username, is_me, drops: lines, notice })) |
|
| 131 | + | } |
|
| 132 | + | ||
| 133 | + | /// The drop `{owner}/drops/{id}` for the viewer, or 404. |
|
| 134 | + | async fn lookup(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> { |
|
| 135 | + | let row = drops::find(app, owner, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 136 | + | let row = row.ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 137 | + | let role = crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 138 | + | if !crate::auth::roles::allows(role, Access::Read) { |
|
| 139 | + | return Err(Error::not_found(shell.clone())); |
|
| 140 | + | } |
|
| 141 | + | Ok(row) |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | /// A file's anchor on the drop page: `file-` and its path, plain. |
|
| 145 | + | fn anchor_of(path: &str) -> String { |
|
| 146 | + | format!("file-{}", path.trim_matches('/').replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_', "-")) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | fn shown(files: Vec<DropFile>, base_file: &str, base_raw: &str) -> Vec<ShownFile> { |
|
| 150 | + | files |
|
| 151 | + | .into_iter() |
|
| 152 | + | .map(|f| { |
|
| 153 | + | let language = highlight::language(&f.path); |
|
| 154 | + | let anchor = anchor_of(&f.path); |
|
| 155 | + | let lower = f.path.to_lowercase(); |
|
| 156 | + | let (lines, md, note) = match &f.text { |
|
| 157 | + | Some(text) if lower.ends_with(".md") || lower.ends_with(".markdown") => { |
|
| 158 | + | (vec![], Some(markdown::render(text, base_file, base_raw)), None) |
|
| 159 | + | } |
|
| 160 | + | Some(text) => (highlight::lines(&f.path, text), None, None), |
|
| 161 | + | None if crate::web::mime_of(&f.path).starts_with("image/") => (vec![], None, Some("An image.".to_string())), |
|
| 162 | + | None => (vec![], None, Some("Not shown: a binary file, or too large to show here.".to_string())), |
|
| 163 | + | }; |
|
| 164 | + | ShownFile { path: f.path, anchor, size: f.size, language, lines, markdown: md, note } |
|
| 165 | + | }) |
|
| 166 | + | .collect() |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | async fn page(app: &Shared, viewer: &Viewer, owner: &str, id: &str, r: &str) -> Result<Page<DropPage>, Error> { |
|
| 170 | + | let path = if r.is_empty() { format!("/{owner}/drops/{id}") } else { format!("/{owner}/drops/{id}/tree/{r}") }; |
|
| 171 | + | let shell = Shell::new(app, &path, viewer); |
|
| 172 | + | let row = lookup(app, &shell, viewer, owner, id).await?; |
|
| 173 | + | let (target, files) = drops::files(app, &row, r).await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 174 | + | let url = drops::url_of(&row); |
|
| 175 | + | let title = drops::shown_title(&row, &files); |
|
| 176 | + | let label = target.label.clone(); |
|
| 177 | + | let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/")); |
|
| 178 | + | let (can_edit, can_delete) = (drops::may_edit(viewer.account(), &row), drops::may_delete(viewer.account(), &row)); |
|
| 179 | + | let role = if can_edit { crate::auth::Role::Admin } else { crate::auth::Role::Read }; |
|
| 180 | + | Ok(Page(DropPage { |
|
| 181 | + | repo: RepoShell::new(app, &row, "code").with_role(role), |
|
| 182 | + | title, |
|
| 183 | + | description: row.description.clone(), |
|
| 184 | + | secret: row.visibility() != hub_api::Visibility::Public, |
|
| 185 | + | files, |
|
| 186 | + | can_edit, |
|
| 187 | + | can_delete, |
|
| 188 | + | clone_url: format!("{}{url}", app.config.base_url()), |
|
| 189 | + | at_default: label == row.default_bookmark, |
|
| 190 | + | target: label, |
|
| 191 | + | created_ms: row.created_ms, |
|
| 192 | + | updated_ms: row.updated_ms, |
|
| 193 | + | shell, |
|
| 194 | + | })) |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | /// `GET /{owner}/drops/{id}`. |
|
| 198 | + | pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result<Page<DropPage>, Error> { |
|
| 199 | + | page(&app, &viewer, &owner, &id, "").await |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | /// `GET /{owner}/drops/{id}/tree/{r}`: the files at a bookmark, tag or |
|
| 203 | + | /// change (where a change page's links go). |
|
| 204 | + | pub async fn show_at( |
|
| 205 | + | State(app): State<Shared>, |
|
| 206 | + | viewer: Viewer, |
|
| 207 | + | Path((owner, id, r)): Path<(String, String, String)>, |
|
| 208 | + | ) -> Result<Page<DropPage>, Error> { |
|
| 209 | + | page(&app, &viewer, &owner, &id, &r).await |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | /// The form's fields: repeated `name` and `content` pairs, in order. |
|
| 213 | + | struct Submitted { |
|
| 214 | + | csrf: String, |
|
| 215 | + | title: String, |
|
| 216 | + | description: String, |
|
| 217 | + | secret: bool, |
|
| 218 | + | message: String, |
|
| 219 | + | files: Vec<NewFile>, |
|
| 220 | + | } |
|
| 221 | + | ||
| 222 | + | fn submitted(pairs: Vec<(String, String)>) -> Submitted { |
|
| 223 | + | let mut s = Submitted { |
|
| 224 | + | csrf: String::new(), |
|
| 225 | + | title: String::new(), |
|
| 226 | + | description: String::new(), |
|
| 227 | + | secret: false, |
|
| 228 | + | message: String::new(), |
|
| 229 | + | files: vec![], |
|
| 230 | + | }; |
|
| 231 | + | for (k, v) in pairs { |
|
| 232 | + | match k.as_str() { |
|
| 233 | + | "_csrf" => s.csrf = v, |
|
| 234 | + | "title" => s.title = v, |
|
| 235 | + | "description" => s.description = v, |
|
| 236 | + | "visibility" => s.secret = v == "secret", |
|
| 237 | + | "message" => s.message = v, |
|
| 238 | + | "name" => s.files.push(NewFile { name: v, content: String::new() }), |
|
| 239 | + | "content" => match s.files.last_mut() { |
|
| 240 | + | Some(f) if f.content.is_empty() => f.content = v, |
|
| 241 | + | _ => s.files.push(NewFile { name: String::new(), content: v }), |
|
| 242 | + | }, |
|
| 243 | + | _ => {} |
|
| 244 | + | } |
|
| 245 | + | } |
|
| 246 | + | // A row left empty is no file. |
|
| 247 | + | s.files.retain(|f| !f.name.trim().is_empty() || !f.content.trim().is_empty()); |
|
| 248 | + | s |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | fn form_files(files: &[NewFile]) -> Vec<(String, String)> { |
|
| 252 | + | let mut out: Vec<(String, String)> = files.iter().map(|f| (f.name.clone(), f.content.clone())).collect(); |
|
| 253 | + | out.push((String::new(), String::new())); |
|
| 254 | + | out |
|
| 255 | + | } |
|
| 256 | + | ||
| 257 | + | /// `GET /drops/new`. |
|
| 258 | + | pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<DropForm>, Error> { |
|
| 259 | + | let shell = Shell::new(&app, "/drops/new", &viewer); |
|
| 260 | + | let me = require(&viewer, &shell)?; |
|
| 261 | + | let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err(); |
|
| 262 | + | Ok(Page(DropForm { |
|
| 263 | + | cancel: format!("/{}/drops", me.account.username), |
|
| 264 | + | shell, |
|
| 265 | + | editing: false, |
|
| 266 | + | action: "/drops/new".into(), |
|
| 267 | + | title: String::new(), |
|
| 268 | + | description: String::new(), |
|
| 269 | + | secret: false, |
|
| 270 | + | files: form_files(&[]), |
|
| 271 | + | kept: vec![], |
|
| 272 | + | message: String::new(), |
|
| 273 | + | delegation, |
|
| 274 | + | error: None, |
|
| 275 | + | })) |
|
| 276 | + | } |
|
| 277 | + | ||
| 278 | + | /// `POST /drops/new`. |
|
| 279 | + | pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(pairs): Form<Vec<(String, String)>>) -> Result<Response, Error> { |
|
| 280 | + | let shell = Shell::new(&app, "/drops/new", &viewer); |
|
| 281 | + | let me = require(&viewer, &shell)?; |
|
| 282 | + | let f = submitted(pairs); |
|
| 283 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 284 | + | match drops::create(&app, &me.account, &f.title, &f.description, f.secret, &f.files).await { |
|
| 285 | + | Ok(row) => Ok(Redirect::to(&drops::url_of(&row)).into_response()), |
|
| 286 | + | Err(drops::DropError::Bad(msg)) => { |
|
| 287 | + | let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err(); |
|
| 288 | + | Ok(Page(DropForm { |
|
| 289 | + | cancel: format!("/{}/drops", me.account.username), |
|
| 290 | + | shell, |
|
| 291 | + | editing: false, |
|
| 292 | + | action: "/drops/new".into(), |
|
| 293 | + | title: f.title, |
|
| 294 | + | description: f.description, |
|
| 295 | + | secret: f.secret, |
|
| 296 | + | files: form_files(&f.files), |
|
| 297 | + | kept: vec![], |
|
| 298 | + | message: String::new(), |
|
| 299 | + | delegation, |
|
| 300 | + | error: Some(msg), |
|
| 301 | + | }) |
|
| 302 | + | .into_response()) |
|
| 303 | + | } |
|
| 304 | + | Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }), |
|
| 305 | + | } |
|
| 306 | + | } |
|
| 307 | + | ||
| 308 | + | /// The drop, when the viewer may change it (its owner) or, `deleting`, |
|
| 309 | + | /// delete it (its owner or a site administrator). |
|
| 310 | + | async fn writable( |
|
| 311 | + | app: &Shared, |
|
| 312 | + | shell: &Shell, |
|
| 313 | + | viewer: &Viewer, |
|
| 314 | + | owner: &str, |
|
| 315 | + | id: &str, |
|
| 316 | + | deleting: bool, |
|
| 317 | + | ) -> Result<db::repos::RepoRow, Error> { |
|
| 318 | + | let row = lookup(app, shell, viewer, owner, id).await?; |
|
| 319 | + | let may = if deleting { drops::may_delete(viewer.account(), &row) } else { drops::may_edit(viewer.account(), &row) }; |
|
| 320 | + | if !may { |
|
| 321 | + | return Err(Error::not_found(shell.clone())); |
|
| 322 | + | } |
|
| 323 | + | Ok(row) |
|
| 324 | + | } |
|
| 325 | + | ||
| 326 | + | async fn edit_page( |
|
| 327 | + | app: &Shared, |
|
| 328 | + | shell: Shell, |
|
| 329 | + | viewer: &Viewer, |
|
| 330 | + | row: &db::repos::RepoRow, |
|
| 331 | + | wanted: Option<&Submitted>, |
|
| 332 | + | error: Option<String>, |
|
| 333 | + | ) -> Result<Page<DropForm>, Error> { |
|
| 334 | + | let me = require(viewer, &shell)?; |
|
| 335 | + | let (_, current) = drops::files(app, row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 336 | + | let kept: Vec<String> = current.iter().filter(|f| f.text.is_none()).map(|f| f.path.clone()).collect(); |
|
| 337 | + | let files = match wanted { |
|
| 338 | + | Some(w) => form_files(&w.files), |
|
| 339 | + | None => form_files( |
|
| 340 | + | ¤t |
|
| 341 | + | .iter() |
|
| 342 | + | .filter_map(|f| f.text.as_ref().map(|t| NewFile { name: f.path.clone(), content: t.clone() })) |
|
| 343 | + | .collect::<Vec<_>>(), |
|
| 344 | + | ), |
|
| 345 | + | }; |
|
| 346 | + | let delegation = crate::web::edit::signer_for(app, me.account.id).await.err(); |
|
| 347 | + | let url = drops::url_of(row); |
|
| 348 | + | Ok(Page(DropForm { |
|
| 349 | + | shell, |
|
| 350 | + | editing: true, |
|
| 351 | + | action: format!("{url}/edit"), |
|
| 352 | + | cancel: url, |
|
| 353 | + | title: wanted.map(|w| w.title.clone()).unwrap_or_else(|| row.title.clone()), |
|
| 354 | + | description: wanted.map(|w| w.description.clone()).unwrap_or_else(|| row.description.clone()), |
|
| 355 | + | secret: wanted.map(|w| w.secret).unwrap_or(row.visibility() != hub_api::Visibility::Public), |
|
| 356 | + | files, |
|
| 357 | + | kept, |
|
| 358 | + | message: wanted.map(|w| w.message.clone()).unwrap_or_default(), |
|
| 359 | + | delegation, |
|
| 360 | + | error, |
|
| 361 | + | })) |
|
| 362 | + | } |
|
| 363 | + | ||
| 364 | + | /// `GET /{owner}/drops/{id}/edit`. |
|
| 365 | + | pub async fn edit_form( |
|
| 366 | + | State(app): State<Shared>, |
|
| 367 | + | viewer: Viewer, |
|
| 368 | + | Path((owner, id)): Path<(String, String)>, |
|
| 369 | + | ) -> Result<Page<DropForm>, Error> { |
|
| 370 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer); |
|
| 371 | + | require(&viewer, &shell)?; |
|
| 372 | + | let row = writable(&app, &shell, &viewer, &owner, &id, false).await?; |
|
| 373 | + | edit_page(&app, shell, &viewer, &row, None, None).await |
|
| 374 | + | } |
|
| 375 | + | ||
| 376 | + | /// `POST /{owner}/drops/{id}/edit`: the files as the form has them, as |
|
| 377 | + | /// one change (only what differs), and the title, description and |
|
| 378 | + | /// visibility. |
|
| 379 | + | pub async fn save( |
|
| 380 | + | State(app): State<Shared>, |
|
| 381 | + | viewer: Viewer, |
|
| 382 | + | Path((owner, id)): Path<(String, String)>, |
|
| 383 | + | Form(pairs): Form<Vec<(String, String)>>, |
|
| 384 | + | ) -> Result<Response, Error> { |
|
| 385 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer); |
|
| 386 | + | let me = require(&viewer, &shell)?; |
|
| 387 | + | let f = submitted(pairs); |
|
| 388 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 389 | + | let row = writable(&app, &shell, &viewer, &owner, &id, false).await?; |
|
| 390 | + | let wanted = match drops::check_files(&f.files, false) { |
|
| 391 | + | Ok(w) => w, |
|
| 392 | + | Err(msg) => return Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()), |
|
| 393 | + | }; |
|
| 394 | + | let (_, current) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 395 | + | let update = drops::Update { |
|
| 396 | + | title: f.title.clone(), |
|
| 397 | + | description: f.description.clone(), |
|
| 398 | + | secret: f.secret, |
|
| 399 | + | files: drops::edits_between(¤t, &wanted), |
|
| 400 | + | message: f.message.clone(), |
|
| 401 | + | }; |
|
| 402 | + | match drops::update(&app, &me.account, &row, update).await { |
|
| 403 | + | Ok(()) => Ok(Redirect::to(&drops::url_of(&row)).into_response()), |
|
| 404 | + | Err(drops::DropError::Bad(msg)) => Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()), |
|
| 405 | + | Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }), |
|
| 406 | + | } |
|
| 407 | + | } |
|
| 408 | + | ||
| 409 | + | #[derive(Deserialize)] |
|
| 410 | + | pub struct DeleteForm { |
|
| 411 | + | #[serde(rename = "_csrf")] |
|
| 412 | + | csrf_token: String, |
|
| 413 | + | } |
|
| 414 | + | ||
| 415 | + | /// `POST /{owner}/drops/{id}/delete`: its owner or a site administrator. |
|
| 416 | + | pub async fn delete( |
|
| 417 | + | State(app): State<Shared>, |
|
| 418 | + | viewer: Viewer, |
|
| 419 | + | Path((owner, id)): Path<(String, String)>, |
|
| 420 | + | Form(f): Form<DeleteForm>, |
|
| 421 | + | ) -> Result<Response, Error> { |
|
| 422 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer); |
|
| 423 | + | let me = require(&viewer, &shell)?; |
|
| 424 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 425 | + | let row = writable(&app, &shell, &viewer, &owner, &id, true).await?; |
|
| 426 | + | drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 427 | + | Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response()) |
|
| 428 | + | } |
|
| 429 | + | ||
| 430 | + | /// The drop's repository name, `drops/ID`, for the repository pages; a |
|
| 431 | + | /// name that is not a drop's finds nothing there. |
|
| 432 | + | fn repo_name(id: &str) -> String { |
|
| 433 | + | format!("{}/{id}", crate::repos::names::DROPS) |
|
| 434 | + | } |
|
| 435 | + | ||
| 436 | + | /// `GET /{owner}/drops/{id}/changes/{r}`: the history page. |
|
| 437 | + | pub async fn changes( |
|
| 438 | + | State(app): State<Shared>, |
|
| 439 | + | viewer: Viewer, |
|
| 440 | + | Path((owner, id, r)): Path<(String, String, String)>, |
|
| 441 | + | q: Query<super::history::HistoryQuery>, |
|
| 442 | + | ) -> Result<Response, Error> { |
|
| 443 | + | let row = drop_or_404(&app, &viewer, &owner, &id).await?; |
|
| 444 | + | super::history::history(State(app), viewer, Path((row.owner, repo_name(&id), r)), q).await.map(IntoResponse::into_response) |
|
| 445 | + | } |
|
| 446 | + | ||
| 447 | + | /// `GET /{owner}/drops/{id}/change/{hash}`: a change with its diff. |
|
| 448 | + | pub async fn change( |
|
| 449 | + | State(app): State<Shared>, |
|
| 450 | + | viewer: Viewer, |
|
| 451 | + | Path((owner, id, hash)): Path<(String, String, String)>, |
|
| 452 | + | q: Query<super::history::DiffQuery>, |
|
| 453 | + | ) -> Result<Response, Error> { |
|
| 454 | + | let row = drop_or_404(&app, &viewer, &owner, &id).await?; |
|
| 455 | + | super::history::change(State(app), viewer, Path((row.owner, repo_name(&id), hash)), q).await.map(IntoResponse::into_response) |
|
| 456 | + | } |
|
| 457 | + | ||
| 458 | + | /// `GET /{owner}/drops/{id}/raw/{r}/{*path}`: a file's bytes. |
|
| 459 | + | pub async fn raw( |
|
| 460 | + | State(app): State<Shared>, |
|
| 461 | + | viewer: Viewer, |
|
| 462 | + | Path((owner, id, r, path)): Path<(String, String, String, String)>, |
|
| 463 | + | ) -> Result<Response, Error> { |
|
| 464 | + | let row = drop_or_404(&app, &viewer, &owner, &id).await?; |
|
| 465 | + | super::repo::raw_file(State(app), viewer, Path((row.owner, repo_name(&id), r, path))).await |
|
| 466 | + | } |
|
| 467 | + | ||
| 468 | + | /// `GET /{owner}/drops/{id}/file/{r}/{*path}`: the drop's page at `r`, |
|
| 469 | + | /// at that file (where rendered Markdown's relative links go). |
|
| 470 | + | pub async fn file(Path((owner, id, r, path)): Path<(String, String, String, String)>) -> Response { |
|
| 471 | + | Redirect::to(&format!("/{owner}/drops/{id}/tree/{r}#{}", anchor_of(&path))).into_response() |
|
| 472 | + | } |
|
| 473 | + | ||
| 474 | + | async fn drop_or_404(app: &Shared, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> { |
|
| 475 | + | let shell = Shell::new(app, &format!("/{owner}/drops/{id}"), viewer); |
|
| 476 | + | lookup(app, &shell, viewer, owner, id).await |
|
| 477 | + | } |
added crates/hub/tests/drops.rs +476 −0
| 1 | + | //! Drops (oxygenhub#279): small snippets, each its own Oxygen repository |
|
| 2 | + | //! named `OWNER/drops/ID`. Made and edited in the browser and through the |
|
| 3 | + | //! API as signed changes by the hub as the owner's delegate; their files |
|
| 4 | + | //! rendered and raw; cloned and pushed with oxy; secret ones readable by |
|
| 5 | + | //! anyone with the address and listed nowhere; never among repositories; |
|
| 6 | + | //! changed and deleted only by their owner (or, deleting, a site admin); |
|
| 7 | + | //! push protection on their pushes and on what the hub commits for them. |
|
| 8 | + | //! |
|
| 9 | + | //! The fake credential is put together at run time, so this file is not |
|
| 10 | + | //! itself a finding. |
|
| 11 | + | ||
| 12 | + | use axum::body::Body; |
|
| 13 | + | use axum::http::{header, Request, StatusCode}; |
|
| 14 | + | use http_body_util::BodyExt; |
|
| 15 | + | use oxy_core::repo::{IdentityScope, Repo}; |
|
| 16 | + | use oxygenhub::auth::random_hex; |
|
| 17 | + | use oxygenhub::config::Config; |
|
| 18 | + | use oxygenhub::db; |
|
| 19 | + | use oxygenhub::repos::RepoName; |
|
| 20 | + | use oxygenhub::Shared; |
|
| 21 | + | use std::path::Path; |
|
| 22 | + | use tower::ServiceExt; |
|
| 23 | + | ||
| 24 | + | fn hermetic() { |
|
| 25 | + | static ONCE: std::sync::Once = std::sync::Once::new(); |
|
| 26 | + | static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new(); |
|
| 27 | + | ONCE.call_once(|| { |
|
| 28 | + | let d = tempfile::tempdir().unwrap(); |
|
| 29 | + | std::env::set_var("OXY_HOME", d.path()); |
|
| 30 | + | DIR.set(d).ok(); |
|
| 31 | + | }); |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | fn write(root: &Path, rel: &str, content: &[u8]) { |
|
| 35 | + | let p = root.join(rel); |
|
| 36 | + | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
|
| 37 | + | std::fs::write(p, content).unwrap(); |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | struct Hub { |
|
| 41 | + | app: Shared, |
|
| 42 | + | base: String, |
|
| 43 | + | _dir: tempfile::TempDir, |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | async fn hub() -> Hub { |
|
| 47 | + | hermetic(); |
|
| 48 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 49 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 50 | + | config.registration.setup_code = "test-setup".into(); |
|
| 51 | + | config.registration.mode = "open".into(); |
|
| 52 | + | // Flow on for everything of andy's: a drop still never runs it. |
|
| 53 | + | config.flow.repos = vec!["andy/*".into()]; |
|
| 54 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 55 | + | let port = listener.local_addr().unwrap().port(); |
|
| 56 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 57 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 58 | + | let router = oxygenhub::router(app.clone()); |
|
| 59 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 60 | + | Hub { app, base: format!("http://127.0.0.1:{port}"), _dir: dir } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | struct Res { |
|
| 64 | + | status: StatusCode, |
|
| 65 | + | body: String, |
|
| 66 | + | cookie: Option<String>, |
|
| 67 | + | location: Option<String>, |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, token: Option<&str>, body: Option<(&str, String)>) -> Res { |
|
| 71 | + | let mut req = Request::builder().method(method).uri(path); |
|
| 72 | + | if let Some(c) = cookie { |
|
| 73 | + | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 74 | + | } |
|
| 75 | + | if let Some(t) = token { |
|
| 76 | + | req = req.header(header::AUTHORIZATION, format!("Bearer {t}")); |
|
| 77 | + | } |
|
| 78 | + | let body = match body { |
|
| 79 | + | Some((ct, b)) => { |
|
| 80 | + | req = req.header(header::CONTENT_TYPE, ct); |
|
| 81 | + | Body::from(b) |
|
| 82 | + | } |
|
| 83 | + | None => Body::empty(), |
|
| 84 | + | }; |
|
| 85 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 86 | + | let status = res.status(); |
|
| 87 | + | let cookie = res |
|
| 88 | + | .headers() |
|
| 89 | + | .get(header::SET_COOKIE) |
|
| 90 | + | .and_then(|v| v.to_str().ok()) |
|
| 91 | + | .and_then(|v| v.split(';').next()) |
|
| 92 | + | .and_then(|kv| kv.split_once('=')) |
|
| 93 | + | .map(|(_, v)| v.to_string()); |
|
| 94 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 95 | + | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 96 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 100 | + | send(app, "GET", path, cookie, None, None).await |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | async fn post(app: &Shared, path: &str, cookie: Option<&str>, form: &str) -> Res { |
|
| 104 | + | send(app, "POST", path, cookie, None, Some(("application/x-www-form-urlencoded", form.to_string()))).await |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | async fn api(app: &Shared, method: &str, path: &str, token: Option<&str>, json: Option<serde_json::Value>) -> Res { |
|
| 108 | + | send(app, method, &format!("/api/v1{path}"), None, token, json.map(|j| ("application/json", j.to_string()))).await |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | async fn register(app: &Shared, user: &str) -> String { |
|
| 112 | + | let r = post( |
|
| 113 | + | app, |
|
| 114 | + | "/register", |
|
| 115 | + | None, |
|
| 116 | + | &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"), |
|
| 117 | + | ) |
|
| 118 | + | .await; |
|
| 119 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 120 | + | r.cookie.unwrap() |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | async fn token(app: &Shared, user: &str) -> String { |
|
| 124 | + | let me = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap(); |
|
| 125 | + | let (secret, hash) = oxygenhub::auth::new_token(); |
|
| 126 | + | db::accounts::create_token(&app.db, me.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap(); |
|
| 127 | + | secret |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// `user`'s identity, delegating to the hub's key and linked to the |
|
| 131 | + | /// account, as web edits need; the client checkout that holds it. |
|
| 132 | + | async fn delegate(app: &Shared, user: &str, work: &Path) -> Repo { |
|
| 133 | + | let id = db::accounts::account_by_name(&app.db, user).await.unwrap().unwrap().id; |
|
| 134 | + | let mut client = Repo::init(work).unwrap(); |
|
| 135 | + | client.create_identity(user, &format!("{user}@example.com"), IdentityScope::Repo).unwrap(); |
|
| 136 | + | client.delegate_add(app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); |
|
| 137 | + | let key = client.config.author_key.unwrap(); |
|
| 138 | + | let ident = client.identity(&key).unwrap(); |
|
| 139 | + | db::accounts::link_identity(&app.db, id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap(); |
|
| 140 | + | client |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | fn csrf_of(page: &str) -> String { |
|
| 144 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 145 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | fn enc(s: &str) -> String { |
|
| 149 | + | let mut out = String::new(); |
|
| 150 | + | for b in s.bytes() { |
|
| 151 | + | match b { |
|
| 152 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), |
|
| 153 | + | _ => out.push_str(&format!("%{b:02X}")), |
|
| 154 | + | } |
|
| 155 | + | } |
|
| 156 | + | out |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | fn drop_id(location: &str) -> String { |
|
| 160 | + | location.rsplit('/').next().unwrap().to_string() |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | const README: &str = "# Hello drops\n\nA *small* snippet.\n"; |
|
| 164 | + | const MAIN_RS: &str = "fn main() {\n println!(\"hi\");\n}\n"; |
|
| 165 | + | ||
| 166 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 167 | + | async fn drops_in_the_browser_the_api_and_oxy() { |
|
| 168 | + | let h = hub().await; |
|
| 169 | + | let app = &h.app; |
|
| 170 | + | let andy = register(app, "andy").await; |
|
| 171 | + | let bob = register(app, "bob").await; |
|
| 172 | + | let andy_tok = token(app, "andy").await; |
|
| 173 | + | let bob_tok = token(app, "bob").await; |
|
| 174 | + | let work = tempfile::tempdir().unwrap(); |
|
| 175 | + | ||
| 176 | + | // `/drops` is your own Drops page; signed out, it asks you to sign in. |
|
| 177 | + | let r = get(app, "/drops", Some(&andy)).await; |
|
| 178 | + | assert_eq!(r.location.as_deref(), Some("/andy/drops")); |
|
| 179 | + | assert!(get(app, "/drops", None).await.location.unwrap().starts_with("/login")); |
|
| 180 | + | let page = get(app, "/andy/drops", Some(&andy)).await; |
|
| 181 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 182 | + | assert!(page.body.contains("No drops yet") && page.body.contains("/drops/new"), "{}", page.body); |
|
| 183 | + | ||
| 184 | + | // Before delegating to the hub's key, the form says so and nothing is made. |
|
| 185 | + | let form = get(app, "/drops/new", Some(&andy)).await; |
|
| 186 | + | assert!(form.body.contains("not delegated"), "{}", form.body); |
|
| 187 | + | assert!(form.body.contains("data-drop-add") && form.body.contains("data-drop-files")); |
|
| 188 | + | let csrf = csrf_of(&form.body); |
|
| 189 | + | let fields = format!( |
|
| 190 | + | "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&name=&content=", |
|
| 191 | + | enc("Two files"), |
|
| 192 | + | enc(README), |
|
| 193 | + | enc(MAIN_RS) |
|
| 194 | + | ); |
|
| 195 | + | let r = post(app, "/drops/new", Some(&andy), &fields).await; |
|
| 196 | + | assert_eq!(r.status, StatusCode::OK); |
|
| 197 | + | assert!(r.body.contains("not delegated"), "{}", r.body); |
|
| 198 | + | assert!(db::repos::drops_of(&app.db, "andy").await.unwrap().is_empty()); |
|
| 199 | + | ||
| 200 | + | // Created in the browser: a repository of its own, the files its first |
|
| 201 | + | // change, authored by andy and signed by the hub as his delegate. |
|
| 202 | + | let mut client = delegate(app, "andy", work.path()).await; |
|
| 203 | + | let r = post(app, "/drops/new", Some(&andy), &fields).await; |
|
| 204 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 205 | + | let url = r.location.unwrap(); |
|
| 206 | + | let id = drop_id(&url); |
|
| 207 | + | assert!(url.starts_with("/andy/drops/") && id.len() == 24 && id.bytes().all(|b| b.is_ascii_hexdigit()), "{url}"); |
|
| 208 | + | let row = db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap(); |
|
| 209 | + | assert!(row.is_drop() && row.title == "Hello" && row.description == "Two files" && row.visibility == "public"); |
|
| 210 | + | let name = RepoName::drop("andy", &id).unwrap(); |
|
| 211 | + | assert!(app.repos.exists(&name)); |
|
| 212 | + | ||
| 213 | + | // Its page renders the files: Markdown as a document, code highlighted. |
|
| 214 | + | let page = get(app, &url, None).await; |
|
| 215 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 216 | + | assert!(page.body.contains("<h1>Hello drops</h1>") || page.body.contains(">Hello drops</h1>"), "{}", page.body); |
|
| 217 | + | assert!(page.body.contains("main.rs") && page.body.contains("println"), "{}", page.body); |
|
| 218 | + | assert!(page.body.contains(&format!("oxy clone {}{url}", h.base))); |
|
| 219 | + | assert!(!page.body.contains(">EDIT<"), "no edit for the signed out"); |
|
| 220 | + | assert!(get(app, &url, Some(&andy)).await.body.contains(">EDIT<")); |
|
| 221 | + | // The raw file, and a change page's tree link. |
|
| 222 | + | let raw = get(app, &format!("{url}/raw/main/main.rs"), None).await; |
|
| 223 | + | assert_eq!(raw.status, StatusCode::OK); |
|
| 224 | + | assert_eq!(raw.body, MAIN_RS); |
|
| 225 | + | assert_eq!(get(app, &format!("{url}/tree/main"), None).await.status, StatusCode::OK); |
|
| 226 | + | ||
| 227 | + | // An edit in the browser: a second signed change; the history shows two. |
|
| 228 | + | let edit = get(app, &format!("{url}/edit"), Some(&andy)).await; |
|
| 229 | + | assert_eq!(edit.status, StatusCode::OK); |
|
| 230 | + | assert!(edit.body.contains("README.md") && edit.body.contains("println"), "{}", edit.body); |
|
| 231 | + | let csrf = csrf_of(&edit.body); |
|
| 232 | + | let changed = "fn main() {\n println!(\"hello, drops\");\n}\n"; |
|
| 233 | + | let r = post( |
|
| 234 | + | app, |
|
| 235 | + | &format!("{url}/edit"), |
|
| 236 | + | Some(&andy), |
|
| 237 | + | &format!( |
|
| 238 | + | "_csrf={csrf}&title=Hello&description={}&visibility=public&name=README.md&content={}&name=main.rs&content={}&message={}", |
|
| 239 | + | enc("Two files"), |
|
| 240 | + | enc(README), |
|
| 241 | + | enc(changed), |
|
| 242 | + | enc("Say hello") |
|
| 243 | + | ), |
|
| 244 | + | ) |
|
| 245 | + | .await; |
|
| 246 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 247 | + | let hist = get(app, &format!("{url}/changes/main"), None).await; |
|
| 248 | + | assert_eq!(hist.status, StatusCode::OK); |
|
| 249 | + | assert!(hist.body.contains("Say hello") && hist.body.contains("Create Hello"), "{}", hist.body); |
|
| 250 | + | assert!(hist.body.contains("aria-label=\"Drop\""), "the drop's own bar"); |
|
| 251 | + | assert!(!hist.body.contains("/proposals\"") && !hist.body.contains("swimlanes"), "no repository tabs"); |
|
| 252 | + | let v = app.repos.read(&name, oxygenhub::repos::view::verification).await.unwrap(); |
|
| 253 | + | assert_eq!((v.total, v.signed, v.invalid.len()), (2, 2, 0), "{v:?}"); |
|
| 254 | + | // A change page with its diff, linked from the history. |
|
| 255 | + | let change = hist.body.split(&format!("{url}/change/")).nth(1).unwrap().split('"').next().unwrap().to_string(); |
|
| 256 | + | let cp = get(app, &format!("{url}/change/{change}"), None).await; |
|
| 257 | + | assert_eq!(cp.status, StatusCode::OK); |
|
| 258 | + | assert!(cp.body.contains("hello, drops"), "{}", cp.body); |
|
| 259 | + | assert_eq!(get(app, &format!("{url}/raw/main/main.rs"), None).await.body, changed); |
|
| 260 | + | // The push log has both, as web edits; Flow never ran. |
|
| 261 | + | let pushes = db::repos::pushes(&app.db, row.id, 10).await.unwrap(); |
|
| 262 | + | assert_eq!(pushes.iter().filter(|p| p.ok && p.reason == "web edit").count(), 2, "{pushes:?}"); |
|
| 263 | + | assert!(db::flow::runs(&app.db, row.id, 10, None).await.unwrap().is_empty()); |
|
| 264 | + | ||
| 265 | + | // A secret drop through the API. |
|
| 266 | + | let r = api( |
|
| 267 | + | app, |
|
| 268 | + | "POST", |
|
| 269 | + | "/drops", |
|
| 270 | + | Some(&andy_tok), |
|
| 271 | + | Some(serde_json::json!({ "title": "Hidden", "secret": true, "files": [{ "name": "notes.txt", "content": "secret plans\n" }] })), |
|
| 272 | + | ) |
|
| 273 | + | .await; |
|
| 274 | + | assert_eq!(r.status, StatusCode::CREATED, "{}", r.body); |
|
| 275 | + | let secret: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); |
|
| 276 | + | assert_eq!(secret.visibility, "secret"); |
|
| 277 | + | assert_eq!(secret.files[0].content.as_deref(), Some("secret plans\n")); |
|
| 278 | + | let secret_url = format!("/andy/drops/{}", secret.id); |
|
| 279 | + | assert_eq!(secret.url, format!("{}{secret_url}", h.base)); |
|
| 280 | + | // Anyone with the address reads it, signed in or not, on the web and the API. |
|
| 281 | + | let page = get(app, &secret_url, None).await; |
|
| 282 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 283 | + | assert!(page.body.contains("secret plans") && page.body.contains("listed nowhere"), "{}", page.body); |
|
| 284 | + | assert_eq!(get(app, &format!("{secret_url}/raw/main/notes.txt"), Some(&bob)).await.body, "secret plans\n"); |
|
| 285 | + | let r = api(app, "GET", &format!("/drops/{}", secret.id), None, None).await; |
|
| 286 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 287 | + | // But it is listed to andy only; the public one to everyone. |
|
| 288 | + | let mine = get(app, "/andy/drops", Some(&andy)).await.body; |
|
| 289 | + | assert!(mine.contains(&secret.id) && mine.contains(&id), "{mine}"); |
|
| 290 | + | for viewer in [Some(bob.as_str()), None] { |
|
| 291 | + | let theirs = get(app, "/andy/drops", viewer).await.body; |
|
| 292 | + | assert!(theirs.contains(&id) && theirs.contains("Hello"), "{theirs}"); |
|
| 293 | + | assert!(!theirs.contains(&secret.id) && !theirs.contains("Hidden"), "{theirs}"); |
|
| 294 | + | } |
|
| 295 | + | let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/drops", Some(&andy_tok), None).await.body).unwrap(); |
|
| 296 | + | assert_eq!(listed.len(), 2); |
|
| 297 | + | let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/users/andy/drops", Some(&bob_tok), None).await.body).unwrap(); |
|
| 298 | + | assert_eq!(listed.iter().map(|d| d.id.as_str()).collect::<Vec<_>>(), vec![id.as_str()]); |
|
| 299 | + | assert!(listed[0].files.iter().all(|f| f.content.is_none()), "a list carries no content"); |
|
| 300 | + | let listed: Vec<hub_api::Drop> = serde_json::from_str(&api(app, "GET", "/users/andy/drops", None, None).await.body).unwrap(); |
|
| 301 | + | assert_eq!(listed.len(), 1); |
|
| 302 | + | ||
| 303 | + | // Drops are never repositories: not on the profile, the dashboard, |
|
| 304 | + | // explore, search, the API's repository list or under /repos. |
|
| 305 | + | for (path, who) in [ |
|
| 306 | + | ("/andy", Some(&andy)), |
|
| 307 | + | ("/andy", None), |
|
| 308 | + | ("/", Some(&andy)), |
|
| 309 | + | ("/explore", None), |
|
| 310 | + | ("/explore", Some(&andy)), |
|
| 311 | + | ("/search?q=drops", Some(&andy)), |
|
| 312 | + | ] { |
|
| 313 | + | let body = get(app, path, who.map(String::as_str)).await.body; |
|
| 314 | + | assert!(!body.contains(&id) && !body.contains(&secret.id), "{path} lists a drop: {body}"); |
|
| 315 | + | } |
|
| 316 | + | for q in ["Hello", "Hidden", id.as_str()] { |
|
| 317 | + | let body = get(app, &format!("/search?q={q}"), Some(&andy)).await.body; |
|
| 318 | + | assert!(!body.contains(&format!("/andy/drops/{id}")) && !body.contains(&secret_url), "search {q}: {body}"); |
|
| 319 | + | } |
|
| 320 | + | for tok in [Some(andy_tok.as_str()), None] { |
|
| 321 | + | let repos = api(app, "GET", "/repos", tok, None).await.body; |
|
| 322 | + | assert!(!repos.contains("drops/"), "{repos}"); |
|
| 323 | + | } |
|
| 324 | + | assert_eq!(api(app, "GET", &format!("/repos/andy/drops%2F{id}"), Some(&andy_tok), None).await.status, StatusCode::NOT_FOUND); |
|
| 325 | + | assert!(db::repos::list(&app.db).await.unwrap().iter().all(|r| !r.is_drop())); |
|
| 326 | + | assert!(db::repos::list_by_owner(&app.db, "andy").await.unwrap().is_empty()); |
|
| 327 | + | assert_eq!(db::repos::list_with_drops(&app.db).await.unwrap().len(), 2, "upkeep sees them"); |
|
| 328 | + | // `drops` is no repository's name. |
|
| 329 | + | let csrf = csrf_of(&get(app, "/new", Some(&andy)).await.body); |
|
| 330 | + | let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=drops&visibility=public")).await; |
|
| 331 | + | assert!(r.status == StatusCode::OK && r.body.contains("class=\"error\""), "{}", r.body); |
|
| 332 | + | assert!(!app.repos.exists(&RepoName { owner: "andy".into(), name: "drops".into() })); |
|
| 333 | + | ||
| 334 | + | // `oxy clone` of a drop, anonymously, even a secret one; the owner pushes back. |
|
| 335 | + | let anon = tempfile::tempdir().unwrap(); |
|
| 336 | + | let (u, p) = (format!("{}{secret_url}", h.base), anon.path().join("c")); |
|
| 337 | + | let cloned = tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().unwrap(); |
|
| 338 | + | assert_eq!(std::fs::read_to_string(cloned.root().join("notes.txt")).unwrap(), "secret plans\n"); |
|
| 339 | + | client.config.set("remote.origin.url", &format!("{}{url}", h.base)).unwrap(); |
|
| 340 | + | client.config.set("remote.origin.token", &andy_tok).unwrap(); |
|
| 341 | + | client.save_config().unwrap(); |
|
| 342 | + | let (u, p, t) = (format!("{}{url}", h.base), work.path().join("mine"), andy_tok.clone()); |
|
| 343 | + | let mut mine = tokio::task::spawn_blocking(move || { |
|
| 344 | + | let mut r = Repo::clone(&u, &p).unwrap(); |
|
| 345 | + | r.config.set("remote.origin.token", &t).unwrap(); |
|
| 346 | + | r.save_config().unwrap(); |
|
| 347 | + | r |
|
| 348 | + | }) |
|
| 349 | + | .await |
|
| 350 | + | .unwrap(); |
|
| 351 | + | assert_eq!(std::fs::read_to_string(mine.root().join("main.rs")).unwrap(), changed); |
|
| 352 | + | mine.create_identity("andy", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 353 | + | write(mine.root(), "extra.sh", b"echo pushed\n"); |
|
| 354 | + | mine.snapshot("t").unwrap(); |
|
| 355 | + | let pushed = mine.commit("Add a script", &[]).unwrap(); |
|
| 356 | + | assert!(mine.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 357 | + | assert!(app.repos.open(&name).unwrap().store().get_change(&pushed).is_ok()); |
|
| 358 | + | let page = get(app, &url, None).await.body; |
|
| 359 | + | assert!(page.contains("extra.sh") && page.contains("> pushed<"), "{page}"); |
|
| 360 | + | // Someone else's token cannot push to it. |
|
| 361 | + | mine.config.set("remote.origin.token", &bob_tok).unwrap(); |
|
| 362 | + | mine.save_config().unwrap(); |
|
| 363 | + | write(mine.root(), "bob.txt", b"bob\n"); |
|
| 364 | + | mine.snapshot("t").unwrap(); |
|
| 365 | + | let bobs = mine.commit("bob's", &[]).unwrap(); |
|
| 366 | + | let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("bob may not push")).to_string(); |
|
| 367 | + | assert!(err.contains("403") || err.contains("write access"), "{err}"); |
|
| 368 | + | mine.abandon(&bobs).unwrap(); |
|
| 369 | + | mine.config.set("remote.origin.token", &andy_tok).unwrap(); |
|
| 370 | + | mine.save_config().unwrap(); |
|
| 371 | + | ||
| 372 | + | // Push protection refuses a drop push that adds a credential. |
|
| 373 | + | let ghp = format!("{}_{}", "ghp", random_hex(18)); |
|
| 374 | + | write(mine.root(), "token.env", format!("GH_TOKEN={ghp}\n").as_bytes()); |
|
| 375 | + | mine.snapshot("t").unwrap(); |
|
| 376 | + | let leak = mine.commit("oops", &[]).unwrap(); |
|
| 377 | + | let err = mine.push("origin", false).err().unwrap_or_else(|| panic!("the push was accepted")).to_string(); |
|
| 378 | + | assert!(err.contains("push protection refused this push") && err.contains("token.env:1"), "{err}"); |
|
| 379 | + | assert!(err.contains("A drop cannot allow a finding") && !err.contains(&ghp), "{err}"); |
|
| 380 | + | assert!(app.repos.open(&name).unwrap().store().get_change(&leak).is_err(), "nothing stored"); |
|
| 381 | + | mine.abandon(&leak).unwrap(); |
|
| 382 | + | // And on the web and the API, before the hub commits anything. |
|
| 383 | + | let csrf = csrf_of(&get(app, &format!("{url}/edit"), Some(&andy)).await.body); |
|
| 384 | + | let r = post( |
|
| 385 | + | app, |
|
| 386 | + | &format!("{url}/edit"), |
|
| 387 | + | Some(&andy), |
|
| 388 | + | &format!("_csrf={csrf}&title=Hello&visibility=public&name=README.md&content={}&name=t.env&content={}", enc(README), enc(&ghp)), |
|
| 389 | + | ) |
|
| 390 | + | .await; |
|
| 391 | + | assert_eq!(r.status, StatusCode::OK); |
|
| 392 | + | assert!(r.body.contains("Push protection refused") && r.body.contains("t.env:1"), "{}", r.body); |
|
| 393 | + | let r = api( |
|
| 394 | + | app, |
|
| 395 | + | "PATCH", |
|
| 396 | + | &format!("/drops/{id}"), |
|
| 397 | + | Some(&andy_tok), |
|
| 398 | + | Some(serde_json::json!({ "files": [{ "name": "t.env", "content": format!("X={ghp}\n") }] })), |
|
| 399 | + | ) |
|
| 400 | + | .await; |
|
| 401 | + | assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY, "{}", r.body); |
|
| 402 | + | assert!(!r.body.contains(&ghp)); |
|
| 403 | + | ||
| 404 | + | // Someone else can neither edit nor delete it, on the web or the API. |
|
| 405 | + | assert_eq!(get(app, &format!("{url}/edit"), Some(&bob)).await.status, StatusCode::NOT_FOUND); |
|
| 406 | + | let bob_csrf = csrf_of(&get(app, "/drops/new", Some(&bob)).await.body); |
|
| 407 | + | let r = post(app, &format!("{url}/edit"), Some(&bob), &format!("_csrf={bob_csrf}&title=Mine&name=x&content=y")).await; |
|
| 408 | + | assert_eq!(r.status, StatusCode::NOT_FOUND); |
|
| 409 | + | let r = post(app, &format!("{url}/delete"), Some(&bob), &format!("_csrf={bob_csrf}")).await; |
|
| 410 | + | assert_eq!(r.status, StatusCode::NOT_FOUND); |
|
| 411 | + | let r = api(app, "PATCH", &format!("/drops/{id}"), Some(&bob_tok), Some(serde_json::json!({ "title": "Mine" }))).await; |
|
| 412 | + | assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body); |
|
| 413 | + | assert_eq!(api(app, "DELETE", &format!("/drops/{id}"), Some(&bob_tok), None).await.status, StatusCode::FORBIDDEN); |
|
| 414 | + | assert_eq!(db::repos::drop_by_id(&app.db, &id).await.unwrap().unwrap().title, "Hello"); |
|
| 415 | + | ||
| 416 | + | // The owner edits through the API: a file, the title, public. |
|
| 417 | + | let r = api( |
|
| 418 | + | app, |
|
| 419 | + | "PATCH", |
|
| 420 | + | &format!("/drops/{}", secret.id), |
|
| 421 | + | Some(&andy_tok), |
|
| 422 | + | Some(serde_json::json!({ "title": "Not hidden", "secret": false, "files": [{ "name": "more.txt", "content": "more\n" }] })), |
|
| 423 | + | ) |
|
| 424 | + | .await; |
|
| 425 | + | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 426 | + | let d: hub_api::Drop = serde_json::from_str(&r.body).unwrap(); |
|
| 427 | + | assert_eq!((d.title.as_str(), d.visibility.as_str(), d.files.len()), ("Not hidden", "public", 2)); |
|
| 428 | + | assert!(get(app, "/andy/drops", None).await.body.contains(&secret.id)); |
|
| 429 | + | ||
| 430 | + | // Deleting: the owner on the web, its directory and row gone. |
|
| 431 | + | let csrf = csrf_of(&get(app, &url, Some(&andy)).await.body); |
|
| 432 | + | let r = post(app, &format!("{url}/delete"), Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 433 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 434 | + | assert!(db::repos::drop_by_id(&app.db, &id).await.unwrap().is_none()); |
|
| 435 | + | assert!(!app.repos.exists(&name)); |
|
| 436 | + | assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND); |
|
| 437 | + | let (u, p) = (format!("{}{url}", h.base), anon.path().join("gone")); |
|
| 438 | + | assert!(tokio::task::spawn_blocking(move || Repo::clone(&u, &p)).await.unwrap().is_err()); |
|
| 439 | + | // And through the API. |
|
| 440 | + | assert_eq!(api(app, "DELETE", &format!("/drops/{}", secret.id), Some(&andy_tok), None).await.status, StatusCode::NO_CONTENT); |
|
| 441 | + | assert_eq!(api(app, "GET", &format!("/drops/{}", secret.id), None, None).await.status, StatusCode::NOT_FOUND); |
|
| 442 | + | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 443 | + | assert!(audit.iter().filter(|a| a.action == "drop.deleted").count() == 2, "{audit:?}"); |
|
| 444 | + | assert!(audit.iter().any(|a| a.action == "drop.created") && audit.iter().any(|a| a.action == "drop.edited")); |
|
| 445 | + | } |
|
| 446 | + | ||
| 447 | + | /// A site administrator may delete someone's drop, and sees their secret |
|
| 448 | + | /// ones listed; the drop's owner alone may change it. |
|
| 449 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 450 | + | async fn a_site_admin_may_delete_a_drop() { |
|
| 451 | + | let h = hub().await; |
|
| 452 | + | let app = &h.app; |
|
| 453 | + | let admin = register(app, "andy").await; |
|
| 454 | + | let carol = register(app, "carol").await; |
|
| 455 | + | assert!(db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().is_admin); |
|
| 456 | + | let work = tempfile::tempdir().unwrap(); |
|
| 457 | + | delegate(app, "carol", work.path()).await; |
|
| 458 | + | let csrf = csrf_of(&get(app, "/drops/new", Some(&carol)).await.body); |
|
| 459 | + | let r = post(app, "/drops/new", Some(&carol), &format!("_csrf={csrf}&visibility=secret&name=a.txt&content=a")).await; |
|
| 460 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 461 | + | let url = r.location.unwrap(); |
|
| 462 | + | // Untitled, it is called by its first file. |
|
| 463 | + | assert!(get(app, &url, None).await.body.contains("<h1>a.txt")); |
|
| 464 | + | assert!(get(app, "/carol/drops", Some(&admin)).await.body.contains(&drop_id(&url)), "admins see secret drops listed"); |
|
| 465 | + | // The admin may not change it: no Edit, and the form and the API refuse. |
|
| 466 | + | let page = get(app, &url, Some(&admin)).await.body; |
|
| 467 | + | assert!(!page.contains(">EDIT<") && page.contains(">DELETE<"), "{page}"); |
|
| 468 | + | assert_eq!(get(app, &format!("{url}/edit"), Some(&admin)).await.status, StatusCode::NOT_FOUND); |
|
| 469 | + | let admin_tok = token(app, "andy").await; |
|
| 470 | + | let r = api(app, "PATCH", &format!("/drops/{}", drop_id(&url)), Some(&admin_tok), Some(serde_json::json!({ "title": "x" }))).await; |
|
| 471 | + | assert_eq!(r.status, StatusCode::FORBIDDEN, "{}", r.body); |
|
| 472 | + | let csrf = csrf_of(&page); |
|
| 473 | + | let r = post(app, &format!("{url}/delete"), Some(&admin), &format!("_csrf={csrf}")).await; |
|
| 474 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 475 | + | assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND); |
|
| 476 | + | } |
added templates/drop.html +36 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}{{ title }} · {{ repo.owner }}'s drop{% 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>{{ title }}{% if !at_default %} <small>at {{ target }}</small>{% endif %}</h1> |
|
| 9 | + | {% if can_edit || can_delete %}<div class="choice">{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %} |
|
| 10 | + | <form class="inline" method="post" action="{{ repo.url }}/delete" data-confirm="Delete this drop and its history? Its address stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="danger-btn">DELETE</button></form></div>{% endif %} |
|
| 11 | + | </div> |
|
| 12 | + | {% if !description.is_empty() %}<p>{{ description }}</p>{% endif %} |
|
| 13 | + | <p class="small mute">A drop by <a href="/{{ repo.owner }}">{{ repo.owner }}</a> · {{ files.len() }} file{% if files.len() != 1 %}s{% endif %} · created <time title="{{ created_ms|when_i }}">{{ created_ms|ago_i }}</time> · updated <time title="{{ updated_ms|when_i }}">{{ updated_ms|ago_i }}</time> · <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}">history</a></p> |
|
| 14 | + | {% if secret %}<div class="strip">Secret: this drop is listed nowhere, but anyone with its address can read it.</div>{% endif %} |
|
| 15 | + | <p class="small"><label for="clone">Clone</label> <input type="text" id="clone" class="code" readonly value="oxy clone {{ clone_url }}" data-select size="60"></p> |
|
| 16 | + | {% if files.is_empty() %}<p class="empty">No files.</p>{% endif %} |
|
| 17 | + | {% for f in files %} |
|
| 18 | + | <section class="filebox drop-filebox" id="{{ f.anchor }}"> |
|
| 19 | + | <header> |
|
| 20 | + | <strong>{{ f.path }}</strong> <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = f.language %} · {{ l }}{% endif %}</span> |
|
| 21 | + | <span class="modes"><a href="{{ repo.url }}/raw/{{ target }}/{{ f.path }}">raw</a><a href="{{ repo.url }}/changes/{{ target }}?path={{ f.path }}">history</a></span> |
|
| 22 | + | </header> |
|
| 23 | + | {% if let Some(html) = f.markdown %} |
|
| 24 | + | <article class="doc">{{ html|safe }}</article> |
|
| 25 | + | {% else if let Some(note) = f.note %} |
|
| 26 | + | <p class="empty">{{ note }} <a href="{{ repo.url }}/raw/{{ target }}/{{ f.path }}">Download it</a>.</p> |
|
| 27 | + | {% else %} |
|
| 28 | + | <table class="code"> |
|
| 29 | + | {% for line in f.lines %} |
|
| 30 | + | <tr id="{{ f.anchor }}-L{{ loop.index }}"><td class="n">{{ loop.index }}</td><td class="t"><pre>{{ line|safe }}</pre></td></tr> |
|
| 31 | + | {% endfor %} |
|
| 32 | + | </table> |
|
| 33 | + | {% endif %} |
|
| 34 | + | </section> |
|
| 35 | + | {% endfor %} |
|
| 36 | + | {% endblock %} |
added templates/drop_form.html +34 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}{% if editing %}Edit drop{% else %}New drop{% endif %}{% endblock %} |
|
| 3 | + | {% block mainclass %} class="wide"{% endblock %} |
|
| 4 | + | {% block content %} |
|
| 5 | + | <div class="head"><h1>{% if editing %}Edit drop{% else %}New drop{% endif %} <small>{% if editing %}saved as one signed change{% else %}a small repository of its own, never listed among your repositories{% endif %}</small></h1></div> |
|
| 6 | + | {% if let Some(d) = delegation %}<div class="strip warn">{{ d }}</div>{% endif %} |
|
| 7 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 8 | + | <form class="card wide editor drop-form" method="post" action="{{ action }}"> |
|
| 9 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 10 | + | <label for="title">Title</label><input type="text" id="title" name="title" value="{{ title }}" maxlength="200" placeholder="optional: the first file's name otherwise"> |
|
| 11 | + | <label for="description">Description</label><input type="text" id="description" name="description" value="{{ description }}" maxlength="2000"> |
|
| 12 | + | <label>Visibility</label> |
|
| 13 | + | <div class="choice"> |
|
| 14 | + | <label><input type="radio" name="visibility" value="public"{% if !secret %} checked{% endif %}> public: listed on your Drops page</label> |
|
| 15 | + | <label><input type="radio" name="visibility" value="secret"{% if secret %} checked{% endif %}> secret: listed nowhere, readable by anyone with its address</label> |
|
| 16 | + | </div> |
|
| 17 | + | <div data-drop-files> |
|
| 18 | + | {% for (name, content) in files %} |
|
| 19 | + | <fieldset class="drop-file" data-drop-file> |
|
| 20 | + | <div class="row"><div><label>File name</label><input type="text" name="name" value="{{ name }}" placeholder="notes.md" maxlength="255"></div><div class="drop-file-actions"><button type="button" class="small" data-drop-remove hidden>REMOVE</button></div></div> |
|
| 21 | + | <textarea class="code" name="content" rows="14" spellcheck="false">{{ content }}</textarea> |
|
| 22 | + | </fieldset> |
|
| 23 | + | {% endfor %} |
|
| 24 | + | </div> |
|
| 25 | + | <p><button type="button" class="small" data-drop-add hidden>+ ADD FILE</button> <span class="small mute">A file left without a name and content is ignored{% if editing %}; one whose name you clear is removed{% endif %}.</span></p> |
|
| 26 | + | {% if !kept.is_empty() %}<p class="small mute">Kept as they are (not text, or pushed with oxy): {% for k in kept %}<code>{{ k }}</code>{% if !loop.last %}, {% endif %}{% endfor %}.</p>{% endif %} |
|
| 27 | + | {% if editing %}<label for="message">Change message</label><input type="text" id="message" name="message" value="{{ message }}" placeholder="Edit {{ title }}">{% endif %} |
|
| 28 | + | <p class="small mute">The change is authored by your linked key and signed by this hub's key as your delegate. Push protection refuses anything that looks like a credential.</p> |
|
| 29 | + | <div class="choice"> |
|
| 30 | + | <button type="submit"{% if delegation.is_some() %} disabled{% endif %}>{% if editing %}SAVE{% else %}CREATE DROP{% endif %}</button> |
|
| 31 | + | <a class="go" href="{{ cancel }}">CANCEL</a> |
|
| 32 | + | </div> |
|
| 33 | + | </form> |
|
| 34 | + | {% endblock %} |
added templates/drops.html +26 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Drops · {{ owner }}{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1><a href="/{{ owner }}">{{ owner }}</a> / Drops <small>{% if is_me %}snippets of one or more files, each its own repository{% else %}public drops{% endif %}</small></h1>{% if is_me %}<a class="go primary" href="/drops/new">+ NEW DROP</a>{% endif %}</div> |
|
| 5 | + | {% if let Some(n) = notice %}<div class="strip">{{ n }}</div>{% endif %} |
|
| 6 | + | {% if is_me %}<p class="small mute">A drop is a small Oxygen repository: every edit is a signed change with history, and <code>oxy clone</code> takes its address. Drops never appear among your repositories. A secret drop is listed nowhere and readable by anyone with its address.</p>{% endif %} |
|
| 7 | + | <section class="card-box"><div class="card-head"><h2>Drops</h2><span class="count-chip">{{ drops.len() }}</span></div> |
|
| 8 | + | {% if drops.is_empty() %} |
|
| 9 | + | <p class="empty small">{% if is_me %}No drops yet.{% else %}No public drops.{% endif %}</p> |
|
| 10 | + | {% else %} |
|
| 11 | + | <ul class="rows drops"> |
|
| 12 | + | {% for d in drops %} |
|
| 13 | + | <li> |
|
| 14 | + | <div> |
|
| 15 | + | <a class="name" href="{{ d.url }}"><strong>{{ d.title }}</strong></a> |
|
| 16 | + | {% if d.secret %}<span class="tag" title="Listed nowhere; readable by anyone with its address">secret</span>{% endif %} |
|
| 17 | + | {% if !d.description.is_empty() %}<p class="small">{{ d.description }}</p>{% endif %} |
|
| 18 | + | <p class="small mute">{% for f in d.files %}<code>{{ f }}</code>{% if !loop.last %} · {% endif %}{% endfor %}</p> |
|
| 19 | + | </div> |
|
| 20 | + | <div class="right small">updated <time title="{{ d.updated_ms|when_i }}">{{ d.updated_ms|ago_i }}</time></div> |
|
| 21 | + | </li> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </ul> |
|
| 24 | + | {% endif %} |
|
| 25 | + | </section> |
|
| 26 | + | {% endblock %} |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Drops: snippets of files, each its own small repository (#279):** a drop is one or more files, as a gist is, kept as an Oxygen repository of its own at `/{owner}/drops/{id}` (a 24-hex-digit id), so every edit is a signed change with history and diffs, and `oxy clone` and `oxy push` take its address. Make one at `/drops/new` (title, description, files, public or secret), with `POST /api/v1/drops`, or with `oh drop create FILE… [--secret] [-d TEXT] [-t TITLE]`; the hub commits it as your delegate, as web edits are, so you delegate to the hub's key first. Your Drops page (`/drops`, under your profile and in the site band) lists and manages them; someone else's shows their public drops only. A secret drop is listed nowhere but readable by anyone with its address. Drops never appear among repositories (profiles, the dashboard, Explore, search, `GET /api/v1/repos`); Admin › Storage labels them. Only the owner edits a drop; the owner or a site administrator deletes it. Push protection covers drop pushes and what the web and the API commit. API: `GET`/`POST /drops`, `GET /users/{name}/drops`, `GET`/`PATCH`/`DELETE /drops/{id}`; `oh drop list|view|delete`. `drops` is now a reserved name, for owners and for repositories. Not yet: forks, stars, comments, embedding, and drops in backups. |
|
| 7 | 8 | - **Proposal lists and the proposals API are much faster on large files:** listing a repository's proposals rendered every open proposal's full diff and looked up each new line's history separately, recomputing a whole file's lines for every line; lists, the API, auto-land and the checks after a push now skip the rendering, and the proposal page computes each file's lines once. |
|
| 8 | 9 | - **Keyless agents can push release tags (andymccall/oxygen#129):** A tag signed by a key the hub certified (an agent's or a Flow job's ten-minute key) was refused "unknown key", and then refused by the policy. With oxy's fix the hub accepts it under the policy's subject grant and countersigns when it accepted it, as it does a bookmark move. It logs the tag's acceptance in the transparency log beside the tag, and serves both with the tag in every refs answer, so a client fetching the tag months later checks the certificate at the hub's time. Runners read repositories with their own oxy, so they need rebuilding to check such tags; older ones ignore the new fields. |
|
| 9 | 10 | - **Secret scanning finds what is already in history (#277):** push protection's rules now also run over each repository's existing history in the background (every change reachable from its bookmarks: the lines it inserts and the text files it replaces), so a credential committed before push protection existed, or while it was off, is found too. Every repository's whole history is scanned once after this deploy, then only what each push adds, a budget at a time (`[limits] history_scan_limit`, 64 MiB, and `history_scan_time_ms`, 5 s, per repository per pass; `0` turns it off), at low priority, resuming where it stopped. Maintainers see the findings, with the change, bookmark, file and line, under Settings › Push protection › Found in history, and either allow one (not a secret) or mark it resolved with how it was revoked (audited as `secret.resolved`); "Scan history now" scans the whole history again. An allowed fingerprint can now be removed (audited as `secret.bypass_removed`), so pushes adding that value are refused again. Repositories with push protection off are not scanned. |
|
| … | 256 unchanged lines |
modified README.md +7 −0
| … | 28 unchanged lines |
||
| 29 | 29 | and source archives; a read-only Git mirror for Git-only tools; and code |
|
| 30 | 30 | search. The runner follows in 0.7. |
|
| 31 | 31 | ||
| 32 | + | **Drops** (oxygenhub#279) are gist-like snippets of one or more files, each |
|
| 33 | + | its own small Oxygen repository: made and edited in the browser, through |
|
| 34 | + | the API or with `oh drop`, every edit a signed change with history, |
|
| 35 | + | cloned and pushed with `oxy`, public or secret (listed nowhere, readable |
|
| 36 | + | by anyone with the address), and never listed among your repositories. |
|
| 37 | + | See `/drops` and docs/spec.md §4.5. |
|
| 38 | + | ||
| 32 | 39 | ## Install oh |
|
| 33 | 40 | ||
| 34 | 41 | Builds for Linux, macOS and Windows are on the [releases page](https://oxygenhub.io/andymccall/oxygenhub/releases). These are early releases: the macOS and Windows builds are not signed and have had little testing. |
|
| … | 54 unchanged lines |
modified assets/hub.js +28 −0
| … | 915 unchanged lines |
||
| 916 | 916 | sel.addEventListener("change", show); |
|
| 917 | 917 | show(); |
|
| 918 | 918 | })(); |
|
| 919 | + | ||
| 920 | + | /* Drops (oxygenhub#279): add and remove file rows on the drop form. |
|
| 921 | + | Without this file the form has its rows and one empty one; more come |
|
| 922 | + | by saving and editing again. */ |
|
| 923 | + | (function () { |
|
| 924 | + | var list = document.querySelector("[data-drop-files]"); |
|
| 925 | + | var add = document.querySelector("[data-drop-add]"); |
|
| 926 | + | if (!list || !add) return; |
|
| 927 | + | function wire(row) { |
|
| 928 | + | var rm = row.querySelector("[data-drop-remove]"); |
|
| 929 | + | if (!rm) return; |
|
| 930 | + | rm.hidden = false; |
|
| 931 | + | rm.addEventListener("click", function () { |
|
| 932 | + | if (list.querySelectorAll("[data-drop-file]").length > 1) row.remove(); |
|
| 933 | + | else row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; }); |
|
| 934 | + | }); |
|
| 935 | + | } |
|
| 936 | + | list.querySelectorAll("[data-drop-file]").forEach(wire); |
|
| 937 | + | add.hidden = false; |
|
| 938 | + | add.addEventListener("click", function () { |
|
| 939 | + | var rows = list.querySelectorAll("[data-drop-file]"); |
|
| 940 | + | var row = rows[rows.length - 1].cloneNode(true); |
|
| 941 | + | row.querySelectorAll("input, textarea").forEach(function (f) { f.value = ""; f.textContent = ""; }); |
|
| 942 | + | list.appendChild(row); |
|
| 943 | + | wire(row); |
|
| 944 | + | row.querySelector("input").focus(); |
|
| 945 | + | }); |
|
| 946 | + | })(); |
|
| 919 | 947 | })(); |
modified crates/hub-api/src/lib.rs +81 −0
| … | 966 unchanged lines |
||
| 967 | 967 | pub prerelease: Option<bool>, |
|
| 968 | 968 | } |
|
| 969 | 969 | ||
| 970 | + | /// A drop (oxygenhub#279): a small snippet of one or more files, its own |
|
| 971 | + | /// Oxygen repository, never listed among repositories. `GET /drops` lists |
|
| 972 | + | /// yours, `GET /users/{name}/drops` someone's public ones, and |
|
| 973 | + | /// `GET /drops/{id}` one with its files' content. |
|
| 970 | 974 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 975 | + | pub struct Drop { |
|
| 976 | + | /// 24 hex digits, unique on the hub. |
|
| 977 | + | pub id: String, |
|
| 978 | + | pub owner: String, |
|
| 979 | + | pub title: String, |
|
| 980 | + | pub description: String, |
|
| 981 | + | /// `public` (listed on its owner's Drops page) or `secret` (unlisted: |
|
| 982 | + | /// readable by anyone with its address). |
|
| 983 | + | pub visibility: String, |
|
| 984 | + | /// The drop's page. |
|
| 985 | + | pub url: String, |
|
| 986 | + | /// What `oxy clone` takes. |
|
| 987 | + | pub clone_url: String, |
|
| 988 | + | pub created_ms: i64, |
|
| 989 | + | pub updated_ms: i64, |
|
| 990 | + | pub files: Vec<DropFile>, |
|
| 991 | + | } |
|
| 992 | + | ||
| 993 | + | /// One of a drop's files. Lists carry no content; one drop's view carries |
|
| 994 | + | /// the content of each text file up to 512 KiB. |
|
| 995 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 996 | + | pub struct DropFile { |
|
| 997 | + | pub name: String, |
|
| 998 | + | pub size: u64, |
|
| 999 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1000 | + | pub content: Option<String>, |
|
| 1001 | + | /// The file's bytes on the default bookmark. |
|
| 1002 | + | pub raw_url: String, |
|
| 1003 | + | } |
|
| 1004 | + | ||
| 1005 | + | /// `POST /drops`: a new drop with at least one file. |
|
| 1006 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1007 | + | pub struct NewDrop { |
|
| 1008 | + | #[serde(default)] |
|
| 1009 | + | pub title: String, |
|
| 1010 | + | #[serde(default)] |
|
| 1011 | + | pub description: String, |
|
| 1012 | + | /// Unlisted, readable by anyone with its address. |
|
| 1013 | + | #[serde(default)] |
|
| 1014 | + | pub secret: bool, |
|
| 1015 | + | pub files: Vec<NewDropFile>, |
|
| 1016 | + | } |
|
| 1017 | + | ||
| 1018 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1019 | + | pub struct NewDropFile { |
|
| 1020 | + | pub name: String, |
|
| 1021 | + | pub content: String, |
|
| 1022 | + | } |
|
| 1023 | + | ||
| 1024 | + | /// `PATCH /drops/{id}`: what to change; what is left out stays. |
|
| 1025 | + | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1026 | + | pub struct DropEdit { |
|
| 1027 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1028 | + | pub title: Option<String>, |
|
| 1029 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1030 | + | pub description: Option<String>, |
|
| 1031 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1032 | + | pub secret: Option<bool>, |
|
| 1033 | + | /// Files to write, or to remove (`content` null); others are kept. |
|
| 1034 | + | #[serde(default)] |
|
| 1035 | + | pub files: Vec<DropFileEdit>, |
|
| 1036 | + | /// The change's message (default "Edit TITLE"). |
|
| 1037 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 1038 | + | pub message: Option<String>, |
|
| 1039 | + | } |
|
| 1040 | + | ||
| 1041 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 1042 | + | pub struct DropFileEdit { |
|
| 1043 | + | pub name: String, |
|
| 1044 | + | #[serde(default)] |
|
| 1045 | + | pub content: Option<String>, |
|
| 1046 | + | } |
|
| 1047 | + | ||
| 1048 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] |
|
| 971 | 1049 | pub struct NewRelease { |
|
| 972 | 1050 | pub tag: String, |
|
| 973 | 1051 | pub title: String, |
|
| … | 124 unchanged lines |
||
| 1098 | 1176 | ("AssetBuild", schemars::schema_for!(AssetBuild)), |
|
| 1099 | 1177 | ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)), |
|
| 1100 | 1178 | ("NewRelease", schemars::schema_for!(NewRelease)), |
|
| 1179 | + | ("Drop", schemars::schema_for!(Drop)), |
|
| 1180 | + | ("NewDrop", schemars::schema_for!(NewDrop)), |
|
| 1181 | + | ("DropEdit", schemars::schema_for!(DropEdit)), |
|
| 1101 | 1182 | ] |
|
| 1102 | 1183 | } |
|
| 1103 | 1184 | ||
| … | 216 unchanged lines |
modified crates/hub-api/ts/index.d.ts +47 −0
| … | 133 unchanged lines |
||
| 134 | 134 | export type Proposal = { |
|
| 135 | 135 | approvals: Approval[]; |
|
| 136 | 136 | author: string; |
|
| 137 | + | auto_land?: string | null; |
|
| 137 | 138 | blockers: string[]; |
|
| 138 | 139 | body: string; |
|
| 139 | 140 | changes: ChangeSummary[]; |
|
| … | 387 unchanged lines |
||
| 527 | 528 | export type AssetBuild = { |
|
| 528 | 529 | artifact: string; |
|
| 529 | 530 | job: string; |
|
| 531 | + | provenance_url?: string | null; |
|
| 530 | 532 | receipt?: string | null; |
|
| 531 | 533 | receipt_url?: string | null; |
|
| 532 | 534 | run: number; |
|
| … | 71 unchanged lines |
||
| 604 | 606 | title: string; |
|
| 605 | 607 | }; |
|
| 606 | 608 | ||
| 609 | + | export type DropFile = { |
|
| 610 | + | content?: string | null; |
|
| 611 | + | name: string; |
|
| 612 | + | raw_url: string; |
|
| 613 | + | size: number; |
|
| 614 | + | }; |
|
| 615 | + | ||
| 616 | + | export type Drop = { |
|
| 617 | + | clone_url: string; |
|
| 618 | + | created_ms: number; |
|
| 619 | + | description: string; |
|
| 620 | + | files: DropFile[]; |
|
| 621 | + | id: string; |
|
| 622 | + | owner: string; |
|
| 623 | + | title: string; |
|
| 624 | + | updated_ms: number; |
|
| 625 | + | url: string; |
|
| 626 | + | visibility: string; |
|
| 627 | + | }; |
|
| 628 | + | ||
| 629 | + | export type NewDropFile = { |
|
| 630 | + | content: string; |
|
| 631 | + | name: string; |
|
| 632 | + | }; |
|
| 633 | + | ||
| 634 | + | export type NewDrop = { |
|
| 635 | + | description?: string; |
|
| 636 | + | files: NewDropFile[]; |
|
| 637 | + | secret?: boolean; |
|
| 638 | + | title?: string; |
|
| 639 | + | }; |
|
| 640 | + | ||
| 641 | + | export type DropFileEdit = { |
|
| 642 | + | content?: string | null; |
|
| 643 | + | name: string; |
|
| 644 | + | }; |
|
| 645 | + | ||
| 646 | + | export type DropEdit = { |
|
| 647 | + | description?: string | null; |
|
| 648 | + | files?: DropFileEdit[]; |
|
| 649 | + | message?: string | null; |
|
| 650 | + | secret?: boolean | null; |
|
| 651 | + | title?: string | null; |
|
| 652 | + | }; |
|
| 653 | + |
modified crates/hub/src/api/mod.rs +8 −0
| … | 16 unchanged lines |
||
| 17 | 17 | ||
| 18 | 18 | mod backups; |
|
| 19 | 19 | pub mod device; |
|
| 20 | + | mod drops; |
|
| 20 | 21 | mod images; |
|
| 21 | 22 | mod projects; |
|
| 22 | 23 | mod reports; |
|
| … | 72 unchanged lines |
||
| 95 | 96 | .route("/notifications/read", post(notifications_read_all)) |
|
| 96 | 97 | .route("/notifications/{id}/read", post(notification_read)) |
|
| 97 | 98 | .route("/users/{name}", get(user)) |
|
| 99 | + | .route("/users/{name}/drops", get(drops::of_user)) |
|
| 100 | + | .route("/drops", get(drops::mine).post(drops::create)) |
|
| 101 | + | .route("/drops/{id}", get(drops::view).patch(drops::edit).delete(drops::delete)) |
|
| 98 | 102 | .route("/orgs/{name}", get(org)) |
|
| 99 | 103 | .route("/search", get(search)) |
|
| 100 | 104 | .route("/repos", get(list_repos).post(create_repo)) |
|
| … | 314 unchanged lines |
||
| 415 | 419 | ) -> Result<(RepoName, db::repos::RepoRow, Role), ApiError> { |
|
| 416 | 420 | let repo = RepoName::new(owner, name).ok_or_else(|| not_found("no such repository"))?; |
|
| 417 | 421 | let row = db::repos::get(&app.db, owner, name).await?.ok_or_else(|| not_found("no such repository"))?; |
|
| 422 | + | // A drop is reached through `/drops` only (#279). |
|
| 423 | + | if row.is_drop() { |
|
| 424 | + | return Err(not_found("no such repository")); |
|
| 425 | + | } |
|
| 418 | 426 | let role = auth::roles::role_for(&app.db, c.account.as_ref(), &row).await?; |
|
| 419 | 427 | if !auth::roles::allows(role, access) { |
|
| 420 | 428 | return Err(not_found("no such repository")); |
|
| … | 2377 unchanged lines |
modified crates/hub/src/auth/roles.rs +10 −0
| … | 62 unchanged lines |
||
| 63 | 63 | /// The role `account` (or nobody) holds on `repo`, from ownership, |
|
| 64 | 64 | /// organisation membership, collaborator entries and visibility. |
|
| 65 | 65 | pub async fn role_for(pool: &Pool, account: Option<&Account>, repo: &RepoRow) -> sqlx::Result<Option<Role>> { |
|
| 66 | + | // A drop (#279): anyone with its address reads it, a secret one too |
|
| 67 | + | // (secret only keeps it off lists); only its owner writes. A site |
|
| 68 | + | // administrator may delete it (`drops::may_delete`), nothing more. |
|
| 69 | + | if repo.is_drop() { |
|
| 70 | + | return Ok(match account { |
|
| 71 | + | Some(a) if !a.blocked() && a.username.eq_ignore_ascii_case(&repo.owner) => Some(Role::Admin), |
|
| 72 | + | Some(a) if a.blocked() => None, |
|
| 73 | + | _ => Some(Role::Read), |
|
| 74 | + | }); |
|
| 75 | + | } |
|
| 66 | 76 | let visibility = repo.visibility(); |
|
| 67 | 77 | let Some(account) = account else { |
|
| 68 | 78 | return Ok(if visibility == Visibility::Public { Some(Role::Read) } else { None }); |
|
| … | 50 unchanged lines |
modified crates/hub/src/backups.rs +3 −1
| … | 177 unchanged lines |
||
| 178 | 178 | pub async fn overview(pool: &Pool) -> sqlx::Result<(Vec<Overview>, Vec<(String, i64)>)> { |
|
| 179 | 179 | let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings ORDER BY owner").fetch_all(pool).await?; |
|
| 180 | 180 | let counts: Vec<(String, i64)> = |
|
| 181 | - | sqlx::query_as("SELECT owner, COUNT(*) FROM repositories GROUP BY owner ORDER BY owner").fetch_all(pool).await?; |
|
| 181 | + | sqlx::query_as("SELECT owner, COUNT(*) FROM repositories WHERE kind = 'repo' GROUP BY owner ORDER BY owner") |
|
| 182 | + | .fetch_all(pool) |
|
| 183 | + | .await?; |
|
| 182 | 184 | let day_ago = db::now_ms() - 86_400_000; |
|
| 183 | 185 | let mut out = Vec::new(); |
|
| 184 | 186 | for s in settings { |
|
| … | 1775 unchanged lines |
modified crates/hub/src/db/accounts.rs +4 −3
| … | 816 unchanged lines |
||
| 817 | 817 | } |
|
| 818 | 818 | ||
| 819 | 819 | /// The repositories an account can see beyond the public ones: its own, |
|
| 820 | - | /// its organisations', and those it collaborates on. |
|
| 820 | + | /// its organisations', and those it collaborates on. Drops are not |
|
| 821 | + | /// repositories here (#279). |
|
| 821 | 822 | pub async fn visible_repo_ids(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<i64>> { |
|
| 822 | 823 | sqlx::query_scalar( |
|
| 823 | - | "SELECT r.id FROM repositories r WHERE r.owner IN (SELECT username FROM accounts WHERE id = $1) \ |
|
| 824 | + | "SELECT r.id FROM repositories r WHERE r.kind = 'repo' AND (r.owner IN (SELECT username FROM accounts WHERE id = $1) \ |
|
| 824 | 825 | OR r.owner IN (SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = $2) \ |
|
| 825 | - | OR r.id IN (SELECT repo_id FROM collaborators WHERE account_id = $3 OR team_id IN (SELECT team_id FROM team_members WHERE account_id = $4))", |
|
| 826 | + | OR r.id IN (SELECT repo_id FROM collaborators WHERE account_id = $3 OR team_id IN (SELECT team_id FROM team_members WHERE account_id = $4)))", |
|
| 826 | 827 | ) |
|
| 827 | 828 | .bind(account_id) |
|
| 828 | 829 | .bind(account_id) |
|
| … | 214 unchanged lines |
modified crates/hub/src/db/repos.rs +92 −2
| … | 58 unchanged lines |
||
| 59 | 59 | /// 0085). On by default. |
|
| 60 | 60 | #[sqlx(default)] |
|
| 61 | 61 | pub push_protection: bool, |
|
| 62 | + | /// `repo`, or `drop` for a drop (#279, migration 0087): a small |
|
| 63 | + | /// repository named `drops/ID` that no repository list shows. |
|
| 64 | + | #[sqlx(default)] |
|
| 65 | + | pub kind: String, |
|
| 66 | + | /// A drop's title. |
|
| 67 | + | #[sqlx(default)] |
|
| 68 | + | pub title: String, |
|
| 62 | 69 | /// Counts for the tabs; filled by `get` only. |
|
| 63 | 70 | #[sqlx(default)] |
|
| 64 | 71 | pub open_issues: i64, |
|
| … | 12 unchanged lines |
||
| 77 | 84 | ||
| 78 | 85 | pub fn slug(&self) -> String { |
|
| 79 | 86 | format!("{}/{}", self.owner, self.name) |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | /// A drop (#279), not a repository. |
|
| 90 | + | pub fn is_drop(&self) -> bool { |
|
| 91 | + | self.kind == DROP |
|
| 80 | 92 | } |
|
| 81 | 93 | } |
|
| 94 | + | ||
| 95 | + | /// `repositories.kind` of a drop (#279). |
|
| 96 | + | pub const DROP: &str = "drop"; |
|
| 82 | 97 | ||
| 83 | 98 | #[derive(Clone, Debug, FromRow)] |
|
| 84 | 99 | pub struct PushRow { |
|
| … | 21 unchanged lines |
||
| 106 | 121 | pub changes: i64, |
|
| 107 | 122 | } |
|
| 108 | 123 | ||
| 124 | + | /// Every repository, drops left out: what every list, search and count |
|
| 125 | + | /// of repositories starts from (#279). |
|
| 109 | 126 | pub async fn list(pool: &Pool) -> sqlx::Result<Vec<RepoRow>> { |
|
| 127 | + | sqlx::query_as("SELECT * FROM repositories WHERE kind = 'repo' ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | /// Every repository and every drop: for upkeep that covers all stored |
|
| 131 | + | /// repositories (gc, the history scan, `oxygenhub check`), never a list |
|
| 132 | + | /// people see unless it labels the drops. |
|
| 133 | + | pub async fn list_with_drops(pool: &Pool) -> sqlx::Result<Vec<RepoRow>> { |
|
| 110 | 134 | sqlx::query_as("SELECT * FROM repositories ORDER BY updated_ms DESC, owner, name").fetch_all(pool).await |
|
| 111 | 135 | } |
|
| 112 | 136 | ||
| … | 2 unchanged lines |
||
| 115 | 139 | } |
|
| 116 | 140 | ||
| 117 | 141 | pub async fn list_by_owner(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> { |
|
| 118 | - | sqlx::query_as("SELECT * FROM repositories WHERE owner = $1 ORDER BY updated_ms DESC, name").bind(owner).fetch_all(pool).await |
|
| 142 | + | sqlx::query_as("SELECT * FROM repositories WHERE owner = $1 AND kind = 'repo' ORDER BY updated_ms DESC, name") |
|
| 143 | + | .bind(owner) |
|
| 144 | + | .fetch_all(pool) |
|
| 145 | + | .await |
|
| 119 | 146 | } |
|
| 147 | + | ||
| 148 | + | // ------------------------------------------------------------------ drops |
|
| 120 | 149 | ||
| 150 | + | /// A drop's row (#279): a repository named `drops/ID`, its kind `drop`, |
|
| 151 | + | /// push protection on and Flow off. `visibility` is `public`, or |
|
| 152 | + | /// `private` for a secret drop. |
|
| 153 | + | pub async fn create_drop( |
|
| 154 | + | pool: &Pool, |
|
| 155 | + | owner: &str, |
|
| 156 | + | id: &str, |
|
| 157 | + | title: &str, |
|
| 158 | + | description: &str, |
|
| 159 | + | visibility: Visibility, |
|
| 160 | + | ) -> sqlx::Result<RepoRow> { |
|
| 161 | + | let now = now_ms(); |
|
| 162 | + | sqlx::query_as( |
|
| 163 | + | "INSERT INTO repositories (owner, name, description, visibility, created_ms, updated_ms, kind, title, flow) \ |
|
| 164 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'off') RETURNING *", |
|
| 165 | + | ) |
|
| 166 | + | .bind(owner) |
|
| 167 | + | .bind(format!("{}/{id}", crate::repos::names::DROPS)) |
|
| 168 | + | .bind(description) |
|
| 169 | + | .bind(visibility.as_str()) |
|
| 170 | + | .bind(now) |
|
| 171 | + | .bind(now) |
|
| 172 | + | .bind(DROP) |
|
| 173 | + | .bind(title) |
|
| 174 | + | .fetch_one(pool) |
|
| 175 | + | .await |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | /// An owner's drops, newest activity first. |
|
| 179 | + | pub async fn drops_of(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> { |
|
| 180 | + | sqlx::query_as("SELECT * FROM repositories WHERE kind = 'drop' AND owner = $1 ORDER BY updated_ms DESC, id DESC") |
|
| 181 | + | .bind(owner) |
|
| 182 | + | .fetch_all(pool) |
|
| 183 | + | .await |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// The drop with this id, whoever owns it: ids are random and made |
|
| 187 | + | /// unique across the hub. |
|
| 188 | + | pub async fn drop_by_id(pool: &Pool, id: &str) -> sqlx::Result<Option<RepoRow>> { |
|
| 189 | + | sqlx::query_as("SELECT * FROM repositories WHERE kind = 'drop' AND name = $1") |
|
| 190 | + | .bind(format!("{}/{id}", crate::repos::names::DROPS)) |
|
| 191 | + | .fetch_optional(pool) |
|
| 192 | + | .await |
|
| 193 | + | } |
|
| 194 | + | ||
| 195 | + | /// A drop's title, description and visibility, and that it changed. |
|
| 196 | + | pub async fn set_drop(pool: &Pool, row_id: i64, title: &str, description: &str, visibility: Visibility) -> sqlx::Result<()> { |
|
| 197 | + | sqlx::query("UPDATE repositories SET title = $1, description = $2, visibility = $3, updated_ms = $4 WHERE id = $5 AND kind = 'drop'") |
|
| 198 | + | .bind(title) |
|
| 199 | + | .bind(description) |
|
| 200 | + | .bind(visibility.as_str()) |
|
| 201 | + | .bind(now_ms()) |
|
| 202 | + | .bind(row_id) |
|
| 203 | + | .execute(pool) |
|
| 204 | + | .await?; |
|
| 205 | + | Ok(()) |
|
| 206 | + | } |
|
| 207 | + | ||
| 121 | 208 | /// One repository, with its open issue and proposal counts for the tabs. |
|
| 122 | 209 | pub async fn get(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> { |
|
| 123 | 210 | sqlx::query_as( |
|
| … | 203 unchanged lines |
||
| 327 | 414 | ||
| 328 | 415 | /// The forks of `id`, newest activity first. |
|
| 329 | 416 | pub async fn forks_of(pool: &Pool, id: i64) -> sqlx::Result<Vec<RepoRow>> { |
|
| 330 | - | sqlx::query_as("SELECT * FROM repositories WHERE forked_from = $1 ORDER BY updated_ms DESC").bind(id).fetch_all(pool).await |
|
| 417 | + | sqlx::query_as("SELECT * FROM repositories WHERE forked_from = $1 AND kind = 'repo' ORDER BY updated_ms DESC") |
|
| 418 | + | .bind(id) |
|
| 419 | + | .fetch_all(pool) |
|
| 420 | + | .await |
|
| 331 | 421 | } |
|
| 332 | 422 | ||
| 333 | 423 | /// `on`, `off`, or `` (follow hub.toml's `[flow] repos`). |
|
| … | 62 unchanged lines |
modified crates/hub/src/flow.rs +4 −0
| … | 21 unchanged lines |
||
| 22 | 22 | /// Whether pushes to this repository start runs: its own setting when a |
|
| 23 | 23 | /// site administrator made one, else hub.toml's `[flow] repos`. |
|
| 24 | 24 | pub fn enabled(app: &Shared, row: &db::repos::RepoRow) -> bool { |
|
| 25 | + | // A drop never runs Flow (#279), whatever it holds. |
|
| 26 | + | if row.is_drop() { |
|
| 27 | + | return false; |
|
| 28 | + | } |
|
| 25 | 29 | match row.flow.as_str() { |
|
| 26 | 30 | // `own`: on, run only by the repository's or its owner's runners (#225). |
|
| 27 | 31 | "on" | "own" => true, |
|
| … | 2565 unchanged lines |
modified crates/hub/src/history_scan.rs +1 −1
| … | 110 unchanged lines |
||
| 111 | 111 | loop { |
|
| 112 | 112 | let mut order = pdb::requested(&app.db).await.unwrap_or_default(); |
|
| 113 | 113 | if sweep { |
|
| 114 | - | order.extend(db::repos::list(&app.db).await.unwrap_or_default().into_iter().map(|r| r.id)); |
|
| 114 | + | order.extend(db::repos::list_with_drops(&app.db).await.unwrap_or_default().into_iter().map(|r| r.id)); |
|
| 115 | 115 | sweep = false; |
|
| 116 | 116 | } |
|
| 117 | 117 | order.extend(std::mem::take(&mut dirty)); |
|
| … | 356 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 15 unchanged lines |
||
| 16 | 16 | pub mod coverage; |
|
| 17 | 17 | pub mod db; |
|
| 18 | 18 | pub mod diffusion; |
|
| 19 | + | pub mod drops; |
|
| 19 | 20 | pub mod events; |
|
| 20 | 21 | pub mod flow; |
|
| 21 | 22 | pub mod headers; |
|
| … | 399 unchanged lines |
modified crates/hub/src/main.rs +4 −3
| … | 288 unchanged lines |
||
| 289 | 289 | println!("created {name}: oxy clone {}/{name}", app.config.base_url()); |
|
| 290 | 290 | } |
|
| 291 | 291 | RepoCmd::List => { |
|
| 292 | - | for r in db::repos::list(&app.db).await? { |
|
| 293 | - | println!("{:<40} {:<8} {}", r.slug(), r.visibility, r.description); |
|
| 292 | + | for r in db::repos::list_with_drops(&app.db).await? { |
|
| 293 | + | let what = if r.is_drop() { format!("drop: {}", r.title) } else { r.description.clone() }; |
|
| 294 | + | println!("{:<40} {:<8} {what}", r.slug(), r.visibility); |
|
| 294 | 295 | } |
|
| 295 | 296 | } |
|
| 296 | 297 | RepoCmd::Delete { slug } => { |
|
| … | 67 unchanged lines |
||
| 364 | 365 | ||
| 365 | 366 | async fn check(app: &oxygenhub::Shared) -> anyhow::Result<()> { |
|
| 366 | 367 | let mut problems = 0; |
|
| 367 | - | for r in db::repos::list(&app.db).await? { |
|
| 368 | + | for r in db::repos::list_with_drops(&app.db).await? { |
|
| 368 | 369 | let name = RepoName::new(&r.owner, &r.name).unwrap(); |
|
| 369 | 370 | let report = app.repos.read(&name, |r, cache| oxygenhub::repos::view::verification(r, cache)).await; |
|
| 370 | 371 | match report { |
|
| … | 23 unchanged lines |
modified crates/hub/src/maintenance.rs +2 −1
| … | 29 unchanged lines |
||
| 30 | 30 | Ok(n) => tracing::info!("gc: {n} reports' files expired under retention"), |
|
| 31 | 31 | Err(e) => tracing::warn!("gc: expiring report files: {e}"), |
|
| 32 | 32 | } |
|
| 33 | - | let rows = match db::repos::list(&app.db).await { |
|
| 33 | + | // Drops are repositories to keep tidy too (#279). |
|
| 34 | + | let rows = match db::repos::list_with_drops(&app.db).await { |
|
| 34 | 35 | Ok(rows) => rows, |
|
| 35 | 36 | Err(e) => { |
|
| 36 | 37 | tracing::warn!("gc: listing repositories: {e}"); |
|
| … | 88 unchanged lines |
modified crates/hub/src/protocol.rs +25 −3
| … | 329 unchanged lines |
||
| 330 | 330 | crate::push_protection::locate(repo, bundle, &mut findings); |
|
| 331 | 331 | let (pusher, via) = self.pusher(headers); |
|
| 332 | 332 | let base = self.app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()).map(|a| a.config.base_url().to_string()); |
|
| 333 | - | let settings = format!("{}/{}/{}/settings", base.unwrap_or_default(), row.owner, row.name); |
|
| 333 | + | let settings = |
|
| 334 | + | if row.is_drop() { String::new() } else { format!("{}/{}/{}/settings", base.unwrap_or_default(), row.owner, row.name) }; |
|
| 334 | 335 | let text = crate::push_protection::refusal(&findings, &settings, &via); |
|
| 335 | 336 | // Recorded for the maintainers (Settings › Push protection) and in |
|
| 336 | 337 | // the push log, as every refused push is. |
|
| … | 13 unchanged lines |
||
| 350 | 351 | tracing::warn!("recording push protection findings for {name}: {e}"); |
|
| 351 | 352 | } |
|
| 352 | 353 | let reason = format!( |
|
| 353 | - | "push protection: {} possible secret{} ({}); see Settings › Push protection", |
|
| 354 | + | "push protection: {} possible secret{} ({}){}", |
|
| 354 | 355 | findings.len(), |
|
| 355 | 356 | if findings.len() == 1 { "" } else { "s" }, |
|
| 356 | - | findings.iter().take(3).map(|f| format!("{}:{}", f.path, f.line)).collect::<Vec<_>>().join(", ") |
|
| 357 | + | findings.iter().take(3).map(|f| format!("{}:{}", f.path, f.line)).collect::<Vec<_>>().join(", "), |
|
| 358 | + | if row.is_drop() { "" } else { "; see Settings › Push protection" } |
|
| 357 | 359 | ); |
|
| 358 | 360 | let refs: Vec<String> = if updates.is_empty() { |
|
| 359 | 361 | vec![oxy_core::repo::OBJECTS_RESULT.to_string()] |
|
| … | 143 unchanged lines |
||
| 503 | 505 | Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]), |
|
| 504 | 506 | } |
|
| 505 | 507 | } |
|
| 508 | + | // A drop (#279) has no issues, webhooks, mirror, Diffusion or |
|
| 509 | + | // runs: its push is recorded, and that is all. |
|
| 510 | + | if row.is_drop() { |
|
| 511 | + | return; |
|
| 512 | + | } |
|
| 506 | 513 | let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade()); |
|
| 507 | 514 | if let Some(app) = app { |
|
| 508 | 515 | // Issues close from every change newly on the default |
|
| … | 75 unchanged lines |
||
| 584 | 591 | let sent = axum::body::HttpBody::size_hint(res.body()).exact().unwrap_or(0); |
|
| 585 | 592 | crate::metrics::protocol_bytes(name, received, sent); |
|
| 586 | 593 | res |
|
| 594 | + | } |
|
| 595 | + | ||
| 596 | + | /// `/{owner}/drops/{id}/oxy/v1/{endpoint}`: a drop is a repository to the |
|
| 597 | + | /// wire protocol, named `owner/drops/ID` (#279). |
|
| 598 | + | pub async fn dispatch_drop( |
|
| 599 | + | State(app): State<Shared>, |
|
| 600 | + | Path((owner, id, endpoint)): Path<(String, String, String)>, |
|
| 601 | + | method: Method, |
|
| 602 | + | headers: HeaderMap, |
|
| 603 | + | body: Bytes, |
|
| 604 | + | ) -> Response { |
|
| 605 | + | let Some(name) = RepoName::drop(&owner, &id) else { |
|
| 606 | + | return not_found().into_response(); |
|
| 607 | + | }; |
|
| 608 | + | dispatch(State(app), Path((name.owner, name.name, endpoint)), method, headers, body).await |
|
| 587 | 609 | } |
|
| 588 | 610 | ||
| 589 | 611 | enum Guard { |
|
| … | 3 unchanged lines |
modified crates/hub/src/push_protection.rs +16 −5
| … | 468 unchanged lines |
||
| 469 | 469 | /// (fingerprint F)` with its masked excerpt, then what to do. `via` names |
|
| 470 | 470 | /// the agent whose token pushed, if one did; `settings` is where a |
|
| 471 | 471 | /// maintainer allows a finding. |
|
| 472 | + | /// `settings` is the repository's settings page, or empty for a drop. |
|
| 472 | 473 | pub fn refusal(findings: &[Finding], settings: &str, via: &str) -> String { |
|
| 473 | 474 | use std::fmt::Write; |
|
| 474 | 475 | let n = findings.len(); |
|
| … | 20 unchanged lines |
||
| 495 | 496 | " - Take {it} out of the change that adds it: edit the file, then `oxy amend` (or `oxy abandon` a change that only adds it), and push again. Read credentials from the environment or a secret store, not from the repository." |
|
| 496 | 497 | ); |
|
| 497 | 498 | let _ = writeln!(out, " - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere."); |
|
| 498 | - | let _ = writeln!( |
|
| 499 | - | out, |
|
| 500 | - | " - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}#push-protection; then push again." |
|
| 501 | - | ); |
|
| 502 | - | if !via.is_empty() { |
|
| 499 | + | if settings.is_empty() { |
|
| 500 | + | // A drop (#279) has no settings: nothing there can be allowed. |
|
| 501 | + | let _ = writeln!(out, " - A drop cannot allow a finding: if {is} not a secret, change the value so it no longer looks like one."); |
|
| 502 | + | } else { |
|
| 503 | + | let _ = writeln!( |
|
| 504 | + | out, |
|
| 505 | + | " - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}#push-protection; then push again." |
|
| 506 | + | ); |
|
| 507 | + | } |
|
| 508 | + | if !via.is_empty() && settings.is_empty() { |
|
| 509 | + | let _ = write!( |
|
| 510 | + | out, |
|
| 511 | + | "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for." |
|
| 512 | + | ); |
|
| 513 | + | } else if !via.is_empty() { |
|
| 503 | 514 | let _ = write!( |
|
| 504 | 515 | out, |
|
| 505 | 516 | "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}#push-protection." |
|
| … | 75 unchanged lines |
modified crates/hub/src/repos/mod.rs +2 −1
| … | 215 unchanged lines |
||
| 216 | 216 | return Err(RepoError::NotFound); |
|
| 217 | 217 | } |
|
| 218 | 218 | let trash = self.root.parent().unwrap_or(&self.root).join("trash").join(&name.owner); |
|
| 219 | - | std::fs::create_dir_all(&trash)?; |
|
| 219 | + | // A drop's name is `drops/ID`: its own directory under the owner's. |
|
| 220 | 220 | let dest = trash.join(format!("{}-{}", name.name, crate::db::now_ms())); |
|
| 221 | + | std::fs::create_dir_all(dest.parent().unwrap_or(&trash))?; |
|
| 221 | 222 | std::fs::rename(&dir, &dest)?; |
|
| 222 | 223 | Ok(dest) |
|
| 223 | 224 | } |
|
| … | 92 unchanged lines |
modified crates/hub/src/repos/names.rs +44 −1
| … | 41 unchanged lines |
||
| 42 | 42 | "log", |
|
| 43 | 43 | "metrics", |
|
| 44 | 44 | "insights", |
|
| 45 | + | // Drops: `/drops` is the viewer's own (oxygenhub#279). |
|
| 46 | + | "drops", |
|
| 45 | 47 | ]; |
|
| 46 | 48 | ||
| 49 | + | /// The repository name no repository may take: `/{owner}/drops` is the |
|
| 50 | + | /// owner's Drops page, and `/{owner}/drops/{id}` a drop (oxygenhub#279). |
|
| 51 | + | pub const DROPS: &str = "drops"; |
|
| 52 | + | ||
| 53 | + | /// A drop's id: lowercase hex, 16 to 40 characters (they are made with |
|
| 54 | + | /// 24, 96 random bits, so a secret drop's address cannot be guessed). |
|
| 55 | + | pub fn valid_drop_id(s: &str) -> bool { |
|
| 56 | + | (16..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | /// A repository name: a segment, not `drops`; or a drop's, `drops/ID`, |
|
| 60 | + | /// which no repository name can be, since it holds a slash. |
|
| 61 | + | pub fn valid_repo_name(s: &str) -> bool { |
|
| 62 | + | match s.split_once('/') { |
|
| 63 | + | Some((DROPS, id)) => valid_drop_id(id), |
|
| 64 | + | Some(_) => false, |
|
| 65 | + | None => valid_segment(s) && !s.eq_ignore_ascii_case(DROPS), |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | ||
| 47 | 69 | /// `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`, not `.`/`..`, not ending in `.git`. |
|
| 48 | 70 | pub fn valid_segment(s: &str) -> bool { |
|
| 49 | 71 | let mut chars = s.chars(); |
|
| … | 13 unchanged lines |
||
| 63 | 85 | ||
| 64 | 86 | impl RepoName { |
|
| 65 | 87 | pub fn new(owner: &str, name: &str) -> Option<RepoName> { |
|
| 66 | - | if valid_owner(owner) && valid_segment(name) { |
|
| 88 | + | if valid_owner(owner) && valid_repo_name(name) { |
|
| 67 | 89 | Some(RepoName { owner: owner.into(), name: name.into() }) |
|
| 68 | 90 | } else { |
|
| 69 | 91 | None |
|
| … | 8 unchanged lines |
||
| 78 | 100 | ||
| 79 | 101 | pub fn url(&self) -> String { |
|
| 80 | 102 | format!("/{}/{}", self.owner, self.name) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// A drop's repository, `owner/drops/ID`. |
|
| 106 | + | pub fn drop(owner: &str, id: &str) -> Option<RepoName> { |
|
| 107 | + | RepoName::new(owner, &format!("{DROPS}/{id}")) |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | /// The drop's id, when this is a drop's repository. |
|
| 111 | + | pub fn drop_id(&self) -> Option<&str> { |
|
| 112 | + | self.name.strip_prefix(DROPS)?.strip_prefix('/') |
|
| 81 | 113 | } |
|
| 82 | 114 | } |
|
| 83 | 115 | ||
| … | 19 unchanged lines |
||
| 103 | 135 | assert!(RepoName::parse("andy").is_none()); |
|
| 104 | 136 | assert!(RepoName::parse(&format!("andy/{}", "a".repeat(65))).is_none()); |
|
| 105 | 137 | assert_eq!(RepoName::parse("andy/oxygen").unwrap().url(), "/andy/oxygen"); |
|
| 138 | + | // `drops` is the Drops page; a drop is `drops/ID` (#279). |
|
| 139 | + | assert!(RepoName::parse("andy/drops").is_none() && RepoName::parse("andy/Drops").is_none()); |
|
| 140 | + | assert!(RepoName::parse("drops/x").is_none(), "reserved owner"); |
|
| 141 | + | let d = RepoName::parse("andy/drops/0123456789abcdef01234567").unwrap(); |
|
| 142 | + | assert_eq!(d.drop_id(), Some("0123456789abcdef01234567")); |
|
| 143 | + | assert_eq!(d.url(), "/andy/drops/0123456789abcdef01234567"); |
|
| 144 | + | assert_eq!(RepoName::drop("andy", "0123456789abcdef01234567"), Some(d)); |
|
| 145 | + | assert!(RepoName::parse("andy/drops/short").is_none()); |
|
| 146 | + | assert!(RepoName::parse("andy/drops/0123456789ABCDEF01234567").is_none()); |
|
| 147 | + | assert!(RepoName::parse("andy/x/0123456789abcdef01234567").is_none()); |
|
| 148 | + | assert_eq!(RepoName::parse("andy/oxygen").unwrap().drop_id(), None); |
|
| 106 | 149 | } |
|
| 107 | 150 | } |
modified crates/hub/src/web/admin.rs +11 −6
| … | 355 unchanged lines |
||
| 356 | 356 | /// One repository's footprint on disk. |
|
| 357 | 357 | pub struct StorageRow { |
|
| 358 | 358 | pub slug: String, |
|
| 359 | + | /// A drop (#279), labelled as one. |
|
| 360 | + | pub drop: bool, |
|
| 359 | 361 | pub bytes: u64, |
|
| 360 | 362 | /// Objects not yet in a pack. |
|
| 361 | 363 | pub loose: usize, |
|
| … | 35 unchanged lines |
||
| 397 | 399 | } |
|
| 398 | 400 | ||
| 399 | 401 | async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> { |
|
| 400 | - | let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 401 | - | let dirs: Vec<(String, std::path::PathBuf)> = |
|
| 402 | - | repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect(); |
|
| 402 | + | // Drops too, labelled as such (#279). |
|
| 403 | + | let repos = crate::db::repos::list_with_drops(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 404 | + | let dirs: Vec<(String, bool, std::path::PathBuf)> = repos |
|
| 405 | + | .iter() |
|
| 406 | + | .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), r.is_drop(), app.repos.dir(&n)))) |
|
| 407 | + | .collect(); |
|
| 403 | 408 | let mut rows = tokio::task::spawn_blocking(move || { |
|
| 404 | 409 | dirs.into_iter() |
|
| 405 | - | .map(|(slug, dir)| { |
|
| 410 | + | .map(|(slug, drop, dir)| { |
|
| 406 | 411 | let (bytes, loose, packs) = footprint(&dir); |
|
| 407 | - | StorageRow { slug, bytes, loose, packs } |
|
| 412 | + | StorageRow { slug, drop, bytes, loose, packs } |
|
| 408 | 413 | }) |
|
| 409 | 414 | .collect::<Vec<_>>() |
|
| 410 | 415 | }) |
|
| … | 25 unchanged lines |
||
| 436 | 441 | let me = require_admin(&viewer, &shell)?; |
|
| 437 | 442 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 438 | 443 | let names: Vec<crate::repos::RepoName> = if f.repo == "*" { |
|
| 439 | - | crate::db::repos::list(&app.db) |
|
| 444 | + | crate::db::repos::list_with_drops(&app.db) |
|
| 440 | 445 | .await |
|
| 441 | 446 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 442 | 447 | .iter() |
|
| … | 97 unchanged lines |
modified crates/hub/src/web/icons.rs +2 −1
| … | 47 unchanged lines |
||
| 48 | 48 | r#"<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="11.5" cy="3.5" r="1.5"/><circle cx="8" cy="12.5" r="1.5"/><path d="M4.5 5v1.5c0 1 .8 1.5 1.8 1.5h3.4c1 0 1.8-.5 1.8-1.5V5M8 8v3"/></svg>"# |
|
| 49 | 49 | ), |
|
| 50 | 50 | "history" => concat!(open!(), r#"<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg>"#), |
|
| 51 | + | "drop" => concat!(open!(), r#"<path d="M8 1.8C6.4 4 3.8 7.1 3.8 9.9a4.2 4.2 0 0 0 8.4 0C12.2 7.1 9.6 4 8 1.8z"/></svg>"#), |
|
| 51 | 52 | _ => "", |
|
| 52 | 53 | } |
|
| 53 | 54 | } |
|
| … | 4 unchanged lines |
||
| 58 | 59 | fn every_named_icon_is_one_closed_svg() { |
|
| 59 | 60 | for name in [ |
|
| 60 | 61 | "home", "explore", "help", "projects", "bell", "settings", "admin", "dir", "file", "link", "subrepo", "up", "history", "star", |
|
| 61 | - | "eye", "fork", |
|
| 62 | + | "eye", "fork", "drop", |
|
| 62 | 63 | ] { |
|
| 63 | 64 | let s = super::svg(name); |
|
| 64 | 65 | assert!(s.starts_with("<svg class=\"ic\"") && s.ends_with("</svg>"), "{name}"); |
|
| … | 5 unchanged lines |
modified crates/hub/src/web/mod.rs +25 −0
| … | 12 unchanged lines |
||
| 13 | 13 | pub mod coverage; |
|
| 14 | 14 | mod device; |
|
| 15 | 15 | mod diffusion; |
|
| 16 | + | mod drops; |
|
| 16 | 17 | pub mod edit; |
|
| 17 | 18 | pub mod filters; |
|
| 18 | 19 | pub mod flow; |
|
| … | 164 unchanged lines |
||
| 183 | 184 | .route("/admin/secrets", post(admin::secrets)) |
|
| 184 | 185 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 185 | 186 | .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize)) |
|
| 187 | + | // Drops (#279): `drops` is reserved, as an owner and as a repository name. |
|
| 188 | + | .route("/drops", get(drops::mine)) |
|
| 189 | + | .route("/drops/new", get(drops::new_form).post(drops::create)) |
|
| 190 | + | .route("/{owner}/drops", get(drops::list)) |
|
| 191 | + | .route("/{owner}/drops/{id}", get(drops::show)) |
|
| 192 | + | .route("/{owner}/drops/{id}/tree/{r}", get(drops::show_at)) |
|
| 193 | + | .route("/{owner}/drops/{id}/edit", get(drops::edit_form).post(drops::save)) |
|
| 194 | + | .route("/{owner}/drops/{id}/delete", post(drops::delete)) |
|
| 195 | + | .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes)) |
|
| 196 | + | .route("/{owner}/drops/{id}/change/{hash}", get(drops::change)) |
|
| 197 | + | .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw)) |
|
| 198 | + | .route("/{owner}/drops/{id}/file/{r}/{*path}", get(drops::file)) |
|
| 199 | + | .route("/{owner}/drops/{id}/oxy/v1/{endpoint}", any(crate::protocol::dispatch_drop).layer(DefaultBodyLimit::max(push_limit))) |
|
| 186 | 200 | .route("/{owner}", get(home::owner)) |
|
| 187 | 201 | .route("/{owner}/{name}", get(repo::overview)) |
|
| 188 | 202 | .route("/{owner}/{name}/tree/{r}", get(repo::tree_root)) |
|
| … | 331 unchanged lines |
||
| 520 | 534 | /// The backdrop's data, a sky of this repository alone; empty for none |
|
| 521 | 535 | /// (docs/backgrounds.md, oxygenhub#196). |
|
| 522 | 536 | pub backdrop: String, |
|
| 537 | + | /// A drop (#279): its pages show the drop's own bar, not the |
|
| 538 | + | /// repository's tabs. Its title, for that bar. |
|
| 539 | + | pub is_drop: bool, |
|
| 540 | + | pub drop_title: String, |
|
| 523 | 541 | } |
|
| 524 | 542 | ||
| 525 | 543 | impl RepoShell { |
|
| … | 20 unchanged lines |
||
| 546 | 564 | reports: crate::reports::has_reports(app, row.id), |
|
| 547 | 565 | diffusion: crate::diffusion::involved(app, row.id), |
|
| 548 | 566 | backdrop: String::new(), |
|
| 567 | + | is_drop: row.is_drop(), |
|
| 568 | + | drop_title: row.title.clone(), |
|
| 549 | 569 | } |
|
| 550 | 570 | } |
|
| 551 | 571 | ||
| … | 246 unchanged lines |
||
| 798 | 818 | if !auth::roles::allows(role, access) { |
|
| 799 | 819 | return Err(Error::not_found(shell.clone())); |
|
| 800 | 820 | } |
|
| 821 | + | // A drop's repository pages are read-only (its history, changes and |
|
| 822 | + | // raw files, #279): it is changed from the drop's own page. |
|
| 823 | + | if row.is_drop() && access != Access::Read { |
|
| 824 | + | return Err(Error::not_found(shell.clone())); |
|
| 825 | + | } |
|
| 801 | 826 | if row.archived && matches!(access, Access::Write | Access::Maintain) { |
|
| 802 | 827 | return Err(archived(shell)); |
|
| 803 | 828 | } |
|
| … | 67 unchanged lines |
modified crates/hub/src/web/transparency.rs +7 −5
| … | 31 unchanged lines |
||
| 32 | 32 | let recent = db::tlog::recent(&app.db, None, 50).await.unwrap_or_default(); |
|
| 33 | 33 | let mut rows = Vec::with_capacity(recent.len()); |
|
| 34 | 34 | for r in recent { |
|
| 35 | - | // Entries of private repositories show only their hashes. |
|
| 35 | + | // Entries of private repositories show only their hashes, and so |
|
| 36 | + | // do a secret drop's (#279), whose address is its secret. |
|
| 36 | 37 | let repo = match r.repo_id { |
|
| 37 | 38 | Some(id) => match db::repos::get_by_id(&app.db, id).await { |
|
| 38 | 39 | Ok(Some(row)) |
|
| 39 | - | if crate::auth::roles::allows( |
|
| 40 | - | crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.unwrap_or(None), |
|
| 41 | - | crate::auth::roles::Access::Read, |
|
| 42 | - | ) => |
|
| 40 | + | if (!row.is_drop() || crate::drops::listed_to(viewer.account(), &row)) |
|
| 41 | + | && crate::auth::roles::allows( |
|
| 42 | + | crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.unwrap_or(None), |
|
| 43 | + | crate::auth::roles::Access::Read, |
|
| 44 | + | ) => |
|
| 43 | 45 | { |
|
| 44 | 46 | Some(row.slug()) |
|
| 45 | 47 | } |
|
| … | 15 unchanged lines |
modified crates/oh/src/main.rs +180 −1
| … | 13 unchanged lines |
||
| 14 | 14 | use target::{Choice, Config, Target}; |
|
| 15 | 15 | ||
| 16 | 16 | #[derive(Parser)] |
|
| 17 | - | #[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs and releases")] |
|
| 17 | + | #[command(name = "oh", version = env!("OXY_VERSION_LONG"), about = "OxygenHub from the command line: issues, proposals, Flow runs, releases and drops")] |
|
| 18 | 18 | struct Cli { |
|
| 19 | 19 | /// The repository, when not inside an Oxygen checkout |
|
| 20 | 20 | #[arg(long, short = 'R', global = true, value_name = "OWNER/NAME")] |
|
| … | 88 unchanged lines |
||
| 109 | 109 | #[command(subcommand)] |
|
| 110 | 110 | cmd: AgentCmd, |
|
| 111 | 111 | }, |
|
| 112 | + | /// Drops: small snippets of files, each its own repository, never |
|
| 113 | + | /// listed among your repositories (oxygenhub#279) |
|
| 114 | + | Drop { |
|
| 115 | + | #[command(subcommand)] |
|
| 116 | + | cmd: DropCmd, |
|
| 117 | + | }, |
|
| 112 | 118 | /// Call the API directly: `oh api /repos/OWNER/NAME/issues` |
|
| 113 | 119 | Api { |
|
| 114 | 120 | /// A path under /api/v1 |
|
| … | 30 unchanged lines |
||
| 145 | 151 | } |
|
| 146 | 152 | ||
| 147 | 153 | #[derive(Subcommand)] |
|
| 154 | + | enum DropCmd { |
|
| 155 | + | /// Make a drop of these files, signed by the hub as your delegate |
|
| 156 | + | Create { |
|
| 157 | + | /// The files (their names in the drop are their file names) |
|
| 158 | + | #[arg(required = true)] |
|
| 159 | + | files: Vec<std::path::PathBuf>, |
|
| 160 | + | /// Unlisted: readable by anyone with its address, listed nowhere |
|
| 161 | + | #[arg(long)] |
|
| 162 | + | secret: bool, |
|
| 163 | + | /// A description |
|
| 164 | + | #[arg(long, short = 'd', value_name = "TEXT")] |
|
| 165 | + | description: Option<String>, |
|
| 166 | + | /// A title (default: the first file's name) |
|
| 167 | + | #[arg(long, short = 't')] |
|
| 168 | + | title: Option<String>, |
|
| 169 | + | }, |
|
| 170 | + | /// Your drops, or someone's public ones |
|
| 171 | + | List { |
|
| 172 | + | /// Whose drops |
|
| 173 | + | #[arg(long, short = 'u', value_name = "NAME")] |
|
| 174 | + | user: Option<String>, |
|
| 175 | + | }, |
|
| 176 | + | /// A drop's files, printed (by id or address) |
|
| 177 | + | View { |
|
| 178 | + | /// The drop's id, or its address |
|
| 179 | + | id: String, |
|
| 180 | + | /// Print only this file's content |
|
| 181 | + | #[arg(long, short = 'f', value_name = "NAME")] |
|
| 182 | + | file: Option<String>, |
|
| 183 | + | }, |
|
| 184 | + | /// Delete a drop and its history |
|
| 185 | + | Delete { |
|
| 186 | + | /// The drop's id, or its address |
|
| 187 | + | id: String, |
|
| 188 | + | }, |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | #[derive(Subcommand)] |
|
| 148 | 192 | enum AuthCmd { |
|
| 149 | 193 | /// Sign in to a hub and make it the default: approve a code on the web |
|
| 150 | 194 | /// (device sign-in), or give a token |
|
| … | 568 unchanged lines |
||
| 719 | 763 | Cmd::Project { cmd, owner } => project(&c, &t, cmd, owner, json), |
|
| 720 | 764 | Cmd::Assistant { cmd, org, site } => assistant(&c, &t, cmd, org, site, json), |
|
| 721 | 765 | Cmd::Agent { cmd } => agent(&c, cmd, json), |
|
| 766 | + | Cmd::Drop { cmd } => drop_cmd(&c, cmd, json), |
|
| 767 | + | } |
|
| 768 | + | } |
|
| 769 | + | ||
| 770 | + | /// A drop's id from an id or its address (`https://hub/owner/drops/ID`). |
|
| 771 | + | fn drop_id(s: &str) -> Result<String> { |
|
| 772 | + | let id = s.trim().trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string(); |
|
| 773 | + | if id.len() < 16 || !id.bytes().all(|b| b.is_ascii_hexdigit()) { |
|
| 774 | + | bail!("{s:?} is not a drop's id or address"); |
|
| 775 | + | } |
|
| 776 | + | Ok(id.to_ascii_lowercase()) |
|
| 777 | + | } |
|
| 778 | + | ||
| 779 | + | fn drop_line(d: &hub_api::Drop) -> String { |
|
| 780 | + | let title = if d.title.is_empty() { d.files.first().map(|f| f.name.clone()).unwrap_or_else(|| d.id.clone()) } else { d.title.clone() }; |
|
| 781 | + | let files: Vec<&str> = d.files.iter().map(|f| f.name.as_str()).collect(); |
|
| 782 | + | format!( |
|
| 783 | + | "{} {} {}{}", |
|
| 784 | + | ui::dim(&d.id), |
|
| 785 | + | ui::strong(&title), |
|
| 786 | + | if d.visibility == "secret" { ui::warn("secret ") } else { String::new() }, |
|
| 787 | + | ui::dim(&format!("{} · {}", files.join(", "), ui::ago(d.updated_ms))) |
|
| 788 | + | ) |
|
| 789 | + | } |
|
| 790 | + | ||
| 791 | + | fn drop_cmd(c: &Client, cmd: DropCmd, json: bool) -> Result<i32> { |
|
| 792 | + | match cmd { |
|
| 793 | + | DropCmd::Create { files, secret, description, title } => { |
|
| 794 | + | let mut out = Vec::with_capacity(files.len()); |
|
| 795 | + | for f in &files { |
|
| 796 | + | let name = f.file_name().and_then(|n| n.to_str()).with_context(|| format!("{} has no file name", f.display()))?; |
|
| 797 | + | let content = |
|
| 798 | + | std::fs::read_to_string(f).with_context(|| format!("read {} (a drop made here holds text files)", f.display()))?; |
|
| 799 | + | out.push(hub_api::NewDropFile { name: name.to_string(), content }); |
|
| 800 | + | } |
|
| 801 | + | let new = |
|
| 802 | + | hub_api::NewDrop { title: title.unwrap_or_default(), description: description.unwrap_or_default(), secret, files: out }; |
|
| 803 | + | let d: hub_api::Drop = c.send("POST", "/drops", &new)?; |
|
| 804 | + | if json { |
|
| 805 | + | println!("{}", serde_json::to_string(&d)?); |
|
| 806 | + | } else { |
|
| 807 | + | println!("{} made {}drop {} {}", ui::ok("✔"), if secret { "secret " } else { "" }, ui::strong(&d.id), ui::dim(&d.url)); |
|
| 808 | + | println!(" {}", ui::dim(&format!("oxy clone {}", d.clone_url))); |
|
| 809 | + | } |
|
| 810 | + | } |
|
| 811 | + | DropCmd::List { user } => { |
|
| 812 | + | let path = match &user { |
|
| 813 | + | Some(u) => format!("/users/{}/drops", enc(u)), |
|
| 814 | + | None => "/drops".to_string(), |
|
| 815 | + | }; |
|
| 816 | + | if json { |
|
| 817 | + | println!("{}", c.get_text(&path)?); |
|
| 818 | + | return Ok(0); |
|
| 819 | + | } |
|
| 820 | + | let list: Vec<hub_api::Drop> = c.get(&path)?; |
|
| 821 | + | if list.is_empty() { |
|
| 822 | + | println!("{}", ui::dim("No drops.")); |
|
| 823 | + | } |
|
| 824 | + | for d in &list { |
|
| 825 | + | println!("{}", drop_line(d)); |
|
| 826 | + | } |
|
| 827 | + | } |
|
| 828 | + | DropCmd::View { id, file } => { |
|
| 829 | + | let path = format!("/drops/{}", drop_id(&id)?); |
|
| 830 | + | if json && file.is_none() { |
|
| 831 | + | println!("{}", c.get_text(&path)?); |
|
| 832 | + | return Ok(0); |
|
| 833 | + | } |
|
| 834 | + | let d: hub_api::Drop = c.get(&path)?; |
|
| 835 | + | if let Some(name) = file { |
|
| 836 | + | let f = d.files.iter().find(|f| f.name == name).with_context(|| format!("the drop has no file {name}"))?; |
|
| 837 | + | let content = f.content.as_deref().with_context(|| format!("{name} is not shown as text: download {}", f.raw_url))?; |
|
| 838 | + | print!("{content}"); |
|
| 839 | + | return Ok(0); |
|
| 840 | + | } |
|
| 841 | + | println!("{}", drop_line(&d)); |
|
| 842 | + | if !d.description.is_empty() { |
|
| 843 | + | println!("{}", d.description); |
|
| 844 | + | } |
|
| 845 | + | println!("{}", ui::kv("url", &d.url)); |
|
| 846 | + | println!("{}", ui::kv("clone", &format!("oxy clone {}", d.clone_url))); |
|
| 847 | + | for f in &d.files { |
|
| 848 | + | println!("\n{} {}", ui::strong(&format!("── {}", f.name)), ui::dim(&format!("({} bytes)", f.size))); |
|
| 849 | + | match &f.content { |
|
| 850 | + | Some(text) => { |
|
| 851 | + | print!("{text}"); |
|
| 852 | + | if !text.ends_with('\n') && !text.is_empty() { |
|
| 853 | + | println!(); |
|
| 854 | + | } |
|
| 855 | + | } |
|
| 856 | + | None => println!("{}", ui::dim(&format!("not text, or too large: {}", f.raw_url))), |
|
| 857 | + | } |
|
| 858 | + | } |
|
| 859 | + | } |
|
| 860 | + | DropCmd::Delete { id } => { |
|
| 861 | + | let id = drop_id(&id)?; |
|
| 862 | + | c.send_text("DELETE", &format!("/drops/{id}"), &serde_json::json!({}))?; |
|
| 863 | + | println!("{} drop {id}", ui::ok("✔ deleted")); |
|
| 864 | + | } |
|
| 722 | 865 | } |
|
| 866 | + | Ok(0) |
|
| 723 | 867 | } |
|
| 724 | 868 | ||
| 725 | 869 | /// `30d`, `12h`, `90m` in milliseconds. |
|
| … | 1048 unchanged lines |
||
| 1774 | 1918 | } |
|
| 1775 | 1919 | } |
|
| 1776 | 1920 | Ok(0) |
|
| 1921 | + | } |
|
| 1922 | + | ||
| 1923 | + | #[cfg(test)] |
|
| 1924 | + | mod tests { |
|
| 1925 | + | use super::*; |
|
| 1926 | + | ||
| 1927 | + | /// `oh drop` (oxygenhub#279): its commands and flags parse as the |
|
| 1928 | + | /// help says, and a drop is named by its id or its address. |
|
| 1929 | + | #[test] |
|
| 1930 | + | fn drop_commands_parse() { |
|
| 1931 | + | let cli = Cli::try_parse_from(["oh", "drop", "create", "a.txt", "b.md", "--secret", "-d", "Two files", "-t", "Notes"]).unwrap(); |
|
| 1932 | + | match cli.cmd { |
|
| 1933 | + | Cmd::Drop { cmd: DropCmd::Create { files, secret, description, title } } => { |
|
| 1934 | + | assert_eq!(files, vec![std::path::PathBuf::from("a.txt"), std::path::PathBuf::from("b.md")]); |
|
| 1935 | + | assert!(secret); |
|
| 1936 | + | assert_eq!((description.as_deref(), title.as_deref()), (Some("Two files"), Some("Notes"))); |
|
| 1937 | + | } |
|
| 1938 | + | _ => panic!("not drop create"), |
|
| 1939 | + | } |
|
| 1940 | + | assert!(Cli::try_parse_from(["oh", "drop", "create"]).is_err(), "a drop needs a file"); |
|
| 1941 | + | match Cli::try_parse_from(["oh", "drop", "list", "--user", "bob", "--json"]).unwrap() { |
|
| 1942 | + | Cli { cmd: Cmd::Drop { cmd: DropCmd::List { user } }, json, .. } => assert_eq!((user.as_deref(), json), (Some("bob"), true)), |
|
| 1943 | + | _ => panic!("not drop list"), |
|
| 1944 | + | } |
|
| 1945 | + | match Cli::try_parse_from(["oh", "drop", "view", "0123456789abcdef01234567", "-f", "a.txt"]).unwrap().cmd { |
|
| 1946 | + | Cmd::Drop { cmd: DropCmd::View { id, file } } => { |
|
| 1947 | + | assert_eq!((id.as_str(), file.as_deref()), ("0123456789abcdef01234567", Some("a.txt"))) |
|
| 1948 | + | } |
|
| 1949 | + | _ => panic!("not drop view"), |
|
| 1950 | + | } |
|
| 1951 | + | assert!(matches!(Cli::try_parse_from(["oh", "drop", "delete", "x"]).unwrap().cmd, Cmd::Drop { cmd: DropCmd::Delete { .. } })); |
|
| 1952 | + | assert_eq!(drop_id("0123456789ABCDEF01234567").unwrap(), "0123456789abcdef01234567"); |
|
| 1953 | + | assert_eq!(drop_id("https://hub.example/andy/drops/0123456789abcdef01234567/").unwrap(), "0123456789abcdef01234567"); |
|
| 1954 | + | assert!(drop_id("andy/notes").is_err()); |
|
| 1955 | + | } |
|
| 1777 | 1956 | } |
modified crates/oh/src/target.rs +8 −0
| … | 101 unchanged lines |
||
| 102 | 102 | let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); |
|
| 103 | 103 | match segs.as_slice() { |
|
| 104 | 104 | [o, n] => Ok((format!("{scheme}://{host}"), o.to_string(), n.trim_end_matches(".oxy").to_string())), |
|
| 105 | + | // A drop's clone (oxygenhub#279): `https://hub/OWNER/drops/ID`. |
|
| 106 | + | [o, "drops", id] => Ok((format!("{scheme}://{host}"), o.to_string(), format!("drops/{id}"))), |
|
| 105 | 107 | _ => bail!("{url} does not name a repository as https://hub/OWNER/NAME"), |
|
| 106 | 108 | } |
|
| 107 | 109 | } |
|
| … | 77 unchanged lines |
||
| 185 | 187 | ("https://earth.example:7343".into(), "andy".into(), "oxygen".into()) |
|
| 186 | 188 | ); |
|
| 187 | 189 | assert_eq!(split_url("http://127.0.0.1:9/a/b/").unwrap().2, "b"); |
|
| 190 | + | // A drop's clone names its hub, so `oh drop` works inside it. |
|
| 191 | + | assert_eq!( |
|
| 192 | + | split_url("https://hub/andy/drops/0123456789abcdef01234567").unwrap(), |
|
| 193 | + | ("https://hub".into(), "andy".into(), "drops/0123456789abcdef01234567".into()) |
|
| 194 | + | ); |
|
| 195 | + | assert!(split_url("https://hub/a/b/c").is_err()); |
|
| 188 | 196 | assert!(split_url("https://hub/only-owner").is_err()); |
|
| 189 | 197 | assert!(split_url("not a url").is_err()); |
|
| 190 | 198 | } |
|
| … | 1 unchanged lines |
modified crates/oh/tests/cli.rs +47 −0
| … | 244 unchanged lines |
||
| 245 | 245 | assert_ne!(code, 0); |
|
| 246 | 246 | assert!(err.contains("HTTP 401") && err.contains("oh auth login"), "{err}"); |
|
| 247 | 247 | } |
|
| 248 | + | ||
| 249 | + | /// `oh drop` (oxygenhub#279): make one from files, list, view, delete. |
|
| 250 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 251 | + | async fn oh_makes_lists_views_and_deletes_drops() { |
|
| 252 | + | let h = hub().await; |
|
| 253 | + | let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 254 | + | let (tok, hash) = oxygenhub::auth::new_token(); |
|
| 255 | + | db::accounts::create_token(&h.app.db, andy.id, "laptop", &hash, "repo:read,repo:write", "", None).await.unwrap(); |
|
| 256 | + | // The hub commits a drop as andy's delegate. |
|
| 257 | + | let work = tempfile::tempdir().unwrap(); |
|
| 258 | + | let mut c = Repo::init(&work.path().join("id")).unwrap(); |
|
| 259 | + | c.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 260 | + | c.delegate_add(h.app.hub_key.identity.clone(), oxy_core::identity::Scope::CHANGES | oxy_core::identity::Scope::REFS, None).unwrap(); |
|
| 261 | + | let key = c.config.author_key.unwrap(); |
|
| 262 | + | let ident = c.identity(&key).unwrap(); |
|
| 263 | + | db::accounts::link_identity(&h.app.db, andy.id, &key.to_hex(), &ident.name, &ident.email, &ident.encode()).await.unwrap(); |
|
| 264 | + | let w = work.path(); |
|
| 265 | + | write(w, "notes.md", "# Notes\n"); |
|
| 266 | + | write(w, "run.sh", "echo hi\n"); |
|
| 267 | + | let env = [("OH_HUB", h.base.as_str()), ("OH_TOKEN", tok.as_str())]; |
|
| 268 | + | ||
| 269 | + | let (code, out, err) = |
|
| 270 | + | oh(w, &env, &["drop", "create", "notes.md", "run.sh", "--secret", "-d", "Two files", "-t", "Notes", "--json"]).await; |
|
| 271 | + | assert_eq!(code, 0, "{err}"); |
|
| 272 | + | let d: hub_api::Drop = serde_json::from_str(&out).unwrap(); |
|
| 273 | + | assert_eq!((d.title.as_str(), d.visibility.as_str(), d.files.len()), ("Notes", "secret", 2)); |
|
| 274 | + | assert!(d.url.ends_with(&format!("/andy/drops/{}", d.id))); |
|
| 275 | + | let (code, out, _) = oh(w, &env, &["drop", "list", "--plain"]).await; |
|
| 276 | + | assert_eq!(code, 0); |
|
| 277 | + | assert!(out.contains(&d.id) && out.contains("Notes") && out.contains("secret"), "{out}"); |
|
| 278 | + | // Someone else's view of andy's drops: the secret one is not listed. |
|
| 279 | + | let (code, out, err) = oh(w, &[("OH_HUB", h.base.as_str())], &["drop", "list", "--user", "andy", "--json"]).await; |
|
| 280 | + | assert_eq!(code, 0, "{err}"); |
|
| 281 | + | assert_eq!(out.trim(), "[]"); |
|
| 282 | + | // But anyone with its address reads it. |
|
| 283 | + | let (code, out, err) = oh(w, &[("OH_HUB", h.base.as_str())], &["drop", "view", &d.url, "--plain"]).await; |
|
| 284 | + | assert_eq!(code, 0, "{err}"); |
|
| 285 | + | assert!(out.contains("── notes.md") && out.contains("# Notes") && out.contains("echo hi") && out.contains("oxy clone"), "{out}"); |
|
| 286 | + | let (_, out, _) = oh(w, &env, &["drop", "view", &d.id, "-f", "run.sh"]).await; |
|
| 287 | + | assert_eq!(out, "echo hi\n"); |
|
| 288 | + | let (code, out, err) = oh(w, &env, &["drop", "delete", &d.id, "--plain"]).await; |
|
| 289 | + | assert_eq!(code, 0, "{err}"); |
|
| 290 | + | assert!(out.contains("deleted"), "{out}"); |
|
| 291 | + | let (code, _, err) = oh(w, &env, &["drop", "view", &d.id]).await; |
|
| 292 | + | assert_ne!(code, 0); |
|
| 293 | + | assert!(err.contains("404"), "{err}"); |
|
| 294 | + | } |
modified docs/oh.md +4 −0
| … | 59 unchanged lines |
||
| 60 | 60 | | `oh release list`, `oh release create TAG [-t TITLE] [-n NOTES]` | Releases | |
|
| 61 | 61 | | `oh release upload TAG FILE…` | Attach assets | |
|
| 62 | 62 | | `oh release trust TAG` | The release's trust audit; exit 1 on a failure | |
|
| 63 | + | | `oh drop create FILE… [--secret] [-d TEXT] [-t TITLE]` | Make a drop of these files (oxygenhub#279), signed by the hub as your delegate; `--secret` lists it nowhere | |
|
| 64 | + | | `oh drop list [--user NAME]` | Your drops, or someone's public ones | |
|
| 65 | + | | `oh drop view ID [-f FILE]` | A drop's files, by id or address; `-f` prints one file's content alone | |
|
| 66 | + | | `oh drop delete ID` | Delete a drop and its history | |
|
| 63 | 67 | | `oh api PATH [-X METHOD] [-d JSON]` | Any API call, JSON out | |
|
| 64 | 68 | ||
| 65 | 69 | Exit codes: 0 on success, 1 when a watched run or an audit fails, 2 on an |
|
| … | 5 unchanged lines |
modified docs/spec.md +17 −2
| … | 101 unchanged lines |
||
| 102 | 102 | cache/ rebuildable: rendered pages, graph layouts, search index |
|
| 103 | 103 | ``` |
|
| 104 | 104 | ||
| 105 | - | `<owner>` and `<name>` MUST match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` and MUST NOT be `.`, `..`, or end in `.git` (reserved for the mirror URL). The reserved top-level path segments are `api`, `static`, `login`, `logout`, `register`, `settings`, `explore`, `search`, `notifications`, `new`, `orgs`, `admin`, `style.css`, `favicon.ico`, `favicon.svg`, `health`; no owner may take those names. |
|
| 105 | + | `<owner>` and `<name>` MUST match `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` and MUST NOT be `.`, `..`, or end in `.git` (reserved for the mirror URL). The reserved top-level path segments are `api`, `static`, `login`, `logout`, `register`, `settings`, `explore`, `search`, `notifications`, `new`, `orgs`, `admin`, `style.css`, `favicon.ico`, `favicon.svg`, `health`, `avatar`, `help`, `projects`, `assets`, `.well-known`, `live`, `csp-report`, `security.txt`, `security-contact`, `log`, `metrics`, `insights` and `drops`; no owner may take those names. No repository may be named `drops` (in any case): `/{owner}/drops` is the owner's Drops page (§4.5). A drop's repository is `repos/<owner>/drops/<id>/`. |
|
| 106 | 106 | ||
| 107 | 107 | ### 3.3 Request path |
|
| 108 | 108 | ||
| … | 57 unchanged lines |
||
| 166 | 166 | ||
| 167 | 167 | `public` (readable by anyone including anonymous clients), `internal` (readable by every signed-in account), `private` (readable by collaborators). Wire protocol reads obey the same rule: a private repository answers `404` to an unauthorized client, not `401`, so its existence is not disclosed. |
|
| 168 | 168 | ||
| 169 | + | ### 4.5 Drops (oxygenhub#279) |
|
| 170 | + | ||
| 171 | + | A **drop** is a small snippet of one or more files, as a gist is, and like a gist it is a repository: each drop is its own Oxygen repository, with its own history, visibility and address, cloned and pushed with `oxy`. |
|
| 172 | + | ||
| 173 | + | - **Names and addresses:** a drop's repository is named `drops/ID` under its owner (a user), where ID is 24 random lowercase hex digits, unique on the hub (96 bits, so an address cannot be guessed). No repository name can hold a slash and none may be `drops`, so the two never collide. The drop's page is `/{owner}/drops/{id}`, and `oxy clone https://HUB/{owner}/drops/{id}` takes that address: the wire protocol answers at `/{owner}/drops/{id}/oxy/v1/…` with the drop's repository behind it, under the same locks, limits and push path as any repository. The row is in `repositories` with `kind = 'drop'` (migration 0087) and a `title`; its `description` is the drop's. |
|
| 174 | + | - **Visibility:** *public* drops are listed on their owner's Drops page for everyone; *secret* drops are listed only to their owner (and site administrators) and are readable by anyone with the address, signed in or not, on the web, the API and the wire protocol (a secret drop is stored as `private`, so anything that lists by visibility hides it; `auth::roles` grants the read). The transparency log page names a secret drop's repository to nobody but those it is listed to. |
|
| 175 | + | - **Never among repositories:** drops are left out of every list, count and search of repositories: profiles, organisation pages, the dashboard and its sky, Explore, `/search`, `GET /api/v1/repos`, forks, backups (for now), Diffusion and the Flow scheduler. `/api/v1/repos/{owner}/drops%2F{id}` is 404; a drop's repository pages under `/{owner}/drops/{id}/…` (history, change, raw) are read-only. Upkeep that covers every stored repository (gc, the history scan, Admin › Storage, which labels them, `oxygenhub admin repo list` and `oxygenhub check`) includes them. |
|
| 176 | + | - **Pages:** `/drops` goes to your own Drops page, `/{owner}/drops`, which lists drops newest first with their files, and `/drops/new` makes one: a title (optional: the first file's name otherwise), a description, public or secret, and one or more files, each a name and a text area (hub.js adds and removes rows; without it the form has its rows and one empty one). The drop's page shows every file rendered (Markdown as a document, other text highlighted, binary or large files as a download link), the clone command, and for its owner Edit and Delete. Edit is the same form; saving makes one change with only what differs (a file whose name is cleared is removed; files that are not text, pushed with `oxy`, are kept). History (`…/changes/main`), each change with its diff (`…/change/{hash}`), raw files (`…/raw/{ref}/{path}`) and the files at a change (`…/tree/{ref}`) are the repository pages with the drop's own bar (Files, History) in place of the tabs. |
|
| 177 | + | - **Changes:** what the web and the API make is authored by the owner's linked identity and signed by the hub as its delegate, exactly as web edits are (§6.9), so the owner must have delegated to the hub's key first; the forms say how. The first change makes `main`. At most 50 files of 1 MiB each go in one change from the web or the API (larger ones go with `oxy push`). |
|
| 178 | + | - **Who may do what:** anyone with the address reads a drop; only its owner writes (the web, the API, and pushes with the owner's token); its owner or a site administrator deletes it. Flow never runs on a drop; its pushes ring no webhooks and close no issues, and are recorded in its push log. |
|
| 179 | + | - **Push protection** (§15.4) applies to a drop's pushes as to any repository's, with no fingerprint to allow (a drop has no settings), and the hub runs the same rules over what it is asked to commit from the web or the API, refusing before anything is stored. |
|
| 180 | + | - **Deleting** moves the repository to the trash under its exclusive lock (oxygenhub#249) and removes the row; the address stops answering. Audited as `drop.created`, `drop.edited`, `drop.visibility` and `drop.deleted`. |
|
| 181 | + | - **API:** `GET /api/v1/drops` (yours, `repo:read`), `POST /api/v1/drops` (`NewDrop`, `repo:write`), `GET /api/v1/users/{name}/drops` (someone's public drops), `GET /api/v1/drops/{id}` (anyone with the id; each text file's content up to 512 KiB), `PATCH /api/v1/drops/{id}` (`DropEdit`: title, description, `secret`, and files to write or, with `content: null`, remove; others are kept) and `DELETE /api/v1/drops/{id}`. `oh drop create|list|view|delete` (docs/oh.md). |
|
| 182 | + | - **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups. |
|
| 183 | + | ||
| 169 | 184 | ## 5. Accounts, identities and authorization |
|
| 170 | 185 | ||
| 171 | 186 | ### 5.1 Accounts |
|
| … | 299 unchanged lines |
||
| 471 | 486 | ||
| 472 | 487 | ### 12.1 API |
|
| 473 | 488 | ||
| 474 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 489 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route, or a known one with the wrong method, too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/users/{name}/drops` and `/drops` (§4.5), `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, and `owner` to transfer it to an owner the caller may make boards under (#125: it takes the next number there, and its old address redirects), `/cards` to list them by column or add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/coverage` and `/tests` (a bookmark's coverage and JUnit test reports from Flow jobs, newest first, `?bookmark=&limit=`), `POST /coverage` (repo:write, #136: `{"bookmark"}` or `{"change"}` and `content`, an LCOV or Cobertura report measured off the hub, kept for that state as uploaded by the caller, shown "uploaded by NAME" wherever a run number would be, and setting no check; `oh coverage upload FILE`), `/coverage/{id}` (a report with its files) and `/tests/{id}` (a report with its cases, `?status=failed` for the failed and errored ones), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state; with `"jobs": [NAME..]` only those jobs and what they depend on, each one a manual run starts, else `409` naming the ones it does (#130); `oh run start --job NAME`), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/jobs/{job}/rerun` (repo:write, oxygenflow#31: that job of a finished run again, in the same run, with the jobs that depend on it; what it depends on keeps its result, so a flaky test reruns without its build; their logs, artifacts and reports go, their checks return to queued and the run is running again; `{"queued": N}`, or `409` while the run is live or for a job it lacks; RE-RUN THIS JOB on the run page, `oh run rerun N --job NAME`), `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link. |
|
| 475 | 490 | ||
| 476 | 491 | ### 12.2 Webhooks |
|
| 477 | 492 | ||
| … | 394 unchanged lines |
modified templates/admin_storage.html +1 −1
| … | 10 unchanged lines |
||
| 11 | 11 | <thead><tr><th>Repository</th><th>On disk</th><th>Loose objects</th><th>Packs</th><th></th></tr></thead> |
|
| 12 | 12 | <tbody> |
|
| 13 | 13 | {% for r in rows %} |
|
| 14 | - | <tr><td><a href="/{{ r.slug }}">{{ r.slug }}</a></td><td>{{ r.bytes|bytes }}</td><td>{{ r.loose }}{% if r.loose > 1000 %} <span class="tag warn">pack me</span>{% endif %}</td><td>{{ r.packs }}</td> |
|
| 14 | + | <tr><td><a href="/{{ r.slug }}">{{ r.slug }}</a>{% if r.drop %} <span class="tag">drop</span>{% endif %}</td><td>{{ r.bytes|bytes }}</td><td>{{ r.loose }}{% if r.loose > 1000 %} <span class="tag warn">pack me</span>{% endif %}</td><td>{{ r.packs }}</td> |
|
| 15 | 15 | <td><form class="tiny" method="post" action="/admin/storage"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="repo" value="{{ r.slug }}"><button type="submit">maintain</button></form></td></tr> |
|
| 16 | 16 | {% endfor %} |
|
| 17 | 17 | </tbody> |
|
| … | 2 unchanged lines |
modified templates/base.html +1 −1
| … | 30 unchanged lines |
||
| 31 | 31 | <a href="/">{{ crate::web::icons::svg("home")|safe }}{% if shell.user.is_some() %}Dashboard{% else %}Repositories{% endif %}</a> |
|
| 32 | 32 | <a href="/explore">{{ crate::web::icons::svg("explore")|safe }}Explore</a> |
|
| 33 | 33 | <a href="/help">{{ crate::web::icons::svg("help")|safe }}Help</a> |
|
| 34 | - | {% if let Some(u) = shell.user %}<a href="/projects/{{ u }}">{{ crate::web::icons::svg("projects")|safe }}Projects</a><a href="/notifications">{{ crate::web::icons::svg("bell")|safe }}Notifications</a><a href="/settings">{{ crate::web::icons::svg("settings")|safe }}Settings</a>{% endif %} |
|
| 34 | + | {% if let Some(u) = shell.user %}<a href="/projects/{{ u }}">{{ crate::web::icons::svg("projects")|safe }}Projects</a><a href="/{{ u }}/drops">{{ crate::web::icons::svg("drop")|safe }}Drops</a><a href="/notifications">{{ crate::web::icons::svg("bell")|safe }}Notifications</a><a href="/settings">{{ crate::web::icons::svg("settings")|safe }}Settings</a>{% endif %} |
|
| 35 | 35 | <span class="grow"></span> |
|
| 36 | 36 | {% if shell.is_admin %}<a class="admin" href="/admin">{{ crate::web::icons::svg("admin")|safe }}Admin</a>{% endif %} |
|
| 37 | 37 | <a class="dim" href="/about" title="OxygenHub {{ shell.version }} ({{ shell.build }})">v{{ shell.version }}</a> |
|
| … | 25 unchanged lines |
modified templates/history.html +1 −1
| … | 4 unchanged lines |
||
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "changes") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | 7 | <div class="head"><h1>Changes <small>{% if h.target.kind == crate::repos::view::RefKind::Change %}up to # {{ h.target.short_label() }}{% else %}on {{ h.target.label }}{% endif %}{% if let Some(p) = h.path %} touching {{ p }}{% endif %} · {{ h.total }}</small></h1></div> |
|
| 8 | - | <p class="small"><a href="{{ repo.url }}/lanes/{{ h.target.label }}">See as swimlanes</a> <span class="mute">(independent work side by side)</span></p> |
|
| 8 | + | {% if !repo.is_drop %}<p class="small"><a href="{{ repo.url }}/lanes/{{ h.target.label }}">See as swimlanes</a> <span class="mute">(independent work side by side)</span></p>{% endif %} |
|
| 9 | 9 | {% if h.target.heads.len() > 1 %}<div class="strip warn">{{ h.target.label }} has {{ h.target.heads.len() }} heads. The graph shows their union; heads are marked.</div>{% endif %} |
|
| 10 | 10 | {% if h.entries.is_empty() %}<p class="empty">No changes.</p>{% else %} |
|
| 11 | 11 | <div class="hist" style="--lanes: {{ lanes }}"> |
|
| … | 20 unchanged lines |
modified templates/profile.html +1 −1
| … | 15 unchanged lines |
||
| 16 | 16 | <li class="mute">joined <time title="{{ account.created_ms|when_i }}">{{ account.created_ms|ago_i }}</time></li> |
|
| 17 | 17 | </ul> |
|
| 18 | 18 | {% if is_me %}<a class="go small" href="/settings">edit profile</a>{% endif %} |
|
| 19 | - | <p><a class="go small" href="/projects/{{ account.username }}">Projects</a></p> |
|
| 19 | + | <p><a class="go small" href="/projects/{{ account.username }}">Projects</a> <a class="go small" href="/{{ account.username }}/drops">Drops</a></p> |
|
| 20 | 20 | {% if !orgs.is_empty() %} |
|
| 21 | 21 | <section class="side-card"><h3>Organisations <span class="count-chip">{{ orgs.len() }}</span></h3><div class="body"> |
|
| 22 | 22 | <ul class="plain">{% for (o, role) in orgs %}<li><img class="avatar xs" src="/avatar/{{ o.name }}" alt=""> <a href="/{{ o.name }}">{{ o.name }}</a>{% if role == "owner" %} <span class="small mute">owner</span>{% endif %}</li>{% endfor %}</ul> |
|
| … | 18 unchanged lines |
modified templates/repo_tabs.html +10 −0
| 1 | 1 | {% macro tabs(repo, tab) %} |
|
| 2 | + | {% if repo.is_drop %}{# A drop (#279): its own bar, none of a repository's tabs. #} |
|
| 3 | + | <nav class="tabs" aria-label="Drop"> |
|
| 4 | + | <div class="tabs-in"> |
|
| 5 | + | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="/{{ repo.owner }}/drops">drops</a> / <a href="{{ repo.url }}"><strong>{% if repo.drop_title.is_empty() %}{{ repo.name }}{% else %}{{ repo.drop_title }}{% endif %}</strong></a> <span class="vis vis-{% if repo.visibility == "public" %}public{% else %}private{% endif %}">{% if repo.visibility == "public" %}public{% else %}secret{% endif %}</span></span> |
|
| 6 | + | <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Files</a> |
|
| 7 | + | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>History</a> |
|
| 8 | + | </div> |
|
| 9 | + | </nav> |
|
| 10 | + | {% else %} |
|
| 2 | 11 | <nav class="tabs" aria-label="Repository"> |
|
| 3 | 12 | <div class="tabs-in"> |
|
| 4 | 13 | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings#storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span> |
|
| … | 30 unchanged lines |
||
| 35 | 44 | {% if repo.can_maintain() %}<a href="{{ repo.url }}/settings"{% if tab == "settings" %} class="on" aria-current="page"{% endif %}>Settings</a>{% endif %} |
|
| 36 | 45 | </div> |
|
| 37 | 46 | </nav> |
|
| 47 | + | {% endif %} |
|
| 38 | 48 | {% endmacro %} |
|
| 39 | 49 | ||
| 40 | 50 | {% macro sig(s) %}<span class="sig {{ s.class() }}" title="{{ s.title() }}">{{ s.glyph() }}</span>{% endmacro %} |
|
| … | 53 unchanged lines |
modified templates/style.css +5 −0
| … | 1362 unchanged lines |
||
| 1363 | 1363 | @keyframes pu-ring { from { r: 4; opacity: .9; } to { r: 15; opacity: 0; } } |
|
| 1364 | 1364 | .pu-ring { animation: pu-ring 2.4s ease-out infinite; } |
|
| 1365 | 1365 | @media (prefers-reduced-motion: reduce) { .pu-ring { animation: none; opacity: 0; } } |
|
| 1366 | + | ||
| 1367 | + | /* Drops (oxygenhub#279). */ |
|
| 1368 | + | .drop-filebox { margin-bottom: 16px; } |
|
| 1369 | + | .drop-file { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; margin: 0 0 12px; } |
|
| 1370 | + | .drop-file-actions { display: flex; align-items: flex-end; justify-content: flex-end; } |