OXYGENHUB

Coverage / crates/hub/src/api/drops.rs 94.4%

135 of 143 lines run, 28 of 62 functions · run #497 on main · run missed

1
//! Drops in the API (oxygenhub#279): `GET`/`POST /drops` (yours; making
2
//! one), `GET /users/{name}/drops` (someone's public ones),
3
//! `GET`/`PATCH`/`DELETE /drops/{id}`. Reading a drop needs nothing but
4
//! its id; changing one needs `repo:write` and to be its owner (or a site
5
//! administrator, who may also delete it).
6
7
use super::{caller, not_found, ApiError};
8
use crate::db;
9
use crate::drops::{self, DropError, DropFile, NewFile};
10
use crate::Shared;
11
use axum::extract::{Path, State};
12
use axum::http::{HeaderMap, StatusCode};
13
use axum::Json;
14
use hub_api::{Drop, DropEdit, NewDrop};
15
16
impl From<DropError> for ApiError {
171×
    fn from(e: DropError) -> ApiError {
181×
        match e {
191×
            DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m),
200×
            DropError::Repo(e) => e.into(),
210×
            DropError::Db(e) => e.into(),
22
        }
231×
    }
24
}
25
26
/// The API's shape of a drop, with its files' content when `content`.
2711×
fn shape(app: &Shared, row: &db::repos::RepoRow, files: &[DropFile], content: bool) -> Drop {
2811×
    let base = app.config.base_url();
2911×
    let url = drops::url_of(row);
30
    Drop {
3111×
        id: drops::id_of(row).to_string(),
3211×
        owner: row.owner.clone(),
3311×
        title: row.title.clone(),
3411×
        description: row.description.clone(),
3511×
        visibility: drops::visibility_label(row).to_string(),
3611×
        url: format!("{base}{url}"),
3711×
        clone_url: format!("{base}{url}"),
3811×
        created_ms: row.created_ms,
3911×
        updated_ms: row.updated_ms,
4011×
        files: files
4111×
            .iter()
4211×
            .map(|f| hub_api::DropFile {
4319×
                name: f.path.clone(),
4419×
                size: f.size,
4519×
                content: if content { f.text.clone() } else { None },
4619×
                raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path),
4719×
            })
4811×
            .collect(),
49
    }
5011×
}
51
525×
async fn listed(app: &Shared, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
535×
    let mut out = Vec::with_capacity(rows.len());
545×
    for row in rows {
555×
        let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
565×
        out.push(shape(app, &row, &files, false));
57
    }
585×
    Ok(out)
595×
}
60
61
/// `GET /drops`: the caller's drops, secret ones too.
622×
pub(super) async fn mine(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Drop>>, ApiError> {
632×
    let c = caller(&app, &headers).await?;
642×
    let me = c.require("repo:read")?.clone();
652×
    let rows = db::repos::drops_of(&app.db, &me.username).await?;
662×
    Ok(Json(listed(&app, rows).await?))
672×
}
68
69
/// `GET /users/{name}/drops`: someone's public drops (all of them to
70
/// themselves and to site administrators).
713×
pub(super) async fn of_user(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Drop>>, ApiError> {
723×
    let c = caller(&app, &headers).await?;
733×
    let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?;
743×
    let rows: Vec<_> =
755×
        db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect();
763×
    Ok(Json(listed(&app, rows).await?))
773×
}
78
79
/// `POST /drops`.
802×
pub(super) async fn create(
812×
    State(app): State<Shared>,
822×
    headers: HeaderMap,
832×
    Json(body): Json<NewDrop>,
842×
) -> Result<(StatusCode, Json<Drop>), ApiError> {
852×
    let c = caller(&app, &headers).await?;
862×
    let me = c.require("repo:write")?.clone();
873×
    let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect();
882×
    let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?;
892×
    let (_, files) = drops::files(&app, &row, "").await?;
902×
    Ok((StatusCode::CREATED, Json(shape(&app, &row, &files, true))))
912×
}
92
9312×
async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> {
9412×
    if !crate::repos::names::valid_drop_id(id) {
950×
        return Err(not_found("no such drop"));
9612×
    }
9712×
    db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop"))
9812×
}
99
100
/// `GET /drops/{id}`: anyone with the id, its files' content included.
1015×
pub(super) async fn view(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<Drop>, ApiError> {
1025×
    let c = caller(&app, &headers).await?;
1035×
    let row = row_of(&app, &id).await?;
1043×
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
1050×
        return Err(not_found("no such drop"));
1063×
    }
1073×
    let (_, files) = drops::files(&app, &row, "").await?;
1083×
    Ok(Json(shape(&app, &row, &files, true)))
1095×
}
110
111
/// The drop, when the caller may change it (its owner) or, `deleting`,
112
/// delete it (its owner or a site administrator); else 403.
1137×
async fn writable(
1147×
    app: &Shared,
1157×
    headers: &HeaderMap,
1167×
    id: &str,
1177×
    deleting: bool,
1187×
) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
1197×
    let c = caller(app, headers).await?;
1207×
    let me = c.require("repo:write")?.clone();
1217×
    let row = row_of(app, id).await?;
1227×
    let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) };
1237×
    if !may {
1243×
        return Err(super::forbidden(if deleting {
1251×
            "only the drop's owner or a site administrator may delete it"
126
        } else {
1272×
            "only the drop's owner may change it"
128
        }));
1294×
    }
1304×
    Ok((me, row))
1317×
}
132
133
/// `PATCH /drops/{id}`.
1344×
pub(super) async fn edit(
1354×
    State(app): State<Shared>,
1364×
    headers: HeaderMap,
1374×
    Path(id): Path<String>,
1384×
    Json(body): Json<DropEdit>,
1394×
) -> Result<Json<Drop>, ApiError> {
1404×
    let (me, row) = writable(&app, &headers, &id, false).await?;
1412×
    let mut seen = Vec::new();
1422×
    let mut files = Vec::with_capacity(body.files.len());
1432×
    for f in body.files {
1442×
        let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?;
1452×
        if seen.contains(&name) {
1460×
            return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice.")));
1472×
        }
1482×
        seen.push(name.clone());
1492×
        files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) });
150
    }
1512×
    let update = drops::Update {
1522×
        title: body.title.unwrap_or_else(|| row.title.clone()),
1532×
        description: body.description.unwrap_or_else(|| row.description.clone()),
1542×
        secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public),
1552×
        files,
1562×
        message: body.message.unwrap_or_default(),
157
    };
1582×
    drops::update(&app, &me, &row, update).await?;
1591×
    let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?;
1601×
    let (_, files) = drops::files(&app, &row, "").await?;
1611×
    Ok(Json(shape(&app, &row, &files, true)))
1624×
}
163
164
/// `DELETE /drops/{id}`.
1653×
pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
1663×
    let (me, row) = writable(&app, &headers, &id, true).await?;
1672×
    drops::delete(&app, &me, &row).await?;
1682×
    Ok(StatusCode::NO_CONTENT)
1693×
}