use actix_web::HttpResponse;
use maud::{Markup, PreEscaped, Render, html};
use super::Page;
use super::components::{
AuthorName, Complaint, DeleteForm, FilterLink, RepositoryChrome, RepositoryTab, TextArea,
TextField,
};
/// A summary as a ticket's heading shows it: markdown, when what the
/// markdown made of it is a single paragraph that fits in a heading, and
/// `None` when it is not - which is the caller's cue to write the summary
/// out as the plain text it also is.
///
/// The HTML that comes back is [`crate::markdown`]'s own sanitized output,
/// so it is safe to embed the way a readme is; a summary of several
/// paragraphs, or of raw HTML that was all stripped away, embeds as escaped
/// text instead of dropping half a document into a heading.
pub(super) fn inline_markdown(summary: &str) -> Option<String> {
let rendered = crate::markdown::render(summary);
let rendered = rendered.trim();
let paragraph = rendered.strip_prefix("<p>")?.strip_suffix("</p>")?;
// Two paragraphs are a `</p>` of their own in the middle of this one,
// and are no fit for a heading at all.
(!paragraph.contains("</p>")).then(|| paragraph.to_string())
}
/// A repository's tickets, as the list page shows them.
pub struct TicketsDetail {
pub slug: String,
pub visibility: &'static str,
/// The branch the repository opens on, for the browse tab.
pub default_branch: String,
/// The labels this repository has, each one a way to filter the list by
/// it.
pub labels: Vec<String>,
/// The labels the list is showing, which is what the search carries on
/// to the next request.
pub selected: Vec<String>,
/// The state filter currently applied ("open" or "closed").
pub state_filter: String,
/// The words the list is searched by, empty when it is not being
/// searched.
pub query: String,
/// The tickets the filter and the search left, newest first.
pub tickets: Vec<TicketRow>,
/// Whether the visitor is signed in, and so has somebody to open a
/// ticket as. A stranger is not offered a way they cannot use.
pub can_open: bool,
/// Whether the visitor owns the repository, and so may make the labels
/// it is filed under.
pub can_label: bool,
/// The label form's name and complaint, when the last attempt at one was
/// refused.
pub label_name: Option<String>,
pub label_error: Option<String>,
}
/// One ticket as the list shows it.
pub struct TicketRow {
pub number: i64,
/// The summary as it was written: plain text here, because a list is
/// scanned rather than read, and a ticket's own page is where its
/// markdown belongs.
pub summary: String,
/// Who opened it, by name.
pub creator: crate::comments::CommentAuthor,
/// The labels it carries, in the order the repository made them.
pub labels: Vec<String>,
}
pub(super) struct TicketsBody<'a> {
owner: &'a str,
detail: &'a TicketsDetail,
viewer: Option<&'a str>,
}
impl Render for TicketsBody<'_> {
fn render(&self) -> Markup {
let detail = self.detail;
let base = format!("/~{}/{}", self.owner, detail.slug);
html! {
(RepositoryChrome {
owner: self.owner,
slug: &detail.slug,
visibility: detail.visibility,
default_branch: &detail.default_branch,
current_tab: RepositoryTab::Tickets,
is_owner: self.viewer == Some(self.owner),
})
// State tabs like patches have: Open and Closed
nav class="ticket-filters" {
(FilterLink {
href: &format!("?state=open&labels={}", detail.selected.join(",")),
label: "Open",
base_class: "ticket-label",
selected: detail.state_filter == "open",
})
(FilterLink {
href: &format!("?state=closed&labels={}", detail.selected.join(",")),
label: "Closed",
base_class: "ticket-label",
selected: detail.state_filter == "closed",
})
}
// Label filters
nav class="ticket-label-filters" {
(FilterLink {
href: &format!("?state={}", detail.state_filter),
label: "All labels",
base_class: "ticket-label",
selected: detail.selected.is_empty(),
})
@for label in &detail.labels {
(FilterLink {
href: &format!("?state={}&labels={}", detail.state_filter, crate::tickets::encode(label)),
label,
base_class: "ticket-label",
selected: detail.selected.contains(label),
})
}
}
form class="ticket-search" method="get" action=(format!("{base}/tickets")) {
// The state and label filters are carried through the search
input type="hidden" name="state" value=(&detail.state_filter);
input type="hidden" name="labels" value=(detail.selected.join(","));
label {
"Search"
input type="search" name="q" value=(&detail.query);
}
button type="submit" { "Search" }
}
@if detail.can_open {
p class="ticket-tools" {
a href=(format!("{base}/tickets/new")) { "New ticket" }
}
}
@if detail.tickets.is_empty() {
p class="empty" { "Nothing here yet." }
} @else {
ul class="tickets" {
@for ticket in &detail.tickets {
li {
a class="ticket-summary"
href=(format!("{base}/tickets/{}", ticket.number)) {
"#" (ticket.number) " " (ticket.summary)
}
span class="ticket-creator" {
"opened by "
(AuthorName(&ticket.creator))
}
@for label in &ticket.labels {
// A label on a ticket is a filter on the
// list, replacing whatever it is showing
// with everything filed under it.
a class="ticket-label"
href=(format!("?labels={}", crate::tickets::encode(label))) {
(label)
}
}
}
}
}
}
}
}
}
/// What the ticket form shows when it is (re)rendered.
pub struct TicketForm {
/// Where the form posts: the collection when a ticket is being opened,
/// the ticket's own address when it is being edited.
pub action: String,
pub heading: String,
/// The repository the form is for, for the tab bar and the heading that
/// say where the visitor is.
pub slug: String,
pub visibility: &'static str,
pub default_branch: String,
pub summary: Option<String>,
pub description: Option<String>,
/// The state of the ticket: "open" or "closed".
pub state: String,
/// The labels the form comes back with checked, which is what saving
/// without touching them keeps.
pub selected: Vec<String>,
/// The labels the repository has, each one offered as a checkbox.
pub labels: Vec<String>,
pub error: Option<String>,
/// What the button says: opening a ticket and saving one are not the
/// same act even though they are the same form.
pub submit: &'static str,
}
pub(super) struct TicketFormBody<'a> {
owner: &'a str,
slug: &'a str,
visibility: &'a str,
default_branch: &'a str,
form: &'a TicketForm,
viewer: Option<&'a str>,
}
impl Render for TicketFormBody<'_> {
fn render(&self) -> Markup {
html! {
(RepositoryChrome {
owner: self.owner,
slug: self.slug,
visibility: self.visibility,
default_branch: self.default_branch,
current_tab: RepositoryTab::Tickets,
is_owner: self.viewer == Some(self.owner),
})
h3 { (self.form.heading) }
(Complaint(self.form.error.as_deref()))
form method="post" action=(&self.form.action) class="ticket-form" {
(TextField {
label: Some("Summary"),
name: "summary",
input_type: "text",
value: self.form.summary.as_deref(),
required: true,
maxlength: u32::MAX,
})
(TextArea {
label: Some("Description"),
name: "description",
rows: 8,
value: self.form.description.as_deref(),
required: false,
})
fieldset {
legend { "State" }
label {
input type="radio" name="state" value="open" checked[self.form.state == "open"];
"Open"
}
label {
input type="radio" name="state" value="closed" checked[self.form.state == "closed"];
"Closed"
}
}
fieldset {
legend { "Labels" }
@for label in &self.form.labels {
label {
input type="checkbox" name="labels" value=(label)
checked[self.form.selected.contains(label)];
(label)
}
}
}
button type="submit" { (self.form.submit) }
}
}
}
}
/// One ticket, as its own page shows it.
pub struct TicketDetail<'a> {
pub slug: String,
pub visibility: &'static str,
/// The branch the repository opens on, for the browse tab.
pub default_branch: String,
pub number: i64,
/// The summary as it was written; [`inline_markdown`] decides on the way
/// out whether the heading shows it as markdown or as the text it is.
pub summary: String,
/// The description, rendered from markdown and sanitized - this page
/// says what a ticket is about in the words whoever opened it wrote.
pub description_html: String,
/// Who opened it, by name.
pub creator: crate::comments::CommentAuthor,
/// When it was opened, already formatted.
pub opened_at: String,
/// The state of the ticket: "open" or "closed".
pub state: String,
/// The labels it carries, in the order the repository made them.
pub labels: Vec<String>,
/// Whether the visitor may edit or delete the ticket.
pub may_change: bool,
/// The attachments on this ticket, with their metadata.
pub attachments: Vec<AttachmentView>,
/// The comments on this ticket, in chronological order.
pub comments: Vec<super::comments::CommentView<'a>>,
/// Whether the visitor may comment on this ticket.
pub may_comment: bool,
/// The complaint, if the last comment attempt was refused.
pub comment_error: Option<String>,
/// The draft, if the last comment attempt was refused.
pub comment_draft: Option<String>,
}
/// An attachment on a ticket, as the detail page shows it.
pub struct AttachmentView {
/// The UUID used in the download URL.
pub uuid: String,
/// The original filename.
pub filename: String,
/// The size in bytes.
pub size: i64,
/// The MIME type.
pub content_type: String,
}
pub(super) struct TicketDetailBody<'a> {
owner: &'a str,
detail: &'a TicketDetail<'a>,
viewer: Option<&'a str>,
}
impl Render for TicketDetailBody<'_> {
fn render(&self) -> Markup {
let detail = self.detail;
let base = format!("/~{}/{}", self.owner, detail.slug);
html! {
(RepositoryChrome {
owner: self.owner,
slug: &detail.slug,
visibility: detail.visibility,
default_branch: &detail.default_branch,
current_tab: RepositoryTab::Tickets,
is_owner: self.viewer == Some(self.owner),
})
div class="ticket-header" {
h3 {
"#" (detail.number) " "
@match inline_markdown(&detail.summary) {
Some(summary) => (PreEscaped(summary)),
None => (detail.summary.as_str()),
}
}
@if detail.may_change {
div class="ticket-tools" {
a href=(format!("{base}/tickets/{}/edit", detail.number)) class="ticket-edit-link" { "Edit" }
(DeleteForm {
action: format!("{base}/tickets/{}/delete", detail.number),
form_class: Some("ticket-delete"),
button_class: Some("danger"),
confirm: None,
})
}
}
}
p class="ticket-meta" {
"opened by "
(AuthorName(&detail.creator))
" on " (detail.opened_at)
}
p class="ticket-state" {
"State: " (detail.state)
}
@if !detail.labels.is_empty() {
p class="ticket-labels" {
@for label in &detail.labels {
span class="ticket-label" { (label) }
}
}
}
// Rendered and sanitized by `crate::markdown`, the same as a
// readme: this is the other place a page embeds markup it did
// not write itself.
section class="ticket-description" {
(PreEscaped(&detail.description_html))
}
@if !detail.attachments.is_empty() {
section class="ticket-attachments" {
h4 { "Attachments" }
ul {
@for attachment in &detail.attachments {
li {
a href=(format!("{base}/tickets/{}/attachments/{}", detail.number, attachment.uuid)) {
(attachment.filename)
}
" (" (attachment.size) " bytes, " (attachment.content_type) ")"
}
}
}
}
}
(super::comments::CommentListView {
comments: &detail.comments,
may_comment: detail.may_comment,
base_comments_url: &format!("{base}/tickets/{}/comments", detail.number),
comment_draft: detail.comment_draft.as_deref(),
comment_error: detail.comment_error.as_deref()
})
}
}
}
/// The page listing a repository's tickets, filtered and searched as it is
/// asked to be.
pub fn tickets(viewer: Option<&str>, owner: &str, detail: &TicketsDetail) -> HttpResponse {
Page {
title: format!("~{owner}/{}: Tickets - Frogue", detail.slug),
viewer: viewer.map(str::to_string),
extra_head: None,
body: TicketsBody {
owner,
detail,
viewer,
},
}
.into_response()
}
/// The form for opening a ticket on `owner`'s repository, or for editing the
/// one at `form.action`.
pub fn ticket_form(viewer: Option<&str>, owner: &str, form: &TicketForm) -> HttpResponse {
Page {
title: format!("~{owner}: {} - Frogue", form.heading),
viewer: viewer.map(str::to_string),
extra_head: None,
body: TicketFormBody {
owner,
slug: &form.slug,
visibility: form.visibility,
default_branch: &form.default_branch,
form,
viewer,
},
}
.into_response()
}
/// The page showing one ticket on `owner`'s repository.
pub fn ticket(viewer: Option<&str>, owner: &str, detail: &TicketDetail) -> HttpResponse {
Page {
title: format!("~{owner}/{}: #{} - Frogue", detail.slug, detail.number),
viewer: viewer.map(str::to_string),
extra_head: None,
body: TicketDetailBody {
owner,
detail,
viewer,
},
}
.into_response()
}
#[cfg(test)]
mod tests {
use super::inline_markdown;
#[test]
fn a_summary_that_is_one_paragraph_comes_back_as_the_html_of_it() {
assert_eq!(
inline_markdown("Cranberry sauce"),
Some("Cranberry sauce".to_string())
);
assert_eq!(
inline_markdown("A **bold** sauce"),
Some("A <strong>bold</strong> sauce".to_string())
);
// What comes back is rendered and sanitized, so a summary that
// tried to be markup is still not markup.
assert_eq!(inline_markdown("<script>alert(1)</script>"), None);
}
#[test]
fn a_summary_that_is_not_one_paragraph_leaves_it_to_the_page_to_escape() {
// Two paragraphs are no fit for a heading; the caller writes the
// summary out as text instead, escaped the way maud escapes
// everything it interpolates.
assert_eq!(inline_markdown("one\n\ntwo"), None);
// Raw HTML that was all stripped away leaves nothing to put in a
// heading either.
assert_eq!(inline_markdown("<iframe src=x>"), None);
// An empty summary is no heading at all, and says nothing twice.
assert_eq!(inline_markdown(""), None);
}
}