0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-calendar/test/javascripts/lib/raw-event-helper-test.js
Kris 0cf23c2854
FEATURE: Add a livestream_allowed_hosts site setting for livestreams (#42374)
Currently the hosts that can be used for a livestream event are
hardcoded in the composer's JavaScript, and the server accepts any https
location, so the list isn't really enforced... and there's no way to use
hosts that would otherwise work, including custom or self-hosted
sources.

This adds a `livestream_allowed_hosts` site setting (defaulting to the
existing hardcoded list) and validates against it both in the composer
and on the server. Whether a livestream embeds a player still depends on
the site's onebox settings.

---------

Co-authored-by: Martin Brennan <martin@discourse.org>
2026-08-06 14:49:38 -04:00

721 lines
21 KiB
JavaScript
Vendored
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import {
attendanceTransition,
buildEventBlock,
buildParams,
defaultReminderFor,
isLivestreamUrl,
parseEventAttrs,
parseEventBlock,
parseReminders,
reconcileDefaultReminder,
reminderToBBCode,
removeEvent,
replaceRaw,
stateToEventInput,
} from "discourse/plugins/discourse-calendar/discourse/lib/raw-event-helper";
const SAME_DAY_CONFIG = {
startsAt: "2024-06-15T10:00:00Z",
endsAt: "2024-06-15T11:00:00Z",
allDay: false,
};
const ALL_DAY_CONFIG = {
startsAt: "2024-06-15",
endsAt: null,
allDay: true,
};
const SHORT_DEFAULT = {
type: "notification",
value: 15,
unit: "minutes",
period: "before",
};
const LONG_DEFAULT = {
type: "notification",
value: 1,
unit: "days",
period: "before",
};
module("Unit | Lib | raw-event-helper", function (hooks) {
setupTest(hooks);
test("removeEvent", function (assert) {
assert.strictEqual(
removeEvent('[event start="2024-01-01"]\nDescription\n[/event]'),
"",
"removes event with content"
);
assert.strictEqual(
removeEvent(
'Before\n[event start="2024-01-01"]\nContent\n[/event]\nAfter'
),
"Before\n\nAfter",
"preserves surrounding text"
);
assert.strictEqual(
removeEvent('[event start="2024-01-01"]\n[/event]'),
"",
"removes event without content"
);
});
test("replaceRaw", function (assert) {
const raw = 'Some text \n[event param1="va]lue1"]\n[/event]\n more text';
const params = {
param1: "newValue1",
param2: "value2",
};
assert.strictEqual(
replaceRaw(params, raw),
"Some text \n[event param1=newValue1 param2=value2]\n[/event]\n more text",
"updates existing parameters and adds new ones"
);
assert.false(
replaceRaw(params, "No event tag here"),
"returns false when no event tag is found"
);
assert.strictEqual(
replaceRaw({}, '[event param1="value1"]\n[/event]'),
"[event ]\n[/event]",
"handles empty params object"
);
assert.strictEqual(
replaceRaw(
{ name: "", location: "Paris" },
'[event original="value"]\n[/event]'
),
"[event location=Paris]\n[/event]",
"omits empty name parameter"
);
assert.strictEqual(
replaceRaw(
{ name: " ", location: "Berlin" },
'[event original="value"]\n[/event]'
),
"[event location=Berlin]\n[/event]",
"omits whitespace-only name parameter"
);
});
test("parseEventBlock extracts attrs and description, buildEventBlock round-trips", function (assert) {
const raw =
'preface\n[event start="2024-06-15 10:00" name="Demo"]\nbody line\n[/event]\ntail';
const parsed = parseEventBlock(raw);
assert.deepEqual(parsed.attrs, { start: "2024-06-15 10:00", name: "Demo" });
assert.strictEqual(parsed.description, "body line");
assert.strictEqual(
parsed.full,
'[event start="2024-06-15 10:00" name="Demo"]\nbody line\n[/event]'
);
const rebuilt = buildEventBlock(parsed.attrs, parsed.description);
assert.deepEqual(
parseEventBlock(rebuilt).attrs,
parsed.attrs,
"buildEventBlock round-trips parsed values"
);
});
test("parseEventBlock keeps brackets inside quoted attr values", function (assert) {
const parsed = parseEventBlock(
'[event start="2024-06-15" location="[RSVP](https://zoom.example.com/j/123)"]\n[/event]'
);
assert.deepEqual(parsed.attrs, {
start: "2024-06-15",
location: "[RSVP](https://zoom.example.com/j/123)",
});
});
test("parseEventBlock reads the quote pairs the rich editor emits", function (assert) {
const parsed = parseEventBlock(
'[event start=«2024-06-15 10:00» location=“Sam\'s "Release"”]\n[/event]'
);
assert.deepEqual(parsed.attrs, {
start: "2024-06-15 10:00",
location: 'Sam\'s "Release"',
});
});
test("buildEventBlock preserves values containing quotes", function (assert) {
const attrs = { location: 'Sam\'s "Release" party' };
const rebuilt = buildEventBlock(attrs, "");
assert.deepEqual(
parseEventBlock(rebuilt).attrs,
attrs,
"quotes survive the serialize/parse round-trip"
);
});
test("parseEventBlock returns null when no event tag", function (assert) {
assert.strictEqual(parseEventBlock("no event here"), null);
assert.strictEqual(parseEventBlock(""), null);
assert.strictEqual(parseEventBlock(null), null);
});
test("parseEventBlock accepts single-quoted attr values", function (assert) {
const parsed = parseEventBlock(
"[event start='2222-02-22 14:22' timezone='Australia/Sydney']\n[/event]"
);
assert.deepEqual(parsed.attrs, {
start: "2222-02-22 14:22",
timezone: "Australia/Sydney",
});
});
test("parseEventBlock accepts unquoted attr values", function (assert) {
const parsed = parseEventBlock(
"[event start=2024-06-15 status=public timezone=Europe/Paris]\n[/event]"
);
assert.deepEqual(parsed.attrs, {
start: "2024-06-15",
status: "public",
timezone: "Europe/Paris",
});
});
test("parseEventBlock normalizes dashed keys to camelCase", function (assert) {
const parsed = parseEventBlock(
'[event start="2024-06-15 10:00" max-attendees="5" recurrence-until="2024-07-15" show-local-time="true"]\n[/event]'
);
assert.deepEqual(parsed.attrs, {
start: "2024-06-15 10:00",
maxAttendees: "5",
recurrenceUntil: "2024-07-15",
showLocalTime: "true",
});
});
test("parseEventBlock accepts mixed quote forms in one tag", function (assert) {
const parsed = parseEventBlock(
`[event start="2024-06-15 10:00" status=public timezone='Europe/Paris' max-attendees=10]\nfine\n[/event]`
);
assert.deepEqual(parsed.attrs, {
start: "2024-06-15 10:00",
status: "public",
timezone: "Europe/Paris",
maxAttendees: "10",
});
assert.strictEqual(parsed.description, "fine");
});
test("parseReminders converts comma-separated BBCode into reminder objects", function (assert) {
assert.deepEqual(parseReminders(""), []);
assert.deepEqual(parseReminders(null), []);
assert.deepEqual(
parseReminders("notification.15.minutes,bumpTopic.-1.hours"),
[
{ type: "notification", value: 15, unit: "minutes", period: "before" },
{ type: "bumpTopic", value: 1, unit: "hours", period: "after" },
]
);
});
test("defaultReminderFor", function (assert) {
assert.deepEqual(
defaultReminderFor({
startsAt: "2024-06-15T10:00:00Z",
endsAt: "2024-06-15T11:00:00Z",
allDay: false,
}),
{ type: "notification", value: 15, unit: "minutes", period: "before" },
"uses 15-minute reminder for same-day timed events"
);
assert.deepEqual(
defaultReminderFor({
startsAt: "2024-06-15T10:00:00Z",
endsAt: "2024-06-16T10:00:00Z",
allDay: false,
}),
{ type: "notification", value: 1, unit: "days", period: "before" },
"uses 1-day reminder for multi-day events"
);
assert.deepEqual(
defaultReminderFor({
startsAt: "2024-06-15",
endsAt: null,
allDay: true,
}),
{ type: "notification", value: 1, unit: "days", period: "before" },
"uses 1-day reminder for all-day events"
);
});
test("reconcileDefaultReminder", function (assert) {
assert.deepEqual(
reconcileDefaultReminder([], SAME_DAY_CONFIG, ALL_DAY_CONFIG),
[],
"empty reminders pass through unchanged"
);
const multiple = [
SHORT_DEFAULT,
{ type: "bumpTopic", value: 1, unit: "hours", period: "before" },
];
assert.strictEqual(
reconcileDefaultReminder(multiple, SAME_DAY_CONFIG, ALL_DAY_CONFIG),
multiple,
"multi-reminder arrays are left alone"
);
const customized = [
{ type: "notification", value: 30, unit: "minutes", period: "before" },
];
assert.strictEqual(
reconcileDefaultReminder(customized, SAME_DAY_CONFIG, ALL_DAY_CONFIG),
customized,
"user-customized reminder is preserved"
);
const otherSameDay = {
startsAt: "2024-07-20T14:00:00Z",
endsAt: "2024-07-20T15:00:00Z",
allDay: false,
};
const reminders = [{ ...SHORT_DEFAULT }];
assert.strictEqual(
reconcileDefaultReminder(reminders, SAME_DAY_CONFIG, otherSameDay),
reminders,
"no swap when both configs share the same default"
);
assert.deepEqual(
reconcileDefaultReminder(
[{ ...SHORT_DEFAULT }],
SAME_DAY_CONFIG,
ALL_DAY_CONFIG
),
[LONG_DEFAULT],
"swaps short default for long default when going to all-day"
);
assert.deepEqual(
reconcileDefaultReminder(
[{ ...LONG_DEFAULT }],
ALL_DAY_CONFIG,
SAME_DAY_CONFIG
),
[SHORT_DEFAULT],
"swaps long default for short default when going to same-day timed"
);
assert.deepEqual(
reconcileDefaultReminder(
[{ ...SHORT_DEFAULT, type: "bumpTopic" }],
SAME_DAY_CONFIG,
ALL_DAY_CONFIG
),
[{ ...LONG_DEFAULT, type: "bumpTopic" }],
"preserves the original type when swapping default"
);
});
test("reminderToBBCode", function (assert) {
assert.strictEqual(
reminderToBBCode({
type: "notification",
value: 15,
unit: "minutes",
period: "before",
}),
"notification.15.minutes",
"serializes a before reminder"
);
assert.strictEqual(
reminderToBBCode({
type: "bumpTopic",
value: 30,
unit: "minutes",
period: "after",
}),
"bumpTopic.-30.minutes",
"serializes an after reminder with negative value"
);
});
test("attendanceTransition: none captures status + max and flips notifications to bumpTopic", function (assert) {
const result = attendanceTransition({
mode: "none",
status: "private",
maxAttendees: 50,
reminders: [
{ type: "notification", value: 15, unit: "minutes", period: "before" },
],
previousRsvpStatus: "public",
previousMaxAttendees: null,
});
assert.strictEqual(result.status, "standalone");
assert.strictEqual(result.maxAttendees, null);
assert.strictEqual(result.reminders[0].type, "bumpTopic");
assert.strictEqual(result.previousRsvpStatus, "private");
assert.strictEqual(result.previousMaxAttendees, 50);
});
test("attendanceTransition: switching from none to unlimited restores prior status and flips reminders", function (assert) {
const result = attendanceTransition({
mode: "unlimited",
status: "standalone",
maxAttendees: null,
reminders: [
{ type: "bumpTopic", value: 15, unit: "minutes", period: "before" },
],
previousRsvpStatus: "private",
previousMaxAttendees: 50,
});
assert.strictEqual(result.status, "private");
assert.strictEqual(result.maxAttendees, null);
assert.strictEqual(result.reminders[0].type, "notification");
});
test("attendanceTransition: upTo restores previousMaxAttendees", function (assert) {
const result = attendanceTransition({
mode: "upTo",
status: "standalone",
maxAttendees: null,
reminders: [],
previousRsvpStatus: "public",
previousMaxAttendees: 25,
});
assert.strictEqual(result.status, "public");
assert.strictEqual(result.maxAttendees, 25);
});
test("attendanceTransition: upTo with no previous yields null max", function (assert) {
const result = attendanceTransition({
mode: "upTo",
status: "public",
maxAttendees: null,
reminders: [],
previousRsvpStatus: "public",
previousMaxAttendees: null,
});
assert.strictEqual(result.maxAttendees, null);
assert.strictEqual(result.status, "public");
});
test("attendanceTransition: unlimited from upTo captures the current max", function (assert) {
const result = attendanceTransition({
mode: "unlimited",
status: "public",
maxAttendees: 25,
reminders: [],
previousRsvpStatus: "public",
previousMaxAttendees: null,
});
assert.strictEqual(result.maxAttendees, null);
assert.strictEqual(result.previousMaxAttendees, 25);
});
test("attendanceTransition: does not mutate the input reminders array", function (assert) {
const reminders = [
{ type: "notification", value: 15, unit: "minutes", period: "before" },
];
attendanceTransition({
mode: "none",
status: "public",
maxAttendees: null,
reminders,
previousRsvpStatus: "public",
previousMaxAttendees: null,
});
assert.strictEqual(reminders[0].type, "notification");
});
test("buildParams image handling", function (assert) {
const startsAt = "2024-06-15T10:00:00Z";
const siteSettings = { discourse_post_event_allowed_custom_fields: "" };
assert.strictEqual(
buildParams(
startsAt,
null,
{
imageUpload: {
short_url: "upload://abc123.png",
url: "/uploads/default/original/1X/abc123.png",
},
},
siteSettings
).image,
"upload://abc123.png",
"prefers short_url when available"
);
assert.strictEqual(
buildParams(
startsAt,
null,
{ imageUpload: { url: "/uploads/default/original/1X/abc123.png" } },
siteSettings
).image,
"/uploads/default/original/1X/abc123.png",
"falls back to url when short_url is not set"
);
assert.strictEqual(
buildParams(startsAt, null, {}, siteSettings).image,
undefined,
"omits image when imageUpload is not set"
);
});
test("livestream round-trips through state, params and parsing", function (assert) {
const startsAt = "2024-06-15T10:00:00Z";
const siteSettings = { discourse_post_event_allowed_custom_fields: "" };
assert.strictEqual(
buildParams(startsAt, null, { livestream: true }, siteSettings)
.livestream,
"true",
"buildParams emits livestream when enabled"
);
assert.strictEqual(
buildParams(startsAt, null, { livestream: false }, siteSettings)
.livestream,
undefined,
"buildParams omits livestream when disabled"
);
assert.true(
stateToEventInput({ livestream: true }).livestream,
"stateToEventInput carries livestream through"
);
assert.true(
parseEventAttrs({ livestream: "true" }).livestream,
"parseEventAttrs reads livestream=true"
);
assert.false(
parseEventAttrs({}).livestream,
"parseEventAttrs defaults livestream to false"
);
});
test("isLivestreamUrl only accepts https URLs for major livestreaming platforms", function (assert) {
const siteSettings = getOwner(this).lookup("service:site-settings");
assert.false(
isLivestreamUrl("https://example.com/live", siteSettings),
"does not accept arbitrary URLs"
);
assert.false(
isLivestreamUrl("http://example.com/live", siteSettings),
"does not accept arbitrary URLs"
);
assert.false(
isLivestreamUrl("http://www.youtube.com/watch?v=1", siteSettings),
"rejects http on an allowed host"
);
assert.false(
isLivestreamUrl("HTTPS://EXAMPLE.COM", siteSettings),
"does not accept arbitrary URLs"
);
assert.false(
isLivestreamUrl("www.example.com", siteSettings),
"rejects schemeless www"
);
assert.false(
isLivestreamUrl("youtube.com/watch?v=dQw4w9WgXcQ", siteSettings),
"rejects a schemeless allowed host, which the save path would not retain"
);
assert.false(
isLivestreamUrl("mailto:host@example.com", siteSettings),
"rejects mailto"
);
assert.false(
isLivestreamUrl("ftp://youtube.com/watch", siteSettings),
"rejects a non-https scheme on an allowed host"
);
assert.false(
isLivestreamUrl("https://", siteSettings),
"rejects a bare scheme"
);
assert.false(isLivestreamUrl("Room 5", siteSettings), "rejects plain text");
assert.false(isLivestreamUrl(null, siteSettings), "handles null");
assert.false(isLivestreamUrl(undefined, siteSettings), "handles undefined");
assert.true(
isLivestreamUrl(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
siteSettings
),
"accepts YouTube"
);
assert.true(
isLivestreamUrl("https://youtu.be/dQw4w9WgXcQ", siteSettings),
"accepts YouTube short link"
);
assert.true(
isLivestreamUrl("https://www.twitch.tv/username", siteSettings),
"accepts Twitch"
);
assert.true(
isLivestreamUrl(
"https://www.facebook.com/username/videos/123456789",
siteSettings
),
"accepts Facebook"
);
assert.true(
isLivestreamUrl(
"https://www.tiktok.com/@username/video/123456789",
siteSettings
),
"accepts TikTok"
);
assert.true(
isLivestreamUrl("https://www.instagram.com/tv/123456789", siteSettings),
"accepts Instagram"
);
assert.true(
isLivestreamUrl("https://zoom.us/j/123456789", siteSettings),
"accepts Zoom"
);
});
test("isLivestreamUrl accepts subdomains of livestreaming platforms", function (assert) {
const siteSettings = getOwner(this).lookup("service:site-settings");
assert.true(
isLivestreamUrl(
"https://us06web.zoom.us/j/123456789?pwd=secret",
siteSettings
),
"accepts the vanity subdomain Zoom actually hands out"
);
assert.true(
isLivestreamUrl(
"https://gaming.youtube.com/watch?v=dQw4w9WgXcQ",
siteSettings
),
"accepts a YouTube subdomain"
);
assert.true(
isLivestreamUrl("HTTPS://US06WEB.ZOOM.US/j/123456789", siteSettings),
"is case-insensitive"
);
});
test("isLivestreamUrl rejects hosts that merely resemble a platform", function (assert) {
const siteSettings = getOwner(this).lookup("service:site-settings");
assert.false(
isLivestreamUrl("https://notzoom.us/j/123456789", siteSettings),
"rejects a host ending in the platform name"
);
assert.false(
isLivestreamUrl("https://zoom.us.evil.com/j/123456789", siteSettings),
"rejects a host starting with the platform name"
);
assert.false(
isLivestreamUrl("https://myyoutube.com/watch?v=1", siteSettings),
"rejects a host prefixed with the platform name"
);
assert.false(
isLivestreamUrl("https://youtube.com@evil.com/live", siteSettings),
"rejects a platform name smuggled into the userinfo"
);
assert.false(
isLivestreamUrl("https://youtube.com\t@evil.com/live", siteSettings),
"rejects userinfo hidden behind a stripped control character"
);
assert.false(
isLivestreamUrl("https://youtube.com。evil.com/live", siteSettings),
"rejects a separator that only looks like a label boundary"
);
});
// The browser resolves these to a host the admin did allow, so the save path
// keeps the livestream flag and the editor has to agree.
test("isLivestreamUrl matches the host the browser would connect to", function (assert) {
const siteSettings = getOwner(this).lookup("service:site-settings");
assert.true(
isLivestreamUrl("https://youtube.com\\@evil.com/live", siteSettings),
"resolves a backslash as a path separator"
);
assert.true(
isLivestreamUrl("https://%79outube.com/live", siteSettings),
"decodes percent escapes in the host"
);
assert.true(
isLivestreamUrl("https://outube.com/live", siteSettings),
"maps Unicode to the host it resolves to"
);
assert.true(
isLivestreamUrl("https://youtube.com./live", siteSettings),
"ignores a fully qualified trailing dot"
);
});
test("isLivestreamUrl follows the livestream_allowed_hosts setting", function (assert) {
const custom = { livestream_allowed_hosts: "stream.example.com|vimeo.com" };
assert.true(
isLivestreamUrl("https://stream.example.com/live/1", custom),
"accepts an admin-added host"
);
assert.true(
isLivestreamUrl("https://player.vimeo.com/video/123", custom),
"accepts subdomains of an admin-added host"
);
assert.false(
isLivestreamUrl("https://www.youtube.com/watch?v=1", custom),
"rejects a default host the admin removed"
);
assert.true(
isLivestreamUrl("https://vimeo.com/123", {
livestream_allowed_hosts: "VIMEO.com",
}),
"canonicalizes a list entry before comparing"
);
assert.false(
isLivestreamUrl("https://vimeo.com/123", {
livestream_allowed_hosts: "https://vimeo.com/videos",
}),
"ignores a list entry that is not a bare host, as the validator rejects it on save"
);
assert.false(
isLivestreamUrl("https://youtube.com/live", {
livestream_allowed_hosts: "outube.com",
}),
"ignores a non-ASCII list entry, which the validator requires in punycode"
);
assert.false(
isLivestreamUrl("https://www.youtube.com/watch?v=1", {
livestream_allowed_hosts: "",
}),
"an empty list allows nothing"
);
assert.false(
isLivestreamUrl("https://www.youtube.com/watch?v=1", {}),
"a missing setting allows nothing"
);
});
});