//! Drops in the API (oxygenhub#279): `GET`/`POST /drops` (yours; making //! one), `GET /users/{name}/drops` (someone's public ones), //! `GET`/`PATCH`/`DELETE /drops/{id}`. Reading a drop needs nothing but //! its id; changing one needs `repo:write` and to be its owner (or a site //! administrator, who may also delete it). Forking, starring and comments //! (oxygenhub#349) need `repo:write` and an account, as on the web. use super::{caller, not_found, ApiError}; use crate::db; use crate::drops::{self, DropError, DropFile, NewFile}; use crate::Shared; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::Json; use hub_api::{Drop, DropComment, DropEdit, DropStars, NewDrop, NewDropComment}; impl From for ApiError { fn from(e: DropError) -> ApiError { match e { DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m), DropError::Repo(e) => e.into(), DropError::Db(e) => e.into(), } } } /// The API's shape of a drop, with its files' content when `content`, its /// stars (and whether `caller` starred it) and where it was forked from. async fn shape( app: &Shared, caller: Option<&db::accounts::Account>, row: &db::repos::RepoRow, files: &[DropFile], content: bool, ) -> Result { let base = app.config.base_url(); let url = drops::url_of(row); let starred = match caller { Some(a) => db::repos::starred(&app.db, a.id, row.id).await?, None => false, }; let forked_from = match row.forked_from { Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|f| format!("{base}{}", drops::url_of(&f))), None => None, }; Ok(Drop { id: drops::id_of(row).to_string(), owner: row.owner.clone(), title: row.title.clone(), description: row.description.clone(), visibility: drops::visibility_label(row).to_string(), url: format!("{base}{url}"), clone_url: format!("{base}{url}"), created_ms: row.created_ms, updated_ms: row.updated_ms, files: files .iter() .map(|f| hub_api::DropFile { name: f.path.clone(), size: f.size, content: if content { f.text.clone() } else { None }, raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path), }) .collect(), stars: db::repos::star_count(&app.db, row.id).await?, starred, forked_from, embed_url: format!("{base}{url}/embed"), }) } async fn listed(app: &Shared, caller: Option<&db::accounts::Account>, rows: Vec) -> Result, ApiError> { let mut out = Vec::with_capacity(rows.len()); for row in rows { let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default(); out.push(shape(app, caller, &row, &files, false).await?); } Ok(out) } /// `GET /drops`: the caller's drops, secret ones too. pub(super) async fn mine(State(app): State, headers: HeaderMap) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:read")?.clone(); let rows = db::repos::drops_of(&app.db, &me.username).await?; Ok(Json(listed(&app, Some(&me), rows).await?)) } /// `GET /users/{name}/drops`: someone's public drops (all of them to /// themselves and to site administrators). pub(super) async fn of_user(State(app): State, headers: HeaderMap, Path(name): Path) -> Result>, ApiError> { let c = caller(&app, &headers).await?; let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?; let rows: Vec<_> = db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect(); Ok(Json(listed(&app, c.account.as_ref(), rows).await?)) } /// `POST /drops`. pub(super) async fn create( State(app): State, headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let c = caller(&app, &headers).await?; let me = c.require("repo:write")?.clone(); let files: Vec = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect(); let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?; let (_, files) = drops::files(&app, &row, "").await?; Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &row, &files, true).await?))) } async fn row_of(app: &Shared, id: &str) -> Result { if !crate::repos::names::valid_drop_id(id) { return Err(not_found("no such drop")); } db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop")) } /// `GET /drops/{id}`: anyone with the id, its files' content included. pub(super) async fn view(State(app): State, headers: HeaderMap, Path(id): Path) -> Result, ApiError> { let c = caller(&app, &headers).await?; let row = row_of(&app, &id).await?; if c.account.as_ref().is_some_and(|a| a.blocked()) { return Err(not_found("no such drop")); } let (_, files) = drops::files(&app, &row, "").await?; Ok(Json(shape(&app, c.account.as_ref(), &row, &files, true).await?)) } /// The drop, when the caller may change it (its owner) or, `deleting`, /// delete it (its owner or a site administrator); else 403. async fn writable( app: &Shared, headers: &HeaderMap, id: &str, deleting: bool, ) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> { let c = caller(app, headers).await?; let me = c.require("repo:write")?.clone(); let row = row_of(app, id).await?; let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) }; if !may { return Err(super::forbidden(if deleting { "only the drop's owner or a site administrator may delete it" } else { "only the drop's owner may change it" })); } Ok((me, row)) } /// `PATCH /drops/{id}`. pub(super) async fn edit( State(app): State, headers: HeaderMap, Path(id): Path, Json(body): Json, ) -> Result, ApiError> { let (me, row) = writable(&app, &headers, &id, false).await?; let mut seen = Vec::new(); let mut files = Vec::with_capacity(body.files.len()); for f in body.files { let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?; if seen.contains(&name) { return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice."))); } seen.push(name.clone()); files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) }); } let update = drops::Update { title: body.title.unwrap_or_else(|| row.title.clone()), description: body.description.unwrap_or_else(|| row.description.clone()), secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public), files, message: body.message.unwrap_or_default(), }; drops::update(&app, &me, &row, update).await?; let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?; let (_, files) = drops::files(&app, &row, "").await?; Ok(Json(shape(&app, Some(&me), &row, &files, true).await?)) } /// `DELETE /drops/{id}`. pub(super) async fn delete(State(app): State, headers: HeaderMap, Path(id): Path) -> Result { let (me, row) = writable(&app, &headers, &id, true).await?; drops::delete(&app, &me, &row).await?; Ok(StatusCode::NO_CONTENT) } /// The drop and the signed-in caller, with `repo:write`, for forking, /// starring and commenting: anyone who may read the drop (its id) may. async fn reader(app: &Shared, headers: &HeaderMap, id: &str) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> { let c = caller(app, headers).await?; let me = c.require("repo:write")?.clone(); if me.blocked() { return Err(not_found("no such drop")); } Ok((me, row_of(app, id).await?)) } /// `POST /drops/{id}/fork` (oxygenhub#349): a new drop of the caller's /// whose history starts from this one, as the page's FORK. pub(super) async fn fork( State(app): State, headers: HeaderMap, Path(id): Path, ) -> Result<(StatusCode, Json), ApiError> { let (me, row) = reader(&app, &headers, &id).await?; let made = drops::fork(&app, &me, &row).await?; let (_, files) = drops::files(&app, &made, "").await?; Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &made, &files, true).await?))) } /// `PUT /drops/{id}/star` stars the drop, `DELETE` takes the star back; /// either is the same however often it is asked. async fn set_star(app: &Shared, headers: &HeaderMap, id: &str, want: bool) -> Result, ApiError> { let (me, row) = reader(app, headers, id).await?; if db::repos::starred(&app.db, me.id, row.id).await? != want { db::repos::toggle_star(&app.db, me.id, row.id).await?; } Ok(Json(DropStars { stars: db::repos::star_count(&app.db, row.id).await?, starred: want })) } pub(super) async fn star(State(app): State, headers: HeaderMap, Path(id): Path) -> Result, ApiError> { set_star(&app, &headers, &id, true).await } pub(super) async fn unstar(State(app): State, headers: HeaderMap, Path(id): Path) -> Result, ApiError> { set_star(&app, &headers, &id, false).await } fn comment_of(c: db::repos::DropComment) -> DropComment { DropComment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms } } /// `GET /drops/{id}/comments`: oldest first, to anyone with the id. pub(super) async fn comments( State(app): State, headers: HeaderMap, Path(id): Path, ) -> Result>, ApiError> { let c = caller(&app, &headers).await?; if c.account.as_ref().is_some_and(|a| a.blocked()) { return Err(not_found("no such drop")); } let row = row_of(&app, &id).await?; Ok(Json(db::repos::drop_comments(&app.db, row.id).await?.into_iter().map(comment_of).collect())) } /// `POST /drops/{id}/comments`: the drop's owner is told. pub(super) async fn add_comment( State(app): State, headers: HeaderMap, Path(id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), ApiError> { let (me, row) = reader(&app, &headers, &id).await?; let n = drops::add_comment(&app, &me, &row, &body.body).await?; let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?; Ok((StatusCode::CREATED, Json(comment_of(c)))) } /// `DELETE /drops/{id}/comments/{n}`: its author, the drop's owner or a /// site administrator. pub(super) async fn delete_comment( State(app): State, headers: HeaderMap, Path((id, n)): Path<(String, i64)>, ) -> Result { let (me, row) = reader(&app, &headers, &id).await?; let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?; if !drops::may_delete_comment(Some(&me), &row, &c) { return Err(super::forbidden("only the comment's author, the drop's owner or a site administrator may delete it")); } db::repos::delete_drop_comment(&app.db, row.id, n).await?; Ok(StatusCode::NO_CONTENT) }