OXYGENHUB

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 +
            &current
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(&current, &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; }