diff --git a/.env.production.sample b/.env.production.sample index f9eeae793..8ffa1eceb 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -9,7 +9,7 @@ URL_HOST=localhost URL_PORT=3000 ## Base secret key for signing cookies etc. -## Run `docker-compose run --rm phoenix gen_secret` +## Run `docker run --rm ghcr.io/asciinema/asciinema-server gen_secret` ## and copy generated secret here. SECRET_KEY_BASE= diff --git a/Dockerfile b/Dockerfile index ee5974d2c..0df03a3fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,13 +68,11 @@ RUN chgrp -R 0 /opt/app && chmod -R g=u /opt/app COPY .iex.exs . ENV PORT 4000 +ENV ADMIN_BIND_ALL 1 ENV DATABASE_URL "postgresql://postgres@postgres/postgres" ENV RSVG_FONT_FAMILY "Dejavu Sans Mono" ENV PATH "/opt/app/bin:${PATH}" -VOLUME /opt/app/uploads -VOLUME /opt/app/cache - ENTRYPOINT ["/sbin/tini", "--"] CMD ["/opt/app/bin/server"] diff --git a/README.md b/README.md index d102c154a..02ea300a2 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,6 @@ at [asciinema/asciinema](https://github.com/asciinema/asciinema), and the source code of asciinema web player at [asciinema/asciinema-player](https://github.com/asciinema/asciinema-player). -Shout-out to our Platinum [sponsors](https://github.com/sponsors/ku1ik), whose -financial support helps keep the project alive: - -[](https://dashcam.io?utm_source=asciinemagithub) - ## Setting up your own asciinema web app instance asciinema terminal recorder uses [asciinema.org](https://asciinema.org) as its @@ -48,9 +43,6 @@ read on ## Sponsors -asciinema is sponsored by: - -- [**Dashcam**](https://dashcam.io?utm_source=asciinemagithub) - [Brightbox](https://www.brightbox.com/) ## Consulting diff --git a/assets/css/_base.scss b/assets/css/_base.scss index efd022227..fa3094142 100644 --- a/assets/css/_base.scss +++ b/assets/css/_base.scss @@ -74,7 +74,7 @@ body, main { background-color: #f7f7f7; } -.c-recording, .c-live-stream { +.c-page, .c-recording, .c-live-stream { main { h1, h2, h3 { margin-top: 2rem; @@ -87,6 +87,10 @@ body, main { } } +.c-page .container > h1 { + margin-bottom: 2rem; +} + section.cinema { color: #fff; background-color: #222; @@ -202,3 +206,7 @@ body pre { background-color: #eee; border-color: #ccc; } + +span.email b { + display: none; +} diff --git a/assets/static/js/embed.js b/assets/static/js/embed.js index 8c8b34b03..c4244f0a5 100644 --- a/assets/static/js/embed.js +++ b/assets/static/js/embed.js @@ -6,40 +6,54 @@ } function params(container, script) { - function format(name) { - var value = script.getAttribute('data-' + name); - if (value) { - return name + '=' + value; - } + if (script.dataset.t !== undefined) { + script.dataset.startAt = script.dataset.t; + } + + if (script.dataset.i !== undefined) { + script.dataset.idleTimeLimit = script.dataset.i; } - var options = ['size', 'speed', 'autoplay', 'loop', 'theme', 't', 'startAt', 'preload', 'cols', 'rows', 'i', 'idleTimeLimit']; + if (script.dataset.autoplay === '') { + script.dataset.autoplay = '1'; + } + + if (script.dataset.loop === '') { + script.dataset.loop = '1'; + } - return '?' + options.map(format).filter(Boolean).join('&'); + if (script.dataset.preload === '') { + script.dataset.preload = '1'; + } + + const keys = new Set(['speed', 'autoplay', 'loop', 'theme', 'startAt', 'preload', 'cols', 'rows', 'idleTimeLimit', 'poster']); + + return Object.entries(script.dataset) + .filter(([key, _]) => keys.has(key)) + .map(kv => kv.join('=')) + .join('&'); } function locationFromString(string) { - var parser = document.createElement('a'); + const parser = document.createElement('a'); parser.href = string; return parser; } function apiHostFromScript(script) { - var location = locationFromString(script.src); + const location = locationFromString(script.src); return location.protocol + '//' + location.host; } function insertPlayer(script) { - // do not insert player if there's one already associated with this script - if (script.dataset.player) { + if (script.dataset.initialized !== undefined) { return; } - var apiHost = apiHostFromScript(script); + const apiHost = apiHostFromScript(script); + const asciicastId = script.id.split('-')[1]; + const container = document.createElement('div'); - var asciicastId = script.id.split('-')[1]; - - var container = document.createElement('div'); container.id = "asciicast-container-" + asciicastId; container.className = 'asciicast'; container.style.display = 'block'; @@ -50,8 +64,8 @@ insertAfter(script, container); - var iframe = document.createElement('iframe'); - iframe.src = apiHost + "/a/" + asciicastId + '/iframe' + params(container, script); + const iframe = document.createElement('iframe'); + iframe.src = apiHost + "/a/" + asciicastId + '/iframe?' + params(container, script); iframe.id = "asciicast-iframe-" + asciicastId; iframe.name = "asciicast-iframe-" + asciicastId; iframe.scrolling = "no"; @@ -68,8 +82,8 @@ container.appendChild(iframe); function receiveSize(e) { - var name = e.data[0]; - var data = e.data[1]; + const name = e.data[0]; + const data = e.data[1]; if (e.origin === apiHost && e.source === iframe.contentWindow && name === 'resize') { iframe.style.height = '' + data.height + 'px'; @@ -77,10 +91,11 @@ } window.addEventListener("message", receiveSize, false); - - script.dataset.player = container; + script.dataset.initialized = '1'; } - var scripts = document.querySelectorAll("script[id^='asciicast-']"); - [].forEach.call(scripts, insertPlayer); + [].forEach.call( + document.querySelectorAll("script[id^='asciicast-']"), + insertPlayer + ); })(); diff --git a/config/config.exs b/config/config.exs index be05879ee..e5a2cda38 100644 --- a/config/config.exs +++ b/config/config.exs @@ -12,13 +12,19 @@ config :asciinema, config :asciinema, Asciinema.Repo, migration_timestamps: [type: :naive_datetime_usec] -# Configures the endpoint +# Configures the public endpoint 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 the admin endpoint +config :asciinema, AsciinemaWeb.Admin.Endpoint, + url: [host: "localhost"], + live_view: [signing_salt: "F3BMP7k9SZ-Y2SMJ"], + pubsub_server: Asciinema.PubSub + # Configures Elixir's Logger config :logger, :console, format: "$time $metadata[$level] $message\n", @@ -45,6 +51,8 @@ config :asciinema, Asciinema.FileStore.Local, path: "uploads/" config :asciinema, Asciinema.FileCache, path: "cache/" +config :asciinema, Asciinema.Emails.Mailer, adapter: Bamboo.LocalAdapter + config :asciinema, :png_generator, Asciinema.PngGenerator.Rsvg config :asciinema, Asciinema.PngGenerator.Rsvg, diff --git a/config/dev.exs b/config/dev.exs index beb48699d..1ad020445 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -38,6 +38,13 @@ config :asciinema, AsciinemaWeb.Endpoint, ] ] +config :asciinema, AsciinemaWeb.Admin.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: secret_key_base + config :asciinema, Asciinema.Accounts, secret: secret_key_base # Watch static and templates for browser reloading. @@ -65,8 +72,6 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime -config :asciinema, Asciinema.Emails.Mailer, adapter: Bamboo.LocalAdapter - config :asciinema, Asciinema.Telemetry, enabled: false # Import custom config. diff --git a/config/prod.exs b/config/prod.exs index 2a4076841..9b4fa7d1b 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,16 +20,16 @@ config :asciinema, AsciinemaWeb.Endpoint, ], cache_static_manifest: "priv/static/cache_manifest.json" +config :asciinema, AsciinemaWeb.Admin.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + check_origin: false + # Do not print debug messages in production config :logger, level: :info config :asciinema, Asciinema.Repo, - pool_size: 20, + pool_size: 10, ssl: false -config :asciinema, Asciinema.Emails.Mailer, - adapter: Bamboo.SMTPAdapter, - server: "smtp", - port: 25 - +config :asciinema, Asciinema.FileStore.Local, path: "/var/opt/asciinema/uploads" config :asciinema, Asciinema.FileCache, path: "/var/cache/asciinema" diff --git a/config/runtime.exs b/config/runtime.exs index 26509b4aa..a303dcae7 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -10,6 +10,7 @@ env = &System.get_env/1 if env.("PHX_SERVER") do config :asciinema, AsciinemaWeb.Endpoint, server: true + config :asciinema, AsciinemaWeb.Admin.Endpoint, server: true end if config_env() == :prod do @@ -26,6 +27,7 @@ end if config_env() in [:prod, :dev] do if secret_key_base = env.("SECRET_KEY_BASE") do config :asciinema, AsciinemaWeb.Endpoint, secret_key_base: secret_key_base + config :asciinema, AsciinemaWeb.Admin.Endpoint, secret_key_base: secret_key_base config :asciinema, Asciinema.Accounts, secret: secret_key_base end @@ -35,6 +37,17 @@ if config_env() in [:prod, :dev] do if url_scheme = env.("URL_SCHEME") do config :asciinema, AsciinemaWeb.Endpoint, url: [scheme: url_scheme] + + case url_scheme do + "http" -> + config :asciinema, AsciinemaWeb.Endpoint, url: [port: 80] + + "https" -> + config :asciinema, AsciinemaWeb.Endpoint, url: [port: 443] + + _ -> + :ok + end end if url_host = env.("URL_HOST") do @@ -50,39 +63,74 @@ if config_env() in [:prod, :dev] do config :asciinema, AsciinemaWeb.Endpoint, url: [port: String.to_integer(url_port)] end + if env.("ADMIN_BIND_ALL") do + config :asciinema, AsciinemaWeb.Admin.Endpoint, http: [ip: {0, 0, 0, 0}] + end + + if port = env.("ADMIN_PORT") do + config :asciinema, AsciinemaWeb.Admin.Endpoint, http: [port: String.to_integer(port)] + end + + if url_scheme = env.("ADMIN_URL_SCHEME") do + config :asciinema, AsciinemaWeb.Admin.Endpoint, url: [scheme: url_scheme] + end + + if url_host = env.("ADMIN_URL_HOST") do + config :asciinema, AsciinemaWeb.Admin.Endpoint, url: [host: url_host] + end + + if url_port = env.("ADMIN_URL_PORT") do + config :asciinema, AsciinemaWeb.Admin.Endpoint, url: [port: String.to_integer(url_port)] + end + if ip_limit = env.("IP_RATE_LIMIT") do config :asciinema, AsciinemaWeb.PlugAttack, ip_limit: String.to_integer(ip_limit), ip_period: String.to_integer(env.("IP_RATE_PERIOD") || "1") * 1_000 end - config :ex_aws, region: {:system, "AWS_REGION"} + cache_path = env.("CACHE_PATH") - file_cache_path = env.("FILE_CACHE_PATH") - - if file_cache_path do - config :asciinema, Asciinema.FileCache, path: file_cache_path + if cache_path do + config :asciinema, Asciinema.FileCache, path: cache_path end - if env.("S3_BUCKET") do + if bucket = env.("S3_BUCKET") do + config :asciinema, Asciinema.FileStore.S3, + bucket: bucket, + path: "uploads/", + proxy: !!env.("S3_PROXY_ENABLED") + config :asciinema, :file_store, Asciinema.FileStore.Cached config :asciinema, Asciinema.FileStore.Cached, remote_store: Asciinema.FileStore.S3, cache_store: Asciinema.FileStore.Local - config :asciinema, Asciinema.FileStore.S3, - region: env.("S3_REGION") || env.("AWS_REGION"), - bucket: env.("S3_BUCKET"), - path: "uploads/", - proxy: !!env.("S3_PROXY_ENABLED") + config :asciinema, Asciinema.FileStore.Local, + path: Path.join(cache_path || "/var/cache/asciinema", "uploads") config :ex_aws, - access_key_id: [{:system, "AWS_ACCESS_KEY_ID"}, :instance_role], - secret_access_key: [{:system, "AWS_SECRET_ACCESS_KEY"}, :instance_role] - - config :asciinema, Asciinema.FileStore.Local, - path: Path.join(file_cache_path || "/var/cache/asciinema", "uploads") + region: [{:system, "S3_REGION"}, {:system, "AWS_REGION"}], + access_key_id: [ + {:system, "S3_ACCESS_KEY_ID"}, + {:system, "AWS_ACCESS_KEY_ID"}, + :instance_role + ], + secret_access_key: [ + {:system, "S3_SECRET_ACCESS_KEY"}, + {:system, "AWS_SECRET_ACCESS_KEY"}, + :instance_role + ] + + if endpoint = env.("S3_ENDPOINT") do + uri = URI.parse(endpoint) + + config :ex_aws, :s3, + scheme: "#{uri.scheme}://", + host: uri.host, + port: uri.port + end end if db_pool_size = env.("DB_POOL_SIZE") do @@ -93,6 +141,41 @@ if config_env() in [:prod, :dev] do config :asciinema, Asciinema.Repo, socket_options: [:inet6] end + if smtp_host = env.("SMTP_HOST") do + config :asciinema, Asciinema.Emails.Mailer, + adapter: Bamboo.SMTPAdapter, + server: smtp_host, + port: String.to_integer(env.("SMTP_PORT") || "587") + + if username = env.("SMTP_USERNAME") do + config :asciinema, Asciinema.Emails.Mailer, username: username + end + + if password = env.("SMTP_PASSWORD") do + config :asciinema, Asciinema.Emails.Mailer, password: password + end + + if auth = env.("SMTP_AUTH") do + config :asciinema, Asciinema.Emails.Mailer, auth: auth + end + + if tls = env.("SMTP_TLS") do + config :asciinema, Asciinema.Emails.Mailer, tls: tls + end + + if versions = env.("SMTP_ALLOWED_TLS_VERSIONS") do + config :asciinema, Asciinema.Emails.Mailer, allowed_tls_versions: versions + end + + if retries = env.("SMTP_RETRIES") do + config :asciinema, Asciinema.Emails.Mailer, retries: String.to_integer(retries) + end + + if no_mx_lookups = env.("SMTP_NO_MX_LOOKUPS") do + config :asciinema, Asciinema.Emails.Mailer, no_mx_lookups: no_mx_lookups + end + end + if rsvg_pool_size = env.("RSVG_POOL_SIZE") do config :asciinema, Asciinema.PngGenerator.Rsvg, pool_size: String.to_integer(rsvg_pool_size) end @@ -119,7 +202,7 @@ if config_env() in [:prod, :dev] do config :sentry, included_environments: [] end - if id = env.("HOME_ASCIICAST_ID") do - config :asciinema, home_asciicast_id: id + if email = env.("CONTACT_EMAIL_ADDRESS") do + config :asciinema, contact_email_address: email end end diff --git a/lib/asciinema/application.ex b/lib/asciinema/application.ex index fa8604335..e78cf34d0 100644 --- a/lib/asciinema/application.ex +++ b/lib/asciinema/application.ex @@ -36,8 +36,10 @@ defmodule Asciinema.Application do 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 + # Start the public endpoint + AsciinemaWeb.Endpoint, + # Start the admin endpoint + AsciinemaWeb.Admin.Endpoint ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/asciinema/emails.ex b/lib/asciinema/emails.ex index 18d3d983b..e64f3f995 100644 --- a/lib/asciinema/emails.ex +++ b/lib/asciinema/emails.ex @@ -28,4 +28,10 @@ defmodule Asciinema.Emails do :ok end + + def send_email(:test, to) do + to + |> Email.test_email() + |> Mailer.deliver_now!() + end end diff --git a/lib/asciinema/emails/email.ex b/lib/asciinema/emails/email.ex index a62daeb22..696134854 100644 --- a/lib/asciinema/emails/email.ex +++ b/lib/asciinema/emails/email.ex @@ -22,6 +22,15 @@ defmodule Asciinema.Emails.Email do |> render("login.html", login_url: login_url, hostname: hostname) end + def test_email(email_address) do + hostname = instance_hostname() + + base_email() + |> to(email_address) + |> subject("Test email from #{hostname}") + |> text_body("It works!") + end + defp base_email do new_email() |> from({"asciinema", from_address()}) @@ -39,6 +48,6 @@ defmodule Asciinema.Emails.Email do end defp instance_hostname do - System.get_env("URL_HOST") || "asciinema.org" + System.get_env("URL_HOST") || "localhost" end end diff --git a/lib/asciinema/file_store/s3.ex b/lib/asciinema/file_store/s3.ex index b4cb1067a..fe914bbdc 100644 --- a/lib/asciinema/file_store/s3.ex +++ b/lib/asciinema/file_store/s3.ex @@ -11,7 +11,7 @@ defmodule Asciinema.FileStore.S3 do def url(path, query_params) do {:ok, url} = - Config.new(:s3, region: region()) + Config.new(:s3) |> S3.presigned_url(:get, bucket(), base_path() <> path, query_params: query_params) url @@ -104,17 +104,13 @@ defmodule Asciinema.FileStore.S3 do end defp make_request(request) do - ExAws.request(request, region: region()) + ExAws.request(request) end defp config do Application.get_env(:asciinema, __MODULE__) end - defp region do - Keyword.get(config(), :region) - end - defp bucket do Keyword.get(config(), :bucket) end diff --git a/lib/asciinema/telemetry.ex b/lib/asciinema/telemetry.ex index 3d913087c..b8ac7d008 100644 --- a/lib/asciinema/telemetry.ex +++ b/lib/asciinema/telemetry.ex @@ -21,7 +21,7 @@ defmodule Asciinema.Telemetry do @buckets [5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000] - defp metrics do + def metrics do repo_distribution = [ unit: {:native, :millisecond}, tags: [:query, :source], diff --git a/lib/asciinema_web/admin/endpoint.ex b/lib/asciinema_web/admin/endpoint.ex new file mode 100644 index 000000000..b3008f6a2 --- /dev/null +++ b/lib/asciinema_web/admin/endpoint.ex @@ -0,0 +1,33 @@ +defmodule AsciinemaWeb.Admin.Endpoint do + use Phoenix.Endpoint, otp_app: :asciinema + + @session_options [ + store: :cookie, + key: "_asciinema_admin_key", + signing_salt: "qJL+3s0T", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :asciinema + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug AsciinemaWeb.Admin.Router +end diff --git a/lib/asciinema_web/admin/router.ex b/lib/asciinema_web/admin/router.ex new file mode 100644 index 000000000..01adf7048 --- /dev/null +++ b/lib/asciinema_web/admin/router.ex @@ -0,0 +1,19 @@ +defmodule AsciinemaWeb.Admin.Router do + use AsciinemaWeb, :router + import Phoenix.LiveDashboard.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_flash + plug :fetch_live_flash + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + scope "/", AsciinemaWeb.Admin do + pipe_through :browser + + live_dashboard "/dashboard", metrics: Asciinema.Telemetry + end +end diff --git a/lib/asciinema_web/controllers/page_controller.ex b/lib/asciinema_web/controllers/page_controller.ex new file mode 100644 index 000000000..ef1ef4871 --- /dev/null +++ b/lib/asciinema_web/controllers/page_controller.ex @@ -0,0 +1,12 @@ +defmodule AsciinemaWeb.PageController do + use AsciinemaWeb, :controller + + def about(conn, _params) do + render( + conn, + "about.html", + page_title: "About", + contact_email_address: Application.get_env(:asciinema, :contact_email_address) + ) + end +end diff --git a/lib/asciinema_web/endpoint.ex b/lib/asciinema_web/endpoint.ex index b8d1d9ad6..1336c32b0 100644 --- a/lib/asciinema_web/endpoint.ex +++ b/lib/asciinema_web/endpoint.ex @@ -39,6 +39,10 @@ defmodule AsciinemaWeb.Endpoint do plug Phoenix.Ecto.CheckRepoStatus, otp_app: :asciinema end + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + plug Plug.RequestId plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] diff --git a/lib/asciinema_web/router.ex b/lib/asciinema_web/router.ex index 5af0efab1..7cd1fd962 100644 --- a/lib/asciinema_web/router.ex +++ b/lib/asciinema_web/router.ex @@ -97,6 +97,8 @@ defmodule AsciinemaWeb.Router do get "/connect/:api_token", ApiTokenController, :show, as: :connect resources "/api_tokens", ApiTokenController, only: [:delete] + + get "/about", PageController, :about end scope "/api", AsciinemaWeb.Api, as: :api do diff --git a/lib/asciinema_web/templates/layout/_footer.html.heex b/lib/asciinema_web/templates/layout/_footer.html.heex index 83fb61673..afb119a75 100644 --- a/lib/asciinema_web/templates/layout/_footer.html.heex +++ b/lib/asciinema_web/templates/layout/_footer.html.heex @@ -1,10 +1,12 @@