OXYGENHUB

Drops are forked, starred and commented over the API, and with oh drop fork/star/unstar/comments/comment (closes #349) 04f9c984439e

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key f2915064945f
When
· 2026-10-29 18:35 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
04f9c984439e1a49dbbbb32a47ebf0fab4279fe7a4cc03f2a798f5e620725c58
Depends on
1553bfc9ae53 166f6e9bf4f1 1ec29691b67a 27322cd2755a 5c6e92fd8d71 8aa5ef384ef1 8ac2aa4ca6d1 93be9ce4653e 9bbca80f777a adbc38e7ea09 b5668108487f b64e2537d702 ca0c443c30cf d23464c0ac60
Depended on by
1c52d940b4a0 041a945f55f8
In bookmarks
main
In tags
v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
32 atoms, 0 tree operations, 11 files

Browse the tree at this change

modified CHANGELOG.md +4 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## Unreleased
6 +
7 +
- **Drops over the API: fork, star and comment (#349).** What a drop's page does, for the VS Code extension and other clients: `POST /api/v1/drops/{id}/fork`, `PUT`/`DELETE /api/v1/drops/{id}/star`, `GET`/`POST /api/v1/drops/{id}/comments` and `DELETE /api/v1/drops/{id}/comments/{n}`, by the web's rules (the owner told of a comment; its author, the owner or an administrator deletes it). A drop over the API now says its stars, whether you starred it, what it was forked from and its embed address. `oh drop fork|star|unstar|comments|comment`.
8 +
5 9
## 0.9.16 (2026-10-29)
6 10
7 11
- **A phone's runner follows the app's releases (oxygenflow#84).** A runner on Android is part of the OxygenHub app, so the hub never offers it a runner build: with updates on, it is told it follows the app's releases (update the app to update it), and Admin › Runners › Updates says so.
…
420 unchanged lines
modified crates/hub-api/src/lib.rs +38 −0
…
1307 unchanged lines
1308 1308
    pub created_ms: i64,
1309 1309
    pub updated_ms: i64,
1310 1310
    pub files: Vec<DropFile>,
1311 +
    /// How many have starred it, and whether the caller has (oxygenhub#349).
1312 +
    #[serde(default)]
1313 +
    pub stars: i64,
1314 +
    #[serde(default)]
1315 +
    pub starred: bool,
1316 +
    /// The drop it was forked from, its page, while that drop exists.
1317 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
1318 +
    pub forked_from: Option<String>,
1319 +
    /// Its files without the site around them, for an `<iframe>` (#280).
1320 +
    #[serde(default)]
1321 +
    pub embed_url: String,
1322 +
}
1323 +
1324 +
/// A comment on a drop (oxygenhub#349).
1325 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1326 +
pub struct DropComment {
1327 +
    /// Its number on the drop.
1328 +
    pub id: i64,
1329 +
    pub author: String,
1330 +
    /// Markdown.
1331 +
    pub body: String,
1332 +
    pub created_ms: i64,
1333 +
}
1334 +
1335 +
/// `POST /drops/{id}/comments`.
1336 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1337 +
pub struct NewDropComment {
1338 +
    pub body: String,
1339 +
}
1340 +
1341 +
/// A drop's stars after `PUT` or `DELETE /drops/{id}/star`.
1342 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1343 +
pub struct DropStars {
1344 +
    pub stars: i64,
1345 +
    pub starred: bool,
1311 1346
}
1312 1347
1313 1348
/// One of a drop's files. Lists carry no content; one drop's view carries
…
200 unchanged lines
1514 1549
        ("ReleaseAsset", schemars::schema_for!(ReleaseAsset)),
1515 1550
        ("NewRelease", schemars::schema_for!(NewRelease)),
1516 1551
        ("Drop", schemars::schema_for!(Drop)),
1552 +
        ("DropComment", schemars::schema_for!(DropComment)),
1553 +
        ("NewDropComment", schemars::schema_for!(NewDropComment)),
1554 +
        ("DropStars", schemars::schema_for!(DropStars)),
1517 1555
        ("NewDrop", schemars::schema_for!(NewDrop)),
1518 1556
        ("DropEdit", schemars::schema_for!(DropEdit)),
1519 1557
    ]
…
297 unchanged lines
modified crates/hub/src/api/drops.rs +122 −13
…
1 unchanged lines
2 2
//! one), `GET /users/{name}/drops` (someone's public ones),
3 3
//! `GET`/`PATCH`/`DELETE /drops/{id}`. Reading a drop needs nothing but
4 4
//! its id; changing one needs `repo:write` and to be its owner (or a site
5 -
//! administrator, who may also delete it).
5 +
//! administrator, who may also delete it). Forking, starring and comments
6 +
//! (oxygenhub#349) need `repo:write` and an account, as on the web.
6 7
7 8
use super::{caller, not_found, ApiError};
8 9
use crate::db;
…
2 unchanged lines
11 12
use axum::extract::{Path, State};
12 13
use axum::http::{HeaderMap, StatusCode};
13 14
use axum::Json;
14 -
use hub_api::{Drop, DropEdit, NewDrop};
15 +
use hub_api::{Drop, DropComment, DropEdit, DropStars, NewDrop, NewDropComment};
15 16
16 17
impl From<DropError> for ApiError {
17 18
    fn from(e: DropError) -> ApiError {
…
5 unchanged lines
23 24
    }
24 25
}
25 26
26 -
/// The API's shape of a drop, with its files' content when `content`.
27 -
fn shape(app: &Shared, row: &db::repos::RepoRow, files: &[DropFile], content: bool) -> Drop {
27 +
/// The API's shape of a drop, with its files' content when `content`, its
28 +
/// stars (and whether `caller` starred it) and where it was forked from.
29 +
async fn shape(
30 +
    app: &Shared,
31 +
    caller: Option<&db::accounts::Account>,
32 +
    row: &db::repos::RepoRow,
33 +
    files: &[DropFile],
34 +
    content: bool,
35 +
) -> Result<Drop, ApiError> {
28 36
    let base = app.config.base_url();
29 37
    let url = drops::url_of(row);
30 -
    Drop {
38 +
    let starred = match caller {
39 +
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await?,
40 +
        None => false,
41 +
    };
42 +
    let forked_from = match row.forked_from {
43 +
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|f| format!("{base}{}", drops::url_of(&f))),
44 +
        None => None,
45 +
    };
46 +
    Ok(Drop {
31 47
        id: drops::id_of(row).to_string(),
32 48
        owner: row.owner.clone(),
33 49
        title: row.title.clone(),
…
12 unchanged lines
46 62
                raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path),
47 63
            })
48 64
            .collect(),
49 -
    }
65 +
        stars: db::repos::star_count(&app.db, row.id).await?,
66 +
        starred,
67 +
        forked_from,
68 +
        embed_url: format!("{base}{url}/embed"),
69 +
    })
