OXYGENHUB

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

230 of 242 lines run, 48 of 106 functions · run #530 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). Forking, starring and comments
6
//! (oxygenhub#349) need `repo:write` and an account, as on the web.
7
8
use super::{caller, not_found, ApiError};
9
use crate::db;
10
use crate::drops::{self, DropError, DropFile, NewFile};
11
use crate::Shared;
12
use axum::extract::{Path, State};
13
use axum::http::{HeaderMap, StatusCode};
14
use axum::Json;
15
use hub_api::{Drop, DropComment, DropEdit, DropStars, NewDrop, NewDropComment};
16
17
impl From<DropError> for ApiError {
182×
    fn from(e: DropError) -> ApiError {
192×
        match e {
202×
            DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m),
210×
            DropError::Repo(e) => e.into(),
220×
            DropError::Db(e) => e.into(),
23
        }
242×
    }
25
}
26
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.
2914×
async fn shape(
3014×
    app: &Shared,
3114×
    caller: Option<&db::accounts::Account>,
3214×
    row: &db::repos::RepoRow,
3314×
    files: &[DropFile],
3414×
    content: bool,
3514×
) -> Result<Drop, ApiError> {
3614×
    let base = app.config.base_url();
3714×
    let url = drops::url_of(row);
3814×
    let starred = match caller {
3911×
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await?,
403×
        None => false,
41
    };
4214×
    let forked_from = match row.forked_from {
431×
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|f| format!("{base}{}", drops::url_of(&f))),
4413×
        None => None,
45
    };
46
    Ok(Drop {
4714×
        id: drops::id_of(row).to_string(),
4814×
        owner: row.owner.clone(),
4914×
        title: row.title.clone(),
5014×
        description: row.description.clone(),
5114×
        visibility: drops::visibility_label(row).to_string(),
5214×
        url: format!("{base}{url}"),
5314×
        clone_url: format!("{base}{url}"),
5414×
        created_ms: row.created_ms,
5514×
        updated_ms: row.updated_ms,
5614×
        files: files
5714×
            .iter()
5814×
            .map(|f| hub_api::DropFile {
5922×
                name: f.path.clone(),
6022×
                size: f.size,
6122×
                content: if content { f.text.clone() } else { None },
6222×
                raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path),
6322×
            })
6414×
            .collect(),
6514×
        stars: db::repos::star_count(&app.db, row.id).await?,
6614×
        starred,
6714×
        forked_from,
6814×
        embed_url: format!("{base}{url}/embed"),
69
    })
7014×
}
71
725×
async fn listed(app: &Shared, caller: Option<&db::accounts::Account>, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
735×
    let mut out = Vec::with_capacity(rows.len());
745×
    for row in rows {
755×
        let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
765×
        out.push(shape(app, caller, &row, &files, false).await?);
77
    }
785×
    Ok(out)
795×
}
80
81
/// `GET /drops`: the caller's drops, secret ones too.
822×
pub(super) async fn mine(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Drop>>, ApiError> {
832×
    let c = caller(&app, &headers).await?;
842×
    let me = c.require("repo:read")?.clone();
852×
    let rows = db::repos::drops_of(&app.db, &me.username).await?;
862×
    Ok(Json(listed(&app, Some(&me), rows).await?))
872×
}
88
89
/// `GET /users/{name}/drops`: someone's public drops (all of them to
90
/// themselves and to site administrators).
913×
pub(super) async fn of_user(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Drop>>, ApiError> {
923×
    let c = caller(&app, &headers).await?;
933×
    let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?;
943×
    let rows: Vec<_> =
955×
        db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect();
963×
    Ok(Json(listed(&app, c.account.as_ref(), rows).await?))
973×
}
98
99
/// `POST /drops`.
1003×
pub(super) async fn create(
1013×
    State(app): State<Shared>,
1023×
    headers: HeaderMap,
1033×
    Json(body): Json<NewDrop>,
1043×
) -> Result<(StatusCode, Json<Drop>), ApiError> {
1053×
    let c = caller(&app, &headers).await?;
1063×
    let me = c.require("repo:write")?.clone();
1074×
    let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect();
1083×
    let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?;
1093×
    let (_, files) = drops::files(&app, &row, "").await?;
1103×
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &row, &files, true).await?)))
1113×
}
112
11325×
async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> {
11425×
    if !crate::repos::names::valid_drop_id(id) {
1150×
        return Err(not_found("no such drop"));
11625×
    }
11725×
    db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop"))
11825×
}
119
120
/// `GET /drops/{id}`: anyone with the id, its files' content included.
1216×
pub(super) async fn view(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<Drop>, ApiError> {
1226×
    let c = caller(&app, &headers).await?;
1236×
    let row = row_of(&app, &id).await?;
1244×
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
1250×
        return Err(not_found("no such drop"));
1264×
    }
1274×
    let (_, files) = drops::files(&app, &row, "").await?;
1284×
    Ok(Json(shape(&app, c.account.as_ref(), &row, &files, true).await?))
1296×
}
130
131
/// The drop, when the caller may change it (its owner) or, `deleting`,
132
/// delete it (its owner or a site administrator); else 403.
1337×
async fn writable(
1347×
    app: &Shared,
1357×
    headers: &HeaderMap,
1367×
    id: &str,
1377×
    deleting: bool,
1387×
) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
1397×
    let c = caller(app, headers).await?;
1407×
    let me = c.require("repo:write")?.clone();
1417×
    let row = row_of(app, id).await?;
1427×
    let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) };
1437×
    if !may {
1443×
        return Err(super::forbidden(if deleting {
1451×
            "only the drop's owner or a site administrator may delete it"
146
        } else {
1472×
            "only the drop's owner may change it"
148
        }));
1494×
    }
