diff --git a/config/config.example.yml b/config/config.example.yml index 2b99345b2..eb3e7d5a3 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -312,12 +312,13 @@ https_only: false # ----------------------------- ## -## Enable/Disable the "Popular" tab on the main page. +## Enable/Disable specific pages on the main page. ## -## Accepted values: true, false -## Default: true +#pages_enabled: +# trending: true +# popular: true +# search: true ## -#popular_enabled: true ## ## Enable/Disable statstics (available at /api/v1/stats). diff --git a/locales/en-US.json b/locales/en-US.json index fa28e7f8b..ddae44741 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -3,7 +3,8 @@ "Add to playlist: ": "Add to playlist: ", "Answer": "Answer", "Search for videos": "Search for videos", - "The Popular feed has been disabled by the administrator.": "The Popular feed has been disabled by the administrator.", + "popular_page_disabled": "The Popular feed has been disabled by the administrator.", + "trending_page_disabled": "The Trending feed has been disabled by the administrator.", "generic_channels_count": "{{count}} channel", "generic_channels_count_plural": "{{count}} channels", "generic_views_count": "{{count}} view", @@ -502,7 +503,10 @@ "carousel_slide": "Slide {{current}} of {{total}}", "carousel_skip": "Skip the Carousel", "carousel_go_to": "Go to slide `x`", + "preferences_trending_enabled_label": "Trending enabled: ", + "preferences_search_enabled_label": "Search enabled: ", "timeline_parse_error_placeholder_heading": "Unable to parse item", "timeline_parse_error_placeholder_message": "Invidious encountered an error while trying to parse this item. For more information see below:", - "timeline_parse_error_show_technical_details": "Show technical details" -} + "timeline_parse_error_show_technical_details": "Show technical details", + "search_page_disabled": "Search has been disabled by the administrator." +} \ No newline at end of file diff --git a/spec/invidious/config_spec.cr b/spec/invidious/config_spec.cr new file mode 100644 index 000000000..1e9d33552 --- /dev/null +++ b/spec/invidious/config_spec.cr @@ -0,0 +1,50 @@ +require "../spec_helper" +require "../../src/invidious/jobs.cr" +require "../../src/invidious/jobs/*" +require "../../src/invidious/config.cr" +require "../../src/invidious/user/preferences.cr" + +# Allow this file to be executed independently of other specs +{% if !@type.has_constant?("CONFIG") %} + CONFIG = Config.from_yaml("") +{% end %} + +private def construct_config(yaml) + config = Config.from_yaml(yaml) + File.open(File::NULL, "w") { |io| config.process_deprecation(io) } + return config +end + +Spectator.describe Config do + context "page_enabled" do + it "Can disable pages" do + config = construct_config <<-YAML + pages_enabled: + popular: false + search: false + YAML + + expect(config.page_enabled?("trending")).to eq(true) + expect(config.page_enabled?("popular")).to eq(false) + expect(config.page_enabled?("search")).to eq(false) + end + + it "Takes precedence over popular_enabled" do + config = construct_config <<-YAML + popular_enabled: false + pages_enabled: + popular: true + YAML + + expect(config.page_enabled?("popular")).to eq(true) + end + end + + it "Deprecated popular_enabled still works" do + config = construct_config <<-YAML + popular_enabled: false + YAML + + expect(config.page_enabled?("popular")).to eq(false) + end +end diff --git a/src/invidious.cr b/src/invidious.cr index 197b150ca..b276bf1a4 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -197,7 +197,7 @@ if (CONFIG.use_pubsub_feeds.is_a?(Bool) && CONFIG.use_pubsub_feeds.as(Bool)) || Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, HMAC_KEY) end -if CONFIG.popular_enabled +if CONFIG.page_enabled?("popular") Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 36f09d282..989d3e495 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -73,6 +73,31 @@ struct HTTPProxyConfig property port : Int32 end +# Structure used for global per-page feature toggles +record PagesEnabled, + trending : Bool = true, + popular : Bool = true, + search : Bool = true do + include YAML::Serializable + + def [](key : String) : Bool + fetch(key) { raise KeyError.new("Unknown page '#{key}'") } + end + + def []?(key : String) : Bool + fetch(key) { nil } + end + + private def fetch(key : String, &) + case key + when "trending" then @trending + when "popular" then @popular + when "search" then @search + else yield + end + end +end + class Config include YAML::Serializable @@ -116,13 +141,37 @@ class Config # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property https_only : Bool? + # HMAC signing key for CSRF tokens and verifying pubsub subscriptions property hmac_key : String = "" # Domain to be used for links to resources on the site where an absolute URL is required property domain : String? # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) property use_pubsub_feeds : Bool | Int32 = false + + # ————————————————————————————————————————————————————————————————————————————————————— + + # A @{{key}}_present variable is required for both fields in order to handle the precedence for + # the deprecated `popular_enabled` in relations to `pages_enabled` + + # DEPRECATED: use `pages_enabled["popular"]` instead. + @[Deprecated("`popular_enabled` will be removed in a future release; use pages_enabled[\"popular\"] instead")] + @[YAML::Field(presence: true)] property popular_enabled : Bool = true + + @[YAML::Field(ignore: true)] + property popular_enabled_present : Bool + + # Global per-page feature toggles. + # Valid keys: "trending", "popular", "search" + # If someone sets both `popular_enabled` and `pages_enabled["popular"]`, the latter takes precedence. + @[YAML::Field(presence: true)] + property pages_enabled : PagesEnabled = PagesEnabled.from_yaml("") + + @[YAML::Field(ignore: true)] + property pages_enabled_present : Bool + # ————————————————————————————————————————————————————————————————————————————————————— + property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true @@ -193,16 +242,17 @@ class Config when Bool return disabled when Array - if disabled.includes? option - return true - else - return false - end + disabled.includes?(option) else - return false + false end end + # Centralized page toggle with legacy fallback for `popular_enabled` + def page_enabled?(page : String) : Bool + return @pages_enabled[page] + end + def self.load # Load config from file or YAML string env var env_config_file = "INVIDIOUS_CONFIG_FILE" @@ -240,6 +290,12 @@ class Config begin config.{{ivar.id}} = ivar_type.from_yaml(env_value) success = true + + # Update associated _present key if any + {% other_ivar = @type.instance_vars.find { |other_ivar| other_ivar.name == ivar.name + "_present" } %} + {% if other_ivar && (ann = other_ivar.annotation(YAML::Field)) && ann[:ignore] == true %} + config.{{other_ivar.name.id}} = true + {% end %} rescue # nop end @@ -297,6 +353,8 @@ class Config exit(1) end + config.process_deprecation + # Build database_url from db.* if it's not set directly if config.database_url.to_s.empty? if db = config.db @@ -334,4 +392,24 @@ class Config return config end + + # Processes deprecated values + # + # Warns when they are set and handles any precedence issue that may arise when present alongside a successor attribute + # + # This method is public as to allow specs to test the behavior without going through #load + # + # :nodoc: + def process_deprecation(log_io : IO = STDOUT) + # Handle deprecated popular_enabled config and warn if it is set + if self.popular_enabled_present + log_io.puts "Warning: `popular_enabled` has been deprecated and replaced by the `pages_enabled` config" + log_io.puts "If both are set `pages_enabled` will take precedence over `popular_enabled`" + + # Only use popular_enabled value when pages_enabled is unset + if !self.pages_enabled_present + self.pages_enabled = self.pages_enabled.copy_with(popular: self.popular_enabled) + end + end + end end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index fea2993c6..2e8328cb0 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -29,11 +29,6 @@ module Invidious::Routes::API::V1::Feeds env.response.content_type = "application/json" - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - haltf env, 403, error_message - end - JSON.build do |json| json.array do popular_videos.each do |video| diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 63b935ec6..8247f37ba 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -103,6 +103,28 @@ module Invidious::Routes::BeforeAll preferences.locale = locale env.set "preferences", preferences + path = env.request.path + page_key = case path + when "/feed/popular", "/api/v1/popular" + "popular" + when "/feed/trending", "/api/v1/trending" + "trending" + when "/search", "/api/v1/search" + "search" + else + nil + end + + if page_key && !CONFIG.page_enabled?(page_key) + if path.starts_with?("/api/") + error_message = {error: "Administrator has disabled this endpoint."}.to_json + haltf env, 403, error_message + else + message = "#{page_key}_page_disabled" + return error_template(403, message) + end + end + # Allow media resources to be loaded from google servers # TODO: check if *.youtube.com can be removed # diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 070c96eb8..1e0a40857 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -33,18 +33,11 @@ module Invidious::Routes::Feeds def self.popular(env) locale = env.get("preferences").as(Preferences).locale - - if CONFIG.popular_enabled - templated "feeds/popular" - else - message = translate(locale, "The Popular feed has been disabled by the administrator.") - templated "message" - end + templated "feeds/popular" end def self.trending(env) locale = env.get("preferences").as(Preferences).locale - trending_type = env.params.query["type"]? trending_type ||= "Default" diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 9936e5230..5ff56b0b1 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -202,9 +202,11 @@ module Invidious::Routes::PreferencesRoute end CONFIG.default_user_preferences.feed_menu = admin_feed_menu - popular_enabled = env.params.body["popular_enabled"]?.try &.as(String) - popular_enabled ||= "off" - CONFIG.popular_enabled = popular_enabled == "on" + CONFIG.pages_enabled = PagesEnabled.new( + popular: (env.params.body["popular_enabled"]?.try &.as(String) || "on") == "on", + trending: (env.params.body["trending_enabled"]?.try &.as(String) || "on") == "on", + search: (env.params.body["search_enabled"]?.try &.as(String) || "on") == "on", + ) captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) captcha_enabled ||= "off" diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index b195c7b37..5b10887c0 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -40,52 +40,47 @@ module Invidious::Routes::Search prefs = env.get("preferences").as(Preferences) locale = prefs.locale + # otherwise, do a normal search region = env.params.query["region"]? || prefs.region - query = Invidious::Search::Query.new(env.params.query, :regular, region) + # empty query → show homepage if query.empty? - # Display the full page search box implemented in #1977 env.set "search", "" - templated "search_homepage", navbar_search: false - else - user = env.get? "user" - - # An URL was copy/pasted in the search box. - # Redirect the user to the appropriate page. - if query.url? - return env.redirect UrlSanitizer.process(query.text).to_s - end - - begin - if user - items = query.process(user.as(User)) - else - items = query.process - end - rescue ex : ChannelSearchException - return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") - rescue ex - return error_template(500, ex) - end - - redirect_url = Invidious::Frontend::Misc.redirect_url(env) - - # Pagination - page_nav_html = Frontend::Pagination.nav_numeric(locale, - base_url: "/search?#{query.to_http_params}", - current_page: query.page, - show_next: (items.size >= 20) - ) - - if query.type == Invidious::Search::Query::Type::Channel - env.set "search", "channel:#{query.channel} #{query.text}" - else - env.set "search", query.text - end - - templated "search" + return templated "search_homepage", navbar_search: false end + + # non‐empty query → process it + user = env.get?("user") + if query.url? + return env.redirect UrlSanitizer.process(query.text).to_s + end + + begin + items = user ? query.process(user.as(User)) : query.process + rescue ex : ChannelSearchException + return error_template 404, "Unable to find channel with id “#{HTML.escape(ex.channel)}”…" + rescue ex + return error_template 500, ex + end + + redirect_url = Invidious::Frontend::Misc.redirect_url(env) + + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/search?#{query.to_http_params}", + current_page: query.page, + show_next: (items.size >= 20) + ) + + # If it's a channel search, prefix the box; otherwise just show the text + if query.type == Invidious::Search::Query::Type::Channel + env.set "search", "channel:#{query.channel} #{query.text}" + else + env.set "search", query.text + end + + templated "search" end def self.hashtag(env : HTTP::Server::Context) diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index 23cb89f69..0af2be0ab 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -302,9 +302,18 @@
- checked<% end %>> + checked<% end %>>
+
+ + checked<% end %>> +
+ +
+ + checked<% end %>> +