OXYGENHUB
# a22e4aec2e28 oxygenhub / crates / hub / src / drops.rs
19.9 KiB · Rust · last changed Drops are forked, starred and commented over the API, and with oh drop fork/star/unstar/comments/comment (closes #349) 7 days ago source blame history raw
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_async(&name, Some(repo_id)).await?;
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
/// 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.
264
pub async fn fork(app: &Shared, account: &Account, source: &RepoRow) -> Result<RepoRow, DropError> {
265
    if account.blocked() {
266
        return Err(bad("This account cannot make drops."));
267
    }
268
    let Some(from) = RepoName::new(&source.owner, &source.name) else { return Err(bad("That is not a drop.")) };
269
    let mut found = None;
270
    for _ in 0..8 {
271
        let id = crate::auth::random_hex(12);
272
        let Some(name) = RepoName::drop(&account.username, &id) else { break };
273
        if db::repos::drop_by_id(&app.db, &id).await?.is_none() && !app.repos.exists(&name) {
274
            found = Some(name);
275
            break;
276
        }
277
    }
278
    let Some(name) = found else { return Err(bad("No free drop id was found; try again.")) };
279
    app.repos.fork(&from, &name).await?;
280
    let id = name.drop_id().unwrap_or_default().to_string();
281
    let row = match db::repos::create_drop(&app.db, &account.username, &id, &source.title, &source.description, source.visibility()).await {
282
        Ok(row) => row,
283
        Err(e) => {
284
            let _ = std::fs::remove_dir_all(app.repos.dir(&name));
285
            return Err(e.into());
286
        }
287
    };
288
    db::repos::set_forked_from(&app.db, row.id, source.id).await?;
289
    db::accounts::audit(&app.db, Some(account), "drop.forked", &url_of(&row), &url_of(source)).await.ok();
290
    Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
291
}
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.
298
pub async fn add_comment(app: &Shared, account: &Account, row: &RepoRow, body: &str) -> Result<i64, DropError> {
299
    let body = body.trim();
300
    if body.is_empty() || body.len() > COMMENT_MAX || account.blocked() {
301
        return Err(bad("A comment is some text, at most 64 KiB."));
302
    }
303
    let n = db::repos::add_drop_comment(&app.db, row.id, account.id, &account.username, body).await?;
304
    if account.username != row.owner {
305
        if let Ok(Some(owner)) = db::accounts::account_by_name(&app.db, &row.owner).await {
306
            let title = format!("{} on your drop {}", account.username, shown_title(row, &[]));
307
            crate::notify::tell(
308
                app,
309
                "drop.comment",
310
                &title,
311
                &format!("{}#comment-{n}", url_of(row)),
312
                &account.username,
313
                Some(row.id),
314
                vec![(owner.id, db::notify::Reason::Participating)],
315
            )
316
            .await;
317
        }
318
    }
319
    Ok(n)
320
}
321
322
/// Whether `account` may delete comment `c` on `row`: its author, the
323
/// drop's owner or a site administrator.
324
pub fn may_delete_comment(account: Option<&Account>, row: &RepoRow, c: &db::repos::DropComment) -> bool {
325
    account.is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin))
