forked from AkkomaGang/akkoma
twitter api registration
fix for twitter api tests
This commit is contained in:
parent
0484f3a8b1
commit
be54e40890
4 changed files with 420 additions and 76 deletions
|
@ -163,22 +163,43 @@ def register_user(params) do
|
||||||
{:error, %{error: Jason.encode!(%{captcha: [error]})}}
|
{:error, %{error: Jason.encode!(%{captcha: [error]})}}
|
||||||
else
|
else
|
||||||
registrations_open = Pleroma.Config.get([:instance, :registrations_open])
|
registrations_open = Pleroma.Config.get([:instance, :registrations_open])
|
||||||
|
registration_process(registrations_open, params, token_string)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# no need to query DB if registration is open
|
defp registration_process(_registration_open = true, params, _token_string) do
|
||||||
|
create_user(params)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp registration_process(registration_open, params, token_string)
|
||||||
|
when registration_open == false or is_nil(registration_open) do
|
||||||
token =
|
token =
|
||||||
unless registrations_open || is_nil(token_string) do
|
unless is_nil(token_string) do
|
||||||
Repo.get_by(UserInviteToken, %{token: token_string})
|
Repo.get_by(UserInviteToken, %{token: token_string})
|
||||||
end
|
end
|
||||||
|
|
||||||
cond do
|
valid_token? = token && UserInviteToken.valid_token?(token)
|
||||||
registrations_open || (!is_nil(token) && !token.used) ->
|
|
||||||
|
case token do
|
||||||
|
nil ->
|
||||||
|
{:error, "Invalid token"}
|
||||||
|
|
||||||
|
token when valid_token? ->
|
||||||
|
UserInviteToken.update_usage(token)
|
||||||
|
create_user(params)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:error, "Expired token"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_user(params) do
|
||||||
changeset = User.register_changeset(%User{}, params)
|
changeset = User.register_changeset(%User{}, params)
|
||||||
|
|
||||||
with {:ok, user} <- User.register(changeset) do
|
case User.register(changeset) do
|
||||||
!registrations_open && UserInviteToken.mark_as_used(token.token)
|
{:ok, user} ->
|
||||||
|
|
||||||
{:ok, user}
|
{:ok, user}
|
||||||
else
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
errors =
|
errors =
|
||||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|
||||||
|
@ -186,14 +207,6 @@ def register_user(params) do
|
||||||
|
|
||||||
{:error, %{error: errors}}
|
{:error, %{error: errors}}
|
||||||
end
|
end
|
||||||
|
|
||||||
!registrations_open && is_nil(token) ->
|
|
||||||
{:error, "Invalid token"}
|
|
||||||
|
|
||||||
!registrations_open && token.used ->
|
|
||||||
{:error, "Expired token"}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def password_reset(nickname_or_email) do
|
def password_reset(nickname_or_email) do
|
||||||
|
|
64
test/fixtures/lambadalambda.json
vendored
Normal file
64
test/fixtures/lambadalambda.json
vendored
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"@context": [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
"https://w3id.org/security/v1",
|
||||||
|
{
|
||||||
|
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||||
|
"toot": "http://joinmastodon.org/ns#",
|
||||||
|
"featured": {
|
||||||
|
"@id": "toot:featured",
|
||||||
|
"@type": "@id"
|
||||||
|
},
|
||||||
|
"alsoKnownAs": {
|
||||||
|
"@id": "as:alsoKnownAs",
|
||||||
|
"@type": "@id"
|
||||||
|
},
|
||||||
|
"movedTo": {
|
||||||
|
"@id": "as:movedTo",
|
||||||
|
"@type": "@id"
|
||||||
|
},
|
||||||
|
"schema": "http://schema.org#",
|
||||||
|
"PropertyValue": "schema:PropertyValue",
|
||||||
|
"value": "schema:value",
|
||||||
|
"Hashtag": "as:Hashtag",
|
||||||
|
"Emoji": "toot:Emoji",
|
||||||
|
"IdentityProof": "toot:IdentityProof",
|
||||||
|
"focalPoint": {
|
||||||
|
"@container": "@list",
|
||||||
|
"@id": "toot:focalPoint"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "https://mastodon.social/users/lambadalambda",
|
||||||
|
"type": "Person",
|
||||||
|
"following": "https://mastodon.social/users/lambadalambda/following",
|
||||||
|
"followers": "https://mastodon.social/users/lambadalambda/followers",
|
||||||
|
"inbox": "https://mastodon.social/users/lambadalambda/inbox",
|
||||||
|
"outbox": "https://mastodon.social/users/lambadalambda/outbox",
|
||||||
|
"featured": "https://mastodon.social/users/lambadalambda/collections/featured",
|
||||||
|
"preferredUsername": "lambadalambda",
|
||||||
|
"name": "Critical Value",
|
||||||
|
"summary": "\u003cp\u003e\u003c/p\u003e",
|
||||||
|
"url": "https://mastodon.social/@lambadalambda",
|
||||||
|
"manuallyApprovesFollowers": false,
|
||||||
|
"publicKey": {
|
||||||
|
"id": "https://mastodon.social/users/lambadalambda#main-key",
|
||||||
|
"owner": "https://mastodon.social/users/lambadalambda",
|
||||||
|
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw0P/Tq4gb4G/QVuMGbJo\nC/AfMNcv+m7NfrlOwkVzcU47jgESuYI4UtJayissCdBycHUnfVUd9qol+eznSODz\nCJhfJloqEIC+aSnuEPGA0POtWad6DU0E6/Ho5zQn5WAWUwbRQqowbrsm/GHo2+3v\neR5jGenwA6sYhINg/c3QQbksyV0uJ20Umyx88w8+TJuv53twOfmyDWuYNoQ3y5cc\nHKOZcLHxYOhvwg3PFaGfFHMFiNmF40dTXt9K96r7sbzc44iLD+VphbMPJEjkMuf8\nPGEFOBzy8pm3wJZw2v32RNW2VESwMYyqDzwHXGSq1a73cS7hEnc79gXlELsK04L9\nQQIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||||
|
},
|
||||||
|
"tag": [],
|
||||||
|
"attachment": [],
|
||||||
|
"endpoints": {
|
||||||
|
"sharedInbox": "https://mastodon.social/inbox"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/gif",
|
||||||
|
"url": "https://files.mastodon.social/accounts/avatars/000/000/264/original/1429214160519.gif"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "Image",
|
||||||
|
"mediaType": "image/gif",
|
||||||
|
"url": "https://files.mastodon.social/accounts/headers/000/000/264/original/28b26104f83747d2.gif"
|
||||||
|
}
|
||||||
|
}
|
|
@ -716,6 +716,10 @@ def get("https://mastodon.social/users/lambadalambda.atom", _, _, _) do
|
||||||
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.atom")}}
|
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.atom")}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get("https://mastodon.social/users/lambadalambda", _, _, _) do
|
||||||
|
{:ok, %Tesla.Env{status: 200, body: File.read!("test/fixtures/lambadalambda.json")}}
|
||||||
|
end
|
||||||
|
|
||||||
def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do
|
def get("https://social.heldscal.la/user/23211", _, _, Accept: "application/activity+json") do
|
||||||
{:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)}
|
{:ok, Tesla.Mock.json(%{"id" => "https://social.heldscal.la/user/23211"}, status: 200)}
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,6 +16,11 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPITest do
|
||||||
|
|
||||||
import Pleroma.Factory
|
import Pleroma.Factory
|
||||||
|
|
||||||
|
setup_all do
|
||||||
|
Tesla.Mock.mock_global(fn env -> apply(HttpRequestMock, :request, [env]) end)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
test "create a status" do
|
test "create a status" do
|
||||||
user = insert(:user)
|
user = insert(:user)
|
||||||
mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"})
|
mentioned_user = insert(:user, %{nickname: "shp", ap_id: "shp"})
|
||||||
|
@ -299,7 +304,6 @@ test "it registers a new user with empty string in bio and returns the user." do
|
||||||
UserView.render("show.json", %{user: fetched_user})
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
end
|
end
|
||||||
|
|
||||||
@moduletag skip: "needs 'account_activation_required: true' in config"
|
|
||||||
test "it sends confirmation email if :account_activation_required is specified in instance config" do
|
test "it sends confirmation email if :account_activation_required is specified in instance config" do
|
||||||
setting = Pleroma.Config.get([:instance, :account_activation_required])
|
setting = Pleroma.Config.get([:instance, :account_activation_required])
|
||||||
|
|
||||||
|
@ -353,8 +357,19 @@ test "it registers a new user and parses mentions in the bio" do
|
||||||
assert user2.bio == expected_text
|
assert user2.bio == expected_text
|
||||||
end
|
end
|
||||||
|
|
||||||
@moduletag skip: "needs 'registrations_open: false' in config"
|
describe "register with one time token" do
|
||||||
test "it registers a new user via invite token and returns the user." do
|
setup do
|
||||||
|
setting = Pleroma.Config.get([:instance, :registrations_open])
|
||||||
|
|
||||||
|
if setting do
|
||||||
|
Pleroma.Config.put([:instance, :registrations_open], false)
|
||||||
|
on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns user on success" do
|
||||||
{:ok, token} = UserInviteToken.create_token()
|
{:ok, token} = UserInviteToken.create_token()
|
||||||
|
|
||||||
data = %{
|
data = %{
|
||||||
|
@ -378,8 +393,7 @@ test "it registers a new user via invite token and returns the user." do
|
||||||
UserView.render("show.json", %{user: fetched_user})
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
end
|
end
|
||||||
|
|
||||||
@moduletag skip: "needs 'registrations_open: false' in config"
|
test "returns error on invalid token" do
|
||||||
test "it returns an error if invalid token submitted" do
|
|
||||||
data = %{
|
data = %{
|
||||||
"nickname" => "GrimReaper",
|
"nickname" => "GrimReaper",
|
||||||
"email" => "death@reapers.afterlife",
|
"email" => "death@reapers.afterlife",
|
||||||
|
@ -396,8 +410,7 @@ test "it returns an error if invalid token submitted" do
|
||||||
refute User.get_by_nickname("GrimReaper")
|
refute User.get_by_nickname("GrimReaper")
|
||||||
end
|
end
|
||||||
|
|
||||||
@moduletag skip: "needs 'registrations_open: false' in config"
|
test "returns error on expired token" do
|
||||||
test "it returns an error if expired token submitted" do
|
|
||||||
{:ok, token} = UserInviteToken.create_token()
|
{:ok, token} = UserInviteToken.create_token()
|
||||||
UserInviteToken.mark_as_used(token.token)
|
UserInviteToken.mark_as_used(token.token)
|
||||||
|
|
||||||
|
@ -416,6 +429,256 @@ test "it returns an error if expired token submitted" do
|
||||||
assert msg == "Expired token"
|
assert msg == "Expired token"
|
||||||
refute User.get_by_nickname("GrimReaper")
|
refute User.get_by_nickname("GrimReaper")
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "registers with date limited token" do
|
||||||
|
setup do
|
||||||
|
setting = Pleroma.Config.get([:instance, :registrations_open])
|
||||||
|
|
||||||
|
if setting do
|
||||||
|
Pleroma.Config.put([:instance, :registrations_open], false)
|
||||||
|
on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "vinny",
|
||||||
|
"email" => "pasta@pizza.vs",
|
||||||
|
"fullname" => "Vinny Vinesauce",
|
||||||
|
"bio" => "streamer",
|
||||||
|
"password" => "hiptofbees",
|
||||||
|
"confirm" => "hiptofbees"
|
||||||
|
}
|
||||||
|
|
||||||
|
check_fn = fn token ->
|
||||||
|
data = Map.put(data, "token", token.token)
|
||||||
|
{:ok, user} = TwitterAPI.register_user(data)
|
||||||
|
fetched_user = User.get_by_nickname("vinny")
|
||||||
|
|
||||||
|
assert UserView.render("show.json", %{user: user}) ==
|
||||||
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, data: data, check_fn: check_fn}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns user on success", %{check_fn: check_fn} do
|
||||||
|
{:ok, token} = UserInviteToken.create_token(expire_at: Date.utc_today())
|
||||||
|
|
||||||
|
check_fn.(token)
|
||||||
|
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
|
||||||
|
refute token.used
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns user on token which expired tomorrow", %{check_fn: check_fn} do
|
||||||
|
{:ok, token} = UserInviteToken.create_token(expire_at: Date.add(Date.utc_today(), 1))
|
||||||
|
|
||||||
|
check_fn.(token)
|
||||||
|
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
|
||||||
|
refute token.used
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns an error on overdue date", %{data: data} do
|
||||||
|
{:ok, token} = UserInviteToken.create_token(expire_at: Date.add(Date.utc_today(), -1))
|
||||||
|
|
||||||
|
data = Map.put(data, "token", token.token)
|
||||||
|
|
||||||
|
{:error, msg} = TwitterAPI.register_user(data)
|
||||||
|
|
||||||
|
assert msg == "Expired token"
|
||||||
|
refute User.get_by_nickname("vinny")
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
|
||||||
|
assert token.used == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "registers with reusable token" do
|
||||||
|
setup do
|
||||||
|
setting = Pleroma.Config.get([:instance, :registrations_open])
|
||||||
|
|
||||||
|
if setting do
|
||||||
|
Pleroma.Config.put([:instance, :registrations_open], false)
|
||||||
|
on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns user on success, after him registration fails" do
|
||||||
|
{:ok, token} = UserInviteToken.create_token(max_use: 100)
|
||||||
|
|
||||||
|
Ecto.Changeset.change(token, uses: 99) |> Repo.update!()
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "vinny",
|
||||||
|
"email" => "pasta@pizza.vs",
|
||||||
|
"fullname" => "Vinny Vinesauce",
|
||||||
|
"bio" => "streamer",
|
||||||
|
"password" => "hiptofbees",
|
||||||
|
"confirm" => "hiptofbees",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, user} = TwitterAPI.register_user(data)
|
||||||
|
fetched_user = User.get_by_nickname("vinny")
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
|
||||||
|
assert token.used == true
|
||||||
|
|
||||||
|
assert UserView.render("show.json", %{user: user}) ==
|
||||||
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "GrimReaper",
|
||||||
|
"email" => "death@reapers.afterlife",
|
||||||
|
"fullname" => "Reaper Grim",
|
||||||
|
"bio" => "Your time has come",
|
||||||
|
"password" => "scythe",
|
||||||
|
"confirm" => "scythe",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, msg} = TwitterAPI.register_user(data)
|
||||||
|
|
||||||
|
assert msg == "Expired token"
|
||||||
|
refute User.get_by_nickname("GrimReaper")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "registers with reusable date limited token" do
|
||||||
|
setup do
|
||||||
|
setting = Pleroma.Config.get([:instance, :registrations_open])
|
||||||
|
|
||||||
|
if setting do
|
||||||
|
Pleroma.Config.put([:instance, :registrations_open], false)
|
||||||
|
on_exit(fn -> Pleroma.Config.put([:instance, :registrations_open], setting) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns user on success" do
|
||||||
|
{:ok, token} =
|
||||||
|
UserInviteToken.create_token(
|
||||||
|
expire_at: Date.utc_today(),
|
||||||
|
max_use: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "vinny",
|
||||||
|
"email" => "pasta@pizza.vs",
|
||||||
|
"fullname" => "Vinny Vinesauce",
|
||||||
|
"bio" => "streamer",
|
||||||
|
"password" => "hiptofbees",
|
||||||
|
"confirm" => "hiptofbees",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, user} = TwitterAPI.register_user(data)
|
||||||
|
fetched_user = User.get_by_nickname("vinny")
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
|
||||||
|
refute token.used
|
||||||
|
|
||||||
|
assert UserView.render("show.json", %{user: user}) ==
|
||||||
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "error after max uses" do
|
||||||
|
{:ok, token} =
|
||||||
|
UserInviteToken.create_token(
|
||||||
|
expire_at: Date.utc_today(),
|
||||||
|
max_use: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
Ecto.Changeset.change(token, uses: 99) |> Repo.update!()
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "vinny",
|
||||||
|
"email" => "pasta@pizza.vs",
|
||||||
|
"fullname" => "Vinny Vinesauce",
|
||||||
|
"bio" => "streamer",
|
||||||
|
"password" => "hiptofbees",
|
||||||
|
"confirm" => "hiptofbees",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, user} = TwitterAPI.register_user(data)
|
||||||
|
fetched_user = User.get_by_nickname("vinny")
|
||||||
|
token = Repo.get_by(UserInviteToken, token: token.token)
|
||||||
|
assert token.used == true
|
||||||
|
|
||||||
|
assert UserView.render("show.json", %{user: user}) ==
|
||||||
|
UserView.render("show.json", %{user: fetched_user})
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "GrimReaper",
|
||||||
|
"email" => "death@reapers.afterlife",
|
||||||
|
"fullname" => "Reaper Grim",
|
||||||
|
"bio" => "Your time has come",
|
||||||
|
"password" => "scythe",
|
||||||
|
"confirm" => "scythe",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, msg} = TwitterAPI.register_user(data)
|
||||||
|
|
||||||
|
assert msg == "Expired token"
|
||||||
|
refute User.get_by_nickname("GrimReaper")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on overdue date" do
|
||||||
|
{:ok, token} =
|
||||||
|
UserInviteToken.create_token(
|
||||||
|
expire_at: Date.add(Date.utc_today(), -1),
|
||||||
|
max_use: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "GrimReaper",
|
||||||
|
"email" => "death@reapers.afterlife",
|
||||||
|
"fullname" => "Reaper Grim",
|
||||||
|
"bio" => "Your time has come",
|
||||||
|
"password" => "scythe",
|
||||||
|
"confirm" => "scythe",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, msg} = TwitterAPI.register_user(data)
|
||||||
|
|
||||||
|
assert msg == "Expired token"
|
||||||
|
refute User.get_by_nickname("GrimReaper")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error on with overdue date and after max" do
|
||||||
|
{:ok, token} =
|
||||||
|
UserInviteToken.create_token(
|
||||||
|
expire_at: Date.add(Date.utc_today(), -1),
|
||||||
|
max_use: 100
|
||||||
|
)
|
||||||
|
|
||||||
|
Ecto.Changeset.change(token, uses: 100) |> Repo.update!()
|
||||||
|
|
||||||
|
data = %{
|
||||||
|
"nickname" => "GrimReaper",
|
||||||
|
"email" => "death@reapers.afterlife",
|
||||||
|
"fullname" => "Reaper Grim",
|
||||||
|
"bio" => "Your time has come",
|
||||||
|
"password" => "scythe",
|
||||||
|
"confirm" => "scythe",
|
||||||
|
"token" => token.token
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, msg} = TwitterAPI.register_user(data)
|
||||||
|
|
||||||
|
assert msg == "Expired token"
|
||||||
|
refute User.get_by_nickname("GrimReaper")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
test "it returns the error on registration problems" do
|
test "it returns the error on registration problems" do
|
||||||
data = %{
|
data = %{
|
||||||
|
|
Loading…
Reference in a new issue