diff --git a/spec/invidious/frontend/video_title_spec.cr b/spec/invidious/frontend/video_title_spec.cr new file mode 100644 index 000000000..8a537508c --- /dev/null +++ b/spec/invidious/frontend/video_title_spec.cr @@ -0,0 +1,72 @@ +require "../../../src/invidious/frontend/video_title" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +Spectator.describe Invidious::Frontend::VideoTitle do + it "preserves an empty title" do + expect(described_class.to_html("")).to eq("") + end + + it "escapes a title without hashtags" do + expect(described_class.to_html(%q(A < B & "C" > D's))).to eq("A < B & "C" > D's") + end + + it "links a hashtag at the start of a title" do + expect(described_class.to_html("#music live")).to eq(%q(#music live)) + end + + it "links multiple hashtags including one at the end" do + expect(described_class.to_html("Live #music and #shorts")).to eq( + %q(Live #music and #shorts) + ) + end + + it "keeps punctuation outside hashtag links" do + expect(described_class.to_html("(#music), #live!")).to eq( + %q((#music), #live!) + ) + end + + it "supports numbers and underscores" do + expect(described_class.to_html("#2026 #live_music")).to eq( + %q(#2026 #live_music) + ) + end + + it "preserves Unicode text around and inside hashtags" do + expect(described_class.to_html("音楽 🎵 #日本語 — #موسيقى")).to eq( + %q(音楽 🎵 #日本語 — #موسيقى) + ) + end + + it "includes combining marks without normalizing the query" do + expect(described_class.to_html("#cafe\u0301")).to eq( + "#cafe\u0301" + ) + end + + it "preserves whitespace and stops at it" do + expect(described_class.to_html("\t#music\n#live ")).to eq( + "\t#music\n#live " + ) + end + + it "does not link bare hashes, embedded hashes or URL fragments" do + title = "# ## C# F#lang word#tag https://example.com/#section https://example.com#other" + expect(described_class.to_html(title)).to eq(title) + end + + it "does not turn literal or generated HTML entities into links" do + expect(described_class.to_html(%q(' ' '))).to eq("' &#39; &#x27;") + end + + it "escapes markup and query delimiters next to hashtags" do + expect(described_class.to_html(%q( #safe&x="bad"))).to eq( + %q(<script>alert('x')</script> #safe&x="bad") + ) + end +end diff --git a/src/invidious/frontend/video_title.cr b/src/invidious/frontend/video_title.cr new file mode 100644 index 000000000..7362a2e0a --- /dev/null +++ b/src/invidious/frontend/video_title.cr @@ -0,0 +1,25 @@ +require "html" +require "uri" + +module Invidious::Frontend::VideoTitle + extend self + + # Keep word-internal hashes, URL fragments and HTML entities as plain text. + HASHTAG = /(?" + html << HTML.escape(match[0]) << "" + offset = match.byte_end + end + + html << HTML.escape(title.byte_slice(offset, title.bytesize - offset)) + end + end +end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 3dc19c9e2..3f531aa34 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -93,7 +93,7 @@ we're going to need to do it here in order to allow for translations.

- <%= title %> + <%= Invidious::Frontend::VideoTitle.to_html(video.title) %> <% if params.listen %> " id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">