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