50 70
}
51 71
52 -
async fn listed(app: &Shared, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
72 +
async fn listed(app: &Shared, caller: Option<&db::accounts::Account>, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
53 73
    let mut out = Vec::with_capacity(rows.len());
54 74
    for row in rows {
55 75
        let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
56 -
        out.push(shape(app, &row, &files, false));
76 +
        out.push(shape(app, caller, &row, &files, false).await?);
57 77
    }
58 78
    Ok(out)
59 79
}
…
3 unchanged lines
63 83
    let c = caller(&app, &headers).await?;
64 84
    let me = c.require("repo:read")?.clone();
65 85
    let rows = db::repos::drops_of(&app.db, &me.username).await?;
66 -
    Ok(Json(listed(&app, rows).await?))
86 +
    Ok(Json(listed(&app, Some(&me), rows).await?))
67 87
}
68 88
69 89
/// `GET /users/{name}/drops`: someone's public drops (all of them to
…
3 unchanged lines
73 93
    let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?;
74 94
    let rows: Vec<_> =
75 95
        db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect();
76 -
    Ok(Json(listed(&app, rows).await?))
96 +
    Ok(Json(listed(&app, c.account.as_ref(), rows).await?))
77 97
}
78 98
79 99
/// `POST /drops`.
…
7 unchanged lines
87 107
    let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect();
88 108
    let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?;
