OXYGENHUB

Coverage / crates/hub/src/web/drops.rs 93.9%

447 of 476 lines run, 69 of 174 functions · run #528 on main · run missed

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.
1061×
pub async fn embed(State(app): State<Shared>, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result<Response, Error> {
1071×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/embed"), &viewer);
1081×
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
1091×
    let (target, files) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?;
1101×
    let url = drops::url_of(&row);
1111×
    let title = drops::shown_title(&row, &files);
1121×
    let label = target.label.clone();
1131×
    let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/"));
1141×
    let base = app.config.base_url().trim_end_matches('/').to_string();
1151×
    let page = EmbedPage {
1161×
        title,
1171×
        owner: row.owner.clone(),
1181×
        url: format!("{base}{url}"),
1191×
        site: shell.site.clone(),
1201×
        site_url: base,
1211×
        assets: shell.assets(),
1221×
        files,
1231×
    };
1241×
    let html = page.render().map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
1251×
    Ok((
1261×
        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"), (axum::http::header::CONTENT_SECURITY_POLICY, EMBED_POLICY)],
1271×
        html,
1281×
    )
1291×
        .into_response())
1301×
}
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.
1602×
pub async fn mine(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
1612×
    let shell = Shell::new(&app, "/drops", &viewer);
1622×
    let me = require(&viewer, &shell)?;
1631×
    Ok(Redirect::to(&format!("/{}/drops", me.account.username)).into_response())
1642×
}
165
166
#[derive(Deserialize, Default)]
167
pub struct ListQuery {
168
    #[serde(default)]
169
    deleted: Option<String>,
170
}
171
172
/// `GET /{owner}/drops`.
1736×
pub async fn list(
1746×
    State(app): State<Shared>,
1756×
    viewer: Viewer,
1766×
    Path(owner): Path<String>,
1776×
    Query(q): Query<ListQuery>,
1786×
) -> Result<Page<DropsPage>, Error> {
1796×
    let shell = Shell::new(&app, &format!("/{owner}/drops"), &viewer);
1806×
    let db_err = |e| Error::from_db(shell.clone(), e);
1816×
    let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? else {
1820×
        return Err(Error::not_found(shell));
183
    };
1846×
    let rows = db::repos::drops_of(&app.db, &account.username).await.map_err(db_err)?;
1856×
    let mut lines = Vec::new();
1869×
    for row in rows.into_iter().filter(|r| drops::listed_to(viewer.account(), r)) {
1877×
        let files = drops::files(&app, &row, "").await.map(|(_, f)| f).unwrap_or_default();
1887×
        lines.push(DropLine {
1897×
            url: drops::url_of(&row),
1907×
            title: drops::shown_title(&row, &files),
1917×
            description: row.description.clone(),
1927×
            secret: row.visibility() != hub_api::Visibility::Public,
1937×
            files: files.into_iter().map(|f| f.path).collect(),
1947×
            updated_ms: row.updated_ms,
195
        });
196
    }
1976×
    let is_me = viewer.account().is_some_and(|a| a.id == account.id);
1986×
    let notice = q.deleted.map(|_| "The drop was deleted.".to_string());
1996×
    Ok(Page(DropsPage { shell, owner: account.username, is_me, drops: lines, notice }))
2006×
}
201
202
/// The drop `{owner}/drops/{id}` for the viewer, or 404.
20356×
async fn lookup(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> {
20456×
    let row = drops::find(app, owner, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
20556×
    let row = row.ok_or_else(|| Error::not_found(shell.clone()))?;
20654×
    let role = crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
20754×
    if !crate::auth::roles::allows(role, Access::Read) {
2080×
        return Err(Error::not_found(shell.clone()));
20954×
    }
21054×
    Ok(row)
21156×
}
212
213
/// A file's anchor on the drop page: `file-` and its path, plain.
21435×
fn anchor_of(path: &str) -> String {
215215×
    format!("file-{}", path.trim_matches('/').replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_', "-"))
21635×
}
217
21828×
fn shown(files: Vec<DropFile>, base_file: &str, base_raw: &str) -> Vec<ShownFile> {
21928×
    files
22028×
        .into_iter()
22135×
        .map(|f| {
22235×
            let language = highlight::language(&f.path);
22335×
            let anchor = anchor_of(&f.path);
22435×
            let lower = f.path.to_lowercase();
22535×
            let (lines, md, note) = match &f.text {
22635×
                Some(text) if lower.ends_with(".md") || lower.ends_with(".markdown") => {
2275×
                    (vec![], Some(markdown::render(text, base_file, base_raw)), None)
228
                }
22930×
                Some(text) => (highlight::lines(&f.path, text), None, None),
2300×
                None if crate::web::mime_of(&f.path).starts_with("image/") => (vec![], None, Some("An image.".to_string())),
2310×
                None => (vec![], None, Some("Not shown: a binary file, or too large to show here.".to_string())),
232
            };
23335×
            ShownFile { path: f.path, anchor, size: f.size, language, lines, markdown: md, note }
23435×
        })
23528×
        .collect()
23628×
}
237
23829×
async fn page(app: &Shared, viewer: &Viewer, owner: &str, id: &str, r: &str) -> Result<Page<DropPage>, Error> {
23929×
    let path = if r.is_empty() { format!("/{owner}/drops/{id}") } else { format!("/{owner}/drops/{id}/tree/{r}") };
24029×
    let shell = Shell::new(app, &path, viewer);
24129×
    let row = lookup(app, &shell, viewer, owner, id).await?;
24227×
    let (target, files) = drops::files(app, &row, r).await.map_err(|e| Error::from_repo(shell.clone(), e))?;
24327×
    let url = drops::url_of(&row);
24427×
    let title = drops::shown_title(&row, &files);
24527×
    let label = target.label.clone();
24627×
    let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/"));
24727×
    let (can_edit, can_delete) = (drops::may_edit(viewer.account(), &row), drops::may_delete(viewer.account(), &row));
24827×
    let role = if can_edit { crate::auth::Role::Admin } else { crate::auth::Role::Read };
24927×
    let can_fork = viewer.account().is_some_and(|a| a.username != row.owner && !a.blocked());
25027×
    let forked_from = match row.forked_from {
2511×
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)),
25226×
        None => None,
253
    };
25427×
    let comments = db::repos::drop_comments(&app.db, row.id)
25527×
        .await
25627×
        .unwrap_or_default()
25727×
        .into_iter()
25827×
        .map(|c| ShownComment {
2598×
            may_delete: may_delete_comment(viewer, &row, &c),
2608×
            id: c.id,
2618×
            html: markdown::render(&c.body, "", ""),
2628×
            author: c.author,
2638×
            created_ms: c.created_ms,
2648×
        })
26527×
        .collect();
26627×
    let embed = format!(
267
        "<iframe src=\"{}{}/embed\" width=\"100%\" height=\"400\" style=\"border:0\" title=\"{}\"></iframe>",
26827×
        app.config.base_url().trim_end_matches('/'),
26927×
        drops::url_of(&row),
27027×
        title.replace('"', "&quot;")
271
    );
27227×
    let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0);
27327×
    let starred = match viewer.account() {
27417×
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false),
27510×
        None => false,
276
    };
27727×
    Ok(Page(DropPage {
27827×
        embed,
27927×
        comments,
28027×
        stars,
28127×
        starred,
28227×
        signed_in: viewer.account().is_some(),
28327×
        can_fork,
28427×
        forked_from,
28527×
        repo: RepoShell::new(app, &row, "code").with_role(role),
28627×
        title,
28727×
        description: row.description.clone(),
28827×
        secret: row.visibility() != hub_api::Visibility::Public,
28927×
        files,
29027×
        can_edit,
29127×
        can_delete,
29227×
        clone_url: format!("{}{url}", app.config.base_url()),
29327×
        at_default: label == row.default_bookmark,
29427×
        target: label,
29527×
        created_ms: row.created_ms,
29627×
        updated_ms: row.updated_ms,
29727×
        shell,
29827×
    }))
29929×
}
300
301
/// `GET /{owner}/drops/{id}`.
30228×
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result<Page<DropPage>, Error> {
30328×
    page(&app, &viewer, &owner, &id, "").await
30428×
}
305
306
/// `GET /{owner}/drops/{id}/tree/{r}`: the files at a bookmark, tag or
307
/// change (where a change page's links go).
3081×
pub async fn show_at(
3091×
    State(app): State<Shared>,
3101×
    viewer: Viewer,
3111×
    Path((owner, id, r)): Path<(String, String, String)>,
3121×
) -> Result<Page<DropPage>, Error> {
3131×
    page(&app, &viewer, &owner, &id, &r).await
3141×
}
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
3267×
fn submitted(pairs: Vec<(String, String)>) -> Submitted {
3277×
    let mut s = Submitted {
3287×
        csrf: String::new(),
3297×
        title: String::new(),
3307×
        description: String::new(),
3317×
        secret: false,
3327×
        message: String::new(),
3337×
        files: vec![],
3347×
    };
33549×
    for (k, v) in pairs {
33649×
        match k.as_str() {
33749×
            "_csrf" => s.csrf = v,
33842×
            "title" => s.title = v,
33936×
            "description" => s.description = v,
34033×
            "visibility" => s.secret = v == "secret",
34127×
            "message" => s.message = v,
34226×
            "name" => s.files.push(NewFile { name: v, content: String::new() }),
34313×
            "content" => match s.files.last_mut() {
34413×
                Some(f) if f.content.is_empty() => f.content = v,
3450×
                _ => s.files.push(NewFile { name: String::new(), content: v }),
346
            },
3470×
            _ => {}
348
        }
349
    }
350
    // A row left empty is no file.
35113×
    s.files.retain(|f| !f.name.trim().is_empty() || !f.content.trim().is_empty());
3527×
    s
3537×
}
354
3558×
fn form_files(files: &[NewFile]) -> Vec<(String, String)> {
3569×
    let mut out: Vec<(String, String)> = files.iter().map(|f| (f.name.clone(), f.content.clone())).collect();
3578×
    out.push((String::new(), String::new()));
3588×
    out
3598×
}
360
361
/// `GET /drops/new`.
3624×
pub async fn new_form(State(app): State<Shared>, viewer: Viewer) -> Result<Page<DropForm>, Error> {
3634×
    let shell = Shell::new(&app, "/drops/new", &viewer);
3644×
    let me = require(&viewer, &shell)?;
3654×
    let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err();
3664×
    Ok(Page(DropForm {
3674×
        cancel: format!("/{}/drops", me.account.username),
3684×
        shell,
3694×
        editing: false,
3704×
        action: "/drops/new".into(),
3714×
        title: String::new(),
3724×
        description: String::new(),
3734×
        secret: false,
3744×
        files: form_files(&[]),
3754×
        kept: vec![],
3764×
        message: String::new(),
3774×
        delegation,
3784×
        error: None,
3794×
    }))
3804×
}
381
382
/// `POST /drops/new`.
3834×
pub async fn create(State(app): State<Shared>, viewer: Viewer, Form(pairs): Form<Vec<(String, String)>>) -> Result<Response, Error> {
3844×
    let shell = Shell::new(&app, "/drops/new", &viewer);
3854×
    let me = require(&viewer, &shell)?;
3864×
    let f = submitted(pairs);
3874×
    csrf(&viewer, &f.csrf, &shell)?;
3884×
    match drops::create(&app, &me.account, &f.title, &f.description, f.secret, &f.files).await {
3893×
        Ok(row) => Ok(Redirect::to(&drops::url_of(&row)).into_response()),
3901×
        Err(drops::DropError::Bad(msg)) => {
3911×
            let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err();
3921×
            Ok(Page(DropForm {
3931×
                cancel: format!("/{}/drops", me.account.username),
3941×
                shell,
3951×
                editing: false,
3961×
                action: "/drops/new".into(),
3971×
                title: f.title,
3981×
                description: f.description,
3991×
                secret: f.secret,
4001×
                files: form_files(&f.files),
4011×
                kept: vec![],
4021×
                message: String::new(),
4031×
                delegation,
4041×
                error: Some(msg),
4051×
            })
4061×
            .into_response())
407
        }
4080×
        Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }),
