| defmodule PlausibleWeb.Api.ExternalSitesController do |
| use PlausibleWeb, :controller |
| use Plausible.Repo |
| use PlausibleWeb.Plugs.ErrorHandler |
|
|
| import Plausible.Pagination |
|
|
| alias Plausible.Sites |
| alias Plausible.Goal |
| alias Plausible.Goals |
| alias Plausible.Props |
| alias Plausible.Teams |
| alias PlausibleWeb.Api.Helpers, as: H |
|
|
| @pagination_opts [cursor_fields: [{:id, :desc}], limit: 100, maximum_limit: 1000] |
|
|
| def index(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team || Teams.get(params["team_id"]) |
|
|
| page = |
| user |
| |> Sites.for_user_query(team) |
| |> paginate(params, @pagination_opts) |
|
|
| json(conn, %{ |
| sites: page.entries |> Enum.map(&get_site_response_for_index/1), |
| meta: pagination_meta(page.metadata) |
| }) |
| end |
|
|
| def guests_index(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do |
| opts = [cursor_fields: [sort_index: :asc], limit: 100, maximum_limit: 1000] |
| page = site |> Sites.list_guests_query() |> paginate(params, opts) |
|
|
| json(conn, %{ |
| guests: |
| Enum.map(page.entries, fn entry -> |
| Map.take(entry, [:email, :role, :status]) |
| end), |
| meta: pagination_meta(page.metadata) |
| }) |
| else |
| {:missing, "site_id"} -> |
| H.bad_request(conn, "Parameter `site_id` is required to list goals") |
|
|
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
| end |
| end |
|
|
| def teams_index(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| page = |
| user |
| |> Teams.Users.teams_query(identifier: team && team.identifier, order_by: :id_desc) |
| |> paginate(params, @pagination_opts) |
|
|
| json(conn, %{ |
| teams: |
| Enum.map(page.entries, fn team -> |
| api_available? = |
| Plausible.Billing.Feature.StatsAPI.check_availability(team) == :ok |
|
|
| %{ |
| id: team.identifier, |
| name: Teams.name(team), |
| api_available: api_available? |
| } |
| end), |
| meta: pagination_meta(page.metadata) |
| }) |
| end |
|
|
| def goals_index(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do |
| page = |
| site |
| |> Plausible.Goals.for_site_query() |
| |> paginate(params, @pagination_opts) |
|
|
| json(conn, %{ |
| goals: |
| Enum.map(page.entries, fn goal -> |
| %{ |
| id: goal.id, |
| display_name: goal.display_name, |
| goal_type: Goal.type(goal), |
| event_name: goal.event_name, |
| page_path: goal.page_path, |
| custom_props: goal.custom_props |
| } |
| end), |
| meta: pagination_meta(page.metadata) |
| }) |
| else |
| {:missing, "site_id"} -> |
| H.bad_request(conn, "Parameter `site_id` is required to list goals") |
|
|
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
| end |
| end |
|
|
| def create_site(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team || Teams.get(params["team_id"]) |
|
|
| case Repo.transact(fn -> |
| with {:ok, %{site: site}} <- Sites.create(user, params, team), |
| {:ok, tracker_script_configuration} <- |
| get_or_create_config(site, params["tracker_script_configuration"] || %{}) do |
| {:ok, |
| struct(site, |
| tracker_script_configuration: tracker_script_configuration |
| )} |
| else |
| |
| {:error, step_id, output, context} -> |
| {:error, {step_id, output, context}} |
|
|
| |
| {:error, reason} -> |
| {:error, reason} |
| end |
| end) do |
| {:ok, site} -> |
| json(conn, get_site_response(site)) |
|
|
| {:error, {_, {:over_limit, limit}, _}} -> |
| conn |
| |> put_status(402) |
| |> json(%{ |
| error: |
| "Your account has reached the limit of #{limit} sites. To unlock more sites, please upgrade your subscription." |
| }) |
|
|
| {:error, {_, :permission_denied, _}} -> |
| conn |
| |> put_status(403) |
| |> json(%{ |
| error: "You can't add sites to the selected team." |
| }) |
|
|
| {:error, {_, :multiple_teams, _}} -> |
| conn |
| |> put_status(400) |
| |> json(%{ |
| error: "You must select a team with 'team_id' parameter." |
| }) |
|
|
| {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> |
| conn |
| |> put_status(400) |
| |> json(serialize_errors(changeset, "tracker_script_configuration.")) |
|
|
| {:error, {_, %Ecto.Changeset{} = changeset, _}} -> |
| conn |
| |> put_status(400) |
| |> json(serialize_errors(changeset)) |
| end |
| end |
|
|
| def get_site(conn, %{"site_id" => site_id}) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]), |
| {:ok, tracker_script_configuration} <- get_or_create_config(site, %{}) do |
| site = struct(site, tracker_script_configuration: tracker_script_configuration) |
|
|
| json(conn, get_site_response(site)) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
| end |
| end |
|
|
| def delete_site(conn, %{"site_id" => site_id}) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| case find_site(user, team, site_id, [:owner]) do |
| {:ok, site} -> |
| {:ok, _} = Plausible.Site.Removal.run(site) |
| json(conn, %{"deleted" => true}) |
|
|
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
| end |
| end |
|
|
| def update_site(conn, %{"site_id" => site_id} = params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, params} <- validate_update_payload(params), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), |
| {:ok, site} <- do_update_site(site, params) do |
| json(conn, get_site_response(site)) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:error, :no_changes} -> |
| H.bad_request( |
| conn, |
| "Payload must contain at least one of the parameters 'domain', 'tracker_script_configuration'" |
| ) |
|
|
| {:error, {:domain_change_invalid, %Ecto.Changeset{} = changeset}} -> |
| conn |
| |> put_status(400) |
| |> json(serialize_errors(changeset)) |
|
|
| {:error, {:tracker_script_configuration_invalid, %Ecto.Changeset{} = changeset}} -> |
| conn |
| |> put_status(400) |
| |> json(serialize_errors(changeset, "tracker_script_configuration.")) |
| end |
| end |
|
|
| defp validate_update_payload(params) do |
| params = params |> Map.take(["domain", "tracker_script_configuration"]) |> Map.drop([nil]) |
|
|
| if map_size(params) == 0 do |
| {:error, :no_changes} |
| else |
| {:ok, params} |
| end |
| end |
|
|
| defp do_update_site(site, params) do |
| Repo.transact(fn -> |
| with {:ok, site} <- |
| if(params["domain"], |
| do: change_domain(site, params["domain"]), |
| else: {:ok, site} |
| ), |
| {:ok, tracker_script_configuration} <- |
| if(params["tracker_script_configuration"], |
| do: update_config(site, params["tracker_script_configuration"]), |
| else: get_or_create_config(site, %{}) |
| ) do |
| {:ok, struct(site, tracker_script_configuration: tracker_script_configuration)} |
| end |
| end) |
| end |
|
|
| defp change_domain(site, domain) do |
| case Plausible.Site.Domain.change(site, domain) do |
| {:ok, site} -> |
| {:ok, site} |
|
|
| {:error, %Ecto.Changeset{} = changeset} -> |
| {:error, {:domain_change_invalid, changeset}} |
| end |
| end |
|
|
| def find_or_create_guest(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, email} <- expect_param_key(params, "email"), |
| {:ok, role} <- expect_param_key(params, "role", ["viewer", "editor"]), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do |
| existing = Repo.one(Sites.list_guests_query(site, email: email)) |
|
|
| if existing do |
| json(conn, %{ |
| role: existing.role, |
| email: existing.email, |
| status: existing.status |
| }) |
| else |
| case Teams.Invitations.InviteToSite.invite( |
| site, |
| conn.assigns.current_user, |
| email, |
| role |
| ) do |
| {:ok, invitation} -> |
| json(conn, %{ |
| role: invitation.role, |
| email: invitation.team_invitation.email, |
| status: "invited" |
| }) |
| end |
| end |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, "role"} -> |
| H.bad_request( |
| conn, |
| "Parameter `role` is required to create guest. Possible values: `viewer` or `editor`" |
| ) |
|
|
| {:missing, param} -> |
| H.bad_request(conn, "Parameter `#{param}` is required to create guest") |
| end |
| end |
|
|
| def delete_guest(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, email} <- expect_param_key(params, "email"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin]) do |
| existing = Repo.one(Sites.list_guests_query(site, email: email)) |
|
|
| case existing do |
| %{status: "invited", id: id} -> |
| with guest_invitation when not is_nil(guest_invitation) <- |
| Repo.get(Teams.GuestInvitation, id) do |
| Teams.Invitations.remove_guest_invitation(guest_invitation) |
| end |
|
|
| %{status: "accepted", email: email} -> |
| with %{} = user <- Repo.get_by(Plausible.Auth.User, email: email) do |
| Teams.Memberships.remove(site, user) |
| end |
|
|
| _ -> |
| :ignore |
| end |
|
|
| json(conn, %{"deleted" => true}) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, param} -> |
| H.bad_request(conn, "Parameter `#{param}` is required to delete a guest") |
| end |
| end |
|
|
| def find_or_create_shared_link(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, link_name} <- expect_param_key(params, "name"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]) do |
| shared_link = Repo.get_by(Plausible.Site.SharedLink, site_id: site.id, name: link_name) |
|
|
| shared_link = |
| case shared_link do |
| nil -> Sites.create_shared_link(site, link_name) |
| link -> {:ok, link} |
| end |
|
|
| case shared_link do |
| {:ok, link} -> |
| json(conn, %{ |
| name: link.name, |
| url: Sites.shared_link_url(site, link) |
| }) |
|
|
| {:error, %Ecto.Changeset{} = changeset} -> |
| {msg, _} = changeset.errors[:name] |
| H.bad_request(conn, msg) |
|
|
| {:error, :upgrade_required} -> |
| H.payment_required(conn, "Your current subscription plan does not include Shared Links") |
| end |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, "site_id"} -> |
| H.bad_request(conn, "Parameter `site_id` is required to create a shared link") |
|
|
| {:missing, "name"} -> |
| H.bad_request(conn, "Parameter `name` is required to create a shared link") |
|
|
| e -> |
| H.bad_request(conn, "Something went wrong: #{inspect(e)}") |
| end |
| end |
|
|
| def find_or_create_goal(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, _} <- expect_param_key(params, "goal_type"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), |
| {:ok, goal} <- Goals.find_or_create(site, params) do |
| json(conn, goal) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, param} -> |
| H.bad_request(conn, "Parameter `#{param}` is required to create a goal") |
|
|
| {:error, %Ecto.Changeset{} = changeset} -> |
| message = Enum.map_join(changeset.errors, ", ", &translate_error/1) |
|
|
| H.bad_request(conn, message) |
|
|
| {:error, :upgrade_required} -> |
| H.payment_required( |
| conn, |
| "Your current subscription plan does not include Custom Properties" |
| ) |
|
|
| e -> |
| H.bad_request(conn, "Something went wrong: #{inspect(e)}") |
| end |
| end |
|
|
| def delete_goal(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, goal_id} <- expect_param_key(params, "goal_id"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), |
| :ok <- Goals.delete(goal_id, site) do |
| json(conn, %{"deleted" => true}) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:error, :not_found} -> |
| H.not_found(conn, "Goal could not be found") |
|
|
| {:missing, "site_id"} -> |
| H.bad_request(conn, "Parameter `site_id` is required to delete a goal") |
|
|
| {:missing, "goal_id"} -> |
| H.bad_request(conn, "Parameter `goal_id` is required to delete a goal") |
|
|
| e -> |
| H.bad_request(conn, "Something went wrong: #{inspect(e)}") |
| end |
| end |
|
|
| def custom_props_index(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor, :viewer]) do |
| properties = |
| (site.allowed_event_props || []) |
| |> Enum.sort() |
| |> Enum.map(fn prop -> %{property: prop} end) |
|
|
| json(conn, %{custom_properties: properties}) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, "site_id"} -> |
| H.bad_request(conn, "Parameter `site_id` is required to list custom properties") |
| end |
| end |
|
|
| def add_custom_prop(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, property} <- expect_param_key(params, "property"), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), |
| {:ok, _} <- Props.allow(site, property) do |
| json(conn, %{"created" => true}) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, param} -> |
| H.bad_request(conn, "Parameter `#{param}` is required to create a custom property") |
|
|
| {:error, :upgrade_required} -> |
| H.payment_required( |
| conn, |
| "Your current subscription plan does not include Custom Properties" |
| ) |
|
|
| {:error, changeset} -> |
| %{allowed_event_props: [error | _]} = |
| Ecto.Changeset.traverse_errors(changeset, fn {_msg, opts} -> |
| cond do |
| opts[:type] == :list and opts[:validation] == :length -> |
| "Can't add any more custom properties" |
|
|
| opts[:type] == :string and opts[:validation] == :length -> |
| "Parameter `property` is too long" |
|
|
| true -> |
| "Parameter `property` is invalid" |
| end |
| end) |
|
|
| H.bad_request(conn, error) |
| end |
| end |
|
|
| def delete_custom_prop(conn, params) do |
| user = conn.assigns.current_user |
| team = conn.assigns.current_team |
|
|
| with {:ok, site_id} <- expect_param_key(params, "site_id"), |
| {:ok, property} <- expect_param_key(params, "property"), |
| |
| |
| property = Path.join(property), |
| {:ok, site} <- find_site(user, team, site_id, [:owner, :admin, :editor]), |
| {:ok, _} <- Props.disallow(site, property) do |
| json(conn, %{"deleted" => true}) |
| else |
| {:error, :site_not_found} -> |
| H.not_found(conn, "Site could not be found") |
|
|
| {:missing, param} -> |
| H.bad_request(conn, "Parameter `#{param}` is required to delete a custom property") |
|
|
| e -> |
| H.bad_request(conn, "Something went wrong: #{inspect(e)}") |
| end |
| end |
|
|
| defp pagination_meta(meta) do |
| %{ |
| after: meta.after, |
| before: meta.before, |
| limit: meta.limit |
| } |
| end |
|
|
| defp find_site(user, team, site_id, roles) do |
| case Plausible.Sites.get_for_user(user, site_id, roles: roles) do |
| nil -> |
| {:error, :site_not_found} |
|
|
| site -> |
| site = Repo.preload(site, :team) |
|
|
| if team && team.id != site.team_id do |
| {:error, :site_not_found} |
| else |
| {:ok, site} |
| end |
| end |
| end |
|
|
| defp serialize_errors(changeset, field_prefix \\ "") do |
| {field, {msg, _opts}} = List.first(changeset.errors) |
| error_msg = field_prefix <> Atom.to_string(field) <> ": " <> msg |
| %{"error" => error_msg} |
| end |
|
|
| defp expect_param_key(params, key, inclusion \\ []) |
|
|
| defp expect_param_key(params, key, []) do |
| case Map.fetch(params, key) do |
| :error -> {:missing, key} |
| res -> res |
| end |
| end |
|
|
| defp expect_param_key(params, key, inclusion) do |
| case expect_param_key(params, key, []) do |
| {:ok, value} = ok -> |
| if value in inclusion, do: ok, else: {:missing, key} |
|
|
| _ -> |
| {:missing, key} |
| end |
| end |
|
|
| defp get_or_create_config(site, params) do |
| case PlausibleWeb.Tracker.get_or_create_tracker_script_configuration(site, params) do |
| {:ok, tracker_script_configuration} -> |
| {:ok, tracker_script_configuration} |
|
|
| {:error, %Ecto.Changeset{} = changeset} -> |
| {:error, {:tracker_script_configuration_invalid, changeset}} |
| end |
| end |
|
|
| defp update_config(site, params) do |
| case PlausibleWeb.Tracker.update_script_configuration(site, params, :installation) do |
| {:ok, tracker_script_configuration} -> |
| {:ok, tracker_script_configuration} |
|
|
| {:error, %Ecto.Changeset{} = changeset} -> |
| {:error, {:tracker_script_configuration_invalid, changeset}} |
| end |
| end |
|
|
| defp get_site_response_for_index(site) do |
| site |> Map.take([:domain, :timezone]) |
| end |
|
|
| defp get_site_response(site) do |
| site |
| |> Map.take([:domain, :timezone, :tracker_script_configuration]) |
| |
| |> Map.put(:custom_properties, site.allowed_event_props || []) |
| end |
|
|
| defp translate_error({field, {msg, opts}}) do |
| Enum.reduce(opts, "#{field}: #{msg}", fn {key, value}, acc -> |
| String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end) |
| end) |
| end |
| end |
|
|