OXYGENHUB
# ab2d8b40f9f4 oxygenhub / crates / hub / src / web / drops.rs
19.1 KiB · Rust · last changed Fork a drop (refs #280) 15 days ago source blame history raw
1
//! Drops' pages (oxygenhub#279): `/{owner}/drops` lists a person's drops
2
//! (all of them to themselves, the public ones to everyone else), `/drops`
3
//! goes to your own, `/drops/new` makes one, and `/{owner}/drops/{id}`
4
//! shows one with its files rendered, with Edit and Delete for its owner.
5
//! A drop's history, changes and raw files are the repository pages under
6
//! `/{owner}/drops/{id}/…`, with the drop's bar in place of the tabs.
7
8
use super::settings::{csrf, require};
9
use super::{filters, Error, Page, RepoShell, Shell};
10
use crate::auth::{Access, Viewer};
11
use crate::db;
12
use crate::drops::{self, DropFile, NewFile};
13
use crate::render::{highlight, markdown};
14
use crate::Shared;
15
use askama::Template;
16
use axum::extract::{Form, Path, Query, State};
17
use axum::response::{IntoResponse, Redirect, Response};
18
use serde::Deserialize;
19
20
/// One drop on a Drops page.
21
pub struct DropLine {
22
    pub url: String,
23
    pub title: String,
24
    pub description: String,
25
    pub secret: bool,
26
    pub files: Vec<String>,
27
    pub updated_ms: i64,
28
}
29
30
#[derive(Template)]
31
#[template(path = "drops.html")]
32
pub struct DropsPage {
33
    shell: Shell,
34
    owner: String,
35
    is_me: bool,
36
    drops: Vec<DropLine>,
37
    notice: Option<String>,
38
}
39
40
/// One file as the drop page shows it.
41
pub struct ShownFile {
42
    pub path: String,
43
    pub anchor: String,
44
    pub size: u64,
45
    pub language: Option<&'static str>,
46
    /// Highlighted lines, as markup.
47
    pub lines: Vec<String>,
48
    pub markdown: Option<String>,
49
    /// Why the file is not shown (binary, too large).
50
    pub note: Option<String>,
51
}
52
53
#[derive(Template)]
54
#[template(path = "drop.html")]
55
pub struct DropPage {
56
    shell: Shell,
57
    repo: RepoShell,
58
    title: String,
59
    description: String,
60
    secret: bool,
61
    files: Vec<ShownFile>,
62
    can_edit: bool,
63
    can_delete: bool,
64
    /// Signed in and not its owner: may fork it (#280).
65
    can_fork: bool,
66
    /// The drop it was forked from, if it still exists: its address.
67
    forked_from: Option<String>,
68
    clone_url: String,
69
    /// What the files are shown at, and whether that is the default bookmark.
70
    target: String,
71
    at_default: bool,
72
    created_ms: i64,
73
    updated_ms: i64,
74
}
75
76
#[derive(Template)]
77
#[template(path = "drop_form.html")]
78
pub struct DropForm {
79
    shell: Shell,
80
    editing: bool,
81
    action: String,
82
    cancel: String,
83
    title: String,
84
    description: String,
85
    secret: bool,
86
    files: Vec<(String, String)>,
87
    /// Files the form does not edit: not text, or too large.
88
    kept: Vec<String>,
89
    message: String,
90
    delegation: Option<String>,
91
    error: Option<String>,
92
}
93
94
/// `GET /drops`: your own Drops page.
95
pub async fn mine(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
96
    let shell = Shell::new(&app, "/drops", &viewer);
97
    let me = require(&viewer, &shell)?;
98
    Ok(Redirect::to(&format!("/{}/drops", me.account.username)).into_response())
99
}
100
101
#[derive(Deserialize, Default)]
102
pub struct ListQuery {
103
    #[serde(default)]
104
    deleted: Option<String>,
105
}
106
107
/// `GET /{owner}/drops`.
108
pub async fn list(
109
    State(app): State<Shared>,
110
    viewer: Viewer,
111
    Path(owner): Path<String>,
112
    Query(q): Query<ListQuery>,
113
) -> Result<Page<DropsPage>, Error> {
114
    let shell = Shell::new(&app, &format!("/{owner}/drops"), &viewer);
115
    let db_err = |e| Error::from_db(shell.clone(), e);
116
    let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? else {
117
        return Err(Error::not_found(shell));
118
    };
119
    let rows = db::repos::drops_of(&app.db, &account.username).await.map_err(db_err)?;
120
    let mut lines = Vec::new();
121
    for row in rows.into_iter().filter(|r| drops::listed_to(viewer.account(), r)) {
122
        let files = drops::files(&app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
123
        lines.push(DropLine {
124
            url: drops::url_of(&row),
125
            title: drops::shown_title(&row, &files),
126
            description: row.description.clone(),
127
            secret: row.visibility() != hub_api::Visibility::Public,
128
            files: files.into_iter().map(|f| f.path).collect(),
129
            updated_ms: row.updated_ms,
130
        });
131
    }
132
    let is_me = viewer.account().is_some_and(|a| a.id == account.id);
133
    let notice = q.deleted.map(|_| "The drop was deleted.".to_string());
134
    Ok(Page(DropsPage { shell, owner: account.username, is_me, drops: lines, notice }))
135
}
136
137
/// The drop `{owner}/drops/{id}` for the viewer, or 404.
138
async fn lookup(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> {
139
    let row = drops::find(app, owner, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
140
    let row = row.ok_or_else(|| Error::not_found(shell.clone()))?;
141
    let role = crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
142
    if !crate::auth::roles::allows(role, Access::Read) {
143
        return Err(Error::not_found(shell.clone()));
144
    }
145
    Ok(row)
146
}
147
148
/// A file's anchor on the drop page: `file-` and its path, plain.
149
fn anchor_of(path: &str) -> String {
150
    format!("file-{}", path.trim_matches('/').replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_', "-"))
151
}
152
153
fn shown(files: Vec<DropFile>, base_file: &str, base_raw: &str) -> Vec<ShownFile> {
154
    files
155
        .into_iter()
156
        .map(|f| {
157
            let language = highlight::language(&f.path);
158
            let anchor = anchor_of(&f.path);
159
            let lower = f.path.to_lowercase();
160
            let (lines, md, note) = match &f.text {
161
                Some(text) if lower.ends_with(".md") || lower.ends_with(".markdown") => {
162
                    (vec![], Some(markdown::render(text, base_file, base_raw)), None)
163
                }
164
                Some(text) => (highlight::lines(&f.path, text), None, None),
165
                None if crate::web::mime_of(&f.path).starts_with("image/") => (vec![], None, Some("An image.".to_string())),
166
                None => (vec![], None, Some("Not shown: a binary file, or too large to show here.".to_string())),
167
            };
168
            ShownFile { path: f.path, anchor, size: f.size, language, lines, markdown: md, note }
169
        })
170
        .collect()
171
}
172
173
async fn page(app: &Shared, viewer: &Viewer, owner: &str, id: &str, r: &str) -> Result<Page<DropPage>, Error> {
174
    let path = if r.is_empty() { format!("/{owner}/drops/{id}") } else { format!("/{owner}/drops/{id}/tree/{r}") };
175
    let shell = Shell::new(app, &path, viewer);
176
    let row = lookup(app, &shell, viewer, owner, id).await?;
177
    let (target, files) = drops::files(app, &row, r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
178
    let url = drops::url_of(&row);
179
    let title = drops::shown_title(&row, &files);
180
    let label = target.label.clone();
181
    let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/"));
182
    let (can_edit, can_delete) = (drops::may_edit(viewer.account(), &row), drops::may_delete(viewer.account(), &row));
183
    let role = if can_edit { crate::auth::Role::Admin } else { crate::auth::Role::Read };
184
    let can_fork = viewer.account().is_some_and(|a| a.username != row.owner && !a.blocked());
185
    let forked_from = match row.forked_from {
186
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)),
187
        None => None,
188
    };
189
    Ok(Page(DropPage {
190
        can_fork,
191
        forked_from,
192
        repo: RepoShell::new(app, &row, "code").with_role(role),
193
        title,
194
        description: row.description.clone(),
195
        secret: row.visibility() != hub_api::Visibility::Public,
196
        files,
197
        can_edit,
198
        can_delete,
199
        clone_url: format!("{}{url}", app.config.base_url()),
200
        at_default: label == row.default_bookmark,
201
        target: label,
202
        created_ms: row.created_ms,
203
        updated_ms: row.updated_ms,
204
        shell,
205
    }))
206
}
207
208
/// `GET /{owner}/drops/{id}`.
209
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result<Page<DropPage>, Error> {
210
    page(&app, &viewer, &owner, &id, "").await
211
}
212
213
/// `GET /{owner}/drops/{id}/tree/{r}`: the files at a bookmark, tag or
214
/// change (where a change page's links go).
215
pub async fn show_at(
216
    State(app): State<Shared>,
217
    viewer: Viewer,
218
    Path((owner, id, r)): Path<(String, String, String)>,
219
) -> Result<Page<DropPage>, Error> {
220
    page(&app, &viewer, &owner, &id, &r).await
221
}
222
223
/// The form's fields: repeated `name` and `content` pairs, in order.
224
struct Submitted {
225
    csrf: String,
226
    title: String,
227
    description: String,
228
    secret: bool,
229
    message: String,
230
    files: Vec<NewFile>,
231
}
232
233
fn submitted(pairs: Vec<(String, String)>) -> Submitted {
234
    let mut s = Submitted {
235
        csrf: String::new(),
236
        title: String::new(),
237
        description: String::new(),
238
        secret: false,
239
        message: String::new(),
240
        files: vec![],
241
    };
242
    for (k, v) in pairs {
243
        match k.as_str() {
244
            "_csrf" => s.csrf = v,
245
            "title" => s.title = v,
246
            "description" => s.description = v,
247
            "visibility" => s.secret = v == "secret",
248
            "message" => s.message = v,
249
            "name" => s.files.push(NewFile { name: v, content: String::new() }),
250
            "content" => match s.files.last_mut() {
251
                Some(f) if f.content.is_empty() => f.content = v,
252
                _ => s.files.push(NewFile { name: String::new(), content: v }),
253
            },
254
            _ => {}
255
        }
256
    }
257
    // A row left empty is no file.
258
    s.files.retain(|f| !f.name.trim().is_empty() || !f.content.trim().is_empty());
259
    s
260
}
261
262
fn form_files(files: &[NewFile]) -> Vec<(String, String)> {
263
    let mut out: Vec<(String, String)> = files.iter().map(|f| (f.name.clone(), f.content.clone())).collect();
264
    out.push((String::new(), String::new()));
265
    out
266
}
267
268
/// `GET /drops/new`.
269
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<DropForm>, Error> {
270
    let shell = Shell::new(&app, "/drops/new", &viewer);
271
    let me = require(&viewer, &shell)?;
272
    let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err();
273
    Ok(Page(DropForm {
274
        cancel: format!("/{}/drops", me.account.username),
275
        shell,
276
        editing: false,
277
        action: "/drops/new".into(),
278
        title: String::new(),
279
        description: String::new(),
280
        secret: false,
281
        files: form_files(&[]),
282
        kept: vec![],
283
        message: String::new(),
284
        delegation,
285
        error: None,
286
    }))
287
}
288
289
/// `POST /drops/new`.
290
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(pairs): Form<Vec<(String, String)>>) -> Result<Response, Error> {
291
    let shell = Shell::new(&app, "/drops/new", &viewer);
292
    let me = require(&viewer, &shell)?;
293
    let f = submitted(pairs);
294
    csrf(&viewer, &f.csrf, &shell)?;
295
    match drops::create(&app, &me.account, &f.title, &f.description, f.secret, &f.files).await {
296
        Ok(row) => Ok(Redirect::to(&drops::url_of(&row)).into_response()),
297
        Err(drops::DropError::Bad(msg)) => {
298
            let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err();
299
            Ok(Page(DropForm {
300
                cancel: format!("/{}/drops", me.account.username),
301
                shell,
302
                editing: false,
303
                action: "/drops/new".into(),
304
                title: f.title,
305
                description: f.description,
306
                secret: f.secret,
307
                files: form_files(&f.files),
308
                kept: vec![],
309
                message: String::new(),
310
                delegation,
311
                error: Some(msg),
312
            })
313
            .into_response())
314
        }
315
        Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }),
316
    }
317
}
318
319
/// The drop, when the viewer may change it (its owner) or, `deleting`,
320
/// delete it (its owner or a site administrator).
321
async fn writable(
322
    app: &Shared,
323
    shell: &Shell,
324
    viewer: &Viewer,
325
    owner: &str,
326
    id: &str,
327
    deleting: bool,
328
) -> Result<db::repos::RepoRow, Error> {
329
    let row = lookup(app, shell, viewer, owner, id).await?;
330
    let may = if deleting { drops::may_delete(viewer.account(), &row) } else { drops::may_edit(viewer.account(), &row) };
331
    if !may {
332
        return Err(Error::not_found(shell.clone()));
333
    }
334
    Ok(row)
335
}
336
337
async fn edit_page(
338
    app: &Shared,
339
    shell: Shell,
340
    viewer: &Viewer,
341
    row: &db::repos::RepoRow,
342
    wanted: Option<&Submitted>,
343
    error: Option<String>,
344
) -> Result<Page<DropForm>, Error> {
345
    let me = require(viewer, &shell)?;
346
    let (_, current) = drops::files(app, row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?;
347
    let kept: Vec<String> = current.iter().filter(|f| f.text.is_none()).map(|f| f.path.clone()).collect();
348
    let files = match wanted {
349
        Some(w) => form_files(&w.files),
350
        None => form_files(
351
            &current
352
                .iter()
353
                .filter_map(|f| f.text.as_ref().map(|t| NewFile { name: f.path.clone(), content: t.clone() }))
354
                .collect::<Vec<_>>(),
355
        ),
356
    };
357
    let delegation = crate::web::edit::signer_for(app, me.account.id).await.err();
358
    let url = drops::url_of(row);
359
    Ok(Page(DropForm {
360
        shell,
361
        editing: true,
362
        action: format!("{url}/edit"),
363
        cancel: url,
364
        title: wanted.map(|w| w.title.clone()).unwrap_or_else(|| row.title.clone()),
365
        description: wanted.map(|w| w.description.clone()).unwrap_or_else(|| row.description.clone()),
366
        secret: wanted.map(|w| w.secret).unwrap_or(row.visibility() != hub_api::Visibility::Public),
367
        files,
368
        kept,
369
        message: wanted.map(|w| w.message.clone()).unwrap_or_default(),
370
        delegation,
371
        error,
372
    }))
373
}
374
375
/// `GET /{owner}/drops/{id}/edit`.
376
pub async fn edit_form(
377
    State(app): State<Shared>,
378
    viewer: Viewer,
379
    Path((owner, id)): Path<(String, String)>,
380
) -> Result<Page<DropForm>, Error> {
381
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer);
382
    require(&viewer, &shell)?;
383
    let row = writable(&app, &shell, &viewer, &owner, &id, false).await?;
384
    edit_page(&app, shell, &viewer, &row, None, None).await
385
}
386
387
/// `POST /{owner}/drops/{id}/edit`: the files as the form has them, as
388
/// one change (only what differs), and the title, description and
389
/// visibility.
390
pub async fn save(
391
    State(app): State<Shared>,
392
    viewer: Viewer,
393
    Path((owner, id)): Path<(String, String)>,
394
    Form(pairs): Form<Vec<(String, String)>>,
395
) -> Result<Response, Error> {
396
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer);
397
    let me = require(&viewer, &shell)?;
398
    let f = submitted(pairs);
399
    csrf(&viewer, &f.csrf, &shell)?;
400
    let row = writable(&app, &shell, &viewer, &owner, &id, false).await?;
401
    let wanted = match drops::check_files(&f.files, false) {
402
        Ok(w) => w,
403
        Err(msg) => return Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()),
404
    };
