| 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 |
| %{ |
| |
| 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 |
|
|