OXYGENHUB
# f4e138301198 oxygenhub / crates / hub / src / api / drops.rs
6.9 KiB · Rust · last changed Drops: per-user gists, each a small hidden Oxygen repository (#279) 15 days ago source blame history raw
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 {
17
    fn from(e: DropError) -> ApiError {
18
        match e {
19
            DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m),
20
            DropError::Repo(e) => e.into(),
21
            DropError::Db(e) => e.into(),
22
        }
23
    }
24
}
25
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 {
28
    let base = app.config.base_url();
29
    let url = drops::url_of(row);
30
    Drop {
31
        id: drops::id_of(row).to_string(),
32
        owner: row.owner.clone(),
33
        title: row.title.clone(),
34
        description: row.description.clone(),
35
        visibility: drops::visibility_label(row).to_string(),
36
        url: format!("{base}{url}"),
37
        clone_url: format!("{base}{url}"),
38
        created_ms: row.created_ms,
39
        updated_ms: row.updated_ms,
40
        files: files
41
            .iter()
42
            .map(|f| hub_api::DropFile {
43
                name: f.path.clone(),
44
                size: f.size,
45
                content: if content { f.text.clone() } else { None },
46
                raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path),
47
            })
48
            .collect(),
49
    }
50
}
51
52
async fn listed(app: &Shared, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
53
    let mut out = Vec::with_capacity(rows.len());
54
    for row in rows {
55
        let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
56
        out.push(shape(app, &row, &files, false));
57
    }
58
    Ok(out)
59
}
60
61
/// `GET /drops`: the caller's drops, secret ones too.
62
pub(super) async fn mine(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Drop>>, ApiError> {
63
    let c = caller(&app, &headers).await?;
64
    let me = c.require("repo:read")?.clone();
65
    let rows = db::repos::drops_of(&app.db, &me.username).await?;
66
    Ok(Json(listed(&app, rows).await?))
67
}
68
69
/// `GET /users/{name}/drops`: someone's public drops (all of them to
70
/// themselves and to site administrators).
71
pub(super) async fn of_user(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Drop>>, ApiError> {
72
    let c = caller(&app, &headers).await?;
73
    let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?;
74
    let rows: Vec<_> =
75
        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?))
77
}
78
79
/// `POST /drops`.
80
pub(super) async fn create(
81
    State(app): State<Shared>,
82
    headers: HeaderMap,
83
    Json(body): Json<NewDrop>,
84
) -> Result<(StatusCode, Json<Drop>), ApiError> {
85
    let c = caller(&app, &headers).await?;
86
    let me = c.require("repo:write")?.clone();
87
    let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect();
88
    let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?;
89
    let (_, files) = drops::files(&app, &row, "").await?;
90
    Ok((StatusCode::CREATED, Json(shape(&app, &row, &files, true))))
91
}
92
93
async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> {
94
    if !crate::repos::names::valid_drop_id(id) {
95
        return Err(not_found("no such drop"));
96
    }
97
    db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop"))
98
}
99
100
/// `GET /drops/{id}`: anyone with the id, its files' content included.
101
pub(super) async fn view(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<Drop>, ApiError> {
102
    let c = caller(&app, &headers).await?;
103
    let row = row_of(&app, &id).await?;
104
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
105
        return Err(not_found("no such drop"));
106
    }
107
    let (_, files) = drops::files(&app, &row, "").await?;
108
    Ok(Json(shape(&app, &row, &files, true)))
109
}
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.
113
async fn writable(
114
    app: &Shared,
115
    headers: &HeaderMap,
116
    id: &str,
117
    deleting: bool,
118
) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
119
    let c = caller(app, headers).await?;
120
    let me = c.require("repo:write")?.clone();
121
    let row = row_of(app, id).await?;
122
    let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) };
123
    if !may {
124
        return Err(super::forbidden(if deleting {
125
            "only the drop's owner or a site administrator may delete it"
126
        } else {
127
            "only the drop's owner may change it"
128
        }));
129
    }
130
    Ok((me, row))
131
}
132
133
/// `PATCH /drops/{id}`.
134
pub(super) async fn edit(
135
    State(app): State<Shared>,
136
    headers: HeaderMap,
137
    Path(id): Path<String>,
138
    Json(body): Json<DropEdit>,
139
) -> Result<Json<Drop>, ApiError> {
140
    let (me, row) = writable(&app, &headers, &id, false).await?;
141
    let mut seen = Vec::new();
142
    let mut files = Vec::with_capacity(body.files.len());
143
    for f in body.files {
144
        let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?;
145
        if seen.contains(&name) {
146
            return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice.")));
147
        }
148
        seen.push(name.clone());
149
        files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) });
150
    }
151
    let update = drops::Update {
152
        title: body.title.unwrap_or_else(|| row.title.clone()),
153
        description: body.description.unwrap_or_else(|| row.description.clone()),
154
        secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public),
155
        files,
156
        message: body.message.unwrap_or_default(),
157
    };
158
    drops::update(&app, &me, &row, update).await?;
159
    let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?;
160
    let (_, files) = drops::files(&app, &row, "").await?;
161
    Ok(Json(shape(&app, &row, &files, true)))
162
}
163
164
/// `DELETE /drops/{id}`.
165
pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
166
    let (me, row) = writable(&app, &headers, &id, true).await?;
167
    drops::delete(&app, &me, &row).await?;
168
    Ok(StatusCode::NO_CONTENT)
169
}