89 109
    let (_, files) = drops::files(&app, &row, "").await?;
90 -
    Ok((StatusCode::CREATED, Json(shape(&app, &row, &files, true))))
110 +
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &row, &files, true).await?)))
91 111
}
92 112
93 113
async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> {
…
11 unchanged lines
105 125
        return Err(not_found("no such drop"));
106 126
    }
107 127
    let (_, files) = drops::files(&app, &row, "").await?;
108 -
    Ok(Json(shape(&app, &row, &files, true)))
128 +
    Ok(Json(shape(&app, c.account.as_ref(), &row, &files, true).await?))
109 129
}
110 130
111 131
/// The drop, when the caller may change it (its owner) or, `deleting`,
…
46 unchanged lines
158 178
    drops::update(&app, &me, &row, update).await?;
159 179
    let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?;
160 180
    let (_, files) = drops::files(&app, &row, "").await?;
161 -
    Ok(Json(shape(&app, &row, &files, true)))
181 +
    Ok(Json(shape(&app, Some(&me), &row, &files, true).await?))
162 182
}
163 183
164 184
/// `DELETE /drops/{id}`.
165 185
pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
166 186
    let (me, row) = writable(&app, &headers, &id, true).await?;
167 187
    drops::delete(&app, &me, &row).await?;
188 +
    Ok(StatusCode::NO_CONTENT)
189 +
}
190 +
191 +
/// The drop and the signed-in caller, with `repo:write`, for forking,
192 +
/// starring and commenting: anyone who may read the drop (its id) may.
193 +
async fn reader(app: &Shared, headers: &HeaderMap, id: &str) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
194 +
    let c = caller(app, headers).await?;
195 +
    let me = c.require("repo:write")?.clone();
196 +
    if me.blocked() {
197 +
        return Err(not_found("no such drop"));
198 +
    }
199 +
    Ok((me, row_of(app, id).await?))
200 +
}
201 +
202 +
/// `POST /drops/{id}/fork` (oxygenhub#349): a new drop of the caller's
203 +
/// whose history starts from this one, as the page's FORK.
204 +
pub(super) async fn fork(
205 +
    State(app): State<Shared>,
206 +
    headers: HeaderMap,
207 +
    Path(id): Path<String>,
208 +
) -> Result<(StatusCode, Json<Drop>), ApiError> {
209 +
    let (me, row) = reader(&app, &headers, &id).await?;
210 +
    let made = drops::fork(&app, &me, &row).await?;
211 +
    let (_, files) = drops::files(&app, &made, "").await?;
212 +
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &made, &files, true).await?)))
213 +
}
214 +
215 +
/// `PUT /drops/{id}/star` stars the drop, `DELETE` takes the star back;
216 +
/// either is the same however often it is asked.
217 +
async fn set_star(app: &Shared, headers: &HeaderMap, id: &str, want: bool) -> Result<Json<DropStars>, ApiError> {
218 +
    let (me, row) = reader(app, headers, id).await?;
219 +
    if db::repos::starred(&app.db, me.id, row.id).await? != want {
220 +
        db::repos::toggle_star(&app.db, me.id, row.id).await?;
221 +
    }
222 +
    Ok(Json(DropStars { stars: db::repos::star_count(&app.db, row.id).await?, starred: want }))
223 +
}
224 +
225 +
pub(super) async fn star(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
226 +
    set_star(&app, &headers, &id, true).await
227 +
}
228 +
229 +
pub(super) async fn unstar(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
230 +
    set_star(&app, &headers, &id, false).await
231 +
}
232 +
233 +
fn comment_of(c: db::repos::DropComment) -> DropComment {
234 +
    DropComment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms }
235 +
}
236 +
237 +
/// `GET /drops/{id}/comments`: oldest first, to anyone with the id.
238 +
pub(super) async fn comments(
239 +
    State(app): State<Shared>,
240 +
    headers: HeaderMap,
241 +
    Path(id): Path<String>,
242 +
) -> Result<Json<Vec<DropComment>>, ApiError> {
243 +
    let c = caller(&app, &headers).await?;
244 +
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
245 +
        return Err(not_found("no such drop"));
246 +
    }