405
    let (_, current) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?;
406
    let update = drops::Update {
407
        title: f.title.clone(),
408
        description: f.description.clone(),
409
        secret: f.secret,
410
        files: drops::edits_between(&current, &wanted),
411
        message: f.message.clone(),
412
    };
413
    match drops::update(&app, &me.account, &row, update).await {
414
        Ok(()) => Ok(Redirect::to(&drops::url_of(&row)).into_response()),
415
        Err(drops::DropError::Bad(msg)) => Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()),
416
        Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }),
417
    }
418
}
419
420
#[derive(Deserialize)]
421
pub struct DeleteForm {
422
    #[serde(rename = "_csrf")]
423
    csrf_token: String,
424
}
425
426
/// `POST /{owner}/drops/{id}/delete`: its owner or a site administrator.
427
pub async fn delete(
428
    State(app): State<Shared>,
429
    viewer: Viewer,
430
    Path((owner, id)): Path<(String, String)>,
431
    Form(f): Form<DeleteForm>,
432
) -> Result<Response, Error> {
433
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
434
    let me = require(&viewer, &shell)?;
435
    csrf(&viewer, &f.csrf_token, &shell)?;
436
    let row = writable(&app, &shell, &viewer, &owner, &id, true).await?;
437
    drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
438
    Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response())
