OXYGENHUB

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

287 of 343 lines run, 48 of 114 functions · run #544 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
631×
fn bad(msg: impl Into<String>) -> DropError {
641×
    DropError::Bad(msg.into())
651×
}
66
67
/// `public` or `secret`, as people and the API say it.
6820×
pub fn visibility_label(row: &RepoRow) -> &'static str {
6920×
    if row.visibility() == Visibility::Public {
709×
        "public"
71
    } else {
7211×
        "secret"
73
    }
7420×
}
75
7610×
fn visibility(secret: bool) -> Visibility {
7710×
    if secret {
784×
        Visibility::Private
79
    } else {
806×
        Visibility::Public
81
    }
8210×
}
83
84
/// The drop's id, from its row.
8514×
pub fn id_of(row: &RepoRow) -> &str {
8614×
    row.name.split_once('/').map(|(_, id)| id).unwrap_or(&row.name)
8714×
}
88
89
/// `/{owner}/drops/{id}`.
90100×
pub fn url_of(row: &RepoRow) -> String {
91100×
    format!("/{}/{}", row.owner, row.name)
92100×
}
93
94
/// The title a page shows: its own, else its first file's name, else its id.
9538×
pub fn shown_title(row: &RepoRow, files: &[DropFile]) -> String {
9638×
    if !row.title.is_empty() {
9735×
        row.title.clone()
983×
    } else if let Some(f) = files.first() {
993×
        f.path.clone()
100
    } else {
1010×
        id_of(row).to_string()
102
    }
10338×
}
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.
12219×
pub fn check_name(name: &str) -> Result<String, String> {
12319×
    let name = name.trim().trim_matches('/');
12419×
    if name.is_empty() {
1250×
        return Err("Every file needs a name.".into());
12619×
    }
12719×
    if name.len() > 255 {
1280×
        return Err(format!("{name:.40}…: a file name is at most 255 bytes."));
12919×
    }
130133×
    if name.chars().any(|c| c.is_control() || c == '\\')
13119×
        || 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."));
13419×
    }
13519×
    Ok(name.to_string())
13619×
}
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.
1409×
pub fn check_files(files: &[NewFile], allow_empty: bool) -> Result<Vec<NewFile>, String> {
1419×
    if files.is_empty() && !allow_empty {
1420×
        return Err("A drop needs at least one file.".into());
1439×
    }
1449×
    if files.len() > MAX_FILES {
1450×
        return Err(format!("A drop takes at most {MAX_FILES} files at a time."));
1469×
    }
1479×
    let mut out: Vec<NewFile> = Vec::with_capacity(files.len());
14814×
    for f in files {
14914×
        let name = check_name(&f.name)?;
15014×
        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));
15214×
        }
15314×
        if out.iter().any(|o| o.name == name) {
1540×
            return Err(format!("{name} is named twice."));
15514×
        }
156
        // Line endings as the browser's editor shows them.
15714×
        out.push(NewFile { name, content: f.content.replace("\r\n", "\n") });
158
    }
1599×
    scan(&out)?;
1608×
    Ok(out)
1619×
}
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.
16612×
pub fn scan(files: &[NewFile]) -> Result<(), String> {
16712×
    let rules = crate::push_protection::rules();
16812×
    let mut found = Vec::new();
16917×
    for f in files {
17017×
        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
    }
17512×
    if found.is_empty() {
17610×
        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
    ))
18712×
}
188
18910×
fn check_text(title: &str, description: &str) -> Result<(), String> {
19010×
    if title.chars().count() > MAX_TITLE {
1910×
        return Err(format!("A title is at most {MAX_TITLE} characters."));
19210×
    }
19310×
    if description.chars().count() > MAX_DESCRIPTION {
1940×
        return Err(format!("A description is at most {MAX_DESCRIPTION} characters."));
19510×
    }
19610×
    Ok(())
19710×
}
198
199
/// The owner's signer: their identity that delegates to the hub's key.
2009×
async fn signer(app: &Shared, account: &Account) -> Result<Signer, DropError> {
2019×
    crate::web::edit::signer_for(app, account.id).await.map_err(DropError::Bad)
2029×
}
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.
2077×
pub async fn create(
2087×
    app: &Shared,
2097×
    account: &Account,
2107×
    title: &str,
2117×
    description: &str,
2127×
    secret: bool,
2137×
    files: &[NewFile],
2147×
) -> Result<RepoRow, DropError> {
2157×
    if account.blocked() {
2160×
        return Err(bad("This account cannot make drops."));
2177×
    }
2187×
    let (title, description) = (title.trim(), description.trim());
2197×
    check_text(title, description).map_err(DropError::Bad)?;
2207×
    let files = check_files(files, false).map_err(DropError::Bad)?;
2217×
    let signer = signer(app, account).await?;
222
    // Ids are unique across the hub, so `oh drop view ID` needs no owner.
2236×
    let mut found = None;
2246×
    for _ in 0..8 {
2256×
        let id = crate::auth::random_hex(12);
2266×
        let Some(name) = RepoName::drop(&account.username, &id) else { break };
2276×
        if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) {
2286×
            found = Some(name);
2296×
            break;
2300×
        }
231
    }