247 +
    let row = row_of(&app, &id).await?;
248 +
    Ok(Json(db::repos::drop_comments(&app.db, row.id).await?.into_iter().map(comment_of).collect()))
249 +
}
250 +
251 +
/// `POST /drops/{id}/comments`: the drop's owner is told.
252 +
pub(super) async fn add_comment(
253 +
    State(app): State<Shared>,
254 +
    headers: HeaderMap,
255 +
    Path(id): Path<String>,
256 +
    Json(body): Json<NewDropComment>,
257 +
) -> Result<(StatusCode, Json<DropComment>), ApiError> {
258 +
    let (me, row) = reader(&app, &headers, &id).await?;
259 +
    let n = drops::add_comment(&app, &me, &row, &body.body).await?;
260 +
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
261 +
    Ok((StatusCode::CREATED, Json(comment_of(c))))
262 +
}
263 +
264 +
/// `DELETE /drops/{id}/comments/{n}`: its author, the drop's owner or a
265 +
/// site administrator.
266 +
pub(super) async fn delete_comment(
267 +
    State(app): State<Shared>,
268 +
    headers: HeaderMap,
269 +
    Path((id, n)): Path<(String, i64)>,
270 +
) -> Result<StatusCode, ApiError> {
271 +
    let (me, row) = reader(&app, &headers, &id).await?;
272 +
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
273 +
    if !drops::may_delete_comment(Some(&me), &row, &c) {
274 +
        return Err(super::forbidden("only the comment's author, the drop's owner or a site administrator may delete it"));
275 +
    }
276 +
    db::repos::delete_drop_comment(&app.db, row.id, n).await?;
168 277
    Ok(StatusCode::NO_CONTENT)
169 278
}
modified crates/hub/src/api/mod.rs +4 −0
…
121 unchanged lines
122 122
        .route("/users/{name}/drops", get(drops::of_user))
123 123
        .route("/drops", get(drops::mine).post(drops::create))
124 124
        .route("/drops/{id}", get(drops::view).patch(drops::edit).delete(drops::delete))
125 +
        .route("/drops/{id}/fork", post(drops::fork))
126 +
        .route("/drops/{id}/star", axum::routing::put(drops::star).delete(drops::unstar))
127 +
        .route("/drops/{id}/comments", get(drops::comments).post(drops::add_comment))
128 +
        .route("/drops/{id}/comments/{n}", axum::routing::delete(drops::delete_comment))
125 129
        .route("/orgs/{name}", get(org))
126 130
        .route("/search", get(search))
127 131
        .route("/repos", get(list_repos).post(create_repo))
…
3344 unchanged lines
modified crates/hub/src/db/repos.rs +5 −0
…
443 unchanged lines
444 444
    Ok((stars, watchers))
445 445
}
446 446
447 +
/// How many have starred one repository.
448 +
pub async fn star_count(pool: &Pool, repo_id: i64) -> sqlx::Result<i64> {
449 +
    sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = $1").bind(repo_id).fetch_one(pool).await
450 +
}
451 +
447 452
/// Star counts of every starred repository.
448 453
pub async fn star_counts(pool: &Pool) -> sqlx::Result<std::collections::HashMap<i64, i64>> {
449 454
    let rows: Vec<(i64, i64)> = sqlx::query_as("SELECT repo_id, COUNT(*) FROM stars GROUP BY repo_id").fetch_all(pool).await?;
…
90 unchanged lines
modified crates/hub/src/drops.rs +35 −0
…
289 unchanged lines
290 290
    Ok(db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row))
291 291
}
292 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 +
293 328
/// What a change to a drop's text or files is.
294 329
pub struct Update {
295 330
    pub title: String,
…
155 unchanged lines
modified crates/hub/src/web/drops.rs +2 −26
…
533 unchanged lines
534 534
/// Who may delete a comment: its author, the drop's owner, a site
535 535
/// administrator.
536 536
fn may_delete_comment(viewer: &Viewer, row: &db::repos::RepoRow, c: &db::repos::DropComment) -> bool {
537 -
    viewer.account().is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin))
537 +
    drops::may_delete_comment(viewer.account(), row, c)
