Skip to content

Commit

Permalink
Generate URLs for emails inside email templates, using verified routes
Browse files Browse the repository at this point in the history
  • Loading branch information
ku1ik committed May 16, 2024
1 parent f282c30 commit 3852ae1
Show file tree
Hide file tree
Showing 15 changed files with 78 additions and 114 deletions.
16 changes: 9 additions & 7 deletions lib/asciinema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ defmodule Asciinema do
end
end

def send_login_email(identifier, sign_up_enabled?, routes) do
case Accounts.generate_login_url(identifier, sign_up_enabled?, routes) do
{:ok, {type, url, email}} ->
Emails.send_email(type, email, url)
def send_login_email(identifier, sign_up_enabled? \\ true)

def send_login_email(identifier, sign_up_enabled?) do
case Accounts.generate_login_token(identifier, sign_up_enabled?) do
{:ok, {type, token, email}} ->
Emails.send_email(type, email, token)

{:error, _reason} = result ->
result
end
end

def send_account_deletion_email(user, routes) do
url = Accounts.generate_deletion_url(user, routes)
Emails.send_email(:account_deletion, user.email, url)
def send_account_deletion_email(user) do
token = Accounts.generate_deletion_token(user)
Emails.send_email(:account_deletion, user.email, token)

:ok
end
Expand Down
17 changes: 5 additions & 12 deletions lib/asciinema/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,23 @@ defmodule Asciinema.Accounts do
from(u in q, where: is_nil(u.email))
end

def generate_login_url(identifier, sign_up_enabled?, routes) do
def generate_login_token(identifier, sign_up_enabled? \\ true)

def generate_login_token(identifier, sign_up_enabled?) do
case {lookup_user(identifier), sign_up_enabled?} do
{{_, %User{email: nil}}, _} ->
{:error, :email_missing}

{{_, %User{} = user}, _} ->
url = user |> login_token() |> routes.login_url()

{:ok, {:login, url, user.email}}
{:ok, {:login, login_token(user), user.email}}

{{:email, nil}, true} ->
changeset = change_user(%User{}, %{email: identifier})

if changeset.valid? do
email = changeset.changes.email
url = email |> signup_token() |> routes.signup_url()

{:ok, {:signup, url, email}}
{:ok, {:signup, signup_token(email), email}}
else
{:error, :email_invalid}
end
Expand Down Expand Up @@ -209,12 +208,6 @@ defmodule Asciinema.Accounts do
end
end

def generate_deletion_url(user, routes) do
user
|> generate_deletion_token()
|> routes.account_deletion_url()
end

def generate_deletion_token(%User{id: user_id}) do
Token.sign(config(:secret), "acct-delete", user_id)
end
Expand Down
11 changes: 5 additions & 6 deletions lib/asciinema/emails.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,17 @@ defmodule Asciinema.Emails do
case job.args["type"] do
"signup" ->
job.args["to"]
|> Email.signup_email(job.args["url"])
|> Email.signup_email(job.args["token"])
|> deliver()

"login" ->
job.args["to"]
|> Email.login_email(job.args["url"])
|> Email.login_email(job.args["token"])
|> deliver()

"account_deletion" ->
job.args["to"]
|> Email.account_deletion_email(job.args["url"])
|> Email.account_deletion_email(job.args["token"])
|> deliver()
end

Expand All @@ -33,9 +33,8 @@ defmodule Asciinema.Emails do
end
end

def send_email(type, to, url) do
Job.new(%{type: type, to: to, url: url})
|> Oban.insert!()
def send_email(type, to, token) do
Oban.insert!(Job.new(%{type: type, to: to, token: token}))

:ok
end
Expand Down
18 changes: 9 additions & 9 deletions lib/asciinema/emails/email.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,30 @@ defmodule Asciinema.Emails.Email do
use Bamboo.Phoenix, view: AsciinemaWeb.EmailView
import Bamboo.Email

def signup_email(email_address, signup_url) do
def signup_email(email_address, token) do
base_email()
|> to(email_address)
|> subject("Welcome to #{instance_hostname()}")
|> render("signup.text", signup_url: signup_url)
|> render("signup.html", signup_url: signup_url)
|> render("signup.text", token: token)
|> render("signup.html", token: token)
|> fix_text_body()
end

