From d0e68999eccfc998e8bc728eb0bb0d6a0638f281 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 19 Jun 2023 12:03:54 +0200 Subject: [PATCH 001/121] Update install doc wrt running recorder in a container Fixes https://github.com/asciinema/asciinema/issues/552 --- .../templates/doc/installation.html.md | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/lib/asciinema_web/templates/doc/installation.html.md b/lib/asciinema_web/templates/doc/installation.html.md index ffc3a3444..795f7f35c 100644 --- a/lib/asciinema_web/templates/doc/installation.html.md +++ b/lib/asciinema_web/templates/doc/installation.html.md @@ -7,7 +7,7 @@ There are several ways to get asciinema recorder: - [Installing on macOS](#installing-on-macos) - [Installing on FreeBSD](#installing-on-freebsd) - [Installing on OpenBSD](#installing-on-openbsd) -- [Running in Docker container](#running-in-docker-container) +- [Running in a container](#running-in-a-container) - [Running from source](#running-from-source) If you use other operating system and you can build a native package for it then @@ -96,30 +96,54 @@ For Fedora >= 22: pkg_add asciinema -## Running in Docker container -{: #running-in-docker-container} +## Running in a container +{: #running-in-a-container} -asciinema Docker image is based on Ubuntu 16.04 and has the latest version of +asciinema Docker image is based on [Ubuntu +22.04](https://releases.ubuntu.com/22.04/) and has the latest version of asciinema recorder pre-installed. - docker pull asciinema/asciinema +```sh +docker pull ghcr.io/asciinema/asciinema +``` When running it don't forget to allocate a pseudo-TTY (`-t`), keep STDIN open (`-i`) and mount config directory volume (`-v`): - docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema +```sh +docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" ghcr.io/asciinema/asciinema rec +``` -Default command run in a container is `asciinema rec`. +Container's entrypoint is set to `/usr/local/bin/asciinema` so you can run the +container with any arguments you would normally pass to `asciinema` binary (see +Usage section for commands and options). There's not much software installed in this image though. In most cases you may want to install extra programs before recording. One option is to derive new image from this one (start your custom Dockerfile with `FROM -asciinema/asciinema`). Another option is to start the container with `/bin/bash` -as the command, install extra packages and manually start `asciinema rec`: - - docker run --rm -ti -v "$HOME/.config/asciinema":/root/.config/asciinema asciinema/asciinema /bin/bash - root@6689517d99a1:~# apt-get install foobar - root@6689517d99a1:~# asciinema rec +ghcr.io/asciinema/asciinema`). Another option is to start the container with +`/bin/bash` as the entrypoint, install extra packages and manually start +`asciinema rec`: + +```console +docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash ghcr.io/asciinema/asciinema rec +root@6689517d99a1:~# apt-get install foobar +root@6689517d99a1:~# asciinema rec +``` + +It is also possible to run the docker container as a non-root user, which has +security benefits. You can specify a user and group id at runtime to give the +application permission similar to the calling user on your host. + +```sh +docker run --rm -it \ + --env=ASCIINEMA_CONFIG_HOME="/run/user/$(id -u)/.config/asciinema" \ + --user="$(id -u):$(id -g)" \ + --volume="${HOME}/.config/asciinema:/run/user/$(id -u)/.config/asciinema:rw" \ + --volume="${PWD}:/data:rw" \ + --workdir='/data' \ + ghcr.io/asciinema/asciinema rec +``` ## Running from source {: #running-from-source} From e49b743bd47e35bc6f69de789eeb08e2e0e86935 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 20 Jun 2023 10:19:14 +0200 Subject: [PATCH 002/121] Add consulting section to README --- README.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 66be60517..a01d211d5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ Alternatively, you can set `ASCIINEMA_API_URL` environment variable: ASCIINEMA_API_URL=https://your.asciinema.host asciinema rec +## Security + +Security of this web app and user data it manages is important. +If you find anything that looks like a potential vulnerability please +read on +[how to report a security issue](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md#reporting-security-issues). + +## Consulting + +I offer consulting services for asciinema project. See https://asciinema.org/consulting for more information. + ## Contributing Check out our [Contributing](http://asciinema.org/contributing) page, which @@ -43,13 +54,6 @@ If you decide to contribute with the code then please read [CONTRIBUTING.md](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md), which covers submitting bugs, requesting new features, preparing your code for a pull request, etc. -## Security - -We're serious about the security of this web app and the user data it manages. -If you find anything that looks like a potential vulnerability please -read on -[how to report a security issue](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md#reporting-security-issues). - ## Authors asciinema is developed by [Marcin Kulik](http://ku1ik.com) with the help of From 469d6c42fde2fce4e0d932b32f72d7e63d0270cf Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 27 Feb 2023 18:46:24 +0100 Subject: [PATCH 003/121] Rough initial version of live streaming --- lib/asciinema/application.ex | 6 +- lib/asciinema/live_stream.ex | 123 ++++++++++++++++++ lib/asciinema/live_stream_supervisor.ex | 32 +++++ lib/asciinema/vt.ex | 3 + .../controllers/live_stream_controller.ex | 26 ++++ lib/asciinema_web/endpoint.ex | 4 + .../live_stream_consumer_socket.ex | 85 ++++++++++++ .../live_stream_producer_socket.ex | 85 ++++++++++++ lib/asciinema_web/router.ex | 2 + .../templates/live_stream/show.html.eex | 46 +++++++ lib/asciinema_web/views/live_stream_view.ex | 68 ++++++++++ native/vt_nif/src/lib.rs | 9 +- 12 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 lib/asciinema/live_stream.ex create mode 100644 lib/asciinema/live_stream_supervisor.ex create mode 100644 lib/asciinema_web/controllers/live_stream_controller.ex create mode 100644 lib/asciinema_web/live_stream_consumer_socket.ex create mode 100644 lib/asciinema_web/live_stream_producer_socket.ex create mode 100644 lib/asciinema_web/templates/live_stream/show.html.eex create mode 100644 lib/asciinema_web/views/live_stream_view.ex diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index e522ff605..7961c26d8 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -25,7 +25,11 @@ defmodule Asciinema.Application do # Start PNG generator poolboy pool :poolboy.child_spec(:worker, Asciinema.PngGenerator.Rsvg.poolboy_config(), []), # Start Oban - {Oban, oban_config()} + {Oban, oban_config()}, + {Registry, [keys: :unique, name: Asciinema.LiveStreamRegistry]}, + {Registry, + [keys: :duplicate, name: Asciinema.PubSubRegistry, partitions: System.schedulers_online()]}, + Asciinema.LiveStreamSupervisor ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex new file mode 100644 index 000000000..e2d45e915 --- /dev/null +++ b/lib/asciinema/live_stream.ex @@ -0,0 +1,123 @@ +defmodule Asciinema.LiveStream do + use GenServer, restart: :transient + alias Asciinema.Vt + require Logger + + # Client + + def start_link(stream_id) do + GenServer.start_link(__MODULE__, stream_id, name: via_tuple(stream_id)) + end + + def reset(stream_id, {_, _} = vt_size) do + :ok = GenServer.call(via_tuple(stream_id), {:reset, vt_size}) + end + + def feed(stream_id, data) do + :ok = GenServer.call(via_tuple(stream_id), {:feed, data}) + end + + def join(stream_id) do + Logger.debug("client: join") + + ref1 = make_ref() + ref2 = make_ref() + + Logger.debug("client: casting") + + :ok = GenServer.cast(via_tuple(stream_id), {:join, {self(), ref1, ref2}}) + + Logger.debug("client: waiting for stream state") + + receive do + {^ref1, pid, {_vt_size, _vt_state, _epoch} = stream_state} -> + Logger.debug("client: got stream state, subscribing") + subscribe(stream_id) + Logger.debug("client: releasing server") + send(pid, ref2) + + {:ok, stream_state} + after + 5000 -> + Logger.debug("client: live stream server not responding") + + :error + end + end + + def stop(stream_id), do: GenServer.stop(via_tuple(stream_id)) + + def crash(stream_id), do: GenServer.cast(via_tuple(stream_id), :raise) + + # Callbacks + + @impl true + def init(stream_id) do + Logger.info("initializing live stream #{stream_id}...") + + # TODO load cols/rows and last known dump from db + + vt_size = {80, 24} + last_vt_state = "" + + {cols, rows} = vt_size + {:ok, vt} = Vt.new(cols, rows) + :ok = Vt.feed(vt, last_vt_state) + + {:ok, %{stream_id: stream_id, vt: vt, vt_size: vt_size, epoch: Timex.now()}} + end + + @impl true + def handle_call({:reset, {cols, rows} = vt_size}, _from, state) do + {:ok, vt} = Vt.new(cols, rows) + publish(state.stream_id, {:reset, vt_size}) + + {:reply, :ok, %{state | vt: vt, vt_size: vt_size, epoch: Timex.now()}} + end + + def handle_call({:feed, {time, data}}, _from, state) do + :ok = Vt.feed(state.vt, data) + publish(state.stream_id, {:feed, {time, data}}) + + {:reply, :ok, state} + end + + @impl true + def handle_cast({:join, {pid, ref1, ref2}}, state) do + Logger.debug("server: sending stream state") + + send(pid, {ref1, self(), {state.vt_size, Vt.dump(state.vt), state.epoch}}) + + Logger.debug("server: waiting for client subscribe") + + receive do + ^ref2 -> + Logger.debug("server: client successfully subscribed") + :ok + after + 5000 -> + Logger.warn("server: timed out waiting for client subscribe") + :ok + end + + {:noreply, state} + end + + def handle_cast(_, state) do + {:noreply, state} + end + + # Private + + defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.LiveStreamRegistry, stream_id}} + + defp subscribe(stream_id) do + {:ok, _} = Registry.register(Asciinema.PubSubRegistry, {:live_stream, stream_id}, []) + end + + defp publish(stream_id, data) do + Registry.dispatch(Asciinema.PubSubRegistry, {:live_stream, stream_id}, fn entries -> + for {pid, _} <- entries, do: send(pid, {:live_stream, data}) + end) + end +end diff --git a/lib/asciinema/live_stream_supervisor.ex b/lib/asciinema/live_stream_supervisor.ex new file mode 100644 index 000000000..59269748a --- /dev/null +++ b/lib/asciinema/live_stream_supervisor.ex @@ -0,0 +1,32 @@ +defmodule Asciinema.LiveStreamSupervisor do + use DynamicSupervisor + alias Asciinema.LiveStream + require Logger + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + def start_child(id) do + Logger.debug("starting server for live stream #{id}") + + DynamicSupervisor.start_child(__MODULE__, {LiveStream, id}) + end + + def ensure_child(id) do + case start_child(id) do + {:error, {:already_started, pid}} -> + Logger.debug("found existing server for live stream #{id}") + + {:ok, pid} + + otherwise -> + otherwise + end + end +end diff --git a/lib/asciinema/vt.ex b/lib/asciinema/vt.ex index a4aaec0ff..f94d97706 100644 --- a/lib/asciinema/vt.ex +++ b/lib/asciinema/vt.ex @@ -13,6 +13,9 @@ defmodule Asciinema.Vt do def feed(_vt, _str), do: :erlang.nif_error(:nif_not_loaded) # => :ok + def dump(_vt), do: :erlang.nif_error(:nif_not_loaded) + # => ... + def dump_screen(_vt), do: :erlang.nif_error(:nif_not_loaded) # => {:ok, {lines, cursor}} end diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex new file mode 100644 index 000000000..ed957acb1 --- /dev/null +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -0,0 +1,26 @@ +defmodule AsciinemaWeb.LiveStreamController do + use AsciinemaWeb, :controller + + plug :clear_main_class + + def show(conn, params) do + live_stream = %{ + cols: 80, + cols_override: nil, + rows: 24, + rows_override: nil, + theme_name: nil, + user: %{id: 1, email: nil, username: nil, temporary_username: nil, theme_name: nil}, + snapshot: nil, + idle_time_limit: nil, + title: nil, + command: nil, + private: nil, + id: params["id"] + } + + conn + |> assign(:live_stream, live_stream) + |> render("show.html") + end +end diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index 37b0e347b..2358fef95 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -15,6 +15,10 @@ defmodule AsciinemaWeb.Endpoint do # socket "/socket", AsciinemaWeb.UserSocket, websocket: true + # compress helps at all? + socket "/ws/S/:id", AsciinemaWeb.LiveStreamProducerSocket, websocket: [path: "", compress: true] + socket "/ws/s/:id", AsciinemaWeb.LiveStreamConsumerSocket, websocket: [path: "", compress: true] + # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phx.digest diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex new file mode 100644 index 000000000..a87431d07 --- /dev/null +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -0,0 +1,85 @@ +defmodule AsciinemaWeb.LiveStreamConsumerSocket do + alias Asciinema.LiveStream + alias Asciinema.LiveStreamSupervisor + require Logger + + @behaviour Phoenix.Socket.Transport + + # Callbacks + + @impl true + def child_spec(_opts) do + # We won't spawn any process, so let's return a dummy task + %{id: __MODULE__, start: {Task, :start_link, [fn -> :ok end]}, restart: :transient} + end + + @impl true + def connect(state) do + state = %{ + stream_id: state.params["id"], + time_offset: 0.0 + } + + {:ok, state} + end + + @impl true + def init(state) do + {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) + {:ok, {vt_size, vt_state, epoch}} = LiveStream.join(state.stream_id) + send(self(), {:push, reset_message(vt_size)}) + send(self(), {:push, feed_message({0.0, vt_state})}) + time_offset = Timex.diff(Timex.now(), epoch, :milliseconds) / 1000.0 + schedule_ping() + + {:ok, %{state | time_offset: time_offset}} + end + + @impl true + def handle_in({_text, _opts}, state) do + {:ok, state} + end + + @impl true + def handle_info({:push, message}, state) do + {:push, message, state} + end + + def handle_info({:live_stream, {:reset, vt_size}}, state) do + {:push, reset_message(vt_size), %{state | time_offset: 0.0}} + end + + def handle_info({:live_stream, {:feed, {time, data}}}, state) do + {:push, feed_message({time - state.time_offset, data}), state} + end + + def handle_info(:ping, state) do + schedule_ping() + + {:push, {:ping, ""}, state} + end + + @impl true + def terminate(_reason, state) do + Logger.debug("consumer: state on termination: #{inspect(state)}") + + :ok + end + + # Private + + defp reset_message({cols, rows}) do + {:text, Jason.encode!(%{cols: cols, rows: rows})} + end + + defp feed_message({time, data}) do + {:text, Jason.encode!([time, "o", data])} + # {:binary, data} + end + + @ping_interval 15_000 + + defp schedule_ping do + Process.send_after(self(), :ping, @ping_interval) + end +end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex new file mode 100644 index 000000000..5a60c57f5 --- /dev/null +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -0,0 +1,85 @@ +defmodule AsciinemaWeb.LiveStreamProducerSocket do + alias Asciinema.LiveStream + alias Asciinema.LiveStreamSupervisor + require Logger + + @behaviour Phoenix.Socket.Transport + + # Callbacks + + @impl true + def child_spec(_opts) do + # We won't spawn any process, so let's return a dummy task + %{id: __MODULE__, start: {Task, :start_link, [fn -> :ok end]}, restart: :transient} + end + + @impl true + def connect(state) do + state = %{ + stream_id: state.params["id"] + } + + {:ok, state} + end + + @impl true + def init(state) do + {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) + schedule_ping() + + {:ok, state} + end + + @impl true + def handle_in({text, _opts}, state) do + case Jason.decode(text) do + {:ok, %{"cols" => cols, "rows" => rows}} -> + Logger.debug("producer: reset (#{cols}x#{rows})") + :ok = LiveStream.reset(state.stream_id, {cols, rows}) + + {:ok, %{"width" => cols, "height" => rows}} -> + Logger.debug("producer: reset (#{cols}x#{rows})") + :ok = LiveStream.reset(state.stream_id, {cols, rows}) + + {:ok, header} when is_map(header) -> + Logger.debug("producer: invalid header: #{inspect(header)}") + + {:ok, [time, "o", data]} -> + :ok = LiveStream.feed(state.stream_id, {time, data}) + + {:ok, [_, _, _]} -> + :ok + + _otherwise -> + Logger.debug("producer: invalid message: #{inspect(text)}") + end + + {:ok, state} + end + + @impl true + def handle_info(:ping, state) do + schedule_ping() + + {:push, {:ping, ""}, state} + end + + def handle_info(_, state) do + {:ok, state} + end + + @impl true + def terminate(_reason, state) do + Logger.debug("producer: state on termination: #{inspect(state)}") + + :ok + end + + # Private + + @ping_interval 15_000 + + defp schedule_ping do + Process.send_after(self(), :ping, @ping_interval) + end +end diff --git a/lib/asciinema_web/router.ex b/lib/asciinema_web/router.ex index f0fa0fa64..f216816cd 100644 --- a/lib/asciinema_web/router.ex +++ b/lib/asciinema_web/router.ex @@ -67,6 +67,8 @@ defmodule AsciinemaWeb.Router do get "/a/:id/iframe", RecordingController, :iframe get "/a/:id/example", RecordingController, :example + get "/s/:id", LiveStreamController, :show + get "/docs", DocController, :index get "/docs/:topic", DocController, :show diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex new file mode 100644 index 000000000..b1dc2e7da --- /dev/null +++ b/lib/asciinema_web/templates/live_stream/show.html.eex @@ -0,0 +1,46 @@ +
+ <%= player @live_stream, [container_id: "cinema", fit: "both"] %> +
+ +
+
+
+
+ + + +

+ Live stream test +

+ + + by me + +
+ +
+ +
+
+
+
+ +
+
+
+
+ + + OS=??? + + SHELL=??? + + TERM=??? + + VIEWerS=livelivelive + +
+
+
+
diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/views/live_stream_view.ex new file mode 100644 index 000000000..e85f36299 --- /dev/null +++ b/lib/asciinema_web/views/live_stream_view.ex @@ -0,0 +1,68 @@ +defmodule AsciinemaWeb.LiveStreamView do + use AsciinemaWeb, :view + import AsciinemaWeb.AsciicastView, only: [cinema_height: 1] + alias AsciinemaWeb.UserView + + def player(src, opts \\ []) + + def player(src, opts) when is_binary(src) do + {container_id, opts} = Keyword.pop!(opts, :container_id) + + src = %{ + driver: "websocket", + url: src, + bufferTime: 1.0 + } + + props = + [src: src] + |> Keyword.merge(opts) + |> Enum.into(%{}) + + props_json = + props + |> Jason.encode!() + |> String.replace(~r/', <%= {:safe, props_json} %>); + """ + end + end + + def player(live_stream, opts) do + opts = + Keyword.merge( + [ + cols: cols(live_stream), + rows: rows(live_stream), + theme: theme_name(live_stream) + # poster: poster(live_stream.snapshot), + ], + opts + ) + + player(ws_consumer_url(live_stream), opts) + end + + defp cols(live_stream), do: live_stream.cols_override || live_stream.cols + + defp rows(live_stream), do: live_stream.rows_override || live_stream.rows + + def theme_name(live_stream) do + live_stream.theme_name || default_theme_name(live_stream) + end + + def default_theme_name(live_stream) do + UserView.theme_name(live_stream.user) || "asciinema" + end + + # TODO use Routes + defp ws_consumer_url(live_stream) do + String.replace(AsciinemaWeb.Endpoint.url() <> "/ws/s/#{live_stream.id}", ~r/^http/, "ws") + end + + # TODO make it live - with cinema height automatically recalculated and updated +end diff --git a/native/vt_nif/src/lib.rs b/native/vt_nif/src/lib.rs index f90ae17e1..c1db660f8 100644 --- a/native/vt_nif/src/lib.rs +++ b/native/vt_nif/src/lib.rs @@ -42,6 +42,13 @@ fn feed(resource: ResourceArc, input: &str) -> NifResult { Ok(atoms::ok()) } +#[rustler::nif] +fn dump(resource: ResourceArc) -> NifResult { + let vt = convert_err(resource.vt.read(), "rw_lock")?; + + Ok(vt.dump()) +} + #[rustler::nif] fn dump_screen(env: Env, resource: ResourceArc) -> NifResult<(Atom, Term)> { let vt = convert_err(resource.vt.read(), "rw_lock")?; @@ -130,4 +137,4 @@ fn convert_err(result: Result, error: &'static str) -> Result Date: Mon, 27 Feb 2023 19:20:20 +0100 Subject: [PATCH 004/121] Keep track of current stream time in LiveStream server --- lib/asciinema/live_stream.ex | 30 +++++++++++++------ .../live_stream_consumer_socket.ex | 20 +++++-------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index e2d45e915..02c9211d0 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -13,8 +13,8 @@ defmodule Asciinema.LiveStream do :ok = GenServer.call(via_tuple(stream_id), {:reset, vt_size}) end - def feed(stream_id, data) do - :ok = GenServer.call(via_tuple(stream_id), {:feed, data}) + def feed(stream_id, event) do + :ok = GenServer.call(via_tuple(stream_id), {:feed, event}) end def join(stream_id) do @@ -30,7 +30,7 @@ defmodule Asciinema.LiveStream do Logger.debug("client: waiting for stream state") receive do - {^ref1, pid, {_vt_size, _vt_state, _epoch} = stream_state} -> + {^ref1, pid, {_vt_size, _vt_state, _stream_time} = stream_state} -> Logger.debug("client: got stream state, subscribing") subscribe(stream_id) Logger.debug("client: releasing server") @@ -64,7 +64,15 @@ defmodule Asciinema.LiveStream do {:ok, vt} = Vt.new(cols, rows) :ok = Vt.feed(vt, last_vt_state) - {:ok, %{stream_id: stream_id, vt: vt, vt_size: vt_size, epoch: Timex.now()}} + state = %{ + stream_id: stream_id, + vt: vt, + vt_size: vt_size, + last_stream_time: 0.0, + last_feed_time: Timex.now() + } + + {:ok, state} end @impl true @@ -72,21 +80,25 @@ defmodule Asciinema.LiveStream do {:ok, vt} = Vt.new(cols, rows) publish(state.stream_id, {:reset, vt_size}) - {:reply, :ok, %{state | vt: vt, vt_size: vt_size, epoch: Timex.now()}} + {:reply, :ok, %{state | vt: vt, vt_size: vt_size}} end - def handle_call({:feed, {time, data}}, _from, state) do + def handle_call({:feed, {time, data} = event}, _from, state) do :ok = Vt.feed(state.vt, data) - publish(state.stream_id, {:feed, {time, data}}) + publish(state.stream_id, {:feed, event}) - {:reply, :ok, state} + {:reply, :ok, %{state | last_stream_time: time, last_feed_time: Timex.now()}} end @impl true def handle_cast({:join, {pid, ref1, ref2}}, state) do Logger.debug("server: sending stream state") - send(pid, {ref1, self(), {state.vt_size, Vt.dump(state.vt), state.epoch}}) + stream_time = + state.last_stream_time + + Timex.diff(Timex.now(), state.last_feed_time, :milliseconds) / 1000.0 + + send(pid, {ref1, self(), {state.vt_size, Vt.dump(state.vt), stream_time}}) Logger.debug("server: waiting for client subscribe") diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index a87431d07..30fe97cb5 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -15,24 +15,18 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def connect(state) do - state = %{ - stream_id: state.params["id"], - time_offset: 0.0 - } - - {:ok, state} + {:ok, %{stream_id: state.params["id"]}} end @impl true def init(state) do {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) - {:ok, {vt_size, vt_state, epoch}} = LiveStream.join(state.stream_id) + {:ok, {vt_size, vt_state, stream_time}} = LiveStream.join(state.stream_id) send(self(), {:push, reset_message(vt_size)}) - send(self(), {:push, feed_message({0.0, vt_state})}) - time_offset = Timex.diff(Timex.now(), epoch, :milliseconds) / 1000.0 + send(self(), {:push, feed_message({stream_time, vt_state})}) schedule_ping() - {:ok, %{state | time_offset: time_offset}} + {:ok, state} end @impl true @@ -46,11 +40,11 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end def handle_info({:live_stream, {:reset, vt_size}}, state) do - {:push, reset_message(vt_size), %{state | time_offset: 0.0}} + {:push, reset_message(vt_size), state} end - def handle_info({:live_stream, {:feed, {time, data}}}, state) do - {:push, feed_message({time - state.time_offset, data}), state} + def handle_info({:live_stream, {:feed, event}}, state) do + {:push, feed_message(event), state} end def handle_info(:ping, state) do From d1c351ccb6e489ce6e16a2113c05ef80b8d6a88e Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 28 Feb 2023 09:41:42 +0100 Subject: [PATCH 005/121] Remove unnecessary intermediate var --- lib/asciinema_web/live_stream_producer_socket.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 5a60c57f5..cfc4858e1 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -15,11 +15,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - state = %{ - stream_id: state.params["id"] - } - - {:ok, state} + {:ok, %{stream_id: state.params["id"]}} end @impl true From e782754abb273a27a86ba39bc634a3180ef06b1a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 1 Mar 2023 22:27:10 +0100 Subject: [PATCH 006/121] Add match guards in live stream parsing --- lib/asciinema_web/live_stream_producer_socket.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index cfc4858e1..3c3b0dcbb 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -29,21 +29,21 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def handle_in({text, _opts}, state) do case Jason.decode(text) do - {:ok, %{"cols" => cols, "rows" => rows}} -> + {:ok, %{"cols" => cols, "rows" => rows}} when is_number(cols) and is_number(rows) -> Logger.debug("producer: reset (#{cols}x#{rows})") :ok = LiveStream.reset(state.stream_id, {cols, rows}) - {:ok, %{"width" => cols, "height" => rows}} -> + {:ok, %{"width" => cols, "height" => rows}} when is_number(cols) and is_number(rows) -> Logger.debug("producer: reset (#{cols}x#{rows})") :ok = LiveStream.reset(state.stream_id, {cols, rows}) {:ok, header} when is_map(header) -> Logger.debug("producer: invalid header: #{inspect(header)}") - {:ok, [time, "o", data]} -> + {:ok, [time, "o", data]} when is_number(time) and is_binary(data) -> :ok = LiveStream.feed(state.stream_id, {time, data}) - {:ok, [_, _, _]} -> + {:ok, [time, _, data]} when is_number(time) and is_binary(data) -> :ok _otherwise -> From 10979c1b102b9eb0e5dd1b9cc06482c8fb649bd0 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 1 Mar 2023 22:27:53 +0100 Subject: [PATCH 007/121] Improve reliability around live streaming producers and consumers --- lib/asciinema/live_stream.ex | 118 +++++++++--------- lib/asciinema/live_stream_supervisor.ex | 6 +- .../live_stream_consumer_socket.ex | 30 +++-- .../live_stream_producer_socket.ex | 78 ++++++++---- 4 files changed, 135 insertions(+), 97 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index 02c9211d0..6cd917442 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -9,66 +9,55 @@ defmodule Asciinema.LiveStream do GenServer.start_link(__MODULE__, stream_id, name: via_tuple(stream_id)) end + def lead(stream_id) do + GenServer.call(via_tuple(stream_id), :lead) + end + def reset(stream_id, {_, _} = vt_size) do - :ok = GenServer.call(via_tuple(stream_id), {:reset, vt_size}) + GenServer.call(via_tuple(stream_id), {:reset, vt_size}) end def feed(stream_id, event) do - :ok = GenServer.call(via_tuple(stream_id), {:feed, event}) + GenServer.call(via_tuple(stream_id), {:feed, event}) end - def join(stream_id) do - Logger.debug("client: join") - - ref1 = make_ref() - ref2 = make_ref() - - Logger.debug("client: casting") - - :ok = GenServer.cast(via_tuple(stream_id), {:join, {self(), ref1, ref2}}) - - Logger.debug("client: waiting for stream state") - - receive do - {^ref1, pid, {_vt_size, _vt_state, _stream_time} = stream_state} -> - Logger.debug("client: got stream state, subscribing") - subscribe(stream_id) - Logger.debug("client: releasing server") - send(pid, ref2) - - {:ok, stream_state} - after - 5000 -> - Logger.debug("client: live stream server not responding") + def heartbeat(stream_id) do + GenServer.call(via_tuple(stream_id), :heartbeat) + end - :error - end + def join(stream_id) do + subscribe({:live_stream, stream_id}) + GenServer.cast(via_tuple(stream_id), {:join, self()}) end def stop(stream_id), do: GenServer.stop(via_tuple(stream_id)) - def crash(stream_id), do: GenServer.cast(via_tuple(stream_id), :raise) - # Callbacks @impl true def init(stream_id) do - Logger.info("initializing live stream #{stream_id}...") - - # TODO load cols/rows and last known dump from db + Logger.info("stream/#{stream_id}: init") + # TODO load vt size and last known state from db vt_size = {80, 24} + last_stream_time = 0.0 last_vt_state = "" {cols, rows} = vt_size {:ok, vt} = Vt.new(cols, rows) :ok = Vt.feed(vt, last_vt_state) + publish( + {:live_stream, stream_id}, + {:live_stream, {:init, {vt_size, last_vt_state, last_stream_time}}} + ) + state = %{ stream_id: stream_id, + producer: nil, vt: vt, vt_size: vt_size, - last_stream_time: 0.0, + last_stream_time: last_stream_time, last_feed_time: Timex.now() } @@ -76,41 +65,54 @@ defmodule Asciinema.LiveStream do end @impl true - def handle_call({:reset, {cols, rows} = vt_size}, _from, state) do + def handle_call(:lead, {pid, _} = _from, state) do + {:reply, :ok, %{state | producer: pid}} + end + + def handle_call({:reset, {cols, rows} = vt_size}, {pid, _} = _from, %{producer: pid} = state) do {:ok, vt} = Vt.new(cols, rows) - publish(state.stream_id, {:reset, vt_size}) + publish({:live_stream, state.stream_id}, {:live_stream, {:reset, vt_size}}) {:reply, :ok, %{state | vt: vt, vt_size: vt_size}} end - def handle_call({:feed, {time, data} = event}, _from, state) do + def handle_call({:reset, _vt_size}, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting reset from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + + def handle_call({:feed, {time, data} = event}, {pid, _} = _from, %{producer: pid} = state) do :ok = Vt.feed(state.vt, data) - publish(state.stream_id, {:feed, event}) + publish({:live_stream, state.stream_id}, {:live_stream, {:feed, event}}) {:reply, :ok, %{state | last_stream_time: time, last_feed_time: Timex.now()}} end - @impl true - def handle_cast({:join, {pid, ref1, ref2}}, state) do - Logger.debug("server: sending stream state") + def handle_call({:feed, _event}, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting feed from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + def handle_call(:heartbeat, {pid, _} = _from, %{producer: pid} = state) do + # TODO schedule shutdown + {:reply, :ok, state} + end + + def handle_call(:heartbeat, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting heartbeat from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + + @impl true + def handle_cast({:join, pid}, state) do stream_time = state.last_stream_time + Timex.diff(Timex.now(), state.last_feed_time, :milliseconds) / 1000.0 - send(pid, {ref1, self(), {state.vt_size, Vt.dump(state.vt), stream_time}}) - - Logger.debug("server: waiting for client subscribe") - - receive do - ^ref2 -> - Logger.debug("server: client successfully subscribed") - :ok - after - 5000 -> - Logger.warn("server: timed out waiting for client subscribe") - :ok - end + send(pid, {:live_stream, {:init, {state.vt_size, Vt.dump(state.vt), stream_time}}}) {:noreply, state} end @@ -123,13 +125,13 @@ defmodule Asciinema.LiveStream do defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.LiveStreamRegistry, stream_id}} - defp subscribe(stream_id) do - {:ok, _} = Registry.register(Asciinema.PubSubRegistry, {:live_stream, stream_id}, []) + defp subscribe(topic) do + {:ok, _} = Registry.register(Asciinema.PubSubRegistry, topic, []) end - defp publish(stream_id, data) do - Registry.dispatch(Asciinema.PubSubRegistry, {:live_stream, stream_id}, fn entries -> - for {pid, _} <- entries, do: send(pid, {:live_stream, data}) + defp publish(topic, payload) do + Registry.dispatch(Asciinema.PubSubRegistry, topic, fn entries -> + for {pid, _} <- entries, do: send(pid, payload) end) end end diff --git a/lib/asciinema/live_stream_supervisor.ex b/lib/asciinema/live_stream_supervisor.ex index 59269748a..27e571c1e 100644 --- a/lib/asciinema/live_stream_supervisor.ex +++ b/lib/asciinema/live_stream_supervisor.ex @@ -13,16 +13,14 @@ defmodule Asciinema.LiveStreamSupervisor do end def start_child(id) do - Logger.debug("starting server for live stream #{id}") - + Logger.debug("stream sup: starting server for live stream #{id}") DynamicSupervisor.start_child(__MODULE__, {LiveStream, id}) end def ensure_child(id) do case start_child(id) do {:error, {:already_started, pid}} -> - Logger.debug("found existing server for live stream #{id}") - + Logger.debug("stream sup: server already exists for live stream #{id}") {:ok, pid} otherwise -> diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 30fe97cb5..fdbc49126 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -1,6 +1,5 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do alias Asciinema.LiveStream - alias Asciinema.LiveStreamSupervisor require Logger @behaviour Phoenix.Socket.Transport @@ -15,15 +14,13 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"]}} + {:ok, %{stream_id: state.params["id"], init: false}} end @impl true def init(state) do - {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) - {:ok, {vt_size, vt_state, stream_time}} = LiveStream.join(state.stream_id) - send(self(), {:push, reset_message(vt_size)}) - send(self(), {:push, feed_message({stream_time, vt_state})}) + Logger.info("consumer/#{state.stream_id}: connected") + LiveStream.join(state.stream_id) schedule_ping() {:ok, state} @@ -35,18 +32,29 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true - def handle_info({:push, message}, state) do - {:push, message, state} + def handle_info( + {:live_stream, {:init, {vt_size, vt_state, stream_time}}}, + %{init: false} = state + ) do + Logger.info("consumer/#{state.stream_id}: init") + send(self(), {:live_stream, {:reset, vt_size}}) + send(self(), {:live_stream, {:feed, {stream_time, vt_state}}}) + + {:ok, %{state | init: true}} end - def handle_info({:live_stream, {:reset, vt_size}}, state) do + def handle_info({:live_stream, {:reset, vt_size}}, %{init: true} = state) do {:push, reset_message(vt_size), state} end - def handle_info({:live_stream, {:feed, event}}, state) do + def handle_info({:live_stream, {:feed, event}}, %{init: true} = state) do {:push, feed_message(event), state} end + def handle_info({:live_stream, _}, %{init: false} = state) do + {:ok, state} + end + def handle_info(:ping, state) do schedule_ping() @@ -55,7 +63,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def terminate(_reason, state) do - Logger.debug("consumer: state on termination: #{inspect(state)}") + Logger.info("consumer/#{state.stream_id}: terminating, state: #{inspect(state)}") :ok end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 3c3b0dcbb..6de51b47b 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -20,44 +20,74 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def init(state) do + Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) + :ok = LiveStream.lead(state.stream_id) schedule_ping() {:ok, state} end @impl true - def handle_in({text, _opts}, state) do - case Jason.decode(text) do - {:ok, %{"cols" => cols, "rows" => rows}} when is_number(cols) and is_number(rows) -> - Logger.debug("producer: reset (#{cols}x#{rows})") - :ok = LiveStream.reset(state.stream_id, {cols, rows}) - - {:ok, %{"width" => cols, "height" => rows}} when is_number(cols) and is_number(rows) -> - Logger.debug("producer: reset (#{cols}x#{rows})") - :ok = LiveStream.reset(state.stream_id, {cols, rows}) - - {:ok, header} when is_map(header) -> - Logger.debug("producer: invalid header: #{inspect(header)}") - - {:ok, [time, "o", data]} when is_number(time) and is_binary(data) -> - :ok = LiveStream.feed(state.stream_id, {time, data}) - - {:ok, [time, _, data]} when is_number(time) and is_binary(data) -> - :ok + def handle_in({"\n", _opts}, state) do + {:ok, state} + end - _otherwise -> - Logger.debug("producer: invalid message: #{inspect(text)}") + def handle_in({text, _opts}, state) do + result = + case Jason.decode(text) do + # TODO add guard for positive cols/rows + {:ok, %{"cols" => cols, "rows" => rows}} when is_integer(cols) and is_integer(rows) -> + Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + LiveStream.reset(state.stream_id, {cols, rows}) + + # TODO add guard for positive cols/rows + {:ok, %{"width" => cols, "height" => rows}} when is_integer(cols) and is_integer(rows) -> + Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + LiveStream.reset(state.stream_id, {cols, rows}) + + {:ok, header} when is_map(header) -> + Logger.debug("producer/#{state.stream_id}: invalid header: #{inspect(header)}") + :ok + + {:ok, [time, "o", data]} when is_number(time) and is_binary(data) -> + LiveStream.feed(state.stream_id, {time, data}) + + {:ok, [time, _, data]} when is_number(time) and is_binary(data) -> + :ok + + {:error, reason} = error -> + Logger.debug("producer/#{state.stream_id}: invalid message: #{inspect(reason)}") + error + + _otherwise -> + Logger.debug("producer/#{state.stream_id}: invalid message: #{inspect(text)}") + :ok + end + + case result do + :ok -> + {:ok, state} + + {:error, :not_a_leader} -> + {:stop, :normal, state} + + {:error, %Jason.DecodeError{}} -> + {:stop, :normal, state} end - - {:ok, state} end @impl true def handle_info(:ping, state) do schedule_ping() - {:push, {:ping, ""}, state} + case LiveStream.heartbeat(state.stream_id) do + :ok -> + {:push, {:ping, ""}, state} + + {:error, :not_a_leader} -> + {:stop, :normal, state} + end end def handle_info(_, state) do @@ -66,7 +96,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def terminate(_reason, state) do - Logger.debug("producer: state on termination: #{inspect(state)}") + Logger.info("producer/#{state.stream_id}: terminating, state: #{inspect(state)}") :ok end From 3c7e23be7c1870a133bfacc139a7f0e150f10aaa Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 16:55:54 +0100 Subject: [PATCH 008/121] Fix websocket consumer crash when live stream server restarts --- lib/asciinema_web/live_stream_consumer_socket.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index fdbc49126..f07f87209 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -32,10 +32,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true - def handle_info( - {:live_stream, {:init, {vt_size, vt_state, stream_time}}}, - %{init: false} = state - ) do + def handle_info({:live_stream, {:init, {vt_size, vt_state, stream_time}}}, state) do Logger.info("consumer/#{state.stream_id}: init") send(self(), {:live_stream, {:reset, vt_size}}) send(self(), {:live_stream, {:feed, {stream_time, vt_state}}}) From 6a72f30aee80ac438817ca2a68aad7235e6f5ce6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 16:56:50 +0100 Subject: [PATCH 009/121] Automatically shutdown live stream server 60s after last heartbeat --- lib/asciinema/live_stream.ex | 26 +++++++++++++++++-- .../live_stream_consumer_socket.ex | 4 +++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index 6cd917442..0210e8bae 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -58,9 +58,12 @@ defmodule Asciinema.LiveStream do vt: vt, vt_size: vt_size, last_stream_time: last_stream_time, - last_feed_time: Timex.now() + last_feed_time: Timex.now(), + shutdown_timer: nil } + state = reschedule_shutdown(state) + {:ok, state} end @@ -96,7 +99,8 @@ defmodule Asciinema.LiveStream do end def handle_call(:heartbeat, {pid, _} = _from, %{producer: pid} = state) do - # TODO schedule shutdown + state = reschedule_shutdown(state) + {:reply, :ok, state} end @@ -121,6 +125,14 @@ defmodule Asciinema.LiveStream do {:noreply, state} end + @impl true + def handle_info(:shutdown, state) do + Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") + publish({:live_stream, state.stream_id}, {:live_stream, :end}) + + {:stop, :normal, state} + end + # Private defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.LiveStreamRegistry, stream_id}} @@ -134,4 +146,14 @@ defmodule Asciinema.LiveStream do for {pid, _} <- entries, do: send(pid, payload) end) end + + defp reschedule_shutdown(state) do + if state.shutdown_timer do + Process.cancel_timer(state.shutdown_timer) + end + + timer = Process.send_after(self(), :shutdown, 60 * 1000) + + %{state | shutdown_timer: timer} + end end diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index f07f87209..b8d6747d2 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -52,6 +52,10 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do {:ok, state} end + def handle_info({:live_stream, :end}, state) do + {:stop, :normal, state} + end + def handle_info(:ping, state) do schedule_ping() From 697c374be4d2a22807624109b41a8d54f9b3d842 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 17:06:02 +0100 Subject: [PATCH 010/121] Extract current stream time calculation into a function --- lib/asciinema/live_stream.ex | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index 0210e8bae..b6c0e8a75 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -41,15 +41,18 @@ defmodule Asciinema.LiveStream do # TODO load vt size and last known state from db vt_size = {80, 24} last_stream_time = 0.0 + last_feed_time = Timex.now() last_vt_state = "" {cols, rows} = vt_size {:ok, vt} = Vt.new(cols, rows) :ok = Vt.feed(vt, last_vt_state) + stream_time = current_stream_time(last_stream_time, last_feed_time) + publish( {:live_stream, stream_id}, - {:live_stream, {:init, {vt_size, last_vt_state, last_stream_time}}} + {:live_stream, {:init, {vt_size, last_vt_state, stream_time}}} ) state = %{ @@ -58,7 +61,7 @@ defmodule Asciinema.LiveStream do vt: vt, vt_size: vt_size, last_stream_time: last_stream_time, - last_feed_time: Timex.now(), + last_feed_time: last_feed_time, shutdown_timer: nil } @@ -112,10 +115,7 @@ defmodule Asciinema.LiveStream do @impl true def handle_cast({:join, pid}, state) do - stream_time = - state.last_stream_time + - Timex.diff(Timex.now(), state.last_feed_time, :milliseconds) / 1000.0 - + stream_time = current_stream_time(state.last_stream_time, state.last_feed_time) send(pid, {:live_stream, {:init, {state.vt_size, Vt.dump(state.vt), stream_time}}}) {:noreply, state} @@ -156,4 +156,8 @@ defmodule Asciinema.LiveStream do %{state | shutdown_timer: timer} end + + defp current_stream_time(last_stream_time, last_feed_time) do + last_stream_time + Timex.diff(Timex.now(), last_feed_time, :milliseconds) / 1000.0 + end end From 4f7b1197868b026d2ae165fa01e4bee0746d66fa Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 17:15:57 +0100 Subject: [PATCH 011/121] Remove commented code --- lib/asciinema_web/live_stream_consumer_socket.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index b8d6747d2..053f27428 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -77,7 +77,6 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do defp feed_message({time, data}) do {:text, Jason.encode!([time, "o", data])} - # {:binary, data} end @ping_interval 15_000 From 305d51ff4d2194416d018da6af949e55fe61dc71 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 17:35:54 +0100 Subject: [PATCH 012/121] Log termination reason of live stream server and websocket handlers --- lib/asciinema/live_stream.ex | 7 +++++++ lib/asciinema_web/live_stream_consumer_socket.ex | 4 ++-- lib/asciinema_web/live_stream_producer_socket.ex | 4 ++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index b6c0e8a75..b9b7392ad 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -133,6 +133,13 @@ defmodule Asciinema.LiveStream do {:stop, :normal, state} end + @impl true + def terminate(reason, state) do + Logger.info("stream/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") + + :ok + end + # Private defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.LiveStreamRegistry, stream_id}} diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 053f27428..84926d1e9 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -63,8 +63,8 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true - def terminate(_reason, state) do - Logger.info("consumer/#{state.stream_id}: terminating, state: #{inspect(state)}") + def terminate(reason, state) do + Logger.info("consumer/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") :ok end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 6de51b47b..c9aee9233 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -95,8 +95,8 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end @impl true - def terminate(_reason, state) do - Logger.info("producer/#{state.stream_id}: terminating, state: #{inspect(state)}") + def terminate(reason, state) do + Logger.info("producer/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") :ok end From 52263a4cc66437af069efa6348326e2bf27ac8f8 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 19:00:04 +0100 Subject: [PATCH 013/121] Fix logger crash when stop reason is not atom --- lib/asciinema/live_stream.ex | 4 +++- lib/asciinema_web/live_stream_consumer_socket.ex | 4 +++- lib/asciinema_web/live_stream_producer_socket.ex | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index b9b7392ad..78fb99585 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -135,7 +135,9 @@ defmodule Asciinema.LiveStream do @impl true def terminate(reason, state) do - Logger.info("stream/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") + Logger.info( + "stream/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" + ) :ok end diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 84926d1e9..b11e5fbfb 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -64,7 +64,9 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def terminate(reason, state) do - Logger.info("consumer/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") + Logger.info( + "consumer/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" + ) :ok end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index c9aee9233..164ad7884 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -96,7 +96,9 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def terminate(reason, state) do - Logger.info("producer/#{state.stream_id}: terminating | reason: #{reason}, state: #{inspect(state)}") + Logger.info( + "producer/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" + ) :ok end From 89b4fd34aace03eeee3f37a7906b63d2458a6863 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 19:01:17 +0100 Subject: [PATCH 014/121] Add vt size guards for live stream producer --- lib/asciinema_web/live_stream_producer_socket.ex | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 164ad7884..acdec8b1f 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -33,16 +33,21 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, state} end + @max_cols 720 + @max_rows 200 + def handle_in({text, _opts}, state) do result = case Jason.decode(text) do - # TODO add guard for positive cols/rows - {:ok, %{"cols" => cols, "rows" => rows}} when is_integer(cols) and is_integer(rows) -> + {:ok, %{"cols" => cols, "rows" => rows}} + when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and + rows <= @max_rows -> Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") LiveStream.reset(state.stream_id, {cols, rows}) - # TODO add guard for positive cols/rows - {:ok, %{"width" => cols, "height" => rows}} when is_integer(cols) and is_integer(rows) -> + {:ok, %{"width" => cols, "height" => rows}} + when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and + rows <= @max_rows -> Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") LiveStream.reset(state.stream_id, {cols, rows}) From fdfbd5c8acfb63a86ad0c8f435969a6539825ee7 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 8 Mar 2023 19:01:38 +0100 Subject: [PATCH 015/121] Simplify error handling in live stream producer socket --- .../live_stream_producer_socket.ex | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index acdec8b1f..73cb9d637 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -52,8 +52,8 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do LiveStream.reset(state.stream_id, {cols, rows}) {:ok, header} when is_map(header) -> - Logger.debug("producer/#{state.stream_id}: invalid header: #{inspect(header)}") - :ok + Logger.info("producer/#{state.stream_id}: invalid header: #{inspect(header)}") + :error {:ok, [time, "o", data]} when is_number(time) and is_binary(data) -> LiveStream.feed(state.stream_id, {time, data}) @@ -61,23 +61,16 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, [time, _, data]} when is_number(time) and is_binary(data) -> :ok - {:error, reason} = error -> - Logger.debug("producer/#{state.stream_id}: invalid message: #{inspect(reason)}") - error - - _otherwise -> - Logger.debug("producer/#{state.stream_id}: invalid message: #{inspect(text)}") - :ok + result -> + Logger.info("producer/#{state.stream_id}: invalid message: #{inspect(result)}") + :error end case result do :ok -> {:ok, state} - {:error, :not_a_leader} -> - {:stop, :normal, state} - - {:error, %Jason.DecodeError{}} -> + :error -> {:stop, :normal, state} end end From 3e0af150e13ea87efe707d1ed8a1833fed8bf3fe Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Thu, 9 Mar 2023 13:04:42 +0100 Subject: [PATCH 016/121] Send heartbeat to live stream server immediately upon producer connection --- .../live_stream_producer_socket.ex | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 73cb9d637..38f51f540 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -5,6 +5,9 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @behaviour Phoenix.Socket.Transport + @ping_interval 15_000 + @heartbeat_interval 15_000 + # Callbacks @impl true @@ -23,7 +26,8 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) :ok = LiveStream.lead(state.stream_id) - schedule_ping() + send(self(), :heartbeat) + Process.send_after(self(), :ping, @ping_interval) {:ok, state} end @@ -77,21 +81,23 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def handle_info(:ping, state) do - schedule_ping() + Process.send_after(self(), :ping, @ping_interval) + + {:push, {:ping, ""}, state} + end + + def handle_info(:heartbeat, state) do + Process.send_after(self(), :heartbeat, @heartbeat_interval) case LiveStream.heartbeat(state.stream_id) do :ok -> - {:push, {:ping, ""}, state} + {:ok, state} {:error, :not_a_leader} -> {:stop, :normal, state} end end - def handle_info(_, state) do - {:ok, state} - end - @impl true def terminate(reason, state) do Logger.info( @@ -100,12 +106,4 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do :ok end - - # Private - - @ping_interval 15_000 - - defp schedule_ping do - Process.send_after(self(), :ping, @ping_interval) - end end From e52704fa3d29795a401ba02f71c4961b6524e2bd Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 10 Mar 2023 16:25:42 +0100 Subject: [PATCH 017/121] Include `init` and `time` attrs in stream header --- lib/asciinema/live_stream.ex | 67 ++++++++++--------- .../live_stream_consumer_socket.ex | 24 +++---- .../live_stream_producer_socket.ex | 4 +- 3 files changed, 47 insertions(+), 48 deletions(-) diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/live_stream.ex index 78fb99585..2edb60b40 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/live_stream.ex @@ -13,8 +13,8 @@ defmodule Asciinema.LiveStream do GenServer.call(via_tuple(stream_id), :lead) end - def reset(stream_id, {_, _} = vt_size) do - GenServer.call(via_tuple(stream_id), {:reset, vt_size}) + def reset(stream_id, {_, _} = vt_size, vt_init \\ nil, stream_time \\ nil) do + GenServer.call(via_tuple(stream_id), {:reset, vt_size, vt_init, stream_time}) end def feed(stream_id, event) do @@ -38,30 +38,13 @@ defmodule Asciinema.LiveStream do def init(stream_id) do Logger.info("stream/#{stream_id}: init") - # TODO load vt size and last known state from db - vt_size = {80, 24} - last_stream_time = 0.0 - last_feed_time = Timex.now() - last_vt_state = "" - - {cols, rows} = vt_size - {:ok, vt} = Vt.new(cols, rows) - :ok = Vt.feed(vt, last_vt_state) - - stream_time = current_stream_time(last_stream_time, last_feed_time) - - publish( - {:live_stream, stream_id}, - {:live_stream, {:init, {vt_size, last_vt_state, stream_time}}} - ) - state = %{ stream_id: stream_id, producer: nil, - vt: vt, - vt_size: vt_size, - last_stream_time: last_stream_time, - last_feed_time: last_feed_time, + vt: nil, + vt_size: nil, + last_stream_time: nil, + last_feed_time: nil, shutdown_timer: nil } @@ -75,14 +58,36 @@ defmodule Asciinema.LiveStream do {:reply, :ok, %{state | producer: pid}} end - def handle_call({:reset, {cols, rows} = vt_size}, {pid, _} = _from, %{producer: pid} = state) do + def handle_call( + {:reset, {cols, rows} = vt_size, vt_init, stream_time}, + {pid, _} = _from, + %{producer: pid} = state + ) do {:ok, vt} = Vt.new(cols, rows) - publish({:live_stream, state.stream_id}, {:live_stream, {:reset, vt_size}}) - {:reply, :ok, %{state | vt: vt, vt_size: vt_size}} + if vt_init do + :ok = Vt.feed(vt, vt_init) + end + + stream_time = stream_time || 0.0 + + publish( + {:live_stream, state.stream_id}, + {:live_stream, {:reset, {vt_size, vt_init, stream_time}}} + ) + + state = %{ + state + | vt: vt, + vt_size: vt_size, + last_stream_time: stream_time, + last_feed_time: Timex.now() + } + + {:reply, :ok, state} end - def handle_call({:reset, _vt_size}, _from, state) do + def handle_call({:reset, _vt_size, _vt_init, _stream_time}, _from, state) do Logger.info("stream/#{state.stream_id}: rejecting reset from non-leader producer") {:reply, {:error, :not_a_leader}, state} @@ -114,21 +119,21 @@ defmodule Asciinema.LiveStream do end @impl true - def handle_cast({:join, pid}, state) do + def handle_cast({:join, pid}, %{vt_size: vt_size} = state) when not is_nil(vt_size) do stream_time = current_stream_time(state.last_stream_time, state.last_feed_time) - send(pid, {:live_stream, {:init, {state.vt_size, Vt.dump(state.vt), stream_time}}}) + send(pid, {:live_stream, {:reset, {vt_size, Vt.dump(state.vt), stream_time}}}) {:noreply, state} end - def handle_cast(_, state) do + def handle_cast({:join, _pid}, state) do {:noreply, state} end @impl true def handle_info(:shutdown, state) do Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") - publish({:live_stream, state.stream_id}, {:live_stream, :end}) + publish({:live_stream, state.stream_id}, {:live_stream, :offline}) {:stop, :normal, state} end diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index b11e5fbfb..601e8ad80 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -32,28 +32,22 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true - def handle_info({:live_stream, {:init, {vt_size, vt_state, stream_time}}}, state) do - Logger.info("consumer/#{state.stream_id}: init") - send(self(), {:live_stream, {:reset, vt_size}}) - send(self(), {:live_stream, {:feed, {stream_time, vt_state}}}) + def handle_info({:live_stream, {:reset, {{_, _}, _, _} = data}}, state) do + Logger.info("consumer/#{state.stream_id}: reset") - {:ok, %{state | init: true}} - end - - def handle_info({:live_stream, {:reset, vt_size}}, %{init: true} = state) do - {:push, reset_message(vt_size), state} + {:push, reset_message(data), %{state | init: true}} end def handle_info({:live_stream, {:feed, event}}, %{init: true} = state) do {:push, feed_message(event), state} end - def handle_info({:live_stream, _}, %{init: false} = state) do - {:ok, state} + def handle_info({:live_stream, :offline}, state) do + {:push, {:text, Jason.encode!(%{state: "offline"})}, state} end - def handle_info({:live_stream, :end}, state) do - {:stop, :normal, state} + def handle_info({:live_stream, _}, %{init: false} = state) do + {:ok, state} end def handle_info(:ping, state) do @@ -73,8 +67,8 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do # Private - defp reset_message({cols, rows}) do - {:text, Jason.encode!(%{cols: cols, rows: rows})} + defp reset_message({{cols, rows}, vt_init, stream_time}) do + {:text, Jason.encode!(%{cols: cols, rows: rows, init: vt_init, time: stream_time})} end defp feed_message({time, data}) do diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 38f51f540..768d1d402 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -43,11 +43,11 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_in({text, _opts}, state) do result = case Jason.decode(text) do - {:ok, %{"cols" => cols, "rows" => rows}} + {:ok, %{"cols" => cols, "rows" => rows} = header} when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows -> Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - LiveStream.reset(state.stream_id, {cols, rows}) + LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) {:ok, %{"width" => cols, "height" => rows}} when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and From 663ae29ca9e2c79126a8e40cd13c8ef8f8abaebe Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 10 Mar 2023 17:05:46 +0100 Subject: [PATCH 018/121] Implement timeout for initial reset in live stream producer --- .../live_stream_producer_socket.ex | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 768d1d402..50a26a4c8 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -7,6 +7,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @ping_interval 15_000 @heartbeat_interval 15_000 + @reset_timeout 5_000 # Callbacks @@ -18,7 +19,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"]}} + {:ok, %{stream_id: state.params["id"], reset_timeout_timer: nil}} end @impl true @@ -26,10 +27,11 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) :ok = LiveStream.lead(state.stream_id) - send(self(), :heartbeat) + reset_timeout_timer = Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) + send(self(), :heartbeat) - {:ok, state} + {:ok, %{state | reset_timeout_timer: reset_timeout_timer}} end @impl true @@ -47,12 +49,22 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows -> Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + + if state.reset_timeout_timer do + Process.cancel_timer(state.reset_timeout_timer) + end + LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) {:ok, %{"width" => cols, "height" => rows}} when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows -> Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + + if state.reset_timeout_timer do + Process.cancel_timer(state.reset_timeout_timer) + end + LiveStream.reset(state.stream_id, {cols, rows}) {:ok, header} when is_map(header) -> @@ -94,10 +106,18 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, state} {:error, :not_a_leader} -> + Logger.info("producer/#{state.stream_id}: stream taken by another producer") + {:stop, :normal, state} end end + def handle_info(:reset_timeout, state) do + Logger.info("producer/#{state.stream_id}: initial reset timeout") + + {:stop, :reset_timeout, state} + end + @impl true def terminate(reason, state) do Logger.info( From 8ff4802573ce63dead37178704b5d39748352ebd Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 10 Mar 2023 19:05:45 +0100 Subject: [PATCH 019/121] Refactor message handling in live stream producer socket --- .../live_stream_producer_socket.ex | 92 +++++++++++-------- 1 file changed, 53 insertions(+), 39 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 50a26a4c8..5a704fb51 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -19,7 +19,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], reset_timeout_timer: nil}} + {:ok, %{stream_id: state.params["id"], init: false}} end @impl true @@ -27,11 +27,11 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) :ok = LiveStream.lead(state.stream_id) - reset_timeout_timer = Process.send_after(self(), :reset_timeout, @reset_timeout) + Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) send(self(), :heartbeat) - {:ok, %{state | reset_timeout_timer: reset_timeout_timer}} + {:ok, state} end @impl true @@ -43,52 +43,64 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @max_rows 200 def handle_in({text, _opts}, state) do - result = - case Jason.decode(text) do - {:ok, %{"cols" => cols, "rows" => rows} = header} - when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and - rows <= @max_rows -> - Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + with {:ok, message} <- Jason.decode(text), + {:ok, state} <- handle_message(message, state) do + {:ok, state} + else + {:error, _} -> + {:stop, :normal, state} + end + end + + def handle_message(%{"cols" => cols, "rows" => rows} = header, state) + when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and + rows <= @max_rows do + Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - if state.reset_timeout_timer do - Process.cancel_timer(state.reset_timeout_timer) - end + with :ok <- LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) do + {:ok, %{state | init: true}} + end + end - LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) + def handle_message(%{"width" => cols, "height" => rows}, state) + when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and + rows <= @max_rows do + Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - {:ok, %{"width" => cols, "height" => rows}} - when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and - rows <= @max_rows -> - Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") + with :ok <- LiveStream.reset(state.stream_id, {cols, rows}) do + {:ok, %{state | init: true}} + end + end - if state.reset_timeout_timer do - Process.cancel_timer(state.reset_timeout_timer) - end + def handle_message(header, state) when is_map(header) do + Logger.info("producer/#{state.stream_id}: invalid header: #{inspect(header)}") - LiveStream.reset(state.stream_id, {cols, rows}) + {:error, :invalid_message} + end - {:ok, header} when is_map(header) -> - Logger.info("producer/#{state.stream_id}: invalid header: #{inspect(header)}") - :error + def handle_message([time, "o", data], %{init: true} = state) + when is_number(time) and is_binary(data) do + with :ok <- LiveStream.feed(state.stream_id, {time, data}) do + {:ok, state} + end + end - {:ok, [time, "o", data]} when is_number(time) and is_binary(data) -> - LiveStream.feed(state.stream_id, {time, data}) + def handle_message([time, type, data], %{init: true} = state) + when is_number(time) and is_binary(type) and is_binary(data) do + {:ok, state} + end - {:ok, [time, _, data]} when is_number(time) and is_binary(data) -> - :ok + def handle_message([time, type, data], %{init: false} = state) + when is_number(time) and is_binary(type) and is_binary(data) do + Logger.info("producer/#{state.stream_id}: expected header, got event") - result -> - Logger.info("producer/#{state.stream_id}: invalid message: #{inspect(result)}") - :error - end + {:error, :unexpected_message} + end - case result do - :ok -> - {:ok, state} + def handle_message(message, state) do + Logger.info("producer/#{state.stream_id}: invalid message: #{inspect(message)}") - :error -> - {:stop, :normal, state} - end + {:error, :invalid_message} end @impl true @@ -112,12 +124,14 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end end - def handle_info(:reset_timeout, state) do + def handle_info(:reset_timeout, %{init: false} = state) do Logger.info("producer/#{state.stream_id}: initial reset timeout") {:stop, :reset_timeout, state} end + def handle_info(:reset_timeout, %{init: true} = state), do: {:ok, state} + @impl true def terminate(reason, state) do Logger.info( From 92b69a26dbc53b460ce4b3be48f30e6d28b6fd68 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 10 Mar 2023 20:07:07 +0100 Subject: [PATCH 020/121] Send offline message to consumers when live stream producer is not there --- .../live_stream_consumer_socket.ex | 22 +++++++++++++------ .../live_stream_producer_socket.ex | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 601e8ad80..1bd593c2e 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -4,6 +4,9 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @behaviour Phoenix.Socket.Transport + @reset_timeout 1_000 + @ping_interval 15_000 + # Callbacks @impl true @@ -21,7 +24,8 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do def init(state) do Logger.info("consumer/#{state.stream_id}: connected") LiveStream.join(state.stream_id) - schedule_ping() + Process.send_after(self(), :reset_timeout, @reset_timeout) + Process.send_after(self(), :ping, @ping_interval) {:ok, state} end @@ -43,7 +47,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end def handle_info({:live_stream, :offline}, state) do - {:push, {:text, Jason.encode!(%{state: "offline"})}, state} + {:push, offline_message(), state} end def handle_info({:live_stream, _}, %{init: false} = state) do @@ -51,11 +55,17 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end def handle_info(:ping, state) do - schedule_ping() + Process.send_after(self(), :ping, @ping_interval) {:push, {:ping, ""}, state} end + def handle_info(:reset_timeout, %{init: false} = state) do + {:push, offline_message(), state} + end + + def handle_info(:reset_timeout, %{init: true} = state), do: {:ok, state} + @impl true def terminate(reason, state) do Logger.info( @@ -75,9 +85,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do {:text, Jason.encode!([time, "o", data])} end - @ping_interval 15_000 - - defp schedule_ping do - Process.send_after(self(), :ping, @ping_interval) + defp offline_message do + {:text, Jason.encode!(%{state: "offline"})} end end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 5a704fb51..3876b1c33 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -5,9 +5,9 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @behaviour Phoenix.Socket.Transport + @reset_timeout 5_000 @ping_interval 15_000 @heartbeat_interval 15_000 - @reset_timeout 5_000 # Callbacks From 6824619091f8c0faaed5b6a54efa8ffa39654a5c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 10 Mar 2023 20:20:54 +0100 Subject: [PATCH 021/121] Improve logging --- lib/asciinema_web/live_stream_consumer_socket.ex | 4 ++-- lib/asciinema_web/live_stream_producer_socket.ex | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 1bd593c2e..30dfff1a4 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -36,8 +36,8 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true - def handle_info({:live_stream, {:reset, {{_, _}, _, _} = data}}, state) do - Logger.info("consumer/#{state.stream_id}: reset") + def handle_info({:live_stream, {:reset, {{cols, rows}, _, _} = data}}, state) do + Logger.info("consumer/#{state.stream_id}: reset (#{cols}x#{rows})") {:push, reset_message(data), %{state | init: true}} end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 3876b1c33..c724b1f6e 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -47,6 +47,11 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, state} <- handle_message(message, state) do {:ok, state} else + {:error, :not_a_leader} -> + Logger.info("producer/#{state.stream_id}: stream taken over by another producer") + + {:stop, :normal, state} + {:error, _} -> {:stop, :normal, state} end @@ -118,7 +123,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, state} {:error, :not_a_leader} -> - Logger.info("producer/#{state.stream_id}: stream taken by another producer") + Logger.info("producer/#{state.stream_id}: stream taken over by another producer") {:stop, :normal, state} end From ea71efb28761ccc3819b7a020adcc55b200b1fb1 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 11 Mar 2023 11:39:19 +0100 Subject: [PATCH 022/121] Rename reset key in websocket state --- lib/asciinema_web/live_stream_consumer_socket.ex | 12 ++++++------ lib/asciinema_web/live_stream_producer_socket.ex | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 30dfff1a4..b25745c02 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -17,7 +17,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], init: false}} + {:ok, %{stream_id: state.params["id"], reset: false}} end @impl true @@ -39,10 +39,10 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do def handle_info({:live_stream, {:reset, {{cols, rows}, _, _} = data}}, state) do Logger.info("consumer/#{state.stream_id}: reset (#{cols}x#{rows})") - {:push, reset_message(data), %{state | init: true}} + {:push, reset_message(data), %{state | reset: true}} end - def handle_info({:live_stream, {:feed, event}}, %{init: true} = state) do + def handle_info({:live_stream, {:feed, event}}, %{reset: true} = state) do {:push, feed_message(event), state} end @@ -50,7 +50,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do {:push, offline_message(), state} end - def handle_info({:live_stream, _}, %{init: false} = state) do + def handle_info({:live_stream, _}, %{reset: false} = state) do {:ok, state} end @@ -60,11 +60,11 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do {:push, {:ping, ""}, state} end - def handle_info(:reset_timeout, %{init: false} = state) do + def handle_info(:reset_timeout, %{reset: false} = state) do {:push, offline_message(), state} end - def handle_info(:reset_timeout, %{init: true} = state), do: {:ok, state} + def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} @impl true def terminate(reason, state) do diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index c724b1f6e..25ad04dce 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -19,7 +19,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], init: false}} + {:ok, %{stream_id: state.params["id"], reset: false}} end @impl true @@ -63,7 +63,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") with :ok <- LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) do - {:ok, %{state | init: true}} + {:ok, %{state | reset: true}} end end @@ -73,7 +73,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") with :ok <- LiveStream.reset(state.stream_id, {cols, rows}) do - {:ok, %{state | init: true}} + {:ok, %{state | reset: true}} end end @@ -83,19 +83,19 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:error, :invalid_message} end - def handle_message([time, "o", data], %{init: true} = state) + def handle_message([time, "o", data], %{reset: true} = state) when is_number(time) and is_binary(data) do with :ok <- LiveStream.feed(state.stream_id, {time, data}) do {:ok, state} end end - def handle_message([time, type, data], %{init: true} = state) + def handle_message([time, type, data], %{reset: true} = state) when is_number(time) and is_binary(type) and is_binary(data) do {:ok, state} end - def handle_message([time, type, data], %{init: false} = state) + def handle_message([time, type, data], %{reset: false} = state) when is_number(time) and is_binary(type) and is_binary(data) do Logger.info("producer/#{state.stream_id}: expected header, got event") @@ -129,13 +129,13 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end end - def handle_info(:reset_timeout, %{init: false} = state) do + def handle_info(:reset_timeout, %{reset: false} = state) do Logger.info("producer/#{state.stream_id}: initial reset timeout") {:stop, :reset_timeout, state} end - def handle_info(:reset_timeout, %{init: true} = state), do: {:ok, state} + def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} @impl true def terminate(reason, state) do From b129d7c1de7d0ece317ea1a9ee6b4feeb07e6f70 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 16 May 2023 21:50:45 +0200 Subject: [PATCH 023/121] Implement live stream rate limiting using token bucket algorithm --- .../live_stream_producer_socket.ex | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 25ad04dce..0fa1e0481 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -8,6 +8,9 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @reset_timeout 5_000 @ping_interval 15_000 @heartbeat_interval 15_000 + @bucket_fill_interval 100 + @bucket_fill_amount 10_000 + @bucket_size 60_000_000 # Callbacks @@ -19,7 +22,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], reset: false}} + {:ok, %{stream_id: state.params["id"], reset: false, bucket: @bucket_size}} end @impl true @@ -29,6 +32,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do :ok = LiveStream.lead(state.stream_id) Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) + Process.send_after(self(), :fill_bucket, @bucket_fill_interval) send(self(), :heartbeat) {:ok, state} @@ -44,7 +48,8 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_in({text, _opts}, state) do with {:ok, message} <- Jason.decode(text), - {:ok, state} <- handle_message(message, state) do + {:ok, state} <- handle_message(message, state), + {:ok, state} <- drain_bucket(state, byte_size(text)) do {:ok, state} else {:error, :not_a_leader} -> @@ -52,6 +57,12 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:stop, :normal, state} + {:error, :empty_bucket} -> + Logger.info("producer/#{state.stream_id}: byte budget exceeded") + + # TODO use reason other than :normal to make streamer reconnect + {:stop, :normal, state} + {:error, _} -> {:stop, :normal, state} end @@ -137,6 +148,18 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} + def handle_info(:fill_bucket, state) do + bucket = min(@bucket_size, state.bucket + @bucket_fill_amount) + + if bucket > state.bucket && bucket < @bucket_size do + Logger.debug("producer/#{state.stream_id}: fill to #{bucket}") + end + + Process.send_after(self(), :fill_bucket, @bucket_fill_interval) + + {:ok, %{state | bucket: bucket}} + end + @impl true def terminate(reason, state) do Logger.info( @@ -145,4 +168,14 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do :ok end + + defp drain_bucket(state, drain_amount) do + bucket = state.bucket - drain_amount + + if bucket < 0 do + {:error, :empty_bucket} + else + {:ok, %{state | bucket: bucket}} + end + end end From 40c829b30ae5bff8a211960fd53fa153aefc9b15 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 28 May 2023 11:42:36 +0200 Subject: [PATCH 024/121] Support only text messages in live stream producer for now --- lib/asciinema_web/live_stream_producer_socket.ex | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 0fa1e0481..585ea1578 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -46,7 +46,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @max_cols 720 @max_rows 200 - def handle_in({text, _opts}, state) do + def handle_in({text, [opcode: :text]}, state) do with {:ok, message} <- Jason.decode(text), {:ok, state} <- handle_message(message, state), {:ok, state} <- drain_bucket(state, byte_size(text)) do @@ -68,6 +68,12 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end end + def handle_in(_, state) do + Logger.info("producer/#{state.stream_id}: binary message received, disconnecting") + + {:stop, :normal, state} + end + def handle_message(%{"cols" => cols, "rows" => rows} = header, state) when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows do From d6a8048e83d3aa6acb32529cdb5ba528897d2342 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 31 May 2023 10:23:13 +0200 Subject: [PATCH 025/121] Fix import --- lib/asciinema_web/views/live_stream_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/views/live_stream_view.ex index e85f36299..7ab9a26b6 100644 --- a/lib/asciinema_web/views/live_stream_view.ex +++ b/lib/asciinema_web/views/live_stream_view.ex @@ -1,6 +1,6 @@ defmodule AsciinemaWeb.LiveStreamView do use AsciinemaWeb, :view - import AsciinemaWeb.AsciicastView, only: [cinema_height: 1] + import AsciinemaWeb.RecordingView, only: [cinema_height: 1] alias AsciinemaWeb.UserView def player(src, opts \\ []) From c7435f3e5170ab3560c8859af8ac875d90627615 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 2 Jun 2023 17:26:04 +0200 Subject: [PATCH 026/121] Initial support for ALiS streaming protocol --- .../live_stream_consumer_socket.ex | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index b25745c02..744698bd9 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -26,6 +26,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do LiveStream.join(state.stream_id) Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) + send(self(), :push_alis_header) {:ok, state} end @@ -36,6 +37,12 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do end @impl true + def handle_info(:push_alis_header, state) do + Logger.debug("consumer/#{state.stream_id}: sending alis header") + + {:push, header_message(), state} + end + def handle_info({:live_stream, {:reset, {{cols, rows}, _, _} = data}}, state) do Logger.info("consumer/#{state.stream_id}: reset (#{cols}x#{rows})") @@ -77,15 +84,57 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do # Private - defp reset_message({{cols, rows}, vt_init, stream_time}) do - {:text, Jason.encode!(%{cols: cols, rows: rows, init: vt_init, time: stream_time})} + @alis_version 1 + @compression_algo_none 0 + + @msg_type_reset 0x01 + @msg_type_output ?o + @msg_type_offline 0x04 + + defp header_message do + msg = << + "ALiS"::binary, + @alis_version::unsigned-8, + @compression_algo_none::unsigned-8, + 0x00, + 0x00, + 0x00, + 0x00 + >> + + {:binary, msg} + end + + defp reset_message({{cols, rows}, init, time}) do + init = init || "" + init_len = byte_size(init) + + msg = << + @msg_type_reset, + cols::little-16, + rows::little-16, + time::little-float-32, + init_len::little-32, + init::binary + >> + + {:binary, msg} end defp feed_message({time, data}) do - {:text, Jason.encode!([time, "o", data])} + data_len = byte_size(data) + + msg = << + @msg_type_output, + time::little-float-32, + data_len::little-32, + data::binary + >> + + {:binary, msg} end defp offline_message do - {:text, Jason.encode!(%{state: "offline"})} + {:binary, <<@msg_type_offline>>} end end From a8feb13b2823e95a64de37a2d5ff43656958b29c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 11 Jun 2023 15:37:10 +0200 Subject: [PATCH 027/121] Rename error atom --- lib/asciinema_web/live_stream_producer_socket.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 585ea1578..57fc911e4 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -57,7 +57,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:stop, :normal, state} - {:error, :empty_bucket} -> + {:error, :bucket_empty} -> Logger.info("producer/#{state.stream_id}: byte budget exceeded") # TODO use reason other than :normal to make streamer reconnect @@ -179,7 +179,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do bucket = state.bucket - drain_amount if bucket < 0 do - {:error, :empty_bucket} + {:error, :bucket_empty} else {:ok, %{state | bucket: bucket}} end From 133a1aa0cbc4b1e470969fe68b432a0347d7792a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 11 Jun 2023 15:53:36 +0200 Subject: [PATCH 028/121] Make stream producer's token bucket configurable via env --- .../live_stream_producer_socket.ex | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 57fc911e4..d192816a1 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -8,9 +8,9 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @reset_timeout 5_000 @ping_interval 15_000 @heartbeat_interval 15_000 - @bucket_fill_interval 100 - @bucket_fill_amount 10_000 - @bucket_size 60_000_000 + @default_bucket_fill_interval 100 + @default_bucket_fill_amount 10_000 + @default_bucket_size 60_000_000 # Callbacks @@ -22,7 +22,16 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], reset: false, bucket: @bucket_size}} + state = %{ + stream_id: state.params["id"], + reset: false, + bucket_size: config(:bucket_size, @default_bucket_size), + bucket_tokens: config(:bucket_size, @default_bucket_size), + bucket_fill_interval: config(:bucket_fill_interval, @default_bucket_fill_interval), + bucket_fill_amount: config(:bucket_fill_amount, @default_bucket_fill_amount) + } + + {:ok, state} end @impl true @@ -32,7 +41,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do :ok = LiveStream.lead(state.stream_id) Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) - Process.send_after(self(), :fill_bucket, @bucket_fill_interval) + Process.send_after(self(), :fill_bucket, state.bucket_fill_interval) send(self(), :heartbeat) {:ok, state} @@ -155,15 +164,15 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} def handle_info(:fill_bucket, state) do - bucket = min(@bucket_size, state.bucket + @bucket_fill_amount) + tokens = min(state.bucket_size, state.bucket_tokens + state.bucket_fill_amount) - if bucket > state.bucket && bucket < @bucket_size do - Logger.debug("producer/#{state.stream_id}: fill to #{bucket}") + if tokens > state.bucket_tokens && tokens < state.bucket_size do + Logger.debug("producer/#{state.stream_id}: fill to #{tokens}") end - Process.send_after(self(), :fill_bucket, @bucket_fill_interval) + Process.send_after(self(), :fill_bucket, state.bucket_fill_interval) - {:ok, %{state | bucket: bucket}} + {:ok, %{state | bucket_tokens: tokens}} end @impl true @@ -176,12 +185,16 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end defp drain_bucket(state, drain_amount) do - bucket = state.bucket - drain_amount + tokens = state.bucket_tokens - drain_amount - if bucket < 0 do + if tokens < 0 do {:error, :bucket_empty} else - {:ok, %{state | bucket: bucket}} + {:ok, %{state | bucket_tokens: tokens}} end end + + defp config(key, default) do + Application.get_env(:asciinema, :"live_stream_producer_#{key}", default) + end end From 215d85b897563e80d4085604422b908147806b30 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 11 Jun 2023 15:59:37 +0200 Subject: [PATCH 029/121] Keep bucket token related values all in one nested map under socket process' state --- .../live_stream_producer_socket.ex | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index d192816a1..0e5741a03 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -25,10 +25,12 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do state = %{ stream_id: state.params["id"], reset: false, - bucket_size: config(:bucket_size, @default_bucket_size), - bucket_tokens: config(:bucket_size, @default_bucket_size), - bucket_fill_interval: config(:bucket_fill_interval, @default_bucket_fill_interval), - bucket_fill_amount: config(:bucket_fill_amount, @default_bucket_fill_amount) + bucket: %{ + size: config(:bucket_size, @default_bucket_size), + tokens: config(:bucket_size, @default_bucket_size), + fill_interval: config(:bucket_fill_interval, @default_bucket_fill_interval), + fill_amount: config(:bucket_fill_amount, @default_bucket_fill_amount) + } } {:ok, state} @@ -41,7 +43,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do :ok = LiveStream.lead(state.stream_id) Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) - Process.send_after(self(), :fill_bucket, state.bucket_fill_interval) + Process.send_after(self(), :fill_bucket, state.bucket.fill_interval) send(self(), :heartbeat) {:ok, state} @@ -163,16 +165,16 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} - def handle_info(:fill_bucket, state) do - tokens = min(state.bucket_size, state.bucket_tokens + state.bucket_fill_amount) + def handle_info(:fill_bucket, %{bucket: bucket} = state) do + tokens = min(bucket.size, bucket.tokens + bucket.fill_amount) - if tokens > state.bucket_tokens && tokens < state.bucket_size do + if tokens > bucket.tokens && tokens < bucket.size do Logger.debug("producer/#{state.stream_id}: fill to #{tokens}") end - Process.send_after(self(), :fill_bucket, state.bucket_fill_interval) + Process.send_after(self(), :fill_bucket, bucket.fill_interval) - {:ok, %{state | bucket_tokens: tokens}} + {:ok, put_in(state, [:bucket, :tokens], tokens)} end @impl true @@ -185,12 +187,12 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end defp drain_bucket(state, drain_amount) do - tokens = state.bucket_tokens - drain_amount + tokens = state.bucket.tokens - drain_amount if tokens < 0 do {:error, :bucket_empty} else - {:ok, %{state | bucket_tokens: tokens}} + {:ok, put_in(state, [:bucket, :tokens], tokens)} end end From 1950331013246cca74b0a18dd95533f6d5084f71 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 12 Jun 2023 21:10:46 +0200 Subject: [PATCH 030/121] Support raw binary streams in live stream producer socket --- lib/asciinema/streaming/producer_handler.ex | 10 ++ .../streaming/producer_handler/json.ex | 45 ++++++++ .../streaming/producer_handler/raw.ex | 36 ++++++ .../live_stream_producer_socket.ex | 109 ++++++++---------- 4 files changed, 138 insertions(+), 62 deletions(-) create mode 100644 lib/asciinema/streaming/producer_handler.ex create mode 100644 lib/asciinema/streaming/producer_handler/json.ex create mode 100644 lib/asciinema/streaming/producer_handler/raw.ex diff --git a/lib/asciinema/streaming/producer_handler.ex b/lib/asciinema/streaming/producer_handler.ex new file mode 100644 index 000000000..e52ed8170 --- /dev/null +++ b/lib/asciinema/streaming/producer_handler.ex @@ -0,0 +1,10 @@ +defmodule Asciinema.Streaming.ProducerHandler do + @callback init() :: term + @callback parse({message :: term, opts :: keyword}, term) :: + {:ok, [{atom, term}], term} | {:error, term} + + alias Asciinema.Streaming.ProducerHandler + + def get(:raw), do: %{impl: ProducerHandler.Raw, state: ProducerHandler.Raw.init()} + def get(:json), do: %{impl: ProducerHandler.Json, state: ProducerHandler.Json.init()} +end diff --git a/lib/asciinema/streaming/producer_handler/json.ex b/lib/asciinema/streaming/producer_handler/json.ex new file mode 100644 index 000000000..763b0f427 --- /dev/null +++ b/lib/asciinema/streaming/producer_handler/json.ex @@ -0,0 +1,45 @@ +defmodule Asciinema.Streaming.ProducerHandler.Json do + @behaviour Asciinema.Streaming.ProducerHandler + + def init, do: %{reset: false} + + def parse({"\n", _opts}, %{reset: true} = state), do: {:ok, [], state} + + def parse({payload, _opts}, state) do + case Jason.decode(payload) do + {:ok, message} -> + handle_message(message, state) + + {:error, %Jason.DecodeError{} = reason} -> + {:error, "JSON decode error: #{Jason.DecodeError.message(reason)}"} + end + end + + def handle_message(%{"cols" => cols, "rows" => rows} = header, state) + when is_integer(cols) and is_integer(rows) do + {:ok, [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}], + %{state | reset: true}} + end + + def handle_message(%{"width" => cols, "height" => rows}, state) + when is_integer(cols) and is_integer(rows) do + {:ok, [reset: %{size: {cols, rows}, init: nil, time: nil}], %{state | reset: true}} + end + + def handle_message(_message, %{reset: false}) do + {:error, :header_expected} + end + + def handle_message([time, "o", data], state) when is_number(time) and is_binary(data) do + {:ok, [feed: {time, data}], state} + end + + def handle_message([time, type, data], state) + when is_number(time) and is_binary(type) and is_binary(data) do + {:ok, [], state} + end + + def handle_message(_message, _state) do + {:error, :message_invalid} + end +end diff --git a/lib/asciinema/streaming/producer_handler/raw.ex b/lib/asciinema/streaming/producer_handler/raw.ex new file mode 100644 index 000000000..0b7cdc643 --- /dev/null +++ b/lib/asciinema/streaming/producer_handler/raw.ex @@ -0,0 +1,36 @@ +defmodule Asciinema.Streaming.ProducerHandler.Raw do + @behaviour Asciinema.Streaming.ProducerHandler + + def init, do: %{reset: false, start_time: nil} + + def parse({payload, _}, %{reset: false} = state) do + size = size_from_resize_seq(payload) || size_from_script_start_message(payload) || {80, 24} + + commands = [ + reset: %{size: size, init: nil, time: 0.0}, + feed: {0.0, payload} + ] + + state = %{state | reset: true, start_time: Timex.now()} + + {:ok, commands, state} + end + + def parse({payload, _}, state) do + time = Timex.diff(Timex.now(), state.start_time, :microsecond) / 1_000_000 + + {:ok, [feed: {time, payload}], state} + end + + defp size_from_resize_seq(text) do + with [_, rows, cols] <- Regex.run(~r/\x1b\[8;(\d+);(\d+)t/, text) do + {String.to_integer(cols), String.to_integer(rows)} + end + end + + defp size_from_script_start_message(text) do + with [_, cols, rows] <- Regex.run(~r/\[.*COLUMNS="(\d{1,3})" LINES="(\d{1,3})".*\]/, text) do + {String.to_integer(cols), String.to_integer(rows)} + end + end +end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 0e5741a03..9b0d8f605 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -1,11 +1,12 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do alias Asciinema.LiveStream alias Asciinema.LiveStreamSupervisor + alias Asciinema.Streaming.ProducerHandler require Logger @behaviour Phoenix.Socket.Transport - @reset_timeout 5_000 + @handler_timeout 5_000 @ping_interval 15_000 @heartbeat_interval 15_000 @default_bucket_fill_interval 100 @@ -24,7 +25,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def connect(state) do state = %{ stream_id: state.params["id"], - reset: false, + handler: nil, bucket: %{ size: config(:bucket_size, @default_bucket_size), tokens: config(:bucket_size, @default_bucket_size), @@ -41,7 +42,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) :ok = LiveStream.lead(state.stream_id) - Process.send_after(self(), :reset_timeout, @reset_timeout) + Process.send_after(self(), :handler_timeout, @handler_timeout) Process.send_after(self(), :ping, @ping_interval) Process.send_after(self(), :fill_bucket, state.bucket.fill_interval) send(self(), :heartbeat) @@ -50,90 +51,74 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end @impl true - def handle_in({"\n", _opts}, state) do - {:ok, state} + def handle_in({_, [opcode: :binary]} = message, %{handler: nil} = state) do + handle_in(message, %{state | handler: ProducerHandler.get(:raw)}) end - @max_cols 720 - @max_rows 200 + def handle_in({_, [opcode: :text]} = message, %{handler: nil} = state) do + handle_in(message, %{state | handler: ProducerHandler.get(:json)}) + end - def handle_in({text, [opcode: :text]}, state) do - with {:ok, message} <- Jason.decode(text), - {:ok, state} <- handle_message(message, state), - {:ok, state} <- drain_bucket(state, byte_size(text)) do - {:ok, state} + def handle_in({payload, _} = message, %{handler: handler} = state) do + with {:ok, commands, new_handler_state} <- run_handler(handler, message), + :ok <- run_commands(commands, state.stream_id), + {:ok, state} <- drain_bucket(state, byte_size(payload)) do + {:ok, put_in(state, [:handler, :state], new_handler_state)} else {:error, :not_a_leader} -> Logger.info("producer/#{state.stream_id}: stream taken over by another producer") {:stop, :normal, state} + {:error, {:invalid_vt_size, {cols, rows}}} -> + Logger.info("producer/#{state.stream_id}: invalid vt size: #{cols}x#{rows}") + + {:stop, :normal, state} + {:error, :bucket_empty} -> Logger.info("producer/#{state.stream_id}: byte budget exceeded") - # TODO use reason other than :normal to make streamer reconnect + # TODO use reason other than :normal to make producer reconnect {:stop, :normal, state} - {:error, _} -> + {:error, {:handler, reason}} -> + Logger.debug("producer/#{state.stream_id}: message: #{inspect(payload)}") + Logger.warn("producer/#{state.stream_id}: handler error: #{reason}") + {:stop, :normal, state} end end - def handle_in(_, state) do - Logger.info("producer/#{state.stream_id}: binary message received, disconnecting") - - {:stop, :normal, state} - end - - def handle_message(%{"cols" => cols, "rows" => rows} = header, state) - when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and - rows <= @max_rows do - Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - - with :ok <- LiveStream.reset(state.stream_id, {cols, rows}, header["init"], header["time"]) do - {:ok, %{state | reset: true}} + defp run_handler(%{impl: impl, state: state}, message) do + with {:error, reason} <- impl.parse(message, state) do + {:error, {:handler, reason}} end end - def handle_message(%{"width" => cols, "height" => rows}, state) - when is_integer(cols) and is_integer(rows) and cols > 0 and rows > 0 and cols <= @max_cols and - rows <= @max_rows do - Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - - with :ok <- LiveStream.reset(state.stream_id, {cols, rows}) do - {:ok, %{state | reset: true}} - end + defp run_commands(commands, stream_id) do + Enum.reduce(commands, :ok, fn command, prev_result -> + with :ok <- prev_result do + run_command(command, stream_id) + end + end) end - def handle_message(header, state) when is_map(header) do - Logger.info("producer/#{state.stream_id}: invalid header: #{inspect(header)}") + @max_cols 720 + @max_rows 200 - {:error, :invalid_message} - end + defp run_command({:reset, %{size: {cols, rows}, init: init, time: time}}, stream_id) + when cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows do + Logger.info("producer/#{stream_id}: reset (#{cols}x#{rows})") - def handle_message([time, "o", data], %{reset: true} = state) - when is_number(time) and is_binary(data) do - with :ok <- LiveStream.feed(state.stream_id, {time, data}) do - {:ok, state} - end + LiveStream.reset(stream_id, {cols, rows}, init, time) end - def handle_message([time, type, data], %{reset: true} = state) - when is_number(time) and is_binary(type) and is_binary(data) do - {:ok, state} + defp run_command({:reset, %{size: {cols, rows}}}, _stream_id) do + {:error, {:invalid_vt_size, {cols, rows}}} end - def handle_message([time, type, data], %{reset: false} = state) - when is_number(time) and is_binary(type) and is_binary(data) do - Logger.info("producer/#{state.stream_id}: expected header, got event") - - {:error, :unexpected_message} - end - - def handle_message(message, state) do - Logger.info("producer/#{state.stream_id}: invalid message: #{inspect(message)}") - - {:error, :invalid_message} + defp run_command({:feed, {time, data}}, stream_id) do + LiveStream.feed(stream_id, {time, data}) end @impl true @@ -157,13 +142,13 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end end - def handle_info(:reset_timeout, %{reset: false} = state) do - Logger.info("producer/#{state.stream_id}: initial reset timeout") + def handle_info(:handler_timeout, %{handler: nil} = state) do + Logger.info("producer/#{state.stream_id}: handler init timeout") - {:stop, :reset_timeout, state} + {:stop, :handler_timeout, state} end - def handle_info(:reset_timeout, %{reset: true} = state), do: {:ok, state} + def handle_info(:handler_timeout, state), do: {:ok, state} def handle_info(:fill_bucket, %{bucket: bucket} = state) do tokens = min(bucket.size, bucket.tokens + bucket.fill_amount) From 52274c48fad015128f994b3eab9a21c5c09aa521 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 12 Jun 2023 21:18:12 +0200 Subject: [PATCH 031/121] Move streaming related modules under Asciinema.Streaming namespace --- lib/asciinema/application.ex | 10 +++++++--- lib/asciinema/{ => streaming}/live_stream.ex | 9 +++++---- .../{ => streaming}/live_stream_supervisor.ex | 4 ++-- lib/asciinema_web/live_stream_consumer_socket.ex | 2 +- lib/asciinema_web/live_stream_producer_socket.ex | 4 +--- 5 files changed, 16 insertions(+), 13 deletions(-) rename lib/asciinema/{ => streaming}/live_stream.ex (93%) rename lib/asciinema/{ => streaming}/live_stream_supervisor.ex (87%) diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index 7961c26d8..c1a7bff80 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -26,10 +26,14 @@ defmodule Asciinema.Application do :poolboy.child_spec(:worker, Asciinema.PngGenerator.Rsvg.poolboy_config(), []), # Start Oban {Oban, oban_config()}, - {Registry, [keys: :unique, name: Asciinema.LiveStreamRegistry]}, + {Registry, [keys: :unique, name: Asciinema.Streaming.LiveStreamRegistry]}, {Registry, - [keys: :duplicate, name: Asciinema.PubSubRegistry, partitions: System.schedulers_online()]}, - Asciinema.LiveStreamSupervisor + [ + keys: :duplicate, + name: Asciinema.Streaming.PubSubRegistry, + partitions: System.schedulers_online() + ]}, + Asciinema.Streaming.LiveStreamSupervisor ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/asciinema/live_stream.ex b/lib/asciinema/streaming/live_stream.ex similarity index 93% rename from lib/asciinema/live_stream.ex rename to lib/asciinema/streaming/live_stream.ex index 2edb60b40..f59f25de1 100644 --- a/lib/asciinema/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -1,4 +1,4 @@ -defmodule Asciinema.LiveStream do +defmodule Asciinema.Streaming.LiveStream do use GenServer, restart: :transient alias Asciinema.Vt require Logger @@ -149,14 +149,15 @@ defmodule Asciinema.LiveStream do # Private - defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.LiveStreamRegistry, stream_id}} + defp via_tuple(stream_id), + do: {:via, Registry, {Asciinema.Streaming.LiveStreamRegistry, stream_id}} defp subscribe(topic) do - {:ok, _} = Registry.register(Asciinema.PubSubRegistry, topic, []) + {:ok, _} = Registry.register(Asciinema.Streaming.PubSubRegistry, topic, []) end defp publish(topic, payload) do - Registry.dispatch(Asciinema.PubSubRegistry, topic, fn entries -> + Registry.dispatch(Asciinema.Streaming.PubSubRegistry, topic, fn entries -> for {pid, _} <- entries, do: send(pid, payload) end) end diff --git a/lib/asciinema/live_stream_supervisor.ex b/lib/asciinema/streaming/live_stream_supervisor.ex similarity index 87% rename from lib/asciinema/live_stream_supervisor.ex rename to lib/asciinema/streaming/live_stream_supervisor.ex index 27e571c1e..3711bd2e6 100644 --- a/lib/asciinema/live_stream_supervisor.ex +++ b/lib/asciinema/streaming/live_stream_supervisor.ex @@ -1,6 +1,6 @@ -defmodule Asciinema.LiveStreamSupervisor do +defmodule Asciinema.Streaming.LiveStreamSupervisor do use DynamicSupervisor - alias Asciinema.LiveStream + alias Asciinema.Streaming.LiveStream require Logger def start_link(init_arg) do diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 744698bd9..6a4fd4f56 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -1,5 +1,5 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do - alias Asciinema.LiveStream + alias Asciinema.Streaming.LiveStream require Logger @behaviour Phoenix.Socket.Transport diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 9b0d8f605..4b388ce2d 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -1,7 +1,5 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do - alias Asciinema.LiveStream - alias Asciinema.LiveStreamSupervisor - alias Asciinema.Streaming.ProducerHandler + alias Asciinema.Streaming.{LiveStream, LiveStreamSupervisor, ProducerHandler} require Logger @behaviour Phoenix.Socket.Transport From 4d4a0f41742dbba4171694fab3697b39c205bd62 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 18 Jun 2023 16:53:03 +0200 Subject: [PATCH 032/121] Don't require live stream reset message upfront, default to 80x24 VT --- lib/asciinema/streaming/live_stream.ex | 37 ++++++++++++------- .../streaming/producer_handler/json.ex | 13 ++----- .../streaming/producer_handler/raw.ex | 20 ++++++---- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex index f59f25de1..39afba17f 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -34,6 +34,9 @@ defmodule Asciinema.Streaming.LiveStream do # Callbacks + @default_cols 80 + @default_rows 24 + @impl true def init(stream_id) do Logger.info("stream/#{stream_id}: init") @@ -48,7 +51,10 @@ defmodule Asciinema.Streaming.LiveStream do shutdown_timer: nil } - state = reschedule_shutdown(state) + state = + state + |> reset_stream({@default_cols, @default_rows}) + |> reschedule_shutdown() {:ok, state} end @@ -59,31 +65,22 @@ defmodule Asciinema.Streaming.LiveStream do end def handle_call( - {:reset, {cols, rows} = vt_size, vt_init, stream_time}, + {:reset, vt_size, vt_init, stream_time}, {pid, _} = _from, %{producer: pid} = state ) do - {:ok, vt} = Vt.new(cols, rows) + stream_time = stream_time || 0.0 + state = reset_stream(state, vt_size, stream_time) if vt_init do - :ok = Vt.feed(vt, vt_init) + :ok = Vt.feed(state.vt, vt_init) end - stream_time = stream_time || 0.0 - publish( {:live_stream, state.stream_id}, {:live_stream, {:reset, {vt_size, vt_init, stream_time}}} ) - state = %{ - state - | vt: vt, - vt_size: vt_size, - last_stream_time: stream_time, - last_feed_time: Timex.now() - } - {:reply, :ok, state} end @@ -156,6 +153,18 @@ defmodule Asciinema.Streaming.LiveStream do {:ok, _} = Registry.register(Asciinema.Streaming.PubSubRegistry, topic, []) end + defp reset_stream(state, {cols, rows} = vt_size, stream_time \\ 0.0) do + {:ok, vt} = Vt.new(cols, rows) + + %{ + state + | vt: vt, + vt_size: vt_size, + last_stream_time: stream_time, + last_feed_time: Timex.now() + } + end + defp publish(topic, payload) do Registry.dispatch(Asciinema.Streaming.PubSubRegistry, topic, fn entries -> for {pid, _} <- entries, do: send(pid, payload) diff --git a/lib/asciinema/streaming/producer_handler/json.ex b/lib/asciinema/streaming/producer_handler/json.ex index 763b0f427..46c5b89ea 100644 --- a/lib/asciinema/streaming/producer_handler/json.ex +++ b/lib/asciinema/streaming/producer_handler/json.ex @@ -1,9 +1,9 @@ defmodule Asciinema.Streaming.ProducerHandler.Json do @behaviour Asciinema.Streaming.ProducerHandler - def init, do: %{reset: false} + def init, do: %{} - def parse({"\n", _opts}, %{reset: true} = state), do: {:ok, [], state} + def parse({"\n", _opts}, state), do: {:ok, [], state} def parse({payload, _opts}, state) do case Jason.decode(payload) do @@ -17,17 +17,12 @@ defmodule Asciinema.Streaming.ProducerHandler.Json do def handle_message(%{"cols" => cols, "rows" => rows} = header, state) when is_integer(cols) and is_integer(rows) do - {:ok, [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}], - %{state | reset: true}} + {:ok, [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}], state} end def handle_message(%{"width" => cols, "height" => rows}, state) when is_integer(cols) and is_integer(rows) do - {:ok, [reset: %{size: {cols, rows}, init: nil, time: nil}], %{state | reset: true}} - end - - def handle_message(_message, %{reset: false}) do - {:error, :header_expected} + {:ok, [reset: %{size: {cols, rows}, init: nil, time: nil}], state} end def handle_message([time, "o", data], state) when is_number(time) and is_binary(data) do diff --git a/lib/asciinema/streaming/producer_handler/raw.ex b/lib/asciinema/streaming/producer_handler/raw.ex index 0b7cdc643..8b090167c 100644 --- a/lib/asciinema/streaming/producer_handler/raw.ex +++ b/lib/asciinema/streaming/producer_handler/raw.ex @@ -1,17 +1,21 @@ defmodule Asciinema.Streaming.ProducerHandler.Raw do @behaviour Asciinema.Streaming.ProducerHandler - def init, do: %{reset: false, start_time: nil} + def init, do: %{first: true, start_time: nil} - def parse({payload, _}, %{reset: false} = state) do - size = size_from_resize_seq(payload) || size_from_script_start_message(payload) || {80, 24} + def parse({payload, _}, %{first: true} = state) do + size = size_from_resize_seq(payload) || size_from_script_start_message(payload) - commands = [ - reset: %{size: size, init: nil, time: 0.0}, - feed: {0.0, payload} - ] + commands = + case size do + nil -> + [feed: {0.0, payload}] - state = %{state | reset: true, start_time: Timex.now()} + size -> + [reset: %{size: size, init: payload, time: 0.0}] + end + + state = %{state | first: false, start_time: Timex.now()} {:ok, commands, state} end From 98c930f527573985e54686865421c199c99af76f Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 18 Jun 2023 17:58:06 +0200 Subject: [PATCH 033/121] Rename Streaming.LiveStream -> Streaming.LiveStreamServer --- .../{live_stream.ex => live_stream_server.ex} | 2 +- lib/asciinema/streaming/live_stream_supervisor.ex | 4 ++-- lib/asciinema_web/live_stream_consumer_socket.ex | 4 ++-- lib/asciinema_web/live_stream_producer_socket.ex | 11 ++++++----- 4 files changed, 11 insertions(+), 10 deletions(-) rename lib/asciinema/streaming/{live_stream.ex => live_stream_server.ex} (98%) diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream_server.ex similarity index 98% rename from lib/asciinema/streaming/live_stream.ex rename to lib/asciinema/streaming/live_stream_server.ex index 39afba17f..794a0ed15 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -1,4 +1,4 @@ -defmodule Asciinema.Streaming.LiveStream do +defmodule Asciinema.Streaming.LiveStreamServer do use GenServer, restart: :transient alias Asciinema.Vt require Logger diff --git a/lib/asciinema/streaming/live_stream_supervisor.ex b/lib/asciinema/streaming/live_stream_supervisor.ex index 3711bd2e6..3de90e772 100644 --- a/lib/asciinema/streaming/live_stream_supervisor.ex +++ b/lib/asciinema/streaming/live_stream_supervisor.ex @@ -1,6 +1,6 @@ defmodule Asciinema.Streaming.LiveStreamSupervisor do use DynamicSupervisor - alias Asciinema.Streaming.LiveStream + alias Asciinema.Streaming.LiveStreamServer require Logger def start_link(init_arg) do @@ -14,7 +14,7 @@ defmodule Asciinema.Streaming.LiveStreamSupervisor do def start_child(id) do Logger.debug("stream sup: starting server for live stream #{id}") - DynamicSupervisor.start_child(__MODULE__, {LiveStream, id}) + DynamicSupervisor.start_child(__MODULE__, {LiveStreamServer, id}) end def ensure_child(id) do diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 6a4fd4f56..556b5f6cf 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -1,5 +1,5 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do - alias Asciinema.Streaming.LiveStream + alias Asciinema.Streaming.LiveStreamServer require Logger @behaviour Phoenix.Socket.Transport @@ -23,7 +23,7 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def init(state) do Logger.info("consumer/#{state.stream_id}: connected") - LiveStream.join(state.stream_id) + LiveStreamServer.join(state.stream_id) Process.send_after(self(), :reset_timeout, @reset_timeout) Process.send_after(self(), :ping, @ping_interval) send(self(), :push_alis_header) diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 4b388ce2d..8ccc6591f 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -1,5 +1,6 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do - alias Asciinema.Streaming.{LiveStream, LiveStreamSupervisor, ProducerHandler} + alias Asciinema.Streaming + alias Asciinema.Streaming.{LiveStreamServer, LiveStreamSupervisor, ProducerHandler} require Logger @behaviour Phoenix.Socket.Transport @@ -39,7 +40,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def init(state) do Logger.info("producer/#{state.stream_id}: connected") {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) - :ok = LiveStream.lead(state.stream_id) + :ok = LiveStreamServer.lead(state.stream_id) Process.send_after(self(), :handler_timeout, @handler_timeout) Process.send_after(self(), :ping, @ping_interval) Process.send_after(self(), :fill_bucket, state.bucket.fill_interval) @@ -108,7 +109,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do when cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows do Logger.info("producer/#{stream_id}: reset (#{cols}x#{rows})") - LiveStream.reset(stream_id, {cols, rows}, init, time) + LiveStreamServer.reset(stream_id, {cols, rows}, init, time) end defp run_command({:reset, %{size: {cols, rows}}}, _stream_id) do @@ -116,7 +117,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end defp run_command({:feed, {time, data}}, stream_id) do - LiveStream.feed(stream_id, {time, data}) + LiveStreamServer.feed(stream_id, {time, data}) end @impl true @@ -129,7 +130,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_info(:heartbeat, state) do Process.send_after(self(), :heartbeat, @heartbeat_interval) - case LiveStream.heartbeat(state.stream_id) do + case LiveStreamServer.heartbeat(state.stream_id) do :ok -> {:ok, state} From c5fd96d6c13d8e5d5a00d8f7c475a9f084f0bcaf Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Thu, 22 Jun 2023 16:40:35 +0200 Subject: [PATCH 034/121] Token-based authorization for live stream sockets --- lib/asciinema/streaming.ex | 12 +++++++ lib/asciinema/streaming/live_stream.ex | 9 ++++++ lib/asciinema_web/endpoint.ex | 4 ++- .../live_stream_consumer_socket.ex | 13 +++++++- .../live_stream_producer_socket.ex | 32 ++++++++++++------- .../20230618154430_create_live_streams.exs | 32 +++++++++++++++++++ 6 files changed, 89 insertions(+), 13 deletions(-) create mode 100644 lib/asciinema/streaming.ex create mode 100644 lib/asciinema/streaming/live_stream.ex create mode 100644 priv/repo/migrations/20230618154430_create_live_streams.exs diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex new file mode 100644 index 000000000..70477a317 --- /dev/null +++ b/lib/asciinema/streaming.ex @@ -0,0 +1,12 @@ +defmodule Asciinema.Streaming do + alias Asciinema.Repo + alias Asciinema.Streaming.LiveStream + + def find_live_stream_by_producer_token(token) do + Repo.get_by(LiveStream, producer_token: token) + end + + def get_live_stream(id) do + Repo.get(LiveStream, id) + end +end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex new file mode 100644 index 000000000..d692b50f6 --- /dev/null +++ b/lib/asciinema/streaming/live_stream.ex @@ -0,0 +1,9 @@ +defmodule Asciinema.Streaming.LiveStream do + use Ecto.Schema + + schema "live_streams" do + field :producer_token, :string + timestamps() + belongs_to :user, Asciinema.Accounts.User + end +end diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index 2358fef95..e82693a90 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -16,7 +16,9 @@ defmodule AsciinemaWeb.Endpoint do # socket "/socket", AsciinemaWeb.UserSocket, websocket: true # compress helps at all? - socket "/ws/S/:id", AsciinemaWeb.LiveStreamProducerSocket, websocket: [path: "", compress: true] + socket "/ws/S/:producer_token", AsciinemaWeb.LiveStreamProducerSocket, + websocket: [path: "", compress: true] + socket "/ws/s/:id", AsciinemaWeb.LiveStreamConsumerSocket, websocket: [path: "", compress: true] # Serve at "/" the static files from "priv/static" directory. diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index 556b5f6cf..f6d6c6d91 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -1,4 +1,5 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do + alias Asciinema.Streaming alias Asciinema.Streaming.LiveStreamServer require Logger @@ -17,7 +18,17 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def connect(state) do - {:ok, %{stream_id: state.params["id"], reset: false}} + id = state.params["id"] + + case Streaming.get_live_stream(id) do + nil -> + Logger.warn("consumer: stream not found for ID #{id}") + + :error + + live_stream -> + {:ok, %{stream_id: live_stream.id, reset: false}} + end end @impl true diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 8ccc6591f..cbd01aff5 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -22,18 +22,28 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def connect(state) do - state = %{ - stream_id: state.params["id"], - handler: nil, - bucket: %{ - size: config(:bucket_size, @default_bucket_size), - tokens: config(:bucket_size, @default_bucket_size), - fill_interval: config(:bucket_fill_interval, @default_bucket_fill_interval), - fill_amount: config(:bucket_fill_amount, @default_bucket_fill_amount) - } - } + token = state.params["producer_token"] + + case Streaming.find_live_stream_by_producer_token(token) do + nil -> + Logger.warn("producer: stream not found for producer token #{token}") + + :error + + live_stream -> + state = %{ + stream_id: live_stream.id, + handler: nil, + bucket: %{ + size: config(:bucket_size, @default_bucket_size), + tokens: config(:bucket_size, @default_bucket_size), + fill_interval: config(:bucket_fill_interval, @default_bucket_fill_interval), + fill_amount: config(:bucket_fill_amount, @default_bucket_fill_amount) + } + } - {:ok, state} + {:ok, state} + end end @impl true diff --git a/priv/repo/migrations/20230618154430_create_live_streams.exs b/priv/repo/migrations/20230618154430_create_live_streams.exs new file mode 100644 index 000000000..b4c346f44 --- /dev/null +++ b/priv/repo/migrations/20230618154430_create_live_streams.exs @@ -0,0 +1,32 @@ +defmodule Asciinema.Repo.Migrations.CreateLiveStreams do + use Ecto.Migration + + def change do + create table(:live_streams) do + add :user_id, references(:users), null: false + add :producer_token, :string, null: false + timestamps() + end + + execute( + fn -> + %{rows: rows} = repo().query!("SELECT id FROM users") + + for [user_id] <- rows do + token = Crypto.random_token(25) + timestamp = Timex.now() + + repo().query!( + "INSERT INTO live_streams (user_id, producer_token, inserted_at, updated_at) VALUES ($1, $2, $3, $3)", + [ + user_id, + token, + timestamp + ] + ) + end + end, + fn -> :ok end + ) + end +end From b731796a9144a2b0b149c612a4950e6ca73f7930 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Thu, 22 Jun 2023 21:26:27 +0200 Subject: [PATCH 035/121] Refactor Accounts module a bit --- lib/asciinema.ex | 31 ++++- lib/asciinema/accounts/accounts.ex | 114 +++++++++++------- lib/asciinema/accounts/user.ex | 33 ----- lib/asciinema/emails.ex | 11 +- lib/asciinema/streaming.ex | 10 ++ .../controllers/login_controller.ex | 14 +-- .../controllers/session_controller.ex | 3 +- .../controllers/user_controller.ex | 4 +- test/asciinema/accounts_test.exs | 86 ++++++------- test/asciinema_test.exs | 81 +++++++++++++ test/support/fixtures.ex | 3 +- 11 files changed, 253 insertions(+), 137 deletions(-) create mode 100644 test/asciinema_test.exs diff --git a/lib/asciinema.ex b/lib/asciinema.ex index d6f16d873..ff56564d7 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -1,5 +1,34 @@ defmodule Asciinema do - alias Asciinema.{Accounts, Recordings, Repo} + alias Asciinema.{Accounts, Emails, Recordings, Repo, Streaming} + + def create_user(params) do + with {:ok, user} <- Accounts.create_user(params) do + Streaming.create_live_stream!(user) + + {:ok, user} + end + end + + defdelegate change_user(user, params \\ %{}), to: Accounts + defdelegate update_user(user, params), to: Accounts + + def create_user_from_signup_token(token) do + with {:ok, email} <- Accounts.verify_signup_token(token) do + create_user(%{email: email}) + end + end + + def send_login_email(identifier, sign_up_enabled?, routes) do + case Accounts.generate_login_url(identifier, sign_up_enabled?, routes) do + {:ok, {type, url, email}} -> + Emails.send_email(type, email, url) + + {:error, _reason} = result -> + result + end + end + + defdelegate verify_login_token(token), to: Accounts def merge_accounts(src_user, dst_user) do Repo.transaction(fn -> diff --git a/lib/asciinema/accounts/accounts.ex b/lib/asciinema/accounts/accounts.ex index 1b3b8f138..a9c82ad24 100644 --- a/lib/asciinema/accounts/accounts.ex +++ b/lib/asciinema/accounts/accounts.ex @@ -3,9 +3,13 @@ defmodule Asciinema.Accounts do import Ecto.Query, warn: false import Ecto, only: [assoc: 2, build_assoc: 2] alias Asciinema.Accounts.{User, ApiToken} - alias Asciinema.{Emails, Repo} + alias Asciinema.Repo alias Ecto.Changeset + @valid_email_re ~r/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i + @valid_username_re ~r/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/ + @valid_theme_names ["asciinema", "tango", "solarized-dark", "solarized-light", "monokai"] + def fetch_user(id) do case get_user(id) do nil -> {:error, :not_found} @@ -21,6 +25,22 @@ defmodule Asciinema.Accounts do Repo.get_by(User, auth_token: auth_token) end + def create_user(attrs) do + import Ecto.Changeset + + result = + %User{} + |> cast(attrs, [:email]) + |> validate_format(:email, @valid_email_re) + |> validate_required([:email]) + |> add_contraints() + |> Repo.insert() + + with {:error, %Ecto.Changeset{errors: [{:email, _}]}} <- result do + {:error, :email_taken} + end + end + def ensure_asciinema_user do case Repo.get_by(User, username: "asciinema") do nil -> @@ -31,7 +51,7 @@ defmodule Asciinema.Accounts do } %User{} - |> User.changeset(attrs) + |> change_user(attrs) |> Repo.insert!() user -> @@ -39,13 +59,32 @@ defmodule Asciinema.Accounts do end end - def change_user(user) do - User.changeset(user) + def change_user(user, params \\ %{}) do + import Ecto.Changeset + + user + |> cast(params, [:email, :name, :username, :theme_name, :asciicasts_private_by_default]) + |> validate_format(:email, @valid_email_re) + |> validate_format(:username, @valid_username_re) + |> validate_length(:username, min: 2, max: 16) + |> validate_inclusion(:theme_name, @valid_theme_names) + |> add_contraints() + end + + defp add_contraints(changeset) do + import Ecto.Changeset + + changeset + |> unique_constraint(:username, name: "index_users_on_username") + |> unique_constraint(:email, name: "index_users_on_email") end def update_user(user, params) do + import Ecto.Changeset + user - |> User.update_changeset(params) + |> change_user(params) + |> validate_required([:username, :email]) |> Repo.update() end @@ -55,41 +94,38 @@ defmodule Asciinema.Accounts do from(u in q, where: is_nil(u.email)) end - def send_login_email(email_or_username, signup_url, login_url, sign_up_enabled?) do - case {lookup_user(email_or_username), sign_up_enabled?} do - {%User{email: nil}, _} -> + def generate_login_url(identifier, sign_up_enabled?, routes) do + case {lookup_user(identifier), sign_up_enabled?} do + {{_, %User{email: nil}}, _} -> {:error, :email_missing} - {%User{} = user, _} -> - url = user |> login_token() |> login_url.() - {:ok, _} = Emails.send_login_email(user.email, url) - - :ok + {{_, %User{} = user}, _} -> + url = user |> login_token() |> routes.login_url() - {%Changeset{errors: [{:email, _}]}, _} -> - {:error, :email_invalid} + {:ok, {:login, url, user.email}} - {%Changeset{} = changeset, true} -> - email = changeset.changes.email - url = email |> signup_token() |> signup_url.() - {:ok, _} = Emails.send_signup_email(email, url) + {{:email, nil}, true} -> + changeset = change_user(%User{}, %{email: identifier}) - :ok + if changeset.valid? do + email = changeset.changes.email + url = email |> signup_token() |> routes.signup_url() - {%Changeset{}, false} -> - {:error, :user_not_found} + {:ok, {:signup, url, email}} + else + {:error, :email_invalid} + end - {nil, _} -> + {{_, nil}, _} -> {:error, :user_not_found} end end - def lookup_user(email_or_username) do - if String.contains?(email_or_username, "@") do - Repo.get_by(User, email: email_or_username) || - User.signup_changeset(%{email: email_or_username}) + def lookup_user(identifier) do + if String.contains?(identifier, "@") do + {:email, Repo.get_by(User, email: identifier)} else - Repo.get_by(User, username: email_or_username) + {:username, Repo.get_by(User, username: identifier)} end end @@ -113,18 +149,10 @@ defmodule Asciinema.Accounts do max_age: config(:login_token_max_age, 60) * 60 ) - with {:ok, email} <- result, - {:ok, user} <- %{email: email} |> User.signup_changeset() |> Repo.insert() do - {:ok, user} - else - {:error, :invalid} -> - {:error, :token_invalid} - - {:error, %Ecto.Changeset{}} -> - {:error, :email_taken} - - {:error, _} -> - {:error, :token_expired} + case result do + {:ok, email} -> {:ok, email} + {:error, :invalid} -> {:error, :token_invalid} + {:error, _} -> {:error, :token_expired} end end @@ -167,10 +195,12 @@ defmodule Asciinema.Accounts do end def create_user_with_api_token(token, tmp_username) do - user_changeset = User.temporary_changeset(tmp_username) + import Ecto.Changeset + + changeset = change(%User{}, %{temporary_username: tmp_username}) Repo.transaction(fn -> - with {:ok, %User{} = user} <- Repo.insert(user_changeset), + with {:ok, %User{} = user} <- Repo.insert(changeset), {:ok, %ApiToken{}} <- create_api_token(user, token) do user else diff --git a/lib/asciinema/accounts/user.ex b/lib/asciinema/accounts/user.ex index 25ed7e6f5..f51001d50 100644 --- a/lib/asciinema/accounts/user.ex +++ b/lib/asciinema/accounts/user.ex @@ -1,11 +1,5 @@ defmodule Asciinema.Accounts.User do use Ecto.Schema - import Ecto.Changeset - alias Asciinema.Accounts.User - - @valid_email_re ~r/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i - @valid_username_re ~r/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/ - @valid_theme_names ["asciinema", "tango", "solarized-dark", "solarized-light", "monokai"] @timestamps_opts [type: :utc_datetime_usec] @@ -25,31 +19,4 @@ defmodule Asciinema.Accounts.User do has_many :asciicasts, Asciinema.Recordings.Asciicast has_many :api_tokens, Asciinema.Accounts.ApiToken end - - def changeset(struct, params \\ %{}) do - struct - |> cast(params, [:email, :name, :username, :theme_name, :asciicasts_private_by_default]) - |> validate_format(:email, @valid_email_re) - |> validate_format(:username, @valid_username_re) - |> validate_length(:username, min: 2, max: 16) - |> validate_inclusion(:theme_name, @valid_theme_names) - |> unique_constraint(:username, name: "index_users_on_username") - |> unique_constraint(:email, name: "index_users_on_email") - end - - def signup_changeset(attrs) do - %User{} - |> changeset(attrs) - |> validate_required([:email]) - end - - def update_changeset(%User{} = user, attrs) do - user - |> changeset(attrs) - |> validate_required([:username, :email]) - end - - def temporary_changeset(temporary_username) do - change(%User{}, %{temporary_username: temporary_username}) - end end diff --git a/lib/asciinema/emails.ex b/lib/asciinema/emails.ex index 9fee6a474..18d3d983b 100644 --- a/lib/asciinema/emails.ex +++ b/lib/asciinema/emails.ex @@ -22,13 +22,10 @@ defmodule Asciinema.Emails do end end - def send_signup_email(to, url) do - Job.new(%{type: :signup, to: to, url: url}) - |> Oban.insert() - end + def send_email(type, to, url) do + Job.new(%{type: type, to: to, url: url}) + |> Oban.insert!() - def send_login_email(to, url) do - Job.new(%{type: :login, to: to, url: url}) - |> Oban.insert() + :ok end end diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 70477a317..177810696 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -1,4 +1,5 @@ defmodule Asciinema.Streaming do + import Ecto.Changeset alias Asciinema.Repo alias Asciinema.Streaming.LiveStream @@ -9,4 +10,13 @@ defmodule Asciinema.Streaming do def get_live_stream(id) do Repo.get(LiveStream, id) end + + def create_live_stream!(user) do + %LiveStream{} + |> change(producer_token: generate_producer_token()) + |> put_assoc(:user, user) + |> Repo.insert!() + end + + defp generate_producer_token, do: Crypto.random_token(25) end diff --git a/lib/asciinema_web/controllers/login_controller.ex b/lib/asciinema_web/controllers/login_controller.ex index bda7a5b90..c71c948d8 100644 --- a/lib/asciinema_web/controllers/login_controller.ex +++ b/lib/asciinema_web/controllers/login_controller.ex @@ -1,6 +1,5 @@ defmodule AsciinemaWeb.LoginController do use AsciinemaWeb, :controller - alias Asciinema.Accounts plug :clear_main_class @@ -8,15 +7,14 @@ defmodule AsciinemaWeb.LoginController do render(conn, "new.html") end - def create(conn, %{"login" => %{"email" => email_or_username}}) do - email_or_username = String.trim(email_or_username) + def create(conn, %{"login" => %{"email" => identifier}}) do + identifier = String.trim(identifier) result = - Accounts.send_login_email( - email_or_username, - &AsciinemaWeb.signup_url/1, - &AsciinemaWeb.login_url/1, - Map.get(conn.assigns, :cfg_sign_up_enabled?, true) + Asciinema.send_login_email( + identifier, + Map.get(conn.assigns, :cfg_sign_up_enabled?, true), + AsciinemaWeb ) case result do diff --git a/lib/asciinema_web/controllers/session_controller.ex b/lib/asciinema_web/controllers/session_controller.ex index 221a93a84..5787f132c 100644 --- a/lib/asciinema_web/controllers/session_controller.ex +++ b/lib/asciinema_web/controllers/session_controller.ex @@ -1,6 +1,5 @@ defmodule AsciinemaWeb.SessionController do use AsciinemaWeb, :controller - alias Asciinema.Accounts alias AsciinemaWeb.Auth alias Asciinema.Accounts.User @@ -18,7 +17,7 @@ defmodule AsciinemaWeb.SessionController do login_token = get_session(conn, :login_token) conn = delete_session(conn, :login_token) - case Accounts.verify_login_token(login_token) do + case Asciinema.verify_login_token(login_token) do {:ok, user} -> conn |> Auth.log_in(user) diff --git a/lib/asciinema_web/controllers/user_controller.ex b/lib/asciinema_web/controllers/user_controller.ex index 2f08da051..1ba7cc2cc 100644 --- a/lib/asciinema_web/controllers/user_controller.ex +++ b/lib/asciinema_web/controllers/user_controller.ex @@ -19,10 +19,10 @@ defmodule AsciinemaWeb.UserController do end def create(conn, _params) do - signup_token = get_session(conn, :signup_token) + token = get_session(conn, :signup_token) conn = delete_session(conn, :signup_token) - case Accounts.verify_signup_token(signup_token) do + case Asciinema.create_user_from_signup_token(token) do {:ok, user} -> conn |> Auth.log_in(user) diff --git a/test/asciinema/accounts_test.exs b/test/asciinema/accounts_test.exs index 5907ae081..fcb5af4ad 100644 --- a/test/asciinema/accounts_test.exs +++ b/test/asciinema/accounts_test.exs @@ -3,75 +3,79 @@ defmodule Asciinema.AccountsTest do use Asciinema.DataCase use Oban.Testing, repo: Asciinema.Repo alias Asciinema.Accounts - alias Asciinema.Accounts.User - describe "send_login_email/1" do - defp signup_url(_), do: "http://signup" - defp login_url(_), do: "http://login" + describe "verify_signup_token/1" do + test "invalid token" do + assert Accounts.verify_signup_token("invalid") == {:error, :token_invalid} + end + + test "valid token" do + token = Accounts.signup_token("test@example.com") + assert {:ok, "test@example.com"} = Accounts.verify_signup_token(token) + end + end + + describe "generate_login_url/3" do + defmodule Routes do + def signup_url(_), do: "http://signup" + def login_url(_), do: "http://login" + end test "existing user, by email" do user = fixture(:user) - assert Accounts.send_login_email(user.email, &signup_url/1, &login_url/1, true) == :ok - - assert_enqueued( - worker: Asciinema.Emails.Job, - args: %{"type" => "login", "to" => user.email, "url" => "http://login"} - ) + assert Accounts.generate_login_url(user.email, true, Routes) == + {:ok, {:login, "http://login", user.email}} end test "existing user, by username" do user = fixture(:user) - assert Accounts.send_login_email(user.username, &signup_url/1, &login_url/1, true) == :ok - - assert_enqueued( - worker: Asciinema.Emails.Job, - args: %{"type" => "login", "to" => user.email, "url" => "http://login"} - ) + assert Accounts.generate_login_url(user.username, true, Routes) == + {:ok, {:login, "http://login", user.email}} end test "non-existing user, by email" do - assert Accounts.send_login_email("new@example.com", &signup_url/1, &login_url/1, true) == - :ok - - assert_enqueued( - worker: Asciinema.Emails.Job, - args: %{"type" => "signup", "to" => "new@example.com", "url" => "http://signup"} - ) + assert Accounts.generate_login_url("new@example.com", true, Routes) == + {:ok, {:signup, "http://signup", "new@example.com"}} end test "non-existing user, by email, when sign up is disabled" do - assert Accounts.send_login_email("new@example.com", &signup_url/1, &login_url/1, false) == + assert Accounts.generate_login_url("new@example.com", false, Routes) == {:error, :user_not_found} - - refute_enqueued(worker: Asciinema.Emails.Job) end test "non-existing user, by email, when email is invalid" do - assert Accounts.send_login_email("new@", &signup_url/1, &login_url/1, true) == - {:error, :email_invalid} - - refute_enqueued(worker: Asciinema.Emails.Job) + assert Accounts.generate_login_url("new@", true, Routes) == {:error, :email_invalid} end test "non-existing user, by username" do - assert Accounts.send_login_email("idontexist", &signup_url/1, &login_url/1, true) == - {:error, :user_not_found} - - refute_enqueued(worker: Asciinema.Emails.Job) + assert Accounts.generate_login_url("idontexist", true, Routes) == {:error, :user_not_found} end end - describe "verify_signup_token/1" do - test "invalid token" do - assert Accounts.verify_signup_token("invalid") == {:error, :token_invalid} + describe "update_user/2" do + setup do + %{user: fixture(:user)} end - test "valid token" do - token = Accounts.signup_token("test@example.com") - assert {:ok, %User{}} = Accounts.verify_signup_token(token) - assert Accounts.verify_signup_token(token) == {:error, :email_taken} + def assert_success(user, attrs) do + assert {:ok, _} = Accounts.update_user(user, attrs) + end + + test "success", %{user: user} do + assert_success(user, %{email: "new@one.com"}) + assert_success(user, %{username: "newone"}) + end + + def assert_validation_error(user, attrs) do + assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, attrs) + end + + test "validation failures", %{user: user} do + assert_validation_error(user, %{email: "newone.com"}) + assert_validation_error(user, %{email: ""}) + assert_validation_error(user, %{username: ""}) end end end diff --git a/test/asciinema_test.exs b/test/asciinema_test.exs new file mode 100644 index 000000000..087618a08 --- /dev/null +++ b/test/asciinema_test.exs @@ -0,0 +1,81 @@ +defmodule AsciinemaTest do + import Asciinema.Fixtures + use Asciinema.DataCase + use Oban.Testing, repo: Asciinema.Repo + alias Asciinema.Accounts + + describe "create_user/1" do + test "succeeds when email not taken" do + assert {:ok, _} = Asciinema.create_user(%{email: "test@example.com"}) + assert {:error, :email_taken} = Asciinema.create_user(%{email: "test@example.com"}) + end + end + + describe "create_user_from_signup_token/1" do + test "succeeds when email not taken" do + # TODO don't reach to Accounts + token = Accounts.signup_token("test@example.com") + assert {:ok, _} = Asciinema.create_user_from_signup_token(token) + end + end + + describe "send_login_email/3" do + defmodule Routes do + def signup_url(_), do: "http://signup" + def login_url(_), do: "http://login" + end + + test "existing user, by email" do + user = fixture(:user) + + assert Asciinema.send_login_email(user.email, true, Routes) == :ok + + assert_enqueued( + worker: Asciinema.Emails.Job, + args: %{"type" => "login", "to" => user.email, "url" => "http://login"} + ) + end + + test "existing user, by username" do + user = fixture(:user) + + assert Asciinema.send_login_email(user.username, true, Routes) == :ok + + assert_enqueued( + worker: Asciinema.Emails.Job, + args: %{"type" => "login", "to" => user.email, "url" => "http://login"} + ) + end + + test "non-existing user, by email" do + assert Asciinema.send_login_email("new@example.com", true, Routes) == + :ok + + assert_enqueued( + worker: Asciinema.Emails.Job, + args: %{"type" => "signup", "to" => "new@example.com", "url" => "http://signup"} + ) + end + + test "non-existing user, by email, when sign up is disabled" do + assert Asciinema.send_login_email("new@example.com", false, Routes) == + {:error, :user_not_found} + + refute_enqueued(worker: Asciinema.Emails.Job) + end + + test "non-existing user, by email, when email is invalid" do + assert Asciinema.send_login_email("new@", true, Routes) == + {:error, :email_invalid} + + refute_enqueued(worker: Asciinema.Emails.Job) + end + + test "non-existing user, by username" do + assert Asciinema.send_login_email("idontexist", true, Routes) == + {:error, :user_not_found} + + refute_enqueued(worker: Asciinema.Emails.Job) + end + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 685692c0d..1e256e25f 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -1,5 +1,6 @@ defmodule Asciinema.Fixtures do alias Asciinema.{Repo, Recordings} + alias Asciinema.Accounts alias Asciinema.Accounts.User def fixture(what, attrs \\ %{}) @@ -38,7 +39,7 @@ defmodule Asciinema.Fixtures do ) %User{} - |> User.changeset(attrs) + |> Accounts.change_user(attrs) |> Repo.insert!() end From 68df4381d8103bf7fa05d606434eaf02e5043b2d Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Thu, 22 Jun 2023 23:03:28 +0200 Subject: [PATCH 036/121] Upgrade js player --- assets/package-lock.json | 8 ++++---- assets/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/package-lock.json b/assets/package-lock.json index e3bbc6d3c..f49631cab 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -8,7 +8,7 @@ "devDependencies": { "@babel/core": "^7.21.0", "@babel/preset-env": "^7.20.2", - "asciinema-player": "^3.4.0", + "asciinema-player": "^3.5.0", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", @@ -2297,9 +2297,9 @@ } }, "node_modules/asciinema-player": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.4.0.tgz", - "integrity": "sha512-dX6jt5S3K6daItsVWzyY9mRDK+ivC2QgqCxFkdSiNslo0vY/ZqA4upcTzqIKZqBtxppovOZk44ltg9VnHG9QVg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.5.0.tgz", + "integrity": "sha512-o4B2AscBuCZo4+JB9TBGrfZ7GQL99wsbm08WwmuNJTPd1lyLQJq8wgacnBsdvb2sC0K875ScYr8T5XmfeH/6dg==", "dev": true, "dependencies": { "@babel/runtime": "^7.21.0", diff --git a/assets/package.json b/assets/package.json index 05cf2cefa..0bd14fef6 100644 --- a/assets/package.json +++ b/assets/package.json @@ -12,7 +12,7 @@ "devDependencies": { "@babel/core": "^7.21.0", "@babel/preset-env": "^7.20.2", - "asciinema-player": "^3.4.0", + "asciinema-player": "^3.5.0", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", From 5a9b6265bcb895bf76607db9beb94d5cd21e7e5b Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 09:32:15 +0200 Subject: [PATCH 037/121] Make dir structure consistent --- lib/asciinema/{accounts => }/accounts.ex | 0 lib/asciinema/{recordings => }/recordings.ex | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename lib/asciinema/{accounts => }/accounts.ex (100%) rename lib/asciinema/{recordings => }/recordings.ex (100%) diff --git a/lib/asciinema/accounts/accounts.ex b/lib/asciinema/accounts.ex similarity index 100% rename from lib/asciinema/accounts/accounts.ex rename to lib/asciinema/accounts.ex diff --git a/lib/asciinema/recordings/recordings.ex b/lib/asciinema/recordings.ex similarity index 100% rename from lib/asciinema/recordings/recordings.ex rename to lib/asciinema/recordings.ex From 7d1906d9dbc2e5f12e7a4409817b7d66407e5196 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 09:42:18 +0200 Subject: [PATCH 038/121] Fix deleting of asciicast when file on disk is already gone --- lib/asciinema/recordings.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/asciinema/recordings.ex b/lib/asciinema/recordings.ex index 099bd73d5..a40ca8b06 100644 --- a/lib/asciinema/recordings.ex +++ b/lib/asciinema/recordings.ex @@ -473,9 +473,11 @@ defmodule Asciinema.Recordings do def delete_asciicast(asciicast) do with {:ok, asciicast} <- Repo.delete(asciicast) do - :ok = FileStore.delete_file(asciicast.path) - - {:ok, asciicast} + case FileStore.delete_file(asciicast.path) do + :ok -> {:ok, asciicast} + {:error, :enoent} -> {:ok, asciicast} + otherwise -> otherwise + end end end From 7e832eedb6001cb71c8845a606226245cc5db619 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 09:43:08 +0200 Subject: [PATCH 039/121] Remove unnecessary changeset function --- lib/asciinema/recordings.ex | 2 +- lib/asciinema/recordings/asciicast.ex | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/asciinema/recordings.ex b/lib/asciinema/recordings.ex index a40ca8b06..761e8cdec 100644 --- a/lib/asciinema/recordings.ex +++ b/lib/asciinema/recordings.ex @@ -486,7 +486,7 @@ defmodule Asciinema.Recordings do rows = asciicast.rows_override || asciicast.rows secs = Asciicast.snapshot_at(asciicast) snapshot = asciicast |> stdout_stream |> generate_snapshot(cols, rows, secs) - asciicast |> Asciicast.snapshot_changeset(snapshot) |> Repo.update() + asciicast |> Changeset.cast(%{snapshot: snapshot}, [:snapshot]) |> Repo.update() end def generate_snapshot(stdout_stream, width, height, secs) do diff --git a/lib/asciinema/recordings/asciicast.ex b/lib/asciinema/recordings/asciicast.ex index 11557c8aa..dd15437ee 100644 --- a/lib/asciinema/recordings/asciicast.ex +++ b/lib/asciinema/recordings/asciicast.ex @@ -153,10 +153,6 @@ defmodule Asciinema.Recordings.Asciicast do end end - def snapshot_changeset(struct, snapshot) do - cast(struct, %{snapshot: snapshot}, [:snapshot]) - end - defp generate_secret_token(changeset) do put_change(changeset, :secret_token, Crypto.random_token(25)) end From 8eb2aba59e5ed280660195e7a665642a3afb7b85 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 10:07:50 +0200 Subject: [PATCH 040/121] Add lookup of stream by owner (user) --- lib/asciinema.ex | 2 ++ lib/asciinema/accounts/user.ex | 1 + lib/asciinema/streaming.ex | 10 +++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/asciinema.ex b/lib/asciinema.ex index ff56564d7..ff6742ad1 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -38,4 +38,6 @@ defmodule Asciinema do Accounts.get_user(dst_user.id) end) end + + defdelegate get_live_stream(id_or_owner), to: Streaming end diff --git a/lib/asciinema/accounts/user.ex b/lib/asciinema/accounts/user.ex index f51001d50..3bc83d4d8 100644 --- a/lib/asciinema/accounts/user.ex +++ b/lib/asciinema/accounts/user.ex @@ -17,6 +17,7 @@ defmodule Asciinema.Accounts.User do timestamps() has_many :asciicasts, Asciinema.Recordings.Asciicast + has_many :live_streams, Asciinema.Streaming.LiveStream has_many :api_tokens, Asciinema.Accounts.ApiToken end end diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 177810696..edf0d27f4 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -2,15 +2,23 @@ defmodule Asciinema.Streaming do import Ecto.Changeset alias Asciinema.Repo alias Asciinema.Streaming.LiveStream + alias Ecto.Query def find_live_stream_by_producer_token(token) do Repo.get_by(LiveStream, producer_token: token) end - def get_live_stream(id) do + def get_live_stream(id) when is_integer(id) or is_binary(id) do Repo.get(LiveStream, id) end + def get_live_stream(owner) do + owner + |> Ecto.assoc(:live_streams) + |> Query.first() + |> Repo.one() + end + def create_live_stream!(user) do %LiveStream{} |> change(producer_token: generate_producer_token()) From b49dd2c79b483befef650d185840498b7c1007bf Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 10:40:18 +0200 Subject: [PATCH 041/121] Track stream activity in LiveStream schema --- lib/asciinema/streaming.ex | 6 ++++++ lib/asciinema/streaming/live_stream.ex | 3 +++ lib/asciinema/streaming/live_stream_server.ex | 17 +++++++++++++++-- .../20230618154430_create_live_streams.exs | 1 + 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index edf0d27f4..1d1a6c02e 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -26,5 +26,11 @@ defmodule Asciinema.Streaming do |> Repo.insert!() end + def touch(stream) do + stream + |> cast(%{last_activity_at: Timex.now()}, [:last_activity_at]) + |> Repo.update!() + end + defp generate_producer_token, do: Crypto.random_token(25) end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex index d692b50f6..2c61fb4df 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -3,7 +3,10 @@ defmodule Asciinema.Streaming.LiveStream do schema "live_streams" do field :producer_token, :string + field :last_activity_at, :naive_datetime + timestamps() + belongs_to :user, Asciinema.Accounts.User end end diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index 794a0ed15..c51a72d0f 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -1,6 +1,6 @@ defmodule Asciinema.Streaming.LiveStreamServer do use GenServer, restart: :transient - alias Asciinema.Vt + alias Asciinema.{Streaming, Vt} require Logger # Client @@ -41,8 +41,12 @@ defmodule Asciinema.Streaming.LiveStreamServer do def init(stream_id) do Logger.info("stream/#{stream_id}: init") + send(self(), :update_stream) + stream = Streaming.get_live_stream(stream_id) + state = %{ - stream_id: stream_id, + stream: stream, + stream_id: stream.id, producer: nil, vt: nil, vt_size: nil, @@ -127,7 +131,16 @@ defmodule Asciinema.Streaming.LiveStreamServer do {:noreply, state} end + @update_stream_interval 10_000 + @impl true + def handle_info(:update_stream, state) do + Process.send_after(self(), :update_stream, @update_stream_interval) + stream = Streaming.touch(state.stream) + + {:noreply, %{state | stream: stream}} + end + def handle_info(:shutdown, state) do Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") publish({:live_stream, state.stream_id}, {:live_stream, :offline}) diff --git a/priv/repo/migrations/20230618154430_create_live_streams.exs b/priv/repo/migrations/20230618154430_create_live_streams.exs index b4c346f44..c3358275a 100644 --- a/priv/repo/migrations/20230618154430_create_live_streams.exs +++ b/priv/repo/migrations/20230618154430_create_live_streams.exs @@ -5,6 +5,7 @@ defmodule Asciinema.Repo.Migrations.CreateLiveStreams do create table(:live_streams) do add :user_id, references(:users), null: false add :producer_token, :string, null: false + add :last_activity_at, :naive_datetime timestamps() end From f878035b3f275c53f4976b294501664b120483c9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 16:45:21 +0200 Subject: [PATCH 042/121] Move player initialization to script tag in HTML templates --- assets/js/app.js | 26 +------- assets/js/iframe.js | 14 +---- assets/js/player.js | 17 ++++++ .../templates/recording/iframe.html.eex | 26 +++++++- .../templates/recording/show.html.eex | 21 ++++++- lib/asciinema_web/views/application_view.ex | 9 +++ lib/asciinema_web/views/recording_view.ex | 60 +++++-------------- .../controllers/recording_controller_test.exs | 2 +- 8 files changed, 88 insertions(+), 87 deletions(-) create mode 100644 assets/js/player.js diff --git a/assets/js/app.js b/assets/js/app.js index fd46c1881..09ff72b97 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -3,25 +3,9 @@ import css from '../css/app.scss'; import $ from 'jquery'; import "bootstrap"; import "phoenix_html"; -import { create } from 'asciinema-player'; +import { createPlayer } from './player'; -window.createPlayer = create; - -function createPlayer(src, container, opts) { - if (opts.customTerminalFontFamily) { - opts.terminalFontFamily = `${opts.customTerminalFontFamily},Consolas,Menlo,'Bitstream Vera Sans Mono',monospace,'Powerline Symbols'`; - - document.fonts.load(`1em ${opts.customTerminalFontFamily}`).then(() => { - console.log(`loaded font ${opts.customTerminalFontFamily}`); - create(src, container, opts); - }).catch(error => { - console.log(`failed to load font ${opts.customTerminalFontFamily}`, error); - create(src, container, opts); - }); - } else { - create(src, container, opts); - } -} +window.createPlayer = createPlayer; $(function() { $('input[data-behavior=focus]:first').focus().select(); @@ -34,10 +18,4 @@ $(function() { if ($('meta[name=referrer][content=origin]').length > 0) { $('a[href*=http]').attr('rel', 'noreferrer'); } - - const players = window.players || new Map(); - - for (const [id, props] of players) { - createPlayer(props.src, document.getElementById(id), { ...props, logger: console }); - }; }); diff --git a/assets/js/iframe.js b/assets/js/iframe.js index 5cd4e8784..b3579ec6b 100644 --- a/assets/js/iframe.js +++ b/assets/js/iframe.js @@ -1,14 +1,4 @@ import css from '../css/iframe.scss'; +import { createPlayer } from './player'; -import { create } from 'asciinema-player'; - -const [id, props] = window.players.entries().next().value; -const player = create(props.src, document.getElementById(id), props); - -if (window.parent !== window) { - player.el.addEventListener('resize', e => { - const w = e.detail.el.offsetWidth; - const h = Math.max(document.body.scrollHeight, document.body.offsetHeight); - window.parent.postMessage(['resize', { width: w, height: h }], '*'); - }); -} +window.createPlayer = createPlayer; diff --git a/assets/js/player.js b/assets/js/player.js new file mode 100644 index 000000000..10163aec0 --- /dev/null +++ b/assets/js/player.js @@ -0,0 +1,17 @@ +import { create } from 'asciinema-player'; + +export function createPlayer(src, container, opts) { + if (opts.customTerminalFontFamily) { + opts.terminalFontFamily = `${opts.customTerminalFontFamily},Consolas,Menlo,'Bitstream Vera Sans Mono',monospace,'Powerline Symbols'`; + + document.fonts.load(`1em ${opts.customTerminalFontFamily}`).then(() => { + console.log(`loaded font ${opts.customTerminalFontFamily}`); + create(src, container, opts); + }).catch(error => { + console.log(`failed to load font ${opts.customTerminalFontFamily}`, error); + create(src, container, opts); + }); + } else { + create(src, container, opts); + } +} diff --git a/lib/asciinema_web/templates/recording/iframe.html.eex b/lib/asciinema_web/templates/recording/iframe.html.eex index 9aa28a8b1..d607bea34 100644 --- a/lib/asciinema_web/templates/recording/iframe.html.eex +++ b/lib/asciinema_web/templates/recording/iframe.html.eex @@ -1,7 +1,29 @@
-<%= player @asciicast, Keyword.merge(@playback_options, container_id: "player", fit: "width") %> -

Recorded with asciinema

+ + diff --git a/lib/asciinema_web/templates/recording/show.html.eex b/lib/asciinema_web/templates/recording/show.html.eex index 0c8aad8db..6d70badea 100644 --- a/lib/asciinema_web/templates/recording/show.html.eex +++ b/lib/asciinema_web/templates/recording/show.html.eex @@ -1,6 +1,4 @@ -
- <%= player @asciicast, Keyword.merge(@playback_options, container_id: "cinema", fit: "both") %> -
+
@@ -140,3 +138,20 @@ <%= if download_filename(@asciicast) do %> <%= render "_download_modal.html", conn: @conn, asciicast: @asciicast %> <% end %> + + diff --git a/lib/asciinema_web/views/application_view.ex b/lib/asciinema_web/views/application_view.ex index 36ac2a8f1..4e49a58f7 100644 --- a/lib/asciinema_web/views/application_view.ex +++ b/lib/asciinema_web/views/application_view.ex @@ -30,4 +30,13 @@ defmodule AsciinemaWeb.ApplicationView do def sign_up_enabled?(conn) do Map.get(conn.assigns, :cfg_sign_up_enabled?, true) end + + def safe_json(value) do + json = + value + |> Jason.encode!() + |> String.replace(~r/ Keyword.merge(opts) - |> Ext.Keyword.rename(t: :startAt) - |> Enum.into(%{}) - |> Map.drop([:container_id]) - - props_json = - props - |> Jason.encode!() - |> String.replace(~r/', <%= {:safe, props_json} %>); - """ - end - end + def player_src(asciicast), do: file_url(asciicast) - def player(asciicast, opts) do - opts = - Keyword.merge( - [ - cols: cols(asciicast), - rows: rows(asciicast), - theme: theme_name(asciicast), - terminalLineHeight: asciicast.terminal_line_height, - customTerminalFontFamily: asciicast.terminal_font_family, - poster: poster(asciicast.snapshot), - markers: markers(asciicast.markers), - idleTimeLimit: asciicast.idle_time_limit, - title: title(asciicast), - author: author_username(asciicast), - "author-url": author_profile_url(asciicast), - "author-img-url": author_avatar_url(asciicast) - ], - opts - ) - - player(file_url(asciicast), opts) + def player_opts(asciicast, opts) do + [ + cols: cols(asciicast), + rows: rows(asciicast), + theme: theme_name(asciicast), + terminalLineHeight: asciicast.terminal_line_height, + customTerminalFontFamily: asciicast.terminal_font_family, + poster: poster(asciicast.snapshot), + markers: markers(asciicast.markers), + idleTimeLimit: asciicast.idle_time_limit + ] + |> Keyword.merge(opts) + |> Ext.Keyword.rename(t: :startAt) + |> Enum.into(%{}) end @container_vertical_padding 2 * 4 diff --git a/test/controllers/recording_controller_test.exs b/test/controllers/recording_controller_test.exs index fcff84ef2..19a2ab008 100644 --- a/test/controllers/recording_controller_test.exs +++ b/test/controllers/recording_controller_test.exs @@ -124,7 +124,7 @@ defmodule Asciinema.RecordingControllerTest do conn = get(conn, Routes.recording_path(conn, :iframe, asciicast)) assert html_response(conn, 200) =~ ~r/iframe\.css/ assert html_response(conn, 200) =~ ~r/iframe\.js/ - assert html_response(conn, 200) =~ ~r/window\.players\.set/ + assert html_response(conn, 200) =~ ~r/window\.createPlayer/ end end From 4684417c3d69897e472ecbb2110d5b4de8974a24 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 17:00:30 +0200 Subject: [PATCH 043/121] Track last known VT size (cols, rows) on live stream schema --- lib/asciinema/streaming.ex | 3 ++- lib/asciinema/streaming/live_stream.ex | 2 ++ lib/asciinema/streaming/live_stream_server.ex | 4 ++-- priv/repo/migrations/20230618154430_create_live_streams.exs | 2 ++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 1d1a6c02e..54eaf884c 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -26,9 +26,10 @@ defmodule Asciinema.Streaming do |> Repo.insert!() end - def touch(stream) do + def update_live_stream(stream, {cols, rows}) do stream |> cast(%{last_activity_at: Timex.now()}, [:last_activity_at]) + |> change(cols: cols, rows: rows) |> Repo.update!() end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex index 2c61fb4df..d2e8d447c 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -3,6 +3,8 @@ defmodule Asciinema.Streaming.LiveStream do schema "live_streams" do field :producer_token, :string + field :cols, :integer + field :rows, :integer field :last_activity_at, :naive_datetime timestamps() diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index c51a72d0f..1cc90db11 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -41,7 +41,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do def init(stream_id) do Logger.info("stream/#{stream_id}: init") - send(self(), :update_stream) + Process.send_after(self(), :update_stream, 1_000) stream = Streaming.get_live_stream(stream_id) state = %{ @@ -136,7 +136,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do @impl true def handle_info(:update_stream, state) do Process.send_after(self(), :update_stream, @update_stream_interval) - stream = Streaming.touch(state.stream) + stream = Streaming.update_live_stream(state.stream, state.vt_size) {:noreply, %{state | stream: stream}} end diff --git a/priv/repo/migrations/20230618154430_create_live_streams.exs b/priv/repo/migrations/20230618154430_create_live_streams.exs index c3358275a..649aeec95 100644 --- a/priv/repo/migrations/20230618154430_create_live_streams.exs +++ b/priv/repo/migrations/20230618154430_create_live_streams.exs @@ -5,6 +5,8 @@ defmodule Asciinema.Repo.Migrations.CreateLiveStreams do create table(:live_streams) do add :user_id, references(:users), null: false add :producer_token, :string, null: false + add :cols, :integer + add :rows, :integer add :last_activity_at, :naive_datetime timestamps() end From cf2a00e6cf3517784d1d2a5564fcfda39aed41ab Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 17:04:06 +0200 Subject: [PATCH 044/121] Basic live stream view page --- assets/css/_recording_show.scss | 2 +- lib/asciinema/streaming.ex | 4 +- .../controllers/live_stream_controller.ex | 33 +++++----- .../templates/live_stream/show.html.eex | 43 +++++-------- lib/asciinema_web/views/live_stream_view.ex | 64 ++++++------------- 5 files changed, 53 insertions(+), 93 deletions(-) diff --git a/assets/css/_recording_show.scss b/assets/css/_recording_show.scss index 07a8ab6ce..971f0bd22 100644 --- a/assets/css/_recording_show.scss +++ b/assets/css/_recording_show.scss @@ -1,4 +1,4 @@ -.c-recording.a-show { +.c-recording.a-show, .c-live-stream.a-show { section.info { small { font-size: 14px; diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 54eaf884c..10ae53acc 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -9,7 +9,9 @@ defmodule Asciinema.Streaming do end def get_live_stream(id) when is_integer(id) or is_binary(id) do - Repo.get(LiveStream, id) + LiveStream + |> Repo.get(id) + |> Repo.preload(:user) end def get_live_stream(owner) do diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex index ed957acb1..da5547cbe 100644 --- a/lib/asciinema_web/controllers/live_stream_controller.ex +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -1,26 +1,25 @@ defmodule AsciinemaWeb.LiveStreamController do use AsciinemaWeb, :controller + alias Asciinema.Streaming plug :clear_main_class - def show(conn, params) do - live_stream = %{ - cols: 80, - cols_override: nil, - rows: 24, - rows_override: nil, - theme_name: nil, - user: %{id: 1, email: nil, username: nil, temporary_username: nil, theme_name: nil}, - snapshot: nil, - idle_time_limit: nil, - title: nil, - command: nil, - private: nil, - id: params["id"] - } + def show(conn, %{"id" => id}) do + with {:ok, stream} <- fetch_live_stream(id) do + do_show(conn, stream) + end + end + defp do_show(conn, stream) do conn - |> assign(:live_stream, live_stream) - |> render("show.html") + |> assign(:stream, stream) + |> render(:show) + end + + defp fetch_live_stream(id) do + case Streaming.get_live_stream(id) do + nil -> {:error, :not_found} + stream -> {:ok, stream} + end end end diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex index b1dc2e7da..b4d1acde9 100644 --- a/lib/asciinema_web/templates/live_stream/show.html.eex +++ b/lib/asciinema_web/templates/live_stream/show.html.eex @@ -1,46 +1,33 @@ -
- <%= player @live_stream, [container_id: "cinema", fit: "both"] %> -
+
+ <%= link to: author_profile_path(@stream), title: author_username(@stream) do %> + <%= img_tag author_avatar_url(@stream), class: "avatar" %> + <% end %>

- Live stream test + Live stream

- by me + by <%= link author_username(@stream), to: author_profile_path(@stream) %>
- -
- -
-
-
-
-
- - - OS=??? - - SHELL=??? - - TERM=??? - - VIEWerS=livelivelive - -
-
-
-
+ diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/views/live_stream_view.ex index 7ab9a26b6..d041796a0 100644 --- a/lib/asciinema_web/views/live_stream_view.ex +++ b/lib/asciinema_web/views/live_stream_view.ex @@ -1,68 +1,40 @@ defmodule AsciinemaWeb.LiveStreamView do use AsciinemaWeb, :view - import AsciinemaWeb.RecordingView, only: [cinema_height: 1] alias AsciinemaWeb.UserView - def player(src, opts \\ []) - - def player(src, opts) when is_binary(src) do - {container_id, opts} = Keyword.pop!(opts, :container_id) - - src = %{ + def player_src(stream) do + %{ driver: "websocket", - url: src, + url: ws_consumer_url(stream), bufferTime: 1.0 } - - props = - [src: src] - |> Keyword.merge(opts) - |> Enum.into(%{}) - - props_json = - props - |> Jason.encode!() - |> String.replace(~r/', <%= {:safe, props_json} %>); - """ - end end - def player(live_stream, opts) do - opts = - Keyword.merge( - [ - cols: cols(live_stream), - rows: rows(live_stream), - theme: theme_name(live_stream) - # poster: poster(live_stream.snapshot), - ], - opts - ) + def cinema_height(stream) do + # TODO make it live - with cinema height automatically recalculated and updated - player(ws_consumer_url(live_stream), opts) + AsciinemaWeb.RecordingView.cinema_height(%{ + cols: stream.cols || 80, + rows: stream.rows || 24, + cols_override: nil, + rows_override: nil + }) end - defp cols(live_stream), do: live_stream.cols_override || live_stream.cols - - defp rows(live_stream), do: live_stream.rows_override || live_stream.rows + def author_username(stream) do + UserView.username(stream.user) + end - def theme_name(live_stream) do - live_stream.theme_name || default_theme_name(live_stream) + def author_avatar_url(stream) do + UserView.avatar_url(stream.user) end - def default_theme_name(live_stream) do - UserView.theme_name(live_stream.user) || "asciinema" + def author_profile_path(stream) do + profile_path(stream.user) end # TODO use Routes defp ws_consumer_url(live_stream) do String.replace(AsciinemaWeb.Endpoint.url() <> "/ws/s/#{live_stream.id}", ~r/^http/, "ws") end - - # TODO make it live - with cinema height automatically recalculated and updated end From a99546a6c51b9d3bb3f42f0a426ce91db773cf0a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 17:21:42 +0200 Subject: [PATCH 045/121] Adjust cinema container height on each VT reset --- assets/js/player.js | 4 ++-- .../templates/live_stream/show.html.eex | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/assets/js/player.js b/assets/js/player.js index 10163aec0..ad4e4902a 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -9,9 +9,9 @@ export function createPlayer(src, container, opts) { create(src, container, opts); }).catch(error => { console.log(`failed to load font ${opts.customTerminalFontFamily}`, error); - create(src, container, opts); + return create(src, container, opts); }); } else { - create(src, container, opts); + return create(src, container, opts); } } diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex index b4d1acde9..8015d39d5 100644 --- a/lib/asciinema_web/templates/live_stream/show.html.eex +++ b/lib/asciinema_web/templates/live_stream/show.html.eex @@ -24,10 +24,22 @@ From 5a61ede59bf052d179de34b1e29d53bec0681165 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 23 Jun 2023 18:19:57 +0200 Subject: [PATCH 046/121] Fix iframe rendering when linking public recording via private URL --- lib/asciinema_web/controllers/recording_controller.ex | 4 ++-- test/controllers/recording_controller_test.exs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 18dcea60c..63f1b8710 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -217,8 +217,8 @@ defmodule AsciinemaWeb.RecordingController do {:ok, asciicast} -> public_id = to_string(asciicast.id) - case {asciicast.private, get_format(conn), id == public_id} do - {false, "html", false} -> + case {asciicast.private, action_name(conn), get_format(conn), id == public_id} do + {false, :show, "html", false} -> conn |> redirect(to: Routes.recording_path(conn, :show, asciicast)) |> halt() diff --git a/test/controllers/recording_controller_test.exs b/test/controllers/recording_controller_test.exs index 19a2ab008..bf9679472 100644 --- a/test/controllers/recording_controller_test.exs +++ b/test/controllers/recording_controller_test.exs @@ -55,6 +55,12 @@ defmodule Asciinema.RecordingControllerTest do assert redirected_to(conn_2, 302) == "/a/#{asciicast.id}" end + test "IFRAME, public recording via secret token", %{conn: conn} do + asciicast = insert(:asciicast, private: false) + conn_2 = get(conn, "/a/#{asciicast.secret_token}/iframe") + assert html_response(conn_2, 200) =~ "createPlayer" + end + test "asciicast file, v1 format", %{conn: conn} do asciicast = fixture(:asciicast_v1) width = asciicast.cols From 95b6426c4e692e5f766d4e22ad0ff76bcff17e46 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 24 Jun 2023 14:55:12 +0200 Subject: [PATCH 047/121] Switch from old fixtures to factories in test --- test/asciinema_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/asciinema_test.exs b/test/asciinema_test.exs index 087618a08..83f74d47a 100644 --- a/test/asciinema_test.exs +++ b/test/asciinema_test.exs @@ -1,5 +1,5 @@ defmodule AsciinemaTest do - import Asciinema.Fixtures + import Asciinema.Factory use Asciinema.DataCase use Oban.Testing, repo: Asciinema.Repo alias Asciinema.Accounts @@ -26,7 +26,7 @@ defmodule AsciinemaTest do end test "existing user, by email" do - user = fixture(:user) + user = insert(:user) assert Asciinema.send_login_email(user.email, true, Routes) == :ok @@ -37,7 +37,7 @@ defmodule AsciinemaTest do end test "existing user, by username" do - user = fixture(:user) + user = insert(:user) assert Asciinema.send_login_email(user.username, true, Routes) == :ok From 583f2434d98fec7951235988c46d6f2b433080f1 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 24 Jun 2023 15:09:25 +0200 Subject: [PATCH 048/121] Fix merge_accounts wrt live streams --- lib/asciinema.ex | 1 + lib/asciinema/streaming.ex | 9 +++++++-- test/asciinema_test.exs | 15 +++++++++++++++ test/support/factory.ex | 19 +++++++++++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/asciinema.ex b/lib/asciinema.ex index ff6742ad1..f28abd76e 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -33,6 +33,7 @@ defmodule Asciinema do def merge_accounts(src_user, dst_user) do Repo.transaction(fn -> Recordings.reassign_asciicasts(src_user.id, dst_user.id) + Streaming.reassign_live_streams(src_user.id, dst_user.id) Accounts.reassign_api_tokens(src_user.id, dst_user.id) Accounts.delete_user!(src_user) Accounts.get_user(dst_user.id) diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 10ae53acc..f56536b54 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -1,8 +1,8 @@ defmodule Asciinema.Streaming do import Ecto.Changeset + import Ecto.Query alias Asciinema.Repo alias Asciinema.Streaming.LiveStream - alias Ecto.Query def find_live_stream_by_producer_token(token) do Repo.get_by(LiveStream, producer_token: token) @@ -17,7 +17,7 @@ defmodule Asciinema.Streaming do def get_live_stream(owner) do owner |> Ecto.assoc(:live_streams) - |> Query.first() + |> first() |> Repo.one() end @@ -35,5 +35,10 @@ defmodule Asciinema.Streaming do |> Repo.update!() end + def reassign_live_streams(src_user_id, dst_user_id) do + from(s in LiveStream, where: s.user_id == ^src_user_id) + |> Repo.update_all(set: [user_id: dst_user_id, updated_at: Timex.now()]) + end + defp generate_producer_token, do: Crypto.random_token(25) end diff --git a/test/asciinema_test.exs b/test/asciinema_test.exs index 83f74d47a..88d63a102 100644 --- a/test/asciinema_test.exs +++ b/test/asciinema_test.exs @@ -78,4 +78,19 @@ defmodule AsciinemaTest do refute_enqueued(worker: Asciinema.Emails.Job) end end + + describe "merge_accounts/1" do + test "succeeds" do + [user1, user2] = insert_pair(:user) + id2 = user2.id + insert(:asciicast, user: user1) + insert(:asciicast, user: user2) + insert(:api_token, user: user1) + insert(:api_token, user: user2) + insert(:live_stream, user: user1) + insert(:live_stream, user: user2) + + assert {:ok, %{id: ^id2}} = Asciinema.merge_accounts(user1, user2) + end + end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 7f0ec275b..4632d4ae8 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -1,8 +1,9 @@ defmodule Asciinema.Factory do use ExMachina.Ecto, repo: Asciinema.Repo - alias Asciinema.Accounts.User - alias Asciinema.Recordings.Asciicast + alias Asciinema.Accounts.{ApiToken, User} alias Asciinema.FileStore + alias Asciinema.Recordings.Asciicast + alias Asciinema.Streaming.LiveStream def user_factory do %User{ @@ -12,6 +13,13 @@ defmodule Asciinema.Factory do } end + def api_token_factory do + %ApiToken{ + user: build(:user), + token: sequence(:token, &"token-#{&1}") + } + end + def asciicast_factory do build(:asciicast_v2) end @@ -57,6 +65,13 @@ defmodule Asciinema.Factory do } end + def live_stream_factory do + %LiveStream{ + user: build(:user), + producer_token: sequence(:producer_token, &"token-#{&1}") + } + end + defp secret_token(n) do "sekrit-#{n}" |> String.codepoints() From b5938fc74645d5a292c2abdb81e8cc11d7091146 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 26 Jun 2023 13:01:34 +0200 Subject: [PATCH 049/121] Add libcluster, for clustering --- lib/asciinema/application.ex | 4 ++++ mix.exs | 1 + mix.lock | 1 + 3 files changed, 6 insertions(+) diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index c1a7bff80..fdf6eceb4 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -10,8 +10,12 @@ defmodule Asciinema.Application do :ok = Oban.Telemetry.attach_default_logger() :ok = Asciinema.ObanErrorReporter.configure() + topologies = Application.get_env(:libcluster, :topologies, []) + # List all child processes to be supervised children = [ + # Start cluster supervisor + {Cluster.Supervisor, [topologies, [name: Asciinema.ClusterSupervisor]]}, # Start telemetry reporters Asciinema.Telemetry, # Start the Ecto repository diff --git a/mix.exs b/mix.exs index ec1bc1e78..ba99ed543 100644 --- a/mix.exs +++ b/mix.exs @@ -56,6 +56,7 @@ defmodule Asciinema.MixProject do {:html_sanitize_ex, "~> 1.4"}, {:inflex, "~> 2.0"}, {:jason, "~> 1.2"}, + {:libcluster, "~> 3.3.3"}, {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, {:oban, "~> 2.14"}, # override for scrivener_html diff --git a/mix.lock b/mix.lock index 26445d031..5c87fd866 100644 --- a/mix.lock +++ b/mix.lock @@ -32,6 +32,7 @@ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, "mail": {:hex, :mail, "0.2.3", "2c6bb5f8a5f74845fa50ecd0fb45ea16b164026f285f45104f1c4c078cd616d4", [:mix], [], "hexpm", "932b398fa9c69fdf290d7ff63175826e0f1e24414d5b0763bb00a2acfc6c6bf5"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, From 38193b3e5fafb5f7bf2cbf1baf0a007abf68124b Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 26 Jun 2023 13:03:51 +0200 Subject: [PATCH 050/121] Disable telemetry in dev env --- config/dev.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/dev.exs b/config/dev.exs index 8a9bedca1..82e305903 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -63,6 +63,8 @@ config :phoenix, :plug_init_mode, :runtime config :asciinema, Asciinema.Emails.Mailer, adapter: Bamboo.LocalAdapter +config :asciinema, Asciinema.Telemetry, enabled: false + # Import custom config. for config <- "custom*.exs" |> Path.expand(__DIR__) |> Path.wildcard() do import_config config From b3c04c33e966b65116ddd44fef401c3889c2c6fb Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 26 Jun 2023 16:03:15 +0200 Subject: [PATCH 051/121] Use Phoenix PubSub instead of Registry for live stream event broadcast --- lib/asciinema/application.ex | 6 ---- lib/asciinema/pub_sub.ex | 9 ++++++ lib/asciinema/streaming/live_stream_server.ex | 29 +++++++++---------- 3 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 lib/asciinema/pub_sub.ex diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index fdf6eceb4..61c6833ad 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -31,12 +31,6 @@ defmodule Asciinema.Application do # Start Oban {Oban, oban_config()}, {Registry, [keys: :unique, name: Asciinema.Streaming.LiveStreamRegistry]}, - {Registry, - [ - keys: :duplicate, - name: Asciinema.Streaming.PubSubRegistry, - partitions: System.schedulers_online() - ]}, Asciinema.Streaming.LiveStreamSupervisor ] diff --git a/lib/asciinema/pub_sub.ex b/lib/asciinema/pub_sub.ex new file mode 100644 index 000000000..514f10706 --- /dev/null +++ b/lib/asciinema/pub_sub.ex @@ -0,0 +1,9 @@ +defmodule Asciinema.PubSub do + def subscribe(topic) do + :ok = Phoenix.PubSub.subscribe(__MODULE__, topic) + end + + def broadcast(topic, payload) do + :ok = Phoenix.PubSub.broadcast(__MODULE__, topic, payload) + end +end diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index 1cc90db11..03cbadb51 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -1,6 +1,6 @@ defmodule Asciinema.Streaming.LiveStreamServer do use GenServer, restart: :transient - alias Asciinema.{Streaming, Vt} + alias Asciinema.{PubSub, Streaming, Vt} require Logger # Client @@ -26,7 +26,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do end def join(stream_id) do - subscribe({:live_stream, stream_id}) + subscribe(stream_id) GenServer.cast(via_tuple(stream_id), {:join, self()}) end @@ -80,10 +80,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do :ok = Vt.feed(state.vt, vt_init) end - publish( - {:live_stream, state.stream_id}, - {:live_stream, {:reset, {vt_size, vt_init, stream_time}}} - ) + publish(state.stream_id, {:reset, {vt_size, vt_init, stream_time}}) {:reply, :ok, state} end @@ -96,7 +93,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do def handle_call({:feed, {time, data} = event}, {pid, _} = _from, %{producer: pid} = state) do :ok = Vt.feed(state.vt, data) - publish({:live_stream, state.stream_id}, {:live_stream, {:feed, event}}) + publish(state.stream_id, {:feed, event}) {:reply, :ok, %{state | last_stream_time: time, last_feed_time: Timex.now()}} end @@ -143,7 +140,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do def handle_info(:shutdown, state) do Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") - publish({:live_stream, state.stream_id}, {:live_stream, :offline}) + publish(state.stream_id, :offline) {:stop, :normal, state} end @@ -162,10 +159,16 @@ defmodule Asciinema.Streaming.LiveStreamServer do defp via_tuple(stream_id), do: {:via, Registry, {Asciinema.Streaming.LiveStreamRegistry, stream_id}} - defp subscribe(topic) do - {:ok, _} = Registry.register(Asciinema.Streaming.PubSubRegistry, topic, []) + defp subscribe(stream_id) do + PubSub.subscribe(topic_name(stream_id)) + end + + defp publish(stream_id, payload) do + PubSub.broadcast(topic_name(stream_id), {:live_stream, payload}) end + defp topic_name(stream_id), do: "stream:#{stream_id}" + defp reset_stream(state, {cols, rows} = vt_size, stream_time \\ 0.0) do {:ok, vt} = Vt.new(cols, rows) @@ -178,12 +181,6 @@ defmodule Asciinema.Streaming.LiveStreamServer do } end - defp publish(topic, payload) do - Registry.dispatch(Asciinema.Streaming.PubSubRegistry, topic, fn entries -> - for {pid, _} <- entries, do: send(pid, payload) - end) - end - defp reschedule_shutdown(state) do if state.shutdown_timer do Process.cancel_timer(state.shutdown_timer) From d1c26a4eece69dc15af08874036a73467bedd015 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 26 Jun 2023 20:50:53 +0200 Subject: [PATCH 052/121] Gracefully handle invalid UTF-8 sequences in Vt.feed/2 --- native/vt_nif/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/native/vt_nif/src/lib.rs b/native/vt_nif/src/lib.rs index c1db660f8..79a26cea7 100644 --- a/native/vt_nif/src/lib.rs +++ b/native/vt_nif/src/lib.rs @@ -1,5 +1,5 @@ use avt::Vt; -use rustler::{Atom, Encoder, Env, Error, NifResult, ResourceArc, Term}; +use rustler::{Atom, Encoder, Env, Error, NifResult, ResourceArc, Term, Binary}; use std::sync::RwLock; mod atoms { @@ -35,9 +35,9 @@ fn new(w: usize, h: usize) -> NifResult<(Atom, ResourceArc)> { } #[rustler::nif] -fn feed(resource: ResourceArc, input: &str) -> NifResult { +fn feed(resource: ResourceArc, input: Binary) -> NifResult { let mut vt = convert_err(resource.vt.write(), "rw_lock")?; - vt.feed_str(input); + vt.feed_str(&String::from_utf8_lossy(&input)); Ok(atoms::ok()) } From ec0ce177e1bb305ea3454e53b574c4a40e744516 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 26 Jun 2023 20:52:58 +0200 Subject: [PATCH 053/121] Use Horde as name registry for live streams --- lib/asciinema/application.ex | 3 ++- lib/asciinema/streaming/live_stream_server.ex | 2 +- mix.exs | 1 + mix.lock | 4 ++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index 61c6833ad..6f1acbfe1 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -30,7 +30,8 @@ defmodule Asciinema.Application do :poolboy.child_spec(:worker, Asciinema.PngGenerator.Rsvg.poolboy_config(), []), # Start Oban {Oban, oban_config()}, - {Registry, [keys: :unique, name: Asciinema.Streaming.LiveStreamRegistry]}, + {Horde.Registry, + [name: Asciinema.Streaming.LiveStreamRegistry, keys: :unique, members: :auto]}, Asciinema.Streaming.LiveStreamSupervisor ] diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index 03cbadb51..f32202c09 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -157,7 +157,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do # Private defp via_tuple(stream_id), - do: {:via, Registry, {Asciinema.Streaming.LiveStreamRegistry, stream_id}} + do: {:via, Horde.Registry, {Asciinema.Streaming.LiveStreamRegistry, stream_id}} defp subscribe(stream_id) do PubSub.subscribe(topic_name(stream_id)) diff --git a/mix.exs b/mix.exs index ba99ed543..b85c3fb4b 100644 --- a/mix.exs +++ b/mix.exs @@ -53,6 +53,7 @@ defmodule Asciinema.MixProject do {:ex_machina, "~> 2.4", only: :test}, {:gettext, "~> 0.18"}, {:hackney, "~> 1.18"}, + {:horde, "~> 0.8.7"}, {:html_sanitize_ex, "~> 1.4"}, {:inflex, "~> 2.0"}, {:jason, "~> 1.2"}, diff --git a/mix.lock b/mix.lock index 5c87fd866..9c9b2b8f0 100644 --- a/mix.lock +++ b/mix.lock @@ -15,6 +15,7 @@ "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "delta_crdt": {:hex, :delta_crdt, "0.6.4", "79d235eef82a58bb0cb668bc5b9558d2e65325ccb46b74045f20b36fd41671da", [:mix], [{:merkle_map, "~> 0.2.0", [hex: :merkle_map, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4a81f579c06aeeb625db54c6c109859a38aa00d837e3e7f8ac27b40cea34885a"}, "earmark": {:hex, :earmark, "1.4.36", "e8d057edc0c762ddd7928502f33d1c98705338963e3f9200af4ca8379994779c", [:mix], [{:earmark_parser, "~> 1.4.30", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "a71f2457c8ac1877b0af43a1093fc57f7cc387015eb503306ce83771f50cb74d"}, "earmark_parser": {:hex, :earmark_parser, "1.4.30", "0b938aa5b9bafd455056440cdaa2a79197ca5e693830b4a982beada840513c5f", [:mix], [], "hexpm", "3b5385c2d36b0473d0b206927b841343d25adb14f95f0110062506b300cd5a1b"}, "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, @@ -27,13 +28,16 @@ "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gettext": {:hex, :gettext, "0.22.0", "a25d71ec21b1848957d9207b81fd61cb25161688d282d58bdafef74c2270bdc4", [:mix], [{:expo, "~> 0.3.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "cb0675141576f73720c8e49b4f0fd3f2c69f0cd8c218202724d4aebab8c70ace"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "horde": {:hex, :horde, "0.8.7", "e51ab8e0e5bc7dcd0caa85d84b144cccfde97994bd865d822c7e489746b87e7f", [:mix], [{:delta_crdt, "~> 0.6.2", [hex: :delta_crdt, repo: "hexpm", optional: false]}, {:libring, "~> 1.4", [hex: :libring, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 0.5.0 or ~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "835aede887d777542f85e0a88293c18113abcc1356006050ec216da16aa5e0e3"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.2", "c479398b6de798c03eb5d04a0a9a9159d73508f83f6590a00b8eacba3619cf4c", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "aef6c28585d06a9109ad591507e508854c5559561f950bbaea773900dd369b0e"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "libcluster": {:hex, :libcluster, "3.3.3", "a4f17721a19004cfc4467268e17cff8b1f951befe428975dd4f6f7b84d927fe0", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7c0a2275a0bb83c07acd17dab3c3bfb4897b145106750eeccc62d302e3bdfee5"}, + "libring": {:hex, :libring, "1.6.0", "d5dca4bcb1765f862ab59f175b403e356dec493f565670e0bacc4b35e109ce0d", [:mix], [], "hexpm", "5e91ece396af4bce99953d49ee0b02f698cd38326d93cd068361038167484319"}, "mail": {:hex, :mail, "0.2.3", "2c6bb5f8a5f74845fa50ecd0fb45ea16b164026f285f45104f1c4c078cd616d4", [:mix], [], "hexpm", "932b398fa9c69fdf290d7ff63175826e0f1e24414d5b0763bb00a2acfc6c6bf5"}, + "merkle_map": {:hex, :merkle_map, "0.2.1", "01a88c87a6b9fb594c67c17ebaf047ee55ffa34e74297aa583ed87148006c4c8", [:mix], [], "hexpm", "fed4d143a5c8166eee4fa2b49564f3c4eace9cb252f0a82c1613bba905b2d04d"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, From 662b2098660dfcf5edc82a8cc652740f415bcfab Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 27 Jun 2023 11:08:58 +0200 Subject: [PATCH 054/121] Wording --- lib/asciinema/gc.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/asciinema/gc.ex b/lib/asciinema/gc.ex index 5dd912c75..603efa291 100644 --- a/lib/asciinema/gc.ex +++ b/lib/asciinema/gc.ex @@ -7,10 +7,10 @@ defmodule Asciinema.GC do @impl Oban.Worker def perform(_job) do if days = Recordings.gc_days() do - Logger.info("archiving unclaimed Recordings...") + Logger.info("archiving unclaimed recordings...") dt = Timex.shift(Timex.now(), days: -days) count = Recordings.archive_asciicasts(Accounts.temporary_users(), dt) - Logger.info("archived #{count} asciicasts") + Logger.info("archived #{count} recordings") :ok else From d1b819f5f02d0a039fd210ebd4ec101362e6b8b5 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 28 Jun 2023 16:12:12 +0200 Subject: [PATCH 055/121] Remove unnecessary function guard --- lib/asciinema/streaming/live_stream_server.ex | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index f32202c09..c92f7ed7e 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -117,17 +117,13 @@ defmodule Asciinema.Streaming.LiveStreamServer do end @impl true - def handle_cast({:join, pid}, %{vt_size: vt_size} = state) when not is_nil(vt_size) do + def handle_cast({:join, pid}, %{vt_size: vt_size} = state) do stream_time = current_stream_time(state.last_stream_time, state.last_feed_time) send(pid, {:live_stream, {:reset, {vt_size, Vt.dump(state.vt), stream_time}}}) {:noreply, state} end - def handle_cast({:join, _pid}, state) do - {:noreply, state} - end - @update_stream_interval 10_000 @impl true From 06591f90798f9d04a91238a5b708a4989a01403a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 28 Jun 2023 20:26:35 +0200 Subject: [PATCH 056/121] Implement ALiS producer handler --- lib/asciinema/streaming/producer_handler.ex | 1 + .../streaming/producer_handler/alis.ex | 53 +++++++++++++++++++ .../live_stream_producer_socket.ex | 11 ++++ 3 files changed, 65 insertions(+) create mode 100644 lib/asciinema/streaming/producer_handler/alis.ex diff --git a/lib/asciinema/streaming/producer_handler.ex b/lib/asciinema/streaming/producer_handler.ex index e52ed8170..17247792d 100644 --- a/lib/asciinema/streaming/producer_handler.ex +++ b/lib/asciinema/streaming/producer_handler.ex @@ -6,5 +6,6 @@ defmodule Asciinema.Streaming.ProducerHandler do alias Asciinema.Streaming.ProducerHandler def get(:raw), do: %{impl: ProducerHandler.Raw, state: ProducerHandler.Raw.init()} + def get(:alis), do: %{impl: ProducerHandler.Alis, state: ProducerHandler.Alis.init()} def get(:json), do: %{impl: ProducerHandler.Json, state: ProducerHandler.Json.init()} end diff --git a/lib/asciinema/streaming/producer_handler/alis.ex b/lib/asciinema/streaming/producer_handler/alis.ex new file mode 100644 index 000000000..4d8755a38 --- /dev/null +++ b/lib/asciinema/streaming/producer_handler/alis.ex @@ -0,0 +1,53 @@ +defmodule Asciinema.Streaming.ProducerHandler.Alis do + @behaviour Asciinema.Streaming.ProducerHandler + + def init, do: %{first: true} + + def parse({"ALiS\x01\x00\x00\x00\x00\x00", _opts}, %{first: true} = state) do + {:ok, [], %{state | first: false}} + end + + def parse({"ALiS" <> rest, _opts}, %{first: true}) do + {:error, "unsupported ALiS version/compression: #{inspect(rest)}"} + end + + def parse( + { + << + 0x01, + cols::little-16, + rows::little-16, + time::little-float-32, + init_len::little-32, + init::binary-size(init_len) + >>, + _opts + }, + state + ) do + {:ok, [reset: %{size: {cols, rows}, init: init, time: time}], state} + end + + def parse( + { + << + ?o, + time::little-float-32, + data_len::little-32, + data::binary-size(data_len) + >>, + _opts + }, + state + ) do + {:ok, [feed: {time, data}], state} + end + + def parse({<<0x04>>, _opts}, state) do + {:ok, [offline: true], state} + end + + def parse({_payload, _opts}, _state) do + {:error, :message_invalid} + end +end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index cbd01aff5..2f645e37f 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -60,11 +60,18 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end @impl true + def handle_in({"ALiS" <> _, [opcode: :binary]} = message, %{handler: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating ALiS handler") + handle_in(message, %{state | handler: ProducerHandler.get(:alis)}) + end + def handle_in({_, [opcode: :binary]} = message, %{handler: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating raw text handler") handle_in(message, %{state | handler: ProducerHandler.get(:raw)}) end def handle_in({_, [opcode: :text]} = message, %{handler: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating json handler") handle_in(message, %{state | handler: ProducerHandler.get(:json)}) end @@ -130,6 +137,10 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do LiveStreamServer.feed(stream_id, {time, data}) end + defp run_command({:offline, true}, _stream_id) do + :ok + end + @impl true def handle_info(:ping, state) do Process.send_after(self(), :ping, @ping_interval) From eea50ef51a8748a7fdce678c144a21651b23bd29 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 13:44:16 +0200 Subject: [PATCH 057/121] Automatically start/stop live stream server when producer reports online/offline --- lib/asciinema/streaming/live_stream_server.ex | 10 +-- .../live_stream_producer_socket.ex | 79 +++++++++++++------ 2 files changed, 59 insertions(+), 30 deletions(-) diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index c92f7ed7e..d7de9773f 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -30,7 +30,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do GenServer.cast(via_tuple(stream_id), {:join, self()}) end - def stop(stream_id), do: GenServer.stop(via_tuple(stream_id)) + def stop(stream_id, reason \\ :normal), do: GenServer.stop(via_tuple(stream_id), reason) # Callbacks @@ -136,16 +136,16 @@ defmodule Asciinema.Streaming.LiveStreamServer do def handle_info(:shutdown, state) do Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") - publish(state.stream_id, :offline) {:stop, :normal, state} end @impl true def terminate(reason, state) do - Logger.info( - "stream/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" - ) + Logger.info("stream/#{state.stream_id}: terminating (#{inspect(reason)})") + Logger.debug("stream/#{state.stream_id}: state: #{inspect(state)}") + + publish(state.stream_id, {:status, :offline}) :ok end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 2f645e37f..61f25f7e8 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -33,6 +33,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do live_stream -> state = %{ stream_id: live_stream.id, + status: :new, handler: nil, bucket: %{ size: config(:bucket_size, @default_bucket_size), @@ -49,12 +50,10 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def init(state) do Logger.info("producer/#{state.stream_id}: connected") - {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) - :ok = LiveStreamServer.lead(state.stream_id) Process.send_after(self(), :handler_timeout, @handler_timeout) Process.send_after(self(), :ping, @ping_interval) Process.send_after(self(), :fill_bucket, state.bucket.fill_interval) - send(self(), :heartbeat) + Process.send_after(self(), :heartbeat, @heartbeat_interval) {:ok, state} end @@ -77,7 +76,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_in({payload, _} = message, %{handler: handler} = state) do with {:ok, commands, new_handler_state} <- run_handler(handler, message), - :ok <- run_commands(commands, state.stream_id), + {:ok, state} <- run_commands(commands, state), {:ok, state} <- drain_bucket(state, byte_size(payload)) do {:ok, put_in(state, [:handler, :state], new_handler_state)} else @@ -111,10 +110,10 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end end - defp run_commands(commands, stream_id) do - Enum.reduce(commands, :ok, fn command, prev_result -> - with :ok <- prev_result do - run_command(command, stream_id) + defp run_commands(commands, state) do + Enum.reduce(commands, {:ok, state}, fn command, prev_result -> + with {:ok, state} <- prev_result do + run_command(command, state) end end) end @@ -122,23 +121,47 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @max_cols 720 @max_rows 200 - defp run_command({:reset, %{size: {cols, rows}, init: init, time: time}}, stream_id) + defp run_command({:reset, %{size: {cols, rows}, init: init, time: time}}, state) when cols > 0 and rows > 0 and cols <= @max_cols and rows <= @max_rows do - Logger.info("producer/#{stream_id}: reset (#{cols}x#{rows})") + Logger.info("producer/#{state.stream_id}: reset (#{cols}x#{rows})") - LiveStreamServer.reset(stream_id, {cols, rows}, init, time) + state = ensure_server(state) + + with :ok <- LiveStreamServer.reset(state.stream_id, {cols, rows}, init, time) do + {:ok, state} + end end - defp run_command({:reset, %{size: {cols, rows}}}, _stream_id) do + defp run_command({:reset, %{size: {cols, rows}}}, _state) do {:error, {:invalid_vt_size, {cols, rows}}} end - defp run_command({:feed, {time, data}}, stream_id) do - LiveStreamServer.feed(stream_id, {time, data}) + defp run_command({:feed, {time, data}}, %{status: :online} = state) do + with :ok <- LiveStreamServer.feed(state.stream_id, {time, data}) do + {:ok, state} + end end - defp run_command({:offline, true}, _stream_id) do - :ok + defp run_command({:status, :offline}, %{status: :new} = state) do + {:ok, state} + end + + defp run_command({:status, :offline}, %{status: :online} = state) do + Logger.info("producer/#{state.stream_id}: stream went offline, stopping server") + LiveStreamServer.stop(state.stream_id) + + {:ok, %{state | status: :offline}} + end + + defp ensure_server(%{status: :online} = state), do: state + + defp ensure_server(state) do + Logger.info("producer/#{state.stream_id}: stream went online, starting server") + {:ok, _pid} = LiveStreamSupervisor.ensure_child(state.stream_id) + :ok = LiveStreamServer.lead(state.stream_id) + Process.send_after(self(), :heartbeat, @heartbeat_interval) + + %{state | status: :online} end @impl true @@ -151,15 +174,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do def handle_info(:heartbeat, state) do Process.send_after(self(), :heartbeat, @heartbeat_interval) - case LiveStreamServer.heartbeat(state.stream_id) do - :ok -> - {:ok, state} - - {:error, :not_a_leader} -> - Logger.info("producer/#{state.stream_id}: stream taken over by another producer") - - {:stop, :normal, state} - end + send_heartbeat(state) end def handle_info(:handler_timeout, %{handler: nil} = state) do @@ -182,6 +197,20 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do {:ok, put_in(state, [:bucket, :tokens], tokens)} end + defp send_heartbeat(%{status: :online} = state) do + case LiveStreamServer.heartbeat(state.stream_id) do + :ok -> + {:ok, state} + + {:error, :not_a_leader} -> + Logger.info("producer/#{state.stream_id}: stream taken over by another producer") + + {:stop, :normal, state} + end + end + + defp send_heartbeat(state), do: {:ok, state} + @impl true def terminate(reason, state) do Logger.info( From 8696a9b7f560e3960877d5fc99b008e96e1ad6be Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 13:45:12 +0200 Subject: [PATCH 058/121] Improve logging in live stream sockets --- lib/asciinema_web/live_stream_consumer_socket.ex | 5 ++--- lib/asciinema_web/live_stream_producer_socket.ex | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/asciinema_web/live_stream_consumer_socket.ex b/lib/asciinema_web/live_stream_consumer_socket.ex index f6d6c6d91..61a23d62a 100644 --- a/lib/asciinema_web/live_stream_consumer_socket.ex +++ b/lib/asciinema_web/live_stream_consumer_socket.ex @@ -86,9 +86,8 @@ defmodule AsciinemaWeb.LiveStreamConsumerSocket do @impl true def terminate(reason, state) do - Logger.info( - "consumer/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" - ) + Logger.info("consumer/#{state.stream_id}: terminating (#{inspect(reason)})") + Logger.debug("consumer/#{state.stream_id}: state: #{inspect(state)}") :ok end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 61f25f7e8..2d2a79cc1 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -213,9 +213,8 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def terminate(reason, state) do - Logger.info( - "producer/#{state.stream_id}: terminating | reason: #{inspect(reason)}, state: #{inspect(state)}" - ) + Logger.info("producer/#{state.stream_id}: terminating (#{inspect(reason)})") + Logger.debug("producer/#{state.stream_id}: state: #{inspect(state)}") :ok end From ba4c88777e598fd728cd97328b072bb13f2d5967 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 13:46:46 +0200 Subject: [PATCH 059/121] Stricter parsing of live stream producer messages --- .../streaming/producer_handler/alis.ex | 23 ++++++++++--------- .../streaming/producer_handler/json.ex | 14 ++++++++--- .../streaming/producer_handler/raw.ex | 18 +++++---------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/lib/asciinema/streaming/producer_handler/alis.ex b/lib/asciinema/streaming/producer_handler/alis.ex index 4d8755a38..94e2f140a 100644 --- a/lib/asciinema/streaming/producer_handler/alis.ex +++ b/lib/asciinema/streaming/producer_handler/alis.ex @@ -1,14 +1,14 @@ defmodule Asciinema.Streaming.ProducerHandler.Alis do @behaviour Asciinema.Streaming.ProducerHandler - def init, do: %{first: true} + def init, do: %{status: :new} - def parse({"ALiS\x01\x00\x00\x00\x00\x00", _opts}, %{first: true} = state) do - {:ok, [], %{state | first: false}} + def parse({"ALiS\x01\x00\x00\x00\x00\x00", _opts}, %{status: :new} = state) do + {:ok, [], %{state | status: :init}} end - def parse({"ALiS" <> rest, _opts}, %{first: true}) do - {:error, "unsupported ALiS version/compression: #{inspect(rest)}"} + def parse({"ALiS" <> rest, _opts}, %{status: :new}) do + {:error, "unsupported ALiS version/configuration: #{inspect(rest)}"} end def parse( @@ -23,9 +23,10 @@ defmodule Asciinema.Streaming.ProducerHandler.Alis do >>, _opts }, - state - ) do - {:ok, [reset: %{size: {cols, rows}, init: init, time: time}], state} + %{status: status} = state + ) + when status in [:init, :offline] do + {:ok, [reset: %{size: {cols, rows}, init: init, time: time}], %{state | status: :online}} end def parse( @@ -38,13 +39,13 @@ defmodule Asciinema.Streaming.ProducerHandler.Alis do >>, _opts }, - state + %{status: :online} = state ) do {:ok, [feed: {time, data}], state} end - def parse({<<0x04>>, _opts}, state) do - {:ok, [offline: true], state} + def parse({<<0x04>>, _opts}, %{status: status} = state) when status in [:init, :online] do + {:ok, [status: :offline], %{state | status: :offline}} end def parse({_payload, _opts}, _state) do diff --git a/lib/asciinema/streaming/producer_handler/json.ex b/lib/asciinema/streaming/producer_handler/json.ex index 46c5b89ea..e05c21278 100644 --- a/lib/asciinema/streaming/producer_handler/json.ex +++ b/lib/asciinema/streaming/producer_handler/json.ex @@ -1,7 +1,7 @@ defmodule Asciinema.Streaming.ProducerHandler.Json do @behaviour Asciinema.Streaming.ProducerHandler - def init, do: %{} + def init, do: %{first: true} def parse({"\n", _opts}, state), do: {:ok, [], state} @@ -17,12 +17,20 @@ defmodule Asciinema.Streaming.ProducerHandler.Json do def handle_message(%{"cols" => cols, "rows" => rows} = header, state) when is_integer(cols) and is_integer(rows) do - {:ok, [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}], state} + commands = [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}] + + {:ok, commands, %{state | first: false}} end def handle_message(%{"width" => cols, "height" => rows}, state) when is_integer(cols) and is_integer(rows) do - {:ok, [reset: %{size: {cols, rows}, init: nil, time: nil}], state} + commands = [reset: %{size: {cols, rows}, init: nil, time: nil}] + + {:ok, commands, %{state | first: false}} + end + + def handle_message(_message, %{first: true}) do + {:error, :reset_expected} end def handle_message([time, "o", data], state) when is_number(time) and is_binary(data) do diff --git a/lib/asciinema/streaming/producer_handler/raw.ex b/lib/asciinema/streaming/producer_handler/raw.ex index 8b090167c..6001d4d0c 100644 --- a/lib/asciinema/streaming/producer_handler/raw.ex +++ b/lib/asciinema/streaming/producer_handler/raw.ex @@ -1,23 +1,17 @@ defmodule Asciinema.Streaming.ProducerHandler.Raw do @behaviour Asciinema.Streaming.ProducerHandler + @default_size {80, 24} + def init, do: %{first: true, start_time: nil} def parse({payload, _}, %{first: true} = state) do - size = size_from_resize_seq(payload) || size_from_script_start_message(payload) - - commands = - case size do - nil -> - [feed: {0.0, payload}] - - size -> - [reset: %{size: size, init: payload, time: 0.0}] - end + size = + size_from_resize_seq(payload) || size_from_script_start_message(payload) || @default_size - state = %{state | first: false, start_time: Timex.now()} + commands = [reset: %{size: size, init: payload, time: 0.0}] - {:ok, commands, state} + {:ok, commands, %{state | first: false, start_time: Timex.now()}} end def parse({payload, _}, state) do From 7a42738ff673f12509b847278330a4bffafe662b Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 15:52:47 +0200 Subject: [PATCH 060/121] Track online status of live streams in db --- config/config.exs | 3 ++- lib/asciinema/streaming.ex | 25 ++++++++++++++++--- lib/asciinema/streaming/gc.ex | 16 ++++++++++++ lib/asciinema/streaming/live_stream.ex | 1 + lib/asciinema/streaming/live_stream_server.ex | 6 ++++- ...30701115122_add_online_to_live_streams.exs | 11 ++++++++ 6 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 lib/asciinema/streaming/gc.ex create mode 100644 priv/repo/migrations/20230701115122_add_online_to_live_streams.exs diff --git a/config/config.exs b/config/config.exs index 408723ace..24f70b1a0 100644 --- a/config/config.exs +++ b/config/config.exs @@ -55,7 +55,8 @@ config :asciinema, Oban, {Oban.Plugins.Pruner, max_age: 604_800}, {Oban.Plugins.Cron, crontab: [ - {"0 * * * *", Asciinema.GC} + {"0 * * * *", Asciinema.GC}, + {"* * * * *", Asciinema.Streaming.GC} ]}, Oban.Plugins.Lifeline, Oban.Plugins.Reindexer diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index f56536b54..590956aaa 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -28,17 +28,36 @@ defmodule Asciinema.Streaming do |> Repo.insert!() end - def update_live_stream(stream, {cols, rows}) do + def update_live_stream(stream, attrs) when is_list(attrs) do stream - |> cast(%{last_activity_at: Timex.now()}, [:last_activity_at]) - |> change(cols: cols, rows: rows) + |> change(attrs) + |> change_last_activity() |> Repo.update!() end + defp change_last_activity(changeset) do + case fetch_field!(changeset, :online) do + true -> + cast(changeset, %{last_activity_at: Timex.now()}, [:last_activity_at]) + + false -> + changeset + end + end + def reassign_live_streams(src_user_id, dst_user_id) do from(s in LiveStream, where: s.user_id == ^src_user_id) |> Repo.update_all(set: [user_id: dst_user_id, updated_at: Timex.now()]) end + def mark_inactive_live_streams_offline do + t = Timex.shift(Timex.now(), minutes: -1) + q = from(s in LiveStream, where: s.online and s.last_activity_at < ^t) + + {count, _} = Repo.update_all(q, set: [online: false]) + + count + end + defp generate_producer_token, do: Crypto.random_token(25) end diff --git a/lib/asciinema/streaming/gc.ex b/lib/asciinema/streaming/gc.ex new file mode 100644 index 000000000..002fc3fb2 --- /dev/null +++ b/lib/asciinema/streaming/gc.ex @@ -0,0 +1,16 @@ +defmodule Asciinema.Streaming.GC do + use Oban.Worker + alias Asciinema.Streaming + require Logger + + @impl Oban.Worker + def perform(_job) do + count = Streaming.mark_inactive_live_streams_offline() + + if count > 0 do + Logger.info("marked #{count} streams offline") + end + + :ok + end +end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex index d2e8d447c..3cf1ded8e 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -5,6 +5,7 @@ defmodule Asciinema.Streaming.LiveStream do field :producer_token, :string field :cols, :integer field :rows, :integer + field :online, :boolean field :last_activity_at, :naive_datetime timestamps() diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index d7de9773f..eb7e39105 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -129,7 +129,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do @impl true def handle_info(:update_stream, state) do Process.send_after(self(), :update_stream, @update_stream_interval) - stream = Streaming.update_live_stream(state.stream, state.vt_size) + stream = Streaming.update_live_stream(state.stream, []) {:noreply, %{state | stream: stream}} end @@ -146,6 +146,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do Logger.debug("stream/#{state.stream_id}: state: #{inspect(state)}") publish(state.stream_id, {:status, :offline}) + Streaming.update_live_stream(state.stream, online: false) :ok end @@ -168,10 +169,13 @@ defmodule Asciinema.Streaming.LiveStreamServer do defp reset_stream(state, {cols, rows} = vt_size, stream_time \\ 0.0) do {:ok, vt} = Vt.new(cols, rows) + stream = Streaming.update_live_stream(state.stream, online: true, cols: cols, rows: rows) + %{ state | vt: vt, vt_size: vt_size, + stream: stream, last_stream_time: stream_time, last_feed_time: Timex.now() } diff --git a/priv/repo/migrations/20230701115122_add_online_to_live_streams.exs b/priv/repo/migrations/20230701115122_add_online_to_live_streams.exs new file mode 100644 index 000000000..5d1af3344 --- /dev/null +++ b/priv/repo/migrations/20230701115122_add_online_to_live_streams.exs @@ -0,0 +1,11 @@ +defmodule Asciinema.Repo.Migrations.AddOnlineToLiveStreams do + use Ecto.Migration + + def change do + alter table(:live_streams) do + add :online, :boolean, null: false, default: false + end + + create index(:live_streams, [:online]) + end +end From 23422177871ed2fd24573c0d489df94e572e2840 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 16:09:27 +0200 Subject: [PATCH 061/121] Move unclaimed recording archival entrypoint to root module --- lib/asciinema.ex | 9 +++++++++ lib/asciinema/gc.ex | 13 ++++++------- lib/asciinema/recordings.ex | 9 +++------ .../controllers/recording_controller.ex | 2 +- .../templates/recording/archived.html.eex | 2 +- lib/asciinema_web/views/api/recording_view.ex | 2 +- lib/asciinema_web/views/recording_view.ex | 4 +--- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/lib/asciinema.ex b/lib/asciinema.ex index f28abd76e..8390db369 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -41,4 +41,13 @@ defmodule Asciinema do end defdelegate get_live_stream(id_or_owner), to: Streaming + + def recording_gc_days do + Application.get_env(:asciinema, :asciicast_gc_days) + end + + def archive_unclaimed_recordings(days) do + t = Timex.shift(Timex.now(), days: -days) + Recordings.archive_asciicasts(Accounts.temporary_users(), t) + end end diff --git a/lib/asciinema/gc.ex b/lib/asciinema/gc.ex index 603efa291..e10c2a392 100644 --- a/lib/asciinema/gc.ex +++ b/lib/asciinema/gc.ex @@ -1,16 +1,15 @@ defmodule Asciinema.GC do use Oban.Worker - alias Asciinema.Accounts - alias Asciinema.Recordings require Logger @impl Oban.Worker def perform(_job) do - if days = Recordings.gc_days() do - Logger.info("archiving unclaimed recordings...") - dt = Timex.shift(Timex.now(), days: -days) - count = Recordings.archive_asciicasts(Accounts.temporary_users(), dt) - Logger.info("archived #{count} recordings") + if days = Asciinema.recording_gc_days() do + count = Asciinema.archive_unclaimed_recordings(days) + + if count > 0 do + Logger.info("archived #{count} recordings") + end :ok else diff --git a/lib/asciinema/recordings.ex b/lib/asciinema/recordings.ex index 761e8cdec..e2931f1a2 100644 --- a/lib/asciinema/recordings.ex +++ b/lib/asciinema/recordings.ex @@ -652,18 +652,15 @@ defmodule Asciinema.Recordings do tmp_path end - def gc_days do - Application.get_env(:asciinema, :asciicast_gc_days) - end - - def archive_asciicasts(users_query, dt) do + def archive_asciicasts(users_query, t) do query = from a in Asciicast, join: u in ^users_query, on: a.user_id == u.id, - where: a.archivable and is_nil(a.archived_at) and a.inserted_at < ^dt + where: a.archivable and is_nil(a.archived_at) and a.inserted_at < ^t {count, _} = Repo.update_all(query, set: [archived_at: Timex.now()]) + count end diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 63f1b8710..4a2746057 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -274,7 +274,7 @@ defmodule AsciinemaWeb.RecordingController do defp put_archival_info_flash(conn, asciicast) do with true <- asciicast.archivable, - days when not is_nil(days) <- Recordings.gc_days(), + days when not is_nil(days) <- Asciinema.recording_gc_days(), %{} = user <- asciicast.user, true <- Accounts.temporary_user?(user), true <- Timex.before?(asciicast.inserted_at, Timex.shift(Timex.now(), days: -days)) do diff --git a/lib/asciinema_web/templates/recording/archived.html.eex b/lib/asciinema_web/templates/recording/archived.html.eex index 8d7f154ca..094520f05 100644 --- a/lib/asciinema_web/templates/recording/archived.html.eex +++ b/lib/asciinema_web/templates/recording/archived.html.eex @@ -3,7 +3,7 @@

This recording has been archived

- <%= if days = asciicast_gc_days() do %> + <%= if days = recording_gc_days() do %>

All unclaimed recordings (the ones not linked to any user account) are automatically archived <%= days %> days after upload. diff --git a/lib/asciinema_web/views/api/recording_view.ex b/lib/asciinema_web/views/api/recording_view.ex index b4a45d5c1..cef377103 100644 --- a/lib/asciinema_web/views/api/recording_view.ex +++ b/lib/asciinema_web/views/api/recording_view.ex @@ -24,7 +24,7 @@ defmodule AsciinemaWeb.Api.RecordingView do """ is_tmp_user = Asciinema.Accounts.temporary_user?(conn.assigns.current_user) - gc_days = Asciinema.Recordings.gc_days() + gc_days = Asciinema.recording_gc_days() if is_tmp_user && gc_days do hostname = AsciinemaWeb.instance_hostname() diff --git a/lib/asciinema_web/views/recording_view.ex b/lib/asciinema_web/views/recording_view.ex index 96c260342..10998cdcc 100644 --- a/lib/asciinema_web/views/recording_view.ex +++ b/lib/asciinema_web/views/recording_view.ex @@ -546,9 +546,7 @@ defmodule AsciinemaWeb.RecordingView do "#{Decimal.round(Decimal.from_float(float), 3)}%" end - def asciicast_gc_days do - Recordings.gc_days() - end + def recording_gc_days, do: Asciinema.recording_gc_days() def terminal_font_family_options do for family <- Recordings.custom_terminal_font_families() do From 8fc5fd91243756dc638c6ca2f0e30ad173ac3a00 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 16:35:35 +0200 Subject: [PATCH 062/121] Fix offline publishing on consumer socket termination --- lib/asciinema/streaming/live_stream_server.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index eb7e39105..565b81550 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -145,7 +145,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do Logger.info("stream/#{state.stream_id}: terminating (#{inspect(reason)})") Logger.debug("stream/#{state.stream_id}: state: #{inspect(state)}") - publish(state.stream_id, {:status, :offline}) + publish(state.stream_id, :offline) Streaming.update_live_stream(state.stream, online: false) :ok From 0c7db0bfaa680e9051c139f2baafd0d090d750df Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 1 Jul 2023 21:18:31 +0200 Subject: [PATCH 063/121] Display 7 rows of thumbnails on index pages --- lib/asciinema_web/controllers/recording_controller.ex | 2 +- lib/asciinema_web/controllers/user_controller.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 4a2746057..76084b05d 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -12,7 +12,7 @@ defmodule AsciinemaWeb.RecordingController do category = params[:category] order = if params["order"] == "popularity", do: :popularity, else: :date - page = Recordings.paginate_asciicasts(category, order, params["page"], 12) + page = Recordings.paginate_asciicasts(category, order, params["page"], 14) assigns = [ page_title: String.capitalize("#{category} recordings"), diff --git a/lib/asciinema_web/controllers/user_controller.ex b/lib/asciinema_web/controllers/user_controller.ex index 1ba7cc2cc..a0fa68a0b 100644 --- a/lib/asciinema_web/controllers/user_controller.ex +++ b/lib/asciinema_web/controllers/user_controller.ex @@ -67,7 +67,7 @@ defmodule AsciinemaWeb.UserController do {user.id, filter}, :date, params["page"], - 15 + 14 ) conn From a48614db7984cf9c0f3f18d18dec39947d1d0ee9 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 2 Jul 2023 23:03:41 +0200 Subject: [PATCH 064/121] Rename Streaming.ProducerHandler -> Streaming.Parser --- lib/asciinema/streaming/parser.ex | 11 +++++ .../{producer_handler => parser}/alis.ex | 4 +- .../{producer_handler => parser}/json.ex | 4 +- .../{producer_handler => parser}/raw.ex | 4 +- lib/asciinema/streaming/producer_handler.ex | 11 ----- .../live_stream_producer_socket.ex | 48 +++++++++---------- 6 files changed, 41 insertions(+), 41 deletions(-) create mode 100644 lib/asciinema/streaming/parser.ex rename lib/asciinema/streaming/{producer_handler => parser}/alis.ex (92%) rename lib/asciinema/streaming/{producer_handler => parser}/json.ex (92%) rename lib/asciinema/streaming/{producer_handler => parser}/raw.ex (90%) delete mode 100644 lib/asciinema/streaming/producer_handler.ex diff --git a/lib/asciinema/streaming/parser.ex b/lib/asciinema/streaming/parser.ex new file mode 100644 index 000000000..9d61fb098 --- /dev/null +++ b/lib/asciinema/streaming/parser.ex @@ -0,0 +1,11 @@ +defmodule Asciinema.Streaming.Parser do + @callback init() :: term + @callback parse({message :: term, opts :: keyword}, term) :: + {:ok, [{atom, term}], term} | {:error, term} + + alias Asciinema.Streaming.Parser + + def get(:raw), do: %{impl: Parser.Raw, state: Parser.Raw.init()} + def get(:alis), do: %{impl: Parser.Alis, state: Parser.Alis.init()} + def get(:json), do: %{impl: Parser.Json, state: Parser.Json.init()} +end diff --git a/lib/asciinema/streaming/producer_handler/alis.ex b/lib/asciinema/streaming/parser/alis.ex similarity index 92% rename from lib/asciinema/streaming/producer_handler/alis.ex rename to lib/asciinema/streaming/parser/alis.ex index 94e2f140a..a7b65c0fa 100644 --- a/lib/asciinema/streaming/producer_handler/alis.ex +++ b/lib/asciinema/streaming/parser/alis.ex @@ -1,5 +1,5 @@ -defmodule Asciinema.Streaming.ProducerHandler.Alis do - @behaviour Asciinema.Streaming.ProducerHandler +defmodule Asciinema.Streaming.Parser.Alis do + @behaviour Asciinema.Streaming.Parser def init, do: %{status: :new} diff --git a/lib/asciinema/streaming/producer_handler/json.ex b/lib/asciinema/streaming/parser/json.ex similarity index 92% rename from lib/asciinema/streaming/producer_handler/json.ex rename to lib/asciinema/streaming/parser/json.ex index e05c21278..b0ad75969 100644 --- a/lib/asciinema/streaming/producer_handler/json.ex +++ b/lib/asciinema/streaming/parser/json.ex @@ -1,5 +1,5 @@ -defmodule Asciinema.Streaming.ProducerHandler.Json do - @behaviour Asciinema.Streaming.ProducerHandler +defmodule Asciinema.Streaming.Parser.Json do + @behaviour Asciinema.Streaming.Parser def init, do: %{first: true} diff --git a/lib/asciinema/streaming/producer_handler/raw.ex b/lib/asciinema/streaming/parser/raw.ex similarity index 90% rename from lib/asciinema/streaming/producer_handler/raw.ex rename to lib/asciinema/streaming/parser/raw.ex index 6001d4d0c..5f0131255 100644 --- a/lib/asciinema/streaming/producer_handler/raw.ex +++ b/lib/asciinema/streaming/parser/raw.ex @@ -1,5 +1,5 @@ -defmodule Asciinema.Streaming.ProducerHandler.Raw do - @behaviour Asciinema.Streaming.ProducerHandler +defmodule Asciinema.Streaming.Parser.Raw do + @behaviour Asciinema.Streaming.Parser @default_size {80, 24} diff --git a/lib/asciinema/streaming/producer_handler.ex b/lib/asciinema/streaming/producer_handler.ex deleted file mode 100644 index 17247792d..000000000 --- a/lib/asciinema/streaming/producer_handler.ex +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Asciinema.Streaming.ProducerHandler do - @callback init() :: term - @callback parse({message :: term, opts :: keyword}, term) :: - {:ok, [{atom, term}], term} | {:error, term} - - alias Asciinema.Streaming.ProducerHandler - - def get(:raw), do: %{impl: ProducerHandler.Raw, state: ProducerHandler.Raw.init()} - def get(:alis), do: %{impl: ProducerHandler.Alis, state: ProducerHandler.Alis.init()} - def get(:json), do: %{impl: ProducerHandler.Json, state: ProducerHandler.Json.init()} -end diff --git a/lib/asciinema_web/live_stream_producer_socket.ex b/lib/asciinema_web/live_stream_producer_socket.ex index 2d2a79cc1..cb19162e1 100644 --- a/lib/asciinema_web/live_stream_producer_socket.ex +++ b/lib/asciinema_web/live_stream_producer_socket.ex @@ -1,11 +1,11 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do alias Asciinema.Streaming - alias Asciinema.Streaming.{LiveStreamServer, LiveStreamSupervisor, ProducerHandler} + alias Asciinema.Streaming.{LiveStreamServer, LiveStreamSupervisor, Parser} require Logger @behaviour Phoenix.Socket.Transport - @handler_timeout 5_000 + @parser_check_timeout 5_000 @ping_interval 15_000 @heartbeat_interval 15_000 @default_bucket_fill_interval 100 @@ -34,7 +34,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do state = %{ stream_id: live_stream.id, status: :new, - handler: nil, + parser: nil, bucket: %{ size: config(:bucket_size, @default_bucket_size), tokens: config(:bucket_size, @default_bucket_size), @@ -50,7 +50,7 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do @impl true def init(state) do Logger.info("producer/#{state.stream_id}: connected") - Process.send_after(self(), :handler_timeout, @handler_timeout) + Process.send_after(self(), :parser_check, @parser_check_timeout) Process.send_after(self(), :ping, @ping_interval) Process.send_after(self(), :fill_bucket, state.bucket.fill_interval) Process.send_after(self(), :heartbeat, @heartbeat_interval) @@ -59,26 +59,26 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do end @impl true - def handle_in({"ALiS" <> _, [opcode: :binary]} = message, %{handler: nil} = state) do - Logger.info("producer/#{state.stream_id}: activating ALiS handler") - handle_in(message, %{state | handler: ProducerHandler.get(:alis)}) + def handle_in({"ALiS" <> _, [opcode: :binary]} = message, %{parser: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating ALiS parser") + handle_in(message, %{state | parser: Parser.get(:alis)}) end - def handle_in({_, [opcode: :binary]} = message, %{handler: nil} = state) do - Logger.info("producer/#{state.stream_id}: activating raw text handler") - handle_in(message, %{state | handler: ProducerHandler.get(:raw)}) + def handle_in({_, [opcode: :binary]} = message, %{parser: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating raw text parser") + handle_in(message, %{state | parser: Parser.get(:raw)}) end - def handle_in({_, [opcode: :text]} = message, %{handler: nil} = state) do - Logger.info("producer/#{state.stream_id}: activating json handler") - handle_in(message, %{state | handler: ProducerHandler.get(:json)}) + def handle_in({_, [opcode: :text]} = message, %{parser: nil} = state) do + Logger.info("producer/#{state.stream_id}: activating json parser") + handle_in(message, %{state | parser: Parser.get(:json)}) end - def handle_in({payload, _} = message, %{handler: handler} = state) do - with {:ok, commands, new_handler_state} <- run_handler(handler, message), + def handle_in({payload, _} = message, %{parser: parser} = state) do + with {:ok, commands, new_parser_state} <- run_parser(parser, message), {:ok, state} <- run_commands(commands, state), {:ok, state} <- drain_bucket(state, byte_size(payload)) do - {:ok, put_in(state, [:handler, :state], new_handler_state)} + {:ok, put_in(state, [:parser, :state], new_parser_state)} else {:error, :not_a_leader} -> Logger.info("producer/#{state.stream_id}: stream taken over by another producer") @@ -96,17 +96,17 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do # TODO use reason other than :normal to make producer reconnect {:stop, :normal, state} - {:error, {:handler, reason}} -> + {:error, {:parser, reason}} -> Logger.debug("producer/#{state.stream_id}: message: #{inspect(payload)}") - Logger.warn("producer/#{state.stream_id}: handler error: #{reason}") + Logger.warn("producer/#{state.stream_id}: parser error: #{reason}") {:stop, :normal, state} end end - defp run_handler(%{impl: impl, state: state}, message) do + defp run_parser(%{impl: impl, state: state}, message) do with {:error, reason} <- impl.parse(message, state) do - {:error, {:handler, reason}} + {:error, {:parser, reason}} end end @@ -177,13 +177,13 @@ defmodule AsciinemaWeb.LiveStreamProducerSocket do send_heartbeat(state) end - def handle_info(:handler_timeout, %{handler: nil} = state) do - Logger.info("producer/#{state.stream_id}: handler init timeout") + def handle_info(:parser_check, %{parser: nil} = state) do + Logger.info("producer/#{state.stream_id}: initial message timeout") - {:stop, :handler_timeout, state} + {:stop, :parser_timeout, state} end - def handle_info(:handler_timeout, state), do: {:ok, state} + def handle_info(:parser_check, state), do: {:ok, state} def handle_info(:fill_bucket, %{bucket: bucket} = state) do tokens = min(bucket.size, bucket.tokens + bucket.fill_amount) From 298ee5715babd04ab50bf8397966b289d3d4b531 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 7 Jul 2023 14:36:49 +0200 Subject: [PATCH 065/121] Make live streams private by default, accessible through secret URL --- lib/asciinema/streaming.ex | 31 ++++++++++++-- lib/asciinema/streaming/live_stream.ex | 17 ++++++++ .../controllers/live_stream_controller.ex | 9 +--- lib/asciinema_web/views/live_stream_view.ex | 4 +- ...4655_add_more_metadata_to_live_streams.exs | 38 +++++++++++++++++ ...07_add_missing_indices_to_live_streams.exs | 8 ++++ .../live_stream_controller_test.exs | 41 +++++++++++++++++++ test/support/factory.ex | 1 + 8 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 priv/repo/migrations/20230707114655_add_more_metadata_to_live_streams.exs create mode 100644 priv/repo/migrations/20230707120207_add_missing_indices_to_live_streams.exs create mode 100644 test/controllers/live_stream_controller_test.exs diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex index 590956aaa..512f4693c 100644 --- a/lib/asciinema/streaming.ex +++ b/lib/asciinema/streaming.ex @@ -8,22 +8,46 @@ defmodule Asciinema.Streaming do Repo.get_by(LiveStream, producer_token: token) end - def get_live_stream(id) when is_integer(id) or is_binary(id) do + def get_live_stream(id) when is_integer(id) do LiveStream |> Repo.get(id) |> Repo.preload(:user) end - def get_live_stream(owner) do + def get_live_stream(id) when is_binary(id) do + stream = + cond do + String.match?(id, ~r/[[:alpha:]]/) -> + Repo.one(from(s in LiveStream, where: s.secret_token == ^id)) + + String.match?(id, ~r/^\d+$/) -> + id = String.to_integer(id) + Repo.one(from(s in LiveStream, where: s.private == false and s.id == ^id)) + + true -> + nil + end + + Repo.preload(stream, :user) + end + + def get_live_stream(%{live_streams: _} = owner) do owner |> Ecto.assoc(:live_streams) |> first() |> Repo.one() end + def fetch_live_stream(id) do + case get_live_stream(id) do + nil -> {:error, :not_found} + stream -> {:ok, stream} + end + end + def create_live_stream!(user) do %LiveStream{} - |> change(producer_token: generate_producer_token()) + |> change(secret_token: generate_secret_token(), producer_token: generate_producer_token()) |> put_assoc(:user, user) |> Repo.insert!() end @@ -60,4 +84,5 @@ defmodule Asciinema.Streaming do end defp generate_producer_token, do: Crypto.random_token(25) + defp generate_secret_token, do: Crypto.random_token(25) end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex index 3cf1ded8e..db68891a4 100644 --- a/lib/asciinema/streaming/live_stream.ex +++ b/lib/asciinema/streaming/live_stream.ex @@ -2,14 +2,31 @@ defmodule Asciinema.Streaming.LiveStream do use Ecto.Schema schema "live_streams" do + field :secret_token, :string field :producer_token, :string + field :private, :boolean, default: true field :cols, :integer field :rows, :integer field :online, :boolean field :last_activity_at, :naive_datetime + field :title, :string + field :description, :string + field :theme_name, :string + field :terminal_line_height, :float + field :terminal_font_family, :string timestamps() belongs_to :user, Asciinema.Accounts.User end + + defimpl Phoenix.Param do + def to_param(%{private: true, secret_token: secret_token}) do + secret_token + end + + def to_param(%{id: id}) do + Integer.to_string(id) + end + end end diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex index da5547cbe..6a5b0fc9b 100644 --- a/lib/asciinema_web/controllers/live_stream_controller.ex +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -5,7 +5,7 @@ defmodule AsciinemaWeb.LiveStreamController do plug :clear_main_class def show(conn, %{"id" => id}) do - with {:ok, stream} <- fetch_live_stream(id) do + with {:ok, stream} <- Streaming.fetch_live_stream(id) do do_show(conn, stream) end end @@ -15,11 +15,4 @@ defmodule AsciinemaWeb.LiveStreamController do |> assign(:stream, stream) |> render(:show) end - - defp fetch_live_stream(id) do - case Streaming.get_live_stream(id) do - nil -> {:error, :not_found} - stream -> {:ok, stream} - end - end end diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/views/live_stream_view.ex index d041796a0..431afd68a 100644 --- a/lib/asciinema_web/views/live_stream_view.ex +++ b/lib/asciinema_web/views/live_stream_view.ex @@ -33,8 +33,8 @@ defmodule AsciinemaWeb.LiveStreamView do profile_path(stream.user) end - # TODO use Routes defp ws_consumer_url(live_stream) do - String.replace(AsciinemaWeb.Endpoint.url() <> "/ws/s/#{live_stream.id}", ~r/^http/, "ws") + param = Phoenix.Param.to_param(live_stream) + String.replace(AsciinemaWeb.Endpoint.url() <> "/ws/s/#{param}", ~r/^http/, "ws") end end diff --git a/priv/repo/migrations/20230707114655_add_more_metadata_to_live_streams.exs b/priv/repo/migrations/20230707114655_add_more_metadata_to_live_streams.exs new file mode 100644 index 000000000..70061fbca --- /dev/null +++ b/priv/repo/migrations/20230707114655_add_more_metadata_to_live_streams.exs @@ -0,0 +1,38 @@ +defmodule Asciinema.Repo.Migrations.AddMoreMetadataToLiveStreams do + use Ecto.Migration + + def change do + alter table(:live_streams) do + add :private, :boolean, default: true, null: false + add :secret_token, :string + add :title, :string + add :description, :text + add :theme_name, :string + add :terminal_line_height, :float + add :terminal_font_family, :string + end + + execute( + fn -> + %{rows: rows} = repo().query!("SELECT id FROM live_streams") + + for [stream_id] <- rows do + token = Crypto.random_token(25) + + repo().query!("UPDATE live_streams SET secret_token = $1 WHERE id = $2", [ + token, + stream_id + ]) + end + end, + fn -> :ok end + ) + + alter table(:live_streams) do + modify :secret_token, :string, null: false + end + + create index(:live_streams, [:private]) + create unique_index(:live_streams, [:secret_token]) + end +end diff --git a/priv/repo/migrations/20230707120207_add_missing_indices_to_live_streams.exs b/priv/repo/migrations/20230707120207_add_missing_indices_to_live_streams.exs new file mode 100644 index 000000000..420c2f93a --- /dev/null +++ b/priv/repo/migrations/20230707120207_add_missing_indices_to_live_streams.exs @@ -0,0 +1,8 @@ +defmodule Asciinema.Repo.Migrations.AddMissingIndicesToLiveStreams do + use Ecto.Migration + + def change do + create index(:live_streams, [:user_id]) + create unique_index(:live_streams, [:producer_token]) + end +end diff --git a/test/controllers/live_stream_controller_test.exs b/test/controllers/live_stream_controller_test.exs new file mode 100644 index 000000000..a1eed10cb --- /dev/null +++ b/test/controllers/live_stream_controller_test.exs @@ -0,0 +1,41 @@ +defmodule Asciinema.LiveStreamControllerTest do + use AsciinemaWeb.ConnCase + import Asciinema.Factory + + describe "show" do + test "HTML, private stream via ID", %{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 + asciicast = insert(:live_stream, private: true) + + conn_2 = get(conn, "/s/#{asciicast.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}") + + 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 + stream = insert(:live_stream, private: false) + + conn_2 = get(conn, "/s/#{stream.secret_token}") + + assert html_response(conn_2, 200) =~ "createPlayer" + assert response_content_type(conn_2, :html) + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 4632d4ae8..c86a1bfd0 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -68,6 +68,7 @@ defmodule Asciinema.Factory do def live_stream_factory do %LiveStream{ user: build(:user), + secret_token: sequence(:secret_token, &secret_token/1), producer_token: sequence(:producer_token, &"token-#{&1}") } end From 2f274f5d35648a1fcf6bc1b271dce4505e82f9c2 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 7 Jul 2023 15:20:11 +0200 Subject: [PATCH 066/121] Remove outdated TODO --- lib/asciinema_web/views/live_stream_view.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/views/live_stream_view.ex index 431afd68a..a88949919 100644 --- a/lib/asciinema_web/views/live_stream_view.ex +++ b/lib/asciinema_web/views/live_stream_view.ex @@ -11,8 +11,6 @@ defmodule AsciinemaWeb.LiveStreamView do end def cinema_height(stream) do - # TODO make it live - with cinema height automatically recalculated and updated - AsciinemaWeb.RecordingView.cinema_height(%{ cols: stream.cols || 80, rows: stream.rows || 24, From 3b882f764c4e14f1f968a2d044542d48921db7ef Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Fri, 7 Jul 2023 16:07:38 +0200 Subject: [PATCH 067/121] Reuse recording view helpers for live streams --- .../controllers/live_stream_controller.ex | 9 ++-- .../controllers/recording_controller.ex | 3 +- .../playback_opts.ex | 14 ++++-- .../live_stream/_private_badge.html.eex | 1 + .../templates/live_stream/show.html.eex | 11 +++-- lib/asciinema_web/views/live_stream_view.ex | 40 +++++++++-------- lib/asciinema_web/views/player_view.ex | 37 ++++++++++++++++ lib/asciinema_web/views/recording_view.ex | 43 ++++--------------- 8 files changed, 96 insertions(+), 62 deletions(-) rename lib/{asciinema/recordings => asciinema_web}/playback_opts.ex (89%) create mode 100644 lib/asciinema_web/templates/live_stream/_private_badge.html.eex create mode 100644 lib/asciinema_web/views/player_view.ex diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex index 6a5b0fc9b..6ca7ba3ea 100644 --- a/lib/asciinema_web/controllers/live_stream_controller.ex +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -1,6 +1,7 @@ defmodule AsciinemaWeb.LiveStreamController do use AsciinemaWeb, :controller alias Asciinema.Streaming + alias AsciinemaWeb.PlayerOpts plug :clear_main_class @@ -11,8 +12,10 @@ defmodule AsciinemaWeb.LiveStreamController do end defp do_show(conn, stream) do - conn - |> assign(:stream, stream) - |> render(:show) + render(conn, stream: stream, player_opts: player_opts(conn.params)) + end + + defp player_opts(params) do + PlayerOpts.parse(params, :live_stream) end end diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 76084b05d..888666510 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -2,6 +2,7 @@ defmodule AsciinemaWeb.RecordingController do use AsciinemaWeb, :controller alias Asciinema.{Recordings, PngGenerator, Accounts} alias Asciinema.Recordings.Asciicast + alias AsciinemaWeb.PlayerOpts plug :clear_main_class plug :load_asciicast when action in [:show, :edit, :update, :delete, :iframe] @@ -292,6 +293,6 @@ defmodule AsciinemaWeb.RecordingController do defp playback_options(params) do params |> Ext.Map.rename(%{"t" => "startAt", "i" => "idleTimeLimit"}) - |> Recordings.PlaybackOpts.parse() + |> PlayerOpts.parse(:recording) end end diff --git a/lib/asciinema/recordings/playback_opts.ex b/lib/asciinema_web/playback_opts.ex similarity index 89% rename from lib/asciinema/recordings/playback_opts.ex rename to lib/asciinema_web/playback_opts.ex index ca27bed65..6498603bc 100644 --- a/lib/asciinema/recordings/playback_opts.ex +++ b/lib/asciinema_web/playback_opts.ex @@ -1,4 +1,4 @@ -defmodule Asciinema.Recordings.PlaybackOpts do +defmodule AsciinemaWeb.PlayerOpts do use Ecto.Schema import Ecto.Changeset @@ -70,10 +70,18 @@ defmodule Asciinema.Recordings.PlaybackOpts do field :theme, :string end - def parse(attrs) do + def parse(attrs, :recording) do + parse(attrs, __MODULE__.__schema__(:fields)) + end + + def parse(attrs, :live_stream) do + parse(attrs, [:autoplay, :cols, :poster, :rows, :theme]) + end + + def parse(attrs, fields) when is_list(fields) do changeset = %__MODULE__{} - |> cast(attrs, __MODULE__.__schema__(:fields)) + |> cast(attrs, fields) |> validate_number(:speed, greater_than: 0.0) |> validate_number(:startAt, greater_than: 0) |> validate_number(:idleTimeLimit, greater_than_or_equal_to: 0.5) diff --git a/lib/asciinema_web/templates/live_stream/_private_badge.html.eex b/lib/asciinema_web/templates/live_stream/_private_badge.html.eex new file mode 100644 index 000000000..57c420db1 --- /dev/null +++ b/lib/asciinema_web/templates/live_stream/_private_badge.html.eex @@ -0,0 +1 @@ +private diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex index 8015d39d5..e6cb43258 100644 --- a/lib/asciinema_web/templates/live_stream/show.html.eex +++ b/lib/asciinema_web/templates/live_stream/show.html.eex @@ -10,12 +10,14 @@ <% end %> -

- Live stream -

+

<%= title(@stream) %>

by <%= link author_username(@stream), to: author_profile_path(@stream) %> + + <%= if @stream.private do %> + <%= render "_private_badge.html" %> + <% end %>
@@ -25,11 +27,12 @@ + <%= render("_footer.html", conn: @conn) %> + diff --git a/lib/asciinema_web/templates/layout/docs.html.heex b/lib/asciinema_web/templates/layout/docs.html.heex index ceb0fc5f5..52007b821 100644 --- a/lib/asciinema_web/templates/layout/docs.html.heex +++ b/lib/asciinema_web/templates/layout/docs.html.heex @@ -2,7 +2,7 @@
- <%= render AsciinemaWeb.DocView, "topics.html", conn: @conn, active_topic: @topic %> + <%= render(AsciinemaWeb.DocView, "topics.html", conn: @conn, active_topic: @topic) %>
<%= @inner_content %> diff --git a/lib/asciinema_web/templates/layout/email.html.heex b/lib/asciinema_web/templates/layout/email.html.heex index 5340c1334..155dae9fc 100644 --- a/lib/asciinema_web/templates/layout/email.html.heex +++ b/lib/asciinema_web/templates/layout/email.html.heex @@ -1,7 +1,7 @@ - + <%= @inner_content %> diff --git a/lib/asciinema_web/templates/layout/iframe.html.heex b/lib/asciinema_web/templates/layout/iframe.html.heex index 7a0c06222..9e1bba32c 100644 --- a/lib/asciinema_web/templates/layout/iframe.html.heex +++ b/lib/asciinema_web/templates/layout/iframe.html.heex @@ -1,13 +1,14 @@ - - - + + + <%= @inner_content %> - + diff --git a/lib/asciinema_web/templates/layout/simple.html.heex b/lib/asciinema_web/templates/layout/simple.html.heex index ecc5394da..67f231e53 100644 --- a/lib/asciinema_web/templates/layout/simple.html.heex +++ b/lib/asciinema_web/templates/layout/simple.html.heex @@ -1,12 +1,12 @@ - - - - <%= page_title @conn %> - - + + + + <%= page_title(@conn) %> + + diff --git a/lib/asciinema_web/templates/recording/gif.html.heex b/lib/asciinema_web/templates/recording/gif.html.heex index b97f5e798..8036a6bcf 100644 --- a/lib/asciinema_web/templates/recording/gif.html.heex +++ b/lib/asciinema_web/templates/recording/gif.html.heex @@ -2,7 +2,7 @@

You can use agg to do it yourself. Once you have it installed run the following commands in your - terminal:

+ terminal:

wget -O <%= @asciicast_id %>.cast <%= @file_url %>
 agg <%= @asciicast_id %>.cast <%= @asciicast_id %>.gif
From 1b4784fac32e6d1feab214d3a02bd70405f1ca02 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 8 Jul 2023 17:03:47 +0200 Subject: [PATCH 080/121] Enable verified routes --- lib/asciinema_web.ex | 15 +++++++++++++++ lib/asciinema_web/endpoint.ex | 2 +- test/support/conn_case.ex | 8 +++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index dbe9bc0c8..793a3bf69 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -17,6 +17,8 @@ defmodule AsciinemaWeb do and import those modules here. """ + def static_paths, do: ~w(css fonts images js favicon.ico robots.txt) + def controller do quote do use Phoenix.Controller, namespace: AsciinemaWeb @@ -29,6 +31,8 @@ defmodule AsciinemaWeb do import AsciinemaWeb.Plug.Authz alias AsciinemaWeb.Router.Helpers, as: Routes + unquote(verified_routes()) + action_fallback AsciinemaWeb.FallbackController defp clear_main_class(conn, _) do @@ -112,6 +116,17 @@ defmodule AsciinemaWeb do import AsciinemaWeb.Router.Helpers.Extra import AsciinemaWeb.ApplicationView + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: AsciinemaWeb.Endpoint, + router: AsciinemaWeb.Router, + statics: AsciinemaWeb.static_paths() end end diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index e82693a90..0d4512c0c 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -29,7 +29,7 @@ defmodule AsciinemaWeb.Endpoint do at: "/", from: :asciinema, gzip: true, - only: ~w(css fonts images js favicon.ico robots.txt) + only: AsciinemaWeb.static_paths() # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index fe77785cf..57be5a80b 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -17,6 +17,11 @@ defmodule AsciinemaWeb.ConnCase do using do quote do + # The default endpoint for testing + @endpoint AsciinemaWeb.Endpoint + + use AsciinemaWeb, :verified_routes + # Import conveniences for testing with connections import Plug.Conn import Phoenix.ConnTest @@ -25,9 +30,6 @@ defmodule AsciinemaWeb.ConnCase do import Asciinema.Fixtures alias AsciinemaWeb.Router.Helpers, as: Routes - # The default endpoint for testing - @endpoint AsciinemaWeb.Endpoint - defp flash(conn, key) do Phoenix.Flash.get(conn.assigns.flash, key) end From f39fc458b1e152e15b1d1b2490eafa75c8a94701 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 8 Jul 2023 17:40:39 +0200 Subject: [PATCH 081/121] Post Phoenix upgrade tweaks --- config/dev.exs | 6 +++++- config/prod.exs | 29 +---------------------------- config/runtime.exs | 2 +- lib/asciinema/application.ex | 2 +- lib/asciinema/telemetry.ex | 9 ++++++++- lib/asciinema_web.ex | 2 +- lib/asciinema_web/endpoint.ex | 3 ++- lib/asciinema_web/router.ex | 2 +- mix.exs | 5 +++-- 9 files changed, 23 insertions(+), 37 deletions(-) diff --git a/config/dev.exs b/config/dev.exs index 82e305903..beb48699d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -47,10 +47,14 @@ config :asciinema, AsciinemaWeb.Endpoint, ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", ~r"lib/asciinema_web/views/.*(ex)$", - ~r"lib/asciinema_web/templates/.*(eex|md)$" + ~r"lib/asciinema_web/templates/.*(eex|md)$", + ~r"lib/asciinema_web/(controllers|live|components)/.*(ex|heex)$" ] ] +# Enable dev routes for dashboard and mailbox +config :asciinema, dev_routes: true + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" diff --git a/config/prod.exs b/config/prod.exs index f06688cf3..dab45c219 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -3,7 +3,7 @@ import Config # For production, don't forget to configure the url host # to something meaningful, Phoenix uses this information # when generating URLs. -# + # Note we also include the path to a cache manifest # containing the digested version of static files. This # manifest is generated by the `mix phx.digest` task, @@ -23,33 +23,6 @@ config :asciinema, AsciinemaWeb.Endpoint, # Do not print debug messages in production config :logger, level: :info -# ## SSL Support -# -# To get SSL working, you will need to add the `https` key -# to the previous section and set your `:url` port to 443: -# -# config :asciinema, AsciinemaWeb.Endpoint, -# ..., -# url: [host: "example.com", port: 443], -# https: [ -# ..., -# port: 443, -# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), -# certfile: System.get_env("SOME_APP_SSL_CERT_PATH") -# ] -# -# Where those two env variables return an absolute path to -# the key and cert in disk or a relative path inside priv, -# for example "priv/ssl/server.key". -# -# We also recommend setting `force_ssl`, ensuring no data is -# ever sent via http, always redirecting to https: -# -# config :asciinema, AsciinemaWeb.Endpoint, -# force_ssl: [hsts: true] -# -# Check `Plug.SSL` for all available options in `force_ssl`. - config :asciinema, Asciinema.Repo, pool_size: 20, ssl: false diff --git a/config/runtime.exs b/config/runtime.exs index d31f93733..8ac5586cf 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -82,7 +82,7 @@ if config_env() in [:prod, :dev] do config :asciinema, Asciinema.Repo, pool_size: String.to_integer(db_pool_size) end - if env.("ECTO_IPV6") do + if env.("ECTO_IPV6") in ~w(true 1) do config :asciinema, Asciinema.Repo, socket_options: [:inet6] end diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index 3d6b93e2f..9a2e53ecf 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -16,7 +16,7 @@ defmodule Asciinema.Application do children = [ # Start cluster supervisor {Cluster.Supervisor, [topologies, [name: Asciinema.ClusterSupervisor]]}, - # Start Phoenix PubSub + # Start the PubSub system {Phoenix.PubSub, [name: Asciinema.PubSub, adapter: Phoenix.PubSub.PG2]}, # Start live stream viewer tracker {Asciinema.Streaming.ViewerTracker, [pubsub_server: Asciinema.PubSub]}, diff --git a/lib/asciinema/telemetry.ex b/lib/asciinema/telemetry.ex index 3312ebe12..3d913087c 100644 --- a/lib/asciinema/telemetry.ex +++ b/lib/asciinema/telemetry.ex @@ -31,7 +31,7 @@ defmodule Asciinema.Telemetry do phoenix_distribution = [ unit: {:native, :millisecond}, - tags: [:plug, :route, :method, :status], + tags: [:plug, :route, :method, :status, :event], tag_values: &phoenix_router_dispatch_tag_values/1, reporter_options: [buckets: @buckets] ] @@ -63,7 +63,14 @@ defmodule Asciinema.Telemetry do distribution("asciinema.repo.query.queue_time", repo_distribution), # Phoenix + distribution("phoenix.endpoint.start.system_time", phoenix_distribution), + distribution("phoenix.endpoint.stop.duration", phoenix_distribution), + distribution("phoenix.router_dispatch.start.system_time", phoenix_distribution), + distribution("phoenix.router_dispatch.exception.duration", phoenix_distribution), distribution("phoenix.router_dispatch.stop.duration", phoenix_distribution), + distribution("phoenix.socket_connected.duration", phoenix_distribution), + distribution("phoenix.channel_join.duration", phoenix_distribution), + distribution("phoenix.channel_handled_in.duration", phoenix_distribution), # Oban counter("oban.job.start.count", oban_counter), diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index 793a3bf69..b32a7cd80 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -1,7 +1,7 @@ defmodule AsciinemaWeb do @moduledoc """ The entrypoint for defining your web interface, such - as controllers, views, channels and so on. + as controllers, components, channels, and so on. This can be used in your application as: diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index 0d4512c0c..3295a0493 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -8,7 +8,8 @@ defmodule AsciinemaWeb.Endpoint do @session_options [ store: :cookie, key: "_asciinema_key", - signing_salt: "qJL+3s0T" + signing_salt: "qJL+3s0T", + same_site: "Lax" ] # socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] diff --git a/lib/asciinema_web/router.ex b/lib/asciinema_web/router.ex index 425f64ccf..fcf2b93f9 100644 --- a/lib/asciinema_web/router.ex +++ b/lib/asciinema_web/router.ex @@ -95,7 +95,7 @@ defmodule AsciinemaWeb.Router do end scope "/dev" do - if Mix.env() == :dev do + if Application.compile_env(:asciinema, :dev_routes) do forward "/mailbox", Bamboo.SentEmailViewerPlug end end diff --git a/mix.exs b/mix.exs index 7cfd2b209..2f1df7b11 100644 --- a/mix.exs +++ b/mix.exs @@ -5,7 +5,7 @@ defmodule Asciinema.MixProject do [ app: :asciinema, version: "0.1.0", - elixir: "~> 1.12", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, aliases: aliases(), @@ -50,7 +50,7 @@ defmodule Asciinema.MixProject do {:ex_aws, "~> 2.2"}, {:ex_aws_s3, "~> 2.1"}, {:ex_machina, "~> 2.4", only: :test}, - {:gettext, "~> 0.18"}, + {:gettext, "~> 0.20"}, {:hackney, "~> 1.18"}, {:horde, "~> 0.8.7"}, {:html_sanitize_ex, "~> 1.4"}, @@ -93,6 +93,7 @@ defmodule Asciinema.MixProject do # See the documentation for `Mix` for more info on aliases. defp aliases do [ + setup: ["deps.get", "ecto.setup"], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] From acb2bebeca06ac43f2e4c3cf61674f3a88d7782a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 8 Jul 2023 17:49:55 +0200 Subject: [PATCH 082/121] Use verified routes for recent changes --- lib/asciinema_web/controllers/live_stream_controller.ex | 2 +- lib/asciinema_web/templates/live_stream/edit.html.eex | 4 ++-- lib/asciinema_web/templates/live_stream/show.html.eex | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex index 6d6a3f9b1..bf89b936d 100644 --- a/lib/asciinema_web/controllers/live_stream_controller.ex +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -29,7 +29,7 @@ defmodule AsciinemaWeb.LiveStreamController do {:ok, stream} -> conn |> put_flash(:info, "Live stream updated.") - |> redirect(to: Routes.live_stream_path(conn, :show, stream)) + |> redirect(to: ~p"/s/#{stream}") {:error, %Ecto.Changeset{} = changeset} -> render(conn, "edit.html", changeset: changeset) diff --git a/lib/asciinema_web/templates/live_stream/edit.html.eex b/lib/asciinema_web/templates/live_stream/edit.html.eex index df805d31f..1526e60e3 100644 --- a/lib/asciinema_web/templates/live_stream/edit.html.eex +++ b/lib/asciinema_web/templates/live_stream/edit.html.eex @@ -1,7 +1,7 @@
- <%= form_for @changeset, Routes.live_stream_path(@conn, :update, @changeset.data), fn f -> %> + <%= form_for @changeset, ~p"/s/#{@changeset.data}", fn f -> %> Live stream settings
@@ -49,7 +49,7 @@

<%= submit "Save", class: "btn btn-primary" %> - <%= link "Cancel", to: Routes.live_stream_path(@conn, :show, @changeset.data), class: "btn" %> + <%= link "Cancel", to: ~p"/s/#{@changeset.data}", class: "btn" %>
<% end %> diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex index 7c33b7643..3e96a4fef 100644 --- a/lib/asciinema_web/templates/live_stream/show.html.eex +++ b/lib/asciinema_web/templates/live_stream/show.html.eex @@ -30,15 +30,15 @@ <% end %> From fa45610f80313e623148cb356fb520011c37e005 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sat, 8 Jul 2023 18:55:21 +0200 Subject: [PATCH 083/121] Convert .eex to .heex --- .../templates/live_stream/edit.html.eex | 58 --------- .../templates/live_stream/edit.html.heex | 81 ++++++++++++ .../templates/live_stream/show.html.eex | 98 -------------- .../templates/live_stream/show.html.heex | 122 ++++++++++++++++++ ...gs.html.eex => _user_recordings.html.heex} | 6 +- 5 files changed, 206 insertions(+), 159 deletions(-) delete mode 100644 lib/asciinema_web/templates/live_stream/edit.html.eex create mode 100644 lib/asciinema_web/templates/live_stream/edit.html.heex delete mode 100644 lib/asciinema_web/templates/live_stream/show.html.eex create mode 100644 lib/asciinema_web/templates/live_stream/show.html.heex rename lib/asciinema_web/templates/recording/{_user_recordings.html.eex => _user_recordings.html.heex} (69%) diff --git a/lib/asciinema_web/templates/live_stream/edit.html.eex b/lib/asciinema_web/templates/live_stream/edit.html.eex deleted file mode 100644 index 1526e60e3..000000000 --- a/lib/asciinema_web/templates/live_stream/edit.html.eex +++ /dev/null @@ -1,58 +0,0 @@ -
-
-
- <%= form_for @changeset, ~p"/s/#{@changeset.data}", fn f -> %> - Live stream settings - -
- <%= label f, :title, class: "col-sm-4 col-md-3 col-lg-3 col-form-label" %> -
- <%= text_input f, :title, class: "form-control", autofocus: true %> - <%= error_tag f, :title %> -
-
- -
- <%= label f, :description, class: "col-sm-4 col-md-3 col-lg-3 col-form-label" %> -
- <%= textarea f, :description, class: "form-control", rows: 10 %> - <%= error_tag f, :description %> - Parsed as Markdown. -
-
- -
- <%= label f, :theme_name, "Terminal theme", class: "col-sm-4 col-md-3 col-lg-3 col-form-label" %> -
- <%= select f, :theme_name, theme_options(), class: "form-control", prompt: "Default (#{default_theme_name(@changeset.data)})" %> -
-
- -
- <%= label f, :terminal_line_height, "Terminal line height", class: "col-sm-4 col-md-3 col-lg-3 col-form-label" %> -
- <%= text_input f, :terminal_line_height, class: "form-control", type: "number", min: 1, max: 2, step: "any", placeholder: "1.33333" %> - <%= error_tag f, :terminal_line_height %> - Relative to font size. Lowering it ~1.1 helps with alignment of block characters like â–€ â–„ â–ˆ -
-
- -
- <%= label f, :terminal_font_family, "Terminal font family", class: "col-sm-4 col-md-3 col-lg-3 col-form-label" %> -
- <%= select f, :terminal_font_family, terminal_font_family_options(), class: "form-control", prompt: "Default (web safe, platform specific)" %> - Choose one of Nerd Font variants if icons or other symbols in your recording are not visible. -
-
- -
-
-
- <%= submit "Save", class: "btn btn-primary" %> - <%= link "Cancel", to: ~p"/s/#{@changeset.data}", class: "btn" %> -
-
- <% end %> -
-
-
diff --git a/lib/asciinema_web/templates/live_stream/edit.html.heex b/lib/asciinema_web/templates/live_stream/edit.html.heex new file mode 100644 index 000000000..f16029e04 --- /dev/null +++ b/lib/asciinema_web/templates/live_stream/edit.html.heex @@ -0,0 +1,81 @@ +
+
+
+ <%= form_for @changeset, ~p"/s/#{@changeset.data}", fn f -> %> + Live stream settings + +
+ <%= label(f, :title, class: "col-sm-4 col-md-3 col-lg-3 col-form-label") %> +
+ <%= text_input(f, :title, class: "form-control", autofocus: true) %> + <%= error_tag(f, :title) %> +
+
+ +
+ <%= label(f, :description, class: "col-sm-4 col-md-3 col-lg-3 col-form-label") %> +
+ <%= textarea(f, :description, class: "form-control", rows: 10) %> + <%= error_tag(f, :description) %> + Parsed as Markdown. +
+
+ +
+ <%= label(f, :theme_name, "Terminal theme", + class: "col-sm-4 col-md-3 col-lg-3 col-form-label" + ) %> +
+ <%= select(f, :theme_name, theme_options(), + class: "form-control", + prompt: "Default (#{default_theme_name(@changeset.data)})" + ) %> +
+
+ +
+ <%= label(f, :terminal_line_height, "Terminal line height", + class: "col-sm-4 col-md-3 col-lg-3 col-form-label" + ) %> +
+ <%= text_input(f, :terminal_line_height, + class: "form-control", + type: "number", + min: 1, + max: 2, + step: "any", + placeholder: "1.33333" + ) %> + <%= error_tag(f, :terminal_line_height) %> + + Relative to font size. Lowering it ~1.1 helps with alignment of block characters like â–€ â–„ â–ˆ + +
+
+ +
+ <%= label(f, :terminal_font_family, "Terminal font family", + class: "col-sm-4 col-md-3 col-lg-3 col-form-label" + ) %> +
+ <%= select(f, :terminal_font_family, terminal_font_family_options(), + class: "form-control", + prompt: "Default (web safe, platform specific)" + ) %> + + Choose one of Nerd Font variants if icons or other symbols in your recording are not visible. + +
+
+ +
+
+
+ <%= submit("Save", class: "btn btn-primary") %> + <%= link("Cancel", to: ~p"/s/#{@changeset.data}", class: "btn") %> +
+
+ <% end %> +
+
+
diff --git a/lib/asciinema_web/templates/live_stream/show.html.eex b/lib/asciinema_web/templates/live_stream/show.html.eex deleted file mode 100644 index 3e96a4fef..000000000 --- a/lib/asciinema_web/templates/live_stream/show.html.eex +++ /dev/null @@ -1,98 +0,0 @@ -
- -
-
-
-
- - <%= link to: author_profile_path(@stream), title: author_username(@stream) do %> - <%= img_tag author_avatar_url(@stream), class: "avatar" %> - <% end %> - - -

<%= title(@stream) %>

- - - by <%= link author_username(@stream), to: author_profile_path(@stream) %> - - <%= if @stream.private do %> - <%= render "_private_badge.html" %> - <% end %> - -
- -
- -
-
-
-
- -
-
-
-
- <%= render PlayerView, "_info_icon.html" %> - - <%= cond do %> - <% @stream.online -> %>Online - <% @stream.last_activity_at -> %>Last streamed <%= time_ago_tag(@stream.last_activity_at) %> - <% true -> %>Stream hasn't started yet - <% end %> - - <%= if desc = render_markdown(@stream.description) do %> -
- -
- <%= desc %> -
- <% end %> -
-
-
-
- -<%= render RecordingView, "_user_recordings.html", conn: @conn, user: @stream.user, asciicasts: @author_asciicasts %> - - diff --git a/lib/asciinema_web/templates/live_stream/show.html.heex b/lib/asciinema_web/templates/live_stream/show.html.heex new file mode 100644 index 000000000..f4a024dd6 --- /dev/null +++ b/lib/asciinema_web/templates/live_stream/show.html.heex @@ -0,0 +1,122 @@ +
+
+ +
+
+
+
+ + <%= link to: author_profile_path(@stream), title: author_username(@stream) do %> + <%= img_tag(author_avatar_url(@stream), class: "avatar") %> + <% end %> + + +

<%= title(@stream) %>

+ + + by <%= link(author_username(@stream), to: author_profile_path(@stream)) %> + + <%= if @stream.private do %> + <%= render("_private_badge.html") %> + <% end %> + +
+ +
+ +
+
+
+
+ +
+
+
+
+ <%= render(PlayerView, "_info_icon.html") %> + + <%= cond do %> + <% @stream.online -> %> + Online + <% @stream.last_activity_at -> %> + Last streamed <%= time_ago_tag(@stream.last_activity_at) %> + <% true -> %> + Stream hasn't started yet + <% end %> + + <%= if desc = render_markdown(@stream.description) do %> +
+ +
+ <%= desc %> +
+ <% end %> +
+
+
+
+ +<%= render(RecordingView, "_user_recordings.html", + conn: @conn, + user: @stream.user, + asciicasts: @author_asciicasts +) %> + + diff --git a/lib/asciinema_web/templates/recording/_user_recordings.html.eex b/lib/asciinema_web/templates/recording/_user_recordings.html.heex similarity index 69% rename from lib/asciinema_web/templates/recording/_user_recordings.html.eex rename to lib/asciinema_web/templates/recording/_user_recordings.html.heex index cd3469a81..485c363a5 100644 --- a/lib/asciinema_web/templates/recording/_user_recordings.html.eex +++ b/lib/asciinema_web/templates/recording/_user_recordings.html.heex @@ -5,20 +5,20 @@
-

More by <%= link username(@user), to: profile_path(@user) %>

+

More by <%= link(username(@user), to: profile_path(@user)) %>

<%= for asciicast <- asciicasts do %>
- <%= render "_card.html", conn: @conn, asciicast: asciicast %> + <%= render("_card.html", conn: @conn, asciicast: asciicast) %>
<% end %>

- <%= link "See all", to: profile_path(@user), class: "btn btn-info" %> + <%= link("See all", to: profile_path(@user), class: "btn btn-info") %>

From 57dfb379adc96fda46dc92832661a7538de962b2 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Sun, 9 Jul 2023 16:34:07 +0200 Subject: [PATCH 084/121] Convert live stream pages to Phoenix.Component --- lib/asciinema_web.ex | 57 ++++++++- .../components/core_components.ex | 116 ++++++++++++++++++ lib/asciinema_web/components/icons.ex | 5 + .../controllers/live_stream/edit.html.heex | 90 ++++++++++++++ .../live_stream/private_badge.html.heex | 6 + .../live_stream/show.html.heex | 80 ++++++------ .../controllers/live_stream_controller.ex | 7 +- .../live_stream_html.ex} | 8 +- .../templates/icons/info_icon.html.heex | 4 + .../templates/layout/app.html.heex | 5 +- .../live_stream/_private_badge.html.eex | 1 - .../templates/live_stream/edit.html.heex | 81 ------------ .../templates/player/_info_icon.html.eex | 1 - .../templates/recording/show.html.eex | 2 +- lib/asciinema_web/views/recording_view.ex | 3 + .../live_stream_controller_test.exs | 9 +- 16 files changed, 337 insertions(+), 138 deletions(-) create mode 100644 lib/asciinema_web/components/core_components.ex create mode 100644 lib/asciinema_web/components/icons.ex create mode 100644 lib/asciinema_web/controllers/live_stream/edit.html.heex create mode 100644 lib/asciinema_web/controllers/live_stream/private_badge.html.heex rename lib/asciinema_web/{templates => controllers}/live_stream/show.html.heex (54%) rename lib/asciinema_web/{views/live_stream_view.ex => controllers/live_stream_html.ex} (89%) create mode 100644 lib/asciinema_web/templates/icons/info_icon.html.heex delete mode 100644 lib/asciinema_web/templates/live_stream/_private_badge.html.eex delete mode 100644 lib/asciinema_web/templates/live_stream/edit.html.heex delete mode 100644 lib/asciinema_web/templates/player/_info_icon.html.eex diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index b32a7cd80..4f0d9f886 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -41,6 +41,28 @@ defmodule AsciinemaWeb do end end + def new_controller do + quote do + use Phoenix.Controller, + formats: [:html, :json] + + import Plug.Conn + import AsciinemaWeb.Gettext + import AsciinemaWeb.Router.Helpers.Extra + import AsciinemaWeb.Auth, only: [require_current_user: 2] + import AsciinemaWeb.Plug.ReturnTo + import AsciinemaWeb.Plug.Authz + + unquote(verified_routes()) + + action_fallback AsciinemaWeb.FallbackController + + defp clear_main_class(conn, _) do + assign(conn, :main_class, "") + end + end + end + def view do quote do use Phoenix.View, @@ -61,7 +83,7 @@ defmodule AsciinemaWeb do use Phoenix.LiveView, layout: {AsciinemaWeb.LayoutView, "live.html"} - unquote(view_helpers()) + unquote(html_helpers()) end end @@ -69,7 +91,7 @@ defmodule AsciinemaWeb do quote do use Phoenix.LiveComponent - unquote(view_helpers()) + unquote(html_helpers()) end end @@ -108,6 +130,7 @@ defmodule AsciinemaWeb do import Phoenix.LiveView.Helpers # Import basic rendering functionality (render, render_layout, etc) + use Phoenix.Component import Phoenix.View import AsciinemaWeb.ErrorHelpers @@ -117,6 +140,36 @@ defmodule AsciinemaWeb do import AsciinemaWeb.Router.Helpers.Extra import AsciinemaWeb.ApplicationView + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def html do + quote do + use Phoenix.Component + import Phoenix.View + import AsciinemaWeb.ApplicationView + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + + # Core UI components and translation + import AsciinemaWeb.CoreComponents + import AsciinemaWeb.Gettext + import AsciinemaWeb.Icons + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil unquote(verified_routes()) end end diff --git a/lib/asciinema_web/components/core_components.ex b/lib/asciinema_web/components/core_components.ex new file mode 100644 index 000000000..aaea51a26 --- /dev/null +++ b/lib/asciinema_web/components/core_components.ex @@ -0,0 +1,116 @@ +defmodule AsciinemaWeb.CoreComponents do + use Phoenix.Component + + attr :for, Phoenix.HTML.FormField + attr :rest, :global + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + attr :id, :any, default: nil + attr :name, :any + attr :value, :any + + attr :type, :string, + default: "text", + values: + ~w(checkbox color date datetime-local email file hidden month number password range radio search select tel text textarea time url week) + + attr :field, Phoenix.HTML.FormField, + doc: "a form field struct retrieved from the form, for example: @form[:email]" + + attr :checked, :boolean, doc: "the checked flag for checkbox inputs" + attr :prompt, :string, default: nil, doc: "the prompt for select inputs" + attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" + + attr :rest, :global, + include: + ~w(autocomplete cols disabled form max maxlength min minlength pattern placeholder readonly required rows size step) + + slot :inner_block + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign_new(:name, fn -> field.name end) + |> assign_new(:value, fn -> field.value end) + |> input() + end + + def input(%{type: "checkbox", value: value} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end) + + ~H""" + + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + + """ + end + + def input(assigns) do + ~H""" + + """ + end + + attr :type, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value) + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + attr :time, :any, required: true + attr :rest, :global + + def time_ago(assigns) do + ~H""" + + """ + end + + attr :form, :any, required: true + attr :field, :atom, required: true + + def error(assigns) do + assigns = assign(assigns, :error, assigns.form.errors[assigns.field]) + + ~H""" + <%= @error %> + """ + end +end diff --git a/lib/asciinema_web/components/icons.ex b/lib/asciinema_web/components/icons.ex new file mode 100644 index 000000000..d9a1df49c --- /dev/null +++ b/lib/asciinema_web/components/icons.ex @@ -0,0 +1,5 @@ +defmodule AsciinemaWeb.Icons do + use Phoenix.Component + + embed_templates "../templates/icons/*" +end diff --git a/lib/asciinema_web/controllers/live_stream/edit.html.heex b/lib/asciinema_web/controllers/live_stream/edit.html.heex new file mode 100644 index 000000000..63f158948 --- /dev/null +++ b/lib/asciinema_web/controllers/live_stream/edit.html.heex @@ -0,0 +1,90 @@ +
+
+
+ <.form :let={f} for={@changeset} action={~p"/s/#{@changeset.data}"}> + Live stream settings + +
+ <.label for={f[:title]} class="col-sm-4 col-md-3 col-lg-3 col-form-label">Title +
+ <.input field={f[:title]} class="form-control" autofocus={true} /> + <.error form={f} field={:title} /> +
+
+ +
+ <.label for={f[:description]} class="col-sm-4 col-md-3 col-lg-3 col-form-label"> + Description + +
+ <.input type="textarea" field={f[:description]} class="form-control" rows={10} /> + <.error form={f} field={:description} /> + Parsed as Markdown. +
+
+ +
+ <.label for={f[:theme_name]} class="col-sm-4 col-md-3 col-lg-3 col-form-label"> + Terminal theme + +
+ <.input + type="select" + field={f[:theme_name]} + options={theme_options()} + class="form-control" + prompt={"Default (#{default_theme_name(@changeset.data)})"} + /> +
+
+ +
+ <.label for={f[:terminal_line_height]} class="col-sm-4 col-md-3 col-lg-3 col-form-label"> + Terminal line height + +
+ <.input + type="number" + field={f[:terminal_line_height]} + class="form-control" + min={1.0} + max={2.0} + step="any" + placeholder="1.33333" + /> + <.error form={f} field={:terminal_line_height} /> + + Relative to font size. Lowering it ~1.1 helps with alignment of block characters like â–€ â–„ â–ˆ + +
+
+ +
+ <.label for={f[:terminal_font_family]} class="col-sm-4 col-md-3 col-lg-3 col-form-label"> + Terminal font family + +
+ <.input + type="select" + field={f[:terminal_font_family]} + options={terminal_font_family_options()} + class="form-control" + prompt="Default (web safe, platform specific)" + /> + + Choose one of Nerd Font variants if icons or other symbols in your recording are not visible. + +
+
+ +
+
+
+ <.button type="submit" class="btn btn-primary">Save + <.link navigate={~p"/s/#{@changeset.data}"} class="btn">Cancel +
+
+ +
+
+
diff --git a/lib/asciinema_web/controllers/live_stream/private_badge.html.heex b/lib/asciinema_web/controllers/live_stream/private_badge.html.heex new file mode 100644 index 000000000..82e66d9c1 --- /dev/null +++ b/lib/asciinema_web/controllers/live_stream/private_badge.html.heex @@ -0,0 +1,6 @@ + + private + diff --git a/lib/asciinema_web/templates/live_stream/show.html.heex b/lib/asciinema_web/controllers/live_stream/show.html.heex similarity index 54% rename from lib/asciinema_web/templates/live_stream/show.html.heex rename to lib/asciinema_web/controllers/live_stream/show.html.heex index f4a024dd6..20eb52417 100644 --- a/lib/asciinema_web/templates/live_stream/show.html.heex +++ b/lib/asciinema_web/controllers/live_stream/show.html.heex @@ -10,55 +10,53 @@
- <%= link to: author_profile_path(@stream), title: author_username(@stream) do %> - <%= img_tag(author_avatar_url(@stream), class: "avatar") %> - <% end %> + <.link navigate={author_profile_path(@stream)} title={author_username(@stream)}> + +

<%= title(@stream) %>

- by <%= link(author_username(@stream), to: author_profile_path(@stream)) %> - - <%= if @stream.private do %> - <%= render("_private_badge.html") %> - <% end %> + by + <.link navigate={author_profile_path(@stream)}><%= author_username(@stream) %> + <.private_badge :if={@stream.private} />
-
@@ -69,13 +67,13 @@
- <%= render(PlayerView, "_info_icon.html") %> + <.info_icon /> <%= cond do %> <% @stream.online -> %> Online <% @stream.last_activity_at -> %> - Last streamed <%= time_ago_tag(@stream.last_activity_at) %> + Last streamed <.time_ago time={@stream.last_activity_at} /> <% true -> %> Stream hasn't started yet <% end %> diff --git a/lib/asciinema_web/controllers/live_stream_controller.ex b/lib/asciinema_web/controllers/live_stream_controller.ex index bf89b936d..af05c3e69 100644 --- a/lib/asciinema_web/controllers/live_stream_controller.ex +++ b/lib/asciinema_web/controllers/live_stream_controller.ex @@ -1,5 +1,5 @@ defmodule AsciinemaWeb.LiveStreamController do - use AsciinemaWeb, :controller + use AsciinemaWeb, :new_controller alias Asciinema.{Authorization, Recordings, Streaming} alias AsciinemaWeb.PlayerOpts @@ -13,6 +13,7 @@ defmodule AsciinemaWeb.LiveStreamController do render( conn, + :show, player_opts: player_opts(params), actions: stream_actions(stream, conn.assigns.current_user), author_asciicasts: Recordings.public_asciicasts(stream.user) @@ -21,7 +22,7 @@ defmodule AsciinemaWeb.LiveStreamController do def edit(conn, _params) do changeset = Streaming.change_live_stream(conn.assigns.stream) - render(conn, "edit.html", changeset: changeset) + render(conn, :edit, changeset: changeset) end def update(conn, %{"live_stream" => params}) do @@ -32,7 +33,7 @@ defmodule AsciinemaWeb.LiveStreamController do |> redirect(to: ~p"/s/#{stream}") {:error, %Ecto.Changeset{} = changeset} -> - render(conn, "edit.html", changeset: changeset) + render(conn, :edit, changeset: changeset) end end diff --git a/lib/asciinema_web/views/live_stream_view.ex b/lib/asciinema_web/controllers/live_stream_html.ex similarity index 89% rename from lib/asciinema_web/views/live_stream_view.ex rename to lib/asciinema_web/controllers/live_stream_html.ex index c2740a626..689968519 100644 --- a/lib/asciinema_web/views/live_stream_view.ex +++ b/lib/asciinema_web/controllers/live_stream_html.ex @@ -1,7 +1,8 @@ -defmodule AsciinemaWeb.LiveStreamView do - use AsciinemaWeb, :view +defmodule AsciinemaWeb.LiveStreamHTML do + use AsciinemaWeb, :html alias AsciinemaWeb.{PlayerView, RecordingView, UserView} - import UserView, only: [theme_options: 0] + + embed_templates "live_stream/*" defdelegate author_username(stream), to: PlayerView defdelegate author_avatar_url(stream), to: PlayerView @@ -9,6 +10,7 @@ defmodule AsciinemaWeb.LiveStreamView do defdelegate theme_name(stream), to: PlayerView defdelegate default_theme_name(stream), to: PlayerView defdelegate terminal_font_family_options, to: PlayerView + defdelegate theme_options, to: UserView def player_src(stream) do %{ diff --git a/lib/asciinema_web/templates/icons/info_icon.html.heex b/lib/asciinema_web/templates/icons/info_icon.html.heex new file mode 100644 index 000000000..bd4a60280 --- /dev/null +++ b/lib/asciinema_web/templates/icons/info_icon.html.heex @@ -0,0 +1,4 @@ + diff --git a/lib/asciinema_web/templates/layout/app.html.heex b/lib/asciinema_web/templates/layout/app.html.heex index 582a38d5c..a0f650c9f 100644 --- a/lib/asciinema_web/templates/layout/app.html.heex +++ b/lib/asciinema_web/templates/layout/app.html.heex @@ -7,7 +7,10 @@ <%= page_title(@conn) %> - <%= render_existing(view_module(@conn), "meta." <> view_template(@conn), assigns) %> + + <%= if function_exported?(view_module(@conn), :meta, 2) do %> + <%= view_module(@conn).meta(view_template(@conn), assigns) %> + <% end %> body_class(@conn)}> diff --git a/lib/asciinema_web/templates/live_stream/_private_badge.html.eex b/lib/asciinema_web/templates/live_stream/_private_badge.html.eex deleted file mode 100644 index 57c420db1..000000000 --- a/lib/asciinema_web/templates/live_stream/_private_badge.html.eex +++ /dev/null @@ -1 +0,0 @@ -private diff --git a/lib/asciinema_web/templates/live_stream/edit.html.heex b/lib/asciinema_web/templates/live_stream/edit.html.heex deleted file mode 100644 index f16029e04..000000000 --- a/lib/asciinema_web/templates/live_stream/edit.html.heex +++ /dev/null @@ -1,81 +0,0 @@ -
-
-
- <%= form_for @changeset, ~p"/s/#{@changeset.data}", fn f -> %> - Live stream settings - -
- <%= label(f, :title, class: "col-sm-4 col-md-3 col-lg-3 col-form-label") %> -
- <%= text_input(f, :title, class: "form-control", autofocus: true) %> - <%= error_tag(f, :title) %> -
-
- -
- <%= label(f, :description, class: "col-sm-4 col-md-3 col-lg-3 col-form-label") %> -
- <%= textarea(f, :description, class: "form-control", rows: 10) %> - <%= error_tag(f, :description) %> - Parsed as Markdown. -
-
- -
- <%= label(f, :theme_name, "Terminal theme", - class: "col-sm-4 col-md-3 col-lg-3 col-form-label" - ) %> -
- <%= select(f, :theme_name, theme_options(), - class: "form-control", - prompt: "Default (#{default_theme_name(@changeset.data)})" - ) %> -
-
- -
- <%= label(f, :terminal_line_height, "Terminal line height", - class: "col-sm-4 col-md-3 col-lg-3 col-form-label" - ) %> -
- <%= text_input(f, :terminal_line_height, - class: "form-control", - type: "number", - min: 1, - max: 2, - step: "any", - placeholder: "1.33333" - ) %> - <%= error_tag(f, :terminal_line_height) %> - - Relative to font size. Lowering it ~1.1 helps with alignment of block characters like â–€ â–„ â–ˆ - -
-
- -
- <%= label(f, :terminal_font_family, "Terminal font family", - class: "col-sm-4 col-md-3 col-lg-3 col-form-label" - ) %> -
- <%= select(f, :terminal_font_family, terminal_font_family_options(), - class: "form-control", - prompt: "Default (web safe, platform specific)" - ) %> - - Choose one of Nerd Font variants if icons or other symbols in your recording are not visible. - -
-
- -
-
-
- <%= submit("Save", class: "btn btn-primary") %> - <%= link("Cancel", to: ~p"/s/#{@changeset.data}", class: "btn") %> -
-
- <% end %> -
-
-
diff --git a/lib/asciinema_web/templates/player/_info_icon.html.eex b/lib/asciinema_web/templates/player/_info_icon.html.eex deleted file mode 100644 index 6c3d4dca1..000000000 --- a/lib/asciinema_web/templates/player/_info_icon.html.eex +++ /dev/null @@ -1 +0,0 @@ - diff --git a/lib/asciinema_web/templates/recording/show.html.eex b/lib/asciinema_web/templates/recording/show.html.eex index f7827d30d..411a2974c 100644 --- a/lib/asciinema_web/templates/recording/show.html.eex +++ b/lib/asciinema_web/templates/recording/show.html.eex @@ -77,7 +77,7 @@
- <%= render PlayerView, "_info_icon.html" %> + <%= AsciinemaWeb.Icons.info_icon(assigns) %> <%= if os = os_info(@asciicast) do %> OS=<%= os %> diff --git a/lib/asciinema_web/views/recording_view.ex b/lib/asciinema_web/views/recording_view.ex index 13fcf25d9..0c11488b1 100644 --- a/lib/asciinema_web/views/recording_view.ex +++ b/lib/asciinema_web/views/recording_view.ex @@ -519,4 +519,7 @@ defmodule AsciinemaWeb.RecordingView do defp cols(asciicast), do: asciicast.cols_override || asciicast.cols defp rows(asciicast), do: asciicast.rows_override || asciicast.rows + + def meta(:show, assigns), do: render("meta.show.html", assigns) + def meta(_, _), do: nil end diff --git a/test/controllers/live_stream_controller_test.exs b/test/controllers/live_stream_controller_test.exs index 9c9b45c8e..a71963ec0 100644 --- a/test/controllers/live_stream_controller_test.exs +++ b/test/controllers/live_stream_controller_test.exs @@ -51,7 +51,8 @@ defmodule Asciinema.LiveStreamControllerTest do end test "requires logged in user", %{conn: conn, stream: stream} do - conn = get(conn, Routes.live_stream_path(conn, :edit, stream)) + conn = get(conn, ~p"/s/#{stream}/edit") + assert redirected_to(conn, 302) == "/login/new" end @@ -59,14 +60,14 @@ defmodule Asciinema.LiveStreamControllerTest do conn = log_in(conn, insert(:user)) assert_raise(Authorization.ForbiddenError, fn -> - get(conn, Routes.live_stream_path(conn, :edit, stream)) + get(conn, ~p"/s/#{stream}/edit") end) end test "displays form", %{conn: conn, stream: stream, user: user} do conn = log_in(conn, user) - conn = get(conn, Routes.live_stream_path(conn, :edit, stream)) + conn = get(conn, ~p"/s/#{stream}/edit") assert html_response(conn, 200) =~ "Save" end @@ -75,7 +76,7 @@ defmodule Asciinema.LiveStreamControllerTest do conn = log_in(conn, user) attrs = %{live_stream: %{title: "Haha!"}} - conn = put conn, Routes.live_stream_path(conn, :update, stream), attrs + conn = put conn, ~p"/s/#{stream}", attrs location = List.first(get_resp_header(conn, "location")) assert flash(conn, :info) =~ ~r/updated/i From a39174a9b1217c352e427f44d850ef24fa162e9c Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 10 Jul 2023 15:04:04 +0200 Subject: [PATCH 085/121] Setup LiveView --- assets/js/app.js | 10 ++++++++ assets/package-lock.json | 23 ++++++++++++------- assets/package.json | 5 ++-- config/config.exs | 1 + lib/asciinema_web.ex | 3 +-- lib/asciinema_web/endpoint.ex | 4 +--- .../templates/layout/app.html.heex | 1 + 7 files changed, 32 insertions(+), 15 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index 09ff72b97..19d243bda 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -19,3 +19,13 @@ $(function() { $('a[href*=http]').attr('rel', 'noreferrer'); } }); + + +import {Socket} from "phoenix"; +import {LiveSocket} from "phoenix_live_view"; + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } }); + +// Connect if there are any LiveViews on the page +liveSocket.connect(); diff --git a/assets/package-lock.json b/assets/package-lock.json index f49631cab..52c772349 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -17,8 +17,9 @@ "expose-loader": "^4.1.0", "jquery": "^3.5.1", "mini-css-extract-plugin": "^2.7.3", - "phoenix": "1.4.17", - "phoenix_html": "3.3.0", + "phoenix": "1.7.6", + "phoenix_html": "3.3.1", + "phoenix_live_view": "0.19.3", "popper.js": "^1.14.3", "sass": "^1.59.2", "sass-loader": "^13.2.0", @@ -4697,15 +4698,21 @@ } }, "node_modules/phoenix": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.4.17.tgz", - "integrity": "sha512-La4NCJR4rfx/d0ifP8zsZCBBQ03D2aLf64QEvKP976+iKjm+KTQcGgBY5EGLAfUNnElH32FeDomdA07VVc6Nfg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.6.tgz", + "integrity": "sha512-TOZmJqQaZIWDXMcRXo/qLSBcROFgfA0W/LlaJ9RpETGSYSTouGTJKw5ozR6dII6iPHpOXHagc9kV5WYO9LtTRQ==", "dev": true }, "node_modules/phoenix_html": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/phoenix_html/-/phoenix_html-3.3.0.tgz", - "integrity": "sha512-Q/X9UhxQLMYOuA8cXrDSH7PWTK/+vCpX+rtSheoNaPb/qDVoi+R3GPYKgL9CJ06+VWhcC3kkZ05O/RVsby7m6A==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/phoenix_html/-/phoenix_html-3.3.1.tgz", + "integrity": "sha512-t/9Saqpe8vznZYHMDim7HS32Dd2/rKf3+uxuKKNRADLpGXVIDjselOY6pK8aNaLiY2gnlqsoz6yIpUBuoLT63w==", + "dev": true + }, + "node_modules/phoenix_live_view": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.19.3.tgz", + "integrity": "sha512-naXvQmIOfpV99+g+YdIFz8I2O79R3vFRziSVuyqmZ1bemMV0TZIHAAwOhsMjoXtkvM8GmGbTrxMU8hq/atRrnw==", "dev": true }, "node_modules/picocolors": { diff --git a/assets/package.json b/assets/package.json index 0bd14fef6..914b20005 100644 --- a/assets/package.json +++ b/assets/package.json @@ -21,8 +21,9 @@ "expose-loader": "^4.1.0", "jquery": "^3.5.1", "mini-css-extract-plugin": "^2.7.3", - "phoenix": "1.4.17", - "phoenix_html": "3.3.0", + "phoenix": "1.7.6", + "phoenix_html": "3.3.1", + "phoenix_live_view": "0.19.3", "popper.js": "^1.14.3", "sass": "^1.59.2", "sass-loader": "^13.2.0", diff --git a/config/config.exs b/config/config.exs index 24f70b1a0..5bfd68f64 100644 --- a/config/config.exs +++ b/config/config.exs @@ -16,6 +16,7 @@ config :asciinema, Asciinema.Repo, migration_timestamps: [type: :naive_datetime_ config :asciinema, AsciinemaWeb.Endpoint, url: [host: "localhost"], render_errors: [view: AsciinemaWeb.ErrorView, accepts: ~w(html json), layout: false], + live_view: [signing_salt: "F3BMP7k9SZ-Y2SMJ"], pubsub_server: Asciinema.PubSub # Configures Elixir's Logger diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index 4f0d9f886..6364a4bbd 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -80,8 +80,7 @@ defmodule AsciinemaWeb do def live_view do quote do - use Phoenix.LiveView, - layout: {AsciinemaWeb.LayoutView, "live.html"} + use Phoenix.LiveView unquote(html_helpers()) end diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index 3295a0493..5fc221e16 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -12,9 +12,7 @@ defmodule AsciinemaWeb.Endpoint do same_site: "Lax" ] - # socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] - - # socket "/socket", AsciinemaWeb.UserSocket, websocket: true + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] # compress helps at all? socket "/ws/S/:producer_token", AsciinemaWeb.LiveStreamProducerSocket, diff --git a/lib/asciinema_web/templates/layout/app.html.heex b/lib/asciinema_web/templates/layout/app.html.heex index a0f650c9f..c0ba49586 100644 --- a/lib/asciinema_web/templates/layout/app.html.heex +++ b/lib/asciinema_web/templates/layout/app.html.heex @@ -4,6 +4,7 @@ + <%= page_title(@conn) %> From 9991edf131223e73cf8867a1ecfcbe7763d13867 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 10 Jul 2023 17:25:18 +0200 Subject: [PATCH 086/121] Fix icons --- assets/css/_icons.css | 13 + assets/css/_recording_show.scss | 5 - assets/css/_user_login.scss | 4 - assets/css/app.scss | 1 + .../fonts/glyphicons-halflings-regular.eot | Bin 14079 -> 0 bytes .../fonts/glyphicons-halflings-regular.svg | 228 ------------------ .../fonts/glyphicons-halflings-regular.ttf | Bin 29512 -> 0 bytes .../fonts/glyphicons-halflings-regular.woff | Bin 16448 -> 0 bytes lib/asciinema_web.ex | 1 + .../templates/icons/eye_solid_icon.html.heex | 10 + .../templates/icons/info_icon.html.heex | 4 - .../icons/info_outline_icon.html.heex | 16 ++ .../templates/icons/info_solid_icon.html.heex | 9 + .../templates/icons/live_icon.html.heex | 1 + .../icons/user_circle_outline_icon.html.heex | 16 ++ .../templates/icons/user_solid_icon.html.heex | 9 + .../login/{new.html.eex => new.html.heex} | 24 +- .../templates/recording/show.html.eex | 2 +- 18 files changed, 90 insertions(+), 253 deletions(-) create mode 100644 assets/css/_icons.css delete mode 100644 assets/static/fonts/glyphicons-halflings-regular.eot delete mode 100644 assets/static/fonts/glyphicons-halflings-regular.svg delete mode 100644 assets/static/fonts/glyphicons-halflings-regular.ttf delete mode 100644 assets/static/fonts/glyphicons-halflings-regular.woff create mode 100644 lib/asciinema_web/templates/icons/eye_solid_icon.html.heex delete mode 100644 lib/asciinema_web/templates/icons/info_icon.html.heex create mode 100644 lib/asciinema_web/templates/icons/info_outline_icon.html.heex create mode 100644 lib/asciinema_web/templates/icons/info_solid_icon.html.heex create mode 100644 lib/asciinema_web/templates/icons/live_icon.html.heex create mode 100644 lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex create mode 100644 lib/asciinema_web/templates/icons/user_solid_icon.html.heex rename lib/asciinema_web/templates/login/{new.html.eex => new.html.heex} (70%) diff --git a/assets/css/_icons.css b/assets/css/_icons.css new file mode 100644 index 000000000..afabd4685 --- /dev/null +++ b/assets/css/_icons.css @@ -0,0 +1,13 @@ +span.icon svg { + height: 1em; + margin-right: 0.2em; + margin-top: -0.15em; +} + +span.icon-live { + font-weight: bold; + color: white; + background-color: #d40000; + padding: 0.1em 0.3em; + border-radius: 3px; +} diff --git a/assets/css/_recording_show.scss b/assets/css/_recording_show.scss index 8234fa340..c921d825b 100644 --- a/assets/css/_recording_show.scss +++ b/assets/css/_recording_show.scss @@ -31,11 +31,6 @@ } section.meta { - img.icon { - width: 16px; - margin-right: 0.3em; - } - code { color: #212529; background-color: #f7f7f7; diff --git a/assets/css/_user_login.scss b/assets/css/_user_login.scss index 7f75a31bc..99aebb232 100644 --- a/assets/css/_user_login.scss +++ b/assets/css/_user_login.scss @@ -14,9 +14,5 @@ .c-login { h2 { margin-top: 30px; - - span.glyphicon { - top: 4px; - } } } diff --git a/assets/css/app.scss b/assets/css/app.scss index 466d353ae..ac12103cf 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -6,6 +6,7 @@ $pagination-active-bg: #06989a; $primary: #06989a; @import "~bootstrap/scss/bootstrap"; +@import "./_icons.css"; @import "./_base.scss"; @import "./_header.scss"; @import "./_footer.scss"; diff --git a/assets/static/fonts/glyphicons-halflings-regular.eot b/assets/static/fonts/glyphicons-halflings-regular.eot deleted file mode 100644 index 87eaa434234e2a984c261e0450a2f4ad837aa7b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14079 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 diff --git a/assets/static/fonts/glyphicons-halflings-regular.svg b/assets/static/fonts/glyphicons-halflings-regular.svg deleted file mode 100644 index 5fee06854..000000000 --- a/assets/static/fonts/glyphicons-halflings-regular.svg +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/assets/static/fonts/glyphicons-halflings-regular.ttf b/assets/static/fonts/glyphicons-halflings-regular.ttf deleted file mode 100644 index be784dc1d5bcb92ab155f578f3723524a3dd9688..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29512 zcmd753w%_?**|{foU^;hX0w~U=bqhcl1(6Nvb)J{LP$Waa=$}B<>qo1h^Sl?5fQHy z3@Rvsm7*022$ABYeX&1l3tg19UZPd{Y7=d(ZPnK*Z!eHN`F)=`XUP&m>-+!xexJ{O zH?uQy&YWkSnR(`!XP)Po6M+eWU=cP6lF%}8|&%ddqyBm-N z{Tbxb7T>Ub5&Qa-3;A|IxTbl@!uc_wt`W~KsKouq5?nAIk=G#~L%w9miksK%HQQQ{ zzfTavPj6Ut{ruBkb_@}Og}BCEUNL`N3kwKu2*ToWl=rNhzhYtg&RxKL@zsJLZD?6_ z)6MT)KY6VnEc-dCU%z(Yf<p=6vpVK=EbUm|aev2Sol<97XHI8v zXGLdiXI~kpyFL~$jshU}17x8WWT8XXk=5bpsP3rg7y`(n zIwk?~f{vDsO&zVBtW(#S)#>Rh>8$RIb`I$r)_Ha3q|SMrEuEV>TRR^k$lafGpY2}M zVffuAzdQcBB_By=ogbJ#NcZG;vOPAB$)oq^in@!GqD0Z(i~d^lRneb|eqZ!a(Je(c z7p*8-T(qcYUeVm5=AxNJ(~Bk+jV>Bi)L0ZPiWI)7_7<@IzyG1}62u2Jz_o}yTA=aj zhtMB^C}pn}Kx-Z(Js2;+fVfHxf(`LpH3)XZht(iB1fdxBC(c1#}I^JNDoFl zLJb1)9itFNdk&aVx@ONUs!x zPPD6&a9)ELICrKYjb}Qu5OR>d9kB-ixC{3pEezwwFAxLw z&Rt0VQV>2yL_q+xojbvUAiRb6BoBh{HsUip2*Nvvf5n3!v?KmI4}$Qn!2a9DgCM+z z*ujG!{06a$2SIoraVZai@Bv~!4+1!nz(8B*M*d+UA_}P=+@vm6KQemx|IZ&{%9ngF z6Ta1luR8(*pAzxKdcc-Q9yHt_1fFL?)u3YrS@cW)NIdu6+TkMQK-BSSzbUXicV+ z7LJQfeo#IlfbN;MP!5Nh#M-dlp!XH~1I+J>hHIkui9{peklW?<)dWOeu~{^D4PL#| zD|wXm^y>OyVQ0aZap5CH^Ox`c<=T>=rVnB_>dwaQEggHy@vmD3>0bzs8&jBFKYXyA z-4;{Y^=v0QH|FM{{VloGGiwhoyXCuqL+fHywXyxPx4yD?S+u!2$5A=EDHezTzc_1^ z$B8G1@Tg7lxULP-7V(4vy6^s)Rm!i)R}n9>dqa`hnlfLpA;5gadZ)u}W=@CenE2(o zg9q0IDl1=D`S|^^4>Hy=gPFMtS+t4OT5HM-I`k92rd^Ug8!~3%Oq=!oi6f_)jfpIynerv~O}wgE zdN%R*EO+keNVFoyJvl1fXv~m)D%p*RiPr3#)hjD9neu_m!lbUMtEAt2Y*Aj8D_t8ZI( zOLJt{`Yi{Vn)Yv5Kdf%{+O_MY7e-ty516`UNd5XvcO08O{n#Cw*4GbNGj)JG8eJ@Q zzbuTBcc6cbBu_DWIP5GH!@THQWpxD<2Gj#x+Ol-P&stk*TFHxBwc zkvJeWBhj@X7L&I0#BsWw7=GzRdEABL@;Hz!%_2nV2boGO$>*rR`I`keR*_V}tZ1jV zxD1pW3422>U9bGVy??I2skAr?3Y@IfSs*s2<`M@|bC=$eb9TLQ$KZ#x_MPtP==*wV`EOH3 z&P~?T11}||T=Rc&Tiu<}Jh`;r`|NR|C7MA*OAN~iMnsRfH?*pM8{gs&flJGQr>@Q4eq1ZnwMC4)3ed| zy64ZIe|{ar5b(>Gz(DuUU*zvXsm~f_TF@bu+v0Jhy(ggfg-Il*vU9i&7^09XY-!SfL3is01oMw=+<0u`OONSvkBOPN(&Wm24|CRYu-M^_clmsRI@E6Vi2O5HsTfyq*CrnqKf^Q?^^DGDyGgj_z>R@RGLqE=-UPD8ENsq-cmp9W_2*&+8QgS3U&jTUppg-(K4_w-?!PX4|`0`BFKde7Se8I9ECN%{OeuH_8Iw7?TfQyu)l%()Epc{}6<1$YOh- z|8f9Vl1~KYle{b};mf=k$cS%!U7q*@JNlM$pW{t-H1TOD?_eIam4tLw3GwF~1Y!^} z-^pU_O~Rp$VzfUCGm>aX_+WolK8mx-xbhLZ_2^Lo!uLz(6ceySkD<-zYsi{Mfr(ov z#FbE?s7~UVCf3vF3;+(ZkIsFxckbN1S|p0f;jh1D)4o>XJI|lr8JCY^h ztaba7r!;0sJXLH4rvy)(Om}Y87%d{sy9Lg>vji`oM*&dp^kGAR3ZmE#f(J%w!x(w& zkquVy#3L>DK7W2E@!(TWZciMzBrACynRNbns`l3H*oC+BGYd$1gSCkjicJg;Nn6Tq+tPaP&9fbY?p?QG^)g^U)lME^EH5{Xn5>uv zRcCthbQ3u};0JAd480i?u0oGmp+&$LC09d8?@i28h<&IgX@UAk7AC2l%fh|#a@+M! zfArZ$PhSrfnPJ}gd#3;WR-WwYFs1EHGw~m>xhIYNTjk9tkH>CS+BsXRyyLCatKYhV z=iXOp=plB7epAvwo90GbZk9fS%miMU!@N3cCWFcb`Wh%}qHdb5;Ezvj9kn(22c<|0 z=1V-Dyns6Zqr#F}I4tlo4og=W#e!(?V?L;mSnG&Y%ZANJ!lZJ0`6o$%5A z6$~H5XaXsLdWjWxZQz|tiVbWb#S^g@zi}?kx0O^PaR5sksL{h8B#Osc6^pS-6y!1t z-KG_c0I5_?WXjWVB77`C0E0X9N$$~z7hXOe1-sAMkd&T~4x>?4OukyeKg!$Ss|6H5 zgB~bOk%}NSOT8$!b!AJRrG^W~W3lvW_(!D??CLo`Fkp;@bdj&gQl!RTR&3Ba+^!HQ zcM>BYMw~rfP*6Cvkbcl06VyMyHCmL{3Z@kl7Saz|0P59!h_)Coo>-$bXk4NXvs9SR z6HF}jXQj^+Q;59=KB5$x&J7=^@jchhecIDX(a}&ek zaq&bvo@jmCXf_+^N9}Lu{ej0(tmnmo;H@o#*0YK+AJaokW}(q74zR({(gF=9v%Bqb zTXDIqP_I|+xK6n-JKxmLVqq&Pno8`~vU{gw^{-X79}C<(l=ZU*%$d@sUAF2xQ?9`< zbf_y*`R9)Y%p5AFv(pbMKjVFXev^KNx?$@i#U6B+n8{|*!U|=?=#N^iqzg!Xot4&{ znled^`m-4O&AK1Ey~P=(w7d~D{ntD@Q886Ci0Q79B3AjGaW@>;{k>V6ZlCj%e6;Ps z=ylQZG=pRcU$tiBwC&?(8N%gKL%zEp(_#oIci%RC%KWbF^QX0NGgLlcYIBh)+oT4{yo9ax;B(`_Zh3EE_-KeH0}s1>WWM1zi|8vM8yb;}!f zhO(RiZ!uU31~)ERJQg?5Gr9D$Xe*Xm5Hp*qC}v^p;w z*N{S;G6K<5kG?@5T>?=z=@LN2k=}Xf-`uBNVd4PSA2h4_n67NfNuN0j;swsG4xaJg z7L*Pbj#Ew^=PZz3RJW3j!b0VUbGT$csKSDU|GP+LcF9pJrBsJ=9lH5vrwS)Ti|K!5=NyGy*{4rGE8dDr?fg=uqmT+G`HiEHcE>4gPhlm$92*;Zd%Ul{ zpmt$35ulqOKA6%j;t{EBA`5A6KB6PRvexkL+I708Ne}>H@zhp9`it*R{N>86N@>x- z3&+I=F1F%dHA>wNv_XcqkjF)D`$D=XZK*6u*orDEi^MOB_}+k3N>3)%@GB4CHv#nt z?eKeKAnG4CEE<Mp%Hx^%i-A(-muYYU(^2Z)~Z|7t3D;wYa+m6+L8#*+-c=@Wm zW509ThTq(o7(us|Eq@Gk^yo;icf3SH!mP#63-wZru;#W47kX(!x~`LE(6$}Vi^47N zi~60;0vj61428fB)@M?iHc3)I^p`;w$?chLv7dAF#F^sX6=eK$oe@it)27o_nti2wO;QUQ$BiYO?c(b z$y08CxwPs&TMntO#Z)Evb|%dVLKxVcG&vO(48(u&^5bWy0(G0UOiUy_ndu-2YWw~_EjnngQRBr9$MJm7l7k%1~8!AYCYpA$= zT8QnrQCZI0jvv?|#|imD02riJ?se-8q?N#qnQE_vj^0^p))|_lA|{W!SiMfXd;0cd z^)uNLWtSoQ>R~g6)n^ngUOcz3fSs&O;xNh6oW$WSsNtI47tQYQuoc6~YGD7wM5eJI zeD(vM0&uBb_>k(Q2OsnXw=bliQaNbYG3DtbF3J~TOsU_U;tY z<)?53WlkyY6HG4WZb4hH%kt7RPE|NKt$?YRQdX67>@#HyaYvH4pnf0A{>X7t(qyZ__dbhJ@DNS8g3wYhwr*rrmI;~1cYLv&N zili4|Knm6RtQ`GL?L(L0OWR9m5@8WgvY|ynH;~r?jS)Uvj;65>V{deEnD}#ewk9Iy zCf9fBXLQlI0$x2AkJ*d7qcy02{DKo|6UG&+pQ&SiIoz6vG^GdTW$-wL91iKx7v;xf`du&bMkZ0 zDWdmMHLyAu+rpSOw8C-)tR1@fFQA+MV((ry8G4I&Tz;T0q~q_+N!MMs!}?LK-r=mm?8D1TwQF%q;k^xz(Wtad5na1(q_0unK2 zkStczCfz_zWDaN)WH<4v-qlWy>udvx^L@eL!MvsSw8|EPUet-{vRSrEc2}BPXYm(g zv&%;%@khy65o!*F$CYR6Tka6`CZj9kVuwa~skwI_5y2mv$! z-JPnCPwkP(WTGLx++|&IKk2l%j*I$4T^mSmmP?up==#je0EHj9kky8pq-br}Stz=7 z&PWt_T*W<`T`RY}k@M25_=EQqzV@1>--zX-JXZOU(U)SQmzEE*jjyE6N& zx3gD`g#u^M0q@C^d5_&5A2e%fG&3G|OuB1C{8!cAjgMLGKJ!NQ@~h*cS7iSRZSJu_ z*h#iZZFAC8V@Xlu@NclqH;?>(4VU1(nZoUN}no& zm0_%$RVIri4)D5v!PgFGvP-RS2?GsUQT^PuXEyuvBk%v?9m|r}*nI83TRc0zJo0Si?GC#&vwQ=pj z{(yY4dP&pJ#?dy)Z7*cxo|-))T{LB}?+ui*oxgTu%L8SfBjWJcz}k0RyiJ}3 zi9fP{qoBZ{yp7*GW3&qKHMb2i?*RCJMWOK*m~Rk+iJu%R;mBt|lIY3;x!b|l66o`x z`45*y3ngC#D~3c4n^lEKl(9+_i!&Pio`U~!+3e0Qy#@Y8qfZo9k%k;xMd|;#&g`*? ziGM18l!|S({bY9KbkrhkVMa&VVSlx?HPe-CYPAK*o=JZH`+*V;C0TDDYsM1yCu58e|qLKI0(-%dwMusZ?{BW7uS~!p1WyU$dRrq$O+%%@ti!fDs$>k;3swe zOt@YCLJng`F_`?_nZc|t4(Q-K(WDO*>fA!8NseMOmUNMb>J5dmojfPNFy$|D_4y+w z-n8bC)<@RdG;w6UKDYOU#E4C6r_8FnI)g#>?)Vygkk?ECJTFS%MHY_o-(WN5>=8Ty|-h$Id&pc$D*Epw+{chQY zVN0{;l?XE0BA_j8*p~%_Iwt+j4c|pi=htTtn&Xg^!Fba}B5}uC`aP`ThOF?hIrm0;S6zLX+Np z0?ny%7Y?+LA@d>U!o}(U7{rfO#X6ylmv_je&z+2lizmuw_4`LL_<14{$byGpU)@TQACXCAB4nM?DW ziH(jrM`EKhPs)lb``Ih(6=gq`!ciXC3xQYiu;mt4wpG~`%eBw>XpTKMrtGq2yDV&Z z^M+>e7s`K_gN_PErsFZ;;`~2 zxwpvUkUoIjF*>TDLTs)8#{sSoT)4jm+2IDD18GGdc8~qP4wI&ldEw*jB7dYNy}zcB zsYX6>3}==4Z2$O$Prmx(!twrWJ+jv6{@T)piXv+Uq$4mEGyt`DGy|H?+ zGWgPESV)nOk97V1H|+LPtUv4j&!6MB@(p(9Z{Us93WF!S2mZkFuxREfe*o?xJe82Hr(qPEN8kx^iW9sEp$L7-p|E;n{Bi2 zvy#pyDGQF%e0CsNhBZGa_()+(I@b@B`Xs+6I7`zaOxE6$NHT* zrMyS70w-*kkEuph1({|uFApmalndC(z?%Yh)sn30QSn=)9wlT9|C z7p2S$i#{I84rOMZ7Y$Aq8qVMy;FR~sdx&Q;gCBc0e918)>Lw2fe-y3~?3Do>6aMtW zAO2}V$AI0tk^b}X{UV7&Bo#vg zBX?XFBhgMM!+9hbyiUpI_gM!s_^O2AlM~9THqYDch&A4pbv{t~WkI7~c{#t)599Uu z_wI}BjD=tjmfOnnPyIZ%RB0I-t7pwc{bQAr*BEwIPFB9?yj{6J#@4pK3+4xbmE)uG zG_n(ezP#vpcsoK9*ucoN;kIkT&Ld86et47m;G~ zADaJ({++k8wK3)X_IEjdOamWr%G1$5johcE6eLl^xF-lmP-O#TQRiMXI9BBL+MBqb z$ZZAvL{;fK7~&{RjvLrAbB5Kl!kjUk1*R`wF>U!~L!L!BWOz2;JTS&e@6zX4-pI1q zvXm&xkkciDEQ>nhBQvN0($Y`$rWUiqW?nz8b%OGo%fByE%(RvouU67$v8m4TLZ_pE zF;UVF-)LZRHKriVX9L%&d%Swi|U!2ZYn*45pNP zL?u}1GUcH7DWu^^pURnjYvSw7@0B~*)CsNQ*!rw2XXcHjXI{>*WTXRS5vL|99LjUE z*x$ZT5toGdv^MF?kTd!IpS*khFnN*g-0ClbWK2@INQzm5SAyFsgwR2B+9pE8;d1M8 zh{4F?%ALw{sB*of)ZF6A;+Tk;nfqQ*(m$X2k}F58JQO0#uwVLs&Cpu6e7f@XG!x5Q z=_*oo==9IZXyW$4b>R zK%~1PJAV=663FfjXf0})6$gWek%4{&k+fC@pI)4R36hHqo9d|8mznqmV{H7?;%dn( zv#e+1TPJ{}9(I(6LXttB?Rt6Y7wqryq@0Gv%w!qVgd0{)1GKZ7 z_4$_9T{fGG#WM_9X;P-`;Tdcyts_`V!2=G#PZjG53ne{FiM!b$u0V$)UbF9_2Iup= zbN7CD3uo@^VP&O!Xs`0Qrq;6WyY<7pa~0d^*H{_rcX5q61lU=ebHS6->EQ0G1RP=z zB%@k!Iz5$y0^rK$*tG_51ndwpx9;N_GZl2=IpyqYr%$Hf+!tJle5AradOe3rN;i)5 z3sA3J0V)?#mt-~7zm@ZnWItyK_X)eGr!VOZc!5AX zg{27FCGFSYGQfHS@vBgby7Y+QtwLlj(oO|`bV5)M+YIS{A`qgHjz(x3P{@jKyaIQk z*ou`!NkJBcdrQPml!uajy#dxoH!fl8<_a}k-d7J>`sX&KSsE=)7=Yke64a&T>5G}k zm7SJ7&DB(2kQR{o4bU^)qP2y^KFJ)&G>^2VH+lkDp)8r{D`YV(C)aJaXXvx^<#~Ej zx!G)&k^nocByC=)a(kt^zOj537v}RzN(0lyn zm~46@Lq8e(mJGL{_(r#PZGQU5oD92cDom>?lx<@iqp(3Vn#9!wB~3+;4-HuvOw7pe zxy33mGfi@p*$Q$B@(Z){j2VpfQtV1cJKg<_=6;TxbemmD&v5&l9z%tcDe2@ApUWgI zu?79IsFzJ?rV@kEL@G|wo(S_WXAWyNSHHT0Cn>zQRC1Z5LK}eI<#0_C*SWMJTQQyC z!A1g#c7c@cy)S`i<-@6R41~5Gq2`hd@a6vKnygO}8+fA|y9EOoG_pf5#O%XL4JnBn zv9VgF$X}#eaexcMI)~%4R_vPmvX|DntAJ1@LNTAcW{f$II_`Jn^y0m!pXaL+nns4xzAU+VF$c{P{P+RK+NU6f1Q zYTj>1Zt8K8Rx46lQ$qe;yfiyTuJ3&~$tT`*c|0z+$HN>f-Q%W=*%GyeuMSrf{Vh;L zx0K?5hwjJ+F7u>UJ*FS<1U%kK?=)sMySzvnx4Q~T!r>B6P-iYupXF6RtPzDtLPY+V z+ziQ$I9CgF&z+ETryz}H; zf!Q~V8hPq=_Nu9AWOM$gc~cG@nYds?-i)i7T(ehQ%ju-P`)hfv{1f0tyB*jFpuh$5 zp`)yHz!ryp8E|pKXD}R!!od;O{028Pt!Rb;ci4a0m$tLJ|323iC@Szphi)Bu-P|F{ zABGNX=P8yqbm&%-VQIT^8x<*t4rM#7{DFD4Ky86#p47VSCsL~NkC z4~9!UBu?cAGa4IbG{&SKIYWWM!a&H`HHx+i&%p%~*BfU5JamLMh&7!;6|{6$p+~H4 zavao?;+=cyg~3X#etsC1aSgoe_63*(XKsubddY1ipF;7(km5m;qUFbS#~zWwf7D)OqeL!D+ezfdi7Z40<)zxj4r6mcIpk{o62e1-9tt} zB8dr$q(@<+x|&9l-05kR0ZlG1f2BXEQl=*PNoBQy&IMT7t#iJg+?&i z(t=RMM1Mc`+ado9cXm|oG+Is8^lDSdhtFm^jOkL7GFTnT=$7+u)z>^NLg8)mK8%_{Gm zf;s@Z#nbp>mDk6vhh+wK8&%IimTZ`C&f!uE)Kc8(`I7pwpu^+dugUt7Rn)3=K$(lf zdF0|;>r1KcVl}7-U>Bkeu2+FIo;I%Ju?dw0s-{yRGVdEYf1}6F-i8`s-BvpWt+D#t zR0VJ0#g5|Ur8t_Tb(RON;aCI67!~gYk6LgM-bF|fhpfSq$HWNMLO{LP`6?`cR7^B} zd<^)WQx6RpjY0}kz=FHGHyJKs3EyK<5~!z^xdECFEi6?WTl)RCumKkisA@nxNsNyW zI1MmWL5>YXHoakka%evSoe9|q1co&{$z^EIp-ZvMBVR^_mwjJ;@ig~P5o=Yq6LL?1 zCQiHheFmo#EYm&rs0z{__S6IVgsz|OF0s+!HA=l|(pgJMANTYZU+yD-f4Qm$UV}1< zjfa0s<#&Sy-3p1+Yu9l#wWLEQgB?F05TAd9L z3Q0E6h@%nayB*5GciH?M?A)4@6%t1Cw3@Ly~}3oNPOqEN2!mgKX09o z^rl*X_FZaMCdVP5k^Uz1xEvj(Wj!J7I_e4Pm@+m`xn2+|vVA`Fx$sPZ5@$yKNm@kF1+Q4>cU8pW*FUVaEn&urJfoWAG`zW{W}K_ z-jV$4RjKmL;)CqrcvoTa{-z%sBvMgnn)JoAYWLMn>PW1uszin{GxgL8Q3XN)_ZzIl z2J@0u@{S}!042UvJ>adVM-|<~*~-eEdbA^91dG(Zm)5f~{*+94mJkr zP3Y@1&u=m5@`+jCgfS)cOa%@xg94;2yvm)i#9400DMNMCN2D8A1eiyVBKbx=*9VFq z17HP%hfbI|k=W>fc*`&gcU~^*NL{0?m$7`>k9pgW8TS>0+c}^+N&oFY&L^^K6 z6R}W;|H)H|?ABYdMieQ#3TnOCdYy6;O3RNxUV1~hirUTo*BgW+jhp&QeULn>HZEyL zp_Ry)ob6#s7fK{ws7JqmmzOqd5VeZ~k~|J}5*Q0|6jRPvoG~Yh39dk0pTo}OjKzzp z=*lu_ohyflb#lW*L}&$>;Yv>^0GEAs$7+{CzW!GhaczY+)f;$ zB>i%#oI?YzD|PDd?xzY^e^AWtjfzjhHo)B~{7VxDu)MYN6$~#Lpac6j7D?VYEzl!V z`lrmV%+$)0`7OR+0md&WSl~giAnv>S>AM%i7bx%HHu^0~$dbP+KSkCqyFriLW1$p= z%8r~t&{<{JVPnrmP9i_t$5>I*!;2Qb_1JAiMNenx?XTKvverJdVdKIzR=xQ<<^l5d zeHs1lf2e)Y;)ff(Y@fBte4kmiu35ZcII9_)YY-LSb zc>*1?!t5+`(4i!}f@6i~Dx1wx~S9Nu`hxbm1Cn_4qy3FNC?n9%a_bu>#r&YX&zx{%*L`kWNWPLi`2`d}6 ziJYg_dSOALOWv33L#8Ia+=B-ETvGcZkFRRP5H8BK z$=)FEN$LbO?z0!D5BNIMyJqwNRjIZ=)~ileQWm(Z&P)~_01CgXze!IDXw;RxYhvei z;sg4;w14UJ37x_1qh%5ppdH?WL|L$T>WOprQ70_#vCS2c`m)XJ+~%_SNX6#fRZ}Br z&6~D)#*EF=XpUTpLlMq*z&EBZ98zhG?Dl+h{GQ>}g11{k04f}c%@ngcGopd#q;X!9C z=q+q19yF>PNIn#(8&i)IL8S;*AH6}zixiGH)70V8;Nl(-MZ!j48?QFs0}R3Q>`Gcno>A@aRC*P*9qwX?+$2H zzCK8QkWG2~HKZCgXDkQK#w$Oh8@mU<5sP50$3R8p-85g}!p8du_BtRBbuBjsxSXn4 zz~zRvmXz^UgI7Eeh>Tg99%{I4R_-HnZhl%cr;k}$UnMUcQ&)+q2EgjLbWC=UXHnzq zyY#beeEMcNOA?okscm*OoVdj+B*} zHlUGVD@=kA=?}^C2(Ci3JklEhR6CaR83ZQU1z;&u4OL)hD1(A{Ar3W~@5`*HQ{@io z+Y!k-wqQ-ztp2fffAUUXR6L7+JC-6O9jUlT#Eib#fUdyQOpcGB$RqCK4?!3!0L zvt0b^>PX4pYVSPX6%efxpoES5fy6IS?q7V+Y{uJ8ay)k6^d?V(z8J4ZfSnCTQ2bt) ze`;XQlI~%77K^!`xkUL>`4z$t?|~@xW1{msi_%ef{F&bFrv0U3OF6A!3n}X z7$wTIDjig)3HXQzD$VC`nTJc8J#tS2$Q+Xm`zE}VNE14xEqvy5ZJ@eiYo@TuDQmFE zRq}0{=n5@ONV7dcvxXS!Dn<7&P%Z3k*5`$ zUt!j=3&rpmfcJo0W_9G{+FVl-=l?ozpe;AgVO=xWa_dx^-sYI&!0*&sErXShZU~y{ zM%HD};WkIPAw54(f!FR-z$NZEHfsDvhsU1lw3piN7_a8}qqHqs#$vf*LgKabtA z0B)b$g~i!x>^1d-8#|$lkT=p?LOU4V&h)2vt!~6 ztFFjpOt(l1`o`_H(X{!td&#HqS)X1~Q_0^&EOhP;}*a(7OaYz&N_ z;R&omD8Wn;RVn4 ze6S;}Xwi!OoCk>T)4H4MAEPdKbKrHp*!R^$85}txZk=@eLgq8KZB87v^tY_CSj1-U zgn7?wQxcMK@-9Nb>VIds!$aXej}+OU;W9 z(vu)>EoR36awH!8KnqVJPxJ9=HKu!bmY#<;2G(Z|r~4atAtd3Gz6)=MrZU|xtKs6k zWEqMJ5SD3Wsl4`#kc%|Ihg8jD88G%BP0!FZR;9W9xL!5!)n75hBJoqY1L`B zrtM1?(#z6Erf*39hq2B$$M~@Eu<@&mK*qX^XEQoXxu!Lyw=)Bo_n1TG?^@C<0m~xG zz{3ATeWSt?ONM?w!^lM>_+% zbmTfFIqq|O*Kyntcl@X0AI^MdlXIQ(Jy)6QLDxBViF=Xz3HOO?A={B%o;@l1iR_oN z&t`v}W6T+v)0%T4SI!-mdnC`87t8xe-skz*`NQ*97c>_fD|o$7EL>N3swlr`LeUYA z%TwdI!SjsgjOTCO67Ll6J>H*q|5jXGJg4~a;xoQ9-w@w2-=n@0zRyeYOClxnN_LjC zm!_2tDqU2%r}Q(ND%nzY!k_OS?qBCWQ7)7ZEWe@rNcqqv_{SprSmSGU=(9=c zWimXY@LpbJe3qJtrOO8Mq-(Ua9cl80rZRECB_?q=EmVsSuU)$~fd9kP@0DAH|KKs7mtT(l z@W8L-27Em!5N_hRg~Cn3LR?*g-xx}cLd$1iUS2JXMy(Tt3BpvAyBe@=5EdaU1^mT$ zW(vwL##<$B;I#ztWHra7L70x(XX3erK4D!BX+SSn-xdQ;ujgj)cH9IESMfeb#c2|6 zg^FPhrb|%rX5o5XehpfwJ`sSgUp25_ftD=?Oe(Vo?W49YK#vE6S{~}q?;-H7zVQ9` zt?YZG`o6kWpl<;EeFH|h1>?U|!}=y%CHzKbHjzzYli3tDl}%&Q*$g(5HM3c4HoJyh%dTT{*jzRb=DY>$db~z%AzQ>2 zvn6aPTgH~-9KZ^;lC5Gb>_)bl-NbHYx3D#AEnCOdvs>A1Yy-QUZDe<_P3%s#ncc;< zu)Enk>|S;syPrM4zQZ15TiG`D5Nt-<*~9D+_9)wdfA;Yhdz|gUy0e?@VNbH}vZvTy z_C2eZR~ldb$-Z>vlpOSdWpTve#Cyv{)3%> zmHQ|7M+>jApF#@%8T&aq$xg9fusA!-UT1HxGwhe_SM1kV;of3zvv*iKdzZb(exv7X zDX2yv!!0Y9R##tDO>wBYIvEGGJim|YVJ%;y#kE=-(c-8U*J*LR7GI^tp^<7_J5nBT z%j#7;6RB1!iB_wHqt(372n`9u{61oi1Y(W^VqQ67UO8f3IbvQpVh(Rab&xj(u?8oo z!3k<`g1j-fufYpy@PZn=paw6f!3$~dLK?h~1}~(+3u*8|8a$kMK&OtV4r%a08oZDO zFRZ}}Yw&QagO?9$aKaj#um&fr!3k?{!Wx_!4Ni>)r$&QQqv2Jf!Ku-nuhE{b(Vnl> zp0CxOuhpKf)t<-ei8)@i8k|}UpIQxGtp=}FgBQ`@MKm}O4NgRZ6Vc#AG&m6rPDFzf z(cnZiI8hC+s0J^p!Ha6}q8hxY1~00?i)!$q8oW9UUY!Q7PJ>sc!K>5Y)oJkRG(REOx>!3#0L5;418eIo9x(;e|9n|PLsL^#$qwAnX*FlZ0gBm>tHF^$e^c>Xa zIjGTdP^0IdM$bWwo`V`a2g7QA1U0%2YIGgc=sBp-b5Nt>phm|*jedhQYCi@wIu2^| z8`S7GsL^jwqu-!Lzd?lBXP@~_VM!&&`I<7&Dj)NK<2Q@kl zYIGdb=s2j+aZsb<(Q#0tzL5+@s8XX5UIu2@d z9MtGIsL^pyqvN1P$3cybgBl$NH98JzbR5*^IH=KaP^06ZM#n*oj)NK<2b1($ug-@c z-fc?!0jq@mmf*;mp~HAItX7S*+z6f<8KtN;7*eAeHHz>k#2=^)MM>6RliwO!E(re{ DlhOCh diff --git a/assets/static/fonts/glyphicons-halflings-regular.woff b/assets/static/fonts/glyphicons-halflings-regular.woff deleted file mode 100644 index 2cc3e4852a5a42e6aadd6284e067b66e14a57bc7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16448 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 + + + + + diff --git a/lib/asciinema_web/templates/icons/info_icon.html.heex b/lib/asciinema_web/templates/icons/info_icon.html.heex deleted file mode 100644 index bd4a60280..000000000 --- a/lib/asciinema_web/templates/icons/info_icon.html.heex +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/lib/asciinema_web/templates/icons/info_outline_icon.html.heex b/lib/asciinema_web/templates/icons/info_outline_icon.html.heex new file mode 100644 index 000000000..4bdbae512 --- /dev/null +++ b/lib/asciinema_web/templates/icons/info_outline_icon.html.heex @@ -0,0 +1,16 @@ + + + + + diff --git a/lib/asciinema_web/templates/icons/info_solid_icon.html.heex b/lib/asciinema_web/templates/icons/info_solid_icon.html.heex new file mode 100644 index 000000000..835e368fc --- /dev/null +++ b/lib/asciinema_web/templates/icons/info_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + + + diff --git a/lib/asciinema_web/templates/icons/live_icon.html.heex b/lib/asciinema_web/templates/icons/live_icon.html.heex new file mode 100644 index 000000000..7fa6a8288 --- /dev/null +++ b/lib/asciinema_web/templates/icons/live_icon.html.heex @@ -0,0 +1 @@ +LIVE diff --git a/lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex b/lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex new file mode 100644 index 000000000..9b81149be --- /dev/null +++ b/lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex @@ -0,0 +1,16 @@ + + + + + diff --git a/lib/asciinema_web/templates/icons/user_solid_icon.html.heex b/lib/asciinema_web/templates/icons/user_solid_icon.html.heex new file mode 100644 index 000000000..447f6ef63 --- /dev/null +++ b/lib/asciinema_web/templates/icons/user_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + + + diff --git a/lib/asciinema_web/templates/login/new.html.eex b/lib/asciinema_web/templates/login/new.html.heex similarity index 70% rename from lib/asciinema_web/templates/login/new.html.eex rename to lib/asciinema_web/templates/login/new.html.heex index 3552e8986..761e8cf8e 100644 --- a/lib/asciinema_web/templates/login/new.html.eex +++ b/lib/asciinema_web/templates/login/new.html.heex @@ -1,32 +1,35 @@
-
-
+
-

Log in

-
+

<.user_circle_outline_icon /> Log in

+
<%= form_for @conn, Routes.login_path(@conn, :create), [as: :login, class: "form-inline login-form"], fn f -> %>
- <%= text_input f, :email, class: "form-control email", "data-behavior": "focus", placeholder: "Email address or username" %> + <%= text_input(f, :email, + class: "form-control email", + "data-behavior": "focus", + placeholder: "Email address or username" + ) %>
<% end %> <%= if error = assigns[:error] do %> -
+

<%= error %>

<% end %> -

+

-

First time here?

+

<.info_outline_icon /> First time here?

<%= if sign_up_enabled?(@conn) do %>

We use email-based, passwordless login process. Enter your email @@ -34,15 +37,14 @@ you'll get in, and you'll be able to pick your username.

<% else %>

Public sign up on this site hasn't been enabled. Bummer! Try contacting the - administrator.

+ administrator.

<% end %> -

Coming back?

+

<.info_outline_icon /> Coming back?

If you already have an account then enter either your username, or the email address you used for the first time here. We'll send you an email with a one-time login link.

-
diff --git a/lib/asciinema_web/templates/recording/show.html.eex b/lib/asciinema_web/templates/recording/show.html.eex index 411a2974c..37b7feeb6 100644 --- a/lib/asciinema_web/templates/recording/show.html.eex +++ b/lib/asciinema_web/templates/recording/show.html.eex @@ -77,7 +77,7 @@
- <%= AsciinemaWeb.Icons.info_icon(assigns) %> + <%= AsciinemaWeb.Icons.info_outline_icon(assigns) %> <%= if os = os_info(@asciicast) do %> OS=<%= os %> From 17c13232389a5b0e4982d4c774ef9f613925b6ac Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Mon, 10 Jul 2023 17:25:33 +0200 Subject: [PATCH 087/121] Change live stream title fallback --- lib/asciinema_web/controllers/live_stream_html.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/asciinema_web/controllers/live_stream_html.ex b/lib/asciinema_web/controllers/live_stream_html.ex index 689968519..9bb169495 100644 --- a/lib/asciinema_web/controllers/live_stream_html.ex +++ b/lib/asciinema_web/controllers/live_stream_html.ex @@ -37,7 +37,7 @@ defmodule AsciinemaWeb.LiveStreamHTML do PlayerView.cinema_height(cols(stream), rows(stream)) end - def title(stream), do: stream.title || "Live stream" + def title(stream), do: stream.title || "#{author_username(stream)}'s live stream" defp ws_consumer_url(live_stream) do param = Phoenix.Param.to_param(live_stream) From 5f59e7f15093ba4473bd4c6f9f5792405129ee92 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 09:50:52 +0200 Subject: [PATCH 088/121] Improve recording metadata display --- assets/css/_recording_show.scss | 14 +++++++++ .../icons/terminal_solid_icon.html.eex | 5 ++++ .../templates/recording/show.html.eex | 30 ++++++++++--------- lib/asciinema_web/views/recording_view.ex | 7 +++-- 4 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex diff --git a/assets/css/_recording_show.scss b/assets/css/_recording_show.scss index c921d825b..e79e359ac 100644 --- a/assets/css/_recording_show.scss +++ b/assets/css/_recording_show.scss @@ -35,6 +35,20 @@ color: #212529; background-color: #f7f7f7; } + + .status-line-item { + margin-right: 2em; + + .icon { + svg { + font-size: 1.5em; + } + } + + .icon.icon-live { + margin-right: 1em; + } + } } } diff --git a/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex b/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex new file mode 100644 index 000000000..fc907555f --- /dev/null +++ b/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/asciinema_web/templates/recording/show.html.eex b/lib/asciinema_web/templates/recording/show.html.eex index 37b7feeb6..6e3ff471e 100644 --- a/lib/asciinema_web/templates/recording/show.html.eex +++ b/lib/asciinema_web/templates/recording/show.html.eex @@ -77,21 +77,23 @@
- <%= AsciinemaWeb.Icons.info_outline_icon(assigns) %> - - <%= if os = os_info(@asciicast) do %> - OS=<%= os %> - <% end %> - - <%= if shell = shell_info(@asciicast) do %> - SHELL=<%= shell %> - <% end %> - - <%= if term = term_info(@asciicast) do %> - TERM=<%= term %> - <% end %> +
+ <% os = os_info(@asciicast) %> + <% term = term_info(@asciicast) %> + <% shell = shell_info(@asciicast) %> + + <%= if Enum.any?([os, term, shell]) do %> + + <%= AsciinemaWeb.Icons.terminal_solid_icon(assigns) %> + <%= Enum.join([os, term, shell], " â—† ") %> + + <% end %> - VIEWS=<%= views_count(@asciicast) %> + + <%= AsciinemaWeb.Icons.eye_solid_icon(assigns) %> + <%= views_count(@asciicast) %> + +
<%= if desc = render_markdown(@asciicast.description) do %>
diff --git a/lib/asciinema_web/views/recording_view.ex b/lib/asciinema_web/views/recording_view.ex index 0c11488b1..542096f0d 100644 --- a/lib/asciinema_web/views/recording_view.ex +++ b/lib/asciinema_web/views/recording_view.ex @@ -183,6 +183,7 @@ defmodule AsciinemaWeb.RecordingView do |> String.replace("-", "/") |> String.split("/") |> List.first() + |> String.replace(~r/^Linux$/i, "GNU/Linux") |> String.replace(~r/Darwin/i, "macOS") end end @@ -191,7 +192,7 @@ defmodule AsciinemaWeb.RecordingView do defp os_from_uname(asciicast) do if uname = asciicast.uname do cond do - uname =~ ~r/Linux/i -> "Linux" + uname =~ ~r/Linux/i -> "GNU/Linux" uname =~ ~r/Darwin/i -> "macOS" true -> uname |> String.split(~r/[\s-]/) |> List.first() end @@ -199,7 +200,9 @@ defmodule AsciinemaWeb.RecordingView do end def shell_info(asciicast) do - Path.basename("#{asciicast.shell}") + if asciicast.shell do + Path.basename("#{asciicast.shell}") + end end def term_info(asciicast) do From e84efabb70939327f5fe27a369929b746ee025c6 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 09:55:57 +0200 Subject: [PATCH 089/121] Convert recording/show to heex --- .../templates/recording/show.html.eex | 133 ------------- .../templates/recording/show.html.heex | 182 ++++++++++++++++++ 2 files changed, 182 insertions(+), 133 deletions(-) delete mode 100644 lib/asciinema_web/templates/recording/show.html.eex create mode 100644 lib/asciinema_web/templates/recording/show.html.heex diff --git a/lib/asciinema_web/templates/recording/show.html.eex b/lib/asciinema_web/templates/recording/show.html.eex deleted file mode 100644 index 6e3ff471e..000000000 --- a/lib/asciinema_web/templates/recording/show.html.eex +++ /dev/null @@ -1,133 +0,0 @@ -
- -
-
-
-
- - <%= link to: author_profile_path(@asciicast), title: author_username(@asciicast) do %> - <%= img_tag author_avatar_url(@asciicast), class: "avatar" %> - <% end %> - - -

- <%= title(@asciicast) %> -

- - - by <%= link author_username(@asciicast), to: author_profile_path(@asciicast) %> - <%= time_ago_tag @asciicast.inserted_at %> - - <%= if @asciicast.private do %> - <%= render "_private_badge.html" %> - <% end %> - - <%= if @asciicast.featured do %> - <%= render "_featured_badge.html" %> - <% end %> - -
- -
- -
-
-
-
- -
-
-
-
-
- <% os = os_info(@asciicast) %> - <% term = term_info(@asciicast) %> - <% shell = shell_info(@asciicast) %> - - <%= if Enum.any?([os, term, shell]) do %> - - <%= AsciinemaWeb.Icons.terminal_solid_icon(assigns) %> - <%= Enum.join([os, term, shell], " â—† ") %> - - <% end %> - - - <%= AsciinemaWeb.Icons.eye_solid_icon(assigns) %> - <%= views_count(@asciicast) %> - -
- - <%= if desc = render_markdown(@asciicast.description) do %> -
- -
- <%= desc %> -
- <% end %> -
-
-
-
- -<%= render "_user_recordings.html", conn: @conn, user: @asciicast.user, asciicasts: @author_asciicasts %> - -<%= render "_share_modal.html", conn: @conn, asciicast: @asciicast %> - -<%= if download_filename(@asciicast) do %> - <%= render "_download_modal.html", conn: @conn, asciicast: @asciicast %> -<% end %> - - diff --git a/lib/asciinema_web/templates/recording/show.html.heex b/lib/asciinema_web/templates/recording/show.html.heex new file mode 100644 index 000000000..b6dd7cb96 --- /dev/null +++ b/lib/asciinema_web/templates/recording/show.html.heex @@ -0,0 +1,182 @@ +
+
+ +
+
+
+
+ + <%= link to: author_profile_path(@asciicast), title: author_username(@asciicast) do %> + <%= img_tag(author_avatar_url(@asciicast), class: "avatar") %> + <% end %> + + +

+ <%= title(@asciicast) %> +

+ + + by <%= link(author_username(@asciicast), to: author_profile_path(@asciicast)) %> + <%= time_ago_tag(@asciicast.inserted_at) %> + + <%= if @asciicast.private do %> + <%= render("_private_badge.html") %> + <% end %> + + <%= if @asciicast.featured do %> + <%= render("_featured_badge.html") %> + <% end %> + +
+ +
+ +
+
+
+
+ +
+
+
+
+
+ <% os = os_info(@asciicast) %> + <% term = term_info(@asciicast) %> + <% shell = shell_info(@asciicast) %> + + <%= if Enum.any?([os, term, shell]) do %> + + <%= AsciinemaWeb.Icons.terminal_solid_icon(assigns) %> + <%= Enum.join([os, term, shell], " â—† ") %> + + <% end %> + + + <%= AsciinemaWeb.Icons.eye_solid_icon(assigns) %> + <%= views_count(@asciicast) %> + +
+ + <%= if desc = render_markdown(@asciicast.description) do %> +
+ +
+ <%= desc %> +
+ <% end %> +
+
+
+
+ +<%= render("_user_recordings.html", + conn: @conn, + user: @asciicast.user, + asciicasts: @author_asciicasts +) %> + +<%= render("_share_modal.html", conn: @conn, asciicast: @asciicast) %> + +<%= if download_filename(@asciicast) do %> + <%= render("_download_modal.html", conn: @conn, asciicast: @asciicast) %> +<% end %> + + From b456d03c234242c8e07a10bac125e44695c2e617 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 11:09:08 +0200 Subject: [PATCH 090/121] Refactor parts of recording/show to use components --- lib/asciinema_web.ex | 5 +- .../icons/terminal_solid_icon.html.eex | 5 - .../icons/terminal_solid_icon.html.heex | 9 ++ .../templates/recording/show.html.heex | 99 +++++++++---------- 4 files changed, 58 insertions(+), 60 deletions(-) delete mode 100644 lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex create mode 100644 lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index ee914b7ae..66619ba1d 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -132,9 +132,12 @@ defmodule AsciinemaWeb do use Phoenix.Component import Phoenix.View - import AsciinemaWeb.ErrorHelpers + # Core UI components and translation + import AsciinemaWeb.CoreComponents import AsciinemaWeb.Gettext import AsciinemaWeb.Icons + + import AsciinemaWeb.ErrorHelpers alias AsciinemaWeb.Router.Helpers, as: Routes import AsciinemaWeb.Router.Helpers.Extra diff --git a/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex b/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex deleted file mode 100644 index fc907555f..000000000 --- a/lib/asciinema_web/templates/icons/terminal_solid_icon.html.eex +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex b/lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex new file mode 100644 index 000000000..284a8af36 --- /dev/null +++ b/lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + + + diff --git a/lib/asciinema_web/templates/recording/show.html.heex b/lib/asciinema_web/templates/recording/show.html.heex index b6dd7cb96..9940bf88f 100644 --- a/lib/asciinema_web/templates/recording/show.html.heex +++ b/lib/asciinema_web/templates/recording/show.html.heex @@ -10,9 +10,9 @@
- <%= link to: author_profile_path(@asciicast), title: author_username(@asciicast) do %> - <%= img_tag(author_avatar_url(@asciicast), class: "avatar") %> - <% end %> + <.link navigate={author_profile_path(@asciicast)} title={author_username(@asciicast)}> + +

@@ -20,8 +20,11 @@

- by <%= link(author_username(@asciicast), to: author_profile_path(@asciicast)) %> - <%= time_ago_tag(@asciicast.inserted_at) %> + by + <.link navigate={author_profile_path(@asciicast)}> + <%= author_username(@asciicast) %> + + <.time_ago time={@asciicast.inserted_at} /> <%= if @asciicast.private do %> <%= render("_private_badge.html") %> @@ -63,53 +66,41 @@ <% end %>
@@ -129,13 +120,13 @@ <%= if Enum.any?([os, term, shell]) do %> - <%= AsciinemaWeb.Icons.terminal_solid_icon(assigns) %> + <.terminal_solid_icon /> <%= Enum.join([os, term, shell], " â—† ") %> <% end %> - <%= AsciinemaWeb.Icons.eye_solid_icon(assigns) %> + <.eye_solid_icon /> <%= views_count(@asciicast) %>
From 9bd1ffa8486dd39c27da72785b587e4099025a3f Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 12:09:24 +0200 Subject: [PATCH 091/121] Move icon templates to components dir --- lib/asciinema_web/components/icons.ex | 2 +- .../{templates => components}/icons/eye_solid_icon.html.heex | 0 .../{templates => components}/icons/info_outline_icon.html.heex | 0 .../{templates => components}/icons/info_solid_icon.html.heex | 0 .../{templates => components}/icons/live_icon.html.heex | 0 .../icons/terminal_solid_icon.html.heex | 0 .../icons/user_circle_outline_icon.html.heex | 0 .../{templates => components}/icons/user_solid_icon.html.heex | 0 8 files changed, 1 insertion(+), 1 deletion(-) rename lib/asciinema_web/{templates => components}/icons/eye_solid_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/info_outline_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/info_solid_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/live_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/terminal_solid_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/user_circle_outline_icon.html.heex (100%) rename lib/asciinema_web/{templates => components}/icons/user_solid_icon.html.heex (100%) diff --git a/lib/asciinema_web/components/icons.ex b/lib/asciinema_web/components/icons.ex index d9a1df49c..8201b613e 100644 --- a/lib/asciinema_web/components/icons.ex +++ b/lib/asciinema_web/components/icons.ex @@ -1,5 +1,5 @@ defmodule AsciinemaWeb.Icons do use Phoenix.Component - embed_templates "../templates/icons/*" + embed_templates "icons/*" end diff --git a/lib/asciinema_web/templates/icons/eye_solid_icon.html.heex b/lib/asciinema_web/components/icons/eye_solid_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/eye_solid_icon.html.heex rename to lib/asciinema_web/components/icons/eye_solid_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/info_outline_icon.html.heex b/lib/asciinema_web/components/icons/info_outline_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/info_outline_icon.html.heex rename to lib/asciinema_web/components/icons/info_outline_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/info_solid_icon.html.heex b/lib/asciinema_web/components/icons/info_solid_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/info_solid_icon.html.heex rename to lib/asciinema_web/components/icons/info_solid_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/live_icon.html.heex b/lib/asciinema_web/components/icons/live_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/live_icon.html.heex rename to lib/asciinema_web/components/icons/live_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex b/lib/asciinema_web/components/icons/terminal_solid_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/terminal_solid_icon.html.heex rename to lib/asciinema_web/components/icons/terminal_solid_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex b/lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/user_circle_outline_icon.html.heex rename to lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex diff --git a/lib/asciinema_web/templates/icons/user_solid_icon.html.heex b/lib/asciinema_web/components/icons/user_solid_icon.html.heex similarity index 100% rename from lib/asciinema_web/templates/icons/user_solid_icon.html.heex rename to lib/asciinema_web/components/icons/user_solid_icon.html.heex From 174b7ed3dd642a10dcdbf9f4a9ff544a1f07e08d Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 12:21:30 +0200 Subject: [PATCH 092/121] Remove unused js file --- assets/js/socket.js | 62 --------------------------------------------- 1 file changed, 62 deletions(-) delete mode 100644 assets/js/socket.js diff --git a/assets/js/socket.js b/assets/js/socket.js deleted file mode 100644 index 0f8d461f1..000000000 --- a/assets/js/socket.js +++ /dev/null @@ -1,62 +0,0 @@ -// NOTE: The contents of this file will only be executed if -// you uncomment its entry in "web/static/js/app.js". - -// To use Phoenix channels, the first step is to import Socket -// and connect at the socket path in "lib/my_app/endpoint.ex": -import {Socket} from "phoenix" - -let socket = new Socket("/socket", {params: {token: window.userToken}}) - -// When you connect, you'll often need to authenticate the client. -// For example, imagine you have an authentication plug, `MyAuth`, -// which authenticates the session and assigns a `:current_user`. -// If the current user exists you can assign the user's token in -// the connection for use in the layout. -// -// In your "web/router.ex": -// -// pipeline :browser do -// ... -// plug MyAuth -// plug :put_user_token -// end -// -// defp put_user_token(conn, _) do -// if current_user = conn.assigns[:current_user] do -// token = Phoenix.Token.sign(conn, "user socket", current_user.id) -// assign(conn, :user_token, token) -// else -// conn -// end -// end -// -// Now you need to pass this token to JavaScript. You can do so -// inside a script tag in "web/templates/layout/app.html.eex": -// -// -// -// You will need to verify the user token in the "connect/2" function -// in "web/channels/user_socket.ex": -// -// def connect(%{"token" => token}, socket) do -// # max_age: 1209600 is equivalent to two weeks in seconds -// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do -// {:ok, user_id} -> -// {:ok, assign(socket, :user, user_id)} -// {:error, reason} -> -// :error -// end -// end -// -// Finally, pass the token on connect as below. Or remove it -// from connect if you don't care about authentication. - -socket.connect() - -// Now that you are connected, you can join channels with a topic: -let channel = socket.channel("topic:subtopic", {}) -channel.join() - .receive("ok", resp => { console.log("Joined successfully", resp) }) - .receive("error", resp => { console.log("Unable to join", resp) }) - -export default socket From 4e2b45b1dc1d0d14644d90ac7043577b2bc8e32a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Tue, 11 Jul 2023 12:27:47 +0200 Subject: [PATCH 093/121] Rename playback_options to player_opts --- lib/asciinema_web/controllers/recording_controller.ex | 6 +++--- lib/asciinema_web/{playback_opts.ex => player_opts.ex} | 0 lib/asciinema_web/templates/recording/iframe.html.eex | 2 +- lib/asciinema_web/templates/recording/show.html.heex | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename lib/asciinema_web/{playback_opts.ex => player_opts.ex} (100%) diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 888666510..e10d1605f 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -61,7 +61,7 @@ defmodule AsciinemaWeb.RecordingController do "show.html", page_title: AsciinemaWeb.RecordingView.title(asciicast), asciicast: asciicast, - playback_options: playback_options(conn.params), + player_opts: player_opts(conn.params), actions: asciicast_actions(asciicast, conn.assigns.current_user), author_asciicasts: Recordings.other_public_asciicasts(asciicast) ) @@ -191,7 +191,7 @@ defmodule AsciinemaWeb.RecordingController do |> put_status(410) |> render("archived.html") else - render(conn, "iframe.html", playback_options: playback_options(params)) + render(conn, "iframe.html", player_opts: player_opts(params)) end end @@ -290,7 +290,7 @@ defmodule AsciinemaWeb.RecordingController do end end - defp playback_options(params) do + defp player_opts(params) do params |> Ext.Map.rename(%{"t" => "startAt", "i" => "idleTimeLimit"}) |> PlayerOpts.parse(:recording) diff --git a/lib/asciinema_web/playback_opts.ex b/lib/asciinema_web/player_opts.ex similarity index 100% rename from lib/asciinema_web/playback_opts.ex rename to lib/asciinema_web/player_opts.ex diff --git a/lib/asciinema_web/templates/recording/iframe.html.eex b/lib/asciinema_web/templates/recording/iframe.html.eex index d607bea34..db2eb924a 100644 --- a/lib/asciinema_web/templates/recording/iframe.html.eex +++ b/lib/asciinema_web/templates/recording/iframe.html.eex @@ -6,7 +6,7 @@