OXYGENHUB

Coverage / crates/hub/src/drops.rs 81.6%

257 of 315 lines run, 43 of 106 functions · run #505 on main · run missed

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 {
420×
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430×
        match self {
440×
            DropError::Bad(m) => f.write_str(m),
450×
            DropError::Repo(e) => write!(f, "{e}"),
460×
            DropError::Db(e) => write!(f, "{e}"),
47
        }
480×
    }
49
}
50
51
impl From<sqlx::Error> for DropError {
520×
    fn from(e: sqlx::Error) -> DropError {
530×
        DropError::Db(e)
540×
    }
55
}
56
57
impl From<RepoError> for DropError {
580×
    fn from(e: RepoError) -> DropError {
590×
        DropError::Repo(e)
600×
    }
61
}
62
630×
fn bad(msg: impl Into<String>) -> DropError {
640×
    DropError::Bad(msg.into())
650×
}
66
67
/// `public` or `secret`, as people and the API say it.
6816×
pub fn visibility_label(row: &RepoRow) -> &'static str {
6916×
    if row.visibility() == Visibility::Public {
705×
        "public"
71
    } else {
7211×
        "secret"
73
    }
7416×
}
75
769×
fn visibility(secret: bool) -> Visibility {
779×
    if secret {
784×
        Visibility::Private
79
    } else {
805×
        Visibility::Public
81
    }
829×
}
83
84
/// The drop's id, from its row.
8511×
pub fn id_of(row: &RepoRow) -> &str {
8611×
    row.name.split_once('/').map(|(_, id)| id).unwrap_or(&row.name)
8711×
}
88
89
/// `/{owner}/drops/{id}`.
9091×
pub fn url_of(row: &RepoRow) -> String {
9191×
    format!("/{}/{}", row.owner, row.name)
9291×
}
93
94
/// The title a page shows: its own, else its first file's name, else its id.
9536×
pub fn shown_title(row: &RepoRow, files: &[DropFile]) -> String {
9636×
    if !row.title.is_empty() {
9733×
        row.title.clone()
983×
    } else if let Some(f) = files.first() {
993×
        f.path.clone()
100
    } else {
1010×
        id_of(row).to_string()
102
    }
10336×
}
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.
12218×
pub fn check_name(name: &str) -> Result<String, String> {
12318×
    let name = name.trim().trim_matches('/');
12418×
    if name.is_empty() {
1250×
        return Err("Every file needs a name.".into());
12618×
    }
12718×
    if name.len() > 255 {
1280×
        return Err(format!("{name:.40}…: a file name is at most 255 bytes."));
12918×
    }
130128×
    if name.chars().any(|c| c.is_control() || c == '\\')
13118×
        || name.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == oxy_core::repo::OXY_DIR || p.trim() != p)
132
    {
1330×
        return Err(format!("{name:?} is not a file name: use letters, digits and punctuation, with / between folders."));
13418×
    }
13518×
    Ok(name.to_string())