def login_email(email_address, login_url) do
def login_email(email_address, token) do
base_email()
|> to(email_address)
|> subject("Login to #{instance_hostname()}")
|> render("login.text", login_url: login_url)
|> render("login.html", login_url: login_url)
|> render("login.text", token: token)
|> render("login.html", token: token)
|> fix_text_body()
end

def account_deletion_email(email_address, confirmation_url) do
def account_deletion_email(email_address, token) do
base_email()
|> to(email_address)
|> subject("Account deletion")
|> render("account_deletion.text", confirmation_url: confirmation_url)
|> render("account_deletion.html", confirmation_url: confirmation_url)
|> render("account_deletion.text", token: token)
|> render("account_deletion.html", token: token)
|> fix_text_body()
end

Expand Down
13 changes: 0 additions & 13 deletions lib/asciinema_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -179,24 +179,11 @@ defmodule AsciinemaWeb do
apply(__MODULE__, which, [])
end

alias AsciinemaWeb.Router.Helpers, as: Routes
alias AsciinemaWeb.Endpoint

def instance_hostname do
Endpoint.url()
|> URI.parse()
|> Map.get(:host)
end

def signup_url(token) do
Routes.users_url(Endpoint, :new, t: token)
end

def login_url(token) do
Routes.session_url(Endpoint, :new, t: token)
end

def account_deletion_url(token) do
Routes.user_deletion_url(Endpoint, :delete, t: token)
end
end
3 changes: 1 addition & 2 deletions lib/asciinema_web/controllers/login_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ defmodule AsciinemaWeb.LoginController do
result =
Asciinema.send_login_email(
identifier,
Map.get(conn.assigns, :cfg_sign_up_enabled?, true),
AsciinemaWeb
Map.get(conn.assigns, :cfg_sign_up_enabled?, true)
)

case result do
Expand Down
2 changes: 1 addition & 1 deletion lib/asciinema_web/controllers/user_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ defmodule AsciinemaWeb.UserController do
user = conn.assigns.current_user
address = user.email

:ok = Asciinema.send_account_deletion_email(user, AsciinemaWeb)
:ok = Asciinema.send_account_deletion_email(user)

conn
|> put_flash(:info, "Account removal initiated - check your inbox (#{address})")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p>If you wish to proceed, open the following link in your browser:</p>

<p><a href="<%= @confirmation_url %>"><%= @confirmation_url %></a></p>
<p><a href="<%= url(~p"/user/delete?t=#{@token}") %>"><%= url(~p"/user/delete?t=#{@token}") %></a></p>

<p>
<br>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ It seems you have requested deletion of your <%= @hostname %> account.

If you wish to proceed, open the following link in your browser:

<%= @confirmation_url %>
<%= url(~p"/user/delete?t=#{@token}") %>


If you did not initiate this request, just ignore this email.
2 changes: 1 addition & 1 deletion lib/asciinema_web/templates/email/login.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p>Click the following link to log in to your <%= @hostname %> account:</p>

<p><a href="<%= @login_url %>"><%= @login_url %></a></p>
<p><a href="<%= url(~p"/session/new?t=#{@token}") %>"><%= url(~p"/session/new?t=#{@token}") %></a></p>

<p>
<br>
Expand Down
2 changes: 1 addition & 1 deletion lib/asciinema_web/templates/email/login.text.eex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Welcome back!

Click the following link to log in to your <%= @hostname %> account:

<%= @login_url %>
<%= url(~p"/session/new?t=#{@token}") %>


If you did not initiate this request, just ignore this email. The request will expire shortly.
2 changes: 1 addition & 1 deletion lib/asciinema_web/templates/email/signup.html.eex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<p>Click the following link to setup your new account:</p>

<p><a href="<%= @signup_url %>"><%= @signup_url %></a></p>
<p><a href="<%= url(~p"/users/new?t=#{@token}") %>"><%= url(~p"/users/new?t=#{@token}") %></a></p>

<p>
<br>
Expand Down
2 changes: 1 addition & 1 deletion lib/asciinema_web/templates/email/signup.text.eex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Welcome to <%= @hostname %>!

