mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-07 17:42:46 -05:00
Fix custom emojis in video descriptions
This commit is contained in:
parent
eb407578a2
commit
1588ac6d17
123
spec/invidious/videos/description_spec.cr
Normal file
123
spec/invidious/videos/description_spec.cr
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
require "json"
|
||||||
|
require "uri"
|
||||||
|
|
||||||
|
require "spectator"
|
||||||
|
|
||||||
|
require "../../../src/invidious/helpers/utils"
|
||||||
|
require "../../../src/invidious/videos/description"
|
||||||
|
|
||||||
|
Spectator.describe "parse_description" do
|
||||||
|
it "renders custom emojis using the current YouTube attachment-run schema" do
|
||||||
|
description = JSON.parse(%({
|
||||||
|
"content":":face-red-heart-shape::face-orange-biting-nails:",
|
||||||
|
"attachmentRuns":[
|
||||||
|
{
|
||||||
|
"startIndex":0,
|
||||||
|
"length":22,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://lh3.googleusercontent.com/red-heart=s16-w24-h24-c-k-nd",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"face-red-heart-shape"}}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startIndex":22,
|
||||||
|
"length":26,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://lh3.googleusercontent.com/biting-nails=s16-w24-h24-c-k-nd",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"face-orange-biting-nails"}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(description, "video-id")).to eq(
|
||||||
|
%(<img alt="face-red-heart-shape" src="/ggpht/red-heart=s16-w24-h24-c-k-nd" title="face-red-heart-shape" width="16" height="16" class="channel-emoji" />) +
|
||||||
|
%(<img alt="face-orange-biting-nails" src="/ggpht/biting-nails=s16-w24-h24-c-k-nd" title="face-orange-biting-nails" width="16" height="16" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "keeps command runs and UTF-16 attachment indexes aligned" do
|
||||||
|
description = JSON.parse(%({
|
||||||
|
"content":"😀visit :custom:",
|
||||||
|
"commandRuns":[{
|
||||||
|
"startIndex":2,
|
||||||
|
"length":5,
|
||||||
|
"onTap":{"innertubeCommand":{"urlEndpoint":{"url":"https://example.com"}}}
|
||||||
|
}],
|
||||||
|
"attachmentRuns":[{
|
||||||
|
"startIndex":8,
|
||||||
|
"length":8,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://yt3.ggpht.com/custom=s16",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"custom"}}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(description, "video-id")).to eq(
|
||||||
|
%(😀<a href="https://example.com">visit</a> <img alt="custom" src="/ggpht/custom=s16" title="custom" width="16" height="16" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "preserves the escaped shortcode when an attachment source is unsupported" do
|
||||||
|
description = JSON.parse(%({
|
||||||
|
"content":"before <:custom:> after",
|
||||||
|
"attachmentRuns":[{
|
||||||
|
"startIndex":8,
|
||||||
|
"length":8,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://example.com/custom.png",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"custom"}}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(description, "video-id")).to eq("before <:custom:> after")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "escapes attachment labels before inserting them into HTML" do
|
||||||
|
description = JSON.parse(%({
|
||||||
|
"content":":custom:",
|
||||||
|
"attachmentRuns":[{
|
||||||
|
"startIndex":0,
|
||||||
|
"length":8,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://lh3.googleusercontent.com/custom=s16",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"custom\\u0022 onerror=\\u0022alert(1)"}}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(description, "video-id")).to eq(
|
||||||
|
%(<img alt="custom" onerror="alert(1)" src="/ggpht/custom=s16" title="custom" onerror="alert(1)" width="16" height="16" class="channel-emoji" />)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not render zero-length graphical attachments" do
|
||||||
|
description = JSON.parse(%({
|
||||||
|
"content":"plain text",
|
||||||
|
"attachmentRuns":[{
|
||||||
|
"startIndex":0,
|
||||||
|
"length":0,
|
||||||
|
"element":{"type":{"imageType":{"image":{"sources":[{
|
||||||
|
"url":"https://lh3.googleusercontent.com/badge=s16",
|
||||||
|
"width":16,
|
||||||
|
"height":16
|
||||||
|
}]}}}},
|
||||||
|
"properties":{"accessibilityProperties":{"label":"badge"}}
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(parse_description(description, "video-id")).to eq("plain text")
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
require "html"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
|
|
||||||
@ -40,6 +41,34 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
|||||||
return copied
|
return copied
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private def render_description_attachment(attachment : JSON::Any, fallback : String) : String?
|
||||||
|
source = attachment.dig?("element", "type", "imageType", "image", "sources", 0)
|
||||||
|
return if source.nil?
|
||||||
|
|
||||||
|
source_url = source["url"]?.try &.as_s?
|
||||||
|
return if source_url.nil?
|
||||||
|
|
||||||
|
uri = URI.parse(source_url)
|
||||||
|
return if uri.scheme != "https"
|
||||||
|
|
||||||
|
# Custom emoji URLs use either host; the existing /ggpht proxy serves the
|
||||||
|
# same request path through yt3.ggpht.com.
|
||||||
|
return unless {"lh3.googleusercontent.com", "yt3.ggpht.com"}.includes?(uri.host)
|
||||||
|
|
||||||
|
label = attachment.dig?("properties", "accessibilityProperties", "label").try &.as_s? || fallback
|
||||||
|
width = source["width"]?.try &.as_i? || 16_i64
|
||||||
|
height = source["height"]?.try &.as_i? || 16_i64
|
||||||
|
|
||||||
|
String.build do |str|
|
||||||
|
str << %(<img alt=") << HTML.escape(label) << %(" )
|
||||||
|
str << %(src="/ggpht) << HTML.escape(uri.request_target) << %(" )
|
||||||
|
str << %(title=") << HTML.escape(label) << %(" )
|
||||||
|
str << %(width=") << width << %(" )
|
||||||
|
str << %(height=") << height << %(" )
|
||||||
|
str << %(class="channel-emoji" />)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def parse_description(desc, video_id : String) : String?
|
def parse_description(desc, video_id : String) : String?
|
||||||
return "" if desc.nil?
|
return "" if desc.nil?
|
||||||
|
|
||||||
@ -47,7 +76,8 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
return "" if content.empty?
|
return "" if content.empty?
|
||||||
|
|
||||||
commands = desc["commandRuns"]?.try &.as_a
|
commands = desc["commandRuns"]?.try &.as_a
|
||||||
if commands.nil?
|
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||||
|
if commands.nil? && attachments.nil?
|
||||||
# Slightly faster than HTML.escape, as we're only doing one pass on
|
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||||
# the string instead of five for the standard library
|
# the string instead of five for the standard library
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
@ -60,31 +90,48 @@ def parse_description(desc, video_id : String) : String?
|
|||||||
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
||||||
# automatically decoded by the JSON parser. It means that we need to count
|
# automatically decoded by the JSON parser. It means that we need to count
|
||||||
# copied byte in a special manner, preventing the use of regular string copy.
|
# copied byte in a special manner, preventing the use of regular string copy.
|
||||||
|
runs = [] of NamedTuple(start: Int32, length: Int32, attachment: Bool, data: JSON::Any)
|
||||||
|
commands.try &.each do |command|
|
||||||
|
runs << {start: command["startIndex"].as_i, length: command["length"].as_i, attachment: false, data: command}
|
||||||
|
end
|
||||||
|
attachments.try &.each do |attachment|
|
||||||
|
runs << {start: attachment["startIndex"].as_i, length: attachment["length"].as_i, attachment: true, data: attachment}
|
||||||
|
end
|
||||||
|
runs.sort_by! { |run| {run[:start], run[:attachment] ? 0 : 1} }
|
||||||
|
|
||||||
iter = content.each_codepoint
|
iter = content.each_codepoint
|
||||||
|
|
||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
return String.build do |str|
|
return String.build do |str|
|
||||||
commands.each do |command|
|
runs.each do |run|
|
||||||
cmd_start = command["startIndex"].as_i
|
run_start = run[:start]
|
||||||
cmd_length = command["length"].as_i
|
run_length = run[:length]
|
||||||
|
|
||||||
# Copy the text chunk between this command and the previous if needed.
|
# If command and attachment runs overlap, ignore a lower-priority run
|
||||||
length = cmd_start - index
|
# once that section has already been consumed.
|
||||||
|
next if run_start < index
|
||||||
|
|
||||||
|
# Copy the text chunk between this run and the previous if needed.
|
||||||
|
length = run_start - index
|
||||||
index += copy_string(str, iter, length)
|
index += copy_string(str, iter, length)
|
||||||
|
|
||||||
# We need to copy the command's text using the iterator
|
# We need to copy the run's text using the iterator and the special
|
||||||
# and the special function defined above.
|
# function defined above.
|
||||||
cmd_content = String.build(cmd_length) do |str2|
|
run_content = String.build(run_length) do |str2|
|
||||||
copy_string(str2, iter, cmd_length)
|
copy_string(str2, iter, run_length)
|
||||||
end
|
end
|
||||||
|
|
||||||
link = cmd_content
|
if run[:attachment] && run_length > 0
|
||||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
str << (render_description_attachment(run[:data], run_content) || run_content)
|
||||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
else
|
||||||
|
link = run_content
|
||||||
|
if on_tap = run[:data].dig?("onTap", "innertubeCommand")
|
||||||
|
link = parse_link_endpoint(on_tap, run_content, video_id)
|
||||||
|
end
|
||||||
|
str << link
|
||||||
end
|
end
|
||||||
str << link
|
index += run_length
|
||||||
index += cmd_length
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Copy the end of the string (past the last command).
|
# Copy the end of the string (past the last command).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user