13618×
}
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.
1408×
pub fn check_files(files: &[NewFile], allow_empty: bool) -> Result<Vec<NewFile>, String> {
1418×
    if files.is_empty() && !allow_empty {
1420×
        return Err("A drop needs at least one file.".into());
1438×
    }
1448×
    if files.len() > MAX_FILES {
1450×
        return Err(format!("A drop takes at most {MAX_FILES} files at a time."));
1468×
    }
1478×
    let mut out: Vec<NewFile> = Vec::with_capacity(files.len());
14813×
    for f in files {
14913×
        let name = check_name(&f.name)?;
15013×
        if f.content.len() > MAX_FILE_BYTES {
1510×
            return Err(format!("{name} is larger than {} KiB; push larger files with oxy.", MAX_FILE_BYTES / 1024));
15213×
        }
15313×
        if out.iter().any(|o| o.name == name) {
1540×
            return Err(format!("{name} is named twice."));
15513×
        }
156
        // Line endings as the browser's editor shows them.
15713×
        out.push(NewFile { name, content: f.content.replace("\r\n", "\n") });
158
    }
1598×
    scan(&out)?;
1607×
    Ok(out)
1618×
}
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.
16611×
pub fn scan(files: &[NewFile]) -> Result<(), String> {
16711×
    let rules = crate::push_protection::rules();
16811×
    let mut found = Vec::new();
16916×
    for f in files {
17016×
        for h in crate::push_protection::scan_text(rules, f.content.as_bytes()) {
1712×
            let rule = &rules.rules[h.rule];
1722×
            found.push(format!("{}:{}: {} (fingerprint {})", f.name, h.line, rule.description, h.fingerprint));
1732×
        }
174
    }
17511×
    if found.is_empty() {
1769×
        return Ok(());
1772×
    }
1782×
    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 {}.",
1802×
        if found.len() == 1 { "a value looks".to_string() } else { format!("{} values look", found.len()) },
1812×
        if found.len() == 1 { "a secret" } else { "secrets" },
1822×
        found.join("; "),
1832×
        if found.len() == 1 { "it" } else { "them" },
1842×
        if found.len() == 1 { "it is" } else { "one is" },
1852×
        if found.len() == 1 { "it" } else { "it as well" },
186
    ))
18711×
}
188
1899×
fn check_text(title: &str, description: &str) -> Result<(), String> {
1909×
    if title.chars().count() > MAX_TITLE {
1910×
        return Err(format!("A title is at most {MAX_TITLE} characters."));
1929×
    }
1939×
    if description.chars().count() > MAX_DESCRIPTION {
1940×
        return Err(format!("A description is at most {MAX_DESCRIPTION} characters."));
1959×
    }
1969×
    Ok(())
1979×
}
198
199
/// The owner's signer: their identity that delegates to the hub's key.
2008×
async fn signer(app: &Shared, account: &Account) -> Result<Signer, DropError> {
2018×
    crate::web::edit::signer_for(app, account.id).await.map_err(DropError::Bad)
2028×
}
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.
2076×
pub async fn create(
2086×
    app: &Shared,
2096×
    account: &Account,
2106×
    title: &str,
2116×
    description: &str,
2126×
    secret: bool,
2136×
    files: &[NewFile],
2146×
) -> Result<RepoRow, DropError> {
2156×
    if account.blocked() {
2160×
        return Err(bad("This account cannot make drops."));
2176×
    }
2186×
    let (title, description) = (title.trim(), description.trim());
2196×
    check_text(title, description).map_err(DropError::Bad)?;
2206×
    let files = check_files(files, false).map_err(DropError::Bad)?;
2216×
    let signer = signer(app, account).await?;
222
    // Ids are unique across the hub, so `oh drop view ID` needs no owner.
2235×
    let mut found = None;
2245×
    for _ in 0..8 {
2255×
        let id = crate::auth::random_hex(12);
2265×
        let Some(name) = RepoName::drop(&account.username, &id) else { break };
2275×
        if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) {
2285×
            found = Some(name);
2295×
            break;
2300×
        }
231
    }
2325×
    let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) };
2335×
    let mut repo_id = [0u8; 16];
2345×
    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut repo_id);
2355×
    app.repos.create_async(&name, Some(repo_id)).await?;
2365×
    let id = name.drop_id().unwrap_or_default().to_string();
2375×
    let row = match db::repos::create_drop(&app.db, &account.username, &id, title, description, visibility(secret)).await {
2385×
        Ok(row) => row,
2390×
        Err(e) => {
2400×
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
2410×
            return Err(e.into());
242
        }
243
    };
2445×
    let edits: Vec<FileEdit> =
2457×
        files.iter().map(|f| FileEdit { path: f.name.clone(), content: Some(f.content.clone().into_bytes()) }).collect();
2465×
    let message = if title.is_empty() { "Create the drop".to_string() } else { format!("Create {title}") };
2475×
    let b = row.default_bookmark.clone();
