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