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 |