409
    }
4104×
}
411
412
/// The drop, when the viewer may change it (its owner) or, `deleting`,
413
/// delete it (its owner or a site administrator).
41410×
async fn writable(
41510×
    app: &Shared,
41610×
    shell: &Shell,
41710×
    viewer: &Viewer,
41810×
    owner: &str,
41910×
    id: &str,
42010×
    deleting: bool,
42110×
) -> Result<db::repos::RepoRow, Error> {
42210×
    let row = lookup(app, shell, viewer, owner, id).await?;
42310×
    let may = if deleting { drops::may_delete(viewer.account(), &row) } else { drops::may_edit(viewer.account(), &row) };
42410×
    if !may {
4254×
        return Err(Error::not_found(shell.clone()));
4266×
    }
4276×
    Ok(row)
42810×
}
429
4303×
async fn edit_page(
4313×
    app: &Shared,
4323×
    shell: Shell,
4333×
    viewer: &Viewer,
4343×
    row: &db::repos::RepoRow,
4353×
    wanted: Option<&Submitted>,
4363×
    error: Option<String>,
4373×
) -> Result<Page<DropForm>, Error> {
4383×
    let me = require(viewer, &shell)?;
4393×
    let (_, current) = drops::files(app, row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?;
4408×
    let kept: Vec<String> = current.iter().filter(|f| f.text.is_none()).map(|f| f.path.clone()).collect();
4413×
    let files = match wanted {
4421×
        Some(w) => form_files(&w.files),
4432×
        None => form_files(
4442×
            &current
4452×
                .iter()
4465×
                .filter_map(|f| f.text.as_ref().map(|t| NewFile { name: f.path.clone(), content: t.clone() }))
4472×
                .collect::<Vec<_>>(),
448
        ),
449
    };
4503×
    let delegation = crate::web::edit::signer_for(app, me.account.id).await.err();
4513×
    let url = drops::url_of(row);
452
    Ok(Page(DropForm {
4533×
        shell,
454
        editing: true,
4553×
        action: format!("{url}/edit"),
4563×
        cancel: url,
4573×
        title: wanted.map(|w| w.title.clone()).unwrap_or_else(|| row.title.clone()),
4583×
        description: wanted.map(|w| w.description.clone()).unwrap_or_else(|| row.description.clone()),
4593×
        secret: wanted.map(|w| w.secret).unwrap_or(row.visibility() != hub_api::Visibility::Public),
4603×
        files,
4613×
        kept,
4623×
        message: wanted.map(|w| w.message.clone()).unwrap_or_default(),
4633×
        delegation,
4643×
        error,
465
    }))
4663×
}
467
468
/// `GET /{owner}/drops/{id}/edit`.
4694×
pub async fn edit_form(
4704×
    State(app): State<Shared>,
4714×
    viewer: Viewer,
4724×
    Path((owner, id)): Path<(String, String)>,
4734×
) -> Result<Page<DropForm>, Error> {
4744×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer);
4754×
    require(&viewer, &shell)?;
4764×
    let row = writable(&app, &shell, &viewer, &owner, &id, false).await?;
4772×
    edit_page(&app, shell, &viewer, &row, None, None).await
4784×
}
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.
4833×
pub async fn save(
4843×
    State(app): State<Shared>,
4853×
    viewer: Viewer,
4863×
    Path((owner, id)): Path<(String, String)>,
4873×
    Form(pairs): Form<Vec<(String, String)>>,
4883×
) -> Result<Response, Error> {
4893×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer);
4903×
    let me = require(&viewer, &shell)?;