2326×
    let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) };
2336×
    let mut repo_id = [0u8; 16];
2346×
    rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut repo_id);
2356×
    app.repos.create_async(&name, Some(repo_id)).await?;
2366×
    let id = name.drop_id().unwrap_or_default().to_string();
2376×
    let row = match db::repos::create_drop(&app.db, &account.username, &id, title, description, visibility(secret)).await {
2386×
        Ok(row) => row,
2390×
        Err(e) => {
2400×
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
2410×
            return Err(e.into());
242
        }
243
    };
2446×
    let edits: Vec<FileEdit> =
2458×
        files.iter().map(|f| FileEdit { path: f.name.clone(), content: Some(f.content.clone().into_bytes()) }).collect();
2466×
    let message = if title.is_empty() { "Create the drop".to_string() } else { format!("Create {title}") };
2476×
    let b = row.default_bookmark.clone();
2486×
    match edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await {
2496×
        Ok(done) => {
2506×
            recorded(app, account, &row, &done, "drop.created", visibility_label(&row)).await;
2516×
            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
    }
2597×
}
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.
2642×
pub async fn fork(app: &Shared, account: &Account, source: &RepoRow) -> Result<RepoRow, DropError> {
2652×
    if account.blocked() {
2660×
        return Err(bad("This account cannot make drops."));
2672×
    }
2682×
    let Some(from) = RepoName::new(&source.owner, &source.name) else { return Err(bad("That is not a drop.")) };
2692×
    let mut found = None;
2702×
    for _ in 0..8 {
2712×
        let id = crate::auth::random_hex(12);
2722×
        let Some(name) = RepoName::drop(&account.username, &id) else { break };
2732×
        if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) {
2742×
            found = Some(name);
2752×
            break;
2760×
        }
277
    }
2782×
    let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) };
2792×
    app.repos.fork(&from, &name).await?;
2802×
    let id = name.drop_id().unwrap_or_default().to_string();
2812×
    let row = match db::repos::create_drop(&app.db, &account.username, &id, &source.title, &source.description, source.visibility()).await {
2822×
        Ok(row) => row,
2830×
        Err(e) => {
2840×
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
2850×
            return Err(e.into());
286
        }
287
    };
2882×
    db::repos::set_forked_from(&app.db, row.id, source.id).await?;
2892×
    db::accounts::audit(&app.db, Some(account), "drop.forked", &url_of(&row), &url_of(source)).await.ok();
2902×
    Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
2912×
}
292
293
/// The longest comment on a drop, in bytes.
294
pub const COMMENT_MAX: usize = 64 * 1024;
295
296
/// `account` comments on `row` (#280, oxygenhub#349): some Markdown, at most
297
/// [`COMMENT_MAX`]; the drop's owner is told. The comment's number.
2985×
pub async fn add_comment(app: &Shared, account: &Account, row: &RepoRow, body: &str) -> Result<i64, DropError> {
2995×
    let body = body.trim();
3005×
    if body.is_empty() || body.len() > COMMENT_MAX || account.blocked() {
3011×
        return Err(bad("A comment is some text, at most 64 KiB."));
3024×
    }
3034×
    let n = db::repos::add_drop_comment(&app.db, row.id, account.id, &account.username, body).await?;
3044×
    if account.username != row.owner {
3053×
        if let Ok(Some(owner)) = db::accounts::account_by_name(&app.db, &row.owner).await {
3063×
            let title = format!("{} on your drop {}", account.username, shown_title(row, &[]));
3073×
            crate::notify::tell(
3083×
                app,
3093×
                "drop.comment",
3103×
                &title,
3113×
                &format!("{}#comment-{n}", url_of(row)),
3123×
                &account.username,
3133×
                Some(row.id),
3143×
                vec![(owner.id, db::notify::Reason::Participating)],
3153×
            )
3163×
            .await;
3170×
        }
3181×
    }
3194×
    Ok(n)
3205×
}
321
322
/// Whether `account` may delete comment `c` on `row`: its author, the
323
/// drop's owner or a site administrator.
32413×
pub fn may_delete_comment(account: Option<&Account>, row: &RepoRow, c: &db::repos::DropComment) -> bool {
32513×
    account.is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin))
32613×
}
327
328
/// What a change to a drop's text or files is.
329
pub struct Update {
330
    pub title: String,
331
    pub description: String,
332
    pub secret: bool,
333
    /// Files to write or (content `None`) remove; others are kept.
334
    pub files: Vec<FileEdit>,
335
    pub message: String,
336
}
337
338
/// Change a drop: its files in one change made as the owner's delegate,
339
/// when any changed, and its title, description and visibility.
3403×
pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> {
3413×
    let (title, description) = (u.title.trim(), u.description.trim());
3423×
    check_text(title, description).map_err(DropError::Bad)?;
3433×
    if !u.files.is_empty() {
3443×
        if u.files.len() > MAX_FILES {
3450×
            return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time.")));
3463×
        }
3473×
        let written: Vec<NewFile> = u
3483×
            .files
3493×
            .iter()
3503×
            .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() }))
