>, number, shift) when byte > 127 do
25 | byte = Bitwise.band(byte, 127)
26 | do_decode(rest, number + Bitwise.bsl(byte, shift), shift + 7)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/asciinema/media.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Media do
2 | alias Asciinema.{Accounts, Themes}
3 |
4 | def term_theme_name(medium) do
5 | cond do
6 | medium.term_theme_name -> medium.term_theme_name
7 | true -> Accounts.default_term_theme_name(medium.user) || "asciinema"
8 | end
9 | end
10 |
11 | def theme(%{term_theme_prefer_original: true, term_theme_palette: p} = medium)
12 | when not is_nil(p) do
13 | Themes.custom_theme(medium.term_theme_fg, medium.term_theme_bg, p)
14 | end
15 |
16 | def theme(medium) do
17 | case term_theme_name(medium) do
18 | "original" ->
19 | Themes.custom_theme(medium.term_theme_fg, medium.term_theme_bg, medium.term_theme_palette)
20 |
21 | name ->
22 | Themes.named_theme(name)
23 | end
24 | end
25 |
26 | def original_theme(%{term_theme_name: "original"} = medium) do
27 | Themes.custom_theme(medium.term_theme_fg, medium.term_theme_bg, medium.term_theme_palette)
28 | end
29 |
30 | def original_theme(_medium), do: nil
31 |
32 | def font_family(medium) do
33 | case medium.term_font_family || Accounts.default_font_family(medium.user) do
34 | "default" -> nil
35 | family -> family
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/asciinema/oban_error_reporter.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.ObanErrorReporter do
2 | def configure do
3 | :telemetry.attach_many(
4 | "oban-errors",
5 | [[:oban, :job, :exception], [:oban, :circuit, :trip]],
6 | &__MODULE__.handle_event/4,
7 | %{}
8 | )
9 | end
10 |
11 | def handle_event([:oban, :job, :exception], measure, %{job: job} = meta, _) do
12 | extra =
13 | job
14 | |> Map.take([:id, :args, :meta, :queue, :worker])
15 | |> Map.merge(measure)
16 |
17 | Sentry.capture_exception(meta.error, stacktrace: meta.stacktrace, extra: extra)
18 | end
19 |
20 | def handle_event([:oban, :circuit, :trip], _measure, meta, _) do
21 | Sentry.capture_exception(meta.error, stacktrace: meta.stacktrace, extra: meta)
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/asciinema/png_generator.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.PngGenerator do
2 | alias Asciinema.Recordings.Asciicast
3 |
4 | @doc "Generates PNG image from asciicast and returns path to it"
5 | @callback generate(asciicast :: %Asciicast{}) :: {:ok, String.t()} | {:error, term}
6 |
7 | def generate(asciicast) do
8 | Keyword.fetch!(Application.get_env(:asciinema, __MODULE__), :adapter).generate(asciicast)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/asciinema/pub_sub.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.PubSub do
2 | def subscribe(topic) do
3 | :ok = Phoenix.PubSub.subscribe(__MODULE__, topic)
4 | end
5 |
6 | def broadcast(topic, payload) do
7 | :ok = Phoenix.PubSub.broadcast(__MODULE__, topic, payload)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/asciinema/recordings/asciicast/v1.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.Asciicast.V1 do
2 | alias Asciinema.Recordings.EventStream
3 |
4 | def event_stream(path) when is_binary(path) do
5 | asciicast =
6 | path
7 | |> File.read!()
8 | |> Jason.decode!()
9 |
10 | 1 = asciicast["version"]
11 |
12 | asciicast
13 | |> Map.fetch!("stdout")
14 | |> Stream.map(fn [time, data] -> {time, "o", data} end)
15 | |> EventStream.to_absolute_time()
16 | end
17 |
18 | def fetch_metadata(path) do
19 | with {:ok, json} <- File.read(path),
20 | {:ok, %{"version" => 1} = attrs} <- Jason.decode(json) do
21 | metadata = %{
22 | version: 1,
23 | term_cols: attrs["width"],
24 | term_rows: attrs["height"],
25 | term_type: get_in(attrs, ["env", "TERM"]),
26 | command: attrs["command"],
27 | duration: attrs["duration"],
28 | title: attrs["title"],
29 | shell: get_in(attrs, ["env", "SHELL"]),
30 | env: attrs["env"] || %{}
31 | }
32 |
33 | {:ok, metadata}
34 | else
35 | {:ok, %{"version" => version}} ->
36 | {:error, {:invalid_version, version}}
37 |
38 | {:error, %Jason.DecodeError{}} ->
39 | {:error, :invalid_format}
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/asciinema/recordings/event_stream.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.EventStream do
2 | def to_absolute_time(stream) do
3 | Stream.scan(stream, &to_absolute_time/2)
4 | end
5 |
6 | defp to_absolute_time({curr_time, code, data}, {prev_time, _, _}) do
7 | {prev_time + curr_time, code, data}
8 | end
9 |
10 | def to_relative_time(stream) do
11 | Stream.transform(stream, 0, &to_relative_time/2)
12 | end
13 |
14 | defp to_relative_time({curr_time, code, data}, prev_time) do
15 | {[{curr_time - prev_time, code, data}], curr_time}
16 | end
17 |
18 | def cap_relative_time({_, _, _} = frame, nil) do
19 | frame
20 | end
21 |
22 | def cap_relative_time({time, code, data}, time_limit) do
23 | {min(time, time_limit), code, data}
24 | end
25 |
26 | def cap_relative_time(stream, time_limit) do
27 | Stream.map(stream, &cap_relative_time(&1, time_limit))
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/asciinema/recordings/markers.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.Markers do
2 | def validate(_, markers) do
3 | case parse(markers) do
4 | {:ok, _} -> []
5 | {:error, index} -> [markers: "invalid syntax in line #{index + 1}"]
6 | end
7 | end
8 |
9 | def parse(markers) do
10 | results =
11 | markers
12 | |> String.trim()
13 | |> String.split("\n")
14 | |> Enum.map(&parse_one/1)
15 |
16 | case Enum.find_index(results, fn result -> result == :error end) do
17 | nil -> {:ok, results}
18 | index -> {:error, index}
19 | end
20 | end
21 |
22 | defp parse_one(marker) do
23 | parts =
24 | marker
25 | |> String.trim()
26 | |> String.split(~r/\s+-\s+/, parts: 2)
27 | |> Kernel.++([""])
28 | |> Enum.take(2)
29 |
30 | with [t, l] <- parts,
31 | {t, ""} <- Float.parse(t),
32 | true <- String.length(l) < 100 do
33 | {t, l}
34 | else
35 | _ -> :error
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/asciinema/recordings/text.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.Text do
2 | alias Asciinema.Recordings
3 | alias Asciinema.Recordings.{Asciicast, Paths}
4 | alias Asciinema.{FileCache, Vt}
5 |
6 | def text(%Asciicast{term_cols: cols, term_rows: rows} = asciicast) do
7 | stream = Recordings.event_stream(asciicast)
8 |
9 | Vt.with_vt(cols, rows, [scrollback_limit: nil], fn vt ->
10 | Enum.each(stream, fn {_, code, data} ->
11 | case code do
12 | "o" ->
13 | Vt.feed(vt, data)
14 |
15 | "r" ->
16 | [cols, rows] = String.split(data, "x")
17 | cols = String.to_integer(cols)
18 | rows = String.to_integer(rows)
19 | Vt.resize(vt, cols, rows)
20 |
21 | _ ->
22 | :ok
23 | end
24 | end)
25 |
26 | Vt.text(vt)
27 | end)
28 | end
29 |
30 | def text_file_path(asciicast) do
31 | FileCache.full_path(
32 | :txt,
33 | Paths.path(asciicast, "txt"),
34 | fn -> text(asciicast) end
35 | )
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/asciinema/release.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Release do
2 | @moduledoc """
3 | Used for executing DB release tasks when run in production without Mix
4 | installed.
5 | """
6 | @app :asciinema
7 |
8 | def migrate do
9 | load_app()
10 |
11 | for repo <- repos() do
12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
13 | end
14 | end
15 |
16 | def rollback(repo, version) do
17 | load_app()
18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
19 | end
20 |
21 | def admin_add(emails) when is_binary(emails) do
22 | emails
23 | |> String.split(~r/[, ]+/)
24 | |> admin_add()
25 | end
26 |
27 | def admin_add(emails) when is_list(emails) do
28 | load_app()
29 |
30 | Ecto.Migrator.with_repo(Asciinema.Repo, fn _repo ->
31 | Asciinema.Accounts.add_admins(emails)
32 | IO.puts("#{Enum.join(emails, ", ")} added to admin users list")
33 | end)
34 | end
35 |
36 | def admin_rm(emails) when is_binary(emails) do
37 | emails
38 | |> String.split(~r/[, ]+/)
39 | |> admin_rm()
40 | end
41 |
42 | def admin_rm(emails) when is_list(emails) do
43 | load_app()
44 |
45 | Ecto.Migrator.with_repo(Asciinema.Repo, fn _repo ->
46 | Asciinema.Accounts.remove_admins(emails)
47 | IO.puts("#{Enum.join(emails, ", ")} removed from admin users list")
48 | end)
49 | end
50 |
51 | defp repos do
52 | Application.fetch_env!(@app, :ecto_repos)
53 | end
54 |
55 | defp load_app do
56 | Application.load(@app)
57 | Application.ensure_all_started(:ssl)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/asciinema/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo do
2 | use Ecto.Repo,
3 | otp_app: :asciinema,
4 | adapter: Ecto.Adapters.Postgres
5 |
6 | use Scrivener, page_size: 10
7 |
8 | def transact(fun, opts \\ []) do
9 | transaction(
10 | fn ->
11 | case fun.() do
12 | {:ok, value} -> value
13 | :ok -> :transaction_commited
14 | {:error, reason} -> rollback(reason)
15 | :error -> rollback(:transaction_rollback_error)
16 | end
17 | end,
18 | opts
19 | )
20 | end
21 |
22 | def count(query) do
23 | aggregate(query, :count, :id)
24 | end
25 |
26 | def pages(query, page_size) do
27 | Stream.iterate(:ok, & &1)
28 | |> Stream.scan({nil, nil}, fn _, {last_id, _} ->
29 | items = page(query, last_id, page_size)
30 | last = List.last(items)
31 | {last && last.id, items}
32 | end)
33 | |> Stream.map(&elem(&1, 1))
34 | |> Stream.take_while(&(length(&1) > 0))
35 | end
36 |
37 | defp page(query, last_id, page_size) do
38 | import Ecto.Query
39 |
40 | query =
41 | from x in query,
42 | order_by: x.id,
43 | limit: ^page_size
44 |
45 | query =
46 | case last_id do
47 | nil ->
48 | query
49 |
50 | id ->
51 | where(query, [x], x.id > ^id)
52 | end
53 |
54 | all(query)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/asciinema/streaming/parser.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Streaming.Parser do
2 | @callback name() :: binary
3 | @callback init() :: term
4 | @callback parse({message :: term, opts :: keyword}, term) ::
5 | {:ok, [{atom, term}], term} | {:error, term}
6 |
7 | alias Asciinema.Streaming.Parser
8 |
9 | def get("raw"), do: %{impl: Parser.Raw, state: Parser.Raw.init()}
10 | def get("v0.alis"), do: %{impl: Parser.AlisV0, state: Parser.AlisV0.init()}
11 | def get("v1.alis"), do: %{impl: Parser.AlisV1, state: Parser.AlisV1.init()}
12 | def get("v2.asciicast"), do: %{impl: Parser.Json, state: Parser.Json.init()}
13 | end
14 |
--------------------------------------------------------------------------------
/lib/asciinema/streaming/parser/raw.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Streaming.Parser.Raw do
2 | @behaviour Asciinema.Streaming.Parser
3 |
4 | @default_size {80, 24}
5 |
6 | def name, do: "raw"
7 |
8 | def init, do: %{first: true, start_time: nil, last_event_id: 0}
9 |
10 | def parse({:binary, text}, %{first: true} = state) do
11 | size = size_from_resize_seq(text) || size_from_script_start_message(text) || @default_size
12 |
13 | commands = [
14 | init: %{last_id: state.last_event_id, time: 0, term_size: size, term_init: text}
15 | ]
16 |
17 | {:ok, commands, %{state | first: false, start_time: Timex.now()}}
18 | end
19 |
20 | def parse({:binary, text}, state) do
21 | {id, state} = get_next_id(state)
22 | time = stream_time(state)
23 |
24 | {:ok, [output: %{id: id, time: time, text: text}], state}
25 | end
26 |
27 | defp size_from_resize_seq(text) do
28 | with [_, rows, cols] <- Regex.run(~r/\x1b\[8;(\d+);(\d+)t/, text) do
29 | {String.to_integer(cols), String.to_integer(rows)}
30 | end
31 | end
32 |
33 | defp size_from_script_start_message(text) do
34 | with [_, cols, rows] <- Regex.run(~r/\[.*COLUMNS="(\d{1,3})" LINES="(\d{1,3})".*\]/, text) do
35 | {String.to_integer(cols), String.to_integer(rows)}
36 | end
37 | end
38 |
39 | defp get_next_id(state) do
40 | id = state.last_event_id + 1
41 |
42 | {id, %{state | last_event_id: id}}
43 | end
44 |
45 | defp stream_time(state), do: Timex.diff(Timex.now(), state.start_time, :microsecond)
46 | end
47 |
--------------------------------------------------------------------------------
/lib/asciinema/streaming/stream.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Streaming.Stream do
2 | use Ecto.Schema
3 |
4 | schema "streams" do
5 | field :public_token, :string
6 | field :producer_token, :string
7 | field :visibility, Ecto.Enum, values: ~w[private unlisted public]a, default: :unlisted
8 | field :term_cols, :integer
9 | field :term_rows, :integer
10 | field :term_type, :string
11 | field :term_version, :string
12 | field :term_theme_name, :string
13 | field :term_theme_fg, :string
14 | field :term_theme_bg, :string
15 | field :term_theme_palette, :string
16 | field :term_theme_prefer_original, :boolean, default: true
17 | field :term_line_height, :float
18 | field :term_font_family, :string
19 | field :online, :boolean
20 | field :last_activity_at, :naive_datetime
21 | field :last_started_at, :naive_datetime
22 | field :title, :string
23 | field :description, :string
24 | field :shell, :string
25 | field :user_agent, :string
26 | field :current_viewer_count, :integer
27 | field :peak_viewer_count, :integer
28 | field :buffer_time, :float
29 | field :protocol, :string
30 | field :snapshot, Asciinema.Ecto.Type.Snapshot
31 |
32 | timestamps()
33 |
34 | belongs_to :user, Asciinema.Accounts.User
35 | end
36 |
37 | defimpl Phoenix.Param do
38 | def to_param(stream), do: stream.public_token
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/asciinema/streaming/stream_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Streaming.StreamSupervisor do
2 | use DynamicSupervisor
3 | alias Asciinema.Streaming.StreamServer
4 | require Logger
5 |
6 | def start_link(init_arg) do
7 | DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
8 | end
9 |
10 | @impl true
11 | def init(_init_arg) do
12 | DynamicSupervisor.init(strategy: :one_for_one)
13 | end
14 |
15 | def start_child(id) do
16 | Logger.debug("stream sup: starting server for stream #{id}")
17 | DynamicSupervisor.start_child(__MODULE__, {StreamServer, id})
18 | end
19 |
20 | def ensure_child(id) do
21 | case start_child(id) do
22 | {:error, {:already_started, pid}} ->
23 | Logger.debug("stream sup: server already exists for stream #{id}")
24 | {:ok, pid}
25 |
26 | otherwise ->
27 | otherwise
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/asciinema/string_utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.StringUtils do
2 | def valid_part(invalid_str, str) do
3 | case String.chunk(invalid_str <> str, :valid) do
4 | [] ->
5 | {"", ""}
6 |
7 | chunks ->
8 | str =
9 | chunks
10 | |> Enum.take(Enum.count(chunks) - 1)
11 | |> Enum.filter(&String.valid?/1)
12 | |> Enum.join()
13 |
14 | last = Enum.at(chunks, -1)
15 |
16 | if String.valid?(last) do
17 | {str <> last, ""}
18 | else
19 | {str, last}
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/asciinema/url_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.UrlProvider do
2 | @type token :: String.t()
3 | @type url :: String.t()
4 |
5 | @callback sign_up(token) :: url
6 | @callback login(token) :: url
7 | @callback account_deletion(token) :: url
8 | end
9 |
--------------------------------------------------------------------------------
/lib/asciinema/vt.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Vt do
2 | use Rustler, otp_app: :asciinema, crate: :vt_nif
3 |
4 | def with_vt(cols, rows, opts \\ [], f) do
5 | scrollback_limit = Keyword.get(opts, :scrollback_limit, 100)
6 |
7 | with {:ok, vt} <- new(cols, rows, scrollback_limit), do: f.(vt)
8 | end
9 |
10 | # When NIF is loaded, it will override following functions.
11 |
12 | @spec new(integer, integer, integer | nil) :: {:ok, reference} | {:error, :invalid_size}
13 | def new(_cols, _rows, _scrollback_limit), do: :erlang.nif_error(:nif_not_loaded)
14 |
15 | @spec feed(reference, binary) :: :ok
16 | def feed(_vt, _str), do: :erlang.nif_error(:nif_not_loaded)
17 |
18 | @spec resize(reference, integer, integer) :: :ok
19 | def resize(_vt, _cols, _rows), do: :erlang.nif_error(:nif_not_loaded)
20 |
21 | @spec dump(reference) :: binary
22 | def dump(_vt), do: :erlang.nif_error(:nif_not_loaded)
23 |
24 | @spec dump_screen(reference) :: {:ok, {list(list({binary, map})), {integer, integer} | nil}}
25 | def dump_screen(_vt), do: :erlang.nif_error(:nif_not_loaded)
26 |
27 | @spec text(reference) :: binary
28 | def text(_vt), do: :erlang.nif_error(:nif_not_loaded)
29 | end
30 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/delete_unclaimed_recordings.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.DeleteUnclaimedRecordings do
2 | use Oban.Worker,
3 | unique: [
4 | period: :infinity,
5 | states: [:scheduled, :available, :executing, :retryable]
6 | ]
7 |
8 | require Logger
9 |
10 | @impl Oban.Worker
11 | def perform(_job) do
12 | hide_unclaimed_recordings(Asciinema.unclaimed_recording_ttl(:hide))
13 | delete_unclaimed_recordings(Asciinema.unclaimed_recording_ttl(:delete))
14 |
15 | :ok
16 | end
17 |
18 | defp hide_unclaimed_recordings(nil), do: :ok
19 |
20 | defp hide_unclaimed_recordings(days) do
21 | count = Asciinema.hide_unclaimed_recordings(days)
22 |
23 | if count > 0 do
24 | Logger.info("hid #{count} unclaimed recordings")
25 | end
26 | end
27 |
28 | defp delete_unclaimed_recordings(nil), do: :ok
29 |
30 | defp delete_unclaimed_recordings(days) do
31 | count = Asciinema.delete_unclaimed_recordings(days)
32 |
33 | if count > 0 do
34 | Logger.info("deleted #{count} unclaimed recordings")
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/generate_snapshots.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.GenerateSnapshots do
2 | use Oban.Worker,
3 | unique: [
4 | period: :infinity,
5 | states: [:available, :retryable]
6 | ]
7 |
8 | alias Asciinema.Recordings
9 | alias Asciinema.Workers.UpdateSnapshot
10 |
11 | @impl Oban.Worker
12 | def perform(_job) do
13 | asciicasts =
14 | :snapshotless
15 | |> Recordings.query()
16 | |> Recordings.stream()
17 |
18 | for asciicast <- asciicasts do
19 | Oban.insert!(UpdateSnapshot.new(%{asciicast_id: asciicast.id}))
20 | end
21 |
22 | :ok
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/initial_seed.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.InitialSeed do
2 | use Oban.Worker
3 | require Logger
4 |
5 | @impl Oban.Worker
6 | def perform(_job) do
7 | user = Asciinema.Accounts.ensure_asciinema_user()
8 | :ok = Asciinema.Recordings.ensure_welcome_asciicast(user)
9 |
10 | Logger.info("database seeded successfully")
11 |
12 | :ok
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/mark_offline_streams.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.MarkOfflineStreams do
2 | use Oban.Worker,
3 | unique: [
4 | period: :infinity,
5 | states: [:scheduled, :available, :executing, :retryable]
6 | ]
7 |
8 | alias Asciinema.Streaming
9 | require Logger
10 |
11 | @impl Oban.Worker
12 | def perform(_job) do
13 | count = Streaming.mark_inactive_streams_offline()
14 |
15 | if count > 0 do
16 | Logger.info("marked #{count} streams offline")
17 | end
18 |
19 | :ok
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/migrate_recording_files.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.MigrateRecordingFiles do
2 | use Oban.Worker,
3 | unique: [
4 | period: :infinity,
5 | states: [:available, :retryable]
6 | ]
7 |
8 | require Logger
9 | alias Asciinema.Recordings
10 |
11 | @impl Oban.Worker
12 | def perform(%Oban.Job{args: %{"asciicast_id" => id}}) do
13 | Logger.info("migrating file for recording #{id}...")
14 | Recordings.migrate_file(id)
15 | Logger.info("recording #{id} file migrated")
16 |
17 | :ok
18 | end
19 |
20 | def perform(%Oban.Job{args: %{"user_id" => user_id}}) do
21 | asciicasts =
22 | {:user_id, user_id}
23 | |> Recordings.query()
24 | |> Recordings.stream()
25 | |> Recordings.migratable()
26 |
27 | for asciicast <- asciicasts do
28 | Oban.insert!(__MODULE__.new(%{asciicast_id: asciicast.id}))
29 | end
30 |
31 | :ok
32 | end
33 |
34 | def perform(_job) do
35 | asciicasts =
36 | Recordings.query()
37 | |> Recordings.stream()
38 | |> Recordings.migratable()
39 |
40 | for asciicast <- asciicasts do
41 | Oban.insert!(__MODULE__.new(%{asciicast_id: asciicast.id}))
42 | end
43 |
44 | :ok
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/asciinema/workers/update_snapshot.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Workers.UpdateSnapshot do
2 | use Oban.Worker,
3 | unique: [
4 | period: :infinity,
5 | states: [:available, :retryable]
6 | ]
7 |
8 | alias Asciinema.Recordings
9 | require Logger
10 |
11 | @impl Oban.Worker
12 | def perform(%Oban.Job{args: %{"asciicast_id" => id}}) do
13 | Logger.info("updating snapshot for recording #{id}...")
14 |
15 | with {:ok, asciicast} <- Recordings.fetch_asciicast(id),
16 | {:ok, _} <- Recordings.update_snapshot(asciicast) do
17 | Logger.info("snapshot for recording #{id} updated")
18 |
19 | :ok
20 | else
21 | {:error, :not_found} -> :discard
22 | {:error, _} = result -> result
23 | result -> {:error, result}
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.Layouts do
2 | @moduledoc """
3 | This module holds different layouts used by your application.
4 |
5 | See the `layouts` directory for all templates available.
6 | The "root" layout is a skeleton rendered as part of the
7 | application router. The "app" layout is set as the default
8 | layout on both `use AsciinemaAdmin, :controller` and
9 | `use AsciinemaAdmin, :live_view`.
10 | """
11 | use AsciinemaAdmin, :html
12 |
13 | embed_templates "layouts/*"
14 | end
15 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Admin panel - asciinema
9 |
47 |
48 |
49 |
50 |
55 |
56 |
57 | {@inner_content}
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/cli_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.CliController do
2 | use AsciinemaAdmin, :controller
3 | alias Asciinema.Accounts
4 |
5 | def create(conn, %{"user_id" => user_id, "cli" => %{"token" => token}}) do
6 | user = Accounts.get_user(user_id)
7 | install_id = extract_install_id(token)
8 |
9 | case Asciinema.register_cli(user, install_id) do
10 | :ok ->
11 | redirect(conn, to: ~p"/admin/users/#{user}")
12 |
13 | {:error, reason} ->
14 | redirect(conn, to: ~p"/admin/users/#{user}?error=#{reason}")
15 | end
16 | end
17 |
18 | defp extract_install_id(value) do
19 | value
20 | |> URI.parse()
21 | |> Map.get(:path)
22 | |> String.split("/")
23 | |> List.last()
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/home_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.HomeController do
2 | use AsciinemaAdmin, :controller
3 |
4 | def show(conn, _params) do
5 | redirect(conn, to: ~p"/admin/users")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/home_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.HomeHTML do
2 | use AsciinemaAdmin, :html
3 |
4 | embed_templates "home_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/user_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.UserHTML do
2 | use AsciinemaAdmin, :html
3 |
4 | embed_templates "user_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/user_html/edit.html.heex:
--------------------------------------------------------------------------------
1 | Users › {@user.username || @user.id}
2 |
3 |
4 |
5 |
6 | <.form :let={f} for={@changeset} action={~p"/admin/users/#{@user}"} method="PUT">
7 |
8 | <.label for={f[:username]}>Username
9 | <.input field={f[:username]} required />
10 | <.error field={f[:username]} />
11 |
12 |
13 |
14 | <.label for={f[:email]}>Email
15 | <.input field={f[:email]} type="email" required />
16 | <.error field={f[:email]} />
17 |
18 |
19 |
20 | <.label for={f[:name]}>Display name
21 | <.input field={f[:name]} />
22 | <.error field={f[:name]} />
23 |
24 |
25 |
26 |
27 | Streaming
28 |
29 |
30 | <.input field={f[:streaming_enabled]} type="checkbox" />
31 | <.label for={f[:streaming_enabled]}>Enabled
32 |
33 |
34 |
35 | <.label for={f[:stream_limit]}>Stream limit
36 | <.input field={f[:stream_limit]} type="number" min="0" placeholder="no limit" />
37 | <.error field={f[:stream_limit]} />
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/controllers/user_html/new.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | <.form :let={f} for={@changeset} action={~p"/admin/users"}>
7 |
8 | <.label for={f[:email]}>Email
9 | <.input field={f[:email]} type="email" required />
10 | <.error field={f[:email]} />
11 |
12 |
13 |
14 | <.label for={f[:username]}>Username
15 | <.input field={f[:username]} required />
16 | <.error field={f[:username]} />
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :asciinema
3 |
4 | @session_options [
5 | store: :cookie,
6 | key: "_asciinema_admin_key",
7 | signing_salt: "qJL+3s0T",
8 | same_site: "Lax"
9 | ]
10 |
11 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
12 |
13 | # Serve at "/" the static files from "priv/static" directory.
14 | #
15 | # You should set gzip to true if you are running phx.digest
16 | # when deploying your static files in production.
17 | plug Plug.Static,
18 | at: "/",
19 | from: :asciinema,
20 | gzip: true,
21 | only: AsciinemaAdmin.static_paths()
22 |
23 | if code_reloading? do
24 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
25 | plug Phoenix.LiveReloader
26 | plug Phoenix.CodeReloader
27 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :asciinema
28 | end
29 |
30 | plug Phoenix.LiveDashboard.RequestLogger,
31 | param_key: "request_logger",
32 | cookie_key: "request_logger"
33 |
34 | plug Plug.Parsers,
35 | parsers: [:urlencoded, :multipart, :json],
36 | pass: ["*/*"],
37 | json_decoder: Phoenix.json_library()
38 |
39 | plug Plug.MethodOverride
40 | plug Plug.Head
41 | plug Plug.Session, @session_options
42 | plug AsciinemaAdmin.Router
43 | end
44 |
--------------------------------------------------------------------------------
/lib/asciinema_admin/router.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaAdmin.Router do
2 | use AsciinemaAdmin, :router
3 | import Oban.Web.Router
4 | import Phoenix.LiveDashboard.Router
5 |
6 | pipeline :browser do
7 | plug :accepts, ["html"]
8 | plug :fetch_session
9 | plug :fetch_flash
10 | plug :fetch_live_flash
11 | plug :protect_from_forgery
12 | plug :put_secure_browser_headers
13 | end
14 |
15 | scope "/", AsciinemaAdmin do
16 | pipe_through :browser
17 |
18 | get "/", HomeController, :show
19 | end
20 |
21 | scope "/admin", AsciinemaAdmin do
22 | pipe_through :browser
23 |
24 | get "/", HomeController, :show
25 | get "/users/lookup", UserController, :lookup
26 |
27 | resources "/users", UserController do
28 | resources "/clis", CliController
29 | end
30 |
31 | live_dashboard "/dashboard", metrics: Asciinema.Telemetry
32 | oban_dashboard("/oban")
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/asciinema_web/caching.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Caching do
2 | import Plug.Conn
3 |
4 | def put_etag(conn, content) do
5 | etag = Crypto.md5(to_string(content))
6 |
7 | conn
8 | |> put_resp_header("etag", etag)
9 | |> register_before_send(fn conn ->
10 | if etag in get_req_header(conn, "if-none-match") do
11 | resp(conn, 304, "")
12 | else
13 | conn
14 | end
15 | end)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", AsciinemaWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | def connect(_params, socket, _connect_info) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # AsciinemaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Icons do
2 | use Phoenix.Component
3 |
4 | embed_templates "icons/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/adjustments_horizontal_mini_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/cog_8_tooth_mini_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/download_mini_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/envelope_outline_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/eye_solid_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/info_outline_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/info_solid_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/live_icon.html.heex:
--------------------------------------------------------------------------------
1 | LIVE
2 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/offline_icon.html.heex:
--------------------------------------------------------------------------------
1 | OFFLINE
2 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/share_mini_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
5 |
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/terminal_solid_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
--------------------------------------------------------------------------------
/lib/asciinema_web/components/icons/user_solid_icon.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/avatar_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.AvatarController do
2 | use AsciinemaWeb, :controller
3 | alias Asciinema.Accounts
4 |
5 | @one_day 24 * 60 * 60
6 |
7 | def show(conn, %{"id" => id}) do
8 | with {:ok, user} <- Accounts.fetch_user(id) do
9 | conn
10 | |> put_resp_header("cache-control", "public, max-age=#{@one_day}")
11 | |> render("show.svg", user: user)
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/avatar_svg.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.AvatarSVG do
2 | def show(%{user: user}) do
3 | email = user.email || "#{user.id}@asciinema"
4 |
5 | {:safe, IdenticonSvg.generate(email, 7, :split2, 1.0, 1)}
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/cli_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.CliController do
2 | use AsciinemaWeb, :controller
3 |
4 | plug :require_current_user
5 |
6 | def register(conn, %{"install_id" => install_id}) do
7 | case Asciinema.register_cli(conn.assigns.current_user, install_id) do
8 | :ok ->
9 | conn
10 | |> put_flash(:info, "CLI successfully authenticated with your account")
11 | |> redirect_to_profile()
12 |
13 | {:error, :token_taken} ->
14 | conn
15 | |> put_flash(:error, "This CLI has been authenticated with a different user account")
16 | |> redirect_to_profile()
17 |
18 | {:error, :token_invalid} ->
19 | conn
20 | |> put_flash(:error, "Invalid installation ID - make sure to paste the URL correctly")
21 | |> redirect(to: ~p"/")
22 |
23 | {:error, :cli_revoked} ->
24 | conn
25 | |> put_flash(:error, "This CLI authentication has been revoked")
26 | |> redirect(to: ~p"/")
27 | end
28 | end
29 |
30 | def delete(conn, %{"id" => id}) do
31 | case Asciinema.revoke_cli(conn.assigns.current_user, id) do
32 | :ok ->
33 | conn
34 | |> put_flash(:info, "CLI authentication revoked")
35 | |> redirect(to: ~p"/user/edit")
36 |
37 | {:error, :not_found} ->
38 | conn
39 | |> put_flash(:error, "CLI not found")
40 | |> redirect(to: ~p"/user/edit")
41 | end
42 | end
43 |
44 | defp redirect_to_profile(conn) do
45 | user = conn.assigns.current_user
46 |
47 | if user.username do
48 | redirect(conn, to: profile_path(user))
49 | else
50 | redirect(conn, to: ~p"/username/new")
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ErrorHTML do
2 | use AsciinemaWeb, :html
3 |
4 | embed_templates "error_html/*"
5 |
6 | def render(template, _assigns) do
7 | Phoenix.Controller.status_message_from_template(template)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/error_html/403.html.eex:
--------------------------------------------------------------------------------
1 | 403 Forbidden
2 | You're not authorized to access this page. Sorry!
3 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/error_html/404.html.eex:
--------------------------------------------------------------------------------
1 | 404 Not Found
2 | This page doesn't exist. Sorry!
3 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ErrorJSON do
2 | def render(template, _assigns) do
3 | %{error: Phoenix.Controller.status_message_from_template(template)}
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/error_text.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ErrorTEXT do
2 | def render(template, _assigns) do
3 | Phoenix.Controller.status_message_from_template(template)
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/fallback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.FallbackController do
2 | use AsciinemaWeb, :controller
3 |
4 | def call(conn, {:error, :bad_request}), do: error(conn, 400)
5 | def call(conn, {:error, :forbidden}), do: error(conn, 403)
6 | def call(conn, {:error, :not_found}), do: error(conn, 404)
7 |
8 | defp error(conn, status) do
9 | conn
10 | |> put_layout(:simple)
11 | |> put_status(status)
12 | |> put_view(
13 | html: AsciinemaWeb.ErrorHTML,
14 | json: AsciinemaWeb.ErrorJSON,
15 | cast: AsciinemaWeb.ErrorJSON,
16 | js: AsciinemaWeb.ErrorTEXT,
17 | txt: AsciinemaWeb.ErrorTEXT,
18 | svg: AsciinemaWeb.ErrorTEXT,
19 | png: AsciinemaWeb.ErrorTEXT,
20 | gif: AsciinemaWeb.ErrorTEXT,
21 | xml: AsciinemaWeb.ErrorTEXT
22 | )
23 | |> render(:"#{status}")
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/login_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.LoginHTML do
2 | use AsciinemaWeb, :html
3 |
4 | embed_templates "login_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/login_html/sent.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | <.envelope_outline_icon /> Check your inbox
6 |
7 |
8 |
9 |
10 |
11 | We've sent an email with one-time login link to your email address.
12 | Click on the link to log in to your account.
13 |
14 |
15 |
16 | If the email doesn't show up in your inbox, check your spam folder,
17 | or try again in a moment.
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/medium_html/env_info.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <%= if is_tuple(item) do %>
3 | {elem(item, 0)}
4 | <% else %>
5 | {item}
6 | <% end %>
7 |
8 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/oembed_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.OembedController do
2 | use AsciinemaWeb, :controller
3 | alias Asciinema.Recordings
4 |
5 | plug :put_layout, nil
6 |
7 | def show(conn, params) do
8 | with {:ok, path} <- parse_url(params["url"] || ""),
9 | {:ok, id} <- extract_id(path),
10 | {:ok, asciicast} <- Recordings.fetch_asciicast(id),
11 | :ok <- authorize(asciicast) do
12 | {mw, mh} = get_size(params)
13 | format = get_embed_format(conn)
14 |
15 | render(
16 | conn,
17 | "show.#{format}",
18 | asciicast: asciicast,
19 | max_width: mw,
20 | max_height: mh
21 | )
22 | else
23 | {:error, reason} ->
24 | {:error, reason}
25 | end
26 | end
27 |
28 | defp parse_url(url) do
29 | case URI.parse(url).path do
30 | nil -> {:error, :bad_request}
31 | path -> {:ok, path}
32 | end
33 | end
34 |
35 | defp extract_id(path) do
36 | case Regex.run(~r|^/a/([^/]+)$|, path) do
37 | [_, id] -> {:ok, id}
38 | _ -> {:error, :bad_request}
39 | end
40 | end
41 |
42 | defp authorize(asciicast) do
43 | if asciicast.visibility == :private do
44 | {:error, :forbidden}
45 | else
46 | :ok
47 | end
48 | end
49 |
50 | defp get_size(params) do
51 | mw = if params["maxwidth"], do: String.to_integer(params["maxwidth"])
52 | mh = if params["maxheight"], do: String.to_integer(params["maxheight"])
53 |
54 | {mw, mh}
55 | end
56 |
57 | defp get_embed_format(conn) do
58 | case conn.params["format"] || get_format(conn) do
59 | "xml" -> "xml"
60 | _ -> "json"
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/oembed_xml.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.OembedXML do
2 | use Phoenix.Component
3 | alias AsciinemaWeb.OembedJSON
4 |
5 | embed_templates "oembed_xml/*"
6 |
7 | def show(assigns) do
8 | ~H"""
9 | <.rich {OembedJSON.show(assigns)} />
10 | """
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/oembed_xml/rich.xml.heex:
--------------------------------------------------------------------------------
1 |
2 | rich
3 | 1.0
4 | {@title}
5 | {@author_name}
6 | {@author_url}
7 | asciinema
8 | {@provider_url}
9 | {@thumbnail_url}
10 | {@thumbnail_width}
11 | {@thumbnail_height}
12 | {@html}
13 | {@thumbnail_width}
14 | {@thumbnail_height}
15 |
16 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.PageController do
2 | use AsciinemaWeb, :controller
3 |
4 | plug :wrap_in_container
5 |
6 | def about(conn, _params) do
7 | render(
8 | conn,
9 | "about.html",
10 | page_title: "About",
11 | contact_email_address: Application.get_env(:asciinema, :contact_email_address),
12 | server_name: AsciinemaWeb.Endpoint.host(),
13 | server_version: Application.get_env(:asciinema, :version)
14 | )
15 | end
16 |
17 | defp wrap_in_container(conn, _), do: assign(conn, :main_class, "container")
18 | end
19 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/page_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.PageHTML do
2 | use AsciinemaWeb, :html
3 |
4 | embed_templates "page_html/*"
5 |
6 | def obfuscated_email(assigns) do
7 | [username, domain] = String.split(assigns[:address], "@")
8 | {domain_1, domain_2} = String.split_at(domain, div(String.length(domain), 2))
9 | assigns = assign(assigns, %{username: username, domain_1: domain_1, domain_2: domain_2})
10 |
11 | ~H"""
12 |
13 | {@username}@{@domain_1}<%= @domain_2 %>{@domain_2}
14 |
15 | """
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/card.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <.link href={~p"/a/#{@asciicast}"} class="thumbnail-link">
3 |
10 |
11 | <.thumbnail asciicast={@asciicast} />
12 |
13 |
14 |
15 |
16 | <.link href={~p"/a/#{@asciicast}"}>{title(@asciicast)}
17 | {duration(@asciicast)}
18 |
19 |
20 |
21 | <.link href={author_profile_path(@asciicast)} title={author_username(@asciicast)}>
22 |
23 |
24 |
25 |
26 |
27 | by <.link href={author_profile_path(@asciicast)}>{author_username(@asciicast)}
28 | <%= if !assigns[:no_created_at] do %>
29 | {time_ago_tag(@asciicast.inserted_at)}
30 | <% end %>
31 | <.visibility_badge
32 | :if={assigns[:conn] && owned_by_current_user?(@asciicast, @conn)}
33 | visibility={@asciicast.visibility}
34 | />
35 | <.featured_badge :if={@asciicast.featured} />
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/deleted.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
This recording has been deleted
5 |
6 | <%= if @ttl do %>
7 |
8 | All unclaimed recordings are automatically deleted {@ttl} days after upload.
9 |
10 |
11 |
12 | See here
13 | for more details.
14 |
15 | <% end %>
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/featured_badge.html.heex:
--------------------------------------------------------------------------------
1 |
5 | featured
6 |
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/gif.html.heex:
--------------------------------------------------------------------------------
1 | Looking for GIF?
2 |
3 | You can use agg to do it
4 | yourself. Once you have it installed run the following commands in your
5 | terminal:
6 |
7 | wget -O <%= @asciicast_id %>.cast <%= @file_url %>
8 | agg <%= @asciicast_id %>.cast <%= @asciicast_id %>.gif
9 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/head_for_show.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 | ".png"} />
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ".png"} />
22 |
23 |
28 |
29 | <%= if @asciicast.visibility != :public do %>
30 |
31 |
32 | <% end %>
33 |
34 | <.theme_style theme={theme(@asciicast)} />
35 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/iframe.html.heex:
--------------------------------------------------------------------------------
1 | <.theme_style theme={theme(@asciicast)} />
2 |
3 |
4 |
5 |
6 | Recorded with asciinema
7 |
8 |
9 |
40 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/secret_badge.html.heex:
--------------------------------------------------------------------------------
1 |
5 | secret
6 |
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/theme_style.html.heex:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/thumbnail.html.heex:
--------------------------------------------------------------------------------
1 |
5 |