4913×
    let f = submitted(pairs);
4923×
    csrf(&viewer, &f.csrf, &shell)?;
4933×
    let row = writable(&app, &shell, &viewer, &owner, &id, false).await?;
4942×
    let wanted = match drops::check_files(&f.files, false) {
4951×
        Ok(w) => w,
4961×
        Err(msg) => return Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()),
497
    };
4981×
    let (_, current) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?;
4991×
    let update = drops::Update {
5001×
        title: f.title.clone(),
5011×
        description: f.description.clone(),
5021×
        secret: f.secret,
5031×
        files: drops::edits_between(&current, &wanted),
5041×
        message: f.message.clone(),
5051×
    };
5061×
    match drops::update(&app, &me.account, &row, update).await {
5071×
        Ok(()) => Ok(Redirect::to(&drops::url_of(&row)).into_response()),
5080×
        Err(drops::DropError::Bad(msg)) => Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()),
5090×
        Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }),
510
    }
5113×
}
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.
5203×
pub async fn delete(
5213×
    State(app): State<Shared>,
5223×
    viewer: Viewer,
5233×
    Path((owner, id)): Path<(String, String)>,
5243×
    Form(f): Form<DeleteForm>,
5253×
) -> Result<Response, Error> {
5263×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
5273×
    let me = require(&viewer, &shell)?;
5283×
    csrf(&viewer, &f.csrf_token, &shell)?;
5293×
    let row = writable(&app, &shell, &viewer, &owner, &id, true).await?;
5302×
    drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
5312×
    Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response())
