//! Drops' pages (oxygenhub#279): `/{owner}/drops` lists a person's drops //! (all of them to themselves, the public ones to everyone else), `/drops` //! goes to your own, `/drops/new` makes one, and `/{owner}/drops/{id}` //! shows one with its files rendered, with Edit and Delete for its owner. //! A drop's history, changes and raw files are the repository pages under //! `/{owner}/drops/{id}/…`, with the drop's bar in place of the tabs. use super::settings::{csrf, require}; use super::{filters, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::db; use crate::drops::{self, DropFile, NewFile}; use crate::render::{highlight, markdown}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; /// One drop on a Drops page. pub struct DropLine { pub url: String, pub title: String, pub description: String, pub secret: bool, pub files: Vec, pub updated_ms: i64, } #[derive(Template)] #[template(path = "drops.html")] pub struct DropsPage { shell: Shell, owner: String, is_me: bool, drops: Vec, notice: Option, } /// One file as the drop page shows it. pub struct ShownFile { pub path: String, pub anchor: String, pub size: u64, pub language: Option<&'static str>, /// Highlighted lines, as markup. pub lines: Vec, pub markdown: Option, /// Why the file is not shown (binary, too large). pub note: Option, } #[derive(Template)] #[template(path = "drop.html")] pub struct DropPage { shell: Shell, repo: RepoShell, title: String, description: String, secret: bool, files: Vec, can_edit: bool, can_delete: bool, clone_url: String, /// What the files are shown at, and whether that is the default bookmark. target: String, at_default: bool, created_ms: i64, updated_ms: i64, } #[derive(Template)] #[template(path = "drop_form.html")] pub struct DropForm { shell: Shell, editing: bool, action: String, cancel: String, title: String, description: String, secret: bool, files: Vec<(String, String)>, /// Files the form does not edit: not text, or too large. kept: Vec, message: String, delegation: Option, error: Option, } /// `GET /drops`: your own Drops page. pub async fn mine(State(app): State, viewer: Viewer) -> Result { let shell = Shell::new(&app, "/drops", &viewer); let me = require(&viewer, &shell)?; Ok(Redirect::to(&format!("/{}/drops", me.account.username)).into_response()) } #[derive(Deserialize, Default)] pub struct ListQuery { #[serde(default)] deleted: Option, } /// `GET /{owner}/drops`. pub async fn list( State(app): State, viewer: Viewer, Path(owner): Path, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/drops"), &viewer); let db_err = |e| Error::from_db(shell.clone(), e); let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? else { return Err(Error::not_found(shell)); }; let rows = db::repos::drops_of(&app.db, &account.username).await.map_err(db_err)?; let mut lines = Vec::new(); for row in rows.into_iter().filter(|r| drops::listed_to(viewer.account(), r)) { let files = drops::files(&app, &row, "").await.map(|(_, f)| f).unwrap_or_default(); lines.push(DropLine { url: drops::url_of(&row), title: drops::shown_title(&row, &files), description: row.description.clone(), secret: row.visibility() != hub_api::Visibility::Public, files: files.into_iter().map(|f| f.path).collect(), updated_ms: row.updated_ms, }); } let is_me = viewer.account().is_some_and(|a| a.id == account.id); let notice = q.deleted.map(|_| "The drop was deleted.".to_string()); Ok(Page(DropsPage { shell, owner: account.username, is_me, drops: lines, notice })) } /// The drop `{owner}/drops/{id}` for the viewer, or 404. async fn lookup(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, id: &str) -> Result { let row = drops::find(app, owner, id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let row = row.ok_or_else(|| Error::not_found(shell.clone()))?; let role = crate::auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; if !crate::auth::roles::allows(role, Access::Read) { return Err(Error::not_found(shell.clone())); } Ok(row) } /// A file's anchor on the drop page: `file-` and its path, plain. fn anchor_of(path: &str) -> String { format!("file-{}", path.trim_matches('/').replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_', "-")) } fn shown(files: Vec, base_file: &str, base_raw: &str) -> Vec { files .into_iter() .map(|f| { let language = highlight::language(&f.path); let anchor = anchor_of(&f.path); let lower = f.path.to_lowercase(); let (lines, md, note) = match &f.text { Some(text) if lower.ends_with(".md") || lower.ends_with(".markdown") => { (vec![], Some(markdown::render(text, base_file, base_raw)), None) } Some(text) => (highlight::lines(&f.path, text), None, None), None if crate::web::mime_of(&f.path).starts_with("image/") => (vec![], None, Some("An image.".to_string())), None => (vec![], None, Some("Not shown: a binary file, or too large to show here.".to_string())), }; ShownFile { path: f.path, anchor, size: f.size, language, lines, markdown: md, note } }) .collect() } async fn page(app: &Shared, viewer: &Viewer, owner: &str, id: &str, r: &str) -> Result, Error> { let path = if r.is_empty() { format!("/{owner}/drops/{id}") } else { format!("/{owner}/drops/{id}/tree/{r}") }; let shell = Shell::new(app, &path, viewer); let row = lookup(app, &shell, viewer, owner, id).await?; let (target, files) = drops::files(app, &row, r).await.map_err(|e| Error::from_repo(shell.clone(), e))?; let url = drops::url_of(&row); let title = drops::shown_title(&row, &files); let label = target.label.clone(); let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/")); let (can_edit, can_delete) = (drops::may_edit(viewer.account(), &row), drops::may_delete(viewer.account(), &row)); let role = if can_edit { crate::auth::Role::Admin } else { crate::auth::Role::Read }; Ok(Page(DropPage { repo: RepoShell::new(app, &row, "code").with_role(role), title, description: row.description.clone(), secret: row.visibility() != hub_api::Visibility::Public, files, can_edit, can_delete, clone_url: format!("{}{url}", app.config.base_url()), at_default: label == row.default_bookmark, target: label, created_ms: row.created_ms, updated_ms: row.updated_ms, shell, })) } /// `GET /{owner}/drops/{id}`. pub async fn show(State(app): State, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result, Error> { page(&app, &viewer, &owner, &id, "").await } /// `GET /{owner}/drops/{id}/tree/{r}`: the files at a bookmark, tag or /// change (where a change page's links go). pub async fn show_at( State(app): State, viewer: Viewer, Path((owner, id, r)): Path<(String, String, String)>, ) -> Result, Error> { page(&app, &viewer, &owner, &id, &r).await } /// The form's fields: repeated `name` and `content` pairs, in order. struct Submitted { csrf: String, title: String, description: String, secret: bool, message: String, files: Vec, } fn submitted(pairs: Vec<(String, String)>) -> Submitted { let mut s = Submitted { csrf: String::new(), title: String::new(), description: String::new(), secret: false, message: String::new(), files: vec![], }; for (k, v) in pairs { match k.as_str() { "_csrf" => s.csrf = v, "title" => s.title = v, "description" => s.description = v, "visibility" => s.secret = v == "secret", "message" => s.message = v, "name" => s.files.push(NewFile { name: v, content: String::new() }), "content" => match s.files.last_mut() { Some(f) if f.content.is_empty() => f.content = v, _ => s.files.push(NewFile { name: String::new(), content: v }), }, _ => {} } } // A row left empty is no file. s.files.retain(|f| !f.name.trim().is_empty() || !f.content.trim().is_empty()); s } fn form_files(files: &[NewFile]) -> Vec<(String, String)> { let mut out: Vec<(String, String)> = files.iter().map(|f| (f.name.clone(), f.content.clone())).collect(); out.push((String::new(), String::new())); out } /// `GET /drops/new`. pub async fn new_form(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/drops/new", &viewer); let me = require(&viewer, &shell)?; let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err(); Ok(Page(DropForm { cancel: format!("/{}/drops", me.account.username), shell, editing: false, action: "/drops/new".into(), title: String::new(), description: String::new(), secret: false, files: form_files(&[]), kept: vec![], message: String::new(), delegation, error: None, })) } /// `POST /drops/new`. pub async fn create(State(app): State, viewer: Viewer, Form(pairs): Form>) -> Result { let shell = Shell::new(&app, "/drops/new", &viewer); let me = require(&viewer, &shell)?; let f = submitted(pairs); csrf(&viewer, &f.csrf, &shell)?; match drops::create(&app, &me.account, &f.title, &f.description, f.secret, &f.files).await { Ok(row) => Ok(Redirect::to(&drops::url_of(&row)).into_response()), Err(drops::DropError::Bad(msg)) => { let delegation = crate::web::edit::signer_for(&app, me.account.id).await.err(); Ok(Page(DropForm { cancel: format!("/{}/drops", me.account.username), shell, editing: false, action: "/drops/new".into(), title: f.title, description: f.description, secret: f.secret, files: form_files(&f.files), kept: vec![], message: String::new(), delegation, error: Some(msg), }) .into_response()) } Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }), } } /// The drop, when the viewer may change it (its owner) or, `deleting`, /// delete it (its owner or a site administrator). async fn writable( app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str, id: &str, deleting: bool, ) -> Result { let row = lookup(app, shell, viewer, owner, id).await?; let may = if deleting { drops::may_delete(viewer.account(), &row) } else { drops::may_edit(viewer.account(), &row) }; if !may { return Err(Error::not_found(shell.clone())); } Ok(row) } async fn edit_page( app: &Shared, shell: Shell, viewer: &Viewer, row: &db::repos::RepoRow, wanted: Option<&Submitted>, error: Option, ) -> Result, Error> { let me = require(viewer, &shell)?; let (_, current) = drops::files(app, row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?; let kept: Vec = current.iter().filter(|f| f.text.is_none()).map(|f| f.path.clone()).collect(); let files = match wanted { Some(w) => form_files(&w.files), None => form_files( ¤t .iter() .filter_map(|f| f.text.as_ref().map(|t| NewFile { name: f.path.clone(), content: t.clone() })) .collect::>(), ), }; let delegation = crate::web::edit::signer_for(app, me.account.id).await.err(); let url = drops::url_of(row); Ok(Page(DropForm { shell, editing: true, action: format!("{url}/edit"), cancel: url, title: wanted.map(|w| w.title.clone()).unwrap_or_else(|| row.title.clone()), description: wanted.map(|w| w.description.clone()).unwrap_or_else(|| row.description.clone()), secret: wanted.map(|w| w.secret).unwrap_or(row.visibility() != hub_api::Visibility::Public), files, kept, message: wanted.map(|w| w.message.clone()).unwrap_or_default(), delegation, error, })) } /// `GET /{owner}/drops/{id}/edit`. pub async fn edit_form( State(app): State, viewer: Viewer, Path((owner, id)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer); require(&viewer, &shell)?; let row = writable(&app, &shell, &viewer, &owner, &id, false).await?; edit_page(&app, shell, &viewer, &row, None, None).await } /// `POST /{owner}/drops/{id}/edit`: the files as the form has them, as /// one change (only what differs), and the title, description and /// visibility. pub async fn save( State(app): State, viewer: Viewer, Path((owner, id)): Path<(String, String)>, Form(pairs): Form>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/edit"), &viewer); let me = require(&viewer, &shell)?; let f = submitted(pairs); csrf(&viewer, &f.csrf, &shell)?; let row = writable(&app, &shell, &viewer, &owner, &id, false).await?; let wanted = match drops::check_files(&f.files, false) { Ok(w) => w, Err(msg) => return Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()), }; let (_, current) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?; let update = drops::Update { title: f.title.clone(), description: f.description.clone(), secret: f.secret, files: drops::edits_between(¤t, &wanted), message: f.message.clone(), }; match drops::update(&app, &me.account, &row, update).await { Ok(()) => Ok(Redirect::to(&drops::url_of(&row)).into_response()), Err(drops::DropError::Bad(msg)) => Ok(edit_page(&app, shell, &viewer, &row, Some(&f), Some(msg)).await?.into_response()), Err(e) => Err(Error { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, ..Error::bad(shell, e.to_string()) }), } } #[derive(Deserialize)] pub struct DeleteForm { #[serde(rename = "_csrf")] csrf_token: String, } /// `POST /{owner}/drops/{id}/delete`: its owner or a site administrator. pub async fn delete( State(app): State, viewer: Viewer, Path((owner, id)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let row = writable(&app, &shell, &viewer, &owner, &id, true).await?; drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response()) } /// The drop's repository name, `drops/ID`, for the repository pages; a /// name that is not a drop's finds nothing there. fn repo_name(id: &str) -> String { format!("{}/{id}", crate::repos::names::DROPS) } /// `GET /{owner}/drops/{id}/changes/{r}`: the history page. pub async fn changes( State(app): State, viewer: Viewer, Path((owner, id, r)): Path<(String, String, String)>, q: Query, ) -> Result { let row = drop_or_404(&app, &viewer, &owner, &id).await?; super::history::history(State(app), viewer, Path((row.owner, repo_name(&id), r)), q).await.map(IntoResponse::into_response) } /// `GET /{owner}/drops/{id}/change/{hash}`: a change with its diff. pub async fn change( State(app): State, viewer: Viewer, Path((owner, id, hash)): Path<(String, String, String)>, q: Query, ) -> Result { let row = drop_or_404(&app, &viewer, &owner, &id).await?; super::history::change(State(app), viewer, Path((row.owner, repo_name(&id), hash)), q).await.map(IntoResponse::into_response) } /// `GET /{owner}/drops/{id}/raw/{r}/{*path}`: a file's bytes. pub async fn raw( State(app): State, viewer: Viewer, Path((owner, id, r, path)): Path<(String, String, String, String)>, ) -> Result { let row = drop_or_404(&app, &viewer, &owner, &id).await?; super::repo::raw_file(State(app), viewer, Path((row.owner, repo_name(&id), r, path))).await } /// `GET /{owner}/drops/{id}/file/{r}/{*path}`: the drop's page at `r`, /// at that file (where rendered Markdown's relative links go). pub async fn file(Path((owner, id, r, path)): Path<(String, String, String, String)>) -> Response { Redirect::to(&format!("/{owner}/drops/{id}/tree/{r}#{}", anchor_of(&path))).into_response() } async fn drop_or_404(app: &Shared, viewer: &Viewer, owner: &str, id: &str) -> Result { let shell = Shell::new(app, &format!("/{owner}/drops/{id}"), viewer); lookup(app, &shell, viewer, owner, id).await }