Click the following link to setup your new account:

<%= @signup_url %>
<%= url(~p"/users/new?t=#{@token}") %>


If you did not initiate this request, just ignore this email. The request will expire shortly.
67 changes: 29 additions & 38 deletions test/asciinema/accounts_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule Asciinema.AccountsTest do
import Asciinema.Fixtures
import Asciinema.Factory
use Asciinema.DataCase
use Oban.Testing, repo: Asciinema.Repo
alias Asciinema.Accounts
Expand All @@ -10,81 +10,72 @@ defmodule Asciinema.AccountsTest do
end

test "valid token" do
token = Accounts.signup_token("test@example.com")
{:ok, {:signup, token, _email}} = Accounts.generate_login_token("test@example.com")
assert {:ok, "test@example.com"} = Accounts.verify_signup_token(token)
end
end

describe "generate_login_url/3" do
defmodule Routes do
def signup_url(_), do: "http://signup"
def login_url(_), do: "http://login"
end

test "existing user, by email" do
user = fixture(:user)
insert(:user, email: "test@example.com")

assert Accounts.generate_login_url(user.email, true, Routes) ==
{:ok, {:login, "http://login", user.email}}
assert {:ok, {:login, _token, "test@example.com"}} =
Accounts.generate_login_token("test@example.com")
end

test "existing user, by username" do
user = fixture(:user)
insert(:user, username: "test", email: "test@example.com")

assert Accounts.generate_login_url(user.username, true, Routes) ==
{:ok, {:login, "http://login", user.email}}
assert {:ok, {:login, _token, "test@example.com"}} = Accounts.generate_login_token("test")
end

test "non-existing user, by email" do
assert Accounts.generate_login_url("foo@example.com", true, Routes) ==
{:ok, {:signup, "http://signup", "foo@example.com"}}
assert {:ok, {:signup, _token, "foo@example.com"}} =
Accounts.generate_login_token("foo@example.com")

assert Accounts.generate_login_url("foo@ex.ample.com", true, Routes) ==
{:ok, {:signup, "http://signup", "foo@ex.ample.com"}}
assert {:ok, {:signup, _token, "foo@ex.ample.com"}} =
Accounts.generate_login_token("foo@ex.ample.com")
end

test "non-existing user, by email, when sign up is disabled" do
assert Accounts.generate_login_url("foo@example.com", false, Routes) ==
{:error, :user_not_found}
assert Accounts.generate_login_token("foo@example.com", false) == {:error, :user_not_found}
end

test "non-existing user, by email, when email is invalid" do
assert Accounts.generate_login_url("foo@", true, Routes) == {:error, :email_invalid}

assert Accounts.generate_login_url("foo@ex.ample..com", true, Routes) ==
{:error, :email_invalid}
assert Accounts.generate_login_token("foo@") == {:error, :email_invalid}
assert Accounts.generate_login_token("foo@ex.ample..com") == {:error, :email_invalid}
end

test "non-existing user, by username" do
assert Accounts.generate_login_url("idontexist", true, Routes) == {:error, :user_not_found}
assert Accounts.generate_login_token("idontexist") == {:error, :user_not_found}
end
end

describe "update_user/2" do
setup do
%{user: fixture(:user)}
end

def success(user, attrs) do
assert {:ok, user} = Accounts.update_user(user, attrs)
test "success" do
user = insert(:user)

user
end

test "success", %{user: user} do
assert success(user, %{email: "new@one.com"})
assert success(user, %{email: "ANOTHER@ONE.COM"}).email == "another@one.com"
assert success(user, %{username: "newone"})
end

def assert_validation_error(user, attrs) do
assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, attrs)
end
test "validation failures" do
user = insert(:user)

test "validation failures", %{user: user} do
assert_validation_error(user, %{email: "newone.com"})
assert_validation_error(user, %{email: ""})
assert_validation_error(user, %{username: ""})
end

defp success(user, attrs) do
assert {:ok, user} = Accounts.update_user(user, attrs)

user
end

defp assert_validation_error(user, attrs) do
assert {:error, %Ecto.Changeset{}} = Accounts.update_user(user, attrs)
end
end
end
Loading

0 comments on commit 3852ae1

Please sign in to comment.