2485×
    match edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await {
2495×
        Ok(done) => {
2505×
            recorded(app, account, &row, &done, "drop.created", visibility_label(&row)).await;
2515×
            Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
252
        }
2530×
        Err(e) => {
2540×
            db::repos::delete(&app.db, row.id).await.ok();
2550×
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
2560×
            Err(DropError::Bad(format!("The drop was not made: {e}")))
257
        }
258
    }
2596×
}
260
261
/// Fork `source` for `account` (#280): a new drop of theirs whose history
262
/// starts from it (its objects linked, its bookmarks copied), with its
263
/// title, description and secrecy, noting where it came from.
2641×
pub async fn fork(app: &Shared, account: &Account, source: &RepoRow) -> Result<RepoRow, DropError> {
2651×
    if account.blocked() {
2660×
        return Err(bad("This account cannot make drops."));
2671×
    }
2681×
    let Some(from) = RepoName::new(&source.owner, &source.name) else { return Err(bad("That is not a drop.")) };
2691×
    let mut found = None;
2701×
    for _ in 0..8 {
2711×
        let id = crate::auth::random_hex(12);
2721×
        let Some(name) = RepoName::drop(&account.username, &id) else { break };
2731×
        if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) {
2741×
            found = Some(name);
2751×
            break;
2760×
        }
277
    }
2781×
    let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) };
2791×
    app.repos.fork(&from, &name).await?;
2801×
    let id = name.drop_id().unwrap_or_default().to_string();
2811×
    let row = match db::repos::create_drop(&app.db, &account.username, &id, &source.title, &source.description, source.visibility()).await {
2821×
        Ok(row) => row,
2830×
        Err(e) => {
2840×
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
2850×
            return Err(e.into());
286
        }
287
    };
2881×
    db::repos::set_forked_from(&app.db, row.id, source.id).await?;
2891×
    db::accounts::audit(&app.db, Some(account), "drop.forked", &url_of(&row), &url_of(source)).await.ok();
2901×
    Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
2911×
}
292
293
/// What a change to a drop's text or files is.
294
pub struct Update {
295
    pub title: String,
296
    pub description: String,
297
    pub secret: bool,
298
    /// Files to write or (content `None`) remove; others are kept.
299
    pub files: Vec<FileEdit>,
300
    pub message: String,
301
}
302
303
/// Change a drop: its files in one change made as the owner's delegate,
304
/// when any changed, and its title, description and visibility.
3053×
pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> {
3063×
    let (title, description) = (u.title.trim(), u.description.trim());
3073×
    check_text(title, description).map_err(DropError::Bad)?;
3083×
    if !u.files.is_empty() {
3093×
        if u.files.len() > MAX_FILES {
3100×
            return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time.")));
3113×
        }
3123×
        let written: Vec<NewFile> = u
3133×
            .files
3143×
            .iter()
3153×
            .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() }))
3163×
            .collect();
3173×
        for f in &written {
3183×
            check_name(&f.name).map_err(DropError::Bad)?;
3193×
            if f.content.len() > MAX_FILE_BYTES {
3200×
                return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024)));
3213×
            }
322
        }
3233×
        scan(&written).map_err(DropError::Bad)?;
3242×
        let signer = signer(app, account).await?;
3252×
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
3262×
        let message = if u.message.trim().is_empty() {
3271×
            format!("Edit {}", if title.is_empty() { id_of(row) } else { title })
328
        } else {
3291×
            u.message.trim().to_string()
330
        };
331
        // Removing every file would leave an empty drop: delete it instead.
3322×
        if u.files.iter().any(|e| e.content.is_none()) {
3330×
            let (_, current) = files(app, row, "").await?;
3340×
            let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count();
3350×
            if left == 0 && written.is_empty() {
3360×
                return Err(bad("A drop keeps at least one file; delete the drop instead."));
3370×
            }
3382×
        }
3392×
        let b = row.default_bookmark.clone();
3402×
        let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer)
3412×
            .await
3422×
            .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?;
3432×
        recorded(app, account, row, &done, "drop.edited", "").await;
