Packages

phoenix_kit

1.7.128
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules sitemap web controller.ex
Raw

lib/modules/sitemap/web/controller.ex

defmodule PhoenixKit.Modules.Sitemap.Web.Controller do
@moduledoc """
Controller for serving XML sitemaps with XSL styling.
## Endpoints
- GET /{prefix}/sitemap.xml - sitemapindex (always)
- GET /{prefix}/sitemap.xml?format=html - Server-rendered HTML (for iframe previews)
- GET /{prefix}/sitemap.html - Redirects to sitemap.xml (deprecated)
- GET /{prefix}/sitemaps/:filename - Per-module sitemap files
- GET /{prefix}/assets/sitemap/:style - XSL stylesheets for urlset files
- GET /{prefix}/assets/sitemap-index/:style - XSL stylesheets for sitemapindex
## Architecture
`/sitemap.xml` always returns a `<sitemapindex>` referencing per-module
sitemap files at `/sitemaps/sitemap-{source}.xml`. Each module file
contains a `<urlset>` with URLs from that source.
"""
use PhoenixKitWeb, :controller
require Logger
alias PhoenixKit.Modules.Sitemap
alias PhoenixKit.Modules.Sitemap.FileStorage
alias PhoenixKit.Modules.Sitemap.Generator
@cache_max_age 3600
@valid_xsl_styles ["table", "minimal"]
# Only allow safe characters in filenames
@filename_pattern ~r/^[a-z0-9-]+$/
@doc """
Serves the sitemapindex XML.
Always returns a `<sitemapindex>`. Generate on first request if missing.
Query parameters:
- `style` - Override XSL style (table, minimal)
- `format=html` - Force server-side HTML rendering (for iframe previews)
"""
def xml(conn, params) do
if Sitemap.enabled?() do
config = Sitemap.get_config()
xsl_style =
case Map.get(params, "style") do
style when style in @valid_xsl_styles -> style
_ -> get_xsl_style(config)
end
if Map.get(params, "format") == "html" do
serve_html(conn, config, xsl_style)
else
serve_index_xml(conn, xsl_style)
end
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "Sitemap not available")
end
end
@doc """
Serves per-module sitemap files from /sitemaps/:filename.
Filename is validated to contain only `[a-z0-9-]` characters.
The `.xml` extension is appended automatically.
"""
def module_sitemap(conn, %{"filename" => raw_filename}) do
cond do
!Sitemap.enabled?() ->
send_plain_error(conn, 404, "Sitemap not available")
Sitemap.flat_mode?() ->
send_plain_error(
conn,
404,
"Per-module sitemaps not available in flat mode. Use /sitemap.xml"
)
true ->
# Strip .xml extension if provided in URL
filename = String.trim_trailing(raw_filename, ".xml")
if Regex.match?(@filename_pattern, filename) do
serve_module_file(conn, filename)
else
send_plain_error(conn, 400, "Invalid filename")
end
end
end
@doc """
Redirects to XML sitemap (deprecated).
"""
def html(conn, _params) do
prefix = PhoenixKit.Config.get_url_prefix()
redirect(conn, to: "#{prefix}/sitemap.xml")
end
@doc """
Serves XSL stylesheet files for urlset display.
Available styles: table, minimal
"""
def xsl_stylesheet(conn, %{"style" => style}) when style in @valid_xsl_styles do
serve_xsl_file(conn, "sitemap-#{style}.xsl")
end
def xsl_stylesheet(conn, _params) do
serve_xsl_file(conn, "sitemap-table.xsl")
end
@doc """
Serves XSL stylesheet files for sitemapindex display.
"""
def xsl_index_stylesheet(conn, %{"style" => style}) when style in @valid_xsl_styles do
serve_xsl_file(conn, "sitemap-index-#{style}.xsl")
end
def xsl_index_stylesheet(conn, _params) do
serve_xsl_file(conn, "sitemap-index-table.xsl")
end
@doc """
Legacy: Serves sitemap index part files.
Kept for backward compatibility. New architecture uses /sitemaps/:filename.
"""
def index_part(conn, %{"index" => index_str}) do
if Sitemap.enabled?() do
serve_legacy_index_part(conn, index_str)
else
send_plain_error(conn, 404, "Sitemap not available")
end
end
# ── Private: Index serving ─────────────────────────────────────────
defp serve_index_xml(conn, xsl_style) do
if FileStorage.index_exists?() do
serve_file_with_etag(conn, FileStorage.file_path(), FileStorage.get_file_stat())
else
generate_and_serve_index(conn, xsl_style)
end
end
defp generate_and_serve_index(conn, xsl_style) do
base_url = Sitemap.get_base_url()
if base_url == "" do
Logger.warning("SitemapController: Base URL not configured")
conn
|> put_resp_content_type("text/plain")
|> send_resp(503, "Sitemap not configured. Please set base URL in settings.")
else
opts = [base_url: base_url, xsl_style: xsl_style, xsl_enabled: true]
case Generator.generate_all(opts) do
{:ok, %{index_xml: xml, total_urls: url_count, modules: modules}} ->
Sitemap.update_generation_stats(%{url_count: url_count})
Sitemap.update_module_stats(modules)
etag = generate_content_etag(xml)
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> put_resp_header("x-sitemap-url-count", to_string(url_count))
|> send_resp(200, xml)
{:error, reason} ->
Logger.error("SitemapController: Generation failed: #{inspect(reason)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(500, "Failed to generate sitemap")
end
end
end
# ── Private: Module file serving ───────────────────────────────────
defp serve_module_file(conn, filename) do
if FileStorage.module_exists?(filename) do
path = FileStorage.module_file_path(filename)
stat = FileStorage.get_module_stat(filename)
serve_file_with_etag(conn, path, stat)
else
# Try generating on demand
generate_module_on_demand(conn, filename)
end
end
defp generate_module_on_demand(conn, filename) do
base_url = Sitemap.get_base_url()
if base_url == "" do
send_plain_error(conn, 503, "Sitemap not configured")
else
opts = [base_url: base_url, xsl_style: "table", xsl_enabled: true]
case Generator.generate_all(opts) do
{:ok, %{total_urls: url_count, modules: modules}} ->
Sitemap.update_generation_stats(%{url_count: url_count})
Sitemap.update_module_stats(modules)
if FileStorage.module_exists?(filename) do
path = FileStorage.module_file_path(filename)
stat = FileStorage.get_module_stat(filename)
serve_file_with_etag(conn, path, stat)
else
send_plain_error(conn, 404, "Module sitemap not found")
end
{:error, _} ->
send_plain_error(conn, 500, "Failed to generate sitemap")
end
end
end
# ── Private: Shared file serving with ETag ─────────────────────────
defp serve_file_with_etag(conn, path, stat_result) do
etag = generate_etag_from_stat(stat_result)
if client_has_fresh_cache?(conn, etag) do
send_not_modified(conn, etag)
else
case File.read(path) do
{:ok, content} ->
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> send_resp(200, content)
{:error, _} ->
send_plain_error(conn, 404, "File not found")
end
end
end
# ── Private: HTML serving ──────────────────────────────────────────
defp serve_html(conn, _config, xsl_style) do
base_url = Sitemap.get_base_url()
if base_url == "" do
conn
|> put_resp_content_type("text/plain")
|> send_resp(503, "Sitemap not configured. Please set base URL in settings.")
else
html_style = xsl_to_html_style(xsl_style)
opts = [base_url: base_url, cache: false, style: html_style]
case Generator.generate_html(opts) do
{:ok, html_content} ->
etag = generate_content_etag(html_content)
conn
|> put_resp_content_type("text/html")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> put_resp_header("etag", etag)
|> send_resp(200, html_content)
{:error, reason} ->
Logger.error("SitemapController: HTML generation failed: #{inspect(reason)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(500, "Failed to generate HTML sitemap")
end
end
end
# ── Private: XSL serving ───────────────────────────────────────────
defp serve_xsl_file(conn, xsl_filename) do
xsl_path = Application.app_dir(:phoenix_kit, "priv/static/assets/#{xsl_filename}")
if File.exists?(xsl_path) do
content = File.read!(xsl_path)
conn
|> put_resp_content_type("application/xslt+xml")
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_resp(200, content)
else
conn
|> put_resp_content_type("text/plain")
|> send_resp(404, "Stylesheet not found")
end
end
# ── Private: Legacy index part ─────────────────────────────────────
defp serve_legacy_index_part(conn, index_str) do
# Try to serve as a module filename first (new architecture)
filename = String.trim_trailing(index_str, ".xml")
if Regex.match?(@filename_pattern, filename) and FileStorage.module_exists?(filename) do
serve_module_file(conn, filename)
else
# Fall back to legacy numbered parts
case Integer.parse(index_str) do
{index, ""} when index > 0 ->
case Generator.get_sitemap_part(index) do
{:ok, xml_content} ->
conn
|> put_resp_content_type("application/xml")
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> send_resp(200, xml_content)
{:error, :not_found} ->
send_plain_error(conn, 404, "Sitemap part not found")
end
_ ->
send_plain_error(conn, 400, "Invalid sitemap index")
end
end
end
# ── Private: Helpers ───────────────────────────────────────────────
defp xsl_to_html_style("table"), do: "grouped"
defp xsl_to_html_style("minimal"), do: "flat"
defp xsl_to_html_style(_), do: "hierarchical"
defp client_has_fresh_cache?(conn, etag) do
case get_req_header(conn, "if-none-match") do
[client_etag] -> client_etag == etag
_ -> false
end
end
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("etag", etag)
|> put_resp_header("cache-control", "public, max-age=#{@cache_max_age}")
|> send_resp(304, "")
end
defp generate_etag_from_stat({:ok, mtime, size}) do
hash =
:crypto.hash(:md5, "#{inspect(mtime)}-#{size}")
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
"\"#{hash}\""
end
defp generate_etag_from_stat(_), do: "\"default\""
defp generate_content_etag(content) do
hash =
:crypto.hash(:md5, content)
|> Base.encode16(case: :lower)
|> binary_part(0, 16)
"\"#{hash}\""
end
defp send_plain_error(conn, status, message) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(status, message)
end
defp get_xsl_style(config) do
html_style = Map.get(config, :html_style, "table")
xsl_style = Map.get(config, :xsl_style)
cond do
xsl_style && xsl_style in @valid_xsl_styles -> xsl_style
html_style == "grouped" -> "table"
html_style == "flat" -> "minimal"
html_style in @valid_xsl_styles -> html_style
true -> "table"
end
end
end