mirror of
				https://gitea.invidious.io/iv-org/invidious
				synced 2025-06-05 23:29:12 +02:00 
			
		
		
		
	Merge pull request #2871 from SamantazFox/user-code-cleaning
User code cleaning & fixing
This commit is contained in:
		@@ -25,9 +25,9 @@ def csv_sample
 | 
			
		||||
  CSV
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
Spectator.describe "Invidious::User::Imports" do
 | 
			
		||||
Spectator.describe Invidious::User::Import do
 | 
			
		||||
  it "imports CSV" do
 | 
			
		||||
    subscriptions = parse_subscription_export_csv(csv_sample)
 | 
			
		||||
    subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample)
 | 
			
		||||
 | 
			
		||||
    expect(subscriptions).to be_an(Array(String))
 | 
			
		||||
    expect(subscriptions.size).to eq(13)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										347
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										347
									
								
								src/invidious.cr
									
									
									
									
									
								
							@@ -38,14 +38,13 @@ require "./invidious/jobs/**"
 | 
			
		||||
CONFIG   = Config.load
 | 
			
		||||
HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32)
 | 
			
		||||
 | 
			
		||||
PG_DB           = DB.open CONFIG.database_url
 | 
			
		||||
ARCHIVE_URL     = URI.parse("https://archive.org")
 | 
			
		||||
LOGIN_URL       = URI.parse("https://accounts.google.com")
 | 
			
		||||
PUBSUB_URL      = URI.parse("https://pubsubhubbub.appspot.com")
 | 
			
		||||
REDDIT_URL      = URI.parse("https://www.reddit.com")
 | 
			
		||||
TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
 | 
			
		||||
YT_URL          = URI.parse("https://www.youtube.com")
 | 
			
		||||
HOST_URL        = make_host_url(Kemal.config)
 | 
			
		||||
PG_DB       = DB.open CONFIG.database_url
 | 
			
		||||
ARCHIVE_URL = URI.parse("https://archive.org")
 | 
			
		||||
LOGIN_URL   = URI.parse("https://accounts.google.com")
 | 
			
		||||
PUBSUB_URL  = URI.parse("https://pubsubhubbub.appspot.com")
 | 
			
		||||
REDDIT_URL  = URI.parse("https://www.reddit.com")
 | 
			
		||||
YT_URL      = URI.parse("https://www.youtube.com")
 | 
			
		||||
HOST_URL    = make_host_url(Kemal.config)
 | 
			
		||||
 | 
			
		||||
CHARS_SAFE         = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
 | 
			
		||||
TEST_IDS           = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"}
 | 
			
		||||
@@ -366,15 +365,8 @@ end
 | 
			
		||||
  Invidious::Routing.get "/results", Invidious::Routes::Search, :results
 | 
			
		||||
  Invidious::Routing.get "/search", Invidious::Routes::Search, :search
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
 | 
			
		||||
  Invidious::Routing.post "/login", Invidious::Routes::Login, :login
 | 
			
		||||
  Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
 | 
			
		||||
 | 
			
		||||
  Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
 | 
			
		||||
  Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
 | 
			
		||||
  Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
 | 
			
		||||
  Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
 | 
			
		||||
  Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
 | 
			
		||||
  # User routes
 | 
			
		||||
  define_user_routes()
 | 
			
		||||
 | 
			
		||||
  # Feeds
 | 
			
		||||
  Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect
 | 
			
		||||
@@ -414,325 +406,6 @@ define_v1_api_routes()
 | 
			
		||||
define_api_manifest_routes()
 | 
			
		||||
define_video_playback_routes()
 | 
			
		||||
 | 
			
		||||
get "/change_password" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
  templated "change_password"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post "/change_password" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
  # We don't store passwords for Google accounts
 | 
			
		||||
  if !user.password
 | 
			
		||||
    next error_template(400, "Cannot change password for Google accounts")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  begin
 | 
			
		||||
    validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    next error_template(400, ex)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  password = env.params.body["password"]?
 | 
			
		||||
  if !password
 | 
			
		||||
    next error_template(401, "Password is a required field")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
 | 
			
		||||
 | 
			
		||||
  if new_passwords.size <= 1 || new_passwords.uniq.size != 1
 | 
			
		||||
    next error_template(400, "New passwords must match")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  new_password = new_passwords.uniq[0]
 | 
			
		||||
  if new_password.empty?
 | 
			
		||||
    next error_template(401, "Password cannot be empty")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if new_password.bytesize > 55
 | 
			
		||||
    next error_template(400, "Password cannot be longer than 55 characters")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
 | 
			
		||||
    next error_template(401, "Incorrect password")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
 | 
			
		||||
  Invidious::Database::Users.update_password(user, new_password.to_s)
 | 
			
		||||
 | 
			
		||||
  env.redirect referer
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/delete_account" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
  templated "delete_account"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post "/delete_account" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
  begin
 | 
			
		||||
    validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    next error_template(400, ex)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  view_name = "subscriptions_#{sha256(user.email)}"
 | 
			
		||||
  Invidious::Database::Users.delete(user)
 | 
			
		||||
  Invidious::Database::SessionIDs.delete(email: user.email)
 | 
			
		||||
  PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
 | 
			
		||||
 | 
			
		||||
  env.request.cookies.each do |cookie|
 | 
			
		||||
    cookie.expires = Time.utc(1990, 1, 1)
 | 
			
		||||
    env.response.cookies << cookie
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  env.redirect referer
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/clear_watch_history" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
  templated "clear_watch_history"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post "/clear_watch_history" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
  begin
 | 
			
		||||
    validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    next error_template(400, ex)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  Invidious::Database::Users.clear_watch_history(user)
 | 
			
		||||
  env.redirect referer
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/authorize_token" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
  scopes = env.params.query["scopes"]?.try &.split(",")
 | 
			
		||||
  scopes ||= [] of String
 | 
			
		||||
 | 
			
		||||
  callback_url = env.params.query["callback_url"]?
 | 
			
		||||
  if callback_url
 | 
			
		||||
    callback_url = URI.parse(callback_url)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  expire = env.params.query["expire"]?.try &.to_i?
 | 
			
		||||
 | 
			
		||||
  templated "authorize_token"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post "/authorize_token" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = env.get("user").as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
  begin
 | 
			
		||||
    validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    next error_template(400, ex)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
 | 
			
		||||
  callback_url = env.params.body["callbackUrl"]?
 | 
			
		||||
  expire = env.params.body["expire"]?.try &.to_i?
 | 
			
		||||
 | 
			
		||||
  access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
  if callback_url
 | 
			
		||||
    access_token = URI.encode_www_form(access_token)
 | 
			
		||||
    url = URI.parse(callback_url)
 | 
			
		||||
 | 
			
		||||
    if url.query
 | 
			
		||||
      query = HTTP::Params.parse(url.query.not_nil!)
 | 
			
		||||
    else
 | 
			
		||||
      query = HTTP::Params.new
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    query["token"] = access_token
 | 
			
		||||
    url.query = query.to_s
 | 
			
		||||
 | 
			
		||||
    env.redirect url.to_s
 | 
			
		||||
  else
 | 
			
		||||
    csrf_token = ""
 | 
			
		||||
    env.set "access_token", access_token
 | 
			
		||||
    templated "authorize_token"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
