OXYGENHUB
◆ v0.9.19 oxygenhub / crates / hub / src / api / drops.rs
11.5 KiB · Rust · last changed Drops are forked, starred and commented over the API, and with oh drop fork/star/unstar/comments/comment (closes #349) 6 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). 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 {
18
    fn from(e: DropError) -> ApiError {
19
        match e {
20
            DropError::Bad(m) => ApiError(StatusCode::UNPROCESSABLE_ENTITY, m),
21
            DropError::Repo(e) => e.into(),
22
            DropError::Db(e) => e.into(),
23
        }
24
    }
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.
29
async fn shape(
30
    app: &Shared,
31
    caller: Option<&db::accounts::Account>,
32
    row: &db::repos::RepoRow,
33
    files: &[DropFile],
34
    content: bool,
35
) -> Result<Drop, ApiError> {
36
    let base = app.config.base_url();
37
    let url = drops::url_of(row);
38
    let starred = match caller {
39
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await?,
40
        None => false,
41
    };
42
    let forked_from = match row.forked_from {
43
        Some(id) => db::repos::get_by_id(&app.db, id).await?.map(|f| format!("{base}{}", drops::url_of(&f))),
44
        None => None,
45
    };
46
    Ok(Drop {
47
        id: drops::id_of(row).to_string(),
48
        owner: row.owner.clone(),
49
        title: row.title.clone(),
50
        description: row.description.clone(),
51
        visibility: drops::visibility_label(row).to_string(),
52
        url: format!("{base}{url}"),
53
        clone_url: format!("{base}{url}"),
54
        created_ms: row.created_ms,
55
        updated_ms: row.updated_ms,
56
        files: files
57
            .iter()
58
            .map(|f| hub_api::DropFile {
59
                name: f.path.clone(),
60
                size: f.size,
61
                content: if content { f.text.clone() } else { None },
62
                raw_url: format!("{base}{url}/raw/{}/{}", row.default_bookmark, f.path),
63
            })
64
            .collect(),
65
        stars: db::repos::star_count(&app.db, row.id).await?,
66
        starred,
67
        forked_from,
68
        embed_url: format!("{base}{url}/embed"),
69
    })
70
}
71
72
async fn listed(app: &Shared, caller: Option<&db::accounts::Account>, rows: Vec<db::repos::RepoRow>) -> Result<Vec<Drop>, ApiError> {
73
    let mut out = Vec::with_capacity(rows.len());
74
    for row in rows {
75
        let files = drops::files(app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
76
        out.push(shape(app, caller, &row, &files, false).await?);
77
    }
78
    Ok(out)
79
}
80
81
/// `GET /drops`: the caller's drops, secret ones too.
82
pub(super) async fn mine(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Drop>>, ApiError> {
83
    let c = caller(&app, &headers).await?;
84
    let me = c.require("repo:read")?.clone();
85
    let rows = db::repos::drops_of(&app.db, &me.username).await?;
86
    Ok(Json(listed(&app, Some(&me), rows).await?))
87
}
88
89
/// `GET /users/{name}/drops`: someone's public drops (all of them to
90
/// themselves and to site administrators).
91
pub(super) async fn of_user(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Drop>>, ApiError> {
92
    let c = caller(&app, &headers).await?;
93
    let account = db::accounts::account_by_name(&app.db, &name).await?.ok_or_else(|| not_found("no such user"))?;
94
    let rows: Vec<_> =
95
        db::repos::drops_of(&app.db, &account.username).await?.into_iter().filter(|r| drops::listed_to(c.account.as_ref(), r)).collect();
96
    Ok(Json(listed(&app, c.account.as_ref(), rows).await?))
97
}
98
99
/// `POST /drops`.
100
pub(super) async fn create(
101
    State(app): State<Shared>,
102
    headers: HeaderMap,
103
    Json(body): Json<NewDrop>,
104
) -> Result<(StatusCode, Json<Drop>), ApiError> {
105
    let c = caller(&app, &headers).await?;
106
    let me = c.require("repo:write")?.clone();
107
    let files: Vec<NewFile> = body.files.into_iter().map(|f| NewFile { name: f.name, content: f.content }).collect();
108
    let row = drops::create(&app, &me, &body.title, &body.description, body.secret, &files).await?;
109
    let (_, files) = drops::files(&app, &row, "").await?;
110
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &row, &files, true).await?)))
111
}
112
113
async fn row_of(app: &Shared, id: &str) -> Result<db::repos::RepoRow, ApiError> {
114
    if !crate::repos::names::valid_drop_id(id) {
115
        return Err(not_found("no such drop"));
116
    }
117
    db::repos::drop_by_id(&app.db, id).await?.ok_or_else(|| not_found("no such drop"))
118
}
119
120
/// `GET /drops/{id}`: anyone with the id, its files' content included.
121
pub(super) async fn view(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<Drop>, ApiError> {
122
    let c = caller(&app, &headers).await?;
123
    let row = row_of(&app, &id).await?;
124
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
125
        return Err(not_found("no such drop"));
126
    }
127
    let (_, files) = drops::files(&app, &row, "").await?;
128
    Ok(Json(shape(&app, c.account.as_ref(), &row, &files, true).await?))
129
}
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.
133
async fn writable(
134
    app: &Shared,
135
    headers: &HeaderMap,
136
    id: &str,
137
    deleting: bool,
138
) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
139
    let c = caller(app, headers).await?;
