fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash

Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
This commit is contained in:
jubnl
2026-05-06 11:18:13 +02:00
parent f089c557e7
commit 7b2928a007
2 changed files with 34 additions and 2 deletions
+11 -2
View File
@@ -621,6 +621,15 @@ export function isNtfyConfiguredAdmin(): boolean {
return !!(getAppSetting('admin_ntfy_topic'));
}
function encodeHeaderValue(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xFF) {
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
}
}
return value;
}
export async function sendNtfy(
url: string,
token: string | null,
@@ -638,11 +647,11 @@ export async function sendNtfy(
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
const headers: Record<string, string> = {
'Title': payload.title,
'Title': encodeHeaderValue(payload.title),
'Priority': String(meta.priority),
};
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
if (payload.link) headers['Click'] = payload.link;
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
if (token) headers['Authorization'] = `Bearer ${token}`;
try {
@@ -455,4 +455,27 @@ describe('sendNtfy', () => {
expect(calledOpts.headers['Priority']).toBe('3');
expect(calledOpts.headers['Tags']).toBeUndefined(); // empty tags = no header
});
it('NTFY-009 — title with non-Latin-1 chars is RFC 2047 encoded', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Buy →€ ticket' });
const [, calledOpts] = mockFetch.mock.calls[0];
const encoded = calledOpts.headers['Title'] as string;
expect(encoded).toMatch(/^=\?UTF-8\?B\?/);
const b64 = encoded.replace(/^=\?UTF-8\?B\?/, '').replace(/\?=$/, '');
expect(Buffer.from(b64, 'base64').toString('utf8')).toBe('Buy →€ ticket');
});
it('NTFY-010 — ASCII-only title is passed through verbatim', async () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockResolvedValueOnce({ ok: true, text: async () => '' } as never);
await sendNtfy(ntfyUrl, null, { ...payload, title: 'Simple ASCII title' });
const [, calledOpts] = mockFetch.mock.calls[0];
expect(calledOpts.headers['Title']).toBe('Simple ASCII title');
});
});