From 407862a4e0e10ce2a035e4e5e76e26dde849aac1 Mon Sep 17 00:00:00 2001 From: Arjun Satarkar Date: Sat, 27 Jul 2024 21:04:28 +0530 Subject: Finish Discord activity implementation --- .gitignore | 3 +- README.md | 5 -- config/runtime.exs | 6 +- lib/mediasync/application.ex | 7 +++ lib/mediasync/discord_api.ex | 15 +++++ lib/mediasync/http_errors.ex | 34 ++++++++++-- lib/mediasync/room.ex | 23 ++++++-- lib/mediasync/router.ex | 116 +++++++++++++++++++++++++++++---------- lib/mediasync/utils.ex | 6 ++ priv/discord_activity.html.eex | 44 +++++++++++++-- priv/static/discordActivity.js | 113 ++++++++++++++++++++++++++++++++++++++ priv/static/discord_activity.js | 20 ------- priv/static/room/displayState.js | 5 +- 13 files changed, 323 insertions(+), 74 deletions(-) create mode 100644 lib/mediasync/discord_api.ex create mode 100644 priv/static/discordActivity.js delete mode 100644 priv/static/discord_activity.js diff --git a/.gitignore b/.gitignore index 83eb072..9b6f7d6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,8 +27,7 @@ mediasync-*.tar ## -/.parcel-cache/ - /.env /node_modules/ +/.parcel-cache/ diff --git a/README.md b/README.md index 8ee7ea5..d99aba2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1 @@ # Mediasync - -TODO: - -- do all the discord integration so it works as an Activity -- present errors nicely on the frontend eg. multiple connections with same user token should show a warning rather than silently failing, a message should be shown when the user is disconnected, etc. diff --git a/config/runtime.exs b/config/runtime.exs index 65f7b0a..35a80c1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -17,9 +17,10 @@ websocket_origin = ) ) -discord_client_id = +{discord_client_id, discord_client_secret} = if enable_discord_activity? do - System.fetch_env!("MEDIASYNC_DISCORD_CLIENT_ID") + {System.fetch_env!("MEDIASYNC_DISCORD_CLIENT_ID"), + System.fetch_env!("MEDIASYNC_DISCORD_CLIENT_SECRET")} end websocket_origin = @@ -37,6 +38,7 @@ config :mediasync, websocket_max_frame_octets: 10_000, enable_discord_activity?: enable_discord_activity?, discord_client_id: discord_client_id, + discord_client_secret: discord_client_secret, secret_key_base: System.fetch_env!("MEDIASYNC_SECRET_KEY_BASE"), session_encryption_salt: System.fetch_env!("MEDIASYNC_SESSION_ENCRYPTION_SALT"), session_signing_salt: System.fetch_env!("MEDIASYNC_SESSION_SIGNING_SALT") diff --git a/lib/mediasync/application.ex b/lib/mediasync/application.ex index 1e8f6d4..5cef37a 100644 --- a/lib/mediasync/application.ex +++ b/lib/mediasync/application.ex @@ -25,6 +25,13 @@ defmodule Mediasync.Application do {Registry, keys: :duplicate, name: Mediasync.RoomSubscriptionRegistry} ] + children = + if Application.fetch_env!(:mediasync, :enable_discord_activity?) do + [{Registry, keys: :duplicate, name: Mediasync.DiscordActivityInstanceRegistry} | children] + else + children + end + System.no_halt(true) # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/mediasync/discord_api.ex b/lib/mediasync/discord_api.ex new file mode 100644 index 0000000..da1d39c --- /dev/null +++ b/lib/mediasync/discord_api.ex @@ -0,0 +1,15 @@ +defmodule Mediasync.DiscordAPI do + @base_url "https://discord.com/api/v10/" + + @spec get_user!(String.t()) :: map() + def get_user!(access_token) do + response = + Req.get!(@base_url <> "users/@me", headers: [authorization: "Bearer #{access_token}"]) + + if response.status == 200 do + response.body + else + raise "Discord API responded with status code #{response.status}" + end + end +end diff --git a/lib/mediasync/http_errors.ex b/lib/mediasync/http_errors.ex index c38486a..cbbb613 100644 --- a/lib/mediasync/http_errors.ex +++ b/lib/mediasync/http_errors.ex @@ -4,8 +4,8 @@ defmodule Mediasync.HTTPErrors do @video_url_too_large Jason.encode!( %{ - "error" => "video_url_too_large", - "max_size" => Application.compile_env(:mediasync, :max_video_url_size) + "error" => "videoUrlTooLarge", + "maxSize" => Application.compile_env(:mediasync, :max_video_url_size) }, pretty: true ) @@ -21,7 +21,7 @@ defmodule Mediasync.HTTPErrors do ) end - @invalid_video_url Jason.encode!(%{"error" => "invalid_video_url"}, pretty: true) + @invalid_video_url Jason.encode!(%{"error" => "invalidVideoUrl"}, pretty: true) @spec send_invalid_video_url(Plug.Conn.t()) :: Plug.Conn.t() @spec send_invalid_video_url(Plug.Conn.t(), []) :: Plug.Conn.t() @@ -36,7 +36,7 @@ defmodule Mediasync.HTTPErrors do @not_found Jason.encode!( %{ - "error" => "not_found", + "error" => "notFound", "message" => "No page was found at this location." }, pretty: true @@ -68,7 +68,7 @@ defmodule Mediasync.HTTPErrors do @invalid_csrf_token Jason.encode!( %{ - "error" => "invalid_csrf_token", + "error" => "invalidCsrfToken", "message" => "Try reloading the previous page and retrying." }, pretty: true @@ -82,7 +82,29 @@ defmodule Mediasync.HTTPErrors do |> send_resp(400, @invalid_csrf_token) end - @bad_gateway Jason.encode!(%{"error" => "bad_gateway"}, pretty: true) + @spec send_bad_request(Plug.Conn.t()) :: Plug.Conn.t() + @spec send_bad_request(Plug.Conn.t(), message: String.t() | nil) :: Plug.Conn.t() + def send_bad_request(conn), do: send_bad_request(conn, message: nil) + def send_bad_request(conn, []), do: send_bad_request(conn, message: nil) + + def send_bad_request(conn, message: message) do + error = %{ + "error" => "badRequest" + } + + error = + if message do + Map.put(error, "message", message) + else + error + end + + conn + |> put_json_content_type() + |> send_resp(400, Jason.encode!(error)) + end + + @bad_gateway Jason.encode!(%{"error" => "badGateway"}, pretty: true) @spec send_bad_gateway(Plug.Conn.t()) :: Plug.Conn.t() @spec send_bad_gateway(Plug.Conn.t(), []) :: Plug.Conn.t() diff --git a/lib/mediasync/room.ex b/lib/mediasync/room.ex index 0367f40..6386893 100644 --- a/lib/mediasync/room.ex +++ b/lib/mediasync/room.ex @@ -18,6 +18,9 @@ defmodule Mediasync.Room.State do :video_info, :host_user_token_hash, :room_id, + :host_username, + :viewer_usernames, + :discord_instance_id, host_disconnected_tries: @host_disconnected_tries_max ] @@ -28,7 +31,10 @@ defmodule Mediasync.Room.State do @type t() :: %Mediasync.Room.State{ video_info: Mediasync.Room.VideoInfo.t(), host_user_token_hash: Mediasync.UserToken.hash(), - room_id: Mediasync.RoomID.t(), + room_id: Mediasync.RoomID.t() | nil, + host_username: String.t() | nil, + viewer_usernames: [String.t()] | nil, + discord_instance_id: String.t() | nil, host_disconnected_tries: integer() } end @@ -90,10 +96,19 @@ defmodule Mediasync.Room do @impl true @spec init(Mediasync.Room.State.t()) :: {:ok, Mediasync.Room.State.t()} - def init(room_state = %Mediasync.Room.State{}) do + def init(state = %Mediasync.Room.State{}) do + if state.discord_instance_id do + Registry.register(Mediasync.DiscordActivityInstanceRegistry, state.discord_instance_id, %{ + host_username: state.host_username, + room_id: state.room_id + }) + end + Process.send_after(self(), :check_if_active, @inactive_check_wait_milliseconds) - {:ok, room_state} + Logger.info("Created room #{state.room_id}") + + {:ok, state} end @impl true @@ -141,7 +156,7 @@ defmodule Mediasync.Room do Process.send_after(self(), :check_if_active, @inactive_check_wait_milliseconds) if state.host_disconnected_tries <= 0 do - Logger.info("Room #{state.room_id} shutting down: no host.") + Logger.info("Room #{state.room_id} shutting down: no host") {:stop, {:shutdown, :no_host}, state} else {:noreply, state} diff --git a/lib/mediasync/router.ex b/lib/mediasync/router.ex index a27bfc9..c60ca8a 100644 --- a/lib/mediasync/router.ex +++ b/lib/mediasync/router.ex @@ -5,7 +5,7 @@ defmodule Mediasync.Router do use Plug.Router use Plug.ErrorHandler - plug(Plug.Logger) + plug(Plug.Logger, log: :debug) plug(Plug.Head) plug(Plug.Static, at: "/static", from: {:mediasync, "priv/static"}) @@ -34,21 +34,19 @@ defmodule Mediasync.Router do conn |> put_html_content_type() - send_resp( - conn, - 200, - cond do - enable_discord_activity? and Map.get(conn.query_params, query_param_instance_id()) -> - Mediasync.Templates.discord_activity() + cond do + enable_discord_activity? and Map.has_key?(conn.query_params, query_param_instance_id()) -> + conn + |> put_session("discord_instance_id", conn.query_params["instance_id"]) + |> send_resp(200, Mediasync.Templates.discord_activity()) - enable_discord_activity? and - Map.get(conn.query_params, query_param_discord_activity_inner()) -> - Mediasync.Templates.home(:discord_activity) + enable_discord_activity? and + Map.has_key?(conn.query_params, query_param_discord_activity_inner()) -> + send_resp(conn, 200, Mediasync.Templates.home(:discord_activity)) - true -> - Mediasync.Templates.home() - end - ) + true -> + send_resp(conn, 200, Mediasync.Templates.home()) + end end post "/host_room" do @@ -73,24 +71,32 @@ defmodule Mediasync.Router do Mediasync.HTTPErrors.send_invalid_video_url(conn) true -> + in_discord_activity? = + Application.fetch_env!(:mediasync, :enable_discord_activity?) and + Map.has_key?(conn.query_params, query_param_discord_activity_inner()) + + {suffix, host_username, instance_id} = + if in_discord_activity? do + {"?#{query_param_discord_activity_inner()}", + Mediasync.DiscordAPI.get_user!(get_session(conn, "discord_access_token"))[ + "username" + ], get_session(conn, "discord_instance_id")} + else + {"", nil, nil} + end + {:ok, _pid, room_id} = DynamicSupervisor.start_child( Mediasync.RoomSupervisor, {Mediasync.Room, %Mediasync.Room.State{ video_info: video_info, - host_user_token_hash: get_user_token_hash!(conn) + host_user_token_hash: get_user_token_hash!(conn), + host_username: host_username, + discord_instance_id: instance_id }} ) - suffix = - if Application.fetch_env!(:mediasync, :enable_discord_activity?) and - Map.get(conn.query_params, query_param_discord_activity_inner()) do - "?#{query_param_discord_activity_inner()}" - else - "" - end - redirect(conn, status: 303, location: "/room/#{room_id}#{suffix}") end end @@ -104,7 +110,7 @@ defmodule Mediasync.Router do {video_info, websocket_path, state_url, home_url} = if Application.fetch_env!(:mediasync, :enable_discord_activity?) and - Map.get(conn.query_params, query_param_discord_activity_inner()) do + Map.has_key?(conn.query_params, query_param_discord_activity_inner()) do {%{video_info | url: "/.proxy/room/#{room_id}/video"}, "/.proxy/room/#{room_id}/websocket?#{query_param_discord_activity_inner()}", "/.proxy/room/#{room_id}/state.json?#{query_param_discord_activity_inner()}", @@ -187,6 +193,60 @@ defmodule Mediasync.Router do end end + post "/discord_activity/access_token" do + if Application.fetch_env!(:mediasync, :enable_discord_activity?) do + if Map.has_key?(conn.query_params, query_param_discord_activity_inner()) do + response = + Req.post!("https://discord.com/api/oauth2/token", + headers: [ + content_type: "application/x-www-form-urlencoded" + ], + form: [ + client_id: Application.fetch_env!(:mediasync, :discord_client_id), + client_secret: Application.fetch_env!(:mediasync, :discord_client_secret), + grant_type: "authorization_code", + code: conn.body_params["code"] + ] + ) + + access_token = response.body["access_token"] + + conn + |> put_session("discord_access_token", access_token) + |> put_plain_text_content_type() + |> send_resp(200, access_token) + else + # If the query param isn't present, we won't be able to modify the session (see session_wrapper/2). + Mediasync.HTTPErrors.send_bad_request(conn, + message: + "This route must always be called with the query param ?#{query_param_discord_activity_inner()}" + ) + end + else + Mediasync.HTTPErrors.send_not_found(conn) + end + end + + get "/discord_activity/rooms_for_instance" do + if Application.fetch_env!(:mediasync, :enable_discord_activity?) do + values = + for {_pid, value} <- + Registry.match( + Mediasync.DiscordActivityInstanceRegistry, + conn.query_params["instance_id"], + :_ + ) do + value + end + + conn + |> put_json_content_type() + |> send_resp(200, Jason.encode!(values)) + else + Mediasync.HTTPErrors.send_not_found(conn) + end + end + match _ do Mediasync.HTTPErrors.send_not_found(conn) end @@ -206,10 +266,10 @@ defmodule Mediasync.Router do query_params = fetch_query_params(conn).query_params in_discord_activity? = - !!(Application.fetch_env!(:mediasync, :enable_discord_activity?) && - (Map.get(query_params, query_param_discord_activity_inner()) || - (conn.request_path == "/" && - Map.get(query_params, query_param_instance_id())))) + Application.fetch_env!(:mediasync, :enable_discord_activity?) and + (Map.has_key?(query_params, query_param_discord_activity_inner()) or + (conn.request_path == "/" and + Map.has_key?(query_params, query_param_instance_id()))) Plug.Session.call( conn, diff --git a/lib/mediasync/utils.ex b/lib/mediasync/utils.ex index 407a9a4..c2f3531 100644 --- a/lib/mediasync/utils.ex +++ b/lib/mediasync/utils.ex @@ -13,6 +13,12 @@ defmodule Mediasync.Utils do put_resp_content_type(conn, "application/json") end + @spec put_plain_text_content_type(Plug.Conn.t()) :: Plug.Conn.t() + @spec put_plain_text_content_type(Plug.Conn.t(), []) :: Plug.Conn.t() + def put_plain_text_content_type(conn, _opts \\ []) do + put_resp_content_type(conn, "text/plain") + end + @spec redirect(Plug.Conn.t(), status: Plug.Conn.status(), location: binary()) :: Plug.Conn.t() def redirect(conn, status: status, location: location) do conn diff --git a/priv/discord_activity.html.eex b/priv/discord_activity.html.eex index 83bda0d..5278400 100644 --- a/priv/discord_activity.html.eex +++ b/priv/discord_activity.html.eex @@ -1,3 +1,4 @@ +<% home_url = "/.proxy/?#{Mediasync.Constants.query_param_discord_activity_inner()}" %> @@ -6,25 +7,58 @@ discord activity | mediasync - - + +
+ currently hosting: +
+
+ + - + diff --git a/priv/static/discordActivity.js b/priv/static/discordActivity.js new file mode 100644 index 0000000..7ec4872 --- /dev/null +++ b/priv/static/discordActivity.js @@ -0,0 +1,113 @@ +import { DiscordSDK } from "/static/discord-embedded-app-sdk/Discord.js"; + +const discordSdk = new DiscordSDK(DISCORD_CLIENT_ID); + +const updateInstanceRoomInfo = () => { + const contentEl = document.getElementById("instance-room-info-content"); + + const defaultContents = document.createElement("i"); + defaultContents.innerText = "none yet"; + if (!contentEl.hasChildNodes()) { + contentEl.replaceChildren(defaultContents); + } + + fetch( + `/.proxy/discord_activity/rooms_for_instance?discord_activity_inner&instance_id=${discordSdk.instanceId}`, + ) + .then((response) => { + if (!response.ok) { + throw new Error(`Fetching instance room info failed with status code ${response.status}`); + } + return response.json(); + }) + .then((json) => { + let docFragment = new DocumentFragment(); + if (json.length === 0) { + docFragment.appendChild(defaultContents); + } else { + const listEl = docFragment.appendChild(document.createElement("ul")); + for (const roomInfo of json) { + const item = listEl.appendChild(document.createElement("li")); + item.innerText = roomInfo["host_username"] + " "; + const form = item.appendChild(document.createElement("form")); + form.action = roomUrl(roomInfo["room_id"]); + form.target = "activity-inner-iframe"; + form.style.display = "inline"; + const hiddenInput = form.appendChild(document.createElement("input")); + hiddenInput.type = "hidden"; + hiddenInput.name = "discord_activity_inner"; + const submitInput = form.appendChild(document.createElement("input")); + submitInput.type = "submit"; + submitInput.value = "join"; + } + } + contentEl.replaceChildren(docFragment); + }) + .catch((error) => { + console.error(error); + }); +}; + +updateInstanceRoomInfo(); +const INSTANCE_ROOM_INFO_INTERVAL = 3000; +let instanceRoomInfoIntervalId = setInterval(updateInstanceRoomInfo, INSTANCE_ROOM_INFO_INTERVAL); + +const iframe = document.querySelector("iframe"); +// It doesn't matter if the iframe's initial loading finishes before this is added +iframe.addEventListener("load", (_) => { + clearInterval(instanceRoomInfoIntervalId); + + const locationURL = new URL(iframe.contentWindow.location); + const locationPath = locationURL.pathname + locationURL.search; + if (locationPath === HOME_URL) { + instanceRoomInfoIntervalId = setInterval(updateInstanceRoomInfo, INSTANCE_ROOM_INFO_INTERVAL); + iframe.className = "at-home"; + } else { + iframe.className = ""; + } +}); + +discordSdk + .ready() + .then(() => { + return discordSdk.commands.authorize({ + client_id: DISCORD_CLIENT_ID, + response_type: "code", + state: "", + prompt: "none", + scope: ["identify"], + }); + }) + .then((result) => { + const { code } = result; + + return fetch(ACCESS_TOKEN_URL, { + body: new URLSearchParams([["code", code]]), + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-csrf-token": document.body.dataset.csrfToken, + }, + method: "POST", + }); + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Getting access token failed with status code ${response.status}`); + } + // Note: the access token should now be in the session cookie as well + return response.text(); + }) + .then((accessToken) => { + return discordSdk.commands.authenticate({ access_token: accessToken }); + }) + .then((authenticationResult) => { + if (authenticationResult === null) { + throw new Error("Authenticating with Discord client failed"); + } + iframe.style.display = null; + }) + .catch((error) => { + console.error(error); + const CLOSE_ABNORMAL = 1006; + discordSdk.close(CLOSE_ABNORMAL, "Could not obtain authorizations required to run this app."); + }); diff --git a/priv/static/discord_activity.js b/priv/static/discord_activity.js deleted file mode 100644 index 344c30d..0000000 --- a/priv/static/discord_activity.js +++ /dev/null @@ -1,20 +0,0 @@ -import { DiscordSDK } from "/static/discord-embedded-app-sdk/Discord.js"; - -const discordSdk = new DiscordSDK(DISCORD_CLIENT_ID); - -discordSdk - .ready() - .then(() => { - console.log("Mediasync: Discord SDK ready."); - return discordSdk.commands.authorize({ - client_id: DISCORD_CLIENT_ID, - response_type: "code", - state: "", - prompt: "none", - scope: ["identify"], - }); - }) - .then((result) => { - const { code } = result; - console.log(code); - }); diff --git a/priv/static/room/displayState.js b/priv/static/room/displayState.js index 3a5e923..a2da229 100644 --- a/priv/static/room/displayState.js +++ b/priv/static/room/displayState.js @@ -9,7 +9,7 @@ fetch(STATE_URL) .then((response) => { if (!response.ok) { - throw new Error(`Error: fetching state returned ${response.status}`); + throw new Error(`Fetching room state failed with status code ${response.status}`); } return response.json(); }) @@ -17,7 +17,8 @@ const viewersConnected = json.viewersConnected; stateElement.dataset.text = `total viewers: ${viewersConnected}`; }) - .catch((_) => { + .catch((error) => { + console.error(error); stateElement.dataset.text = STATE_ELEMENT_INITIAL_TEXT; }); } -- cgit v1.2.3-57-g22cb