aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--README.md5
-rw-r--r--config/runtime.exs6
-rw-r--r--lib/mediasync/application.ex7
-rw-r--r--lib/mediasync/discord_api.ex15
-rw-r--r--lib/mediasync/http_errors.ex34
-rw-r--r--lib/mediasync/room.ex23
-rw-r--r--lib/mediasync/router.ex116
-rw-r--r--lib/mediasync/utils.ex6
-rw-r--r--priv/discord_activity.html.eex44
-rw-r--r--priv/static/discordActivity.js113
-rw-r--r--priv/static/discord_activity.js20
-rw-r--r--priv/static/room/displayState.js5
13 files changed, 323 insertions, 74 deletions
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()}" %>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -6,25 +7,58 @@
<meta name="robots" content="noindex, nofollow">
<title>discord activity | mediasync</title>
<style>
+ :root {
+ --instance-room-info-height: 20svh;
+ --instance-room-info-remainder: calc(100svh - var(--instance-room-info-height));
+ }
+
body {
overflow: hidden;
+ background-color: black;
+ color: white;
+ font-family: monospace;
}
iframe {
border: none;
}
html, body, iframe {
margin: 0;
- width: 100%;
- height: 100%;
+ width: 100svw;
+ height: 100svh;
+ }
+
+ iframe.at-home {
+ height: var(--instance-room-info-remainder);
+ }
+
+ div#instance-room-info {
+ display: none;
+ overflow: auto;
+ }
+
+ body:has(> iframe.at-home) > div#instance-room-info {
+ display: block;
+ height: var(--instance-room-info-height);
+ border-bottom: 1px solid white;
}
</style>
</head>
-<body>
- <iframe src="/.proxy/?<%= Mediasync.Constants.query_param_discord_activity_inner() %>"></iframe>
+<body data-csrf-token="<%= Plug.CSRFProtection.get_csrf_token() %>">
+ <div id="instance-room-info">
+ <span style="font-weight: bold;">currently hosting:</span>
+ <div id="instance-room-info-content"></div>
+ </div>
+ </div>
+ <iframe src="<%= home_url %>" style="display: none;" class="at-home" name="activity-inner-iframe"></iframe>
<script>
const DISCORD_CLIENT_ID = "<%= Application.fetch_env!(:mediasync, :discord_client_id) %>";
+ const ACCESS_TOKEN_URL = "/.proxy/discord_activity/access_token?<%= Mediasync.Constants.query_param_discord_activity_inner() %>";
+ const HOME_URL = "<%= home_url %>";
+ const roomUrl = (roomId) => {
+ return `/.proxy/room/${roomId}?<%= Mediasync.Constants.query_param_discord_activity_inner() %>`;
+ };
</script>
<script src="/static/discord-embedded-app-sdk/Discord.js" type="module"></script>
- <script src="/static/discord_activity.js" type="module"></script>
+ <script src="/static/discordActivity.js" type="module"></script>
</body>
</html>
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;
});
}