diff --git a/README.md b/README.md index 2766fb4..614eba2 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,9 @@ Contributor references are in [CONTRIBUTING.md](CONTRIBUTING.md#code-and-referen ## Make Core your own +See [private playback](docs/private_playback.md) for per-video access controls, +local MinIO support and deployment adapter integration. + - **Your storage:** use a compatible hosted S3 provider or run your own storage. MinIO is the bundled default, not a requirement. Other providers need matching storage, upload, and public-media configuration; see the diff --git a/assets/js/app.js b/assets/js/app.js index fb5d214..6f7dc7f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -254,10 +254,50 @@ const ComponentLoaderHook = { } await window.__maveComponentImports[src] + this.applyPlaybackSession() } catch (error) { console.error("Failed to load mave components bundle", error) } }, + + destroyed() { + clearTimeout(this.playbackRenewal) + this.playbackDestroyed = true + }, + + applyPlaybackSession() { + const key = `${this.el.dataset.playbackEmbed}:${this.el.dataset.playbackStatus}` + if (!this.el.dataset.playbackEmbed || this.playbackDestroyed) return + + const apply = ({ session, embed }) => { + document.querySelectorAll("mave-player, mave-clip, mave-audio").forEach((element) => { + if (element.embed === embed) element.token = session?.token || "" + }) + } + if (this.playbackKey === key && this.playbackData && + (!this.playbackData.session || this.playbackData.session.expires_at * 1000 > Date.now() + 60000)) { + apply(this.playbackData) + return + } + if (this.playbackPending) return + this.playbackPending = true + this.pushEvent("playback_session", {}, (data) => { + this.playbackPending = false + if (this.playbackDestroyed) return + if (key !== `${this.el.dataset.playbackEmbed}:${this.el.dataset.playbackStatus}`) { + this.applyPlaybackSession() + return + } + this.playbackKey = key + this.playbackData = data + apply(data) + clearTimeout(this.playbackRenewal) + if (data.session) { + this.playbackRenewal = setTimeout(() => this.applyPlaybackSession(), + Math.max(1000, data.session.expires_at * 1000 - Date.now() - 60000)) + } + }) + }, } const FolderDropHook = { @@ -401,6 +441,14 @@ const ThumbnailPreviewHook = { this.eventsBound = true + this.video.addEventListener("error", () => { + const preferredSrc = this.el.dataset.previewSrc + if (this.currentSource === preferredSrc && this.el.dataset.fallbackSrc !== preferredSrc) { + this.failedPreferredSrc = preferredSrc + this.loadVideoSource() + } + }) + this.scrubArea.addEventListener("mousedown", (event) => { this.scrubStart(event) }) @@ -438,7 +486,10 @@ const ThumbnailPreviewHook = { const preferredSrc = this.el.dataset.previewSrc || "" const fallbackSrc = this.el.dataset.fallbackSrc || "" - const nextSrc = this.selectPlayableSource(preferredSrc, fallbackSrc) + const nextSrc = this.selectPlayableSource( + preferredSrc === this.failedPreferredSrc ? "" : preferredSrc, + fallbackSrc, + ) if (!nextSrc || this.currentSource === nextSrc) { return @@ -458,7 +509,7 @@ const ThumbnailPreviewHook = { }, selectPlayableSource(preferredSrc, fallbackSrc) { - if (preferredSrc && !preferredSrc.endsWith(".m3u8")) { + if (preferredSrc && !new URL(preferredSrc, document.baseURI).pathname.endsWith(".m3u8")) { return preferredSrc } diff --git a/assets/test/thumbnail_preview.test.mjs b/assets/test/thumbnail_preview.test.mjs new file mode 100644 index 0000000..ce9c6c3 --- /dev/null +++ b/assets/test/thumbnail_preview.test.mjs @@ -0,0 +1,49 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const source = readFileSync(new URL('../js/app.js', import.meta.url), 'utf8') +const hookSource = source.slice( + source.indexOf('const ThumbnailPreviewHook = {'), + source.indexOf('const AutoOpenFilePickerHook = {'), +) +const hook = vm.runInNewContext(`${hookSource}; ThumbnailPreviewHook`, { + URL, + document: { baseURI: 'https://dashboard.example/' }, + window: { addEventListener() {} }, +}) +const playlist = 'https://media.example/playlist.m3u8?token=example' +const original = 'https://media.example/original?signature=example' + +test('signed playlists use the original when native HLS is unavailable', () => { + for (const supported of [false, true]) { + const context = { video: { canPlayType: () => supported ? 'maybe' : '' } } + assert.equal(hook.selectPlayableSource.call(context, playlist, original), supported ? playlist : original) + assert.equal(hook.selectPlayableSource.call(context, original, ''), original) + } +}) + +test('failed native HLS falls back once and stays on the original during updates', () => { + let onError + let loads = 0 + const context = { + ...hook, + el: { dataset: { previewSrc: playlist, fallbackSrc: original } }, + video: { + canPlayType: () => 'maybe', + addEventListener(event, handler) { if (event === 'error') onError = handler }, + load() { loads++ }, + }, + scrubArea: { addEventListener() {} }, + videoContainer: {}, + } + context.bindEvents() + context.loadVideoSource() + assert.equal(context.video.src, playlist) + onError() + assert.equal(context.video.src, original) + context.loadVideoSource() + onError() + assert.equal(loads, 2) +}) diff --git a/config/dev.exs b/config/dev.exs index 047505e..a496d12 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -1,5 +1,13 @@ import Config +config :mave_core, :playback_adapter, MaveCore.Playback.Minio +config :mave_core, :bucket_cors_syncer, MaveCore.Playback.Minio +config :mave_core, :playback_storage_adapter, MaveCore.Playback.Minio + +config :mave_core, + :playback_public_storage_endpoint, + System.get_env("MAVE_PLAYBACK_STORAGE_ENDPOINT", "http://localhost:9000") + # Configure your database config :mave_core, MaveCore.Repo, username: System.get_env("POSTGRES_USER", "postgres"), diff --git a/config/runtime.exs b/config/runtime.exs index 199f169..018ef35 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -139,6 +139,21 @@ if external_uri do |> String.trim_trailing("/") end +# Optional per-space media origin; an unset value retains the API playback route. +if playback_origin = System.get_env("MAVE_PLAYBACK_ORIGIN") do + uri = playback_origin |> String.trim() |> URI.parse() + + unless uri.scheme in ["http", "https"] and is_binary(uri.host) and uri.host != "" and + uri.path in [nil, "", "/"] and is_nil(uri.query) and is_nil(uri.fragment) and + is_nil(uri.userinfo) do + raise "MAVE_PLAYBACK_ORIGIN must be an HTTP(S) origin without a path or credentials" + end + + config :mave_core, + :playback_origin, + URI.to_string(%{uri | host: String.downcase(uri.host), path: nil}) +end + if public_cdn_host = System.get_env("MAVE_PUBLIC_CDN_HOST") do trimmed = String.trim(public_cdn_host) diff --git a/deploy/self-hosted/compose.development.yml b/deploy/self-hosted/compose.development.yml index e065e21..d0f0f0f 100644 --- a/deploy/self-hosted/compose.development.yml +++ b/deploy/self-hosted/compose.development.yml @@ -16,6 +16,7 @@ x-development: &development POSTGRES_USER: mave_core CLICKHOUSE_HOST: clickhouse MAVE_MEDIA_SANDBOX: required + MAVE_PLAYBACK_STORAGE_ENDPOINT: http://localhost:${MAVE_MINIO_API_PORT:-9000} volumes: - ../..:/app - dev_deps:/app/deps @@ -23,6 +24,9 @@ x-development: &development - dev_node_modules:/app/assets/node_modules services: + minio: + ports: + - "127.0.0.1:${MAVE_MINIO_API_PORT:-9000}:9000" welcome: environment: MAVE_DEVELOPMENT: "true" diff --git a/docs/private_playback.md b/docs/private_playback.md new file mode 100644 index 0000000..c7ab81c --- /dev/null +++ b/docs/private_playback.md @@ -0,0 +1,63 @@ +# Private playback + +Videos are public by default. A configured playback adapter enables the dashboard +access menu and the API `visibility` field (`"public"` or `"private"`). The response +reports the last visibility confirmed by storage synchronization. Changing access +runs in the background: `PUT /api/v1/videos/:id` returns `202 Accepted` while the +change is pending. Poll `GET /api/v1/videos/:id` until it returns `200 OK` with the +requested visibility; it also returns `202` while pending, including on retries. +Creation, ordinary updates and already confirmed visibility retain `200 OK`. + +## Using tokens + +Create or update a video with `visibility: "private"` using a write-capable API key. +Sign playback JWTs on your server with a read-only space API key, using the complete +key copied from the dashboard. `sub` scopes access to a space, collection or video. +Use `exp` to set an expiry in Unix seconds; `iat` and `aud` are not required. +Without `exp`, the JWT remains valid until its signing key is revoked. + +```html + +``` + +For a custom player, use +`/api/v1/playback/media/{public_embed_id}/playlist.m3u8?token=YOUR_PLAYBACK_TOKEN`, +or the optional media hostname below. Other media files use the same base path. +Media routes also accept `Authorization: Bearer YOUR_PLAYBACK_TOKEN`. +Playlists, captions, storyboards and images use the same authorization. Child +playlists retain the JWT; media files use temporary signed storage URLs, valid +for at most 24 hours and no longer than the token's remaining lifetime. + +Authorize viewers before issuing tokens. Keep API keys on the server and exclude +tokens and signed URLs from logs. Issued storage URLs remain usable until expiry. + +Dashboard previews receive an automatic 24-hour token and refresh it before expiry. +Overview thumbnails also use signed URLs, with five minutes of private browser +caching. Private iframe snippets are omitted because they cannot pass a token. + +## Storage adapters + +Configure `:mave_core, :playback_adapter` with a module implementing +`MaveCore.Playback.Adapter`. It must protect existing and future objects, purge +cached public copies and verify access before reporting success. Domain/CORS +synchronization must preserve these policies. The optional `token_endpoint/0` +callback selects the Phoenix endpoint used to sign dashboard tokens. + +Development includes a MinIO adapter without a CDN. The standalone development +Compose stack exposes its API on loopback port 9000 (`MAVE_MINIO_API_PORT` can +change this). For other local setups, set `MAVE_PLAYBACK_STORAGE_ENDPOINT` to the +browser-reachable MinIO endpoint; signatures must use that exact endpoint. + +## Optional media hostname + +Set `MAVE_PLAYBACK_ORIGIN=https://signed.example.com` to serve private media at: + +```text +https://space-{space_hash}.signed.example.com/{embed_hash}/playlist.m3u8?token=JWT +``` + +Point wildcard DNS at the application and use TLS for `*.signed.example.com`. +This host serves only media GET/HEAD and CORS preflight requests. The API’s existing +`poster_image` field and component `cdn.playback_endpoint` settings use it automatically. Leaving it unset +uses `/api/v1/playback/media/{public_embed_id}/...`; those routes remain available +when a media hostname is configured. Do not enable shared caching on these routes. diff --git a/lib/mave_core/embeds.ex b/lib/mave_core/embeds.ex index 09a18fc..a27b4bc 100644 --- a/lib/mave_core/embeds.ex +++ b/lib/mave_core/embeds.ex @@ -16,6 +16,7 @@ defmodule MaveCore.Embeds do alias MaveCore.LegacyShortUUID alias MaveCore.Media.RenditionSizing alias MaveCore.Media.Storage + alias MaveCore.Playback.Media, as: PlaybackMedia alias MaveCore.Repo alias MaveCore.Spaces alias MaveCore.Spaces.Space @@ -83,12 +84,14 @@ defmodule MaveCore.Embeds do end name = Map.get(attrs, :name) || Map.get(attrs, "name") + visibility = Map.get(attrs, :visibility, Map.get(attrs, "visibility", :public)) parent_folder_id = parent_folder_id(attrs) Repo.transaction(fn -> with {:ok, current_space} <- lock_active_space(space.id), :ok <- UsageLimits.can_create_video_embed?(current_space), + :ok <- MaveCore.Playback.validate_visibility(current_space, visibility), {:ok, asset} <- %Asset{} |> Asset.changeset(%{space_id: current_space.id, name: name}) @@ -104,6 +107,7 @@ defmodule MaveCore.Embeds do archived: archived }) |> Repo.insert(), + {:ok, embed} <- MaveCore.Playback.initialize_visibility(embed, visibility), :ok <- maybe_attach_to_parent(embed, current_space.id, parent_folder_id) do preload_embed(embed) else @@ -1435,9 +1439,13 @@ defmodule MaveCore.Embeds do cache_buster ) when is_binary(space_hash) and is_binary(embed_hash) do - space - |> snippet_object_url(embed, "thumbnail.jpg") - |> with_cache_buster(cache_buster) + url = snippet_object_url(space, embed, "thumbnail.jpg") + + if is_binary(url) and MaveCore.Playback.protected?(embed) do + PlaybackMedia.dashboard_thumbnail_url(space, embed, cache_buster) + else + with_cache_buster(url, cache_buster) + end end defp thumbnail_url(%Space{}, %Embed{}, _cache_buster) do @@ -1574,13 +1582,13 @@ defmodule MaveCore.Embeds do "#{embed.hash}/original" end - SettingsSerializer.storage_object_url(bucket, key) + preview_storage_url(space, embed, bucket, key) end defp hls_preview_src( %Video{id: video_id}, %Space{} = space, - %Embed{}, + %Embed{} = embed, flow_run ) do bucket = Storage.bucket_for_space(space.hash, space.region) @@ -1599,15 +1607,15 @@ defmodule MaveCore.Embeds do |> Repo.one() if is_binary(stable_key) and stable_key != "" do - SettingsSerializer.storage_object_url(bucket, stable_key) + preview_storage_url(space, embed, bucket, stable_key) else - processing_hls_preview_src(space, flow_run) + processing_hls_preview_src(space, embed, flow_run) end end defp hls_preview_src(_video, _space, _embed, _flow_run), do: nil - defp processing_hls_preview_src(%Space{} = space, %Run{status: status} = run) + defp processing_hls_preview_src(%Space{} = space, %Embed{} = embed, %Run{status: status} = run) when status in ["queued", "running"] do bucket = Storage.bucket_for_space(space.hash, space.region) @@ -1619,14 +1627,14 @@ defmodule MaveCore.Embeds do output: %{"playlist_key" => key, "rendition" => %{"size" => "sd"}} } when is_binary(key) and key != "" -> - SettingsSerializer.storage_object_url(bucket, key) + preview_storage_url(space, embed, bucket, key) _ -> nil end) end - defp processing_hls_preview_src(_space, _flow_run), do: nil + defp processing_hls_preview_src(_space, _embed, _flow_run), do: nil defp preview_frame_srcs(%Space{} = space, %Embed{} = embed, duration, version) do frame_count = 6 @@ -1643,20 +1651,29 @@ defmodule MaveCore.Embeds do %{ time: timestamp, - src: preview_thumbnail_src(bucket, embed.hash, version, index) + src: preview_thumbnail_src(space, embed, bucket, version, index) } end) end - defp preview_thumbnail_src(bucket, embed_hash, version, index) do + defp preview_thumbnail_src(space, embed, bucket, version, index) do key = if version > 0 do - "#{embed_hash}/v#{version}/thumbnail_#{index}.jpg" + "#{embed.hash}/v#{version}/thumbnail_#{index}.jpg" else - "#{embed_hash}/thumbnail_#{index}.jpg" + "#{embed.hash}/thumbnail_#{index}.jpg" end - SettingsSerializer.storage_object_url(bucket, key) + preview_storage_url(space, embed, bucket, key) + end + + defp preview_storage_url(space, embed, bucket, key) do + if MaveCore.Playback.protected?(embed) do + path = String.replace_prefix(key, embed.hash <> "/", "") + PlaybackMedia.dashboard_preview_url(space, embed, path) + else + SettingsSerializer.storage_object_url(bucket, key) + end end defp resolution_label(video, audio_only? \\ false) diff --git a/lib/mave_core/embeds/embed.ex b/lib/mave_core/embeds/embed.ex index a1959f5..1bbf4ea 100644 --- a/lib/mave_core/embeds/embed.ex +++ b/lib/mave_core/embeds/embed.ex @@ -16,6 +16,13 @@ defmodule MaveCore.Embeds.Embed do field :external_url, :string field :archived, :boolean, default: false field :replacing, :boolean, default: false + # Last visibility confirmed by storage synchronization. + field :playback_visibility, Ecto.Enum, values: [:public, :private], default: :public + + field :playback_status, Ecto.Enum, + values: [:public, :protecting, :private, :publishing], + default: :public + field :deleted_at, :utc_datetime_usec belongs_to :space, Space diff --git a/lib/mave_core/embeds/settings_serializer.ex b/lib/mave_core/embeds/settings_serializer.ex index b232f49..5d12691 100644 --- a/lib/mave_core/embeds/settings_serializer.ex +++ b/lib/mave_core/embeds/settings_serializer.ex @@ -4,6 +4,7 @@ defmodule MaveCore.Embeds.SettingsSerializer do alias MaveCore.Assets.Video alias MaveCore.Embeds.{Embed, EmbedSettings} alias MaveCore.Media.Storage + alias MaveCore.Playback.URLs alias MaveCore.Spaces.Space @upload_bucket "mave-upload" @@ -47,7 +48,10 @@ defmodule MaveCore.Embeds.SettingsSerializer do def component_runtime_config do %{ "api" => %{"endpoint" => api_endpoint()}, - "cdn" => %{"endpoint" => cdn_component_endpoint()}, + "cdn" => %{ + "endpoint" => cdn_component_endpoint(), + "playback_endpoint" => URLs.endpoint(api_endpoint()) + }, "metrics" => %{"endpoint" => metrics_endpoint()}, "upload" => %{ "endpoint" => upload_endpoint(), @@ -60,7 +64,11 @@ defmodule MaveCore.Embeds.SettingsSerializer do def default_component_runtime_config do %{ "api" => %{"endpoint" => "https://api.mave.io/api/v1"}, - "cdn" => %{"endpoint" => @default_component_cdn_endpoint}, + "cdn" => %{ + "endpoint" => @default_component_cdn_endpoint, + "playback_endpoint" => + "https://space-${this.spaceId}.signed.video-dns.com/${this.embedId}" + }, "metrics" => %{"endpoint" => "https://metrics.video-dns.com/v1/events"}, "upload" => %{ "endpoint" => "https://upload.mave.io/files", @@ -577,7 +585,7 @@ defmodule MaveCore.Embeds.SettingsSerializer do when cdn_endpoint in @default_component_cdn_endpoint_aliases do default = default_component_runtime_config() - Map.put(config, "cdn", default["cdn"]) == default + put_in(config, ["cdn", "endpoint"], default["cdn"]["endpoint"]) == default end defp default_component_runtime_config?(_config), do: false diff --git a/lib/mave_core/media/storage.ex b/lib/mave_core/media/storage.ex index e2dac8c..12a3890 100644 --- a/lib/mave_core/media/storage.ex +++ b/lib/mave_core/media/storage.ex @@ -43,6 +43,7 @@ defmodule MaveCore.Media.Storage do @multipart_part_request_timeout_ms 5 * 60 * 1000 @direct_upload_part_size 16 * 1024 * 1024 @direct_upload_url_expiry_seconds 2 * 60 * 60 + @direct_download_url_max_expiry_seconds 24 * 60 * 60 @download_to_file_request_timeout_ms 30 * 60 * 1000 @object_prefix_max_bytes 1_048_576 @copy_tmp_root "mave-storage-copy" @@ -588,7 +589,9 @@ defmodule MaveCore.Media.Storage do bucket: bucket, key: path, method: :get, - expires: min(expires, @direct_upload_url_expiry_seconds) + datetime: Keyword.get(opts, :datetime, DateTime.utc_now()), + query: Keyword.get(opts, :query_params, []), + expires: min(expires, @direct_download_url_max_expiry_seconds) )} end rescue @@ -1673,7 +1676,8 @@ defmodule MaveCore.Media.Storage do |> Map.put("Statement", statements) end - defp get_bucket_access_policy(bucket, storage_profile) do + @doc "Reads the current bucket policy without changing access." + def get_bucket_access_policy(bucket, storage_profile) do case Req.get(build_service_req(storage_profile, bucket), url: "/#{bucket}?policy", raw: true) do {:ok, %{status: status, body: body}} when status in 200..299 -> decode_bucket_policy(body) @@ -1703,7 +1707,8 @@ defmodule MaveCore.Media.Storage do defp decode_bucket_policy(_policy), do: {:error, :invalid_bucket_policy} - defp put_bucket_policy_document(bucket, policy, storage_profile) do + @doc "Writes a complete bucket policy supplied by a deployment storage adapter." + def put_bucket_policy_document(bucket, policy, storage_profile) do case Req.put(build_service_req(storage_profile, bucket), url: "/#{bucket}?policy", body: Jason.encode_to_iodata!(policy), diff --git a/lib/mave_core/playback.ex b/lib/mave_core/playback.ex new file mode 100644 index 0000000..9d6b149 --- /dev/null +++ b/lib/mave_core/playback.ex @@ -0,0 +1,185 @@ +defmodule MaveCore.Playback do + @moduledoc """ + Per-embed playback access using API JWTs and internal dashboard tokens. + + Deployments supply a storage adapter implementing `MaveCore.Playback.Adapter`. + """ + import Ecto.Query + + alias MaveCore.Embeds + alias MaveCore.Embeds.Embed + alias MaveCore.PublicApi + alias MaveCore.Repo + alias MaveCore.Spaces + alias MaveCore.Spaces.Space + alias MaveCore.Workers.PlaybackVisibilityWorker + alias MaveCoreWeb.Endpoint + + @salt "media-playback-v1" + @dashboard_ttl 86_400 + + def adapter, do: Application.get_env(:mave_core, :playback_adapter) + + defp token_endpoint do + module = adapter() + + if module && Code.ensure_loaded?(module) && function_exported?(module, :token_endpoint, 0), + do: module.token_endpoint(), + else: Endpoint + end + + def available?(%Space{} = space) do + case adapter() do + nil -> false + module -> module.available?(space) + end + end + + def protected?(%Embed{playback_visibility: visibility, playback_status: status}), + do: visibility == :private or status != :public + + def validate_visibility(_space, visibility) when visibility in [:public, "public"], do: :ok + + def validate_visibility(space, visibility) when visibility in [:private, "private"] do + if available?(space), do: :ok, else: {:error, :playback_unavailable} + end + + def validate_visibility(_space, _visibility), do: {:error, :invalid_visibility} + + # Creation holds the space lock. Finish storage protection before allowing uploads. + def initialize_visibility(embed, visibility) when visibility in [:public, "public"], + do: {:ok, embed} + + def initialize_visibility(embed, visibility) when visibility in [:private, "private"] do + embed = Repo.preload(embed, [:space, :asset]) + + with :ok <- adapter().apply_visibility(embed, :private) do + embed + |> Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update() + end + end + + def request_visibility(%Embed{} = embed, visibility) when visibility in [:public, :private] do + Repo.transaction(fn -> + space = Repo.one!(from(s in Space, where: s.id == ^embed.space_id, lock: "FOR UPDATE")) + current = Repo.get!(Embed, embed.id) + + if current.type != :video or not is_nil(current.deleted_at) or + (visibility == :private and not available?(space)) or is_nil(adapter()) do + Repo.rollback(:playback_unavailable) + end + + if current.playback_status == visibility do + current + else + updated = + current + |> Ecto.Changeset.change( + playback_status: if(visibility == :private, do: :protecting, else: :publishing) + ) + |> Repo.update!() + + %{embed_id: updated.id} + |> PlaybackVisibilityWorker.new() + |> Oban.insert!() + + updated + end + end) + end + + # Call only after dashboard membership authorization; never from a public route. + def dashboard_session(%Embed{} = embed), + do: session(embed, System.system_time(:second) + @dashboard_ttl) + + def authorize(token, %Embed{} = embed) do + case authorize_dashboard(token, embed) do + {:ok, expires_at} -> {:ok, expires_at} + _ -> authorize_jwt(token, embed) + end + end + + defp authorize_jwt(token, embed) do + with {:ok, %{claims: claims, key: key, space: space}} <- Spaces.validate_api_jwt(token, false), + true <- space.id == embed.space_id and is_nil(key.purpose), + true <- is_nil(claims["exp"]) or claims["exp"] > System.system_time(:second), + true <- scope_allows?(space, embed, claims["sub"]), + true <- is_nil(claims["collection"]) or scope_allows?(space, embed, claims["collection"]) do + # Storage URLs remain bounded, even when the customer's JWT has no expiry. + {:ok, + min( + Map.get(claims, "exp", System.system_time(:second) + @dashboard_ttl), + System.system_time(:second) + @dashboard_ttl + )} + else + _ -> {:error, :unauthorized} + end + end + + defp authorize_dashboard(token, %Embed{} = embed) do + with {:ok, %{embed_id: id, space_id: space_id, expires_at: expires_at}} <- + Phoenix.Token.verify(token_endpoint(), @salt, token || "", max_age: @dashboard_ttl), + true <- id == embed.id and space_id == embed.space_id, + true <- expires_at > System.system_time(:second) do + {:ok, expires_at} + else + _ -> {:error, :unauthorized} + end + end + + defp session(embed, expires_at) do + claims = %{embed_id: embed.id, space_id: embed.space_id, expires_at: expires_at} + + %{ + token: Phoenix.Token.sign(token_endpoint(), @salt, claims), + expires_at: expires_at, + media_base_path: "/playback/media/#{embed.id}" + } + end + + defp scope_allows?(space, embed, scope) when is_binary(scope) do + scope in [space.id, space.hash, embed.id, embed.hash, space.hash <> embed.hash] or + PublicApi.jwt_scoped_video_deletable?(space, embed, collection_scope(space, scope)) + end + + defp scope_allows?(_space, _embed, _scope), do: false + + defp collection_scope(space, scope) do + case MaveCore.LegacyShortUUID.cast(scope) do + {:ok, id} -> + Repo.one( + from(e in Embed, + where: + e.id == ^id and e.space_id == ^space.id and e.type == :collection and + is_nil(e.deleted_at) + ) + ) || scope + + _ -> + scope + end + end + + def get_embed(identifier) when is_binary(identifier) and byte_size(identifier) == 15 do + with {:ok, %{space_hash: space_hash, embed_hash: embed_hash}} <- + MaveCore.EmbedId.split(identifier), + %Embed{type: :video} = embed <- Embeds.get_embed_by_hashes(space_hash, embed_hash) do + embed + else + _ -> nil + end + end + + def get_embed(id) do + case Repo.get(Embed, id) do + %Embed{type: :video, deleted_at: nil} = embed -> + Embeds.get_embed_by_hashes(Repo.get!(Space, embed.space_id).hash, embed.hash) + + _ -> + nil + end + rescue + Ecto.Query.CastError -> nil + end +end diff --git a/lib/mave_core/playback/adapter.ex b/lib/mave_core/playback/adapter.ex new file mode 100644 index 0000000..ed41ced --- /dev/null +++ b/lib/mave_core/playback/adapter.ex @@ -0,0 +1,18 @@ +defmodule MaveCore.Playback.Adapter do + @moduledoc """ + Deployment boundary for private-media storage and availability. + + `apply_visibility/2` must protect every existing and future object under the + embed prefix, invalidate cached public copies, and verify unsigned reads are + denied before returning `:ok` for private visibility. Errors are retried and + must never restore public access as a fallback. + + Storage synchronization must preserve this boundary on subsequent uploads, + replacement, settings publication, and domain/CORS updates. + """ + @callback available?(MaveCore.Spaces.Space.t()) :: boolean() + @callback apply_visibility(MaveCore.Embeds.Embed.t(), :public | :private) :: + :ok | {:error, term()} + @callback token_endpoint() :: module() + @optional_callbacks token_endpoint: 0 +end diff --git a/lib/mave_core/playback/embed_code.ex b/lib/mave_core/playback/embed_code.ex new file mode 100644 index 0000000..7730135 --- /dev/null +++ b/lib/mave_core/playback/embed_code.ex @@ -0,0 +1,21 @@ +defmodule MaveCore.Playback.EmbedCode do + @moduledoc false + + def add_token(code, type, public_id, tag) when type in [:script, :clip] do + String.replace( + code, + ~s(<#{tag} embed="#{public_id}"), + ~s(<#{tag} embed="#{public_id}" token="YOUR_PLAYBACK_TOKEN") + ) + end + + def add_token(code, type, _public_id, tag) when type in [:react, :vue] do + token_prop = if type == :react, do: "token={playbackToken}", else: ":token=\"playbackToken\"" + code = String.replace(code, "<#{tag} embed=", "<#{tag} #{token_prop} embed=") + + """ + const playbackToken = "YOUR_PLAYBACK_TOKEN"; + + """ <> code + end +end diff --git a/lib/mave_core/playback/media.ex b/lib/mave_core/playback/media.ex new file mode 100644 index 0000000..51b375b --- /dev/null +++ b/lib/mave_core/playback/media.ex @@ -0,0 +1,257 @@ +defmodule MaveCore.Playback.Media do + @moduledoc false + + alias MaveCore.Embeds.{ManifestPublisher, SettingsSerializer} + alias MaveCore.Media.Storage + alias MaveCore.Playback.URLs + + @max_playlist_bytes 2_000_000 + @thumbnail_cache_seconds 300 + @thumbnail_url_ttl 86_400 + + # Call only for an authorized dashboard listing. The signature covers one image. + def dashboard_thumbnail_url(space, embed, cache_buster) do + query = if cache_buster, do: [{"e", cache_buster}], else: [] + dashboard_asset_url(space, embed, "thumbnail.jpg", query) + end + + # Playlists must pass through the playback endpoint to sign their child URLs. + def dashboard_preview_url(space, embed, path) do + if String.ends_with?(path, ".m3u8") do + session = MaveCore.Playback.dashboard_session(embed) + endpoint = SettingsSerializer.component_runtime_config()["cdn"]["playback_endpoint"] + url = URLs.media_url(endpoint, space.hash, embed.hash, path) + url <> "?" <> URI.encode_query(%{"token" => session.token}) + else + dashboard_asset_url(space, embed, path, []) + end + end + + defp dashboard_asset_url(space, embed, path, query) do + now = System.system_time(:second) + signed_at = now - rem(now, @thumbnail_cache_seconds) + bucket = Storage.bucket_for_space(space.hash, space.region) + query = [{"response-cache-control", "private, max-age=#{@thumbnail_cache_seconds}"} | query] + + case storage().presigned_get_url(bucket, embed.hash <> "/" <> path, space.region, + datetime: DateTime.from_unix!(signed_at), + expires: @thumbnail_url_ttl, + query_params: query + ) do + {:ok, url} -> url + {:error, _reason} -> nil + end + end + + def response(embed, "manifest.json", base, token, _expires_at) do + with {:ok, manifest} <- ManifestPublisher.current(embed) do + {:ok, "application/json", Jason.encode!(rewrite_manifest(manifest, embed, base, token))} + end + end + + def response(embed, path, base, token, expires_at) do + with :ok <- valid_path(path) do + cond do + String.ends_with?(path, ".m3u8") -> playlist(embed, path, base, token, expires_at) + Path.basename(path) == "storyboard.vtt" -> storyboard(embed, path, expires_at) + true -> signed_url(embed, path, expires_at) + end + end + end + + def valid_path(path) when is_binary(path) and byte_size(path) <= 2048 do + segments = String.split(path, "/") + + if Enum.all?(segments, &Regex.match?(~r/\A[A-Za-z0-9_.-]+\z/, &1)) and + Enum.all?(segments, &(&1 not in [".", "..", ""])) do + :ok + else + {:error, :invalid_path} + end + end + + def valid_path(_path), do: {:error, :invalid_path} + + def media_url(base, path, token), + do: base <> "/" <> path <> "?" <> URI.encode_query(%{"token" => token}) + + defp signed_url(embed, path, expires_at) do + ttl = expires_at - System.system_time(:second) + bucket = Storage.bucket_for_space(embed.space.hash, embed.space.region) + + if ttl > 0 do + case storage().presigned_get_url(bucket, embed.hash <> "/" <> path, embed.space.region, + expires: ttl + ) do + {:ok, url} -> {:redirect, url} + {:error, _reason} -> {:error, :storage_unavailable} + end + else + {:error, :expired} + end + end + + defp playlist(embed, path, base, token, expires_at) do + bucket = Storage.bucket_for_space(embed.space.hash, embed.space.region) + key = embed.hash <> "/" <> path + + with {:ok, %{size_bytes: size}} when size <= @max_playlist_bytes <- + storage().object_info(bucket, key, embed.space.region), + {:ok, body} when is_binary(body) and byte_size(body) <= @max_playlist_bytes <- + storage().get(bucket, key, embed.space.region), + {:ok, lines} <- + rewrite_lines(String.split(body, "\n"), embed, path, base, token, expires_at) do + {:ok, "application/vnd.apple.mpegurl", Enum.join(lines, "\n")} + else + _ -> {:error, :playlist_unavailable} + end + end + + defp storyboard(embed, path, expires_at) do + bucket = Storage.bucket_for_space(embed.space.hash, embed.space.region) + key = embed.hash <> "/" <> path + + with {:ok, %{size_bytes: size}} when size <= @max_playlist_bytes <- + storage().object_info(bucket, key, embed.space.region), + {:ok, body} when is_binary(body) and byte_size(body) <= @max_playlist_bytes <- + storage().get(bucket, key, embed.space.region) do + rewrite_storyboard(body, embed, path, expires_at) + else + _ -> {:error, :storyboard_unavailable} + end + end + + defp rewrite_storyboard(body, embed, path, expires_at) do + body + |> String.split("\n") + |> Enum.reduce_while({:ok, []}, fn line, {:ok, lines} -> + case storyboard_line(line, embed, path, expires_at) do + {:ok, rewritten} -> {:cont, {:ok, [rewritten | lines]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, lines} -> {:ok, "text/vtt", lines |> Enum.reverse() |> Enum.join("\n")} + error -> error + end + end + + defp storyboard_line(line, embed, path, expires_at) do + case String.split(String.trim(line), "#xywh=", parts: 2) do + [raw, coordinates] -> + with true <- Regex.match?(~r/\A\d+,\d+,\d+,\d+\z/, coordinates), + {:ok, target} <- resolve_path(raw, embed, Path.dirname(path)), + {:redirect, url} <- signed_url(embed, target, expires_at) do + {:ok, url <> "#xywh=" <> coordinates} + else + _ -> {:error, :invalid_storyboard} + end + + _ -> + {:ok, line} + end + end + + defp rewrite_lines(lines, embed, path, base, token, expires_at) do + Enum.reduce_while(lines, {:ok, []}, fn line, {:ok, acc} -> + case rewrite_line(line, embed, path, base, token, expires_at) do + {:ok, rewritten} -> {:cont, {:ok, [rewritten | acc]}} + error -> {:halt, error} + end + end) + |> case do + {:ok, lines} -> {:ok, Enum.reverse(lines)} + error -> error + end + end + + defp rewrite_line("#" <> _ = line, embed, path, base, token, expires_at) do + matches = Regex.scan(~r/URI="([^"]+)"/, line, capture: :all_but_first) + + Enum.reduce_while(matches, {:ok, line}, fn [raw], {:ok, text} -> + case playlist_url(raw, embed, path, base, token, expires_at) do + {:ok, url} -> {:cont, {:ok, String.replace(text, "URI=\"#{raw}\"", "URI=\"#{url}\"")}} + error -> {:halt, error} + end + end) + end + + defp rewrite_line(line, embed, path, base, token, expires_at) do + if String.trim(line) == "", + do: {:ok, line}, + else: playlist_url(String.trim(line), embed, path, base, token, expires_at) + end + + defp playlist_url(raw, embed, path, base, token, expires_at) do + with {:ok, target} <- resolve_path(raw, embed, Path.dirname(path)), + :ok <- valid_path(target) do + if String.ends_with?(target, ".m3u8") do + {:ok, media_url(base, target, token)} + else + playlist_signed_url(embed, target, expires_at) + end + end + end + + defp playlist_signed_url(embed, target, expires_at) do + case signed_url(embed, target, expires_at) do + {:redirect, url} -> {:ok, url} + error -> error + end + end + + def resolve_path(raw, %{hash: hash, space: space}, directory) do + bucket = Storage.bucket_for_space(space.hash, space.region) + prefix = SettingsSerializer.storage_object_url(bucket, hash <> "/") |> URI.parse() + uri = URI.parse(raw) + + # Path-style S3 origins include the bucket before the embed hash. + if is_binary(uri.path) and String.starts_with?(uri.path, prefix.path) do + resolve_path(String.replace_prefix(uri.path, prefix.path, ""), hash, ".") + else + resolve_path(raw, hash, directory) + end + end + + def resolve_path(raw, embed_hash, directory) when is_binary(embed_hash) do + uri = URI.parse(raw) + path = uri.path || "" + prefix = "/#{embed_hash}/" + + target = + cond do + String.starts_with?(path, prefix) -> String.replace_prefix(path, prefix, "") + absolute_path?(uri) -> nil + directory in [".", ""] -> path + true -> Path.join(directory, path) + end + + if is_binary(target) and valid_path(target) == :ok, + do: {:ok, target}, + else: {:error, :invalid_path} + end + + defp absolute_path?(uri), + do: uri.scheme != nil or uri.host != nil or String.starts_with?(uri.path || "", "/") + + defp rewrite_manifest(value, embed, base, token) when is_map(value) do + Map.new(value, fn {key, child} -> {key, rewrite_manifest(child, embed, base, token)} end) + end + + defp rewrite_manifest(value, embed, base, token) when is_list(value), + do: Enum.map(value, &rewrite_manifest(&1, embed, base, token)) + + defp rewrite_manifest(value, embed, base, token) when is_binary(value) do + if String.starts_with?(value, ["https://", "http://", "/#{embed.hash}/"]) do + case resolve_path(value, embed, ".") do + {:ok, path} -> media_url(base, path, token) + _ -> nil + end + else + value + end + end + + defp rewrite_manifest(value, _embed, _base, _token), do: value + defp storage, do: Application.get_env(:mave_core, :playback_storage_adapter, Storage) +end diff --git a/lib/mave_core/playback/minio.ex b/lib/mave_core/playback/minio.ex new file mode 100644 index 0000000..5c0cf5e --- /dev/null +++ b/lib/mave_core/playback/minio.ex @@ -0,0 +1,89 @@ +defmodule MaveCore.Playback.Minio do + @moduledoc "Local MinIO implementation of private playback for development." + @behaviour MaveCore.Playback.Adapter + + alias MaveCore.Media.Storage + alias MaveCore.Playback.StoragePolicy + alias MaveCore.Spaces.Space + + @impl true + def available?(%Space{deleted_at: nil}), + do: local_storage?() + + def available?(_space), do: false + + @impl true + def apply_visibility(embed, visibility) do + if local_storage?(), + do: StoragePolicy.apply_visibility(embed, visibility, __MODULE__), + else: {:error, :local_storage_required} + end + + def sync_space_domain_cors(space) do + if local_storage?(), + do: StoragePolicy.sync_space_domain_cors(space, __MODULE__), + else: {:error, :local_storage_required} + end + + def policy_for(bucket, hashes, _region), do: bucket_policy(bucket, hashes) + def purge(_embed), do: :ok + + def put_cors(bucket, region) do + case Storage.put_bucket_cors(bucket, ["*"], region) do + {:error, {:bucket_cors_failed, 501}} -> :ok + result -> result + end + end + + @doc false + def bucket_policy(bucket, hashes) do + {:ok, + %{ + "Version" => "2012-10-17", + "Statement" => [ + %{ + "Sid" => "PublicMedia", + "Effect" => "Allow", + "Principal" => "*", + "Action" => "s3:GetObject", + "Resource" => + Enum.map( + ["themes/*", "*/player.html"] ++ + Enum.map(Enum.uniq(hashes), &(&1 <> "/*")), + &("arn:aws:s3:::" <> bucket <> "/" <> &1) + ) + } + ] + }} + end + + def object_url(bucket, path), + do: String.trim_trailing(s3()[:endpoint], "/") <> "/" <> bucket <> "/" <> path + + defdelegate get(bucket, path, profile), to: Storage + defdelegate object_info(bucket, path, profile), to: Storage + + # Sign with the browser endpoint itself: rewriting a signed host invalidates + # the signature. Reads from the application still use the internal endpoint. + def presigned_get_url(bucket, path, _profile, opts) do + if local_storage?() do + config = + Keyword.put( + s3(), + :endpoint, + Application.get_env(:mave_core, :playback_public_storage_endpoint, s3()[:endpoint]) + ) + + Storage.presigned_get_url(bucket, path, config, opts) + else + {:error, :local_storage_required} + end + end + + defp local_storage? do + URI.parse(s3()[:endpoint] || "").host in ["localhost", "127.0.0.1", "::1", "cdn", "minio"] and + Application.get_env(:mave_core, :storage_providers, %{}) == %{} + end + + defp s3, do: Application.get_env(:mave_core, :s3, []) +end diff --git a/lib/mave_core/playback/storage_policy.ex b/lib/mave_core/playback/storage_policy.ex new file mode 100644 index 0000000..6fbb3f3 --- /dev/null +++ b/lib/mave_core/playback/storage_policy.ex @@ -0,0 +1,87 @@ +defmodule MaveCore.Playback.StoragePolicy do + @moduledoc false + import Ecto.Query + alias MaveCore.Embeds.Embed + alias MaveCore.Media.Storage + alias MaveCore.Repo + alias MaveCore.Spaces.Space + + def apply_visibility(embed, visibility, backend) do + with :ok <- write_policy(embed.space, {embed.id, visibility}, backend), + :ok <- backend.purge(embed) do + verify_origin(embed, visibility, backend) + end + end + + @doc false + def sync_space_domain_cors(%Space{} = space, backend) do + # Access configuration changes must never republish protected media. + Repo.transaction(fn -> + Repo.one!(from(s in Space, where: s.id == ^space.id, lock: "FOR UPDATE")) + + result = + if Repo.exists?( + from(e in Embed, + where: + e.space_id == ^space.id and + (e.playback_visibility == :private or e.playback_status != :public) + ) + ) do + write_policy(space, nil, backend) + else + Storage.sync_space_domain_cors(space) + end + + case result do + :ok -> :ok + {:error, reason} -> Repo.rollback(reason) + end + end) + |> case do + {:ok, result} -> result + {:error, reason} -> {:error, reason} + end + end + + defp write_policy(space, override, backend) do + bucket = Storage.bucket_for_space(space.hash, space.region) + embeds = Repo.all(from(e in Embed, where: e.space_id == ^space.id and is_nil(e.deleted_at))) + + public_hashes = + Enum.filter(embeds, fn embed -> + case override do + {id, visibility} when id == embed.id -> visibility == :public + _ -> embed.playback_visibility == :public and embed.playback_status == :public + end + end) + |> Enum.map(& &1.hash) + + with {:ok, policy} <- backend.policy_for(bucket, public_hashes, space.region), + :ok <- Storage.put_bucket_policy_document(bucket, policy, space.region) do + backend.put_cors(bucket, space.region) + end + end + + defp verify_origin(embed, visibility, backend) do + bucket = Storage.bucket_for_space(embed.space.hash, embed.space.region) + path = embed.hash <> "/manifest.json" + + url = backend.object_url(bucket, path) + + with {:ok, _info} <- Storage.object_info(bucket, path, embed.space.region), + {:ok, %{status: unsigned}} <- Req.head(url, retry: false, redirect: false) do + if (visibility == :private and unsigned in [401, 403, 404]) or + (visibility == :public and unsigned in 200..299), + do: :ok, + else: {:error, :playback_public_access_not_converged} + else + {:error, :not_found} when visibility == :private -> + if is_nil(Repo.preload(embed, :asset).asset.current_video_id), + do: :ok, + else: {:error, :playback_storage_verification_failed} + + _ -> + {:error, :playback_storage_verification_failed} + end + end +end diff --git a/lib/mave_core/playback/urls.ex b/lib/mave_core/playback/urls.ex new file mode 100644 index 0000000..7732e61 --- /dev/null +++ b/lib/mave_core/playback/urls.ex @@ -0,0 +1,55 @@ +defmodule MaveCore.Playback.URLs do + @moduledoc false + + def origin do + case Application.get_env(:mave_core, :playback_origin) do + value when is_binary(value) and value != "" -> URI.parse(value) + _ -> nil + end + end + + def endpoint(api_endpoint) do + case origin() do + %URI{} = uri -> + URI.to_string(%{ + uri + | host: "space-${this.spaceId}." <> uri.host, + path: "/${this.embedId}" + }) + + nil -> + String.trim_trailing(api_endpoint, "/") <> + "/playback/media/${this.spaceId}${this.embedId}" + end + end + + def media_url(endpoint, space_hash, embed_hash, path) do + endpoint + |> String.replace("${this.spaceId}", space_hash) + |> String.replace("${this.embedId}", embed_hash) + |> Kernel.<>("/" <> path) + end + + def space_hash(host) do + with %URI{host: suffix} <- origin(), + host = String.downcase(host), + true <- String.ends_with?(host, "." <> suffix), + "space-" <> hash <- String.replace_suffix(host, "." <> suffix, ""), + true <- Regex.match?(~r/\A[a-z0-9]{5}\z/, hash) do + {:ok, hash} + else + _ -> :error + end + end + + def playback_host?(host) do + case origin() do + %URI{host: suffix} -> + host = String.downcase(host) + host == suffix or String.ends_with?(host, "." <> suffix) + + nil -> + false + end + end +end diff --git a/lib/mave_core/public_api.ex b/lib/mave_core/public_api.ex index 70e6c3b..abbc9ca 100644 --- a/lib/mave_core/public_api.ex +++ b/lib/mave_core/public_api.ex @@ -9,6 +9,7 @@ defmodule MaveCore.PublicApi do alias MaveCore.Embeds.{Embed, Events, SettingsSerializer} alias MaveCore.Flow alias MaveCore.Languages + alias MaveCore.Playback.URLs alias MaveCore.PublicHttpUrl alias MaveCore.Repo alias MaveCore.Spaces.Space @@ -141,6 +142,7 @@ defmodule MaveCore.PublicApi do if embed.version == 2 do %{ id: public_embed_id(space, embed), + visibility: embed.playback_visibility, name: embed_name(embed), duration: current_video && current_video.duration, width: current_video && current_video.max_width, @@ -157,6 +159,7 @@ defmodule MaveCore.PublicApi do else %{ id: public_embed_id(space, embed), + visibility: embed.playback_visibility, embed_url: embed_url(space, embed), poster_image: poster_image(space, embed), object: "video", @@ -474,7 +477,14 @@ defmodule MaveCore.PublicApi do end defp poster_image(%Space{} = space, %Embed{} = embed) do - SettingsSerializer.preview_poster_url(space, embed, embed.settings) + if MaveCore.Playback.protected?(embed), + do: private_poster_url(space, embed), + else: SettingsSerializer.preview_poster_url(space, embed, embed.settings) + end + + defp private_poster_url(space, embed) do + endpoint = SettingsSerializer.component_runtime_config()["cdn"]["playback_endpoint"] + URLs.media_url(endpoint, space.hash, embed.hash, "thumbnail.jpg") end defp video_renditions(nil), do: [] diff --git a/lib/mave_core/spaces.ex b/lib/mave_core/spaces.ex index b3f9a04..5cf5c9b 100644 --- a/lib/mave_core/spaces.ex +++ b/lib/mave_core/spaces.ex @@ -776,14 +776,24 @@ defmodule MaveCore.Spaces do true -> case LegacyShortUUID.cast(sub) do - {:ok, id} -> list_space_by_id(id) - :error -> [] + {:ok, id} -> + list_space_by_scope_id(id) + + :error -> + [] end end end defp jwt_space_candidates(_sub), do: [] + defp list_space_by_scope_id(id) do + case list_space_by_id(id) do + [] -> list_space_by_embed_id(id) + spaces -> spaces + end + end + defp list_spaces_by_hash(hash) when is_binary(hash) do normalized_space_hash = normalize_space_hash(hash) @@ -800,6 +810,16 @@ defmodule MaveCore.Spaces do end end + defp list_space_by_embed_id(id) do + from(s in Space, + join: e in MaveCore.Embeds.Embed, + on: e.space_id == s.id, + where: e.id == ^id and is_nil(e.deleted_at) and is_nil(s.deleted_at), + select: s + ) + |> Repo.all() + end + defp matching_jwt_key(%Space{} = space, jwt_string) do space |> list_keys() diff --git a/lib/mave_core/uploads.ex b/lib/mave_core/uploads.ex index 8da23b1..6e2a025 100644 --- a/lib/mave_core/uploads.ex +++ b/lib/mave_core/uploads.ex @@ -247,9 +247,9 @@ defmodule MaveCore.Uploads do defp handle_standard_upload(scope, bucket, key, metadata, upload) do source_region = present(metadata, "source_region") || upload_config(:source_region) - with :ok <- publish_completed_upload(bucket, key, source_region), + with {:ok, upload_embed} <- resolve_standard_upload_embed(scope, metadata), + :ok <- maybe_publish_completed_upload(upload_embed, bucket, key, source_region), {:ok, source_url} <- resolve_source_url(metadata, bucket, key), - {:ok, upload_embed} <- resolve_standard_upload_embed(scope, metadata), %Space{hash: space_hash} <- upload_embed.space, embed_hash <- upload_embed.hash, {:ok, embed} <- begin_video_upload(space_hash, embed_hash, metadata, upload, source_url), @@ -294,6 +294,12 @@ defmodule MaveCore.Uploads do end end + defp maybe_publish_completed_upload(embed, bucket, key, region) do + if MaveCore.Playback.protected?(embed), + do: :ok, + else: publish_completed_upload(bucket, key, region) + end + defp begin_video_upload(space_hash, embed_hash, metadata, upload, source_url) do Embeds.begin_video_upload(space_hash, embed_hash, %{ "title" => present(metadata, "title"), @@ -681,6 +687,7 @@ defmodule MaveCore.Uploads do defp build_run_input(upload, metadata, bucket, key, source_url, space_hash, embed_hash, embed) do space = upload_space(embed, space_hash) + {ffmpeg_url, public_url} = public_upload_urls(embed, bucket, key) region = case space do @@ -696,8 +703,8 @@ defmodule MaveCore.Uploads do "version" => parse_integer(present(metadata, "version"), Embeds.current_video_version(embed)), "source_url" => source_url, - "upload_ffmpeg_input_url" => upload_ffmpeg_input_url(bucket, key), - "upload_public_url" => Storage.upload_public_object_url(key), + "upload_ffmpeg_input_url" => ffmpeg_url, + "upload_public_url" => public_url, "source_bucket" => bucket, "source_key" => key, "source_region" => present(metadata, "source_region") || upload_config(:source_region), @@ -728,6 +735,12 @@ defmodule MaveCore.Uploads do build_storage_url(base_url, bucket, key) end + defp public_upload_urls(embed, bucket, key) do + if MaveCore.Playback.protected?(embed), + do: {nil, nil}, + else: {upload_ffmpeg_input_url(bucket, key), Storage.upload_public_object_url(key)} + end + defp upload_ffmpeg_input_url(bucket, key) do case Storage.ffmpeg_input_url(bucket, key, upload_config(:source_region)) do {:ok, url} -> url diff --git a/lib/mave_core/workers/playback_visibility_worker.ex b/lib/mave_core/workers/playback_visibility_worker.ex new file mode 100644 index 0000000..43b81dd --- /dev/null +++ b/lib/mave_core/workers/playback_visibility_worker.ex @@ -0,0 +1,52 @@ +defmodule MaveCore.Workers.PlaybackVisibilityWorker do + @moduledoc false + use Oban.Worker, queue: :flow_low, max_attempts: 10 + + import Ecto.Query + alias MaveCore.Embeds.Embed + alias MaveCore.Embeds.Events + alias MaveCore.Playback + alias MaveCore.Repo + alias MaveCore.Spaces.Space + + @impl true + def perform(%Oban.Job{args: %{"embed_id" => id}}) do + case Repo.get(Embed, id) do + %Embed{deleted_at: nil} = embed -> synchronize(embed) + _ -> :ok + end + end + + defp synchronize(embed) do + Repo.transaction(fn -> + Repo.one!(from(s in Space, where: s.id == ^embed.space_id, lock: "FOR UPDATE")) + current = Repo.get!(Embed, embed.id) |> Repo.preload(:space) + + apply_visibility(current, Playback.adapter()) + end) + |> case do + {:ok, updated} -> + Events.broadcast_updated(updated.space_id, updated.id) + :ok + + {:error, reason} -> + {:error, reason} + end + end + + defp apply_visibility(_embed, nil), do: Repo.rollback(:playback_adapter_missing) + + defp apply_visibility(embed, adapter) do + visibility = if embed.playback_status in [:private, :protecting], do: :private, else: :public + + case adapter.apply_visibility(embed, visibility) do + :ok -> + embed + |> Ecto.Changeset.change(playback_visibility: visibility, playback_status: visibility) + |> Repo.update!() + + {:error, _reason} -> + Repo.rollback(:playback_storage_sync_failed) + end + end +end diff --git a/lib/mave_core_web/browser_security.ex b/lib/mave_core_web/browser_security.ex index c02b4e1..98b91eb 100644 --- a/lib/mave_core_web/browser_security.ex +++ b/lib/mave_core_web/browser_security.ex @@ -1,5 +1,6 @@ defmodule MaveCoreWeb.BrowserSecurity do @moduledoc false + alias MaveCore.Playback.URLs def init(opts), do: opts @@ -50,6 +51,8 @@ defmodule MaveCoreWeb.BrowserSecurity do [ Application.get_env(:mave_core, :domain), Application.get_env(:mave_core, :public_cdn_base_url), + Application.get_env(:mave_core, :playback_public_storage_endpoint), + playback_origin(), Application.get_env(:mave_core, :image_base_url), Keyword.get(upload, :endpoint), Keyword.get(upload, :public_base_url) @@ -74,4 +77,11 @@ defmodule MaveCoreWeb.BrowserSecurity do end defp extract_origin(_), do: [] + + defp playback_origin do + case URLs.origin() do + %URI{} = uri -> URI.to_string(%{uri | host: "*." <> uri.host}) + nil -> nil + end + end end diff --git a/lib/mave_core_web/components/dashboard_components/buttons.ex b/lib/mave_core_web/components/dashboard_components/buttons.ex index 8d27aef..e1050be 100644 --- a/lib/mave_core_web/components/dashboard_components/buttons.ex +++ b/lib/mave_core_web/components/dashboard_components/buttons.ex @@ -227,10 +227,17 @@ defmodule MaveCoreWeb.DashboardComponents.Buttons do !@disabled && "cursor-pointer hover:bg-stone-100" ]} > -
+
<.dropdown_icon name={@icon} />
-
+
{render_slot(@inner_block)}
@@ -255,220 +262,29 @@ defmodule MaveCoreWeb.DashboardComponents.Buttons do end end - # Icons for dropdown items - defp dropdown_icon(%{name: "render"} = assigns) do - ~H""" - - - - - - """ - end - - defp dropdown_icon(%{name: "replace"} = assigns) do - ~H""" - - - - - """ - end - - defp dropdown_icon(%{name: "delete"} = assigns) do - ~H""" - - - - - - - """ - end - - defp dropdown_icon(%{name: "archive"} = assigns) do - ~H""" - - - - - - - """ - end - - defp dropdown_icon(%{name: "move"} = assigns) do - ~H""" - - - - - - - """ - end - - defp dropdown_icon(%{name: "remove"} = assigns) do - ~H""" - - - - - - """ - end - - defp dropdown_icon(%{name: "create"} = assigns) do - ~H""" - - - - - """ - end - - defp dropdown_icon(%{name: "video"} = assigns) do - ~H""" - - - - - """ - end - - defp dropdown_icon(%{name: "folder"} = assigns) do - ~H""" - - - - - """ - end - - defp dropdown_icon(%{name: "hero-" <> _rest} = assigns) do - ~H""" - <.icon name={@name} class="size-5" /> - """ - end - + # Keep menu icons in the same outline family and at the same optical size. defp dropdown_icon(assigns) do - # Fallback for unknown icons + icons = %{ + "render" => "hero-cloud-arrow-down", + "replace" => "hero-arrow-path", + "delete" => "hero-trash", + "archive" => "hero-archive-box", + "move" => "hero-arrows-right-left", + "remove" => "hero-arrow-left", + "create" => "hero-plus", + "video" => "hero-play-circle", + "folder" => "hero-folder" + } + + name = + if String.starts_with?(assigns.name, "hero-"), + do: assigns.name, + else: Map.get(icons, assigns.name, "hero-ellipsis-horizontal-circle") + + assigns = assign(assigns, :name, name) + ~H""" - - - + <.icon name={@name} class="block size-4 shrink-0" /> """ end diff --git a/lib/mave_core_web/components/dashboard_components/playback_access.ex b/lib/mave_core_web/components/dashboard_components/playback_access.ex new file mode 100644 index 0000000..ba0901f --- /dev/null +++ b/lib/mave_core_web/components/dashboard_components/playback_access.ex @@ -0,0 +1,33 @@ +defmodule MaveCoreWeb.DashboardComponents.PlaybackAccess do + @moduledoc false + use Phoenix.Component + use Gettext, backend: MaveCoreWeb.Gettext + import MaveCoreWeb.CoreComponents, only: [icon: 1] + + attr(:embed, :map, required: true) + + def guidance(assigns) do + ~H""" +

+ <.icon name="hero-lock-closed" class="size-4 shrink-0" /> + + {gettext( + "Replace YOUR_PLAYBACK_TOKEN with a temporary JWT signed on your server using a read-only API key from your space. Dashboard previews work automatically." + )} + <.link + href="https://www.mave.io/docs/secure-video-playback/" + target="_blank" + rel="noopener noreferrer" + class="underline underline-offset-2 hover:text-stone-200" + > + {gettext("Secure video playback docs")} + + +

+ """ + end +end diff --git a/lib/mave_core_web/components/dashboard_components/video_detail.ex b/lib/mave_core_web/components/dashboard_components/video_detail.ex index a125223..da5bd1f 100644 --- a/lib/mave_core_web/components/dashboard_components/video_detail.ex +++ b/lib/mave_core_web/components/dashboard_components/video_detail.ex @@ -17,6 +17,7 @@ defmodule MaveCoreWeb.DashboardComponents.VideoDetail do attr :audio_only, :boolean, default: false attr :change_event, :string, default: "change_code_preview" slot :player, required: true + slot :guidance def video_embed_panel(assigns) do ~H""" @@ -31,6 +32,7 @@ defmodule MaveCoreWeb.DashboardComponents.VideoDetail do {render_slot(@player)}
+ {render_slot(@guidance)}
<.video_embed_tab :for={tab <- @tabs} tab={tab} change_event={@change_event} /> diff --git a/lib/mave_core_web/controllers/api/image/image_controller.ex b/lib/mave_core_web/controllers/api/image/image_controller.ex index 75365e6..027d898 100644 --- a/lib/mave_core_web/controllers/api/image/image_controller.ex +++ b/lib/mave_core_web/controllers/api/image/image_controller.ex @@ -49,14 +49,42 @@ defmodule MaveCoreWeb.Api.ImageController do end defp serve_image(conn, space_hash, embed_hash, format) do - with {:ok, time} <- parse_time(conn.query_params["time"]), + with {:ok, conn} <- authorize_image(conn, space_hash, embed_hash), + {:ok, time} <- parse_time(conn.query_params["time"]), {:ok, dimensions} <- parse_dimensions(conn.query_params) do opts = [{:format, format} | dimensions] ImageProcessor.process(space_hash, embed_hash, time, opts) |> respond_to_image_result(conn, format) else - :error -> send_resp(conn, 400, "Invalid image parameters") + {:error, :unauthorized} -> + conn + |> put_resp_header("cache-control", "private, no-store") + |> send_resp(401, "Authorization required") + + :error -> + send_resp(conn, 400, "Invalid image parameters") + end + end + + defp authorize_image(conn, space_hash, embed_hash) do + case MaveCore.Embeds.get_embed_by_hashes(space_hash, embed_hash) do + %MaveCore.Embeds.Embed{} = embed -> + if MaveCore.Playback.protected?(embed) do + authorize_private_image(conn, embed) + else + {:ok, conn} + end + + _ -> + {:ok, conn} + end + end + + defp authorize_private_image(conn, embed) do + case MaveCore.Playback.authorize(conn.query_params["token"], embed) do + {:ok, _expires_at} -> {:ok, put_private(conn, :protected_playback, true)} + _ -> {:error, :unauthorized} end end @@ -93,7 +121,13 @@ defmodule MaveCoreWeb.Api.ImageController do defp respond_to_image_result({:ok, _path, image_data}, conn, format) do conn |> put_resp_header("x-content-type-options", "nosniff") - |> put_resp_header("cache-control", "public, max-age=604800, immutable") + |> put_resp_header( + "cache-control", + if(conn.private[:protected_playback], + do: "private, no-store", + else: "public, max-age=604800, immutable" + ) + ) |> send_download( {:binary, image_data}, filename: "image.#{format}", diff --git a/lib/mave_core_web/controllers/api/legacy_embed_controller.ex b/lib/mave_core_web/controllers/api/legacy_embed_controller.ex index ea8eb25..f19a228 100644 --- a/lib/mave_core_web/controllers/api/legacy_embed_controller.ex +++ b/lib/mave_core_web/controllers/api/legacy_embed_controller.ex @@ -16,6 +16,7 @@ defmodule MaveCoreWeb.Api.LegacyEmbedController do with {:ok, %{space_hash: space_hash, embed_hash: embed_hash}} <- EmbedId.split(String.trim(embed_id || "")), %Embed{type: :video} = embed <- Embeds.get_embed_by_hashes(space_hash, embed_hash), + false <- MaveCore.Playback.protected?(embed), {:ok, manifest} <- ManifestPublisher.current(embed) do json(conn, manifest) else diff --git a/lib/mave_core_web/controllers/api/playback_controller.ex b/lib/mave_core_web/controllers/api/playback_controller.ex new file mode 100644 index 0000000..00869ad --- /dev/null +++ b/lib/mave_core_web/controllers/api/playback_controller.ex @@ -0,0 +1,73 @@ +defmodule MaveCoreWeb.Api.PlaybackController do + use MaveCoreWeb, :controller + + alias MaveCore.Embeds.Embed + alias MaveCore.Playback + alias MaveCore.Playback.Media + alias MaveCoreWeb.Api.ImageController + + def show(conn, %{"id" => id, "path" => path} = params) do + conn = private_response(conn) + token = params["token"] || bearer(conn) + + with %Embed{} = embed <- Playback.get_embed(id), + {:ok, expires_at} <- Playback.authorize(token, embed), + true <- not Playback.protected?(embed) or Playback.available?(embed.space) do + base = String.replace_suffix(conn.request_path, "/" <> Enum.join(path, "/"), "") + + base = + URI.to_string(%URI{ + scheme: Atom.to_string(conn.scheme), + host: conn.host, + port: conn.port, + path: base + }) + + if Enum.join(path, "/") in ["dynamic.jpg", "dynamic.jpeg", "dynamic.webp", "dynamic.avif"] do + ext = path |> List.first() |> Path.extname() + + ImageController.show( + %{conn | query_params: Map.put(conn.query_params, "token", token)}, + %{"mave_id" => embed.space.hash <> embed.hash <> ext} + ) + else + respond_media(conn, Media.response(embed, Enum.join(path, "/"), base, token, expires_at)) + end + else + _ -> send_resp(conn, 401, "Playback authorization required") + end + end + + defp respond_media(conn, result) do + case result do + {:ok, "application/json", body} -> + conn |> put_resp_content_type("application/json") |> send_resp(200, body) + + {:ok, "application/vnd.apple.mpegurl", body} -> + conn |> put_resp_content_type("application/vnd.apple.mpegurl") |> send_resp(200, body) + + {:ok, "text/vtt", body} -> + conn |> put_resp_content_type("text/vtt") |> send_resp(200, body) + + {:redirect, url} -> + redirect(conn, external: url) + + {:error, _reason} -> + send_resp(conn, 404, "Media unavailable") + end + end + + defp bearer(conn) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> token + _ -> "" + end + end + + defp private_response(conn) do + conn + |> put_resp_header("cache-control", "private, no-store") + |> put_resp_header("referrer-policy", "no-referrer") + |> put_resp_header("x-content-type-options", "nosniff") + end +end diff --git a/lib/mave_core_web/controllers/api/videos_controller.ex b/lib/mave_core_web/controllers/api/videos_controller.ex index f98dc8d..e06db8e 100644 --- a/lib/mave_core_web/controllers/api/videos_controller.ex +++ b/lib/mave_core_web/controllers/api/videos_controller.ex @@ -1,7 +1,7 @@ defmodule MaveCoreWeb.Api.VideosController do use MaveCoreWeb, :controller - plug MaveCoreWeb.Plugs.ApiKeyWriteAuth when action in [:create, :update, :delete] + plug(MaveCoreWeb.Plugs.ApiKeyWriteAuth when action in [:create, :update, :delete]) alias MaveCore.Embeds alias MaveCore.PublicApi @@ -41,7 +41,9 @@ defmodule MaveCoreWeb.Api.VideosController do def show(%{assigns: %{current_space: %Space{} = space}} = conn, %{"hash" => hash}) do case PublicApi.get_embed(space, hash) do %Embeds.Embed{type: :video} = embed -> - json(conn, PublicApi.video_response(space, embed)) + conn + |> put_visibility_response_status(embed) + |> json(PublicApi.video_response(space, embed)) nil -> conn |> put_status(:not_found) |> json(%{error: "This video embed does not exist."}) @@ -70,10 +72,13 @@ defmodule MaveCoreWeb.Api.VideosController do def create(%{assigns: %{current_space: %Space{} = space}} = conn, params) do with {:ok, parent_folder_id} <- resolve_parent_folder_id(space, params), + :ok <- + MaveCore.Playback.validate_visibility(space, Map.get(params, "visibility", "public")), :ok <- maybe_validate_input_url(params), {:ok, embed} <- Embeds.create_video_embed(space, %{ name: blank_to_nil(params["name"]), + visibility: Map.get(params, "visibility", "public"), parent_folder_id: parent_folder_id }), :ok <- maybe_start_input_url_upload(space, embed, params) do @@ -91,10 +96,19 @@ defmodule MaveCoreWeb.Api.VideosController do def update(%{assigns: %{current_space: %Space{} = space}} = conn, %{"hash" => hash} = params) do case PublicApi.get_embed(space, hash) do %Embeds.Embed{type: :video} = embed -> - with {:ok, embed} <- maybe_rename(embed, params), - {:ok, embed} <- maybe_move(space, embed, params) do + with :ok <- + MaveCore.Playback.validate_visibility( + space, + Map.get(params, "visibility", "public") + ), + {:ok, embed} <- maybe_rename(embed, params), + {:ok, embed} <- maybe_move(space, embed, params), + {:ok, embed} <- maybe_playback_visibility(embed, params) do embed = PublicApi.get_embed(space, hash) || embed - json(conn, PublicApi.video_response(space, embed)) + + conn + |> maybe_put_visibility_response_status(params, embed) + |> json(PublicApi.video_response(space, embed)) else {:error, error} -> conn |> put_status(:bad_request) |> json(%{error: format_error(error)}) @@ -132,6 +146,26 @@ defmodule MaveCoreWeb.Api.VideosController do defp serialize_embed(space, %Embeds.Embed{} = embed), do: PublicApi.video_response(space, embed) + defp maybe_put_visibility_response_status(conn, %{"visibility" => _}, embed), + do: put_visibility_response_status(conn, embed) + + defp maybe_put_visibility_response_status(conn, _params, _embed), do: conn + + defp put_visibility_response_status(conn, %{playback_status: status}) + when status in [:protecting, :publishing], + do: put_status(conn, :accepted) + + defp put_visibility_response_status(conn, _embed), do: conn + + defp maybe_playback_visibility(embed, %{"visibility" => "private"}), + do: MaveCore.Playback.request_visibility(embed, :private) + + defp maybe_playback_visibility(embed, %{"visibility" => "public"}), + do: MaveCore.Playback.request_visibility(embed, :public) + + defp maybe_playback_visibility(_embed, %{"visibility" => _}), do: {:error, :invalid_visibility} + defp maybe_playback_visibility(embed, _params), do: {:ok, embed} + defp resolve_collection(params, space), do: PublicApi.resolve_collection_id(space, params["collection"]) @@ -203,6 +237,8 @@ defmodule MaveCoreWeb.Api.VideosController do do: "This collection does not seem to be part of your space." defp format_error(:not_found), do: "This video embed does not exist." + defp format_error(:invalid_visibility), do: "visibility must be public or private." + defp format_error(:playback_unavailable), do: "Private playback is unavailable for this space." defp format_error(:embed_limit_reached), do: "This space has reached its video limit." defp format_error(error) when is_binary(error), do: error defp format_error(_error), do: "Could not update this video." diff --git a/lib/mave_core_web/endpoint.ex b/lib/mave_core_web/endpoint.ex index bc97ed8..fcb7f52 100644 --- a/lib/mave_core_web/endpoint.ex +++ b/lib/mave_core_web/endpoint.ex @@ -58,6 +58,7 @@ defmodule MaveCoreWeb.Endpoint do plug Plug.MethodOverride plug Plug.Head + plug MaveCoreWeb.Plugs.PlaybackHost plug Plug.Session, @session_options plug MaveCoreWeb.Router diff --git a/lib/mave_core_web/live/dashboard/videos/show.ex b/lib/mave_core_web/live/dashboard/videos/show.ex index 7d72005..1f9795f 100644 --- a/lib/mave_core_web/live/dashboard/videos/show.ex +++ b/lib/mave_core_web/live/dashboard/videos/show.ex @@ -9,6 +9,7 @@ defmodule MaveCoreWeb.Dashboard.Videos.Show do alias MaveCore.Embeds.Events, as: EmbedEvents alias MaveCore.Embeds.SettingsSerializer alias MaveCore.Flow.Diagnostics + alias MaveCore.Playback.EmbedCode alias MaveCore.Spaces alias MaveCore.Uploads.Token alias MaveCore.UsageLimits @@ -441,6 +442,38 @@ defmodule MaveCoreWeb.Dashboard.Videos.Show do end @impl true + def handle_event( + "playback_session", + _params, + %{assigns: %{resource_embed: %Embed{} = embed}} = socket + ) do + session = + if MaveCore.Playback.protected?(embed), + do: MaveCore.Playback.dashboard_session(embed), + else: nil + + {:reply, %{session: session, embed: socket.assigns.current_space.hash <> embed.hash}, socket} + end + + def handle_event( + "toggle_playback_visibility", + _params, + %{assigns: %{resource_embed: %Embed{} = embed}} = socket + ) do + visibility = if embed.playback_visibility == :private, do: :public, else: :private + + case MaveCore.Playback.request_visibility(embed, visibility) do + {:ok, updated} -> + {:noreply, + socket + |> assign(:resource_embed, updated) + |> put_flash(:info, gettext("Playback access is being updated."))} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, gettext("Playback access could not be updated."))} + end + end + def handle_event("change_code_preview", %{"preview" => preview}, socket) do {:noreply, assign(socket, :code_preview, String.to_existing_atom(preview))} end @@ -1527,9 +1560,9 @@ defmodule MaveCoreWeb.Dashboard.Videos.Show do } end - defp line_numbers(type, video) do + defp line_numbers(type, video, embed) do type - |> snippet_string(video) + |> snippet_string(video, embed) |> String.split("\n") |> Enum.with_index(1) |> Enum.map(&elem(&1, 1)) @@ -1555,7 +1588,9 @@ defmodule MaveCoreWeb.Dashboard.Videos.Show do end) end - defp code_tabs(current, video) do + defp code_tabs(current, video, embed) do + current = effective_code_preview(current, embed) + [ %{name: :script, label: "Player", current: current == :script}, %{name: :clip, label: "Clip", current: current == :clip}, @@ -1563,7 +1598,36 @@ defmodule MaveCoreWeb.Dashboard.Videos.Show do %{name: :react, label: "React", current: current == :react}, %{name: :vue, label: "Vue", current: current == :vue} ] - |> Enum.reject(&(dashboard_player_tag(video) == "mave-audio" and &1.name == :clip)) + |> Enum.reject(fn tab -> + (dashboard_player_tag(video) == "mave-audio" and tab.name == :clip) or + (MaveCore.Playback.protected?(embed) and tab.name == :iframe) + end) + end + + defp effective_code_preview(:iframe, embed) do + if MaveCore.Playback.protected?(embed), do: :script, else: :iframe + end + + defp effective_code_preview(type, _embed), do: type + + defp snippet_string(type, video, embed) do + type = effective_code_preview(type, embed) + + if MaveCore.Playback.protected?(embed) do + # Public thumbnail URLs are not suitable as private embed backgrounds. + video = video |> Map.put(:snippet_player_poster, nil) |> Map.put(:snippet_clip_poster, nil) + + tag = + case type do + :clip -> "mave-clip" + framework when framework in [:react, :vue] -> framework_player_name(video) + _ -> dashboard_player_tag(video) + end + + EmbedCode.add_token(snippet_string(type, video), type, video.public_id, tag) + else + snippet_string(type, video) + end end defp snippet_string(:script, video) do diff --git a/lib/mave_core_web/live/dashboard/videos/show.html.heex b/lib/mave_core_web/live/dashboard/videos/show.html.heex index 4e991e4..b82a58f 100644 --- a/lib/mave_core_web/live/dashboard/videos/show.html.heex +++ b/lib/mave_core_web/live/dashboard/videos/show.html.heex @@ -207,6 +207,9 @@ <% else %>
{@video.public_id}
- <.dropdown_button id="video-actions"> + <.dropdown_button id="video-actions" menu_class="w-40"> + <:item + :if={MaveCore.Playback.available?(@current_space)} + id="video-playback-visibility" + icon={if @resource_embed.playback_visibility == :private, do: "hero-lock-open", else: "hero-lock-closed"} + phx-click="toggle_playback_visibility" + disabled={@resource_embed.playback_status in [:protecting, :publishing]} + > + {cond do + @resource_embed.playback_status in [:protecting, :publishing] -> gettext("updating access…") + @resource_embed.playback_visibility == :private -> gettext("public") + true -> gettext("private (token)") + end} + <:item :if={@video_ready or @replace_mode} icon="replace" phx-click={if @replace_mode, do: "cancel_replace", else: "replace"}> {if @replace_mode, do: gettext("cancel replace"), else: gettext("replace")} @@ -246,10 +262,13 @@ <.video_embed_panel player_dom_id={@player_dom_id} audio_only={match?(%{original: %{audio_only: true}}, @video) and !@replace_mode} - tabs={code_tabs(@code_preview, @video)} - snippet={snippet_string(@code_preview, @video)} - line_numbers={line_numbers(@code_preview, @video)} + tabs={code_tabs(@code_preview, @video, @resource_embed)} + snippet={snippet_string(@code_preview, @video, @resource_embed)} + line_numbers={line_numbers(@code_preview, @video, @resource_embed)} > + <:guidance> + + <:player> <%= if @replace_mode do %> <.video_upload_panel token={@upload_token} dom_id="video-upload-bridge-detail" /> diff --git a/lib/mave_core_web/plugs/playback_host.ex b/lib/mave_core_web/plugs/playback_host.ex new file mode 100644 index 0000000..0778481 --- /dev/null +++ b/lib/mave_core_web/plugs/playback_host.ex @@ -0,0 +1,41 @@ +defmodule MaveCoreWeb.Plugs.PlaybackHost do + @moduledoc "Routes a configured media hostname without exposing dashboard or management routes." + @behaviour Plug + + import Plug.Conn + + alias MaveCore.Playback.URLs + alias MaveCoreWeb.Api.PlaybackController + alias MaveCoreWeb.Plugs.CORS + + def init(opts), do: opts + + def call(conn, _opts) do + if URLs.playback_host?(conn.host), do: serve(conn), else: conn + end + + defp serve(conn) do + conn = CORS.call(conn, []) + + if conn.halted do + conn + else + dispatch(conn) |> halt() + end + end + + defp dispatch(%{method: method, path_info: [hash | path]} = conn) + when method in ["GET", "HEAD"] and path != [] do + with {:ok, space_hash} <- URLs.space_hash(conn.host), + true <- Regex.match?(~r/\A[A-Za-z0-9]{10}\z/, hash) do + conn = fetch_query_params(conn) + # URL identity comes only from the configured host and path, never query parameters. + params = Map.merge(conn.query_params, %{"id" => space_hash <> hash, "path" => path}) + PlaybackController.show(conn, params) + else + _ -> send_resp(conn, 404, "Not found") + end + end + + defp dispatch(conn), do: send_resp(conn, 404, "Not found") +end diff --git a/lib/mave_core_web/router.ex b/lib/mave_core_web/router.ex index 612cd63..0487296 100644 --- a/lib/mave_core_web/router.ex +++ b/lib/mave_core_web/router.ex @@ -81,6 +81,18 @@ defmodule MaveCoreWeb.Router do plug MaveCoreWeb.Plugs.RequireHost, env_var: "MAVE_IMAGE_HOST" end + scope "/api/v1", MaveCoreWeb do + pipe_through [:public_cors] + get "/playback/media/:id/*path", Api.PlaybackController, :show, log: false + options "/playback/media/:id/*path", Api.PlaybackController, :show, log: false + end + + scope "/v1", MaveCoreWeb do + pipe_through [:api_host, :public_cors] + get "/playback/media/:id/*path", Api.PlaybackController, :show, log: false + options "/playback/media/:id/*path", Api.PlaybackController, :show, log: false + end + scope "/", MaveCoreWeb do pipe_through :browser diff --git a/mix.lock b/mix.lock index 5dc44a4..779a6bb 100644 --- a/mix.lock +++ b/mix.lock @@ -8,7 +8,7 @@ "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, "db_connection": {:hex, :db_connection, "2.10.2", "ae391e803a5adff104da913c2fc1c0c14a37f8b10001dcef568796e1fb7bf95c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "510b14482330f1af6490a2fa0efd8d4f1435d1529b165647df22ac0f2df0fa93"}, - "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, + "decimal": {:hex, :decimal, "3.1.2", "5111bb7f15ceb8412ad408f908025e7a5ec3080b9978612a647ac82430306726", [:mix], [], "hexpm", "6aaf9d352d1c80fd3983b82af666f7ea2f3fe225ae202c89055ca38f3582ba5b"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"}, "ecto_ch": {:hex, :ecto_ch, "0.10.0", "ccfad102d866eba426c71d202a73c6c138cfeefa6eb6964c7c186acdd6306aad", [:mix], [{:ch, "~> 0.8.2", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "3d876ceb0d18808bee68d4fcd764faac0591de2987f6f8057a09eb869ee2cf17"}, diff --git a/priv/repo/migrations/20261009095534_add_embed_playback_visibility.exs b/priv/repo/migrations/20261009095534_add_embed_playback_visibility.exs new file mode 100644 index 0000000..4733bc3 --- /dev/null +++ b/priv/repo/migrations/20261009095534_add_embed_playback_visibility.exs @@ -0,0 +1,22 @@ +defmodule MaveCore.Repo.Migrations.AddEmbedPlaybackVisibility do + use Ecto.Migration + + def change do + alter table(:embeds) do + add(:playback_visibility, :string, null: false, default: "public") + add(:playback_status, :string, null: false, default: "public") + end + + create( + constraint(:embeds, :embeds_playback_visibility_check, + check: "playback_visibility IN ('public', 'private')" + ) + ) + + create( + constraint(:embeds, :embeds_playback_status_check, + check: "playback_status IN ('public', 'protecting', 'private', 'publishing')" + ) + ) + end +end diff --git a/storybook/dashboard/buttons/dropdown_button.story.exs b/storybook/dashboard/buttons/dropdown_button.story.exs index 5695cd4..001d74d 100644 --- a/storybook/dashboard/buttons/dropdown_button.story.exs +++ b/storybook/dashboard/buttons/dropdown_button.story.exs @@ -6,15 +6,33 @@ defmodule Storybook.Dashboard.Buttons.DropdownButton do def variations do [ %Variation{ - id: :default, - description: "Dropdown with render, replace, and delete actions", + id: :public_video, + description: "Public video actions", attributes: %{ - id: "dropdown-default" + id: "dropdown-public-video", + menu_class: "w-40" }, slots: [ """ - <:item icon="render">(re)render + <:item icon="hero-lock-closed">private (token) <:item icon="replace">replace + <:item icon="archive">archive + <:item icon="delete" danger>delete + """ + ] + }, + %Variation{ + id: :private_video, + description: "Private video actions", + attributes: %{ + id: "dropdown-private-video", + menu_class: "w-40" + }, + slots: [ + """ + <:item icon="hero-lock-open">public + <:item icon="replace">replace + <:item icon="archive">archive <:item icon="delete" danger>delete """ ] diff --git a/test/mave_core/embeds_test.exs b/test/mave_core/embeds_test.exs index 5cee6e3..94d3144 100644 --- a/test/mave_core/embeds_test.exs +++ b/test/mave_core/embeds_test.exs @@ -1969,19 +1969,19 @@ defmodule MaveCore.EmbedsTest do Application.delete_env(:mave_core, :components_src) Application.delete_env(:mave_core, :components_base_url) - Application.put_env(:mave_core, :domain, "https://dash.staging.mave.io/") + Application.put_env(:mave_core, :domain, "https://dashboard.example.test/") Application.put_env(:mave_core, :upload, - endpoint: "https://upload.staging.mave.io/files", - source_base_url: "https://s3.fr-par.scw.cloud", + endpoint: "https://upload.example.test/files", + source_base_url: "https://storage.example.test", source_region: "fr-par", hook_secret: "secret", default_template: "publish_default" ) - Application.put_env(:mave_core, :public_cdn_host, "staging.video-dns.com") + Application.put_env(:mave_core, :public_cdn_host, "storage.example.test") Application.put_env(:mave_core, :public_cdn_scheme, "https") - System.put_env("MAVE_METRICS_HOST", "metrics.staging.video-dns.com") + System.put_env("MAVE_METRICS_HOST", "metrics.example.test") assert SettingsSerializer.component_src() == "https://cdn.video-dns.com/npm/@maveio/components/+esm" @@ -1990,20 +1990,25 @@ defmodule MaveCore.EmbedsTest do "https://cdn.video-dns.com/npm/@maveio/components/dist/config.js" assert Jason.decode!(SettingsSerializer.component_config_json()) == %{ - "api" => %{"endpoint" => "https://dash.staging.mave.io/api/v1"}, - "cdn" => %{"endpoint" => "https://space-${this.spaceId}.s3.fr-par.scw.cloud"}, - "metrics" => %{"endpoint" => "https://metrics.staging.video-dns.com/v1/events"}, + "api" => %{"endpoint" => "https://dashboard.example.test/api/v1"}, + "cdn" => %{ + "endpoint" => "https://space-${this.spaceId}.storage.example.test", + "playback_endpoint" => + "https://dashboard.example.test/api/v1/playback/media/${this.spaceId}${this.embedId}" + }, + "metrics" => %{"endpoint" => "https://metrics.example.test/v1/events"}, "upload" => %{ - "endpoint" => "https://upload.staging.mave.io/files", - "socket" => "wss://dash.staging.mave.io/api/v1/socket" + "endpoint" => "https://upload.example.test/files", + "socket" => "wss://dashboard.example.test/api/v1/socket" } } assert SettingsSerializer.storage_object_url("space-ofyrn", "Vkmazex4WB/manifest.json") == - "https://space-ofyrn.s3.fr-par.scw.cloud/Vkmazex4WB/manifest.json" + "https://space-ofyrn.storage.example.test/Vkmazex4WB/manifest.json" end test "settings serializer does not require component config for production CDN aliases" do + original_playback_origin = Application.get_env(:mave_core, :playback_origin) original_src = Application.get_env(:mave_core, :components_src) original_base = Application.get_env(:mave_core, :components_base_url) original_domain = Application.get_env(:mave_core, :domain) @@ -2015,6 +2020,7 @@ defmodule MaveCore.EmbedsTest do original_metrics_host = System.get_env("MAVE_METRICS_HOST") on_exit(fn -> + restore_env(:playback_origin, original_playback_origin) restore_env(:components_src, original_src) restore_env(:components_base_url, original_base) restore_env(:domain, original_domain) @@ -2026,13 +2032,14 @@ defmodule MaveCore.EmbedsTest do restore_system_env("MAVE_METRICS_HOST", original_metrics_host) end) + Application.put_env(:mave_core, :playback_origin, "https://signed.video-dns.com") Application.delete_env(:mave_core, :components_src) Application.delete_env(:mave_core, :components_base_url) Application.put_env(:mave_core, :domain, "https://dash.mave.io") Application.put_env(:mave_core, :upload, endpoint: "https://upload.mave.io/files", - source_base_url: "https://s3.fr-par.scw.cloud", + source_base_url: "https://storage.example.test", public_base_url: "https://storage.mave.io", source_region: "fr-par", hook_secret: "secret", @@ -2046,17 +2053,15 @@ defmodule MaveCore.EmbedsTest do Application.delete_env(:mave_core, :public_cdn_mode) - assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"] == %{ - "endpoint" => "https://space-${this.spaceId}.video-dns.com" - } + assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"]["endpoint"] == + "https://space-${this.spaceId}.video-dns.com" refute SettingsSerializer.component_config_required?() Application.put_env(:mave_core, :public_cdn_mode, "path") - assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"] == %{ - "endpoint" => "https://cdn.video-dns.com/space-${this.spaceId}" - } + assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"]["endpoint"] == + "https://cdn.video-dns.com/space-${this.spaceId}" refute SettingsSerializer.component_config_required?() end @@ -2079,7 +2084,14 @@ defmodule MaveCore.EmbedsTest do assert SettingsSerializer.storage_object_url("space-lt1ij", "yrATPMmNde/poster.jpg") == "https://cdn.saas.orb.local/space-lt1ij/yrATPMmNde/poster.jpg" - assert Jason.decode!(SettingsSerializer.component_config_json()) == %{ + config = Jason.decode!(SettingsSerializer.component_config_json()) + + assert config["cdn"]["playback_endpoint"] == + "http://localhost:4000/api/v1/playback/media/${this.spaceId}${this.embedId}" + + config = update_in(config, ["cdn"], &Map.delete(&1, "playback_endpoint")) + + assert config == %{ "api" => %{"endpoint" => "http://localhost:4000/api/v1"}, "cdn" => %{"endpoint" => "https://cdn.saas.orb.local/space-${this.spaceId}"}, "metrics" => %{"endpoint" => "http://localhost:4000/v1/events"}, @@ -2103,9 +2115,8 @@ defmodule MaveCore.EmbedsTest do assert SettingsSerializer.storage_object_url("space-lt1ij", "video/manifest.json") == "http://localhost:9000/space-lt1ij/video/manifest.json" - assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"] == %{ - "endpoint" => "http://localhost:9000/space-${this.spaceId}" - } + assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"]["endpoint"] == + "http://localhost:9000/space-${this.spaceId}" end test "settings serializer honors explicit path-style public CDN mode" do @@ -2126,9 +2137,8 @@ defmodule MaveCore.EmbedsTest do assert SettingsSerializer.storage_object_url("space-ubg50", "XiyviR3oEq/thumbnail.jpg") == "https://cdn.video-dns.com/space-ubg50/XiyviR3oEq/thumbnail.jpg" - assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"] == %{ - "endpoint" => "https://cdn.video-dns.com/space-${this.spaceId}" - } + assert Jason.decode!(SettingsSerializer.component_config_json())["cdn"]["endpoint"] == + "https://cdn.video-dns.com/space-${this.spaceId}" end test "settings serializer uses public upload base for legacy uploaded poster keys" do @@ -2222,6 +2232,32 @@ defmodule MaveCore.EmbedsTest do "https://space-#{space.hash}.s3.fr-par.scw.cloud/#{video.hash}/thumbnail_4.jpg", "https://space-#{space.hash}.s3.fr-par.scw.cloud/#{video.hash}/thumbnail_5.jpg" ] + + private_video = + video + |> Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update!() + + preview = Embeds.get_video_dashboard_payload(space, private_video).thumbnail_preview + playlist = URI.parse(preview.preferred_src) + token = URI.decode_query(playlist.query)["token"] + assert is_binary(token) + + assert String.ends_with?(playlist.path, "/#{video.hash}/h264_sd_hls/playlist.m3u8") or + String.ends_with?( + playlist.path, + "/#{space.hash}#{video.hash}/h264_sd_hls/playlist.m3u8" + ) + + assert {:ok, _expires_at} = MaveCore.Playback.authorize(token, private_video) + + for src <- [preview.fallback_src | Enum.map(preview.frame_srcs, & &1.src)] do + uri = URI.parse(src) + query = URI.decode_query(uri.query) + assert query["X-Amz-Signature"] + assert query["X-Amz-Expires"] == "86400" + assert String.contains?(uri.path, "/#{video.hash}/") + end end test "settings serializer omits default controls from player attrs but keeps manifest default" do diff --git a/test/mave_core/media/storage_test.exs b/test/mave_core/media/storage_test.exs index acb1e45..4d438b2 100644 --- a/test/mave_core/media/storage_test.exs +++ b/test/mave_core/media/storage_test.exs @@ -9,6 +9,56 @@ defmodule MaveCore.Media.StorageTest do setup {Req.Test, :verify_on_exit!} + test "signed reads support a full dashboard day while remaining bounded" do + profile = [ + endpoint: "https://storage.example.test", + access_key_id: "test", + secret_access_key: "test" + ] + + for requested <- [86_400, 172_800] do + assert {:ok, url} = + Storage.presigned_get_url("bucket", "video.mp4", profile, expires: requested) + + query = url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() + assert query["X-Amz-Expires"] == "86400" + end + end + + test "presigned image URLs reuse a signing time and cover cache parameters" do + profile = [ + endpoint: "https://storage.example.test", + region: "test-region", + access_key_id: "test-key", + secret_access_key: "test-secret" + ] + + opts = [ + datetime: ~U[2026-01-01 12:00:00Z], + expires: 600, + query_params: [{"response-cache-control", "private, max-age=300"}, {"e", 1}] + ] + + assert {:ok, url} = Storage.presigned_get_url("bucket", "video/thumbnail.jpg", profile, opts) + assert {:ok, ^url} = Storage.presigned_get_url("bucket", "video/thumbnail.jpg", profile, opts) + query = url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() + assert query["response-cache-control"] == "private, max-age=300" + assert query["X-Amz-Date"] == "20260101T120000Z" + assert query["X-Amz-Expires"] == "600" + + changed_opts = + Keyword.put(opts, :query_params, [ + {"response-cache-control", "private, max-age=300"}, + {"e", 2} + ]) + + assert {:ok, changed_url} = + Storage.presigned_get_url("bucket", "video/thumbnail.jpg", profile, changed_opts) + + changed_query = changed_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() + refute changed_query["X-Amz-Signature"] == query["X-Amz-Signature"] + end + test "bucket overrides sign requests and presigned URLs for the correct account" do Req.default_options(plug: {Req.Test, __MODULE__}) diff --git a/test/mave_core/playback/embed_code_test.exs b/test/mave_core/playback/embed_code_test.exs new file mode 100644 index 0000000..e8f2dcd --- /dev/null +++ b/test/mave_core/playback/embed_code_test.exs @@ -0,0 +1,27 @@ +defmodule MaveCore.Playback.EmbedCodeTest do + use ExUnit.Case, async: true + alias MaveCore.Playback.EmbedCode + + test "browser examples put the token directly in the HTML attribute" do + html = + EmbedCode.add_token( + ~s(), + :script, + "spacevideo00001", + "mave-player" + ) + + assert html == + ~s() + + refute html =~ "), :react, "video", "Player") =~ + ~s(), :vue, "video", "Player") =~ + ~s( + if previous, + do: Application.put_env(:mave_core, :playback_adapter, previous), + else: Application.delete_env(:mave_core, :playback_adapter) + end) + + {:ok, user} = + Accounts.create_user("playback-#{System.unique_integer([:positive])}@example.com") + + space = user.current_space_membership.space + {:ok, key} = Spaces.create_key(space, %{access_level: "read_only"}) + {:ok, embed} = Embeds.create_video_embed(space, %{name: "Private video"}) + + embed = + embed + |> Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update!() + + %{space: space, key: key, embed: embed, identifier: space.hash <> embed.hash} + end + + test "read-only keys sign a sub and exp token without aud or iat", context do + exp = System.system_time(:second) + 600 + token = sign(context.key, %{"sub" => context.identifier, "exp" => exp}) + assert context.key.access_level == :read_only + assert {:ok, expires_at} = Playback.authorize(token, context.embed) + assert expires_at == exp + end + + test "expiration is optional and revoking the key rejects subsequent media requests", context do + token = sign(context.key, %{"sub" => context.space.id}) + assert {:ok, expires_at} = Playback.authorize(token, context.embed) + assert expires_at <= System.system_time(:second) + 86_400 + assert {:ok, _} = Spaces.delete_key(context.key) + assert {:error, :unauthorized} = Playback.authorize(token, context.embed) + end + + test "tokens may last longer than a day while signed storage URLs remain bounded", context do + now = System.system_time(:second) + + token = + sign(context.key, %{"sub" => context.identifier, "iat" => now, "exp" => now + 604_800}) + + assert {:ok, expires_at} = Playback.authorize(token, context.embed) + assert expires_at <= System.system_time(:second) + 86_400 + end + + test "expired or malformed time claims and incorrect signatures are rejected", context do + now = System.system_time(:second) + + for claims <- [%{"exp" => now - 1}, %{"exp" => "tomorrow"}, %{"iat" => now + 3600}] do + token = sign(context.key, Map.put(claims, "sub", context.identifier)) + assert {:error, :unauthorized} = Playback.authorize(token, context.embed) + end + + token = sign(%{context.key | secret: "incorrect-secret"}, %{"sub" => context.identifier}) + assert {:error, :unauthorized} = Playback.authorize(token, context.embed) + end + + test "a token for one video does not authorize another video", context do + {:ok, other} = Embeds.create_video_embed(context.space, %{name: "Other video"}) + token = sign(context.key, %{"sub" => context.identifier}) + + assert {:error, :unauthorized} = + Playback.authorize(token, other) + end + + defp sign(key, claims) do + header = encode(%{"alg" => "HS256", "typ" => "JWT"}) + payload = encode(claims) + input = header <> "." <> payload + signature = :crypto.mac(:hmac, :sha256, Spaces.display_api_key(key.key, key.secret), input) + input <> "." <> Base.url_encode64(signature, padding: false) + end + + defp encode(value), do: value |> Jason.encode!() |> Base.url_encode64(padding: false) +end diff --git a/test/mave_core/playback/media_test.exs b/test/mave_core/playback/media_test.exs new file mode 100644 index 0000000..ce7eaf9 --- /dev/null +++ b/test/mave_core/playback/media_test.exs @@ -0,0 +1,28 @@ +defmodule MaveCore.Playback.MediaTest do + use ExUnit.Case, async: true + alias MaveCore.Playback.Media + + test "root playlists resolve rendition, audio and subtitle paths without a dot prefix" do + for path <- [ + "h264_sd_hls/playlist.m3u8", + "audio_hls/playlist.m3u8", + "subtitle_en_hls/playlist.m3u8" + ] do + assert {:ok, ^path} = Media.resolve_path(path, "video", ".") + end + + assert {:ok, "h264_sd_hls/segment0.ts"} = + Media.resolve_path("segment0.ts", "video", "h264_sd_hls") + end + + test "media paths cannot escape the authorized video prefix" do + for path <- [ + "../other/playlist.m3u8", + "/other/playlist.m3u8", + "https://example.com/other/playlist.m3u8", + "%2e%2e/other" + ] do + assert {:error, :invalid_path} = Media.resolve_path(path, "video", ".") + end + end +end diff --git a/test/mave_core/playback/minio_test.exs b/test/mave_core/playback/minio_test.exs new file mode 100644 index 0000000..7f0dc7a --- /dev/null +++ b/test/mave_core/playback/minio_test.exs @@ -0,0 +1,18 @@ +defmodule MaveCore.Playback.MinioTest do + use ExUnit.Case, async: true + + alias MaveCore.Playback.Minio, as: Local + + test "local policy permits only public prefixes without granting anonymous writes" do + {:ok, policy} = Local.bucket_policy("space-test", ["public-video", "public-video"]) + assert policy["Version"] == "2012-10-17" + assert [statement] = policy["Statement"] + assert statement["Action"] == "s3:GetObject" + + assert statement["Resource"] == [ + "arn:aws:s3:::space-test/themes/*", + "arn:aws:s3:::space-test/*/player.html", + "arn:aws:s3:::space-test/public-video/*" + ] + end +end diff --git a/test/mave_core/playback/session_test.exs b/test/mave_core/playback/session_test.exs new file mode 100644 index 0000000..db4cc69 --- /dev/null +++ b/test/mave_core/playback/session_test.exs @@ -0,0 +1,47 @@ +defmodule MaveCore.Playback.SessionTest do + use ExUnit.Case, async: false + alias MaveCore.Embeds.Embed + alias MaveCore.Playback + + defmodule Endpoint do + def config(:secret_key_base), do: String.duplicate("playback-test", 8) + end + + defmodule Adapter do + def token_endpoint, do: Endpoint + end + + setup do + previous = Application.get_env(:mave_core, :playback_adapter) + Application.put_env(:mave_core, :playback_adapter, Adapter) + + on_exit(fn -> + if previous, + do: Application.put_env(:mave_core, :playback_adapter, previous), + else: Application.delete_env(:mave_core, :playback_adapter) + end) + end + + test "dashboard sessions use the active deployment endpoint and bind to one video" do + embed = %Embed{id: Ecto.UUID.generate(), space_id: Ecto.UUID.generate()} + session = Playback.dashboard_session(embed) + assert_in_delta session.expires_at - System.system_time(:second), 86_400, 1 + assert {:ok, session.expires_at} == Playback.authorize(session.token, embed) + + assert {:error, :unauthorized} == + Playback.authorize(session.token, %{embed | id: Ecto.UUID.generate()}) + + assert {:error, :unauthorized} == Playback.authorize(nil, embed) + end + + test "dashboard sessions remain valid after an hour but never past their expiry" do + embed = %Embed{id: Ecto.UUID.generate(), space_id: Ecto.UUID.generate()} + now = System.system_time(:second) + claims = %{embed_id: embed.id, space_id: embed.space_id, expires_at: now + 79_200} + token = Phoenix.Token.sign(Endpoint, "media-playback-v1", claims, signed_at: now - 7200) + assert {:ok, claims.expires_at} == Playback.authorize(token, embed) + + expired = Phoenix.Token.sign(Endpoint, "media-playback-v1", %{claims | expires_at: now - 1}) + assert {:error, :unauthorized} == Playback.authorize(expired, embed) + end +end diff --git a/test/mave_core/playback/storage_policy_test.exs b/test/mave_core/playback/storage_policy_test.exs new file mode 100644 index 0000000..1459614 --- /dev/null +++ b/test/mave_core/playback/storage_policy_test.exs @@ -0,0 +1,62 @@ +defmodule MaveCore.Playback.StoragePolicyTest do + use MaveCore.DataCase, async: false + + import Plug.Conn + + alias MaveCore.{Accounts, Embeds} + alias MaveCore.Playback.Minio + + setup {Req.Test, :verify_on_exit!} + + setup do + previous = Application.get_env(:mave_core, :playback_adapter) + previous_req = Req.default_options() + Application.put_env(:mave_core, :playback_adapter, Minio) + Req.default_options(plug: {Req.Test, __MODULE__}) + + on_exit(fn -> + Req.default_options(previous_req) + + if previous, + do: Application.put_env(:mave_core, :playback_adapter, previous), + else: Application.delete_env(:mave_core, :playback_adapter) + end) + + {:ok, user} = + Accounts.create_user("storage-policy-#{System.unique_integer([:positive])}@example.com") + + %{space: user.current_space_membership.space} + end + + test "new private embeds protect their empty prefix before media exists", %{space: space} do + {:ok, public} = Embeds.create_video_embed(space) + + Req.Test.expect(__MODULE__, fn conn -> + assert conn.method == "PUT" + assert conn.query_string == "policy" + {:ok, body, conn} = read_body(conn) + resources = Jason.decode!(body)["Statement"] |> hd() |> Map.fetch!("Resource") + assert Enum.any?(resources, &String.ends_with?(&1, "/#{public.hash}/*")) + send(self(), {:policy_resources, resources}) + send_resp(conn, 200, "") + end) + + Req.Test.expect(__MODULE__, fn conn -> + assert conn.method == "PUT" + assert conn.query_string == "cors" + send_resp(conn, 200, "") + end) + + Req.Test.expect(__MODULE__, fn conn -> + assert conn.method == "HEAD" + assert String.ends_with?(conn.request_path, "/manifest.json") + send_resp(conn, 404, "") + end) + + assert {:ok, private} = Embeds.create_video_embed(space, %{visibility: :private}) + assert private.playback_status == :private + assert is_nil(private.asset.current_video_id) + assert_receive {:policy_resources, resources} + refute Enum.any?(resources, &String.ends_with?(&1, "/#{private.hash}/*")) + end +end diff --git a/test/mave_core_web/browser_security_test.exs b/test/mave_core_web/browser_security_test.exs index d70b2f6..3fbd283 100644 --- a/test/mave_core_web/browser_security_test.exs +++ b/test/mave_core_web/browser_security_test.exs @@ -56,6 +56,38 @@ defmodule MaveCoreWeb.BrowserSecurityTest do assert csp =~ "http://localhost:1080" end + test "browser CSP allows private playback storage without allowing its scripts", %{conn: conn} do + original_endpoint = Application.get_env(:mave_core, :playback_public_storage_endpoint) + Application.put_env(:mave_core, :playback_public_storage_endpoint, "http://localhost:9010") + on_exit(fn -> restore_env(:playback_public_storage_endpoint, original_endpoint) end) + + conn = get(conn, ~p"/health") + assert [csp] = get_resp_header(conn, "content-security-policy") + directives = String.split(csp, "; ") + + for name <- ["img-src", "media-src", "connect-src"] do + directive = Enum.find(directives, &String.starts_with?(&1, name <> " ")) + assert "http://localhost:9010" in String.split(directive) + end + + script_src = Enum.find(directives, &String.starts_with?(&1, "script-src ")) + refute "http://localhost:9010" in String.split(script_src) + end + + test "browser CSP permits the configured local space media host" do + previous = Application.get_env(:mave_core, :playback_origin) + Application.put_env(:mave_core, :playback_origin, "http://signed.localhost:4001") + on_exit(fn -> restore_env(:playback_origin, previous) end) + directives = MaveCoreWeb.BrowserSecurity.content_security_policy() |> String.split("; ") + + for name <- ["img-src", "media-src", "connect-src"] do + assert Enum.find(directives, &String.starts_with?(&1, name <> " ")) =~ + "http://*.signed.localhost:4001" + end + + refute Enum.find(directives, &String.starts_with?(&1, "script-src ")) =~ "signed.localhost" + end + defp restore_env(key, nil), do: Application.delete_env(:mave_core, key) defp restore_env(key, value), do: Application.put_env(:mave_core, key, value) end diff --git a/test/mave_core_web/controllers/api/playback_controller_test.exs b/test/mave_core_web/controllers/api/playback_controller_test.exs new file mode 100644 index 0000000..85ad5e3 --- /dev/null +++ b/test/mave_core_web/controllers/api/playback_controller_test.exs @@ -0,0 +1,458 @@ +defmodule MaveCoreWeb.Api.PlaybackControllerTest do + use MaveCoreWeb.ConnCase, async: false + + alias MaveCore.{Accounts, Embeds, PublicApi, Repo, Spaces} + alias MaveCore.Assets.{AudioTrack, Subtitle} + alias MaveCore.Embeds.SettingsSerializer + alias MaveCore.Playback.Media + alias MaveCore.Workers.PlaybackVisibilityWorker + + def available?(_space), do: Process.get(:playback_available, true) + def token_endpoint, do: MaveCoreWeb.Endpoint + + def apply_visibility(embed, visibility) do + send(self(), {:storage_visibility, embed.id, visibility}) + Process.get(:playback_storage_result, :ok) + end + + def object_info(_bucket, _path, _region), do: {:ok, %{size_bytes: 100}} + + def put_public(_bucket, _path, _body, _content_type, _region), do: {:ok, %{}} + + def get(_bucket, path, _region) do + cond do + String.ends_with?(path, ".json") -> + {:error, :not_found} + + String.ends_with?(path, "/storyboard.vtt") -> + {:ok, + Process.get( + :storyboard_body, + "WEBVTT\n\n00:00.000 --> 00:05.000\nstoryboard.jpg#xywh=0,0,320,180\n" + )} + + String.ends_with?(path, "/h264_hd_hls/playlist.m3u8") -> + {:ok, "#EXTM3U\nsegment_000.ts\n#EXT-X-ENDLIST\n"} + + true -> + {:ok, "#EXTM3U\nh264_hd_hls/playlist.m3u8\n"} + end + end + + def presigned_get_url(bucket, path, _region, opts), + do: {:ok, "https://storage.example.test/#{bucket}/#{path}?expires=#{opts[:expires]}"} + + setup do + previous_origin = Application.get_env(:mave_core, :playback_origin) + Application.delete_env(:mave_core, :playback_origin) + + on_exit(fn -> + if previous_origin, + do: Application.put_env(:mave_core, :playback_origin, previous_origin), + else: Application.delete_env(:mave_core, :playback_origin) + end) + + previous = + for key <- [:playback_adapter, :playback_storage_adapter, :flow_storage_adapter], + into: %{}, + do: {key, Application.get_env(:mave_core, key)} + + Enum.each(Map.keys(previous), &Application.put_env(:mave_core, &1, __MODULE__)) + + on_exit(fn -> + Enum.each(previous, fn {key, value} -> + if value, + do: Application.put_env(:mave_core, key, value), + else: Application.delete_env(:mave_core, key) + end) + end) + + {:ok, user} = + Accounts.create_user("playback-api-#{System.unique_integer([:positive])}@example.com") + + space = user.current_space_membership.space + {:ok, key} = Spaces.create_key(space) + {:ok, read_key} = Spaces.create_key(space, %{access_level: "read_only"}) + %{space: space, key: key, read_key: read_key} + end + + test "private creation protects storage before returning, and default creation stays public", + context do + conn = api(context.key) |> post("/api/v1/videos", %{name: "Private", visibility: "private"}) + + assert %{ + "id" => id, + "visibility" => "private" + } = json_response(conn, 200) + + embed = PublicApi.get_embed(context.space, id) + assert_receive {:storage_visibility, embed_id, :private} + assert embed_id == embed.id + assert is_nil(embed.asset.current_video_id) + + conn = api(context.key) |> post("/api/v1/videos", %{name: "Default"}) + assert %{"visibility" => "public"} = json_response(conn, 200) + end + + test "failed or unavailable private protection and invalid visibility leave no video behind", + context do + Process.put(:playback_storage_result, {:error, :storage_unavailable}) + conn = api(context.key) |> post("/api/v1/videos", %{visibility: "private"}) + assert conn.status == 400 + assert PublicApi.count_videos(context.space, []) == 0 + + Process.put(:playback_available, false) + conn = api(context.key) |> post("/api/v1/videos", %{visibility: "private"}) + assert conn.status == 400 + conn = api(context.key) |> post("/api/v1/videos", %{visibility: "unknown"}) + assert conn.status == 400 + assert PublicApi.count_videos(context.space, []) == 0 + end + + test "visibility updates require write access", context do + {:ok, embed} = Embeds.create_video_embed(context.space) + conn = api(context.read_key) |> put("/api/v1/videos/#{embed.hash}", %{visibility: "private"}) + assert conn.status == 403 + assert PublicApi.get_embed(context.space, embed.hash).playback_visibility == :public + + conn = api(context.key) |> put("/api/v1/videos/#{embed.hash}", %{visibility: "private"}) + assert %{"visibility" => "private"} = json_response(conn, 200) + conn = api(context.key) |> put("/api/v1/videos/#{embed.hash}", %{visibility: "public"}) + assert %{"visibility" => "public"} = json_response(conn, 200) + end + + test "visibility changes only after storage synchronization succeeds", context do + {:ok, embed} = Embeds.create_video_embed(context.space) + path = "/api/v1/videos/#{embed.hash}" + job = %Oban.Job{args: %{"embed_id" => embed.id}} + + for {previous, target} <- [{"public", "private"}, {"private", "public"}] do + Oban.Testing.with_testing_mode(:manual, fn -> + conn = api(context.key) |> put(path, %{visibility: target}) + response = json_response(conn, 202) + assert response["visibility"] == previous + refute Map.has_key?(response, "visibility_status") + end) + + for reason <- [:cache_purge_timeout, :cache_purge_failed, :cache_pipeline_missing] do + Process.put(:playback_storage_result, {:error, reason}) + + assert {:error, :playback_storage_sync_failed} = PlaybackVisibilityWorker.perform(job) + conn = api(context.read_key) |> get(path) + assert json_response(conn, 202)["visibility"] == previous + assert MaveCore.Playback.protected?(PublicApi.get_embed(context.space, embed.hash)) + end + + Process.put(:playback_storage_result, :ok) + assert :ok = PlaybackVisibilityWorker.perform(job) + conn = api(context.read_key) |> get(path) + assert json_response(conn, 200)["visibility"] == target + end + end + + test "reversing a pending change retains the last confirmed visibility", context do + {:ok, embed} = Embeds.create_video_embed(context.space) + path = "/api/v1/videos/#{embed.hash}" + + Oban.Testing.with_testing_mode(:manual, fn -> + for target <- ["private", "public"] do + conn = api(context.key) |> put(path, %{visibility: target}) + assert json_response(conn, 202)["visibility"] == "public" + end + + conn = api(context.read_key) |> get(path) + assert json_response(conn, 202)["visibility"] == "public" + end) + + assert :ok = PlaybackVisibilityWorker.perform(%Oban.Job{args: %{"embed_id" => embed.id}}) + assert_receive {:storage_visibility, _, :public} + conn = api(context.read_key) |> get(path) + assert json_response(conn, 200)["visibility"] == "public" + end + + test "ordinary updates and already confirmed visibility keep HTTP 200", context do + {:ok, embed} = Embeds.create_video_embed(context.space) + path = "/api/v1/videos/#{embed.hash}" + + Oban.Testing.with_testing_mode(:manual, fn -> + conn = api(context.key) |> put(path, %{visibility: "public"}) + assert json_response(conn, 200)["visibility"] == "public" + + conn = api(context.key) |> put(path, %{visibility: "private"}) + assert conn.status == 202 + conn = api(context.key) |> put(path, %{name: "Renamed"}) + assert json_response(conn, 200)["visibility"] == "public" + end) + end + + test "media URLs play with the original JWT, including child playlists and files", context do + {:ok, embed} = Embeds.create_video_embed(context.space, %{visibility: :private}) + + {:ok, embed} = + Embeds.begin_video_upload(context.space.hash, embed.hash, %{"title" => "video.mp4"}) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + for {container, key} <- [ + {"hls", "#{embed.hash}/v1/h264_hd_hls/playlist.m3u8"}, + {"mp4", "#{embed.hash}/v1/h264_hd.mp4"} + ] do + Repo.insert_all("renditions", [ + %{ + id: MaveCore.LegacyShortUUID.dump!(Ecto.UUID.generate()), + video_id: MaveCore.LegacyShortUUID.dump!(embed.asset.current_video_id), + rendition_key: key, + type: "video", + codec: "h264", + container: container, + size: "hd", + progress: 100.0, + inserted_at: now, + updated_at: now + } + ]) + end + + conn = api(context.read_key) |> get("/api/v1/videos/#{embed.hash}") + refute Map.has_key?(json_response(conn, 200), "sources") + base = "/api/v1/playback/media/#{context.space.hash}#{embed.hash}" + path = base <> "/playlist.m3u8" + + token = + sign(context.read_key, %{ + "sub" => context.space.hash <> embed.hash, + "exp" => System.system_time(:second) + 600 + }) + + assert get(build_conn(), path).status == 401 + conn = get(build_conn(), path <> "?" <> URI.encode_query(%{token: token})) + playlist = response(conn, 200) + child = playlist |> String.split("\n") |> Enum.find(&String.contains?(&1, "h264_hd_hls")) + assert URI.decode_query(URI.parse(child).query)["token"] == token + assert response(get(build_conn(), child), 200) =~ "https://storage.example.test/" + + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token) + |> get(base <> "/v1/h264_hd.mp4") + + assert conn.status == 302 + assert hd(get_resp_header(conn, "location")) =~ "v1/h264_hd.mp4" + + {:ok, other} = Embeds.create_video_embed(context.space) + wrong = sign(context.read_key, %{"sub" => context.space.hash <> other.hash}) + assert get(build_conn(), path <> "?" <> URI.encode_query(%{token: wrong})).status == 401 + {:ok, _} = Spaces.delete_key(context.read_key) + assert get(build_conn(), path <> "?" <> URI.encode_query(%{token: token})).status == 401 + end + + test "video responses do not add a sources field to either format", context do + {:ok, embed} = Embeds.create_video_embed(context.space) + + for version <- [1, 2], visibility <- [:public, :private] do + response = + PublicApi.video_response(context.space, %{ + embed + | version: version, + playback_visibility: visibility, + playback_status: visibility + }) + + refute Map.has_key?(response, :sources) + refute Map.has_key?(response, :visibility_status) + assert response.visibility == visibility + assert Map.has_key?(response, :poster_image) + end + end + + test "space, collection and video database IDs constrain JWT scope", context do + {:ok, folder} = Embeds.create_folder_embed(context.space, %{name: "Folder"}) + {:ok, child} = Embeds.create_video_embed(context.space, %{parent_folder_id: folder.id}) + {:ok, other} = Embeds.create_video_embed(context.space) + + for sub <- [context.space.id, folder.id, child.id] do + token = sign(context.read_key, %{"sub" => sub}) + assert {:ok, _} = MaveCore.Playback.authorize(token, child) + + if sub != context.space.id, + do: assert({:error, :unauthorized} == MaveCore.Playback.authorize(token, other)) + end + end + + test "custom players can preflight bearer authorization" do + conn = + build_conn() + |> put_req_header("origin", "https://player.example.test") + |> put_req_header("access-control-request-method", "GET") + |> put_req_header("access-control-request-headers", "authorization") + |> options("/api/v1/playback/media/aaaaabbbbbccccc/playlist.m3u8") + + assert conn.status == 204 + assert get_resp_header(conn, "access-control-allow-origin") == ["*"] + assert hd(get_resp_header(conn, "access-control-allow-headers")) =~ "authorization" + end + + test "space media host serves JWT playlists and keeps management routes closed", context do + Application.put_env(:mave_core, :playback_origin, "https://signed.example.test") + {:ok, embed} = Embeds.create_video_embed(context.space, %{visibility: :private}) + token = sign(context.read_key, %{"sub" => context.space.hash <> embed.hash}) + origin = "https://space-#{context.space.hash}.signed.example.test" + path = "/#{embed.hash}/playlist.m3u8" + query = URI.encode_query(%{token: token}) + + conn = get(build_conn(), origin <> path <> "?" <> query) + assert conn.status == 200 + assert conn.resp_body =~ "/#{embed.hash}/h264_hd_hls/playlist.m3u8?token=" + refute conn.resp_body =~ "/api/v1/" + assert get_resp_header(conn, "cache-control") == ["private, no-store"] + assert get_resp_header(conn, "access-control-allow-origin") == ["*"] + assert get(build_conn(), origin <> path).status == 401 + assert get(build_conn(), origin <> "/api/v1/videos").status == 404 + assert get(build_conn(), origin <> "/login").status == 404 + assert post(build_conn(), origin <> path, %{}).status == 404 + assert options(build_conn(), origin <> path).status == 204 + + {:ok, other_user} = + Accounts.create_user("other-host-#{System.unique_integer([:positive])}@example.com") + + other = other_user.current_space_membership.space + other_origin = "https://space-#{other.hash}.signed.example.test" + assert get(build_conn(), other_origin <> path <> "?" <> query).status == 401 + # A query-string id cannot override the tenant selected by the hostname. + assert get( + build_conn(), + other_origin <> path <> "?" <> query <> "&id=" <> context.space.hash <> embed.hash + ).status == 401 + + assert get(build_conn(), "https://bad.signed.example.test" <> path <> "?" <> query).status == + 404 + + response = PublicApi.video_response(context.space, embed) + assert response.poster_image == origin <> "/#{embed.hash}/thumbnail.jpg" + + endpoint = + SettingsSerializer.component_runtime_config()["cdn"]["playback_endpoint"] + + assert endpoint == "https://space-${this.spaceId}.signed.example.test/${this.embedId}" + + # Existing API links remain valid during rollout and for self-hosted installs. + assert get( + build_conn(), + "/api/v1/playback/media/#{context.space.hash}#{embed.hash}/playlist.m3u8?" <> query + ).status == 200 + end + + test "storyboards authorize their sprite images and keep crop coordinates", context do + {:ok, embed} = Embeds.create_video_embed(context.space, %{visibility: :private}) + + token = + sign(context.read_key, %{ + sub: context.space.hash <> embed.hash, + exp: System.system_time(:second) + 3600 + }) + + base = "/api/v1/playback/media/#{context.space.hash}#{embed.hash}" + + for version <- ["", "v2/"] do + path = base <> "/" <> version <> "storyboard.vtt" + assert get(build_conn(), path).status == 401 + conn = Phoenix.ConnTest.get(build_conn(), path, %{token: token}) + assert [type] = get_resp_header(conn, "content-type") + assert type =~ "text/vtt" + assert conn.resp_body =~ "WEBVTT" + assert conn.resp_body =~ "00:00.000 --> 00:05.000" + assert conn.resp_body =~ "/#{embed.hash}/#{version}storyboard.jpg?expires=" + assert conn.resp_body =~ "#xywh=0,0,320,180" + assert get_resp_header(conn, "cache-control") == ["private, no-store"] + end + + Process.put( + :storyboard_body, + "WEBVTT\n\n00:00.000 --> 00:05.000\n../other/storyboard.jpg#xywh=0,0,320,180\n" + ) + + assert Phoenix.ConnTest.get(build_conn(), base <> "/storyboard.vtt", %{token: token}).status == + 404 + end + + test "private manifests authorize path-style storage subtitles, audio and posters", context do + original = Application.get_env(:mave_core, :public_cdn_base_url) + Application.put_env(:mave_core, :public_cdn_base_url, "http://storage.example.test:9000") + + on_exit(fn -> + if original, + do: Application.put_env(:mave_core, :public_cdn_base_url, original), + else: Application.delete_env(:mave_core, :public_cdn_base_url) + end) + + {:ok, embed} = Embeds.create_video_embed(context.space, %{visibility: :private}) + + {:ok, embed} = + Embeds.begin_video_upload(context.space.hash, embed.hash, %{"title" => "video.mp4"}) + + video_id = embed.asset.current_video_id + + %Subtitle{} + |> Subtitle.changeset(%{ + video_id: video_id, + language: "en", + path: "#{embed.hash}/v1/subtitle_en.vtt" + }) + |> Repo.insert!() + + %AudioTrack{} + |> AudioTrack.changeset(%{ + video_id: video_id, + filename: "audio.mp3", + default: true + }) + |> Repo.insert!() + + token = sign(context.read_key, %{sub: context.space.hash <> embed.hash}) + base = "/api/v1/playback/media/#{context.space.hash}#{embed.hash}" + conn = Phoenix.ConnTest.get(build_conn(), base <> "/manifest.json", %{token: token}) + manifest = json_response(conn, 200) + assert [%{"path" => subtitle}] = manifest["subtitles"] + assert [%{"path" => audio}] = manifest["audio_tracks"] + + for {url, filename} <- [{subtitle, "v1/subtitle_en.vtt"}, {audio, "audio.mp3"}] do + uri = URI.parse(url) + assert uri.host == "www.example.com" + assert uri.path == base <> "/" <> filename + assert URI.decode_query(uri.query)["token"] == token + assert get(build_conn(), uri.path).status == 401 + conn = get(build_conn(), url) + assert conn.status == 302 + assert redirected_to(conn) =~ "/#{embed.hash}/#{filename}?expires=" + end + + for filename <- [ + "poster.jpg", + "thumbnail.jpg", + "storyboard.jpg", + "subtitle.json", + "h264_hd.mp4" + ] do + assert get(build_conn(), base <> "/v1/" <> filename).status == 401 + + assert Phoenix.ConnTest.get(build_conn(), base <> "/v1/" <> filename, %{token: token}).status == + 302 + end + + embed = Repo.preload(embed, :space) + foreign = "http://storage.example.test:9000/space-other/#{embed.hash}/v1/subtitle_en.vtt" + assert {:error, :invalid_path} = Media.resolve_path(foreign, embed, ".") + end + + defp api(key), + do: + build_conn() + |> put_req_header("authorization", "Bearer " <> Spaces.display_api_key(key.key, key.secret)) + + defp sign(key, claims) do + encode = fn value -> value |> Jason.encode!() |> Base.url_encode64(padding: false) end + input = encode.(%{alg: "HS256", typ: "JWT"}) <> "." <> encode.(claims) + signature = :crypto.mac(:hmac, :sha256, Spaces.display_api_key(key.key, key.secret), input) + input <> "." <> Base.url_encode64(signature, padding: false) + end +end diff --git a/test/mave_core_web/live/dashboard/videos/index_test.exs b/test/mave_core_web/live/dashboard/videos/index_test.exs index c3b8d2a..84a3404 100644 --- a/test/mave_core_web/live/dashboard/videos/index_test.exs +++ b/test/mave_core_web/live/dashboard/videos/index_test.exs @@ -45,6 +45,28 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.IndexTest do assert has_element?(view, "#videos-empty-state.min-h-full") end + test "/videos signs private thumbnails for the matching video", %{conn: conn} do + {conn, space} = authenticated_conn(conn) + video = video_embed_fixture(space, %{name: "Private overview thumbnail"}) + + video = + video + |> Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update!() + + %{videos: [row]} = MaveCore.Embeds.list_root_items(space) + uri = URI.parse(row.thumb) + assert uri.path == "/space-#{space.hash}/#{video.hash}/thumbnail.jpg" + query = URI.decode_query(uri.query) + assert query["X-Amz-Signature"] + assert query["X-Amz-Expires"] == "86400" + assert query["response-cache-control"] == "private, max-age=300" + + {:ok, view, _html} = live(conn, "/videos") + assert has_element?(view, ~s([style*="#{uri.path}?"][style*="X-Amz-Signature="])) + refute has_element?(view, ~s([style*="/api/v1/playback/media/"])) + end + test "/videos renders cache-busted custom thumbnails in overview rows", %{conn: conn} do {conn, space} = authenticated_conn(conn) video = video_embed_fixture(space, %{name: "Custom Overview Poster"}) diff --git a/test/mave_core_web/live/dashboard/videos/show_test.exs b/test/mave_core_web/live/dashboard/videos/show_test.exs index b11627f..c888986 100644 --- a/test/mave_core_web/live/dashboard/videos/show_test.exs +++ b/test/mave_core_web/live/dashboard/videos/show_test.exs @@ -24,6 +24,10 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.ShowTest do do: {:error, :visibility_failed} end + defmodule AvailablePlayback do + def available?(_space), do: true + end + setup do old_storage_adapter = Application.get_env(:mave_core, :flow_storage_adapter) @@ -48,6 +52,102 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.ShowTest do {:ok, db_name: db_name} end + @tag :private_playback_ui + test "playback visibility actions are only shown when available for the space", %{conn: conn} do + original_adapter = Application.get_env(:mave_core, :playback_adapter) + on_exit(fn -> restore_env(:playback_adapter, original_adapter) end) + {conn, space} = authenticated_conn(conn) + embed = video_embed_fixture(space, %{name: "Playback visibility"}) + + for visibility <- [:public, :private], available? <- [false, true] do + embed + |> Ecto.Changeset.change(playback_visibility: visibility, playback_status: visibility) + |> Repo.update!() + + Application.put_env(:mave_core, :playback_adapter, if(available?, do: AvailablePlayback)) + {:ok, view, _html} = live(conn, "/videos/#{embed.id}") + + assert has_element?(view, "#video-playback-visibility") == available? + end + end + + @tag :private_playback_ui + test "private videos explain token setup and display token-aware embed code", %{conn: conn} do + {conn, space} = authenticated_conn(conn) + embed = video_embed_fixture(space, %{name: "Private embed setup"}) + + embed + |> Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update!() + + {:ok, view, _html} = live(conn, "/videos/#{embed.id}") + refute has_element?(view, "#video-access-status") + assert has_element?(view, "#private-playback-guide", "Dashboard previews work automatically") + assert has_element?(view, "#snippet-code", "YOUR_PLAYBACK_TOKEN") + assert has_element?(view, "#snippet-code", ~s(token="YOUR_PLAYBACK_TOKEN")) + refute has_element?(view, "#private-playback-server-help") + refute has_element?(view, ~s([phx-value-preview="iframe"])) + + view |> element(~s([phx-value-preview="react"])) |> render_click() + assert has_element?(view, "#snippet-code", "token={playbackToken}") + view |> element(~s([phx-value-preview="vue"])) |> render_click() + assert has_element?(view, "#snippet-code", ~s(:token="playbackToken")) + view |> element(~s([phx-value-preview="clip"])) |> render_click() + + assert has_element?( + view, + "#snippet-code", + ~s( Ecto.Changeset.change(playback_visibility: :private, playback_status: :private) + |> Repo.update!() + + {:ok, view, _html} = live(conn, "/videos/#{embed.id}") + + player_id = fn -> + view + |> element("#video-components-loader") + |> render() + |> LazyHTML.from_fragment() + |> LazyHTML.filter("#video-components-loader") + |> LazyHTML.attribute("data-playback-player") + |> List.first() + end + + original_id = player_id.() + assert is_binary(original_id) + assert has_element?(view, "##{original_id} mave-player") + + view + |> element(~s([phx-value-title="controls"][phx-value-label="big"])) + |> render_click() + + replacement_id = player_id.() + assert is_binary(replacement_id) + refute replacement_id == original_id + assert has_element?(view, ~s(##{replacement_id} mave-player[controls="big"])) + assert has_element?(view, ~s(#video-components-loader[data-playback-status="private"])) + end + + @tag :private_playback_ui + test "public videos retain the ordinary embed code and iframe option", %{conn: conn} do + {conn, space} = authenticated_conn(conn) + embed = video_embed_fixture(space, %{name: "Public embed setup"}) + {:ok, view, _html} = live(conn, "/videos/#{embed.id}") + refute has_element?(view, "#video-access-status") + assert has_element?(view, ~s([phx-value-preview="iframe"])) + refute has_element?(view, "#private-playback-guide") + refute has_element?(view, "#snippet-code", "YOUR_PLAYBACK_TOKEN") + end + test "/videos/:id renders folder mode when embed type is collection", %{conn: conn} do {conn, space} = authenticated_conn(conn) folder = folder_embed_fixture(space, %{name: "Folder Mode"}) @@ -2773,6 +2873,7 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.ShowTest do end defp with_default_component_runtime(fun) do + original_playback_origin = Application.get_env(:mave_core, :playback_origin) original_domain = Application.get_env(:mave_core, :domain) original_upload = Application.get_env(:mave_core, :upload) original_cdn_host = Application.get_env(:mave_core, :public_cdn_host) @@ -2784,6 +2885,7 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.ShowTest do original_metrics_host = System.get_env("MAVE_METRICS_HOST") try do + Application.put_env(:mave_core, :playback_origin, "https://signed.video-dns.com") Application.put_env(:mave_core, :domain, "https://dash.mave.io") Application.put_env(:mave_core, :upload, @@ -2805,6 +2907,7 @@ defmodule MaveCoreWeb.Live.Dashboard.Videos.ShowTest do fun.() after + restore_env(:playback_origin, original_playback_origin) restore_env(:domain, original_domain) restore_env(:upload, original_upload) restore_env(:public_cdn_host, original_cdn_host)