* Refactor formatter * Move custom emoji pre-rendering logic to view helpers * Move more methods out of Formatter * Fix code style issues * Remove Formatter * Add inline poll options to RSS feeds * Remove unused helper method * Fix code style issues * Various fixes and improvements * Fix testclosed-social-glitch-2
@ -0,0 +1,19 @@ | |||
# frozen_string_literal: true | |||
module FormattingHelper | |||
def html_aware_format(text, local, options = {}) | |||
HtmlAwareFormatter.new(text, local, options).to_s | |||
end | |||
def linkify(text, options = {}) | |||
TextFormatter.new(text, options).to_s | |||
end | |||
def extract_plain_text(text, local) | |||
PlainTextFormatter.new(text, local).to_s | |||
end | |||
def status_content_format(status) | |||
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [])) | |||
end | |||
end |
@ -0,0 +1,98 @@ | |||
# frozen_string_literal: true | |||
class EmojiFormatter | |||
include RoutingHelper | |||
DISALLOWED_BOUNDING_REGEX = /[[:alnum:]:]/.freeze | |||
attr_reader :html, :custom_emojis, :options | |||
# @param [ActiveSupport::SafeBuffer] html | |||
# @param [Array<CustomEmoji>] custom_emojis | |||
# @param [Hash] options | |||
# @option options [Boolean] :animate | |||
def initialize(html, custom_emojis, options = {}) | |||
raise ArgumentError unless html.html_safe? | |||
@html = html | |||
@custom_emojis = custom_emojis | |||
@options = options | |||
end | |||
def to_s | |||
return html if custom_emojis.empty? || html.blank? | |||
i = -1 | |||
tag_open_index = nil | |||
inside_shortname = false | |||
shortname_start_index = -1 | |||
invisible_depth = 0 | |||
last_index = 0 | |||
result = ''.dup | |||
while i + 1 < html.size | |||
i += 1 | |||
if invisible_depth.zero? && inside_shortname && html[i] == ':' | |||
inside_shortname = false | |||
shortcode = html[shortname_start_index + 1..i - 1] | |||
char_after = html[i + 1] | |||
next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode]) | |||
result << html[last_index..shortname_start_index - 1] if shortname_start_index.positive? | |||
result << image_for_emoji(shortcode, emoji) | |||
last_index = i + 1 | |||
elsif tag_open_index && html[i] == '>' | |||
tag = html[tag_open_index..i] | |||
tag_open_index = nil | |||
if invisible_depth.positive? | |||
invisible_depth += count_tag_nesting(tag) | |||
elsif tag == '<span class="invisible">' | |||
invisible_depth = 1 | |||
end | |||
elsif html[i] == '<' | |||
tag_open_index = i | |||
inside_shortname = false | |||
elsif !tag_open_index && html[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(html[i - 1])) | |||
inside_shortname = true | |||
shortname_start_index = i | |||
end | |||
end | |||
result << html[last_index..-1] | |||
result.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
private | |||
def emoji_map | |||
@emoji_map ||= custom_emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] } | |||
end | |||
def count_tag_nesting(tag) | |||
if tag[1] == '/' | |||
-1 | |||
elsif tag[-2] == '/' | |||
0 | |||
else | |||
1 | |||
end | |||
end | |||
def image_for_emoji(shortcode, emoji) | |||
original_url, static_url = emoji | |||
if animate? | |||
image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:") | |||
else | |||
image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url }) | |||
end | |||
end | |||
def animate? | |||
@options[:animate] | |||
end | |||
end |
@ -1,294 +0,0 @@ | |||
# frozen_string_literal: true | |||
require 'singleton' | |||
class Formatter | |||
include Singleton | |||
include RoutingHelper | |||
include ActionView::Helpers::TextHelper | |||
def format(status, **options) | |||
if status.respond_to?(:reblog?) && status.reblog? | |||
prepend_reblog = status.reblog.account.acct | |||
status = status.proper | |||
else | |||
prepend_reblog = false | |||
end | |||
raw_content = status.text | |||
if options[:inline_poll_options] && status.preloadable_poll | |||
raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n") | |||
end | |||
return '' if raw_content.blank? | |||
unless status.local? | |||
html = reformat(raw_content) | |||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] | |||
return html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : [] | |||
linkable_accounts << status.account | |||
html = raw_content | |||
html = "RT @#{prepend_reblog} #{html}" if prepend_reblog | |||
html = encode_and_link_urls(html, linkable_accounts) | |||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] | |||
html = simple_format(html, {}, sanitize: false) | |||
html = html.delete("\n") | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def reformat(html) | |||
sanitize(html, Sanitize::Config::MASTODON_STRICT) | |||
rescue ArgumentError | |||
'' | |||
end | |||
def plaintext(status) | |||
return status.text if status.local? | |||
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" } | |||
strip_tags(text) | |||
end | |||
def simplified_format(account, **options) | |||
return '' if account.note.blank? | |||
html = account.local? ? linkify(account.note) : reformat(account.note) | |||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify] | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def sanitize(html, config) | |||
Sanitize.fragment(html, config) | |||
end | |||
def format_spoiler(status, **options) | |||
html = encode(status.spoiler_text) | |||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def format_poll_option(status, option, **options) | |||
html = encode(option.title) | |||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def format_display_name(account, **options) | |||
html = encode(account.display_name.presence || account.username) | |||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify] | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def format_field(account, str, **options) | |||
html = account.local? ? encode_and_link_urls(str, me: true, with_domain: true) : reformat(str) | |||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify] | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
def linkify(text) | |||
html = encode_and_link_urls(text) | |||
html = simple_format(html, {}, sanitize: false) | |||
html = html.delete("\n") | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
private | |||
def html_entities | |||
@html_entities ||= HTMLEntities.new | |||
end | |||
def encode(html) | |||
html_entities.encode(html) | |||
end | |||
def encode_and_link_urls(html, accounts = nil, options = {}) | |||
entities = utf8_friendly_extractor(html, extract_url_without_protocol: false) | |||
if accounts.is_a?(Hash) | |||
options = accounts | |||
accounts = nil | |||
end | |||
rewrite(html.dup, entities) do |entity| | |||
if entity[:url] | |||
link_to_url(entity, options) | |||
elsif entity[:hashtag] | |||
link_to_hashtag(entity) | |||
elsif entity[:screen_name] | |||
link_to_mention(entity, accounts, options) | |||
end | |||
end | |||
end | |||
def count_tag_nesting(tag) | |||
if tag[1] == '/' then -1 | |||
elsif tag[-2] == '/' then 0 | |||
else 1 | |||
end | |||
end | |||
# rubocop:disable Metrics/BlockNesting | |||
def encode_custom_emojis(html, emojis, animate = false) | |||
return html if emojis.empty? | |||
emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] } | |||
i = -1 | |||
tag_open_index = nil | |||
inside_shortname = false | |||
shortname_start_index = -1 | |||
invisible_depth = 0 | |||
while i + 1 < html.size | |||
i += 1 | |||
if invisible_depth.zero? && inside_shortname && html[i] == ':' | |||
shortcode = html[shortname_start_index + 1..i - 1] | |||
emoji = emoji_map[shortcode] | |||
if emoji | |||
original_url, static_url = emoji | |||
replacement = begin | |||
if animate | |||
image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:") | |||
else | |||
image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url }) | |||
end | |||
end | |||
before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : '' | |||
html = before_html + replacement + html[i + 1..-1] | |||
i += replacement.size - (shortcode.size + 2) - 1 | |||
else | |||
i -= 1 | |||
end | |||
inside_shortname = false | |||
elsif tag_open_index && html[i] == '>' | |||
tag = html[tag_open_index..i] | |||
tag_open_index = nil | |||
if invisible_depth.positive? | |||
invisible_depth += count_tag_nesting(tag) | |||
elsif tag == '<span class="invisible">' | |||
invisible_depth = 1 | |||
end | |||
elsif html[i] == '<' | |||
tag_open_index = i | |||
inside_shortname = false | |||
elsif !tag_open_index && html[i] == ':' | |||
inside_shortname = true | |||
shortname_start_index = i | |||
end | |||
end | |||
html | |||
end | |||
# rubocop:enable Metrics/BlockNesting | |||
def rewrite(text, entities) | |||
text = text.to_s | |||
# Sort by start index | |||
entities = entities.sort_by do |entity| | |||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices] | |||
indices.first | |||
end | |||
result = [] | |||
last_index = entities.reduce(0) do |index, entity| | |||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices] | |||
result << encode(text[index...indices.first]) | |||
result << yield(entity) | |||
indices.last | |||
end | |||
result << encode(text[last_index..-1]) | |||
result.flatten.join | |||
end | |||
def utf8_friendly_extractor(text, options = {}) | |||
# Note: I couldn't obtain list_slug with @user/list-name format | |||
# for mention so this requires additional check | |||
special = Extractor.extract_urls_with_indices(text, options) | |||
standard = Extractor.extract_entities_with_indices(text, options) | |||
extra = Extractor.extract_extra_uris_with_indices(text, options) | |||
Extractor.remove_overlapping_entities(special + standard + extra) | |||
end | |||
def link_to_url(entity, options = {}) | |||
url = Addressable::URI.parse(entity[:url]) | |||
html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' } | |||
html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me] | |||
Twitter::TwitterText::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs) | |||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError | |||
encode(entity[:url]) | |||
end | |||
def link_to_mention(entity, linkable_accounts, options = {}) | |||
acct = entity[:screen_name] | |||
return link_to_account(acct, options) unless linkable_accounts | |||
same_username_hits = 0 | |||
account = nil | |||
username, domain = acct.split('@') | |||
domain = nil if TagManager.instance.local_domain?(domain) | |||
linkable_accounts.each do |item| | |||
same_username = item.username.casecmp(username).zero? | |||
same_domain = item.domain.nil? ? domain.nil? : item.domain.casecmp(domain)&.zero? | |||
if same_username && !same_domain | |||
same_username_hits += 1 | |||
elsif same_username && same_domain | |||
account = item | |||
end | |||
end | |||
account ? mention_html(account, with_domain: same_username_hits.positive? || options[:with_domain]) : "@#{encode(acct)}" | |||
end | |||
def link_to_account(acct, options = {}) | |||
username, domain = acct.split('@') | |||
domain = nil if TagManager.instance.local_domain?(domain) | |||
account = EntityCache.instance.mention(username, domain) | |||
account ? mention_html(account, with_domain: options[:with_domain]) : "@#{encode(acct)}" | |||
end | |||
def link_to_hashtag(entity) | |||
hashtag_html(entity[:hashtag]) | |||
end | |||
def link_html(url) | |||
url = Addressable::URI.parse(url).to_s | |||
prefix = url.match(/\A(https?:\/\/(www\.)?|xmpp:)/).to_s | |||
text = url[prefix.length, 30] | |||
suffix = url[prefix.length + 30..-1] | |||
cutoff = url[prefix.length..-1].length > 30 | |||
"<span class=\"invisible\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>" | |||
end | |||
def hashtag_html(tag) | |||
"<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>" | |||
end | |||
def mention_html(account, with_domain: false) | |||
"<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>" | |||
end | |||
end |
@ -0,0 +1,38 @@ | |||
# frozen_string_literal: true | |||
class HtmlAwareFormatter | |||
attr_reader :text, :local, :options | |||
alias local? local | |||
# @param [String] text | |||
# @param [Boolean] local | |||
# @param [Hash] options | |||
def initialize(text, local, options = {}) | |||
@text = text | |||
@local = local | |||
@options = options | |||
end | |||
def to_s | |||
return ''.html_safe if text.blank? | |||
if local? | |||
linkify | |||
else | |||
reformat.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
rescue ArgumentError | |||
''.html_safe | |||
end | |||
private | |||
def reformat | |||
Sanitize.fragment(text, Sanitize::Config::MASTODON_STRICT) | |||
end | |||
def linkify | |||
TextFormatter.new(text, options).to_s | |||
end | |||
end |
@ -0,0 +1,30 @@ | |||
# frozen_string_literal: true | |||
class PlainTextFormatter | |||
include ActionView::Helpers::TextHelper | |||
NEWLINE_TAGS_RE = /(<br \/>|<br>|<\/p>)+/.freeze | |||
attr_reader :text, :local | |||
alias local? local | |||
def initialize(text, local) | |||
@text = text | |||
@local = local | |||
end | |||
def to_s | |||
if local? | |||
text | |||
else | |||
strip_tags(insert_newlines).chomp | |||
end | |||
end | |||
private | |||
def insert_newlines | |||
text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" } | |||
end | |||
end |
@ -0,0 +1,158 @@ | |||
# frozen_string_literal: true | |||
class TextFormatter | |||
include ActionView::Helpers::TextHelper | |||
include ERB::Util | |||
include RoutingHelper | |||
URL_PREFIX_REGEX = /\A(https?:\/\/(www\.)?|xmpp:)/.freeze | |||
DEFAULT_REL = %w(nofollow noopener noreferrer).freeze | |||
DEFAULT_OPTIONS = { | |||
multiline: true, | |||
}.freeze | |||
attr_reader :text, :options | |||
# @param [String] text | |||
# @param [Hash] options | |||
# @option options [Boolean] :multiline | |||
# @option options [Boolean] :with_domains | |||
# @option options [Boolean] :with_rel_me | |||
# @option options [Array<Account>] :preloaded_accounts | |||
def initialize(text, options = {}) | |||
@text = text | |||
@options = DEFAULT_OPTIONS.merge(options) | |||
end | |||
def entities | |||
@entities ||= Extractor.extract_entities_with_indices(text, extract_url_without_protocol: false) | |||
end | |||
def to_s | |||
return ''.html_safe if text.blank? | |||
html = rewrite do |entity| | |||
if entity[:url] | |||
link_to_url(entity) | |||
elsif entity[:hashtag] | |||
link_to_hashtag(entity) | |||
elsif entity[:screen_name] | |||
link_to_mention(entity) | |||
end | |||
end | |||
html = simple_format(html, {}, sanitize: false).delete("\n") if multiline? | |||
html.html_safe # rubocop:disable Rails/OutputSafety | |||
end | |||
private | |||
def rewrite | |||
entities.sort_by! do |entity| | |||
entity[:indices].first | |||
end | |||
result = ''.dup | |||
last_index = entities.reduce(0) do |index, entity| | |||
indices = entity[:indices] | |||
result << h(text[index...indices.first]) | |||
result << yield(entity) | |||
indices.last | |||
end | |||
result << h(text[last_index..-1]) | |||
result | |||
end | |||
def link_to_url(entity) | |||
url = Addressable::URI.parse(entity[:url]).to_s | |||
rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL | |||
prefix = url.match(URL_PREFIX_REGEX).to_s | |||
display_url = url[prefix.length, 30] | |||
suffix = url[prefix.length + 30..-1] | |||
cutoff = url[prefix.length..-1].length > 30 | |||
<<~HTML.squish | |||
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a> | |||
HTML | |||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError | |||
h(entity[:url]) | |||
end | |||
def link_to_hashtag(entity) | |||
hashtag = entity[:hashtag] | |||
url = tag_url(hashtag) | |||
<<~HTML.squish | |||
<a href="#{h(url)}" class="mention hashtag" rel="tag">#<span>#{h(hashtag)}</span></a> | |||
HTML | |||
end | |||
def link_to_mention(entity) | |||
username, domain = entity[:screen_name].split('@') | |||
domain = nil if local_domain?(domain) | |||
account = nil | |||
if preloaded_accounts? | |||
same_username_hits = 0 | |||
preloaded_accounts.each do |other_account| | |||
same_username = other_account.username.casecmp(username).zero? | |||
same_domain = other_account.domain.nil? ? domain.nil? : other_account.domain.casecmp(domain)&.zero? | |||
if same_username && !same_domain | |||
same_username_hits += 1 | |||
elsif same_username && same_domain | |||
account = other_account | |||
end | |||
end | |||
else | |||
account = entity_cache.mention(username, domain) | |||
end | |||
return "@#{h(entity[:screen_name])}" if account.nil? | |||
url = ActivityPub::TagManager.instance.url_for(account) | |||
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username | |||
<<~HTML.squish | |||
<span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span> | |||
HTML | |||
end | |||
def entity_cache | |||
@entity_cache ||= EntityCache.instance | |||
end | |||
def tag_manager | |||
@tag_manager ||= TagManager.instance | |||
end | |||
delegate :local_domain?, to: :tag_manager | |||
def multiline? | |||
options[:multiline] | |||
end | |||
def with_domains? | |||
options[:with_domains] | |||
end | |||
def with_rel_me? | |||
options[:with_rel_me] | |||
end | |||
def preloaded_accounts | |||
options[:preloaded_accounts] | |||
end | |||
def preloaded_accounts? | |||
preloaded_accounts.present? | |||
end | |||
end |
@ -0,0 +1,55 @@ | |||
require 'rails_helper' | |||
RSpec.describe EmojiFormatter do | |||
let!(:emoji) { Fabricate(:custom_emoji, shortcode: 'coolcat') } | |||
def preformat_text(str) | |||
TextFormatter.new(str).to_s | |||
end | |||
describe '#to_s' do | |||
subject { described_class.new(text, emojis).to_s } | |||
let(:emojis) { [emoji] } | |||
context 'given text that is not marked as html-safe' do | |||
let(:text) { 'Foo' } | |||
it 'raises an argument error' do | |||
expect { subject }.to raise_error ArgumentError | |||
end | |||
end | |||
context 'given text with an emoji shortcode at the start' do | |||
let(:text) { preformat_text(':coolcat: Beep boop') } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given text with an emoji shortcode in the middle' do | |||
let(:text) { preformat_text('Beep :coolcat: boop') } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given text with concatenated emoji shortcodes' do | |||
let(:text) { preformat_text(':coolcat::coolcat:') } | |||
it 'does not touch the shortcodes' do | |||
is_expected.to match(/:coolcat::coolcat:/) | |||
end | |||
end | |||
context 'given text with an emoji shortcode at the end' do | |||
let(:text) { preformat_text('Beep boop :coolcat:') } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
end |
@ -1,626 +0,0 @@ | |||
require 'rails_helper' | |||
RSpec.describe Formatter do | |||
let(:local_account) { Fabricate(:account, domain: nil, username: 'alice') } | |||
let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') } | |||
shared_examples 'encode and link URLs' do | |||
context 'given a stand-alone medium URL' do | |||
let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"' | |||
end | |||
end | |||
context 'given a stand-alone google URL' do | |||
let(:text) { 'http://google.com' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="http://google.com"' | |||
end | |||
end | |||
context 'given a stand-alone URL with a newer TLD' do | |||
let(:text) { 'http://example.gay' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="http://example.gay"' | |||
end | |||
end | |||
context 'given a stand-alone IDN URL' do | |||
let(:text) { 'https://nic.みんな/' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://nic.みんな/"' | |||
end | |||
it 'has display URL' do | |||
is_expected.to include '<span class="">nic.みんな/</span>' | |||
end | |||
end | |||
context 'given a URL with a trailing period' do | |||
let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' } | |||
it 'matches the full URL but not the period' do | |||
is_expected.to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"' | |||
end | |||
end | |||
context 'given a URL enclosed with parentheses' do | |||
let(:text) { '(http://google.com/)' } | |||
it 'matches the full URL but not the parentheses' do | |||
is_expected.to include 'href="http://google.com/"' | |||
end | |||
end | |||
context 'given a URL with a trailing exclamation point' do | |||
let(:text) { 'http://www.google.com!' } | |||
it 'matches the full URL but not the exclamation point' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a trailing single quote' do | |||
let(:text) { "http://www.google.com'" } | |||
it 'matches the full URL but not the single quote' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a trailing angle bracket' do | |||
let(:text) { 'http://www.google.com>' } | |||
it 'matches the full URL but not the angle bracket' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a query string' do | |||
context 'with escaped unicode character' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink"' | |||
end | |||
end | |||
context 'with unicode character' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&q=autolink"' | |||
end | |||
end | |||
context 'with unicode character at the end' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"' | |||
end | |||
end | |||
context 'with escaped and not escaped unicode characters' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' } | |||
it 'preserves escaped unicode characters' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink"' | |||
end | |||
end | |||
end | |||
context 'given a URL with parentheses in it' do | |||
let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"' | |||
end | |||
end | |||
context 'given a URL in quotation marks' do | |||
let(:text) { '"https://example.com/"' } | |||
it 'does not match the quotation marks' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL in angle brackets' do | |||
let(:text) { '<https://example.com/>' } | |||
it 'does not match the angle brackets' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL with Japanese path string' do | |||
let(:text) { 'https://ja.wikipedia.org/wiki/日本' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://ja.wikipedia.org/wiki/日本"' | |||
end | |||
end | |||
context 'given a URL with Korean path string' do | |||
let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://ko.wikipedia.org/wiki/대한민국"' | |||
end | |||
end | |||
context 'given a URL with a full-width space' do | |||
let(:text) { 'https://example.com/ abc123' } | |||
it 'does not match the full-width space' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL in Japanese quotation marks' do | |||
let(:text) { '「[https://example.org/」' } | |||
it 'does not match the quotation marks' do | |||
is_expected.to include 'href="https://example.org/"' | |||
end | |||
end | |||
context 'given a URL with Simplified Chinese path string' do | |||
let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://baike.baidu.com/item/中华人民共和国"' | |||
end | |||
end | |||
context 'given a URL with Traditional Chinese path string' do | |||
let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://zh.wikipedia.org/wiki/臺灣"' | |||
end | |||
end | |||
context 'given a URL containing unsafe code (XSS attack, visible part)' do | |||
let(:text) { %q{http://example.com/b<del>b</del>} } | |||
it 'does not include the HTML in the URL' do | |||
is_expected.to include '"http://example.com/b"' | |||
end | |||
it 'escapes the HTML' do | |||
is_expected.to include '<del>b</del>' | |||
end | |||
end | |||
context 'given a URL containing unsafe code (XSS attack, invisible part)' do | |||
let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} } | |||
it 'does not include the HTML in the URL' do | |||
is_expected.to include '"http://example.com/blahblahblahblah/a"' | |||
end | |||
it 'escapes the HTML' do | |||
is_expected.to include '<script>alert("Hello")</script>' | |||
end | |||
end | |||
context 'given text containing HTML code (script tag)' do | |||
let(:text) { '<script>alert("Hello")</script>' } | |||
it 'escapes the HTML' do | |||
is_expected.to include '<p><script>alert("Hello")</script></p>' | |||
end | |||
end | |||
context 'given text containing HTML (XSS attack)' do | |||
let(:text) { %q{<img src="javascript:alert('XSS');">} } | |||
it 'escapes the HTML' do | |||
is_expected.to include '<p><img src="javascript:alert('XSS');"></p>' | |||
end | |||
end | |||
context 'given an invalid URL' do | |||
let(:text) { 'http://www\.google\.com' } | |||
it 'outputs the raw URL' do | |||
is_expected.to eq '<p>http://www\.google\.com</p>' | |||
end | |||
end | |||
context 'given text containing a hashtag' do | |||
let(:text) { '#hashtag' } | |||
it 'creates a hashtag link' do | |||
is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>' | |||
end | |||
end | |||
context 'given text containing a hashtag with Unicode chars' do | |||
let(:text) { '#hashtagタグ' } | |||
it 'creates a hashtag link' do | |||
is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>' | |||
end | |||
end | |||
context 'given a stand-alone xmpp: URI' do | |||
let(:text) { 'xmpp:user@instance.com' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="xmpp:user@instance.com"' | |||
end | |||
end | |||
context 'given a an xmpp: URI with a query-string' do | |||
let(:text) { 'please join xmpp:muc@instance.com?join right now' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="xmpp:muc@instance.com?join"' | |||
end | |||
end | |||
context 'given text containing a magnet: URI' do | |||
let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"' | |||
end | |||
end | |||
end | |||
describe '#format_spoiler' do | |||
subject { Formatter.instance.format_spoiler(status) } | |||
context 'given a post containing plain text' do | |||
let(:status) { Fabricate(:status, text: 'text', spoiler_text: 'Secret!', uri: nil) } | |||
it 'Returns the spoiler text' do | |||
is_expected.to eq 'Secret!' | |||
end | |||
end | |||
context 'given a post with an emoji shortcode at the start' do | |||
let!(:emoji) { Fabricate(:custom_emoji) } | |||
let(:status) { Fabricate(:status, text: 'text', spoiler_text: ':coolcat: Secret!', uri: nil) } | |||
let(:text) { ':coolcat: Beep boop' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
describe '#format' do | |||
subject { Formatter.instance.format(status) } | |||
context 'given a post with local status' do | |||
context 'given a reblogged post' do | |||
let(:reblog) { Fabricate(:status, account: local_account, text: 'Hello world', uri: nil) } | |||
let(:status) { Fabricate(:status, reblog: reblog) } | |||
it 'returns original status with credit to its author' do | |||
is_expected.to include 'RT <span class="h-card"><a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span> Hello world' | |||
end | |||
end | |||
context 'given a post containing plain text' do | |||
let(:status) { Fabricate(:status, text: 'text', uri: nil) } | |||
it 'paragraphizes the text' do | |||
is_expected.to eq '<p>text</p>' | |||
end | |||
end | |||
context 'given a post containing line feeds' do | |||
let(:status) { Fabricate(:status, text: "line\nfeed", uri: nil) } | |||
it 'removes line feeds' do | |||
is_expected.not_to include "\n" | |||
end | |||
end | |||
context 'given a post containing linkable mentions' do | |||
let(:status) { Fabricate(:status, mentions: [ Fabricate(:mention, account: local_account) ], text: '@alice') } | |||
it 'creates a mention link' do | |||
is_expected.to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>' | |||
end | |||
end | |||
context 'given a post containing unlinkable mentions' do | |||
let(:status) { Fabricate(:status, text: '@alice', uri: nil) } | |||
it 'does not create a mention link' do | |||
is_expected.to include '@alice' | |||
end | |||
end | |||
context do | |||
subject do | |||
status = Fabricate(:status, text: text, uri: nil) | |||
Formatter.instance.format(status) | |||
end | |||
include_examples 'encode and link URLs' | |||
end | |||
context 'given a post with custom_emojify option' do | |||
let!(:emoji) { Fabricate(:custom_emoji) } | |||
let(:status) { Fabricate(:status, account: local_account, text: text) } | |||
subject { Formatter.instance.format(status, custom_emojify: true) } | |||
context 'given a post with an emoji shortcode at the start' do | |||
let(:text) { ':coolcat: Beep boop' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode in the middle' do | |||
let(:text) { 'Beep :coolcat: boop' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with concatenated emoji shortcodes' do | |||
let(:text) { ':coolcat::coolcat:' } | |||
it 'does not touch the shortcodes' do | |||
is_expected.to match(/:coolcat::coolcat:/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode at the end' do | |||
let(:text) { 'Beep boop :coolcat:' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
end | |||
context 'given a post with remote status' do | |||
let(:status) { Fabricate(:status, account: remote_account, text: 'Beep boop') } | |||
it 'reformats the post' do | |||
is_expected.to eq 'Beep boop' | |||
end | |||
context 'given a post with custom_emojify option' do | |||
let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) } | |||
let(:status) { Fabricate(:status, account: remote_account, text: text) } | |||
subject { Formatter.instance.format(status, custom_emojify: true) } | |||
context 'given a post with an emoji shortcode at the start' do | |||
let(:text) { '<p>:coolcat: Beep boop<br />' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode in the middle' do | |||
let(:text) { '<p>Beep :coolcat: boop</p>' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with concatenated emoji' do | |||
let(:text) { '<p>:coolcat::coolcat:</p>' } | |||
it 'does not touch the shortcodes' do | |||
is_expected.to match(/<p>:coolcat::coolcat:<\/p>/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode at the end' do | |||
let(:text) { '<p>Beep boop<br />:coolcat:</p>' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
end | |||
end | |||
describe '#reformat' do | |||
subject { Formatter.instance.reformat(text) } | |||
context 'given a post containing plain text' do | |||
let(:text) { 'Beep boop' } | |||
it 'keeps the plain text' do | |||
is_expected.to include 'Beep boop' | |||
end | |||
end | |||
context 'given a post containing script tags' do | |||
let(:text) { '<script>alert("Hello")</script>' } | |||
it 'strips the scripts' do | |||
is_expected.to_not include '<script>alert("Hello")</script>' | |||
end | |||
end | |||
context 'given a post containing malicious classes' do | |||
let(:text) { '<span class="mention status__content__spoiler-link">Show more</span>' } | |||
it 'strips the malicious classes' do | |||
is_expected.to_not include 'status__content__spoiler-link' | |||
end | |||
end | |||
end | |||
describe '#plaintext' do | |||
subject { Formatter.instance.plaintext(status) } | |||
context 'given a post with local status' do | |||
let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) } | |||
it 'returns the raw text' do | |||
is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>' | |||
end | |||
end | |||
context 'given a post with remote status' do | |||
let(:status) { Fabricate(:status, account: remote_account, text: '<script>alert("Hello")</script>') } | |||
it 'returns tag-stripped text' do | |||
is_expected.to eq '' | |||
end | |||
end | |||
end | |||
describe '#simplified_format' do | |||
subject { Formatter.instance.simplified_format(account) } | |||
context 'given a post with local status' do | |||
let(:account) { Fabricate(:account, domain: nil, note: text) } | |||
context 'given a post containing linkable mentions for local accounts' do | |||
let(:text) { '@alice' } | |||
before { local_account } | |||
it 'creates a mention link' do | |||
is_expected.to eq '<p><span class="h-card"><a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span></p>' | |||
end | |||
end | |||
context 'given a post containing linkable mentions for remote accounts' do | |||
let(:text) { '@bob@remote.test' } | |||
before { remote_account } | |||
it 'creates a mention link' do | |||
is_expected.to eq '<p><span class="h-card"><a href="https://remote.test/" class="u-url mention">@<span>bob</span></a></span></p>' | |||
end | |||
end | |||
context 'given a post containing unlinkable mentions' do | |||
let(:text) { '@alice' } | |||
it 'does not create a mention link' do | |||
is_expected.to eq '<p>@alice</p>' | |||
end | |||
end | |||
context 'given a post with custom_emojify option' do | |||
let!(:emoji) { Fabricate(:custom_emoji) } | |||
before { account.note = text } | |||
subject { Formatter.instance.simplified_format(account, custom_emojify: true) } | |||
context 'given a post with an emoji shortcode at the start' do | |||
let(:text) { ':coolcat: Beep boop' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode in the middle' do | |||
let(:text) { 'Beep :coolcat: boop' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with concatenated emoji shortcodes' do | |||
let(:text) { ':coolcat::coolcat:' } | |||
it 'does not touch the shortcodes' do | |||
is_expected.to match(/:coolcat::coolcat:/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode at the end' do | |||
let(:text) { 'Beep boop :coolcat:' } | |||
it 'converts the shortcode to an image tag' do | |||
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
include_examples 'encode and link URLs' | |||
end | |||
context 'given a post with remote status' do | |||
let(:text) { '<script>alert("Hello")</script>' } | |||
let(:account) { Fabricate(:account, domain: 'remote', note: text) } | |||
it 'reformats' do | |||
is_expected.to_not include '<script>alert("Hello")</script>' | |||
end | |||
context 'with custom_emojify option' do | |||
let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) } | |||
before { remote_account.note = text } | |||
subject { Formatter.instance.simplified_format(remote_account, custom_emojify: true) } | |||
context 'given a post with an emoji shortcode at the start' do | |||
let(:text) { '<p>:coolcat: Beep boop<br />' } | |||
it 'converts shortcode to image tag' do | |||
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode in the middle' do | |||
let(:text) { '<p>Beep :coolcat: boop</p>' } | |||
it 'converts shortcode to image tag' do | |||
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
context 'given a post with concatenated emoji shortcodes' do | |||
let(:text) { '<p>:coolcat::coolcat:</p>' } | |||
it 'does not touch the shortcodes' do | |||
is_expected.to match(/<p>:coolcat::coolcat:<\/p>/) | |||
end | |||
end | |||
context 'given a post with an emoji shortcode at the end' do | |||
let(:text) { '<p>Beep boop<br />:coolcat:</p>' } | |||
it 'converts shortcode to image tag' do | |||
is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/) | |||
end | |||
end | |||
end | |||
end | |||
end | |||
describe '#sanitize' do | |||
let(:html) { '<script>alert("Hello")</script>' } | |||
subject { Formatter.instance.sanitize(html, Sanitize::Config::MASTODON_STRICT) } | |||
it 'sanitizes' do | |||
is_expected.to eq '' | |||
end | |||
end | |||
end |
@ -0,0 +1,44 @@ | |||
require 'rails_helper' | |||
RSpec.describe HtmlAwareFormatter do | |||
describe '#to_s' do | |||
subject { described_class.new(text, local).to_s } | |||
context 'when local' do | |||
let(:local) { true } | |||
let(:text) { 'Foo bar' } | |||
it 'returns formatted text' do | |||
is_expected.to eq '<p>Foo bar</p>' | |||
end | |||
end | |||
context 'when remote' do | |||
let(:local) { false } | |||
context 'given plain text' do | |||
let(:text) { 'Beep boop' } | |||
it 'keeps the plain text' do | |||
is_expected.to include 'Beep boop' | |||
end | |||
end | |||
context 'given text containing script tags' do | |||
let(:text) { '<script>alert("Hello")</script>' } | |||
it 'strips the scripts' do | |||
is_expected.to_not include '<script>alert("Hello")</script>' | |||
end | |||
end | |||
context 'given text containing malicious classes' do | |||
let(:text) { '<span class="mention status__content__spoiler-link">Show more</span>' } | |||
it 'strips the malicious classes' do | |||
is_expected.to_not include 'status__content__spoiler-link' | |||
end | |||
end | |||
end | |||
end | |||
end |
@ -0,0 +1,24 @@ | |||
require 'rails_helper' | |||
RSpec.describe PlainTextFormatter do | |||
describe '#to_s' do | |||
subject { described_class.new(status.text, status.local?).to_s } | |||
context 'given a post with local status' do | |||
let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) } | |||
it 'returns the raw text' do | |||
is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>' | |||
end | |||
end | |||
context 'given a post with remote status' do | |||
let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') } | |||
let(:status) { Fabricate(:status, account: remote_account, text: '<p>Hello</p><script>alert("Hello")</script>') } | |||
it 'returns tag-stripped text' do | |||
is_expected.to eq 'Hello' | |||
end | |||
end | |||
end | |||
end |
@ -0,0 +1,313 @@ | |||
require 'rails_helper' | |||
RSpec.describe TextFormatter do | |||
describe '#to_s' do | |||
let(:preloaded_accounts) { nil } | |||
subject { described_class.new(text, preloaded_accounts: preloaded_accounts).to_s } | |||
context 'given text containing plain text' do | |||
let(:text) { 'text' } | |||
it 'paragraphizes the text' do | |||
is_expected.to eq '<p>text</p>' | |||
end | |||
end | |||
context 'given text containing line feeds' do | |||
let(:text) { "line\nfeed" } | |||
it 'removes line feeds' do | |||
is_expected.not_to include "\n" | |||
end | |||
end | |||
context 'given text containing linkable mentions' do | |||
let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] } | |||
let(:text) { '@alice' } | |||
it 'creates a mention link' do | |||
is_expected.to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>' | |||
end | |||
end | |||
context 'given text containing unlinkable mentions' do | |||
let(:preloaded_accounts) { [] } | |||
let(:text) { '@alice' } | |||
it 'does not create a mention link' do | |||
is_expected.to include '@alice' | |||
end | |||
end | |||
context 'given a stand-alone medium URL' do | |||
let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"' | |||
end | |||
end | |||
context 'given a stand-alone google URL' do | |||
let(:text) { 'http://google.com' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="http://google.com"' | |||
end | |||
end | |||
context 'given a stand-alone URL with a newer TLD' do | |||
let(:text) { 'http://example.gay' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="http://example.gay"' | |||
end | |||
end | |||
context 'given a stand-alone IDN URL' do | |||
let(:text) { 'https://nic.みんな/' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://nic.みんな/"' | |||
end | |||
it 'has display URL' do | |||
is_expected.to include '<span class="">nic.みんな/</span>' | |||
end | |||
end | |||
context 'given a URL with a trailing period' do | |||
let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' } | |||
it 'matches the full URL but not the period' do | |||
is_expected.to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"' | |||
end | |||
end | |||
context 'given a URL enclosed with parentheses' do | |||
let(:text) { '(http://google.com/)' } | |||
it 'matches the full URL but not the parentheses' do | |||
is_expected.to include 'href="http://google.com/"' | |||
end | |||
end | |||
context 'given a URL with a trailing exclamation point' do | |||
let(:text) { 'http://www.google.com!' } | |||
it 'matches the full URL but not the exclamation point' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a trailing single quote' do | |||
let(:text) { "http://www.google.com'" } | |||
it 'matches the full URL but not the single quote' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a trailing angle bracket' do | |||
let(:text) { 'http://www.google.com>' } | |||
it 'matches the full URL but not the angle bracket' do | |||
is_expected.to include 'href="http://www.google.com"' | |||
end | |||
end | |||
context 'given a URL with a query string' do | |||
context 'with escaped unicode character' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink"' | |||
end | |||
end | |||
context 'with unicode character' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&q=autolink"' | |||
end | |||
end | |||
context 'with unicode character at the end' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"' | |||
end | |||
end | |||
context 'with escaped and not escaped unicode characters' do | |||
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' } | |||
it 'preserves escaped unicode characters' do | |||
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink"' | |||
end | |||
end | |||
end | |||
context 'given a URL with parentheses in it' do | |||
let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"' | |||
end | |||
end | |||
context 'given a URL in quotation marks' do | |||
let(:text) { '"https://example.com/"' } | |||
it 'does not match the quotation marks' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL in angle brackets' do | |||
let(:text) { '<https://example.com/>' } | |||
it 'does not match the angle brackets' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL with Japanese path string' do | |||
let(:text) { 'https://ja.wikipedia.org/wiki/日本' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://ja.wikipedia.org/wiki/日本"' | |||
end | |||
end | |||
context 'given a URL with Korean path string' do | |||
let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://ko.wikipedia.org/wiki/대한민국"' | |||
end | |||
end | |||
context 'given a URL with a full-width space' do | |||
let(:text) { 'https://example.com/ abc123' } | |||
it 'does not match the full-width space' do | |||
is_expected.to include 'href="https://example.com/"' | |||
end | |||
end | |||
context 'given a URL in Japanese quotation marks' do | |||
let(:text) { '「[https://example.org/」' } | |||
it 'does not match the quotation marks' do | |||
is_expected.to include 'href="https://example.org/"' | |||
end | |||
end | |||
context 'given a URL with Simplified Chinese path string' do | |||
let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://baike.baidu.com/item/中华人民共和国"' | |||
end | |||
end | |||
context 'given a URL with Traditional Chinese path string' do | |||
let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' } | |||
it 'matches the full URL' do | |||
is_expected.to include 'href="https://zh.wikipedia.org/wiki/臺灣"' | |||
end | |||
end | |||
context 'given a URL containing unsafe code (XSS attack, visible part)' do | |||
let(:text) { %q{http://example.com/b<del>b</del>} } | |||
it 'does not include the HTML in the URL' do | |||
is_expected.to include '"http://example.com/b"' | |||
end | |||
it 'escapes the HTML' do | |||
is_expected.to include '<del>b</del>' | |||
end | |||
end | |||
context 'given a URL containing unsafe code (XSS attack, invisible part)' do | |||
let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} } | |||
it 'does not include the HTML in the URL' do | |||
is_expected.to include '"http://example.com/blahblahblahblah/a"' | |||
end | |||
it 'escapes the HTML' do | |||
is_expected.to include '<script>alert("Hello")</script>' | |||
end | |||
end | |||
context 'given text containing HTML code (script tag)' do | |||
let(:text) { '<script>alert("Hello")</script>' } | |||
it 'escapes the HTML' do | |||
is_expected.to include '<p><script>alert("Hello")</script></p>' | |||
end | |||
end | |||
context 'given text containing HTML (XSS attack)' do | |||
let(:text) { %q{<img src="javascript:alert('XSS');">} } | |||
it 'escapes the HTML' do | |||
is_expected.to include '<p><img src="javascript:alert('XSS');"></p>' | |||
end | |||
end | |||
context 'given an invalid URL' do | |||
let(:text) { 'http://www\.google\.com' } | |||
it 'outputs the raw URL' do | |||
is_expected.to eq '<p>http://www\.google\.com</p>' | |||
end | |||
end | |||
context 'given text containing a hashtag' do | |||
let(:text) { '#hashtag' } | |||
it 'creates a hashtag link' do | |||
is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>' | |||
end | |||
end | |||
context 'given text containing a hashtag with Unicode chars' do | |||
let(:text) { '#hashtagタグ' } | |||
it 'creates a hashtag link' do | |||
is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>' | |||
end | |||
end | |||
context 'given text with a stand-alone xmpp: URI' do | |||
let(:text) { 'xmpp:user@instance.com' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="xmpp:user@instance.com"' | |||
end | |||
end | |||
context 'given text with an xmpp: URI with a query-string' do | |||
let(:text) { 'please join xmpp:muc@instance.com?join right now' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="xmpp:muc@instance.com?join"' | |||
end | |||
end | |||
context 'given text containing a magnet: URI' do | |||
let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' } | |||
it 'matches the full URI' do | |||
is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"' | |||
end | |||
end | |||
end | |||
end |