| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
use super::settings::csrf; |
| 5 |
use super::{filters, Error, Page, Shell}; |
| 6 |
use crate::auth::{roles, Access, Viewer}; |
| 7 |
use crate::db::{self, projects as pdb}; |
| 8 |
use crate::Shared; |
| 9 |
use askama::Template; |
| 10 |
use axum::extract::{Form, Path, State}; |
| 11 |
use axum::response::{IntoResponse, Redirect, Response}; |
| 12 |
use serde::Deserialize; |
| 13 |
|
| 14 |
|
| 15 |
struct Rights { |
| 16 |
|
| 17 |
member: bool, |
| 18 |
|
| 19 |
edit: bool, |
| 20 |
} |
| 21 |
|
| 22 |
async fn rights(app: &Shared, shell: &Shell, viewer: &Viewer, owner: &str) -> Result<Rights, Error> { |
| 23 |
let e = |e| Error::from_db(shell.clone(), e); |
| 24 |
let admin = viewer.is_admin(); |
| 25 |
if let Some(a) = db::accounts::account_by_name(&app.db, owner).await.map_err(e)? { |
| 26 |
let me = viewer.account().is_some_and(|v| v.id == a.id); |
| 27 |
return Ok(Rights { member: me || admin, edit: me || admin }); |
| 28 |
} |
| 29 |
if let Some(o) = db::accounts::org_by_name(&app.db, owner).await.map_err(e)? { |
| 30 |
let member = match viewer.account() { |
| 31 |
Some(v) => db::accounts::org_role(&app.db, o.id, v.id).await.map_err(e)?.is_some(), |
| 32 |
None => false, |
| 33 |
}; |
| 34 |
return Ok(Rights { member: member || admin, edit: member || admin }); |
| 35 |
} |
| 36 |
Err(Error::not_found(shell.clone())) |
| 37 |
} |
| 38 |
|
| 39 |
#[derive(Template)] |
| 40 |
#[template(path = "projects.html")] |
| 41 |
pub struct ProjectsPage { |
| 42 |
shell: Shell, |
| 43 |
owner: String, |
| 44 |
projects: Vec<pdb::Project>, |
| 45 |
can_edit: bool, |
| 46 |
error: Option<String>, |
| 47 |
} |
| 48 |
|
| 49 |
pub async fn list(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Page<ProjectsPage>, Error> { |
| 50 |
let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
| 51 |
let r = rights(&app, &shell, &viewer, &owner).await?; |
| 52 |
let projects = pdb::of_owner(&app.db, &owner) |
| 53 |
.await |
| 54 |
.map_err(|e| Error::from_db(shell.clone(), e))? |
| 55 |
.into_iter() |
| 56 |
.filter(|p| p.public || r.member) |
| 57 |
.collect(); |
| 58 |
Ok(Page(ProjectsPage { shell, owner, projects, can_edit: r.edit, error: None })) |
| 59 |
} |
| 60 |
|
| 61 |
#[derive(Deserialize)] |
| 62 |
pub struct NewProjectForm { |
| 63 |
#[serde(rename = "_csrf")] |
| 64 |
csrf_token: String, |
| 65 |
title: String, |
| 66 |
#[serde(default)] |
| 67 |
description: String, |
| 68 |
#[serde(default)] |
| 69 |
private: Option<String>, |
| 70 |
} |
| 71 |
|
| 72 |
pub async fn create( |
| 73 |
State(app): State<Shared>, |
| 74 |
viewer: Viewer, |
| 75 |
Path(owner): Path<String>, |
| 76 |
Form(f): Form<NewProjectForm>, |
| 77 |
) -> Result<Response, Error> { |
| 78 |
let shell = Shell::new(&app, &format!("/projects/{owner}"), &viewer); |
| 79 |
csrf(&viewer, &f.csrf_token, &shell)?; |
| 80 |
let r = rights(&app, &shell, &viewer, &owner).await?; |
| 81 |
if !r.edit { |
| 82 |
return Err(Error::not_found(shell)); |
| 83 |
} |
| 84 |
let title = f.title.trim(); |
| 85 |
if title.is_empty() || title.len() > 100 { |
| 86 |
let projects = pdb::of_owner(&app.db, &owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 87 |
return Ok(Page(ProjectsPage { |
| 88 |
shell, |
| 89 |
owner, |
| 90 |
projects, |
| 91 |
can_edit: true, |
| 92 |
error: Some("A project needs a title of up to 100 characters.".into()), |
| 93 |
}) |
| 94 |
.into_response()); |
| 95 |
} |
| 96 |
let p = pdb::create(&app.db, &owner, title, f.description.trim(), f.private.is_none()) |
| 97 |
.await |
| 98 |
.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 99 |
db::accounts::audit(&app.db, viewer.account(), "project.created", &format!("{owner}/{}", p.number), title).await.ok(); |
| 100 |
Ok(Redirect::to(&format!("/projects/{owner}/{}", p.number)).into_response()) |
| 101 |
} |
| 102 |
|
| 103 |
|
| 104 |
pub struct CardView { |
| 105 |
pub card: pdb::Card, |
| 106 |
|
| 107 |
pub reference: String, |
| 108 |
pub url: String, |
| 109 |
pub title: String, |
| 110 |
|
| 111 |
pub state: String, |
| 112 |
} |
| 113 |
|
| 114 |
pub struct ColumnView { |
| 115 |
pub column: pdb::Column, |
| 116 |
pub cards: Vec<CardView>, |
| 117 |
} |
| 118 |
|
| 119 |
#[derive(Template)] |
| 120 |
#[template(path = "project.html")] |
| 121 |
pub struct BoardPage { |
| 122 |
shell: Shell, |
| 123 |
project: pdb::Project, |
| 124 |
columns: Vec<ColumnView>, |
| 125 |
|
| 126 |
repos: Vec<String>, |
| 127 |
|
| 128 |
can_edit: bool, |
| 129 |
|
| 130 |
can_manage: bool, |
| 131 |
notice: Option<String>, |
| 132 |
error: Option<String>, |
| 133 |
} |
| 134 |
|
| 135 |
async fn board( |
| 136 |
app: &Shared, |
| 137 |
shell: Shell, |
| 138 |
viewer: &Viewer, |
| 139 |
owner: &str, |
| 140 |
number: i64, |
| 141 |
notice: Option<String>, |
| 142 |
error: Option<String>, |
| 143 |
) -> Result<Page<BoardPage>, Error> { |
| 144 |
let e = |e| Error::from_db(shell.clone(), e); |
| 145 |
let r = rights(app, &shell, viewer, owner).await?; |
| 146 |
let project = pdb::get(&app.db, owner, number) |
| 147 |
.await |
| 148 |
.map_err(e)? |
| 149 |
.filter(|p| p.public || r.member) |
| 150 |
.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 151 |
let mut readable: std::collections::HashMap<i64, db::repos::RepoRow> = Default::default(); |
| 152 |
for row in db::repos::list_by_owner(&app.db, owner).await.map_err(e)? { |
| 153 |
let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?; |
| 154 |
if roles::allows(role, Access::Read) { |
| 155 |
readable.insert(row.id, row); |
| 156 |
} |
| 157 |
} |
| 158 |
let cards = pdb::cards(&app.db, project.id).await.map_err(e)?; |
| 159 |
let mut columns = Vec::new(); |
| 160 |
for column in pdb::columns(&app.db, project.id).await.map_err(e)? { |
| 161 |
let mut views = Vec::new(); |
| 162 |
for card in cards.iter().filter(|c| c.column_id == column.id) { |
| 163 |
match (card.kind.as_str(), card.repo_id, card.number) { |
| 164 |
("note", _, _) => views.push(CardView { |
| 165 |
card: card.clone(), |
| 166 |
reference: String::new(), |
| 167 |
url: String::new(), |
| 168 |
title: card.note.clone(), |
| 169 |
state: String::new(), |
| 170 |
}), |
| 171 |
(kind, Some(rid), Some(n)) => { |
| 172 |
|
| 173 |
let Some(row) = readable.get(&rid) else { continue }; |
| 174 |
let (title, state) = if kind == "issue" { |
| 175 |
match db::issues::get(&app.db, rid, n).await.map_err(e)? { |
| 176 |
Some(i) => (i.title, i.state), |
| 177 |
None => continue, |
| 178 |
} |
| 179 |
} else { |
| 180 |
match db::proposals::get(&app.db, rid, n).await.map_err(e)? { |
| 181 |
Some(p) => (p.title, p.state), |
| 182 |
None => continue, |
| 183 |
} |
| 184 |
}; |
| 185 |
let slug = format!("{}/{}", row.owner, row.name); |
| 186 |
views.push(CardView { |
| 187 |
card: card.clone(), |
| 188 |
reference: format!("{slug}#{n}"), |
| 189 |
url: format!("/{slug}/{kind}/{n}"), |
| 190 |
title, |
| 191 |
state, |
| 192 |
}); |
| 193 |
} |
| 194 |
_ => {} |
| 195 |
} |
| 196 |
} |
| 197 |
columns.push(ColumnView { column, cards: views }); |
| 198 |
} |
| 199 |
let mut repos: Vec<String> = readable.values().map(|r| r.name.clone()).collect(); |
| 200 |
repos.sort(); |
| 201 |
let can_edit = r.edit && !project.closed; |
| 202 |
Ok(Page(BoardPage { shell, project, columns, repos, can_edit, can_manage: r.edit, notice, error })) |
| 203 |
} |
| 204 |
|
| 205 |
pub async fn show(State(app): State<Shared>, viewer: Viewer, Path((owner, number)): Path<(String, i64)>) -> Result<Page<BoardPage>, Error> { |
| 206 |
let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); |
| 207 |
board(&app, shell, &viewer, &owner, number, None, None).await |
| 208 |
} |
| 209 |
|
| 210 |
#[derive(Deserialize)] |
| 211 |
pub struct BoardForm { |
| 212 |
#[serde(rename = "_csrf")] |
| 213 |
csrf_token: String, |
| 214 |
action: String, |
| 215 |
#[serde(default)] |
| 216 |
card: Option<i64>, |
| 217 |
#[serde(default)] |
| 218 |
column: Option<i64>, |
| 219 |
#[serde(default)] |
| 220 |
name: String, |
| 221 |
|
| 222 |
#[serde(default)] |
| 223 |
reference: String, |
| 224 |
#[serde(default)] |
| 225 |
repo: String, |
| 226 |
#[serde(default)] |
| 227 |
note: String, |
| 228 |
#[serde(default)] |
| 229 |
title: String, |
| 230 |
#[serde(default)] |
| 231 |
description: String, |
| 232 |
#[serde(default)] |
| 233 |
private: Option<String>, |
| 234 |
#[serde(default)] |
| 235 |
closed: Option<String>, |
| 236 |
} |
| 237 |
|
| 238 |
|
| 239 |
fn parse_reference(reference: &str, repo: &str) -> Option<(String, i64)> { |
| 240 |
let r = reference.trim(); |
| 241 |
let (name, n) = r.split_once('#')?; |
| 242 |
let name = if name.is_empty() { repo.trim() } else { name.trim() }; |
| 243 |
let n: i64 = n.trim().parse().ok()?; |
| 244 |
(!name.is_empty() && n > 0 && !name.contains('/')).then(|| (name.to_string(), n)) |
| 245 |
} |
| 246 |
|
| 247 |
pub async fn act( |
| 248 |
State(app): State<Shared>, |
| 249 |
viewer: Viewer, |
| 250 |
Path((owner, number)): Path<(String, i64)>, |
| 251 |
Form(f): Form<BoardForm>, |
| 252 |
) -> Result<Response, Error> { |
| 253 |
let shell = Shell::new(&app, &format!("/projects/{owner}/{number}"), &viewer); |
| 254 |
csrf(&viewer, &f.csrf_token, &shell)?; |
| 255 |
let r = rights(&app, &shell, &viewer, &owner).await?; |
| 256 |
let e = |e| Error::from_db(shell.clone(), e); |
| 257 |
let project = pdb::get(&app.db, &owner, number) |
| 258 |
.await |
| 259 |
.map_err(e)? |
| 260 |
.filter(|p| p.public || r.member) |
| 261 |
.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 262 |
if !r.edit { |
| 263 |
return Err(Error::not_found(shell)); |
| 264 |
} |
| 265 |
if project.closed && !matches!(f.action.as_str(), "settings" | "delete") { |
| 266 |
return Ok(board(&app, shell, &viewer, &owner, number, None, Some("This project is closed; reopen it to change it.".into())) |
| 267 |
.await? |
| 268 |
.into_response()); |
| 269 |
} |
| 270 |
let actor = viewer.account().map(|a| a.username.clone()).unwrap_or_default(); |
| 271 |
let columns = pdb::columns(&app.db, project.id).await.map_err(e)?; |
| 272 |
let column = f.column.filter(|c| columns.iter().any(|x| x.id == *c)).or(columns.first().map(|c| c.id)); |
| 273 |
let mut notice = None; |
| 274 |
let mut error = None; |
| 275 |
match f.action.as_str() { |
| 276 |
"add" => { |
| 277 |
let Some(column_id) = column else { return Err(Error::bad(shell, "no column")) }; |
| 278 |
if !f.note.trim().is_empty() { |
| 279 |
let note: String = f.note.trim().chars().take(500).collect(); |
| 280 |
pdb::add_card( |
| 281 |
&app.db, |
| 282 |
project.id, |
| 283 |
pdb::NewCard { column_id, kind: "note", repo_id: None, number: None, note: ¬e, added_by: &actor }, |
| 284 |
) |
| 285 |
.await |
| 286 |
.map_err(e)?; |
| 287 |
} else { |
| 288 |
match parse_reference(&f.reference, &f.repo) { |
| 289 |
None => error = Some("Name an issue or proposal as repo#12, or write a note.".to_string()), |
| 290 |
Some((repo, n)) => match db::repos::get(&app.db, &owner, &repo).await.map_err(e)? { |
| 291 |
None => error = Some(format!("{owner} has no repository {repo}.")), |
| 292 |
Some(row) => { |
| 293 |
let role = roles::role_for(&app.db, viewer.account(), &row).await.map_err(e)?; |
| 294 |
let (issues, proposals) = db::issues::numbers(&app.db, row.id).await.map_err(e)?; |
| 295 |
let kind = if issues.contains(&n) { |
| 296 |
Some("issue") |
| 297 |
} else if proposals.contains(&n) { |
| 298 |
Some("proposal") |
| 299 |
} else { |
| 300 |
None |
| 301 |
}; |
| 302 |
match (roles::allows(role, Access::Read), kind) { |
| 303 |
(true, Some(kind)) => { |
| 304 |
let added = pdb::add_card( |
| 305 |
&app.db, |
| 306 |
project.id, |
| 307 |
pdb::NewCard { |
| 308 |
column_id, |
| 309 |
kind, |
| 310 |
repo_id: Some(row.id), |
| 311 |
number: Some(n), |
| 312 |
note: "", |
| 313 |
added_by: &actor, |
| 314 |
}, |
| 315 |
) |
| 316 |
.await |
| 317 |
.map_err(e)?; |
| 318 |
if !added { |
| 319 |
error = Some(format!("{repo}#{n} is already on this project.")); |
| 320 |
} |
| 321 |
} |
| 322 |
_ => error = Some(format!("{repo} has no issue or proposal #{n}.")), |
| 323 |
} |
| 324 |
} |
| 325 |
}, |
| 326 |
} |
| 327 |
} |
| 328 |
} |
| 329 |
"move" => { |
| 330 |
if let (Some(card), Some(col)) = (f.card, f.column) { |
| 331 |
pdb::move_card(&app.db, project.id, card, col).await.map_err(e)?; |
| 332 |
} |
| 333 |
} |
| 334 |
"up" | "down" => { |
| 335 |
if let Some(card) = f.card { |
| 336 |
pdb::shift_card(&app.db, project.id, card, if f.action == "up" { -1 } else { 1 }).await.map_err(e)?; |
| 337 |
} |
| 338 |
} |
| 339 |
"remove" => { |
| 340 |
if let Some(card) = f.card { |
| 341 |
pdb::remove_card(&app.db, project.id, card).await.map_err(e)?; |
| 342 |
} |
| 343 |
} |
| 344 |
"add_column" => { |
| 345 |
let name = f.name.trim(); |
| 346 |
if name.is_empty() || name.len() > 40 { |
| 347 |
error = Some("A column has a name of up to 40 characters.".into()); |
| 348 |
} else { |
| 349 |
pdb::add_column(&app.db, project.id, name).await.map_err(e)?; |
| 350 |
} |
| 351 |
} |
| 352 |
"rename_column" => { |
| 353 |
if let (Some(col), name) = (f.column, f.name.trim()) { |
| 354 |
if !name.is_empty() && name.len() <= 40 { |
| 355 |
pdb::rename_column(&app.db, project.id, col, name).await.map_err(e)?; |
| 356 |
} |
| 357 |
} |
| 358 |
} |
| 359 |
"left" | "right" => { |
| 360 |
if let Some(col) = f.column { |
| 361 |
pdb::shift_column(&app.db, project.id, col, if f.action == "left" { -1 } else { 1 }).await.map_err(e)?; |
| 362 |
} |
| 363 |
} |
| 364 |
"remove_column" => { |
| 365 |
if let Some(col) = f.column { |
| 366 |
if !pdb::remove_column(&app.db, project.id, col).await.map_err(e)? { |
| 367 |
error = Some("A project keeps at least one column.".into()); |
| 368 |
} |
| 369 |
} |
| 370 |
} |
| 371 |
"settings" => { |
| 372 |
let title = if f.title.trim().is_empty() { project.title.clone() } else { f.title.trim().chars().take(100).collect() }; |
| 373 |
pdb::set(&app.db, project.id, &title, f.description.trim(), f.private.is_none(), f.closed.is_some()).await.map_err(e)?; |
| 374 |
notice = Some("Saved.".into()); |
| 375 |
} |
| 376 |
"delete" => { |
| 377 |
pdb::delete(&app.db, project.id).await.map_err(e)?; |
| 378 |
db::accounts::audit(&app.db, viewer.account(), "project.deleted", &format!("{owner}/{number}"), &project.title).await.ok(); |
| 379 |
return Ok(Redirect::to(&format!("/projects/{owner}")).into_response()); |
| 380 |
} |
| 381 |
_ => return Err(Error::bad(shell, "unknown action")), |
| 382 |
} |
| 383 |
if notice.is_none() && error.is_none() { |
| 384 |
|
| 385 |
return Ok(Redirect::to(&format!("/projects/{owner}/{number}")).into_response()); |
| 386 |
} |
| 387 |
Ok(board(&app, shell, &viewer, &owner, number, notice, error).await?.into_response()) |
| 388 |
} |
| 389 |
|
| 390 |
|
| 391 |
|
| 392 |
pub async fn placements_for(app: &Shared, viewer: &Viewer, repo: &db::repos::RepoRow, number: i64) -> Vec<(pdb::Project, String)> { |
| 393 |
let Ok(all) = pdb::placements(&app.db, repo.id, number).await else { return vec![] }; |
| 394 |
let mut out = Vec::new(); |
| 395 |
for (p, col) in all { |
| 396 |
let visible = p.public |
| 397 |
|| viewer.is_admin() |
| 398 |
|| match viewer.account() { |
| 399 |
Some(v) if v.username == p.owner => true, |
| 400 |
Some(v) => match db::accounts::org_by_name(&app.db, &p.owner).await.ok().flatten() { |
| 401 |
Some(o) => db::accounts::org_role(&app.db, o.id, v.id).await.ok().flatten().is_some(), |
| 402 |
None => false, |
| 403 |
}, |
| 404 |
None => false, |
| 405 |
}; |
| 406 |
if visible { |
| 407 |
out.push((p, col)); |
| 408 |
} |
| 409 |
} |
| 410 |
out |
| 411 |
} |
| 412 |
|
| 413 |
#[cfg(test)] |
| 414 |
mod tests { |
| 415 |
use super::parse_reference; |
| 416 |
|
| 417 |
#[test] |
| 418 |
fn references() { |
| 419 |
assert_eq!(parse_reference("oxygen#12", ""), Some(("oxygen".into(), 12))); |
| 420 |
assert_eq!(parse_reference("#7", "hub"), Some(("hub".into(), 7))); |
| 421 |
assert_eq!(parse_reference(" hub # 3 ", ""), Some(("hub".into(), 3))); |
| 422 |
assert_eq!(parse_reference("#7", ""), None, "a repository is needed"); |
| 423 |
assert_eq!(parse_reference("other/repo#1", ""), None, "only the owner's repositories"); |
| 424 |
assert_eq!(parse_reference("hub#0", ""), None); |
| 425 |
assert_eq!(parse_reference("hub", ""), None); |
| 426 |
} |
| 427 |
} |