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 =