diff --git a/.sobelow-conf b/.sobelow-conf index ed5bb1f..0c4088d 100644 --- a/.sobelow-conf +++ b/.sobelow-conf @@ -9,7 +9,15 @@ threshold: :low, # All of these are ignorable because this app is intended to be single-user and self-hosted. # There is an expectation that the user won't intentionally run a FS Traversal on themselves - ignore: ["CI.System", "Traversal.FileModule", "Config.HTTPS", "Config.CSP"], + ignore: + [ + "CI.System", + "Traversal.FileModule", + "Config.HTTPS", + "Config.CSP", + "XSS.ContentType", + "Traversal.SendFile" + ], ignore_files: [], version: false ] diff --git a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex index 95847dd..56f8f7d 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_controller.ex +++ b/lib/pinchflat_web/controllers/media_items/media_item_controller.ex @@ -29,4 +29,68 @@ defmodule PinchflatWeb.MediaItems.MediaItemController do |> put_flash(:info, flash_message) |> redirect(to: ~p"/sources/#{media_item.source_id}") end + + # See here for details on streaming files and range requests: + # https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/ + def stream(conn, %{"id" => id}) do + media_item = Media.get_media_item!(id) + + # TODO: show audio vs. video element in UI depending on media type + # TODO: consider how a podcast RSS feed would interact with HTTP basic auth + # TODO: reconsider the sobelow changes I made + # TODO: UUID stuff + + if File.exists?(media_item.media_filepath) do + file_size = File.stat!(media_item.media_filepath).size + mime_type = MIME.from_path(media_item.media_filepath) + + case parse_range(conn, file_size) do + {:ok, {start_pos, end_pos}} -> + length = end_pos - start_pos + 1 + + conn + |> put_resp_content_type(mime_type) + |> put_resp_header("accept-ranges", "bytes") + |> put_resp_header("content-range", "bytes #{start_pos}-#{end_pos}/#{file_size}") + |> put_resp_header("content-length", to_string(length)) + |> send_file(206, media_item.media_filepath, start_pos, length) + + {:error, :invalid_range} -> + conn + |> put_resp_content_type(mime_type) + |> put_resp_header("content-length", to_string(file_size)) + |> put_resp_header("accept-ranges", "bytes") + |> send_file(200, media_item.media_filepath) + end + else + send_resp(conn, 404, "File not found") + end + end + + defp parse_range(conn, file_size) do + with [range_header | _] <- get_req_header(conn, "range"), + ["bytes", range] <- String.split(range_header, "="), + [start_pos, end_pos] <- String.split(range, "-") do + validate_range(start_pos, end_pos, file_size) + else + _ -> {:error, :invalid_range} + end + end + + defp validate_range(start_pos, end_pos, file_size) do + case {Integer.parse(start_pos), Integer.parse(end_pos)} do + {:error, :error} -> + {:error, :invalid_range} + + {{start_pos, _}, :error} -> + {:ok, {start_pos, file_size - 1}} + + # See RFC7233 + {{start_pos, _}, {end_pos, _}} when end_pos >= file_size -> + {:ok, {start_pos, file_size - 1}} + + {{start_pos, _}, {end_pos, _}} -> + {:ok, {start_pos, end_pos}} + end + end end diff --git a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex index 660c179..3d379e5 100644 --- a/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex +++ b/lib/pinchflat_web/controllers/media_items/media_item_html/show.html.heex @@ -13,6 +13,14 @@ <.tabbed_layout> <:tab title="Attributes">
+ <%!-- --%> +

Attributes

