lib/openagents_web/controllers/og_image_controller.ex

main at 58e6347eeb72 · 10 KB

defmodule OpenAgentsWeb.OgImageController do
  @moduledoc """
  Serves server-generated Open Graph card PNGs.

  Contract, from `docs/2026-08-21-open-graph-cards.md`:

    * Every request path is HMAC-signed (`?sig=`); an invalid or missing
      signature is the same 404 as everything else this endpoint refuses.
    * Repositories resolve through the public visibility predicate only. A
      private repository, a missing repository, and a bad signature are
      indistinguishable.
    * The version segment is advisory: it exists so a page's emitted URL is
      content-addressed and caches immutably. The controller always renders
      current data for any well-formed request, so stale shared links heal
      instead of pinning old facts.
    * When rasterization is unavailable, busy, or fails, the committed
      fallback card ships under identical headers — previews degrade,
      nothing errors.
  """

  use OpenAgentsWeb, :controller

  require Logger

  alias OpenAgents.Forge
  alias OpenAgents.Forge.Browse
  alias OpenAgents.Forum
  alias OpenAgents.Issues
  alias OpenAgents.PullRequests
  alias OpenAgents.Repositories
  alias OpenAgents.Stacks
  alias OpenAgentsWeb.{DocsCatalog, OG, RepositoryAccess}

  @cache_control "public, max-age=21600, immutable"
  @not_found_cache_control "public, max-age=60"

  # Last-resort bytes if even the committed fallback asset cannot be read: a
  # valid transparent 1x1 PNG keeps responses well-formed.
  @transparent_png Base.decode64!(
                     "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
                   )

  ## Actions -------------------------------------------------------------------

  def static(conn, _params) do
    send_png(conn)
  end

  def repo(conn, params) do
    authorize_and_run(conn, params, fn owner, name ->
      with {:ok, repository} <- public_repository(owner, name) do
        OG.repo_card_for(repository)
      else
        _error -> :error
      end
    end)
  end

  def issue(conn, params) do
    authorize_and_run(conn, params, fn owner, name ->
      with {number, ""} <- Integer.parse(strip_png(params["number"])),
           %Issues.Issue{} = issue <-
             safe(fn -> Issues.get_issue_by_path!(owner, name, number) end) do
        OG.issue(owner, name, issue)
      else
        _error -> :error
      end
    end)
  end

  def pull(conn, params) do
    authorize_and_run(conn, params, fn owner, name ->
      with {number, ""} <- Integer.parse(strip_png(params["number"])),
           {:ok, repository} <- public_repository(owner, name),
           %PullRequests.PullRequest{} = pull_request <-
             safe(fn -> PullRequests.get_by_number!(repository, number) end) do
        {position, size} = stack_placement(repository, pull_request)

        OG.pull_request(owner, name, pull_request,
          stack_position: position,
          stack_size: size
        )
      else
        _error -> :error
      end
    end)
  end

  # Documentation is public by construction: the catalog is the compile-time
  # allowlist, so a slug it cannot render is the same 404 as a bad signature.
  def docs(conn, params) do
    authorized(conn, fn ->
      case DocsCatalog.render(strip_png(params["slug"]) || "") do
        {:ok, page} -> OG.docs(page)
        :error -> :error
      end
    end)
  end

  # Forum cards resolve through the same readability predicates as the forum
  # pages, with no operator: a private board, a missing topic, and a bad
  # signature are indistinguishable.
  def forum_board(conn, params) do
    authorized(conn, fn ->
      case Forum.fetch_readable_forum_by_slug(strip_png(params["slug"]) || "") do
        {:ok, forum} -> OG.forum_board(forum)
        _error -> :error
      end
    end)
  end

  def forum_topic(conn, params) do
    authorized(conn, fn ->
      with {:ok, topic} <- Forum.fetch_readable_topic(strip_png(params["id"]) || ""),
           %Forum.Forum{} = forum <- safe(fn -> Forum.get_forum!(topic.forum_id) end) do
        OG.forum_topic(forum, topic, summary: first_visible_post_body(topic))
      else
        _error -> :error
      end
    end)
  end

  defp first_visible_post_body(topic) do
    case safe(fn -> Forum.list_posts(topic) end) do
      [first | _rest] -> first.body_text
      _none -> nil
    end
  end

  def commit(conn, params) do
    authorize_and_run(conn, params, fn owner, name ->
      sha = strip_png(params["sha"])

      with {:ok, repository} <- public_repository(owner, name),
           true <- Forge.enabled?(),
           true <- Browse.valid_ref?(sha),
           {:ok, commit} <- safe(fn -> Browse.commit(repository, sha) end) do
        files =
          case safe(fn -> Browse.changed_files(repository, commit.sha) end) do
            {:ok, list} when is_list(list) -> list
            _other -> nil
          end

        OG.commit(owner, name, commit, files && length(files))
      else
        _error -> :error
      end
    end)
  end

  # The blob card must never show what the anonymous file page would not:
  # the same disclosure gate as `OpenAgentsWeb.CodeBlobLive` runs here, with
  # no user (crawlers carry no session).
  def blob(conn, params) do
    authorize_and_run(conn, params, fn owner, name ->
      ref = strip_png(params["ref"])
      path = joined_path(params["path"])

      with {:ok, repository} <- public_repository(owner, name),
           true <- Forge.enabled?() and repository.lifecycle_state == "ready",
           true <- Browse.valid_ref?(ref) and Browse.valid_path?(path),
           {:ok, sha} <- safe(fn -> Browse.resolve_commit(repository, ref) end),
           head <- resolved_head(repository),
           true <- RepositoryAccess.allows_file?(repository, nil, path, sha, head),
           {:ok, blob_info} <- safe(fn -> Browse.blob(repository, sha, path) end) do
        OG.blob(owner, name, path, %{
          ref: ref,
          size: blob_info.size,
          lines: blob_lines(blob_info),
          truncated: blob_info.truncated
        })
      else
        _refused -> :error
      end
    end)
  end

  ## Pipeline ------------------------------------------------------------------

  # One response for every refusal — bad signature, unknown repository,
  # private repository, missing resource — so none of them can be told apart.
  defp authorize_and_run(conn, params, build) do
    authorized(conn, fn ->
      build.(strip_png(params["owner"]), strip_png(params["repo"]))
    end)
  end

  defp authorized(conn, build) do
    if OG.valid_signature?(conn.request_path, conn.query_params["sig"]) do
      case build.() do
        %OG{} = card -> respond_with_card(conn, card)
        _refused -> not_found(conn)
      end
    else
      not_found(conn)
    end
  end

  defp respond_with_card(conn, card) do
    svg = OG.Templates.render(card)

    case OG.Rasterizer.rasterize(svg) do
      {:ok, png} ->
        send_png(conn, png)

      {:error, reason} ->
        # Exit tuples can carry payloads; log the safe classification only.
        safe_reason =
          case reason do
            {:exit, _payload} -> :exit
            other -> other
          end

        Logger.warning("og_card_fallback kind=#{card.kind} reason=#{safe_reason}")

        send_png(conn, fallback_png())
    end
  end

  defp send_png(conn, bytes \\ nil) do
    conn
    |> put_resp_header("content-type", "image/png")
    |> put_resp_header("cache-control", @cache_control)
    |> put_resp_header("x-content-type-options", "nosniff")
    |> send_resp(200, bytes || default_card_bytes())
  end

  defp not_found(conn) do
    conn
    |> put_resp_header("cache-control", @not_found_cache_control)
    |> send_resp(404, "")
  end

  ## Helpers -------------------------------------------------------------------

  defp stack_placement(repository, pull_request) do
    case safe(fn -> Stacks.review_context(repository, pull_request) end) do
      {:ok, context} -> {context.position, context.size}
      _other -> merged_stack_placement(pull_request)
    end
  end

  defp merged_stack_placement(pull_request) do
    case safe(fn -> Stacks.merged_context(pull_request) end) do
      {:ok, context} -> {context.position, context.size}
      _other -> {nil, nil}
    end
  end

  defp public_repository(owner, name) do
    case safe(fn -> Repositories.get_public_by_path!(owner, name) end) do
      %Repositories.Repository{} = repository -> {:ok, repository}
      _other -> :error
    end
  end

  # `Browse.head/1` reports emptiness as an error tuple; the disclosure gate
  # only wants a sha or nil, exactly as the file page derives it.
  defp resolved_head(repository) do
    case safe(fn -> Browse.head(repository) end) do
      {:ok, sha} -> sha
      _other -> nil
    end
  end

  # ".png" rides at the end of the last path segment; a resource genuinely
  # named "*.png" arrives doubled and survives one strip intact.
  defp strip_png(nil), do: nil

  defp strip_png(value) when is_binary(value),
    do: String.replace_suffix(value, ".png", "")

  defp joined_path(segments) when is_list(segments) do
    segments
    |> Enum.join("/")
    |> String.replace_suffix(".png", "")
  end

  defp joined_path(_other), do: ""

  defp blob_lines(%{binary: true}), do: nil

  defp blob_lines(%{content: content}) when is_binary(content),
    do: content |> String.split("\n") |> length()

  defp blob_lines(_blob_info), do: nil

  defp safe(fun) do
    fun.()
  rescue
    _error -> nil
  catch
    :exit, _reason -> nil
  end

  # The committed brand card doubles as the rasterization fallback, cached in
  # process-global storage after its first read.
  defp fallback_png do
    key = {__MODULE__, :fallback_png}

    case :persistent_term.get(key, nil) do
      nil ->
        path = Application.app_dir(:openagents, "priv/static/images/og-card-default.png")

        case File.read(path) do
          {:ok, bytes} ->
            :persistent_term.put(key, bytes)
            bytes

          _unreadable ->
            @transparent_png
        end

      bytes ->
        bytes
    end
  end

  defp default_card_bytes do
    # The static route serves exactly the committed asset; the fallback bytes
    # are the same file, so both paths share one read.
    fallback_png()
  end
end