From cd794243a5bba358d995e26ba024268e7d5d3f85 Mon Sep 17 00:00:00 2001 From: Arjun Satarkar Date: Fri, 26 Jul 2024 19:50:14 +0530 Subject: Add Discord Activity functionality (mostly) + general improvements --- lib/mediasync/constants.ex | 4 + lib/mediasync/http_errors.ex | 30 +++++-- lib/mediasync/room.ex | 67 ++++++++++----- lib/mediasync/room_connection.ex | 11 +-- lib/mediasync/room_id.ex | 3 +- lib/mediasync/router.ex | 171 ++++++++++++++++++++++++++++++++------- lib/mediasync/templates.ex | 17 +++- lib/mediasync/utils.ex | 45 +++++------ lib/mix/tasks/vendor.ex | 22 ++++- 9 files changed, 280 insertions(+), 90 deletions(-) create mode 100644 lib/mediasync/constants.ex (limited to 'lib') diff --git a/lib/mediasync/constants.ex b/lib/mediasync/constants.ex new file mode 100644 index 0000000..9aabc45 --- /dev/null +++ b/lib/mediasync/constants.ex @@ -0,0 +1,4 @@ +defmodule Mediasync.Constants do + def query_param_discord_activity_inner, do: "discord_activity_inner" + def query_param_instance_id, do: "instance_id" +end diff --git a/lib/mediasync/http_errors.ex b/lib/mediasync/http_errors.ex index 2b11ea6..c38486a 100644 --- a/lib/mediasync/http_errors.ex +++ b/lib/mediasync/http_errors.ex @@ -21,12 +21,7 @@ defmodule Mediasync.HTTPErrors do ) end - @invalid_video_url Jason.encode!( - %{ - "error" => "invalid_video_url" - }, - pretty: true - ) + @invalid_video_url Jason.encode!(%{"error" => "invalid_video_url"}, pretty: true) @spec send_invalid_video_url(Plug.Conn.t()) :: Plug.Conn.t() @spec send_invalid_video_url(Plug.Conn.t(), []) :: Plug.Conn.t() @@ -58,6 +53,19 @@ defmodule Mediasync.HTTPErrors do ) end + @forbidden Jason.encode!(%{"error" => "forbidden"}, pretty: true) + + @spec send_forbidden(Plug.Conn.t()) :: Plug.Conn.t() + @spec send_forbidden(Plug.Conn.t(), []) :: Plug.Conn.t() + def send_forbidden(conn, _opts \\ []) do + conn + |> put_json_content_type() + |> send_resp( + 403, + @forbidden + ) + end + @invalid_csrf_token Jason.encode!( %{ "error" => "invalid_csrf_token", @@ -74,6 +82,16 @@ defmodule Mediasync.HTTPErrors do |> send_resp(400, @invalid_csrf_token) end + @bad_gateway Jason.encode!(%{"error" => "bad_gateway"}, pretty: true) + + @spec send_bad_gateway(Plug.Conn.t()) :: Plug.Conn.t() + @spec send_bad_gateway(Plug.Conn.t(), []) :: Plug.Conn.t() + def send_bad_gateway(conn, _opts \\ []) do + conn + |> put_json_content_type() + |> send_resp(502, @bad_gateway) + end + @unknown Jason.encode!( %{ "error" => "unknown", diff --git a/lib/mediasync/room.ex b/lib/mediasync/room.ex index c425441..0367f40 100644 --- a/lib/mediasync/room.ex +++ b/lib/mediasync/room.ex @@ -1,10 +1,21 @@ +defmodule Mediasync.Room.VideoInfo do + @enforce_keys [:url, :content_type] + + defstruct [:url, :content_type] + + @type t() :: %Mediasync.Room.VideoInfo{ + url: binary(), + content_type: binary() + } +end + defmodule Mediasync.Room.State do - @enforce_keys [:video_url, :host_user_token_hash] + @enforce_keys [:video_info, :host_user_token_hash] @host_disconnected_tries_max 5 * 6 defstruct [ - :video_url, + :video_info, :host_user_token_hash, :room_id, host_disconnected_tries: @host_disconnected_tries_max @@ -15,7 +26,7 @@ defmodule Mediasync.Room.State do end @type t() :: %Mediasync.Room.State{ - video_url: binary(), + video_info: Mediasync.Room.VideoInfo.t(), host_user_token_hash: Mediasync.UserToken.hash(), room_id: Mediasync.RoomID.t(), host_disconnected_tries: integer() @@ -24,6 +35,7 @@ end defmodule Mediasync.Room do use GenServer + require Logger @spec start_link(Mediasync.Room.State.t()) :: tuple() def start_link(state = %Mediasync.Room.State{}) do @@ -41,9 +53,9 @@ defmodule Mediasync.Room do ) end - @spec get_video_url(GenServer.server()) :: binary() - def get_video_url(pid) do - GenServer.call(pid, :get_video_url) + @spec get_video_info(GenServer.server()) :: Mediasync.Room.VideoInfo.t() + def get_video_info(pid) do + GenServer.call(pid, :get_video_info) end @spec host?(GenServer.server(), Mediasync.UserToken.hash()) :: boolean() @@ -51,6 +63,24 @@ defmodule Mediasync.Room do GenServer.call(pid, {:host?, user_token_hash}) end + @spec host_connected?(GenServer.server()) :: boolean() + def host_connected?(pid) do + GenServer.call(pid, :host_connected?) + end + + defp host_connected_inner(state = %Mediasync.Room.State{}) do + case Registry.lookup( + Mediasync.RoomConnectionRegistry, + {state.room_id, state.host_user_token_hash} + ) do + [{_pid, _value}] -> + true + + [] -> + false + end + end + @spec publish_playback_state(GenServer.server(), Mediasync.PlaybackState.t()) :: :ok def publish_playback_state(pid, playback_state = %Mediasync.PlaybackState{}) do GenServer.call(pid, {:publish_playback_state, playback_state}) @@ -67,8 +97,8 @@ defmodule Mediasync.Room do end @impl true - def handle_call(:get_video_url, _from, state = %Mediasync.Room.State{}) do - {:reply, state.video_url, state} + def handle_call(:get_video_info, _from, state = %Mediasync.Room.State{}) do + {:reply, state.video_info, state} end @impl true @@ -81,6 +111,11 @@ defmodule Mediasync.Room do end end + @impl true + def handle_call(:host_connected?, _from, state = %Mediasync.Room.State{}) do + {:reply, host_connected_inner(state), state} + end + @impl true def handle_call( {:publish_playback_state, playback_state = %Mediasync.PlaybackState{}}, @@ -97,21 +132,17 @@ defmodule Mediasync.Room do @impl true def handle_info(:check_if_active, state) do state = - case Registry.lookup( - Mediasync.RoomConnectionRegistry, - {state.room_id, state.host_user_token_hash} - ) do - [{_pid, _value}] -> - %{state | host_disconnected_tries: Mediasync.Room.State.host_disconnected_tries_max()} - - _ -> - %{state | host_disconnected_tries: state.host_disconnected_tries - 1} + if host_connected_inner(state) do + %{state | host_disconnected_tries: Mediasync.Room.State.host_disconnected_tries_max()} + else + %{state | host_disconnected_tries: state.host_disconnected_tries - 1} end Process.send_after(self(), :check_if_active, @inactive_check_wait_milliseconds) if state.host_disconnected_tries <= 0 do - {:stop, :no_host, state} + Logger.info("Room #{state.room_id} shutting down: no host.") + {:stop, {:shutdown, :no_host}, state} else {:noreply, state} end diff --git a/lib/mediasync/room_connection.ex b/lib/mediasync/room_connection.ex index ec44b7b..f165dc1 100644 --- a/lib/mediasync/room_connection.ex +++ b/lib/mediasync/room_connection.ex @@ -20,7 +20,7 @@ defmodule Mediasync.RoomConnection.State do end defmodule Mediasync.RoomConnection do - import Mediasync.Utils, only: [bool_to_int_repr: 1, int_repr_to_bool!: 1] + import Mediasync.Utils, only: [bool_to_int_repr: 1, int_repr_to_bool: 1] @behaviour WebSock @@ -66,7 +66,7 @@ defmodule Mediasync.RoomConnection do ) do if state.host? do Mediasync.Room.publish_playback_state(state.room_pid, %Mediasync.PlaybackState{ - paused?: int_repr_to_bool!(paused?), + paused?: int_repr_to_bool(paused?), position_milliseconds: position_milliseconds }) @@ -93,13 +93,14 @@ defmodule Mediasync.RoomConnection do @impl true def handle_info( - {:DOWN, ref, :process, _object, _reason}, + {:DOWN, ref, :process, _object, reason}, state = %Mediasync.RoomConnection.State{} ) do room_monitor_ref = state.room_monitor_ref - case ref do - ^room_monitor_ref -> {:stop, {:error, :room_exited}, state} + case {ref, reason} do + {^room_monitor_ref, :shutdown} -> {:stop, :normal, state} + {^room_monitor_ref, _} -> {:stop, {:error, :room_exited}, state} _ -> {:stop, {:error, :unexpected_down_message}, state} end end diff --git a/lib/mediasync/room_id.ex b/lib/mediasync/room_id.ex index 64a8d23..3579f07 100644 --- a/lib/mediasync/room_id.ex +++ b/lib/mediasync/room_id.ex @@ -3,7 +3,6 @@ defmodule Mediasync.RoomID do @spec generate() :: t() def generate do - Application.get_env(:mediasync, :node_id) <> - "~" <> Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) + Base.url_encode64(:crypto.strong_rand_bytes(16), padding: false) end end diff --git a/lib/mediasync/router.ex b/lib/mediasync/router.ex index 54ef533..a27bfc9 100644 --- a/lib/mediasync/router.ex +++ b/lib/mediasync/router.ex @@ -1,4 +1,5 @@ defmodule Mediasync.Router do + import Mediasync.Constants import Mediasync.Utils import Mediasync.UserToken use Plug.Router @@ -11,15 +12,8 @@ defmodule Mediasync.Router do plug(:put_secret_key_base) - plug(Plug.Session, - store: :cookie, - key: "_mediasync_session", - encryption_salt: {Mediasync.Utils, :get_session_encryption_salt, []}, - signing_salt: {Mediasync.Utils, :get_session_signing_salt, []} - ) - + plug(:session_wrapper) plug(:fetch_session) - plug(:ensure_user_token) plug(:match) @@ -34,19 +28,48 @@ defmodule Mediasync.Router do plug(:dispatch) get "/" do - conn - |> put_html_content_type() - |> send_resp(200, Mediasync.Templates.home()) + enable_discord_activity? = Application.fetch_env!(:mediasync, :enable_discord_activity?) + + conn = + 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() + + enable_discord_activity? and + Map.get(conn.query_params, query_param_discord_activity_inner()) -> + Mediasync.Templates.home(:discord_activity) + + true -> + Mediasync.Templates.home() + end + ) end post "/host_room" do - video_url = conn.body_params["video_url"] + param_video_url = conn.body_params["video_url"] + + video_info = %Mediasync.Room.VideoInfo{ + url: param_video_url, + content_type: + hd( + Req.Response.get_header( + Req.head!(param_video_url, receive_timeout: 5000, retry: false), + "content-type" + ) + ) + } cond do - byte_size(video_url) > Application.get_env(:mediasync, :max_video_url_size) -> + byte_size(video_info.url) > Application.get_env(:mediasync, :max_video_url_size) -> Mediasync.HTTPErrors.send_video_url_too_large(conn) - elem(URI.new(video_url), 0) != :ok -> + elem(URI.new(video_info.url), 0) != :ok -> Mediasync.HTTPErrors.send_invalid_video_url(conn) true -> @@ -55,21 +78,47 @@ defmodule Mediasync.Router do Mediasync.RoomSupervisor, {Mediasync.Room, %Mediasync.Room.State{ - video_url: video_url, + video_info: video_info, host_user_token_hash: get_user_token_hash!(conn) }} ) - redirect(conn, status: 303, location: "/room/#{room_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 get "/room/:room_id" do - case Registry.lookup(Mediasync.RoomRegistry, conn.path_params["room_id"]) do + room_id = conn.path_params["room_id"] + + case Registry.lookup(Mediasync.RoomRegistry, room_id) do [{pid, _value}] -> + video_info = Mediasync.Room.get_video_info(pid) + + {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 + {%{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()}", + "/.proxy/?#{query_param_discord_activity_inner()}"} + else + {video_info, "/room/#{room_id}/websocket", "/room/#{room_id}/state.json", nil} + end + conn |> put_html_content_type() - |> send_resp(200, Mediasync.Templates.room(Mediasync.Room.get_video_url(pid))) + |> send_resp( + 200, + Mediasync.Templates.room(video_info, websocket_path, state_url, home_url) + ) [] -> Mediasync.HTTPErrors.send_not_found(conn) @@ -77,22 +126,48 @@ defmodule Mediasync.Router do end get "/room/:room_id/websocket" do - # TODO: verify origin before doing any of this + if MapSet.member?( + Application.fetch_env!(:mediasync, :websocket_origin), + hd(get_req_header(conn, "origin")) + ) do + user_token_hash = get_user_token_hash!(conn) + room_id = conn.path_params["room_id"] + + case Registry.lookup(Mediasync.RoomRegistry, room_id) do + [{pid, _value}] -> + conn + |> WebSockAdapter.upgrade( + Mediasync.RoomConnection, + %Mediasync.RoomConnection.State{ + room_pid: pid, + room_id: room_id, + user_token_hash: user_token_hash + }, + max_frame_size: Application.fetch_env!(:mediasync, :websocket_max_frame_octets) + ) + + [] -> + Mediasync.HTTPErrors.send_not_found(conn) + end + else + Mediasync.HTTPErrors.send_forbidden(conn) + end + end - user_token_hash = get_user_token_hash!(conn) + get "/room/:room_id/state.json" do room_id = conn.path_params["room_id"] case Registry.lookup(Mediasync.RoomRegistry, room_id) do [{pid, _value}] -> conn - |> WebSockAdapter.upgrade( - Mediasync.RoomConnection, - %Mediasync.RoomConnection.State{ - room_pid: pid, - room_id: room_id, - user_token_hash: user_token_hash - }, - max_frame_size: Application.fetch_env!(:mediasync, :websocket_max_frame_octets) + |> put_json_content_type() + |> send_resp( + 200, + Jason.encode!(%{ + "hostConnected" => Mediasync.Room.host_connected?(pid), + "viewersConnected" => + Registry.count_match(Mediasync.RoomSubscriptionRegistry, room_id, nil) + }) ) [] -> @@ -100,14 +175,24 @@ defmodule Mediasync.Router do end end + get "/room/:room_id/video" do + room_id = conn.path_params["room_id"] + + case Registry.lookup(Mediasync.RoomRegistry, room_id) do + [{pid, _value}] -> + redirect(conn, status: 301, location: Mediasync.Room.get_video_info(pid).url) + + [] -> + Mediasync.HTTPErrors.send_not_found(conn) + end + end + match _ do Mediasync.HTTPErrors.send_not_found(conn) end @impl Plug.ErrorHandler def handle_errors(conn, %{kind: kind, reason: reason, stack: _stack}) do - IO.inspect({kind, reason}) - case {kind, reason} do {:error, %Plug.CSRFProtection.InvalidCSRFTokenError{}} -> Mediasync.HTTPErrors.send_invalid_csrf_token(conn) @@ -116,4 +201,30 @@ defmodule Mediasync.Router do Mediasync.HTTPErrors.send_unknown(conn) end end + + defp session_wrapper(conn, _opts) 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())))) + + Plug.Session.call( + conn, + Plug.Session.init( + store: :cookie, + key: "_mediasync_session", + encryption_salt: {Mediasync.Utils, :get_session_encryption_salt, []}, + signing_salt: {Mediasync.Utils, :get_session_signing_salt, []}, + extra: + if in_discord_activity? do + "Domain=#{Application.fetch_env!(:mediasync, :discord_client_id)}.discordsays.com; SameSite=None; Partitioned; Secure;" + else + "" + end + ) + ) + end end diff --git a/lib/mediasync/templates.ex b/lib/mediasync/templates.ex index fb91aa0..c6b15cc 100644 --- a/lib/mediasync/templates.ex +++ b/lib/mediasync/templates.ex @@ -1,5 +1,18 @@ defmodule Mediasync.Templates do require EEx - EEx.function_from_file(:def, :home, "priv/home.html.eex") - EEx.function_from_file(:def, :room, "priv/room.html.eex", [:video_url]) + + def home() do + home(:normal) + end + + EEx.function_from_file(:def, :home, "priv/home.html.eex", [:mode]) + + EEx.function_from_file(:def, :room, "priv/room.html.eex", [ + :video_info, + :websocket_path, + :state_url, + :home_url + ]) + + EEx.function_from_file(:def, :discord_activity, "priv/discord_activity.html.eex") end diff --git a/lib/mediasync/utils.ex b/lib/mediasync/utils.ex index fa0ab68..407a9a4 100644 --- a/lib/mediasync/utils.ex +++ b/lib/mediasync/utils.ex @@ -20,6 +20,21 @@ defmodule Mediasync.Utils do |> send_resp(status, "Redirecting to #{location}") end + def put_resp_header_or_ignore(conn, key, value) do + if value do + Plug.Conn.put_resp_header(conn, key, value) + else + conn + end + end + + @spec get_req_header_list(Plug.Conn.t(), [String.t()]) :: [{String.t(), String.t()}] + def get_req_header_list(conn, keys) do + for key <- keys, value = List.first(Plug.Conn.get_req_header(conn, key)) do + {key, value} + end + end + @spec put_secret_key_base(Plug.Conn.t()) :: Plug.Conn.t() @spec put_secret_key_base(Plug.Conn.t(), []) :: Plug.Conn.t() def put_secret_key_base(conn, _opts \\ []) do @@ -36,31 +51,9 @@ defmodule Mediasync.Utils do Application.fetch_env!(:mediasync, :session_signing_salt) end - @spec bool_to_int_repr(boolean()) :: 0 | 1 - @doc """ - Convert false to 0 and true to 1. Useful for sending boolean values over binary protocols. + def bool_to_int_repr(false), do: 0 + def bool_to_int_repr(true), do: 1 - Inverse of `int_repr_to_bool/1`. - """ - def bool_to_int_repr(bool) do - case bool do - false -> 0 - true -> 1 - end - end - - @spec int_repr_to_bool!(0 | 1) :: boolean() - @doc """ - Convert 0 to false and 1 to true. Useful for receiving boolean values over binary protocols. - Raises `ArgumentError` if given an argument other than 0 or 1. - - Inverse of `bool_to_int_repr/1`. - """ - def int_repr_to_bool!(int_repr) do - case int_repr do - 0 -> false - 1 -> true - _ -> raise ArgumentError - end - end + def int_repr_to_bool(0), do: false + def int_repr_to_bool(1), do: true end diff --git a/lib/mix/tasks/vendor.ex b/lib/mix/tasks/vendor.ex index 1f64236..06e3aa5 100644 --- a/lib/mix/tasks/vendor.ex +++ b/lib/mix/tasks/vendor.ex @@ -3,9 +3,29 @@ defmodule Mix.Tasks.Vendor do @impl Mix.Task def run([]) do - {_, 0} = System.cmd("npm", ~w(install)) + {_, 0} = System.cmd("npm", ~w(install --include=dev)) + File.cp_r!("node_modules/video.js/dist", "priv/static/video.js") File.cp_r!("node_modules/video.js/LICENSE", "priv/static/video.js/LICENSE") + + {_, 0} = System.cmd("npx", ~w(parcel build --target discord-embedded-app-sdk)) + + discord_readme_path = "priv/static/discord-embedded-app-sdk/README.md" + + File.cp_r!( + "node_modules/@discord/embedded-app-sdk/LICENSE.md", + discord_readme_path + ) + + File.write!( + discord_readme_path, + """ + This directory contains a bundled version of https://github.com/discord/embedded-app-sdk/ + See lib/mix/tasks/vendor.ex for how it was generated. The license for the \ + original library is reproduced below:\n + """ <> File.read!(discord_readme_path) + ) + nil end end -- cgit v1.2.3-57-g22cb