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 <noreply@anthropic.com>
This commit is contained in:
NeskireDK 2026-08-13 06:37:14 +02:00
parent 437c6194d6
commit c47d9a93f0
6 changed files with 335 additions and 0 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,121 @@
<% content_for "header" do %>
<title><%= I18n.translate(locale, "ariktube_settings_title") %> - Invidious</title>
<% end %>
<div class="pure-g h-box">
<div class="pure-u-2-3">
<h3><%= I18n.translate(locale, "ariktube_settings_title") %></h3>
</div>
<div class="pure-u-1-3" style="text-align:right">
<h3>
<a href="/preferences?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "Preferences") %></a>
</h3>
</div>
</div>
<div class="h-box">
<p><%= I18n.translate(locale, "ariktube_settings_description") %></p>
<% if saved %>
<p><b><%= I18n.translate(locale, "ariktube_settings_saved") %></b></p>
<% end %>
<% if !errors.empty? %>
<p><b><%= I18n.translate(locale, "ariktube_settings_not_saved") %></b></p>
<ul>
<% errors.each do |error| %>
<li><%= HTML.escape(error) %></li>
<% end %>
</ul>
<% end %>
<% if !warnings.empty? %>
<p><b><%= I18n.translate(locale, "ariktube_settings_warnings") %></b></p>
<ul>
<% warnings.each do |warning| %>
<li><%= HTML.escape(warning) %></li>
<% end %>
</ul>
<% end %>
</div>
<div class="h-box">
<form class="pure-form pure-form-aligned" action="/admin/settings?referer=<%= URI.encode_www_form(referer) %>" method="post">
<% {"popular", "trending"}.each do |feed| %>
<% chosen = feed == "popular" ? popular : trending %>
<fieldset>
<legend><%= I18n.translate(locale, feed == "popular" ? "ariktube_popular_playlists_label" : "ariktube_trending_playlists_label") %></legend>
<p><%= I18n.translate(locale, "ariktube_playlists_help") %></p>
<% if playlists.empty? %>
<p><%= I18n.translate(locale, "ariktube_no_public_playlists") %></p>
<% else %>
<% playlists.each do |playlist| %>
<div class="pure-control-group">
<input name="<%= feed %>_playlist[]" id="<%= feed %>_playlist_<%= HTML.escape(playlist.id) %>"
type="checkbox" value="<%= HTML.escape(playlist.id) %>"
<% if chosen.includes?(playlist.id) %>checked<% end %>>
<label for="<%= feed %>_playlist_<%= HTML.escape(playlist.id) %>">
<%= HTML.escape(playlist.title) %> — <code><%= HTML.escape(playlist.id) %></code>
</label>
<input name="<%= feed %>_order[<%= HTML.escape(playlist.id) %>]" type="number" min="1" style="width:5em"
title="<%= I18n.translate(locale, "ariktube_playlist_order_label") %>"
value="<% if (position = chosen.index(playlist.id)) %><%= position + 1 %><% end %>">
</div>
<% end %>
<% end %>
<div class="pure-control-group">
<label for="<%= feed %>_extra"><%= I18n.translate(locale, "ariktube_extra_playlists_label") %></label>
<textarea class="pure-input-1" name="<%= feed %>_extra" id="<%= feed %>_extra" rows="3"><%= HTML.escape(chosen.reject { |plid| playlists.any? { |playlist| playlist.id == plid } }.join("\n")) %></textarea>
</div>
</fieldset>
<% end %>
<fieldset>
<legend><%= I18n.translate(locale, "ariktube_trusted_header_auth_label") %></legend>
<p><%= I18n.translate(locale, "ariktube_trusted_header_auth_help") %></p>
<div class="pure-control-group">
<label for="tha_enabled"><%= I18n.translate(locale, "ariktube_tha_enabled_label") %></label>
<input name="tha_enabled" id="tha_enabled" type="checkbox" <% if trusted_header_auth.enabled %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="tha_header"><%= I18n.translate(locale, "ariktube_tha_header_label") %></label>
<input name="tha_header" id="tha_header" type="text" value="<%= HTML.escape(trusted_header_auth.header) %>">
</div>
<div class="pure-control-group">
<label for="tha_trusted_proxies"><%= I18n.translate(locale, "ariktube_tha_trusted_proxies_label") %></label>
<textarea class="pure-input-1" name="tha_trusted_proxies" id="tha_trusted_proxies" rows="3"
placeholder="192.168.1.101"><%= HTML.escape(trusted_header_auth.trusted_proxies.join("\n")) %></textarea>
</div>
<div class="pure-control-group">
<label for="tha_logout_url"><%= I18n.translate(locale, "ariktube_tha_logout_url_label") %></label>
<input class="pure-input-1" name="tha_logout_url" id="tha_logout_url" type="url" value="<%= HTML.escape(trusted_header_auth.logout_url) %>">
</div>
<div class="pure-control-group">
<label for="tha_password_self_service"><%= I18n.translate(locale, "ariktube_tha_password_self_service_label") %></label>
<input name="tha_password_self_service" id="tha_password_self_service" type="checkbox" <% if trusted_header_auth.password_self_service %>checked<% end %>>
</div>
<div class="pure-control-group">
<label for="tha_auto_approve"><%= I18n.translate(locale, "ariktube_tha_auto_approve_label") %></label>
<textarea class="pure-input-1" name="tha_auto_approve" id="tha_auto_approve" rows="3"
placeholder="https://yt.example.com"><%= HTML.escape(trusted_header_auth.auto_approve_token_callbacks.join("\n")) %></textarea>
</div>
<p><%= I18n.translate(locale, "ariktube_tha_auto_approve_help") %></p>
</fieldset>
<button type="submit" class="pure-button pure-button-primary">
<%= I18n.translate(locale, "ariktube_settings_save") %>
</button>
<input type="hidden" name="csrf_token" value="<%= HTML.escape(csrf_token) %>">
</form>
</div>

View File

@ -336,6 +336,10 @@
<label for="modified_source_code_url"><%= I18n.translate(locale, "adminprefs_modified_source_code_url_label") %></label>
<input name="modified_source_code_url" id="modified_source_code_url" type="url" value="<%= CONFIG.modified_source_code_url %>">
</div>
<div class="pure-control-group">
<a href="/admin/settings?referer=<%= URI.encode_www_form(referer) %>"><%= I18n.translate(locale, "ariktube_settings_link") %></a>
</div>
<% end %>
<% if env.get? "user" %>