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 |