From c47d9a93f04be0f1496e79b6cb422a1fe6ad562d Mon Sep 17 00:00:00 2001 From: NeskireDK <10115530+NeskireDK@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:37:14 +0200 Subject: [PATCH] Add an admin settings page for the ArikTube extensions /admin/settings, linked from the administrator section of the preferences page and refused to everybody else, edits the settings the fork added: the playlists behind the Popular and Trending feeds, and the whole trusted-header block. - The playlists are picked from the instance's public playlists with a tick box each and a number beside it for the position, so no ordering has to be typed. A text area takes IDs that are not local playlists. Unticking everything restores the stock feed for that feed. - A playlist that is missing or not public is reported as a warning and still saved: the feed skips it the same way, and an admin may be listing a playlist they are about to create. - The trusted-header block is refused whole when anything in it is wrong, so a half-applied block can never reach the running config. A CIDR range and an empty proxy list with the feature on are the two the page exists to catch. - A save writes the rows and applies the values to the running CONFIG, so it needs no restart, and it survives one. Co-Authored-By: Claude Fable 5 --- locales/en-US.json | 22 +++ src/invidious/database/playlists.cr | 12 ++ src/invidious/routes/admin_settings.cr | 172 +++++++++++++++++++++++ src/invidious/routing.cr | 4 + src/invidious/views/admin/settings.ecr | 121 ++++++++++++++++ src/invidious/views/user/preferences.ecr | 4 + 6 files changed, 335 insertions(+) create mode 100644 src/invidious/routes/admin_settings.cr create mode 100644 src/invidious/views/admin/settings.ecr diff --git a/locales/en-US.json b/locales/en-US.json index 8032cf987..f0cef76f6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -477,6 +477,28 @@ "footer_original_source_code": "Original source code", "footer_modfied_source_code": "Modified source code", "adminprefs_modified_source_code_url_label": "URL to modified source code repository", + "ariktube_settings_link": "ArikTube settings", + "ariktube_settings_title": "ArikTube settings", + "ariktube_settings_description": "These settings are stored in the database, not in the configuration file, and take effect straight away. What the configuration file sets is used until you save something here.", + "ariktube_settings_saved": "Settings saved.", + "ariktube_settings_not_saved": "Nothing was saved. Correct the following and try again:", + "ariktube_settings_warnings": "Saved, but these entries will be skipped:", + "ariktube_settings_save": "Save settings", + "ariktube_popular_playlists_label": "Playlists behind the Popular feed", + "ariktube_trending_playlists_label": "Playlists behind the Trending feed", + "ariktube_playlists_help": "Tick the playlists this feed serves and number them to set their order. Leave everything unticked to serve the stock feed.", + "ariktube_playlist_order_label": "Position", + "ariktube_extra_playlists_label": "Other playlist IDs, one per line", + "ariktube_no_public_playlists": "This instance has no public playlists yet.", + "ariktube_trusted_header_auth_label": "Trusted-header authentication", + "ariktube_trusted_header_auth_help": "The reverse proxy asserts the user name in a request header. The header is honored only when the direct peer is one of the trusted proxies below, which must be literal IP addresses — an address range stops the instance from starting.", + "ariktube_tha_enabled_label": "Trusted-header authentication enabled: ", + "ariktube_tha_header_label": "Header carrying the user name", + "ariktube_tha_trusted_proxies_label": "Trusted proxy IP addresses, one per line", + "ariktube_tha_logout_url_label": "Logout URL of the proxy", + "ariktube_tha_password_self_service_label": "Let these sessions set a password without the current one: ", + "ariktube_tha_auto_approve_label": "Origins approved for tokens without asking, one per line", + "ariktube_tha_auto_approve_help": "A client calling back to one of these origins gets its token without the consent page, but only for a session the proxy vouches for. Write the origin alone, such as https://yt.example.com, with no path. Leave this empty to ask every client.", "none": "none", "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", "videoinfo_watch_on_youTube": "Watch on YouTube", diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 4db6b11d9..56d1a98e9 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -136,6 +136,18 @@ module Invidious::Database::Playlists PG_DB.query_all(request, email, as: InvidiousPlaylist) end + # Every public playlist of this instance. Used by the ArikTube admin + # settings page, where the playlist-backed feeds are picked from a list. + def select_public : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE privacy = 'Public' + ORDER BY title + SQL + + PG_DB.query_all(request, as: InvidiousPlaylist) + end + def select_user_created_playlists(email : String) : Array({String, String}) request = <<-SQL SELECT id,title FROM playlists diff --git a/src/invidious/routes/admin_settings.cr b/src/invidious/routes/admin_settings.cr new file mode 100644 index 000000000..83167de77 --- /dev/null +++ b/src/invidious/routes/admin_settings.cr @@ -0,0 +1,172 @@ +{% skip_file if flag?(:api_only) %} + +# Admin settings page for the ArikTube extensions. +# +# Nothing here is written to config.yml: production passes the config through +# INVIDIOUS_CONFIG, so a written file would be thrown away on the next +# restart. A save validates the form, writes the rows of the arik_settings +# table and applies the values to the running CONFIG, so it takes effect at +# once and survives the restart. +module Invidious::Routes::AdminSettings + extend self + + # Show the settings form (GET request) + def show(env) + locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env, "/preferences") + + user = env.get?("user") + if !user + return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" + end + + user = user.as(User) + if !CONFIG.admins.includes?(user.email) + return error_template(403, "Administrator privileges are required to open this page") + end + + sid = env.get("sid").as(String) + csrf_token = generate_response(sid, {":admin/settings"}, HMAC_KEY) + + playlists = self.public_playlists + popular = CONFIG.popular_playlists + trending = CONFIG.trending_playlists + trusted_header_auth = ArikSettings::TrustedHeaderAuthSettings.from_config(CONFIG.trusted_header_auth) + + saved = false + errors = [] of String + warnings = self.playlist_warnings(popular + trending) + + templated "admin/settings" + end + + # Validate, store and apply the settings (POST request) + def update(env) + locale = env.get("preferences").as(Preferences).locale + referer = get_referer(env, "/preferences") + + user = env.get?("user") + if !user + return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" + end + + user = user.as(User) + if !CONFIG.admins.includes?(user.email) + return error_template(403, "Administrator privileges are required to open this page") + end + + sid = env.get("sid").as(String) + + begin + validate_request(env.params.body["csrf_token"]?, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + csrf_token = generate_response(sid, {":admin/settings"}, HMAC_KEY) + playlists = self.public_playlists + + errors = [] of String + saved = false + + popular, popular_errors = self.submitted_playlists(env, "popular", playlists) + trending, trending_errors = self.submitted_playlists(env, "trending", playlists) + errors.concat(popular_errors) + errors.concat(trending_errors) + + trusted_header_auth = self.submitted_trusted_header_auth(env).cleaned + errors.concat(trusted_header_auth.errors) + + # Nothing is stored while anything is wrong: a half-applied trusted-header + # block is exactly the state this page exists to prevent. + if errors.empty? + begin + ArikSettings.store(ArikSettings::KEY_POPULAR_PLAYLISTS, popular.to_json) + ArikSettings.store(ArikSettings::KEY_TRENDING_PLAYLISTS, trending.to_json) + ArikSettings.store(ArikSettings::KEY_TRUSTED_HEADER_AUTH, trusted_header_auth.to_json) + + CONFIG.popular_playlists = popular + CONFIG.trending_playlists = trending + trusted_header_auth.apply_to(CONFIG.trusted_header_auth) + + saved = true + LOGGER.info("AdminSettings: #{user.email} updated the ArikTube settings") + rescue ex + errors << "The settings could not be stored: #{ex.message}" + end + end + + # A playlist that does not resolve is reported, not refused: the feed + # skips it the same way, and an admin may well be listing a playlist + # they are about to create. + warnings = self.playlist_warnings(popular + trending) + + templated "admin/settings" + end + + # ------------------------------------------------------------------ + # Form reading + # ------------------------------------------------------------------ + + # The playlist IDs submitted for one feed. + # + # The tick boxes carry the selection and the number beside each one carries + # the position; entries without a number fall to the end, in the order the + # playlists are listed. The text area below takes IDs that are not local + # playlists at all, and is appended after the ticked ones. + private def submitted_playlists(env, prefix : String, playlists) : {Array(String), Array(String)} + selected = env.params.body.fetch_all("#{prefix}_playlist[]") + + ordered = selected.map_with_index { |plid, index| {plid, index} } + .sort_by do |(plid, index)| + position = env.params.body["#{prefix}_order[#{plid}]"]?.try &.to_i? + {position || Int32::MAX, index} + end + .map { |(plid, _index)| plid } + + extra = (env.params.body["#{prefix}_extra"]? || "").lines + + plids, errors = ArikSettings.clean_playlist_ids(ordered + extra) + {plids, errors.map { |error| "#{prefix.capitalize}: #{error}" }} + end + + private def submitted_trusted_header_auth(env) : ArikSettings::TrustedHeaderAuthSettings + ArikSettings::TrustedHeaderAuthSettings.new( + enabled: env.params.body["tha_enabled"]? == "on", + header: env.params.body["tha_header"]? || "Remote-User", + trusted_proxies: (env.params.body["tha_trusted_proxies"]? || "").lines, + logout_url: env.params.body["tha_logout_url"]? || "", + password_self_service: env.params.body["tha_password_self_service"]? == "on", + auto_approve_token_callbacks: (env.params.body["tha_auto_approve"]? || "").lines, + ) + end + + # ------------------------------------------------------------------ + # Playlist lookups + # ------------------------------------------------------------------ + + # The public playlists of this instance, for the tick-box lists. A database + # that can't answer costs the lists, not the page. + private def public_playlists : Array(InvidiousPlaylist) + Invidious::Database::Playlists.select_public + rescue ex + LOGGER.error("AdminSettings: cannot list public playlists (#{ex.message})") + [] of InvidiousPlaylist + end + + # One message per configured playlist the feeds will skip. + private def playlist_warnings(plids : Array(String)) : Array(String) + plids.uniq.compact_map do |plid| + playlist = Invidious::Database::Playlists.select(id: plid) + + if playlist.nil? + "Playlist #{plid} does not exist on this instance and will be skipped" + elsif playlist.privacy != PlaylistPrivacy::Public + "Playlist #{plid} is not public and will be skipped" + end + end + rescue ex + LOGGER.error("AdminSettings: cannot check the configured playlists (#{ex.message})") + [] of String + end +end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 12bf5459f..d266e4a5d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -79,6 +79,10 @@ module Invidious::Routing post "/token_ajax", Routes::Account, :token_ajax post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription get "/subscription_manager", Routes::Subscriptions, :subscription_manager + + # ArikTube settings, stored in the database (admins only) + get "/admin/settings", Routes::AdminSettings, :show + post "/admin/settings", Routes::AdminSettings, :update end def register_iv_playlist_routes diff --git a/src/invidious/views/admin/settings.ecr b/src/invidious/views/admin/settings.ecr new file mode 100644 index 000000000..5b05bf24e --- /dev/null +++ b/src/invidious/views/admin/settings.ecr @@ -0,0 +1,121 @@ +<% content_for "header" do %> +<%= I18n.translate(locale, "ariktube_settings_title") %> - Invidious +<% end %> + +
+
+

