defmodule TarakanWeb.FeedController do
@moduledoc """
Atom 1.0 feeds over the public record.
Both feeds reuse the anonymous disclosure rules: findings come from
`Scans.list_recent_disclosed_findings/1` (listed repositories, fully public
reviews, terminal trust states), infestations from `Infestations.list_infestations/1`
(public canonical findings on listed repositories only).
"""
use TarakanWeb, :controller
alias Tarakan.Infestations
alias Tarakan.Scans
@feed_limit 50
@cache_control "public, max-age=300"
def findings(conn, _params) do
base = TarakanWeb.Endpoint.url()
entries =
[limit: @feed_limit]
|> Scans.list_recent_disclosed_findings()
|> Enum.map(fn %{canonical: canonical, repository: repository} = row ->
url = base <> ~p"/findings/#{row.occurrence_public_id}"
%{
id: url,
link: url,
title: canonical.title,
updated: canonical.updated_at,
summary:
"#{String.capitalize(canonical.severity)} in " <>
"#{repository.owner}/#{repository.name} (#{canonical.file_path}): " <>
truncate(canonical.description, 240)
}
end)
send_feed(conn, "Tarakan findings", "#{base}/feeds/findings.xml", entries)
end
def infestations(conn, _params) do
base = TarakanWeb.Endpoint.url()
entries =
[days: 30, limit: @feed_limit]
|> Infestations.list_infestations()
|> Enum.map(fn pattern ->
url = base <> ~p"/infestations/#{pattern.pattern_key}"
%{
id: url,
link: url,
title: pattern.title,
updated: pattern.last_seen_at || pattern.first_seen_at || DateTime.utc_now(),
summary:
"Seen in #{pattern.repo_count} repositories over 30 days: " <>
"#{pattern.open_count} open, #{pattern.verified_count} verified, " <>
"#{pattern.fixed_count} fixed, #{pattern.disputed_count} disputed."
}
end)
send_feed(conn, "Tarakan infestations", "#{base}/feeds/infestations.xml", entries)
end
defp send_feed(conn, title, self_url, entries) do
base = TarakanWeb.Endpoint.url()
updated =
entries |> Enum.map(& &1.updated) |> Enum.max(DateTime, fn -> DateTime.utc_now() end)
body = [
~s(),
~s(),
"",
xml_escape(title),
"",
"",
xml_escape(self_url),
"",
~s(),
~s(),
"",
rfc3339(updated),
"",
Enum.map(entries, &entry_xml/1),
""
]
conn
|> put_resp_content_type("application/atom+xml")
|> put_resp_header("cache-control", @cache_control)
|> send_resp(200, body)
end
defp entry_xml(entry) do
[
"",
"",
xml_escape(entry.id),
"",
"",
xml_escape(entry.title),
"",
"",
rfc3339(entry.updated),
"",
~s(),
"",
xml_escape(entry.summary),
"",
""
]
end
defp rfc3339(%DateTime{} = datetime) do
datetime |> DateTime.truncate(:second) |> DateTime.to_iso8601()
end
defp truncate(text, max) when byte_size(text) <= max, do: text
defp truncate(text, max), do: String.slice(text, 0, max - 1) <> "…"
defp xml_escape(nil), do: ""
defp xml_escape(text) do
text
|> String.replace("&", "&")
|> String.replace("<", "<")
|> String.replace(">", ">")
|> String.replace("\"", """)
|> String.replace("'", "'")
end
end