diff --git a/lib/asciinema_web/views/media_view.ex b/lib/asciinema_web/views/media_view.ex index 72ba6ce5f..bb0114cab 100644 --- a/lib/asciinema_web/views/media_view.ex +++ b/lib/asciinema_web/views/media_view.ex @@ -34,6 +34,15 @@ defmodule AsciinemaWeb.MediaView do end end + def theme_options(medium) do + for theme <- original_theme_option(medium.theme_palette) ++ Themes.terminal_themes() do + {Themes.display_name(theme), theme} + end + end + + defp original_theme_option(nil), do: [] + defp original_theme_option(_theme_palette), do: ["original"] + def font_family_options do for family <- Fonts.terminal_font_families() do {Fonts.display_name(family), family} diff --git a/lib/media.ex b/lib/media.ex index bf261f6f6..237188dcb 100644 --- a/lib/media.ex +++ b/lib/media.ex @@ -4,14 +4,17 @@ defmodule Asciinema.Media do def theme_name(medium) do cond do medium.theme_name -> medium.theme_name - medium.theme_palette -> nil true -> Accounts.default_theme_name(medium.user) || "asciinema" end end + def theme(%{theme_prefer_original: true, theme_palette: p} = medium) when not is_nil(p) do + Themes.custom_theme(medium.theme_fg, medium.theme_bg, p) + end + def theme(medium) do case theme_name(medium) do - nil -> + "original" -> Themes.custom_theme(medium.theme_fg, medium.theme_bg, medium.theme_palette) name -> @@ -19,16 +22,12 @@ defmodule Asciinema.Media do end end - def original_theme(medium) do - case theme_name(medium) do - nil -> - Themes.custom_theme(medium.theme_fg, medium.theme_bg, medium.theme_palette) - - _name -> - nil - end + def original_theme(%{theme_name: "original"} = medium) do + Themes.custom_theme(medium.theme_fg, medium.theme_bg, medium.theme_palette) end + def original_theme(_medium), do: nil + def font_family(medium) do case medium.terminal_font_family || Accounts.default_font_family(medium.user) do "default" -> nil diff --git a/priv/repo/migrations/20240329195017_add_theme_prefer_original_to_live_streams.exs b/priv/repo/migrations/20240329195017_add_theme_prefer_original_to_live_streams.exs new file mode 100644 index 000000000..da1bb3e53 --- /dev/null +++ b/priv/repo/migrations/20240329195017_add_theme_prefer_original_to_live_streams.exs @@ -0,0 +1,9 @@ +defmodule Asciinema.Repo.Migrations.AddThemePreferOriginalToLiveStreams do + use Ecto.Migration + + def change do + alter table(:live_streams) do + add :theme_prefer_original, :boolean, null: false, default: true + end + end +end diff --git a/priv/repo/migrations/20240329201531_add_theme_prefer_original_to_users.exs b/priv/repo/migrations/20240329201531_add_theme_prefer_original_to_users.exs new file mode 100644 index 000000000..f95c3df73 --- /dev/null +++ b/priv/repo/migrations/20240329201531_add_theme_prefer_original_to_users.exs @@ -0,0 +1,11 @@ +defmodule Asciinema.Repo.Migrations.AddThemePreferOriginalToUsers do + use Ecto.Migration + + def change do + alter table(:users) do + add :theme_prefer_original, :boolean, null: false, default: true + end + + execute "UPDATE asciicasts SET theme_name='original' WHERE theme_name IS NULL AND theme_palette IS NOT NULL" + end +end diff --git a/priv/repo/migrations/20240409082127_add_index_on_user_id_secret_token_to_live_streams.exs b/priv/repo/migrations/20240409082127_add_index_on_user_id_secret_token_to_live_streams.exs new file mode 100644 index 000000000..6585b690f --- /dev/null +++ b/priv/repo/migrations/20240409082127_add_index_on_user_id_secret_token_to_live_streams.exs @@ -0,0 +1,7 @@ +defmodule Asciinema.Repo.Migrations.AddIndexOnUserIdSecretTokenToLiveStreams do + use Ecto.Migration + + def change do + create index(:live_streams, [:user_id, :secret_token]) + end +end diff --git a/priv/repo/migrations/20240410075535_rename_and_shorten_stream_tokens.exs b/priv/repo/migrations/20240410075535_rename_and_shorten_stream_tokens.exs new file mode 100644 index 000000000..7f94a6a79 --- /dev/null +++ b/priv/repo/migrations/20240410075535_rename_and_shorten_stream_tokens.exs @@ -0,0 +1,9 @@ +defmodule Asciinema.Repo.Migrations.RenameAndShortenStreamTokens do + use Ecto.Migration + + def change do + rename table(:live_streams), :secret_token, to: :public_token + + execute "UPDATE live_streams SET public_token = LEFT(public_token, 16), producer_token = LEFT(producer_token, 16)" + end +end diff --git a/priv/repo/migrations/20240421122804_add_snapshot_to_live_streams.exs b/priv/repo/migrations/20240421122804_add_snapshot_to_live_streams.exs new file mode 100644 index 000000000..30582d99a --- /dev/null +++ b/priv/repo/migrations/20240421122804_add_snapshot_to_live_streams.exs @@ -0,0 +1,9 @@ +defmodule Asciinema.Repo.Migrations.AddSnapshotToLiveStreams do + use Ecto.Migration + + def change do + alter table(:live_streams) do + add :snapshot, :text + end + end +end diff --git a/test/controllers/api/live_stream_controller_test.exs b/test/controllers/api/live_stream_controller_test.exs new file mode 100644 index 000000000..6e3398b6b --- /dev/null +++ b/test/controllers/api/live_stream_controller_test.exs @@ -0,0 +1,152 @@ +defmodule Asciinema.Api.LiveStreamControllerTest do + use AsciinemaWeb.ConnCase + import Asciinema.Factory + alias Asciinema.Accounts + + @default_install_id "9da34ff4-9bf7-45d4-aa88-98c933b15a3f" + + setup %{conn: conn} = context do + install_id = Map.get(context, :install_id, @default_install_id) + mode = Map.get(context, :register, true) + + {:ok, conn: add_auth_header(conn, install_id), user: register_install_id(install_id, mode)} + end + + describe "get default stream" do + @tag install_id: nil + test "responds with 401 when auth missing", %{conn: conn} do + conn = get(conn, ~p"/api/user/stream") + assert response(conn, 401) + end + + @tag register: false + test "responds with 401 when the install ID is unknown", %{conn: conn} do + conn = get(conn, ~p"/api/user/stream") + assert response(conn, 401) + end + + @tag register: :revoked + test "responds with 401 when the install ID has been revoked", %{conn: conn} do + conn = get(conn, ~p"/api/user/stream") + assert response(conn, 401) + end + + @tag register: :tmp + test "responds with 401 when the user has not been verified", %{conn: conn} do + conn = get(conn, ~p"/api/user/stream") + assert json_response(conn, 401) + end + + test "responds with 404 when no stream is available", %{conn: conn} do + conn = get(conn, ~p"/api/user/stream") + assert %{} = json_response(conn, 404) + end + + test "responds with stream info when a stream is available", %{conn: conn, user: user} do + insert(:live_stream, user: user, public_token: "foobar", producer_token: "bazqux") + conn = get(conn, ~p"/api/user/stream") + + assert %{ + "url" => "http://localhost:4001/s/foobar", + "ws_producer_url" => "ws://localhost:4001/ws/S/bazqux" + } = json_response(conn, 200) + end + end + + describe "get stream by ID" do + @tag install_id: nil + test "responds with 401 when auth missing", %{conn: conn} do + conn = get(conn, ~p"/api/user/streams/x") + assert response(conn, 401) + end + + @tag register: false + test "responds with 401 when the install ID is unknown", %{conn: conn} do + conn = get(conn, ~p"/api/user/streams/x") + assert response(conn, 401) + end + + @tag register: :revoked + test "responds with 401 when the install ID has been revoked", %{conn: conn} do + conn = get(conn, ~p"/api/user/streams/x") + assert response(conn, 401) + end + + @tag register: :tmp + test "responds with 401 when the user has not been verified", %{conn: conn} do + conn = get(conn, ~p"/api/user/streams/x") + assert json_response(conn, 401) + end + + test "responds with 404 when stream is not found", %{conn: conn, user: user} do + insert(:live_stream, user: user) + conn = get(conn, ~p"/api/user/streams/x") + assert %{} = json_response(conn, 404) + end + + test "responds with 404 when stream belongs to another user", %{conn: conn, user: user} do + insert(:live_stream, user: user) + stream = insert(:live_stream) + conn = get(conn, ~p"/api/user/streams/#{stream}") + assert %{} = json_response(conn, 404) + end + + test "responds with stream info when a stream is found", %{conn: conn, user: user} do + insert(:live_stream, user: user) + insert(:live_stream, user: user, public_token: "foobar", producer_token: "bazqux") + conn = get(conn, ~p"/api/user/streams/foobar") + + assert %{ + "url" => "http://localhost:4001/s/foobar", + "ws_producer_url" => "ws://localhost:4001/ws/S/bazqux" + } = json_response(conn, 200) + end + + test "responds with stream info when a stream is found by token prefix", %{ + conn: conn, + user: user + } do + insert(:live_stream, user: user, public_token: "foobar", producer_token: "bazqux") + conn = get(conn, ~p"/api/user/streams/foo") + + assert %{ + "url" => "http://localhost:4001/s/foobar", + "ws_producer_url" => "ws://localhost:4001/ws/S/bazqux" + } = json_response(conn, 200) + end + end + + defp add_auth_header(conn, nil), do: conn + + defp add_auth_header(conn, install_id) do + put_req_header(conn, "authorization", "Basic " <> Base.encode64(":" <> install_id)) + end + + defp register_install_id(nil, _mode), do: nil + + defp register_install_id(install_id, mode) do + case mode do + false -> + nil + + :revoked -> + user = insert(:user) + {:ok, token} = Accounts.create_api_token(user, install_id) + Accounts.revoke_api_token!(token) + + user + + :tmp -> + user = insert(:temporary_user) + {:ok, _} = Accounts.create_api_token(user, install_id) + + user + + true -> + user = insert(:user) + {:ok, _} = Accounts.create_api_token(user, install_id) + + user + end + end +end diff --git a/test/controllers/api/recording_controller_test.exs b/test/controllers/api/recording_controller_test.exs index c7c11f2fa..2ae6727ef 100644 --- a/test/controllers/api/recording_controller_test.exs +++ b/test/controllers/api/recording_controller_test.exs @@ -15,46 +15,63 @@ defmodule Asciinema.Api.RecordingControllerTest do {:ok, conn: conn, token: token} end + defp upload(conn, upload) do + post conn, ~p"/api/asciicasts", %{"asciicast" => upload} + end + @recording_url ~r|^http://localhost:4001/a/[a-zA-Z0-9]{25}| @successful_response ~r|View.+at.+http://localhost:4001/a/[a-zA-Z0-9]{25}\n|s describe ".create" do test "json file, v1 format", %{conn: conn} do upload = fixture(:upload, %{path: "1/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert text_response(conn, 201) =~ @successful_response assert List.first(get_resp_header(conn, "location")) =~ @recording_url end - test "json file, v2 format", %{conn: conn} do + test "json file, v2 format, minimal", %{conn: conn} do upload = fixture(:upload, %{path: "2/minimal.cast"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert text_response(conn, 201) =~ @successful_response assert List.first(get_resp_header(conn, "location")) =~ @recording_url end - test "json file, v1 format (missing required data)", %{conn: conn} do + test "json file, v2 format, full", %{conn: conn} do + upload = fixture(:upload, %{path: "2/full.cast"}) + conn = upload(conn, upload) + assert text_response(conn, 201) =~ @successful_response + assert List.first(get_resp_header(conn, "location")) =~ @recording_url + end + + test "json file, v1 format, missing required data", %{conn: conn} do upload = fixture(:upload, %{path: "1/invalid.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) + assert %{"errors" => _} = json_response(conn, 422) + end + + test "json file, v2 format, invalid theme format", %{conn: conn} do + upload = fixture(:upload, %{path: "2/invalid-theme.cast"}) + conn = upload(conn, upload) assert %{"errors" => _} = json_response(conn, 422) end test "json file, unsupported version number", %{conn: conn} do upload = fixture(:upload, %{path: "5/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert text_response(conn, 422) =~ ~r|not supported| end test "non-json file", %{conn: conn} do upload = fixture(:upload, %{path: "new-logo-bars.png"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert text_response(conn, 400) =~ ~r|valid asciicast| end test "existing user (API token)", %{conn: conn, token: token} do {:ok, _} = Accounts.create_user_with_api_token(token, "test") upload = fixture(:upload, %{path: "1/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert text_response(conn, 201) =~ @successful_response assert List.first(get_resp_header(conn, "location")) =~ @recording_url end @@ -62,7 +79,7 @@ defmodule Asciinema.Api.RecordingControllerTest do @tag token: nil test "no authentication", %{conn: conn} do upload = fixture(:upload, %{path: "1/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert response(conn, 401) end @@ -71,21 +88,21 @@ defmodule Asciinema.Api.RecordingControllerTest do Accounts.get_user_with_api_token(token, "test") token |> Accounts.get_api_token!() |> Accounts.revoke_api_token!() upload = fixture(:upload, %{path: "1/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert response(conn, 401) end @tag token: "invalid-lol" test "authentication with invalid token", %{conn: conn} do upload = fixture(:upload, %{path: "1/asciicast.json"}) - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert response(conn, 401) end test "requesting json response", %{conn: conn} do upload = fixture(:upload, %{path: "2/minimal.cast"}) conn = put_req_header(conn, "accept", "application/json") - conn = post conn, Routes.api_recording_path(conn, :create), %{"asciicast" => upload} + conn = upload(conn, upload) assert %{"url" => "http" <> _} = json_response(conn, 201) assert List.first(get_resp_header(conn, "location")) =~ @recording_url end diff --git a/test/controllers/live_stream_controller_test.exs b/test/controllers/live_stream_controller_test.exs index cf2fdf8d8..f1f693d5a 100644 --- a/test/controllers/live_stream_controller_test.exs +++ b/test/controllers/live_stream_controller_test.exs @@ -4,36 +4,19 @@ defmodule Asciinema.LiveStreamControllerTest do alias Asciinema.Authorization describe "show" do - test "HTML, private stream via ID", %{conn: conn} do + test "HTML, private stream", %{conn: conn} do stream = insert(:live_stream, private: true) - conn_2 = get(conn, "/s/#{stream.id}") - - assert html_response(conn_2, 404) - end - - test "HTML, private stream via secret token", %{conn: conn} do - stream = insert(:live_stream, private: true) - - conn_2 = get(conn, "/s/#{stream.secret_token}") - - assert html_response(conn_2, 200) =~ "createPlayer" - assert response_content_type(conn_2, :html) - end - - test "HTML, public stream via ID", %{conn: conn} do - stream = insert(:live_stream, private: false) - - conn_2 = get(conn, "/s/#{stream.id}") + conn_2 = get(conn, ~p"/s/#{stream}") assert html_response(conn_2, 200) =~ "createPlayer" assert response_content_type(conn_2, :html) end - test "HTML, public stream via secret token", %{conn: conn} do + test "HTML, public stream", %{conn: conn} do stream = insert(:live_stream, private: false) - conn_2 = get(conn, "/s/#{stream.secret_token}") + conn_2 = get(conn, ~p"/s/#{stream}") assert html_response(conn_2, 200) =~ "createPlayer" assert response_content_type(conn_2, :html) @@ -43,15 +26,15 @@ defmodule Asciinema.LiveStreamControllerTest do user = insert(:user) stream = insert(:live_stream, user: user) - conn_2 = get(conn, "/s/#{stream.secret_token}") + conn_2 = get(conn, ~p"/s/#{stream}") refute html_response(conn_2, 200) =~ stream.producer_token conn_2 = log_in(conn, insert(:user)) - conn_2 = get(conn_2, "/s/#{stream.secret_token}") + conn_2 = get(conn_2, ~p"/s/#{stream}") refute html_response(conn_2, 200) =~ stream.producer_token conn_2 = log_in(conn, user) - conn_2 = get(conn_2, "/s/#{stream.secret_token}") + conn_2 = get(conn_2, ~p"/s/#{stream}") assert html_response(conn_2, 200) =~ stream.producer_token end end diff --git a/test/fixtures/2/invalid-theme.cast b/test/fixtures/2/invalid-theme.cast new file mode 100644 index 000000000..99ceb5fcd --- /dev/null +++ b/test/fixtures/2/invalid-theme.cast @@ -0,0 +1,4 @@ +{"version": 2, "width": 96, "height": 26, "theme": {"fg": "white", "bg": "000000", "palette": ""}} +[1.234567, "o", "foo bar"] +[5.678987, "o", "baz qux"] +[8.456789, "o", "żółć jaźń"] diff --git a/test/support/factory.ex b/test/support/factory.ex index da6600803..d2d1dca84 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -72,11 +72,19 @@ defmodule Asciinema.Factory do def live_stream_factory do %LiveStream{ user: build(:user), - secret_token: sequence(:secret_token, &secret_token/1), + public_token: sequence(:public_token, &public_token/1), producer_token: sequence(:producer_token, &"token-#{&1}") } end + defp public_token(n) do + "public-#{n}" + |> String.codepoints() + |> Stream.cycle() + |> Stream.take(16) + |> Enum.join("") + end + defp secret_token(n) do "sekrit-#{n}" |> String.codepoints()