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