RSS feed for sources (#110)
* Add media streaming (#108) * [WIP] set up streaming route * Added UUID to sources and media items * Added media preview to MI show page * Added plug to strip file extensions * [VERY WIP] basic podcast RSS setup * [WIP] got basic podcast RSS working * [WIP] more expanding on RSS * Comment * [WIP] Working on refactoring feed * Added UUID backfill to a migration * [WIP] Moar refactoring * [WIP] Adding UI for getting RSS feed * Many tests * Added conditional routing for feed URLs * Removed the need for url_base to be set * Updated preset name * Rendered certain fields HTML-safe; Added logging to confirm range request support * Fixed incorrect scheme issue * Updated env var * Updated other UI to use dropdown * removed commented code * Generate rss feeds (#123) * Added plug to strip file extensions * [VERY WIP] basic podcast RSS setup * [WIP] got basic podcast RSS working * [WIP] more expanding on RSS * [WIP] Working on refactoring feed * Added UUID backfill to a migration * [WIP] Moar refactoring * [WIP] Adding UI for getting RSS feed * Many tests * Added conditional routing for feed URLs * Removed the need for url_base to be set * Updated preset name * Rendered certain fields HTML-safe; Added logging to confirm range request support * Fixed incorrect scheme issue * Updated env var * Updated other UI to use dropdown * removed commented code * docs * Added unique index to UUID fields
This commit is contained in:
@@ -14,7 +14,7 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
|
||||
import PinchflatWeb.Gettext
|
||||
|
||||
@@ -654,10 +654,11 @@ defmodule PinchflatWeb.CoreComponents do
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
<span class={[@name, @class]} {@rest} />
|
||||
"""
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||
@moduledoc false
|
||||
use Phoenix.Component
|
||||
use Phoenix.Component, global_prefixes: ~w(x-)
|
||||
|
||||
alias PinchflatWeb.CoreComponents
|
||||
|
||||
@doc """
|
||||
Render a button
|
||||
@@ -39,4 +41,52 @@ defmodule PinchflatWeb.CustomComponents.ButtonComponents do
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Render a dropdown based off a button
|
||||
|
||||
## Examples
|
||||
|
||||
<.button_dropdown text="Actions">
|
||||
<:option>TEST</:option>
|
||||
</.button_dropdown>
|
||||
"""
|
||||
attr :text, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
slot :option, required: true
|
||||
|
||||
def button_dropdown(assigns) do
|
||||
~H"""
|
||||
<div x-data="{ dropdownOpen: false }" class={["relative flex", @class]}>
|
||||
<span
|
||||
x-on:click.prevent="dropdownOpen = !dropdownOpen"
|
||||
class={[
|
||||
"cursor-pointer inline-flex gap-2.5 rounded-md bg-primary px-5.5 py-3",
|
||||
"font-medium text-white hover:bg-opacity-95"
|
||||
]}
|
||||
>
|
||||
<%= @text %>
|
||||
<CoreComponents.icon
|
||||
name="hero-chevron-down"
|
||||
class="fill-current duration-200 ease-linear mt-1"
|
||||
x-bind:class="dropdownOpen && 'rotate-180'"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
x-show="dropdownOpen"
|
||||
x-on:click.outside="dropdownOpen = false"
|
||||
class="absolute left-0 top-full z-40 mt-2 w-full rounded-md bg-black py-3 shadow-card"
|
||||
>
|
||||
<ul class="flex flex-col">
|
||||
<li :for={option <- @option}>
|
||||
<span class="flex px-5 py-2 font-medium text-bodydark2 hover:text-white cursor-pointer">
|
||||
<%= render_slot(option) %>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,24 +9,31 @@ defmodule PinchflatWeb.CustomComponents.TabComponents do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
slot :tab_append, required: false
|
||||
|
||||
def tabbed_layout(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10">
|
||||
<a
|
||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||
href="#"
|
||||
@click.prevent={"openTab = #{idx}"}
|
||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base"
|
||||
>
|
||||
<span class="text-xl"><%= tab.title %></span>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<header class="flex flex-col md:flex-row md:justify-between border-b border-strokedark">
|
||||
<div class="flex flex-wrap gap-5 sm:gap-10">
|
||||
<a
|
||||
:for={{tab, idx} <- Enum.with_index(@tab)}
|
||||
href="#"
|
||||
@click.prevent={"openTab = #{idx}"}
|
||||
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
|
||||
class="border-b-2 py-4 w-full sm:w-fit text-sm font-medium hover:text-meta-5 md:text-base"
|
||||
>
|
||||
<span class="text-xl"><%= tab.title %></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mx-4 my-4 lg:my-0 flex gap-5 sm:gap-10 items-center">
|
||||
<%= render_slot(@tab_append) %>
|
||||
</div>
|
||||
</header>
|
||||
<div class="mt-4">
|
||||
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
|
||||
<%= render_slot(tab) %>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
defmodule PinchflatWeb.MediaItems.MediaItemController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
require Logger
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Media.MediaItem
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
media_item =
|
||||
@@ -29,4 +32,69 @@ 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/
|
||||
#
|
||||
# Uses the UUID instead of the ID to avoid enumeration attacks
|
||||
# since streaming is a public endpoint (ie: no auth required)
|
||||
def stream(conn, %{"uuid" => uuid}) do
|
||||
media_item = Repo.get_by!(MediaItem, uuid: uuid)
|
||||
|
||||
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}} ->
|
||||
Logger.debug("Streaming media item: #{media_item.uuid} from #{start_pos} to #{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} ->
|
||||
Logger.debug("Invalid range request for media item: #{media_item.uuid} - serving full file")
|
||||
|
||||
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
|
||||
|
||||
@@ -2,4 +2,16 @@ defmodule PinchflatWeb.MediaItems.MediaItemHTML do
|
||||
use PinchflatWeb, :html
|
||||
|
||||
embed_templates "media_item_html/*"
|
||||
|
||||
def media_file_exists?(media_item) do
|
||||
!!media_item.media_filepath and File.exists?(media_item.media_filepath)
|
||||
end
|
||||
|
||||
def media_type(media_item) do
|
||||
case Path.extname(media_item.media_filepath) do
|
||||
ext when ext in [".mp4", ".webm", ".mkv"] -> :video
|
||||
ext when ext in [".mp3", ".m4a"] -> :audio
|
||||
_ -> :unknown
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<%= if media_type(@media_item) == :video do %>
|
||||
<video controls class="max-h-96 w-full lg:w-2/3 xl:w-1/2">
|
||||
<source src={~p"/media/#{@media_item.uuid}/stream"} type="video/mp4" />
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
<% end %>
|
||||
|
||||
<%= if media_type(@media_item) == :audio do %>
|
||||
<audio controls class="w-full lg:w-2/3 xl:w-1/2">
|
||||
<source src={~p"/media/#{@media_item.uuid}/stream"} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
<% end %>
|
||||
@@ -8,11 +8,30 @@
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
||||
>
|
||||
Delete Files
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Attributes">
|
||||
<div class="flex flex-col gap-10 dark:text-white">
|
||||
<%= if media_file_exists?(@media_item) do %>
|
||||
<h3 class="font-bold text-xl">Preview</h3>
|
||||
<.media_preview media_item={@media_item} />
|
||||
<% end %>
|
||||
|
||||
<h3 class="font-bold text-xl">Attributes</h3>
|
||||
<section>
|
||||
<strong>Source:</strong>
|
||||
@@ -23,18 +42,6 @@
|
||||
|
||||
<.list_items_from_map map={Map.from_struct(@media_item)} />
|
||||
</div>
|
||||
|
||||
<section class="flex justify-center my-10">
|
||||
<.link
|
||||
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Files
|
||||
</.button>
|
||||
</.link>
|
||||
</section>
|
||||
</:tab>
|
||||
<:tab title="Tasks">
|
||||
<%= if match?([_|_], @media_item.tasks) do %>
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
presets: {
|
||||
default: 'Default',
|
||||
media_center: 'TV Shows',
|
||||
audio: 'Audio',
|
||||
audio: 'Music',
|
||||
archiving: 'Archiving'
|
||||
}
|
||||
}">
|
||||
|
||||
@@ -19,33 +19,35 @@
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
Delete Profile
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
Delete Profile + Files
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Attributes">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
|
||||
<.list_items_from_map map={Map.from_struct(@media_profile)} />
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col md:flex-row items-center md:justify-around my-10">
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Profile and its Sources
|
||||
</.button>
|
||||
</.link>
|
||||
<.link
|
||||
href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Profile, Sources, and Files
|
||||
</.button>
|
||||
</.link>
|
||||
</section>
|
||||
</:tab>
|
||||
<:tab title="Sources">
|
||||
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
defmodule PinchflatWeb.Podcasts.PodcastController do
|
||||
use PinchflatWeb, :controller
|
||||
|
||||
alias Pinchflat.Repo
|
||||
alias Pinchflat.Media
|
||||
alias Pinchflat.Sources.Source
|
||||
alias Pinchflat.Podcasts.RssFeedBuilder
|
||||
alias Pinchflat.Podcasts.PodcastHelpers
|
||||
|
||||
def rss_feed(conn, %{"uuid" => uuid}) do
|
||||
source = Repo.get_by!(Source, uuid: uuid)
|
||||
url_base = url(conn, ~p"/")
|
||||
xml = RssFeedBuilder.build(source, limit: 300, url_base: url_base)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/rss+xml")
|
||||
|> put_resp_header("content-disposition", "inline")
|
||||
|> send_resp(200, xml)
|
||||
end
|
||||
|
||||
def feed_image(conn, %{"uuid" => uuid}) do
|
||||
source = Repo.get_by!(Source, uuid: uuid)
|
||||
# This provides a fallback image if the source has none.
|
||||
# We only need one since we're using the internal metadata image which
|
||||
# we know exists.
|
||||
media_items = Media.list_downloaded_media_items_for(source, limit: 1)
|
||||
|
||||
case PodcastHelpers.select_cover_image(source, media_items) do
|
||||
{:error, _} ->
|
||||
send_resp(conn, 404, "Image not found")
|
||||
|
||||
{:ok, filepath} ->
|
||||
conn
|
||||
|> put_resp_content_type(MIME.from_path(filepath))
|
||||
|> send_file(200, filepath)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,4 +24,8 @@ defmodule PinchflatWeb.Sources.SourceHTML do
|
||||
{"Monthly", 30 * 24 * 60}
|
||||
]
|
||||
end
|
||||
|
||||
def rss_feed_url(conn, source) do
|
||||
url(conn, ~p"/sources/#{source.uuid}/feed") <> ".xml"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,45 @@
|
||||
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
|
||||
<div class="max-w-full overflow-x-auto">
|
||||
<.tabbed_layout>
|
||||
<:tab_append>
|
||||
<.button_dropdown text="Actions" class="justify-center w-full sm:w-50">
|
||||
<:option>
|
||||
<span
|
||||
x-data="{ copied: false }"
|
||||
x-on:click={"
|
||||
navigator.clipboard.writeText('#{rss_feed_url(@conn, @source)}');
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 4000);
|
||||
"}
|
||||
>
|
||||
Copy RSS Feed <span x-show="copied"><.icon name="hero-check" class="ml-2 h-4 w-4" /></span>
|
||||
</span>
|
||||
</:option>
|
||||
<:option>
|
||||
<div class="h-px w-full bg-bodydark2"></div>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@source}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
Delete Source
|
||||
</.link>
|
||||
</:option>
|
||||
<:option>
|
||||
<.link
|
||||
href={~p"/sources/#{@source}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
Delete Source + Files
|
||||
</.link>
|
||||
</:option>
|
||||
</.button_dropdown>
|
||||
</:tab_append>
|
||||
|
||||
<:tab title="Attributes">
|
||||
<div class="flex flex-col gap-10 text-white">
|
||||
<h3 class="font-bold text-lg">Attributes</h3>
|
||||
@@ -31,28 +70,6 @@
|
||||
|
||||
<.list_items_from_map map={Map.from_struct(@source)} />
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
|
||||
<.link
|
||||
href={~p"/sources/#{@source}"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Source
|
||||
</.button>
|
||||
</.link>
|
||||
<.link
|
||||
href={~p"/sources/#{@source}?delete_files=true"}
|
||||
method="delete"
|
||||
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
|
||||
class="mt-5 md:mt-0"
|
||||
>
|
||||
<.button color="bg-meta-1" rounding="rounded-lg">
|
||||
Delete Source and Files
|
||||
</.button>
|
||||
</.link>
|
||||
</section>
|
||||
</:tab>
|
||||
<:tab title="Pending Media">
|
||||
<%= if match?([_|_], @pending_media) do %>
|
||||
|
||||
@@ -47,5 +47,54 @@ defmodule PinchflatWeb.Endpoint do
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
|
||||
plug :override_base_url
|
||||
plug :strip_trailing_extension
|
||||
|
||||
plug PinchflatWeb.Router
|
||||
|
||||
# URLs need to be generated using the host of the current page being accessed
|
||||
# for things like Podcast RSS feeds to contain links to the right location.
|
||||
#
|
||||
# Normally you'd set the `url` option in the Endpoint configuration, but
|
||||
# since this is self-hosted and often accessed at multiple different URLs,
|
||||
# that would probably be more difficult for end-users to set up than just
|
||||
# having the application figure it out.
|
||||
defp override_base_url(conn, _opts) do
|
||||
new_scheme =
|
||||
case get_req_header(conn, "x-forwarded-proto") do
|
||||
[scheme] -> scheme
|
||||
_ -> to_string(conn.scheme)
|
||||
end
|
||||
|
||||
new_port = if conn.port in [80, 443], do: "", else: ":#{conn.port}"
|
||||
new_base_url = "#{new_scheme}://#{conn.host}#{new_port}"
|
||||
|
||||
Phoenix.Controller.put_router_url(conn, new_base_url)
|
||||
end
|
||||
|
||||
defp strip_trailing_extension(%{path_info: []} = conn, _opts), do: conn
|
||||
|
||||
defp strip_trailing_extension(conn, _opts) do
|
||||
path =
|
||||
conn.path_info
|
||||
|> List.last()
|
||||
|> String.split(".")
|
||||
|> Enum.reverse()
|
||||
|
||||
case path do
|
||||
[_] ->
|
||||
conn
|
||||
|
||||
[_format | fragments] ->
|
||||
new_path =
|
||||
fragments
|
||||
|> Enum.reverse()
|
||||
|> Enum.join(".")
|
||||
|
||||
path_fragments = List.replace_at(conn.path_info, -1, new_path)
|
||||
|
||||
%{conn | path_info: path_fragments}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
defmodule PinchflatWeb.Router do
|
||||
use PinchflatWeb, :router
|
||||
|
||||
# IMPORTANT: `strip_trailing_extension` in endpoint.ex removes
|
||||
# the extension from the path
|
||||
pipeline :browser do
|
||||
plug :basic_auth
|
||||
plug :accepts, ["html"]
|
||||
@@ -15,6 +17,10 @@ defmodule PinchflatWeb.Router do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
pipeline :feeds do
|
||||
plug :maybe_basic_auth
|
||||
end
|
||||
|
||||
scope "/", PinchflatWeb do
|
||||
pipe_through :browser
|
||||
|
||||
@@ -28,10 +34,16 @@ defmodule PinchflatWeb.Router do
|
||||
end
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", PinchflatWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
# Routes in here _may not be_ protected by basic auth. This is necessary for
|
||||
# media streaming to work for RSS podcast feeds.
|
||||
scope "/", PinchflatWeb do
|
||||
pipe_through :feeds
|
||||
|
||||
get "/sources/:uuid/feed", Podcasts.PodcastController, :rss_feed
|
||||
get "/sources/:uuid/feed_image", Podcasts.PodcastController, :feed_image
|
||||
|
||||
get "/media/:uuid/stream", MediaItems.MediaItemController, :stream
|
||||
end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:pinchflat, :dev_routes) do
|
||||
@@ -50,6 +62,14 @@ defmodule PinchflatWeb.Router do
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_basic_auth(conn, opts) do
|
||||
if Application.get_env(:pinchflat, :expose_feed_endpoints) do
|
||||
conn
|
||||
else
|
||||
basic_auth(conn, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp basic_auth(conn, _opts) do
|
||||
username = Application.get_env(:pinchflat, :basic_auth_username)
|
||||
password = Application.get_env(:pinchflat, :basic_auth_password)
|
||||
|
||||
Reference in New Issue
Block a user