diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index c5220837c6d..9309e3ac0da 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -64,7 +64,8 @@ export async function handleNextcloudTalkInbound(params: { }); const rawBody = message.text?.trim() ?? ""; - if (!rawBody) { + const hasMedia = (message.mediaUrls?.length ?? 0) > 0; + if (!rawBody && !hasMedia) { return; } @@ -273,6 +274,7 @@ export async function handleNextcloudTalkInbound(params: { Provider: CHANNEL_ID, Surface: CHANNEL_ID, WasMentioned: isGroup ? wasMentioned : undefined, + MediaUrls: message.mediaUrls, MessageSid: message.messageId, Timestamp: message.timestamp, OriginatingChannel: CHANNEL_ID, diff --git a/extensions/nextcloud-talk/src/monitor.attachment.test.ts b/extensions/nextcloud-talk/src/monitor.attachment.test.ts new file mode 100644 index 00000000000..c7e4b0a5336 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.attachment.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; +import type { NextcloudTalkInboundMessage } from "./types.js"; + +const SECRET = "nextcloud-secret"; // pragma: allowlist secret + +function createSignedRequest(objectOverrides: Record) { + const payload = { + type: "Create", + actor: { type: "Person", id: "alice", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "", + content: "", + mediaType: "text/plain", + ...objectOverrides, + }, + target: { type: "Collection", id: "room-1", name: "Room 1" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = generateNextcloudTalkSignature({ body, secret: SECRET }); + return { + body, + headers: { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.example", + }, + }; +} + +describe("nextcloud-talk webhook attachment parsing", () => { + it("parses file share rich object into displayText and mediaUrls", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-test", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const richContent = JSON.stringify({ + message: "{file}", + parameters: { + file: { + type: "file", + id: "12345", + name: "IMG_123.jpg", + path: "Talk/IMG_123.jpg", + link: "https://cloud.example.com/f/12345", + mimetype: "image/jpeg", + }, + }, + }); + + const { body, headers } = createSignedRequest({ content: richContent }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("IMG_123.jpg"); + expect(received[0].mediaUrls).toEqual(["https://cloud.example.com/f/12345"]); + }); + + it("returns undefined mediaUrls for regular text messages", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-text", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const { body, headers } = createSignedRequest({ + name: "hello world", + content: "hello world", + }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("hello world"); + expect(received[0].mediaUrls).toBeUndefined(); + }); + + it("falls back to raw content for malformed JSON in object.content", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-malformed", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const { body, headers } = createSignedRequest({ content: "not json {{{" }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("not json {{{"); + expect(received[0].mediaUrls).toBeUndefined(); + }); + + it("uses object.name as fallback when object.content is empty", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-name-fallback", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const { body, headers } = createSignedRequest({ + content: "", + name: "fallback text", + }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("fallback text"); + expect(received[0].mediaUrls).toBeUndefined(); + }); + + it("resolves displayText for non-file rich objects (mentions) without populating mediaUrls", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-mention", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const mentionContent = JSON.stringify({ + message: "{mention} joined the room", + parameters: { + mention: { + type: "user", + id: "alice", + name: "Alice", + }, + }, + }); + + const { body, headers } = createSignedRequest({ content: mentionContent }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("Alice joined the room"); + expect(received[0].mediaUrls).toBeUndefined(); + }); + + it("rejects non-http(s) URLs in file parameters", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-bad-url", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const maliciousContent = JSON.stringify({ + message: "{file}", + parameters: { + file: { + type: "file", + id: "1", + name: "passwd", + path: "Talk/passwd", + link: "file:///etc/passwd", + mimetype: "text/plain", + }, + }, + }); + + const { body, headers } = createSignedRequest({ content: maliciousContent }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + // displayText still resolves but malicious URL is not in mediaUrls + expect(received[0].text).toBe("passwd"); + expect(received[0].mediaUrls).toBeUndefined(); + }); + + it("handles multiple file attachments in a single message", async () => { + const received: NextcloudTalkInboundMessage[] = []; + const harness = await startWebhookServer({ + path: "/attachment-multi", + onMessage: async (msg) => { + received.push(msg); + }, + }); + + const multiContent = JSON.stringify({ + message: "{file1} and {file2}", + parameters: { + file1: { + type: "file", + id: "1", + name: "photo1.jpg", + path: "Talk/photo1.jpg", + link: "https://cloud.example.com/f/1", + mimetype: "image/jpeg", + }, + file2: { + type: "file", + id: "2", + name: "photo2.jpg", + path: "Talk/photo2.jpg", + link: "https://cloud.example.com/f/2", + mimetype: "image/jpeg", + }, + }, + }); + + const { body, headers } = createSignedRequest({ content: multiContent }); + const res = await fetch(harness.webhookUrl, { method: "POST", headers, body }); + + expect(res.status).toBe(200); + expect(received).toHaveLength(1); + expect(received[0].text).toBe("photo1.jpg and photo2.jpg"); + expect(received[0].mediaUrls).toEqual([ + "https://cloud.example.com/f/1", + "https://cloud.example.com/f/2", + ]); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.live-integration.test.ts b/extensions/nextcloud-talk/src/monitor.live-integration.test.ts new file mode 100644 index 00000000000..754754c724d --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.live-integration.test.ts @@ -0,0 +1,168 @@ +/** + * Live integration test: fire real signed NC Talk webhook payloads at the + * patched server and assert the full parsing pipeline works end-to-end. + */ +import { describe, expect, it } from "vitest"; +import { startWebhookServer } from "./monitor.test-harness.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; +import type { NextcloudTalkInboundMessage } from "./types.js"; + +const SECRET = "test-secret-live"; // pragma: allowlist secret + +function signed(objectOverrides: Record) { + const payload = { + type: "Create", + actor: { type: "Person", id: "testuser", name: "Alice" }, + object: { + type: "Note", + id: "msg-1", + name: "", + content: "", + mediaType: "text/plain", + ...objectOverrides, + }, + target: { type: "Collection", id: "room-abc", name: "Test Room" }, + }; + const body = JSON.stringify(payload); + const { random, signature } = generateNextcloudTalkSignature({ body, secret: SECRET }); + return { + body, + headers: { + "content-type": "application/json", + "x-nextcloud-talk-random": random, + "x-nextcloud-talk-signature": signature, + "x-nextcloud-talk-backend": "https://nextcloud.example", + }, + }; +} + +describe("live integration: NC Talk attachment parsing pipeline", () => { + it("file share: text=filename, mediaUrls=[https link]", async () => { + const msgs: NextcloudTalkInboundMessage[] = []; + const h = await startWebhookServer({ + path: "/live-1", + secret: SECRET, + onMessage: async (m) => { + msgs.push(m); + }, + }); + const content = JSON.stringify({ + message: "{file}", + parameters: { + file: { + type: "file", + id: "42", + name: "chart.png", + path: "Talk/chart.png", + link: "https://cloud.example.com/f/42", + mimetype: "image/png", + }, + }, + }); + const { body, headers } = signed({ content }); + await fetch(h.webhookUrl, { method: "POST", headers, body }); + expect(msgs[0].text).toBe("chart.png"); + expect(msgs[0].mediaUrls).toEqual(["https://cloud.example.com/f/42"]); + }); + + it("mention: text resolved, mediaUrls=undefined", async () => { + const msgs: NextcloudTalkInboundMessage[] = []; + const h = await startWebhookServer({ + path: "/live-2", + secret: SECRET, + onMessage: async (m) => { + msgs.push(m); + }, + }); + const content = JSON.stringify({ + message: "{mention} sent a message", + parameters: { mention: { type: "user", id: "testuser", name: "Alice" } }, + }); + const { body, headers } = signed({ content }); + await fetch(h.webhookUrl, { method: "POST", headers, body }); + expect(msgs[0].text).toBe("Alice sent a message"); + expect(msgs[0].mediaUrls).toBeUndefined(); + }); + + it("plain text: no regression, mediaUrls=undefined", async () => { + const msgs: NextcloudTalkInboundMessage[] = []; + const h = await startWebhookServer({ + path: "/live-3", + secret: SECRET, + onMessage: async (m) => { + msgs.push(m); + }, + }); + const { body, headers } = signed({ content: "Hello from Alice", name: "Hello from Alice" }); + await fetch(h.webhookUrl, { method: "POST", headers, body }); + expect(msgs[0].text).toBe("Hello from Alice"); + expect(msgs[0].mediaUrls).toBeUndefined(); + }); + + it("malicious file:// URL: text resolved, mediaUrls=undefined", async () => { + const msgs: NextcloudTalkInboundMessage[] = []; + const h = await startWebhookServer({ + path: "/live-4", + secret: SECRET, + onMessage: async (m) => { + msgs.push(m); + }, + }); + const content = JSON.stringify({ + message: "{file}", + parameters: { + file: { + type: "file", + id: "1", + name: "passwd", + path: "Talk/passwd", + link: "file:///etc/passwd", + mimetype: "text/plain", + }, + }, + }); + const { body, headers } = signed({ content }); + await fetch(h.webhookUrl, { method: "POST", headers, body }); + expect(msgs[0].text).toBe("passwd"); + expect(msgs[0].mediaUrls).toBeUndefined(); + }); + + it("multi-file: both links in mediaUrls, text has both names", async () => { + const msgs: NextcloudTalkInboundMessage[] = []; + const h = await startWebhookServer({ + path: "/live-5", + secret: SECRET, + onMessage: async (m) => { + msgs.push(m); + }, + }); + const content = JSON.stringify({ + message: "{f1} and {f2}", + parameters: { + f1: { + type: "file", + id: "1", + name: "photo1.jpg", + path: "Talk/photo1.jpg", + link: "https://cloud.example.com/f/1", + mimetype: "image/jpeg", + }, + f2: { + type: "file", + id: "2", + name: "photo2.jpg", + path: "Talk/photo2.jpg", + link: "https://cloud.example.com/f/2", + mimetype: "image/jpeg", + }, + }, + }); + const { body, headers } = signed({ content }); + await fetch(h.webhookUrl, { method: "POST", headers, body }); + expect(msgs[0].text).toBe("photo1.jpg and photo2.jpg"); + expect(msgs[0].mediaUrls).toEqual([ + "https://cloud.example.com/f/1", + "https://cloud.example.com/f/2", + ]); + }); +}); diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index b40024e5eb0..51f5d3b9660 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -149,11 +149,82 @@ function decodeWebhookCreateMessage(params: { return { kind: "message", message: payloadToInboundMessage(payload) }; } +interface RichObjectFileParam { + type: string; + id?: string; + name?: string; + path?: string; + link?: string; + mimetype?: string; +} + +interface RichObjectContent { + message: string; + parameters: Record; +} + +function parseRichObjectContent( + content: string, +): { displayText: string; mediaUrls?: string[] } | null { + let parsed: RichObjectContent; + try { + const raw = JSON.parse(content) as unknown; + if ( + typeof raw !== "object" || + raw === null || + typeof (raw as Record).message !== "string" || + typeof (raw as Record).parameters !== "object" || + (raw as Record).parameters === null + ) { + return null; + } + parsed = raw as RichObjectContent; + } catch { + return null; + } + + const mediaUrls: string[] = []; + let displayText = parsed.message; + let hadPlaceholders = false; + + for (const [key, value] of Object.entries(parsed.parameters)) { + const param = value as RichObjectFileParam; + // Only accept http(s) URLs to prevent file:// or javascript: injection + if ( + param?.type === "file" && + typeof param.link === "string" && + /^https?:\/\//i.test(param.link) + ) { + mediaUrls.push(param.link); + } + const placeholder = `{${key}}`; + if (displayText.includes(placeholder)) { + hadPlaceholders = true; + const fileName = typeof param?.name === "string" && param.name ? param.name : key; + // Use replaceAll to handle duplicate placeholders + displayText = displayText.replaceAll(placeholder, fileName); + } + } + + // Return if we have files OR if we resolved any placeholders (non-file rich objects + // like mentions, deck cards, polls — don't discard their resolved displayText) + if (mediaUrls.length === 0 && !hadPlaceholders) { + return null; + } + + return { + displayText: displayText.trim(), + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, + }; +} + function payloadToInboundMessage( payload: NextcloudTalkWebhookPayload, ): NextcloudTalkInboundMessage { // Payload doesn't indicate DM vs room; mark as group and let inbound handler refine. const isGroupChat = true; + const rawContent = payload.object.content || payload.object.name || ""; + const richObject = rawContent ? parseRichObjectContent(rawContent) : null; return { messageId: String(payload.object.id), @@ -161,8 +232,9 @@ function payloadToInboundMessage( roomName: payload.target.name, senderId: payload.actor.id, senderName: payload.actor.name ?? "", - text: payload.object.content || payload.object.name || "", + text: richObject?.displayText ?? rawContent, mediaType: payload.object.mediaType || "text/plain", + mediaUrls: richObject?.mediaUrls, timestamp: Date.now(), isGroupChat, }; diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index a7f2dc38ab0..1cd5428ceca 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -151,6 +151,7 @@ export type NextcloudTalkInboundMessage = { senderName: string; text: string; mediaType: string; + mediaUrls?: string[]; timestamp: number; isGroupChat: boolean; };