get "/token_manager" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env, "/subscription_manager")
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  tokens = Invidious::Database::SessionIDs.select_all(user.email)
 | 
			
		||||
 | 
			
		||||
  templated "token_manager"
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
post "/token_ajax" do |env|
 | 
			
		||||
  locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
  user = env.get? "user"
 | 
			
		||||
  sid = env.get? "sid"
 | 
			
		||||
  referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
  redirect = env.params.query["redirect"]?
 | 
			
		||||
  redirect ||= "true"
 | 
			
		||||
  redirect = redirect == "true"
 | 
			
		||||
 | 
			
		||||
  if !user
 | 
			
		||||
    if redirect
 | 
			
		||||
      next env.redirect referer
 | 
			
		||||
    else
 | 
			
		||||
      next error_json(403, "No such user")
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  user = user.as(User)
 | 
			
		||||
  sid = sid.as(String)
 | 
			
		||||
  token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
  begin
 | 
			
		||||
    validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
  rescue ex
 | 
			
		||||
    if redirect
 | 
			
		||||
      next error_template(400, ex)
 | 
			
		||||
    else
 | 
			
		||||
      next error_json(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if env.params.query["action_revoke_token"]?
 | 
			
		||||
    action = "action_revoke_token"
 | 
			
		||||
  else
 | 
			
		||||
    next env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  session = env.params.query["session"]?
 | 
			
		||||
  session ||= ""
 | 
			
		||||
 | 
			
		||||
  case action
 | 
			
		||||
  when .starts_with? "action_revoke_token"
 | 
			
		||||
    Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
 | 
			
		||||
  else
 | 
			
		||||
    next error_json(400, "Unsupported action #{action}")
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  if redirect
 | 
			
		||||
    env.redirect referer
 | 
			
		||||
  else
 | 
			
		||||
    env.response.content_type = "application/json"
 | 
			
		||||
    "{}"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
# Channels
 | 
			
		||||
 | 
			
		||||
{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route|
 | 
			
		||||
@@ -876,7 +549,7 @@ add_handler AuthHandler.new
 | 
			
		||||
add_handler DenyFrame.new
 | 
			
		||||
add_context_storage_type(Array(String))
 | 
			
		||||
add_context_storage_type(Preferences)
 | 
			
		||||
add_context_storage_type(User)
 | 
			
		||||
add_context_storage_type(Invidious::User)
 | 
			
		||||
 | 
			
		||||
Kemal.config.logger = LOGGER
 | 
			
		||||
Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										358
									
								
								src/invidious/routes/account.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										358
									
								
								src/invidious/routes/account.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,358 @@
 | 
			
		||||
{% skip_file if flag?(:api_only) %}
 | 
			
		||||
 | 
			
		||||
module Invidious::Routes::Account
 | 
			
		||||
  extend self
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  Password update
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Show the password change interface (GET request)
 | 
			
		||||
  def get_change_password(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
    templated "user/change_password"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Handle the password change (POST request)
 | 
			
		||||
  def post_change_password(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
    # We don't store passwords for Google accounts
 | 
			
		||||
    if !user.password
 | 
			
		||||
      return error_template(400, "Cannot change password for Google accounts")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_template(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    password = env.params.body["password"]?
 | 
			
		||||
    if !password
 | 
			
		||||
      return error_template(401, "Password is a required field")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v }
 | 
			
		||||
 | 
			
		||||
    if new_passwords.size <= 1 || new_passwords.uniq.size != 1
 | 
			
		||||
      return error_template(400, "New passwords must match")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    new_password = new_passwords.uniq[0]
 | 
			
		||||
    if new_password.empty?
 | 
			
		||||
      return error_template(401, "Password cannot be empty")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if new_password.bytesize > 55
 | 
			
		||||
      return error_template(400, "Password cannot be longer than 55 characters")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55))
 | 
			
		||||
      return error_template(401, "Incorrect password")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10)
 | 
			
		||||
    Invidious::Database::Users.update_password(user, new_password.to_s)
 | 
			
		||||
 | 
			
		||||
    env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  Account deletion
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Show the account deletion confirmation prompt (GET request)
 | 
			
		||||
  def get_delete(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
    templated "user/delete_account"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Handle the account deletion (POST request)
 | 
			
		||||
  def post_delete(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_template(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    view_name = "subscriptions_#{sha256(user.email)}"
 | 
			
		||||
    Invidious::Database::Users.delete(user)
 | 
			
		||||
    Invidious::Database::SessionIDs.delete(email: user.email)
 | 
			
		||||
    PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
 | 
			
		||||
 | 
			
		||||
    env.request.cookies.each do |cookie|
 | 
			
		||||
      cookie.expires = Time.utc(1990, 1, 1)
 | 
			
		||||
      env.response.cookies << cookie
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  Clear history
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Show the watch history deletion confirmation prompt (GET request)
 | 
			
		||||
  def get_clear_history(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
    templated "user/clear_watch_history"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Handle the watch history clearing (POST request)
 | 
			
		||||
  def post_clear_history(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_template(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    Invidious::Database::Users.clear_watch_history(user)
 | 
			
		||||
    env.redirect referer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  Authorize tokens
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Show the "authorize token?" confirmation prompt (GET request)
 | 
			
		||||
  def get_authorize_token(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
    scopes = env.params.query["scopes"]?.try &.split(",")
 | 
			
		||||
    scopes ||= [] of String
 | 
			
		||||
 | 
			
		||||
    callback_url = env.params.query["callback_url"]?
 | 
			
		||||
    if callback_url
 | 
			
		||||
      callback_url = URI.parse(callback_url)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    expire = env.params.query["expire"]?.try &.to_i?
 | 
			
		||||
 | 
			
		||||
    templated "user/authorize_token"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # Handle token authorization (POST request)
 | 
			
		||||
  def post_authorize_token(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = env.get("user").as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      return error_template(400, ex)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v }
 | 
			
		||||
    callback_url = env.params.body["callbackUrl"]?
 | 
			
		||||
    expire = env.params.body["expire"]?.try &.to_i?
 | 
			
		||||
 | 
			
		||||
    access_token = generate_token(user.email, scopes, expire, HMAC_KEY)
 | 
			
		||||
 | 
			
		||||
    if callback_url
 | 
			
		||||
      access_token = URI.encode_www_form(access_token)
 | 
			
		||||
      url = URI.parse(callback_url)
 | 
			
		||||
 | 
			
		||||
      if url.query
 | 
			
		||||
        query = HTTP::Params.parse(url.query.not_nil!)
 | 
			
		||||
      else
 | 
			
		||||
        query = HTTP::Params.new
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      query["token"] = access_token
 | 
			
		||||
      url.query = query.to_s
 | 
			
		||||
 | 
			
		||||
      env.redirect url.to_s
 | 
			
		||||
    else
 | 
			
		||||
      csrf_token = ""
 | 
			
		||||
      env.set "access_token", access_token
 | 
			
		||||
      templated "user/authorize_token"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  Manage tokens
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Show the token manager page (GET request)
 | 
			
		||||
  def token_manager(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env, "/subscription_manager")
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    tokens = Invidious::Database::SessionIDs.select_all(user.email)
 | 
			
		||||
 | 
			
		||||
    templated "user/token_manager"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  # -------------------
 | 
			
		||||
  #  AJAX for tokens
 | 
			
		||||
  # -------------------
 | 
			
		||||
 | 
			
		||||
  # Handle internal (non-API) token actions (POST request)
 | 
			
		||||
  def token_ajax(env)
 | 
			
		||||
    locale = env.get("preferences").as(Preferences).locale
 | 
			
		||||
 | 
			
		||||
    user = env.get? "user"
 | 
			
		||||
    sid = env.get? "sid"
 | 
			
		||||
    referer = get_referer(env)
 | 
			
		||||
 | 
			
		||||
    redirect = env.params.query["redirect"]?
 | 
			
		||||
    redirect ||= "true"
 | 
			
		||||
    redirect = redirect == "true"
 | 
			
		||||
 | 
			
		||||
    if !user
 | 
			
		||||
      if redirect
 | 
			
		||||
        return env.redirect referer
 | 
			
		||||
      else
 | 
			
		||||
        return error_json(403, "No such user")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    sid = sid.as(String)
 | 
			
		||||
    token = env.params.body["csrf_token"]?
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      validate_request(token, sid, env.request, HMAC_KEY, locale)
 | 
			
		||||
    rescue ex
 | 
			
		||||
      if redirect
 | 
			
		||||
        return error_template(400, ex)
 | 
			
		||||
      else
 | 
			
		||||
        return error_json(400, ex)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if env.params.query["action_revoke_token"]?
 | 
			
		||||
      action = "action_revoke_token"
 | 
			
		||||
    else
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    session = env.params.query["session"]?
 | 
			
		||||
    session ||= ""
 | 
			
		||||
 | 
			
		||||
    case action
 | 
			
		||||
    when .starts_with? "action_revoke_token"
 | 
			
		||||
      Invidious::Database::SessionIDs.delete(sid: session, email: user.email)
 | 
			
		||||
    else
 | 
			
		||||
      return error_json(400, "Unsupported action #{action}")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if redirect
 | 
			
		||||
      return env.redirect referer
 | 
			
		||||
    else
 | 
			
		||||
      env.response.content_type = "application/json"
 | 
			
		||||
      return "{}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -343,7 +343,7 @@ module Invidious::Routes::API::V1::Authenticated
 | 
			
		||||
      env.response.content_type = "text/html"
 | 
			
		||||
 | 
			
		||||
      csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true)
 | 
			
		||||
      return templated "authorize_token"
 | 
			
		||||
      return templated "user/authorize_token"
 | 
			
		||||
    else
 | 
			
		||||
      env.response.content_type = "application/json"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,7 @@ module Invidious::Routes::Login
 | 
			
		||||
    tfa = env.params.query["tfa"]?
 | 
			
		||||
    prompt = nil
 | 
			
		||||
 | 
			
		||||
    templated "login"
 | 
			
		||||
    templated "user/login"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.login(env)
 | 
			
		||||
@@ -133,7 +133,7 @@ module Invidious::Routes::Login
 | 
			
		||||
          tfa = tfa_code
 | 
			
		||||
          captcha = {tokens: [token], question: ""}
 | 
			
		||||
 | 
			
		||||
          return templated "login"
 | 
			
		||||
          return templated "user/login"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED"
 | 
			
		||||
@@ -190,7 +190,7 @@ module Invidious::Routes::Login
 | 
			
		||||
 | 
			
		||||
            tfa = nil
 | 
			
		||||
            captcha = nil
 | 
			
		||||
            return templated "login"
 | 
			
		||||
            return templated "user/login"
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          tl = challenge_results[1][2]
 | 
			
		||||
@@ -282,18 +282,8 @@ module Invidious::Routes::Login
 | 
			
		||||
 | 
			
		||||
        host = URI.parse(env.request.headers["Host"]).host
 | 
			
		||||
 | 
			
		||||
        if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
          secure = true
 | 
			
		||||
        else
 | 
			
		||||
          secure = false
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        cookies.each do |cookie|
 | 
			
		||||
          if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
            cookie.secure = secure
 | 
			
		||||
          else
 | 
			
		||||
            cookie.secure = secure
 | 
			
		||||
          end
 | 
			
		||||
          cookie.secure = Invidious::User::Cookies::SECURE
 | 
			
		||||
 | 
			
		||||
          if cookie.extension
 | 
			
		||||
            cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host)
 | 
			
		||||
@@ -338,19 +328,7 @@ module Invidious::Routes::Login
 | 
			
		||||
          sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
 | 
			
		||||
          Invidious::Database::SessionIDs.insert(sid, email)
 | 
			
		||||
 | 
			
		||||
          if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
            secure = true
 | 
			
		||||
          else
 | 
			
		||||
            secure = false
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          if CONFIG.domain
 | 
			
		||||
            env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
 | 
			
		||||
              secure: secure, http_only: true)
 | 
			
		||||
          else
 | 
			
		||||
            env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
 | 
			
		||||
              secure: secure, http_only: true)
 | 
			
		||||
          end
 | 
			
		||||
          env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
 | 
			
		||||
        else
 | 
			
		||||
          return error_template(401, "Wrong username or password")
 | 
			
		||||
        end
 | 
			
		||||
@@ -393,12 +371,12 @@ module Invidious::Routes::Login
 | 
			
		||||
            prompt = ""
 | 
			
		||||
 | 
			
		||||
            if captcha_type == "image"
 | 
			
		||||
              captcha = generate_captcha(HMAC_KEY)
 | 
			
		||||
              captcha = Invidious::User::Captcha.generate_image(HMAC_KEY)
 | 
			
		||||
            else
 | 
			
		||||
              captcha = generate_text_captcha(HMAC_KEY)
 | 
			
		||||
              captcha = Invidious::User::Captcha.generate_text(HMAC_KEY)
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            return templated "login"
 | 
			
		||||
            return templated "user/login"
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v }
 | 
			
		||||
@@ -455,19 +433,7 @@ module Invidious::Routes::Login
 | 
			
		||||
        view_name = "subscriptions_#{sha256(user.email)}"
 | 
			
		||||
        PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
 | 
			
		||||
 | 
			
		||||
        if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
          secure = true
 | 
			
		||||
        else
 | 
			
		||||
          secure = false
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        if CONFIG.domain
 | 
			
		||||
          env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years,
 | 
			
		||||
            secure: secure, http_only: true)
 | 
			
		||||
        else
 | 
			
		||||
          env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years,
 | 
			
		||||
            secure: secure, http_only: true)
 | 
			
		||||
        end
 | 
			
		||||
        env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
 | 
			
		||||
 | 
			
		||||
        if env.request.cookies["PREFS"]?
 | 
			
		||||
          user.preferences = env.get("preferences").as(Preferences)
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ module Invidious::Routes::PreferencesRoute
 | 
			
		||||
 | 
			
		||||
    preferences = env.get("preferences").as(Preferences)
 | 
			
		||||
 | 
			
		||||
    templated "preferences"
 | 
			
		||||
    templated "user/preferences"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.update(env)
 | 
			
		||||
@@ -214,19 +214,7 @@ module Invidious::Routes::PreferencesRoute
 | 
			
		||||
        File.write("config/config.yml", CONFIG.to_yaml)
 | 
			
		||||
      end
 | 
			
		||||
    else
 | 
			
		||||
      if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
        secure = true
 | 
			
		||||
      else
 | 
			
		||||
        secure = false
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if CONFIG.domain
 | 
			
		||||
        env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
 | 
			
		||||
          secure: secure, http_only: true)
 | 
			
		||||
      else
 | 
			
		||||
        env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years,
 | 
			
		||||
          secure: secure, http_only: true)
 | 
			
		||||
      end
 | 
			
		||||
      env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    env.redirect referer
 | 
			
		||||