<%= I18n.translate(locale, "ariktube_settings_title") %>

+
+ +
+ +
+

<%= I18n.translate(locale, "ariktube_settings_description") %>

+ + <% if saved %> +

<%= I18n.translate(locale, "ariktube_settings_saved") %>

+ <% end %> + + <% if !errors.empty? %> +

<%= I18n.translate(locale, "ariktube_settings_not_saved") %>

+ + <% end %> + + <% if !warnings.empty? %> +

<%= I18n.translate(locale, "ariktube_settings_warnings") %>

+ + <% end %> +
+ +
+
+ <% {"popular", "trending"}.each do |feed| %> + <% chosen = feed == "popular" ? popular : trending %> + +
+ <%= I18n.translate(locale, feed == "popular" ? "ariktube_popular_playlists_label" : "ariktube_trending_playlists_label") %> +

<%= I18n.translate(locale, "ariktube_playlists_help") %>

+ + <% if playlists.empty? %> +

<%= I18n.translate(locale, "ariktube_no_public_playlists") %>

+ <% else %> + <% playlists.each do |playlist| %> +
+ checked<% end %>> + + " + value="<% if (position = chosen.index(playlist.id)) %><%= position + 1 %><% end %>"> +
+ <% end %> + <% end %> + +
+ + +
+
+ <% end %> + +
+ <%= I18n.translate(locale, "ariktube_trusted_header_auth_label") %> +

<%= I18n.translate(locale, "ariktube_trusted_header_auth_help") %>

+ +
+ + checked<% end %>> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + checked<% end %>> +
+ +
+ + +
+ +

<%= I18n.translate(locale, "ariktube_tha_auto_approve_help") %>

+
+ + + + +
+
diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index d6692081c..63144cf76 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -336,6 +336,10 @@ + +
+ <%= I18n.translate(locale, "ariktube_settings_link") %> +
<% end %> <% if env.get? "user" %>