diff --git a/.env.production.sample b/.env.production.sample index 1d33b89c6..f9eeae793 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -46,3 +46,11 @@ MAILNAME=localhost # AWS_SECRET_ACCESS_KEY= # S3_BUCKET=my-asciinema-bucket # S3_REGION=us-east-1 + +### File cache + +## Location of local cache directory used for storing .txt versions of the +## recordings, local copy of .cast files when S3 file store is used above, +## and other cached items. +#Default: /var/cache/asciinema +#FILE_CACHE_PATH= diff --git a/.gitignore b/.gitignore index 1d1126844..bd37de2ed 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ npm-debug.log /config/custom.exs /priv/native /uploads/* +/cache /volumes diff --git a/Dockerfile b/Dockerfile index 5fe10c40d..ee5974d2c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,9 @@ RUN apk upgrade && \ mix local.rebar --force && \ mix local.hex --force +COPY native native/ +RUN cd native/vt_nif && cargo build -r + COPY mix.* ./ RUN mix do deps.get --only prod, deps.compile @@ -37,7 +40,6 @@ RUN mix phx.digest COPY config/*.exs config/ COPY lib lib/ COPY priv priv/ -COPY native native/ # recompile sentry with our source code RUN mix deps.compile sentry --force diff --git a/README.md b/README.md index a01d211d5..d102c154a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ at [asciinema/asciinema](https://github.com/asciinema/asciinema), and the source code of asciinema web player at [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player). +Shout-out to our Platinum [sponsors](https://github.com/sponsors/ku1ik), whose +financial support helps keep the project alive: + +[](https://dashcam.io?utm_source=asciinemagithub) + ## Setting up your own asciinema web app instance asciinema terminal recorder uses [asciinema.org](https://asciinema.org) as its @@ -41,6 +46,13 @@ 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). +## Sponsors + +asciinema is sponsored by: + +- [**Dashcam**](https://dashcam.io?utm_source=asciinemagithub) +- [Brightbox](https://www.brightbox.com/) + ## Consulting I offer consulting services for asciinema project. See https://asciinema.org/consulting for more information. diff --git a/assets/css/_base.scss b/assets/css/_base.scss index 2ac64c428..efd022227 100644 --- a/assets/css/_base.scss +++ b/assets/css/_base.scss @@ -182,6 +182,11 @@ body pre { border-color: #ddd; } +.modal-dialog pre { + background-color: #f7f7f7; + margin-bottom: 1rem; +} + .has-error .form-control { border-color: #dc3545; box-shadow: inset 0 1px 1px rgba(0,0,0,.075); @@ -190,3 +195,10 @@ body pre { .form-control::placeholder { color: #959fa7; } + +.btn-gh-sponsors { + font-weight: 600; + color: #333; + background-color: #eee; + border-color: #ccc; +} diff --git a/assets/package-lock.json b/assets/package-lock.json index de57ccb11..f1b6ca48e 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": "next", + "asciinema-player": "3.6.3", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", @@ -2298,9 +2298,9 @@ } }, "node_modules/asciinema-player": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.6.0.tgz", - "integrity": "sha512-+cScxWtyam4PBS3qv2o9n6MmF1qY8mbCMZScw3y8oEIZiyaa2busEPdUxcsI562+yyJSzzl8hnnNccna1HPhKw==", + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.6.3.tgz", + "integrity": "sha512-62aDgLpbuduhmpFfNgPOzf6fOluACLsftVnjpWJjUXX6dqhqTckFqWoJ+ayA0XjSlZ9l9wXTcJqRqvAAIpMblg==", "dev": true, "dependencies": { "@babel/runtime": "^7.21.0", diff --git a/assets/package.json b/assets/package.json index 6883d628e..8aa1c0dcc 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": "next", + "asciinema-player": "3.6.3", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", diff --git a/assets/static/images/sponsor-logos/dashcam/logo-on-dark.png b/assets/static/images/sponsor-logos/dashcam/logo-on-dark.png new file mode 100644 index 000000000..eef914643 Binary files /dev/null and b/assets/static/images/sponsor-logos/dashcam/logo-on-dark.png differ diff --git a/assets/static/images/sponsor-logos/dashcam/logo-on-light.png b/assets/static/images/sponsor-logos/dashcam/logo-on-light.png new file mode 100644 index 000000000..e04dda2ad Binary files /dev/null and b/assets/static/images/sponsor-logos/dashcam/logo-on-light.png differ diff --git a/config/config.exs b/config/config.exs index 5bfd68f64..be05879ee 100644 --- a/config/config.exs +++ b/config/config.exs @@ -43,6 +43,8 @@ config :sentry, config :asciinema, :file_store, Asciinema.FileStore.Local config :asciinema, Asciinema.FileStore.Local, path: "uploads/" +config :asciinema, Asciinema.FileCache, path: "cache/" + config :asciinema, :png_generator, Asciinema.PngGenerator.Rsvg config :asciinema, Asciinema.PngGenerator.Rsvg, diff --git a/config/prod.exs b/config/prod.exs index dab45c219..2a4076841 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -31,3 +31,5 @@ config :asciinema, Asciinema.Emails.Mailer, adapter: Bamboo.SMTPAdapter, server: "smtp", port: 25 + +config :asciinema, Asciinema.FileCache, path: "/var/cache/asciinema" diff --git a/config/runtime.exs b/config/runtime.exs index 8ac5586cf..26509b4aa 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -58,6 +58,12 @@ if config_env() in [:prod, :dev] do config :ex_aws, region: {:system, "AWS_REGION"} + file_cache_path = env.("FILE_CACHE_PATH") + + if file_cache_path do + config :asciinema, Asciinema.FileCache, path: file_cache_path + end + if env.("S3_BUCKET") do config :asciinema, :file_store, Asciinema.FileStore.Cached @@ -75,7 +81,8 @@ if config_env() in [:prod, :dev] do access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role] - config :asciinema, Asciinema.FileStore.Local, path: "cache/uploads/" + config :asciinema, Asciinema.FileStore.Local, + path: Path.join(file_cache_path || "/var/cache/asciinema", "uploads") end if db_pool_size = env.("DB_POOL_SIZE") do diff --git a/config/test.exs b/config/test.exs index 15d5b7d51..1d07f4e38 100644 --- a/config/test.exs +++ b/config/test.exs @@ -31,6 +31,8 @@ config :asciinema, Asciinema.Accounts, config :asciinema, Asciinema.FileStore.Local, path: "uploads/test/" +config :asciinema, Asciinema.FileCache, path: "/tmp/asciinema/" + config :asciinema, :snapshot_updater, Asciinema.Recordings.SnapshotUpdater.Noop config :asciinema, Oban, testing: :manual diff --git a/lib/asciinema/emails/email.ex b/lib/asciinema/emails/email.ex index 21139a134..a62daeb22 100644 --- a/lib/asciinema/emails/email.ex +++ b/lib/asciinema/emails/email.ex @@ -25,6 +25,7 @@ defmodule Asciinema.Emails.Email do defp base_email do new_email() |> from({"asciinema", from_address()}) + |> put_header("Date", Timex.format!(Timex.now(), "{RFC1123}")) |> put_header("Reply-To", reply_to_address()) |> put_html_layout({AsciinemaWeb.LayoutView, "email.html"}) end diff --git a/lib/asciinema/file_cache.ex b/lib/asciinema/file_cache.ex new file mode 100644 index 000000000..cad76e887 --- /dev/null +++ b/lib/asciinema/file_cache.ex @@ -0,0 +1,24 @@ +defmodule Asciinema.FileCache do + def full_path(namespace, path, generator) do + path = full_path(namespace, path) + + case File.stat(path) do + {:ok, _} -> + path + + {:error, :enoent} -> + content = generator.() + parent_dir = Path.dirname(path) + :ok = File.mkdir_p(parent_dir) + File.write!(path, content) + + path + end + end + + defp full_path(namespace, path), do: Path.join([base_path(), to_string(namespace), path]) + + defp base_path do + Keyword.get(Application.get_env(:asciinema, __MODULE__), :path) + end +end diff --git a/lib/asciinema/file_store/local.ex b/lib/asciinema/file_store/local.ex index af8632bed..6c49e2c89 100644 --- a/lib/asciinema/file_store/local.ex +++ b/lib/asciinema/file_store/local.ex @@ -9,7 +9,7 @@ defmodule Asciinema.FileStore.Local do @impl true def put_file(dst_path, src_local_path, _content_type) do - full_dst_path = base_path() <> dst_path + full_dst_path = full_path(dst_path) parent_dir = Path.dirname(full_dst_path) with :ok <- File.mkdir_p(parent_dir), @@ -20,8 +20,8 @@ defmodule Asciinema.FileStore.Local do @impl true def move_file(from_path, to_path) do - full_from_path = base_path() <> from_path - full_to_path = base_path() <> to_path + full_from_path = full_path(from_path) + full_to_path = full_path(to_path) parent_dir = Path.dirname(full_to_path) :ok = File.mkdir_p(parent_dir) File.rename(full_from_path, full_to_path) @@ -41,13 +41,13 @@ defmodule Asciinema.FileStore.Local do defp do_serve_file(conn, path) do conn |> put_resp_header("content-type", MIME.from_path(path)) - |> send_file(200, base_path() <> path) + |> send_file(200, full_path(path)) |> halt end @impl true def open_file(path) do - File.open(base_path() <> path, [:binary, :read]) + File.open(full_path(path), [:binary, :read]) end @impl true @@ -56,19 +56,21 @@ defmodule Asciinema.FileStore.Local do end def open_file(path, function) do - File.open(base_path() <> path, [:binary, :read], function) + File.open(full_path(path), [:binary, :read], function) end @impl true def delete_file(path) do - File.rm(base_path() <> path) + File.rm(full_path(path)) end - defp config do - Application.get_env(:asciinema, __MODULE__) - end + defp full_path(path), do: Path.join(base_path(), path) defp base_path do Keyword.get(config(), :path) end + + defp config do + Application.get_env(:asciinema, __MODULE__) + end end diff --git a/lib/asciinema/recordings.ex b/lib/asciinema/recordings.ex index 59395a989..347b86142 100644 --- a/lib/asciinema/recordings.ex +++ b/lib/asciinema/recordings.ex @@ -1,8 +1,8 @@ defmodule Asciinema.Recordings do require Logger import Ecto.Query, warn: false - alias Asciinema.{FileStore, Media, Repo, StringUtils, Vt} - alias Asciinema.Recordings.{Asciicast, SnapshotUpdater} + alias Asciinema.{FileStore, Media, Repo, Vt} + alias Asciinema.Recordings.{Asciicast, Output, Paths, SnapshotUpdater, Text} alias Ecto.Changeset def fetch_asciicast(id) do @@ -181,7 +181,7 @@ defmodule Asciinema.Recordings do tmp_path = {timing.path, data.path} - |> stdout_stream() + |> Output.stream() |> write_v2_file(header) upload = %Plug.Upload{ @@ -273,7 +273,8 @@ defmodule Asciinema.Recordings do defp get_v2_duration(path) do path - |> stdout_stream + # TODO use any last event, not specifically output + |> Output.stream() |> Enum.reduce(fn {t, _}, _prev_t -> t end) end @@ -289,7 +290,7 @@ defmodule Asciinema.Recordings do Repo.transaction(fn -> case Repo.insert(changeset) do {:ok, asciicast} -> - path = gen_file_store_path(asciicast) + path = Paths.sharded_path(asciicast) asciicast = asciicast @@ -308,145 +309,10 @@ defmodule Asciinema.Recordings do result end - defp gen_file_store_path(asciicast) do - ext = - case asciicast.version do - 1 -> "json" - 2 -> "cast" - end - - <> = - asciicast.id - |> Integer.to_string(10) - |> String.pad_leading(4, "0") - |> String.reverse() - |> String.slice(0, 4) - - "asciicasts/#{a}/#{b}/#{asciicast.id}.#{ext}" - end - defp save_file(path, %{path: tmp_path, content_type: content_type}) do :ok = FileStore.put_file(path, tmp_path, content_type) end - def stdout_stream(%Asciicast{version: 0} = asciicast) do - {:ok, tmp_dir_path} = Briefly.create(directory: true) - local_timing_path = tmp_dir_path <> "/timing" - local_data_path = tmp_dir_path <> "/data" - store_timing_path = "asciicast/stdout_timing/#{asciicast.id}/#{asciicast.stdout_timing}" - store_data_path = "asciicast/stdout/#{asciicast.id}/#{asciicast.stdout_data}" - :ok = FileStore.download_file(store_timing_path, local_timing_path) - :ok = FileStore.download_file(store_data_path, local_data_path) - stdout_stream({local_timing_path, local_data_path}) - end - - def stdout_stream(%Asciicast{} = asciicast) do - {:ok, local_path} = Briefly.create() - :ok = FileStore.download_file(asciicast.path, local_path) - stdout_stream(local_path) - end - - def stdout_stream(asciicast_file_path) when is_binary(asciicast_file_path) do - first_two_lines = - asciicast_file_path - |> File.stream!([], :line) - |> Stream.take(2) - |> Enum.to_list() - - case first_two_lines do - ["{" <> _ = header_line, "[" <> _] -> - header = Jason.decode!(header_line) - 2 = header["version"] - - asciicast_file_path - |> File.stream!([], :line) - |> Stream.drop(1) - |> Stream.reject(fn line -> line == "\n" end) - |> Stream.map(&Jason.decode!/1) - |> Stream.filter(fn [_, type, _] -> type == "o" end) - |> Stream.map(fn [t, _, s] -> {t, s} end) - |> to_relative_time - |> cap_relative_time(header["idle_time_limit"]) - |> to_absolute_time - - ["{" <> _, _] -> - asciicast = - asciicast_file_path - |> File.read!() - |> Jason.decode!() - - 1 = asciicast["version"] - - asciicast - |> Map.get("stdout") - |> Enum.map(&List.to_tuple/1) - |> to_absolute_time - end - end - - def stdout_stream({stdout_timing_path, stdout_data_path}) do - stream = - Stream.resource( - fn -> open_stream_files(stdout_timing_path, stdout_data_path) end, - &generate_stream_elem/1, - &close_stream_files/1 - ) - - to_absolute_time(stream) - end - - defp open_stream_files(stdout_timing_path, stdout_data_path) do - {open_stream_file(stdout_timing_path), open_stream_file(stdout_data_path), ""} - end - - defp open_stream_file(path) do - header = File.open!(path, [:read], fn file -> IO.binread(file, 2) end) - - case header do - # gzip - <<0x1F, 0x8B>> -> - File.open!(path, [:read, :compressed]) - - # bzip - <<0x42, 0x5A>> -> - {:ok, tmp_path} = Briefly.create() - {_, 0} = System.cmd("sh", ["-c", "bzip2 -d -k -c #{path} >#{tmp_path}"]) - File.open!(tmp_path, [:read]) - - _ -> - File.open!(path, [:read]) - end - end - - defp generate_stream_elem({timing_file, data_file, invalid_str} = files) do - case IO.read(timing_file, :line) do - line when is_binary(line) -> - {delay, count} = parse_line(line) - - case IO.binread(data_file, count) do - text when is_binary(text) -> - {valid_str, invalid_str} = StringUtils.valid_part(invalid_str, text) - {[{delay, valid_str}], {timing_file, data_file, invalid_str}} - - otherwise -> - {:error, otherwise} - end - - _ -> - {:halt, files} - end - end - - defp close_stream_files({timing_file, data_file, _}) do - File.close(timing_file) - File.close(data_file) - end - - defp parse_line(line) do - [delay_s, bytes_s] = line |> String.trim_trailing() |> String.split(" ") - {String.to_float(delay_s), String.to_integer(bytes_s)} - end - def change_asciicast(asciicast, attrs \\ %{}) do Asciicast.update_changeset(asciicast, attrs) end @@ -482,6 +348,12 @@ defmodule Asciinema.Recordings do end end + def set_featured(asciicast, featured \\ true) do + asciicast + |> Changeset.change(%{featured: featured}) + |> Repo.update!() + end + def delete_asciicast(asciicast) do with {:ok, asciicast} <- Repo.delete(asciicast) do case FileStore.delete_file(asciicast.path) do @@ -504,16 +376,24 @@ defmodule Asciinema.Recordings do cols = asciicast.cols_override || asciicast.cols rows = asciicast.rows_override || asciicast.rows secs = Asciicast.snapshot_at(asciicast) - snapshot = asciicast |> stdout_stream |> generate_snapshot(cols, rows, secs) - asciicast |> Changeset.cast(%{snapshot: snapshot}, [:snapshot]) |> Repo.update() + + snapshot = + asciicast + |> Output.stream() + |> generate_snapshot(cols, rows, secs) + + asciicast + |> Changeset.cast(%{snapshot: snapshot}, [:snapshot]) + |> Repo.update() end - def generate_snapshot(stdout_stream, width, height, secs) do + def generate_snapshot(stdout_stream, cols, rows, secs) do frames = Stream.take_while(stdout_stream, &frame_before_or_at?(&1, secs)) {:ok, {lines, cursor}} = - Vt.with_vt(width, height, fn vt -> + Vt.with_vt(cols, rows, [resizable: false, scrollback_limit: 0], fn vt -> Enum.each(frames, fn {_, text} -> Vt.feed(vt, text) end) + Vt.dump_screen(vt) end) @@ -539,33 +419,8 @@ defmodule Asciinema.Recordings do end) end - defp to_absolute_time(stream) do - Stream.scan(stream, &to_absolute_time/2) - end - - defp to_absolute_time({curr_time, data}, {prev_time, _}) do - {prev_time + curr_time, data} - end - - defp to_relative_time(stream) do - Stream.transform(stream, 0, &to_relative_time/2) - end - - defp to_relative_time({t, s}, prev_time) do - {[{t - prev_time, s}], t} - end - - defp cap_relative_time({_, _} = frame, nil) do - frame - end - - defp cap_relative_time({t, s}, time_limit) do - {min(t, time_limit), s} - end - - defp cap_relative_time(stream, time_limit) do - Stream.map(stream, &cap_relative_time(&1, time_limit)) - end + defdelegate text(asciicast), to: Text + defdelegate text_file_path(asciicast), to: Text defp frame_before_or_at?({time, _}, secs) do time <= secs @@ -603,12 +458,11 @@ defmodule Asciinema.Recordings do v2_path = asciicast - |> stdout_stream() + |> Output.stream() |> write_v2_file(header) upload = %Plug.Upload{path: v2_path, content_type: "application/octet-stream"} - - path = gen_file_store_path(%{asciicast | version: 2}) + path = Paths.sharded_path(%{asciicast | version: 2}) changeset = Changeset.change(asciicast, @@ -629,7 +483,7 @@ defmodule Asciinema.Recordings do {:ok, asciicast} = Repo.transaction(fn -> - new_path = gen_file_store_path(asciicast) + new_path = Paths.sharded_path(asciicast) asciicast = Repo.update!(Changeset.change(asciicast, path: new_path)) :ok = FileStore.move_file(old_path, new_path) diff --git a/lib/asciinema/recordings/output.ex b/lib/asciinema/recordings/output.ex new file mode 100644 index 000000000..85caa95b1 --- /dev/null +++ b/lib/asciinema/recordings/output.ex @@ -0,0 +1,169 @@ +defmodule Asciinema.Recordings.Output do + alias Asciinema.{FileStore, StringUtils} + alias Asciinema.Recordings.Asciicast + + def stream(%Asciicast{version: 0} = asciicast) do + {:ok, tmp_dir_path} = Briefly.create(directory: true) + local_timing_path = tmp_dir_path <> "/timing" + local_data_path = tmp_dir_path <> "/data" + store_timing_path = "asciicast/stdout_timing/#{asciicast.id}/#{asciicast.stdout_timing}" + store_data_path = "asciicast/stdout/#{asciicast.id}/#{asciicast.stdout_data}" + :ok = FileStore.download_file(store_timing_path, local_timing_path) + :ok = FileStore.download_file(store_data_path, local_data_path) + + stream({local_timing_path, local_data_path}) + end + + def stream(%Asciicast{} = asciicast) do + {:ok, local_path} = Briefly.create() + :ok = FileStore.download_file(asciicast.path, local_path) + + stream(local_path) + end + + def stream(path) when is_binary(path) do + first_two_lines = + path + |> File.stream!([], :line) + |> Stream.take(2) + |> Enum.to_list() + + case first_two_lines do + ["{" <> _ = header_line, "[" <> _] -> + header = Jason.decode!(header_line) + 2 = header["version"] + + path + |> File.stream!([], :line) + |> Stream.drop(1) + |> Stream.reject(fn line -> line == "\n" end) + |> Stream.map(&Jason.decode!/1) + |> Stream.map(&convert_resize_to_output/1) + |> Stream.filter(fn [_, type, _] -> type == "o" end) + |> Stream.map(fn [t, _, s] -> {t, s} end) + |> to_relative_time() + |> cap_relative_time(header["idle_time_limit"]) + |> to_absolute_time() + + ["{" <> _, _] -> + asciicast = + path + |> File.read!() + |> Jason.decode!() + + 1 = asciicast["version"] + + asciicast + |> Map.get("stdout") + |> Enum.map(&List.to_tuple/1) + |> to_absolute_time() + end + end + + def stream({timing_path, data_path}) do + stream = + Stream.resource( + fn -> open_files(timing_path, data_path) end, + &generate_elem/1, + &close_stream_files/1 + ) + + to_absolute_time(stream) + end + + defp convert_resize_to_output([time, "r", size]) do + [cols, rows] = String.split(size, "x") + cols = String.to_integer(cols) + rows = String.to_integer(rows) + + [time, "o", "\x1b[8;#{rows};#{cols}t"] + end + + defp convert_resize_to_output(event), do: event + + defp open_files(timing_path, data_path) do + {open_file(timing_path), open_file(data_path), ""} + end + + defp open_file(path) do + header = File.open!(path, [:read], fn file -> IO.binread(file, 2) end) + + case header do + # gzip + <<0x1F, 0x8B>> -> + File.open!(path, [:read, :compressed]) + + # bzip + <<0x42, 0x5A>> -> + {:ok, tmp_path} = Briefly.create() + {_, 0} = System.cmd("sh", ["-c", "bzip2 -d -k -c #{path} >#{tmp_path}"]) + + File.open!(tmp_path, [:read]) + + _ -> + File.open!(path, [:read]) + end + end + + defp generate_elem({timing_file, data_file, invalid_str} = files) do + case IO.read(timing_file, :line) do + line when is_binary(line) -> + {delay, count} = parse_line(line) + + case IO.binread(data_file, count) do + text when is_binary(text) -> + {valid_str, invalid_str} = StringUtils.valid_part(invalid_str, text) + + {[{delay, valid_str}], {timing_file, data_file, invalid_str}} + + otherwise -> + {:error, otherwise} + end + + _ -> + {:halt, files} + end + end + + defp parse_line(line) do + [delay_s, bytes_s] = + line + |> String.trim_trailing() + |> String.split(" ") + + {String.to_float(delay_s), String.to_integer(bytes_s)} + end + + defp close_stream_files({timing_file, data_file, _}) do + File.close(timing_file) + File.close(data_file) + end + + defp to_absolute_time(stream) do + Stream.scan(stream, &to_absolute_time/2) + end + + defp to_absolute_time({curr_time, data}, {prev_time, _}) do + {prev_time + curr_time, data} + end + + defp to_relative_time(stream) do + Stream.transform(stream, 0, &to_relative_time/2) + end + + defp to_relative_time({t, s}, prev_time) do + {[{t - prev_time, s}], t} + end + + defp cap_relative_time({_, _} = frame, nil) do + frame + end + + defp cap_relative_time({t, s}, time_limit) do + {min(t, time_limit), s} + end + + defp cap_relative_time(stream, time_limit) do + Stream.map(stream, &cap_relative_time(&1, time_limit)) + end +end diff --git a/lib/asciinema/recordings/paths.ex b/lib/asciinema/recordings/paths.ex new file mode 100644 index 000000000..a05d1084d --- /dev/null +++ b/lib/asciinema/recordings/paths.ex @@ -0,0 +1,19 @@ +defmodule Asciinema.Recordings.Paths do + def sharded_path(asciicast, ext \\ nil) do + ext = + case {ext, asciicast.version} do + {nil, 1} -> ".json" + {nil, 2} -> ".cast" + {ext, _} when is_binary(ext) -> ext + end + + <> = + asciicast.id + |> Integer.to_string(10) + |> String.pad_leading(4, "0") + |> String.reverse() + |> String.slice(0, 4) + + "asciicasts/#{a}/#{b}/#{asciicast.id}#{ext}" + end +end diff --git a/lib/asciinema/recordings/text.ex b/lib/asciinema/recordings/text.ex new file mode 100644 index 000000000..3a039a0cb --- /dev/null +++ b/lib/asciinema/recordings/text.ex @@ -0,0 +1,22 @@ +defmodule Asciinema.Recordings.Text do + alias Asciinema.Recordings.{Asciicast, Output, Paths} + alias Asciinema.{FileCache, Vt} + + def text(%Asciicast{cols: cols, rows: rows} = asciicast) do + output = Output.stream(asciicast) + + Vt.with_vt(cols, rows, [resizable: true, scrollback_limit: nil], fn vt -> + Enum.each(output, fn {_, text} -> Vt.feed(vt, text) end) + + Vt.text(vt) + end) + end + + def text_file_path(asciicast) do + FileCache.full_path( + :txt, + Paths.sharded_path(asciicast, ".txt"), + fn -> text(asciicast) end + ) + end +end diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex index e3d1ce340..791e77999 100644 --- a/lib/asciinema/streaming/live_stream_server.ex +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -221,7 +221,7 @@ defmodule Asciinema.Streaming.LiveStreamServer do defp topic_name(stream_id, type), do: "stream:#{stream_id}:#{type}" defp reset_stream(state, {cols, rows} = vt_size, time \\ 0.0) do - {:ok, vt} = Vt.new(cols, rows) + {:ok, vt} = Vt.new(cols, rows, true, 100) stream = Streaming.update_live_stream(state.stream, diff --git a/lib/asciinema/vt.ex b/lib/asciinema/vt.ex index f268db406..4f815f9c1 100644 --- a/lib/asciinema/vt.ex +++ b/lib/asciinema/vt.ex @@ -1,21 +1,28 @@ defmodule Asciinema.Vt do use Rustler, otp_app: :asciinema, crate: :vt_nif - def with_vt(width, height, f) do - with {:ok, vt} <- new(width, height), do: f.(vt) + def with_vt(cols, rows, opts \\ [], f) do + resizable = Keyword.get(opts, :resizable, true) + scrollback_limit = Keyword.get(opts, :scrollback_limit, 100) + + with {:ok, vt} <- new(cols, rows, resizable, scrollback_limit), do: f.(vt) end # When NIF is loaded, it will override following functions. - def new(_width, _height), do: :erlang.nif_error(:nif_not_loaded) - # => {:ok, vt} | {:error, :invalid_size} + @spec new(integer, integer, boolean, integer | nil) :: + {:ok, reference} | {:error, :invalid_size} + def new(_cols, _rows, _resizable, _scrollback_limit), do: :erlang.nif_error(:nif_not_loaded) + @spec feed(reference, binary) :: {integer, integer} | nil def feed(_vt, _str), do: :erlang.nif_error(:nif_not_loaded) - # => nil | {cols, rows} + @spec dump(reference) :: binary def dump(_vt), do: :erlang.nif_error(:nif_not_loaded) - # => ... + @spec dump_screen(reference) :: {:ok, {list(list({binary, map})), {integer, integer} | nil}} def dump_screen(_vt), do: :erlang.nif_error(:nif_not_loaded) - # => {:ok, {lines, cursor}} + + @spec text(reference) :: binary + def text(_vt), do: :erlang.nif_error(:nif_not_loaded) end diff --git a/lib/asciinema_web/components/icons/download_mini_icon.html.heex b/lib/asciinema_web/components/icons/download_mini_icon.html.heex new file mode 100644 index 000000000..379393f24 --- /dev/null +++ b/lib/asciinema_web/components/icons/download_mini_icon.html.heex @@ -0,0 +1,6 @@ + + + + + + diff --git a/lib/asciinema_web/components/icons/share_mini_icon.html.heex b/lib/asciinema_web/components/icons/share_mini_icon.html.heex new file mode 100644 index 000000000..5e52e9451 --- /dev/null +++ b/lib/asciinema_web/components/icons/share_mini_icon.html.heex @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/asciinema_web/controllers/live_stream/edit.html.heex b/lib/asciinema_web/controllers/live_stream/edit.html.heex index 96c958993..b24bdb1ae 100644 --- a/lib/asciinema_web/controllers/live_stream/edit.html.heex +++ b/lib/asciinema_web/controllers/live_stream/edit.html.heex @@ -77,7 +77,7 @@ /> <.error form={f} field={:terminal_line_height} /> - Relative to font size. Lowering it ~1.1 helps with alignment of block characters like ▀ ▄ █ + Relative to font size. Lowering it to ~1.1 helps with alignment of block characters like ▀ ▄ █ diff --git a/lib/asciinema_web/controllers/live_stream/private_badge.html.heex b/lib/asciinema_web/controllers/live_stream/private_badge.html.heex deleted file mode 100644 index 82e66d9c1..000000000 --- a/lib/asciinema_web/controllers/live_stream/private_badge.html.heex +++ /dev/null @@ -1,6 +0,0 @@ - - private - diff --git a/lib/asciinema_web/controllers/live_stream/secret_badge.html.heex b/lib/asciinema_web/controllers/live_stream/secret_badge.html.heex new file mode 100644 index 000000000..dfaba1139 --- /dev/null +++ b/lib/asciinema_web/controllers/live_stream/secret_badge.html.heex @@ -0,0 +1,6 @@ + + secret + diff --git a/lib/asciinema_web/controllers/live_stream/show.html.heex b/lib/asciinema_web/controllers/live_stream/show.html.heex index 5c19e5e0d..cae61b69a 100644 --- a/lib/asciinema_web/controllers/live_stream/show.html.heex +++ b/lib/asciinema_web/controllers/live_stream/show.html.heex @@ -20,7 +20,7 @@ by <.link navigate={author_profile_path(@stream)}><%= author_username(@stream) %> - <.private_badge :if={@stream.private} /> + <.secret_badge :if={@stream.private} /> @@ -45,7 +45,7 @@ class="dropdown-item" method="put" > - Make private + Make it secret <.link @@ -54,7 +54,7 @@ class="dropdown-item" method="put" > - Make public + Make it public diff --git a/lib/asciinema_web/controllers/recording_controller.ex b/lib/asciinema_web/controllers/recording_controller.ex index 814f7f22c..3292c9c43 100644 --- a/lib/asciinema_web/controllers/recording_controller.ex +++ b/lib/asciinema_web/controllers/recording_controller.ex @@ -91,6 +91,18 @@ defmodule AsciinemaWeb.RecordingController do |> send_file(200, path) end + def do_show(conn, "txt", asciicast) do + if asciicast.archived_at do + conn + |> put_status(410) + |> text("This recording has been archived\n") + else + send_download(conn, {:file, Recordings.text_file_path(asciicast)}, + filename: "#{asciicast.id}.txt" + ) + end + end + def do_show(conn, "svg", asciicast) do if asciicast.archived_at do path = Application.app_dir(:asciinema, "priv/static/images/archived.png") diff --git a/lib/asciinema_web/router.ex b/lib/asciinema_web/router.ex index 31183e075..5af0efab1 100644 --- a/lib/asciinema_web/router.ex +++ b/lib/asciinema_web/router.ex @@ -14,7 +14,7 @@ defmodule AsciinemaWeb.Router do pipeline :asciicast do plug AsciinemaWeb.TrailingFormat - plug :accepts, ["html", "js", "json", "cast", "svg", "png", "gif"] + plug :accepts, ["html", "js", "json", "cast", "txt", "svg", "png", "gif"] plug :format_specific_plugs plug :put_secure_browser_headers end diff --git a/lib/asciinema_web/templates/doc/embedding.html.md b/lib/asciinema_web/templates/doc/embedding.html.md index aea9e2bf4..272c3e74f 100644 --- a/lib/asciinema_web/templates/doc/embedding.html.md +++ b/lib/asciinema_web/templates/doc/embedding.html.md @@ -148,6 +148,7 @@ to a theme set by the asciicast author (or to "asciinema" if not set by the author). The available themes are: * asciinema +* dracula * monokai * nord * solarized-dark diff --git a/lib/asciinema_web/templates/layout/_donate_modal.html.heex b/lib/asciinema_web/templates/layout/_donate_modal.html.heex new file mode 100644 index 000000000..135320f53 --- /dev/null +++ b/lib/asciinema_web/templates/layout/_donate_modal.html.heex @@ -0,0 +1,54 @@ + diff --git a/lib/asciinema_web/templates/layout/_footer.html.heex b/lib/asciinema_web/templates/layout/_footer.html.heex index 619778ce4..83fb61673 100644 --- a/lib/asciinema_web/templates/layout/_footer.html.heex +++ b/lib/asciinema_web/templates/layout/_footer.html.heex @@ -1,8 +1,10 @@