5323×
}
533
534
/// Who may delete a comment: its author, the drop's owner, a site
535
/// administrator.
53611×
fn may_delete_comment(viewer: &Viewer, row: &db::repos::RepoRow, c: &db::repos::DropComment) -> bool {
53711×
    drops::may_delete_comment(viewer.account(), row, c)
53811×
}
539
540
#[derive(Deserialize)]
541
pub struct CommentForm {
542
    #[serde(rename = "_csrf")]
543
    csrf_token: String,
544
    body: String,
545
}
546
547
/// `POST /{owner}/drops/{id}/comment` (#280): a reader's comment, in
548
/// Markdown; the drop's owner is told.
5492×
pub async fn comment(
5502×
    State(app): State<Shared>,
5512×
    viewer: Viewer,
5522×
    Path((owner, id)): Path<(String, String)>,
5532×
    Form(f): Form<CommentForm>,
5542×
) -> Result<Response, Error> {
5552×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
5562×
    let me = require(&viewer, &shell)?;
5572×
    csrf(&viewer, &f.csrf_token, &shell)?;
5582×
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
5592×
    let n = drops::add_comment(&app, &me.account, &row, &f.body).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
5602×
    let url = drops::url_of(&row);
5612×
    Ok(Redirect::to(&format!("{url}#comment-{n}")).into_response())
5622×
}
563
564
/// `POST /{owner}/drops/{id}/comment/{n}/delete` (#280).
5653×
pub async fn delete_comment(
5663×
    State(app): State<Shared>,
5673×
    viewer: Viewer,
5683×
    Path((owner, id, n)): Path<(String, String, i64)>,
5693×
    Form(f): Form<DeleteForm>,
5703×
) -> Result<Response, Error> {
5713×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
5723×
    require(&viewer, &shell)?;
5733×
    csrf(&viewer, &f.csrf_token, &shell)?;
5743×
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
5753×
    let c = db::repos::drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5763×
    match c {
5773×
        Some(c) if may_delete_comment(&viewer, &row, &c) => {
5782×
            db::repos::delete_drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5792×
            Ok(Redirect::to(&format!("{}#comments", drops::url_of(&row))).into_response())
580
        }
5811×
        _ => Err(Error::not_found(shell)),
582
    }
5833×
}
584
585
/// `POST /{owner}/drops/{id}/star` (#280): star it, or take the star back.
5863×
pub async fn star(
5873×
    State(app): State<Shared>,
5883×
    viewer: Viewer,
5893×
    Path((owner, id)): Path<(String, String)>,
5903×
    Form(f): Form<DeleteForm>,
5913×
) -> Result<Response, Error> {
5923×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
5933×
    let me = require(&viewer, &shell)?;
5943×
    csrf(&viewer, &f.csrf_token, &shell)?;
5953×
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
5963×
    db::repos::toggle_star(&app.db, me.account.id, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
5973×
    Ok(Redirect::to(&drops::url_of(&row)).into_response())
5983×
}
599
600
/// `POST /{owner}/drops/{id}/fork` (#280): a copy of the drop, history and
601
/// all, as a drop of the viewer's.
6021×
pub async fn fork(
6031×
    State(app): State<Shared>,
6041×
    viewer: Viewer,
6051×
    Path((owner, id)): Path<(String, String)>,
6061×
    Form(f): Form<DeleteForm>,
6071×
) -> Result<Response, Error> {
6081×
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
6091×
    let me = require(&viewer, &shell)?;
6101×
    csrf(&viewer, &f.csrf_token, &shell)?;
6111×
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
6121×
    let made = drops::fork(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
6131×
    Ok(Redirect::to(&drops::url_of(&made)).into_response())
6141×
}
615
616
/// The drop's repository name, `drops/ID`, for the repository pages; a
617
/// name that is not a drop's finds nothing there.
6187×
fn repo_name(id: &str) -> String {
6197×
    format!("{}/{id}", crate::repos::names::DROPS)
6207×
}
621
622
/// `GET /{owner}/drops/{id}/changes/{r}`: the history page.
6232×
pub async fn changes(
6242×
    State(app): State<Shared>,
6252×
    viewer: Viewer,
6262×
    Path((owner, id, r)): Path<(String, String, String)>,
6272×
    q: Query<super::history::HistoryQuery>,
6282×
) -> Result<Response, Error> {
6292×
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
6302×
    super::history::history(State(app), viewer, Path((row.owner, repo_name(&id), r)), q).await.map(IntoResponse::into_response)
6312×
}
632
633
/// `GET /{owner}/drops/{id}/change/{hash}`: a change with its diff.
6341×
pub async fn change(
6351×
    State(app): State<Shared>,
6361×
    viewer: Viewer,
6371×
    Path((owner, id, hash)): Path<(String, String, String)>,
6381×
    q: Query<super::history::DiffQuery>,
6391×
) -> Result<Response, Error> {
6401×
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
6411×
    super::history::change(State(app), viewer, Path((row.owner, repo_name(&id), hash)), q).await.map(IntoResponse::into_response)
6421×
}
643
644
/// `GET /{owner}/drops/{id}/raw/{r}/{*path}`: a file's bytes.
6454×
pub async fn raw(
6464×
    State(app): State<Shared>,
6474×
    viewer: Viewer,
6484×
    Path((owner, id, r, path)): Path<(String, String, String, String)>,
6494×
) -> Result<Response, Error> {
6504×
    let row = drop_or_404(&app, &viewer, &owner, &id).await?;
6514×
    super::repo::raw_file(State(app), viewer, Path((row.owner, repo_name(&id), r, path))).await
6524×
}
653
654
/// `GET /{owner}/drops/{id}/file/{r}/{*path}`: the drop's page at `r`,
655
/// at that file (where rendered Markdown's relative links go).
6560×
pub async fn file(Path((owner, id, r, path)): Path<(String, String, String, String)>) -> Response {
6570×
    Redirect::to(&format!("/{owner}/drops/{id}/tree/{r}#{}", anchor_of(&path))).into_response()
6580×
}
659
6607×
async fn drop_or_404(app: &Shared, viewer: &Viewer, owner: &str, id: &str) -> Result<db::repos::RepoRow, Error> {
6617×
    let shell = Shell::new(app, &format!("/{owner}/drops/{id}"), viewer);
6627×
    lookup(app, &shell, viewer, owner, id).await
6637×
}