c768b12c05b0fc8c40b3fec6daf3c6b5cda7d097 / frogue / src / webui / repositories / tickets.rs
Raw
//! A repository's tickets: the list and its filters, the search over it,
//! and what may be done to a ticket and by whom.
//!
//! Every address here hangs off a repository's own, because a ticket's
//! number names a ticket *of one repository* - that is what keeps the number
//! short enough to be worth having, and what makes a number that is not
//! there a `404` rather than a guess.
use std::sync::Arc;
use actix_multipart::Multipart;
use actix_web::{HttpResponse, get, post, web};
use futures_util::StreamExt as _;
use sea_orm::EntityTrait;
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;
use uuid::Uuid;
use crate::AppState;
use crate::auth::{CurrentUser, viewer_name};
use crate::comments;
use crate::entities::repositories::Model as Repository;
use crate::entities::ticket_labels::Model as Label;
use crate::entities::users;
use crate::markdown;
use crate::state_read;
use crate::tickets::{self, store};
use crate::views;
use super::readable_repository;
/// What the list page is asked to show.
#[derive(Debug, Default, Deserialize)]
struct TicketsQuery {
/// The state filter: "open", "closed", or absent for open tickets (the default).
state: Option<String>,
/// The labels to filter by, absent when none are selected.
labels: Option<String>,
/// The search box, empty when nothing was typed into it.
#[serde(default)]
q: String,
}
/// What an open-or-edit-ticket form posts.
#[derive(Debug)]
struct TicketFields {
summary: String,
description: String,
/// The state of the ticket: "open" or "closed".
state: String,
/// The labels that were checked, read in the order the boxes are
/// written.
labels: Vec<String>,
}
///
/// `serde_urlencoded` rejects repeated fields (like multiple `labels=...`),
/// but HTML checkboxes send repeated field names. This parser collects all
/// values for each field name, handling both single and repeated fields.
fn parse_ticket_form(body: &str) -> Result<TicketFields, String> {
let mut summary: Option<String> = None;
let mut description: Option<String> = None;
let mut state: Option<String> = None;
let mut labels: Vec<String> = Vec::new();
for pair in body.split('&') {
if pair.is_empty() {
continue;
}
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
let key = urlencoding_decode(key)?;
let value = urlencoding_decode(value)?;
match key.as_str() {
"summary" => summary = Some(value),
"description" => description = Some(value),
"state" => state = Some(value),
"labels" => labels.push(value),
_ => {} // Ignore unknown fields
}
}
Ok(TicketFields {
summary: summary.ok_or_else(|| "missing summary field".to_string())?,
description: description.unwrap_or_default(),
state: state.unwrap_or_else(|| "open".to_string()),
labels,
})
}
/// Decodes a URL-encoded string, handling `+` as space.
fn urlencoding_decode(s: &str) -> Result<String, String> {
let mut result = String::new();
let mut chars = s.bytes();
while let Some(b) = chars.next() {
match b {
b'+' => result.push(' '),
b'%' => {
let hi = chars.next().ok_or("incomplete percent encoding")?;
let lo = chars.next().ok_or("incomplete percent encoding")?;
let hex = format!("{}{}", hi as char, lo as char);
let byte = u8::from_str_radix(&hex, 16)
.map_err(|_| "invalid percent encoding".to_string())?;
result.push(byte as char);
}
_ => result.push(b as char),
}
}
Ok(result)
}
/// The address of a repository's tickets, which every link and form here
/// hangs off.
fn tickets_address(owner: &users::Model, repository: &Repository) -> String {
format!("/~{}/{}/tickets", owner.name, repository.slug)
}
/// The answer for a ticket this repository does not have.
///
/// A number that names nothing is answered the same way as a repository
/// that has no such number at all: a visitor who guessed either has learned
/// nothing about which of the two they got wrong.
fn no_such_ticket(number: &str) -> HttpResponse {
HttpResponse::NotFound().body(format!("This repository has no ticket {number}"))
}
/// The labels a form checked, as names: trimmed, without the blanks an
/// unchecked set of boxes leaves behind, and without the same label twice.
///
/// Whether this repository has them is the caller's to say - that refusal
/// is about the repository, and this is only about what was asked for.
fn checked_fields(fields: &[String]) -> Vec<String> {
let mut chosen: Vec<String> = Vec::with_capacity(fields.len());
for field in fields {
let name = field.trim();
if name.is_empty() || chosen.iter().any(|taken| taken == name) {
continue;
}
chosen.push(name.to_string());
}
chosen
}
/// The rows the names in `chosen` are of this repository, in the order they
/// were asked for.
///
/// The names have already been checked against the repository's own labels
/// by the caller, so one that is not there is a label deleted between the
/// two - left out rather than refused, since there is nothing left to file
/// the ticket under.
async fn label_ids(
db: &sea_orm::DatabaseConnection,
repository_id: i64,
chosen: &[String],
) -> Result<Vec<i64>, sea_orm::DbErr> {
let mut ids = Vec::with_capacity(chosen.len());
for name in chosen {
if let Some(label) = store::find_label(db, repository_id, name).await? {
ids.push(label.id);
}
}
Ok(ids)
}
/// One ticket form's own half: where it posts, what it says, and what has
/// already been put in it.
///
/// A form that was refused comes back with what was typed rather than
/// empty: retyping a rejected summary is the least useful thing to ask.
struct FormPage {
action: String,
heading: String,
summary: Option<String>,
description: Option<String>,
state: String,
selected: Vec<String>,
error: Option<String>,
submit: &'static str,
}
/// A ticket form, filled with this repository's labels as the checkboxes it
/// offers.
fn ticket_form(
viewer: Option<&CurrentUser>,
owner: &users::Model,
repository: &Repository,
labels: &[Label],
page: FormPage,
) -> HttpResponse {
views::ticket_form(
viewer_name(viewer),
&owner.name,
&views::TicketForm {
action: page.action,
heading: page.heading,
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
summary: page.summary,
description: page.description,
state: page.state,
selected: page.selected,
labels: labels.iter().map(|label| label.name.clone()).collect(),
error: page.error,
submit: page.submit,
},
)
}
#[get("/~{user_or_team}/{repository_slug}/tickets")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
state = tracing::field::Empty,
labels = tracing::field::Empty,
query = tracing::field::Empty,
)
)]
async fn get_tickets(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String)>,
query: web::Query<TicketsQuery>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
// The path and the ask arrive as pieces, so the span's fields are
// recorded by hand rather than named in the attribute.
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("state", query.state.as_deref().unwrap_or("open"));
span.record("labels", query.labels.as_deref().unwrap_or("<none>"));
span.record("query", query.q.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
list_page(
&data,
viewer.as_ref(),
&owner,
&repository,
&query,
None,
None,
)
.await
}
/// The list page: the labels to filter by, the tickets the filter and the
/// search leave, and who opened each of them.
///
/// It is also the page a refused new label comes back to, with the
/// complaint beside the form that made it - labels are made from the list,
/// so saying no to one belongs there too.
async fn list_page(
data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<&CurrentUser>,
owner: &users::Model,
repository: &Repository,
query: &TicketsQuery,
label_name: Option<String>,
label_error: Option<String>,
) -> HttpResponse {
let db = { state_read(data).await.db.clone() };
let state = tickets::parse_state(query.state.as_deref());
// When state is None (empty parameter), show "all" as the filter
let state_filter = match state {
Some(s) => tickets::state_string(s),
None => "all".to_string(),
};
let selected = tickets::selected_labels(query.labels.as_deref());
// The defaults are made before anything is filtered by them, so the
// first time a repository's tickets page is opened it filters the same
// way it will the tenth time.
let labels = match store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let listed = match store::list_tickets(&db, repository.id, state, &selected, &query.q).await {
Ok(listed) => listed,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's tickets failed");
return HttpResponse::InternalServerError().finish();
}
};
let creator_ids: Vec<i64> = listed
.iter()
.filter_map(|ticket| ticket.creator_id)
.collect();
let remote_actor_ids: Vec<i64> = listed
.iter()
.filter_map(|ticket| ticket.remote_actor_id)
.collect();
let creators = match store::creator_authors(&db, &creator_ids, &remote_actor_ids).await {
Ok(creators) => creators,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "looking up who opened tickets failed");
return HttpResponse::InternalServerError().finish();
}
};
let ticket_ids: Vec<i64> = listed.iter().map(|ticket| ticket.id).collect();
let filed = match store::labels_by_ticket(&db, &ticket_ids).await {
Ok(filed) => filed,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading what tickets are filed under failed");
return HttpResponse::InternalServerError().finish();
}
};
let tickets: Vec<views::TicketRow> = listed
.into_iter()
.map(|ticket| {
let creator = ticket
.creator_id
.or(ticket.remote_actor_id)
.and_then(|id| creators.get(&id))
.cloned()
.unwrap_or_else(|| comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
});
let labels = filed.get(&ticket.id).cloned().unwrap_or_default();
views::TicketRow {
number: ticket.number,
summary: ticket.summary,
creator,
labels,
}
})
.collect();
views::tickets(
viewer_name(viewer),
&owner.name,
&views::TicketsDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
labels: labels.iter().map(|label| label.name.clone()).collect(),
selected,
state_filter,
query: query.q.clone(),
tickets,
can_open: viewer.is_some(),
can_label: viewer.is_some_and(|viewer| viewer.id == repository.owner_id),
label_name,
label_error,
},
)
}
#[get("/~{user_or_team}/{repository_slug}/tickets/new")]
#[tracing::instrument(
skip_all,
fields(owner = tracing::field::Empty, repository_slug = tracing::field::Empty)
)]
async fn get_new_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let db = { state_read(&data).await.db.clone() };
let labels = match store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// What a ticket is filed under when its opener picked nothing is what
// the form starts checked, so the boxes say what will happen.
ticket_form(
Some(¤t),
&owner,
&repository,
&labels,
FormPage {
action: tickets_address(&owner, &repository),
heading: "New ticket".to_string(),
summary: None,
description: None,
state: "open".to_string(),
selected: vec![],
error: None,
submit: "Open",
},
)
}
#[post("/~{user_or_team}/{repository_slug}/tickets")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
summary = tracing::field::Empty,
)
)]
async fn post_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String)>,
body: String,
) -> HttpResponse {
let (owner_name, repository_slug) = path.into_inner();
let form = match parse_ticket_form(&body) {
Ok(f) => f,
Err(e) => return HttpResponse::BadRequest().body(format!("Invalid form data: {e}")),
};
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("summary", form.summary.trim().chars().count());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let db = { state_read(&data).await.db.clone() };
let labels = match store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let refused = |page: FormPage, error: String| {
let mut page = page;
page.error = Some(error);
ticket_form(Some(¤t), &owner, &repository, &labels, page)
};
let blank = FormPage {
action: tickets_address(&owner, &repository),
heading: "New ticket".to_string(),
summary: Some(form.summary.clone()),
description: Some(form.description.clone()),
state: "open".to_string(),
selected: checked_fields(&form.labels),
error: None,
submit: "Open",
};
// Everything is checked before anything is written: a ticket that was
// not opened must not have taken a number either.
let summary = match tickets::checked_summary(&form.summary) {
Ok(summary) => summary.to_string(),
Err(error) => return refused(blank, error),
};
for name in checked_fields(&form.labels) {
if !labels.iter().any(|label| label.name == name) {
return refused(
blank,
format!("`{name}` is not a label of {owner_name}/{repository_slug}"),
);
}
}
let chosen = checked_fields(&form.labels);
let ids = match label_ids(&db, repository.id, &chosen).await {
Ok(ids) => ids,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "reading the labels of a new ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// The number is taken here rather than first, so that a refusal above
// leaves the repository counting nothing but tickets that exist.
let number = match store::take_ticket_number(&db, repository.id).await {
Ok(number) => number,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "numbering a new ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
let state = tickets::parse_state(Some(&form.state)).unwrap_or(tickets::State::Open);
let description = (!form.description.is_empty()).then_some(form.description.as_str());
let opened = match store::create_ticket(
&db,
repository.id,
current.id,
number,
&summary,
description,
state,
)
.await
{
Ok(opened) => opened,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "opening a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// The ticket is there either way: a fault filing it under its labels
// costs the labels, and answering with an error page would only invite
// the same ticket to be opened twice.
if let Err(error) = store::set_ticket_labels(&db, opened.id, &ids).await {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "labelling a new ticket failed");
}
// Emit federation activities if enabled and repo is public
let state = state_read(&data).await;
if crate::activitypub::enabled(&state)
&& repository.visibility == crate::entities::repositories::Visibility::Public
{
let urls = crate::activitypub::ApUrls::new(&state.public_url());
drop(state); // Release lock before DB operations
// Get user and repo actors
let user_actor = match crate::activitypub::actor::get_or_create(&db, current.id).await {
Ok(actor) => actor,
Err(e) => {
error!(%e, "failed to get user actor for federation");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
let repo_actor =
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(actor) => actor,
Err(e) => {
error!(%e, "failed to get repo actor for federation");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
// Fetch the user entity for the ticket document
let _user_entity = match crate::entities::users::Entity::find_by_id(current.id)
.one(&db)
.await
{
Ok(Some(user)) => user,
Ok(None) => {
error!("user entity not found for current user");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
Err(e) => {
error!(%e, "failed to fetch user entity for federation");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
let user_actor_url = urls.actor(¤t.name);
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, number);
let offer_id = urls.activity(¤t.name, Uuid::new_v4());
// Build ticket document without id (per ForgeFed spec §3.7.1)
let mut ticket_doc = crate::activitypub::forge::builders::build_ticket_document(
&opened,
&owner.name,
&repository.slug,
&user_actor_url,
&urls,
);
ticket_doc.id = None;
// Build Offer activity
let offer = crate::activitypub::types::Offer {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: offer_id.clone(),
kind: "Offer",
actor: user_actor_url.clone(),
object: serde_json::to_value(&ticket_doc).expect("ticket doc should serialize"),
target: repo_actor_url.clone(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let offer_json = match serde_json::to_string(&offer) {
Ok(json) => json,
Err(e) => {
error!(%e, "failed to serialize Offer activity");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
// Insert Offer into user's activities (no delivery - tracker is local)
if let Err(e) = crate::activitypub::forge::activity::publish_activity(
&db,
user_actor.id,
&offer_json,
&ticket_url,
)
.await
{
error!(%e, "failed to publish Offer activity");
}
// Announce Accept from repo actor to followers
if let Err(e) = crate::activitypub::forge::activity::announce_accept(
&db,
repo_actor.id,
&repo_actor_url,
&offer_id,
&ticket_url,
None,
)
.await
{
error!(%e, "failed to announce Accept activity");
}
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish()
}
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
)
)]
pub async fn get_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
let filed = match store::labels_by_ticket(&db, &[ticket.id]).await {
Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let creator = if let Some(creator_id) = ticket.creator_id {
match store::creator_authors(&db, &[creator_id], &[]).await {
Ok(authors) => {
authors
.get(&creator_id)
.cloned()
.unwrap_or_else(|| comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
})
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up who opened a ticket failed");
return HttpResponse::InternalServerError().finish();
}
}
} else if let Some(remote_actor_id) = ticket.remote_actor_id {
match store::creator_authors(&db, &[], &[remote_actor_id]).await {
Ok(authors) => {
authors
.get(&remote_actor_id)
.cloned()
.unwrap_or_else(|| comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: true,
})
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up remote ticket creator failed");
return HttpResponse::InternalServerError().finish();
}
}
} else {
comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
}
};
let may_change = viewer.as_ref().is_some_and(|viewer| {
tickets::may_change(repository.owner_id, ticket.creator_id, viewer.id)
});
let attachments = match store::list_attachments(&db, ticket.id).await {
Ok(attachments) => attachments
.into_iter()
.map(|a| views::AttachmentView {
uuid: a.uuid.to_string(),
filename: a.filename,
size: a.size,
content_type: a.content_type,
})
.collect(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "listing a ticket's attachments failed");
Vec::new()
}
};
let comments = match comments::store::list_for_ticket(&db, ticket.id).await {
Ok(comments) => {
let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
let remote_actor_ids: Vec<i64> =
comments.iter().filter_map(|c| c.remote_actor_id).collect();
let authors = match comments::store::comment_authors(
&db,
&author_ids,
&remote_actor_ids,
)
.await
{
Ok(authors) => authors,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up comment authors failed");
std::collections::HashMap::new()
}
};
let viewer_id = viewer.as_ref().map(|v| v.id);
comments
.into_iter()
.map(|c| {
// Look up author by either author_id or remote_actor_id
let author = c
.author_id
.and_then(|id| authors.get(&id))
.or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
.cloned()
.unwrap_or_else(|| comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
});
views::CommentView {
base_url: "", // filled later on in [`crate::views::comments::CommentListView`]
uuid: c.uuid.to_string(),
author,
content: c.content.clone(),
content_html: markdown::render(&c.content),
created_at: c.created_at,
updated_at: c.updated_at,
may_change: viewer_id.is_some_and(|vid| {
comments::may_change(c.author_id, repository.owner_id, vid)
}),
}
})
.collect()
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "listing a ticket's comments failed");
Vec::new()
}
};
let may_comment = viewer.is_some();
views::ticket(
viewer_name(viewer.as_ref()),
&owner.name,
&views::TicketDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
number: ticket.number,
summary: ticket.summary,
description_html: markdown::render(ticket.description.as_deref().unwrap_or("")),
creator,
opened_at: ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
state: tickets::state_string(ticket.state),
labels: filed,
may_change,
attachments,
comments,
may_comment,
comment_error: None,
comment_draft: None,
},
)
}
#[get("/~{user_or_team}/{repository_slug}/tickets/{number}/edit")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
)
)]
async fn get_edit_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// Somebody who may not change the ticket is not handed the form to
// change it with either: the form is the way in, not a lesser one.
if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
return HttpResponse::Forbidden().finish();
}
let labels = match store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
// What comes back checked is what a save without touching the boxes
// keeps.
let selected = match store::labels_by_ticket(&db, &[ticket.id]).await {
Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
ticket_form(
Some(¤t),
&owner,
&repository,
&labels,
FormPage {
action: format!("{}/tickets/{number}", tickets_address(&owner, &repository)),
heading: format!("Edit #{number}"),
summary: Some(ticket.summary),
description: Some(ticket.description.unwrap_or_default()),
state: tickets::state_string(ticket.state),
selected,
error: None,
submit: "Save",
},
)
}
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
summary = tracing::field::Empty,
)
)]
pub async fn post_edit_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String)>,
body: String,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let form = match parse_ticket_form(&body) {
Ok(f) => f,
Err(e) => return HttpResponse::BadRequest().body(format!("Invalid form data: {e}")),
};
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
span.record("summary", form.summary.trim().chars().count());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
return HttpResponse::Forbidden().finish();
}
let labels = match store::labels(&db, repository.id).await {
Ok(labels) => labels,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, "listing a repository's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let refused = |page: FormPage, error: String| {
let mut page = page;
page.error = Some(error);
ticket_form(Some(¤t), &owner, &repository, &labels, page)
};
let blank = FormPage {
action: format!("{}/tickets/{number}", tickets_address(&owner, &repository)),
heading: format!("Edit #{number}"),
summary: Some(form.summary.clone()),
description: Some(form.description.clone()),
state: tickets::state_string(ticket.state),
selected: checked_fields(&form.labels),
error: None,
submit: "Save",
};
let summary = match tickets::checked_summary(&form.summary) {
Ok(summary) => summary.to_string(),
Err(error) => return refused(blank, error),
};
let chosen = checked_fields(&form.labels);
for name in &chosen {
if !labels.iter().any(|label| &label.name == name) {
return refused(
blank,
format!("`{name}` is not a label of {owner_name}/{repository_slug}"),
);
}
}
let ids = match label_ids(&db, repository.id, &chosen).await {
Ok(ids) => ids,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "reading a ticket's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let state = tickets::parse_state(Some(&form.state)).unwrap_or(tickets::State::Open);
let description = (!form.description.is_empty()).then_some(form.description.as_str());
// Store the old values before updating
let old_state = ticket.state;
let old_summary = ticket.summary.clone();
let old_description = ticket.description.clone();
let old_resolve_url = ticket.resolve_activity_url.clone();
// Determine what activities to emit and what resolve_activity_url to set
let state_changed = old_state != state;
let summary_changed = old_summary != summary;
let description_changed = old_description.as_deref() != description;
// Calculate the new resolve_activity_url value
let new_resolve_url = if state_changed && state == tickets::State::Closed {
// Open→Closed: will emit Resolve, need to generate and store the URL
let state_guard = state_read(&data).await;
let federation_enabled = crate::activitypub::enabled(&state_guard);
let is_public = repository.visibility.label() == "public";
drop(state_guard);
if federation_enabled && is_public {
let state_guard = state_read(&data).await;
let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());
Some(urls.activity(&owner.name, Uuid::new_v4()))
} else {
None
}
} else if state_changed && state == tickets::State::Open {
// Closed→Open: will emit Undo(Resolve), clear the URL
None
} else {
// No state change or same state: keep existing value
old_resolve_url.clone()
};
if let Err(error) = store::update_ticket(
&db,
ticket.id,
&summary,
description,
state,
new_resolve_url.clone(),
)
.await
{
error!(%error, owner = %owner.name, slug = %repository.slug, number, "editing a ticket failed");
return HttpResponse::InternalServerError().finish();
}
// An edit is the labels the ticket carries now, which is why nothing
// checked takes them all away rather than leaving the old ones in
// place.
if let Err(error) = store::set_ticket_labels(&db, ticket.id, &ids).await {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "labelling a ticket failed");
}
// Emit federation activities
let state_guard = state_read(&data).await;
let federation_enabled = crate::activitypub::enabled(&state_guard);
let is_public = repository.visibility.label() == "public";
drop(state_guard);
if federation_enabled && is_public {
let state_guard = state_read(&data).await;
let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());
let repo_actor =
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(actor) => actor,
Err(e) => {
error!(%e, "failed to get repo actor for federation");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
let parsed_number: i64 = number.parse().unwrap_or(0);
let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, parsed_number);
// Emit Resolve if ticket was closed
if state_changed && state == tickets::State::Closed {
if let Some(resolve_url) = &new_resolve_url {
let resolve = crate::activitypub::types::Resolve {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: resolve_url.clone(),
kind: "Resolve",
actor: repo_actor_url.clone(),
object: ticket_url.clone(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let resolve_json = match serde_json::to_string(&resolve) {
Ok(json) => json,
Err(e) => {
error!(%e, "failed to serialize Resolve activity");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
if let Err(e) = crate::activitypub::forge::activity::publish_activity(
&db,
repo_actor.id,
&resolve_json,
&ticket_url,
)
.await
{
error!(%e, "failed to publish Resolve activity");
}
}
}
// Emit Undo(Resolve) if ticket was reopened
if state_changed && state == tickets::State::Open {
if let Some(old_resolve_url) = old_resolve_url {
let resolve_to_undo = crate::activitypub::types::Resolve {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: old_resolve_url,
kind: "Resolve",
actor: repo_actor_url.clone(),
object: ticket_url.clone(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let undo = crate::activitypub::types::Undo {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: urls.activity(&owner.name, Uuid::new_v4()),
kind: "Undo",
actor: repo_actor_url.clone(),
object: resolve_to_undo,
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let undo_json = match serde_json::to_string(&undo) {
Ok(json) => json,
Err(e) => {
error!(%e, "failed to serialize Undo activity");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
if let Err(e) = crate::activitypub::forge::activity::publish_activity(
&db,
repo_actor.id,
&undo_json,
&ticket_url,
)
.await
{
error!(%e, "failed to publish Undo activity");
}
}
}
// Emit Edit if summary or description changed
if summary_changed || description_changed {
let edit = crate::activitypub::types::Edit {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: urls.activity(&owner.name, Uuid::new_v4()),
kind: "Edit",
actor: repo_actor_url.clone(),
object: serde_json::json!(ticket_url.clone()),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let edit_json = match serde_json::to_string(&edit) {
Ok(json) => json,
Err(e) => {
error!(%e, "failed to serialize Edit activity");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish();
}
};
if let Err(e) = crate::activitypub::forge::activity::publish_activity(
&db,
repo_actor.id,
&edit_json,
&ticket_url,
)
.await
{
error!(%e, "failed to publish Edit activity");
}
}
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish()
}
#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/delete")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
)
)]
async fn post_delete_ticket(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
return HttpResponse::Forbidden().finish();
}
// Emit Delete activity before deletion
let state_guard = state_read(&data).await;
let federation_enabled = crate::activitypub::enabled(&state_guard);
let is_public = repository.visibility.label() == "public";
drop(state_guard);
if federation_enabled && is_public {
let state_guard = state_read(&data).await;
let urls = crate::activitypub::ApUrls::new(&state_guard.public_url().to_string());
let repo_actor =
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(actor) => actor,
Err(e) => {
error!(%e, "failed to get repo actor for federation");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
tickets_address(&owner, &repository),
))
.finish();
}
};
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
let parsed_number: i64 = number.parse().unwrap_or(0);
let ticket_url = urls.repo_ticket(&owner.name, &repository.slug, parsed_number);
let delete = crate::activitypub::types::Delete {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: urls.activity(&owner.name, Uuid::new_v4()),
kind: "Delete",
actor: repo_actor_url.clone(),
object: ticket_url.clone(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
published: chrono::Utc::now().to_rfc3339(),
};
let delete_json = match serde_json::to_string(&delete) {
Ok(json) => json,
Err(e) => {
error!(%e, "failed to serialize Delete activity");
return HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
tickets_address(&owner, &repository),
))
.finish();
}
};
if let Err(e) = crate::activitypub::forge::activity::publish_activity(
&db,
repo_actor.id,
&delete_json,
&ticket_url,
)
.await
{
error!(%e, "failed to publish Delete activity");
}
}
// The number is not given out again, so this says nothing about the
// repository's count: a ticket that is gone is still talked about by
// its number.
if let Err(error) = store::delete_ticket(&db, ticket.id).await {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "deleting a ticket failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
tickets_address(&owner, &repository),
))
.finish()
}
#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
)
)]
async fn post_attachment(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String)>,
mut payload: Multipart,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
return HttpResponse::Forbidden().finish();
}
// Check the current count before accepting a new one.
let current_count = match store::count_attachments(&db, ticket.id).await {
Ok(count) => count as usize,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "counting a ticket's attachments failed");
return HttpResponse::InternalServerError().finish();
}
};
let max_count = tickets::max_attachments_per_ticket();
if current_count >= max_count {
return HttpResponse::BadRequest().body(format!(
"This ticket already has {current_count} attachments (maximum is {max_count})"
));
}
let max_size = tickets::max_attachment_size();
// Read the first file field from the multipart payload.
let mut filename: Option<String> = None;
let mut content_type: Option<String> = None;
let mut data_bytes: Option<Vec<u8>> = None;
while let Some(Ok(mut field)) = payload.next().await {
let field_name = field
.content_disposition()
.and_then(|cd| cd.get_name().map(|s| s.to_string()));
if field_name.as_deref() != Some("attachment") {
continue;
}
filename = field
.content_disposition()
.and_then(|cd| cd.get_filename().map(|s| s.to_string()));
content_type = field.content_type().map(|m| m.to_string());
let mut bytes = Vec::new();
while let Some(chunk) = field.next().await {
match chunk {
Ok(data) => bytes.extend_from_slice(&data),
Err(e) => {
error!(%e, owner = %owner_name, slug = %repository_slug, number, "reading attachment chunk failed");
return HttpResponse::InternalServerError().finish();
}
}
}
if bytes.len() > max_size {
return HttpResponse::BadRequest().body(format!(
"Attachment is too large ({} bytes, maximum is {max_size})",
bytes.len()
));
}
data_bytes = Some(bytes);
break;
}
let Some(filename) = filename else {
return HttpResponse::BadRequest().body("No attachment was uploaded");
};
let Some(content_type) = content_type else {
return HttpResponse::BadRequest().body("Attachment has no content type");
};
let Some(data_bytes) = data_bytes else {
return HttpResponse::BadRequest().body("No attachment was uploaded");
};
match store::create_attachment(&db, ticket.id, &filename, &content_type, data_bytes).await {
Ok(_) => HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "uploading an attachment failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[get("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments/{uuid}")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
uuid = tracing::field::Empty,
)
)]
async fn get_attachment(
data: web::Data<Arc<RwLock<AppState>>>,
viewer: Option<CurrentUser>,
path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number, uuid_str) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
span.record("uuid", uuid_str.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, viewer.as_ref(), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(uuid) = uuid_str.parse::<Uuid>() else {
return HttpResponse::NotFound().body("No such attachment");
};
let db = { state_read(&data).await.db.clone() };
let attachment = match store::find_attachment_by_uuid(&db, uuid).await {
Ok(Some(attachment)) => attachment,
Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
return HttpResponse::InternalServerError().finish();
}
};
// Verify the attachment belongs to a ticket in this repository.
match store::find_ticket(&db, repository.id, attachment.ticket_id).await {
Ok(Some(_)) => {}
Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a ticket for an attachment failed");
return HttpResponse::InternalServerError().finish();
}
}
HttpResponse::Ok()
.content_type(attachment.content_type)
.append_header((
actix_web::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", attachment.filename),
))
.body(attachment.data)
}
#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/attachments/{uuid}/delete")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
uuid = tracing::field::Empty,
)
)]
async fn post_delete_attachment(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number, uuid_str) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
span.record("uuid", uuid_str.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(uuid) = uuid_str.parse::<Uuid>() else {
return HttpResponse::NotFound().body("No such attachment");
};
let db = { state_read(&data).await.db.clone() };
let attachment = match store::find_attachment_by_uuid(&db, uuid).await {
Ok(Some(attachment)) => attachment,
Ok(None) => return HttpResponse::NotFound().body("No such attachment"),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up an attachment failed");
return HttpResponse::InternalServerError().finish();
}
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if attachment.ticket_id != ticket.id {
return HttpResponse::NotFound().body("No such attachment");
}
if !tickets::may_change(repository.owner_id, ticket.creator_id, current.id) {
return HttpResponse::Forbidden().finish();
}
if let Err(error) = store::delete_attachment(&db, attachment.id).await {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting an attachment failed");
return HttpResponse::InternalServerError().finish();
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish()
}
#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/comments")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
)
)]
pub async fn post_comment(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String)>,
body: String,
) -> HttpResponse {
let (owner_name, repository_slug, number) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let db = { state_read(&data).await.db.clone() };
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
// Parse the form body to extract content
let mut content: Option<String> = None;
for pair in body.split('&') {
if pair.is_empty() {
continue;
}
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key == "content" {
content = Some(urlencoding_decode(value).unwrap_or_default());
}
}
let content = content.unwrap_or_default();
let content = match comments::checked_content(&content) {
Ok(c) => c,
Err(error) => {
// Re-render the ticket page with the error
return ticket_with_comment_error(
&data,
Some(¤t),
&owner,
&repository,
&ticket,
Some(error),
Some(content),
)
.await;
}
};
match comments::store::create_on_ticket(&db, current.id, ticket.id, &content).await {
Ok(comment) => {
// Emit Create activity for federation
let state = state_read(&data).await;
if crate::activitypub::enabled(&state) {
let urls = crate::activitypub::ApUrls::new(state.public_url());
// Get or create the repo actor
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(repo_actor) => {
let user_actor_url = urls.actor(&owner.name);
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
// Build the Note document
let note_doc =
crate::activitypub::forge::builders::build_comment_note_document(
&comment,
&owner.name,
&repository.slug,
ticket.number,
true, // is_ticket
&owner,
&urls,
);
// Build the Create activity
let activity_id =
format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
let create_activity = crate::activitypub::types::Create {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: activity_id,
kind: "Create",
actor: user_actor_url.clone(),
object: note_doc,
published: chrono::Utc::now().to_rfc3339(),
to: vec![repo_actor_url.clone()],
cc: vec![urls.repo_followers(&owner.name, &repository.slug)],
};
let create_json =
serde_json::to_string(&create_activity).unwrap_or_default();
// Publish with user as signer, repo followers as delivery target
let comment_url = urls.repo_ticket_comment(
&owner.name,
&repository.slug,
ticket.number,
comment.uuid,
);
if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
&db,
current.id, // signer = user actor
repo_actor.id, // fan-out to repo followers
&create_json,
&comment_url,
).await {
tracing::error!(%e, "failed to publish Create comment activity");
}
}
Err(e) => {
tracing::error!(%e, "failed to get repo actor for comment emission");
}
}
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish()
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "creating a comment failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
uuid = tracing::field::Empty,
)
)]
pub async fn post_edit_comment(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String, String)>,
body: String,
) -> HttpResponse {
let (owner_name, repository_slug, number, uuid_str) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
span.record("uuid", uuid_str.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(uuid) = uuid_str.parse::<Uuid>() else {
return HttpResponse::NotFound().body("No such comment");
};
let db = { state_read(&data).await.db.clone() };
let comment = match comments::store::find_by_uuid(&db, uuid).await {
Ok(Some(comment)) => comment,
Ok(None) => return HttpResponse::NotFound().body("No such comment"),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
return HttpResponse::InternalServerError().finish();
}
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if comment.ticket_id != Some(ticket.id) {
return HttpResponse::NotFound().body("No such comment");
}
if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
return HttpResponse::Forbidden().finish();
}
// Parse the form body to extract content
let mut content: Option<String> = None;
for pair in body.split('&') {
if pair.is_empty() {
continue;
}
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
if key == "content" {
content = Some(urlencoding_decode(value).unwrap_or_default());
}
}
let content = content.unwrap_or_default();
let content = match comments::checked_content(&content) {
Ok(c) => c,
Err(error) => {
// Re-render the ticket page with the error
return ticket_with_comment_error(
&data,
Some(¤t),
&owner,
&repository,
&ticket,
Some(error),
Some(content),
)
.await;
}
};
match comments::store::update(&db, comment.id, content).await {
Ok(()) => {
// Re-fetch the updated comment for federation
let updated_comment = match comments::store::find_by_uuid(&db, uuid).await {
Ok(Some(comment)) => comment,
Ok(None) => {
tracing::error!("comment disappeared after update");
return HttpResponse::InternalServerError().finish();
}
Err(error) => {
tracing::error!(%error, "failed to re-fetch comment after update");
return HttpResponse::InternalServerError().finish();
}
};
// Emit Edit activity for federation
let state = state_read(&data).await;
if crate::activitypub::enabled(&state) {
let urls = crate::activitypub::ApUrls::new(state.public_url());
// Get or create the repo actor
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(repo_actor) => {
let user_actor_url = urls.actor(&owner.name);
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
// Build the updated Note document
let note_doc =
crate::activitypub::forge::builders::build_comment_note_document(
&updated_comment,
&owner.name,
&repository.slug,
ticket.number,
true, // is_ticket
&owner,
&urls,
);
let note_doc_id = note_doc.id.clone();
// Build the Edit activity
let activity_id =
format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
let edit_activity = crate::activitypub::types::Edit {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: activity_id,
kind: "Update",
actor: user_actor_url.clone(),
object: serde_json::to_value(¬e_doc).unwrap_or_default(),
published: chrono::Utc::now().to_rfc3339(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
};
let edit_json = serde_json::to_string(&edit_activity).unwrap_or_default();
// Publish with user as signer, repo followers as delivery target
if let Err(e) = crate::activitypub::forge::activity::publish_activity_with_separate_signer(
&db,
current.id, // signer = user actor
repo_actor.id, // fan-out to repo followers
&edit_json,
¬e_doc_id,
).await {
tracing::error!(%e, "failed to publish Edit comment activity");
}
}
Err(e) => {
tracing::error!(%e, "failed to get repo actor for comment edit emission");
}
}
}
HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish()
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "editing a comment failed");
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/~{user_or_team}/{repository_slug}/tickets/{number}/comments/{uuid}/delete")]
#[tracing::instrument(
skip_all,
fields(
owner = tracing::field::Empty,
repository_slug = tracing::field::Empty,
number = tracing::field::Empty,
uuid = tracing::field::Empty,
)
)]
pub async fn post_delete_comment(
data: web::Data<Arc<RwLock<AppState>>>,
current: CurrentUser,
path: web::Path<(String, String, String, String)>,
) -> HttpResponse {
let (owner_name, repository_slug, number, uuid_str) = path.into_inner();
let span = tracing::Span::current();
span.record("owner", owner_name.as_str());
span.record("repository_slug", repository_slug.as_str());
span.record("number", number.as_str());
span.record("uuid", uuid_str.as_str());
let (owner, repository, _repo_root) =
match readable_repository(&data, Some(¤t), &owner_name, &repository_slug).await {
Ok(found) => found,
Err(response) => return *response,
};
let Ok(uuid) = uuid_str.parse::<Uuid>() else {
return HttpResponse::NotFound().body("No such comment");
};
let db = { state_read(&data).await.db.clone() };
let comment = match comments::store::find_by_uuid(&db, uuid).await {
Ok(Some(comment)) => comment,
Ok(None) => return HttpResponse::NotFound().body("No such comment"),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "looking up a comment failed");
return HttpResponse::InternalServerError().finish();
}
};
let Ok(parsed) = number.parse::<i64>() else {
return no_such_ticket(&number);
};
let ticket = match store::find_ticket(&db, repository.id, parsed).await {
Ok(Some(ticket)) => ticket,
Ok(None) => return no_such_ticket(&number),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number, "looking up a ticket failed");
return HttpResponse::InternalServerError().finish();
}
};
if comment.ticket_id != Some(ticket.id) {
return HttpResponse::NotFound().body("No such comment");
}
if !comments::may_change(comment.author_id, repository.owner_id, current.id) {
return HttpResponse::Forbidden().finish();
}
// Emit Delete activity before deleting
let state = state_read(&data).await;
if crate::activitypub::enabled(&state) {
let urls = crate::activitypub::ApUrls::new(state.public_url());
// Get or create the repo actor
match crate::activitypub::actor::get_or_create_for_repo(&db, repository.id).await {
Ok(repo_actor) => {
let user_actor_url = urls.actor(&owner.name);
let repo_actor_url = urls.repo_actor(&owner.name, &repository.slug);
// Build the comment URL
let comment_url = urls.repo_ticket_comment(
&owner.name,
&repository.slug,
ticket.number,
comment.uuid,
);
// Build the Delete activity
let activity_id = format!("{}/activities/{}", repo_actor_url, uuid::Uuid::new_v4());
let delete_activity = crate::activitypub::types::Delete {
context: crate::activitypub::types::FORGEFED_CONTEXT.to_vec(),
id: activity_id,
kind: "Delete",
actor: user_actor_url.clone(),
object: comment_url.clone(),
published: chrono::Utc::now().to_rfc3339(),
to: vec![repo_actor_url.clone()],
cc: Some(vec![urls.repo_followers(&owner.name, &repository.slug)]),
};
let delete_json = serde_json::to_string(&delete_activity).unwrap_or_default();
// Publish with user as signer, repo followers as delivery target
if let Err(e) =
crate::activitypub::forge::activity::publish_activity_with_separate_signer(
&db,
current.id, // signer = user actor
repo_actor.id, // fan-out to repo followers
&delete_json,
&comment_url,
)
.await
{
tracing::error!(%e, "failed to publish Delete comment activity");
}
}
Err(e) => {
tracing::error!(%e, "failed to get repo actor for comment delete emission");
}
}
}
match comments::store::delete(&db, comment.id).await {
Ok(_) => HttpResponse::SeeOther()
.append_header((
actix_web::http::header::LOCATION,
format!("{}/{number}", tickets_address(&owner, &repository)),
))
.finish(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, %uuid, "deleting a comment failed");
HttpResponse::InternalServerError().finish()
}
}
}
/// Re-renders the ticket page with a comment error and draft preserved
async fn ticket_with_comment_error(
data: &web::Data<Arc<RwLock<AppState>>>,
viewer: Option<&CurrentUser>,
owner: &users::Model,
repository: &Repository,
ticket: &crate::entities::tickets::Model,
error: Option<String>,
draft: Option<String>,
) -> HttpResponse {
let db = { state_read(data).await.db.clone() };
let filed = match store::labels_by_ticket(&db, &[ticket.id]).await {
Ok(filed) => filed.get(&ticket.id).cloned().unwrap_or_default(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "reading a ticket's labels failed");
return HttpResponse::InternalServerError().finish();
}
};
let creator = if let Some(creator_id) = ticket.creator_id {
match store::creator_authors(&db, &[creator_id], &[]).await {
Ok(creators) => creators.get(&creator_id).cloned().unwrap_or_else(|| {
crate::comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
}
}),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up who created a ticket failed");
return HttpResponse::InternalServerError().finish();
}
}
} else if let Some(remote_actor_id) = ticket.remote_actor_id {
match store::creator_authors(&db, &[], &[remote_actor_id]).await {
Ok(creators) => creators.get(&remote_actor_id).cloned().unwrap_or_else(|| {
crate::comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: true,
}
}),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up remote ticket creator failed");
return HttpResponse::InternalServerError().finish();
}
}
} else {
crate::comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
}
};
let may_change = viewer.is_some_and(|viewer| {
tickets::may_change(repository.owner_id, ticket.creator_id, viewer.id)
});
let attachments = match store::list_attachments(&db, ticket.id).await {
Ok(attachments) => attachments
.into_iter()
.map(|a| views::AttachmentView {
uuid: a.uuid.to_string(),
filename: a.filename,
size: a.size,
content_type: a.content_type,
})
.collect(),
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "listing a ticket's attachments failed");
Vec::new()
}
};
let comments = match comments::store::list_for_ticket(&db, ticket.id).await {
Ok(comments) => {
let author_ids: Vec<i64> = comments.iter().filter_map(|c| c.author_id).collect();
let remote_actor_ids: Vec<i64> =
comments.iter().filter_map(|c| c.remote_actor_id).collect();
let authors = match comments::store::comment_authors(
&db,
&author_ids,
&remote_actor_ids,
)
.await
{
Ok(authors) => authors,
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "looking up comment authors failed");
std::collections::HashMap::new()
}
};
let viewer_id = viewer.map(|v| v.id);
comments
.into_iter()
.map(|c| {
let author = c
.author_id
.and_then(|id| authors.get(&id))
.or_else(|| c.remote_actor_id.and_then(|id| authors.get(&id)))
.cloned()
.unwrap_or_else(|| comments::CommentAuthor {
name: String::from("someone"),
actor_url: None,
is_remote: false,
});
views::CommentView {
base_url: "", // filled later on in [`crate::views::comments::CommentListView`]
uuid: c.uuid.to_string(),
author,
content: c.content.clone(),
content_html: markdown::render(&c.content),
created_at: c.created_at,
updated_at: c.updated_at,
may_change: viewer_id.is_some_and(|vid| {
comments::may_change(c.author_id, repository.owner_id, vid)
}),
}
})
.collect()
}
Err(error) => {
error!(%error, owner = %owner.name, slug = %repository.slug, number = ticket.number, "listing a ticket's comments failed");
Vec::new()
}
};
let may_comment = viewer.is_some();
views::ticket(
viewer_name(viewer),
&owner.name,
&views::TicketDetail {
slug: repository.slug.clone(),
visibility: repository.visibility.label(),
default_branch: repository.default_branch.clone(),
number: ticket.number,
summary: ticket.summary.clone(),
description_html: markdown::render(ticket.description.as_deref().unwrap_or("")),
creator,
opened_at: ticket.created_at.format("%Y-%m-%d %H:%M UTC").to_string(),
state: tickets::state_string(ticket.state),
labels: filed,
may_change,
attachments,
comments,
may_comment,
comment_error: error,
comment_draft: draft,
},
)
}