//! Projects (hub spec §8.2): boards of issues, proposals and notes across //! the repositories of one person or organisation. use super::settings::csrf; use super::{filters, Error, Page, Shell}; use crate::auth::{roles, Access, Viewer}; use crate::db::{self, projects as pdb}; use crate::Shared; use askama::Template; use axum::extract::{Form, Path, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use serde::Deserialize; /// What a caller may do with an owner's projects. pub struct Rights { /// Sees private projects. pub member: bool, /// Creates and changes projects. pub edit: bool, } /// Rights of `account` (anonymous when `None`) over `owner`'s projects; /// `None` when there is no such person or organisation. pub async fn rights_of(app: &Shared, account: Option<&db::accounts::Account>, owner: &str) -> sqlx::Result> { let admin = account.is_some_and(|a| a.is_admin); if let Some(a) = db::accounts::account_by_name(&app.db, owner).await? { let me = account.is_some_and(|v| v.id == a.id); return Ok(Some(Rights { member: me || admin, edit: me || admin })); } if let Some(o) = db::accounts::org_by_name(&app.db, owner).await? { let member = match account { Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await?.is_some(), None => false, }; return Ok(Some(Rights { member: member || admin, edit: member || admin })); } Ok(None) } async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result { rights_of(app, viewer.account(), owner) .await .map_err(|e| Error::from_db(shell.clone(), e))? .ok_or_else(|| Error::not_found(shell.clone())) } #[derive(Template)] #[template(path = "projects.html")] pub struct ProjectsPage { shell: Shell, owner: String, /// The projects shown: open or closed as `closed` says, matching `q`. projects: Vec, can_edit: bool, error: Option, /// On your own page, the organisations you belong to: their boards /// are one click away, and where a board for one is made (#119). orgs: Vec, /// The title filter, and whether the closed ones are shown (#124). q: String, closed: bool, open_count: usize, closed_count: usize, } impl ProjectsPage { /// The page for `all` the projects the viewer may see: counted open /// and closed among those matching `q`, then the ones asked for. #[allow(clippy::too_many_arguments)] fn build( shell: Shell, owner: String, all: Vec, can_edit: bool, error: Option, orgs: Vec, q: Option, closed: bool, ) -> ProjectsPage { let q = q.unwrap_or_default().trim().to_string(); let needle = q.to_lowercase(); let matching: Vec = all .into_iter() .filter(|p| needle.is_empty() || p.title.to_lowercase().contains(&needle) || p.description.to_lowercase().contains(&needle)) .collect(); let closed_count = matching.iter().filter(|p| p.closed).count(); let open_count = matching.len() - closed_count; let projects = matching.into_iter().filter(|p| p.closed == closed).collect(); ProjectsPage { shell, owner, projects, can_edit, error, orgs, q, closed, open_count, closed_count } } } /// `?q=TEXT&state=open|closed` on the Projects page (#124). #[derive(Deserialize, Default)] pub struct ProjectsQuery { #[serde(default)] q: Option, #[serde(default)] state: Option, } /// The organisations the viewer belongs to, when `owner` is the viewer. async fn own_orgs(app: &Shared, viewer: &Viewer, owner: &str) -> Vec { match viewer.account() { Some(a) if a.username == owner => { crate::db::accounts::orgs_of(&app.db, a.id).await.map(|v| v.into_iter().map(|(o, _)| o.name).collect()).unwrap_or_default() } _ => vec![], } } pub async fn list( State(app): State, viewer: Viewer, Path(owner): Path, Query(query): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); let r = rights(&app, &shell, &viewer, &owner).await?; let projects = pdb::of_owner(&app.db, &owner) .await .map_err(|e| Error::from_db(shell.clone(), e))? .into_iter() .filter(|p| p.public || r.member) .collect(); let orgs = own_orgs(&app, &viewer, &owner).await; let closed = query.state.as_deref() == Some("closed"); Ok(Page(ProjectsPage::build(shell, owner, projects, r.edit, None, orgs, query.q, closed))) } #[derive(Deserialize)] pub struct NewProjectForm { #[serde(rename = "_csrf")] csrf_token: String, title: String, #[serde(default)] description: String, #[serde(default)] private: Option, } pub async fn create( State(app): State, viewer: Viewer, Path(owner): Path, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let r = rights(&app, &shell, &viewer, &owner).await?; if !r.edit { return Err(Error::not_found(shell)); } let title = f.title.trim(); if title.is_empty() || title.len() > 100 { let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; let orgs = own_orgs(&app, &viewer, &owner).await; return Ok(Page(ProjectsPage::build( shell, owner, projects, true, Some("A project needs a title of up to 100 characters.".into()), orgs, None, false, )) .into_response()); } let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none()) .await .map_err(|e| Error::from_db(shell.clone(), e))?; db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response()) } /// A card as the board shows it. pub struct CardView { pub card: pdb::Card, /// `owner/name#N`, for issue and proposal cards. pub reference: String, pub url: String, pub title: String, /// `open`, `closed`, `landed`, `draft`… for issues and proposals. pub state: String, } pub struct ColumnView { pub column: pdb::Column, pub cards: Vec, } #[derive(Template)] #[template(path = "project.html")] pub struct BoardPage { shell: Shell, project: pdb::Project, columns: Vec, /// The owner's repositories the viewer can read, for adding cards. repos: Vec, /// Changes cards and columns (an open project). can_edit: bool, /// Changes the settings (to reopen a closed project too). can_manage: bool, notice: Option, error: Option, /// Owners the viewer may move the project to (#125): themselves and the /// organisations they belong to, but not its owner now. transfer_to: Vec, } async fn board( app: &Shared, shell: Shell, viewer: &Viewer, owner: &str, number: i64, notice: Option, error: Option, ) -> Result, Error> { let e = |e| Error::from_db(shell.clone(), e); let r = rights(app, &shell, viewer, owner).await?; let project = pdb::get(&app.db, owner, number) .await .map_err(e)? .filter(|p| p.public || r.member) .ok_or_else(|| Error::not_found(shell.clone()))?; let (columns, repos) = board_columns(app, viewer.account(), owner, &project).await.map_err(e)?; let can_edit = r.edit && !project.closed; let mut transfer_to = Vec::new(); if let (true, Some(a)) = (r.edit, viewer.account()) { transfer_to.push(a.username.clone()); let orgs = db::accounts::orgs_of(&app.db, a.id).await.map_err(e)?; transfer_to.extend(orgs.into_iter().map(|(o, _)| o.name)); transfer_to.retain(|o| o != owner); } Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error, transfer_to })) } /// A project's columns with the cards `account` may see, and the owner's /// repositories it can read (for adding cards). pub async fn board_columns( app: &Shared, account: Option<&db::accounts::Account>, owner: &str, project: &pdb::Project, ) -> sqlx::Result<(Vec, Vec)> { let mut readable: std::collections::HashMap = Default::default(); for row in db::repos::list_by_owner(&app.db, owner).await? { let role = roles::role_for(&app.db, account, &row).await?; if roles::allows(role, Access::Read) { readable.insert(row.id, row); } } let cards = pdb::cards(&app.db, project.id).await?; let mut columns = Vec::new(); for column in pdb::columns(&app.db, project.id).await? { let mut views = Vec::new(); for card in cards.iter().filter(|c| c.column_id == column.id) { match (card.kind.as_str(), card.repo_id, card.number) { ("note", _, _) => views.push(CardView { card: card.clone(), reference: String::new(), url: String::new(), title: card.note.clone(), state: String::new(), }), (kind, Some(rid), Some(n)) => { // Cards of repositories the viewer cannot read are not shown at all. let Some(row) = readable.get(&rid) else { continue }; let (title, state) = if kind == "issue" { match db::issues::get(&app.db, rid, n).await? { Some(i) => (i.title, i.state), None => continue, } } else { match db::proposals::get(&app.db, rid, n).await? { Some(p) => (p.title, p.state), None => continue, } }; let slug = format!("{}/{}", row.owner, row.name); views.push(CardView { card: card.clone(), reference: format!("{slug}#{n}"), url: format!("/{slug}/{kind}/{n}"), title, state, }); } _ => {} } } columns.push(ColumnView { column, cards: views }); } let mut repos: Vec = readable.values().map(|r| r.name.clone()).collect(); repos.sort(); Ok((columns, repos)) } pub async fn show(State(app): State, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); // A board transferred away (#125): its old address goes to its new one. let e = |e| Error::from_db(shell.clone(), e); if pdb::get(&app.db, &owner, number).await.map_err(e)?.is_none() { if let Some(p) = pdb::redirected(&app.db, &owner, number).await.map_err(e)? { return Ok(Redirect::permanent(&format!("/projects/{}/{}", p.owner, p.number)).into_response()); } } Ok(board(&app, shell, &viewer, &owner, number, None, None).await?.into_response()) } #[derive(Deserialize)] pub struct BoardForm { #[serde(rename = "_csrf")] csrf_token: String, action: String, #[serde(default)] card: Option, #[serde(default)] column: Option, #[serde(default)] name: String, /// `repo#12`, or `#12` with `repo`. #[serde(default)] reference: String, #[serde(default)] repo: String, #[serde(default)] note: String, #[serde(default)] title: String, #[serde(default)] description: String, #[serde(default)] private: Option, #[serde(default)] closed: Option, /// The owner to transfer the project to (#125). #[serde(default)] to: String, } /// `repo#12` or `#12` (with a repository chosen) to (repository, number). fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> { let r = reference.trim(); let (name, n) = r.split_once('#')?; let name = if name.is_empty() { repo.trim() } else { name.trim() }; let n: i64 = n.trim().parse().ok()?; (!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n)) } /// Add a note, or the issue or proposal `reference` names (`repo#12`, or /// `#12` with `repo`), to a column. `Ok(Err(why))` for what the caller /// should be told: an unknown repository or number, or a card already there. #[allow(clippy::too_many_arguments)] pub async fn add_card( app: &Shared, account: Option<&db::accounts::Account>, owner: &str, project_id: i64, column_id: i64, reference: &str, repo: &str, note: &str, actor: &str, ) -> sqlx::Result> { if !note.trim().is_empty() { let note: String = note.trim().chars().take(500).collect(); pdb::add_card( &app.db, project_id, pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: ¬e, added_by: actor }, ) .await?; return Ok(Ok(newest_card(app, project_id).await?)); } let Some((repo, n)) = parse_reference(reference, repo) else { return Ok(Err("Name an issue or proposal as repo#12, or write a note.".into())); }; let Some(row) = db::repos::get(&app.db, owner, &repo).await? else { return Ok(Err(format!("{owner} has no repository {repo}."))) }; let role = roles::role_for(&app.db, account, &row).await?; let (issues, proposals) = db::issues::numbers(&app.db, row.id).await?; let kind = if issues.contains(&n) { "issue" } else if proposals.contains(&n) { "proposal" } else { return Ok(Err(format!("{repo} has no issue or proposal #{n}."))); }; if !roles::allows(role, Access::Read) { return Ok(Err(format!("{repo} has no issue or proposal #{n}."))); } if !pdb::add_card( &app.db, project_id, pdb::NewCard { column_id, kind, repo_id: Some(row.id), number: Some(n), note: "", added_by: actor }, ) .await? { return Ok(Err(format!("{repo}#{n} is already on this project."))); } Ok(Ok(newest_card(app, project_id).await?)) } async fn newest_card(app: &Shared, project_id: i64) -> sqlx::Result { sqlx::query_as("SELECT * FROM project_cards WHERE project_id = ? ORDER BY id DESC LIMIT 1").bind(project_id).fetch_one(&app.db).await } pub async fn act( State(app): State, viewer: Viewer, Path((owner, number)): Path<(String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let r = rights(&app, &shell, &viewer, &owner).await?; let e = |e| Error::from_db(shell.clone(), e); let project = pdb::get(&app.db, &owner, number) .await .map_err(e)? .filter(|p| p.public || r.member) .ok_or_else(|| Error::not_found(shell.clone()))?; if !r.edit { return Err(Error::not_found(shell)); } if project.closed && !matches!(f.action.as_str(), "settings" | "delete" | "transfer") { return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into())) .await? .into_response()); } let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id)); let mut notice = None; let mut error = None; match f.action.as_str() { "add" => { let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) }; if let Err(msg) = add_card(&app, viewer.account(), &owner, project.id, column_id, &f.reference, &f.repo, &f.note, &actor).await.map_err(e)? { error = Some(msg); } } "move" => { if let (Some(card), Some(col)) = (f.card, f.column) { pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?; } } "up" | "down" => { if let Some(card) = f.card { pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?; } } "remove" => { if let Some(card) = f.card { pdb::remove_card(&app.db, project.id, card).await.map_err(e)?; } } "add_column" => { let name = f.name.trim(); if name.is_empty() || name.len() > 40 { error = Some("A column has a name of up to 40 characters.".into()); } else { pdb::add_column(&app.db, project.id, name).await.map_err(e)?; } } "rename_column" => { if let (Some(col), name) = (f.column, f.name.trim()) { if !name.is_empty() && name.len() <= 40 { pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?; } } } "left" | "right" => { if let Some(col) = f.column { pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?; } } "remove_column" => { if let Some(col) = f.column { if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? { error = Some("A project keeps at least one column.".into()); } } } "settings" => { let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() }; pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?; notice = Some("Saved.".into()); } "transfer" => { let to = f.to.trim(); match rights_of(&app, viewer.account(), to).await.map_err(e)? { _ if to == owner => error = Some(format!("It is {owner}'s already.")), Some(t) if t.edit => { let moved = pdb::transfer(&app.db, project.id, to).await.map_err(e)?; let detail = format!("{owner}/{number} to {to}/{}", moved.number); db::accounts::audit(&app.db, viewer.account(), "project.transferred", &detail, &project.title).await.ok(); return Ok(Redirect::to(&format!("/projects/{to}/{}", moved.number)).into_response()); } _ => error = Some(format!("You cannot make projects under {to}, so you cannot move this one there.")), } } "delete" => { pdb::delete(&app.db, project.id).await.map_err(e)?; db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok(); return Ok(Redirect::to(&format!("/projects/{owner}")).into_response()); } _ => return Err(Error::bad(shell, "unknown action")), } if notice.is_none() && error.is_none() { // Back to the board, so a reload does not repeat the action. return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response()); } Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response()) } /// The projects an issue or proposal is on that the viewer may see, with /// their column. pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> { let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] }; let mut out = Vec::new(); for (p, col) in all { let visible = p.public || viewer.is_admin() || match viewer.account() { Some(v) if v.username == p.owner => true, Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() { Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(), None => false, }, None => false, }; if visible { out.push((p, col)); } } out } #[cfg(test)] mod tests { use super::parse_reference; #[test] fn references() { assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12))); assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7))); assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3))); assert_eq!(parse_reference("#7", ""), None, "a repository is needed"); assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories"); assert_eq!(parse_reference("hub#0", ""), None); assert_eq!(parse_reference("hub", ""), None); } } #[derive(Deserialize)] pub struct PlaceForm { #[serde(rename = "_csrf")] csrf_token: String, /// The project's number among its owner's. project: i64, /// `add` or `remove`. action: String, } /// Put an issue or proposal on one of its repository owner's projects (in /// the first column), or take it off, from its own page. async fn place( app: Shared, viewer: Viewer, owner: String, name: String, kind: &'static str, number: i64, f: PlaceForm, ) -> Result { let page = format!("/{owner}/{name}/{kind}/{number}"); let shell = Shell::new(&app, &page, &viewer); csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, _) = super::lookup(&app, &shell, &viewer, &owner, &name).await?; let r = rights(&app, &shell, &viewer, &row.owner).await?; if !r.edit { return Err(Error::not_found(shell)); } let e = |e| Error::from_db(shell.clone(), e); let project = pdb::get(&app.db, &row.owner, f.project).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?; let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); let mut error = None; match f.action.as_str() { "add" => { let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; let Some(first) = columns.first() else { return Err(Error::bad(shell, "that project has no columns")) }; if let Err(msg) = add_card(&app, viewer.account(), &row.owner, project.id, first.id, &format!("{}#{number}", row.name), &row.name, "", &actor) .await .map_err(e)? { error = Some(msg); } } "remove" => { for c in pdb::cards(&app.db, project.id).await.map_err(e)? { if c.repo_id == Some(row.id) && c.number == Some(number) { pdb::remove_card(&app.db, project.id, c.id).await.map_err(e)?; } } } _ => return Err(Error::bad(shell, "add or remove")), } let to = match error { Some(m) => format!("{page}?error={}", super::settings::urlencode(&m)), None => page, }; Ok(Redirect::to(&to).into_response()) } /// `POST /{owner}/{name}/proposal/{number}/project`. pub async fn place_proposal( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { place(app, viewer, owner, name, "proposal", number, f).await } /// `POST /{owner}/{name}/issue/{number}/project`. pub async fn place_issue( State(app): State, viewer: Viewer, Path((owner, name, number)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { place(app, viewer, owner, name, "issue", number, f).await } /// The owner's open projects `viewer` may add an item to that it is not on /// yet; `None` when the viewer may not change the owner's projects at all. pub async fn project_choices( app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, placed: &[(pdb::Project, String)], ) -> Option> { let Ok(Some(r)) = rights_of(app, viewer.account(), &repo.owner).await else { return None }; if !r.edit { return None; } Some( pdb::of_owner(&app.db, &repo.owner) .await .unwrap_or_default() .into_iter() .filter(|p| !p.closed && !placed.iter().any(|(q, _)| q.id == p.id)) .collect(), ) }