//! Drops (oxygenhub#279): small snippets of one or more files, as gists //! are, each its own Oxygen repository. //! //! A drop is a repository named `drops/ID` under its owner, with //! `repositories.kind = 'drop'`: stored, locked, cloned and pushed as any //! repository is (`oxy clone https://HUB/OWNER/drops/ID`), but never //! listed among repositories. Its id is 24 random hex digits, so a secret //! drop's address cannot be guessed; a secret drop is `private` to every //! list and readable by anyone who has the address (`auth::roles`). The //! hub makes its changes as the owner's delegate, as web edits are made, //! after the same push protection scan a push gets. use crate::db::{self, accounts::Account, repos::RepoRow}; use crate::repos::edits::{self, Signer}; use crate::repos::{RepoError, RepoName}; use crate::Shared; use hub_api::Visibility; use oxy_core::objects::NodeKind; use oxy_core::repo::FileEdit; use oxy_core::state::FileContent; /// Most files a drop takes from the web or the API in one go. pub const MAX_FILES: usize = 50; /// Largest file the web or the API takes, in bytes. pub const MAX_FILE_BYTES: usize = 1 << 20; /// Longest title and description. pub const MAX_TITLE: usize = 200; pub const MAX_DESCRIPTION: usize = 2000; /// Most text a drop page shows of one file; past it, the raw link. const SHOWN_BYTES: u64 = 512 * 1024; /// Why a drop was not made or changed. #[derive(Debug)] pub enum DropError { /// What the person can fix, said to them. Bad(String), Repo(RepoError), Db(sqlx::Error), } impl std::fmt::Display for DropError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DropError::Bad(m) => f.write_str(m), DropError::Repo(e) => write!(f, "{e}"), DropError::Db(e) => write!(f, "{e}"), } } } impl From for DropError { fn from(e: sqlx::Error) -> DropError { DropError::Db(e) } } impl From for DropError { fn from(e: RepoError) -> DropError { DropError::Repo(e) } } fn bad(msg: impl Into) -> DropError { DropError::Bad(msg.into()) } /// `public` or `secret`, as people and the API say it. pub fn visibility_label(row: &RepoRow) -> &'static str { if row.visibility() == Visibility::Public { "public" } else { "secret" } } fn visibility(secret: bool) -> Visibility { if secret { Visibility::Private } else { Visibility::Public } } /// The drop's id, from its row. pub fn id_of(row: &RepoRow) -> &str { row.name.split_once('/').map(|(_, id)| id).unwrap_or(&row.name) } /// `/{owner}/drops/{id}`. pub fn url_of(row: &RepoRow) -> String { format!("/{}/{}", row.owner, row.name) } /// The title a page shows: its own, else its first file's name, else its id. pub fn shown_title(row: &RepoRow, files: &[DropFile]) -> String { if !row.title.is_empty() { row.title.clone() } else if let Some(f) = files.first() { f.path.clone() } else { id_of(row).to_string() } } /// One of a drop's files. #[derive(Clone, Debug)] pub struct DropFile { pub path: String, pub size: u64, /// The text, when the file is text and not too large to show. pub text: Option, } /// A file as given from the web or the API: a name and its content. #[derive(Clone, Debug)] pub struct NewFile { pub name: String, pub content: String, } /// A file name: a path of plain parts, at most 255 bytes. pub fn check_name(name: &str) -> Result { let name = name.trim().trim_matches('/'); if name.is_empty() { return Err("Every file needs a name.".into()); } if name.len() > 255 { return Err(format!("{name:.40}…: a file name is at most 255 bytes.")); } if name.chars().any(|c| c.is_control() || c == '\\') || name.split('/').any(|p| p.is_empty() || p == "." || p == ".." || p == oxy_core::repo::OXY_DIR || p.trim() != p) { return Err(format!("{name:?} is not a file name: use letters, digits and punctuation, with / between folders.")); } Ok(name.to_string()) } /// Files from the web or the API, checked: named, not too many or too /// large, no name twice, and nothing push protection refuses. pub fn check_files(files: &[NewFile], allow_empty: bool) -> Result, String> { if files.is_empty() && !allow_empty { return Err("A drop needs at least one file.".into()); } if files.len() > MAX_FILES { return Err(format!("A drop takes at most {MAX_FILES} files at a time.")); } let mut out: Vec = Vec::with_capacity(files.len()); for f in files { let name = check_name(&f.name)?; if f.content.len() > MAX_FILE_BYTES { return Err(format!("{name} is larger than {} KiB; push larger files with oxy.", MAX_FILE_BYTES / 1024)); } if out.iter().any(|o| o.name == name) { return Err(format!("{name} is named twice.")); } // Line endings as the browser's editor shows them. out.push(NewFile { name, content: f.content.replace("\r\n", "\n") }); } scan(&out)?; Ok(out) } /// Push protection (#268) over what the hub is asked to commit: the same /// rules a push gets, so a drop made on the web cannot carry what a push /// would be refused for. pub fn scan(files: &[NewFile]) -> Result<(), String> { let rules = crate::push_protection::rules(); let mut found = Vec::new(); for f in files { for h in crate::push_protection::scan_text(rules, f.content.as_bytes()) { let rule = &rules.rules[h.rule]; found.push(format!("{}:{}: {} (fingerprint {})", f.name, h.line, rule.description, h.fingerprint)); } } if found.is_empty() { return Ok(()); } Err(format!( "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 {}.", if found.len() == 1 { "a value looks".to_string() } else { format!("{} values look", found.len()) }, if found.len() == 1 { "a secret" } else { "secrets" }, found.join("; "), if found.len() == 1 { "it" } else { "them" }, if found.len() == 1 { "it is" } else { "one is" }, if found.len() == 1 { "it" } else { "it as well" }, )) } fn check_text(title: &str, description: &str) -> Result<(), String> { if title.chars().count() > MAX_TITLE { return Err(format!("A title is at most {MAX_TITLE} characters.")); } if description.chars().count() > MAX_DESCRIPTION { return Err(format!("A description is at most {MAX_DESCRIPTION} characters.")); } Ok(()) } /// The owner's signer: their identity that delegates to the hub's key. async fn signer(app: &Shared, account: &Account) -> Result { crate::web::edit::signer_for(app, account.id).await.map_err(DropError::Bad) } /// Make a drop for `account`: a new repository with the files as its first /// change on `main`, authored by the account's identity and signed by the /// hub as its delegate. Nothing is left behind when that fails. pub async fn create( app: &Shared, account: &Account, title: &str, description: &str, secret: bool, files: &[NewFile], ) -> Result { if account.blocked() { return Err(bad("This account cannot make drops.")); } let (title, description) = (title.trim(), description.trim()); check_text(title, description).map_err(DropError::Bad)?; let files = check_files(files, false).map_err(DropError::Bad)?; let signer = signer(app, account).await?; // Ids are unique across the hub, so `oh drop view ID` needs no owner. let mut found = None; for _ in 0..8 { let id = crate::auth::random_hex(12); let Some(name) = RepoName::drop(&account.username, &id) else { break }; if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) { found = Some(name); break; } } let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) }; let mut repo_id = [0u8; 16]; rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut repo_id); app.repos.create_async(&name, Some(repo_id)).await?; let id = name.drop_id().unwrap_or_default().to_string(); let row = match db::repos::create_drop(&app.db, &account.username, &id, title, description, visibility(secret)).await { Ok(row) => row, Err(e) => { let _ = std::fs::remove_dir_all(app.repos.dir(&name)); return Err(e.into()); } }; let edits: Vec = files.iter().map(|f| FileEdit { path: f.name.clone(), content: Some(f.content.clone().into_bytes()) }).collect(); let message = if title.is_empty() { "Create the drop".to_string() } else { format!("Create {title}") }; let b = row.default_bookmark.clone(); match edits::commit(&app.repos, &name, b.clone(), b, edits, message, signer).await { Ok(done) => { recorded(app, account, &row, &done, "drop.created", visibility_label(&row)).await; Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) } Err(e) => { db::repos::delete(&app.db, row.id).await.ok(); let _ = std::fs::remove_dir_all(app.repos.dir(&name)); Err(DropError::Bad(format!("The drop was not made: {e}"))) } } } /// Fork `source` for `account` (#280): a new drop of theirs whose history /// starts from it (its objects linked, its bookmarks copied), with its /// title, description and secrecy, noting where it came from. pub async fn fork(app: &Shared, account: &Account, source: &RepoRow) -> Result { if account.blocked() { return Err(bad("This account cannot make drops.")); } let Some(from) = RepoName::new(&source.owner, &source.name) else { return Err(bad("That is not a drop.")) }; let mut found = None; for _ in 0..8 { let id = crate::auth::random_hex(12); let Some(name) = RepoName::drop(&account.username, &id) else { break }; if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) { found = Some(name); break; } } let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) }; app.repos.fork(&from, &name).await?; let id = name.drop_id().unwrap_or_default().to_string(); let row = match db::repos::create_drop(&app.db, &account.username, &id, &source.title, &source.description, source.visibility()).await { Ok(row) => row, Err(e) => { let _ = std::fs::remove_dir_all(app.repos.dir(&name)); return Err(e.into()); } }; db::repos::set_forked_from(&app.db, row.id, source.id).await?; db::accounts::audit(&app.db, Some(account), "drop.forked", &url_of(&row), &url_of(source)).await.ok(); Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row)) } /// The longest comment on a drop, in bytes. pub const COMMENT_MAX: usize = 64 * 1024; /// `account` comments on `row` (#280, oxygenhub#349): some Markdown, at most /// [`COMMENT_MAX`]; the drop's owner is told. The comment's number. pub async fn add_comment(app: &Shared, account: &Account, row: &RepoRow, body: &str) -> Result { let body = body.trim(); if body.is_empty() || body.len() > COMMENT_MAX || account.blocked() { return Err(bad("A comment is some text, at most 64 KiB.")); } let n = db::repos::add_drop_comment(&app.db, row.id, account.id, &account.username, body).await?; if account.username != row.owner { if let Ok(Some(owner)) = db::accounts::account_by_name(&app.db, &row.owner).await { let title = format!("{} on your drop {}", account.username, shown_title(row, &[])); crate::notify::tell( app, "drop.comment", &title, &format!("{}#comment-{n}", url_of(row)), &account.username, Some(row.id), vec![(owner.id, db::notify::Reason::Participating)], ) .await; } } Ok(n) } /// Whether `account` may delete comment `c` on `row`: its author, the /// drop's owner or a site administrator. pub fn may_delete_comment(account: Option<&Account>, row: &RepoRow, c: &db::repos::DropComment) -> bool { account.is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin)) } /// What a change to a drop's text or files is. pub struct Update { pub title: String, pub description: String, pub secret: bool, /// Files to write or (content `None`) remove; others are kept. pub files: Vec, pub message: String, } /// Change a drop: its files in one change made as the owner's delegate, /// when any changed, and its title, description and visibility. pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> { let (title, description) = (u.title.trim(), u.description.trim()); check_text(title, description).map_err(DropError::Bad)?; if !u.files.is_empty() { if u.files.len() > MAX_FILES { return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time."))); } let written: Vec = u .files .iter() .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() })) .collect(); for f in &written { check_name(&f.name).map_err(DropError::Bad)?; if f.content.len() > MAX_FILE_BYTES { return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024))); } } scan(&written).map_err(DropError::Bad)?; let signer = signer(app, account).await?; let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; let message = if u.message.trim().is_empty() { format!("Edit {}", if title.is_empty() { id_of(row) } else { title }) } else { u.message.trim().to_string() }; // Removing every file would leave an empty drop: delete it instead. if u.files.iter().any(|e| e.content.is_none()) { let (_, current) = files(app, row, "").await?; let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count(); if left == 0 && written.is_empty() { return Err(bad("A drop keeps at least one file; delete the drop instead.")); } } let b = row.default_bookmark.clone(); let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer) .await .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?; recorded(app, account, row, &done, "drop.edited", "").await; } db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?; if visibility(u.secret) != row.visibility() { db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok(); } Ok(()) } /// The push log and the audit log for a change the hub made to a drop. async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) { let outcome = db::repos::PushOutcome { reference: done.bookmark.clone(), ok: true, reason: "web edit".into(), old_state: done.old_state.clone(), new_state: done.new_state.clone(), heads: done.heads.len() as i64, changes: 1, }; db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok(); let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) }; db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok(); } /// Delete a drop: its directory to the trash and its row, once nothing /// reads or writes it (#249), audited. pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> { let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?; let _guard = app.repos.exclusive_guard(&name).await; match app.repos.trash(&name) { Ok(_) | Err(RepoError::NotFound) => {} Err(e) => return Err(e.into()), } db::repos::delete(&app.db, row.id).await?; db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok(); Ok(()) } /// The drop's files at `spec` (its default bookmark when empty): every /// file, with its text when it is text and small enough to show. pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec), RepoError> { let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?; let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() }; app.repos .read(&name, move |repo, cache| { let target = crate::repos::view::resolve(repo, cache, &spec)?; let loaded = cache.load(repo, &target.heads)?; let mut out = Vec::new(); for (path, f) in &loaded.snapshot.files { let bytes = match (&f.kind, &f.content) { (NodeKind::Dir, _) | (_, FileContent::Dir) => continue, (_, FileContent::Text { bytes, .. }) => bytes.clone(), (_, FileContent::Opaque { blobs }) => match blobs.first() { Some(b) => repo.read_blob(b)?, None => Vec::new(), }, }; let size = bytes.len() as u64; let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None }; out.push(DropFile { path: path.clone(), size, text }); } Ok((target, out)) }) .await } /// The drop `id` of `owner`, or none. pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result> { let Some(name) = RepoName::drop(owner, id) else { return Ok(None) }; Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop())) } /// Whether `account` may change the drop: its owner alone. pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool { account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner)) } /// Whether `account` may delete the drop: its owner, or a site /// administrator. pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool { may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin) } /// Whether the drop is listed to `account`: public ones to everyone, secret /// ones to their owner and site administrators only. pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool { row.visibility() == Visibility::Public || may_delete(account, row) } /// The edits that turn `current` text files into `wanted`: files gone are /// removed, new or changed ones written. Files that are not text (pushed /// with oxy) are left alone. pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec { let mut out = Vec::new(); for c in current.iter().filter(|c| c.text.is_some()) { if !wanted.iter().any(|w| w.name == c.path) { out.push(FileEdit { path: c.path.clone(), content: None }); } } for w in wanted { let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str())); if !same { out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) }); } } out }