@@ -261,21 +249,7 @@ module Invidious::Routes::PreferencesRoute
 | 
			
		||||
        preferences.dark_mode = "dark"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      preferences = preferences.to_json
 | 
			
		||||
 | 
			
		||||
      if Kemal.config.ssl || CONFIG.https_only
 | 
			
		||||
        secure = true
 | 
			
		||||
      else
 | 
			
		||||
        secure = false
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if CONFIG.domain
 | 
			
		||||
        env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
 | 
			
		||||
          secure: secure, http_only: true)
 | 
			
		||||
      else
 | 
			
		||||
        env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years,
 | 
			
		||||
          secure: secure, http_only: true)
 | 
			
		||||
      end
 | 
			
		||||
      env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if redirect
 | 
			
		||||
@@ -298,7 +272,7 @@ module Invidious::Routes::PreferencesRoute
 | 
			
		||||
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
 | 
			
		||||
    templated "data_control"
 | 
			
		||||
    templated "user/data_control"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.update_data_control(env)
 | 
			
		||||
@@ -321,149 +295,27 @@ module Invidious::Routes::PreferencesRoute
 | 
			
		||||
        # TODO: Unify into single import based on content-type
 | 
			
		||||
        case part.name
 | 
			
		||||
        when "import_invidious"
 | 
			
		||||
          body = JSON.parse(body)
 | 
			
		||||
 | 
			
		||||
          if body["subscriptions"]?
 | 
			
		||||
            user.subscriptions += body["subscriptions"].as_a.map(&.as_s)
 | 
			
		||||
            user.subscriptions.uniq!
 | 
			
		||||
 | 
			
		||||
            user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
            Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          if body["watch_history"]?
 | 
			
		||||
            user.watched += body["watch_history"].as_a.map(&.as_s)
 | 
			
		||||
            user.watched.uniq!
 | 
			
		||||
            Invidious::Database::Users.update_watch_history(user)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          if body["preferences"]?
 | 
			
		||||
            user.preferences = Preferences.from_json(body["preferences"].to_json)
 | 
			
		||||
            Invidious::Database::Users.update_preferences(user)
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          if playlists = body["playlists"]?.try &.as_a?
 | 
			
		||||
            playlists.each do |item|
 | 
			
		||||
              title = item["title"]?.try &.as_s?.try &.delete("<>")
 | 
			
		||||
              description = item["description"]?.try &.as_s?.try &.delete("\r")
 | 
			
		||||
              privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
 | 
			
		||||
 | 
			
		||||
              next if !title
 | 
			
		||||
              next if !description
 | 
			
		||||
              next if !privacy
 | 
			
		||||
 | 
			
		||||
              playlist = create_playlist(title, privacy, user)
 | 
			
		||||
              Invidious::Database::Playlists.update_description(playlist.id, description)
 | 
			
		||||
 | 
			
		||||
              videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
 | 
			
		||||
                raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
 | 
			
		||||
 | 
			
		||||
                video_id = video_id.try &.as_s?
 | 
			
		||||
                next if !video_id
 | 
			
		||||
 | 
			
		||||
                begin
 | 
			
		||||
                  video = get_video(video_id)
 | 
			
		||||
                rescue ex
 | 
			
		||||
                  next
 | 
			
		||||
                end
 | 
			
		||||
 | 
			
		||||
                playlist_video = PlaylistVideo.new({
 | 
			
		||||
                  title:          video.title,
 | 
			
		||||
                  id:             video.id,
 | 
			
		||||
                  author:         video.author,
 | 
			
		||||
                  ucid:           video.ucid,
 | 
			
		||||
                  length_seconds: video.length_seconds,
 | 
			
		||||
                  published:      video.published,
 | 
			
		||||
                  plid:           playlist.id,
 | 
			
		||||
                  live_now:       video.live_now,
 | 
			
		||||
                  index:          Random::Secure.rand(0_i64..Int64::MAX),
 | 
			
		||||
                })
 | 
			
		||||
 | 
			
		||||
                Invidious::Database::PlaylistVideos.insert(playlist_video)
 | 
			
		||||
                Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
          Invidious::User::Import.from_invidious(user, body)
 | 
			
		||||
        when "import_youtube"
 | 
			
		||||
          filename = part.filename || ""
 | 
			
		||||
          extension = filename.split(".").last
 | 
			
		||||
          success = Invidious::User::Import.from_youtube(user, body, filename, type)
 | 
			
		||||
 | 
			
		||||
          if extension == "xml" || type == "application/xml" || type == "text/xml"
 | 
			
		||||
            subscriptions = XML.parse(body)
 | 
			
		||||
            user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
 | 
			
		||||
              channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
 | 
			
		||||
            end
 | 
			
		||||
          elsif extension == "json" || type == "application/json"
 | 
			
		||||
            subscriptions = JSON.parse(body)
 | 
			
		||||
            user.subscriptions += subscriptions.as_a.compact_map do |entry|
 | 
			
		||||
              entry["snippet"]["resourceId"]["channelId"].as_s
 | 
			
		||||
            end
 | 
			
		||||
          elsif extension == "csv" || type == "text/csv"
 | 
			
		||||
            subscriptions = parse_subscription_export_csv(body)
 | 
			
		||||
            user.subscriptions += subscriptions
 | 
			
		||||
          else
 | 
			
		||||
          if !success
 | 
			
		||||
            haltf(env, status_code: 415,
 | 
			
		||||
              response: error_template(415, "Invalid subscription file uploaded")
 | 
			
		||||
            )
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          user.subscriptions.uniq!
 | 
			
		||||
          user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
          Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
        when "import_freetube"
 | 
			
		||||
          user.subscriptions += body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/).map do |md|
 | 
			
		||||
            md["channel_id"]
 | 
			
		||||
          end
 | 
			
		||||
          user.subscriptions.uniq!
 | 
			
		||||
 | 
			
		||||
          user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
          Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
          Invidious::User::Import.from_freetube(user, body)
 | 
			
		||||
        when "import_newpipe_subscriptions"
 | 
			
		||||
          body = JSON.parse(body)
 | 
			
		||||
          user.subscriptions += body["subscriptions"].as_a.compact_map do |channel|
 | 
			
		||||
            if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
 | 
			
		||||
              next match["channel"]
 | 
			
		||||
            elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
 | 
			
		||||
              response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US")
 | 
			
		||||
              html = XML.parse_html(response.body)
 | 
			
		||||
              ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1]
 | 
			
		||||
              next ucid if ucid
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            nil
 | 
			
		||||
          end
 | 
			
		||||
          user.subscriptions.uniq!
 | 
			
		||||
 | 
			
		||||
          user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
          Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
          Invidious::User::Import.from_newpipe_subs(user, body)
 | 
			
		||||
        when "import_newpipe"
 | 
			
		||||
          Compress::Zip::Reader.open(IO::Memory.new(body)) do |file|
 | 
			
		||||
            file.each_entry do |entry|
 | 
			
		||||
              if entry.filename == "newpipe.db"
 | 
			
		||||
                tempfile = File.tempfile(".db")
 | 
			
		||||
                File.write(tempfile.path, entry.io.gets_to_end)
 | 
			
		||||
                db = DB.open("sqlite3://" + tempfile.path)
 | 
			
		||||
          success = Invidious::User::Import.from_newpipe(user, body)
 | 
			
		||||
 | 
			
		||||
                user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v="))
 | 
			
		||||
                user.watched.uniq!
 | 
			
		||||
 | 
			
		||||
                Invidious::Database::Users.update_watch_history(user)
 | 
			
		||||
 | 
			
		||||
                user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/"))
 | 
			
		||||
                user.subscriptions.uniq!
 | 
			
		||||
 | 
			
		||||
                user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
                Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
 | 
			
		||||
                db.close
 | 
			
		||||
                tempfile.delete
 | 
			
		||||
              end
 | 
			
		||||
            end
 | 
			
		||||
          if !success
 | 
			
		||||
            haltf(env, status_code: 415,
 | 
			
		||||
              response: error_template(415, "Uploaded file is too large")
 | 
			
		||||
            )
 | 
			
		||||
          end
 | 
			
		||||
        else nil # Ignore
 | 
			
		||||
        end
 | 
			
		||||
 
 | 
			
		||||