140
    let me = c.require("repo:write")?.clone();
141
    let row = row_of(app, id).await?;
142
    let may = if deleting { drops::may_delete(Some(&me), &row) } else { drops::may_edit(Some(&me), &row) };
143
    if !may {
144
        return Err(super::forbidden(if deleting {
145
            "only the drop's owner or a site administrator may delete it"
146
        } else {
147
            "only the drop's owner may change it"
148
        }));
149
    }
150
    Ok((me, row))
151
}
152
153
/// `PATCH /drops/{id}`.
154
pub(super) async fn edit(
155
    State(app): State<Shared>,
156
    headers: HeaderMap,
157
    Path(id): Path<String>,
158
    Json(body): Json<DropEdit>,
159
) -> Result<Json<Drop>, ApiError> {
160
    let (me, row) = writable(&app, &headers, &id, false).await?;
161
    let mut seen = Vec::new();
162
    let mut files = Vec::with_capacity(body.files.len());
163
    for f in body.files {
164
        let name = drops::check_name(&f.name).map_err(|m| ApiError(StatusCode::UNPROCESSABLE_ENTITY, m))?;
165
        if seen.contains(&name) {
166
            return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{name} is named twice.")));
167
        }
168
        seen.push(name.clone());
169
        files.push(oxy_core::repo::FileEdit { path: name, content: f.content.map(|c| c.replace("\r\n", "\n").into_bytes()) });
170
    }
171
    let update = drops::Update {
172
        title: body.title.unwrap_or_else(|| row.title.clone()),
173
        description: body.description.unwrap_or_else(|| row.description.clone()),
174
        secret: body.secret.unwrap_or(row.visibility() != hub_api::Visibility::Public),
175
        files,
176
        message: body.message.unwrap_or_default(),
177
    };
178
    drops::update(&app, &me, &row, update).await?;
179
    let row = db::repos::get_by_id(&app.db, row.id).await?.ok_or_else(|| not_found("no such drop"))?;
180
    let (_, files) = drops::files(&app, &row, "").await?;
181
    Ok(Json(shape(&app, Some(&me), &row, &files, true).await?))
182
}
183
184
/// `DELETE /drops/{id}`.
185
pub(super) async fn delete(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<StatusCode, ApiError> {
186
    let (me, row) = writable(&app, &headers, &id, true).await?;
187
    drops::delete(&app, &me, &row).await?;
188
    Ok(StatusCode::NO_CONTENT)
189
}
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.
193
async fn reader(app: &Shared, headers: &HeaderMap, id: &str) -> Result<(db::accounts::Account, db::repos::RepoRow), ApiError> {
194
    let c = caller(app, headers).await?;
195
    let me = c.require("repo:write")?.clone();
196
    if me.blocked() {
197
        return Err(not_found("no such drop"));
198
    }
199
    Ok((me, row_of(app, id).await?))
200
}
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.
204
pub(super) async fn fork(
205
    State(app): State<Shared>,
206
    headers: HeaderMap,
207
    Path(id): Path<String>,
208
) -> Result<(StatusCode, Json<Drop>), ApiError> {
209
    let (me, row) = reader(&app, &headers, &id).await?;
210
    let made = drops::fork(&app, &me, &row).await?;
211
    let (_, files) = drops::files(&app, &made, "").await?;
212
    Ok((StatusCode::CREATED, Json(shape(&app, Some(&me), &made, &files, true).await?)))
213
}
214
215
/// `PUT /drops/{id}/star` stars the drop, `DELETE` takes the star back;
216
/// either is the same however often it is asked.
217
async fn set_star(app: &Shared, headers: &HeaderMap, id: &str, want: bool) -> Result<Json<DropStars>, ApiError> {
218
    let (me, row) = reader(app, headers, id).await?;
219
    if db::repos::starred(&app.db, me.id, row.id).await? != want {
220
        db::repos::toggle_star(&app.db, me.id, row.id).await?;
221
    }
222
    Ok(Json(DropStars { stars: db::repos::star_count(&app.db, row.id).await?, starred: want }))
223
}
224
225
pub(super) async fn star(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
226
    set_star(&app, &headers, &id, true).await
227
}
228
229
pub(super) async fn unstar(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<String>) -> Result<Json<DropStars>, ApiError> {
230
    set_star(&app, &headers, &id, false).await
231
}
232
233
fn comment_of(c: db::repos::DropComment) -> DropComment {
234
    DropComment { id: c.id, author: c.author, body: c.body, created_ms: c.created_ms }
235
}
236
237
/// `GET /drops/{id}/comments`: oldest first, to anyone with the id.
238
pub(super) async fn comments(
239
    State(app): State<Shared>,
240
    headers: HeaderMap,
241
    Path(id): Path<String>,
242
) -> Result<Json<Vec<DropComment>>, ApiError> {
243
    let c = caller(&app, &headers).await?;
244
    if c.account.as_ref().is_some_and(|a| a.blocked()) {
245
        return Err(not_found("no such drop"));
246
    }
247
    let row = row_of(&app, &id).await?;
248
    Ok(Json(db::repos::drop_comments(&app.db, row.id).await?.into_iter().map(comment_of).collect()))
249
}
250
251
/// `POST /drops/{id}/comments`: the drop's owner is told.
252
pub(super) async fn add_comment(
253
    State(app): State<Shared>,
254
    headers: HeaderMap,
255
    Path(id): Path<String>,
256
    Json(body): Json<NewDropComment>,
257
) -> Result<(StatusCode, Json<DropComment>), ApiError> {
258
    let (me, row) = reader(&app, &headers, &id).await?;
259
    let n = drops::add_comment(&app, &me, &row, &body.body).await?;
260
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
261
    Ok((StatusCode::CREATED, Json(comment_of(c))))
262
}
263
264
/// `DELETE /drops/{id}/comments/{n}`: its author, the drop's owner or a
265
/// site administrator.
266
pub(super) async fn delete_comment(
267
    State(app): State<Shared>,
268
    headers: HeaderMap,
269
    Path((id, n)): Path<(String, i64)>,
270
) -> Result<StatusCode, ApiError> {
271
    let (me, row) = reader(&app, &headers, &id).await?;
272
    let c = db::repos::drop_comment(&app.db, row.id, n).await?.ok_or_else(|| not_found("no such comment"))?;
273
    if !drops::may_delete_comment(Some(&me), &row, &c) {
274
        return Err(super::forbidden("only the comment's author, the drop's owner or a site administrator may delete it"));
275
    }
276
    db::repos::delete_drop_comment(&app.db, row.id, n).await?;
277
    Ok(StatusCode::NO_CONTENT)
278
}