538 538
}
539 539
540 -
/// The longest comment taken, in bytes.
541 -
const COMMENT_MAX: usize = 64 * 1024;
542 -
543 540
#[derive(Deserialize)]
544 541
pub struct CommentForm {
545 542
    #[serde(rename = "_csrf")]
…
13 unchanged lines
559 556
    let me = require(&viewer, &shell)?;
560 557
    csrf(&viewer, &f.csrf_token, &shell)?;
561 558
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
562 -
    let body = f.body.trim();
563 -
    if body.is_empty() || body.len() > COMMENT_MAX || me.account.blocked() {
564 -
        return Err(Error::bad(shell, "A comment is some text, at most 64 KiB."));
565 -
    }
566 -
    let n = db::repos::add_drop_comment(&app.db, row.id, me.account.id, &me.account.username, body)
567 -
        .await
568 -
        .map_err(|e| Error::from_db(shell.clone(), e))?;
559 +
    let n = drops::add_comment(&app, &me.account, &row, &f.body).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
569 560
    let url = drops::url_of(&row);
570 -
    if me.account.username != row.owner {
571 -
        if let Ok(Some(owner_account)) = db::accounts::account_by_name(&app.db, &row.owner).await {
572 -
            let title = format!("{} on your drop {}", me.account.username, drops::shown_title(&row, &[]));
573 -
            crate::notify::tell(
574 -
                &app,
575 -
                "drop.comment",
576 -
                &title,
577 -
                &format!("{url}#comment-{n}"),
578 -
                &me.account.username,
579 -
                Some(row.id),
580 -
                vec![(owner_account.id, db::notify::Reason::Participating)],
581 -
            )
582 -
            .await;
583 -
        }
584 -
    }
585 561
    Ok(Redirect::to(&format!("{url}#comment-{n}")).into_response())
586 562
}
587 563
…
100 unchanged lines
modified crates/hub/tests/drops.rs +71 −0
…
570 unchanged lines
571 571
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
572 572
    assert_eq!(get(app, &url, None).await.status, StatusCode::NOT_FOUND);
573 573
}
574 +
575 +
/// oxygenhub#349: forking, stars and comments over the API, by the web's
576 +
/// rules, for the extensions.
577 +
#[tokio::test(flavor = "multi_thread")]
578 +
async fn drops_are_forked_starred_and_commented_over_the_api() {
579 +
    let h = hub().await;
580 +
    let app = &h.app;
581 +
    register(app, "andy").await;
582 +
    register(app, "carol").await;
583 +
    register(app, "dave").await;
584 +
    let (andy, carol, dave) = (token(app, "andy").await, token(app, "carol").await, token(app, "dave").await);
585 +
    let work = tempfile::tempdir().unwrap();
586 +
    delegate(app, "carol", work.path()).await;
587 +
    let new = serde_json::json!({ "title": "Notes", "description": "", "secret": false, "files": [{ "name": "a.txt", "content": "a" }] });
588 +
    let r = api(app, "POST", "/drops", Some(&carol), Some(new)).await;
589 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.body);
590 +
    let source: hub_api::Drop = serde_json::from_str(&r.body).unwrap();
591 +
    assert!(source.embed_url.ends_with(&format!("/carol/drops/{}/embed", source.id)) && source.stars == 0);
592 +
593 +
    // A fork is the caller's, from the source.
594 +
    let r = api(app, "POST", &format!("/drops/{}/fork", source.id), Some(&andy), None).await;
595 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.body);
596 +
    let fork: hub_api::Drop = serde_json::from_str(&r.body).unwrap();
597 +
    assert_eq!((fork.owner.as_str(), fork.title.as_str()), ("andy", "Notes"));
598 +
    assert_eq!(fork.forked_from.as_deref(), Some(source.url.as_str()));
599 +
    assert_eq!(fork.files[0].content.as_deref(), Some("a"));
600 +
    assert_eq!(api(app, "POST", &format!("/drops/{}/fork", source.id), None, None).await.status, StatusCode::UNAUTHORIZED);
601 +
602 +
    // Stars: PUT stars, again changes nothing, DELETE takes it back.
