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")}
+
+
+