| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 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 |
|
| 28 |
|
| 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 |
|
| 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 |
|
| 90 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 132 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 192 |
|
| 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 |
|
| 203 |
|
| 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 |
|
| 216 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 265 |
|
| 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 |
} |