@@ -163,6 +163,6 @@ module Invidious::Routes::Subscriptions
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    templated "subscription_manager"
 | 
			
		||||
    templated "user/subscription_manager"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,32 @@ module Invidious::Routing
 | 
			
		||||
  {% end %}
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
macro define_user_routes
 | 
			
		||||
  # User login/out
 | 
			
		||||
  Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page
 | 
			
		||||
  Invidious::Routing.post "/login", Invidious::Routes::Login, :login
 | 
			
		||||
  Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout
 | 
			
		||||
 | 
			
		||||
  # User preferences
 | 
			
		||||
  Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show
 | 
			
		||||
  Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update
 | 
			
		||||
  Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme
 | 
			
		||||
  Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control
 | 
			
		||||
  Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control
 | 
			
		||||
 | 
			
		||||
  # User account management
 | 
			
		||||
  Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password
 | 
			
		||||
  Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password
 | 
			
		||||
  Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete
 | 
			
		||||
  Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete
 | 
			
		||||
  Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history
 | 
			
		||||
  Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history
 | 
			
		||||
  Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token
 | 
			
		||||
  Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token
 | 
			
		||||
  Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager
 | 
			
		||||
  Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
macro define_v1_api_routes
 | 
			
		||||
  {{namespace = Invidious::Routes::API::V1}}
 | 
			
		||||
  # Videos
 | 
			
		||||
 
 | 
			
		||||
