fix: bound template storage and callback data

This commit is contained in:
2026-09-25 01:49:31 +08:00
parent 2045c3cea1
commit 9d40f3bc50
3 changed files with 169 additions and 27 deletions
+121 -11
View File
@@ -106,6 +106,16 @@ Links are handled in private chats only; in a group use inline mode.";
/// Cap on template names echoed by `/settings`: a chat with hundreds of /// Cap on template names echoed by `/settings`: a chat with hundreds of
/// templates must not produce a message Telegram rejects for length. /// templates must not produce a message Telegram rejects for length.
const MAX_SETTINGS_TEMPLATE_NAMES: usize = 30; const MAX_SETTINGS_TEMPLATE_NAMES: usize = 30;
/// Telegram's callback data limit is 64 bytes. Reserve the `template|` prefix
/// so a name can always be carried by a prompt button.
const MAX_TEMPLATE_NAME_BYTES: usize = 64 - "template|".len();
/// Keep the persisted map bounded well below the prompt keyboard's 60-button
/// cap so every stored template remains usable in a prompt.
const MAX_TEMPLATES: usize = 50;
/// Keep the persisted template body within a caption-sized value. It is
/// escaped before storage, so validate the user's reply text before encoding.
const MAX_TEMPLATE_BODY_CHARS: usize = x_media::site::MAX_CAPTION_CHARS;
const MAX_SETTINGS_CHARS: usize = 4000;
/// Sorted template names: the order `/settings`, `/remove_template` and the /// Sorted template names: the order `/settings`, `/remove_template` and the
/// prompt's buttons all show. /// prompt's buttons all show.
@@ -162,7 +172,7 @@ fn settings_text(data: &ChatData) -> String {
} }
), ),
}); });
lines.join("\n") cap_text(lines.join("\n"), MAX_SETTINGS_CHARS)
} }
/// The first `{…}` token in a caption format that is not a known placeholder /// The first `{…}` token in a caption format that is not a known placeholder
@@ -372,6 +382,7 @@ pub(crate) async fn execute_command(
} }
Command::SetTemplate(name) => { Command::SetTemplate(name) => {
let chat_id = message.chat.id.0; let chat_id = message.chat.id.0;
let name = name.trim().to_string();
let text = match message.reply_to_message() { let text = match message.reply_to_message() {
None => "Please reply to a message to set as template.".to_string(), None => "Please reply to a message to set as template.".to_string(),
Some(reply) => { Some(reply) => {
@@ -380,25 +391,40 @@ pub(crate) async fn execute_command(
"Please reply to a message with [] to set as template.".to_string() "Please reply to a message with [] to set as template.".to_string()
} else if name.is_empty() { } else if name.is_empty() {
"Please provide a name for the template.".to_string() "Please provide a name for the template.".to_string()
} else if name.len() > MAX_TEMPLATE_NAME_BYTES {
format!("Template name is too long (max {MAX_TEMPLATE_NAME_BYTES} bytes).")
} else {
let template = html_escape::encode_text(reply_text);
if template.chars().count() > MAX_TEMPLATE_BODY_CHARS {
format!(
"Template is too long (max {MAX_TEMPLATE_BODY_CHARS} characters)."
)
} else { } else {
match ctx match ctx
.chat_store .chat_store
.update(chat_id, |data| { .update(chat_id, |data| {
data.template.insert( if data.template.len() >= MAX_TEMPLATES
name, && !data.template.contains_key(&name)
html_escape::encode_text(reply_text).into_owned(), {
); return Err(());
}
data.template.insert(name.clone(), template.into_owned());
Ok(())
}) })
.await .await
{ {
Ok((_, true)) => "Template set.".to_string(), Ok((Ok(()), true)) => "Template set.".to_string(),
Ok((_, false)) => { Ok((Ok(()), false)) => {
"Template set only in memory; retry later.".to_string() "Template set only in memory; retry later.".to_string()
} }
Ok((Err(()), _)) => format!(
"This chat already has the maximum of {MAX_TEMPLATES} templates."
),
Err(()) => CHAT_STATE_READ_ERROR.to_string(), Err(()) => CHAT_STATE_READ_ERROR.to_string(),
} }
} }
} }
}
}; };
reply(ctx.sender, message.chat.id.0, message.id, text).await?; reply(ctx.sender, message.chat.id.0, message.id, text).await?;
} }
@@ -749,10 +775,9 @@ const MAX_DEBUG_REPORT_CHARS: usize = 4000;
/// message, so it must stay under Telegram's 4096-char limit. /// message, so it must stay under Telegram's 4096-char limit.
const MAX_DEBUG_DUMP_CHARS: usize = 3500; const MAX_DEBUG_DUMP_CHARS: usize = 3500;
/// Truncates `text` to at most `max` characters (the cut lands on a byte /// Truncates `text` to at most `max` characters. The byte boundary keeps the
/// boundary, and the byte before `max` is left free for the ellipsis, so the /// result valid UTF-8; Telegram's message limit is character-based, so this
/// result never exceeds `max`). Both callers stay under Telegram's 4096-char /// remains conservative for non-ASCII text.
/// message limit this way.
fn cap_text(text: String, max: usize) -> String { fn cap_text(text: String, max: usize) -> String {
if text.len() <= max { if text.len() <= max {
return text; return text;
@@ -1211,6 +1236,91 @@ mod tests {
assert!(text.contains("Templates (2): a, b"), "{text}"); assert!(text.contains("Templates (2): a, b"), "{text}");
} }
#[test]
fn settings_text_is_capped_before_telegram_limit() {
use crate::state::ChatData;
let data = ChatData {
message_format: [("twitter", "x".repeat(4000))]
.into_iter()
.map(|(site, format)| (site.to_string(), format))
.collect(),
template: (0..super::MAX_TEMPLATES)
.map(|i| (format!("t{i}"), "[]".to_string()))
.collect(),
..ChatData::default()
};
let text = settings_text(&data);
assert!(
text.chars().count() <= super::MAX_SETTINGS_CHARS,
"{}",
text.chars().count()
);
assert!(text.ends_with('…'), "{text}");
}
#[tokio::test]
async fn template_limits_reject_unusable_names_bodies_and_overflow() {
let sender = MockSender::scripted(vec![Outcome::MessageOk; 4], || api_error("boom"));
let stores = TestStores::new();
let ctx = stores.ctx(&sender);
let bot = Bot::new("42:TEST");
let message = |reply: &str| {
serde_json::from_value::<Message>(serde_json::json!({
"message_id": 2,
"date": 0,
"chat": { "id": 1, "type": "private" },
"from": { "id": 5, "is_bot": false, "first_name": "u" },
"reply_to_message": {
"message_id": 1,
"date": 0,
"chat": { "id": 1, "type": "private" },
"text": reply,
},
"text": "/set_template x",
}))
.unwrap()
};
let short = message("before [] after");
execute_command(&ctx, &bot, &short, Command::SetTemplate("漢".repeat(22)))
.await
.unwrap();
assert!(sender.messages()[0].contains("name is too long"));
assert!(stores.chat_store().get(1).await.template.is_empty());
let long_body = message(&format!(
"{} []",
"<".repeat(super::MAX_TEMPLATE_BODY_CHARS)
));
execute_command(&ctx, &bot, &long_body, Command::SetTemplate("long".into()))
.await
.unwrap();
assert!(sender.messages()[1].contains("Template is too long"));
assert!(stores.chat_store().get(1).await.template.is_empty());
execute_command(&ctx, &bot, &short, Command::SetTemplate("ok".into()))
.await
.unwrap();
stores
.chat_store()
.update(1, |data| {
for i in 0..super::MAX_TEMPLATES - 1 {
data.template.insert(format!("t{i}"), "[]".into());
}
})
.await
.unwrap();
execute_command(&ctx, &bot, &short, Command::SetTemplate("overflow".into()))
.await
.unwrap();
assert!(sender.messages().last().unwrap().contains("maximum"));
assert_eq!(
stores.chat_store().get(1).await.template.len(),
super::MAX_TEMPLATES
);
}
#[test] #[test]
fn help_and_start_cover_what_the_command_list_cannot() { fn help_and_start_cover_what_the_command_list_cannot() {
// The placeholders the renderer substitutes must be the ones the help // The placeholders the renderer substitutes must be the ones the help
+15
View File
@@ -936,6 +936,21 @@ mod tests {
); );
} }
#[test]
fn edit_markup_omits_names_that_exceed_callback_data() {
let allowed: HashMap<String, String> =
[("x".repeat(55), "[]".to_string())].into_iter().collect();
let allowed_markup = build_edit_markup(&allowed);
assert_eq!(allowed_markup.inline_keyboard.len(), 2);
assert_eq!(allowed_markup.inline_keyboard[0].len(), 1);
let too_long: HashMap<String, String> =
[("x".repeat(56), "[]".to_string())].into_iter().collect();
let rejected_markup = build_edit_markup(&too_long);
assert_eq!(rejected_markup.inline_keyboard.len(), 1);
assert_eq!(rejected_markup.inline_keyboard[0].len(), 2);
}
#[test] #[test]
fn failure_text_names_the_post_and_the_cause() { fn failure_text_names_the_post_and_the_cause() {
// A send failure names the post (the cache key) and the cause, so the // A send failure names the post (the cache key) and the cause, so the
+25 -8
View File
@@ -13,7 +13,10 @@ use crate::queue::{PersistentTaskQueue, QueueError};
use crate::state::EditMessage; use crate::state::EditMessage;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageId}; use teloxide::types::{
ChatId, InlineKeyboardButton, InlineKeyboardButtonKind, InlineKeyboardMarkup, Message,
MessageId,
};
/// Persists a successful send under the post's cache key. Skips a send that was /// Persists a successful send under the post's cache key. Skips a send that was
/// served from the cache — its entry already holds the file ids the next repeat /// served from the cache — its entry already holds the file ids the next repeat
@@ -182,13 +185,19 @@ fn coarsest_unit(ttl: std::time::Duration) -> String {
pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3; pub(super) const TEMPLATE_BUTTONS_PER_ROW: usize = 3;
/// Hard cap on template buttons; the prompt text names the ones not shown. /// Hard cap on template buttons; the prompt text names the ones not shown.
pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60; pub(super) const MAX_TEMPLATE_BUTTONS: usize = 60;
const MAX_CALLBACK_DATA_BYTES: usize = 64;
const TEMPLATE_CALLBACK_PREFIX: &str = "template|";
/// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most /// Template buttons ([`TEMPLATE_BUTTONS_PER_ROW`] per row, at most
/// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the /// [`MAX_TEMPLATE_BUTTONS`]), then the confirm/skip pair. Sorted by name: the
/// templates live in a `HashMap`, so an unsorted walk would reshuffle the /// templates live in a `HashMap`, so an unsorted walk would reshuffle the
/// buttons between prompts. /// buttons between prompts. A name that cannot fit Telegram's callback-data
/// limit is omitted; legacy/imported state cannot poison the whole prompt.
pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup { pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKeyboardMarkup {
let mut names: Vec<&String> = templates.keys().collect(); let mut names: Vec<&String> = templates
.keys()
.filter(|name| TEMPLATE_CALLBACK_PREFIX.len() + name.len() <= MAX_CALLBACK_DATA_BYTES)
.collect();
names.sort(); names.sort();
let shown = names.len().min(MAX_TEMPLATE_BUTTONS); let shown = names.len().min(MAX_TEMPLATE_BUTTONS);
let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2); let mut rows = Vec::with_capacity(shown / TEMPLATE_BUTTONS_PER_ROW + 2);
@@ -197,14 +206,14 @@ pub(super) fn build_edit_markup(templates: &HashMap<String, String>) -> InlineKe
chunk chunk
.iter() .iter()
.map(|name| { .map(|name| {
InlineKeyboardButton::callback(name.as_str(), format!("template|{name}")) InlineKeyboardButton::callback(
name.as_str(),
format!("{TEMPLATE_CALLBACK_PREFIX}{name}"),
)
}) })
.collect(), .collect(),
); );
} }
// Skip exists because the prompt holds the forward hostage until Confirm:
// without it the only escape was deleting the message and waiting out the
// TTL for a forward that then never happens.
rows.push(vec![ rows.push(vec![
InlineKeyboardButton::callback("↩️ Confirm", "forward"), InlineKeyboardButton::callback("↩️ Confirm", "forward"),
InlineKeyboardButton::callback("🛑 Skip", "skip"), InlineKeyboardButton::callback("🛑 Skip", "skip"),
@@ -277,7 +286,15 @@ pub(crate) async fn post_send_actions(ctx: &AppContext<'_>, task: &Task, message
let templates = ctx.chat_store.get(chat_id).await.template; let templates = ctx.chat_store.get(chat_id).await.template;
let keyboard = build_edit_markup(&templates); let keyboard = build_edit_markup(&templates);
let mut text = edit_prompt_text(ctx.config.edit_message_ttl); let mut text = edit_prompt_text(ctx.config.edit_message_ttl);
let hidden = templates.len().saturating_sub(MAX_TEMPLATE_BUTTONS); let shown = keyboard
.inline_keyboard
.iter()
.flatten()
.filter(|button| {
matches!(&button.kind, InlineKeyboardButtonKind::CallbackData(data) if data.starts_with(TEMPLATE_CALLBACK_PREFIX))
})
.count();
let hidden = templates.len().saturating_sub(shown);
if hidden > 0 { if hidden > 0 {
// The keyboard is capped; say so instead of silently hiding them. // The keyboard is capped; say so instead of silently hiding them.
text.push_str(&format!( text.push_str(&format!(