mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(notifications): publish ntfy messages as JSON so non-ASCII labels need no header encoding
This commit is contained in:
1 parent
bc91a542e2
commit
4ff9aa7e39
5 files changed
+206
-140
No files matched your search
@@ -179,6 +179,12 @@ HealthLog publishes to that topic.
|
||||
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
|
||||
server if you self-host ntfy) and **Topic**, then save.
|
||||
|
||||
HealthLog publishes each message as JSON to the server's root URL (the topic
|
||||
travels in the body, see ntfy's
|
||||
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
|
||||
and button labels in any language arrive as written. A reverse proxy in front
|
||||
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
|
||||
|
||||
If your ntfy server requires auth, an access token can be attached; the
|
||||
server URL is validated at save time and dialled through the egress guard
|
||||
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
|
||||
@@ -474,7 +480,8 @@ once per event type, and every channel carries the same target:
|
||||
|
||||
How each channel carries it:
|
||||
|
||||
- **ntfy**: the `Click` header, so tapping the notification opens the page.
|
||||
- **ntfy**: the message's `click` field, so tapping the notification opens
|
||||
the page.
|
||||
- **Telegram**: an **Open in HealthLog** button under the message, below any
|
||||
action buttons. Telegram only accepts `https` button links on a public
|
||||
host, so a plain-HTTP instance gets a text link instead, and an instance
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* ntfy sender unit tests.
|
||||
*
|
||||
* Covers the outbound shape (topic appended to the server URL, priority and
|
||||
* tag headers, bearer token), the outcome classification, and (#947) the
|
||||
* Covers the outbound shape (a JSON publish to the server root carrying
|
||||
* topic, priority and tags, bearer token), the outcome classification, and (#947) the
|
||||
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the
|
||||
* operator-approved pin, an unlisted one keeps the public pin, and a listed
|
||||
* origin admits nothing beside itself.
|
||||
@@ -85,7 +85,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("sendViaNtfy", () => {
|
||||
it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => {
|
||||
it("POSTs a JSON message to the server root through safeFetch under the public pin and returns ok on 200", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await sendViaNtfy(config, payload());
|
||||
@@ -96,10 +96,17 @@ describe("sendViaNtfy", () => {
|
||||
{ method?: string; headers?: Record<string, string>; body?: string },
|
||||
EgressOpts,
|
||||
];
|
||||
expect(url).toBe("https://ntfy.example.com/health");
|
||||
expect(url).toBe("https://ntfy.example.com/");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers?.Authorization).toBe("Bearer tk_secret");
|
||||
expect(init.body).toBe("Body");
|
||||
expect(init.headers?.["Content-Type"]).toBe("application/json");
|
||||
expect(JSON.parse(init.body!)).toEqual({
|
||||
topic: "health",
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
priority: 3,
|
||||
tags: ["system-alert"],
|
||||
});
|
||||
expect(opts.requirePublicHost).toBe(true);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
@@ -164,7 +171,7 @@ describe("sendViaNtfy", () => {
|
||||
unknown,
|
||||
EgressOpts,
|
||||
];
|
||||
expect(url).toBe("http://ntfy.lan:8080/health");
|
||||
expect(url).toBe("http://ntfy.lan:8080/");
|
||||
expect(opts.requirePublicHost).toBe(false);
|
||||
expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080");
|
||||
expect(annotateMock).toHaveBeenCalledWith({
|
||||
@@ -270,15 +277,19 @@ describe("sendViaNtfy — what the server said (#947)", () => {
|
||||
});
|
||||
|
||||
describe("sendViaNtfy — tap target and quick actions", () => {
|
||||
function headersOfFirstCall(): Record<string, string> {
|
||||
function messageOfFirstCall(): Record<string, unknown> {
|
||||
const [, init] = safeFetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
{ headers: Record<string, string>; body: string },
|
||||
];
|
||||
return init.headers;
|
||||
// Nothing of the message rides a header any more.
|
||||
expect(Object.keys(init.headers).sort()).toEqual(
|
||||
["Authorization", "Content-Type"].sort(),
|
||||
);
|
||||
return JSON.parse(init.body) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("sets Click to the resolved absolute tap URL", async () => {
|
||||
it("sets click to the resolved absolute tap URL", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
config,
|
||||
@@ -291,17 +302,18 @@ describe("sendViaNtfy — tap target and quick actions", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(headersOfFirstCall().Click).toBe("https://health.example/");
|
||||
expect(headersOfFirstCall().Actions).toBeUndefined();
|
||||
const message = messageOfFirstCall();
|
||||
expect(message.click).toBe("https://health.example/");
|
||||
expect(message.actions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits Click when no public origin produced a URL", async () => {
|
||||
it("omits click when no public origin produced a URL", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
config,
|
||||
payload({ tap: { path: "/", url: null, label: "Open" } }),
|
||||
);
|
||||
expect(headersOfFirstCall().Click).toBeUndefined();
|
||||
expect(messageOfFirstCall().click).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the quick answers as three POST http actions that clear the notification", async () => {
|
||||
@@ -317,18 +329,66 @@ describe("sendViaNtfy — tap target and quick actions", () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(headersOfFirstCall().Actions).toBe(
|
||||
"http, Good, https://h.example/a/1, method=POST, clear=true; " +
|
||||
"http, Okay, https://h.example/a/2, method=POST, clear=true; " +
|
||||
"http, Bad, https://h.example/a/3, method=POST, clear=true",
|
||||
expect(messageOfFirstCall().actions).toEqual([
|
||||
{
|
||||
action: "http",
|
||||
label: "Good",
|
||||
url: "https://h.example/a/1",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
{
|
||||
action: "http",
|
||||
label: "Okay",
|
||||
url: "https://h.example/a/2",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
{
|
||||
action: "http",
|
||||
label: "Bad",
|
||||
url: "https://h.example/a/3",
|
||||
method: "POST",
|
||||
clear: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries non-ASCII titles and labels as plain UTF-8, URLs untouched", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
config,
|
||||
payload({
|
||||
eventType: "MOOD_REMINDER",
|
||||
title: "기분은 어떠세요?",
|
||||
quickActions: [
|
||||
{ id: "mood-good", label: "Świetnie", url: "https://h.example/a/1" },
|
||||
{ id: "mood-okay", label: "보통", url: "https://h.example/a/2" },
|
||||
{ id: "mood-bad", label: "Così così", url: "https://h.example/a/3" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const message = messageOfFirstCall();
|
||||
expect(message.title).toBe("기분은 어떠세요?");
|
||||
const actions = message.actions as { label: string; url: string }[];
|
||||
expect(actions.map((a) => a.label)).toEqual([
|
||||
"Świetnie",
|
||||
"보통",
|
||||
"Così così",
|
||||
]);
|
||||
expect(actions.map((a) => a.url)).toEqual([
|
||||
"https://h.example/a/1",
|
||||
"https://h.example/a/2",
|
||||
"https://h.example/a/3",
|
||||
]);
|
||||
expect(JSON.stringify(message)).not.toContain("=?UTF-8?");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ntfy header helpers", () => {
|
||||
it("caps the actions at three and strips separators from labels", async () => {
|
||||
const { buildNtfyActionsHeader } = await import("../ntfy");
|
||||
const header = buildNtfyActionsHeader({
|
||||
describe("ntfy message helpers", () => {
|
||||
it("caps the actions at three and keeps labels verbatim", async () => {
|
||||
const { buildNtfyActions } = await import("../ntfy");
|
||||
const actions = buildNtfyActions({
|
||||
quickActions: [
|
||||
{ id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" },
|
||||
{ id: "mood-okay", label: "B", url: "https://h/2" },
|
||||
@@ -336,20 +396,20 @@ describe("ntfy header helpers", () => {
|
||||
{ id: "mood-bad", label: "D", url: "https://h/4" },
|
||||
],
|
||||
});
|
||||
expect(header?.split("; ")).toHaveLength(3);
|
||||
expect(header).toContain("http, Go od yes, https://h/1");
|
||||
expect(header).not.toContain("https://h/4");
|
||||
expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull();
|
||||
expect(actions).toHaveLength(3);
|
||||
// JSON needs no separator escaping: the label reaches ntfy as written.
|
||||
expect(actions[0]!.label).toBe('Go;od, "yes"');
|
||||
expect(actions.map((a) => a.url)).not.toContain("https://h/4");
|
||||
expect(buildNtfyActions({ quickActions: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => {
|
||||
const { ntfyHeaderValue } = await import("../ntfy");
|
||||
expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78");
|
||||
const encoded = ntfyHeaderValue("아침 점수 78");
|
||||
expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
const inner = encoded.slice("=?UTF-8?B?".length, -2);
|
||||
expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78");
|
||||
// `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte.
|
||||
expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/);
|
||||
it("maps a medication reminder to high priority (4)", async () => {
|
||||
const { buildNtfyMessage } = await import("../ntfy");
|
||||
expect(
|
||||
buildNtfyMessage(
|
||||
"t",
|
||||
payload({ eventType: "MEDICATION_REMINDER" }) as never,
|
||||
).priority,
|
||||
).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -104,6 +104,15 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
/** The JSON message the ntfy sender published on call `i`. */
|
||||
function ntfyMessage(i: number): {
|
||||
priority: number;
|
||||
tags: string[];
|
||||
actions?: unknown;
|
||||
} {
|
||||
return JSON.parse((safeFetchMock.mock.calls[i][1] as { body: string }).body);
|
||||
}
|
||||
|
||||
describe("ntfy urgent mapping", () => {
|
||||
it("urgent → Priority 5 (max) + warning tag", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
@@ -111,10 +120,8 @@ describe("ntfy urgent mapping", () => {
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload({ urgent: true }),
|
||||
);
|
||||
const [, init] = safeFetchMock.mock.calls[0];
|
||||
const headers = (init as { headers: Record<string, string> }).headers;
|
||||
expect(headers.Priority).toBe("5");
|
||||
expect(headers.Tags).toContain("warning");
|
||||
expect(ntfyMessage(0).priority).toBe(5);
|
||||
expect(ntfyMessage(0).tags).toContain("warning");
|
||||
});
|
||||
|
||||
it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => {
|
||||
@@ -123,13 +130,8 @@ describe("ntfy urgent mapping", () => {
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload(),
|
||||
);
|
||||
const headers = (
|
||||
safeFetchMock.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
).headers;
|
||||
expect(headers.Priority).toBe("default");
|
||||
expect(headers.Tags).not.toContain("warning");
|
||||
expect(ntfyMessage(0).priority).toBe(3);
|
||||
expect(ntfyMessage(0).tags).not.toContain("warning");
|
||||
});
|
||||
|
||||
it("discreet urgent keeps the generic tag (no warning leak)", async () => {
|
||||
@@ -138,16 +140,11 @@ describe("ntfy urgent mapping", () => {
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
payload({ urgent: true, discreet: true }),
|
||||
);
|
||||
const headers = (
|
||||
safeFetchMock.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
).headers;
|
||||
expect(headers.Priority).toBe("5");
|
||||
expect(headers.Tags).toBe("reminder");
|
||||
expect(ntfyMessage(0).priority).toBe(5);
|
||||
expect(ntfyMessage(0).tags).toEqual(["reminder"]);
|
||||
});
|
||||
|
||||
it("does not send ntfy action headers to a managed Guardian", async () => {
|
||||
it("does not send ntfy actions to a managed Guardian", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaNtfy(
|
||||
{ serverUrl: "https://ntfy.example.com", topic: "t" },
|
||||
@@ -157,12 +154,7 @@ describe("ntfy urgent mapping", () => {
|
||||
recipientUserId: "guardian-1",
|
||||
}),
|
||||
);
|
||||
const headers = (
|
||||
safeFetchMock.mock.calls[0][1] as {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
).headers;
|
||||
expect(headers.Actions).toBeUndefined();
|
||||
expect(ntfyMessage(0).actions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,10 +237,7 @@ describe("APNs-less instance degrades gracefully", () => {
|
||||
|
||||
expect(ntfy.ok).toBe(true);
|
||||
expect(web.ok).toBe(true);
|
||||
expect(
|
||||
(safeFetchMock.mock.calls[0][1] as { headers: Record<string, string> })
|
||||
.headers.Priority,
|
||||
).toBe("5");
|
||||
expect(ntfyMessage(0).priority).toBe(5);
|
||||
expect(
|
||||
(generateRequestDetailsMock.mock.calls[0][2] as { urgency?: string })
|
||||
.urgency,
|
||||
|
||||
@@ -41,48 +41,24 @@ export async function sendViaNtfy(
|
||||
// can open.
|
||||
let policyReason: OriginReason | null = null;
|
||||
try {
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
|
||||
|
||||
// v1.18.4 — an explicitly urgent payload escalates to ntfy's top
|
||||
// priority (`5` / max) so it bypasses the relay's batching and surfaces
|
||||
// loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the
|
||||
// rest stay `default` (3). `urgent` also adds a tag so the lock-screen
|
||||
// entry reads as an alert (unless discreet privacy collapses tags).
|
||||
const urgent = isUrgentPayload(payload) && payload.urgent === true;
|
||||
const priority = urgent
|
||||
? "5"
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? "high"
|
||||
: "default";
|
||||
const eventTag = payload.discreet
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-");
|
||||
// Published as JSON to the server root rather than as a body with
|
||||
// headers to the topic URL. A header carries only Latin-1 (`fetch`
|
||||
// refuses anything above U+00FF, and ntfy reads a Latin-1 byte as invalid
|
||||
// UTF-8), so a Korean title or a Polish action label had to travel as an
|
||||
// RFC 2047 encoded word, and an `Actions` header encoded whole, URLs and
|
||||
// all, is not a form ntfy's action parser is documented to accept, so a
|
||||
// reminder with an Italian, Korean or Polish label risked a 400 and never
|
||||
// arrived. A JSON body is UTF-8 end to end and has no header encoding
|
||||
// at all (https://docs.ntfy.sh/publish/#publish-as-json).
|
||||
const url = `${config.serverUrl.replace(/\/$/, "")}/`;
|
||||
const message = buildNtfyMessage(config.topic, payload);
|
||||
const headers: Record<string, string> = {
|
||||
Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)),
|
||||
Priority: priority,
|
||||
// Discreet mode (cycle privacy): the X-Tags header is visible on the
|
||||
// lock screen, so collapse it to a generic tag instead of leaking the
|
||||
// cycle event name. An urgent (non-discreet) event also carries a
|
||||
// `warning` tag so the entry renders as an alert.
|
||||
Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (config.authToken) {
|
||||
headers["Authorization"] = `Bearer ${config.authToken}`;
|
||||
}
|
||||
|
||||
// Tapping the notification opens this URL (ntfy `Click`). Absent when
|
||||
// the operator has not configured a public origin.
|
||||
if (payload.tap?.url) {
|
||||
headers["Click"] = payload.tap.url;
|
||||
}
|
||||
const actions = buildNtfyActionsHeader(payload);
|
||||
if (actions) {
|
||||
headers["Actions"] = ntfyHeaderValue(actions);
|
||||
}
|
||||
|
||||
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
|
||||
const body = plainPushText(stripHtml(payload.message), payload.eventType);
|
||||
const body = JSON.stringify(message);
|
||||
|
||||
// The DNS-rebinding pin (issue #217) sits on top of the input-time
|
||||
// guard enforced when the user saved the serverUrl; the authToken would
|
||||
@@ -201,47 +177,81 @@ export async function sendViaNtfy(
|
||||
/** ntfy shows at most three actions per notification. */
|
||||
const NTFY_MAX_ACTIONS = 3;
|
||||
|
||||
/**
|
||||
* A label safe inside the `Actions` header's short format: `,` and `;`
|
||||
* separate fields and actions there, and a quote would open a quoted value.
|
||||
*/
|
||||
function ntfyActionLabel(label: string): string {
|
||||
return label.replace(/[,;"'\r\n]/g, " ").trim() || "?";
|
||||
/** One `http` action button in ntfy's JSON form. */
|
||||
export interface NtfyHttpAction {
|
||||
action: "http";
|
||||
label: string;
|
||||
url: string;
|
||||
method: "POST";
|
||||
clear: true;
|
||||
}
|
||||
|
||||
/** The JSON publish body (https://docs.ntfy.sh/publish/#publish-as-json). */
|
||||
export interface NtfyMessage {
|
||||
topic: string;
|
||||
title: string;
|
||||
message: string;
|
||||
priority: 3 | 4 | 5;
|
||||
tags: string[];
|
||||
click?: string;
|
||||
actions?: NtfyHttpAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Actions` header for the payload's quick answers, in ntfy's short
|
||||
* format (https://docs.ntfy.sh/publish/#action-buttons):
|
||||
* The message ntfy publishes for a payload.
|
||||
*
|
||||
* http, <label>, <url>, method=POST, clear=true; …
|
||||
*
|
||||
* `clear=true` dismisses the notification once the request succeeded, so a
|
||||
* reminder answered from the shade does not linger. The token in the URL is
|
||||
* the request's only credential (see `action-token.ts`).
|
||||
* - Priority: an explicitly urgent payload escalates to ntfy's top priority
|
||||
* (5, max) so it bypasses the relay's batching and surfaces loudest;
|
||||
* MEDICATION_REMINDER keeps its long-standing high (4); the rest stay at
|
||||
* the default (3).
|
||||
* - Tags: discreet mode (cycle privacy) collapses them to a generic tag,
|
||||
* because tags are visible on the lock screen and would name the cycle
|
||||
* event. An urgent, non-discreet event also carries `warning` so the entry
|
||||
* renders as an alert.
|
||||
* - Click: tapping the notification opens the resolved tap URL; absent when
|
||||
* the operator configured no public origin.
|
||||
* - Actions: the payload's quick answers, at most three, as `http` POST
|
||||
* buttons with `clear` so an answered reminder leaves the shade. The token
|
||||
* in each URL is the request's only credential (see `action-token.ts`).
|
||||
*/
|
||||
export function buildNtfyActionsHeader(
|
||||
export function buildNtfyMessage(
|
||||
topic: string,
|
||||
payload: NotificationPayload,
|
||||
): NtfyMessage {
|
||||
const urgent = isUrgentPayload(payload) && payload.urgent === true;
|
||||
const priority = urgent
|
||||
? 5
|
||||
: payload.eventType === "MEDICATION_REMINDER"
|
||||
? 4
|
||||
: 3;
|
||||
const eventTag = payload.discreet
|
||||
? "reminder"
|
||||
: payload.eventType.toLowerCase().replace(/_/g, "-");
|
||||
const message: NtfyMessage = {
|
||||
topic,
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
// Plain text only, and no emoji on routine reminders.
|
||||
message: plainPushText(stripHtml(payload.message), payload.eventType),
|
||||
priority,
|
||||
tags: urgent && !payload.discreet ? ["warning", eventTag] : [eventTag],
|
||||
};
|
||||
if (payload.tap?.url) message.click = payload.tap.url;
|
||||
const actions = buildNtfyActions(payload);
|
||||
if (actions.length > 0) message.actions = actions;
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The payload's quick answers as ntfy `http` actions, at most three. */
|
||||
export function buildNtfyActions(
|
||||
payload: Pick<NotificationPayload, "quickActions">,
|
||||
): string | null {
|
||||
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
|
||||
if (actions.length === 0) return null;
|
||||
return actions
|
||||
.map(
|
||||
(action) =>
|
||||
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* A header value fetch can send and ntfy reads as UTF-8.
|
||||
*
|
||||
* `fetch` refuses any header character above U+00FF outright (a Korean
|
||||
* title or action label would fail the whole send), and a Latin-1 byte such
|
||||
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
|
||||
* in every header, so a value with anything beyond printable ASCII travels as
|
||||
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
|
||||
*/
|
||||
export function ntfyHeaderValue(value: string): string {
|
||||
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
||||
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
|
||||
): NtfyHttpAction[] {
|
||||
return (payload.quickActions ?? [])
|
||||
.slice(0, NTFY_MAX_ACTIONS)
|
||||
.map((action) => ({
|
||||
action: "http",
|
||||
label: action.label.trim() || "?",
|
||||
url: action.url,
|
||||
method: "POST",
|
||||
clear: true,
|
||||
}));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* the dispatcher resolves the path once per delivery and every channel
|
||||
* carries the same target in its own vocabulary:
|
||||
*
|
||||
* - ntfy → `Click` header (absolute URL)
|
||||
* - ntfy → `click` in the JSON publish body (absolute URL)
|
||||
* - Telegram → an inline URL button (absolute `https` URL only, see
|
||||
* `telegramLinkMode`)
|
||||
* - Web Push → `url` in the service-worker payload (same-origin path)
|
||||
|
||||
Reference in new issue
Block a user