| 257 | } | 257 | } |
| 258 | div class="issue-main" { | 258 | div class="issue-main" { |
| 259 | article class="issue-thread" { | 259 | article class="issue-thread" { |
| 260 | (comment_card(&author, &issue.body, issue.created_at, None)) | 260 | @let body_action = format!("/issue/{}/edit-body", issue.id); |
| | 261 | (comment_card(&author, &issue.body, issue.created_at, |
| | 262 | (can_write || is_author).then_some((body_action.as_str(), csrf.as_str())))) |
| 261 | @for (c, cauthor) in &comment_rows { | 263 | @for (c, cauthor) in &comment_rows { |
| 262 | @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); | 264 | @let editable = can_write || viewer_id.as_deref() == Some(c.author_id.as_str()); |
| | 265 | @let action = format!("/comment/{}/edit", c.id); |
| 263 | (comment_card(cauthor, &c.body, c.created_at, | 266 | (comment_card(cauthor, &c.body, c.created_at, |
| 264 | editable.then_some((c.id.as_str(), csrf.as_str())))) | 267 | editable.then_some((action.as_str(), csrf.as_str())))) |
| 265 | } | 268 | } |
| 266 | @if issue.locked() { (locked_note(can_write)) } | 269 | @if issue.locked() { (locked_note(can_write)) } |
| 267 | @if can_write || (is_author && !issue.locked()) { | 270 | @if can_write || (is_author && !issue.locked()) { |
| 299 | | 302 | |
| 300 | | 303 | |
| 301 | | 304 | |
| 302 | | 305 | |
| | 306 | |
| | 307 | |
| 303 | pub(crate) fn comment_card( | 308 | pub(crate) fn comment_card( |
| 304 | author: &str, | 309 | author: &str, |
| 305 | body: &str, | 310 | body: &str, |
| 311 | div class="comment-head muted" { | 316 | div class="comment-head muted" { |
| 312 | strong { (author) } | 317 | strong { (author) } |
| 313 | span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) } | 318 | span { " commented " (crate::activity::fmt_relative(created_at, crate::now_ms())) } |
| 314 | @if let Some((id, csrf)) = edit { | 319 | @if let Some((action, csrf)) = edit { |
| 315 | details class="comment-edit" { | 320 | details class="comment-edit" { |
| 316 | summary { "Edit" } | 321 | summary { "Edit" } |
| 317 | form method="post" action=(format!("/comment/{id}/edit")) { | 322 | form method="post" action=(action) { |
| 318 | input type="hidden" name="_csrf" value=(csrf); | 323 | input type="hidden" name="_csrf" value=(csrf); |
| 319 | textarea name="body" rows="4" required { (body) } | 324 | textarea name="body" rows="4" required { (body) } |
| 320 | button class="btn inline-btn" type="submit" { "Save" } | 325 | button class="btn inline-btn" type="submit" { "Save" } |
| 411 | } | 416 | } |
| 412 | } | 417 | } |
| 413 | | 418 | |
| 414 | | 419 | |
| | 420 | |
| 415 | pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup { | 421 | pub(crate) fn edit_issue_form(issue: &Issue, kind: Kind, csrf: &str) -> Markup { |
| 416 | html! { | 422 | html! { |
| 417 | details class="issue-edit" { | 423 | details class="issue-edit" { |
| 418 | summary class="btn" { "Edit" } | 424 | summary class="btn" { "Edit" } |
| 419 | div class="card" { | 425 | div class="card" { |
| 420 | form method="post" action=(format!("/issue/{}/edit", issue.id)) { | 426 | form method="post" action=(format!("/issue/{}/edit-title", issue.id)) { |
| 421 | input type="hidden" name="_csrf" value=(csrf); | 427 | input type="hidden" name="_csrf" value=(csrf); |
| 422 | label for="edit-title" { "Title" } | 428 | label for="edit-title" { "Title" } |
| 423 | input type="text" id="edit-title" name="title" value=(issue.title) required; | 429 | input type="text" id="edit-title" name="title" value=(issue.title) required; |
| 424 | label for="edit-body" { "Description" } | 430 | button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) " title" } |
| 425 | textarea id="edit-body" name="body" rows="6" { (issue.body) } | | |
| 426 | button class="btn btn-primary inline-btn" type="submit" { "Save " (kind.noun) } | | |
| 427 | } | 431 | } |
| 428 | } | 432 | } |
| 429 | } | 433 | } |
| 547 | button class="btn inline-btn" type="submit" { "Add" } | 551 | button class="btn inline-btn" type="submit" { "Add" } |
| 548 | } | 552 | } |
| 549 | } | 553 | } |
| 550 | @if !dependents.is_empty() { | 554 | } |
| | 555 | @if !dependents.is_empty() { |
| | 556 | section { |
| 551 | h3 { "Blocks" } | 557 | h3 { "Blocks" } |
| 552 | ul class="dep-list" { @for d in dependents { (row(d, false)) } } | 558 | ul class="dep-list" { @for d in dependents { (row(d, false)) } } |
| 553 | } | 559 | } |
| 692 | respond_redirect(result) | 698 | respond_redirect(result) |
| 693 | } | 699 | } |
| 694 | | 700 | |
| 695 | | 701 | |
| 696 | #[derive(Debug, Deserialize)] | 702 | #[derive(Debug, Deserialize)] |
| 697 | pub struct EditIssueForm { | 703 | pub struct EditTitleForm { |
| 698 | | 704 | |
| 699 | #[serde(default)] | 705 | #[serde(default)] |
| 700 | title: String, | 706 | title: String, |
| 701 | | | |
| 702 | #[serde(default)] | | |
| 703 | body: String, | | |
| 704 | | 707 | |
| 705 | #[serde(rename = "_csrf")] | 708 | #[serde(rename = "_csrf")] |
| 706 | csrf: String, | 709 | csrf: String, |
| 707 | } | 710 | } |
| 708 | | 711 | |
| 709 | | 712 | |
| 710 | | 713 | pub async fn edit_title( |
| 711 | pub async fn edit( | | |
| 712 | State(state): State<AppState>, | 714 | State(state): State<AppState>, |
| 713 | MaybeUser(viewer): MaybeUser, | 715 | MaybeUser(viewer): MaybeUser, |
| 714 | jar: CookieJar, | 716 | jar: CookieJar, |
| 715 | headers: HeaderMap, | 717 | headers: HeaderMap, |
| 716 | Path(id): Path<String>, | 718 | Path(id): Path<String>, |
| 717 | Form(form): Form<EditIssueForm>, | 719 | Form(form): Form<EditTitleForm>, |
| 718 | ) -> Response { | 720 | ) -> Response { |
| 719 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | 721 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| 720 | return AppError::Forbidden.into_response(); | 722 | return AppError::Forbidden.into_response(); |
| 721 | } | 723 | } |
| 722 | let result = async { | 724 | let result = async { |
| 723 | let user = viewer.ok_or(AppError::Forbidden)?; | 725 | let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?; |
| 724 | let issue = state | | |
| 725 | .store | | |
| 726 | .issue_by_id(&id) | | |
| 727 | .await? | | |
| 728 | .ok_or(AppError::NotFound)?; | | |
| 729 | let (repo, access) = repo_of_issue(&state, Some(&user), &issue).await?; | | |
| 730 | if access < auth::Access::Write && user.id != issue.author_id { | | |
| 731 | return Err(AppError::Forbidden); | | |
| 732 | } | | |
| 733 | let title = form.title.trim(); | 726 | let title = form.title.trim(); |
| 734 | if title.is_empty() { | 727 | if title.is_empty() { |
| 735 | return Err(AppError::BadRequest("A title is required.".to_string())); | 728 | return Err(AppError::BadRequest("A title is required.".to_string())); |
| 736 | } | 729 | } |
| 737 | state | 730 | state |
| 738 | .store | 731 | .store |
| 739 | .update_issue(&issue.id, title, form.body.trim()) | 732 | .update_issue(&issue.id, title, &issue.body) |
| 740 | .await?; | 733 | .await?; |
| 741 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) | 734 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| 742 | } | 735 | } |
| 744 | respond_redirect(result) | 737 | respond_redirect(result) |
| 745 | } | 738 | } |
| 746 | | 739 | |
| | 740 | |
| | 741 | |
| | 742 | pub async fn edit_body( |
| | 743 | State(state): State<AppState>, |
| | 744 | MaybeUser(viewer): MaybeUser, |
| | 745 | jar: CookieJar, |
| | 746 | headers: HeaderMap, |
| | 747 | Path(id): Path<String>, |
| | 748 | Form(form): Form<CommentForm>, |
| | 749 | ) -> Response { |
| | 750 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| | 751 | return AppError::Forbidden.into_response(); |
| | 752 | } |
| | 753 | let result = async { |
| | 754 | let (repo, issue) = edit_target(&state, viewer.as_ref(), &id).await?; |
| | 755 | state |
| | 756 | .store |
| | 757 | .update_issue(&issue.id, &issue.title, form.body.trim()) |
| | 758 | .await?; |
| | 759 | Ok::<String, AppError>(issue_url(&state, &repo, &issue).await) |
| | 760 | } |
| | 761 | .await; |
| | 762 | respond_redirect(result) |
| | 763 | } |
| | 764 | |
| | 765 | |
| | 766 | async fn edit_target( |
| | 767 | state: &AppState, |
| | 768 | viewer: Option<&User>, |
| | 769 | id: &str, |
| | 770 | ) -> AppResult<(model::Repo, Issue)> { |
| | 771 | let user = viewer.ok_or(AppError::Forbidden)?; |
| | 772 | let issue = state |
| | 773 | .store |
| | 774 | .issue_by_id(id) |
| | 775 | .await? |
| | 776 | .ok_or(AppError::NotFound)?; |
| | 777 | let (repo, access) = repo_of_issue(state, Some(user), &issue).await?; |
| | 778 | if access < auth::Access::Write && user.id != issue.author_id { |
| | 779 | return Err(AppError::Forbidden); |
| | 780 | } |
| | 781 | Ok((repo, issue)) |
| | 782 | } |
| | 783 | |
| 747 | | 784 | |
| 748 | pub async fn comment_edit( | 785 | pub async fn comment_edit( |
| 749 | State(state): State<AppState>, | 786 | State(state): State<AppState>, |