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