diff options
Diffstat (limited to 'src/invidious.cr')
| -rw-r--r-- | src/invidious.cr | 1815 |
1 files changed, 172 insertions, 1643 deletions
diff --git a/src/invidious.cr b/src/invidious.cr index 284b238c..8d4c2e58 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -64,7 +64,7 @@ HTTP_CHUNK_SIZE = 10485760 # ~10MB CURRENT_BRANCH = {{ "#{`git branch | sed -n '/* /s///p'`.strip}" }} CURRENT_COMMIT = {{ "#{`git rev-list HEAD --max-count=1 --abbrev-commit`.strip}" }} -CURRENT_VERSION = {{ "#{`git describe --tags --abbrev=0`.strip}" }} +CURRENT_VERSION = {{ "#{`git log -1 --format=%ci | awk '{print $1}' | sed s/-/./g`.strip}" }} # This is used to determine the `?v=` on the end of file URLs (for cache busting). We # only need to expire modified assets, so we can use this to find the last commit that changes @@ -104,10 +104,11 @@ LOCALES = { "zh-TW" => load_locale("zh-TW"), } -YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 0.1) +YT_POOL = QUICPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0) config = CONFIG -logger = Invidious::LogHandler.new +output = STDOUT +loglvl = LogLevel::Debug Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" @@ -127,11 +128,14 @@ Kemal.config.extra_options do |parser| exit end end - parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output| - FileUtils.mkdir_p(File.dirname(output)) - logger = Invidious::LogHandler.new(File.open(output, mode: "a")) + parser.on("-o OUTPUT", "--output=OUTPUT", "Redirect output (default: STDOUT)") do |output_arg| + FileUtils.mkdir_p(File.dirname(output_arg)) + output = File.open(output_arg, mode: "a") end - parser.on("-v", "--version", "Print version") do |output| + parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{loglvl})") do |loglvl_arg| + loglvl = LogLevel.parse(loglvl_arg) + end + parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json exit end @@ -139,6 +143,8 @@ end Kemal::CLI.new ARGV +logger = Invidious::LogHandler.new(output, loglvl) + # Check table integrity if CONFIG.check_tables check_enum(PG_DB, logger, "privacy", PlaylistPrivacy) @@ -162,13 +168,16 @@ end Invidious::Jobs.register Invidious::Jobs::RefreshChannelsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB, logger, config) Invidious::Jobs.register Invidious::Jobs::SubscribeToFeedsJob.new(PG_DB, logger, config, HMAC_KEY) -Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new if config.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, config, SOFTWARE) end +if config.popular_enabled + Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) +end + if config.captcha_key Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new(logger, config) end @@ -197,6 +206,7 @@ before_all do |env| extra_media_csp = "" if CONFIG.disabled?("local") || !preferences.local extra_media_csp += " https://*.googlevideo.com:443" + extra_media_csp += " https://*.youtube.com:443" end # TODO: Remove style-src's 'unsafe-inline', requires to remove all inline styles (<style> [..] </style>, style=" [..] ") env.response.headers["Content-Security-Policy"] = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; manifest-src 'self'; media-src 'self' blob:#{extra_media_csp}" @@ -249,7 +259,7 @@ before_all do |env| headers["Cookie"] = env.request.headers["Cookie"] begin - user, sid = get_user(sid, headers, PG_DB, false) + user, sid = get_user(sid, headers, PG_DB, logger, false) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -299,1394 +309,30 @@ Invidious::Routing.get "/licenses", Invidious::Routes::Licenses Invidious::Routing.get "/watch", Invidious::Routes::Watch Invidious::Routing.get "/embed/", Invidious::Routes::Embed::Index Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed::Show - -# Playlists - -get "/feed/playlists" do |env| - env.redirect "/view_all_playlists" -end - -get "/view_all_playlists" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_created.map! do |item| - item.author = "" - item - end - - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_saved.map! do |item| - item.author = "" - item - end - - templated "view_all_playlists" -end - -get "/create_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) - - templated "create_playlist" -end - -post "/create_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" - end - - title = env.params.body["title"]?.try &.as(String) - if !title || title.empty? - error_message = "Title cannot be empty." - next templated "error" - end - - privacy = PlaylistPrivacy.parse?(env.params.body["privacy"]?.try &.as(String) || "") - if !privacy - error_message = "Invalid privacy setting." - next templated "error" - end - - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 - error_message = "User cannot have more than 100 playlists." - next templated "error" - end - - playlist = create_playlist(PG_DB, title, privacy, user) - - env.redirect "/playlist?list=#{playlist.id}" -end - -get "/subscribe_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - - playlist_id = env.params.query["list"] - playlist = get_playlist(PG_DB, playlist_id, locale) - subscribe_playlist(PG_DB, user, playlist) - - env.redirect "/playlist?list=#{playlist.id}" -end - -get "/delete_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - sid = sid.as(String) - - plid = env.params.query["list"]? - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - next env.redirect referer - end - - csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) - - templated "delete_playlist" -end - -post "/delete_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - plid = env.params.query["list"]? - if !plid - 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, PG_DB, locale) - rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" - end - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - next env.redirect referer - end - - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) - - env.redirect "/view_all_playlists" -end - -get "/edit_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - sid = sid.as(String) - - plid = env.params.query["list"]? - if !plid || !plid.starts_with?("IV") - next env.redirect referer - end - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - next env.redirect referer - end - rescue ex - next env.redirect referer - end - - begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) - rescue ex - videos = [] of PlaylistVideo - end - - csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) - - templated "edit_playlist" -end - -post "/edit_playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - plid = env.params.query["list"]? - if !plid - 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, PG_DB, locale) - rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" - end - - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - next env.redirect referer - end - - title = env.params.body["title"]?.try &.delete("<>") || "" - privacy = PlaylistPrivacy.parse(env.params.body["privacy"]? || "Public") - description = env.params.body["description"]?.try &.delete("\r") || "" - - if title != playlist.title || - privacy != playlist.privacy || - description != playlist.description - updated = Time.utc - else - updated = playlist.updated - end - - PG_DB.exec("UPDATE playlists SET title = $1, privacy = $2, description = $3, updated = $4 WHERE id = $5", title, privacy, description, updated, plid) - - env.redirect "/playlist?list=#{plid}" -end - -get "/add_playlist_items" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect "/" - end - - user = user.as(User) - sid = sid.as(String) - - plid = env.params.query["list"]? - if !plid || !plid.starts_with?("IV") - next env.redirect referer - end - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) - if !playlist || playlist.author != user.email - next env.redirect referer - end - rescue ex - next env.redirect referer - end - - query = env.params.query["q"]? - if query - begin - search_query, count, items = process_search_query(query, page, user, region: nil) - videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } - rescue ex - videos = [] of SearchVideo - count = 0 - end - else - videos = [] of SearchVideo - count = 0 - end - - env.set "add_playlist_items", plid - templated "add_playlist_items" -end - -post "/playlist_ajax" do |env| - locale = LOCALES[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 - error_message = {"error" => "No such user"}.to_json - env.response.status_code = 403 - next error_message - 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, PG_DB, locale) - rescue ex - if redirect - error_message = ex.message - env.response.status_code = 400 - next templated "error" - else - error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 - next error_message - end - end - - if env.params.query["action_create_playlist"]? - action = "action_create_playlist" - elsif env.params.query["action_delete_playlist"]? - action = "action_delete_playlist" - elsif env.params.query["action_edit_playlist"]? - action = "action_edit_playlist" - elsif env.params.query["action_add_video"]? - action = "action_add_video" - video_id = env.params.query["video_id"] - elsif env.params.query["action_remove_video"]? - action = "action_remove_video" - elsif env.params.query["action_move_video_before"]? - action = "action_move_video_before" - else - next env.redirect referer - end - - begin - playlist_id = env.params.query["playlist_id"] - playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) - raise "Invalid user" if playlist.author != user.email - rescue ex - if redirect - error_message = ex.message - env.response.status_code = 400 - next templated "error" - else - error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 - next error_message - end - end - - if !user.password - # TODO: Playlist stub, sync with YouTube for Google accounts - # playlist_ajax(playlist_id, action, env.request.headers) - end - email = user.email - - case action - when "action_edit_playlist" - # TODO: Playlist stub - when "action_add_video" - if playlist.index.size >= 500 - env.response.status_code = 400 - if redirect - error_message = "Playlist cannot have more than 500 videos" - next templated "error" - else - error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json - next error_message - end - end - - video_id = env.params.query["video_id"] - - begin - video = get_video(video_id, PG_DB) - rescue ex - env.response.status_code = 500 - if redirect - error_message = ex.message - next templated "error" - else - error_message = {"error" => ex.message}.to_json - next error_message - end - 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), - }) - - video_array = playlist_video.to_a - args = arg_array(video_array) - - PG_DB.exec("INSERT INTO playlist_videos VALUES (#{args})", args: video_array) - PG_DB.exec("UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) - when "action_remove_video" - index = env.params.query["set_video_id"] - PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) - PG_DB.exec("UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) - when "action_move_video_before" - # TODO: Playlist stub - else - error_message = {"error" => "Unsupported action #{action}"}.to_json - env.response.status_code = 400 - next error_message - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -get "/playlist" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get?("user").try &.as(User) - referer = get_referer(env) - - plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - if !plid - next env.redirect "/" - end - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - if plid.starts_with? "RD" - next env.redirect "/mix?list=#{plid}" - end - - begin - playlist = get_playlist(PG_DB, plid, locale) - rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" - end - - if playlist.privacy == PlaylistPrivacy::Private && playlist.author != user.try &.email - error_message = "This playlist is private." - env.response.status_code = 403 - next templated "error" - end - - begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) - rescue ex - videos = [] of PlaylistVideo - end - - if playlist.author == user.try &.email - env.set "remove_playlist_items", plid - end - - templated "playlist" -end - -get "/mix" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - rdid = env.params.query["list"]? - if !rdid - next env.redirect "/" - end - - continuation = env.params.query["continuation"]? - continuation ||= rdid.lchop("RD") - - begin - mix = fetch_mix(rdid, continuation, locale: locale) - rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" - end - - templated "mix" -end - -# Search - -get "/opensearch.xml" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/opensearchdescription+xml" - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("OpenSearchDescription", xmlns: "http://a9.com/-/spec/opensearch/1.1/") do - xml.element("ShortName") { xml.text "Invidious" } - xml.element("LongName") { xml.text "Invidious Search" } - xml.element("Description") { xml.text "Search for videos, channels, and playlists on Invidious" } - xml.element("InputEncoding") { xml.text "UTF-8" } - xml.element("Image", width: 48, height: 48, type: "image/x-icon") { xml.text "#{HOST_URL}/favicon.ico" } - xml.element("Url", type: "text/html", method: "get", template: "#{HOST_URL}/search?q={searchTerms}") - end - end -end - -get "/results" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - query = env.params.query["search_query"]? - query ||= env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - if query - env.redirect "/search?q=#{URI.encode_www_form(query)}&page=#{page}" - else - env.redirect "/" - end -end - -get "/search" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - query = env.params.query["search_query"]? - query ||= env.params.query["q"]? - query ||= "" - - if query.empty? - next env.redirect "/" - end - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - user = env.get? "user" - - begin - search_query, count, videos = process_search_query(query, page, user, region: nil) - rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" - end - - env.set "search", query - templated "search" -end +Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Playlists, :index +Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new +Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create +Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe +Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page +Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete +Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit +Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update +Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page +Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax +Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show +Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix +Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch +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::UserPreferences, :show +Invidious::Routing.post "/preferences", Invidious::Routes::UserPreferences, :update +Invidious::Routing.get "/toggle_theme", Invidious::Routes::UserPreferences, :toggle_theme # Users -get "/login" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - if user - next env.redirect "/feed/subscriptions" - end - - if !config.login_enabled - error_message = "Login has been disabled by administrator." - env.response.status_code = 400 - next templated "error" - end - - referer = get_referer(env, "/feed/subscriptions") - - email = nil - password = nil - captcha = nil - - account_type = env.params.query["type"]? - account_type ||= "invidious" - - captcha_type = env.params.query["captcha"]? - captcha_type ||= "image" - - tfa = env.params.query["tfa"]? - prompt = nil - - templated "login" -end - -post "/login" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - referer = get_referer(env, "/feed/subscriptions") - - if !config.login_enabled - error_message = "Login has been disabled by administrator." - env.response.status_code = 403 - next templated "error" - end - - # https://stackoverflow.com/a/574698 - email = env.params.body["email"]?.try &.downcase.byte_slice(0, 254) - password = env.params.body["password"]? - - account_type = env.params.query["type"]? - account_type ||= "invidious" - - case account_type - when "google" - tfa_code = env.params.body["tfa"]?.try &.lchop("G-") - traceback = IO::Memory.new - - # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 - begin - client = QUIC::Client.new(LOGIN_URL) - headers = HTTP::Headers.new - - login_page = client.get("/ServiceLogin") - headers = login_page.cookies.add_request_headers(headers) - - lookup_req = { - email, nil, [] of String, nil, "US", nil, nil, 2, false, true, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - email, - }.to_json - - traceback << "Getting lookup..." - - headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" - headers["Google-Accounts-XSRF"] = "1" - - response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) - lookup_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.<br/>" - - user_hash = lookup_results[0][2] - - if token = env.params.body["token"]? - answer = env.params.body["answer"]? - captcha = {token, answer} - else - captcha = nil - end - - challenge_req = { - user_hash, nil, 1, nil, - {1, nil, nil, nil, - {password, captcha, true}, - }, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - }.to_json - - traceback << "Getting challenge..." - - response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.<br/>" - - headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) - - if challenge_results[0][3]?.try &.== 7 - error_message = translate(locale, "Account has temporarily been disabled") - env.response.status_code = 423 - next templated "error" - end - - if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s - account_type = "google" - captcha_type = "image" - prompt = nil - tfa = tfa_code - captcha = {tokens: [token], question: ""} - - next templated "login" - end - - if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" - error_message = translate(locale, "Incorrect password") - env.response.status_code = 401 - next templated "error" - end - - prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? - if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type - traceback << "Handling prompt #{prompt_type}.<br/>" - case prompt_type - when "TWO_STEP_VERIFICATION" - prompt_type = 2 - else # "LOGIN_CHALLENGE" - prompt_type = 4 - end - - # Prefer Authenticator app and SMS over unsupported protocols - if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 - tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] - - traceback << "Selecting challenge #{tfa[8]}..." - select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json - - tl = challenge_results[1][2] - - tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body - tfa = tfa[5..-1] - tfa = JSON.parse(tfa)[0][-1] - - traceback << "done.<br/>" - else - traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.<br/>" - tfa = challenge_results[0][-1][0][0] - end - - if tfa[5] == "QUOTA_EXCEEDED" - error_message = translate(locale, "Quota exceeded, try again in a few hours") - env.response.status_code = 423 - next templated "error" - end - - if !tfa_code - account_type = "google" - captcha_type = "image" - - case tfa[8] - when 6, 9 - prompt = "Google verification code" - when 12 - prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - when 15 - prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - else - prompt = "Google verification code" - end - - tfa = nil - captcha = nil - next templated "login" - end - - tl = challenge_results[1][2] - - request_type = tfa[8] - case request_type - when 6 # Authenticator app - tfa_req = { - user_hash, nil, 2, nil, - {6, nil, nil, nil, nil, - {tfa_code, false}, - }, - }.to_json - when 9 # Voice or text message - tfa_req = { - user_hash, nil, 2, nil, - {9, nil, nil, nil, nil, nil, nil, nil, - {nil, tfa_code, false, 2}, - }, - }.to_json - when 12 # Recovery email - tfa_req = { - user_hash, nil, 4, nil, - {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - when 15 # Security question - tfa_req = { - user_hash, nil, 5, nil, - {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - else - error_message = translate(locale, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") - env.response.status_code = 500 - next templated "error" - end - - traceback << "Submitting challenge..." - - response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || - (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") - error_message = translate(locale, "Invalid TFA code") - env.response.status_code = 401 - next templated "error" - end - - traceback << "done.<br/>" - end - - traceback << "Logging in..." - - location = URI.parse(challenge_results[0][-1][2].to_s) - cookies = HTTP::Cookies.from_headers(headers) - - headers.delete("Content-Type") - headers.delete("Google-Accounts-XSRF") - - loop do - if !location || location.path == "/ManageAccount" - break - end - - # Occasionally there will be a second page after login confirming - # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. - - if location.path.starts_with? "/b/0/SmsAuthInterstitial" - traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." - end - - login = client.get(location.full_path, headers) - - headers = login.cookies.add_request_headers(headers) - location = login.headers["Location"]?.try { |u| URI.parse(u) } - end - - cookies = HTTP::Cookies.from_headers(headers) - sid = cookies["SID"]?.try &.value - if !sid - raise "Couldn't get SID." - end - - user, sid = get_user(sid, headers, PG_DB) - - # We are now logged in - traceback << "done.<br/>" - - 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 - - if cookie.extension - cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) - cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") - end - env.response.cookies << cookie - end - - if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer - rescue ex - traceback.rewind - # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") - error_message = %(#{ex.message}<br/>Traceback:<br/><div style="padding-left:2em" id="traceback">#{traceback.gets_to_end}</div>) - env.response.status_code = 500 - next templated "error" - end - when "invidious" - if !email - error_message = translate(locale, "User ID is a required field") - env.response.status_code = 401 - next templated "error" - end - - if !password - error_message = translate(locale, "Password is a required field") - env.response.status_code = 401 - next templated "error" - end - - user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) - - if user - if !user.password - error_message = translate(locale, "Please sign in using 'Log in with Google'") - env.response.status_code = 400 - next templated "error" - end - - if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) - sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) - - 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 - else - error_message = translate(locale, "Wrong username or password") - env.response.status_code = 401 - next templated "error" - end - - # Since this user has already registered, we don't want to overwrite their preferences - if env.request.cookies["PREFS"]? - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - else - if !config.registration_enabled - error_message = "Registration has been disabled by administrator." - env.response.status_code = 400 - next templated "error" - end - - if password.empty? - error_message = translate(locale, "Password cannot be empty") - env.response.status_code = 401 - next templated "error" - end - - # See https://security.stackexchange.com/a/39851 - if password.bytesize > 55 - error_message = translate(locale, "Password should not be longer than 55 characters") - env.response.status_code = 400 - next templated "error" - end - - password = password.byte_slice(0, 55) - - if config.captcha_enabled - captcha_type = env.params.body["captcha_type"]? - answer = env.params.body["answer"]? - change_type = env.params.body["change_type"]? - - if !captcha_type || change_type - if change_type - captcha_type = change_type - end - captcha_type ||= "image" - - account_type = "invidious" - tfa = false - prompt = "" - - if captcha_type == "image" - captcha = generate_captcha(HMAC_KEY, PG_DB) - else - captcha = generate_text_captcha(HMAC_KEY, PG_DB) - end - - next templated "login" - end - - tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v } - - answer ||= "" - captcha_type ||= "image" - - case captcha_type - when "image" - answer = answer.lstrip('0') - answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) - - begin - validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale) - rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" - end - else # "text" - answer = Digest::MD5.hexdigest(answer.downcase.strip) - - found_valid_captcha = false - - error_message = translate(locale, "Erroneous CAPTCHA") - tokens.each_with_index do |token, i| - begin - validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) - found_valid_captcha = true - rescue ex - error_message = ex.message - end - end - - if !found_valid_captcha - env.response.status_code = 500 - next templated "error" - end - end - end - - sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user, sid = create_user(sid, email, password) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - - args = arg_array(user_array) - - PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) - - 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 - - if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - end - - env.redirect referer - else - env.redirect referer - end -end - -post "/signout" do |env| - locale = LOCALES[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, PG_DB, locale) - rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" - end - - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid) - - env.request.cookies.each do |cookie| - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer -end - -get "/preferences" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - referer = get_referer(env) - - preferences = env.get("preferences").as(Preferences) - - templated "preferences" -end - -post "/preferences" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - referer = get_referer(env) - - video_loop = env.params.body["video_loop"]?.try &.as(String) - video_loop ||= "off" - video_loop = video_loop == "on" - - annotations = env.params.body["annotations"]?.try &.as(String) - annotations ||= "off" - annotations = annotations == "on" - - annotations_subscribed = env.params.body["annotations_subscribed"]?.try &.as(String) - annotations_subscribed ||= "off" - annotations_subscribed = annotations_subscribed == "on" - - autoplay = env.params.body["autoplay"]?.try &.as(String) - autoplay ||= "off" - autoplay = autoplay == "on" - - continue = env.params.body["continue"]?.try &.as(String) - continue ||= "off" - continue = continue == "on" - - continue_autoplay = env.params.body["continue_autoplay"]?.try &.as(String) - continue_autoplay ||= "off" - continue_autoplay = continue_autoplay == "on" - - listen = env.params.body["listen"]?.try &.as(String) - listen ||= "off" - listen = listen == "on" - - local = env.params.body["local"]?.try &.as(String) - local ||= "off" - local = local == "on" - - speed = env.params.body["speed"]?.try &.as(String).to_f32? - speed ||= CONFIG.default_user_preferences.speed - - player_style = env.params.body["player_style"]?.try &.as(String) - player_style ||= CONFIG.default_user_preferences.player_style - - quality = env.params.body["quality"]?.try &.as(String) - quality ||= CONFIG.default_user_preferences.quality - - volume = env.params.body["volume"]?.try &.as(String).to_i? - volume ||= CONFIG.default_user_preferences.volume - - comments = [] of String - 2.times do |i| - comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) - end - - captions = [] of String - 3.times do |i| - captions << (env.params.body["captions[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.captions[i]) - end - - related_videos = env.params.body["related_videos"]?.try &.as(String) - related_videos ||= "off" - related_videos = related_videos == "on" - - default_home = env.params.body["default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home - - feed_menu = [] of String - 5.times do |index| - option = env.params.body["feed_menu[#{index}]"]?.try &.as(String) || "" - if !option.empty? - feed_menu << option - end - end - - locale = env.params.body["locale"]?.try &.as(String) - locale ||= CONFIG.default_user_preferences.locale - - dark_mode = env.params.body["dark_mode"]?.try &.as(String) - dark_mode ||= CONFIG.default_user_preferences.dark_mode - - thin_mode = env.params.body["thin_mode"]?.try &.as(String) - thin_mode ||= "off" - thin_mode = thin_mode == "on" - - max_results = env.params.body["max_results"]?.try &.as(String).to_i? - max_results ||= CONFIG.default_user_preferences.max_results - - sort = env.params.body["sort"]?.try &.as(String) - sort ||= CONFIG.default_user_preferences.sort - - latest_only = env.params.body["latest_only"]?.try &.as(String) - latest_only ||= "off" - latest_only = latest_only == "on" - - unseen_only = env.params.body["unseen_only"]?.try &.as(String) - unseen_only ||= "off" - unseen_only = unseen_only == "on" - - notifications_only = env.params.body["notifications_only"]?.try &.as(String) - notifications_only ||= "off" - notifications_only = notifications_only == "on" - - # Convert to JSON and back again to take advantage of converters used for compatability - preferences = Preferences.from_json({ - annotations: annotations, - annotations_subscribed: annotations_subscribed, - autoplay: autoplay, - captions: captions, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - dark_mode: dark_mode, - latest_only: latest_only, - listen: listen, - local: local, - locale: locale, - max_results: max_results, - notifications_only: notifications_only, - player_style: player_style, - quality: quality, - default_home: default_home, - feed_menu: feed_menu, - related_videos: related_videos, - sort: sort, - speed: speed, - thin_mode: thin_mode, - unseen_only: unseen_only, - video_loop: video_loop, - volume: volume, - }.to_json).to_json - - if user = env.get? "user" - user = user.as(User) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) - - if config.admins.includes? user.email - config.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || config.default_user_preferences.default_home - - admin_feed_menu = [] of String - 5.times do |index| - option = env.params.body["admin_feed_menu[#{index}]"]?.try &.as(String) || "" - if !option.empty? - admin_feed_menu << option - end - end - config.default_user_preferences.feed_menu = admin_feed_menu - - captcha_enabled = env.params.body["captcha_enabled"]?.try &.as(String) - captcha_enabled ||= "off" - config.captcha_enabled = captcha_enabled == "on" - - login_enabled = env.params.body["login_enabled"]?.try &.as(String) - login_enabled ||= "off" - config.login_enabled = login_enabled == "on" - - registration_enabled = env.params.body["registration_enabled"]?.try &.as(String) - registration_enabled ||= "off" - config.registration_enabled = registration_enabled == "on" - - statistics_enabled = env.params.body["statistics_enabled"]?.try &.as(String) - statistics_enabled ||= "off" - config.statistics_enabled = statistics_enabled == "on" - - CONFIG.default_user_preferences = config.default_user_preferences - 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: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end - end - - env.redirect referer -end - -get "/toggle_theme" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - referer = get_referer(env, unroll: false) - - redirect = env.params.query["redirect"]? - redirect ||= "true" - redirect = redirect == "true" - - if user = env.get? "user" - user = user.as(User) - preferences = user.preferences - - case preferences.dark_mode - when "dark" - preferences.dark_mode = "light" - else - preferences.dark_mode = "dark" - end - - preferences = preferences.to_json - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) - else - preferences = env.get("preferences").as(Preferences) - - case preferences.dark_mode - when "dark" - preferences.dark_mode = "light" - else - 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: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: preferences, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - post "/watch_ajax" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -1702,9 +348,7 @@ post "/watch_ajax" do |env| if redirect next env.redirect referer else - error_message = {"error" => "No such user"}.to_json - env.response.status_code = 403 - next error_message + next error_json(403, "No such user") end end @@ -1721,13 +365,10 @@ post "/watch_ajax" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex - env.response.status_code = 400 if redirect - error_message = ex.message - next templated "error" + next error_template(400, ex) else - error_message = {"error" => ex.message}.to_json - next error_message + next error_json(400, ex) end end @@ -1747,9 +388,7 @@ post "/watch_ajax" do |env| when "action_mark_unwatched" PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email) else - error_message = {"error" => "Unsupported action #{action}"}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Unsupported action #{action}") end if redirect @@ -1779,9 +418,7 @@ get "/modify_notifications" do |env| if redirect next env.redirect referer else - error_message = {"error" => "No such user"}.to_json - env.response.status_code = 403 - next error_message + next error_json(403, "No such user") end end @@ -1854,9 +491,7 @@ post "/subscription_ajax" do |env| if redirect next env.redirect referer else - error_message = {"error" => "No such user"}.to_json - env.response.status_code = 403 - next error_message + next error_json(403, "No such user") end end @@ -1868,13 +503,9 @@ post "/subscription_ajax" do |env| validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex if redirect - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) else - error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, ex) end end @@ -1898,15 +529,13 @@ post "/subscription_ajax" do |env| case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, false, false) + get_channel(channel_id, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) end when "action_remove_subscriptions" PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email) else - error_message = {"error" => "Unsupported action #{action}"}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Unsupported action #{action}") end if redirect @@ -1935,7 +564,7 @@ get "/subscription_manager" do |env| headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers, PG_DB, logger) end action_takeout = env.params.query["action_takeout"]?.try &.to_i? @@ -2059,7 +688,7 @@ post "/data_control" do |env| user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) end @@ -2089,7 +718,7 @@ post "/data_control" do |env| PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise "Playlist cannot have more than 500 videos" if idx > 500 + raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 video_id = video_id.try &.as_s? next if !video_id @@ -2121,13 +750,14 @@ post "/data_control" do |env| end end when "import_youtube" - 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] + subscriptions = JSON.parse(body) + + user.subscriptions += subscriptions.as_a.compact_map do |entry| + entry["snippet"]["resourceId"]["channelId"].as_s end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_freetube" @@ -2136,7 +766,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe_subscriptions" @@ -2155,7 +785,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) when "import_newpipe" @@ -2174,7 +804,7 @@ post "/data_control" do |env| user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") } user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) @@ -2226,51 +856,37 @@ post "/change_password" do |env| # We don't store passwords for Google accounts if !user.password - error_message = "Cannot change password for Google accounts" - env.response.status_code = 400 - next templated "error" + next error_template(400, "Cannot change password for Google accounts") end begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) end password = env.params.body["password"]? if !password - error_message = translate(locale, "Password is a required field") - env.response.status_code = 401 - next templated "error" + 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 - error_message = translate(locale, "New passwords must match") - env.response.status_code = 400 - next templated "error" + next error_template(400, "New passwords must match") end new_password = new_passwords.uniq[0] if new_password.empty? - error_message = translate(locale, "Password cannot be empty") - env.response.status_code = 401 - next templated "error" + next error_template(401, "Password cannot be empty") end if new_password.bytesize > 55 - error_message = translate(locale, "Password should not be longer than 55 characters") - env.response.status_code = 400 - next templated "error" + 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)) - error_message = translate(locale, "Incorrect password") - env.response.status_code = 401 - next templated "error" + next error_template(401, "Incorrect password") end new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) @@ -2315,9 +931,7 @@ post "/delete_account" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) end view_name = "subscriptions_#{sha256(user.email)}" @@ -2369,9 +983,7 @@ post "/clear_watch_history" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) end PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email) @@ -2424,9 +1036,7 @@ post "/authorize_token" do |env| begin validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) end scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } @@ -2489,9 +1099,7 @@ post "/token_ajax" do |env| if redirect next env.redirect referer else - error_message = {"error" => "No such user"}.to_json - env.response.status_code = 403 - next error_message + next error_json(403, "No such user") end end @@ -2503,13 +1111,9 @@ post "/token_ajax" do |env| validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) rescue ex if redirect - error_message = ex.message - env.response.status_code = 400 - next templated "error" + next error_template(400, ex) else - error_message = {"error" => ex.message}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, ex) end end @@ -2526,9 +1130,7 @@ post "/token_ajax" do |env| when .starts_with? "action_revoke_token" PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) else - error_message = {"error" => "Unsupported action #{action}"}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Unsupported action #{action}") end if redirect @@ -2541,15 +1143,26 @@ end # Feeds +get "/feed/playlists" do |env| + env.redirect "/view_all_playlists" +end + get "/feed/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.redirect "/" + + message = translate(locale, "The Top feed has been removed from Invidious.") + templated "message" end get "/feed/popular" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - templated "popular" + if config.popular_enabled + templated "popular" + else + message = translate(locale, "The Popular feed has been disabled by the administrator.") + templated "message" + end end get "/feed/trending" do |env| @@ -2564,9 +1177,7 @@ get "/feed/trending" do |env| begin trending, plid = fetch_trending(trending_type, region, locale) rescue ex - error_message = "#{ex.message}" - env.response.status_code = 500 - next templated "error" + next error_template(500, ex) end templated "trending" @@ -2596,7 +1207,7 @@ get "/feed/subscriptions" do |env| headers["Cookie"] = env.request.headers["Cookie"] if !user.password - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers, PG_DB, logger) end max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) @@ -2661,9 +1272,7 @@ get "/feed/channel/:ucid" do |env| rescue ex : ChannelRedirect next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex - error_message = ex.message - env.response.status_code = 500 - next error_message + next error_atom(500, ex) end response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") @@ -2704,6 +1313,7 @@ get "/feed/channel/:ucid" do |env| xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") xml.element("id") { xml.text "yt:channel:#{channel.ucid}" } xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } xml.element("title") { xml.text channel.author } xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") @@ -2901,7 +1511,7 @@ post "/feed/webhook/:token" do |env| signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) - logger.puts("#{token} : Invalid signature") + logger.error("/feed/webhook/#{token} : Invalid signature") env.response.status_code = 200 next end @@ -3074,9 +1684,7 @@ get "/channel/:ucid" do |env| rescue ex : ChannelRedirect next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" + next error_template(500, ex) end if channel.auto_generated @@ -3143,9 +1751,7 @@ get "/channel/:ucid/playlists" do |env| rescue ex : ChannelRedirect next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" + next error_template(500, ex) end if channel.auto_generated @@ -3183,9 +1789,7 @@ get "/channel/:ucid/community" do |env| rescue ex : ChannelRedirect next env.redirect env.request.resource.gsub(ucid, ex.channel_id) rescue ex - error_message = ex.message - env.response.status_code = 500 - next templated "error" + next error_template(500, ex) end if !channel.tabs.includes? "community" @@ -3194,9 +1798,11 @@ get "/channel/:ucid/community" do |env| begin items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) - rescue ex + rescue ex : InfoException env.response.status_code = 500 error_message = ex.message + rescue ex + next error_template(500, ex) end env.set "search", "channel:#{channel.ucid} " @@ -3206,12 +1812,11 @@ end # API Endpoints get "/api/v1/stats" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" if !config.statistics_enabled - error_message = {"error" => "Statistics are not enabled."}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Statistics are not enabled.") end Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json @@ -3231,10 +1836,8 @@ get "/api/v1/storyboards/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_message + next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex env.response.status_code = 500 next @@ -3280,14 +1883,14 @@ get "/api/v1/storyboards/:id" do |env| storyboard[:storyboard_count].times do |i| url = storyboard[:url] authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? - url = storyboard[:url].gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") url = "#{HOST_URL}/sb/#{authority}/#{url}" storyboard[:storyboard_height].times do |j| storyboard[:storyboard_width].times do |k| str << <<-END_CUE #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width]},#{storyboard[:height]} + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} END_CUE @@ -3319,10 +1922,8 @@ get "/api/v1/captions/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_message + next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex env.response.status_code = 500 next @@ -3454,9 +2055,7 @@ get "/api/v1/comments/:id" do |env| begin comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end next comments @@ -3499,13 +2098,7 @@ end get "/api/v1/insights/:id" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - id = env.params.url["id"] - env.response.content_type = "application/json" - - error_message = {"error" => "YouTube has removed publicly available analytics."}.to_json - env.response.status_code = 410 - error_message + next error_json(410, "YouTube has removed publicly available analytics.") end get "/api/v1/annotations/:id" do |env| @@ -3540,14 +2133,13 @@ get "/api/v1/annotations/:id" do |env| file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - client = make_client(ARCHIVE_URL) - location = client.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}") + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) if !location.headers["Location"]? env.response.status_code = location.status_code end - response = make_client(URI.parse(location.headers["Location"])).get(location.headers["Location"]) + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) if response.body.empty? env.response.status_code = 404 @@ -3594,14 +2186,10 @@ get "/api/v1/videos/:id" do |env| begin video = get_video(id, PG_DB, region: region) rescue ex : VideoRedirect - error_message = {"error" => "Video is unavailable", "videoId" => ex.video_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_message + next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end video.to_json(locale) @@ -3618,9 +2206,7 @@ get "/api/v1/trending" do |env| begin trending, plid = fetch_trending(trending_type, region, locale) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end videos = JSON.build do |json| @@ -3639,6 +2225,12 @@ get "/api/v1/popular" do |env| env.response.content_type = "application/json" + if !config.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + env.response.status_code = 400 + next error_message + end + JSON.build do |json| json.array do popular_videos.each do |video| @@ -3652,7 +2244,8 @@ get "/api/v1/top" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" - "[]" + env.response.status_code = 400 + {"error" => "The Top feed has been removed from Invidious."}.to_json end get "/api/v1/channels/:ucid" do |env| @@ -3667,14 +2260,10 @@ get "/api/v1/channels/:ucid" do |env| begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect - error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_message + next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end page = 1 @@ -3685,9 +2274,7 @@ get "/api/v1/channels/:ucid" do |env| begin count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end end @@ -3802,22 +2389,16 @@ end begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect - error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_message + next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end begin count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end JSON.build do |json| @@ -3841,9 +2422,7 @@ end begin videos = get_latest_videos(ucid) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end JSON.build do |json| @@ -3871,14 +2450,10 @@ end begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect - error_message = {"error" => "Channel is unavailable", "authorId" => ex.channel_id}.to_json - env.response.status_code = 302 env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_message + next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end items, continuation = fetch_channel_playlists(channel.ucid, channel.author, channel.auto_generated, continuation, sort_by) @@ -3919,9 +2494,7 @@ end begin fetch_channel_community(ucid, continuation, locale, format, thin_mode) rescue ex - env.response.status_code = 400 - error_message = {"error" => ex.message}.to_json - next error_message + next error_json(500, ex) end end end @@ -3979,9 +2552,7 @@ get "/api/v1/search" do |env| begin search_params = produce_search_params(sort_by, date, content_type, duration, features) rescue ex - env.response.status_code = 400 - error_message = {"error" => ex.message}.to_json - next error_message + next error_json(400, ex) end count, search_results = search(query, page, search_params, region).as(Tuple) @@ -4024,9 +2595,7 @@ get "/api/v1/search/suggestions" do |env| end end rescue ex - env.response.status_code = 500 - error_message = {"error" => ex.message}.to_json - next error_message + next error_json(500, ex) end end @@ -4053,16 +2622,12 @@ end begin playlist = get_playlist(PG_DB, plid, locale) rescue ex - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end user = env.get?("user").try &.as(User) if !playlist || playlist.privacy.private? && playlist.author != user.try &.email - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end response = playlist.to_json(offset, locale, continuation: continuation) @@ -4106,9 +2671,7 @@ get "/api/v1/mixes/:rdid" do |env| mix.videos = mix.videos[index..-1] rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end response = JSON.build do |json| @@ -4264,7 +2827,7 @@ post "/api/v1/auth/subscriptions/:ucid" do |env| ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) + get_channel(ucid, PG_DB, logger, false, false) PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) end @@ -4310,22 +2873,16 @@ post "/api/v1/auth/playlists" do |env| title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) if !title - error_message = {"error" => "Invalid title."}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Invalid title.") end privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } if !privacy - error_message = {"error" => "Invalid privacy setting."}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Invalid privacy setting.") end if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 - error_message = {"error" => "User cannot have more than 100 playlists."}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "User cannot have more than 100 playlists.") end playlist = create_playlist(PG_DB, title, privacy, user) @@ -4347,15 +2904,11 @@ patch "/api/v1/auth/playlists/:plid" do |env| playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) if !playlist || playlist.author != user.email && playlist.privacy.private? - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end if playlist.author != user.email - env.response.status_code = 403 - error_message = {"error" => "Invalid user"}.to_json - next error_message + next error_json(403, "Invalid user") end title = env.params.json["title"].try &.as(String).delete("<>").byte_slice(0, 150) || playlist.title @@ -4375,6 +2928,8 @@ patch "/api/v1/auth/playlists/:plid" do |env| end delete "/api/v1/auth/playlists/:plid" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" user = env.get("user").as(User) @@ -4382,15 +2937,11 @@ delete "/api/v1/auth/playlists/:plid" do |env| playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) if !playlist || playlist.author != user.email && playlist.privacy.private? - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end if playlist.author != user.email - env.response.status_code = 403 - error_message = {"error" => "Invalid user"}.to_json - next error_message + next error_json(403, "Invalid user") end PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) @@ -4409,36 +2960,26 @@ post "/api/v1/auth/playlists/:plid/videos" do |env| playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) if !playlist || playlist.author != user.email && playlist.privacy.private? - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end if playlist.author != user.email - env.response.status_code = 403 - error_message = {"error" => "Invalid user"}.to_json - next error_message + next error_json(403, "Invalid user") end if playlist.index.size >= 500 - env.response.status_code = 400 - error_message = {"error" => "Playlist cannot have more than 500 videos"}.to_json - next error_message + next error_json(400, "Playlist cannot have more than 500 videos") end video_id = env.params.json["videoId"].try &.as(String) if !video_id - env.response.status_code = 403 - error_message = {"error" => "Invalid videoId"}.to_json - next error_message + next error_json(403, "Invalid videoId") end begin video = get_video(video_id, PG_DB) rescue ex - error_message = {"error" => ex.message}.to_json - env.response.status_code = 500 - next error_message + next error_json(500, ex) end playlist_video = PlaylistVideo.new({ @@ -4465,6 +3006,8 @@ post "/api/v1/auth/playlists/:plid/videos" do |env| end delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" user = env.get("user").as(User) @@ -4473,21 +3016,15 @@ delete "/api/v1/auth/playlists/:plid/videos/:index" do |env| playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) if !playlist || playlist.author != user.email && playlist.privacy.private? - env.response.status_code = 404 - error_message = {"error" => "Playlist does not exist."}.to_json - next error_message + next error_json(404, "Playlist does not exist.") end if playlist.author != user.email - env.response.status_code = 403 - error_message = {"error" => "Invalid user"}.to_json - next error_message + next error_json(403, "Invalid user") end if !playlist.index.includes? index - env.response.status_code = 404 - error_message = {"error" => "Playlist does not contain index"}.to_json - next error_message + next error_json(404, "Playlist does not contain index") end PG_DB.exec("DELETE FROM playlist_videos * WHERE index = $1", index) @@ -4533,9 +3070,7 @@ post "/api/v1/auth/tokens/register" do |env| callback_url = env.params.json["callbackUrl"]?.try &.as(String) expire = env.params.json["expire"]?.try &.as(Int64) else - error_message = {"error" => "Invalid or missing header 'Content-Type'"}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Invalid or missing header 'Content-Type'") end if callback_url && callback_url.empty? @@ -4585,6 +3120,7 @@ post "/api/v1/auth/tokens/register" do |env| end post "/api/v1/auth/tokens/unregister" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" user = env.get("user").as(User) scopes = env.get("scopes").as(Array(String)) @@ -4598,9 +3134,7 @@ post "/api/v1/auth/tokens/unregister" do |env| elsif scopes_include_scope(scopes, "GET:tokens") PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) else - error_message = {"error" => "Cannot revoke session #{session}"}.to_json - env.response.status_code = 400 - next error_message + next error_json(400, "Cannot revoke session #{session}") end env.response.status_code = 204 @@ -4924,6 +3458,7 @@ get "/videoplayback/*" do |env| end get "/videoplayback" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? query_params = env.params.query fvip = query_params["fvip"]? || "3" @@ -4962,8 +3497,12 @@ get "/videoplayback" do |env| location = URI.parse(response.headers["Location"]) env.response.headers["Access-Control-Allow-Origin"] = "*" - host = "#{location.scheme}://#{location.host}" - client = make_client(URI.parse(host), region) + new_host = "#{location.scheme}://#{location.host}" + if new_host != host + host = new_host + client.close + client = make_client(URI.parse(new_host), region) + end url = "#{location.full_path}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" else @@ -4990,13 +3529,10 @@ get "/videoplayback" do |env| if url.includes? "&file=seg.ts" if CONFIG.disabled?("livestreams") - env.response.status_code = 403 - error_message = "Administrator has disabled this endpoint." - next templated "error" + next error_template(403, "Administrator has disabled this endpoint.") end begin - client = make_client(URI.parse(host), region) client.get(url, headers) do |response| response.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) @@ -5024,9 +3560,7 @@ get "/videoplayback" do |env| else if query_params["title"]? && CONFIG.disabled?("downloads") || CONFIG.disabled?("dash") - env.response.status_code = 403 - error_message = "Administrator has disabled this endpoint." - next templated "error" + next error_template(403, "Administrator has disabled this endpoint.") end content_length = nil @@ -5039,8 +3573,6 @@ get "/videoplayback" do |env| chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 end - client = make_client(URI.parse(host), region) - # TODO: Record bytes written so we can restart after a chunk fails while true if !range_end && content_length @@ -5104,6 +3636,7 @@ get "/videoplayback" do |env| if ex.message != "Error reading socket: Connection reset by peer" break else + client.close client = make_client(URI.parse(host), region) end end @@ -5113,6 +3646,7 @@ get "/videoplayback" do |env| first_chunk = false end end + client.close end get "/ggpht/*" do |env| @@ -5367,14 +3901,9 @@ error 404 do |env| halt env, status_code: 302 end -error 500 do |env| - error_message = <<-END_HTML - Looks like you've found a bug in Invidious. Feel free to open a new issue - <a href="https://github.com/iv-org/invidious/issues">here</a> - or send an email to - <a href="mailto:#{CONFIG.admin_email}">#{CONFIG.admin_email}</a>. - END_HTML - templated "error" +error 500 do |env, ex| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + error_template(500, ex) end static_headers do |response, filepath, filestat| |
