Skip to content

Commit 5557342

Browse files
committed
Merge branch 'develop'
2 parents 0260656 + 346e867 commit 5557342

File tree

14 files changed

+892
-34
lines changed

14 files changed

+892
-34
lines changed

.github/workflows/release.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,6 @@ jobs:
3030
echo IMAGE_ID=$IMAGE_ID >> $GITHUB_OUTPUT
3131
echo IMAGE_TAG=$IMAGE_TAG >> $GITHUB_OUTPUT
3232
33-
- name: Create the release
34-
env:
35-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36-
run: |
37-
gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ steps.vars.outputs.VERSION }}
38-
3933
- name: Set up Docker Buildx
4034
uses: docker/setup-buildx-action@v3
4135

@@ -58,3 +52,9 @@ jobs:
5852
platforms: linux/amd64,linux/arm64
5953
build-args: |
6054
VERSION=${{ steps.vars.outputs.VERSION }}
55+
56+
- name: Create the release
57+
env:
58+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
run: |
60+
gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ steps.vars.outputs.VERSION }}

lib/asciinema/streaming/parser.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ defmodule Asciinema.Streaming.Parser do
99
def get("raw"), do: %{impl: Parser.Raw, state: Parser.Raw.init()}
1010
def get("v0.alis"), do: %{impl: Parser.AlisV0, state: Parser.AlisV0.init()}
1111
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()}
12+
def get("v2.asciicast"), do: %{impl: Parser.AsciicastV2, state: Parser.AsciicastV2.init()}
13+
def get("v3.asciicast"), do: %{impl: Parser.AsciicastV3, state: Parser.AsciicastV3.init()}
1314
end

lib/asciinema/streaming/parser/json.ex renamed to lib/asciinema/streaming/parser/asciicast_v2.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
defmodule Asciinema.Streaming.Parser.Json do
1+
defmodule Asciinema.Streaming.Parser.AsciicastV2 do
22
@moduledoc """
33
asciicast v2 compatible stream protocol parser.
44
"""
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
defmodule Asciinema.Streaming.Parser.AsciicastV3 do
2+
@moduledoc """
3+
asciicast v3 compatible stream protocol parser.
4+
"""
5+
6+
alias Asciinema.Colors
7+
8+
@behaviour Asciinema.Streaming.Parser
9+
10+
def name, do: "v3.asciicast"
11+
12+
def init, do: %{first: true, last_event_id: 0, time_offset: 0}
13+
14+
def parse({:text, "\n"}, state), do: {:ok, [], state}
15+
def parse({:text, "#" <> _}, state), do: {:ok, [], state}
16+
17+
def parse({:text, payload}, state) do
18+
case Jason.decode(payload) do
19+
{:ok, message} ->
20+
handle_message(message, state)
21+
22+
{:error, %Jason.DecodeError{} = reason} ->
23+
{:error, "JSON decode error: #{Jason.DecodeError.message(reason)}"}
24+
end
25+
end
26+
27+
def handle_message(%{"term" => %{"cols" => cols, "rows" => rows}} = header, state)
28+
when is_integer(cols) and is_integer(rows) do
29+
commands = [
30+
init: %{
31+
last_id: state.last_event_id,
32+
time: 0,
33+
term_size: {cols, rows},
34+
term_theme: parse_theme(get_in(header, ["term", "theme"]))
35+
}
36+
]
37+
38+
{:ok, commands, %{state | first: false}}
39+
end
40+
41+
def handle_message(_message, %{first: true}) do
42+
{:error, :init_expected}
43+
end
44+
45+
def handle_message([time, "o", text], state) when is_number(time) and is_binary(text) do
46+
{id, state} = get_next_id(state)
47+
time = state.time_offset + time_as_micros(time)
48+
49+
{:ok, [output: %{id: id, time: time, text: text}], %{state | time_offset: time}}
50+
end
51+
52+
def handle_message([time, "i", text], state) when is_number(time) and is_binary(text) do
53+
{id, state} = get_next_id(state)
54+
time = state.time_offset + time_as_micros(time)
55+
56+
{:ok, [input: %{id: id, time: time, text: text}], %{state | time_offset: time}}
57+
end
58+
59+
def handle_message([time, "r", data], state) when is_number(time) and is_binary(data) do
60+
{id, state} = get_next_id(state)
61+
time = state.time_offset + time_as_micros(time)
62+
[cols, rows] = String.split(data, "x")
63+
cols = String.to_integer(cols)
64+
rows = String.to_integer(rows)
65+
66+
{:ok, [resize: %{id: id, time: time, term_size: {cols, rows}}], %{state | time_offset: time}}
67+
end
68+
69+
def handle_message([time, "m", label], state) when is_number(time) and is_binary(label) do
70+
{id, state} = get_next_id(state)
71+
time = state.time_offset + time_as_micros(time)
72+
73+
{:ok, [marker: %{id: id, time: time, label: label}], %{state | time_offset: time}}
74+
end
75+
76+
def handle_message([time, "x", status], state) when is_number(time) and is_binary(status) do
77+
{id, state} = get_next_id(state)
78+
time = state.time_offset + time_as_micros(time)
79+
status = String.to_integer(status)
80+
81+
{:ok, [exit: %{id: id, time: time, status: status}], %{state | time_offset: time}}
82+
end
83+
84+
def handle_message([time, type, data], state)
85+
when is_number(time) and is_binary(type) and is_binary(data) do
86+
time = state.time_offset + time
87+
88+
{:ok, [], %{state | time_offset: time}}
89+
end
90+
91+
def handle_message(_message, _state) do
92+
{:error, :message_invalid}
93+
end
94+
95+
defp parse_theme(nil), do: nil
96+
97+
defp parse_theme(%{"fg" => fg, "bg" => bg, "palette" => palette}) do
98+
palette =
99+
palette
100+
|> String.split(":")
101+
|> Enum.map(&Colors.parse/1)
102+
103+
true = length(palette) in [8, 16]
104+
105+
%{
106+
fg: Colors.parse(fg),
107+
bg: Colors.parse(bg),
108+
palette: palette
109+
}
110+
end
111+
112+
defp get_next_id(state) do
113+
id = state.last_event_id + 1
114+
115+
{id, %{state | last_event_id: id}}
116+
end
117+
118+
defp time_as_micros(time), do: round(time * 1_000_000)
119+
end