1504×
    Ok((me, row))
1517×
}
152
153
/// `PATCH /drops/{id}`.
1544×
pub(super) async fn edit(
1554×
    State(app): State<Shared>,
1564×
    headers: HeaderMap,
1574×
    Path(id): Path<String>,
1584×
    Json(body): Json<DropEdit>,
1594×
) -> Result<Json<Drop>, ApiError> {
1604×
    let (me, row) = writable(&app, &headers, &id, false).await?;
1612×
    let mut seen = Vec::new();
1622×
    let mut files = Vec::with_capacity(body.files.len());
1632×
    for f in body.files {
1642×
        let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?;
1652×
        if seen.contains(&name) {
1660×
            return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice.")));
1672×
        }
1682×
        seen.push(name.clone());
1692×
        files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) });
170
    }
1712×
    let update = drops::Update {
1722×
        title: body.title.unwrap_or_else(|| row.title.clone()),
1732×
        description: body.description.unwrap_or_else(|| row.description.clone()),
1742×
        secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public),
1752×
        files,
1762×
        message: body.message.unwrap_or_default(),
177
    };
1782×
    drops::update(&app, &me, &row, update).await?;
1791×
    let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?;
1801×
    let (_, files) = drops::files(&app, &row, "").await?;
1811×
    Ok(Json(shape(&app, Some(&me), &row, &files, true).await?))
1824×
}
183
184
/// `DELETE /drops/{id}`.
1853×
pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
1863×
    let (me, row) = writable(&app, &headers, &id, true).await?;
1872×
    drops::delete(&app, &me, &row).await?;
1882×
    Ok(StatusCode::NO_CONTENT)
1893×
}
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.
19312×
async fn reader(app: &Shared, headers: &HeaderMap, id: &str) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
19412×
    let c = caller(app, headers).await?;
19512×
    let me = c.require("repo:write")?.clone();
19611×
    if me.blocked() {
1970×
        return Err(not_found("no such drop"));
19811×
    }
19911×
    Ok((me, row_of(app, id).await?))
20012×
}
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.
2042×
pub(super) async fn fork(
2052×
    State(app): State<Shared>,
2062×
    headers: HeaderMap,
2072×
    Path(id): Path<String>,
2082×
) -> Result<(StatusCode, Json<Drop>), ApiError> {
2092×
    let (me, row) = reader(&app, &headers, &id).await?;
2101×
    let made = drops::fork(&app, &me, &row).await?;
2111×
    let (_, files) = drops::files(&app, &made, "").await?;
2121×
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &made, &files, true).await?)))
2132×
}
214
215
/// `PUT /drops/{id}/star` stars the drop, `DELETE` takes the star back;
216
/// either is the same however often it is asked.
2174×
async fn set_star(app: &Shared, headers: &HeaderMap, id: &str, want: bool) -> Result<Json<DropStars>, ApiError> {
2184×
    let (me, row) = reader(app, headers, id).await?;
2194×
    if db::repos::starred(&app.db, me.id, row.id).await? != want {
2203×
        db::repos::toggle_star(&app.db, me.id, row.id).await?;
2211×
    }
2224×
    Ok(Json(DropStars { stars: db::repos::star_count(&app.db, row.id).await?, starred: want }))
2234×
}
224
2253×
pub(super) async fn star(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
2263×
    set_star(&app, &headers, &id, true).await
2273×
}
228
2291×
pub(super) async fn unstar(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
2301×
    set_star(&app, &headers, &id, false).await
2311×
}
232
2334×
fn comment_of(c: db::repos::DropComment) -> DropComment {
2344×
    DropComment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms }
2354×
}
236
237
/// `GET /drops/{id}/comments`: oldest first, to anyone with the id.
2381×
pub(super) async fn comments(
2391×
    State(app): State<Shared>,
2401×
    headers: HeaderMap,
2411×
    Path(id): Path<String>,
2421×
) -> Result<Json<Vec<DropComment>>, ApiError> {
2431×
    let c = caller(&app, &headers).await?;
2441×
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
2450×
        return Err(not_found("no such drop"));
2461×
    }
2471×
    let row = row_of(&app, &id).await?;
2481×
    Ok(Json(db::repos::drop_comments(&app.db, row.id).await?.into_iter().map(comment_of).collect()))
2491×
}
250
251
/// `POST /drops/{id}/comments`: the drop's owner is told.
2523×
pub(super) async fn add_comment(
2533×
    State(app): State<Shared>,
2543×
    headers: HeaderMap,
2553×
    Path(id): Path<String>,
2563×
    Json(body): Json<NewDropComment>,
2573×
) -> Result<(StatusCode, Json<DropComment>), ApiError> {
2583×
    let (me, row) = reader(&app, &headers, &id).await?;
2593×
    let n = drops::add_comment(&app, &me, &row, &body.body).await?;
2602×
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
2612×
    Ok((StatusCode::CREATED, Json(comment_of(c))))
2623×
}
263
264
/// `DELETE /drops/{id}/comments/{n}`: its author, the drop's owner or a
265
/// site administrator.
2663×
pub(super) async fn delete_comment(
2673×
    State(app): State<Shared>,
2683×
    headers: HeaderMap,
2693×
    Path((id, n)): Path<(String, i64)>,
2703×
) -> Result<StatusCode, ApiError> {
2713×
    let (me, row) = reader(&app, &headers, &id).await?;
2723×
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
2732×
    if !drops::may_delete_comment(Some(&me), &row, &c) {
2741×
        return Err(super::forbidden("only the comment's author, the drop's owner or a site administrator may delete it"));
2751×
    }
2761×
    db::repos::delete_drop_comment(&app.db, row.id, n).await?;
2771×
    Ok(StatusCode::NO_CONTENT)
2783×
}