@@ -176,7 +176,7 @@ end
 | 
			
		||||
 | 
			
		||||
def process_search_query(query, page, user, region)
 | 
			
		||||
  if user
 | 
			
		||||
    user = user.as(User)
 | 
			
		||||
    user = user.as(Invidious::User)
 | 
			
		||||
    view_name = "subscriptions_#{sha256(user.email)}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										78
									
								
								src/invidious/user/captcha.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/invidious/user/captcha.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,78 @@
 | 
			
		||||
require "openssl/hmac"
 | 
			
		||||
 | 
			
		||||
struct Invidious::User
 | 
			
		||||
  module Captcha
 | 
			
		||||
    extend self
 | 
			
		||||
 | 
			
		||||
    private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com")
 | 
			
		||||
 | 
			
		||||
    def generate_image(key)
 | 
			
		||||
      second = Random::Secure.rand(12)
 | 
			
		||||
      second_angle = second * 30
 | 
			
		||||
      second = second * 5
 | 
			
		||||
 | 
			
		||||
      minute = Random::Secure.rand(12)
 | 
			
		||||
      minute_angle = minute * 30
 | 
			
		||||
      minute = minute * 5
 | 
			
		||||
 | 
			
		||||
      hour = Random::Secure.rand(12)
 | 
			
		||||
      hour_angle = hour * 30 + minute_angle.to_f / 12
 | 
			
		||||
      if hour == 0
 | 
			
		||||
        hour = 12
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      clock_svg = <<-END_SVG
 | 
			
		||||
      <svg viewBox="0 0 100 100" width="200px" height="200px">
 | 
			
		||||
      <circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
 | 
			
		||||
 | 
			
		||||
      <text x="69"     y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
 | 
			
		||||
      <text x="82.909" y="34"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
 | 
			
		||||
      <text x="88"     y="53"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
 | 
			
		||||
      <text x="82.909" y="72"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
 | 
			
		||||
      <text x="69"     y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
 | 
			
		||||
      <text x="50"     y="91"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
 | 
			
		||||
      <text x="31"     y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
 | 
			
		||||
      <text x="17.091" y="72"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
 | 
			
		||||
      <text x="12"     y="53"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
 | 
			
		||||
      <text x="17.091" y="34"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
 | 
			
		||||
      <text x="31"     y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
 | 
			
		||||
      <text x="50"     y="15"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
 | 
			
		||||
 | 
			
		||||
      <circle cx="50" cy="50" r="3" fill="black"></circle>
 | 
			
		||||
      <line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
 | 
			
		||||
      <line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
 | 
			
		||||
      <line id="hour"   transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
 | 
			
		||||
      </svg>
 | 
			
		||||
      END_SVG
 | 
			
		||||
 | 
			
		||||
      image = "data:image/png;base64,"
 | 
			
		||||
      image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
 | 
			
		||||
        input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
 | 
			
		||||
      ) do |proc|
 | 
			
		||||
        Base64.strict_encode(proc.output.gets_to_end)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
 | 
			
		||||
      answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        question: image,
 | 
			
		||||
        tokens:   {generate_response(answer, {":login"}, key, use_nonce: true)},
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def generate_text(key)
 | 
			
		||||
      response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
 | 
			
		||||
      response = JSON.parse(response)
 | 
			
		||||
 | 
			
		||||
      tokens = response["a"].as_a.map do |answer|
 | 
			
		||||
        generate_response(answer.as_s, {":login"}, key, use_nonce: true)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        question: response["q"].as_s,
 | 
			
		||||
        tokens:   tokens,
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										37
									
								
								src/invidious/user/cookies.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/invidious/user/cookies.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
require "http/cookie"
 | 
			
		||||
 | 
			
		||||
