diff --git a/spec/invidious/videos/description_spec.cr b/spec/invidious/videos/description_spec.cr new file mode 100644 index 000000000..054e5ca66 --- /dev/null +++ b/spec/invidious/videos/description_spec.cr @@ -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( + %(face-red-heart-shape) + + %(face-orange-biting-nails) + ) + 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( + %(๐Ÿ˜€visit custom) + ) + 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( + %(custom" onerror="alert(1)) + ) + 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 diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2c9832690..d31c2c73d 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -1,3 +1,4 @@ +require "html" require "json" require "uri" @@ -40,6 +41,34 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied 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 << %() << HTML.escape(label) << %() + end +end + def parse_description(desc, video_id : String) : String? return "" if desc.nil? @@ -47,7 +76,8 @@ def parse_description(desc, video_id : String) : String? return "" if content.empty? 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 # the string instead of five for the standard library 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 # 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. + 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 index = 0 return String.build do |str| - commands.each do |command| - cmd_start = command["startIndex"].as_i - cmd_length = command["length"].as_i + runs.each do |run| + run_start = run[:start] + run_length = run[:length] - # Copy the text chunk between this command and the previous if needed. - length = cmd_start - index + # If command and attachment runs overlap, ignore a lower-priority run + # 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) - # We need to copy the command's text using the iterator - # and the special function defined above. - cmd_content = String.build(cmd_length) do |str2| - copy_string(str2, iter, cmd_length) + # We need to copy the run's text using the iterator and the special + # function defined above. + run_content = String.build(run_length) do |str2| + copy_string(str2, iter, run_length) end - link = cmd_content - if on_tap = command.dig?("onTap", "innertubeCommand") - link = parse_link_endpoint(on_tap, cmd_content, video_id) + if run[:attachment] && run_length > 0 + str << (render_description_attachment(run[:data], run_content) || run_content) + 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 - str << link - index += cmd_length + index += run_length end # Copy the end of the string (past the last command).