fix: sanitize path separators per-variable in template expansion

Sanitize each template variable value individually in path mode so
slashes inside values (e.g. URLs in content) become _ instead of
injecting unintended subdirectories, while preserving the template's
own literal / separators as real path structure.
This commit is contained in:
tmlnv 2026-06-29 22:48:41 +03:00
parent b70131db90
commit 1217b9fe71
2 changed files with 50 additions and 20 deletions

View file

@ -4,11 +4,10 @@ interface ExpandOptions {
isPath?: boolean;
}
function sanitizePath(value: string): string {
return value
.replace(/[\\:*?"<>|\n\r]/g, "_")
.replace(/\/+/g, "/")
.replace(/(^\/|\/$)/g, "");
const PATH_UNSAFE_CHARS = /[\\/:*?"<>|\n\r]/g;
function sanitizeForPath(value: string): string {
return value.replace(PATH_UNSAFE_CHARS, "_");
}
function normalizeTemplatePath(value: string): string {
@ -53,8 +52,12 @@ function getChatLabel(message: MessageRow): string {
return message.telegram_chat_title ?? String(message.telegram_chat_id);
}
function getTopicLabel(message: MessageRow): string {
return message.topic_name ? `${message.topic_name}/` : "";
function getTopicLabel(message: MessageRow, isPath: boolean): string {
if (!message.topic_name) {
return "";
}
const name = isPath ? sanitizeForPath(message.topic_name) : message.topic_name;
return `${name}/`;
}
function getUserLabel(message: MessageRow): string {
@ -95,27 +98,30 @@ export function expandTemplate(
message: MessageRow,
options: ExpandOptions = {},
): string {
const isPath = options.isPath ?? false;
const safe = (value: string): string => (isPath ? sanitizeForPath(value) : value);
const content = getContent(message);
let result = template;
result = result.replace(/\{\{messageDate:([^}]+)\}\}/g, (_match, format) =>
formatDate(message.telegram_date, format),
safe(formatDate(message.telegram_date, format)),
);
result = result.replace(/\{\{content(?::(\d+))?\}\}/g, (_match, length) =>
sliceContent(content, length),
safe(sliceContent(content, length)),
);
result = result.replace(/\{\{chatId\}\}/g, String(message.telegram_chat_id));
result = result.replace(/\{\{chat\}\}/g, getChatLabel(message));
result = result.replace(/\{\{topicId\}\}/g, message.topic_id ? String(message.topic_id) : "");
result = result.replace(/\{\{topic\}\}/g, getTopicLabel(message));
result = result.replace(/\{\{user\}\}/g, getUserLabel(message));
result = result.replace(/\{\{messageId\}\}/g, String(message.telegram_message_id));
result = result.replace(/\{\{messageType\}\}/g, message.message_type);
result = result.replace(/\{\{file:name\}\}/g, getFileBaseName(message));
result = result.replace(/\{\{file:extension\}\}/g, getFileExtension(message));
result = result.replace(/\{\{chatId\}\}/g, safe(String(message.telegram_chat_id)));
result = result.replace(/\{\{chat\}\}/g, safe(getChatLabel(message)));
result = result.replace(/\{\{topicId\}\}/g, message.topic_id ? safe(String(message.topic_id)) : "");
result = result.replace(/\{\{topic\}\}/g, getTopicLabel(message, isPath));
result = result.replace(/\{\{user\}\}/g, safe(getUserLabel(message)));
result = result.replace(/\{\{messageId\}\}/g, safe(String(message.telegram_message_id)));
result = result.replace(/\{\{messageType\}\}/g, safe(message.message_type));
result = result.replace(/\{\{file:name\}\}/g, safe(getFileBaseName(message)));
result = result.replace(/\{\{file:extension\}\}/g, safe(getFileExtension(message)));
if (options.isPath) {
return normalizeTemplatePath(sanitizePath(result));
if (isPath) {
return normalizeTemplatePath(result);
}
return result;

View file

@ -61,4 +61,28 @@ describe("expandTemplate", () => {
it("supports content slicing", () => {
expect(expandTemplate("{{content:5}}", createMessage())).toBe("hello");
});
it("strips path separators from variable values in path mode", () => {
expect(
expandTemplate(
"Telegram/{{chat}}/{{content:30}}.md",
createMessage({
telegram_chat_title: "Saved",
topic_id: null,
topic_name: null,
text_content: "https://radio.garden/something",
}),
{ isPath: true },
),
).toBe("Telegram/Saved/https___radio.garden_something.md");
});
it("preserves path separators in non-path mode", () => {
expect(
expandTemplate(
"see {{content}}",
createMessage({ text_content: "https://radio.garden/x" }),
),
).toBe("see https://radio.garden/x");
});
});