File size: 6,516 Bytes
8da2481 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | defmodule Plausible.Google.API do
@moduledoc """
API to Google services.
"""
alias Plausible.Google.HTTP
alias Plausible.Google.SearchConsole
alias Plausible.Stats.Query
@search_console_scope URI.encode_www_form(
"email https://www.googleapis.com/auth/webmasters.readonly"
)
@import_scope URI.encode_www_form("email https://www.googleapis.com/auth/analytics.readonly")
@verified_permission_levels ["siteOwner", "siteFullUser", "siteRestrictedUser"]
@valid_oauth_contexts ["search-console", "import"]
@oauth_salt "google-oauth-state"
def search_console_authorize_url(site) do
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@search_console_scope}&state=" <>
sign_oauth_state(site, "search-console")
end
def import_authorize_url(site) do
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@import_scope}&state=" <>
sign_oauth_state(site, "import")
end
@doc false
def sign_oauth_state(site, context) do
Phoenix.Token.sign(PlausibleWeb.Endpoint, @oauth_salt, [site.id, context])
end
def verify_oauth_state(state) do
case Phoenix.Token.verify(PlausibleWeb.Endpoint, @oauth_salt, state, max_age: 3600) do
{:ok, [site_id, context]} when context in @valid_oauth_contexts ->
if site = Plausible.Repo.get(Plausible.Site, site_id) do
{:ok, %{site: site, context: context}}
else
{:error, {:invalid_oauth_state, :missing_site}}
end
{:error, token_error} ->
{:error, {:invalid_oauth_state, token_error}}
end
end
def fetch_access_token!(code) do
HTTP.fetch_access_token!(code)
end
def list_properties(access_token) do
Plausible.Google.GA4.API.list_properties(access_token)
end
def get_property(access_token, property) do
Plausible.Google.GA4.API.get_property(access_token, property)
end
def get_analytics_start_date(access_token, property) do
Plausible.Google.GA4.API.get_analytics_start_date(access_token, property)
end
def get_analytics_end_date(access_token, property) do
Plausible.Google.GA4.API.get_analytics_end_date(access_token, property)
end
def fetch_verified_properties(auth) do
with {:ok, access_token} <- maybe_refresh_token(auth),
{:ok, sites} <- Plausible.Google.HTTP.list_sites(access_token) do
sites
|> Map.get("siteEntry", [])
|> Enum.filter(fn site -> site["permissionLevel"] in @verified_permission_levels end)
|> Enum.map(fn site -> site["siteUrl"] end)
|> Enum.map(fn url -> String.trim_trailing(url, "/") end)
|> then(&{:ok, &1})
end
end
def fetch_stats(site, query, pagination, search) do
with {:ok, site} <- ensure_search_console_property(site),
{:ok, access_token} <- maybe_refresh_token(site.google_auth),
{:ok, gsc_filters} <-
SearchConsole.Filters.transform(site.google_auth.property, query.filters, search),
{:ok, stats} <-
HTTP.list_stats(
access_token,
site.google_auth.property,
Query.date_range(query),
pagination,
gsc_filters
) do
stats
|> Map.get("rows", [])
|> Enum.map(&search_console_row/1)
|> then(&{:ok, &1})
else
:google_property_not_configured -> {:error, :google_property_not_configured}
:unsupported_filters -> {:error, :unsupported_filters}
{:error, error} -> {:error, error}
end
end
def maybe_refresh_token(%Plausible.Site.GoogleAuth{} = auth) do
with true <- needs_to_refresh_token?(auth.expires),
{:ok, {new_access_token, expires_at}} <- do_refresh_token(auth.refresh_token),
changeset <-
Plausible.Site.GoogleAuth.changeset(auth, %{
access_token: new_access_token,
expires: expires_at
}),
{:ok, _google_auth} <- Plausible.Repo.update(changeset) do
{:ok, new_access_token}
else
false -> {:ok, auth.access_token}
{:error, cause} -> {:error, cause}
end
end
def maybe_refresh_token({access_token, refresh_token, expires_at}) do
with true <- needs_to_refresh_token?(expires_at),
{:ok, {new_access_token, _expires_at}} <- do_refresh_token(refresh_token) do
{:ok, new_access_token}
else
false -> {:ok, access_token}
{:error, cause} -> {:error, cause}
end
end
def property?(value), do: String.starts_with?(value, "properties/")
defp do_refresh_token(refresh_token) do
case HTTP.refresh_auth_token(refresh_token) do
{:ok, %{"access_token" => new_access_token, "expires_in" => expires_in}} ->
expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in)
{:ok, {new_access_token, expires_at}}
{:error, cause} ->
{:error, cause}
end
end
defp needs_to_refresh_token?(expires_at) when is_binary(expires_at) do
expires_at
|> NaiveDateTime.from_iso8601!()
|> needs_to_refresh_token?()
end
defp needs_to_refresh_token?(%NaiveDateTime{} = expires_at) do
thirty_seconds_ago = DateTime.shift(DateTime.utc_now(), second: 30)
NaiveDateTime.before?(expires_at, thirty_seconds_ago)
end
defp ensure_search_console_property(site) do
site = Plausible.Repo.preload(site, :google_auth)
if site.google_auth && site.google_auth.property do
{:ok, site}
else
:google_property_not_configured
end
end
defp search_console_row(row) do
%{
# We always request just one dimension at a time (`query`)
name: row["keys"] |> List.first(),
visitors: row["clicks"],
impressions: row["impressions"],
ctr: rounded_ctr(row["ctr"]),
position: rounded_position(row["position"])
}
end
defp rounded_ctr(ctr) do
{:ok, decimal} = Decimal.cast(ctr)
decimal
|> Decimal.mult(100)
|> Decimal.round(1)
|> Decimal.to_float()
end
defp rounded_position(position) do
{:ok, decimal} = Decimal.cast(position)
decimal
|> Decimal.round(1)
|> Decimal.to_float()
end
defp client_id() do
Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
end
defp redirect_uri() do
PlausibleWeb.Endpoint.url() <> "/auth/google/callback"
end
end
|