';
const output = html.htmlStringToComponents(input, {
- allowedTags: new Set(['p', 'em']),
+ allowedTags: { p: {}, em: {} },
});
expect(output).toMatchSnapshot();
});
diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts
index 1686322300..971aefa6d1 100644
--- a/app/javascript/mastodon/utils/html.ts
+++ b/app/javascript/mastodon/utils/html.ts
@@ -1,5 +1,7 @@
import React from 'react';
+import htmlConfig from '../../config/html-tags.json';
+
// NB: This function can still return unsafe HTML
export const unescapeHTML = (html: string) => {
const wrapper = document.createElement('div');
@@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => {
return wrapper.textContent;
};
+interface AllowedTag {
+ /* True means allow, false disallows global attributes, string renames the attribute name for React. */
+ attributes?: Record;
+ /* If false, the tag cannot have children. Undefined or true means allowed. */
+ children?: boolean;
+}
+
+type AllowedTagsType = {
+ [Tag in keyof React.ReactHTML]?: AllowedTag;
+};
+
+const globalAttributes: Record = htmlConfig.global;
+const defaultAllowedTags: AllowedTagsType = htmlConfig.tags;
+
interface QueueItem {
node: Node;
parent: React.ReactNode[];
depth: number;
}
-interface Options {
+export interface HTMLToStringOptions> {
maxDepth?: number;
- onText?: (text: string) => React.ReactNode;
+ onText?: (text: string, extra: Arg) => React.ReactNode;
onElement?: (
element: HTMLElement,
children: React.ReactNode[],
+ extra: Arg,
) => React.ReactNode;
onAttribute?: (
name: string,
value: string,
tagName: string,
+ extra: Arg,
) => [string, unknown] | null;
- allowedTags?: Set;
+ allowedTags?: AllowedTagsType;
+ extraArgs?: Arg;
}
-const DEFAULT_ALLOWED_TAGS: ReadonlySet = new Set([
- 'a',
- 'abbr',
- 'b',
- 'blockquote',
- 'br',
- 'cite',
- 'code',
- 'del',
- 'dfn',
- 'dl',
- 'dt',
- 'em',
- 'h1',
- 'h2',
- 'h3',
- 'h4',
- 'h5',
- 'h6',
- 'hr',
- 'i',
- 'li',
- 'ol',
- 'p',
- 'pre',
- 'small',
- 'span',
- 'strong',
- 'sub',
- 'sup',
- 'time',
- 'u',
- 'ul',
-]);
-export function htmlStringToComponents(
+let uniqueIdCounter = 0;
+
+export function htmlStringToComponents>(
htmlString: string,
- options: Options = {},
+ options: HTMLToStringOptions = {},
) {
const wrapper = document.createElement('template');
wrapper.innerHTML = htmlString;
@@ -79,10 +66,11 @@ export function htmlStringToComponents(
const {
maxDepth = 10,
- allowedTags = DEFAULT_ALLOWED_TAGS,
+ allowedTags = defaultAllowedTags,
onAttribute,
onElement,
onText,
+ extraArgs = {} as Arg,
} = options;
while (queue.length > 0) {
@@ -109,9 +97,9 @@ export function htmlStringToComponents(
// Text can be added directly if it has any non-whitespace content.
case Node.TEXT_NODE: {
const text = node.textContent;
- if (text && text.trim() !== '') {
+ if (text) {
if (onText) {
- parent.push(onText(text));
+ parent.push(onText(text, extraArgs));
} else {
parent.push(text);
}
@@ -127,7 +115,9 @@ export function htmlStringToComponents(
}
// If the tag is not allowed, skip it and its children.
- if (!allowedTags.has(node.tagName.toLowerCase())) {
+ const tagName = node.tagName.toLowerCase();
+ const tagInfo = allowedTags[tagName as keyof typeof allowedTags];
+ if (!tagInfo) {
continue;
}
@@ -137,7 +127,8 @@ export function htmlStringToComponents(
// If onElement is provided, use it to create the element.
if (onElement) {
- const component = onElement(node, children);
+ const component = onElement(node, children, extraArgs);
+
// Check for undefined to allow returning null.
if (component !== undefined) {
element = component;
@@ -147,25 +138,56 @@ export function htmlStringToComponents(
// If the element wasn't created, use the default conversion.
if (element === undefined) {
const props: Record = {};
+ props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
for (const attr of node.attributes) {
+ let name = attr.name.toLowerCase();
+
+ // Custom attribute handler.
if (onAttribute) {
const result = onAttribute(
- attr.name,
+ name,
attr.value,
node.tagName.toLowerCase(),
+ extraArgs,
);
if (result) {
- const [name, value] = result;
- props[name] = value;
+ const [cbName, value] = result;
+ props[cbName] = value;
}
} else {
- props[attr.name] = attr.value;
+ // Check global attributes first, then tag-specific ones.
+ const globalAttr = globalAttributes[name];
+ const tagAttr = tagInfo.attributes?.[name];
+
+ // Exit if neither global nor tag-specific attribute is allowed.
+ if (!globalAttr && !tagAttr) {
+ continue;
+ }
+
+ // Rename if needed.
+ if (typeof tagAttr === 'string') {
+ name = tagAttr;
+ } else if (typeof globalAttr === 'string') {
+ name = globalAttr;
+ }
+
+ let value: string | boolean | number = attr.value;
+
+ // Handle boolean attributes.
+ if (value === 'true') {
+ value = true;
+ } else if (value === 'false') {
+ value = false;
+ }
+
+ props[name] = value;
}
}
+
element = React.createElement(
- node.tagName.toLowerCase(),
+ tagName,
props,
- children,
+ tagInfo.children !== false ? children : undefined,
);
}
diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb
index 7a4cf3c1b8..e536883a55 100644
--- a/spec/lib/activitypub/tag_manager_spec.rb
+++ b/spec/lib/activitypub/tag_manager_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe ActivityPub::TagManager do
subject { described_class.instance }
- let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" }
+ let(:host_prefix) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" }
describe '#public_collection?' do
it 'returns true for the special public collection and common shorthands' do
@@ -22,18 +22,123 @@ RSpec.describe ActivityPub::TagManager do
end
describe '#url_for' do
- it 'returns a string starting with web domain' do
- account = Fabricate(:account)
- expect(subject.url_for(account)).to be_a(String)
- .and start_with(domain)
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.url_for(account))
+ .to eq("#{host_prefix}/@#{account.username}")
+ end
+ end
+
+ context 'with a remote account' do
+ let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') }
+
+ it 'returns the expected URL' do
+ expect(subject.url_for(account)).to eq account.url
+ end
+ end
+
+ context 'with a local status' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.url_for(status))
+ .to eq("#{host_prefix}/@#{status.account.username}/#{status.id}")
+ end
+ end
+
+ context 'with a remote status' do
+ let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') }
+ let(:status) { Fabricate(:status, account: account, url: 'https://example.com/posts/1234') }
+
+ it 'returns the expected URL' do
+ expect(subject.url_for(status)).to eq status.url
+ end
end
end
describe '#uri_for' do
- it 'returns a string starting with web domain' do
- account = Fabricate(:account)
- expect(subject.uri_for(account)).to be_a(String)
- .and start_with(domain)
+ context 'with the instance actor' do
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.uri_for(Account.representative))
+ .to eq("#{host_prefix}/actor")
+ end
+ end
+
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}")
+ end
+ end
+
+ context 'with a remote account' do
+ let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
+
+ it 'returns the expected URL' do
+ expect(subject.uri_for(account)).to eq account.uri
+ end
+ end
+
+ context 'with a local status' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.uri_for(status))
+ .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}")
+ end
+ end
+
+ context 'with a remote status' do
+ let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
+ let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') }
+
+ it 'returns the expected URL' do
+ expect(subject.uri_for(status)).to eq status.uri
+ end
+ end
+
+ context 'with a local conversation' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.uri_for(status.conversation))
+ .to eq("#{host_prefix}/contexts/#{status.account.id}-#{status.id}")
+ end
+ end
+
+ context 'with a remote conversation' do
+ let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
+ let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') }
+
+ before do
+ status.conversation.update!(uri: 'https://example.com/conversations/1234')
+ end
+
+ it 'returns the expected URL' do
+ expect(subject.uri_for(status.conversation)).to eq status.conversation.uri
+ end
+ end
+ end
+
+ describe '#key_uri_for' do
+ context 'with the instance actor' do
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.key_uri_for(Account.representative))
+ .to eq("#{host_prefix}/actor#main-key")
+ end
+ end
+
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.key_uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}#main-key")
+ end
end
end
@@ -49,7 +154,137 @@ RSpec.describe ActivityPub::TagManager do
it 'returns a string starting with web domain' do
status = Fabricate(:status)
expect(subject.uri_for(status)).to be_a(String)
- .and start_with(domain)
+ .and start_with(host_prefix)
+ end
+ end
+ end
+
+ describe '#approval_uri_for' do
+ context 'with a valid local approval' do
+ let(:quote) { Fabricate(:quote, state: :accepted) }
+
+ it 'returns a string with the web domain and expected path' do
+ expect(subject.approval_uri_for(quote))
+ .to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}")
+ end
+ end
+
+ context 'with an unapproved local quote' do
+ let(:quote) { Fabricate(:quote, state: :rejected) }
+
+ it 'returns nil' do
+ expect(subject.approval_uri_for(quote))
+ .to be_nil
+ end
+ end
+
+ context 'with a valid remote approval' do
+ let(:quoted_account) { Fabricate(:account, domain: 'example.com') }
+ let(:quoted_status) { Fabricate(:status, account: quoted_account) }
+ let(:quote) { Fabricate(:quote, state: :accepted, quoted_status: quoted_status, approval_uri: 'https://example.com/approvals/1') }
+
+ it 'returns the expected URI' do
+ expect(subject.approval_uri_for(quote)).to eq quote.approval_uri
+ end
+ end
+
+ context 'with an unapproved local quote but check_approval override' do
+ let(:quote) { Fabricate(:quote, state: :rejected) }
+
+ it 'returns a string with the web domain and expected path' do
+ expect(subject.approval_uri_for(quote, check_approval: false))
+ .to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}")
+ end
+ end
+ end
+
+ describe '#replies_uri_for' do
+ context 'with a local status' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.replies_uri_for(status))
+ .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/replies")
+ end
+ end
+ end
+
+ describe '#likes_uri_for' do
+ context 'with a local status' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.likes_uri_for(status))
+ .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/likes")
+ end
+ end
+ end
+
+ describe '#shares_uri_for' do
+ context 'with a local status' do
+ let(:status) { Fabricate(:status) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.shares_uri_for(status))
+ .to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/shares")
+ end
+ end
+ end
+
+ describe '#following_uri_for' do
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.following_uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}/following")
+ end
+ end
+ end
+
+ describe '#followers_uri_for' do
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.followers_uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}/followers")
+ end
+ end
+ end
+
+ describe '#inbox_uri_for' do
+ context 'with the instance actor' do
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.inbox_uri_for(Account.representative))
+ .to eq("#{host_prefix}/actor/inbox")
+ end
+ end
+
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.inbox_uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}/inbox")
+ end
+ end
+ end
+
+ describe '#outbox_uri_for' do
+ context 'with the instance actor' do
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.outbox_uri_for(Account.representative))
+ .to eq("#{host_prefix}/actor/outbox")
+ end
+ end
+
+ context 'with a local account' do
+ let(:account) { Fabricate(:account) }
+
+ it 'returns a string starting with web domain and with the expected path' do
+ expect(subject.outbox_uri_for(account))
+ .to eq("#{host_prefix}/users/#{account.username}/outbox")
end
end
end