326
}
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.
340
pub async fn update(app: &Shared, account: &Account, row: &RepoRow, u: Update) -> Result<(), DropError> {
341
    let (title, description) = (u.title.trim(), u.description.trim());
342
    check_text(title, description).map_err(DropError::Bad)?;
343
    if !u.files.is_empty() {
344
        if u.files.len() > MAX_FILES {
345
            return Err(bad(format!("A drop takes at most {MAX_FILES} files at a time.")));
346
        }
347
        let written: Vec<NewFile> = u
348
            .files
349
            .iter()
350
            .filter_map(|e| e.content.as_ref().map(|c| NewFile { name: e.path.clone(), content: String::from_utf8_lossy(c).into_owned() }))
351
            .collect();
352
        for f in &written {
353
            check_name(&f.name).map_err(DropError::Bad)?;
354
            if f.content.len() > MAX_FILE_BYTES {
355
                return Err(bad(format!("{} is larger than {} KiB; push larger files with oxy.", f.name, MAX_FILE_BYTES / 1024)));
356
            }
357
        }
358
        scan(&written).map_err(DropError::Bad)?;
359
        let signer = signer(app, account).await?;
360
        let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
361
        let message = if u.message.trim().is_empty() {
362
            format!("Edit {}", if title.is_empty() { id_of(row) } else { title })
363
        } else {
364
            u.message.trim().to_string()
365
        };
366
        // Removing every file would leave an empty drop: delete it instead.
367
        if u.files.iter().any(|e| e.content.is_none()) {
368
            let (_, current) = files(app, row, "").await?;
369
            let left = current.iter().filter(|c| !u.files.iter().any(|e| e.path == c.path && e.content.is_none())).count();
370
            if left == 0 && written.is_empty() {
371
                return Err(bad("A drop keeps at least one file; delete the drop instead."));
372
            }
373
        }
374
        let b = row.default_bookmark.clone();
375
        let done = edits::commit(&app.repos, &name, b.clone(), b, u.files, message, signer)
376
            .await
377
            .map_err(|e| DropError::Bad(format!("The drop was not changed: {e}")))?;
378
        recorded(app, account, row, &done, "drop.edited", "").await;
379
    }
380
    db::repos::set_drop(&app.db, row.id, title, description, visibility(u.secret)).await?;
381
    if visibility(u.secret) != row.visibility() {
382
        db::accounts::audit(&app.db, Some(account), "drop.visibility", &row.slug(), if u.secret { "secret" } else { "public" }).await.ok();
383
    }
384
    Ok(())
385
}
386
387
/// The push log and the audit log for a change the hub made to a drop.
388
async fn recorded(app: &Shared, account: &Account, row: &RepoRow, done: &edits::Committed, action: &str, detail: &str) {
389
    let outcome = db::repos::PushOutcome {
390
        reference: done.bookmark.clone(),
391
        ok: true,
392
        reason: "web edit".into(),
393
        old_state: done.old_state.clone(),
394
        new_state: done.new_state.clone(),
395
        heads: done.heads.len() as i64,
396
        changes: 1,
397
    };
398
    db::repos::record_push(&app.db, row.id, Some((account.id, &account.username)), &[outcome]).await.ok();
399
    let detail = if detail.is_empty() { done.change.short(12) } else { format!("{detail} {}", done.change.short(12)) };
400
    db::accounts::audit(&app.db, Some(account), action, &row.slug(), &detail).await.ok();
401
}
402
403
/// Delete a drop: its directory to the trash and its row, once nothing
404
/// reads or writes it (#249), audited.
405
pub async fn delete(app: &Shared, actor: &Account, row: &RepoRow) -> Result<(), DropError> {
406
    let name = RepoName::new(&row.owner, &row.name).ok_or_else(|| bad("not a drop"))?;
407
    let _guard = app.repos.exclusive_guard(&name).await;
408
    match app.repos.trash(&name) {
409
        Ok(_) | Err(RepoError::NotFound) => {}
410
        Err(e) => return Err(e.into()),
411
    }
412
    db::repos::delete(&app.db, row.id).await?;
413
    db::accounts::audit(&app.db, Some(actor), "drop.deleted", &row.slug(), "moved to trash").await.ok();
414
    Ok(())
415
}
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.
419
pub async fn files(app: &Shared, row: &RepoRow, spec: &str) -> Result<(crate::repos::view::RefTarget, Vec<DropFile>), RepoError> {
420
    let name = RepoName::new(&row.owner, &row.name).ok_or(RepoError::NotFound)?;
421
    let spec = if spec.is_empty() { row.default_bookmark.clone() } else { spec.to_string() };
422
    app.repos
423
        .read(&name, move |repo, cache| {
424
            let target = crate::repos::view::resolve(repo, cache, &spec)?;
425
            let loaded = cache.load(repo, &target.heads)?;
426
            let mut out = Vec::new();
427
            for (path, f) in &loaded.snapshot.files {
428
                let bytes = match (&f.kind, &f.content) {
429
                    (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
430
                    (_, FileContent::Text { bytes, .. }) => bytes.clone(),
431
                    (_, FileContent::Opaque { blobs }) => match blobs.first() {
432
                        Some(b) => repo.read_blob(b)?,
433
                        None => Vec::new(),
434
                    },
435
                };
436
                let size = bytes.len() as u64;
437
                let text = if size <= SHOWN_BYTES && !crate::repos::view::is_binary(&bytes) { String::from_utf8(bytes).ok() } else { None };
438
                out.push(DropFile { path: path.clone(), size, text });
439
            }
440
            Ok((target, out))
441
        })
442
        .await
443
}
444
445
/// The drop `id` of `owner`, or none.
446
pub async fn find(app: &Shared, owner: &str, id: &str) -> sqlx::Result<Option<RepoRow>> {
447
    let Some(name) = RepoName::drop(owner, id) else { return Ok(None) };
448
    Ok(db::repos::get(&app.db, &name.owner, &name.name).await?.filter(|r| r.is_drop()))
449
}
450
451
/// Whether `account` may change the drop: its owner alone.
452
pub fn may_edit(account: Option<&Account>, row: &RepoRow) -> bool {
453
    account.is_some_and(|a| !a.blocked() && a.username.eq_ignore_ascii_case(&row.owner))
454
}
455
456
/// Whether `account` may delete the drop: its owner, or a site
457
/// administrator.
458
pub fn may_delete(account: Option<&Account>, row: &RepoRow) -> bool {
459
    may_edit(account, row) || account.is_some_and(|a| !a.blocked() && a.is_admin)
460
}
461
462
/// Whether the drop is listed to `account`: public ones to everyone, secret
463
/// ones to their owner and site administrators only.
464
pub fn listed_to(account: Option<&Account>, row: &RepoRow) -> bool {
465
    row.visibility() == Visibility::Public || may_delete(account, row)
466
}
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.
471
pub fn edits_between(current: &[DropFile], wanted: &[NewFile]) -> Vec<FileEdit> {
472
    let mut out = Vec::new();
473
    for c in current.iter().filter(|c| c.text.is_some()) {
474
        if !wanted.iter().any(|w| w.name == c.path) {
475
            out.push(FileEdit { path: c.path.clone(), content: None });
476
        }
477
    }
478
    for w in wanted {
479
        let same = current.iter().any(|c| c.path == w.name && c.text.as_deref() == Some(w.content.as_str()));
480
        if !same {
481
            out.push(FileEdit { path: w.name.clone(), content: Some(w.content.clone().into_bytes()) });
482
        }
483
    }
484
    out
485
}