OXYGENHUB

Fork a drop (refs #280) adbc38e7ea09

A signed-in visitor who is not the owner forks a drop they can read: a
new drop of theirs whose store is a fork of the source (objects linked,
bookmarks copied), with its title, description and secrecy, forked_from
naming the source, which its page shows. Audited as drop.forked.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key a3e5eeafe0e6
When
· 2026-10-21 02:04 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
adbc38e7ea09d65e35433f0b506609ad0cde3ff0fa2aebfb9e4d28a2e02181c3
Depends on
166f6e9bf4f1 8bf3010760d7 93be9ce4653e 9e8cbfc3ad00 a7e6e8e6094c b5668108487f d23464c0ac60
Depended on by
04f9c984439e 8ac2aa4ca6d1 ca0c443c30cf c419455a82dd ab2d8b40f9f4
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
13 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Fork a drop (#280):** a signed-in visitor's FORK makes a drop of their own from someone else's, with its files, history, title and secrecy; the fork's page says which drop it came from.
7 8
- **Flow's job times in /metrics (oxygenflow#35, #212):** `oxyhub_flow_job_queue_seconds` (ready to started) and `oxyhub_flow_job_run_seconds` histograms by runner, finished jobs by runner and result, reused passes, and the work queue's waiting and dead items, all over the last day, for the Grafana dashboard.
8 9
- **A durable work queue (#276):** work that fails where it starts is queued and retried instead of lost: a notification's email the mail server refused, and a Git mirror export after a push. Any worker process takes due items (on PostgreSQL several at once, each skipping what another holds), retries back off from 30 seconds to an hour, and an item still failing after eight attempts is kept as dead.
9 10
- **One auto-land and one Diffusion bump at a time across hub processes (#275):** auto-land's check of a proposal, and Diffusion's bump and landing of a consumer's pin, were serialised only within a process, so two web processes could land one proposal twice. Each now holds a lock named for the proposal or the pin across every process sharing the database (a PostgreSQL advisory lock; on SQLite, one process, as before).
…
278 unchanged lines
modified crates/hub/src/drops.rs +32 −0
…
257 unchanged lines
258 258
    }
259 259
}
260 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 +
261 293
/// What a change to a drop's text or files is.
262 294
pub struct Update {
263 295
    pub title: String,
…
155 unchanged lines
modified crates/hub/src/web/drops.rs +27 −0
…
60 unchanged lines
61 61
    files: Vec<ShownFile>,
62 62
    can_edit: bool,
63 63
    can_delete: bool,
64 +
    /// Signed in and not its owner: may fork it (#280).
65 +
    can_fork: bool,
66 +
    /// The drop it was forked from, if it still exists: its address.
67 +
    forked_from: Option<String>,
64 68
    clone_url: String,
65 69
    /// What the files are shown at, and whether that is the default bookmark.
66 70
    target: String,
…
110 unchanged lines
177 181
    let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/"));
178 182
    let (can_edit, can_delete) = (drops::may_edit(viewer.account(), &row), drops::may_delete(viewer.account(), &row));
179 183
    let role = if can_edit { crate::auth::Role::Admin } else { crate::auth::Role::Read };
184 +
    let can_fork = viewer.account().is_some_and(|a| a.username != row.owner && !a.blocked());
185 +
    let forked_from = match row.forked_from {
186 +
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)),
187 +
        None => None,
188 +
    };
180 189
    Ok(Page(DropPage {
190 +
        can_fork,
191 +
        forked_from,
181 192
        repo: RepoShell::new(app, &row, "code").with_role(role),
182 193
        title,
183 194
        description: row.description.clone(),
…
241 unchanged lines
425 436
    let row = writable(&app, &shell, &viewer, &owner, &id, true).await?;
426 437
    drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
427 438
    Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response())
439 +
}
440 +
441 +
/// `POST /{owner}/drops/{id}/fork` (#280): a copy of the drop, history and
442 +
/// all, as a drop of the viewer's.
443 +
pub async fn fork(
444 +
    State(app): State<Shared>,
445 +
    viewer: Viewer,
446 +
    Path((owner, id)): Path<(String, String)>,
447 +
    Form(f): Form<DeleteForm>,
448 +
) -> Result<Response, Error> {
449 +
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
450 +
    let me = require(&viewer, &shell)?;
451 +
    csrf(&viewer, &f.csrf_token, &shell)?;
452 +
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
453 +
    let made = drops::fork(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
454 +
    Ok(Redirect::to(&drops::url_of(&made)).into_response())
428 455
}
429 456
430 457
/// The drop's repository name, `drops/ID`, for the repository pages; a
…
47 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
194 unchanged lines
195 195
        .route("/{owner}/drops/{id}/tree/{r}", get(drops::show_at))
196 196
        .route("/{owner}/drops/{id}/edit", get(drops::edit_form).post(drops::save))
197 197
        .route("/{owner}/drops/{id}/delete", post(drops::delete))
198 +
        .route("/{owner}/drops/{id}/fork", post(drops::fork))
198 199
        .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes))
199 200
        .route("/{owner}/drops/{id}/change/{hash}", get(drops::change))
200 201
        .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw))
