diff --git a/guides/api.md b/guides/api.md new file mode 100644 index 000000000..66a457fdd --- /dev/null +++ b/guides/api.md @@ -0,0 +1,39 @@ +# Hubs Server API +Reticulum includes a [GraphQL](https://graphql.org/) API to better allow you to write plugins or customize the app to your needs. + +## Accessing the API +The API can be accessed by sending `GET` or `POST` requests to `/api/v2_alpha/` with a valid GraphQL document in the request body. Note: This path is subject to change as we get out of early testing. + +Requests can be sent by a variety of standard tools: +- an `HTTP` client library, +- a command line tool like `curl`, +- a GraphQL-specific client library, +- any other tool that speaks `HTTP`. + +Reticulum ships with [GraphiQL](https://github.com/graphql/graphiql/tree/main/packages/graphiql#graphiql), a graphical interactive in-browser GraphQL IDE that makes it easy to test and learn the API. It can be accessed by navigating to `/api/v2_alpha/graphiql`. [This example workspace](../test/api/v2/graphiql-workspace-2020-10-28-15-28-39.json) demonstrates several queries and can be loaded into the GraphiQL interface. You will have to generate and supply your own API access tokens. + +## Authentication and Authorization +Most requests require an API Access Token for authentication and authorization. + +### API Access Token Types +There are two types of API Access Tokens: +- `:account` tokens act on behalf of a specific user +- `:app` tokens act on behalf of the hubs cloud itself + +### Scopes +When generating API Access Tokens, you specify which `scopes` to grant that token. Scopes allow the token to be used to perform specific actions. + +| Scope | API Actions | +| --: | --- | +| `read_rooms` | `myRooms`, `favoriteRooms`, `publicRooms` | +| `write_rooms` | `createRoom`, `updateRoom` | + +Scopes, actions, and token types are expected to expand over time. + +Tokens can be generated on the command line with `mix generate_api_token`. Soon this method will be replaced with a web API and interface. + +### Using API Access Tokens + +To attach an API Access Token to a request, add the `HTTP` header `Authorization` with value `Bearer: `. + + diff --git a/lib/mix/tasks/generate_api_token.ex b/lib/mix/tasks/generate_api_token.ex new file mode 100644 index 000000000..f58dd8b51 --- /dev/null +++ b/lib/mix/tasks/generate_api_token.ex @@ -0,0 +1,78 @@ +defmodule Mix.Tasks.GenerateApiToken do + @moduledoc "Generates an Api Token for the given account email" + + use Mix.Task + + alias Ret.{Account} + alias Ret.Api.TokenUtils + + @impl Mix.Task + def run(_) do + user_or_app = + "Generate user token or app token? [user or app]" + |> Mix.shell().prompt() + |> String.trim() + + case user_or_app do + "user" -> + gen_user_token() + + "app" -> + gen_app_token() + + _ -> + Mix.shell().error("Input not recognized. Type \"user\" or \"app\".") + run([]) + end + end + + defp gen_user_token() do + email = + "Enter email address of the user whose account will be associated in this token: [foo@bar.com]\n" + |> Mix.shell().prompt() + |> String.trim() + + Mix.Task.run("app.start") + + case Account.account_for_email(email) do + nil -> + Mix.shell().error("Could not find account for the given email address: #{email}") + + account -> + IO.puts("Account found:") + + account + |> Inspect.Algebra.to_doc(%Inspect.Opts{}) + |> Inspect.Algebra.format(80) + |> IO.puts() + + if Mix.shell().yes?("Generate token for this account [#{email}]?") do + gen_token_for_account(account) + end + end + end + + defp gen_app_token() do + if Mix.shell().yes?("Are you sure you want to generate an app token?") do + Mix.Task.run("app.start") + + case TokenUtils.gen_app_token() do + {:ok, token, _claims} -> + Mix.shell().info("Successfully generated token:\n#{token}") + + {:error, reason} -> + Mix.shell().error("Error: #{reason}") + end + end + end + + defp gen_token_for_account(account) do + case TokenUtils.gen_token_for_account(account) do + {:ok, token, _claims} -> + Mix.shell().info("Successfully generated token:\n#{token}") + + {:error, reason} -> + Mix.shell().error("Error: #{reason}") + end + end +end diff --git a/lib/ret/account.ex b/lib/ret/account.ex index 3a052c0a1..d56f20076 100644 --- a/lib/ret/account.ex +++ b/lib/ret/account.ex @@ -24,6 +24,14 @@ defmodule Ret.Account do timestamps() end + def query do + from(account in Account) + end + + def where_account_id_is(query, id) do + from(account in query, where: account.account_id == ^id) + end + def has_accounts?(), do: from(a in Account, limit: 1) |> Repo.exists?() def has_admin_accounts?(), do: from(a in Account, limit: 1) |> where(is_admin: true) |> Repo.exists?() def exists_for_email?(email), do: account_for_email(email) != nil diff --git a/lib/ret/api/can_credentials.ex b/lib/ret/api/can_credentials.ex new file mode 100644 index 000000000..4d003d757 --- /dev/null +++ b/lib/ret/api/can_credentials.ex @@ -0,0 +1,103 @@ +defimpl Canada.Can, for: Ret.Api.Credentials do + import Canada, only: [can?: 2] + alias Ret.{Account, Hub} + alias Ret.Api.{Credentials, Scopes} + + def can?( + %Credentials{is_revoked: true}, + _action, + _resource + ) do + false + end + + def can?( + %Credentials{subject_type: :app, scopes: scopes}, + :get_rooms_created_by, + %Account{} = account + ) do + Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_rooms_created_by(account)) + end + + def can?( + %Credentials{subject_type: :account, account: subject, scopes: scopes}, + :get_rooms_created_by, + %Account{} = account + ) do + Scopes.read_rooms() in scopes and can?(subject, get_rooms_created_by(account)) + end + + def can?( + %Credentials{subject_type: :app, scopes: scopes}, + :get_favorite_rooms_of, + %Account{} = account + ) do + Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_favorite_rooms_of(account)) + end + + def can?( + %Credentials{subject_type: :account, account: subject, scopes: scopes}, + :get_favorite_rooms_of, + %Account{} = account + ) do + Scopes.read_rooms() in scopes and can?(subject, get_favorite_rooms_of(account)) + end + + def can?( + %Credentials{subject_type: :app, scopes: scopes}, + :get_public_rooms, + _ + ) do + Scopes.read_rooms() in scopes and can?(:reticulum_app_token, get_public_rooms(nil)) + end + + def can?( + %Credentials{subject_type: :account, account: subject, scopes: scopes}, + :get_public_rooms, + _ + ) do + Scopes.read_rooms() in scopes and can?(subject, get_public_rooms(nil)) + end + + def can?( + %Credentials{subject_type: :app, scopes: scopes}, + :create_room, + _ + ) do + Scopes.write_rooms() in scopes && can?(:reticulum_app_token, create_hub(nil)) + end + + def can?( + %Credentials{subject_type: :account, account: subject, scopes: scopes}, + :create_room, + _ + ) do + Scopes.write_rooms() in scopes && can?(subject, create_hub(nil)) + end + + def can?(%Credentials{subject_type: :app, scopes: scopes}, :embed_hub, %Hub{} = hub) do + Scopes.read_rooms() in scopes && can?(:reticulum_app_token, embed_hub(hub)) + end + + def can?(%Credentials{subject_type: :account, account: subject, scopes: scopes}, :embed_hub, %Hub{} = hub) do + Scopes.read_rooms() in scopes && can?(subject, embed_hub(hub)) + end + + def can?( + %Credentials{subject_type: :app, scopes: scopes}, + :update_room, + %Hub{} = hub + ) do + Scopes.write_rooms() in scopes && can?(:reticulum_app_token, update_hub(hub)) + end + + def can?( + %Credentials{subject_type: :account, account: subject, scopes: scopes}, + :update_room, + %Hub{} = hub + ) do + Scopes.write_rooms() in scopes && can?(subject, update_hub(hub)) + end + + def can?(_, _, _), do: false +end diff --git a/lib/ret/api/credentials.ex b/lib/ret/api/credentials.ex new file mode 100644 index 000000000..b6b4c7cc4 --- /dev/null +++ b/lib/ret/api/credentials.ex @@ -0,0 +1,128 @@ +defmodule Ret.Api.Credentials do + @moduledoc """ + Credentials for API access. + """ + alias Ret.Api.Credentials + + alias Ret.Account + + use Ecto.Schema + import Ecto.Query, only: [from: 2] + import Ecto.Changeset + alias Ret.Api.{TokenSubjectType, ScopeType} + + @schema_prefix "ret0" + @primary_key {:api_credentials_id, :id, autogenerate: true} + + schema "api_credentials" do + field(:api_credentials_sid, :string) + field(:token_hash, :string) + field(:subject_type, TokenSubjectType) + field(:is_revoked, :boolean) + field(:scopes, {:array, ScopeType}) + + belongs_to(:account, Account, references: :account_id) + timestamps() + end + + @required_keys [:api_credentials_sid, :token_hash, :subject_type, :is_revoked, :scopes] + @permitted_keys @required_keys + + def generate_credentials(%{subject_type: _st, scopes: _sc, account_or_nil: account_or_nil} = params) do + sid = Ret.Sids.generate_sid() + + # Use 18 bytes (not 16, the default) to avoid having all tokens end in "09" + # See https://github.com/patricksrobertson/secure_random.ex/issues/11 + # Prefix the sid to the rest of the token for ease of management + token = "#{sid}.#{SecureRandom.urlsafe_base64(18)}" + + params = + Map.merge(params, %{ + api_credentials_sid: sid, + token_hash: Ret.Crypto.hash(token), + is_revoked: false + }) + + case %Credentials{} + |> change() + |> cast(params, @permitted_keys) + |> maybe_put_assoc_account(account_or_nil) + |> validate_required(@required_keys) + |> validate_change(:subject_type, &validate_field/2) + |> validate_change(:scopes, &validate_field/2) + |> unique_constraint(:api_credentials_sid) + |> unique_constraint(:token_hash) + # TODO: We can pass multiple fields to unique_contraint when we update ecto + # https://github.com/elixir-ecto/ecto/pull/3276 + |> Ret.Repo.insert() do + {:ok, credentials} -> + {:ok, token, credentials} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_put_assoc_account(changeset, %Account{} = account) do + put_assoc(changeset, :account, account) + end + + defp maybe_put_assoc_account(changeset, nil) do + changeset + end + + defp validate_single_scope_type(scope) do + if ScopeType.valid_value?(scope) do + [] + else + [invalid_scope: "Unrecognized scope type. Got #{scope}."] + end + end + + def validate_field(:scopes, scopes) do + Enum.reduce(scopes, [], fn scope, errors -> + errors ++ validate_single_scope_type(scope) + end) + end + + def validate_field(:subject_type, subject_type) do + if TokenSubjectType.valid_value?(subject_type) do + [] + else + [invalid_subject_type: "Unrecognized subject type. Must be app or account. Got #{subject_type}."] + end + end + + def revoke(credentials) do + credentials + |> change() + |> put_change(:is_revoked, true) + |> Ret.Repo.update() + end + + def query do + from(c in Credentials, left_join: a in Account, on: c.account_id == a.account_id, preload: [account: a]) + end + + def where_sid_is(query, sid) do + from([credential, _account] in query, + where: credential.api_credentials_sid == ^sid + ) + end + + def where_token_hash_is(query, hash) do + from([credential, _account] in query, + where: credential.token_hash == ^hash + ) + end + + def where_account_is(query, %Account{account_id: id}) do + from([credential, _account] in query, + where: credential.account_id == ^id + ) + end + + def app_token_query() do + from(c in Credentials, where: c.subject_type == ^:app) + end +end diff --git a/lib/ret/api/dataloader.ex b/lib/ret/api/dataloader.ex new file mode 100644 index 000000000..cc73be3bf --- /dev/null +++ b/lib/ret/api/dataloader.ex @@ -0,0 +1,11 @@ +defmodule Ret.Api.Dataloader do + @moduledoc "Configuration for dataloader" + + import Ecto.Query + alias Ret.{Repo, Scene, SceneListing} + + def source(), do: Dataloader.Ecto.new(Repo, query: &query/2) + # Guard against loading removed scenes or delisted scene listings + def query(Scene, _), do: from(s in Scene, where: s.state != ^:removed) + def query(SceneListing, _), do: from(sl in SceneListing, where: sl.state != ^:delisted) +end diff --git a/lib/ret/api/rooms.ex b/lib/ret/api/rooms.ex new file mode 100644 index 000000000..8889dd342 --- /dev/null +++ b/lib/ret/api/rooms.ex @@ -0,0 +1,112 @@ +defmodule Ret.Api.Rooms do + @moduledoc "Functions for accessing rooms in an authenticated way" + + alias Ret.{Account, Hub, Repo} + alias RetWeb.Api.V1.HubView + alias Ret.Api.{Credentials} + + import Canada, only: [can?: 2] + + def authed_get_embed_token(%Credentials{} = credentials, hub) do + if can?(credentials, embed_hub(hub)) do + {:ok, hub.embed_token} + else + {:ok, nil} + end + end + + def authed_get_rooms_created_by(%Account{} = account, %Credentials{} = credentials, params) do + if can?(credentials, get_rooms_created_by(account)) do + {:ok, Hub.get_my_rooms(account, params)} + else + {:error, :invalid_credentials} + end + end + + def authed_get_favorite_rooms_of(%Account{} = account, %Credentials{} = credentials, params) do + if can?(credentials, get_favorite_rooms_of(account)) do + {:ok, Hub.get_favorite_rooms(account, params)} + else + {:error, :invalid_credentials} + end + end + + def authed_get_public_rooms(%Credentials{} = credentials, params) do + if can?(credentials, get_public_rooms(nil)) do + {:ok, Hub.get_public_rooms(params)} + else + {:error, :invalid_credentials} + end + end + + def authed_create_room(%Credentials{} = credentials, params) do + if can?(credentials, create_room(nil)) do + Hub.create_room(params, credentials.account) + else + {:error, :invalid_credentials} + end + end + + def authed_update_room(hub_sid, %Credentials{} = credentials, params) do + hub = Hub |> Repo.get_by(hub_sid: hub_sid) |> Repo.preload([:hub_role_memberships, :hub_bindings]) + + if is_nil(hub) do + {:error, "Cannot find room with id: " <> hub_sid} + else + if can?(credentials, update_room(hub)) do + do_update_room(hub, credentials, params) + else + {:error, :invalid_credentials} + end + end + end + + defp do_update_room(hub, %Credentials{subject_type: :app}, params) do + hub + |> Repo.preload(Hub.hub_preloads()) + |> Hub.add_attrs_to_changeset(params) + |> Hub.maybe_add_member_permissions(hub, params) + |> Hub.add_scene_changes_to_changeset(params) + |> try_do_update_room(:reticulum_app_token) + end + + defp do_update_room(hub, %Credentials{subject_type: :account, account: account}, params) do + hub + |> Repo.preload(Hub.hub_preloads()) + |> Hub.add_attrs_to_changeset(params) + |> Hub.maybe_add_member_permissions(hub, params) + |> Hub.maybe_add_promotion(account, hub, params) + |> Hub.add_scene_changes_to_changeset(params) + |> try_do_update_room(account) + end + + defp try_do_update_room({:error, reason}, _) do + {:error, reason} + end + + defp try_do_update_room(changeset, subject) do + case changeset |> Repo.update() do + {:error, changeset} -> + {:error, changeset} + + {:ok, hub} -> + hub = Repo.preload(hub, Hub.hub_preloads()) + + case broadcast_hub_refresh(hub, subject, Map.keys(changeset.changes) |> Enum.map(&Atom.to_string(&1))) do + {:error, reason} -> {:error, reason} + :ok -> {:ok, hub} + end + end + end + + defp broadcast_hub_refresh(hub, subject, stale_fields) do + payload = + HubView.render("show.json", %{ + hub: hub, + embeddable: subject |> can?(embed_hub(hub)) + }) + |> Map.put(:stale_fields, stale_fields) + + RetWeb.Endpoint.broadcast("hub:" <> hub.hub_sid, "hub_refresh", payload) + end +end diff --git a/lib/ret/api/scopes.ex b/lib/ret/api/scopes.ex new file mode 100644 index 000000000..f3739a730 --- /dev/null +++ b/lib/ret/api/scopes.ex @@ -0,0 +1,11 @@ +defmodule Ret.Api.Scopes do + @moduledoc false + def read_rooms, do: :read_rooms + def write_rooms, do: :write_rooms + + def all_scopes, + do: [ + read_rooms(), + write_rooms() + ] +end diff --git a/lib/ret/api/token.ex b/lib/ret/api/token.ex new file mode 100644 index 000000000..4c2d71d25 --- /dev/null +++ b/lib/ret/api/token.ex @@ -0,0 +1,18 @@ +defmodule Ret.Api.Token do + @moduledoc """ + ApiTokens determine what actions are allowed to be taken via the public API. + """ + use Guardian, token_module: Ret.Api.TokenModule, otp_app: :ret + + alias Ret.Api.Credentials + + def subject_for_token(_, _), do: {:ok, nil} + + def resource_from_claims(%Credentials{} = credentials) do + {:ok, credentials} + end + + def resource_from_claims(_) do + {:error, :invalid_token} + end +end diff --git a/lib/ret/api/token_module.ex b/lib/ret/api/token_module.ex new file mode 100644 index 000000000..ecb5cb06a --- /dev/null +++ b/lib/ret/api/token_module.ex @@ -0,0 +1,109 @@ +defmodule Ret.Api.TokenModule do + @moduledoc """ + This module should not be used directly. + + It is intended to be used by Guardian. + """ + alias Ret.{Account, Repo} + alias Ret.Api.Credentials + @behaviour Guardian.Token + + @doc """ + No concept of validating signature so we just decode the token + """ + def peek(mod, token) do + case decode_token(mod, token) do + {:ok, %Credentials{} = credentials} -> %{claims: credentials} + {:ok, {:error, _reason}} -> nil + _ -> nil + end + end + + @doc """ + Do not need to generate a token_id here + """ + def token_id, do: nil + + @doc """ + Builds the default claims for API tokens. + """ + def build_claims(_mod, _resource, _sub, claims \\ %{}, _options \\ []) do + {:ok, claims} + end + + defp ensure_atom(x) when is_atom(x), do: x + defp ensure_atom(x) when is_binary(x), do: String.to_atom(x) + + defp get_account(id) when is_nil(id) do + {:ok, nil} + end + + defp get_account(id) do + case Account.query() + |> Account.where_account_id_is(id) + |> Repo.one() do + nil -> {:error, "Could not find account"} + account -> {:ok, account} + end + end + + @doc """ + Create a token. + """ + def create_token(_mod, claims, _options \\ []) do + account_id = Map.get(claims, "account_id", nil) + + case get_account(account_id) do + {:ok, account_or_nil} -> + case Ret.Api.Credentials.generate_credentials(%{ + subject_type: ensure_atom(Map.get(claims, "subject_type")), + scopes: Map.get(claims, "scopes"), + account_or_nil: account_or_nil + }) do + {:ok, token, _credentials} -> {:ok, token} + _ -> {:error, "Failed to create token for claims."} + end + + {:error, _reason} -> + {:error, "Failed to create token for claims."} + end + end + + @doc """ + Decodes the token and validates the signature. + """ + def decode_token(_mod, token, _options \\ []) do + case Credentials.query() + |> Credentials.where_token_hash_is(Ret.Crypto.hash(token)) + |> Ret.Repo.one() do + # Don't want to return the error at this level, + # so we pass it along for graphql to handle + nil -> {:ok, {:error, :invalid_token}} + credentials -> {:ok, credentials} + end + end + + @doc """ + Verifies the claims. + """ + def verify_claims(_mod, claims, _options) do + {:ok, claims} + end + + @doc """ + Revoke a token + """ + def revoke(_mod, %Credentials{} = credentials, _token, _options) do + Ret.Api.Credentials.revoke(credentials) + end + + @doc """ + Refresh the token + """ + def refresh(_mod, _old_token, _options), do: nil + + @doc """ + Exchange a token of one type to another. + """ + def exchange(_mod, _old_token, _from_type, _to_type, _options), do: nil +end diff --git a/lib/ret/api/token_utils.ex b/lib/ret/api/token_utils.ex new file mode 100644 index 000000000..15d725797 --- /dev/null +++ b/lib/ret/api/token_utils.ex @@ -0,0 +1,115 @@ +defmodule Ret.Api.TokenUtils do + @moduledoc """ + Utility functions for generating API access tokens. + """ + alias Ret.{Account, Repo} + alias Ret.Api.{Credentials, Token, Scopes} + + import Canada, only: [can?: 2] + + def gen_app_token(scopes \\ [Scopes.read_rooms(), Scopes.write_rooms()]) do + Token.encode_and_sign(nil, %{ + subject_type: :app, + scopes: scopes, + account_id: nil + }) + end + + def gen_token_for_account(%Account{} = account, scopes \\ [Scopes.read_rooms(), Scopes.write_rooms()]) do + Token.encode_and_sign(nil, %{ + subject_type: :account, + scopes: scopes, + account_id: account.account_id + }) + end + + defp ensure_atom(x) when is_atom(x), do: x + defp ensure_atom(x) when is_binary(x), do: String.to_atom(x) + + defp account_id_from_args(%Account{}, %{"account_id" => account_id}) do + # This account wants to create credentials on behalf of another account + account_id + end + + defp account_id_from_args(%Account{} = account, _params) do + # This account wants to create credentials for itself + account.account_id + end + + defp validate_account_id(%Account{account_id: account_id}, account_id) do + # Skip a trip to the DB if account is creating credentials for itself + [] + end + + defp validate_account_id(%Account{}, account_id) do + # Make sure the given account_id refers to an account that actually exists + validate_field(:account_id, account_id) + end + + defp validate_field(:account_id, account_id) do + if Account.query() + |> Account.where_account_id_is(account_id) + |> Repo.exists?() do + [] + else + [account_id: "Invalid account id #{account_id}"] + end + end + + def to_claims(%Account{} = account, %{"subject_type" => subject_type, "scopes" => scopes} = params) do + account_id_for_claims = account_id_from_args(account, params) + + case validate_account_id(account, account_id_for_claims) ++ + Credentials.validate_field(:scopes, scopes) ++ + Credentials.validate_field(:subject_type, subject_type) do + [] -> + # It is safe to cast user-provided strings to atoms here + # because we validated that the strings match our atoms + # (with &valid_value?/1 from ecto_enum) + {:ok, + %{ + account_id: account_id_for_claims, + subject_type: ensure_atom(subject_type), + scopes: Enum.map(scopes, &ensure_atom/1) + }} + + error_list -> + {:error, error_list} + end + end + + def authed_create_credentials(account, claims) do + if can?(account, create_credentials(claims)) do + Token.encode_and_sign(nil, claims) + else + {:error, :unauthorized} + end + end + + def authed_list_credentials(account, subject_type) do + if can?(account, list_credentials(subject_type)) do + list_credentials(account, subject_type) + else + {:error, :unauthorized} + end + end + + defp list_credentials(account, :account) do + Credentials.query() + |> Credentials.where_account_is(account) + |> Repo.all() + end + + defp list_credentials(_account, :app) do + Credentials.app_token_query() + |> Repo.all() + end + + def authed_revoke_credentials(account, credentials) do + if can?(account, revoke_credentials(credentials)) do + Credentials.revoke(credentials) + else + {:error, :unauthorized} + end + end +end diff --git a/lib/ret/enums.ex b/lib/ret/enums.ex index 89cd8614a..580b1bebb 100644 --- a/lib/ret/enums.ex +++ b/lib/ret/enums.ex @@ -11,3 +11,5 @@ defenum(Ret.Avatar.State, :avatar_state, [:active, :removed], schema: "ret0") defenum(Ret.AvatarListing.State, :avatar_listing_state, [:active, :delisted, :removed], schema: "ret0") defenum(Ret.Account.State, :account_state, [:enabled, :disabled], schema: "ret0") defenum(Ret.Asset.Type, :asset_type, [:image, :video, :model, :audio], schema: "ret0") +defenum(Ret.Api.TokenSubjectType, :api_token_subject_type, [:app, :account], schema: "ret0") +defenum(Ret.Api.ScopeType, :api_scope_type, Ret.Api.Scopes.all_scopes(), schema: "ret0") diff --git a/lib/ret/hub.ex b/lib/ret/hub.ex index 52cd745c1..9ba3b3a7f 100644 --- a/lib/ret/hub.ex +++ b/lib/ret/hub.ex @@ -24,7 +24,8 @@ defmodule Ret.Hub do RoomAssigner, BitFieldUtils, HubRoleMembership, - AppConfig + AppConfig, + AccountFavorite } alias Ret.Hub.{HubSlug} @@ -100,8 +101,8 @@ defmodule Ret.Hub do field(:spawned_object_types, :integer, default: 0) field(:entry_mode, Ret.Hub.EntryMode) field(:user_data, :map) - belongs_to(:scene, Ret.Scene, references: :scene_id) - belongs_to(:scene_listing, Ret.SceneListing, references: :scene_listing_id) + belongs_to(:scene, Ret.Scene, references: :scene_id, on_replace: :nilify) + belongs_to(:scene_listing, Ret.SceneListing, references: :scene_listing_id, on_replace: :nilify) has_many(:web_push_subscriptions, Ret.WebPushSubscription, foreign_key: :hub_id) belongs_to(:created_by_account, Ret.Account, references: :account_id) has_many(:hub_invites, Ret.HubInvite, foreign_key: :hub_id) @@ -115,6 +116,202 @@ defmodule Ret.Hub do timestamps() end + @required_keys [ + :name, + :hub_sid, + :host, + :entry_code, + :entry_code_expires_at, + :embed_token, + :member_permissions, + :max_occupant_count, + :spawned_object_types, + :room_size + ] + @permitted_keys [ + :creator_assignment_token, + :description, + :embedded, + :default_environment_gltf_bundle_url, + :user_data, + :last_active_at, + :entry_mode, + :allow_promotion | @required_keys + ] + + # TODO: This function was created for use in the public API. + # It would be good to revisit this and the alternatives below + # so that there did not need to be as many variations. + def create_room(params, account_or_nil) do + with {:ok, params} <- parse_member_permissions(params) do + params = + Map.merge( + %{ + name: Ret.RandomRoomNames.generate_room_name(), + hub_sid: Ret.Sids.generate_sid(), + host: RoomAssigner.get_available_host(nil), + entry_code: generate_entry_code!(), + entry_code_expires_at: + Timex.now() + |> Timex.shift(hours: @entry_code_expiration_hours) + |> DateTime.truncate(:second), + creator_assignment_token: SecureRandom.hex(), + embed_token: SecureRandom.hex(), + member_permissions: default_member_permissions(), + room_size: AppConfig.get_cached_config_value("features|default_room_size") + }, + params + ) + + result = + %Hub{} + |> change() + |> cast(params, @permitted_keys) + |> add_account_to_changeset(account_or_nil) + |> add_scene_changes_to_changeset(params) + |> HubSlug.maybe_generate_slug() + |> validate_required(@required_keys) + |> validate_length(:name, max: 64) + |> validate_length(:description, max: 64_000) + |> validate_number(:room_size, + greater_than_or_equal_to: 0, + less_than_or_equal_to: AppConfig.get_cached_config_value("features|max_room_size") + ) + |> unique_constraint(:hub_sid) + |> unique_constraint(:entry_code) + |> Repo.insert() + + case result do + {:ok, hub} -> + {:ok, Repo.preload(hub, hub_preloads())} + + _ -> + result + end + end + end + + # TODO: Clean up handling of member_permissions so that it is + # clear everywhere whether we are dealing with a map or an int + defp parse_member_permissions(%{member_permissions: map} = params) when is_map(map) do + case Hub.lenient_member_permissions_to_int(map) do + {:ok, member_permissions} -> + {:ok, %{params | member_permissions: member_permissions}} + + {ArgumentError, e} -> + {:error, e} + end + end + + defp parse_member_permissions(%{member_permissions: nil} = params) do + {:ok, Map.delete(params, :member_permissions)} + end + + defp parse_member_permissions(params) do + {:ok, params} + end + + defp default_member_permissions() do + if Ret.AppConfig.get_config_bool("features|permissive_rooms") do + member_permissions_to_int(@default_member_permissions) + else + member_permissions_to_int(@default_restrictive_member_permissions) + end + end + + def add_scene_changes_to_changeset(changeset, %{} = params) do + add_scene_changes(changeset, scene_change_from_params(params)) + end + + defp scene_change_from_params(%{scene_id: nil, scene_url: nil}) do + :nilify + end + + defp scene_change_from_params(%{scene_id: _id, scene_url: _url}) do + {:error, + %{key: :scene_id, message: "Cannot specify both scene_id and scene_url. Choose one or the other (or neither)."}} + end + + defp scene_change_from_params(%{scene_url: nil}) do + :nilify + end + + defp scene_change_from_params(%{scene_id: nil}) do + :nilify + end + + defp scene_change_from_params(%{scene_url: url}) do + endpoint_host = RetWeb.Endpoint.host() + + case url |> URI.parse() do + %URI{host: ^endpoint_host, path: "/scenes/" <> scene_path} -> + scene_or_scene_listing = scene_path |> String.split("/") |> Enum.at(0) |> Scene.scene_or_scene_listing_by_sid() + + if is_nil(scene_or_scene_listing) do + {:error, %{key: :scene_url, message: "Cannot find scene with url: " <> url}} + else + scene_or_scene_listing + end + + _ -> + url + end + end + + defp scene_change_from_params(%{scene_id: id}) do + scene_or_scene_listing = Scene.scene_or_scene_listing_by_sid(id) + + if is_nil(scene_or_scene_listing) do + {:error, %{key: :scene_id, message: "Cannot find scene with id: " <> id}} + else + scene_or_scene_listing + end + end + + defp scene_change_from_params(_params) do + nil + end + + defp add_scene_changes(changeset, {:error, %{key: key, message: message}}) do + add_error(changeset, key, message) + end + + defp add_scene_changes(changeset, nil) do + # No scene info in params. Leave unchanged + changeset + end + + defp add_scene_changes(changeset, :nilify) do + # Clear scene info + changeset + |> put_change(:default_environment_gltf_bundle_url, nil) + |> put_assoc(:scene, nil) + |> put_assoc(:scene_listing, nil) + end + + defp add_scene_changes(changeset, %Scene{} = scene) do + changeset + |> put_assoc(:scene, scene) + |> put_assoc(:scene_listing, nil) + |> put_change(:default_environment_gltf_bundle_url, nil) + end + + defp add_scene_changes(changeset, %SceneListing{} = scene_listing) do + changeset + |> put_assoc(:scene, nil) + |> put_assoc(:scene_listing, scene_listing) + |> put_change(:default_environment_gltf_bundle_url, nil) + end + + defp add_scene_changes(changeset, url) do + changeset + |> cast(%{default_environment_gltf_bundle_url: url}, [:default_environment_gltf_bundle_url]) + # TODO: Should we validate the format of the URL? + |> validate_required([:default_environment_gltf_bundle_url]) + |> put_assoc(:scene, nil) + |> put_assoc(:scene_listing, nil) + end + # Create new room, inserts into db # returns newly created %Hub def create_new_room(%{"name" => _name} = params, true = _add_to_db) do @@ -134,6 +331,14 @@ defmodule Ret.Hub do |> changeset(scene_or_scene_listing, params) end + def create(params) do + scene_or_scene_listing = get_scene_or_scene_listing(params) + + %Hub{} + |> changeset(scene_or_scene_listing, params) + |> Repo.insert() + end + defp get_scene_or_scene_listing(params) do if is_nil(params["scene_id"]) do SceneListing.get_random_default_scene_listing() @@ -142,6 +347,14 @@ defmodule Ret.Hub do end end + defp get_scene_or_scene_listing_by_id(nil) do + SceneListing.get_random_default_scene_listing() + end + + defp get_scene_or_scene_listing_by_id(id) do + Scene.scene_or_scene_listing_by_sid(id) + end + def get_by_entry_code_string(entry_code_string) when is_binary(entry_code_string) do case Integer.parse(entry_code_string) do {entry_code, _} -> Hub |> Repo.get_by(entry_code: entry_code) @@ -149,6 +362,31 @@ defmodule Ret.Hub do end end + def get_my_rooms(account, params) do + Hub + |> where([h], h.created_by_account_id == ^account.account_id and h.entry_mode in ^["allow", "invite"]) + |> order_by(desc: :inserted_at) + |> preload(^Hub.hub_preloads()) + |> Repo.paginate(params) + end + + def get_favorite_rooms(account, params) do + Hub + |> where([h], h.entry_mode in ^["allow", "invite"]) + |> join(:inner, [h], f in AccountFavorite, on: f.hub_id == h.hub_id and f.account_id == ^account.account_id) + |> order_by([h, f], desc: f.last_activated_at) + |> preload(^Hub.hub_preloads()) + |> Repo.paginate(params) + end + + def get_public_rooms(params) do + Hub + |> where([h], h.allow_promotion and h.entry_mode in ^["allow", "invite"]) + |> order_by(desc: :inserted_at) + |> preload(^Hub.hub_preloads()) + |> Repo.paginate(params) + end + def changeset(%Hub{} = hub, %Scene{} = scene, attrs) do hub |> changeset(nil, attrs) @@ -190,9 +428,9 @@ defmodule Ret.Hub do attrs["member_permissions"] |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) |> member_permissions_to_int end - def add_member_permissions_update_to_changeset(changeset, hub, attrs) do + defp add_member_permissions_update_to_changeset(changeset, hub, member_permissions) do member_permissions = - Map.merge(member_permissions_for_hub(hub), attrs["member_permissions"]) + Map.merge(member_permissions_for_hub(hub), member_permissions) |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) |> member_permissions_to_int @@ -213,7 +451,8 @@ defmodule Ret.Hub do end def add_promotion_to_changeset(changeset, attrs) do - changeset |> put_change(:allow_promotion, !!attrs["allow_promotion"]) + changeset + |> put_change(:allow_promotion, Map.get(attrs, "allow_promotion", false) || Map.get(attrs, :allow_promotion, false)) end def maybe_add_entry_mode_to_changeset(changeset, attrs) do @@ -224,6 +463,20 @@ defmodule Ret.Hub do end end + def maybe_add_new_scene_to_changeset(changeset, %{scene_id: scene_id}) do + scene_or_scene_listing = get_scene_or_scene_listing_by_id(scene_id) + + if is_nil(scene_or_scene_listing) do + {:error, "Cannot find scene with id " <> scene_id} + else + Hub.add_new_scene_to_changeset(changeset, scene_or_scene_listing) + end + end + + def maybe_add_new_scene_to_changeset(changeset, _args) do + changeset + end + def changeset_for_new_seen_occupant_count(%Hub{} = hub, occupant_count) do new_max_occupant_count = max(hub.max_occupant_count, occupant_count) @@ -358,6 +611,15 @@ defmodule Ret.Hub do hub.room_size || AppConfig.get_cached_config_value("features|default_room_size") end + def scene_or_scene_listing_for(%Hub{} = hub) do + case hub.scene || hub.scene_listing do + nil -> nil + %Scene{state: :removed} -> nil + %SceneListing{state: :delisted} -> nil + scene_or_scene_listing -> scene_or_scene_listing + end + end + defp changeset_for_new_entry_code(%Hub{} = hub) do hub |> Ecto.Changeset.change() @@ -524,17 +786,35 @@ defmodule Ret.Hub do is_creator?(hub, account_id) || hub_role_memberships |> Enum.any?(&(&1.account_id === account_id)) end - def member_permissions_to_int(%{} = member_permissions) do + @doc """ + Lenient version of member permissions conversion + Does not throw on invalid permissions + """ + def lenient_member_permissions_to_int(%{} = member_permissions) do invalid_member_permissions = member_permissions |> Map.drop(@member_permissions_keys) |> Map.keys() if invalid_member_permissions |> Enum.count() > 0 do - raise ArgumentError, "Invalid permissions #{invalid_member_permissions |> Enum.join(", ")}" + {ArgumentError, "Invalid permissions #{invalid_member_permissions |> Enum.join(", ")}"} + else + {:ok, + @member_permissions + |> Enum.reduce(0, fn {val, member_permission}, acc -> + if(member_permissions[member_permission], do: val, else: 0) + acc + end)} end + end - @member_permissions - |> Enum.reduce(0, fn {val, member_permission}, acc -> - if(member_permissions[member_permission], do: val, else: 0) + acc - end) + # TODO: Rename (lenient_)member_permissions_to_int + # to follow the elixir pattern of using an exclamation mark (!) + # to indicate possibly raising an error + def member_permissions_to_int(%{} = member_permissions) do + case lenient_member_permissions_to_int(member_permissions) do + {:ok, int} -> + int + + {ArgumentError, e} -> + raise ArgumentError, e + end end def has_member_permission?(%Hub{} = hub, member_permission) do @@ -551,6 +831,43 @@ defmodule Ret.Hub do |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end) end + def member_permissions_for_hub_as_atoms(%Hub{} = hub) do + hub.member_permissions + |> BitFieldUtils.permissions_to_map(@member_permissions) + end + + def maybe_add_member_permissions(changeset, hub, %{"member_permissions" => member_permissions}) do + add_member_permissions_update_to_changeset( + changeset, + hub, + member_permissions + ) + end + + def maybe_add_member_permissions(changeset, _hub, %{:member_permissions => nil}) do + changeset + end + + def maybe_add_member_permissions(changeset, hub, %{:member_permissions => member_permissions}) do + add_member_permissions_update_to_changeset( + changeset, + hub, + Map.new(member_permissions, fn {k, v} -> {Atom.to_string(k), v} end) + ) + end + + def maybe_add_member_permissions(changeset, _hub, _params) do + changeset + end + + def maybe_add_promotion(changeset, account, hub, %{"allow_promotion" => _} = hub_params), + do: changeset |> Hub.maybe_add_promotion_to_changeset(account, hub, hub_params) + + def maybe_add_promotion(changeset, account, hub, %{allow_promotion: _} = hub_params), + do: changeset |> Hub.maybe_add_promotion_to_changeset(account, hub, hub_params) + + def maybe_add_promotion(changeset, _account, _hub, _), do: changeset + # The account argument here can be a Ret.Account, a Ret.OAuthProvider or nil. def perms_for_account(%Ret.Hub{} = hub, account) do %{ @@ -580,6 +897,36 @@ end defimpl Canada.Can, for: Ret.Account do alias Ret.{Hub, AppConfig} + alias Ret.Api.Credentials + + def can?(%Ret.Account{is_admin: is_admin}, :create_credentials, _params) do + is_admin + end + + def can?(%Ret.Account{is_admin: is_admin}, :list_credentials, :app) do + is_admin + end + + def can?(%Ret.Account{}, :list_credentials, :account) do + # TODO: Allow admins to disable this in config + true + end + + def can?(%Ret.Account{}, :list_credentials, _subject_type) do + false + end + + def can?(%Ret.Account{account_id: account_id}, :revoke_credentials, %Credentials{account_id: account_id}) do + true + end + + def can?(%Ret.Account{is_admin: true}, :revoke_credentials, %Credentials{}) do + true + end + + def can?(%Ret.Account{}, :revoke_credentials, %Credentials{}) do + false + end @owner_actions [:update_hub, :close_hub, :embed_hub, :kick_users, :mute_users] @object_actions [:spawn_and_move_media, :spawn_camera, :spawn_drawing, :pin_objects, :spawn_emoji, :fly] @@ -653,6 +1000,13 @@ defimpl Canada.Can, for: Ret.Account do hub |> Hub.has_member_permission?(action) or hub |> Ret.Hub.is_owner?(account_id) end + @self_allowed_actions [:get_rooms_created_by, :get_favorite_rooms_of] + # Allow accounts to access their own rooms + def can?(%Ret.Account{} = a, action, %Ret.Account{} = b) when action in @self_allowed_actions, + do: a.account_id == b.account_id + + def can?(%Ret.Account{}, :get_public_rooms, _), do: true + # Create hubs def can?(%Ret.Account{is_admin: true}, :create_hub, _), do: true @@ -699,11 +1053,32 @@ defimpl Canada.Can, for: Ret.OAuthProvider do def can?(_, _, _), do: false end -# Permissions for un-authenticated clients +# Permissions for app tokens and un-authenticated clients defimpl Canada.Can, for: Atom do - alias Ret.{AppConfig, Hub} + @allowed_app_token_actions [ + :get_rooms_created_by, + :get_favorite_rooms_of, + :get_public_rooms, + :create_hub, + :update_hub + ] + def can?(:reticulum_app_token, action, _) when action in @allowed_app_token_actions do + true + end - @object_actions [:spawn_and_move_media, :spawn_camera, :spawn_drawing, :pin_objects, :spawn_emoji, :fly] + # Bound hubs - Always prevent embedding and role assignment (since it's dictated by binding) + def can?(:reticulum_app_token, action, %Ret.Hub{hub_bindings: hub_bindings}) + when length(hub_bindings) > 0 and action in [:embed_hub, :update_roles], + do: false + + # Allow app tokens to act like owners/creators if the room has no bindings + def can?(:reticulum_app_token, action, %Ret.Hub{}) + when action in [:embed_hub, :update_roles], + do: true + + def can?(:reticulum_app_token, _, _), do: false + + alias Ret.{AppConfig, Hub} # Always deny access to non-enterable hubs def can?(_, :join_hub, %Ret.Hub{entry_mode: :deny}), do: false @@ -712,6 +1087,7 @@ defimpl Canada.Can, for: Atom do def can?(_, :join_hub, %Ret.Hub{hub_bindings: []}), do: !AppConfig.get_cached_config_value("features|require_account_for_join") + @object_actions [:spawn_and_move_media, :spawn_camera, :spawn_drawing, :pin_objects, :spawn_emoji, :fly] # Object permissions for anonymous users are based on member permission settings def can?(_account, action, hub) when action in @object_actions do hub |> Hub.has_member_permission?(action) diff --git a/lib/ret/json_schema_api_error_formatter.ex b/lib/ret/json_schema_api_error_formatter.ex index 7cb3a2f12..5ca6a6cd2 100644 --- a/lib/ret/json_schema_api_error_formatter.ex +++ b/lib/ret/json_schema_api_error_formatter.ex @@ -1,4 +1,5 @@ defmodule Ret.JsonSchemaApiErrorFormatter do + @moduledoc false def format(errors) do ExJsonSchema.Validator.Error.StringFormatter.format(errors) |> Enum.map(&{:MALFORMED_RECORD, elem(&1, 0), elem(&1, 1)}) diff --git a/lib/ret/random_room_names.ex b/lib/ret/random_room_names.ex new file mode 100644 index 000000000..35531107a --- /dev/null +++ b/lib/ret/random_room_names.ex @@ -0,0 +1,673 @@ +defmodule Ret.RandomRoomNames do + @moduledoc false + + @adjectives [ + "able", + "absolute", + "acceptable", + "acclaimed", + "accomplished", + "accurate", + "aching", + "acrobatic", + "adorable", + "adventurous", + "basic", + "belated", + "beloved", + "calm", + "candid", + "capital", + "carefree", + "caring", + "cautious", + "celebrated", + "charming", + "daring", + "darling", + "dearest", + "each", + "eager", + "early", + "earnest", + "easy", + "easygoing", + "ecstatic", + "edible", + "fabulous", + "fair", + "faithful", + "familiar", + "famous", + "fancy", + "fantastic", + "far", + "generous", + "gentle", + "genuine", + "giant", + "handmade", + "handsome", + "handy", + "happy", + "icy", + "ideal", + "identical", + "keen", + "lasting", + "lavish", + "magnificent", + "majestic", + "mammoth", + "marvelous", + "natural", + "obedient", + "palatable", + "parched", + "passionate", + "pastel", + "peaceful", + "perfect", + "perfumed", + "quaint", + "qualified", + "radiant", + "rapid", + "rare", + "safe", + "sandy", + "satisfied", + "scaly", + "scarce", + "scared", + "scary", + "scented", + "scientific", + "secret", + "sentimental", + "talkative", + "tangible", + "tart", + "tasty", + "tattered", + "teeming", + "ultimate", + "uncommon", + "unconscious", + "understated", + "warm", + "active", + "adept", + "admirable", + "admired", + "adorable", + "adored", + "advanced", + "affectionate", + "beneficial", + "best", + "better", + "big", + "cheerful", + "cheery", + "chief", + "chilly", + "classic", + "clean", + "clear", + "clever", + "decent", + "decisive", + "deep", + "defiant", + "definitive", + "delectable", + "delicious", + "elaborate", + "elastic", + "elated", + "elegant", + "elementary", + "elliptical", + "fast", + "favorable", + "favorite", + "fearless", + "gifted", + "glamorous", + "gleaming", + "glittering", + "harmonious", + "imaginative", + "immense", + "jealous", + "kind", + "leafy", + "legal", + "mature", + "mean", + "nautical", + "neat", + "necessary", + "needy", + "oddball", + "offbeat", + "periodic", + "perky", + "personal", + "pertinent", + "petty", + "quarterly", + "ready", + "real", + "realistic", + "reasonable", + "regal", + "serene", + "shabby", + "sharp", + "shiny", + "showy", + "shy", + "silky", + "tempting", + "tense", + "terrific", + "testy", + "thankful", + "uniform", + "unique", + "vast", + "weary", + "wee", + "welcome", + "agile", + "alarmed", + "alert", + "alive", + "bleak", + "blissful", + "blushing", + "coarse", + "colorful", + "colossal", + "comfortable", + "compassionate", + "complete", + "delightful", + "dense", + "dependable", + "dependent", + "descriptive", + "detailed", + "determined", + "devoted", + "different", + "eminent", + "emotional", + "enchanted", + "enchanting", + "energetic", + "enormous", + "fine", + "finished", + "firm", + "firsthand", + "fixed", + "flashy", + "flawless", + "glorious", + "glossy", + "golden", + "good", + "gorgeous", + "graceful", + "healthy", + "heartfelt", + "hearty", + "helpful", + "impartial", + "impressive", + "jolly", + "jovial", + "lighthearted", + "likable", + "lined", + "mellow", + "melodic", + "memorable", + "mild", + "new", + "opulent", + "playful", + "pleasant", + "pleasing", + "plump", + "plush", + "polished", + "polite", + "reliable", + "relieved", + "remarkable", + "remote", + "respectful", + "responsible", + "simple", + "simplistic", + "sizzling", + "sleepy", + "slight", + "slim", + "smart", + "smooth", + "snappy", + "snoopy", + "thirsty", + "this", + "thorough", + "those", + "thoughtful", + "united", + "vibrant", + "vicious", + "wellmade", + "whimsical", + "whirlwind", + "zesty", + "amazing", + "ambitious", + "ample", + "amused", + "amusing", + "ancient", + "angelic", + "antique", + "bold", + "bossy", + "both", + "bouncy", + "bountiful", + "complex", + "conscious", + "considerate", + "constant", + "content", + "conventional", + "cooked", + "cool", + "cooperative", + "diligent", + "dimwitted", + "direct", + "discrete", + "envious", + "essential", + "ethical", + "euphoric", + "flippant", + "fluffy", + "flustered", + "focused", + "fond", + "gracious", + "grand", + "grandiose", + "granular", + "grateful", + "grave", + "great", + "hidden", + "high", + "hilarious", + "homely", + "incomparable", + "incredible", + "infamous", + "joyful", + "lively", + "loathsome", + "lonely", + "long", + "mindless", + "miniature", + "minor", + "misty", + "next", + "nice", + "nifty", + "nimble", + "orderly", + "organic", + "ornate", + "popular", + "posh", + "positive", + "potable", + "powerful", + "powerless", + "precious", + "present", + "prestigious", + "quick", + "rewarding", + "rich", + "right", + "sociable", + "soft", + "solid", + "some", + "sophisticated", + "soulful", + "sparkling", + "spectacular", + "speedy", + "spicy", + "spiffy", + "spirited", + "spiteful", + "splendid", + "spotless", + "spry", + "thrifty", + "tidy", + "tight", + "timely", + "tinted", + "unruly", + "untimely", + "violet", + "wicked", + "wide", + "wild", + "willing", + "winding", + "windy", + "zigzag", + "apprehensive", + "appropriate", + "artistic", + "assured", + "astonishing", + "bright", + "brilliant", + "bronze", + "coordinated", + "courageous", + "courteous", + "crafty", + "crazy", + "creamy", + "creative", + "crisp", + "distant", + "distinct", + "downright", + "evergreen", + "everlasting", + "every", + "evil", + "excellent", + "excitable", + "exemplary", + "exhausted", + "forthright", + "fortunate", + "fragrant", + "frank", + "free", + "frequent", + "fresh", + "friendly", + "frightened", + "frigid", + "gripping", + "grounded", + "honest", + "honorable", + "honored", + "hopeful", + "hospitable", + "hot", + "huge", + "infatuated", + "infinite", + "informal", + "insistent", + "instructive", + "juicy", + "jumbo", + "knowing", + "knowledgeable", + "longterm", + "loud", + "lovable", + "loving", + "modern", + "modest", + "monumental", + "normal", + "notable", + "outgoing", + "precious", + "pretty", + "prickly", + "primary", + "pristine", + "private", + "prize", + "productive", + "profitable", + "quiet", + "quintessential", + "roasted", + "robust", + "square", + "squiggly", + "stable", + "staid", + "starry", + "steel", + "stimulating", + "striking", + "striped", + "strong", + "studious", + "stunning", + "tough", + "trained", + "treasured", + "tremendous", + "triangular", + "tricky", + "unused", + "unusual", + "upbeat", + "virtual", + "witty", + "wonderful", + "wooden", + "worldly", + "youthful", + "attached", + "attentive", + "attractive", + "austere", + "authentic", + "automatic", + "aware", + "awesome", + "bubbly", + "bustling", + "busy", + "buttery", + "cuddly", + "cultured", + "curly", + "curvy", + "cute", + "cylindrical", + "downright", + "dramatic", + "excited", + "exciting", + "exotic", + "experienced", + "expert", + "frosty", + "fruitful", + "full", + "fumbling", + "funny", + "fussy", + "growing", + "grown", + "gummy", + "humble", + "humongous", + "hungry", + "intelligent", + "interesting", + "known", + "kooky", + "loyal", + "lucky", + "luminous", + "lustrous", + "luxurious", + "multicolored", + "mysterious", + "noteworthy", + "numb", + "nutritious", + "outstanding", + "overjoyed", + "proper", + "proud", + "prudent", + "punctual", + "puny", + "pure", + "puzzled", + "puzzling", + "quirky", + "stupendous", + "sturdy", + "stylish", + "subdued", + "subtle", + "sunny", + "super", + "superb", + "supportive", + "surprised", + "sweet", + "swift", + "sympathetic", + "trivial", + "trusting", + "trustworthy", + "trusty", + "truthful", + "twin", + "usable", + "used", + "useful", + "utilized", + "vital", + "vivid", + "worried", + "worthwhile", + "worthy", + "writhing", + "wry", + "yummy", + "chocolate", + "crimson", + "cyan", + "fuchsia", + "gold", + "honeydew", + "lime", + "linen", + "magenta", + "olive", + "peru", + "salmon", + "seashell", + "sienna", + "snow", + "thistle", + "tomato", + "transparent", + "turquoise", + "violet" + ] + + @nouns [ + "space", + "land", + "world", + "universe", + "plane", + "room", + "nation", + "plaza", + "gathering", + "meetup", + "get together", + "conclave", + "party", + "domain", + "dominion", + "realm", + "square", + "commons", + "park", + "cosmos", + "sphere", + "terrain", + "spot", + "zone", + "area", + "tract", + "turf", + "place", + "territory", + "volume", + "camp", + "picnic", + "outing", + "vacation", + "adventure", + "exploration", + "outing", + "walkabout", + "safari", + "venture", + "roundtable", + "barbecue", + "celebration", + "festivity", + "gala", + "shindig", + "social", + "convention", + "assembly", + "congregation", + "rendezvous", + "huddle", + "meet", + "soiree" + ] + + defp random_from(words) do + Enum.at(words, :rand.uniform(length(words)) - 1) + end + + def generate_room_name() do + [@adjectives, @adjectives, @nouns] + |> Stream.map(&random_from/1) + |> Stream.map(&String.capitalize(&1)) + |> Enum.join(" ") + end +end diff --git a/lib/ret_web/auth_error_handler.ex b/lib/ret_web/auth_error_handler.ex index 717125b16..11d81664a 100644 --- a/lib/ret_web/auth_error_handler.ex +++ b/lib/ret_web/auth_error_handler.ex @@ -1,4 +1,5 @@ defmodule RetWeb.Guardian.AuthErrorHandler do + @moduledoc false import Plug.Conn def auth_error(conn, {type, _reason}, _opts) do diff --git a/lib/ret_web/auth_optional_pipeline.ex b/lib/ret_web/auth_optional_pipeline.ex index fe3fef456..bd861bfc7 100644 --- a/lib/ret_web/auth_optional_pipeline.ex +++ b/lib/ret_web/auth_optional_pipeline.ex @@ -1,4 +1,5 @@ defmodule RetWeb.Guardian.AuthOptionalPipeline do + @moduledoc false use Guardian.Plug.Pipeline, otp_app: :ret, module: Ret.Guardian, diff --git a/lib/ret_web/controllers/api/v1/hub_controller.ex b/lib/ret_web/controllers/api/v1/hub_controller.ex index f9ae36661..c873a85b3 100644 --- a/lib/ret_web/controllers/api/v1/hub_controller.ex +++ b/lib/ret_web/controllers/api/v1/hub_controller.ex @@ -68,8 +68,8 @@ defmodule RetWeb.Api.V1.HubController do hub |> Hub.add_attrs_to_changeset(hub_params) |> maybe_add_new_scene(scene) - |> maybe_add_member_permissions(hub, hub_params) - |> maybe_add_promotion(account, hub, hub_params) + |> Hub.maybe_add_member_permissions(hub, hub_params) + |> Hub.maybe_add_promotion(account, hub, hub_params) hub = changeset |> Repo.update!() |> Repo.preload(Hub.hub_preloads()) @@ -80,16 +80,6 @@ defmodule RetWeb.Api.V1.HubController do defp maybe_add_new_scene(changeset, scene), do: changeset |> Hub.add_new_scene_to_changeset(scene) - defp maybe_add_member_permissions(changeset, hub, %{"member_permissions" => %{}} = hub_params), - do: changeset |> Hub.add_member_permissions_update_to_changeset(hub, hub_params) - - defp maybe_add_member_permissions(changeset, _hub, _), do: changeset - - defp maybe_add_promotion(changeset, account, hub, %{"allow_promotion" => _} = hub_params), - do: changeset |> Hub.maybe_add_promotion_to_changeset(account, hub, hub_params) - - defp maybe_add_promotion(changeset, _account, _hub, _), do: changeset - def delete(conn, %{"id" => hub_sid}) do Hub |> Repo.get_by(hub_sid: hub_sid) diff --git a/lib/ret_web/controllers/api/v2/credentials_controller.ex b/lib/ret_web/controllers/api/v2/credentials_controller.ex new file mode 100644 index 000000000..757a5b572 --- /dev/null +++ b/lib/ret_web/controllers/api/v2/credentials_controller.ex @@ -0,0 +1,129 @@ +defmodule RetWeb.Api.V2.CredentialsController do + use RetWeb, :controller + + alias Ret.{Repo} + alias Ret.Api.Credentials + alias Ecto.Changeset + + import Ret.Api.TokenUtils, + only: [to_claims: 2, authed_create_credentials: 2, authed_list_credentials: 2, authed_revoke_credentials: 2] + + # Limit to 1 TPS + plug(RetWeb.Plugs.RateLimit when action in [:create, :update]) + + def index(conn, %{"app" => _anything} = _params) do + handle_list_credentials_result(conn, authed_list_credentials(Guardian.Plug.current_resource(conn), :app)) + end + + def index(conn, _params) do + handle_list_credentials_result(conn, authed_list_credentials(Guardian.Plug.current_resource(conn), :account)) + end + + def show(conn, %{"id" => credentials_sid}) do + case Repo.get_by(Credentials, api_credentials_sid: credentials_sid) do + nil -> + render_errors(conn, 400, {:error, "Invalid request"}) + + credentials -> + conn + |> put_resp_header("content-type", "application/json") + |> put_status(200) + |> render("show.json", credentials: credentials) + end + end + + def create(conn, params) do + account = Guardian.Plug.current_resource(conn) + + case to_claims(account, params) do + {:ok, claims} -> + handle_create_credentials_result(conn, authed_create_credentials(account, claims)) + + {:error, error_list} -> + render_errors(conn, 400, error_list) + end + end + + def update(conn, %{"id" => credentials_sid, "revoke" => _anything}) do + account = Guardian.Plug.current_resource(conn) + + case Credentials.query() + |> Credentials.where_sid_is(credentials_sid) + |> Repo.one() do + nil -> + render_errors(conn, 400, {:error, "Invalid request"}) + + credentials -> + handle_revoke_credentials_result(conn, authed_revoke_credentials(account, credentials)) + end + end + + defp handle_list_credentials_result(conn, {:error, :unauthorized}) do + render_errors(conn, 401, {:unauthorized, "You do not have permission to view these credentials."}) + end + + defp handle_list_credentials_result(conn, {:error, reason}) do + render_errors(conn, 400, reason) + end + + defp handle_list_credentials_result(conn, credentials) do + conn + |> put_resp_header("content-type", "application/json") + |> put_status(200) + |> render("index.json", credentials: credentials) + end + + defp handle_create_credentials_result(conn, {:error, :unauthorized}) do + render_errors(conn, 401, {:unauthorized, "You do not have permission to create these credentials."}) + end + + defp handle_create_credentials_result(conn, {:error, reason}) do + render_errors(conn, 400, reason) + end + + defp handle_create_credentials_result(conn, {:ok, token, _claims}) do + # Lookup credentials because token creation returns the + # claims map, not the credentials object written to DB. + credentials = + Credentials.query() + |> Credentials.where_token_hash_is(Ret.Crypto.hash(token)) + |> Repo.one() + + conn + |> put_resp_header("content-type", "application/json") + |> put_status(200) + |> render("show.json", token: token, credentials: credentials) + end + + defp handle_revoke_credentials_result(conn, {:error, :unauthorized}) do + render_errors(conn, 401, {:unauthorized, "You do not have permission to revoke these credentials."}) + end + + defp handle_revoke_credentials_result(conn, {:error, reason}) do + render_errors(conn, 400, reason) + end + + defp handle_revoke_credentials_result(conn, {:ok, credentials}) do + conn + |> put_resp_header("content-type", "application/json") + |> put_status(200) + |> render("show.json", credentials: credentials) + end + + defp render_errors(conn, status, errors) when is_list(errors) do + conn + |> put_resp_header("content-type", "application/json") + |> put_status(status) + |> render("errors.json", errors: errors) + end + + defp render_errors(conn, status, %Changeset{} = changeset) do + render_errors(conn, status, + errors: changeset |> Ecto.Changeset.traverse_errors(fn {err, _opts} -> err end) |> Enum.to_list() + ) + end + + defp render_errors(conn, status, error) do + render_errors(conn, status, List.wrap(error)) + end +end diff --git a/lib/ret_web/endpoint.ex b/lib/ret_web/endpoint.ex index ec120ca14..dff9480b0 100644 --- a/lib/ret_web/endpoint.ex +++ b/lib/ret_web/endpoint.ex @@ -1,6 +1,7 @@ defmodule RetWeb.Endpoint do use Phoenix.Endpoint, otp_app: :ret use Sentry.Phoenix.Endpoint + use Absinthe.Phoenix.Endpoint socket("/socket", RetWeb.SessionSocket, websocket: [check_origin: {RetWeb.Endpoint, :allowed_origin?, []}]) diff --git a/lib/ret_web/middleware.ex b/lib/ret_web/middleware.ex new file mode 100644 index 000000000..7d7416bc9 --- /dev/null +++ b/lib/ret_web/middleware.ex @@ -0,0 +1,30 @@ +defmodule RetWeb.Middleware do + @moduledoc "Adds absinthe middleware on matching fields/objects" + + alias RetWeb.Middleware.{ + HandleApiTokenAuthErrors, + HandleChangesetErrors, + StartTiming, + EndTiming, + InspectTiming + } + + @timing_ids [ + :my_rooms, + :public_rooms, + :favorite_rooms, + :create_room, + :update_room + ] + + def build_middleware(middleware, %{identifier: field_id} = _field, _object) do + include_timing = field_id in @timing_ids + + if(include_timing, do: [StartTiming], else: []) ++ + [HandleApiTokenAuthErrors] ++ + middleware ++ + [HandleChangesetErrors] ++ + if(include_timing, do: [EndTiming], else: []) ++ + if(include_timing, do: [InspectTiming], else: []) + end +end diff --git a/lib/ret_web/middleware/handle_api_token_auth_errors.ex b/lib/ret_web/middleware/handle_api_token_auth_errors.ex new file mode 100644 index 000000000..510464fe3 --- /dev/null +++ b/lib/ret_web/middleware/handle_api_token_auth_errors.ex @@ -0,0 +1,41 @@ +defmodule RetWeb.Middleware.HandleApiTokenAuthErrors do + @moduledoc false + + @behaviour Absinthe.Middleware + + import RetWeb.Middleware.PutErrorResult, only: [put_error_result: 3] + + alias Ret.Api.Credentials + + def call(%{state: :resolved} = resolution, _) do + resolution + end + + # Don't enforce authentication on introspection queries + # See Absinthe.Introspection.type? + # https://github.com/absinthe-graphql/absinthe/blob/cdb8c39beb6a79b03a5095fffbe761e0dd9918ac/lib/absinthe/introspection.ex#L106 + def call(%{parent_type: %{name: "__" <> _}} = resolution, _) do + resolution + end + + def call(%{context: %{api_token_auth_errors: errors}} = resolution, _) when is_list(errors) and length(errors) > 0 do + {type, reason} = Enum.at(errors, 0) + put_error_result(resolution, type, reason) + end + + def call(%{context: %{credentials: nil}} = resolution, _) do + put_error_result( + resolution, + :api_access_token_not_found, + "Failed to find api access token when searching for header 'Authorization: Bearer '" + ) + end + + def call(%{context: %{credentials: %Credentials{is_revoked: true}}} = resolution, _) do + put_error_result(resolution, :token_revoked, "Token is revoked") + end + + def call(%{context: %{credentials: %Credentials{}}} = resolution, _) do + resolution + end +end diff --git a/lib/ret_web/middleware/handle_changeset_errors.ex b/lib/ret_web/middleware/handle_changeset_errors.ex new file mode 100644 index 000000000..26839584d --- /dev/null +++ b/lib/ret_web/middleware/handle_changeset_errors.ex @@ -0,0 +1,15 @@ +defmodule RetWeb.Middleware.HandleChangesetErrors do + @moduledoc false + @behaviour Absinthe.Middleware + def call(resolution, _) do + %{resolution | errors: Enum.flat_map(resolution.errors, &handle_error/1)} + end + + defp handle_error(%Ecto.Changeset{} = changeset) do + changeset + |> Ecto.Changeset.traverse_errors(fn {err, _opts} -> err end) + |> Enum.map(fn {k, v} -> "#{k}: #{v}" end) + end + + defp handle_error(error), do: [error] +end diff --git a/lib/ret_web/middleware/put_error_result.ex b/lib/ret_web/middleware/put_error_result.ex new file mode 100644 index 000000000..fff8678de --- /dev/null +++ b/lib/ret_web/middleware/put_error_result.ex @@ -0,0 +1,12 @@ +defmodule RetWeb.Middleware.PutErrorResult do + @moduledoc "Helper for returning auth errors in a uniform way in graphql api" + + import Absinthe.Resolution, only: [put_result: 2] + + def put_error_result(resolution, type, message) do + put_result( + resolution, + {:error, [type: type, message: message]} + ) + end +end diff --git a/lib/ret_web/middleware/timing.ex b/lib/ret_web/middleware/timing.ex new file mode 100644 index 000000000..a50f4498f --- /dev/null +++ b/lib/ret_web/middleware/timing.ex @@ -0,0 +1,68 @@ +defmodule RetWeb.Middleware.TimingUtil do + @moduledoc false + def add_timing_info(%Absinthe.Resolution{private: private} = resolution, identifier, key, value) do + timing = Map.get(private, :timing) || %{} + info = Map.put(Map.get(timing, identifier) || %{}, key, value) + + %{ + resolution + | private: Map.put(private, :timing, Map.put(timing, identifier, info)) + } + end +end + +defmodule RetWeb.Middleware.StartTiming do + @moduledoc false + + import RetWeb.Middleware.TimingUtil, only: [add_timing_info: 4] + + @behaviour Absinthe.Middleware + def call(resolution, _) do + add_timing_info(resolution, resolution.definition.schema_node.identifier, :started_at, NaiveDateTime.utc_now()) + end +end + +defmodule RetWeb.Middleware.EndTiming do + @moduledoc false + + import RetWeb.Middleware.TimingUtil, only: [add_timing_info: 4] + + @behaviour Absinthe.Middleware + def call(resolution, _) do + add_timing_info(resolution, resolution.definition.schema_node.identifier, :ended_at, NaiveDateTime.utc_now()) + end +end + +defmodule RetWeb.Middleware.InspectTiming do + @moduledoc false + + @behaviour Absinthe.Middleware + def call(resolution, _) do + case resolution do + %{private: %{timing: timing}} -> + log_timing_info(timing) + nil + + _ -> + nil + end + + resolution + end + + defp log_timing_info(_timing) do + nil + end + + # # TODO: Log these metrics with something like :telemetry or Statix + # defp log_timing_info(timing) do + # Enum.each(timing, fn + # {identifier, %{started_at: started_at, ended_at: ended_at}} -> + # diff = NaiveDateTime.diff(ended_at, started_at, :microsecond) + # IO.puts("#{Atom.to_string(identifier)} took #{diff} microseconds to run.") + + # _ -> + # nil + # end) + # end +end diff --git a/lib/ret_web/plugs/add_absinthe_context.ex b/lib/ret_web/plugs/add_absinthe_context.ex new file mode 100644 index 000000000..f84a2621a --- /dev/null +++ b/lib/ret_web/plugs/add_absinthe_context.ex @@ -0,0 +1,25 @@ +defmodule RetWeb.AddAbsintheContext do + @moduledoc false + @behaviour Plug + + def init(opts), do: opts + + def call(conn, _) do + Absinthe.Plug.put_options(conn, context: build_context(conn)) + end + + defp build_context(conn) do + auth_errors = conn.assigns[:api_token_auth_errors] || [] + + case Guardian.Plug.current_claims(conn) do + {:error, :invalid_token} -> + %{api_token_auth_errors: [{:invalid_token, "Invalid token error. Could not find credentials."}] ++ auth_errors} + + credentials -> + %{ + api_token_auth_errors: auth_errors, + credentials: credentials + } + end + end +end diff --git a/lib/ret_web/plugs/api_token_auth_pipeline.ex b/lib/ret_web/plugs/api_token_auth_pipeline.ex new file mode 100644 index 000000000..8747f7a18 --- /dev/null +++ b/lib/ret_web/plugs/api_token_auth_pipeline.ex @@ -0,0 +1,29 @@ +defmodule RetWeb.ApiTokenAuthPipeline do + @moduledoc false + use Guardian.Plug.Pipeline, + otp_app: :ret, + module: Ret.Api.Token, + error_handler: RetWeb.ApiTokenAuthErrorHandler + + plug(Guardian.Plug.VerifyHeader, halt: false) +end + +defmodule RetWeb.ApiTokenAuthErrorHandler do + @moduledoc false + + def auth_error(conn, {failure_type, %ArgumentError{message: reason}}, _opts) do + append_error(conn, failure_type, reason) + end + + def auth_error(conn, {failure_type, reason}, _opts) do + append_error(conn, failure_type, reason) + end + + def append_error(conn, failure_type, reason) do + Plug.Conn.assign( + conn, + :api_token_auth_errors, + (conn.assigns[:api_token_auth_errors] || []) ++ [{failure_type, reason}] + ) + end +end diff --git a/lib/ret_web/plugs/require_public_api_access.ex b/lib/ret_web/plugs/require_public_api_access.ex new file mode 100644 index 000000000..7ec07cec1 --- /dev/null +++ b/lib/ret_web/plugs/require_public_api_access.ex @@ -0,0 +1,13 @@ +defmodule RetWeb.Plugs.RequirePublicApiAccess do + import Plug.Conn + + def init([]), do: [] + + def call(conn, []) do + if Ret.AppConfig.get_config_bool("features|public_api_access") do + conn + else + conn |> send_resp(404, "") |> halt() + end + end +end diff --git a/lib/ret_web/resolvers/resolver_error.ex b/lib/ret_web/resolvers/resolver_error.ex new file mode 100644 index 000000000..422f93365 --- /dev/null +++ b/lib/ret_web/resolvers/resolver_error.ex @@ -0,0 +1,6 @@ +defmodule RetWeb.Resolvers.ResolverError do + @moduledoc false + def resolver_error(type, reason) do + {:error, [type: type, message: reason]} + end +end diff --git a/lib/ret_web/resolvers/room_resolver.ex b/lib/ret_web/resolvers/room_resolver.ex new file mode 100644 index 000000000..7a57ecb4c --- /dev/null +++ b/lib/ret_web/resolvers/room_resolver.ex @@ -0,0 +1,143 @@ +defmodule RetWeb.Resolvers.RoomResolver do + @moduledoc """ + Resolvers for room queries and mutations via the graphql API + """ + alias Ret.Hub + alias Ret.Api.Credentials + import RetWeb.Resolvers.ResolverError, only: [resolver_error: 2] + + def my_rooms(_parent, _args, %{ + context: %{ + credentials: %Credentials{ + subject_type: :app + } + } + }) do + resolver_error(:not_implemented, "Not implemented for app tokens") + end + + def my_rooms(_parent, args, %{ + context: %{ + credentials: + %Credentials{ + subject_type: :account, + account: account + } = credentials + } + }) do + Ret.Api.Rooms.authed_get_rooms_created_by(account, credentials, args) + end + + def my_rooms(_parent, _args, _resolutions) do + resolver_error(:unauthorized, "Unauthorized access") + end + + def favorite_rooms(_parent, _args, %{ + context: %{ + credentials: %Credentials{ + subject_type: :app + } + } + }) do + resolver_error(:not_implemented, "Not implemented for app tokens") + end + + def favorite_rooms(_parent, args, %{ + context: %{ + credentials: + %Credentials{ + subject_type: :account, + account: account + } = credentials + } + }) do + Ret.Api.Rooms.authed_get_favorite_rooms_of(account, credentials, args) + end + + def favorite_rooms(_parent, _args, _resolutions) do + resolver_error(:unauthorized, "Unauthorized access") + end + + def public_rooms(_parent, args, %{ + context: %{ + credentials: %Credentials{} = credentials + } + }) do + Ret.Api.Rooms.authed_get_public_rooms(credentials, args) + end + + def public_rooms(_, _, _) do + resolver_error(:unauthorized, "Unauthorized access") + end + + def create_room(_parent, args, %{ + context: %{ + credentials: %Credentials{} = credentials + } + }) do + Ret.Api.Rooms.authed_create_room(credentials, args) + end + + def create_room(_parent, _args, _resolutions) do + resolver_error(:unauthorized, "Unauthorized access") + end + + def embed_token(hub, _args, %{ + context: %{ + credentials: %Credentials{} = credentials + } + }) do + Ret.Api.Rooms.authed_get_embed_token(credentials, hub) + end + + def embed_token(_hub, _args, _resolutions) do + resolver_error(:unauthorized, "Unauthorized access") + end + + def port(_hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.janus_port()} + end + + def turn(_hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.generate_turn_info()} + end + + def member_permissions(hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.member_permissions_for_hub_as_atoms(hub)} + end + + def room_size(hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.room_size_for(hub)} + end + + def member_count(hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.member_count_for(hub)} + end + + def lobby_count(hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.lobby_count_for(hub)} + end + + def scene(hub, _args, _resolutions) do + # No permission check needed + {:ok, Hub.scene_or_scene_listing_for(hub)} + end + + def update_room(_parent, %{id: hub_sid} = args, %{ + context: %{ + credentials: %Credentials{} = credentials + } + }) do + Ret.Api.Rooms.authed_update_room(hub_sid, credentials, args) + end + + def update_room(_parent, _args, _resolutions) do + resolver_error(:unauthorized, "Unauthorized access") + end +end diff --git a/lib/ret_web/router.ex b/lib/ret_web/router.ex index 56e94c89d..3342fc143 100644 --- a/lib/ret_web/router.ex +++ b/lib/ret_web/router.ex @@ -32,6 +32,10 @@ defmodule RetWeb.Router do plug(:accepts, ["json"]) end + pipeline :public_api_access do + plug(RetWeb.Plugs.RequirePublicApiAccess) + end + pipeline :proxy_api do plug(:accepts, ["json"]) plug(RetWeb.Plugs.RewriteAuthorizationHeaderToPerms) @@ -65,6 +69,11 @@ defmodule RetWeb.Router do plug(RetWeb.Plugs.RedirectToMainDomain) end + pipeline :graphql do + plug RetWeb.ApiTokenAuthPipeline + plug RetWeb.AddAbsintheContext + end + scope "/health", RetWeb do get("/", HealthController, :index) end @@ -142,6 +151,24 @@ defmodule RetWeb.Router do end end + scope "/api/v2_alpha", RetWeb do + pipe_through( + [:secure_headers, :parsed_body, :api, :public_api_access, :auth_required] ++ + if(Mix.env() == :prod, do: [:ssl_only, :canonicalize_domain], else: []) + ) + + resources("/credentials", Api.V2.CredentialsController, only: [:create, :index, :update, :show]) + end + + scope "/api/v2_alpha", as: :api_v2_alpha do + pipe_through( + [:parsed_body, :api, :public_api_access, :graphql] ++ if(Mix.env() == :prod, do: [:ssl_only], else: []) + ) + + forward "/graphiql", Absinthe.Plug.GraphiQL, json_codec: Jason, schema: RetWeb.Schema + forward "/", Absinthe.Plug, json_codec: Jason, schema: RetWeb.Schema + end + # Directly accessible APIs. # Permit direct file uploads without intermediate ALB/Cloudfront/CDN proxying. scope "/api", RetWeb do diff --git a/lib/ret_web/schema.ex b/lib/ret_web/schema.ex new file mode 100644 index 000000000..fe4b8b770 --- /dev/null +++ b/lib/ret_web/schema.ex @@ -0,0 +1,35 @@ +defmodule RetWeb.Schema do + @moduledoc false + + use Absinthe.Schema + + import RetWeb.Middleware, only: [build_middleware: 3] + + def middleware(middleware, field, object) do + build_middleware(middleware, field, object) + end + + import_types(Absinthe.Type.Custom) + import_types(RetWeb.Schema.RoomTypes) + import_types(RetWeb.Schema.SceneTypes) + + query do + import_fields(:room_queries) + end + + mutation do + import_fields(:room_mutations) + end + + def context(ctx) do + loader = + Dataloader.new() + |> Dataloader.add_source(:db, Ret.Api.Dataloader.source()) + + Map.put(ctx, :loader, loader) + end + + def plugins do + [Absinthe.Middleware.Dataloader] ++ Absinthe.Plugin.defaults() + end +end diff --git a/lib/ret_web/schema/room_types.ex b/lib/ret_web/schema/room_types.ex new file mode 100644 index 000000000..dfb6047fa --- /dev/null +++ b/lib/ret_web/schema/room_types.ex @@ -0,0 +1,239 @@ +defmodule RetWeb.Schema.RoomTypes do + @moduledoc "GraphQL Schema" + + use Absinthe.Schema.Notation + alias RetWeb.Resolvers + import_types(RetWeb.Schema.Types.Custom.JSON) + + @desc "Public TLS port number used for TURN" + object :turn_transport do + @desc "Public TLS port number used for TURN" + field(:port, :integer) + end + + @desc "TURN information for DLTS over TURN fallback, when enabled" + object :turn_info do + @desc "Cryptographic credential, good for two minutes" + field(:credential, :string) + @desc "Whether TURN is enabled/configured" + field(:enabled, :boolean) + @desc "List of public TLS ports" + field(:transports, list_of(:turn_transport)) + @desc "Username, good for two minutes" + field(:username, :string) + end + + @desc "Permissions for participants in the room" + input_object :input_member_permissions do + @desc "Allows non-admin participants to spawn and move media" + field(:spawn_and_move_media, :boolean) + @desc "Allows non-admin participants to spawn in-game cameras" + field(:spawn_camera, :boolean) + @desc "Allows non-admin participants to draw with a pen" + field(:spawn_drawing, :boolean) + @desc "Allows non-admin participants to pin media to the room" + field(:pin_objects, :boolean) + @desc "Allows non-admin participants to spawn emoji" + field(:spawn_emoji, :boolean) + @desc "Allows non-admin participants to toggle fly mode" + field(:fly, :boolean) + end + + @desc "Permissions for participants in the room" + object :member_permissions do + @desc "Allows non-admin participants to spawn and move media" + field(:spawn_and_move_media, :boolean) + @desc "Allows non-admin participants to spawn in-game cameras" + field(:spawn_camera, :boolean) + @desc "Allows non-admin participants to draw with a pen" + field(:spawn_drawing, :boolean) + @desc "Allows non-admin participants to pin media to the room" + field(:pin_objects, :boolean) + @desc "Allows non-admin participants to spawn emoji" + field(:spawn_emoji, :boolean) + @desc "Allows non-admin participants to toggle fly mode" + field(:fly, :boolean) + end + + @desc "A room" + object :room do + @desc "The room's unique ID" + field(:hub_sid, :id, name: "id") + @desc "The room's name" + field(:name, :string) + @desc "The room's name as it appears at the end of its URL" + field(:slug, :string) + @desc "A description of the room" + field(:description, :string) + @desc "Makes this room as public (while it is still open)" + field(:allow_promotion, :boolean) + @desc "Temporary entry code" + field(:entry_code, :string) + @desc "Determines if entry is allowed, denied, or by-invite-only. (Values are \"allow\", \"deny\", or \"invite\".)" + field(:entry_mode, :string) + @desc "The host server associated with this room via the load balancer" + field(:host, :string) + + @desc "The port number used to connect to the host server" + field(:port, :integer) do + @desc "The port number used to connect to the host server" + resolve(&Resolvers.RoomResolver.port/3) + end + + @desc "TURN information for DLTS over TURN fallback, when enabled" + field(:turn, :turn_info) do + resolve(&Resolvers.RoomResolver.turn/3) + end + + @desc """ + Can be used to remove the X-Frame-Options header that is usually served to the Hubs client when this room is loaded, so that the client can access this room from a ,