".svg?f=t&v=#{svg_cache_key(@asciicast)}"} />
6 |
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_html/visibility_badge.html.heex:
--------------------------------------------------------------------------------
1 |
6 | public
7 |
8 |
13 | unlisted
14 |
15 |
16 | private
17 |
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/recording_svg/logo.svg.heex:
--------------------------------------------------------------------------------
1 |
30 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/session_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.SessionController do
2 | use AsciinemaWeb, :controller
3 |
4 | def new(conn, %{"t" => login_token}) do
5 | conn
6 | |> put_session(:login_token, login_token)
7 | |> redirect(to: ~p"/session/new")
8 | end
9 |
10 | def new(conn, _params) do
11 | render(conn, "new.html")
12 | end
13 |
14 | def create(conn, _params) do
15 | login_token = get_session(conn, :login_token)
16 | conn = delete_session(conn, :login_token)
17 |
18 | case Asciinema.verify_login_token(login_token) do
19 | {:ok, user} ->
20 | conn
21 | |> log_in(user)
22 | |> put_flash(:info, "Welcome back!")
23 | |> redirect_to_profile()
24 |
25 | {:error, :token_invalid} ->
26 | conn
27 | |> put_flash(:error, "Invalid login link.")
28 | |> redirect(to: ~p"/login/new")
29 |
30 | {:error, :token_expired} ->
31 | conn
32 | |> put_flash(:error, "This login link has expired, sorry.")
33 | |> redirect(to: ~p"/login/new")
34 |
35 | {:error, :user_not_found} ->
36 | conn
37 | |> put_flash(:error, "This account has been removed.")
38 | |> redirect(to: ~p"/login/new")
39 | end
40 | end
41 |
42 | defp redirect_to_profile(conn) do
43 | user = conn.assigns.current_user
44 |
45 | if user.username do
46 | redirect_back_or(conn, to: profile_path(user))
47 | else
48 | redirect(conn, to: ~p"/username/new")
49 | end
50 | end
51 |
52 | def delete(conn, _params) do
53 | conn
54 | |> log_out()
55 | |> put_flash(:info, "See you later!")
56 | |> redirect(to: ~p"/")
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/session_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.SessionHTML do
2 | use AsciinemaWeb, :html
3 |
4 | embed_templates "session_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/session_html/new.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Verifying link...
5 |
6 |
7 |
8 |
9 | <.form for={@conn} action={~p"/session"} id="login" as={:login}>
10 |
11 |
14 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/stream_html/card.html.heex:
--------------------------------------------------------------------------------
1 |
2 | <.link href={~p"/s/#{@stream}"} class="thumbnail-link">
3 |
10 |
11 | <.thumbnail stream={@stream} />
12 |
13 |
14 |
15 |
16 | <.link href={~p"/s/#{@stream}"}>{title(@stream)}
17 | <.live_icon :if={@stream.online} />
18 |
19 |
20 |
21 | <.link href={author_profile_path(@stream)} title={author_username(@stream)}>
22 |
23 |
24 |
25 |
26 |
27 | by <.link href={author_profile_path(@stream)}>{author_username(@stream)}
28 | <.visibility_badge
29 | :if={assigns[:conn] && owned_by_current_user?(@stream, @conn)}
30 | visibility={@stream.visibility}
31 | />
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/stream_html/head_for_show.html.heex:
--------------------------------------------------------------------------------
1 | <%= if @stream.visibility != :public do %>
2 |
3 |
4 | <% end %>
5 |
6 | <.theme_style theme={theme(@stream)} />
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/stream_html/theme_style.html.heex:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/stream_html/thumbnail.html.heex:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/stream_html/visibility_badge.html.heex:
--------------------------------------------------------------------------------
1 |
6 | public
7 |
8 |
13 | unlisted
14 |
15 |
16 | private
17 |
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/user_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UserHTML do
2 | use AsciinemaWeb, :html
3 | import AsciinemaWeb.ErrorHelpers
4 | import Scrivener.HTML
5 | alias Asciinema.Fonts
6 | alias AsciinemaWeb.{DefaultAvatar, MediaView, RecordingHTML}
7 |
8 | embed_templates "user_html/*"
9 |
10 | defdelegate theme_options, to: MediaView
11 | defdelegate font_family_options, to: MediaView
12 | defdelegate default_font_display_name, to: Fonts
13 |
14 | def avatar_url(user) do
15 | DefaultAvatar.url(user)
16 | end
17 |
18 | def username(user) do
19 | user.username || user.temporary_username || "user:#{user.id}"
20 | end
21 |
22 | def display_name(user) do
23 | if String.trim("#{user.name}") != "" do
24 | user.name
25 | end
26 | end
27 |
28 | def joined_at(user) do
29 | Timex.format!(user.inserted_at, "{Mfull} {D}, {YYYY}")
30 | end
31 |
32 | def active_clis(clis) do
33 | clis
34 | |> Enum.reject(& &1.revoked_at)
35 | |> Enum.sort_by(&(-Timex.to_unix(&1.inserted_at)))
36 | end
37 |
38 | def revoked_clis(clis) do
39 | clis
40 | |> Enum.filter(& &1.revoked_at)
41 | |> Enum.sort_by(&(-Timex.to_unix(&1.inserted_at)))
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/user_html/delete.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Are you sure?
5 |
6 |
This will permanently delete your account and all associated recordings.
7 |
8 | <.form for={@conn} method="delete" action={~p"/user"}>
9 | <.input name="confirmed" type="hidden" value="1" />
10 | <.input name="token" type="hidden" value={@token} />
11 | <.button type="submit" class="btn btn-danger" data-confirm="There's no going back!">
12 | Yes, delete my account
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/user_html/new.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Verifying link...
5 |
6 |
7 |
8 |
9 | <.form for={@conn} action={~p"/users"} id="login" as={:login}>
10 |
11 |
14 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/username_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UsernameController do
2 | use AsciinemaWeb, :controller
3 |
4 | plug :require_current_user
5 |
6 | def new(conn, _params) do
7 | user = conn.assigns.current_user
8 | changeset = Asciinema.change_user(user)
9 | render(conn, "new.html", user: user, changeset: changeset)
10 | end
11 |
12 | def create(conn, %{"user" => user_params}) do
13 | user = conn.assigns.current_user
14 |
15 | case Asciinema.update_user(user, user_params) do
16 | {:ok, user} ->
17 | redirect(conn, to: profile_path(conn, user))
18 |
19 | {:error, %Ecto.Changeset{} = changeset} ->
20 | error =
21 | case Keyword.get(changeset.errors, :username) do
22 | {_msg, [{_, :format}]} -> :username_invalid
23 | {_msg, [{_, :required}]} -> :username_invalid
24 | {_msg, _} -> :username_taken
25 | end
26 |
27 | conn
28 | |> put_status(422)
29 | |> render(
30 | "new.html",
31 | user: user,
32 | error: error,
33 | changeset: changeset
34 | )
35 | end
36 | end
37 |
38 | def skip(conn, _params) do
39 | redirect(conn, to: profile_path(conn, conn.assigns.current_user))
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/username_html.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UsernameHTML do
2 | use AsciinemaWeb, :html
3 |
4 | embed_templates "username_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/username_html/new.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Choose your username
5 |
6 |
7 |
8 | Every asciinema user gets a profile page at <%= url(~p"/") %>~username.
9 |
10 |
11 | <.form :let={f} for={@changeset} action={~p"/username"} class="username-form" method="post">
12 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/webfinger_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.WebFingerController do
2 | use AsciinemaWeb, :controller
3 | alias Asciinema.Accounts
4 |
5 | def show(conn, %{"resource" => resource}) do
6 | resource =
7 | resource
8 | |> String.trim()
9 | |> String.downcase()
10 |
11 | with "acct:" <> acct <- resource,
12 | [username, domain] <- String.split(acct, "@"),
13 | ^domain <- AsciinemaWeb.Endpoint.host(),
14 | {:username, user} when not is_nil(user) <- Accounts.lookup_user(username) do
15 | render(conn, :show, user: user, domain: domain)
16 | else
17 | _ -> {:error, :not_found}
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/asciinema_web/controllers/webfinger_json.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.WebFingerJSON do
2 | use AsciinemaWeb, :json
3 |
4 | def show(%{user: %{username: username}, domain: domain}) do
5 | %{
6 | subject: "acct:#{username}@#{domain}",
7 | aliases: [
8 | url(~p"/~#{username}")
9 | ],
10 | links: [
11 | %{
12 | rel: "http://webfinger.net/rel/profile-page",
13 | type: "text/html",
14 | href: url(~p"/~#{username}")
15 | }
16 | ]
17 | }
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/asciinema_web/default_avatar.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.DefaultAvatar do
2 | @type url :: String.t()
3 | @type user :: Asciinema.User.t()
4 |
5 | @callback url(user) :: url
6 |
7 | def url(user) do
8 | Keyword.fetch!(Application.fetch_env!(:asciinema, __MODULE__), :adapter).url(user)
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/asciinema_web/default_avatar/gravatar.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.DefaultAvatar.Gravatar do
2 | @behaviour AsciinemaWeb.DefaultAvatar
3 |
4 | @size 128
5 | @style "retro"
6 |
7 | @impl true
8 | def url(user) do
9 | email = user.email || "#{user.id}@asciinema"
10 |
11 | hash =
12 | email
13 | |> String.downcase()
14 | |> Crypto.md5()
15 |
16 | "//gravatar.com/avatar/#{hash}?s=#{@size}&d=#{@style}"
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/asciinema_web/default_avatar/identicon.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.DefaultAvatar.Identicon do
2 | use Phoenix.VerifiedRoutes,
3 | endpoint: AsciinemaWeb.Endpoint,
4 | router: AsciinemaWeb.Router
5 |
6 | @behaviour AsciinemaWeb.DefaultAvatar
7 |
8 | @impl true
9 | def url(user) do
10 | ~p"/u/#{user}/avatar"
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/asciinema_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Endpoint do
2 | use Sentry.PlugCapture
3 | use Phoenix.Endpoint, otp_app: :asciinema
4 |
5 | @session_opts Application.compile_env!(:asciinema, :session_opts)
6 |
7 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_opts]]
8 |
9 | # Serve at "/" the static files from "priv/static" directory.
10 | #
11 | # You should set gzip to true if you are running phx.digest
12 | # when deploying your static files in production.
13 | plug Plug.Static,
14 | at: "/",
15 | from: :asciinema,
16 | gzip: true,
17 | only: AsciinemaWeb.static_paths()
18 |
19 | # Code reloading can be explicitly enabled under the
20 | # :code_reloader configuration of your endpoint.
21 | if code_reloading? do
22 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
23 | plug Phoenix.LiveReloader
24 | plug Phoenix.CodeReloader
25 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :asciinema
26 | end
27 |
28 | plug Phoenix.LiveDashboard.RequestLogger,
29 | param_key: "request_logger",
30 | cookie_key: "request_logger"
31 |
32 | plug Plug.RequestId
33 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
34 |
35 | plug Plug.Parsers,
36 | parsers: [:urlencoded, AsciinemaWeb.Plug.Parsers.MULTIPART, :json],
37 | pass: ["*/*"],
38 | json_decoder: Phoenix.json_library()
39 |
40 | plug RemoteIp
41 | plug Sentry.PlugContext
42 | plug Plug.MethodOverride
43 | plug Plug.Head
44 | plug Plug.Session, @session_opts
45 | plug AsciinemaWeb.PlugAttack
46 | plug AsciinemaWeb.Router
47 | end
48 |
--------------------------------------------------------------------------------
/lib/asciinema_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import Asciinema.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :asciinema
24 | end
25 |
--------------------------------------------------------------------------------
/lib/asciinema_web/live/stream_card_live.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.StreamCardLive do
2 | use AsciinemaWeb, :live_view
3 | alias Asciinema.Streaming
4 | alias Asciinema.Streaming.StreamServer
5 |
6 | @impl true
7 | def render(assigns) do
8 | ~H"""
9 |
10 | """
11 | end
12 |
13 | @impl true
14 | def mount(_params, %{"stream_id" => stream_id}, socket) do
15 | socket = assign(socket, :stream, Streaming.get_stream(stream_id))
16 |
17 | if connected?(socket) do
18 | StreamServer.subscribe(stream_id, :metadata)
19 | end
20 |
21 | {:ok, socket}
22 | end
23 |
24 | @impl true
25 | def handle_info(%StreamServer.Update{event: :metadata} = update, socket) do
26 | {:noreply, assign(socket, stream: update.data)}
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug/authn.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Plug.Authn do
2 | import Plug.Conn
3 | alias AsciinemaWeb.Authentication
4 | alias Plug.Conn
5 |
6 | def init(opts), do: opts
7 |
8 | def call(%Conn{assigns: %{current_user: user}} = conn, _opts) when not is_nil(user), do: conn
9 |
10 | def call(conn, _opts) do
11 | conn
12 | |> assign(:current_user, nil)
13 | |> Authentication.try_log_in_from_session()
14 | |> Authentication.try_log_in_from_cookie()
15 | |> setup_sentry_context()
16 | end
17 |
18 | defp setup_sentry_context(%Conn{assigns: %{current_user: nil}} = conn), do: conn
19 |
20 | defp setup_sentry_context(%Conn{assigns: %{current_user: user}} = conn) do
21 | Sentry.Context.set_user_context(%{
22 | id: user.id,
23 | username: user.username,
24 | email: user.email
25 | })
26 |
27 | conn
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug/authz.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Plug.Authz do
2 | alias Asciinema.Authorization
3 | alias AsciinemaWeb.FallbackController
4 | alias Plug.Conn
5 |
6 | def authorize(conn, assign_key) do
7 | user = conn.assigns[:current_user]
8 | action = Phoenix.Controller.action_name(conn)
9 | resource = conn.assigns[assign_key]
10 |
11 | if Authorization.can?(user, action, resource) do
12 | conn
13 | else
14 | conn
15 | |> FallbackController.call({:error, :forbidden})
16 | |> Conn.halt()
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug/parsers/multipart.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Plug.Parsers.MULTIPART do
2 | @multipart Plug.Parsers.MULTIPART
3 |
4 | def init(opts), do: opts
5 |
6 | def parse(conn, "multipart", subtype, headers, opts) do
7 | opts = if length = config(:length), do: [{:length, length} | opts], else: opts
8 | opts = @multipart.init(opts)
9 |
10 | @multipart.parse(conn, "multipart", subtype, headers, opts)
11 | end
12 |
13 | def parse(conn, _type, _subtype, _headers, _opts), do: {:next, conn}
14 |
15 | defp config(key) do
16 | Keyword.get(Application.get_env(:asciinema, __MODULE__, []), key)
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug/return_to.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Plug.ReturnTo do
2 | import Plug.Conn
3 | import Phoenix.Controller, only: [redirect: 2]
4 |
5 | def save_return_path(conn) do
6 | qs = if conn.query_string != "", do: "?#{conn.query_string}", else: ""
7 | save_return_path(conn, conn.request_path <> qs)
8 | end
9 |
10 | def save_return_path(conn, return_path) do
11 | put_session(conn, :return_to, return_path)
12 | end
13 |
14 | def redirect_back_or(conn, target) do
15 | target =
16 | if return_to = get_session(conn, :return_to) do
17 | [to: return_to]
18 | else
19 | target
20 | end
21 |
22 | conn
23 | |> clear_return_path()
24 | |> redirect(target)
25 | |> halt()
26 | end
27 |
28 | def clear_return_path(conn) do
29 | delete_session(conn, :return_to)
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug/trailing_format.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Plug.TrailingFormat do
2 | @known_exts ["js", "json", "cast", "txt", "svg", "png", "gif", "xml"]
3 |
4 | def init(opts), do: opts
5 |
6 | def call(conn, _opts) do
7 | with [last | segments] <- Enum.reverse(conn.path_info),
8 | [id, format] when format in @known_exts <- String.split(last, ".") do
9 | path_info = Enum.reverse([id | segments])
10 | params = Map.merge(conn.params, %{"id" => id, "_format" => format})
11 | path_params = Map.put(conn.path_params, "id", id)
12 | %{conn | path_info: path_info, params: params, path_params: path_params}
13 | else
14 | _ -> conn
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/asciinema_web/plug_attack.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.PlugAttack do
2 | use PlugAttack
3 | import Plug.Conn
4 |
5 | rule "allow local", conn do
6 | allow(conn.remote_ip == {127, 0, 0, 1})
7 | end
8 |
9 | rule "throttle by ip", conn do
10 | if limit = config(:ip_limit) do
11 | throttle(conn.remote_ip,
12 | limit: limit,
13 | period: config(:ip_period),
14 | storage: {PlugAttack.Storage.Ets, AsciinemaWeb.PlugAttack.Storage}
15 | )
16 | end
17 | end
18 |
19 | def block_action(conn, {:throttle, data}, _opts) do
20 | reset = div(data[:expires_at], 1_000)
21 |
22 | conn
23 | |> put_resp_header("x-ratelimit-limit", to_string(data[:limit]))
24 | |> put_resp_header("x-ratelimit-remaining", to_string(data[:remaining]))
25 | |> put_resp_header("x-ratelimit-reset", to_string(reset))
26 | |> send_resp(429, "Too Many Requests\n")
27 | |> halt()
28 | end
29 |
30 | def block_action(conn, _data, _opts) do
31 | conn
32 | |> send_resp(:forbidden, "Forbidden\n")
33 | |> halt()
34 | end
35 |
36 | defp config(key) do
37 | Keyword.get(Application.get_env(:asciinema, __MODULE__, []), key)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/_flash.html.heex:
--------------------------------------------------------------------------------
1 | <%= if message = Phoenix.Flash.get(@flash, :info) do %>
2 |
3 |
4 | {message}
5 |
8 |
9 |
10 | <% end %>
11 |
12 | <%= if message = Phoenix.Flash.get(@flash, :error) do %>
13 |
14 |
15 | {message}
16 |
19 |
20 |
21 | <% end %>
22 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/_footer.html.heex:
--------------------------------------------------------------------------------
1 |
14 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/app.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {page_title(@conn)}
9 |
10 |
11 |
12 | <%= if function_exported?(view_module(@conn), :head, 2) do %>
13 | {view_module(@conn).head(view_template(@conn), assigns)}
14 | <% end %>
15 |
16 |
17 | body_class(@conn)}>
18 | {render("_header.html", conn: @conn, current_user: @current_user)}
19 | {render("_flash.html", flash: @flash)}
20 |
21 |
22 | {@inner_content}
23 |
24 |
25 | {render("_footer.html", conn: @conn)}
26 |
29 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/email.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {@inner_content}
8 |
9 |
10 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/iframe.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {@inner_content}
12 |
15 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/lib/asciinema_web/templates/layout/simple.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | {page_title(@conn)}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {@inner_content}
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/lib/asciinema_web/url_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UrlHelpers do
2 | use Phoenix.VerifiedRoutes,
3 | endpoint: AsciinemaWeb.Endpoint,
4 | router: AsciinemaWeb.Router
5 |
6 | alias AsciinemaWeb.Endpoint
7 |
8 | def profile_path(_conn, user), do: profile_path(user)
9 |
10 | def profile_path(%Plug.Conn{} = conn), do: profile_path(conn.assigns.current_user)
11 |
12 | def profile_path(%{id: id, username: username}) do
13 | if username do
14 | ~p"/~#{username}"
15 | else
16 | ~p"/u/#{id}"
17 | end
18 | end
19 |
20 | def profile_url(user) do
21 | Endpoint.url() <> profile_path(user)
22 | end
23 |
24 | def asciicast_file_url(asciicast) do
25 | url(~p"/a/#{asciicast}") <> "." <> ext(asciicast)
26 | end
27 |
28 | defp ext(asciicast) do
29 | case asciicast.version do
30 | 1 -> "json"
31 | 2 -> "cast"
32 | 3 -> "cast"
33 | end
34 | end
35 |
36 | @http_to_ws %{"http" => "ws", "https" => "wss"}
37 |
38 | def ws_producer_url(stream) do
39 | uri = Endpoint.struct_url()
40 | scheme = @http_to_ws[uri.scheme]
41 | path = "/ws/S/#{stream.producer_token}"
42 |
43 | to_string(%{uri | scheme: scheme, path: path})
44 | end
45 |
46 | def ws_public_url(stream) do
47 | uri = Endpoint.struct_url()
48 | scheme = @http_to_ws[uri.scheme]
49 | param = Phoenix.Param.to_param(stream)
50 | path = "/ws/s/#{param}"
51 |
52 | to_string(%{uri | scheme: scheme, path: path})
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/asciinema_web/url_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UrlProvider do
2 | use Phoenix.VerifiedRoutes,
3 | endpoint: AsciinemaWeb.Endpoint,
4 | router: AsciinemaWeb.Router
5 |
6 | @behaviour Asciinema.UrlProvider
7 |
8 | @impl true
9 | def sign_up(token) do
10 | url(~p"/users/new?t=#{token}")
11 | end
12 |
13 | @impl true
14 | def login(token) do
15 | url(~p"/session/new?t=#{token}")
16 | end
17 |
18 | @impl true
19 | def account_deletion(token) do
20 | url(~p"/user/delete?t=#{token}")
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/api/recording_json.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Api.RecordingJSON do
2 | use AsciinemaWeb, :json
3 |
4 | def created(assigns) do
5 | %{
6 | url: assigns.url,
7 | message: message(assigns)
8 | }
9 | end
10 |
11 | def error(%{changeset: changeset}) do
12 | %{errors: translate_errors(changeset)}
13 | end
14 |
15 | defp message(%{cli: cli, url: url}) do
16 | %{user: user, token: install_id} = cli
17 |
18 | message = """
19 | View the recording at:
20 |
21 | #{url}
22 | """
23 |
24 | is_tmp_user = Asciinema.Accounts.temporary_user?(user)
25 | ttl = Asciinema.unclaimed_recording_ttl()
26 |
27 | if is_tmp_user && ttl do
28 | hostname = AsciinemaWeb.instance_hostname()
29 | url = url(~p"/connect/#{install_id}")
30 |
31 | """
32 | #{message}
33 | This asciinema CLI hasn't been linked to any #{hostname} account.
34 |
35 | Recordings uploaded from unrecognized systems, such as this one, are automatically
36 | deleted #{ttl} days after upload.
37 |
38 | If you want to preserve all recordings uploaded from this machine,
39 | authenticate this CLI with your #{hostname} account by opening the following link:
40 |
41 | #{url}
42 | """
43 | else
44 | message
45 | end
46 | end
47 |
48 | def translate_errors(changeset) do
49 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/api/recording_text.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.Api.RecordingTEXT do
2 | use Phoenix.Component
3 | alias AsciinemaWeb.Api.RecordingJSON
4 |
5 | def created(assigns) do
6 | RecordingJSON.created(assigns).message
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/application_view.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ApplicationView do
2 | import Phoenix.HTML.Tag, only: [content_tag: 3]
3 | alias Asciinema.Accounts
4 |
5 | def present?([]), do: false
6 | def present?(nil), do: false
7 | def present?(""), do: false
8 | def present?(_), do: true
9 |
10 | def time_tag(time) do
11 | iso_8601_ts = Timex.format!(time, "{ISO:Extended:Z}")
12 | rfc_1123_ts = Timex.format!(time, "{RFC1123z}")
13 |
14 | content_tag(:time, datetime: iso_8601_ts) do
15 | "on #{rfc_1123_ts}"
16 | end
17 | end
18 |
19 | def time_ago_tag(time) do
20 | iso_8601_ts = Timex.format!(time, "{ISO:Extended:Z}")
21 | rfc_1123_ts = Timex.format!(time, "{RFC1123z}")
22 |
23 | content_tag(:time, datetime: iso_8601_ts, title: rfc_1123_ts) do
24 | Timex.from_now(time)
25 | end
26 | end
27 |
28 | def pluralize(1, thing), do: "1 #{thing}"
29 | def pluralize(n, thing), do: "#{n} #{Inflex.pluralize(thing)}"
30 |
31 | def sign_up_enabled?, do: Accounts.sign_up_enabled?()
32 |
33 | def safe_json(value) do
34 | json =
35 | value
36 | |> Jason.encode!()
37 | |> String.replace(~r/, "\\u003c")
38 |
39 | {:safe, json}
40 | end
41 |
42 | def render_markdown(input) do
43 | input = String.trim("#{input}")
44 |
45 | if present?(input) do
46 | {:safe, HtmlSanitizeEx.basic_html(Earmark.as_html!(input))}
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | def error_class(form, field) do
9 | if form.errors[field] do
10 | "has-error"
11 | end
12 | end
13 |
14 | @doc """
15 | Translates an error message using gettext.
16 | """
17 | def translate_error({msg, opts}) do
18 | # Because error messages were defined within Ecto, we must
19 | # call the Gettext module passing our Gettext backend. We
20 | # also use the "errors" domain as translations are placed
21 | # in the errors.po file.
22 | # Ecto will pass the :count keyword if the error message is
23 | # meant to be pluralized.
24 | # On your own code and templates, depending on whether you
25 | # need the message to be pluralized or not, this could be
26 | # written simply as:
27 | #
28 | # dngettext "errors", "1 file", "%{count} files", count
29 | # dgettext "errors", "is invalid"
30 | #
31 | if count = opts[:count] do
32 | Gettext.dngettext(AsciinemaWeb.Gettext, "errors", msg, msg, count, opts)
33 | else
34 | Gettext.dgettext(AsciinemaWeb.Gettext, "errors", msg, opts)
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.LayoutView do
2 | use AsciinemaWeb, :view
3 | import AsciinemaWeb.UserHTML, only: [avatar_url: 1]
4 |
5 | def page_title(conn) do
6 | title = conn.assigns[:page_title] || "Record and share your terminal sessions, the simple way"
7 |
8 | "#{title} - #{conn.host}"
9 | end
10 |
11 | def body_class(conn) do
12 | action = Phoenix.Controller.action_name(conn)
13 |
14 | controller =
15 | conn
16 | |> Phoenix.Controller.controller_module()
17 | |> Atom.to_string()
18 | |> String.replace(~r/(Elixir\.AsciinemaWeb\.)|(Controller)/, "")
19 | |> String.replace(".", "")
20 | |> Inflex.underscore()
21 | |> String.replace("_", " ")
22 | |> Inflex.parameterize()
23 |
24 | "c-#{controller} a-#{action}"
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/asciinema_web/views/media_view.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.MediaView do
2 | use AsciinemaWeb, :view
3 | alias Asciinema.{Fonts, Themes}
4 | alias AsciinemaWeb.UserHTML
5 |
6 | @container_vertical_padding 2 * 4
7 | @approx_char_width 7
8 | @approx_char_height 16
9 |
10 | def cinema_height(cols, rows) do
11 | ratio = rows * @approx_char_height / (cols * @approx_char_width)
12 | round(@container_vertical_padding + 100 * ratio)
13 | end
14 |
15 | def author_username(%{user: user}) do
16 | UserHTML.username(user)
17 | end
18 |
19 | def author_avatar_url(%{user: user}) do
20 | UserHTML.avatar_url(user)
21 | end
22 |
23 | def author_profile_path(%{user: user}) do
24 | profile_path(user)
25 | end
26 |
27 | def theme_options do
28 | for theme <- Themes.terminal_themes() do
29 | {Themes.display_name(theme), theme}
30 | end
31 | end
32 |
33 | def theme_options(medium) do
34 | for theme <- original_theme_option(medium.term_theme_palette) ++ Themes.terminal_themes() do
35 | {Themes.display_name(theme), theme}
36 | end
37 | end
38 |
39 | defp original_theme_option(nil), do: []
40 | defp original_theme_option(_term_theme_palette), do: ["original"]
41 |
42 | def font_family_options do
43 | for family <- Fonts.terminal_font_families() do
44 | {Fonts.display_name(family), family}
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/ext/keyword.ex:
--------------------------------------------------------------------------------
1 | defmodule Ext.Keyword do
2 | def rename(list, old, new) do
3 | case Keyword.pop(list, old, :not_found) do
4 | {:not_found, _} -> list
5 | {value, list} -> Keyword.put(list, new, value)
6 | end
7 | end
8 |
9 | def rename(list, mapping) do
10 | Enum.reduce(mapping, list, fn {old, new}, list ->
11 | rename(list, old, new)
12 | end)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/ext/map.ex:
--------------------------------------------------------------------------------
1 | defmodule Ext.Map do
2 | def rename(map, old, new) do
3 | case Map.pop(map, old, :not_found) do
4 | {:not_found, _} -> map
5 | {value, map} -> Map.put(map, new, value)
6 | end
7 | end
8 |
9 | def rename(map, mapping) do
10 | Enum.reduce(mapping, map, fn {old, new}, map ->
11 | rename(map, old, new)
12 | end)
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/native/vt_nif/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | .cargo/
3 |
--------------------------------------------------------------------------------
/native/vt_nif/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "vt_nif"
3 | version = "0.1.0"
4 | authors = []
5 | edition = "2021"
6 |
7 | [lib]
8 | name = "vt_nif"
9 | path = "src/lib.rs"
10 | crate-type = ["dylib"]
11 |
12 | [dependencies]
13 | rustler = "0.36.1"
14 | avt = "0.16.0"
15 |
--------------------------------------------------------------------------------
/native/vt_nif/README.md:
--------------------------------------------------------------------------------
1 | # NIF for Elixir.Asciinema.Vt
2 |
3 | ## To build the NIF module:
4 |
5 | - Make sure your projects `mix.exs` has the `:rustler` compiler listed in the `project` function: `compilers: [:rustler] ++ Mix.compilers()` If there already is a `:compilers` list, you should append `:rustler` to it.
6 | - Add your crate to the `rustler_crates` attribute in the `project function. [See here](https://hexdocs.pm/rustler/basics.html#crate-configuration).
7 | - Your NIF will now build along with your project.
8 |
9 | ## To load the NIF:
10 |
11 | ```elixir
12 | defmodule Asciinema.Vt do
13 | use Rustler, otp_app: , crate: "vt_nif"
14 |
15 | # When your NIF is loaded, it will override this function.
16 | def add(_a, _b), do: :erlang.nif_error(:nif_not_loaded)
17 | end
18 | ```
19 |
20 | ## Examples
21 |
22 | [This](https://github.com/hansihe/NifIo) is a complete example of a NIF written in Rust.
23 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asciinema/asciinema-server/33dadd53aac1351c27b0f46ee1589ab719896df6/priv/repo/migrations/.gitkeep
--------------------------------------------------------------------------------
/priv/repo/migrations/20170921075634_drop_stdin_from_asciicast.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.DropStdinFromAsciicast do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | remove :stdin_data
7 | remove :stdin_timing
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170926072604_add_recorded_at_to_asciicast.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddRecordedAtToAsciicast do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :recorded_at, :naive_datetime
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20170926074750_add_theme_fields_to_asciicast.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddThemeFieldsToAsciicast do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :theme_fg, :string
7 | add :theme_bg, :string
8 | add :theme_palette, :string
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20171027085309_add_idle_time_limit_to_asciicast.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddIdleTimeLimitToAsciicast do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :idle_time_limit, :float
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180514074835_fix_uniq_index_on_username.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.FixUniqIndexOnUsername do
2 | use Ecto.Migration
3 |
4 | def change do
5 | drop index(:users, [:username], name: "index_users_on_username")
6 | create unique_index(:users, ["(lower(username))"], name: "index_users_on_username")
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180514075806_fix_uniq_index_on_api_token.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.FixUniqIndexOnApiToken do
2 | use Ecto.Migration
3 |
4 | def change do
5 | drop index(:api_tokens, [:token], name: "index_api_tokens_on_token")
6 | create unique_index(:api_tokens, [:token], name: "index_api_tokens_on_token")
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180624124338_add_is_admin_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddIsAdminToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :is_admin, :boolean, null: false, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20181214193102_add_archived_at_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddArchivedAtToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :archived_at, :naive_datetime
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20190112165543_add_archivable_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddArchivableToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :archivable, :boolean, null: false, default: true
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200831195118_remove_stdout_frames.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RemoveStdoutFrames do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | remove :stdout_frames
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200901164454_convert_file_to_path.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.ConvertFileToPath do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:asciicasts), :file, to: :filename
6 |
7 | alter table(:asciicasts) do
8 | add :path, :string
9 | end
10 |
11 | execute "UPDATE asciicasts SET path=concat('asciicast/file/', id, '/', filename) WHERE filename IS NOT NULL"
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20210417192109_add_oban_jobs_table.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddObanJobsTable do
2 | use Ecto.Migration
3 |
4 | def up do
5 | Oban.Migrations.up()
6 | end
7 |
8 | # We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if
9 | # necessary, regardless of which version we've migrated `up` to.
10 | def down do
11 | Oban.Migrations.down(version: 1)
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20210509143841_add_more_indexes.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddMoreIndexes do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:asciicasts, ["id DESC"],
6 | where: "archived_at IS NULL AND private = false",
7 | name: "asciicasts_public_active_id_desc_index"
8 | )
9 |
10 | create index(:asciicasts, ["views_count DESC"],
11 | where: "archived_at IS NULL AND private = false",
12 | name: "asciicasts_public_active_views_count_desc_index"
13 | )
14 |
15 | create index(:asciicasts, ["(1)"],
16 | where: "archived_at IS NULL AND private = false",
17 | name: "asciicasts_public_active_index"
18 | )
19 |
20 | create index(:users, ["(1)"], where: "email IS NULL", name: "users_anonymous_index")
21 | create index(:users, [:username])
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20211211190233_rename_created_at_to_inserted_at.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameCreatedAtToInsertedAt do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:users), :created_at, to: :inserted_at
6 | rename table(:api_tokens), :created_at, to: :inserted_at
7 | rename table(:asciicasts), :created_at, to: :inserted_at
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20211211202637_rename_terminal_columns_lines_to_cols_rows.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameTerminalColumnsLinesToColsRows do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:asciicasts), :terminal_columns, to: :cols
6 | rename table(:asciicasts), :terminal_lines, to: :rows
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20211211210159_add_cols_rows_overrides.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddColsRowsOverrides do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :cols_override, :integer
7 | add :rows_override, :integer
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20211212114046_insert_upgrade_jobs.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.InsertUpgradeJobs do
2 | use Ecto.Migration
3 |
4 | def up do
5 | %{}
6 | |> Oban.Job.new(worker: Asciinema.Workers.InitialSeed)
7 | |> Asciinema.Repo.insert!()
8 |
9 | :ok
10 | end
11 |
12 | def down, do: :ok
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230211162625_create_oban_peers.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.CreateObanPeers do
2 | use Ecto.Migration
3 |
4 | def up, do: Oban.Migrations.up(version: 11)
5 |
6 | def down, do: Oban.Migrations.down(version: 11)
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230211162915_swap_primary_oban_indexes.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.SwapPrimaryObanIndexes do
2 | use Ecto.Migration
3 |
4 | @disable_ddl_transaction true
5 | @disable_migration_lock true
6 |
7 | def change do
8 | create_if_not_exists index(
9 | :oban_jobs,
10 | [:state, :queue, :priority, :scheduled_at, :id],
11 | concurrently: true,
12 | prefix: "public"
13 | )
14 |
15 | drop_if_exists index(
16 | :oban_jobs,
17 | [:queue, :state, :priority, :scheduled_at, :id],
18 | concurrently: true,
19 | prefix: "public"
20 | )
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230402201325_add_terminal_line_height_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddTerminalLineHeightToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :terminal_line_height, :float
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230403182405_add_terminal_font_family_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddTerminalFontFamilyToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :terminal_font_family, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230515080531_add_markers_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddMarkersToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :markers, :text
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230618154430_create_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.CreateLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:live_streams) do
6 | add :user_id, references(:users), null: false
7 | add :producer_token, :string, null: false
8 | add :cols, :integer
9 | add :rows, :integer
10 | add :last_activity_at, :naive_datetime
11 | timestamps()
12 | end
13 |
14 | execute(
15 | fn ->
16 | %{rows: rows} = repo().query!("SELECT id FROM users")
17 |
18 | for [user_id] <- rows do
19 | token = Crypto.random_token(25)
20 | timestamp = Timex.now()
21 |
22 | repo().query!(
23 | "INSERT INTO live_streams (user_id, producer_token, inserted_at, updated_at) VALUES ($1, $2, $3, $3)",
24 | [
25 | user_id,
26 | token,
27 | timestamp
28 | ]
29 | )
30 | end
31 | end,
32 | fn -> :ok end
33 | )
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230701115122_add_online_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddOnlineToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :online, :boolean, null: false, default: false
7 | end
8 |
9 | create index(:live_streams, [:online])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230707114655_add_more_metadata_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddMoreMetadataToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :private, :boolean, default: true, null: false
7 | add :secret_token, :string
8 | add :title, :string
9 | add :description, :text
10 | add :theme_name, :string
11 | add :terminal_line_height, :float
12 | add :terminal_font_family, :string
13 | end
14 |
15 | execute(
16 | fn ->
17 | %{rows: rows} = repo().query!("SELECT id FROM live_streams")
18 |
19 | for [stream_id] <- rows do
20 | token = Crypto.random_token(25)
21 |
22 | repo().query!("UPDATE live_streams SET secret_token = $1 WHERE id = $2", [
23 | token,
24 | stream_id
25 | ])
26 | end
27 | end,
28 | fn -> :ok end
29 | )
30 |
31 | alter table(:live_streams) do
32 | modify :secret_token, :string, null: false
33 | end
34 |
35 | create index(:live_streams, [:private])
36 | create unique_index(:live_streams, [:secret_token])
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230707120207_add_missing_indices_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddMissingIndicesToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:live_streams, [:user_id])
6 | create unique_index(:live_streams, [:producer_token])
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230708120726_add_viewer_count_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddViewerCountToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :current_viewer_count, :integer
7 | add :peak_viewer_count, :integer
8 | end
9 |
10 | create index(:live_streams, [:current_viewer_count])
11 | create index(:live_streams, [:peak_viewer_count])
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230712082725_add_started_at_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddStartedAtToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :last_started_at, :naive_datetime
7 | end
8 |
9 | create index(:live_streams, :last_started_at)
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230713075841_add_more_indices_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddMoreIndicesToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:live_streams, [:inserted_at])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230716092144_add_buffer_time_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddBufferTimeToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :buffer_time, :float
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20230817131250_add_parser_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddParserToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :parser, :string
7 | end
8 |
9 | create index(:live_streams, [:parser])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20231219195815_add_speed_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddSpeedToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :speed, :float
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240114204549_upgrade_oban_jobs_to_v12.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.UpgradeObanJobsToV12 do
2 | use Ecto.Migration
3 |
4 | def up, do: Oban.Migrations.up(version: 12)
5 |
6 | def down, do: Oban.Migrations.down(version: 11)
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240217154250_add_terminal_font_family_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddTerminalFontFamilyToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :terminal_font_family, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240308110750_add_theme_fields_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddThemeFieldsToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :theme_fg, :string
7 | add :theme_bg, :string
8 | add :theme_palette, :string
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240329195017_add_theme_prefer_original_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddThemePreferOriginalToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :theme_prefer_original, :boolean, null: false, default: true
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240329201531_add_theme_prefer_original_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddThemePreferOriginalToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :theme_prefer_original, :boolean, null: false, default: true
7 | end
8 |
9 | execute "UPDATE asciicasts SET theme_name='original' WHERE theme_name IS NULL AND theme_palette IS NOT NULL"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240409082127_add_index_on_user_id_secret_token_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddIndexOnUserIdSecretTokenToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:live_streams, [:user_id, :secret_token])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240410075535_rename_and_shorten_stream_tokens.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameAndShortenStreamTokens do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:live_streams), :secret_token, to: :public_token
6 |
7 | execute "UPDATE live_streams SET public_token = LEFT(public_token, 16), producer_token = LEFT(producer_token, 16)"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240421122804_add_snapshot_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddSnapshotToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:live_streams) do
6 | add :snapshot, :text
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240502083959_lowercase_emails.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.LowercaseEmails do
2 | use Ecto.Migration
3 |
4 | @get_dup_emails "SELECT LOWER(email) AS e FROM users WHERE email IS NOT NULL GROUP BY e HAVING COUNT(*) > 1"
5 | @get_users_for_email "SELECT u.id, COUNT(a.id) AS asciicast_count FROM users u LEFT OUTER JOIN asciicasts a ON (a.user_id = u.id) WHERE LOWER(u.email) = $1 GROUP BY u.id ORDER BY asciicast_count DESC, name, username"
6 | @update_email "UPDATE users SET email = CONCAT('_', $2::int, '_', email) WHERE id = $1"
7 |
8 | def change do
9 | execute(fn ->
10 | for [email] <- repo().query!(@get_dup_emails).rows do
11 | for {[id | _], i} <- Enum.with_index(repo().query!(@get_users_for_email, [email]).rows) do
12 | if i > 0 do
13 | repo().query!(@update_email, [id, i])
14 | end
15 | end
16 | end
17 | end, fn -> :ok end)
18 |
19 | execute "UPDATE users SET email=LOWER(email) WHERE email IS NOT NULL", ""
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240512060058_add_visibility_to_live_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddVisibilityToLiveStreams do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute("CREATE TYPE live_stream_visibility AS ENUM ('private', 'unlisted', 'public')")
6 |
7 | alter table(:live_streams) do
8 | add :visibility, :live_stream_visibility, null: false, default: "unlisted"
9 | end
10 |
11 | execute "UPDATE live_streams SET visibility = 'public' WHERE NOT private"
12 |
13 | alter table(:live_streams) do
14 | remove :private
15 | end
16 | end
17 |
18 | def down do
19 | alter table(:live_streams) do
20 | add :private, :boolean, null: false, default: true
21 | end
22 |
23 | execute "UPDATE live_streams SET private = FALSE WHERE visibility = 'public'"
24 |
25 | alter table(:live_streams) do
26 | remove :visibility
27 | end
28 |
29 | execute("DROP TYPE live_stream_visibility")
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240516181634_add_visibility_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddVisibilityToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute("CREATE TYPE asciicast_visibility AS ENUM ('private', 'unlisted', 'public')")
6 |
7 | alter table(:asciicasts) do
8 | add :visibility, :asciicast_visibility, null: false, default: "unlisted"
9 | end
10 |
11 | execute "UPDATE asciicasts SET visibility = 'public' WHERE NOT private"
12 |
13 | alter table(:asciicasts) do
14 | remove :private
15 | end
16 | end
17 |
18 | def down do
19 | alter table(:asciicasts) do
20 | add :private, :boolean, null: false, default: true
21 | end
22 |
23 | execute "UPDATE asciicasts SET private = FALSE WHERE visibility = 'public'"
24 |
25 | alter table(:asciicasts) do
26 | remove :visibility
27 | end
28 |
29 | execute("DROP TYPE asciicast_visibility")
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240516183427_add_default_asciicast_visibility_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddDefaultAsciicastVisibilityToUsers do
2 | use Ecto.Migration
3 |
4 | def up do
5 | alter table(:users) do
6 | add :default_asciicast_visibility, :asciicast_visibility, null: false, default: "unlisted"
7 | end
8 |
9 | execute "UPDATE users SET default_asciicast_visibility = 'public' WHERE NOT asciicasts_private_by_default"
10 |
11 | alter table(:users) do
12 | remove :asciicasts_private_by_default
13 | end
14 | end
15 |
16 | def down do
17 | alter table(:users) do
18 | add :asciicasts_private_by_default, :boolean, null: false, default: true
19 | end
20 |
21 | execute "UPDATE users SET asciicasts_private_by_default = FALSE WHERE default_asciicast_visibility = 'public'"
22 |
23 | alter table(:users) do
24 | remove :default_asciicast_visibility
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20240519083033_add_index_on_asciicasts_visibility.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddIndexOnAsciicastsVisibility do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:asciicasts, [:visibility])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20241215191710_add_streaming_enabled_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddStreamingEnabledToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :streaming_enabled, :boolean, default: true, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250119143802_rename_stream_parser_to_protocol.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameStreamParserToProtocol do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:live_streams), :parser, to: :protocol
6 |
7 | execute "UPDATE live_streams SET protocol='v0.alis' WHERE protocol='alis'"
8 | execute "UPDATE live_streams SET protocol='v2.asciicast' WHERE protocol='json'"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250208135448_remove_legacy_stdout_from_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RemoveLegacyStdoutFromAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | remove :stdout_data
7 | remove :stdout_timing
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250208140242_rename_api_tokens_to_clis.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameApiTokensToClis do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:api_tokens), to: table(:clis)
6 | rename index(:clis, [:token], name: "api_tokens_pkey"), to: "clis_pkey"
7 | rename index(:clis, [:token], name: "index_api_tokens_on_token"), to: "clis_token_index"
8 | rename index(:clis, [:token], name: "index_api_tokens_on_user_id"), to: "clis_user_id_index"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250209094100_add_cli_id_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddCliIdToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :cli_id, references(:clis)
7 | end
8 |
9 | create index(:asciicasts, [:cli_id])
10 |
11 | execute "UPDATE asciicasts AS a SET cli_id = (SELECT id FROM clis WHERE user_id=a.user_id ORDER BY id LIMIT 1) WHERE a.inserted_at < COALESCE((SELECT inserted_at FROM clis WHERE user_id=a.user_id ORDER BY id OFFSET 1 LIMIT 1), NOW())", ""
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250209212642_make_asciiast_user_id_non_null.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.MakeAsciiastUserIdNonNull do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | modify :user_id, references(:users), null: false
7 | end
8 |
9 | drop constraint(:asciicasts, "asciicasts_user_id_fk")
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250211203036_rename_live_streams_to_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameLiveStreamsToStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:live_streams), to: table(:streams)
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250218201917_add_stream_id_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddStreamIdToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :stream_id, references(:streams)
7 | end
8 |
9 | create index(:asciicasts, [:stream_id])
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250219164050_add_stream_recording_enabled_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddStreamRecordingEnabledToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :stream_recording_enabled, :boolean, default: true, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250220102157_make_path_on_asciicasts_unique.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.MakePathOnAsciicastsUnique do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create unique_index(:asciicasts, [:path])
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250307113701_rename_visibility_columns.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameVisibilityColumns do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:users), :default_asciicast_visibility, to: :default_recording_visibility
6 | execute "ALTER TYPE live_stream_visibility RENAME TO stream_visibility", "ALTER TYPE stream_visibility RENAME TO live_stream_visibility"
7 | execute "ALTER TYPE asciicast_visibility RENAME TO recording_visibility", "ALTER TYPE recording_visibility RENAME TO asciicast_visibility"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250307115515_add_default_stream_visibility_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddDefaultStreamVisibilityToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :default_stream_visibility, :stream_visibility, null: false, default: "unlisted"
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250405191811_add_env_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddEnvToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :env, :map
7 | end
8 |
9 | execute("CREATE INDEX asciicasts_env_index ON asciicasts USING GIN(env)", "DROP INDEX asciicasts_env_index")
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250411105026_rename_terminal_fields.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.RenameTerminalFields do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:asciicasts), :terminal_type, to: :term_type
6 | rename table(:asciicasts), :terminal_line_height, to: :term_line_height
7 | rename table(:asciicasts), :terminal_font_family, to: :term_font_family
8 | rename table(:asciicasts), :theme_name, to: :term_theme_name
9 | rename table(:asciicasts), :theme_fg, to: :term_theme_fg
10 | rename table(:asciicasts), :theme_bg, to: :term_theme_bg
11 | rename table(:asciicasts), :theme_palette, to: :term_theme_palette
12 | rename table(:asciicasts), :cols, to: :term_cols
13 | rename table(:asciicasts), :cols_override, to: :term_cols_override
14 | rename table(:asciicasts), :rows, to: :term_rows
15 | rename table(:asciicasts), :rows_override, to: :term_rows_override
16 |
17 | rename table(:streams), :terminal_line_height, to: :term_line_height
18 | rename table(:streams), :terminal_font_family, to: :term_font_family
19 | rename table(:streams), :theme_name, to: :term_theme_name
20 | rename table(:streams), :theme_prefer_original, to: :term_theme_prefer_original
21 | rename table(:streams), :theme_fg, to: :term_theme_fg
22 | rename table(:streams), :theme_bg, to: :term_theme_bg
23 | rename table(:streams), :theme_palette, to: :term_theme_palette
24 | rename table(:streams), :cols, to: :term_cols
25 | rename table(:streams), :rows, to: :term_rows
26 |
27 | rename table(:users), :terminal_font_family, to: :term_font_family
28 | rename table(:users), :theme_name, to: :term_theme_name
29 | rename table(:users), :theme_prefer_original, to: :term_theme_prefer_original
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250411121050_add_term_type_and_version_to_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddTermTypeAndVersionToStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:streams) do
6 | add :term_type, :string
7 | add :term_version, :string
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250411121133_add_term_version_to_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddTermVersionToAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | add :term_version, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250417130426_add_index_for_snapshotless_asciicasts.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddIndexForSnapshotlessAsciicasts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create index(:asciicasts, ["(1)"], where: "snapshot IS NULL", name: "asciicasts_snapshotless_index")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250417130647_force_snapshot_regeneration.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.ForceSnapshotRegeneration do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute "UPDATE asciicasts SET snapshot = NULL"
6 |
7 | %{}
8 | |> Oban.Job.new(worker: Asciinema.Workers.GenerateSnapshots)
9 | |> Asciinema.Repo.insert!()
10 | end
11 |
12 | def down, do: :ok
13 | end
14 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250418154208_add_user_agent_and_shell_to_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddUserAgentAndShellToStreams do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:streams) do
6 | add :user_agent, :string
7 | add :shell, :string
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250423163015_add_stream_limit_to_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.AddStreamLimitToUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :stream_limit, :integer
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250425104755_reset_cur_viewer_count_for_offline_streams.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.ResetCurViewerCountForOfflineStreams do
2 | use Ecto.Migration
3 |
4 | def up do
5 | execute "UPDATE streams SET current_viewer_count = 0 WHERE NOT online"
6 | end
7 |
8 | def down do
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20250426120254_nullify_asciicast_stream_id_on_stream_delete.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Repo.Migrations.NullifyAsciicastStreamIdOnStreamDelete do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:asciicasts) do
6 | modify :stream_id, references(:streams, on_delete: :nilify_all),
7 | from: references(:streams, on_delete: :nothing)
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Asciinema.Repo.insert!(%Asciinema.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
13 | user = Asciinema.Accounts.ensure_asciinema_user()
14 | Asciinema.Recordings.ensure_welcome_asciicast(user)
15 |
--------------------------------------------------------------------------------
/priv/svg2png.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | # usage: svg2png.sh
4 |
5 | set -Eeuo pipefail
6 |
7 | if which timeout 2>/dev/null; then
8 | if timeout --help 2>&1 | grep BusyBox >/dev/null; then
9 | timeout="timeout -s KILL 15"
10 | else
11 | timeout="timeout -k 5 10"
12 | fi
13 | elif which gtimeout 2>/dev/null; then
14 | timeout="gtimeout -k 5 10"
15 | else
16 | timeout=""
17 | fi
18 |
19 | $timeout rsvg-convert -z $3 -o $2 $1
20 |
21 | out=$2
22 |
23 | if which pngquant 2>/dev/null; then
24 | echo "Optimizing PNG with pngquant..."
25 | pngquant 24 -o "${out}.q" "$out"
26 | mv "${out}.q" "$out"
27 | fi
28 |
29 | echo "Done."
30 |
--------------------------------------------------------------------------------
/rel/env.bat.eex:
--------------------------------------------------------------------------------
1 | @echo off
2 | rem Set the release to work across nodes. If using the long name format like
3 | rem the one below (my_app@127.0.0.1), you need to also uncomment the
4 | rem RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none".
5 | rem set RELEASE_DISTRIBUTION=name
6 | rem set RELEASE_NODE=<%= @release.name %>@127.0.0.1
7 |
--------------------------------------------------------------------------------
/rel/env.sh.eex:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Sets and enables heart (recommended only in daemon mode)
4 | # case $RELEASE_COMMAND in
5 | # daemon*)
6 | # HEART_COMMAND="$RELEASE_ROOT/bin/$RELEASE_NAME $RELEASE_COMMAND"
7 | # export HEART_COMMAND
8 | # export ELIXIR_ERL_OPTIONS="-heart"
9 | # ;;
10 | # *)
11 | # ;;
12 | # esac
13 |
14 | if [ "$RELEASE_COMMAND" = "start" ]; then
15 | echo "Running db migrations..."
16 | $RELEASE_ROOT/bin/migrate
17 | fi
18 |
19 | # Set the release to work across nodes. If using the long name format like
20 | # the one below (my_app@127.0.0.1), you need to also uncomment the
21 | # RELEASE_DISTRIBUTION variable below. Must be "sname", "name" or "none".
22 | # export RELEASE_DISTRIBUTION=name
23 | # export RELEASE_NODE=<%= @release.name %>@127.0.0.1
24 |
--------------------------------------------------------------------------------
/rel/overlays/bin/admin_add:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | emails="$@"
4 | exec ./bin/asciinema rpc "Asciinema.Release.admin_add(\"$emails\")"
5 |
--------------------------------------------------------------------------------
/rel/overlays/bin/admin_rm:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | emails="$@"
4 | exec ./bin/asciinema rpc "Asciinema.Release.admin_rm(\"$emails\")"
5 |
--------------------------------------------------------------------------------
/rel/overlays/bin/gen_secret:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | tr -dc A-Za-z0-9 _}, EctoSnapshot.dump(snapshot))
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/test/asciinema/recordings/asciicast/v1_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.Asciicast.V1Test do
2 | use Asciinema.DataCase
3 | alias Asciinema.Recordings.Asciicast.V1
4 |
5 | describe "fetch_metadata/1" do
6 | test "minimal" do
7 | {:ok, metadata} = V1.fetch_metadata("test/fixtures/1/minimal.json")
8 |
9 | assert metadata == %{
10 | version: 1,
11 | term_cols: 96,
12 | term_rows: 26,
13 | term_type: nil,
14 | command: nil,
15 | duration: 8.456789,
16 | title: nil,
17 | env: %{},
18 | shell: nil
19 | }
20 | end
21 |
22 | test "full" do
23 | {:ok, metadata} = V1.fetch_metadata("test/fixtures/1/full.json")
24 |
25 | assert metadata == %{
26 | version: 1,
27 | term_cols: 96,
28 | term_rows: 26,
29 | term_type: "screen-256color",
30 | command: "/bin/bash",
31 | duration: 11.146430,
32 | title: "bashing :)",
33 | env: %{
34 | "TERM" => "screen-256color",
35 | "SHELL" => "/bin/zsh"
36 | },
37 | shell: "/bin/zsh"
38 | }
39 | end
40 | end
41 |
42 | describe "event_stream/1" do
43 | test "full" do
44 | stream = V1.event_stream("test/fixtures/1/full.json")
45 |
46 | assert Enum.take(stream, 2) == [{1.234567, "o", "foo bar"}, {6.913554, "o", "baz qux"}]
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/asciinema/recordings/event_stream_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.EventStreamTest do
2 | use Asciinema.DataCase
3 | alias Asciinema.Recordings.EventStream
4 |
5 | describe "to_absolute_time/1" do
6 | test "transforms relative timestamps to absolute" do
7 | stream = [{0.0, "o", "a"}, {1.0, "o", "b"}, {0.5, "o", "c"}, {2.345, "o", "d"}]
8 |
9 | stream =
10 | stream
11 | |> EventStream.to_absolute_time()
12 | |> Enum.to_list()
13 |
14 | assert stream == [{0.0, "o", "a"}, {1.0, "o", "b"}, {1.5, "o", "c"}, {3.845, "o", "d"}]
15 | end
16 | end
17 |
18 | describe "to_relative_time/1" do
19 | test "transforms absolute timestamps to relative" do
20 | stream = [{0.0, "o", "a"}, {1.0, "o", "b"}, {1.5, "o", "c"}, {3.845, "o", "d"}]
21 |
22 | stream =
23 | stream
24 | |> EventStream.to_relative_time()
25 | |> Enum.to_list()
26 |
27 | assert stream == [{0.0, "o", "a"}, {1.0, "o", "b"}, {0.5, "o", "c"}, {2.345, "o", "d"}]
28 | end
29 | end
30 |
31 | describe "cap_relative_time/2" do
32 | test "caps relative time to a given limit" do
33 | stream = [{0.0, "o", "a"}, {1.0, "o", "b"}, {2.5, "o", "c"}, {0.5, "o", "d"}]
34 |
35 | stream =
36 | stream
37 | |> EventStream.cap_relative_time(2)
38 | |> Enum.to_list()
39 |
40 | assert stream == [{0.0, "o", "a"}, {1.0, "o", "b"}, {2.0, "o", "c"}, {0.5, "o", "d"}]
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/asciinema/recordings/markers_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Recordings.MarkersTest do
2 | use ExUnit.Case, async: true
3 | alias Asciinema.Recordings.Markers
4 |
5 | describe "parse_markers/1" do
6 | test "returns markers for valid syntax" do
7 | result = Markers.parse("1.0 - Intro\n2.5\n5.0 - Tips & Tricks\n")
8 |
9 | assert result == {:ok, [{1.0, "Intro"}, {2.5, ""}, {5.0, "Tips & Tricks"}]}
10 | end
11 |
12 | test "returns error for invalid syntax" do
13 | result = Markers.parse("1.0 - Intro\nFoobar\n")
14 |
15 | assert result == {:error, 1}
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/asciinema/string_utils_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.StringUtilsTest do
2 | use ExUnit.Case
3 |
4 | describe "valid_part/2" do
5 | import Asciinema.StringUtils, only: [valid_part: 2]
6 |
7 | test "no accumulator, valid string" do
8 | assert valid_part("", "foo") == {"foo", ""}
9 | end
10 |
11 | test "no accumulator, partial utf-8 seq" do
12 | assert valid_part("", <<0xC5>>) == {"", <<0xC5>>}
13 | end
14 |
15 | test "no accumulator, valid string + partial utf-8 seq at the end" do
16 | assert valid_part("", "foo" <> <<0xC5>>) == {"foo", <<0xC5>>}
17 | end
18 |
19 | test "with accumulator, rest of utf-8 seq + valid string at the end" do
20 | assert valid_part(<<0xC5>>, <<0x82>> <> "ćfoo") == {"łćfoo", ""}
21 | end
22 |
23 | test "with accumulator, mixed valid/invalid string + partial utf-8 seq at the end" do
24 | assert valid_part(<<0xC5>>, "x" <> <<0xC5, 0xC5>> <> "y" <> <<0xC5>>) == {"xy", <<0xC5>>}
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/test/asciinema/vt_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.VtTest do
2 | use ExUnit.Case
3 | alias Asciinema.Vt
4 |
5 | @moduletag :vt
6 |
7 | describe "vt" do
8 | test "basic usage" do
9 | result =
10 | Vt.with_vt(8, 3, fn vt ->
11 | Vt.feed(vt, "foobar\r\n")
12 | Vt.feed(vt, "baz")
13 | Vt.feed(vt, "全\r\n")
14 | Vt.feed(vt, "\x1b[1;38:2:16:32:48mqux")
15 | Vt.dump_screen(vt)
16 | end)
17 |
18 | assert {:ok,
19 | {[
20 | [{"foobar ", %{}, 1}],
21 | [{"baz", %{}, 1}, {"全", %{}, 2}, {" ", %{}, 1}],
22 | [{"qux", %{"bold" => true, "fg" => "#102030"}, 1}, {" ", %{}, 1}]
23 | ], {3, 2}}} = result
24 | end
25 |
26 | test "feeding it a lot of data" do
27 | result =
28 | Vt.with_vt(120, 80, fn vt ->
29 | Enum.each(1..300_000, fn _ ->
30 | Vt.feed(vt, "aaaaaaaaaaaaaaaaaaaaaaaa")
31 | end)
32 |
33 | Vt.dump_screen(vt)
34 | end)
35 |
36 | assert {:ok, {[_ | _], {120, 79}}} = result
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/avatar_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.AvatarControllerTest do
2 | use AsciinemaWeb.ConnCase
3 | import Asciinema.Factory
4 |
5 | test "image response", %{conn: conn} do
6 | user = insert(:user)
7 |
8 | conn = get(conn, ~p"/u/#{user}/avatar")
9 |
10 | assert response(conn, 200)
11 | assert List.first(get_resp_header(conn, "content-type")) =~ ~r|image/.+|
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/medium_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.MediumHTMLTest do
2 | use ExUnit.Case, async: true
3 | doctest AsciinemaWeb.MediumHTML, import: true
4 | end
5 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/oembed_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.OembedControllerTest do
2 | use AsciinemaWeb.ConnCase
3 | import Asciinema.Factory
4 |
5 | describe "show" do
6 | test "JSON format", %{conn: conn} do
7 | asciicast = insert(:asciicast)
8 | url = ~p"/a/#{asciicast}"
9 |
10 | conn =
11 | get(conn, ~p"/oembed?#{%{url: url, format: "json", maxwidth: "500", maxheight: "300"}}")
12 |
13 | assert json_response(conn, 200)
14 | end
15 |
16 | test "XML format", %{conn: conn} do
17 | asciicast = insert(:asciicast)
18 | url = ~p"/a/#{asciicast}"
19 |
20 | conn = get(conn, ~p"/oembed?#{%{url: url, format: "xml"}}")
21 |
22 | assert response(conn, 200)
23 | assert response_content_type(conn, :xml)
24 | end
25 |
26 | test "maxwidth without maxheight", %{conn: conn} do
27 | asciicast = insert(:asciicast)
28 | url = ~p"/a/#{asciicast}"
29 |
30 | conn = get(conn, ~p"/oembed?#{%{url: url, format: "json", maxwidth: "500"}}")
31 |
32 | assert json_response(conn, 200)
33 | end
34 |
35 | test "private recording", %{conn: conn} do
36 | asciicast = insert(:asciicast, visibility: :private)
37 | url = ~p"/a/#{asciicast}"
38 |
39 | conn =
40 | get(conn, ~p"/oembed?#{%{url: url, format: "json", maxwidth: "500", maxheight: "300"}}")
41 |
42 | assert json_response(conn, 403)
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.PageControllerTest do
2 | use AsciinemaWeb.ConnCase
3 |
4 | test "static pages", %{conn: conn} do
5 | Enum.each(
6 | ["/about"],
7 | fn path ->
8 | conn = get(conn, path)
9 | assert html_response(conn, 200) =~ ~r{.+
}s
10 | end
11 | )
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/recording_svg_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.RecordingSvgTest do
2 | use ExUnit.Case, async: true
3 | alias Asciinema.Recordings.Snapshot
4 | alias AsciinemaWeb.RecordingSVG
5 | import Asciinema.Factory
6 |
7 | describe "show/1" do
8 | test "renders SVG document" do
9 | asciicast =
10 | build(:asciicast, snapshot: Snapshot.new([[["foobar", %{}, 1]], [["bazqux", %{}, 1]]]))
11 |
12 | svg = render_svg(asciicast)
13 |
14 | assert svg =~ ~r/^<\?xml.+foobar.+bazqux/s
15 | end
16 |
17 | test "supports RGB color in fg/bg text attrs" do
18 | asciicast =
19 | build(:asciicast,
20 | snapshot:
21 | Snapshot.new([
22 | [["foo", %{"fg" => [16, 32, 48]}, 1], ["bar", %{"bg" => "rgb(64,80,96)"}, 1]],
23 | [["baz", %{"fg" => "#708090"}, 1]]
24 | ])
25 | )
26 |
27 | svg = render_svg(asciicast)
28 |
29 | assert svg =~ "#102030"
30 | assert svg =~ "rgb(64,80,96)"
31 | assert svg =~ "#708090"
32 | end
33 | end
34 |
35 | defp render_svg(asciicast) do
36 | Phoenix.LiveViewTest.rendered_to_string(RecordingSVG.show(%{asciicast: asciicast}))
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/username_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.UsernameControllerTest do
2 | use AsciinemaWeb.ConnCase
3 | import Asciinema.Factory
4 |
5 | describe "setting username" do
6 | test "requires logged in user", %{conn: conn} do
7 | conn = get(conn, "/username/new")
8 | assert redirected_to(conn, 302) == "/login/new"
9 | end
10 |
11 | test "displays form", %{conn: conn} do
12 | user = insert(:user)
13 | conn = log_in(conn, user)
14 | conn = get(conn, "/username/new")
15 | assert html_response(conn, 200) =~ ~r/your username/i
16 | end
17 |
18 | test "redirects to profile on success", %{conn: conn} do
19 | user = insert(:user)
20 | conn = log_in(conn, user)
21 |
22 | conn = post conn, "/username", %{user: %{username: "ricksanchez"}}
23 |
24 | assert response(conn, 302)
25 | location = List.first(get_resp_header(conn, "location"))
26 | assert location == "/~ricksanchez"
27 | end
28 |
29 | test "redisplays form on error", %{conn: conn} do
30 | user = insert(:user)
31 | conn = log_in(conn, user)
32 |
33 | conn = post conn, "/username", %{user: %{username: "---"}}
34 |
35 | assert html_response(conn, 422) =~ "only letters"
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/asciinema_web/controllers/webfinger_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.WebFingerControllerTest do
2 | use AsciinemaWeb.ConnCase
3 | import Asciinema.Factory
4 |
5 | setup [:create_user]
6 |
7 | describe "show" do
8 | test "returns basic info", %{conn: conn} do
9 | conn = get(conn, ~p"/.well-known/webfinger?resource=acct:Pinky@localhost")
10 |
11 | assert json_response(conn, 200) == %{
12 | "subject" => "acct:Pinky@localhost",
13 | "aliases" => [
14 | url(~p"/~Pinky")
15 | ],
16 | "links" => [
17 | %{
18 | "rel" => "http://webfinger.net/rel/profile-page",
19 | "type" => "text/html",
20 | "href" => url(~p"/~Pinky")
21 | }
22 | ]
23 | }
24 | end
25 |
26 | test "does case-insensitive acct lookup", %{conn: conn} do
27 | conn = get(conn, ~p"/.well-known/webfinger?resource=acct:pinky@lOcaLhOst")
28 |
29 | assert %{"subject" => "acct:Pinky@localhost"} = json_response(conn, 200)
30 | end
31 |
32 | test "returns 404 when username not found", %{conn: conn} do
33 | conn = get(conn, ~p"/.well-known/webfinger?resource=acct:nope@localhost")
34 |
35 | assert json_response(conn, 404)
36 | end
37 |
38 | test "returns 404 when domain doesn't match", %{conn: conn} do
39 | conn = get(conn, ~p"/.well-known/webfinger?resource=acct:pinky@nope.nope")
40 |
41 | assert json_response(conn, 404)
42 | end
43 | end
44 |
45 | defp create_user(_) do
46 | %{user: insert(:user, username: "Pinky")}
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/test/asciinema_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.LayoutViewTest do
2 | use AsciinemaWeb.ConnCase, async: true
3 |
4 | # When testing helpers, you may want to import Phoenix.HTML and
5 | # use functions such as safe_to_string() to convert the helper
6 | # result into an HTML string.
7 | # import Phoenix.HTML
8 | end
9 |
--------------------------------------------------------------------------------
/test/fixtures/1/full.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "width": 96,
4 | "height": 26,
5 | "duration": 11.146430,
6 | "command": "/bin/bash",
7 | "title": "bashing :)",
8 | "env": {
9 | "TERM": "screen-256color",
10 | "SHELL": "/bin/zsh"
11 | },
12 | "stdout": [
13 | [1.234567, "foo bar"],
14 | [5.678987, "baz qux"],
15 | [3.456789, "żółć jaźń"]
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/test/fixtures/1/invalid.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "height": 26,
4 | "duration": 11.146430015564,
5 | "command": "/bin/bash",
6 | "title": "bashing :)",
7 | "env": {
8 | "TERM": "screen-256color",
9 | "SHELL": "/bin/zsh"
10 | },
11 | "stdout": [
12 | [1.234567, "foo bar"],
13 | [5.678987, "baz qux"],
14 | [3.456789, "żółć jaźń"]
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/test/fixtures/1/minimal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "width": 96,
4 | "height": 26,
5 | "duration": 8.456789,
6 | "stdout": [
7 | [1.234567, "foo bar"],
8 | [4.419754, "baz qux"],
9 | [2.802468, "żółć jaźń"]
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/test/fixtures/1/screenshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "width": 96,
4 | "height": 26,
5 | "duration": 11.146430015564,
6 | "command": "/bin/bash",
7 | "title": "bashing :)",
8 | "env": {
9 | "TERM": "screen-256color",
10 | "SHELL": "/bin/zsh"
11 | },
12 | "stdout": [
13 | [1.234567, "foo \u001b[41mbar\u001b[32m"],
14 | [5.678987, "baz qux"],
15 | [3.456789, "żółć jaźń"]
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/test/fixtures/2/blank-lines.cast:
--------------------------------------------------------------------------------
1 | {"version": 2, "width": 96, "height": 26}
2 | [1.234567, "o", "foo bar"]
3 | [5.678987, "o", "baz qux"]
4 |
5 | [8.456789, "o", "żółć jaźń"]
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/test/fixtures/2/full.cast:
--------------------------------------------------------------------------------
1 | {"version": 2, "width": 96, "height": 26, "timestamp": 1506410422, "command": "/bin/bash -l", "title": "bashing :)", "idle_time_limit": 2.5, "env": {"TERM": "screen-256color", "SHELL": "/bin/zsh"}, "theme": {"fg": "#aaaaaa", "bg": "#bbbbbb", "palette": "#151515:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#d0d0d0:#505050:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#f5f5f5"}}
2 | [1.234567, "o", "foo bar"]
3 | [2.345670, "i", "\r"]
4 | [5.678987, "o", "baz qux"]
5 | [8.456789, "o", "żółć jaźń"]
6 |
--------------------------------------------------------------------------------
/test/fixtures/2/invalid-theme.cast:
--------------------------------------------------------------------------------
1 | {"version": 2, "width": 96, "height": 26, "theme": {"fg": "white", "bg": "000000", "palette": ""}}
2 | [1.234567, "o", "foo bar"]
3 | [5.678987, "o", "baz qux"]
4 | [8.456789, "o", "żółć jaźń"]
5 |
--------------------------------------------------------------------------------
/test/fixtures/2/minimal.cast:
--------------------------------------------------------------------------------
1 | {"version": 2, "width": 96, "height": 26}
2 | [1.234567, "o", "foo bar"]
3 | [5.678987, "o", "baz qux"]
4 | [8.456789, "o", "żółć jaźń"]
--------------------------------------------------------------------------------
/test/fixtures/3/blank-lines-and-comments.cast:
--------------------------------------------------------------------------------
1 | {"version": 3, "term": {"cols": 96, "rows": 26}}
2 | # stuff
3 | [1.234567, "o", "foo bar"]
4 | [4.44442, "o", "baz qux"]
5 |
6 | # more stuff
7 | [2.802468, "o", "żółć jaźń"]
8 |
9 |
10 | # more and more
11 |
12 |
13 | # stuff
14 |
15 | [1.0, "o", "bye!"]
16 |
17 |
18 |
--------------------------------------------------------------------------------
/test/fixtures/3/full.cast:
--------------------------------------------------------------------------------
1 | {"version": 3, "term": {"cols": 96, "rows": 26, "type": "xterm-ghostty", "version": "ghostty 1.1.3-889478f-nix", "theme": {"fg": "#aaaaaa", "bg": "#bbbbbb", "palette": "#151515:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#d0d0d0:#505050:#ac4142:#7e8e50:#e5b567:#6c99bb:#9f4e85:#7dd6cf:#f5f5f5"}}, "timestamp": 1744302022, "command": "/bin/bash -l", "title": "bashing :)", "idle_time_limit": 2.5, "env": {"TERM": "xterm-ghostty", "SHELL": "/usr/bin/fish"}}
2 | [1.234567, "o", "foo bar"]
3 | [1.0, "i", "\r"]
4 | [4.678987, "o", "baz qux"]
5 | [1.0, "r", "80x24"]
6 | [2.456789, "o", "żółć jaźń"]
7 |
--------------------------------------------------------------------------------
/test/fixtures/3/minimal.cast:
--------------------------------------------------------------------------------
1 | {"version": 3, "term": {"cols": 96, "rows": 26}}
2 | [1.234567, "o", "foo bar"]
3 | [4.419754, "o", "baz qux"]
4 | [2.802468, "o", "żółć jaźń"]
5 |
--------------------------------------------------------------------------------
/test/fixtures/5/asciicast.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 5,
3 | "width": 96,
4 | "height": 26,
5 | "duration": 11.146430015564,
6 | "command": "/bin/bash",
7 | "title": "bashing :)",
8 | "env": {
9 | "TERM": "screen-256color",
10 | "SHELL": "/bin/zsh"
11 | },
12 | "stdout": [
13 | [1.234567, "foo bar"],
14 | [5.678987, "baz qux"],
15 | [3.456789, "żółć jaźń"]
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/test/fixtures/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/asciinema/asciinema-server/33dadd53aac1351c27b0f46ee1589ab719896df6/test/fixtures/favicon.png
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build and query models.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint AsciinemaWeb.Endpoint
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Asciinema.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(Asciinema.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule AsciinemaWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build and query models.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # The default endpoint for testing
21 | @endpoint AsciinemaWeb.Endpoint
22 |
23 | use AsciinemaWeb, :verified_routes
24 | use Asciinema.OnExit
25 |
26 | # Import conveniences for testing with connections
27 | import Plug.Conn
28 | import Phoenix.ConnTest
29 |
30 | import Asciinema.Fixtures
31 |
32 | alias AsciinemaWeb.Router.Helpers, as: Routes
33 |
34 | defp flash(conn, key) do
35 | Phoenix.Flash.get(conn.assigns.flash, key)
36 | end
37 |
38 | def log_in(conn, user) do
39 | assign(conn, :current_user, user)
40 | end
41 | end
42 | end
43 |
44 | setup tags do
45 | Asciinema.DataCase.setup_sandbox(tags)
46 |
47 | {:ok, conn: Phoenix.ConnTest.build_conn()}
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/support/fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.Fixtures do
2 | def fixture(what, attrs \\ %{})
3 |
4 | def fixture(:upload, attrs) do
5 | fixture(:upload_v1, attrs)
6 | end
7 |
8 | def fixture(:upload_v1, attrs) do
9 | path = Map.get(attrs, :path) || "1/full.json"
10 | filename = Path.basename(path)
11 |
12 | %Plug.Upload{
13 | path: "test/fixtures/#{path}",
14 | filename: filename,
15 | content_type: "application/json"
16 | }
17 | end
18 |
19 | def fixture(:upload_v2, attrs) do
20 | path = Map.get(attrs, :path) || "2/full.cast"
21 | filename = Path.basename(path)
22 |
23 | %Plug.Upload{
24 | path: "test/fixtures/#{path}",
25 | filename: filename,
26 | content_type: "application/octet-stream"
27 | }
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/support/on_exit.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.OnExit do
2 | defmacro __using__(_) do
3 | quote do
4 | def on_exit_restore_config(module) do
5 | config = Application.get_env(:asciinema, module, [])
6 | on_exit(fn -> Application.put_env(:asciinema, module, config) end)
7 | end
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/test/support/url_provider.ex:
--------------------------------------------------------------------------------
1 | defmodule Asciinema.TestUrlProvider do
2 | @behaviour Asciinema.UrlProvider
3 |
4 | @impl true
5 | def sign_up(token) do
6 | "http://example.com/sign_up/#{token}"
7 | end
8 |
9 | @impl true
10 | def login(token) do
11 | "http://example.com/login/#{token}"
12 | end
13 |
14 | @impl true
15 | def account_deletion(token) do
16 | "http://example.com/account_deletion/#{token}"
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | {:ok, _} = Application.ensure_all_started(:ex_machina)
2 |
3 | ExUnit.configure(exclude: [rsvg: true, vt: true])
4 | ExUnit.start()
5 |
6 | Ecto.Adapters.SQL.Sandbox.mode(Asciinema.Repo, :manual)
7 |
--------------------------------------------------------------------------------
/uploads/.gitignore:
--------------------------------------------------------------------------------
1 | *
2 |
--------------------------------------------------------------------------------