…
702 unchanged lines
modified crates/hub/tests/drops.rs +34 −0
…
445 unchanged lines
446 446
447 447
/// A site administrator may delete someone's drop, and sees their secret
448 448
/// ones listed; the drop's owner alone may change it.
449 +
/// oxygenhub#280: someone else forks a drop: a drop of theirs with the
450 +
/// same files, history and secrecy, which says where it came from.
449 451
#[tokio::test(flavor = "multi_thread")]
452 +
async fn a_drop_is_forked_with_its_history() {
453 +
    let h = hub().await;
454 +
    let app = &h.app;
455 +
    let andy = register(app, "andy").await;
456 +
    let carol = register(app, "carol").await;
457 +
    let work = tempfile::tempdir().unwrap();
458 +
    delegate(app, "carol", work.path()).await;
459 +
    let csrf = csrf_of(&get(app, "/drops/new", Some(&carol)).await.body);
460 +
    let r = post(app, "/drops/new", Some(&carol), &format!("_csrf={csrf}&title=Notes&visibility=secret&name=a.txt&content=a")).await;
461 +
    let source = r.location.unwrap();
462 +
    assert!(!get(app, &source, Some(&carol)).await.body.contains(">FORK<"), "not one's own");
463 +
    assert!(!get(app, &source, None).await.body.contains(">FORK<"), "not signed out");
464 +
    let page = get(app, &source, Some(&andy)).await.body;
465 +
    assert!(page.contains(">FORK<"), "{page}");
466 +
    let r = post(app, &format!("{source}/fork"), Some(&andy), &format!("_csrf={}", csrf_of(&page))).await;
467 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
468 +
    let fork = r.location.unwrap();
469 +
    assert!(fork.starts_with("/andy/drops/") && fork != source, "{fork}");
470 +
    let (from, made) = (
471 +
        db::repos::drop_by_id(&app.db, &drop_id(&source)).await.unwrap().unwrap(),
472 +
        db::repos::drop_by_id(&app.db, &drop_id(&fork)).await.unwrap().unwrap(),
473 +
    );
474 +
    assert!(made.is_drop() && made.owner == "andy" && made.title == "Notes" && made.visibility == "private", "{made:?}");
475 +
    assert_eq!(made.forked_from, Some(from.id));
476 +
    let shown = get(app, &fork, Some(&andy)).await.body;
477 +
    assert!(shown.contains(&format!("forked from <a href=\"{source}\">")), "{shown}");
478 +
    assert_eq!(get(app, &format!("{fork}/raw/main/a.txt"), None).await.body, "a");
479 +
    let history = get(app, &format!("{fork}/changes/main"), Some(&andy)).await.body;
480 +
    assert!(history.contains("Create Notes"), "the source's change: {history}");
481 +
}
482 +
483 +
#[tokio::test(flavor = "multi_thread")]
450 484
async fn a_site_admin_may_delete_a_drop() {
451 485
    let h = hub().await;
452 486
    let app = &h.app;
…
24 unchanged lines
modified docs/spec.md +1 −0
…
177 unchanged lines
178 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 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 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 +
- **Forks** (#280): a signed-in visitor who is not the owner may fork a drop they can read (`POST /{owner}/drops/{id}/fork`, the page's FORK): a new drop of theirs whose store is a fork of the source (objects linked, bookmarks copied, as a repository fork), with its title, description and secrecy, `forked_from` naming the source; its page says "forked from" with the source's address while it exists. Audited as `drop.forked`.
181 182
- **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 183
- **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups.
183 184
…
705 unchanged lines
modified templates/drop.html +2 −2
…
7 unchanged lines
8 8
  <div class="drop-id">
9 9
    <h1>{{ title }}{% if !at_default %} <small>at {{ target }}</small>{% endif %}</h1>
10 10
    {% if !description.is_empty() %}<p class="drop-desc">{{ description }}</p>{% endif %}
11 -
    <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>
11 +
    <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>{% if let Some(f) = forked_from %} · forked from <a href="{{ f }}">{{ f }}</a>{% endif %}</p>
12 12
  </div>
13 -
  {% if can_edit || can_delete %}<div class="drop-actions">{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %}{% if can_delete %}<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>{% endif %}</div>{% endif %}
13 +
  {% if can_edit || can_delete || can_fork %}<div class="drop-actions">{% if can_fork %}<form class="inline" method="post" action="{{ repo.url }}/fork"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="A copy of this drop, history and all, as a drop of yours">FORK</button></form>{% endif %}{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %}{% if can_delete %}<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>{% endif %}</div>{% endif %}
14 14
</div>
15 15
{% if secret %}<div class="strip">Secret: this drop is listed nowhere, but anyone with its address can read it.</div>{% endif %}
16 16
<div class="drop-clone"><label for="clone">Clone</label><input type="text" id="clone" class="code" readonly value="oxy clone {{ clone_url }}" data-select><button type="button" class="small" data-copy="oxy clone {{ clone_url }}">copy</button></div>
…
21 unchanged lines