3440×
    }
3452×
    db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?;
3462×
    if visibility(u.secret) != row.visibility() {
3471×
        db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok();
3481×
    }
3492×
    Ok(())
3503×
}
351
352
/// The push log and the audit log for a change the hub made to a drop.
3537×
async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) {
3547×
    let outcome = db::repos::PushOutcome {
3557×
        reference: done.bookmark.clone(),
3567×
        ok: true,
3577×
        reason: "web edit".into(),
3587×
        old_state: done.old_state.clone(),
3597×
        new_state: done.new_state.clone(),
3607×
        heads: done.heads.len() as i64,
3617×
        changes: 1,
3627×
    };
3637×
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
3647×
    let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) };
3657×
    db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok();
3667×
}
367
368
/// Delete a drop: its directory to the trash and its row, once nothing
369
/// reads or writes it (#249), audited.
3704×
pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> {
3714×
    let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
3724×
    let _guard = app.repos.exclusive_guard(&name).await;
3734×
    match app.repos.trash(&name) {
3744×
        Ok(_) | Err(RepoError::NotFound) => {}
3750×
        Err(e) => return Err(e.into()),
376
    }
3774×
    db::repos::delete(&app.db, row.id).await?;
3784×
    db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok();
3794×
    Ok(())
3804×
}
381
382
/// The drop's files at `spec` (its default bookmark when empty): every
383
/// file, with its text when it is text and small enough to show.
38450×
pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec<DropFile>), RepoError> {
38550×
    let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?;
38650×
    let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() };
38750×
    app.repos
38850×
        .read(&name, move |repo, cache| {
38950×
            let target = crate::repos::view::resolve(repo, cache, &spec)?;
39050×
            let loaded = cache.load(repo, &target.heads)?;
39150×
            let mut out = Vec::new();
39277×
            for (path, f) in &loaded.snapshot.files {
39377×
                let bytes = match (&f.kind, &f.content) {
3940×
                    (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
39577×
                    (_, FileContent::Text { bytes, .. }) => bytes.clone(),
3960×
                    (_, FileContent::Opaque { blobs }) => match blobs.first() {
3970×
                        Some(b) => repo.read_blob(b)?,
3980×
                        None => Vec::new(),
399
                    },
400
                };
40177×
                let size = bytes.len() as u64;
40277×
                let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None };
40377×
                out.push(DropFile { path: path.clone(), size, text });
404
            }
40550×
            Ok((target, out))
40650×
        })
40750×
        .await
40850×
}
409
410
/// The drop `id` of `owner`, or none.
41156×
pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result<Option<RepoRow>> {
41256×
    let Some(name) = RepoName::drop(owner, id) else { return Ok(None) };
41356×
    Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop()))
41456×
}
415
416
/// Whether `account` may change the drop: its owner alone.
41778×
pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool {
41878×
    account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner))
41978×
}
420
421
/// Whether `account` may delete the drop: its owner, or a site
422
/// administrator.
42340×
pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool {
42440×
    may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin)
42540×
}
426
427
/// Whether the drop is listed to `account`: public ones to everyone, secret
428
/// ones to their owner and site administrators only.
42914×
pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool {
43014×
    row.visibility() == Visibility::Public || may_delete(account, row)
43114×
}
432
433
/// The edits that turn `current` text files into `wanted`: files gone are
434
/// removed, new or changed ones written. Files that are not text (pushed
435
/// with oxy) are left alone.
4361×
pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec<FileEdit> {
4371×
    let mut out = Vec::new();
4382×
    for c in current.iter().filter(|c| c.text.is_some()) {
4393×
        if !wanted.iter().any(|w| w.name == c.path) {
4400×
            out.push(FileEdit { path: c.path.clone(), content: None });
4412×
        }
442
    }
4432×
    for w in wanted {
4443×
        let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str()));
4452×
        if !same {
4461×
            out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) });
4471×
        }
448
    }
4491×
    out
4501×
}