439
}
440
441
/// `POST /{owner}/drops/{id}/fork` (#280): a copy of the drop, history and
442
/// all, as a drop of the viewer's.
443
pub async fn fork(
444
    State(app): State<Shared>,
445
    viewer: Viewer,
446
    Path((owner, id)): Path<(String, String)>,
447
    Form(f): Form<DeleteForm>,
448
) -> Result<Response, Error> {
449
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
450
    let me = require(&viewer, &shell)?;
451
    csrf(&viewer, &f.csrf_token, &shell)?;
452
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
453
    let made = drops::fork(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
454
    Ok(Redirect::to(&drops::url_of(&made)).into_response())
455
}
456
457
/// The drop's repository name, `drops/ID`, for the repository pages; a
458
/// name that is not a drop's finds nothing there.
459
fn repo_name(id: &str) -> String {
460
    format!("{}/{id}", crate::repos::names::DROPS)
461
}
462
463
/// `GET /{owner}/drops/{id}/changes/{r}`: the history page.
464
pub async fn changes(
465
    State(app): State<Shared>,
466
    viewer: Viewer,
467
    Path((owner, id, r)): Path<(String, String, String)>,
468
    q: Query<super::history::HistoryQuery>,
469
) -> Result<Response, Error> {
470
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
471
    super::history::history(State(app), viewer, Path((row.owner, repo_name(&id), r)), q).await.map(IntoResponse::into_response)
472
}
473
474
/// `GET /{owner}/drops/{id}/change/{hash}`: a change with its diff.
475
pub async fn change(
476
    State(app): State<Shared>,
477
    viewer: Viewer,
478
    Path((owner, id, hash)): Path<(String, String, String)>,
479
    q: Query<super::history::DiffQuery>,
480
) -> Result<Response, Error> {
481
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
482
    super::history::change(State(app), viewer, Path((row.owner, repo_name(&id), hash)), q).await.map(IntoResponse::into_response)
483
}
484
485
/// `GET /{owner}/drops/{id}/raw/{r}/{*path}`: a file's bytes.
486
pub async fn raw(
487
    State(app): State<Shared>,
488
    viewer: Viewer,
489
    Path((owner, id, r, path)): Path<(String, String, String, String)>,
490
) -> Result<Response, Error> {
491
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
492
    super::repo::raw_file(State(app), viewer, Path((row.owner, repo_name(&id), r, path))).await
493
}
494
495
/// `GET /{owner}/drops/{id}/file/{r}/{*path}`: the drop's page at `r`,
496
/// at that file (where rendered Markdown's relative links go).
497
pub async fn file(Path((owner, id, r, path)): Path<(String, String, String, String)>) -> Response {
498
    Redirect::to(&format!("/{owner}/drops/{id}/tree/{r}#{}", anchor_of(&path))).into_response()
499
}
500
501
async fn drop_or_404(app: &Shared, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> {
502
    let shell = Shell::new(app, &format!("/{owner}/drops/{id}"), viewer);
503
    lookup(app, &shell, viewer, owner, id).await
504
}