struct Invidious::User
 | 
			
		||||
  module Cookies
 | 
			
		||||
    extend self
 | 
			
		||||
 | 
			
		||||
    # Note: we use ternary operator because the two variables
 | 
			
		||||
    # used in here are not booleans.
 | 
			
		||||
    SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false
 | 
			
		||||
 | 
			
		||||
    # Session ID (SID) cookie
 | 
			
		||||
    # Parameter "domain" comes from the global config
 | 
			
		||||
    def sid(domain : String?, sid) : HTTP::Cookie
 | 
			
		||||
      return HTTP::Cookie.new(
 | 
			
		||||
        name: "SID",
 | 
			
		||||
        domain: domain,
 | 
			
		||||
        value: sid,
 | 
			
		||||
        expires: Time.utc + 2.years,
 | 
			
		||||
        secure: SECURE,
 | 
			
		||||
        http_only: true
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Preferences (PREFS) cookie
 | 
			
		||||
    # Parameter "domain" comes from the global config
 | 
			
		||||
    def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie
 | 
			
		||||
      return HTTP::Cookie.new(
 | 
			
		||||
        name: "PREFS",
 | 
			
		||||
        domain: domain,
 | 
			
		||||
        value: URI.encode_www_form(preferences.to_json),
 | 
			
		||||
        expires: Time.utc + 2.years,
 | 
			
		||||
        secure: SECURE,
 | 
			
		||||
        http_only: true
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -1,27 +1,242 @@
 | 
			
		||||
require "csv"
 | 
			
		||||
 | 
			
		||||
def parse_subscription_export_csv(csv_content : String)
 | 
			
		||||
  rows = CSV.new(csv_content, headers: true)
 | 
			
		||||
  subscriptions = Array(String).new
 | 
			
		||||
struct Invidious::User
 | 
			
		||||
  module Import
 | 
			
		||||
    extend self
 | 
			
		||||
 | 
			
		||||
  # Counter to limit the amount of imports.
 | 
			
		||||
  # This is intended to prevent DoS.
 | 
			
		||||
  row_counter = 0
 | 
			
		||||
    # Parse a youtube CSV subscription file
 | 
			
		||||
    def parse_subscription_export_csv(csv_content : String)
 | 
			
		||||
      rows = CSV.new(csv_content, headers: true)
 | 
			
		||||
      subscriptions = Array(String).new
 | 
			
		||||
 | 
			
		||||
  rows.each do |row|
 | 
			
		||||
    # Limit to 1200
 | 
			
		||||
    row_counter += 1
 | 
			
		||||
    break if row_counter > 1_200
 | 
			
		||||
      # Counter to limit the amount of imports.
 | 
			
		||||
      # This is intended to prevent DoS.
 | 
			
		||||
      row_counter = 0
 | 
			
		||||
 | 
			
		||||
    # Channel ID is the first column in the csv export we can't use the header
 | 
			
		||||
    # name, because the header name is localized depending on the
 | 
			
		||||
    # language the user has set on their account
 | 
			
		||||
    channel_id = row[0].strip
 | 
			
		||||
      rows.each do |row|
 | 
			
		||||
        # Limit to 1200
 | 
			
		||||
        row_counter += 1
 | 
			
		||||
        break if row_counter > 1_200
 | 
			
		||||
 | 
			
		||||
    next if channel_id.empty?
 | 
			
		||||
        # Channel ID is the first column in the csv export we can't use the header
 | 
			
		||||
        # name, because the header name is localized depending on the
 | 
			
		||||
        # language the user has set on their account
 | 
			
		||||
        channel_id = row[0].strip
 | 
			
		||||
 | 
			
		||||
    subscriptions << channel_id
 | 
			
		||||
  end
 | 
			
		||||
        next if channel_id.empty?
 | 
			
		||||
        subscriptions << channel_id
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
  return subscriptions
 | 
			
		||||
      return subscriptions
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # -------------------
 | 
			
		||||
    #  Invidious
 | 
			
		||||
    # -------------------
 | 
			
		||||
 | 
			
		||||
    # Import from another invidious account
 | 
			
		||||
    def from_invidious(user : User, body : String)
 | 
			
		||||
      data = JSON.parse(body)
 | 
			
		||||
 | 
			
		||||
      if data["subscriptions"]?
 | 
			
		||||
        user.subscriptions += data["subscriptions"].as_a.map(&.as_s)
 | 
			
		||||
        user.subscriptions.uniq!
 | 
			
		||||
        user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
        Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if data["watch_history"]?
 | 
			
		||||
        user.watched += data["watch_history"].as_a.map(&.as_s)
 | 
			
		||||
        user.watched.uniq!
 | 
			
		||||
        Invidious::Database::Users.update_watch_history(user)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if data["preferences"]?
 | 
			
		||||
        user.preferences = Preferences.from_json(data["preferences"].to_json)
 | 
			
		||||
        Invidious::Database::Users.update_preferences(user)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if playlists = data["playlists"]?.try &.as_a?
 | 
			
		||||
        playlists.each do |item|
 | 
			
		||||
          title = item["title"]?.try &.as_s?.try &.delete("<>")
 | 
			
		||||
          description = item["description"]?.try &.as_s?.try &.delete("\r")
 | 
			
		||||
          privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy }
 | 
			
		||||
 | 
			
		||||
          next if !title
 | 
			
		||||
          next if !description
 | 
			
		||||
          next if !privacy
 | 
			
		||||
 | 
			
		||||
          playlist = create_playlist(title, privacy, user)
 | 
			
		||||
          Invidious::Database::Playlists.update_description(playlist.id, description)
 | 
			
		||||
 | 
			
		||||
          videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx|
 | 
			
		||||
            raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500
 | 
			
		||||
 | 
			
		||||
            video_id = video_id.try &.as_s?
 | 
			
		||||
            next if !video_id
 | 
			
		||||
 | 
			
		||||
            begin
 | 
			
		||||
              video = get_video(video_id)
 | 
			
		||||
            rescue ex
 | 
			
		||||
              next
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            playlist_video = PlaylistVideo.new({
 | 
			
		||||
              title:          video.title,
 | 
			
		||||
              id:             video.id,
 | 
			
		||||
              author:         video.author,
 | 
			
		||||
              ucid:           video.ucid,
 | 
			
		||||
              length_seconds: video.length_seconds,
 | 
			
		||||
              published:      video.published,
 | 
			
		||||
              plid:           playlist.id,
 | 
			
		||||
              live_now:       video.live_now,
 | 
			
		||||
              index:          Random::Secure.rand(0_i64..Int64::MAX),
 | 
			
		||||
            })
 | 
			
		||||
 | 
			
		||||
            Invidious::Database::PlaylistVideos.insert(playlist_video)
 | 
			
		||||
            Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index)
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # -------------------
 | 
			
		||||
    #  Youtube
 | 
			
		||||
    # -------------------
 | 
			
		||||
 | 
			
		||||
    private def is_opml?(mimetype : String, extension : String)
 | 
			
		||||
      opml_mimetypes = [
 | 
			
		||||
        "application/xml",
 | 
			
		||||
        "text/xml",
 | 
			
		||||
        "text/x-opml",
 | 
			
		||||
        "text/x-opml+xml",
 | 
			
		||||
      ]
 | 
			
		||||
 | 
			
		||||
      opml_extensions = ["xml", "opml"]
 | 
			
		||||
 | 
			
		||||
      return opml_mimetypes.any?(&.== mimetype) || opml_extensions.any?(&.== extension)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Import subscribed channels from Youtube
 | 
			
		||||
    # Returns success status
 | 
			
		||||
    def from_youtube(user : User, body : String, filename : String, type : String) : Bool
 | 
			
		||||
      extension = filename.split(".").last
 | 
			
		||||
 | 
			
		||||
      if is_opml?(type, extension)
 | 
			
		||||
        subscriptions = XML.parse(body)
 | 
			
		||||
        user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel|
 | 
			
		||||
          channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0]
 | 
			
		||||
        end
 | 
			
		||||
      elsif extension == "json" || type == "application/json"
 | 
			
		||||
        subscriptions = JSON.parse(body)
 | 
			
		||||
        user.subscriptions += subscriptions.as_a.compact_map do |entry|
 | 
			
		||||
          entry["snippet"]["resourceId"]["channelId"].as_s
 | 
			
		||||
        end
 | 
			
		||||
      elsif extension == "csv" || type == "text/csv"
 | 
			
		||||
        subscriptions = parse_subscription_export_csv(body)
 | 
			
		||||
        user.subscriptions += subscriptions
 | 
			
		||||
      else
 | 
			
		||||
        return false
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      user.subscriptions.uniq!
 | 
			
		||||
      user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
      Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
      return true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # -------------------
 | 
			
		||||
    #  Freetube
 | 
			
		||||
    # -------------------
 | 
			
		||||
 | 
			
		||||
    def from_freetube(user : User, body : String)
 | 
			
		||||
      # Legacy import?
 | 
			
		||||
      matches = body.scan(/"channelId":"(?<channel_id>[a-zA-Z0-9_-]{24})"/)
 | 
			
		||||
      subs = matches.map(&.["channel_id"])
 | 
			
		||||
 | 
			
		||||
      if subs.empty?
 | 
			
		||||
        data = JSON.parse(body)["subscriptions"]
 | 
			
		||||
        subs = data.as_a.map(&.["id"].as_s)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      user.subscriptions += subs
 | 
			
		||||
      user.subscriptions.uniq!
 | 
			
		||||
      user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
      Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # -------------------
 | 
			
		||||
    #  Newpipe
 | 
			
		||||
    # -------------------
 | 
			
		||||
 | 
			
		||||
    def from_newpipe_subs(user : User, body : String)
 | 
			
		||||
      data = JSON.parse(body)
 | 
			
		||||
 | 
			
		||||
      user.subscriptions += data["subscriptions"].as_a.compact_map do |channel|
 | 
			
		||||
        if match = channel["url"].as_s.match(/\/channel\/(?<channel>UC[a-zA-Z0-9_-]{22})/)
 | 
			
		||||
          next match["channel"]
 | 
			
		||||
        elsif match = channel["url"].as_s.match(/\/user\/(?<user>.+)/)
 | 
			
		||||
          # Resolve URL using the API
 | 
			
		||||
          resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com/user/#{match["user"]}")
 | 
			
		||||
          ucid = resolved_url.dig?("endpoint", "browseEndpoint", "browseId")
 | 
			
		||||
          next ucid.as_s if ucid
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        nil
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      user.subscriptions.uniq!
 | 
			
		||||
      user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
      Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def from_newpipe(user : User, body : String) : Bool
 | 
			
		||||
      io = IO::Memory.new(body)
 | 
			
		||||
 | 
			
		||||
      Compress::Zip::File.open(io) do |file|
 | 
			
		||||
        file.entries.each do |entry|
 | 
			
		||||
          entry.open do |file_io|
 | 
			
		||||
            # Ensure max size of 4MB
 | 
			
		||||
            io_sized = IO::Sized.new(file_io, 0x400000)
 | 
			
		||||
 | 
			
		||||
            next if entry.filename != "newpipe.db"
 | 
			
		||||
 | 
			
		||||
            tempfile = File.tempfile(".db")
 | 
			
		||||
 | 
			
		||||
            begin
 | 
			
		||||
              File.write(tempfile.path, io_sized.gets_to_end)
 | 
			
		||||
            rescue
 | 
			
		||||
              return false
 | 
			
		||||
            end
 | 
			
		||||
 | 
			
		||||
            db = DB.open("sqlite3://" + tempfile.path)
 | 
			
		||||
 | 
			
		||||
            user.watched += db.query_all("SELECT url FROM streams", as: String)
 | 
			
		||||
              .map(&.lchop("https://www.youtube.com/watch?v="))
 | 
			
		||||
 | 
			
		||||
            user.watched.uniq!
 | 
			
		||||
            Invidious::Database::Users.update_watch_history(user)
 | 
			
		||||
 | 
			
		||||
            user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String)
 | 
			
		||||
              .map(&.lchop("https://www.youtube.com/channel/"))
 | 
			
		||||
 | 
			
		||||
            user.subscriptions.uniq!
 | 
			
		||||
            user.subscriptions = get_batch_channels(user.subscriptions)
 | 
			
		||||
 | 
			
		||||
            Invidious::Database::Users.update_subscriptions(user)
 | 
			
		||||
 | 
			
		||||
            db.close
 | 
			
		||||
            tempfile.delete
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # Success!
 | 
			
		||||
      return true
 | 
			
		||||
    end
 | 
			
		||||
  end # module
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										27
									
								
								src/invidious/user/user.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/invidious/user/user.cr
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
			
		||||