603 +
    let stars = |tok: String, method: &'static str| {
604 +
        let (app, id) = (app.clone(), source.id.clone());
605 +
        async move {
606 +
            let r = api(&app, method, &format!("/drops/{id}/star"), Some(&tok), None).await;
607 +
            assert_eq!(r.status, StatusCode::OK, "{}", r.body);
608 +
            serde_json::from_str::<hub_api::DropStars>(&r.body).unwrap()
609 +
        }
610 +
    };
611 +
    assert_eq!(stars(andy.clone(), "PUT").await, hub_api::DropStars { stars: 1, starred: true });
612 +
    assert_eq!(stars(andy.clone(), "PUT").await, hub_api::DropStars { stars: 1, starred: true }, "once per account");
613 +
    assert_eq!(stars(dave.clone(), "PUT").await.stars, 2);
614 +
    assert_eq!(stars(andy.clone(), "DELETE").await, hub_api::DropStars { stars: 1, starred: false });
615 +
    let seen: hub_api::Drop =
616 +
        serde_json::from_str(&api(app, "GET", &format!("/drops/{}", source.id), Some(&dave), None).await.body).unwrap();
617 +
    assert!(seen.stars == 1 && seen.starred, "{seen:?}");
618 +
619 +
    // Comments: listed oldest first; the owner is told; only the author,
620 +
    // the owner or an administrator deletes one.
621 +
    let say = |tok: String, body: &'static str| {
622 +
        let (app, id) = (app.clone(), source.id.clone());
623 +
        async move { api(&app, "POST", &format!("/drops/{id}/comments"), Some(&tok), Some(serde_json::json!({ "body": body }))).await }
624 +
    };
625 +
    let r = say(andy.clone(), "Nice **notes**").await;
626 +
    assert_eq!(r.status, StatusCode::CREATED, "{}", r.body);
627 +
    let first: hub_api::DropComment = serde_json::from_str(&r.body).unwrap();
628 +
    assert_eq!((first.author.as_str(), first.body.as_str()), ("andy", "Nice **notes**"));
629 +
    assert_eq!(say(dave.clone(), "  ").await.status, StatusCode::UNPROCESSABLE_ENTITY, "says something");
630 +
    say(dave.clone(), "Thanks").await;
631 +
    let listed: Vec<hub_api::DropComment> =
632 +
        serde_json::from_str(&api(app, "GET", &format!("/drops/{}/comments", source.id), None, None).await.body).unwrap();
633 +
    assert_eq!(listed.iter().map(|c| c.author.as_str()).collect::<Vec<_>>(), ["andy", "dave"]);
634 +
    let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap();
635 +
    let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap();
636 +
    assert!(told.iter().any(|n| n.kind == "drop.comment"), "{told:?}");
637 +
    let del = |tok: String, n: i64| {
638 +
        let (app, id) = (app.clone(), source.id.clone());
639 +
        async move { api(&app, "DELETE", &format!("/drops/{id}/comments/{n}"), Some(&tok), None).await.status }
640 +
    };
641 +
    assert_eq!(del(dave.clone(), first.id).await, StatusCode::FORBIDDEN, "not someone else's");
642 +
    assert_eq!(del(carol.clone(), first.id).await, StatusCode::NO_CONTENT, "the drop's owner may");
643 +
    assert_eq!(del(carol.clone(), first.id).await, StatusCode::NOT_FOUND);
644 +
}
modified crates/oh/src/main.rs +81 −0
…
204 unchanged lines
205 205
        /// The drop's id, or its address
206 206
        id: String,
207 207
    },
208 +
    /// A copy of someone's drop, history and all, as a drop of yours
209 +
    Fork {
210 +
        /// The drop's id, or its address
211 +
        id: String,
212 +
    },
213 +
    /// Star a drop
214 +
    Star {
215 +
        /// The drop's id, or its address
216 +
        id: String,
217 +
    },
218 +
    /// Take your star back
219 +
    Unstar {
220 +
        /// The drop's id, or its address
221 +
        id: String,
222 +
    },
223 +
    /// A drop's comments, oldest first
224 +
    Comments {
225 +
        /// The drop's id, or its address
226 +
        id: String,
227 +
    },
