mirror of
				https://github.com/iv-org/invidious.git
				synced 2025-10-31 04:32:02 +00:00 
			
		
		
		
	Add authentication API
This commit is contained in:
		
							
								
								
									
										359
									
								
								src/invidious.cr
									
									
									
									
									
								
							
							
						
						
									
										359
									
								
								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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										146
									
								
								src/invidious/helpers/tokens.cr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								src/invidious/helpers/tokens.cr
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										78
									
								
								src/invidious/views/authorize_token.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/invidious/views/authorize_token.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= translate(locale, "Token") %> - Invidious</title> | ||||
| <% end %> | ||||
|  | ||||
| <% if env.get? "access_token" %> | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-1-3"> | ||||
|         <h3> | ||||
|             <%= translate(locale, "Token") %> | ||||
|         </h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:center"> | ||||
|         <h3> | ||||
|             <a href="/token_manager"><%= translate(locale, "Token manager") %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:right"> | ||||
|         <h3> | ||||
|             <a href="/preferences"><%= translate(locale, "Preferences") %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="h-box"> | ||||
|     <h4 style="padding-left:0.5em"> | ||||
|         <code><%= env.get "access_token" %></code> | ||||
|     </h4> | ||||
| </div> | ||||
| <% else %> | ||||
| <div class="h-box"> | ||||
|     <form class="pure-form pure-form-aligned" action="/authorize_token" method="post"> | ||||
|         <% if callback_url %> | ||||
|         <legend><%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}")  %></legend> | ||||
|         <% else %> | ||||
|         <legend><%= translate(locale, "Authorize token?") %></legend> | ||||
|         <% end %> | ||||
|  | ||||
|         <div class="pure-g"> | ||||
|             <div class="pure-u-1"> | ||||
|                 <ul> | ||||
|                 <% scopes.each do |scope| %> | ||||
|                     <li><%= scope %></li> | ||||
|                 <% end %> | ||||
|                 </ul> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="pure-g"> | ||||
|             <div class="pure-u-1-2"> | ||||
|                 <button type="submit" name="submit" value="clear_watch_history" class="pure-button pure-button-primary"> | ||||
|                     <%= translate(locale, "Yes") %> | ||||
|                 </button> | ||||
|             </div> | ||||
|             <div class="pure-u-1-2"> | ||||
|                 <% if callback_url %> | ||||
|                 <a class="pure-button" href="<%= callback_url %>"> | ||||
|                 <% else %> | ||||
|                 <a class="pure-button" href="/"> | ||||
|                 <% end %> | ||||
|                     <%= translate(locale, "No") %> | ||||
|                 </a> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <% scopes.each_with_index do |scope, i| %> | ||||
|         <input type="hidden" name="scopes[<%= i %>]" value="<%= scope %>"> | ||||
|         <% end %> | ||||
|         <% if callback_url %> | ||||
|         <input type="hidden" name="callbackUrl" value="<%= callback_url %>"> | ||||
|         <% end %> | ||||
|         <% if expire %> | ||||
|         <input type="hidden" name="expire" value="<%= expire %>"> | ||||
|         <% end %> | ||||
|  | ||||
|         <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>"> | ||||
|     </form> | ||||
| </div> | ||||
| <% end %> | ||||
| @@ -19,6 +19,6 @@ | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <input type="hidden" name="token" value="<%= URI.escape(token) %>"> | ||||
|         <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>"> | ||||
|     </form> | ||||
| </div> | ||||
|   | ||||
| @@ -86,7 +86,7 @@ | ||||
|                 <img class="thumbnail" src="/vi/<%= item.id %>/mqdefault.jpg"/> | ||||
|                 <% if env.get? "show_watched" %> | ||||
|                 <form onsubmit="return false;" action="/watch_ajax?action_mark_watched=1&id=<%= item.id %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                     <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|                     <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                     <p class="watched"> | ||||
|                         <a onclick="mark_watched(this)" data-id="<%= item.id %>" href="#"> | ||||
|                             <button type="submit" style="all:unset"> | ||||
|   | ||||
| @@ -2,7 +2,7 @@ | ||||
|     <% if subscriptions.includes? ucid %> | ||||
|     <p> | ||||
|         <form onsubmit="return false;" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|           <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|           <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|           <a id="subscribe" onclick="unsubscribe()" class="pure-button pure-button-primary" href="#"> | ||||
|               <b><input style="all:unset" type="submit" value="<%= translate(locale, "Unsubscribe") %> | <%= sub_count_text %>"></b> | ||||
|           </a> | ||||
| @@ -11,7 +11,7 @@ | ||||
|     <% else %> | ||||
|     <p> | ||||
|       <form onsubmit="return false;" action="/subscription_ajax?action_create_subscription_to_channel=1&c=<%= ucid %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|         <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|         <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|         <a id="subscribe" onclick="subscribe()" class="pure-button pure-button-primary" href="#"> | ||||
|             <b><input style="all:unset" type="submit" value="<%= translate(locale, "Subscribe") %> | <%= sub_count_text %>"></b> | ||||
|         </a> | ||||
|   | ||||
| @@ -17,7 +17,7 @@ function subscribe(timeouts = 0) { | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     var fallback = subscribe_button.innerHTML; | ||||
|     subscribe_button.onclick = unsubscribe; | ||||
| @@ -53,7 +53,7 @@ function unsubscribe(timeouts = 0) { | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     var fallback = subscribe_button.innerHTML; | ||||
|     subscribe_button.onclick = subscribe; | ||||
|   | ||||
| @@ -19,6 +19,6 @@ | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <input type="hidden" name="token" value="<%= URI.escape(token) %>"> | ||||
|         <input type="hidden" name="csrf_token" value="<%= URI.escape(csrf_token) %>"> | ||||
|     </form> | ||||
| </div> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ | ||||
|                     <div class="thumbnail"> | ||||
|                         <img class="thumbnail" src="/vi/<%= item %>/mqdefault.jpg"/> | ||||
|                         <form onsubmit="return false;" action="/watch_ajax?action_mark_unwatched=1&id=<%= item %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                             <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|                             <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                             <p class="watched"> | ||||
|                                 <a onclick="mark_unwatched(this)" data-id="<%= item %>" href="#"> | ||||
|                                     <button type="submit" style="all:unset"> | ||||
| @@ -61,7 +61,7 @@ function mark_unwatched(target) { | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     xhr.onreadystatechange = function() { | ||||
|         if (xhr.readyState == 4) { | ||||
|   | ||||
| @@ -216,6 +216,10 @@ function update_value(element) { | ||||
|                 <a href="/subscription_manager"><%= translate(locale, "Manage subscriptions") %></a> | ||||
|             </div> | ||||
|  | ||||
|             <div class="pure-control-group"> | ||||
|                 <a href="/token_manager"><%= translate(locale, "Manage tokens") %></a> | ||||
|             </div> | ||||
|  | ||||
|             <div class="pure-control-group"> | ||||
|                 <a href="/feed/history"><%= translate(locale, "Watch history") %></a> | ||||
|             </div> | ||||
|   | ||||
| @@ -8,12 +8,12 @@ | ||||
|             <a href="/feed/subscriptions"><%= translate(locale, "`x` subscriptions", %(<span id="count">#{subscriptions.size}</span>)) %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:center;"> | ||||
|     <div class="pure-u-1-3" style="text-align:center"> | ||||
|         <h3> | ||||
|             <a href="/feed/history"><%= translate(locale, "Watch history") %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3" style="text-align:right;"> | ||||
|     <div class="pure-u-1-3" style="text-align:right"> | ||||
|         <h3> | ||||
|             <a href="/data_control?referer=<%= referer %>"><%= translate(locale, "Import/Export") %></a> | ||||
|         </h3> | ||||
| @@ -22,17 +22,17 @@ | ||||
|  | ||||
| <% subscriptions.each do |channel| %> | ||||
| <div class="h-box"> | ||||
|     <div class="pure-g<% if channel.deleted %> deleted <% end%>"> | ||||
|     <div class="pure-g<% if channel.deleted %> deleted <% end %>"> | ||||
|         <div class="pure-u-2-5"> | ||||
|             <h3 style="padding-left: 0.5em"> | ||||
|             <h3 style="padding-left:0.5em"> | ||||
|                 <a href="/channel/<%= channel.id %>"><%= channel.author %></a> | ||||
|             </h3> | ||||
|         </div> | ||||
|         <div class="pure-u-2-5"></div> | ||||
|         <div class="pure-u-1-5" style="text-align: right;"> | ||||
|             <h3 style="padding-right: 0.5em"> | ||||
|                 <form onsubmit="return false;" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                     <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|         <div class="pure-u-1-5" style="text-align:right"> | ||||
|             <h3 style="padding-right:0.5em"> | ||||
|                 <form onsubmit="return false" action="/subscription_ajax?action_remove_subscriptions=1&c=<%= channel.id %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                     <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                     <a onclick="remove_subscription(this)" data-ucid="<%= channel.id %>" href="#"> | ||||
|                         <input style="all:unset" type="submit" value="<%= translate(locale, "unsubscribe") %>"> | ||||
|                     </a> | ||||
| @@ -60,7 +60,7 @@ function remove_subscription(target) { | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     xhr.onreadystatechange = function() { | ||||
|         if (xhr.readyState == 4) { | ||||
|   | ||||
| @@ -62,7 +62,7 @@ function mark_watched(target) { | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("token=<%= URI.escape(env.get?("token").try &.as(String) || "") %>"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     xhr.onreadystatechange = function() { | ||||
|         if (xhr.readyState == 4) { | ||||
|   | ||||
| @@ -70,7 +70,7 @@ | ||||
|                 </div> | ||||
|                 <div class="pure-u-1-4"> | ||||
|                     <form action="/signout?referer=<%= env.get?("current_page") %>" method="post"> | ||||
|                         <input type="hidden" name="token" value="<%= URI.escape(env.get?("token").try &.as(String) || "") %>"> | ||||
|                         <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                         <a class="pure-menu-heading" href="#"> | ||||
|                             <input style="all:unset" type="submit" value="<%= translate(locale, "Sign out") %>"> | ||||
|                         </a> | ||||
|   | ||||
							
								
								
									
										72
									
								
								src/invidious/views/token_manager.ecr
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/invidious/views/token_manager.ecr
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| <% content_for "header" do %> | ||||
| <title><%= translate(locale, "Token manager") %> - Invidious</title> | ||||
| <% end %> | ||||
|  | ||||
| <div class="pure-g h-box"> | ||||
|     <div class="pure-u-1-3"> | ||||
|         <h3> | ||||
|             <%= translate(locale, "`x` tokens", %(<span id="count">#{tokens.size}</span>)) %> | ||||
|         </h3> | ||||
|     </div> | ||||
|     <div class="pure-u-1-3"></div> | ||||
|     <div class="pure-u-1-3" style="text-align:right"> | ||||
|         <h3> | ||||
|             <a href="/preferences?referer=<%= referer %>"><%= translate(locale, "Preferences") %></a> | ||||
|         </h3> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <% tokens.each do |token| %> | ||||
| <div class="h-box"> | ||||
|     <div class="pure-g<% if token[:session] == sid %> deleted <% end %>"> | ||||
|         <div class="pure-u-3-5"> | ||||
|             <h4 style="padding-left:0.5em"> | ||||
|                 <code><%= token[:session] %></code> | ||||
|             </h4> | ||||
|         </div> | ||||
|         <div class="pure-u-1-5" style="text-align:center"> | ||||
|             <h4><%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %></h4> | ||||
|         </div> | ||||
|         <div class="pure-u-1-5" style="text-align:right"> | ||||
|             <h3 style="padding-right:0.5em"> | ||||
|                 <form onsubmit="return false" action="/token_ajax?action_revoke_token=1&session=<%= token[:session] %>&referer=<%= env.get("current_page") %>" method="post"> | ||||
|                     <input type="hidden" name="csrf_token" value="<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"> | ||||
|                     <a onclick="revoke_token(this)" data-session="<%= token[:session] %>" href="#"> | ||||
|                         <input style="all:unset" type="submit" value="<%= translate(locale, "revoke") %>"> | ||||
|                     </a> | ||||
|                 </form> | ||||
|             </h3> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
|     <% if tokens[-1].try &.[:session]? != token[:session] %> | ||||
|     <hr> | ||||
|     <% end %> | ||||
| </div> | ||||
| <% end %> | ||||
|  | ||||
| <script> | ||||
| function revoke_token(target) { | ||||
|     var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; | ||||
|     row.style.display = "none"; | ||||
|     var count = document.getElementById("count") | ||||
|     count.innerText = count.innerText - 1; | ||||
|  | ||||
|     var url = "/token_ajax?action_revoke_token=1&redirect=false&referer=<%= env.get("current_page") %>&session=" + target.getAttribute("data-session"); | ||||
|     var xhr = new XMLHttpRequest(); | ||||
|     xhr.responseType = "json"; | ||||
|     xhr.timeout = 20000; | ||||
|     xhr.open("POST", url, true); | ||||
|     xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); | ||||
|     xhr.send("csrf_token=<%= URI.escape(env.get?("csrf_token").try &.as(String) || "") %>"); | ||||
|  | ||||
|     xhr.onreadystatechange = function() { | ||||
|         if (xhr.readyState == 4) { | ||||
|             if (xhr.status != 200) { | ||||
|                 count.innerText = count.innerText - 1 + 2; | ||||
|                 row.style.display = ""; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| </script> | ||||
		Reference in New Issue
	
	Block a user
	 Omar Roth
					Omar Roth