michal/tit
Browse tree · Show commit · Download archive
Blob: src/http/issues.rs
use askama::Template;
use axum::Router;
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::Response;
use axum::routing::{get, post};
use serde::Deserialize;
use crate::issue::{IssueError, IssueService};
use crate::markdown::{self, RenderedMarkdown};
use crate::store::{IssueDetail, StoreError};
use super::filters;
use super::{
CSRF_COOKIE, RequestActor, RequestId, WebState, authenticate_mutation, cookie,
parse_named_form, render, render_error,
};
const MAX_ISSUE_REQUEST_BYTES: usize = 300 * 1024;
pub(super) fn routes() -> Router<WebState> {
Router::new()
.route(
"/{owner}/{repository}/issues",
get(issue_list).post(create_issue),
)
.route("/{owner}/{repository}/issues/{number}", get(issue_detail))
.route(
"/{owner}/{repository}/issues/{number}/edit",
post(edit_issue),
)
.route(
"/{owner}/{repository}/issues/{number}/comments",
post(comment_issue),
)
.route(
"/{owner}/{repository}/issues/{number}/state",
post(change_state),
)
.layer(DefaultBodyLimit::max(MAX_ISSUE_REQUEST_BYTES))
}
async fn issue_list(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Extension(actor): Extension<RequestActor>,
Path(path): Path<RepositoryPath>,
Query(query): Query<ListQuery>,
headers: HeaderMap,
) -> Response {
let Some(service) = state.issues.clone() else {
return issue_read_error(
IssueError::Store(StoreError::Integrity(
"issue service is unavailable".to_owned(),
)),
&request_id.0,
);
};
let owner = path.owner.clone();
let repository = path.repository.clone();
let authenticated = actor.0.is_some();
let state_filter = query.state.unwrap_or_else(|| "open".to_owned());
let page_number = query.page.unwrap_or(1);
let state_for_job = state_filter.clone();
let result = issue_job(state, move || {
service.list_page(
&owner,
&repository,
actor.0.as_deref(),
&state_for_job,
page_number,
)
})
.await;
match result {
Ok((record, page)) => {
let csrf = cookie(&headers, CSRF_COOKIE).unwrap_or_default();
render(
StatusCode::OK,
&IssueListTemplate {
request_id: &request_id.0,
signed_in: authenticated,
owner: &record.owner,
repository: &record.slug,
issues: page
.items
.iter()
.map(|issue| IssueListItem {
number: issue.number,
title: &issue.title,
state: &issue.state,
author: &issue.author,
updated_at: issue.updated_at,
})
.collect(),
csrf: &csrf,
can_create: authenticated && !csrf.is_empty(),
state: &state_filter,
state_all: state_filter == "all",
state_open: state_filter == "open",
state_closed: state_filter == "closed",
has_previous: page.page > 1,
has_next: page.has_next,
previous_page: page.page.saturating_sub(1),
next_page: page.page.saturating_add(1),
},
)
}
Err(error) => issue_read_error(error, &request_id.0),
}
}
async fn issue_detail(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Extension(actor): Extension<RequestActor>,
Path(path): Path<IssuePath>,
Query(query): Query<ActivityQuery>,
headers: HeaderMap,
) -> Response {
let Some(service) = state.issues.clone() else {
return issue_read_error(
IssueError::Store(StoreError::Integrity(
"issue service is unavailable".to_owned(),
)),
&request_id.0,
);
};
let owner = path.owner.clone();
let repository = path.repository.clone();
let number = path.number;
let comments_page = query.comments_page.unwrap_or(1);
let timeline_page = query.timeline_page.unwrap_or(1);
let result = issue_job(state, move || {
service.get_page(
&owner,
&repository,
number,
actor.0.as_deref(),
comments_page,
timeline_page,
)
})
.await;
match result {
Ok(detail) => render_issue(&request_id.0, &headers, &detail),
Err(error) => issue_read_error(error, &request_id.0),
}
}
async fn create_issue(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Path(path): Path<RepositoryPath>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
Ok(fields) => fields,
Err(()) => return issue_bad_request(&request_id.0),
};
let actor =
match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
Ok(actor) => actor,
Err(response) => return response,
};
let Some(service) = state.issues.clone() else {
return issue_internal(&request_id.0);
};
let owner = path.owner.clone();
let repository = path.repository.clone();
let result = issue_job(state, move || {
service.create(&owner, &repository, &actor, &fields[1], &fields[2])
})
.await;
match result {
Ok(issue) => issue_redirect(&path.owner, &path.repository, issue.number),
Err(error) => issue_mutation_error(error, &request_id.0),
}
}
async fn edit_issue(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Path(path): Path<IssuePath>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let fields = match parse_named_form(&headers, &body, &["csrf", "title", "body"]) {
Ok(fields) => fields,
Err(()) => return issue_bad_request(&request_id.0),
};
let actor =
match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
Ok(actor) => actor,
Err(response) => return response,
};
mutate(state, request_id, path, move |service, path| {
service.edit(
&path.owner,
&path.repository,
path.number,
&actor,
&fields[1],
&fields[2],
)
})
.await
}
async fn comment_issue(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Path(path): Path<IssuePath>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let fields = match parse_named_form(&headers, &body, &["csrf", "body"]) {
Ok(fields) => fields,
Err(()) => return issue_bad_request(&request_id.0),
};
let actor =
match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
Ok(actor) => actor,
Err(response) => return response,
};
mutate(state, request_id, path, move |service, path| {
service
.comment(
&path.owner,
&path.repository,
path.number,
&actor,
&fields[1],
)
.map(|_| ())
})
.await
}
async fn change_state(
State(state): State<WebState>,
Extension(request_id): Extension<RequestId>,
Path(path): Path<IssuePath>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let fields = match parse_named_form(&headers, &body, &["csrf", "state"]) {
Ok(fields) => fields,
Err(()) => return issue_bad_request(&request_id.0),
};
let actor =
match authenticate_mutation(state.clone(), &headers, &fields[0], &request_id.0).await {
Ok(actor) => actor,
Err(response) => return response,
};
mutate(state, request_id, path, move |service, path| {
service.set_state(
&path.owner,
&path.repository,
path.number,
&actor,
&fields[1],
)
})
.await
}
async fn mutate(
state: WebState,
request_id: RequestId,
path: IssuePath,
operation: impl FnOnce(IssueService, &IssuePath) -> Result<(), IssueError> + Send + 'static,
) -> Response {
let Some(service) = state.issues.clone() else {
return issue_internal(&request_id.0);
};
let redirect_owner = path.owner.clone();
let redirect_repository = path.repository.clone();
let redirect_number = path.number;
let result = issue_job(state, move || operation(service, &path)).await;
match result {
Ok(()) => issue_redirect(&redirect_owner, &redirect_repository, redirect_number),
Err(error) => issue_mutation_error(error, &request_id.0),
}
}
async fn issue_job<T: Send + 'static>(
state: WebState,
operation: impl FnOnce() -> Result<T, IssueError> + Send + 'static,
) -> Result<T, IssueError> {
let permit = state.jobs.acquire_owned().await.map_err(|_| {
IssueError::Store(StoreError::Integrity(
"issue worker pool is unavailable".to_owned(),
))
})?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
operation()
})
.await
.map_err(|_| IssueError::Store(StoreError::Integrity("issue worker failed".to_owned())))?
}
fn render_issue(request_id: &str, headers: &HeaderMap, detail: &IssueDetail) -> Response {
let csrf = cookie(headers, CSRF_COOKIE).unwrap_or_default();
render(
StatusCode::OK,
&IssueTemplate {
request_id,
signed_in: !csrf.is_empty(),
owner: &detail.repository.owner,
repository: &detail.repository.slug,
number: detail.issue.number,
title: &detail.issue.title,
body: &detail.issue.body,
body_html: markdown::render(&detail.issue.body),
state: &detail.issue.state,
author: &detail.issue.author,
created_at: detail.issue.created_at,
updated_at: detail.issue.updated_at,
comments: detail
.comments
.iter()
.map(|comment| CommentView {
id: &comment.id,
author: &comment.author,
body_html: markdown::render(&comment.body),
created_at: comment.created_at,
})
.collect(),
timeline: detail
.timeline
.iter()
.map(|event| TimelineView {
sequence: event.sequence,
kind: &event.kind,
actor: &event.actor,
created_at: event.created_at,
})
.collect(),
csrf: &csrf,
can_comment: detail.can_comment && !csrf.is_empty(),
can_edit: detail.can_edit && !csrf.is_empty(),
is_open: detail.issue.state == "open",
comments_page: detail.comments_page,
comments_has_previous: detail.comments_page > 1,
comments_has_next: detail.comments_has_next,
comments_previous_page: detail.comments_page.saturating_sub(1),
comments_next_page: detail.comments_page.saturating_add(1),
timeline_page: detail.timeline_page,
timeline_has_previous: detail.timeline_page > 1,
timeline_has_next: detail.timeline_has_next,
timeline_previous_page: detail.timeline_page.saturating_sub(1),
timeline_next_page: detail.timeline_page.saturating_add(1),
},
)
}
fn issue_read_error(error: IssueError, request_id: &str) -> Response {
match error {
IssueError::Store(
StoreError::RepositoryNotFound(_, _)
| StoreError::IssueNotFound(_, _, _)
| StoreError::IssueDenied
| StoreError::IssueHidden,
)
| IssueError::Auth(_)
| IssueError::RepositoryName(_)
| IssueError::Number => render_error(
StatusCode::NOT_FOUND,
request_id,
"Not found",
"The issue was not found.",
),
IssueError::State => issue_bad_request(request_id),
_ => issue_internal(request_id),
}
}
fn issue_mutation_error(error: IssueError, request_id: &str) -> Response {
match error {
IssueError::Auth(_)
| IssueError::RepositoryName(_)
| IssueError::Number
| IssueError::Title
| IssueError::Body
| IssueError::State => issue_bad_request(request_id),
IssueError::Store(StoreError::IssueDenied) => render_error(
StatusCode::FORBIDDEN,
request_id,
"Forbidden",
"The issue change is not authorized.",
),
IssueError::Store(StoreError::IssueState(_)) => render_error(
StatusCode::CONFLICT,
request_id,
"Issue conflict",
"The issue already has the requested state.",
),
IssueError::Store(
StoreError::RepositoryNotFound(_, _)
| StoreError::IssueNotFound(_, _, _)
| StoreError::IssueHidden,
) => render_error(
StatusCode::NOT_FOUND,
request_id,
"Not found",
"The issue or account was not found.",
),
_ => issue_internal(request_id),
}
}
fn issue_bad_request(request_id: &str) -> Response {
render_error(
StatusCode::BAD_REQUEST,
request_id,
"Issue error",
"The issue request is not valid.",
)
}
fn issue_internal(request_id: &str) -> Response {
render_error(
StatusCode::INTERNAL_SERVER_ERROR,
request_id,
"Issue error",
"The issue request could not be completed.",
)
}
fn issue_redirect(owner: &str, repository: &str, number: i64) -> Response {
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(
header::LOCATION,
format!("/{owner}/{repository}/issues/{number}"),
)
.header(header::CACHE_CONTROL, "no-store")
.body(axum::body::Body::empty())
.expect("the issue redirect is valid")
}
#[derive(Clone, Deserialize)]
struct RepositoryPath {
owner: String,
repository: String,
}
#[derive(Clone, Deserialize)]
struct IssuePath {
owner: String,
repository: String,
number: i64,
}
#[derive(Default, Deserialize)]
struct ListQuery {
state: Option<String>,
page: Option<usize>,
}
#[derive(Default, Deserialize)]
struct ActivityQuery {
comments_page: Option<usize>,
timeline_page: Option<usize>,
}
#[derive(Template)]
#[template(path = "issues.html")]
struct IssueListTemplate<'a> {
request_id: &'a str,
signed_in: bool,
owner: &'a str,
repository: &'a str,
issues: Vec<IssueListItem<'a>>,
csrf: &'a str,
can_create: bool,
state: &'a str,
state_all: bool,
state_open: bool,
state_closed: bool,
has_previous: bool,
has_next: bool,
previous_page: usize,
next_page: usize,
}
struct IssueListItem<'a> {
number: i64,
title: &'a str,
state: &'a str,
author: &'a str,
updated_at: i64,
}
#[derive(Template)]
#[template(path = "issue.html")]
struct IssueTemplate<'a> {
request_id: &'a str,
signed_in: bool,
owner: &'a str,
repository: &'a str,
number: i64,
title: &'a str,
body: &'a str,
body_html: RenderedMarkdown,
state: &'a str,
author: &'a str,
created_at: i64,
updated_at: i64,
comments: Vec<CommentView<'a>>,
timeline: Vec<TimelineView<'a>>,
csrf: &'a str,
can_comment: bool,
can_edit: bool,
is_open: bool,
comments_page: usize,
comments_has_previous: bool,
comments_has_next: bool,
comments_previous_page: usize,
comments_next_page: usize,
timeline_page: usize,
timeline_has_previous: bool,
timeline_has_next: bool,
timeline_previous_page: usize,
timeline_next_page: usize,
}
struct CommentView<'a> {
id: &'a str,
author: &'a str,
body_html: RenderedMarkdown,
created_at: i64,
}
struct TimelineView<'a> {
sequence: i64,
kind: &'a str,
actor: &'a str,
created_at: i64,
}