diff --git a/src/invidious.cr b/src/invidious.cr index 299ef8bc..33f35afa 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -197,16 +197,20 @@ before_all do |env| if env.request.cookies.has_key? "SID" sid = env.request.cookies["SID"].value + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + # Invidious users only have SID if !env.request.cookies.has_key? "SSID" if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) - token = create_response(sid, {"signout", "watch_ajax", "subscription_ajax"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences env.set "sid", sid - env.set "token", token + env.set "csrf_token", csrf_token env.set "user", user end else @@ -215,12 +219,12 @@ before_all do |env| begin user, sid = get_user(sid, headers, PG_DB, false) - token = create_response(sid, {"signout", "watch_ajax", "subscription_ajax"}, HMAC_KEY, PG_DB, 1.week) + csrf_token = generate_response(sid, {":signout", ":watch_ajax", ":subscription_ajax", ":token_ajax", ":authorize_token"}, HMAC_KEY, PG_DB, 1.week) preferences = user.preferences env.set "sid", sid - env.set "token", token + env.set "csrf_token", csrf_token env.set "user", user rescue ex end @@ -1096,9 +1100,10 @@ post "/login" do |env| answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) begin - validate_response(tokens[0], answer, env.request.path, HMAC_KEY, PG_DB, locale) + 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 when "text" @@ -1109,7 +1114,7 @@ post "/login" do |env| error_message = translate(locale, "Invalid CAPTCHA") tokens.each_with_index do |token, i| begin - validate_response(token, answer, env.request.path, HMAC_KEY, PG_DB, locale) + validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) found_valid_captcha = true rescue ex error_message = ex.message @@ -1189,12 +1194,13 @@ post "/signout" do |env| if user user = user.as(User) sid = sid.as(String) - token = env.params.body["token"]? + token = env.params.body["csrf_token"]? begin - validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale) + 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 @@ -1424,12 +1430,18 @@ post "/watch_ajax" do |env| redirect = redirect == "true" if !user - next env.redirect referer + 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["token"]? + token = env.params.body["csrf_token"]? id = env.params.query["id"]? if !id @@ -1437,19 +1449,16 @@ post "/watch_ajax" do |env| next end - user = user.as(User) - sid = sid.as(String) - token = env.params.body["token"]? - begin - validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale) + 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 = 500 + env.response.status_code = 400 next error_message end end @@ -1494,8 +1503,14 @@ get "/modify_notifications" do |env| redirect ||= "false" redirect = redirect == "true" - if !user && !sid - next env.redirect referer + 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) @@ -1566,22 +1581,29 @@ post "/subscription_ajax" do |env| redirect = redirect == "true" if !user - next env.redirect referer + 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["token"]? + token = env.params.body["csrf_token"]? begin - validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale) + 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 = 500 + env.response.status_code = 400 next error_message end end @@ -1660,9 +1682,9 @@ get "/subscription_manager" do |env| user = env.get? "user" sid = env.get? "sid" - referer = get_referer(env, "/subscription_manager") + referer = get_referer(env) - if !user && !sid + if !user next env.redirect referer end @@ -1856,7 +1878,7 @@ get "/delete_account" do |env| if user user = user.as(User) sid = sid.as(String) - token = create_response(sid, {"delete_account"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB) templated "delete_account" else @@ -1874,12 +1896,13 @@ post "/delete_account" do |env| if user user = user.as(User) sid = sid.as(String) - token = env.params.body["token"]? + token = env.params.body["csrf_token"]? begin - validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale) + 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 @@ -1907,7 +1930,7 @@ get "/clear_watch_history" do |env| if user user = user.as(User) sid = sid.as(String) - token = create_response(sid, {"clear_watch_history"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB) templated "clear_watch_history" else @@ -1925,12 +1948,13 @@ post "/clear_watch_history" do |env| if user user = user.as(User) sid = sid.as(String) - token = env.params.body["token"]? + token = env.params.body["csrf_token"]? begin - validate_response(token, sid, env.request.path, HMAC_KEY, PG_DB, locale) + 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 @@ -1940,6 +1964,137 @@ post "/clear_watch_history" do |env| env.redirect referer end +# TODO? +# get "/authorize_token" do |env| +# ... +# end + +post "/authorize_token" do |env| + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if user + 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, PG_DB, locale) + rescue ex + error_message = ex.message + env.response.status_code = 400 + next templated "error" + 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, PG_DB) + + if callback_url + access_token = URI.escape(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 +end + +get "/token_manager" do |env| + locale = LOCALES[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 = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time}) + + templated "token_manager" +end + +post "/token_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 + 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_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" + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end +end + # Feeds get "/feed/top" do |env| @@ -4127,6 +4282,142 @@ get "/api/v1/mixes/:rdid" do |env| response end +# TODO +# get "/api/v1/auth/preferences" do |env| +# ... +# end + +# TODO +# post "/api/v1/auth/preferences" do |env| +# ... +# end + +# TODO +# get "/api/v1/auth/subscriptions" do |env| +# ... +# end + +# TODO +# post "/api/v1/auth/subscriptions/:ucid" do |env| +# ... +# end + +# TODO +# delete "/api/v1/auth/subscriptions/:ucid" do |env| +# ... +# end + +get "/api/v1/auth/tokens" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) + + # Only allow user sessions to view other user sessions + # if !scopes.includes? [":*"] + # tokens.select { |token| token[:session].starts_with? "v1:" } + # end + + JSON.build do |json| + json.array do + tokens.each do |token| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + end + end +end + +post "/api/v1/auth/tokens/register" do |env| + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + case env.request.headers["Content-Type"]? + when "application/x-www-form-urlencoded" + 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? + when "application/json" + scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } + 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 + end + + if callback_url && callback_url.empty? + callback_url = nil + end + + if callback_url + callback_url = URI.parse(callback_url) + end + + if sid = env.get?("sid").try &.as(String) + env.response.content_type = "text/html" + + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) + next templated "authorize_token" + else + env.response.content_type = "application/json" + + superset_scopes = env.get("scopes").as(Array(String)) + + authorized_scopes = [] of String + scopes.each do |scope| + if scopes_include_scope(superset_scopes, scope) + authorized_scopes << scope + end + end + + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) + + if callback_url + access_token = URI.escape(access_token) + + if query = callback_url.query + query = HTTP::Params.parse(query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + callback_url.query = query.to_s + + env.redirect callback_url.to_s + else + access_token + end + end +end + +post "/api/v1/auth/tokens/unregister" do |env| + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + session = env.params.json["session"]?.try &.as(String) + session ||= env.get("session").as(String) + + # Allow tokens to revoke other tokens with correct scope + if session == env.get("session").as(String) + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + 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 + end + + env.response.status_code = 204 +end + get "/api/manifest/dash/id/videoplayback" do |env| env.response.headers.delete("Content-Type") env.response.headers["Access-Control-Allow-Origin"] = "*" @@ -4708,8 +4999,8 @@ error 404 do |env| end # Check if item is video ID - client = make_client(URI.parse("https://youtu.be")) - if client.head("/#{item}").status_code != 404 + client = make_client(YT_URL) + if item.match(/^[a-zA-Z0-9_-]{11}$/) && client.head("/watch?v=#{item}").status_code != 404 env.response.headers["Location"] = url halt env, status_code: 302 end @@ -4760,9 +5051,11 @@ public_folder "assets" Kemal.config.powered_by_header = false add_handler FilteredCompressHandler.new add_handler APIHandler.new +add_handler AuthHandler.new add_handler DenyFrame.new -add_context_storage_type(User) +add_context_storage_type(Array(String)) add_context_storage_type(Preferences) +add_context_storage_type(User) Kemal.config.logger = logger Kemal.run diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index b613488a..0c1b7bd2 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -20,7 +20,9 @@ module HTTP::Handler end class Kemal::RouteHandler - exclude ["/api/v1/*"] + {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %} + exclude ["/api/v1/*"], {{method}} + {% end %} # Processes the route if it's a match. Otherwise renders 404. private def process_request(context) @@ -37,7 +39,9 @@ class Kemal::RouteHandler end class Kemal::ExceptionHandler - exclude ["/api/v1/*"] + {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %} + exclude ["/api/v1/*"], {{method}} + {% end %} private def call_exception_with_status_code(context : HTTP::Server::Context, exception : Exception, status_code : Int32) return if context.response.closed? @@ -76,8 +80,59 @@ class FilteredCompressHandler < Kemal::Handler end end +class AuthHandler < Kemal::Handler + {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %} + only ["/api/v1/auth/*"], {{method}} + {% end %} + + def call(env) + return call_next env unless only_match? env + + begin + if token = env.request.headers["Authorization"]? + token = JSON.parse(URI.unescape(token.lchop("Bearer "))) + session = URI.unescape(token["session"].as_s) + scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) + + if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String) + user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + end + elsif sid = env.request.cookies["SID"]?.try &.value + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + + if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) + user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + end + + scopes = [":*"] + session = sid + end + + if !user + raise "Request must be authenticated" + end + + env.set "scopes", scopes + env.set "user", user + env.set "session", session + + call_next env + rescue ex + env.response.content_type = "application/json" + + error_message = {"error" => ex.message}.to_json + env.response.status_code = 403 + env.response.puts error_message + end + end +end + class APIHandler < Kemal::Handler - only ["/api/v1/*"] + {% for method in %w(GET POST PUT HEAD DELETE PATCH OPTIONS) %} + only ["/api/v1/*"], {{method}} + {% end %} def call(env) return call_next env unless only_match? env diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr new file mode 100644 index 00000000..6841127a --- /dev/null +++ b/src/invidious/helpers/tokens.cr @@ -0,0 +1,146 @@ +def generate_token(email, scopes, expire, key, db) + session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" + PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.now) + + token = { + "session" => session, + "scopes" => scopes, + "expire" => expire, + } + + if !expire + token.delete("expire") + end + + token["signature"] = sign_token(key, token) + + return token.to_json +end + +def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) + expire = Time.now + expire + + token = { + "session" => session, + "expire" => expire.to_unix, + "scopes" => scopes, + } + + if use_nonce + nonce = Random::Secure.hex(16) + db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) + token["nonce"] = nonce + end + + token["signature"] = sign_token(key, token) + + return token.to_json +end + +def sign_token(key, hash) + string_to_sign = [] of String + + hash.each do |key, value| + if key == "signature" + next + end + + if value.is_a?(JSON::Any) + case value + when .as_a? + value = value.as_a.map { |item| item.as_s } + end + end + + case value + when Array + string_to_sign << "#{key}=#{value.sort.join(",")}" + when Tuple + string_to_sign << "#{key}=#{value.to_a.sort.join(",")}" + else + string_to_sign << "#{key}=#{value}" + end + end + + string_to_sign = string_to_sign.sort.join("\n") + return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip +end + +def validate_request(token, session, request, key, db, locale = nil) + case token + when String + token = JSON.parse(URI.unescape(token)).as_h + when JSON::Any + token = token.as_h + when Nil + raise translate(locale, "Hidden field \"token\" is a required field") + end + + if token["signature"] != sign_token(key, token) + raise translate(locale, "Invalid signature") + end + + if token["session"] != session + raise translate(locale, "Invalid token") + end + + if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) + if nonce[1] > Time.now + db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0]) + else + raise translate(locale, "Invalid token") + end + end + + scopes = token["scopes"].as_a.map { |v| v.as_s } + scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}" + + if !scopes_include_scope(scopes, scope) + raise translate(locale, "Invalid scope") + end + + expire = token["expire"]?.try &.as_i + if expire.try &.< Time.now.to_unix + raise translate(locale, "Token is expired, please try again") + end + + return {scopes, expire, token["signature"].as_s} +end + +def scope_includes_scope(scope, subset) + methods, endpoint = scope.split(":") + methods = methods.split(";").map { |method| method.upcase }.reject { |method| method.empty? }.sort + endpoint = endpoint.downcase + + subset_methods, subset_endpoint = subset.split(":") + subset_methods = subset_methods.split(";").map { |method| method.upcase }.sort + subset_endpoint = subset_endpoint.downcase + + if methods.empty? + methods = %w(GET POST PUT HEAD DELETE PATCH OPTIONS) + end + + if methods & subset_methods != subset_methods + return false + end + + if endpoint.ends_with?("*") && !subset_endpoint.starts_with? endpoint.rchop("*") + return false + end + + if !endpoint.ends_with?("*") && subset_endpoint != endpoint + return false + end + + return true +end + +def scopes_include_scope(scopes, subset) + scopes.each do |scope| + if scope_includes_scope(scope, subset) + return true + end + end + + return false +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 689d9434..43a55eac 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -197,83 +197,6 @@ def create_user(sid, email, password) return user, sid end -def create_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) - expire = Time.now + expire - - token = { - "session" => session, - "expire" => expire.to_unix, - "scopes" => scopes, - } - - if use_nonce - nonce = Random::Secure.hex(16) - db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) - token["nonce"] = nonce - end - - token["signature"] = sign_token(key, token) - - return token.to_json -end - -def sign_token(key, hash) - string_to_sign = [] of String - - hash.each do |key, value| - if key == "signature" - next - end - - if value.is_a?(JSON::Any) - case value - when .as_a? - value = value.as_a.map { |item| item.as_s } - end - end - - case value - when Array - string_to_sign << "#{key}=#{value.sort.join(",")}" - when Tuple - string_to_sign << "#{key}=#{value.to_a.sort.join(",")}" - else - string_to_sign << "#{key}=#{value}" - end - end - - string_to_sign = string_to_sign.sort.join("\n") - return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip -end - -def validate_response(token, session, scope, key, db, locale) - if !token - raise translate(locale, "Hidden field \"token\" is a required field") - end - - token = JSON.parse(URI.unescape(token)).as_h - - if token["signature"]? != sign_token(key, token) - raise translate(locale, "Invalid token") - end - - if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) - if nonce[1] > Time.now - db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.new(1990, 1, 1), nonce[0]) - else - raise translate(locale, "Invalid token") - end - end - - if !token["scopes"].as_a.includes? scope.strip("/") - raise translate(locale, "Invalid token") - end - - if token["expire"].as_i < Time.now.to_unix - raise translate(locale, "Token is expired, please try again") - end -end - def generate_captcha(key, db) second = Random::Secure.rand(12) second_angle = second * 30 @@ -326,7 +249,7 @@ def generate_captcha(key, db) return { question: image, - tokens: {create_response(answer, {"login"}, key, db, use_nonce: true)}, + tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)}, } end @@ -335,7 +258,7 @@ def generate_text_captcha(key, db) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| - create_response(answer.as_s, {"login"}, key, db, use_nonce: true) + generate_response(answer.as_s, {":login"}, key, db, use_nonce: true) end return { diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c72458b2..bdd21a48 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -486,15 +486,15 @@ struct Video if storyboard = storyboards.try &.["spec"]? .try &.as_s return [{ - url: storyboard.split("#")[0], - width: 106, - height: 60, - count: -1, - interval: 5000, - storyboard_width: 3, - storyboard_height: 3, - storyboard_count: -1, - }] + url: storyboard.split("#")[0], + width: 106, + height: 60, + count: -1, + interval: 5000, + storyboard_width: 3, + storyboard_height: 3, + storyboard_count: -1, + }] end end diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr new file mode 100644 index 00000000..d00335e2 --- /dev/null +++ b/src/invidious/views/authorize_token.ecr @@ -0,0 +1,78 @@ +<% content_for "header" do %> +
<%= env.get "access_token" %>
+