OXYGENHUB

Star a drop (refs #280) c419455a82dd

A signed-in visitor stars a drop, or takes the star back, from its page,
which shows the count; drops share the repositories' stars table and
never join a list of starred repositories.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7bbbb9f96f8e
When
· 2026-10-21 03:05 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c419455a82dd6399649a6bf25e2ea61ee2e3c9b7562b50c768f73b766a1e0c5d
Depends on
166f6e9bf4f1 8bf3010760d7 93be9ce4653e a7e6e8e6094c ab2d8b40f9f4 adbc38e7ea09 b5668108487f d23464c0ac60
Depended on by
8ac2aa4ca6d1 ca0c443c30cf
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
11 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Star a drop (#280):** a signed-in visitor stars a drop, or takes the star back, from its page, which shows how many it has.
7 8
## 0.7.0 (2026-10-21)
8 9
9 10
- **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.
…
281 unchanged lines
modified crates/hub/src/web/drops.rs +27 −0
…
62 unchanged lines
63 63
    can_delete: bool,
64 64
    /// Signed in and not its owner: may fork it (#280).
65 65
    can_fork: bool,
66 +
    /// Its stars, and whether the viewer gave one (#280).
67 +
    stars: i64,
68 +
    starred: bool,
69 +
    signed_in: bool,
66 70
    /// The drop it was forked from, if it still exists: its address.
67 71
    forked_from: Option<String>,
68 72
    clone_url: String,
…
117 unchanged lines
186 190
        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 191
        None => None,
188 192
    };
193 +
    let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0);
194 +
    let starred = match viewer.account() {
195 +
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false),
196 +
        None => false,
197 +
    };
189 198
    Ok(Page(DropPage {
199 +
        stars,
200 +
        starred,
201 +
        signed_in: viewer.account().is_some(),
190 202
        can_fork,
191 203
        forked_from,
192 204
        repo: RepoShell::new(app, &row, "code").with_role(role),
…
243 unchanged lines
436 448
    let row = writable(&app, &shell, &viewer, &owner, &id, true).await?;
437 449
    drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
438 450
    Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response())
451 +
}
452 +
453 +
/// `POST /{owner}/drops/{id}/star` (#280): star it, or take the star back.
454 +
pub async fn star(
455 +
    State(app): State<Shared>,
456 +
    viewer: Viewer,
457 +
    Path((owner, id)): Path<(String, String)>,
458 +
    Form(f): Form<DeleteForm>,
459 +
) -> Result<Response, Error> {
460 +
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
461 +
    let me = require(&viewer, &shell)?;
462 +
    csrf(&viewer, &f.csrf_token, &shell)?;
463 +
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
464 +
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
465 +
    Ok(Redirect::to(&drops::url_of(&row)).into_response())
439 466
}
440 467
441 468
/// `POST /{owner}/drops/{id}/fork` (#280): a copy of the drop, history and
…
63 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
195 unchanged lines
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 198
        .route("/{owner}/drops/{id}/fork", post(drops::fork))
199 +
        .route("/{owner}/drops/{id}/star", post(drops::star))
199 200
        .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes))
200 201
        .route("/{owner}/drops/{id}/change/{hash}", get(drops::change))
201 202
        .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw))
…
702 unchanged lines
modified crates/hub/tests/drops.rs +18 −0
…
477 unchanged lines
478 478
    assert_eq!(get(app, &format!("{fork}/raw/main/a.txt"), None).await.body, "a");
479 479
    let history = get(app, &format!("{fork}/changes/main"), Some(&andy)).await.body;
480 480
    assert!(history.contains("Create Notes"), "the source's change: {history}");
481 +
482 +
    // Stars: one per account, taken back by a second press.
483 +
    let page = get(app, &source, Some(&andy)).await.body;
484 +
    assert!(page.contains("☆ STAR 0"), "{page}");
485 +
    let star = |cookie: String| {
486 +
        let (app, source) = (app.clone(), source.clone());
487 +
        async move {
488 +
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
489 +
            let r = post(&app, &format!("{source}/star"), Some(&cookie), &format!("_csrf={csrf}")).await;
490 +
            assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
491 +
        }
492 +
    };
493 +
    star(andy.clone()).await;
494 +
    star(carol.clone()).await;
495 +
    assert!(get(app, &source, Some(&andy)).await.body.contains("★ STARRED 2"));
496 +
    star(andy.clone()).await;
497 +
    assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1"));
498 +
    assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count");
481 499
}
482 500
483 501
#[tokio::test(flavor = "multi_thread")]
…
27 unchanged lines
modified docs/spec.md +1 −0
…
178 unchanged lines
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 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`.
182 +
- **Stars** (#280): a signed-in visitor stars a drop, or takes the star back, from its page (`POST /{owner}/drops/{id}/star`, the repositories' `stars` table); the page shows the count, which never joins a list of starred repositories.
182 183
- **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).
183 184
- **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups.
184 185
…
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>{% if let Some(f) = forked_from %} · forked from <a href="{{ f }}">{{ f }}</a>{% endif %}</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 !signed_in && stars > 0 %} · {{ stars }} star{% if stars != 1 %}s{% endif %}{% endif %}{% if let Some(f) = forked_from %} · forked from <a href="{{ f }}">{{ f }}</a>{% endif %}</p>
12 12
  </div>
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 %}
13 +
  {% if signed_in || can_edit || can_delete || can_fork %}<div class="drop-actions">{% if signed_in %}<form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="{% if starred %}Take your star back{% else %}Star this drop{% endif %}" aria-pressed="{% if starred %}true{% else %}false{% endif %}">{% if starred %}★ STARRED{% else %}☆ STAR{% endif %} {{ stars }}</button></form>{% endif %}{% 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