mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Merge branch 'provision-api'
This commit is contained in:
commit
edffe64a39
@ -3,8 +3,11 @@ defmodule Plausible.Auth.ApiKey do
|
||||
import Ecto.Changeset
|
||||
|
||||
@required [:user_id, :key, :name]
|
||||
@optional [:scopes]
|
||||
schema "api_keys" do
|
||||
field :name, :string
|
||||
field :scopes, {:array, :string}, default: ["stats:read:*"]
|
||||
|
||||
field :key, :string, virtual: true
|
||||
field :key_hash, :string
|
||||
field :key_prefix, :string
|
||||
@ -16,7 +19,7 @@ defmodule Plausible.Auth.ApiKey do
|
||||
|
||||
def changeset(schema, attrs \\ %{}) do
|
||||
schema
|
||||
|> cast(attrs, @required)
|
||||
|> cast(attrs, @required ++ @optional)
|
||||
|> validate_required(@required)
|
||||
|> process_key
|
||||
end
|
||||
|
@ -4,9 +4,10 @@ defmodule Plausible.Site do
|
||||
alias Plausible.Auth.User
|
||||
alias Plausible.Site.GoogleAuth
|
||||
|
||||
@derive {Jason.Encoder, only: [:domain, :timezone]}
|
||||
schema "sites" do
|
||||
field :domain, :string
|
||||
field :timezone, :string
|
||||
field :timezone, :string, default: "Etc/UTC"
|
||||
field :public, :boolean
|
||||
|
||||
many_to_many :members, User, join_through: Plausible.Site.Membership
|
||||
|
@ -17,6 +17,7 @@ defmodule Plausible.Site.SharedLink do
|
||||
|> cast(attrs, [:slug, :password, :name])
|
||||
|> validate_required([:slug, :name])
|
||||
|> unique_constraint(:slug)
|
||||
|> unique_constraint(:name, name: :shared_links_site_id_name_index)
|
||||
|> hash_password()
|
||||
end
|
||||
|
||||
|
@ -1,15 +1,53 @@
|
||||
defmodule Plausible.Sites do
|
||||
use Plausible.Repo
|
||||
alias Plausible.Site.CustomDomain
|
||||
alias Plausible.Site.{CustomDomain, SharedLink}
|
||||
|
||||
def get_for_user!(user_id, domain) do
|
||||
Repo.one!(
|
||||
from s in Plausible.Site,
|
||||
join: sm in Plausible.Site.Membership,
|
||||
on: sm.site_id == s.id,
|
||||
where: sm.user_id == ^user_id,
|
||||
where: s.domain == ^domain,
|
||||
select: s
|
||||
def create(user_id, params) do
|
||||
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:site, site_changeset)
|
||||
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
|
||||
membership_changeset =
|
||||
Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{
|
||||
site_id: site.id,
|
||||
user_id: user_id
|
||||
})
|
||||
|
||||
repo.insert(membership_changeset)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
|
||||
def create_shared_link(site, name) do
|
||||
changes =
|
||||
SharedLink.changeset(
|
||||
%SharedLink{
|
||||
site_id: site.id,
|
||||
slug: Nanoid.generate()
|
||||
},
|
||||
%{name: name}
|
||||
)
|
||||
|
||||
Repo.insert(changes)
|
||||
end
|
||||
|
||||
def shared_link_url(site, link) do
|
||||
base = PlausibleWeb.Endpoint.url()
|
||||
domain = "/share/#{URI.encode_www_form(site.domain)}"
|
||||
base <> domain <> "?auth=" <> link.slug
|
||||
end
|
||||
|
||||
def get_for_user!(user_id, domain), do: Repo.one!(get_for_user_q(user_id, domain))
|
||||
def get_for_user(user_id, domain), do: Repo.one(get_for_user_q(user_id, domain))
|
||||
|
||||
def get_for_user_q(user_id, domain) do
|
||||
from(s in Plausible.Site,
|
||||
join: sm in Plausible.Site.Membership,
|
||||
on: sm.site_id == s.id,
|
||||
where: sm.user_id == ^user_id,
|
||||
where: s.domain == ^domain,
|
||||
select: s
|
||||
)
|
||||
end
|
||||
|
||||
|
@ -0,0 +1,72 @@
|
||||
defmodule PlausibleWeb.Api.ExternalSitesController do
|
||||
use PlausibleWeb, :controller
|
||||
use Plausible.Repo
|
||||
use Plug.ErrorHandler
|
||||
alias Plausible.Sites
|
||||
alias PlausibleWeb.Api.Helpers, as: H
|
||||
|
||||
def create_site(conn, params) do
|
||||
user_id = conn.assigns[:current_user_id]
|
||||
|
||||
case Sites.create(user_id, params) do
|
||||
{:ok, %{site: site}} ->
|
||||
json(conn, site)
|
||||
|
||||
{:error, :site, changeset, _} ->
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> json(serialize_errors(changeset))
|
||||
end
|
||||
end
|
||||
|
||||
defp expect_param_key(params, key) do
|
||||
case Map.fetch(params, key) do
|
||||
:error -> {:missing, key}
|
||||
res -> res
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_create_shared_link(conn, params) do
|
||||
with {:ok, site_id} <- expect_param_key(params, "site_id"),
|
||||
{:ok, link_name} <- expect_param_key(params, "name"),
|
||||
site when not is_nil(site) <- Sites.get_for_user(conn.assigns[:current_user_id], site_id) 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)
|
||||
})
|
||||
end
|
||||
else
|
||||
nil ->
|
||||
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(400, "Something went wrong: #{inspect(e)}")
|
||||
end
|
||||
end
|
||||
|
||||
defp serialize_errors(changeset) do
|
||||
{field, {msg, _opts}} = List.first(changeset.errors)
|
||||
error_msg = Atom.to_string(field) <> " " <> msg
|
||||
%{"error" => error_msg}
|
||||
end
|
||||
|
||||
def handle_errors(conn, %{kind: kind, reason: reason}) do
|
||||
json(conn, %{error: Exception.format_banner(kind, reason)})
|
||||
end
|
||||
end
|
24
lib/plausible_web/controllers/api/helpers.ex
Normal file
24
lib/plausible_web/controllers/api/helpers.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule PlausibleWeb.Api.Helpers do
|
||||
import Plug.Conn
|
||||
|
||||
def unauthorized(conn, msg) do
|
||||
conn
|
||||
|> put_status(401)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
def bad_request(conn, msg) do
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
def not_found(conn, msg) do
|
||||
conn
|
||||
|> put_status(404)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
end
|
@ -41,7 +41,7 @@ defmodule PlausibleWeb.SiteController do
|
||||
def create_site(conn, %{"site" => site_params}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
case insert_site(user.id, site_params) do
|
||||
case Sites.create(user.id, site_params) do
|
||||
{:ok, %{site: site}} ->
|
||||
Plausible.Slack.notify("#{user.name} created #{site.domain} [email=#{user.email}]")
|
||||
|
||||
@ -517,16 +517,7 @@ defmodule PlausibleWeb.SiteController do
|
||||
def create_shared_link(conn, %{"website" => website, "shared_link" => link}) do
|
||||
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
||||
|
||||
changes =
|
||||
Plausible.Site.SharedLink.changeset(
|
||||
%Plausible.Site.SharedLink{
|
||||
site_id: site.id,
|
||||
slug: Nanoid.generate()
|
||||
},
|
||||
link
|
||||
)
|
||||
|
||||
case Repo.insert(changes) do
|
||||
case Sites.create_shared_link(site, link["name"]) do
|
||||
{:ok, _created} ->
|
||||
redirect(conn, to: "/#{URI.encode_www_form(site.domain)}/settings/visibility")
|
||||
|
||||
@ -652,21 +643,4 @@ defmodule PlausibleWeb.SiteController do
|
||||
|> put_flash(:success, "Custom domain deleted successfully")
|
||||
|> redirect(to: "/#{URI.encode_www_form(site.domain)}/settings/custom-domain")
|
||||
end
|
||||
|
||||
defp insert_site(user_id, params) do
|
||||
site_changeset = Plausible.Site.changeset(%Plausible.Site{}, params)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:site, site_changeset)
|
||||
|> Ecto.Multi.run(:site_membership, fn repo, %{site: site} ->
|
||||
membership_changeset =
|
||||
Plausible.Site.Membership.changeset(%Plausible.Site.Membership{}, %{
|
||||
site_id: site.id,
|
||||
user_id: user_id
|
||||
})
|
||||
|
||||
repo.insert(membership_changeset)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
end
|
||||
|
56
lib/plausible_web/plugs/authorize_sites_api.ex
Normal file
56
lib/plausible_web/plugs/authorize_sites_api.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule PlausibleWeb.AuthorizeSitesApiPlug do
|
||||
import Plug.Conn
|
||||
use Plausible.Repo
|
||||
alias Plausible.Auth.ApiKey
|
||||
alias PlausibleWeb.Api.Helpers, as: H
|
||||
|
||||
def init(options) do
|
||||
options
|
||||
end
|
||||
|
||||
def call(conn, _opts) do
|
||||
with {:ok, raw_api_key} <- get_bearer_token(conn),
|
||||
{:ok, api_key} <- verify_access(raw_api_key) do
|
||||
assign(conn, :current_user_id, api_key.user_id)
|
||||
else
|
||||
{:error, :missing_api_key} ->
|
||||
H.unauthorized(
|
||||
conn,
|
||||
"Missing API key. Please use a valid Plausible API key as a Bearer Token."
|
||||
)
|
||||
|
||||
{:error, :invalid_api_key} ->
|
||||
H.unauthorized(
|
||||
conn,
|
||||
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_access(api_key) do
|
||||
hashed_key = ApiKey.do_hash(api_key)
|
||||
|
||||
found_key =
|
||||
Repo.one(
|
||||
from a in ApiKey,
|
||||
where: a.key_hash == ^hashed_key,
|
||||
where: fragment("? @> ?", a.scopes, ["sites:provision:*"])
|
||||
)
|
||||
|
||||
cond do
|
||||
found_key -> {:ok, found_key}
|
||||
true -> {:error, :invalid_api_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_bearer_token(conn) do
|
||||
authorization_header =
|
||||
Plug.Conn.get_req_header(conn, "authorization")
|
||||
|> List.first()
|
||||
|
||||
case authorization_header do
|
||||
"Bearer " <> token -> {:ok, String.trim(token)}
|
||||
_ -> {:error, :missing_api_key}
|
||||
end
|
||||
end
|
||||
end
|
@ -1,7 +1,8 @@
|
||||
defmodule PlausibleWeb.AuthorizeApiStatsPlug do
|
||||
defmodule PlausibleWeb.AuthorizeStatsApiPlug do
|
||||
import Plug.Conn
|
||||
use Plausible.Repo
|
||||
alias Plausible.Auth.ApiKey
|
||||
alias PlausibleWeb.Api.Helpers, as: H
|
||||
|
||||
def init(options) do
|
||||
options
|
||||
@ -13,19 +14,19 @@ defmodule PlausibleWeb.AuthorizeApiStatsPlug do
|
||||
assign(conn, :site, site)
|
||||
else
|
||||
{:error, :missing_api_key} ->
|
||||
unauthorized(
|
||||
H.unauthorized(
|
||||
conn,
|
||||
"Missing API key. Please use a valid Plausible API key as a Bearer Token."
|
||||
)
|
||||
|
||||
{:error, :missing_site_id} ->
|
||||
bad_request(
|
||||
H.bad_request(
|
||||
conn,
|
||||
"Missing site ID. Please provide the required site_id parameter with your request."
|
||||
)
|
||||
|
||||
{:error, :invalid_api_key} ->
|
||||
unauthorized(
|
||||
H.unauthorized(
|
||||
conn,
|
||||
"Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested."
|
||||
)
|
||||
@ -56,18 +57,4 @@ defmodule PlausibleWeb.AuthorizeApiStatsPlug do
|
||||
_ -> {:error, :missing_api_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp bad_request(conn, msg) do
|
||||
conn
|
||||
|> put_status(400)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
|
||||
defp unauthorized(conn, msg) do
|
||||
conn
|
||||
|> put_status(401)
|
||||
|> Phoenix.Controller.json(%{error: msg})
|
||||
|> halt()
|
||||
end
|
||||
end
|
@ -36,10 +36,9 @@ defmodule PlausibleWeb.Router do
|
||||
plug PlausibleWeb.AuthorizeStatsPlug
|
||||
end
|
||||
|
||||
pipeline :external_stats_api do
|
||||
pipeline :public_api do
|
||||
plug :accepts, ["json"]
|
||||
plug PlausibleWeb.Firewall
|
||||
plug PlausibleWeb.AuthorizeApiStatsPlug
|
||||
end
|
||||
|
||||
if Application.get_env(:plausible, :environment) == "dev" do
|
||||
@ -73,7 +72,7 @@ defmodule PlausibleWeb.Router do
|
||||
end
|
||||
|
||||
scope "/api/v1/stats", PlausibleWeb.Api do
|
||||
pipe_through :external_stats_api
|
||||
pipe_through [:public_api, PlausibleWeb.AuthorizeStatsApiPlug]
|
||||
|
||||
get "/realtime/visitors", ExternalStatsController, :realtime_visitors
|
||||
get "/aggregate", ExternalStatsController, :aggregate
|
||||
@ -81,6 +80,13 @@ defmodule PlausibleWeb.Router do
|
||||
get "/timeseries", ExternalStatsController, :timeseries
|
||||
end
|
||||
|
||||
scope "/api/v1/sites", PlausibleWeb.Api do
|
||||
pipe_through [:public_api, PlausibleWeb.AuthorizeSitesApiPlug]
|
||||
|
||||
post "/", ExternalSitesController, :create_site
|
||||
put "/shared-links", ExternalSitesController, :find_or_create_shared_link
|
||||
end
|
||||
|
||||
scope "/api", PlausibleWeb do
|
||||
pipe_through :api
|
||||
|
||||
|
@ -8,5 +8,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= submit "Edit shared link", class: "button mt-4 w-full" %>
|
||||
<%= submit "Update", class: "button mt-4 w-full" %>
|
||||
<% end %>
|
||||
|
@ -22,8 +22,7 @@ defmodule PlausibleWeb.SiteView do
|
||||
end
|
||||
|
||||
def shared_link_dest(site, link) do
|
||||
domain = "/share/#{URI.encode_www_form(site.domain)}"
|
||||
plausible_url() <> domain <> "?auth=" <> link.slug
|
||||
Plausible.Sites.shared_link_url(site, link)
|
||||
end
|
||||
|
||||
def snippet(site) do
|
||||
|
@ -0,0 +1,7 @@
|
||||
defmodule Plausible.Repo.Migrations.AddUniqueIndexToSharedLinkName do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create unique_index(:shared_links, [:site_id, :name], name: :shared_links_site_id_name_index)
|
||||
end
|
||||
end
|
24
priv/repo/migrations/20210409082603_add_api_key_scopes.exs
Normal file
24
priv/repo/migrations/20210409082603_add_api_key_scopes.exs
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule Plausible.Repo.Migrations.AddApiKeyScopes do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:api_keys) do
|
||||
add :scopes, {:array, :text}
|
||||
end
|
||||
|
||||
execute "UPDATE api_keys SET scopes='{stats:read:*}'"
|
||||
|
||||
alter table(:api_keys) do
|
||||
modify :scopes, {:array, :text}, null: false
|
||||
end
|
||||
|
||||
# https://stackoverflow.com/a/4059785
|
||||
create index(:api_keys, [:scopes], using: "GIN")
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:api_keys) do
|
||||
remove :scopes
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,110 @@
|
||||
defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
|
||||
use PlausibleWeb.ConnCase
|
||||
import Plausible.TestUtils
|
||||
|
||||
setup %{conn: conn} do
|
||||
user = insert(:user)
|
||||
api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"])
|
||||
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
|
||||
{:ok, user: user, api_key: api_key, conn: conn}
|
||||
end
|
||||
|
||||
describe "POST /api/v1/sites" do
|
||||
test "can create a site", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, "/api/v1/sites", %{
|
||||
"domain" => "some-site.domain",
|
||||
"timezone" => "Europe/Tallinn"
|
||||
})
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"domain" => "some-site.domain",
|
||||
"timezone" => "Europe/Tallinn"
|
||||
}
|
||||
end
|
||||
|
||||
test "timezone defaults to Etc/UTC", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, "/api/v1/sites", %{
|
||||
"domain" => "some-site.domain"
|
||||
})
|
||||
|
||||
assert json_response(conn, 200) == %{
|
||||
"domain" => "some-site.domain",
|
||||
"timezone" => "Etc/UTC"
|
||||
}
|
||||
end
|
||||
|
||||
test "domain is required", %{conn: conn} do
|
||||
conn = post(conn, "/api/v1/sites", %{})
|
||||
|
||||
assert json_response(conn, 400) == %{
|
||||
"error" => "domain can't be blank"
|
||||
}
|
||||
end
|
||||
|
||||
test "cannot access with a bad API key scope", %{conn: conn, user: user} do
|
||||
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|
||||
|> post("/api/v1/sites", %{"site" => %{"domain" => "domain.com"}})
|
||||
|
||||
assert json_response(conn, 401) == %{
|
||||
"error" =>
|
||||
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /api/v1/sites/shared-links" do
|
||||
setup :create_site
|
||||
|
||||
test "can add a shared link to a site", %{conn: conn, site: site} do
|
||||
conn = put(conn, "/api/v1/sites/shared-links", %{
|
||||
site_id: site.domain,
|
||||
name: "Wordpress"
|
||||
})
|
||||
|
||||
res = json_response(conn, 200)
|
||||
assert res["name"] == "Wordpress"
|
||||
assert String.starts_with?(res["url"], "http://")
|
||||
end
|
||||
|
||||
test "is idempotent find or create op", %{conn: conn, site: site} do
|
||||
conn = put(conn, "/api/v1/sites/shared-links", %{
|
||||
site_id: site.domain,
|
||||
name: "Wordpress"
|
||||
})
|
||||
|
||||
%{"url" => url} = json_response(conn, 200)
|
||||
|
||||
conn = put(conn, "/api/v1/sites/shared-links", %{
|
||||
site_id: site.domain,
|
||||
name: "Wordpress"
|
||||
})
|
||||
|
||||
assert %{"url" => ^url} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
test "returns 400 when site id missing", %{conn: conn} do
|
||||
conn = put(conn, "/api/v1/sites/shared-links", %{
|
||||
name: "Wordpress"
|
||||
})
|
||||
|
||||
res = json_response(conn, 400)
|
||||
assert res["error"] == "Parameter `site_id` is required to create a shared link"
|
||||
end
|
||||
|
||||
test "returns 404 when site id is non existent", %{conn: conn} do
|
||||
conn = put(conn, "/api/v1/sites/shared-links", %{
|
||||
name: "Wordpress",
|
||||
site_id: "bad"
|
||||
})
|
||||
|
||||
res = json_response(conn, 404)
|
||||
assert res["error"] == "Site could not be found"
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user