//! 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). 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, DropEdit, NewDrop}; 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`. fn shape(app: &Shared, row: &db::repos::RepoRow, files: &[DropFile], content: bool) -> Drop { let base = app.config.base_url(); let url = drops::url_of(row); 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(), } } async fn listed(app: &Shared, 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, &row, &files, false)); } 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, 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, 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, &row, &files, true)))) } 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, &row, &files, true))) } /// 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, &row, &files, true))) } /// `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) }