Source: diff --git a/lib/pinchflat_web/router.ex b/lib/pinchflat_web/router.ex index e68e799..a45efa2 100644 --- a/lib/pinchflat_web/router.ex +++ b/lib/pinchflat_web/router.ex @@ -28,10 +28,11 @@ defmodule PinchflatWeb.Router do end end - # Other scopes may use custom stacks. - # scope "/api", PinchflatWeb do - # pipe_through :api - # end + # Routes in here are NOT protected by basic auth. This is necessary for + # media streaming to work for RSS podcast feeds. + scope "/", PinchflatWeb do + get "/media/:id/stream", MediaItems.MediaItemController, :stream + end # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:pinchflat, :dev_routes) do diff --git a/test/pinchflat_web/controllers/media_item_controller_test.exs b/test/pinchflat_web/controllers/media_item_controller_test.exs index 1f50462..71992a4 100644 --- a/test/pinchflat_web/controllers/media_item_controller_test.exs +++ b/test/pinchflat_web/controllers/media_item_controller_test.exs @@ -66,6 +66,147 @@ defmodule PinchflatWeb.MediaItemControllerTest do end end + describe "streaming media" do + test "returns 404 if the media isn't found", %{conn: conn} do + media_item = media_item_fixture() + conn = get(conn, ~p"/media/#{media_item.id}/stream") + + assert conn.status == 404 + end + + test "automatically sets the content type", %{conn: conn} do + media_item = media_item_with_attachments() + conn = get(conn, ~p"/media/#{media_item.id}/stream") + + assert {"content-type", "video/mp4; charset=utf-8"} in conn.resp_headers + end + + test "sets the content length", %{conn: conn} do + media_item = media_item_with_attachments() + filesize = File.stat!(media_item.media_filepath).size + + conn = get(conn, ~p"/media/#{media_item.id}/stream") + + assert {"content-length", to_string(filesize)} in conn.resp_headers + end + end + + describe "streaming media when range is valid" do + setup do + media_item = media_item_with_attachments() + + %{media_item: media_item} + end + + test "sets the correct status and headers", %{conn: conn, media_item: media_item} do + filesize = File.stat!(media_item.media_filepath).size + + conn = + conn + |> put_req_header("range", "bytes=0-100") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.status == 206 + assert {"content-range", "bytes 0-100/#{filesize}"} in conn.resp_headers + assert {"content-length", "101"} in conn.resp_headers + end + + test "streams the specified range", %{conn: conn, media_item: media_item} do + conn = + conn + |> put_req_header("range", "bytes=0-100") + |> get(~p"/media/#{media_item.id}/stream") + + assert byte_size(conn.resp_body) == 101 + end + + test "supports range offsets", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + expected = String.slice(contents, 100..200) + + conn = + conn + |> put_req_header("range", "bytes=100-200") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.resp_body == expected + end + + test "returns as expected if the requested range is larger than the file", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + filesize = File.stat!(media_item.media_filepath).size + + conn = + conn + |> put_req_header("range", "bytes=0-#{filesize * 10}") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.resp_body == contents + assert {"content-range", "bytes 0-#{filesize - 1}/#{filesize}"} in conn.resp_headers + assert {"content-length", to_string(filesize)} in conn.resp_headers + end + + test "supports endless ranges", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + + conn = + conn + |> put_req_header("range", "bytes=0-") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.resp_body == contents + end + + test "supports endless ranges with offsets", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + {_, expected} = String.split_at(contents, 100) + + conn = + conn + |> put_req_header("range", "bytes=100-") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.resp_body == expected + end + end + + describe "streaming media when range is invalid or not present" do + setup do + media_item = media_item_with_attachments() + + %{media_item: media_item} + end + + test "sets the correct status and headers", %{conn: conn, media_item: media_item} do + filesize = File.stat!(media_item.media_filepath).size + + conn = get(conn, ~p"/media/#{media_item.id}/stream") + + assert conn.status == 200 + assert {"content-length", to_string(filesize)} in conn.resp_headers + end + + test "streams the entire file", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + + conn = get(conn, ~p"/media/#{media_item.id}/stream") + + assert conn.resp_body == contents + end + + test "doesn't blow up if the range header is invalid", %{conn: conn, media_item: media_item} do + contents = File.read!(media_item.media_filepath) + + conn = + conn + |> put_req_header("range", "bytes=-") + |> get(~p"/media/#{media_item.id}/stream") + + assert conn.status == 200 + assert conn.resp_body == contents + end + end + defp create_media_item(_) do media_item = media_item_fixture() %{media_item: media_item} diff --git a/test/support/fixtures/media_fixtures.ex b/test/support/fixtures/media_fixtures.ex index ce0a3a7..a9cf75d 100644 --- a/test/support/fixtures/media_fixtures.ex +++ b/test/support/fixtures/media_fixtures.ex @@ -49,7 +49,7 @@ defmodule Pinchflat.MediaFixtures do Path.join([ Application.get_env(:pinchflat, :media_directory), "#{:rand.uniform(1_000_000)}", - "#{:rand.uniform(1_000_000)}_media.mkv" + "#{:rand.uniform(1_000_000)}_media.mp4" ]) fixture_media_filepath =