lib/asciinema/streaming/parser/raw.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ defmodule Asciinema.Streaming.Parser.Raw do
77

88
def init, do: %{first: true, start_time: nil, last_event_id: 0}
99

10-
def parse({:binary, text}, %{first: true} = state) do
10+
def parse({_type, text}, %{first: true} = state) do
1111
size = size_from_resize_seq(text) || size_from_script_start_message(text) || @default_size
1212

1313
commands = [
@@ -17,7 +17,7 @@ defmodule Asciinema.Streaming.Parser.Raw do
1717
{:ok, commands, %{state | first: false, start_time: Timex.now()}}
1818
end
1919

20-
def parse({:binary, text}, state) do
20+
def parse({_type, text}, state) do
2121
{id, state} = get_next_id(state)
2222
time = stream_time(state)
2323

lib/asciinema_web/controllers/error_json.ex

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,27 @@ defmodule AsciinemaWeb.ErrorJSON do
1010
def render("404.json", _assigns) do
1111
%{type: "not_found", message: "Resource not found"}
1212
end
13+
14+
def render("413.json", _assigns) do
15+
{_, limit, _, _} = AsciinemaWeb.Plug.Parsers.MULTIPART.init([])
16+
limit = format_byte_size(limit)
17+
18+
%{
19+
type: "content_too_large",
20+
message: "The recording exceeds the server-configured size limit (#{limit})"
21+
}
22+
end
23+
24+
defp format_byte_size(size) do
25+
cond do
26+
rem(size, 1024 * 1024) == 0 ->
27+
"#{div(size, 1024 * 1024)} MiB"
28+
29+
rem(size, 1000 * 1000) == 0 ->
30+
"#{div(size, 1000 * 1000)} MB"
31+
32+
true ->
33+
to_string(size)
34+
end
35+
end
1336
end

lib/asciinema_web/plug/parsers/multipart.ex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
defmodule AsciinemaWeb.Plug.Parsers.MULTIPART do
22
@multipart Plug.Parsers.MULTIPART
33

4-
def init(opts), do: opts
5-
6-
def parse(conn, "multipart", subtype, headers, opts) do
4+
def init(opts) do
75
opts = if length = config(:length), do: [{:length, length} | opts], else: opts
8-
opts = @multipart.init(opts)
96

7+
@multipart.init(opts)
8+
end
9+
10+
def parse(conn, "multipart", subtype, headers, opts) do
1011
@multipart.parse(conn, "multipart", subtype, headers, opts)
1112
end
1213

lib/asciinema_web/stream_consumer_socket.ex

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ defmodule AsciinemaWeb.StreamConsumerSocket do
4141

4242
def websocket_init(%{token: token, user_id: user_id}) do
4343
with {:ok, stream} <- fetch_stream(token),
44-
:ok <- authorize(stream, user_id) do
44+
:ok <- authorize(stream, user_id, token) do
4545
Logger.info("consumer/#{stream.id}: connected")
4646
state = %{stream_id: stream.id, init: false, last_event_time: 0.0}
4747
StreamServer.subscribe(stream.id, [:output, :input, :resize, :marker, :end, :reset])
@@ -134,9 +134,9 @@ defmodule AsciinemaWeb.StreamConsumerSocket do
134134
end
135135

136136
def websocket_info(%StreamServer.Update{event: :marker} = update, state) do
137-
%{time: time, label: label} = update.data
137+
%{id: id, time: time, label: label} = update.data
138138
rel_time = time - state.last_event_time
139-
msg = serialize_marker(rel_time, label)
139+
msg = serialize_marker(id, rel_time, label)
140140
state = %{state | last_event_time: time}
141141

142142
{:reply, msg, state}
@@ -190,9 +190,11 @@ defmodule AsciinemaWeb.StreamConsumerSocket do
190190

191191
defp fetch_stream(token), do: OK.required(Streaming.lookup_stream(token), :stream_not_found)
192192

193-
defp authorize(stream, user_id) do
194-
if Authorization.can?(nil, :show, stream) ||
195-
Authorization.can?(Accounts.get_user(user_id), :show, stream) do
193+
defp authorize(stream, user_id, token) do
194+
resource = Map.put(stream, :id, token)
195+
196+
if Authorization.can?(nil, :show, resource) ||
197+
(user_id && Authorization.can?(Accounts.get_user(user_id), :show, resource)) do
196198
:ok
197199
else
198200
{:error, :forbidden}
@@ -239,8 +241,8 @@ defmodule AsciinemaWeb.StreamConsumerSocket do
239241
{:binary, msg}
240242
end
241243

242-
defp serialize_marker(time, label) do
243-
msg = <<?m>> <> encode_varint(time) <> serialize_string(label)
244+
defp serialize_marker(id, time, label) do
245+
msg = <<?m>> <> encode_varint(id) <> encode_varint(time) <> serialize_string(label)
244246

245247
{:binary, msg}
246248
end

lib/asciinema_web/stream_producer_socket.ex

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ defmodule AsciinemaWeb.StreamProducerSocket do
6060
Logger.info("producer/#{stream.id}: negotiated #{parser.impl.name()} protocol")
6161
save_protocol(state.stream_id, parser.impl.name())
6262
else
63-
Logger.info("producer/#{stream.id}: no protocol negotiated (legacy client)")
63+
Logger.info("producer/#{stream.id}: no protocol negotiated, will try to auto-detect")
6464
end
6565

6666
{:ok, state}
@@ -70,8 +70,6 @@ defmodule AsciinemaWeb.StreamProducerSocket do
7070
@impl true
7171
def websocket_handle(frame, state)
7272

73-
# legacy clause for CLI 3.0 RC 3 and earlier, which doesn't do protocol negotiation
74-
# TODO: remove after release of the final CLI 3.0
7573
def websocket_handle(message, %{parser: nil} = state) do
7674
parser = Parser.get(detect_protocol(message))
7775
Logger.info("producer/#{state.stream_id}: detected #{parser.impl.name()} protocol")
@@ -293,7 +291,7 @@ defmodule AsciinemaWeb.StreamProducerSocket do
293291
end
294292
end
295293

296-
@protos ~w(v1.alis v2.asciicast raw)
294+
@protos ~w(v1.alis v2.asciicast v3.asciicast raw)
297295

298296
defp select_protocol(protos) do
299297
# Choose common protos between the client and the server using client preferred order.
@@ -302,16 +300,21 @@ defmodule AsciinemaWeb.StreamProducerSocket do
302300
List.first(common)
303301
end
304302

305-
defp detect_protocol({:binary, "ALiS" <> _}), do: "v0.alis"
306-
defp detect_protocol({:binary, _}), do: "raw"
307-
defp detect_protocol({:text, _}), do: "v2.asciicast"
303+
def detect_protocol({:binary, "ALiS" <> _}), do: "v0.alis"
304+
def detect_protocol({:binary, _}), do: "raw"
305+
306+
def detect_protocol({:text, header}) do
307+
case Jason.decode(header) do
308+
{:ok, %{"version" => 2}} -> "v2.asciicast"
309+
{:ok, %{"version" => 3}} -> "v3.asciicast"
310+
_otherwise -> "raw"
311+
end
312+
end
308313

309314
defp save_protocol(stream_id, protocol) do
310-
Task.Supervisor.start_child(Asciinema.TaskSupervisor, fn ->
311-
stream_id
312-
|> Streaming.get_stream()
313-
|> Streaming.update_stream(protocol: protocol)
314-
end)
315+
stream_id
316+
|> Streaming.get_stream()
317+
|> Streaming.update_stream(protocol: protocol)
315318
end
316319

317320
defp config(key, default) do

0 commit comments

Comments
 (0)