diff --git a/.formatter.exs b/.formatter.exs index 8a6391c6a..ef8840ce6 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,6 @@ [ - import_deps: [:ecto, :phoenix], - inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], - subdirectories: ["priv/*/migrations"] + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] ] diff --git a/.gitignore b/.gitignore index b837248d3..1d1126844 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ erl_crash.dump # Also ignore archive artifacts (built via "mix archive.build"). *.ez +# Temporary files, for example, from tests. +/tmp/ + # Ignore package tarball (built via "mix hex.build"). sample_app-*.tar diff --git a/README.md b/README.md index 66be60517..a01d211d5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ Alternatively, you can set `ASCIINEMA_API_URL` environment variable: ASCIINEMA_API_URL=https://your.asciinema.host asciinema rec +## Security + +Security of this web app and user data it manages is important. +If you find anything that looks like a potential vulnerability please +read on +[how to report a security issue](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md#reporting-security-issues). + +## Consulting + +I offer consulting services for asciinema project. See https://asciinema.org/consulting for more information. + ## Contributing Check out our [Contributing](http://asciinema.org/contributing) page, which @@ -43,13 +54,6 @@ If you decide to contribute with the code then please read [CONTRIBUTING.md](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md), which covers submitting bugs, requesting new features, preparing your code for a pull request, etc. -## Security - -We're serious about the security of this web app and the user data it manages. -If you find anything that looks like a potential vulnerability please -read on -[how to report a security issue](https://github.com/asciinema/asciinema-server/blob/main/CONTRIBUTING.md#reporting-security-issues). - ## Authors asciinema is developed by [Marcin Kulik](http://ku1ik.com) with the help of diff --git a/assets/css/_base.scss b/assets/css/_base.scss index 57f814961..2ac64c428 100644 --- a/assets/css/_base.scss +++ b/assets/css/_base.scss @@ -74,7 +74,7 @@ body, main { background-color: #f7f7f7; } -.c-recording { +.c-recording, .c-live-stream { main { h1, h2, h3 { margin-top: 2rem; diff --git a/assets/css/_header.scss b/assets/css/_header.scss index ef971e54c..3f4f92a45 100644 --- a/assets/css/_header.scss +++ b/assets/css/_header.scss @@ -14,6 +14,7 @@ img { height: 20px; margin-right: 0.7rem; + border-radius: 2px; } } } diff --git a/assets/css/_icons.css b/assets/css/_icons.css new file mode 100644 index 000000000..d8b56e312 --- /dev/null +++ b/assets/css/_icons.css @@ -0,0 +1,21 @@ +span.icon svg { + height: 1em; + margin-right: 0.2em; + margin-top: -0.15em; +} + +span.icon-live { + font-weight: bold; + color: white; + background-color: #d40000; + padding: 0.1em 0.3em; + border-radius: 3px; +} + +span.icon-offline { + font-weight: bold; + color: white; + background-color: #666; + padding: 0.1em 0.3em; + border-radius: 3px; +} diff --git a/assets/css/_recording_card.scss b/assets/css/_recording_card.scss index 29f09d817..a7675ad1b 100644 --- a/assets/css/_recording_card.scss +++ b/assets/css/_recording_card.scss @@ -84,6 +84,7 @@ div.asciicast-card { img { width: 100%; height: 100%; + border-radius: 2px; } } diff --git a/assets/css/_recording_show.scss b/assets/css/_recording_show.scss index 07a8ab6ce..366c57e48 100644 --- a/assets/css/_recording_show.scss +++ b/assets/css/_recording_show.scss @@ -1,4 +1,4 @@ -.c-recording.a-show { +.c-recording.a-show, .c-live-stream.a-show { section.info { small { font-size: 14px; @@ -14,6 +14,7 @@ img { width: 100%; height: 100%; + border-radius: 3px; } } @@ -31,14 +32,50 @@ } section.meta { - img { - width: 16px; - } - code { color: #212529; background-color: #f7f7f7; } + + .status-line-item { + margin-right: 2em; + + .icon { + svg { + font-size: 1.5em; + } + } + + .icon { + &.icon-live, &.icon-offline { + margin-right: 0.5em; + } + } + } + } + + section.instructions { + padding-top: 0; + + input[type=text] { + width: 100%; + font-family: Consolas, Menlo, 'Bitstream Vera Sans Mono', monospace; + font-size: 14px; + color: #212529; + background-color: #fff; + border-radius: 3px; + border: 1px solid #e0e0e0; + margin-bottom: 8px; + padding: 9px; + } + + h2 { + margin-top: 2em; + } + + h3 { + font-weight: 600; + } } } diff --git a/assets/css/_user_login.scss b/assets/css/_user_login.scss index 7f75a31bc..99aebb232 100644 --- a/assets/css/_user_login.scss +++ b/assets/css/_user_login.scss @@ -14,9 +14,5 @@ .c-login { h2 { margin-top: 30px; - - span.glyphicon { - top: 4px; - } } } diff --git a/assets/css/_user_profile.scss b/assets/css/_user_profile.scss index 58f11e08d..eab187ec0 100644 --- a/assets/css/_user_profile.scss +++ b/assets/css/_user_profile.scss @@ -8,6 +8,7 @@ img { width: 100%; height: 100%; + border-radius: 3px; } } diff --git a/assets/css/app.scss b/assets/css/app.scss index 466d353ae..ac12103cf 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -6,6 +6,7 @@ $pagination-active-bg: #06989a; $primary: #06989a; @import "~bootstrap/scss/bootstrap"; +@import "./_icons.css"; @import "./_base.scss"; @import "./_header.scss"; @import "./_footer.scss"; diff --git a/assets/js/app.js b/assets/js/app.js index fd46c1881..19d243bda 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -3,25 +3,9 @@ import css from '../css/app.scss'; import $ from 'jquery'; import "bootstrap"; import "phoenix_html"; -import { create } from 'asciinema-player'; - -window.createPlayer = create; - -function createPlayer(src, container, opts) { - if (opts.customTerminalFontFamily) { - opts.terminalFontFamily = `${opts.customTerminalFontFamily},Consolas,Menlo,'Bitstream Vera Sans Mono',monospace,'Powerline Symbols'`; - - document.fonts.load(`1em ${opts.customTerminalFontFamily}`).then(() => { - console.log(`loaded font ${opts.customTerminalFontFamily}`); - create(src, container, opts); - }).catch(error => { - console.log(`failed to load font ${opts.customTerminalFontFamily}`, error); - create(src, container, opts); - }); - } else { - create(src, container, opts); - } -} +import { createPlayer } from './player'; + +window.createPlayer = createPlayer; $(function() { $('input[data-behavior=focus]:first').focus().select(); @@ -34,10 +18,14 @@ $(function() { if ($('meta[name=referrer][content=origin]').length > 0) { $('a[href*=http]').attr('rel', 'noreferrer'); } +}); - const players = window.players || new Map(); - for (const [id, props] of players) { - createPlayer(props.src, document.getElementById(id), { ...props, logger: console }); - }; -}); +import {Socket} from "phoenix"; +import {LiveSocket} from "phoenix_live_view"; + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); +let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken } }); + +// Connect if there are any LiveViews on the page +liveSocket.connect(); diff --git a/assets/js/iframe.js b/assets/js/iframe.js index 5cd4e8784..b3579ec6b 100644 --- a/assets/js/iframe.js +++ b/assets/js/iframe.js @@ -1,14 +1,4 @@ import css from '../css/iframe.scss'; +import { createPlayer } from './player'; -import { create } from 'asciinema-player'; - -const [id, props] = window.players.entries().next().value; -const player = create(props.src, document.getElementById(id), props); - -if (window.parent !== window) { - player.el.addEventListener('resize', e => { - const w = e.detail.el.offsetWidth; - const h = Math.max(document.body.scrollHeight, document.body.offsetHeight); - window.parent.postMessage(['resize', { width: w, height: h }], '*'); - }); -} +window.createPlayer = createPlayer; diff --git a/assets/js/player.js b/assets/js/player.js new file mode 100644 index 000000000..ad4e4902a --- /dev/null +++ b/assets/js/player.js @@ -0,0 +1,17 @@ +import { create } from 'asciinema-player'; + +export function createPlayer(src, container, opts) { + if (opts.customTerminalFontFamily) { + opts.terminalFontFamily = `${opts.customTerminalFontFamily},Consolas,Menlo,'Bitstream Vera Sans Mono',monospace,'Powerline Symbols'`; + + document.fonts.load(`1em ${opts.customTerminalFontFamily}`).then(() => { + console.log(`loaded font ${opts.customTerminalFontFamily}`); + create(src, container, opts); + }).catch(error => { + console.log(`failed to load font ${opts.customTerminalFontFamily}`, error); + return create(src, container, opts); + }); + } else { + return create(src, container, opts); + } +} diff --git a/assets/js/socket.js b/assets/js/socket.js deleted file mode 100644 index 0f8d461f1..000000000 --- a/assets/js/socket.js +++ /dev/null @@ -1,62 +0,0 @@ -// NOTE: The contents of this file will only be executed if -// you uncomment its entry in "web/static/js/app.js". - -// To use Phoenix channels, the first step is to import Socket -// and connect at the socket path in "lib/my_app/endpoint.ex": -import {Socket} from "phoenix" - -let socket = new Socket("/socket", {params: {token: window.userToken}}) - -// When you connect, you'll often need to authenticate the client. -// For example, imagine you have an authentication plug, `MyAuth`, -// which authenticates the session and assigns a `:current_user`. -// If the current user exists you can assign the user's token in -// the connection for use in the layout. -// -// In your "web/router.ex": -// -// pipeline :browser do -// ... -// plug MyAuth -// plug :put_user_token -// end -// -// defp put_user_token(conn, _) do -// if current_user = conn.assigns[:current_user] do -// token = Phoenix.Token.sign(conn, "user socket", current_user.id) -// assign(conn, :user_token, token) -// else -// conn -// end -// end -// -// Now you need to pass this token to JavaScript. You can do so -// inside a script tag in "web/templates/layout/app.html.eex": -// -// -// -// You will need to verify the user token in the "connect/2" function -// in "web/channels/user_socket.ex": -// -// def connect(%{"token" => token}, socket) do -// # max_age: 1209600 is equivalent to two weeks in seconds -// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do -// {:ok, user_id} -> -// {:ok, assign(socket, :user, user_id)} -// {:error, reason} -> -// :error -// end -// end -// -// Finally, pass the token on connect as below. Or remove it -// from connect if you don't care about authentication. - -socket.connect() - -// Now that you are connected, you can join channels with a topic: -let channel = socket.channel("topic:subtopic", {}) -channel.join() - .receive("ok", resp => { console.log("Joined successfully", resp) }) - .receive("error", resp => { console.log("Unable to join", resp) }) - -export default socket diff --git a/assets/package-lock.json b/assets/package-lock.json index e3bbc6d3c..52c772349 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -8,7 +8,7 @@ "devDependencies": { "@babel/core": "^7.21.0", "@babel/preset-env": "^7.20.2", - "asciinema-player": "^3.4.0", + "asciinema-player": "^3.5.0", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", @@ -17,8 +17,9 @@ "expose-loader": "^4.1.0", "jquery": "^3.5.1", "mini-css-extract-plugin": "^2.7.3", - "phoenix": "1.4.17", - "phoenix_html": "3.3.0", + "phoenix": "1.7.6", + "phoenix_html": "3.3.1", + "phoenix_live_view": "0.19.3", "popper.js": "^1.14.3", "sass": "^1.59.2", "sass-loader": "^13.2.0", @@ -2297,9 +2298,9 @@ } }, "node_modules/asciinema-player": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.4.0.tgz", - "integrity": "sha512-dX6jt5S3K6daItsVWzyY9mRDK+ivC2QgqCxFkdSiNslo0vY/ZqA4upcTzqIKZqBtxppovOZk44ltg9VnHG9QVg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/asciinema-player/-/asciinema-player-3.5.0.tgz", + "integrity": "sha512-o4B2AscBuCZo4+JB9TBGrfZ7GQL99wsbm08WwmuNJTPd1lyLQJq8wgacnBsdvb2sC0K875ScYr8T5XmfeH/6dg==", "dev": true, "dependencies": { "@babel/runtime": "^7.21.0", @@ -4697,15 +4698,21 @@ } }, "node_modules/phoenix": { - "version": "1.4.17", - "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.4.17.tgz", - "integrity": "sha512-La4NCJR4rfx/d0ifP8zsZCBBQ03D2aLf64QEvKP976+iKjm+KTQcGgBY5EGLAfUNnElH32FeDomdA07VVc6Nfg==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.7.6.tgz", + "integrity": "sha512-TOZmJqQaZIWDXMcRXo/qLSBcROFgfA0W/LlaJ9RpETGSYSTouGTJKw5ozR6dII6iPHpOXHagc9kV5WYO9LtTRQ==", "dev": true }, "node_modules/phoenix_html": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/phoenix_html/-/phoenix_html-3.3.0.tgz", - "integrity": "sha512-Q/X9UhxQLMYOuA8cXrDSH7PWTK/+vCpX+rtSheoNaPb/qDVoi+R3GPYKgL9CJ06+VWhcC3kkZ05O/RVsby7m6A==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/phoenix_html/-/phoenix_html-3.3.1.tgz", + "integrity": "sha512-t/9Saqpe8vznZYHMDim7HS32Dd2/rKf3+uxuKKNRADLpGXVIDjselOY6pK8aNaLiY2gnlqsoz6yIpUBuoLT63w==", + "dev": true + }, + "node_modules/phoenix_live_view": { + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/phoenix_live_view/-/phoenix_live_view-0.19.3.tgz", + "integrity": "sha512-naXvQmIOfpV99+g+YdIFz8I2O79R3vFRziSVuyqmZ1bemMV0TZIHAAwOhsMjoXtkvM8GmGbTrxMU8hq/atRrnw==", "dev": true }, "node_modules/picocolors": { diff --git a/assets/package.json b/assets/package.json index 05cf2cefa..914b20005 100644 --- a/assets/package.json +++ b/assets/package.json @@ -12,7 +12,7 @@ "devDependencies": { "@babel/core": "^7.21.0", "@babel/preset-env": "^7.20.2", - "asciinema-player": "^3.4.0", + "asciinema-player": "^3.5.0", "babel-loader": "^8.3.0", "bootstrap": "^4.5.0", "copy-webpack-plugin": "^11.0.0", @@ -21,8 +21,9 @@ "expose-loader": "^4.1.0", "jquery": "^3.5.1", "mini-css-extract-plugin": "^2.7.3", - "phoenix": "1.4.17", - "phoenix_html": "3.3.0", + "phoenix": "1.7.6", + "phoenix_html": "3.3.1", + "phoenix_live_view": "0.19.3", "popper.js": "^1.14.3", "sass": "^1.59.2", "sass-loader": "^13.2.0", diff --git a/assets/static/fonts/glyphicons-halflings-regular.eot b/assets/static/fonts/glyphicons-halflings-regular.eot deleted file mode 100644 index 87eaa4342..000000000 Binary files a/assets/static/fonts/glyphicons-halflings-regular.eot and /dev/null differ diff --git a/assets/static/fonts/glyphicons-halflings-regular.svg b/assets/static/fonts/glyphicons-halflings-regular.svg deleted file mode 100644 index 5fee06854..000000000 --- a/assets/static/fonts/glyphicons-halflings-regular.svg +++ /dev/null @@ -1,228 +0,0 @@ - - - \ No newline at end of file diff --git a/assets/static/fonts/glyphicons-halflings-regular.ttf b/assets/static/fonts/glyphicons-halflings-regular.ttf deleted file mode 100644 index be784dc1d..000000000 Binary files a/assets/static/fonts/glyphicons-halflings-regular.ttf and /dev/null differ diff --git a/assets/static/fonts/glyphicons-halflings-regular.woff b/assets/static/fonts/glyphicons-halflings-regular.woff deleted file mode 100644 index 2cc3e4852..000000000 Binary files a/assets/static/fonts/glyphicons-halflings-regular.woff and /dev/null differ diff --git a/config/config.exs b/config/config.exs index 408723ace..5bfd68f64 100644 --- a/config/config.exs +++ b/config/config.exs @@ -16,6 +16,7 @@ config :asciinema, Asciinema.Repo, migration_timestamps: [type: :naive_datetime_ config :asciinema, AsciinemaWeb.Endpoint, url: [host: "localhost"], render_errors: [view: AsciinemaWeb.ErrorView, accepts: ~w(html json), layout: false], + live_view: [signing_salt: "F3BMP7k9SZ-Y2SMJ"], pubsub_server: Asciinema.PubSub # Configures Elixir's Logger @@ -55,7 +56,8 @@ config :asciinema, Oban, {Oban.Plugins.Pruner, max_age: 604_800}, {Oban.Plugins.Cron, crontab: [ - {"0 * * * *", Asciinema.GC} + {"0 * * * *", Asciinema.GC}, + {"* * * * *", Asciinema.Streaming.GC} ]}, Oban.Plugins.Lifeline, Oban.Plugins.Reindexer diff --git a/config/dev.exs b/config/dev.exs index 8a9bedca1..beb48699d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -47,10 +47,14 @@ config :asciinema, AsciinemaWeb.Endpoint, ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/gettext/.*(po)$", ~r"lib/asciinema_web/views/.*(ex)$", - ~r"lib/asciinema_web/templates/.*(eex|md)$" + ~r"lib/asciinema_web/templates/.*(eex|md)$", + ~r"lib/asciinema_web/(controllers|live|components)/.*(ex|heex)$" ] ] +# Enable dev routes for dashboard and mailbox +config :asciinema, dev_routes: true + # Do not include metadata nor timestamps in development logs config :logger, :console, format: "[$level] $message\n" @@ -63,6 +67,8 @@ config :phoenix, :plug_init_mode, :runtime config :asciinema, Asciinema.Emails.Mailer, adapter: Bamboo.LocalAdapter +config :asciinema, Asciinema.Telemetry, enabled: false + # Import custom config. for config <- "custom*.exs" |> Path.expand(__DIR__) |> Path.wildcard() do import_config config diff --git a/config/prod.exs b/config/prod.exs index f06688cf3..dab45c219 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -3,7 +3,7 @@ import Config # For production, don't forget to configure the url host # to something meaningful, Phoenix uses this information # when generating URLs. -# + # Note we also include the path to a cache manifest # containing the digested version of static files. This # manifest is generated by the `mix phx.digest` task, @@ -23,33 +23,6 @@ config :asciinema, AsciinemaWeb.Endpoint, # Do not print debug messages in production config :logger, level: :info -# ## SSL Support -# -# To get SSL working, you will need to add the `https` key -# to the previous section and set your `:url` port to 443: -# -# config :asciinema, AsciinemaWeb.Endpoint, -# ..., -# url: [host: "example.com", port: 443], -# https: [ -# ..., -# port: 443, -# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), -# certfile: System.get_env("SOME_APP_SSL_CERT_PATH") -# ] -# -# Where those two env variables return an absolute path to -# the key and cert in disk or a relative path inside priv, -# for example "priv/ssl/server.key". -# -# We also recommend setting `force_ssl`, ensuring no data is -# ever sent via http, always redirecting to https: -# -# config :asciinema, AsciinemaWeb.Endpoint, -# force_ssl: [hsts: true] -# -# Check `Plug.SSL` for all available options in `force_ssl`. - config :asciinema, Asciinema.Repo, pool_size: 20, ssl: false diff --git a/config/runtime.exs b/config/runtime.exs index d31f93733..8ac5586cf 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -82,7 +82,7 @@ if config_env() in [:prod, :dev] do config :asciinema, Asciinema.Repo, pool_size: String.to_integer(db_pool_size) end - if env.("ECTO_IPV6") do + if env.("ECTO_IPV6") in ~w(true 1) do config :asciinema, Asciinema.Repo, socket_options: [:inet6] end diff --git a/lib/asciinema.ex b/lib/asciinema.ex index d6f16d873..8390db369 100644 --- a/lib/asciinema.ex +++ b/lib/asciinema.ex @@ -1,12 +1,53 @@ defmodule Asciinema do - alias Asciinema.{Accounts, Recordings, Repo} + alias Asciinema.{Accounts, Emails, Recordings, Repo, Streaming} + + def create_user(params) do + with {:ok, user} <- Accounts.create_user(params) do + Streaming.create_live_stream!(user) + + {:ok, user} + end + end + + defdelegate change_user(user, params \\ %{}), to: Accounts + defdelegate update_user(user, params), to: Accounts + + def create_user_from_signup_token(token) do + with {:ok, email} <- Accounts.verify_signup_token(token) do + create_user(%{email: email}) + end + end + + def send_login_email(identifier, sign_up_enabled?, routes) do + case Accounts.generate_login_url(identifier, sign_up_enabled?, routes) do + {:ok, {type, url, email}} -> + Emails.send_email(type, email, url) + + {:error, _reason} = result -> + result + end + end + + defdelegate verify_login_token(token), to: Accounts def merge_accounts(src_user, dst_user) do Repo.transaction(fn -> Recordings.reassign_asciicasts(src_user.id, dst_user.id) + Streaming.reassign_live_streams(src_user.id, dst_user.id) Accounts.reassign_api_tokens(src_user.id, dst_user.id) Accounts.delete_user!(src_user) Accounts.get_user(dst_user.id) end) end + + defdelegate get_live_stream(id_or_owner), to: Streaming + + def recording_gc_days do + Application.get_env(:asciinema, :asciicast_gc_days) + end + + def archive_unclaimed_recordings(days) do + t = Timex.shift(Timex.now(), days: -days) + Recordings.archive_asciicasts(Accounts.temporary_users(), t) + end end diff --git a/lib/asciinema/accounts/accounts.ex b/lib/asciinema/accounts.ex similarity index 67% rename from lib/asciinema/accounts/accounts.ex rename to lib/asciinema/accounts.ex index 1b3b8f138..27dabe53c 100644 --- a/lib/asciinema/accounts/accounts.ex +++ b/lib/asciinema/accounts.ex @@ -3,9 +3,12 @@ defmodule Asciinema.Accounts do import Ecto.Query, warn: false import Ecto, only: [assoc: 2, build_assoc: 2] alias Asciinema.Accounts.{User, ApiToken} - alias Asciinema.{Emails, Repo} + alias Asciinema.{Media, Repo} alias Ecto.Changeset + @valid_email_re ~r/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i + @valid_username_re ~r/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/ + def fetch_user(id) do case get_user(id) do nil -> {:error, :not_found} @@ -21,6 +24,22 @@ defmodule Asciinema.Accounts do Repo.get_by(User, auth_token: auth_token) end + def create_user(attrs) do + import Ecto.Changeset + + result = + %User{} + |> cast(attrs, [:email]) + |> validate_format(:email, @valid_email_re) + |> validate_required([:email]) + |> add_contraints() + |> Repo.insert() + + with {:error, %Ecto.Changeset{errors: [{:email, _}]}} <- result do + {:error, :email_taken} + end + end + def ensure_asciinema_user do case Repo.get_by(User, username: "asciinema") do nil -> @@ -31,7 +50,7 @@ defmodule Asciinema.Accounts do } %User{} - |> User.changeset(attrs) + |> change_user(attrs) |> Repo.insert!() user -> @@ -39,13 +58,32 @@ defmodule Asciinema.Accounts do end end - def change_user(user) do - User.changeset(user) + def change_user(user, params \\ %{}) do + import Ecto.Changeset + + user + |> cast(params, [:email, :name, :username, :theme_name, :asciicasts_private_by_default]) + |> validate_format(:email, @valid_email_re) + |> validate_format(:username, @valid_username_re) + |> validate_length(:username, min: 2, max: 16) + |> validate_inclusion(:theme_name, Media.themes()) + |> add_contraints() + end + + defp add_contraints(changeset) do + import Ecto.Changeset + + changeset + |> unique_constraint(:username, name: "index_users_on_username") + |> unique_constraint(:email, name: "index_users_on_email") end def update_user(user, params) do + import Ecto.Changeset + user - |> User.update_changeset(params) + |> change_user(params) + |> validate_required([:username, :email]) |> Repo.update() end @@ -55,41 +93,38 @@ defmodule Asciinema.Accounts do from(u in q, where: is_nil(u.email)) end - def send_login_email(email_or_username, signup_url, login_url, sign_up_enabled?) do - case {lookup_user(email_or_username), sign_up_enabled?} do - {%User{email: nil}, _} -> + def generate_login_url(identifier, sign_up_enabled?, routes) do + case {lookup_user(identifier), sign_up_enabled?} do + {{_, %User{email: nil}}, _} -> {:error, :email_missing} - {%User{} = user, _} -> - url = user |> login_token() |> login_url.() - {:ok, _} = Emails.send_login_email(user.email, url) - - :ok + {{_, %User{} = user}, _} -> + url = user |> login_token() |> routes.login_url() - {%Changeset{errors: [{:email, _}]}, _} -> - {:error, :email_invalid} + {:ok, {:login, url, user.email}} - {%Changeset{} = changeset, true} -> - email = changeset.changes.email - url = email |> signup_token() |> signup_url.() - {:ok, _} = Emails.send_signup_email(email, url) + {{:email, nil}, true} -> + changeset = change_user(%User{}, %{email: identifier}) - :ok + if changeset.valid? do + email = changeset.changes.email + url = email |> signup_token() |> routes.signup_url() - {%Changeset{}, false} -> - {:error, :user_not_found} + {:ok, {:signup, url, email}} + else + {:error, :email_invalid} + end - {nil, _} -> + {{_, nil}, _} -> {:error, :user_not_found} end end - def lookup_user(email_or_username) do - if String.contains?(email_or_username, "@") do - Repo.get_by(User, email: email_or_username) || - User.signup_changeset(%{email: email_or_username}) + def lookup_user(identifier) do + if String.contains?(identifier, "@") do + {:email, Repo.get_by(User, email: identifier)} else - Repo.get_by(User, username: email_or_username) + {:username, Repo.get_by(User, username: identifier)} end end @@ -113,18 +148,10 @@ defmodule Asciinema.Accounts do max_age: config(:login_token_max_age, 60) * 60 ) - with {:ok, email} <- result, - {:ok, user} <- %{email: email} |> User.signup_changeset() |> Repo.insert() do - {:ok, user} - else - {:error, :invalid} -> - {:error, :token_invalid} - - {:error, %Ecto.Changeset{}} -> - {:error, :email_taken} - - {:error, _} -> - {:error, :token_expired} + case result do + {:ok, email} -> {:ok, email} + {:error, :invalid} -> {:error, :token_invalid} + {:error, _} -> {:error, :token_expired} end end @@ -167,10 +194,12 @@ defmodule Asciinema.Accounts do end def create_user_with_api_token(token, tmp_username) do - user_changeset = User.temporary_changeset(tmp_username) + import Ecto.Changeset + + changeset = change(%User{}, %{temporary_username: tmp_username}) Repo.transaction(fn -> - with {:ok, %User{} = user} <- Repo.insert(user_changeset), + with {:ok, %User{} = user} <- Repo.insert(changeset), {:ok, %ApiToken{}} <- create_api_token(user, token) do user else diff --git a/lib/asciinema/accounts/user.ex b/lib/asciinema/accounts/user.ex index 25ed7e6f5..3bc83d4d8 100644 --- a/lib/asciinema/accounts/user.ex +++ b/lib/asciinema/accounts/user.ex @@ -1,11 +1,5 @@ defmodule Asciinema.Accounts.User do use Ecto.Schema - import Ecto.Changeset - alias Asciinema.Accounts.User - - @valid_email_re ~r/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i - @valid_username_re ~r/^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]$/ - @valid_theme_names ["asciinema", "tango", "solarized-dark", "solarized-light", "monokai"] @timestamps_opts [type: :utc_datetime_usec] @@ -23,33 +17,7 @@ defmodule Asciinema.Accounts.User do timestamps() has_many :asciicasts, Asciinema.Recordings.Asciicast + has_many :live_streams, Asciinema.Streaming.LiveStream has_many :api_tokens, Asciinema.Accounts.ApiToken end - - def changeset(struct, params \\ %{}) do - struct - |> cast(params, [:email, :name, :username, :theme_name, :asciicasts_private_by_default]) - |> validate_format(:email, @valid_email_re) - |> validate_format(:username, @valid_username_re) - |> validate_length(:username, min: 2, max: 16) - |> validate_inclusion(:theme_name, @valid_theme_names) - |> unique_constraint(:username, name: "index_users_on_username") - |> unique_constraint(:email, name: "index_users_on_email") - end - - def signup_changeset(attrs) do - %User{} - |> changeset(attrs) - |> validate_required([:email]) - end - - def update_changeset(%User{} = user, attrs) do - user - |> changeset(attrs) - |> validate_required([:username, :email]) - end - - def temporary_changeset(temporary_username) do - change(%User{}, %{temporary_username: temporary_username}) - end end diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index e522ff605..ad5b58ed4 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -10,22 +10,32 @@ defmodule Asciinema.Application do :ok = Oban.Telemetry.attach_default_logger() :ok = Asciinema.ObanErrorReporter.configure() + topologies = Application.get_env(:libcluster, :topologies, []) + # List all child processes to be supervised children = [ + # Start cluster supervisor + {Cluster.Supervisor, [topologies, [name: Asciinema.ClusterSupervisor]]}, + # Start the PubSub system + {Phoenix.PubSub, [name: Asciinema.PubSub, adapter: Phoenix.PubSub.PG2]}, + # Start live stream viewer tracker + {Asciinema.Streaming.ViewerTracker, [pubsub_server: Asciinema.PubSub]}, # Start telemetry reporters Asciinema.Telemetry, # Start the Ecto repository Asciinema.Repo, - # Start rate limiter - {PlugAttack.Storage.Ets, name: AsciinemaWeb.PlugAttack.Storage, clean_period: 60_000}, - # Start the endpoint when the application starts - AsciinemaWeb.Endpoint, - # Start Phoenix PubSub - {Phoenix.PubSub, [name: Asciinema.PubSub, adapter: Phoenix.PubSub.PG2]}, # Start PNG generator poolboy pool :poolboy.child_spec(:worker, Asciinema.PngGenerator.Rsvg.poolboy_config(), []), # Start Oban - {Oban, oban_config()} + {Oban, oban_config()}, + # Start distributed registry + {Horde.Registry, + [name: Asciinema.Streaming.LiveStreamRegistry, keys: :unique, members: :auto]}, + Asciinema.Streaming.LiveStreamSupervisor, + # Start rate limiter + {PlugAttack.Storage.Ets, name: AsciinemaWeb.PlugAttack.Storage, clean_period: 60_000}, + # Start the endpoint when the application starts + AsciinemaWeb.Endpoint ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/asciinema/authorization.ex b/lib/asciinema/authorization.ex index e1ade7254..285a65adb 100644 --- a/lib/asciinema/authorization.ex +++ b/lib/asciinema/authorization.ex @@ -1,30 +1,17 @@ defmodule Asciinema.Authorization do alias Asciinema.Accounts.User alias Asciinema.Recordings.Asciicast + alias Asciinema.Streaming.LiveStream defmodule Policy do - def can?(nil, _action, _thing) do - false - end - - def can?(%User{is_admin: true}, _action, _thing) do - true - end - + def can?(nil, _action, _thing), do: false + def can?(%User{is_admin: true}, _action, _thing), do: true def can?(_user, :make_featured, %Asciicast{}), do: false def can?(_user, :make_not_featured, %Asciicast{}), do: false - - def can?(user, _action, %Asciicast{user_id: uid}) do - user.id == uid - end - - def can?(user, :update, %User{id: uid}) do - user.id == uid - end - - def can?(_user, _action, _thing) do - false - end + def can?(user, _action, %Asciicast{user_id: uid}), do: user.id == uid + def can?(user, _action, %LiveStream{user_id: uid}), do: user.id == uid + def can?(user, :update, %User{id: uid}), do: user.id == uid + def can?(_user, _action, _thing), do: false end def can?(user, action, thing) do diff --git a/lib/asciinema/emails.ex b/lib/asciinema/emails.ex index 9fee6a474..18d3d983b 100644 --- a/lib/asciinema/emails.ex +++ b/lib/asciinema/emails.ex @@ -22,13 +22,10 @@ defmodule Asciinema.Emails do end end - def send_signup_email(to, url) do - Job.new(%{type: :signup, to: to, url: url}) - |> Oban.insert() - end + def send_email(type, to, url) do + Job.new(%{type: type, to: to, url: url}) + |> Oban.insert!() - def send_login_email(to, url) do - Job.new(%{type: :login, to: to, url: url}) - |> Oban.insert() + :ok end end diff --git a/lib/asciinema/gc.ex b/lib/asciinema/gc.ex index 5dd912c75..e10c2a392 100644 --- a/lib/asciinema/gc.ex +++ b/lib/asciinema/gc.ex @@ -1,16 +1,15 @@ defmodule Asciinema.GC do use Oban.Worker - alias Asciinema.Accounts - alias Asciinema.Recordings require Logger @impl Oban.Worker def perform(_job) do - if days = Recordings.gc_days() do - Logger.info("archiving unclaimed Recordings...") - dt = Timex.shift(Timex.now(), days: -days) - count = Recordings.archive_asciicasts(Accounts.temporary_users(), dt) - Logger.info("archived #{count} asciicasts") + if days = Asciinema.recording_gc_days() do + count = Asciinema.archive_unclaimed_recordings(days) + + if count > 0 do + Logger.info("archived #{count} recordings") + end :ok else diff --git a/lib/asciinema/pub_sub.ex b/lib/asciinema/pub_sub.ex new file mode 100644 index 000000000..514f10706 --- /dev/null +++ b/lib/asciinema/pub_sub.ex @@ -0,0 +1,9 @@ +defmodule Asciinema.PubSub do + def subscribe(topic) do + :ok = Phoenix.PubSub.subscribe(__MODULE__, topic) + end + + def broadcast(topic, payload) do + :ok = Phoenix.PubSub.broadcast(__MODULE__, topic, payload) + end +end diff --git a/lib/asciinema/recordings/recordings.ex b/lib/asciinema/recordings.ex similarity index 95% rename from lib/asciinema/recordings/recordings.ex rename to lib/asciinema/recordings.ex index 099bd73d5..396bc8992 100644 --- a/lib/asciinema/recordings/recordings.ex +++ b/lib/asciinema/recordings.ex @@ -1,15 +1,10 @@ defmodule Asciinema.Recordings do require Logger import Ecto.Query, warn: false - alias Asciinema.{Repo, FileStore, StringUtils, Vt} + alias Asciinema.{FileStore, Media, Repo, StringUtils, Vt} alias Asciinema.Recordings.{Asciicast, SnapshotUpdater} alias Ecto.Changeset - @custom_terminal_font_families [ - "FiraCode Nerd Font", - "JetBrainsMono Nerd Font" - ] - def fetch_asciicast(id) do case get_asciicast(id) do nil -> {:error, :not_found} @@ -42,6 +37,16 @@ defmodule Asciinema.Recordings do |> Repo.preload(:user) end + def public_asciicasts(%{asciicasts: _} = owner, limit \\ 4) do + owner + |> Ecto.assoc(:asciicasts) + |> filter(:public) + |> sort(:random) + |> limit(^limit) + |> preload(:user) + |> Repo.all() + end + def other_public_asciicasts(asciicast, limit \\ 4) do Asciicast |> filter({asciicast.user_id, :public}) @@ -447,7 +452,13 @@ defmodule Asciinema.Recordings do end def update_asciicast(asciicast, attrs \\ %{}) do - changeset = Asciicast.update_changeset(asciicast, attrs, @custom_terminal_font_families) + changeset = + Asciicast.update_changeset( + asciicast, + attrs, + Media.custom_terminal_font_families(), + Media.themes() + ) with {:ok, asciicast} <- Repo.update(changeset) do if stale_snapshot?(changeset) do @@ -473,9 +484,11 @@ defmodule Asciinema.Recordings do def delete_asciicast(asciicast) do with {:ok, asciicast} <- Repo.delete(asciicast) do - :ok = FileStore.delete_file(asciicast.path) - - {:ok, asciicast} + case FileStore.delete_file(asciicast.path) do + :ok -> {:ok, asciicast} + {:error, :enoent} -> {:ok, asciicast} + otherwise -> otherwise + end end end @@ -484,7 +497,7 @@ defmodule Asciinema.Recordings do rows = asciicast.rows_override || asciicast.rows secs = Asciicast.snapshot_at(asciicast) snapshot = asciicast |> stdout_stream |> generate_snapshot(cols, rows, secs) - asciicast |> Asciicast.snapshot_changeset(snapshot) |> Repo.update() + asciicast |> Changeset.cast(%{snapshot: snapshot}, [:snapshot]) |> Repo.update() end def generate_snapshot(stdout_stream, width, height, secs) do @@ -650,18 +663,15 @@ defmodule Asciinema.Recordings do tmp_path end - def gc_days do - Application.get_env(:asciinema, :asciicast_gc_days) - end - - def archive_asciicasts(users_query, dt) do + def archive_asciicasts(users_query, t) do query = from a in Asciicast, join: u in ^users_query, on: a.user_id == u.id, - where: a.archivable and is_nil(a.archived_at) and a.inserted_at < ^dt + where: a.archivable and is_nil(a.archived_at) and a.inserted_at < ^t {count, _} = Repo.update_all(query, set: [archived_at: Timex.now()]) + count end @@ -669,6 +679,4 @@ defmodule Asciinema.Recordings do q = from(a in Asciicast, where: a.user_id == ^src_user_id) Repo.update_all(q, set: [user_id: dst_user_id, updated_at: Timex.now()]) end - - def custom_terminal_font_families, do: @custom_terminal_font_families end diff --git a/lib/asciinema/recordings/asciicast.ex b/lib/asciinema/recordings/asciicast.ex index 11557c8aa..a7dde3879 100644 --- a/lib/asciinema/recordings/asciicast.ex +++ b/lib/asciinema/recordings/asciicast.ex @@ -91,7 +91,7 @@ defmodule Asciinema.Recordings.Asciicast do |> generate_secret_token end - def update_changeset(struct, attrs, custom_terminal_font_families \\ []) do + def update_changeset(struct, attrs, custom_terminal_font_families \\ [], themes \\ []) do struct |> changeset(attrs) |> cast(attrs, [ @@ -107,6 +107,7 @@ defmodule Asciinema.Recordings.Asciicast do |> validate_number(:cols_override, greater_than: 0, less_than: 1024) |> validate_number(:rows_override, greater_than: 0, less_than: 512) |> validate_number(:idle_time_limit, greater_than_or_equal_to: 0.5) + |> validate_inclusion(:theme_name, themes) |> validate_number(:terminal_line_height, greater_than_or_equal_to: 1.0, less_than_or_equal_to: 2.0 @@ -153,10 +154,6 @@ defmodule Asciinema.Recordings.Asciicast do end end - def snapshot_changeset(struct, snapshot) do - cast(struct, %{snapshot: snapshot}, [:snapshot]) - end - defp generate_secret_token(changeset) do put_change(changeset, :secret_token, Crypto.random_token(25)) end diff --git a/lib/asciinema/streaming.ex b/lib/asciinema/streaming.ex new file mode 100644 index 000000000..1efa879b6 --- /dev/null +++ b/lib/asciinema/streaming.ex @@ -0,0 +1,130 @@ +defmodule Asciinema.Streaming do + import Ecto.Changeset + import Ecto.Query + alias Asciinema.{Media, Repo} + alias Asciinema.Streaming.LiveStream + + def find_live_stream_by_producer_token(token) do + Repo.get_by(LiveStream, producer_token: token) + end + + def get_live_stream(id) when is_integer(id) do + LiveStream + |> Repo.get(id) + |> Repo.preload(:user) + end + + def get_live_stream(id) when is_binary(id) do + stream = + cond do + String.match?(id, ~r/[[:alpha:]]/) -> + Repo.one(from(s in LiveStream, where: s.secret_token == ^id)) + + String.match?(id, ~r/^\d+$/) -> + id = String.to_integer(id) + Repo.one(from(s in LiveStream, where: s.private == false and s.id == ^id)) + + true -> + nil + end + + Repo.preload(stream, :user) + end + + def get_live_stream(%{live_streams: _} = owner) do + owner + |> Ecto.assoc(:live_streams) + |> first() + |> Repo.one() + end + + def fetch_live_stream(id) do + case get_live_stream(id) do + nil -> {:error, :not_found} + stream -> {:ok, stream} + end + end + + def create_live_stream!(user) do + %LiveStream{} + |> change(secret_token: generate_secret_token(), producer_token: generate_producer_token()) + |> put_assoc(:user, user) + |> Repo.insert!() + end + + def change_live_stream(stream, attrs \\ %{}) + + def change_live_stream(stream, attrs) when is_map(attrs) do + stream + |> cast(attrs, [ + :title, + :description, + :private, + :theme_name, + :buffer_time, + :terminal_line_height, + :terminal_font_family + ]) + |> validate_number(:buffer_time, + greater_than_or_equal_to: 0.0, + less_than_or_equal_to: 30.0 + ) + |> validate_number(:terminal_line_height, + greater_than_or_equal_to: 1.0, + less_than_or_equal_to: 2.0 + ) + |> validate_inclusion(:terminal_font_family, Media.custom_terminal_font_families()) + end + + def update_live_stream(stream, attrs) when is_list(attrs) do + stream + |> cast(Enum.into(attrs, %{}), LiveStream.__schema__(:fields)) + |> update_peak_viewer_count() + |> change_last_activity() + |> Repo.update!() + end + + def update_live_stream(stream, attrs) when is_map(attrs) do + stream + |> change_live_stream(attrs) + |> Repo.update() + end + + defp update_peak_viewer_count(changeset) do + case get_change(changeset, :current_viewer_count, :not_changed) do + :not_changed -> + changeset + + count -> + peak_viewer_count = fetch_field!(changeset, :peak_viewer_count) || 0 + change(changeset, peak_viewer_count: max(count, peak_viewer_count)) + end + end + + defp change_last_activity(changeset) do + case fetch_field!(changeset, :online) do + true -> + cast(changeset, %{last_activity_at: Timex.now()}, [:last_activity_at]) + + false -> + changeset + end + end + + def reassign_live_streams(src_user_id, dst_user_id) do + from(s in LiveStream, where: s.user_id == ^src_user_id) + |> Repo.update_all(set: [user_id: dst_user_id, updated_at: Timex.now()]) + end + + def mark_inactive_live_streams_offline do + t = Timex.shift(Timex.now(), minutes: -1) + q = from(s in LiveStream, where: s.online and s.last_activity_at < ^t) + + {count, _} = Repo.update_all(q, set: [online: false]) + + count + end + + defp generate_producer_token, do: Crypto.random_token(25) + defp generate_secret_token, do: Crypto.random_token(25) +end diff --git a/lib/asciinema/streaming/gc.ex b/lib/asciinema/streaming/gc.ex new file mode 100644 index 000000000..002fc3fb2 --- /dev/null +++ b/lib/asciinema/streaming/gc.ex @@ -0,0 +1,16 @@ +defmodule Asciinema.Streaming.GC do + use Oban.Worker + alias Asciinema.Streaming + require Logger + + @impl Oban.Worker + def perform(_job) do + count = Streaming.mark_inactive_live_streams_offline() + + if count > 0 do + Logger.info("marked #{count} streams offline") + end + + :ok + end +end diff --git a/lib/asciinema/streaming/live_stream.ex b/lib/asciinema/streaming/live_stream.ex new file mode 100644 index 000000000..e5660b683 --- /dev/null +++ b/lib/asciinema/streaming/live_stream.ex @@ -0,0 +1,36 @@ +defmodule Asciinema.Streaming.LiveStream do + use Ecto.Schema + + schema "live_streams" do + field :secret_token, :string + field :producer_token, :string + field :private, :boolean, default: true + field :cols, :integer + field :rows, :integer + field :online, :boolean + field :last_activity_at, :naive_datetime + field :last_started_at, :naive_datetime + field :title, :string + field :description, :string + field :theme_name, :string + field :terminal_line_height, :float + field :terminal_font_family, :string + field :current_viewer_count, :integer + field :peak_viewer_count, :integer + field :buffer_time, :float + + timestamps() + + belongs_to :user, Asciinema.Accounts.User + end + + defimpl Phoenix.Param do + def to_param(%{private: true, secret_token: secret_token}) do + secret_token + end + + def to_param(%{id: id}) do + Integer.to_string(id) + end + end +end diff --git a/lib/asciinema/streaming/live_stream_server.ex b/lib/asciinema/streaming/live_stream_server.ex new file mode 100644 index 000000000..578eaae00 --- /dev/null +++ b/lib/asciinema/streaming/live_stream_server.ex @@ -0,0 +1,242 @@ +defmodule Asciinema.Streaming.LiveStreamServer do + use GenServer, restart: :transient + alias Asciinema.Streaming.ViewerTracker + alias Asciinema.{PubSub, Streaming, Vt} + require Logger + + defmodule Update do + defstruct [:stream_id, :event, :data] + end + + # Client + + def start_link(stream_id) do + GenServer.start_link(__MODULE__, stream_id, name: via_tuple(stream_id)) + end + + def lead(stream_id) do + GenServer.call(via_tuple(stream_id), :lead) + end + + def reset(stream_id, {_, _} = vt_size, vt_init \\ nil, stream_time \\ nil) do + GenServer.call(via_tuple(stream_id), {:reset, vt_size, vt_init, stream_time}) + end + + def feed(stream_id, event) do + GenServer.call(via_tuple(stream_id), {:feed, event}) + end + + def heartbeat(stream_id) do + GenServer.call(via_tuple(stream_id), :heartbeat) + end + + def subscribe(stream_id, type) when type in [:stream, :status] do + PubSub.subscribe(topic_name(stream_id, type)) + end + + def request_info(stream_id) do + GenServer.cast(via_tuple(stream_id), {:info, self()}) + end + + def stop(stream_id, reason \\ :normal), do: GenServer.stop(via_tuple(stream_id), reason) + + # Callbacks + + @default_cols 80 + @default_rows 24 + + @impl true + def init(stream_id) do + Logger.info("stream/#{stream_id}: init") + + Process.send_after(self(), :update_stream, 1_000) + ViewerTracker.subscribe(stream_id) + viewer_count = ViewerTracker.count(stream_id) + + stream = + stream_id + |> Streaming.get_live_stream() + |> Streaming.update_live_stream(online: true) + + state = %{ + stream: stream, + stream_id: stream.id, + producer: nil, + vt: nil, + vt_size: nil, + last_stream_time: nil, + last_feed_time: nil, + shutdown_timer: nil, + viewer_count: viewer_count + } + + state = + state + |> reset_stream({@default_cols, @default_rows}) + |> reschedule_shutdown() + + publish(stream_id, :status, %Update{ + stream_id: stream_id, + event: :status, + data: :online + }) + + {:ok, state} + end + + @impl true + def handle_call(:lead, {pid, _} = _from, state) do + {:reply, :ok, %{state | producer: pid}} + end + + def handle_call( + {:reset, vt_size, vt_init, stream_time}, + {pid, _} = _from, + %{producer: pid} = state + ) do + stream_time = stream_time || 0.0 + state = reset_stream(state, vt_size, stream_time) + + if vt_init do + :ok = Vt.feed(state.vt, vt_init) + end + + publish(state.stream_id, :stream, %Update{ + stream_id: state.stream_id, + event: :reset, + data: {vt_size, vt_init, stream_time} + }) + + {:reply, :ok, state} + end + + def handle_call({:reset, _vt_size, _vt_init, _stream_time}, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting reset from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + + def handle_call({:feed, {time, data} = event}, {pid, _} = _from, %{producer: pid} = state) do + :ok = Vt.feed(state.vt, data) + + publish(state.stream_id, :stream, %Update{ + stream_id: state.stream_id, + event: :feed, + data: event + }) + + {:reply, :ok, %{state | last_stream_time: time, last_feed_time: Timex.now()}} + end + + def handle_call({:feed, _event}, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting feed from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + + def handle_call(:heartbeat, {pid, _} = _from, %{producer: pid} = state) do + state = reschedule_shutdown(state) + + {:reply, :ok, state} + end + + def handle_call(:heartbeat, _from, state) do + Logger.info("stream/#{state.stream_id}: rejecting heartbeat from non-leader producer") + + {:reply, {:error, :not_a_leader}, state} + end + + @impl true + def handle_cast({:info, pid}, %{vt_size: vt_size} = state) do + stream_time = current_stream_time(state.last_stream_time, state.last_feed_time) + + send(pid, %Update{ + stream_id: state.stream_id, + event: :info, + data: {vt_size, Vt.dump(state.vt), stream_time} + }) + + {:noreply, state} + end + + @update_stream_interval 10_000 + + @impl true + def handle_info(%ViewerTracker.Update{viewer_count: c}, state) do + {:noreply, %{state | viewer_count: c}} + end + + def handle_info(:update_stream, state) do + Process.send_after(self(), :update_stream, @update_stream_interval) + stream = Streaming.update_live_stream(state.stream, current_viewer_count: state.viewer_count) + + {:noreply, %{state | stream: stream}} + end + + def handle_info(:shutdown, state) do + Logger.info("stream/#{state.stream_id}: shutting down due to missing heartbeats") + + {:stop, :normal, state} + end + + @impl true + def terminate(reason, state) do + Logger.info("stream/#{state.stream_id}: terminating (#{inspect(reason)})") + Logger.debug("stream/#{state.stream_id}: state: #{inspect(state)}") + + publish(state.stream_id, :status, %Update{ + stream_id: state.stream_id, + event: :status, + data: :offline + }) + + Streaming.update_live_stream(state.stream, online: false) + + :ok + end + + # Private + + defp via_tuple(stream_id), + do: {:via, Horde.Registry, {Asciinema.Streaming.LiveStreamRegistry, stream_id}} + + defp publish(stream_id, type, payload) do + PubSub.broadcast(topic_name(stream_id, type), payload) + end + + defp topic_name(stream_id, type), do: "stream:#{stream_id}:#{type}" + + defp reset_stream(state, {cols, rows} = vt_size, time \\ 0.0) do + {:ok, vt} = Vt.new(cols, rows) + + stream = + Streaming.update_live_stream(state.stream, + last_started_at: Timex.shift(Timex.now(), milliseconds: -round(time * 1000.0)), + cols: cols, + rows: rows + ) + + %{ + state + | vt: vt, + vt_size: vt_size, + stream: stream, + last_stream_time: time, + last_feed_time: Timex.now() + } + end + + defp reschedule_shutdown(state) do + if state.shutdown_timer do + Process.cancel_timer(state.shutdown_timer) + end + + timer = Process.send_after(self(), :shutdown, 60 * 1000) + + %{state | shutdown_timer: timer} + end + + defp current_stream_time(last_stream_time, last_feed_time) do + last_stream_time + Timex.diff(Timex.now(), last_feed_time, :milliseconds) / 1000.0 + end +end diff --git a/lib/asciinema/streaming/live_stream_supervisor.ex b/lib/asciinema/streaming/live_stream_supervisor.ex new file mode 100644 index 000000000..3de90e772 --- /dev/null +++ b/lib/asciinema/streaming/live_stream_supervisor.ex @@ -0,0 +1,30 @@ +defmodule Asciinema.Streaming.LiveStreamSupervisor do + use DynamicSupervisor + alias Asciinema.Streaming.LiveStreamServer + require Logger + + def start_link(init_arg) do + DynamicSupervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + DynamicSupervisor.init(strategy: :one_for_one) + end + + def start_child(id) do + Logger.debug("stream sup: starting server for live stream #{id}") + DynamicSupervisor.start_child(__MODULE__, {LiveStreamServer, id}) + end + + def ensure_child(id) do + case start_child(id) do + {:error, {:already_started, pid}} -> + Logger.debug("stream sup: server already exists for live stream #{id}") + {:ok, pid} + + otherwise -> + otherwise + end + end +end diff --git a/lib/asciinema/streaming/parser.ex b/lib/asciinema/streaming/parser.ex new file mode 100644 index 000000000..9d61fb098 --- /dev/null +++ b/lib/asciinema/streaming/parser.ex @@ -0,0 +1,11 @@ +defmodule Asciinema.Streaming.Parser do + @callback init() :: term + @callback parse({message :: term, opts :: keyword}, term) :: + {:ok, [{atom, term}], term} | {:error, term} + + alias Asciinema.Streaming.Parser + + def get(:raw), do: %{impl: Parser.Raw, state: Parser.Raw.init()} + def get(:alis), do: %{impl: Parser.Alis, state: Parser.Alis.init()} + def get(:json), do: %{impl: Parser.Json, state: Parser.Json.init()} +end diff --git a/lib/asciinema/streaming/parser/alis.ex b/lib/asciinema/streaming/parser/alis.ex new file mode 100644 index 000000000..a7b65c0fa --- /dev/null +++ b/lib/asciinema/streaming/parser/alis.ex @@ -0,0 +1,54 @@ +defmodule Asciinema.Streaming.Parser.Alis do + @behaviour Asciinema.Streaming.Parser + + def init, do: %{status: :new} + + def parse({"ALiS\x01\x00\x00\x00\x00\x00", _opts}, %{status: :new} = state) do + {:ok, [], %{state | status: :init}} + end + + def parse({"ALiS" <> rest, _opts}, %{status: :new}) do + {:error, "unsupported ALiS version/configuration: #{inspect(rest)}"} + end + + def parse( + { + << + 0x01, + cols::little-16, + rows::little-16, + time::little-float-32, + init_len::little-32, + init::binary-size(init_len) + >>, + _opts + }, + %{status: status} = state + ) + when status in [:init, :offline] do + {:ok, [reset: %{size: {cols, rows}, init: init, time: time}], %{state | status: :online}} + end + + def parse( + { + << + ?o, + time::little-float-32, + data_len::little-32, + data::binary-size(data_len) + >>, + _opts + }, + %{status: :online} = state + ) do + {:ok, [feed: {time, data}], state} + end + + def parse({<<0x04>>, _opts}, %{status: status} = state) when status in [:init, :online] do + {:ok, [status: :offline], %{state | status: :offline}} + end + + def parse({_payload, _opts}, _state) do + {:error, :message_invalid} + end +end diff --git a/lib/asciinema/streaming/parser/json.ex b/lib/asciinema/streaming/parser/json.ex new file mode 100644 index 000000000..b0ad75969 --- /dev/null +++ b/lib/asciinema/streaming/parser/json.ex @@ -0,0 +1,48 @@ +defmodule Asciinema.Streaming.Parser.Json do + @behaviour Asciinema.Streaming.Parser + + def init, do: %{first: true} + + def parse({"\n", _opts}, state), do: {:ok, [], state} + + def parse({payload, _opts}, state) do + case Jason.decode(payload) do + {:ok, message} -> + handle_message(message, state) + + {:error, %Jason.DecodeError{} = reason} -> + {:error, "JSON decode error: #{Jason.DecodeError.message(reason)}"} + end + end + + def handle_message(%{"cols" => cols, "rows" => rows} = header, state) + when is_integer(cols) and is_integer(rows) do + commands = [reset: %{size: {cols, rows}, init: header["init"], time: header["time"]}] + + {:ok, commands, %{state | first: false}} + end + + def handle_message(%{"width" => cols, "height" => rows}, state) + when is_integer(cols) and is_integer(rows) do + commands = [reset: %{size: {cols, rows}, init: nil, time: nil}] + + {:ok, commands, %{state | first: false}} + end + + def handle_message(_message, %{first: true}) do + {:error, :reset_expected} + end + + def handle_message([time, "o", data], state) when is_number(time) and is_binary(data) do + {:ok, [feed: {time, data}], state} + end + + def handle_message([time, type, data], state) + when is_number(time) and is_binary(type) and is_binary(data) do + {:ok, [], state} + end + + def handle_message(_message, _state) do + {:error, :message_invalid} + end +end diff --git a/lib/asciinema/streaming/parser/raw.ex b/lib/asciinema/streaming/parser/raw.ex new file mode 100644 index 000000000..5f0131255 --- /dev/null +++ b/lib/asciinema/streaming/parser/raw.ex @@ -0,0 +1,34 @@ +defmodule Asciinema.Streaming.Parser.Raw do + @behaviour Asciinema.Streaming.Parser + + @default_size {80, 24} + + def init, do: %{first: true, start_time: nil} + + def parse({payload, _}, %{first: true} = state) do + size = + size_from_resize_seq(payload) || size_from_script_start_message(payload) || @default_size + + commands = [reset: %{size: size, init: payload, time: 0.0}] + + {:ok, commands, %{state | first: false, start_time: Timex.now()}} + end + + def parse({payload, _}, state) do + time = Timex.diff(Timex.now(), state.start_time, :microsecond) / 1_000_000 + + {:ok, [feed: {time, payload}], state} + end + + defp size_from_resize_seq(text) do + with [_, rows, cols] <- Regex.run(~r/\x1b\[8;(\d+);(\d+)t/, text) do + {String.to_integer(cols), String.to_integer(rows)} + end + end + + defp size_from_script_start_message(text) do + with [_, cols, rows] <- Regex.run(~r/\[.*COLUMNS="(\d{1,3})" LINES="(\d{1,3})".*\]/, text) do + {String.to_integer(cols), String.to_integer(rows)} + end + end +end diff --git a/lib/asciinema/streaming/viewer_tracker.ex b/lib/asciinema/streaming/viewer_tracker.ex new file mode 100644 index 000000000..22a60f653 --- /dev/null +++ b/lib/asciinema/streaming/viewer_tracker.ex @@ -0,0 +1,76 @@ +defmodule Asciinema.Streaming.ViewerTracker do + use Phoenix.Tracker + alias Asciinema.PubSub + alias Phoenix.Tracker + require Logger + + defmodule Update do + defstruct [:stream_id, :viewer_count] + end + + # Public API + + def start_link(opts) do + opts = Keyword.merge([name: __MODULE__], opts) + Tracker.start_link(__MODULE__, opts, opts) + end + + def count(stream_id) do + length(Tracker.list(__MODULE__, stream_id)) + end + + def track(stream_id) do + Tracker.track(__MODULE__, self(), stream_id, "", %{}) + end + + def untrack(stream_id) do + Tracker.untrack(__MODULE__, self(), stream_id, "") + end + + def subscribe(stream_id) do + PubSub.subscribe(topic_name(stream_id)) + end + + # Callbacks + + @impl true + def init(opts) do + server = Keyword.fetch!(opts, :pubsub_server) + {:ok, %{counts: %{}, pubsub_server: server}} + end + + @impl true + def handle_diff(diff, state) do + counts = + Enum.reduce(diff, %{}, fn {stream_id, {joins, leaves}}, counts -> + delta = length(joins) - length(leaves) + + if delta == 0 do + counts + else + Map.update(counts, stream_id, delta, fn c -> c + delta end) + end + end) + + send(self(), {:publish, Map.keys(counts)}) + + counts = Map.merge(state.counts, counts, fn _k, c1, c2 -> c1 + c2 end) + + {:ok, %{state | counts: counts}} + end + + @impl true + def handle_info({:publish, stream_ids}, state) do + for stream_id <- stream_ids do + count = Map.get(state.counts, stream_id, 0) + Logger.debug("tracker/#{stream_id}: viewer count: #{count}") + PubSub.broadcast(topic_name(stream_id), %Update{stream_id: stream_id, viewer_count: count}) + end + + {:noreply, state} + end + + # Private + + defp topic_name(stream_id), do: "stream:#{stream_id}:viewers" +end diff --git a/lib/asciinema/telemetry.ex b/lib/asciinema/telemetry.ex index 3312ebe12..3d913087c 100644 --- a/lib/asciinema/telemetry.ex +++ b/lib/asciinema/telemetry.ex @@ -31,7 +31,7 @@ defmodule Asciinema.Telemetry do phoenix_distribution = [ unit: {:native, :millisecond}, - tags: [:plug, :route, :method, :status], + tags: [:plug, :route, :method, :status, :event], tag_values: &phoenix_router_dispatch_tag_values/1, reporter_options: [buckets: @buckets] ] @@ -63,7 +63,14 @@ defmodule Asciinema.Telemetry do distribution("asciinema.repo.query.queue_time", repo_distribution), # Phoenix + distribution("phoenix.endpoint.start.system_time", phoenix_distribution), + distribution("phoenix.endpoint.stop.duration", phoenix_distribution), + distribution("phoenix.router_dispatch.start.system_time", phoenix_distribution), + distribution("phoenix.router_dispatch.exception.duration", phoenix_distribution), distribution("phoenix.router_dispatch.stop.duration", phoenix_distribution), + distribution("phoenix.socket_connected.duration", phoenix_distribution), + distribution("phoenix.channel_join.duration", phoenix_distribution), + distribution("phoenix.channel_handled_in.duration", phoenix_distribution), # Oban counter("oban.job.start.count", oban_counter), diff --git a/lib/asciinema/vt.ex b/lib/asciinema/vt.ex index a4aaec0ff..f94d97706 100644 --- a/lib/asciinema/vt.ex +++ b/lib/asciinema/vt.ex @@ -13,6 +13,9 @@ defmodule Asciinema.Vt do def feed(_vt, _str), do: :erlang.nif_error(:nif_not_loaded) # => :ok + def dump(_vt), do: :erlang.nif_error(:nif_not_loaded) + # => ... + def dump_screen(_vt), do: :erlang.nif_error(:nif_not_loaded) # => {:ok, {lines, cursor}} end diff --git a/lib/asciinema_web.ex b/lib/asciinema_web.ex index dbe9bc0c8..66619ba1d 100644 --- a/lib/asciinema_web.ex +++ b/lib/asciinema_web.ex @@ -1,7 +1,7 @@ defmodule AsciinemaWeb do @moduledoc """ The entrypoint for defining your web interface, such - as controllers, views, channels and so on. + as controllers, components, channels, and so on. This can be used in your application as: @@ -17,6 +17,8 @@ defmodule AsciinemaWeb do and import those modules here. """ + def static_paths, do: ~w(css fonts images js favicon.ico robots.txt) + def controller do quote do use Phoenix.Controller, namespace: AsciinemaWeb @@ -29,6 +31,30 @@ defmodule AsciinemaWeb do import AsciinemaWeb.Plug.Authz alias AsciinemaWeb.Router.Helpers, as: Routes + unquote(verified_routes()) + + action_fallback AsciinemaWeb.FallbackController + + defp clear_main_class(conn, _) do + assign(conn, :main_class, "") + end + end + end + + def new_controller do + quote do + use Phoenix.Controller, + formats: [:html, :json] + + import Plug.Conn + import AsciinemaWeb.Gettext + import AsciinemaWeb.Router.Helpers.Extra + import AsciinemaWeb.Auth, only: [require_current_user: 2] + import AsciinemaWeb.Plug.ReturnTo + import AsciinemaWeb.Plug.Authz + + unquote(verified_routes()) + action_fallback AsciinemaWeb.FallbackController defp clear_main_class(conn, _) do @@ -54,10 +80,9 @@ defmodule AsciinemaWeb do def live_view do quote do - use Phoenix.LiveView, - layout: {AsciinemaWeb.LayoutView, "live.html"} + use Phoenix.LiveView - unquote(view_helpers()) + unquote(html_helpers()) end end @@ -65,7 +90,7 @@ defmodule AsciinemaWeb do quote do use Phoenix.LiveComponent - unquote(view_helpers()) + unquote(html_helpers()) end end @@ -104,14 +129,60 @@ defmodule AsciinemaWeb do import Phoenix.LiveView.Helpers # Import basic rendering functionality (render, render_layout, etc) + use Phoenix.Component import Phoenix.View - import AsciinemaWeb.ErrorHelpers + # Core UI components and translation + import AsciinemaWeb.CoreComponents import AsciinemaWeb.Gettext + import AsciinemaWeb.Icons + + import AsciinemaWeb.ErrorHelpers alias AsciinemaWeb.Router.Helpers, as: Routes import AsciinemaWeb.Router.Helpers.Extra import AsciinemaWeb.ApplicationView + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def html do + quote do + use Phoenix.Component + import Phoenix.View + import AsciinemaWeb.ApplicationView + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # HTML escaping functionality + import Phoenix.HTML + + # Core UI components and translation + import AsciinemaWeb.CoreComponents + import AsciinemaWeb.Gettext + import AsciinemaWeb.Icons + + # Shortcut for generating JS commands + alias Phoenix.LiveView.JS + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: AsciinemaWeb.Endpoint, + router: AsciinemaWeb.Router, + statics: AsciinemaWeb.static_paths() end end diff --git a/lib/asciinema_web/components/core_components.ex b/lib/asciinema_web/components/core_components.ex new file mode 100644 index 000000000..aaea51a26 --- /dev/null +++ b/lib/asciinema_web/components/core_components.ex @@ -0,0 +1,116 @@ +defmodule AsciinemaWeb.CoreComponents do + use Phoenix.Component + + attr :for, Phoenix.HTML.FormField + attr :rest, :global + slot :inner_block, required: true + + def label(assigns) do + ~H""" + + """ + end + + attr :id, :any, default: nil + attr :name, :any + attr :value, :any + + attr :type, :string, + default: "text", + values: + ~w(checkbox color date datetime-local email file hidden month number password range radio search select tel text textarea time url week) + + attr :field, Phoenix.HTML.FormField, + doc: "a form field struct retrieved from the form, for example: @form[:email]" + + attr :checked, :boolean, doc: "the checked flag for checkbox inputs" + attr :prompt, :string, default: nil, doc: "the prompt for select inputs" + attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" + + attr :rest, :global, + include: + ~w(autocomplete cols disabled form max maxlength min minlength pattern placeholder readonly required rows size step) + + slot :inner_block + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign_new(:name, fn -> field.name end) + |> assign_new(:value, fn -> field.value end) + |> input() + end + + def input(%{type: "checkbox", value: value} = assigns) do + assigns = + assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end) + + ~H""" + + + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + + """ + end + + def input(assigns) do + ~H""" + + """ + end + + attr :type, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value) + slot :inner_block, required: true + + def button(assigns) do + ~H""" + + """ + end + + attr :time, :any, required: true + attr :rest, :global + + def time_ago(assigns) do + ~H""" + + """ + end + + attr :form, :any, required: true + attr :field, :atom, required: true + + def error(assigns) do + assigns = assign(assigns, :error, assigns.form.errors[assigns.field]) + + ~H""" + <%= @error %> + """ + end +end diff --git a/lib/asciinema_web/components/icons.ex b/lib/asciinema_web/components/icons.ex new file mode 100644 index 000000000..8201b613e --- /dev/null +++ b/lib/asciinema_web/components/icons.ex @@ -0,0 +1,5 @@ +defmodule AsciinemaWeb.Icons do + use Phoenix.Component + + embed_templates "icons/*" +end diff --git a/lib/asciinema_web/components/icons/adjustments_horizontal_mini_icon.html.heex b/lib/asciinema_web/components/icons/adjustments_horizontal_mini_icon.html.heex new file mode 100644 index 000000000..d84931257 --- /dev/null +++ b/lib/asciinema_web/components/icons/adjustments_horizontal_mini_icon.html.heex @@ -0,0 +1,5 @@ + + + diff --git a/lib/asciinema_web/components/icons/cog_8_tooth_mini_icon.html.heex b/lib/asciinema_web/components/icons/cog_8_tooth_mini_icon.html.heex new file mode 100644 index 000000000..4eabffd75 --- /dev/null +++ b/lib/asciinema_web/components/icons/cog_8_tooth_mini_icon.html.heex @@ -0,0 +1,9 @@ + + + diff --git a/lib/asciinema_web/components/icons/envelope_outline_icon.html.heex b/lib/asciinema_web/components/icons/envelope_outline_icon.html.heex new file mode 100644 index 000000000..6f1d0b65e --- /dev/null +++ b/lib/asciinema_web/components/icons/envelope_outline_icon.html.heex @@ -0,0 +1,16 @@ + + + diff --git a/lib/asciinema_web/components/icons/eye_solid_icon.html.heex b/lib/asciinema_web/components/icons/eye_solid_icon.html.heex new file mode 100644 index 000000000..74f96ee73 --- /dev/null +++ b/lib/asciinema_web/components/icons/eye_solid_icon.html.heex @@ -0,0 +1,10 @@ + + + diff --git a/lib/asciinema_web/components/icons/info_outline_icon.html.heex b/lib/asciinema_web/components/icons/info_outline_icon.html.heex new file mode 100644 index 000000000..4bdbae512 --- /dev/null +++ b/lib/asciinema_web/components/icons/info_outline_icon.html.heex @@ -0,0 +1,16 @@ + + + diff --git a/lib/asciinema_web/components/icons/info_solid_icon.html.heex b/lib/asciinema_web/components/icons/info_solid_icon.html.heex new file mode 100644 index 000000000..835e368fc --- /dev/null +++ b/lib/asciinema_web/components/icons/info_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + diff --git a/lib/asciinema_web/components/icons/live_icon.html.heex b/lib/asciinema_web/components/icons/live_icon.html.heex new file mode 100644 index 000000000..7fa6a8288 --- /dev/null +++ b/lib/asciinema_web/components/icons/live_icon.html.heex @@ -0,0 +1 @@ +LIVE diff --git a/lib/asciinema_web/components/icons/offline_icon.html.heex b/lib/asciinema_web/components/icons/offline_icon.html.heex new file mode 100644 index 000000000..eae77488d --- /dev/null +++ b/lib/asciinema_web/components/icons/offline_icon.html.heex @@ -0,0 +1 @@ +OFFLINE diff --git a/lib/asciinema_web/components/icons/terminal_solid_icon.html.heex b/lib/asciinema_web/components/icons/terminal_solid_icon.html.heex new file mode 100644 index 000000000..284a8af36 --- /dev/null +++ b/lib/asciinema_web/components/icons/terminal_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + diff --git a/lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex b/lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex new file mode 100644 index 000000000..9b81149be --- /dev/null +++ b/lib/asciinema_web/components/icons/user_circle_outline_icon.html.heex @@ -0,0 +1,16 @@ + + + diff --git a/lib/asciinema_web/components/icons/user_solid_icon.html.heex b/lib/asciinema_web/components/icons/user_solid_icon.html.heex new file mode 100644 index 000000000..447f6ef63 --- /dev/null +++ b/lib/asciinema_web/components/icons/user_solid_icon.html.heex @@ -0,0 +1,9 @@ + + + diff --git a/lib/asciinema_web/controllers/live_stream/edit.html.heex b/lib/asciinema_web/controllers/live_stream/edit.html.heex new file mode 100644 index 000000000..96c958993 --- /dev/null +++ b/lib/asciinema_web/controllers/live_stream/edit.html.heex @@ -0,0 +1,113 @@ +
This is your unique live stream URL. Share this + freely with your viewers.
+ +Do not share this with anyone. Use it only with + the commands provided below to go live.
+ +mkfifo live.pipe
+
+# in shell 1
+websocat <%= ws_producer_url(@stream) %> <live.pipe
+
+# in shell 2
+asciinema rec live.pipe
+
+ mkfifo live.pipe
+
+# in shell 1
+asp --in-fmt raw -i live.pipe -f <%= ws_producer_url(@stream) %>
+
+# in shell 2
+script -f -O live.pipe
+
+
+ mkfifo live.pipe
+
+# in shell 1
+websocat --binary <%= ws_producer_url(@stream) %> <live.pipe
+
+# in shell 2
+script -f -O live.pipe
+ + See all +
+<%= error %>
<% end %> -We use email-based, passwordless login process. Enter your email @@ -34,15 +38,14 @@ you'll get in, and you'll be able to pick your username.
<% else %>Public sign up on this site hasn't been enabled. Bummer! Try contacting the - administrator.
+ administrator. <% end %> -If you already have an account then enter either your username, or the email address you used for the first time here. We'll send you an email with a one-time login link.
-