use diesel::sql_types::{BigInt, Nullable, Text, Timestamp};
use diesel_async::RunQueryDsl as _;
use scoped_futures::ScopedFutureExt as _;
use crate::counter_cache::{
CounterCacheSpec, TenantScope, counter_cache_apply_delta, is_plain_identifier, quote_ident,
};
use crate::db::{RuntimeConnection, scoped_immediate_transaction};
use crate::{AutumnError, AutumnResult};
#[cfg(not(feature = "sqlite"))]
fn ph(n: usize) -> String {
format!("${n}")
}
#[cfg(feature = "sqlite")]
fn ph(_n: usize) -> String {
"?".to_owned()
}
#[cfg(not(feature = "sqlite"))]
const FOR_NO_KEY_UPDATE: &str = " FOR NO KEY UPDATE";
#[cfg(feature = "sqlite")]
const FOR_NO_KEY_UPDATE: &str = "";
pub trait CommentAuthorKey: sealed::Sealed {}
impl CommentAuthorKey for i64 {}
impl CommentAuthorKey for i32 {}
mod sealed {
pub trait Sealed {}
impl Sealed for i64 {}
impl Sealed for i32 {}
}
const DELETED_AT: &str = "deleted_at";
const RECURSION_GUARD: i64 = 1_000;
pub const DEFAULT_MAX_DEPTH: u32 = 5;
pub const DEFAULT_MAX_BODY_BYTES: usize = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommentableSpec {
pub comments_table: &'static str,
pub comment_pk: &'static str,
pub type_column: &'static str,
pub id_column: &'static str,
pub parent_column: &'static str,
pub author_column: &'static str,
pub body_column: &'static str,
pub created_at_column: &'static str,
pub soft_delete: bool,
pub parent_table: &'static str,
pub parent_pk: &'static str,
pub parent_soft_delete: bool,
pub counter_column: Option<&'static str>,
pub parent_tenant_column: Option<&'static str>,
pub parent_sharded: bool,
pub author_table: Option<&'static str>,
pub author_pk: &'static str,
pub author_name_column: Option<&'static str>,
pub max_depth: u32,
pub max_body_bytes: usize,
}
impl CommentableSpec {
const fn idents(&self) -> [(&'static str, Option<&'static str>); 15] {
[
("comments_table", Some(self.comments_table)),
("comment_pk", Some(self.comment_pk)),
("type_column", Some(self.type_column)),
("id_column", Some(self.id_column)),
("parent_column", Some(self.parent_column)),
("author_column", Some(self.author_column)),
("body_column", Some(self.body_column)),
("created_at_column", Some(self.created_at_column)),
("parent_table", Some(self.parent_table)),
("parent_pk", Some(self.parent_pk)),
("author_pk", Some(self.author_pk)),
("counter_column", self.counter_column),
("parent_tenant_column", self.parent_tenant_column),
("author_table", self.author_table),
("author_name_column", self.author_name_column),
]
}
pub fn validate(&self) -> AutumnResult<()> {
for (field, value) in self.idents() {
let Some(value) = value else { continue };
if !is_plain_identifier(value) {
return Err(AutumnError::internal_server_error_msg(format!(
"commentable spec field `{field}` is {value:?}, which is not a plain SQL \
identifier; it would be spliced verbatim into generated SQL"
)));
}
}
Ok(())
}
fn live_comments(&self, alias: &str) -> String {
if self.soft_delete {
format!(" AND {alias}.{} IS NULL", quote_ident(DELETED_AT))
} else {
String::new()
}
}
fn counter_spec(&self, counter_column: &'static str) -> CounterCacheSpec<Comment> {
CounterCacheSpec {
child_table: self.comments_table,
child_pk: self.comment_pk,
child_soft_delete: self.soft_delete,
fk_column: self.id_column,
parent_table: self.parent_table,
parent_pk: self.parent_pk,
counter_column,
fk_of: |_| None,
pk_of: |comment| comment.id,
live_of: |_| true,
tenant_column: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, diesel::QueryableByName)]
pub struct Comment {
#[diesel(sql_type = BigInt)]
pub id: i64,
#[diesel(sql_type = Nullable<BigInt>)]
pub parent_id: Option<i64>,
#[diesel(sql_type = BigInt)]
pub author_id: i64,
#[diesel(sql_type = Text)]
pub body: String,
#[diesel(sql_type = Timestamp)]
pub created_at: chrono::NaiveDateTime,
#[diesel(sql_type = Nullable<Text>)]
pub author_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentNode {
pub comment: Comment,
pub depth: usize,
pub replies: Vec<Self>,
}
#[derive(diesel::QueryableByName)]
struct DepthRow {
#[diesel(sql_type = Nullable<BigInt>)]
depth: Option<i64>,
}
#[derive(diesel::QueryableByName)]
struct ParentRow {
#[allow(dead_code)]
#[diesel(sql_type = BigInt)]
id: i64,
}
#[derive(diesel::QueryableByName)]
struct CountRow {
#[diesel(sql_type = BigInt)]
count: i64,
}
#[derive(diesel::QueryableByName)]
struct SubtreeRow {
#[diesel(sql_type = BigInt)]
id: i64,
#[diesel(sql_type = BigInt)]
depth: i64,
}
#[derive(diesel::QueryableByName)]
struct TargetRow {
#[diesel(sql_type = Text)]
commentable_type: String,
#[diesel(sql_type = BigInt)]
commentable_id: i64,
}
#[doc(hidden)]
pub struct CommentableDescriptor {
pub type_name: &'static str,
pub model: fn() -> &'static str,
pub spec: &'static CommentableSpec,
}
inventory::collect!(CommentableDescriptor);
#[cfg(feature = "db")]
pub struct RepositoryFacts {
pub model: fn() -> &'static str,
pub sharded: bool,
pub tenant_scoped: bool,
pub soft_delete: bool,
}
#[cfg(feature = "db")]
inventory::collect!(RepositoryFacts);
#[cfg(feature = "db")]
#[must_use]
pub fn model_has_sharded_repository(model: &str) -> bool {
repository_facts_for(model).any(|facts| facts.sharded)
}
#[cfg(feature = "db")]
fn repository_facts_for(model: &str) -> impl Iterator<Item = &'static RepositoryFacts> {
inventory::iter::<RepositoryFacts>().filter(move |facts| (facts.model)() == model)
}
#[cfg(feature = "db")]
#[must_use]
pub fn model_requires_tenant(model: &str, has_tenant_column: bool) -> bool {
requires_tenant_from(repository_facts_for(model), has_tenant_column)
}
#[cfg(feature = "db")]
fn requires_tenant_from<'a>(
facts: impl Iterator<Item = &'a RepositoryFacts>,
has_tenant_column: bool,
) -> bool {
if !has_tenant_column {
return false;
}
let mut registered = false;
for entry in facts {
if entry.tenant_scoped {
return true;
}
registered = true;
}
!registered
}
#[cfg(feature = "db")]
#[must_use]
pub fn model_soft_deletes(model: &str) -> Option<bool> {
soft_deletes_from(repository_facts_for(model))
}
#[cfg(feature = "db")]
fn soft_deletes_from<'a>(facts: impl Iterator<Item = &'a RepositoryFacts>) -> Option<bool> {
let mut any = false;
let mut registered = false;
for entry in facts {
any |= entry.soft_delete;
registered = true;
}
registered.then_some(any)
}
#[must_use]
pub fn commentable_spec_for(type_name: &str) -> Option<&'static CommentableSpec> {
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| descriptor.type_name == type_name)
.map(|descriptor| descriptor.spec)
}
#[cfg(feature = "db")]
#[must_use]
pub fn commentable_model_for_spec(spec: &CommentableSpec) -> Option<&'static str> {
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| std::ptr::eq(descriptor.spec, spec))
.map(|descriptor| (descriptor.model)())
}
#[cfg(feature = "db")]
#[must_use]
pub fn commentable_model_for(type_name: &str) -> Option<&'static str> {
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| descriptor.type_name == type_name)
.map(|descriptor| (descriptor.model)())
}
#[must_use]
pub fn registered_commentable_types() -> Vec<&'static str> {
inventory::iter::<CommentableDescriptor>()
.map(|descriptor| descriptor.type_name)
.collect()
}
#[must_use]
pub fn duplicate_commentable_type() -> Option<&'static str> {
let mut seen = std::collections::HashSet::new();
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| !seen.insert(descriptor.type_name))
.map(|descriptor| descriptor.type_name)
}
#[must_use]
pub fn sharded_commentable_type() -> Option<&'static str> {
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| {
descriptor.spec.parent_sharded || model_has_sharded_repository((descriptor.model)())
})
.map(|descriptor| descriptor.type_name)
}
#[cfg(feature = "db")]
fn assert_unique_discriminators() {
static CHECKED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
CHECKED.get_or_init(|| {
assert!(
duplicate_commentable_storage().is_none(),
"two #[commentable] models share the commentable_type {:?} **and** the same \
comments storage, so the table cannot tell their rows apart: each would read \
and delete the other's comments. Give one of them \
`#[commentable(type_name = \"…\")]`, or point it at its own \
`#[commentable(table = …)]`.",
duplicate_commentable_storage().unwrap_or_default(),
);
});
}
#[cfg(feature = "db")]
#[must_use]
pub fn duplicate_commentable_storage() -> Option<&'static str> {
let mut seen = std::collections::HashSet::new();
inventory::iter::<CommentableDescriptor>()
.find(|descriptor| {
let spec = descriptor.spec;
!seen.insert((
descriptor.type_name,
spec.comments_table,
spec.type_column,
spec.id_column,
))
})
.map(|descriptor| descriptor.type_name)
}
#[allow(clippy::too_many_arguments)] pub async fn add_comment(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
author_id: i64,
body: &str,
reply_to: Option<i64>,
tenant: Option<&str>,
) -> AutumnResult<Comment> {
assert_unique_discriminators();
spec.validate()?;
let body = body.trim();
if body.is_empty() {
return Err(AutumnError::unprocessable_msg("Comment cannot be empty"));
}
if body.len() > spec.max_body_bytes {
return Err(AutumnError::unprocessable_msg(format!(
"Comment is too long (limit {} bytes)",
spec.max_body_bytes
)));
}
let spec = *spec;
let parent_type = parent_type.to_owned();
let body = body.to_owned();
let tenant = tenant.map(str::to_owned);
scoped_immediate_transaction::<Comment, AutumnError, _>(conn, |conn| {
async move {
lock_parent(conn, &spec, parent_id, tenant.as_deref()).await?;
if let Some(reply_to) = reply_to {
let parent_depth =
comment_depth(conn, &spec, &parent_type, parent_id, reply_to).await?;
let depth = parent_depth.saturating_add(1);
if depth > i64::from(spec.max_depth) {
return Err(AutumnError::unprocessable_msg(format!(
"Replies are nested at most {} deep here",
spec.max_depth
)));
}
}
let inserted = insert_comment(
conn,
&spec,
&parent_type,
parent_id,
author_id,
&body,
reply_to,
)
.await?;
if let Some(counter_column) = spec.counter_column {
counter_cache_apply_delta(
conn,
&spec.counter_spec(counter_column),
parent_id,
1,
TenantScope::Unscoped,
)
.await?;
}
Ok(inserted)
}
.scope_boxed()
})
.await
}
pub async fn delete_comment(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
comment_id: i64,
tenant: Option<&str>,
) -> AutumnResult<usize> {
assert_unique_discriminators();
spec.validate()?;
let spec = *spec;
let parent_type = parent_type.to_owned();
let tenant = tenant.map(str::to_owned);
scoped_immediate_transaction::<usize, AutumnError, _>(conn, |conn| {
async move {
let comments = quote_ident(spec.comments_table);
let pk = quote_ident(spec.comment_pk);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let target: Option<TargetRow> = diesel::sql_query(format!(
"SELECT {type_column} AS commentable_type, {id_column} AS commentable_id \
FROM {comments} WHERE {pk} = {}",
ph(1)
))
.bind::<BigInt, _>(comment_id)
.get_result::<TargetRow>(conn)
.await
.optional_row()?;
let Some(target) = target
.filter(|t| t.commentable_type == parent_type && t.commentable_id == parent_id)
else {
return Err(AutumnError::not_found_msg("Comment not found"));
};
lock_parent(conn, &spec, target.commentable_id, tenant.as_deref()).await?;
let removed = delete_subtree(conn, &spec, &parent_type, parent_id, comment_id).await?;
if removed > 0
&& let Some(counter_column) = spec.counter_column
{
let delta = i64::try_from(removed).unwrap_or(i64::MAX);
counter_cache_apply_delta(
conn,
&spec.counter_spec(counter_column),
target.commentable_id,
-delta,
TenantScope::Unscoped,
)
.await?;
}
Ok(removed)
}
.scope_boxed()
})
.await
}
pub async fn recompute_comment_count(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
tenant: Option<&str>,
) -> AutumnResult<i64> {
assert_unique_discriminators();
spec.validate()?;
let Some(counter_column) = spec.counter_column else {
probe_parent(conn, spec, parent_id, tenant, false).await?;
return Ok(0);
};
let spec = *spec;
let parent_type = parent_type.to_owned();
let tenant = tenant.map(str::to_owned);
scoped_immediate_transaction::<i64, AutumnError, _>(conn, |conn| {
async move {
lock_parent(conn, &spec, parent_id, tenant.as_deref()).await?;
let comments = quote_ident(spec.comments_table);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let parent_table = quote_ident(spec.parent_table);
let parent_pk = quote_ident(spec.parent_pk);
let counter = quote_ident(counter_column);
let live = spec.live_comments("c");
let truth: CountRow = diesel::sql_query(format!(
"SELECT COUNT(*) AS count FROM {comments} AS c \
WHERE c.{type_column} = {} AND c.{id_column} = {}{live}",
ph(1),
ph(2),
))
.bind::<Text, _>(&parent_type)
.bind::<BigInt, _>(parent_id)
.get_result::<CountRow>(conn)
.await
.map_err(AutumnError::from)?;
diesel::sql_query(format!(
"UPDATE {parent_table} SET {counter} = {} WHERE {parent_pk} = {}",
ph(1),
ph(2),
))
.bind::<BigInt, _>(truth.count)
.bind::<BigInt, _>(parent_id)
.execute(conn)
.await
.map_err(AutumnError::from)?;
Ok(truth.count)
}
.scope_boxed()
})
.await
}
pub async fn comment_thread(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
tenant: Option<&str>,
) -> AutumnResult<Vec<CommentNode>> {
assert_unique_discriminators();
spec.validate()?;
probe_parent(conn, spec, parent_id, tenant, false).await?;
let comments = quote_ident(spec.comments_table);
let pk = quote_ident(spec.comment_pk);
let parent_column = quote_ident(spec.parent_column);
let author_column = quote_ident(spec.author_column);
let body_column = quote_ident(spec.body_column);
let created_at = quote_ident(spec.created_at_column);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let live = spec.live_comments("c");
let (author_join, author_name) = author_name_fragments(spec);
let sql = format!(
"SELECT c.{pk} AS id, c.{parent_column} AS parent_id, \
c.{author_column} AS author_id, c.{body_column} AS body, \
c.{created_at} AS created_at, {author_name} AS author_name \
FROM {comments} AS c{author_join} \
WHERE c.{type_column} = {} AND c.{id_column} = {}{live} \
ORDER BY c.{created_at} ASC, c.{pk} ASC",
ph(1),
ph(2)
);
let rows: Vec<Comment> = diesel::sql_query(sql)
.bind::<Text, _>(parent_type)
.bind::<BigInt, _>(parent_id)
.load::<Comment>(conn)
.await
.map_err(AutumnError::from)?;
Ok(nest(rows))
}
fn author_name_fragments(spec: &CommentableSpec) -> (String, String) {
match (spec.author_table, spec.author_name_column) {
(Some(table), Some(column)) => (
format!(
" LEFT JOIN {} AS __autumn_cmt_author ON __autumn_cmt_author.{} = c.{}",
quote_ident(table),
quote_ident(spec.author_pk),
quote_ident(spec.author_column),
),
format!("__autumn_cmt_author.{}", quote_ident(column)),
),
_ => (String::new(), "CAST(NULL AS TEXT)".to_owned()),
}
}
fn nest(rows: Vec<Comment>) -> Vec<CommentNode> {
use std::collections::HashMap;
let index: HashMap<i64, usize> = rows
.iter()
.enumerate()
.map(|(position, comment)| (comment.id, position))
.collect();
let mut children: Vec<Vec<usize>> = vec![Vec::new(); rows.len()];
let mut roots: Vec<usize> = Vec::new();
for (position, comment) in rows.iter().enumerate() {
match comment.parent_id.and_then(|parent| index.get(&parent)) {
Some(&parent) if parent < position => children[parent].push(position),
_ => roots.push(position),
}
}
let mut comments: Vec<Option<Comment>> = rows.into_iter().map(Some).collect();
build_nodes(&roots, 0, &children, &mut comments)
}
const MAX_NESTING: usize = 1_000;
fn flatten_subtree(
positions: &[usize],
depth: usize,
children: &[Vec<usize>],
comments: &mut [Option<Comment>],
) -> Vec<CommentNode> {
let mut pending: Vec<usize> = positions.iter().rev().copied().collect();
let mut out = Vec::new();
while let Some(position) = pending.pop() {
let Some(comment) = comments[position].take() else {
continue;
};
out.push(CommentNode {
comment,
depth,
replies: Vec::new(),
});
pending.extend(children[position].iter().rev().copied());
}
out
}
fn build_nodes(
positions: &[usize],
depth: usize,
children: &[Vec<usize>],
comments: &mut [Option<Comment>],
) -> Vec<CommentNode> {
positions
.iter()
.filter_map(|&position| {
let comment = comments[position].take()?;
let replies = if depth < MAX_NESTING {
build_nodes(&children[position], depth + 1, children, comments)
} else {
flatten_subtree(&children[position], depth, children, comments)
};
Some(CommentNode {
comment,
depth,
replies,
})
})
.collect()
}
async fn probe_parent(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_id: i64,
tenant: Option<&str>,
lock: bool,
) -> AutumnResult<()> {
let parent_table = quote_ident(spec.parent_table);
let parent_pk = quote_ident(spec.parent_pk);
let soft_deletes = commentable_model_for_spec(spec)
.and_then(model_soft_deletes)
.unwrap_or(spec.parent_soft_delete);
let live = if soft_deletes {
format!(" AND {parent_table}.{} IS NULL", quote_ident(DELETED_AT))
} else {
String::new()
};
let lock_clause = if lock { FOR_NO_KEY_UPDATE } else { "" };
let found: Option<ParentRow> =
if let (Some(column), Some(tenant)) = (spec.parent_tenant_column, tenant) {
{
let sql = format!(
"SELECT {parent_pk} AS id FROM {parent_table} \
WHERE {parent_table}.{parent_pk} = {} \
AND {parent_table}.{} = {}{live}{lock_clause}",
ph(1),
quote_ident(column),
ph(2),
);
diesel::sql_query(sql)
.bind::<BigInt, _>(parent_id)
.bind::<Text, _>(tenant)
.get_result::<ParentRow>(conn)
.await
.optional_row()?
}
} else {
{
let sql = format!(
"SELECT {parent_pk} AS id FROM {parent_table} \
WHERE {parent_table}.{parent_pk} = {}{live}{lock_clause}",
ph(1),
);
diesel::sql_query(sql)
.bind::<BigInt, _>(parent_id)
.get_result::<ParentRow>(conn)
.await
.optional_row()?
}
};
if found.is_none() {
return Err(AutumnError::not_found_msg("Comment target not found"));
}
Ok(())
}
async fn lock_parent(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_id: i64,
tenant: Option<&str>,
) -> AutumnResult<()> {
probe_parent(conn, spec, parent_id, tenant, true).await
}
async fn comment_depth(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
comment_id: i64,
) -> AutumnResult<i64> {
let comments = quote_ident(spec.comments_table);
let pk = quote_ident(spec.comment_pk);
let parent_column = quote_ident(spec.parent_column);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let live = spec.live_comments("c");
let sql = format!(
"WITH RECURSIVE __autumn_cmt_anc(id, parent_id, depth) AS (\
SELECT c.{pk}, c.{parent_column}, CAST(0 AS BIGINT) FROM {comments} AS c \
WHERE c.{pk} = {} AND c.{type_column} = {} AND c.{id_column} = {}{live} \
UNION ALL \
SELECT p.{pk}, p.{parent_column}, a.depth + 1 \
FROM {comments} AS p JOIN __autumn_cmt_anc AS a ON p.{pk} = a.parent_id \
WHERE a.depth < {RECURSION_GUARD}\
) SELECT CAST(MAX(depth) AS BIGINT) AS depth FROM __autumn_cmt_anc",
ph(1),
ph(2),
ph(3),
);
let row: DepthRow = diesel::sql_query(sql)
.bind::<BigInt, _>(comment_id)
.bind::<Text, _>(parent_type)
.bind::<BigInt, _>(parent_id)
.get_result::<DepthRow>(conn)
.await
.map_err(AutumnError::from)?;
let depth = row.depth.ok_or_else(|| {
AutumnError::unprocessable_msg("Cannot reply to that comment: it is not on this record")
})?;
if depth >= RECURSION_GUARD {
return Err(AutumnError::unprocessable_msg(
"This thread is nested too deeply to reply to",
));
}
Ok(depth)
}
#[allow(clippy::too_many_arguments)] async fn insert_comment(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
author_id: i64,
body: &str,
reply_to: Option<i64>,
) -> AutumnResult<Comment> {
let comments = quote_ident(spec.comments_table);
let pk = quote_ident(spec.comment_pk);
let parent_column = quote_ident(spec.parent_column);
let author_column = quote_ident(spec.author_column);
let body_column = quote_ident(spec.body_column);
let created_at = quote_ident(spec.created_at_column);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let resolves_author_name = spec.author_table.is_some() && spec.author_name_column.is_some();
let author_name = match (spec.author_table, spec.author_name_column) {
(Some(table), Some(column)) => format!(
"(SELECT {} FROM {} WHERE {} = {})",
quote_ident(column),
quote_ident(table),
quote_ident(spec.author_pk),
ph(6),
),
_ => "CAST(NULL AS TEXT)".to_owned(),
};
let sql = format!(
"INSERT INTO {comments} \
({type_column}, {id_column}, {parent_column}, {author_column}, {body_column}) \
VALUES ({}, {}, {}, {}, {}) \
RETURNING {pk} AS id, {parent_column} AS parent_id, {author_column} AS author_id, \
{body_column} AS body, {created_at} AS created_at, \
{author_name} AS author_name",
ph(1),
ph(2),
ph(3),
ph(4),
ph(5),
);
let query = diesel::sql_query(sql)
.bind::<Text, _>(parent_type)
.bind::<BigInt, _>(parent_id)
.bind::<Nullable<BigInt>, _>(reply_to)
.bind::<BigInt, _>(author_id)
.bind::<Text, _>(body);
if resolves_author_name {
query
.bind::<BigInt, _>(author_id)
.get_result::<Comment>(conn)
.await
.map_err(AutumnError::from)
} else {
query
.get_result::<Comment>(conn)
.await
.map_err(AutumnError::from)
}
}
async fn delete_subtree(
conn: &mut RuntimeConnection,
spec: &CommentableSpec,
parent_type: &str,
parent_id: i64,
comment_id: i64,
) -> AutumnResult<usize> {
let comments = quote_ident(spec.comments_table);
let pk = quote_ident(spec.comment_pk);
let parent_column = quote_ident(spec.parent_column);
let type_column = quote_ident(spec.type_column);
let id_column = quote_ident(spec.id_column);
let deleted_at = quote_ident(DELETED_AT);
let anchor_live = spec.live_comments("c");
let descendant_live = spec.live_comments("d");
let ids: Vec<SubtreeRow> = diesel::sql_query(format!(
"WITH RECURSIVE __autumn_cmt_sub(id, depth) AS (\
SELECT c.{pk}, CAST(0 AS BIGINT) FROM {comments} AS c \
WHERE c.{pk} = {} AND c.{type_column} = {} AND c.{id_column} = {}{anchor_live} \
UNION ALL \
SELECT d.{pk}, s.depth + 1 \
FROM {comments} AS d JOIN __autumn_cmt_sub AS s ON d.{parent_column} = s.id \
WHERE s.depth < {RECURSION_GUARD} \
AND d.{type_column} = {} AND d.{id_column} = {}{descendant_live}\
) SELECT id, depth FROM __autumn_cmt_sub",
ph(1),
ph(2),
ph(3),
ph(4),
ph(5),
))
.bind::<BigInt, _>(comment_id)
.bind::<Text, _>(parent_type)
.bind::<BigInt, _>(parent_id)
.bind::<Text, _>(parent_type)
.bind::<BigInt, _>(parent_id)
.load::<SubtreeRow>(conn)
.await
.map_err(AutumnError::from)?;
let mut ids = ids;
ids.sort_by_key(|row| (row.id, row.depth));
ids.dedup_by_key(|row| row.id);
if !spec.soft_delete && ids.iter().any(|row| row.depth >= RECURSION_GUARD) {
return Err(AutumnError::unprocessable_msg(format!(
"comment {comment_id} has a reply chain deeper than {RECURSION_GUARD}, which this \
hard-delete path cannot remove without leaving the parent's comment counter wrong: \
the database would cascade past what the traversal can see. Shorten or repair the \
chain, or run the delete in batches from the leaves."
)));
}
if ids.is_empty() {
return Ok(0);
}
let ids: Vec<i64> = ids.into_iter().map(|row| row.id).collect();
let id_list = ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
if spec.soft_delete {
diesel::sql_query(format!(
"UPDATE {comments} SET {deleted_at} = {} \
WHERE {pk} IN ({id_list}) AND {deleted_at} IS NULL",
ph(1),
))
.bind::<Timestamp, _>(chrono::Utc::now().naive_utc())
.execute(conn)
.await
.map_err(AutumnError::from)?;
} else {
diesel::sql_query(format!("DELETE FROM {comments} WHERE {pk} IN ({id_list})"))
.execute(conn)
.await
.map_err(AutumnError::from)?;
}
Ok(ids.len())
}
trait OptionalRow<T> {
fn optional_row(self) -> AutumnResult<Option<T>>;
}
impl<T> OptionalRow<T> for Result<T, diesel::result::Error> {
fn optional_row(self) -> AutumnResult<Option<T>> {
match self {
Ok(value) => Ok(Some(value)),
Err(diesel::result::Error::NotFound) => Ok(None),
Err(err) => Err(AutumnError::from(err)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn comment(id: i64, parent_id: Option<i64>, body: &str) -> Comment {
Comment {
id,
parent_id,
author_id: 1,
body: body.to_owned(),
created_at: chrono::NaiveDateTime::default(),
author_name: None,
}
}
fn walk(nodes: &[CommentNode], out: &mut Vec<(usize, String)>) {
for node in nodes {
out.push((node.depth, node.comment.body.clone()));
walk(&node.replies, out);
}
}
fn flatten(nodes: &[CommentNode]) -> Vec<(usize, String)> {
let mut out = Vec::new();
walk(nodes, &mut out);
out
}
#[test]
fn nest_preserves_input_order_at_every_level() {
let rows = vec![
comment(1, None, "a"),
comment(2, Some(1), "a1"),
comment(3, Some(2), "a1x"),
comment(4, Some(1), "a2"),
comment(5, None, "b"),
];
assert_eq!(
flatten(&nest(rows)),
vec![
(0, "a".to_owned()),
(1, "a1".to_owned()),
(2, "a1x".to_owned()),
(1, "a2".to_owned()),
(0, "b".to_owned()),
]
);
}
#[test]
fn nest_promotes_an_orphan_rather_than_dropping_it() {
let rows = vec![comment(1, None, "a"), comment(2, Some(99), "orphan")];
assert_eq!(
flatten(&nest(rows)),
vec![(0, "a".to_owned()), (0, "orphan".to_owned())]
);
}
#[test]
fn nest_survives_a_self_referential_row() {
let rows = vec![comment(1, Some(1), "self")];
assert_eq!(flatten(&nest(rows)), vec![(0, "self".to_owned())]);
}
#[test]
fn nest_keeps_both_rows_of_a_two_node_cycle() {
let rows = vec![comment(1, Some(2), "a"), comment(2, Some(1), "b")];
let flattened = flatten(&nest(rows));
assert_eq!(flattened.len(), 2, "no comment may vanish: {flattened:?}");
assert!(
flattened.iter().any(|(_, body)| body == "a"),
"{flattened:?}"
);
assert!(
flattened.iter().any(|(_, body)| body == "b"),
"{flattened:?}"
);
}
#[test]
fn nest_keeps_every_row_of_a_longer_cycle() {
let rows = vec![
comment(1, Some(3), "a"),
comment(2, Some(1), "b"),
comment(3, Some(2), "c"),
];
assert_eq!(flatten(&nest(rows)).len(), 3);
}
#[test]
fn nest_of_an_empty_thread_is_empty() {
assert!(nest(Vec::new()).is_empty());
}
#[cfg(all(feature = "db", feature = "maud"))]
#[test]
fn only_a_relative_single_slash_path_is_a_safe_return_target() {
for safe in [
"/",
"/r/rust/posts/hello",
"/posts?page=2",
"/posts#comment-7",
] {
assert!(is_safe_return_path(safe), "{safe:?} should be safe");
}
for unsafe_path in [
"",
"//evil.example",
"https://evil.example",
"/\\evil.example",
"/a\\b",
"/\tevil",
"/\t/evil.example",
"/ok\r\nSet-Cookie: x=1",
"/ok\n",
"/with space",
"/\u{1}",
"/\u{7f}",
"evil.example",
] {
assert!(
!is_safe_return_path(unsafe_path),
"{unsafe_path:?} must be refused"
);
}
}
#[test]
fn quoted_identifiers_are_rejected_before_they_reach_sql() {
assert!(is_plain_identifier("comment_count"));
assert!(!is_plain_identifier("comment_count\"; DROP TABLE posts --"));
assert!(!is_plain_identifier(""));
assert!(!is_plain_identifier("1bad"));
}
fn sample_spec() -> CommentableSpec {
CommentableSpec {
comments_table: "comments",
comment_pk: "id",
type_column: "commentable_type",
id_column: "commentable_id",
parent_column: "parent_id",
author_column: "author_id",
body_column: "body",
created_at_column: "created_at",
soft_delete: true,
parent_table: "posts",
parent_pk: "id",
parent_soft_delete: false,
counter_column: Some("comment_count"),
parent_tenant_column: None,
parent_sharded: false,
author_table: Some("users"),
author_pk: "id",
author_name_column: Some("username"),
max_depth: DEFAULT_MAX_DEPTH,
max_body_bytes: DEFAULT_MAX_BODY_BYTES,
}
}
#[cfg(all(feature = "db", feature = "maud"))]
#[test]
fn a_reply_form_exists_only_while_the_target_can_still_be_replied_to() {
fn node(id: i64, depth: usize, replies: Vec<CommentNode>) -> CommentNode {
CommentNode {
comment: Comment {
id,
parent_id: None,
author_id: 1,
body: String::new(),
created_at: chrono::NaiveDateTime::default(),
author_name: None,
},
depth,
replies,
}
}
let thread = vec![node(1, 0, vec![node(2, 1, vec![node(3, 2, Vec::new())])])];
assert!(reply_form_exists(&thread, 1, 3));
assert!(reply_form_exists(&thread, 3, 3));
assert!(!reply_form_exists(&thread, 3, 3 - 1));
assert!(!reply_form_exists(&thread, 99, 3));
assert!(!reply_form_exists(&[], 1, 3));
}
#[test]
fn any_soft_deleting_repository_filters_deleted_parents() {
let soft = RepositoryFacts {
model: || "app::Post",
sharded: false,
tenant_scoped: false,
soft_delete: true,
};
let plain = RepositoryFacts {
model: || "app::Post",
sharded: false,
tenant_scoped: false,
soft_delete: false,
};
assert_eq!(
soft_deletes_from([&soft, &plain].into_iter()),
Some(true),
"soft first"
);
assert_eq!(
soft_deletes_from([&plain, &soft].into_iter()),
Some(true),
"plain first — the answer must not change"
);
assert_eq!(soft_deletes_from([&plain].into_iter()), Some(false));
assert_eq!(soft_deletes_from(std::iter::empty()), None);
}
#[test]
fn any_scoped_repository_keeps_the_routes_scoped() {
let scoped = RepositoryFacts {
model: || "app::Post",
sharded: false,
tenant_scoped: true,
soft_delete: false,
};
let unscoped = RepositoryFacts {
model: || "app::Post",
sharded: false,
tenant_scoped: false,
soft_delete: false,
};
assert!(
requires_tenant_from([&scoped, &unscoped].into_iter(), true),
"scoped first"
);
assert!(
requires_tenant_from([&unscoped, &scoped].into_iter(), true),
"unscoped first — the answer must not change"
);
assert!(!requires_tenant_from([&unscoped].into_iter(), true));
assert!(requires_tenant_from(std::iter::empty(), true));
assert!(!requires_tenant_from([&scoped].into_iter(), false));
}
#[test]
fn an_unregistered_model_stays_tenant_scoped() {
assert!(
model_requires_tenant("nobody::Unregistered", true),
"absent positive evidence of opting out, a tenant column scopes"
);
assert!(
!model_requires_tenant("nobody::Unregistered", false),
"…but a model with no tenant column is never scoped"
);
assert!(model_requires_tenant("", true));
}
#[test]
fn validate_refuses_a_hand_built_spec_carrying_sql() {
let spec = sample_spec();
assert!(spec.validate().is_ok());
let mut smuggled = sample_spec();
smuggled.comments_table = "comments\"; DROP TABLE users --";
let err = smuggled
.validate()
.expect_err("a quoted name must be refused");
assert!(err.to_string().contains("comments_table"), "{err}");
let mut smuggled = sample_spec();
smuggled.counter_column = Some("count; DROP TABLE users");
let err = smuggled.validate().expect_err("an optional name too");
assert!(err.to_string().contains("counter_column"), "{err}");
let mut sparse = sample_spec();
sparse.counter_column = None;
sparse.author_table = None;
sparse.author_name_column = None;
assert!(sparse.validate().is_ok());
}
#[test]
fn the_macro_depth_ceiling_matches_the_recursion_guard() {
assert_eq!(RECURSION_GUARD, 1_000);
assert_eq!(MAX_NESTING, usize::try_from(RECURSION_GUARD).expect("fits"));
assert!(i64::from(DEFAULT_MAX_DEPTH) < RECURSION_GUARD);
}
#[test]
fn nest_stops_nesting_at_the_recursion_guard() {
let depth_beyond = i64::try_from(MAX_NESTING).expect("fits") + 10;
let rows: Vec<Comment> = (1..=depth_beyond)
.map(|id| comment(id, (id > 1).then_some(id - 1), &format!("c{id}")))
.collect();
let flat = flatten(&nest(rows));
let deepest = flat.iter().map(|(depth, _)| *depth).max().expect("nodes");
assert_eq!(deepest, MAX_NESTING);
assert_eq!(
flat.len(),
MAX_NESTING + 10,
"every comment still renders; only the nesting is capped"
);
}
}
#[cfg(all(feature = "db", feature = "maud"))]
#[derive(Clone)]
#[non_exhaustive]
pub struct CommentsConfig {
pub mount_path: String,
pub session_author_key: String,
pub sign_in_prompt: String,
pub label: String,
pub authorize: Option<CommentAuthorizer>,
pub on_comment: Option<CommentCreatedHook>,
}
#[cfg(all(feature = "db", feature = "maud"))]
pub type CommentAuthorizer = std::sync::Arc<
dyn Fn(CommentAccess) -> futures::future::BoxFuture<'static, bool> + Send + Sync,
>;
#[cfg(all(feature = "db", feature = "maud"))]
pub type CommentCreatedHook =
std::sync::Arc<dyn Fn(CommentCreated) -> futures::future::BoxFuture<'static, ()> + Send + Sync>;
#[cfg(all(feature = "db", feature = "maud"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommentCreated {
pub commentable_type: String,
pub parent_id: i64,
pub comment_id: i64,
pub reply_to: Option<i64>,
pub author_id: i64,
pub body: String,
}
#[cfg(all(feature = "db", feature = "maud"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CommentAccess {
pub commentable_type: String,
pub parent_id: i64,
pub viewer_id: Option<i64>,
pub write: bool,
}
#[cfg(all(feature = "db", feature = "maud"))]
impl std::fmt::Debug for CommentsConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CommentsConfig")
.field("mount_path", &self.mount_path)
.field("session_author_key", &self.session_author_key)
.field("sign_in_prompt", &self.sign_in_prompt)
.field("label", &self.label)
.field("authorize", &self.authorize.as_ref().map(|_| "<fn>"))
.field("on_comment", &self.on_comment.as_ref().map(|_| "<fn>"))
.finish()
}
}
#[cfg(all(feature = "db", feature = "maud"))]
impl CommentsConfig {
#[must_use]
pub fn authorize<F>(mut self, authorize: F) -> Self
where
F: Fn(CommentAccess) -> futures::future::BoxFuture<'static, bool> + Send + Sync + 'static,
{
self.authorize = Some(std::sync::Arc::new(authorize));
self
}
#[must_use]
pub fn on_comment<F>(mut self, on_comment: F) -> Self
where
F: Fn(CommentCreated) -> futures::future::BoxFuture<'static, ()> + Send + Sync + 'static,
{
self.on_comment = Some(std::sync::Arc::new(on_comment));
self
}
}
#[cfg(all(feature = "db", feature = "maud"))]
impl Default for CommentsConfig {
fn default() -> Self {
Self {
mount_path: "/comments".to_owned(),
session_author_key: "user_id".to_owned(),
sign_in_prompt: "Sign in to join the discussion.".to_owned(),
label: "Comments".to_owned(),
authorize: None,
on_comment: None,
}
}
}
#[cfg(all(feature = "db", feature = "maud"))]
pub fn router<S>(config: CommentsConfig) -> axum::Router<S>
where
S: crate::db::DbState + Clone + Send + Sync + 'static,
{
assert_unique_discriminators();
assert!(
duplicate_commentable_type().is_none(),
"two #[commentable] models share the commentable_type {:?}, so the comment router \
cannot tell `/comments/{}/…` apart: one of them would be unreachable. Give one a \
`#[commentable(type_name = \"…\")]`, or serve them from your own routes instead of \
mounting the generic router.",
duplicate_commentable_type().unwrap_or_default(),
duplicate_commentable_type().unwrap_or_default(),
);
assert!(
sharded_commentable_type().is_none(),
"#[commentable] model {:?} is sharded, and the generic comment router cannot serve it: \
it checks out the control database, while the model's repository helpers route through \
the tenant's shard. Serve its comments from your own handlers using the generated \
`{{Model}}Comments` methods.",
sharded_commentable_type().unwrap_or_default(),
);
axum::Router::new()
.route(
"/{commentable_type}/{parent_id}",
axum::routing::get(show_thread).post(post_comment),
)
.layer(axum::Extension(std::sync::Arc::new(config)))
}
#[cfg(all(feature = "db", feature = "maud"))]
#[derive(Debug, serde::Deserialize)]
struct CommentSubmission {
body: String,
#[serde(default)]
reply_to: Option<String>,
#[serde(default)]
return_to: Option<String>,
}
#[cfg(all(feature = "db", feature = "maud"))]
fn request_tenant(spec: &CommentableSpec) -> AutumnResult<Option<String>> {
let model = commentable_model_for_spec(spec).unwrap_or("");
if !model_requires_tenant(model, spec.parent_tenant_column.is_some()) {
return Ok(None);
}
crate::tenancy::CURRENT_TENANT
.try_with(Clone::clone)
.ok()
.flatten()
.map(Some)
.ok_or_else(|| {
AutumnError::internal_server_error_msg(
"This model is tenant-scoped but no tenant context was established for the \
comment routes — mount them inside the tenancy middleware.",
)
})
}
#[cfg(all(feature = "db", feature = "maud"))]
async fn authorize(
config: &CommentsConfig,
commentable_type: &str,
parent_id: i64,
viewer_id: Option<i64>,
write: bool,
) -> AutumnResult<()> {
let Some(authorize) = config.authorize.as_ref() else {
return Ok(());
};
let allowed = authorize(CommentAccess {
commentable_type: commentable_type.to_owned(),
parent_id,
viewer_id,
write,
})
.await;
if allowed {
Ok(())
} else {
Err(AutumnError::not_found_msg("Comment target not found"))
}
}
#[cfg(all(feature = "db", feature = "maud"))]
struct AuthorizedComment {
author_id: Option<i64>,
}
#[cfg(all(feature = "db", feature = "maud"))]
impl<S> axum::extract::FromRequestParts<S> for AuthorizedComment
where
S: Send + Sync,
{
type Rejection = AutumnError;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
let axum::Extension(config) =
axum::Extension::<std::sync::Arc<CommentsConfig>>::from_request_parts(parts, state)
.await
.map_err(|_| {
AutumnError::internal_server_error_msg(
"comment router is not mounted with its CommentsConfig",
)
})?;
let axum::extract::Path((commentable_type, parent_id)) =
axum::extract::Path::<(String, i64)>::from_request_parts(parts, state).await?;
let session = crate::session::Session::from_request_parts(parts, state).await?;
resolve_spec(&commentable_type)?;
let write = parts.method == axum::http::Method::POST;
let author_id = session_author(&session, &config).await;
if write && author_id.is_none() {
return Err(AutumnError::unauthorized_msg("Sign in to comment"));
}
authorize(&config, &commentable_type, parent_id, author_id, write).await?;
Ok(Self { author_id })
}
}
#[cfg(all(feature = "db", feature = "maud"))]
async fn show_thread(
axum::Extension(config): axum::Extension<std::sync::Arc<CommentsConfig>>,
axum::extract::Path((commentable_type, parent_id)): axum::extract::Path<(String, i64)>,
axum::extract::Query(query): axum::extract::Query<ThreadQuery>,
csrf: Option<crate::security::csrf::CsrfToken>,
csrf_field: Option<crate::security::csrf::CsrfFormField>,
AuthorizedComment { author_id }: AuthorizedComment,
mut db: crate::db::Db,
) -> AutumnResult<maud::Markup> {
let spec = resolve_spec(&commentable_type)?;
let tenant = request_tenant(spec)?;
let thread = comment_thread(
&mut db,
spec,
&commentable_type,
parent_id,
tenant.as_deref(),
)
.await?;
Ok(render(
&config,
spec,
&commentable_type,
parent_id,
&thread,
csrf.as_ref(),
csrf_field.as_ref(),
author_id.is_some(),
query
.return_to
.as_deref()
.filter(|p| is_safe_return_path(p)),
None,
None,
))
}
#[cfg(all(feature = "db", feature = "maud"))]
#[derive(Debug, Default, serde::Deserialize)]
struct ThreadQuery {
#[serde(default)]
return_to: Option<String>,
}
#[cfg(all(feature = "db", feature = "maud"))]
#[allow(clippy::too_many_arguments)] async fn post_comment(
axum::Extension(config): axum::Extension<std::sync::Arc<CommentsConfig>>,
axum::extract::Path((commentable_type, parent_id)): axum::extract::Path<(String, i64)>,
csrf: Option<crate::security::csrf::CsrfToken>,
csrf_field: Option<crate::security::csrf::CsrfFormField>,
htmx: crate::htmx::HxRequest,
AuthorizedComment { author_id }: AuthorizedComment,
deferred_db: crate::db::DeferredDb,
axum::extract::Form(submission): axum::extract::Form<CommentSubmission>,
) -> AutumnResult<axum::response::Response> {
use axum::response::IntoResponse as _;
let spec = resolve_spec(&commentable_type)?;
let author_id = author_id.ok_or_else(|| AutumnError::unauthorized_msg("Sign in to comment"))?;
let reply_to = match submission.reply_to.as_deref().map(str::trim) {
None | Some("") => None,
Some(raw) => Some(
raw.parse::<i64>()
.map_err(|_| AutumnError::bad_request_msg("Invalid reply target"))?,
),
};
let tenant = request_tenant(spec)?;
let return_to = submission
.return_to
.as_deref()
.filter(|path| is_safe_return_path(path));
let mut db = deferred_db.checkout().await?;
let outcome = add_comment(
&mut db,
spec,
&commentable_type,
parent_id,
author_id,
&submission.body,
reply_to,
tenant.as_deref(),
)
.await;
let (created, error) = match outcome {
Ok(created) => (Some(created), None),
Err(err) if err.status() == http::StatusCode::UNPROCESSABLE_ENTITY => {
(None, Some(err.to_string()))
}
Err(err) => return Err(err),
};
let redirecting = error.is_none() && !htmx.is_htmx && return_to.is_some();
let thread = if redirecting {
Ok(Vec::new())
} else {
comment_thread(
&mut db,
spec,
&commentable_type,
parent_id,
tenant.as_deref(),
)
.await
};
drop(db);
if let Some(created) = created
&& let Some(hook) = config.on_comment.as_ref()
{
hook(CommentCreated {
commentable_type: commentable_type.clone(),
parent_id,
comment_id: created.id,
reply_to,
author_id,
body: created.body,
})
.await;
}
let thread = thread?;
let draft = error.is_some().then(|| {
let target = reply_to.filter(|id| reply_form_exists(&thread, *id, spec.max_depth));
(target, submission.body.clone())
});
if redirecting && let Some(return_to) = return_to {
return Ok(crate::Redirect::to(return_to).into_response());
}
Ok(render(
&config,
spec,
&commentable_type,
parent_id,
&thread,
csrf.as_ref(),
csrf_field.as_ref(),
true,
return_to,
error,
draft,
)
.into_response())
}
#[cfg(all(feature = "db", feature = "maud"))]
fn reply_form_exists(nodes: &[CommentNode], target: i64, max_depth: u32) -> bool {
nodes.iter().any(|node| {
(node.comment.id == target && node.depth < max_depth as usize)
|| reply_form_exists(&node.replies, target, max_depth)
})
}
#[cfg(all(feature = "db", feature = "maud"))]
fn is_safe_return_path(path: &str) -> bool {
path.starts_with('/')
&& !path.starts_with("//")
&& !path.contains('\\')
&& path.bytes().all(|byte| byte > 0x20 && byte != 0x7f)
}
#[cfg(all(feature = "db", feature = "maud"))]
fn resolve_spec(commentable_type: &str) -> AutumnResult<&'static CommentableSpec> {
commentable_spec_for(commentable_type)
.ok_or_else(|| AutumnError::not_found_msg("Unknown commentable type"))
}
#[cfg(all(feature = "db", feature = "maud"))]
async fn session_author(session: &crate::session::Session, config: &CommentsConfig) -> Option<i64> {
session
.get(&config.session_author_key)
.await
.and_then(|raw| raw.trim().parse::<i64>().ok())
}
#[cfg(all(feature = "db", feature = "maud"))]
#[allow(clippy::too_many_arguments)] fn render(
config: &CommentsConfig,
spec: &CommentableSpec,
commentable_type: &str,
parent_id: i64,
thread: &[CommentNode],
csrf: Option<&crate::security::csrf::CsrfToken>,
csrf_field: Option<&crate::security::csrf::CsrfFormField>,
can_comment: bool,
return_to: Option<&str>,
error: Option<String>,
draft: Option<(Option<i64>, String)>,
) -> maud::Markup {
let mut widget = crate::widgets::CommentThread::from_spec(
thread_dom_id(commentable_type, parent_id),
thread_action(config, commentable_type, parent_id),
spec,
)
.label(config.label.clone());
if let Some(csrf) = csrf {
widget = widget.csrf_token(csrf.token());
}
if let Some(field) = csrf_field {
widget = widget.csrf_field(field.0.clone());
}
if let Some(return_to) = return_to {
widget = widget.return_to(return_to);
}
if let Some(error) = error {
widget = widget.error(error);
}
if let Some((reply_to, body)) = draft {
widget = widget.draft(reply_to, body);
}
if !can_comment {
widget = widget
.read_only()
.sign_in_prompt(config.sign_in_prompt.clone());
}
crate::widgets::comment_thread(&widget, &crate::widgets::CommentView::from_thread(thread))
}
#[cfg(all(feature = "db", feature = "maud"))]
#[must_use]
pub fn thread_dom_id(commentable_type: &str, parent_id: i64) -> String {
format!("autumn-comments-{commentable_type}-{parent_id}")
}
#[cfg(all(feature = "db", feature = "maud"))]
#[must_use]
pub fn thread_action(config: &CommentsConfig, commentable_type: &str, parent_id: i64) -> String {
format!(
"{}/{commentable_type}/{parent_id}",
config.mount_path.trim_end_matches('/')
)
}