3513×
            .collect();
3523×
        for f in &written {
3533×
            check_name(&f.name).map_err(DropError::Bad)?;
3543×
            if f.content.len() > MAX_FILE_BYTES {
3550×
                return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024)));
3563×
            }
357
        }
3583×
        scan(&written).map_err(DropError::Bad)?;
3592×
        let signer = signer(app, account).await?;
3602×
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
3612×
        let message = if u.message.trim().is_empty() {
3621×
            format!("Edit {}", if title.is_empty() { id_of(row) } else { title })
363
        } else {
3641×
            u.message.trim().to_string()
365
        };
366
        // Removing every file would leave an empty drop: delete it instead.
3672×
        if u.files.iter().any(|e| e.content.is_none()) {
3680×
            let (_, current) = files(app, row, "").await?;
3690×
            let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count();
3700×
            if left == 0 && written.is_empty() {
3710×
                return Err(bad("A drop keeps at least one file; delete the drop instead."));
3720×
            }
3732×
        }
3742×
        let b = row.default_bookmark.clone();
3752×
        let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer)
3762×
            .await
3772×
            .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?;
3782×
        recorded(app, account, row, &done, "drop.edited", "").await;
3790×
    }
3802×
    db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?;
3812×
    if visibility(u.secret) != row.visibility() {
3821×
        db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok();
3831×
    }
3842×
    Ok(())
3853×
}
386
387
/// The push log and the audit log for a change the hub made to a drop.
3888×
async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) {
3898×
    let outcome = db::repos::PushOutcome {
3908×
        reference: done.bookmark.clone(),
3918×
        ok: true,
3928×
        reason: "web edit".into(),
3938×
        old_state: done.old_state.clone(),
3948×
        new_state: done.new_state.clone(),
3958×
        heads: done.heads.len() as i64,
3968×
        changes: 1,
3978×
    };
3988×
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
3998×
    let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) };
4008×
    db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok();
4018×
}
402
403
/// Delete a drop: its directory to the trash and its row, once nothing
404
/// reads or writes it (#249), audited.
4054×
pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> {
4064×
    let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
4074×
    let _guard = app.repos.exclusive_guard(&name).await;
4084×
    match app.repos.trash(&name) {
4094×
        Ok(_) | Err(RepoError::NotFound) => {}
4100×
        Err(e) => return Err(e.into()),
411
    }
4124×
    db::repos::delete(&app.db, row.id).await?;
4134×
    db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok();
4144×
    Ok(())
4154×
}
416
417
/// The drop's files at `spec` (its default bookmark when empty): every
418
/// file, with its text when it is text and small enough to show.
41953×
pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec<DropFile>), RepoError> {
42053×
    let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?;
42153×
    let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() };
42253×
    app.repos
42353×
        .read(&name, move |repo, cache| {
42453×
            let target = crate::repos::view::resolve(repo, cache, &spec)?;
42553×
            let loaded = cache.load(repo, &target.heads)?;
42653×
            let mut out = Vec::new();
42780×
            for (path, f) in &loaded.snapshot.files {
42880×
                let bytes = match (&f.kind, &f.content) {
4290×
                    (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
43080×
                    (_, FileContent::Text { bytes, .. }) => bytes.clone(),
4310×
                    (_, FileContent::Opaque { blobs }) => match blobs.first() {
4320×
                        Some(b) => repo.read_blob(b)?,
4330×
                        None => Vec::new(),
434
                    },
435
                };
43680×
                let size = bytes.len() as u64;
43780×
                let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None };
43880×
                out.push(DropFile { path: path.clone(), size, text });
439
            }
44053×
            Ok((target, out))
44153×
        })
44253×
        .await
44353×
}
444
445
/// The drop `id` of `owner`, or none.
44656×
pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result<Option<RepoRow>> {
44756×
    let Some(name) = RepoName::drop(owner, id) else { return Ok(None) };
44856×
    Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop()))
44956×
}
450
451
/// Whether `account` may change the drop: its owner alone.
45278×
pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool {
45378×
    account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner))
45478×
}
455
456
/// Whether `account` may delete the drop: its owner, or a site
457
/// administrator.
45840×
pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool {
45940×
    may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin)
46040×
}
461
462
/// Whether the drop is listed to `account`: public ones to everyone, secret
463
/// ones to their owner and site administrators only.
46414×
pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool {
46514×
    row.visibility() == Visibility::Public || may_delete(account, row)
46614×
}
467
468
/// The edits that turn `current` text files into `wanted`: files gone are
469
/// removed, new or changed ones written. Files that are not text (pushed
470
/// with oxy) are left alone.
4711×
pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec<FileEdit> {
4721×
    let mut out = Vec::new();
4732×
    for c in current.iter().filter(|c| c.text.is_some()) {
4743×
        if !wanted.iter().any(|w| w.name == c.path) {
4750×
            out.push(FileEdit { path: c.path.clone(), content: None });
4762×
        }
477
    }
4782×
    for w in wanted {
4793×
        let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str()));
4802×
        if !same {
4811×
            out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) });
4821×
        }
483
    }
4841×
    out
4851×
}