require "db"
 | 
			
		||||
 | 
			
		||||
struct Invidious::User
 | 
			
		||||
  include DB::Serializable
 | 
			
		||||
 | 
			
		||||
  property updated : Time
 | 
			
		||||
  property notifications : Array(String)
 | 
			
		||||
  property subscriptions : Array(String)
 | 
			
		||||
  property email : String
 | 
			
		||||
 | 
			
		||||
  @[DB::Field(converter: Invidious::User::PreferencesConverter)]
 | 
			
		||||
  property preferences : Preferences
 | 
			
		||||
  property password : String?
 | 
			
		||||
  property token : String
 | 
			
		||||
  property watched : Array(String)
 | 
			
		||||
  property feed_needs_update : Bool?
 | 
			
		||||
 | 
			
		||||
  module PreferencesConverter
 | 
			
		||||
    def self.from_rs(rs)
 | 
			
		||||
      begin
 | 
			
		||||
        Preferences.from_json(rs.read(String))
 | 
			
		||||
      rescue ex
 | 
			
		||||
        Preferences.from_json("{}")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -3,32 +3,6 @@ require "crypto/bcrypt/password"
 | 
			
		||||
# Materialized views may not be defined using bound parameters (`$1` as used elsewhere)
 | 
			
		||||
MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" }
 | 
			
		||||
 | 
			
		||||
struct User
 | 
			
		||||
  include DB::Serializable
 | 
			
		||||
 | 
			
		||||
  property updated : Time
 | 
			
		||||
  property notifications : Array(String)
 | 
			
		||||
  property subscriptions : Array(String)
 | 
			
		||||
  property email : String
 | 
			
		||||
 | 
			
		||||
  @[DB::Field(converter: User::PreferencesConverter)]
 | 
			
		||||
  property preferences : Preferences
 | 
			
		||||
  property password : String?
 | 
			
		||||
  property token : String
 | 
			
		||||
  property watched : Array(String)
 | 
			
		||||
  property feed_needs_update : Bool?
 | 
			
		||||
 | 
			
		||||
  module PreferencesConverter
 | 
			
		||||
    def self.from_rs(rs)
 | 
			
		||||
      begin
 | 
			
		||||
        Preferences.from_json(rs.read(String))
 | 
			
		||||
      rescue ex
 | 
			
		||||
        Preferences.from_json("{}")
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def get_user(sid, headers, refresh = true)
 | 
			
		||||
  if email = Invidious::Database::SessionIDs.select_email(sid)
 | 
			
		||||
    user = Invidious::Database::Users.select!(email: email)
 | 
			
		||||