228 +
    /// Comment on a drop (Markdown); its owner is told
229 +
    Comment {
230 +
        /// The drop's id, or its address
231 +
        id: String,
232 +
        /// What to say
233 +
        #[arg(long, short = 'm')]
234 +
        message: String,
235 +
    },
208 236
}
209 237
210 238
#[derive(Subcommand)]
…
867 unchanged lines
1078 1106
        if d.visibility == "secret" { ui::warn("secret ") } else { String::new() },
1079 1107
        ui::dim(&format!("{} · {}", files.join(", "), ui::ago(d.updated_ms)))
1080 1108
    )
1109 +
}
1110 +
1111 +
/// `oh drop star|unstar` (oxygenhub#349).
1112 +
fn drop_star(c: &Client, id: &str, method: &str, json: bool) -> Result<()> {
1113 +
    let s: hub_api::DropStars = c.send(method, &format!("/drops/{}/star", drop_id(id)?), &serde_json::json!({}))?;
1114 +
    if json {
1115 +
        println!("{}", serde_json::to_string(&s)?);
1116 +
    } else {
1117 +
        println!("{} {} · {} stars", ui::ok("✔"), if s.starred { "starred" } else { "unstarred" }, s.stars);
1118 +
    }
1119 +
    Ok(())
1081 1120
}
1082 1121
1083 1122
fn drop_cmd(c: &Client, cmd: DropCmd, json: bool) -> Result<i32> {
…
69 unchanged lines
1153 1192
            let id = drop_id(&id)?;
1154 1193
            c.send_text("DELETE", &format!("/drops/{id}"), &serde_json::json!({}))?;
1155 1194
            println!("{} drop {id}", ui::ok("✔ deleted"));
1195 +
        }
1196 +
        DropCmd::Fork { id } => {
1197 +
            let d: hub_api::Drop = c.send("POST", &format!("/drops/{}/fork", drop_id(&id)?), &serde_json::json!({}))?;
1198 +
            if json {
1199 +
                println!("{}", serde_json::to_string(&d)?);
1200 +
            } else {
1201 +
                println!("{} forked as {} {}", ui::ok("✔"), ui::strong(&d.id), ui::dim(&d.url));
1202 +
            }
1203 +
        }
1204 +
        DropCmd::Star { id } => drop_star(c, &id, "PUT", json)?,
1205 +
        DropCmd::Unstar { id } => drop_star(c, &id, "DELETE", json)?,
1206 +
        DropCmd::Comments { id } => {
1207 +
            let path = format!("/drops/{}/comments", drop_id(&id)?);
1208 +
            if json {
1209 +
                println!("{}", c.get_text(&path)?);
1210 +
                return Ok(0);
1211 +
            }
1212 +
            let list: Vec<hub_api::DropComment> = c.get(&path)?;
1213 +
            if list.is_empty() {
1214 +
                println!("{}", ui::dim("No comments."));
1215 +
            }
1216 +
            for m in &list {
1217 +
                println!("{} {}", ui::strong(&m.author), ui::dim(&format!("#{} · {}", m.id, ui::ago(m.created_ms))));
1218 +
                println!("{}\n", m.body);
1219 +
            }
1156 1220
        }
1221 +
        DropCmd::Comment { id, message } => {
1222 +
            let m: hub_api::DropComment =
1223 +
                c.send("POST", &format!("/drops/{}/comments", drop_id(&id)?), &hub_api::NewDropComment { body: message })?;
1224 +
            if json {
1225 +
                println!("{}", serde_json::to_string(&m)?);
1226 +
            } else {
1227 +
                println!("{} commented (#{})", ui::ok("✔"), m.id);
1228 +
            }
1229 +
        }
1157 1230
    }
1158 1231
    Ok(0)
1159 1232
}
…
1424 unchanged lines
2584 2657
            _ => panic!("not drop view"),
2585 2658
        }
2586 2659
        assert!(matches!(Cli::try_parse_from(["oh", "drop", "delete", "x"]).unwrap().cmd, Cmd::Drop { cmd: DropCmd::Delete { .. } }));
