mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
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:
parent
437c6194d6
commit
c47d9a93f0
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
172
src/invidious/routes/admin_settings.cr
Normal file
172
src/invidious/routes/admin_settings.cr
Normal 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
|
||||
@ -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
|
||||
|
||||
121
src/invidious/views/admin/settings.ecr
Normal file
121
src/invidious/views/admin/settings.ecr
Normal 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>
|
||||
@ -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" %>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user