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