2660 +
        for (verb, ok) in [("fork", true), ("star", true), ("unstar", true), ("comments", true)] {
2661 +
            assert_eq!(Cli::try_parse_from(["oh", "drop", verb, "x"]).is_ok(), ok, "{verb}");
2662 +
        }
2663 +
        assert!(Cli::try_parse_from(["oh", "drop", "comment", "x"]).is_err(), "a comment says something");
2664 +
        match Cli::try_parse_from(["oh", "drop", "comment", "x", "-m", "Nice"]).unwrap().cmd {
2665 +
            Cmd::Drop { cmd: DropCmd::Comment { message, .. } } => assert_eq!(message, "Nice"),
2666 +
            _ => panic!("not drop comment"),
2667 +
        }
2587 2668
        assert_eq!(drop_id("0123456789ABCDEF01234567").unwrap(), "0123456789abcdef01234567");
2588 2669
        assert_eq!(drop_id("https://hub.example/andy/drops/0123456789abcdef01234567/").unwrap(), "0123456789abcdef01234567");
2589 2670
        assert!(drop_id("andy/notes").is_err());
…
2 unchanged lines
modified docs/oh.md +4 −0
…
64 unchanged lines
65 65
| `oh drop list [--user NAME]` | Your drops, or someone's public ones |
66 66
| `oh drop view ID [-f FILE]` | A drop's files, by id or address; `-f` prints one file's content alone |
67 67
| `oh drop delete ID` | Delete a drop and its history |
68 +
| `oh drop fork ID` | A copy of someone's drop, history and all, as a drop of yours (oxygenhub#349) |
69 +
| `oh drop star ID` / `oh drop unstar ID` | Star a drop, or take the star back |
70 +
| `oh drop comments ID` | A drop's comments, oldest first |
71 +
| `oh drop comment ID -m TEXT` | Comment on a drop (Markdown); its owner is told |
68 72
| `oh runner-updates [show]` | Site administrators: runners that update themselves (oxygenhub#322): where builds come from, every level's settings, test groups and their waves (oxygenflow#84), each runner's policy and what it is offered (or which wave it waits for), held builds |
69 73
| `oh runner-updates set SCOPE [--updates on\|off\|inherit] [--channel C] [--window 02-05] [--dry-run on\|off\|inherit]` | Change one level (`site`, `owner:NAME`, `group:NAME`, `runner:NAME`); what is not given stays; `inherit` clears a setting |
70 74
| `oh runner-updates lift VERSION` | Offer a build held after it rolled back on a runner (#326) |
…
8 unchanged lines
modified docs/spec.md +2 −2
…
181 unchanged lines
182 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.
183 183
- **Comments** (#280): signed-in readers comment on a drop in Markdown (`POST /{owner}/drops/{id}/comment`, table `drop_comments`, migration 0092, at most 64 KiB), shown oldest first under its files; the owner is told (`drop.comment`); the author, the owner or a site administrator deletes one (`…/comment/{n}/delete`).
184 184
- **Embedding** (#280): `GET /{owner}/drops/{id}/embed` (whoever may read the drop) is the drop's files and a link back, without the site's chrome, sent with its own policy (`default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors *; …`, so no `X-Frame-Options`): any site may frame it, and it runs no script and posts no form. The drop's page offers the `<iframe>` to copy.
185 -
- **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).
186 -
- **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups.
185 +
- **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}`; a drop carries its `stars`, whether the caller `starred` it, `forked_from` and its `embed_url`. Forks, stars and comments (oxygenhub#349, `repo:write`, the web's rules): `POST /api/v1/drops/{id}/fork` (201, the caller's new drop), `PUT`/`DELETE /api/v1/drops/{id}/star` (`DropStars`, the same however often asked), `GET`/`POST /api/v1/drops/{id}/comments` (`DropComment`, `NewDropComment`; the owner is told) and `DELETE /api/v1/drops/{id}/comments/{n}` (its author, the owner or an administrator). `oh drop create|list|view|delete|fork|star|unstar|comments|comment` (docs/oh.md).
186 +
- **Later:** drops in backups.
187 187
188 188
## 5. Accounts, identities and authorization
189 189
…
705 unchanged lines