@@ -84,7 +58,7 @@ def fetch_user(sid, headers)
 | 
			
		||||
 | 
			
		||||
  token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
 | 
			
		||||
 | 
			
		||||
  user = User.new({
 | 
			
		||||
  user = Invidious::User.new({
 | 
			
		||||
    updated:           Time.utc,
 | 
			
		||||
    notifications:     [] of String,
 | 
			
		||||
    subscriptions:     channels,
 | 
			
		||||
@@ -102,7 +76,7 @@ def create_user(sid, email, password)
 | 
			
		||||
  password = Crypto::Bcrypt::Password.create(password, cost: 10)
 | 
			
		||||
  token = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
 | 
			
		||||
 | 
			
		||||
  user = User.new({
 | 
			
		||||
  user = Invidious::User.new({
 | 
			
		||||
    updated:           Time.utc,
 | 
			
		||||
    notifications:     [] of String,
 | 
			
		||||
    subscriptions:     [] of String,
 | 
			
		||||
@@ -117,75 +91,6 @@ def create_user(sid, email, password)
 | 
			
		||||
  return user, sid
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def generate_captcha(key)
 | 
			
		||||
  second = Random::Secure.rand(12)
 | 
			
		||||
  second_angle = second * 30
 | 
			
		||||
  second = second * 5
 | 
			
		||||
 | 
			
		||||
  minute = Random::Secure.rand(12)
 | 
			
		||||
  minute_angle = minute * 30
 | 
			
		||||
  minute = minute * 5
 | 
			
		||||
 | 
			
		||||
  hour = Random::Secure.rand(12)
 | 
			
		||||
  hour_angle = hour * 30 + minute_angle.to_f / 12
 | 
			
		||||
  if hour == 0
 | 
			
		||||
    hour = 12
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  clock_svg = <<-END_SVG
 | 
			
		||||
  <svg viewBox="0 0 100 100" width="200px" height="200px">
 | 
			
		||||
  <circle cx="50" cy="50" r="45" fill="#eee" stroke="black" stroke-width="2"></circle>
 | 
			
		||||
 | 
			
		||||
  <text x="69"     y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 1</text>
 | 
			
		||||
  <text x="82.909" y="34"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 2</text>
 | 
			
		||||
  <text x="88"     y="53"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 3</text>
 | 
			
		||||
  <text x="82.909" y="72"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 4</text>
 | 
			
		||||
  <text x="69"     y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 5</text>
 | 
			
		||||
  <text x="50"     y="91"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 6</text>
 | 
			
		||||
  <text x="31"     y="85.909" text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 7</text>
 | 
			
		||||
  <text x="17.091" y="72"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 8</text>
 | 
			
		||||
  <text x="12"     y="53"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px"> 9</text>
 | 
			
		||||
  <text x="17.091" y="34"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px">10</text>
 | 
			
		||||
  <text x="31"     y="20.091" text-anchor="middle" fill="black" font-family="Arial" font-size="10px">11</text>
 | 
			
		||||
  <text x="50"     y="15"     text-anchor="middle" fill="black" font-family="Arial" font-size="10px">12</text>
 | 
			
		||||
 | 
			
		||||
  <circle cx="50" cy="50" r="3" fill="black"></circle>
 | 
			
		||||
  <line id="second" transform="rotate(#{second_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="12" fill="black" stroke="black" stroke-width="1"></line>
 | 
			
		||||
  <line id="minute" transform="rotate(#{minute_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="16" fill="black" stroke="black" stroke-width="2"></line>
 | 
			
		||||
  <line id="hour"   transform="rotate(#{hour_angle}, 50, 50)" x1="50" y1="50" x2="50" y2="24" fill="black" stroke="black" stroke-width="2"></line>
 | 
			
		||||
  </svg>
 | 
			
		||||
  END_SVG
 | 
			
		||||
 | 
			
		||||
  image = "data:image/png;base64,"
 | 
			
		||||
  image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true,
 | 
			
		||||
    input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe
 | 
			
		||||
  ) do |proc|
 | 
			
		||||
    Base64.strict_encode(proc.output.gets_to_end)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}"
 | 
			
		||||
  answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer)
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    question: image,
 | 
			
		||||
    tokens:   {generate_response(answer, {":login"}, key, use_nonce: true)},
 | 
			
		||||
  }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def generate_text_captcha(key)
 | 
			
		||||
  response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body)
 | 
			
		||||
  response = JSON.parse(response)
 | 
			
		||||
 | 
			
		||||
  tokens = response["a"].as_a.map do |answer|
 | 
			
		||||
    generate_response(answer.as_s, {":login"}, key, use_nonce: true)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    question: response["q"].as_s,
 | 
			
		||||
    tokens:   tokens,
 | 
			
		||||
  }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
def subscribe_ajax(channel_id, action, env_headers)
 | 
			
		||||
  headers = HTTP::Headers.new
 | 
			
		||||
  headers["Cookie"] = env_headers["Cookie"]
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,7 @@
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="pure-u-1-4">
 | 
			
		||||
                            <a id="notification_ticker" title="<%= translate(locale, "Subscriptions") %>" href="/feed/subscriptions" class="pure-menu-heading">
 | 
			
		||||
                                <% notification_count = env.get("user").as(User).notifications.size %>
 | 
			
		||||
                                <% notification_count = env.get("user").as(Invidious::User).notifications.size %>
 | 
			
		||||
                                <% if notification_count > 0 %>
 | 
			
		||||
                                    <span id="notification_count"><%= notification_count %></span> <i class="icon ion-ios-notifications"></i>
 | 
			
		||||
                                <% else %>
 | 
			
		||||
@@ -67,7 +67,7 @@
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <% if env.get("preferences").as(Preferences).show_nick %>
 | 
			
		||||
                            <div class="pure-u-1-4">
 | 
			
		||||
                                <span id="user_name"><%= env.get("user").as(User).email %></span>
 | 
			
		||||
                                <span id="user_name"><%= env.get("user").as(Invidious::User).email %></span>
 | 
			
		||||
                            </div>
 | 
			
		||||
                        <% end %>
 | 
			
		||||
                        <div class="pure-u-1-4">
 | 
			
		||||
 
 | 
			
		||||
@@ -252,7 +252,7 @@
 | 
			
		||||
                <% end %>
 | 
			
		||||
            <% end %>
 | 
			
		||||
 | 
			
		||||
            <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %>
 | 
			
		||||
            <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %>
 | 
			
		||||
                <legend><%= translate(locale, "preferences_category_admin") %></legend>
 | 
			
		||||
 | 
			
		||||
                <div class="pure-control-group">
 | 
			
		||||
		Reference in New Issue
	
	Block a user