diff --git a/lib/something_erlang/accounts.ex b/lib/something_erlang/accounts.ex index bb5ac18..93b5175 100644 --- a/lib/something_erlang/accounts.ex +++ b/lib/something_erlang/accounts.ex @@ -6,42 +6,34 @@ defmodule SomethingErlang.Accounts do import Ecto.Query, warn: false alias SomethingErlang.Repo + alias SomethingErlang.AwfulApi.Client alias SomethingErlang.Accounts.{User, UserToken, UserNotifier} ## Database getters - @doc """ - Gets a user by email. - - ## Examples - - iex> get_user_by_email("foo@example.com") - %User{} - - iex> get_user_by_email("unknown@example.com") - nil - - """ - def get_user_by_email(email) when is_binary(email) do - Repo.get_by(User, email: email) + def get_user_by_bbuserid(bbuserid) when is_binary(bbuserid) do + Repo.get_by(User, :bbuserid, bbuserid) end - @doc """ - Gets a user by email and password. + def get_or_create_user_by_bbuserid(bbuserid) + when is_binary(bbuserid) do + if user = Repo.get_by(User, bbuserid: bbuserid) do + user + else + %User{bbuserid: bbuserid} |> Repo.insert!() + end + end - ## Examples + def login_sa_user_and_get_cookies(username, password) + when is_binary(username) and is_binary(password) do + case Client.login(username, password) do + %{bbuserid: userid, bbpassword: _hash} = bbuser -> + user = get_or_create_user_by_bbuserid(userid) + Map.merge(user, bbuser) - iex> get_user_by_email_and_password("foo@example.com", "correct_password") - %User{} - - iex> get_user_by_email_and_password("foo@example.com", "invalid_password") - nil - - """ - def get_user_by_email_and_password(email, password) - when is_binary(email) and is_binary(password) do - user = Repo.get_by(User, email: email) - if User.valid_password?(user, password), do: user + _ -> + nil + end end @doc """ @@ -90,129 +82,7 @@ defmodule SomethingErlang.Accounts do """ def change_user_registration(%User{} = user, attrs \\ %{}) do - User.registration_changeset(user, attrs, hash_password: false, validate_email: false) - end - - ## Settings - - @doc """ - Returns an `%Ecto.Changeset{}` for changing the user email. - - ## Examples - - iex> change_user_email(user) - %Ecto.Changeset{data: %User{}} - - """ - def change_user_email(user, attrs \\ %{}) do - User.email_changeset(user, attrs, validate_email: false) - end - - @doc """ - Emulates that the email will change without actually changing - it in the database. - - ## Examples - - iex> apply_user_email(user, "valid password", %{email: ...}) - {:ok, %User{}} - - iex> apply_user_email(user, "invalid password", %{email: ...}) - {:error, %Ecto.Changeset{}} - - """ - def apply_user_email(user, password, attrs) do - user - |> User.email_changeset(attrs) - |> User.validate_current_password(password) - |> Ecto.Changeset.apply_action(:update) - end - - @doc """ - Updates the user email using the given token. - - If the token matches, the user email is updated and the token is deleted. - The confirmed_at date is also updated to the current time. - """ - def update_user_email(user, token) do - context = "change:#{user.email}" - - with {:ok, query} <- UserToken.verify_change_email_token_query(token, context), - %UserToken{sent_to: email} <- Repo.one(query), - {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do - :ok - else - _ -> :error - end - end - - defp user_email_multi(user, email, context) do - changeset = - user - |> User.email_changeset(%{email: email}) - |> User.confirm_changeset() - - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context])) - end - - @doc ~S""" - Delivers the update email instructions to the given user. - - ## Examples - - iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})") - {:ok, %{to: ..., body: ...}} - - """ - def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun) - when is_function(update_email_url_fun, 1) do - {encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}") - - Repo.insert!(user_token) - UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) - end - - @doc """ - Returns an `%Ecto.Changeset{}` for changing the user password. - - ## Examples - - iex> change_user_password(user) - %Ecto.Changeset{data: %User{}} - - """ - def change_user_password(user, attrs \\ %{}) do - User.password_changeset(user, attrs, hash_password: false) - end - - @doc """ - Updates the user password. - - ## Examples - - iex> update_user_password(user, "valid password", %{password: ...}) - {:ok, %User{}} - - iex> update_user_password(user, "invalid password", %{password: ...}) - {:error, %Ecto.Changeset{}} - - """ - def update_user_password(user, password, attrs) do - changeset = - user - |> User.password_changeset(attrs) - |> User.validate_current_password(password) - - Ecto.Multi.new() - |> Ecto.Multi.update(:user, changeset) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) - |> Repo.transaction() - |> case do - {:ok, %{user: user}} -> {:ok, user} - {:error, :user, changeset, _} -> {:error, changeset} - end + User.registration_changeset(user, attrs) end ## Session @@ -241,113 +111,4 @@ defmodule SomethingErlang.Accounts do Repo.delete_all(UserToken.token_and_context_query(token, "session")) :ok end - - ## Confirmation - - @doc ~S""" - Delivers the confirmation email instructions to the given user. - - ## Examples - - iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}")) - {:ok, %{to: ..., body: ...}} - - iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}")) - {:error, :already_confirmed} - - """ - def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun) - when is_function(confirmation_url_fun, 1) do - if user.confirmed_at do - {:error, :already_confirmed} - else - {encoded_token, user_token} = UserToken.build_email_token(user, "confirm") - Repo.insert!(user_token) - UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) - end - end - - @doc """ - Confirms a user by the given token. - - If the token matches, the user account is marked as confirmed - and the token is deleted. - """ - def confirm_user(token) do - with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), - %User{} = user <- Repo.one(query), - {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do - {:ok, user} - else - _ -> :error - end - end - - defp confirm_user_multi(user) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.confirm_changeset(user)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"])) - end - - ## Reset password - - @doc ~S""" - Delivers the reset password email to the given user. - - ## Examples - - iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}")) - {:ok, %{to: ..., body: ...}} - - """ - def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun) - when is_function(reset_password_url_fun, 1) do - {encoded_token, user_token} = UserToken.build_email_token(user, "reset_password") - Repo.insert!(user_token) - UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token)) - end - - @doc """ - Gets the user by reset password token. - - ## Examples - - iex> get_user_by_reset_password_token("validtoken") - %User{} - - iex> get_user_by_reset_password_token("invalidtoken") - nil - - """ - def get_user_by_reset_password_token(token) do - with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"), - %User{} = user <- Repo.one(query) do - user - else - _ -> nil - end - end - - @doc """ - Resets the user password. - - ## Examples - - iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) - {:ok, %User{}} - - iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) - {:error, %Ecto.Changeset{}} - - """ - def reset_user_password(user, attrs) do - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.password_changeset(user, attrs)) - |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) - |> Repo.transaction() - |> case do - {:ok, %{user: user}} -> {:ok, user} - {:error, :user, changeset, _} -> {:error, changeset} - end - end end diff --git a/lib/something_erlang/accounts/user.ex b/lib/something_erlang/accounts/user.ex index 608afbb..002a837 100644 --- a/lib/something_erlang/accounts/user.ex +++ b/lib/something_erlang/accounts/user.ex @@ -3,153 +3,14 @@ defmodule SomethingErlang.Accounts.User do import Ecto.Changeset schema "users" do - field :email, :string - field :password, :string, virtual: true, redact: true - field :hashed_password, :string, redact: true - field :confirmed_at, :naive_datetime + field :bbuserid, :string timestamps() end - @doc """ - A user changeset for registration. - - It is important to validate the length of both email and password. - Otherwise databases may truncate the email without warnings, which - could lead to unpredictable or insecure behaviour. Long passwords may - also be very expensive to hash for certain algorithms. - - ## Options - - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. - Defaults to `true`. - - * `:validate_email` - Validates the uniqueness of the email, in case - you don't want to validate the uniqueness of the email (like when - using this changeset for validations on a LiveView form before - submitting the form), this option can be set to `false`. - Defaults to `true`. - """ def registration_changeset(user, attrs, opts \\ []) do user - |> cast(attrs, [:email, :password]) - |> validate_email(opts) - |> validate_password(opts) - end - - defp validate_email(changeset, opts) do - changeset - |> validate_required([:email]) - |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") - |> validate_length(:email, max: 160) - |> maybe_validate_unique_email(opts) - end - - defp validate_password(changeset, opts) do - changeset - |> validate_required([:password]) - |> validate_length(:password, min: 12, max: 72) - # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") - # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") - # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") - |> maybe_hash_password(opts) - end - - defp maybe_hash_password(changeset, opts) do - hash_password? = Keyword.get(opts, :hash_password, true) - password = get_change(changeset, :password) - - if hash_password? && password && changeset.valid? do - changeset - # If using Bcrypt, then further validate it is at most 72 bytes long - |> validate_length(:password, max: 72, count: :bytes) - |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) - |> delete_change(:password) - else - changeset - end - end - - defp maybe_validate_unique_email(changeset, opts) do - if Keyword.get(opts, :validate_email, true) do - changeset - |> unsafe_validate_unique(:email, SomethingErlang.Repo) - |> unique_constraint(:email) - else - changeset - end - end - - @doc """ - A user changeset for changing the email. - - It requires the email to change otherwise an error is added. - """ - def email_changeset(user, attrs, opts \\ []) do - user - |> cast(attrs, [:email]) - |> validate_email(opts) - |> case do - %{changes: %{email: _}} = changeset -> changeset - %{} = changeset -> add_error(changeset, :email, "did not change") - end - end - - @doc """ - A user changeset for changing the password. - - ## Options - - * `:hash_password` - Hashes the password so it can be stored securely - in the database and ensures the password field is cleared to prevent - leaks in the logs. If password hashing is not needed and clearing the - password field is not desired (like when using this changeset for - validations on a LiveView form), this option can be set to `false`. - Defaults to `true`. - """ - def password_changeset(user, attrs, opts \\ []) do - user - |> cast(attrs, [:password]) - |> validate_confirmation(:password, message: "does not match password") - |> validate_password(opts) - end - - @doc """ - Confirms the account by setting `confirmed_at`. - """ - def confirm_changeset(user) do - now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) - change(user, confirmed_at: now) - end - - @doc """ - Verifies the password. - - If there is no user or the user doesn't have a password, we call - `Bcrypt.no_user_verify/0` to avoid timing attacks. - """ - def valid_password?(%SomethingErlang.Accounts.User{hashed_password: hashed_password}, password) - when is_binary(hashed_password) and byte_size(password) > 0 do - Bcrypt.verify_pass(password, hashed_password) - end - - def valid_password?(_, _) do - Bcrypt.no_user_verify() - false - end - - @doc """ - Validates the current password otherwise adds an error to the changeset. - """ - def validate_current_password(changeset, password) do - if valid_password?(changeset.data, password) do - changeset - else - add_error(changeset, :current_password, "is not valid") - end + |> cast(attrs, [:bbuserid]) + |> validate_required([:bbuserid]) end end diff --git a/lib/something_erlang/application.ex b/lib/something_erlang/application.ex index d9bc5f5..28d9e1e 100644 --- a/lib/something_erlang/application.ex +++ b/lib/something_erlang/application.ex @@ -10,7 +10,6 @@ defmodule SomethingErlang.Application do children = [ {Registry, [name: SomethingErlang.Registry.Grovers, keys: :unique]}, {DynamicSupervisor, [name: SomethingErlang.Supervisor.Grovers, strategy: :one_for_one]}, - # Start the Ecto repository # Start the Telemetry supervisor SomethingErlangWeb.Telemetry, # Start the Ecto repository diff --git a/lib/something_erlang/awful_api/client.ex b/lib/something_erlang/awful_api/client.ex index e8b07e8..5097051 100644 --- a/lib/something_erlang/awful_api/client.ex +++ b/lib/something_erlang/awful_api/client.ex @@ -3,12 +3,12 @@ defmodule SomethingErlang.AwfulApi.Client do @user_agent "SomethingErlangClient/0.1" def thread_doc(id, page, user) do - resp = new_request(user) |> get_thread(id, page) + resp = new(user) |> get_thread(id, page) :unicode.characters_to_binary(resp.body, :latin1) end def thread_lastseen_page(id, user) do - resp = new_request(user) |> get_thread_newpost(id) + resp = new(user) |> get_thread_newpost(id) %{status: 302, headers: headers} = resp {"location", redir_url} = List.keyfind(headers, "location", 0) [_, page] = Regex.run(~r/pagenumber=(\d+)/, redir_url) @@ -16,11 +16,11 @@ defmodule SomethingErlang.AwfulApi.Client do end def bookmarks_doc(page, user) do - resp = new_request(user) |> get_bookmarks(page) + resp = new(user) |> get_bookmarks(page) :unicode.characters_to_binary(resp.body, :latin1) end - defp get_thread(req, id, page \\ 1) do + defp get_thread(req, id, page) do url = "showthread.php" params = [threadid: id, pagenumber: page] Req.get!(req, url: url, params: params) @@ -32,13 +32,34 @@ defmodule SomethingErlang.AwfulApi.Client do Req.get!(req, url: url, params: params, follow_redirects: false) end - defp get_bookmarks(req, page \\ 1) do + defp get_bookmarks(req, page) do url = "bookmarkthreads.php" params = [pagenumber: page] Req.get!(req, url: url, params: params) end - defp new_request(user) do + def login(username, password) do + form = [action: "login", username: username, password: password] + url = "account.php" + + new() + |> Req.post!(url: url, form: form) + |> extract_cookies() + end + + defp extract_cookies(%Req.Response{} = response) do + cookies = response.headers["set-cookie"] + + for cookie <- cookies, String.starts_with?(cookie, "bb"), into: %{} do + cookie + |> String.split(";", parts: 2) + |> List.first() + |> String.split("=") + |> then(fn [k, v] -> {String.to_existing_atom(k), v} end) + end + end + + defp new(user) do Req.new( base_url: @base_url, user_agent: @user_agent, @@ -49,6 +70,16 @@ defmodule SomethingErlang.AwfulApi.Client do # |> Req.Request.append_request_steps(inspect: &IO.inspect/1) end + defp new() do + Req.new( + base_url: @base_url, + user_agent: @user_agent, + redirect: false + ) + + # |> Req.Request.append_request_steps(inspect: &IO.inspect/1) + end + defp cookies(args) when is_map(args) do Enum.map_join(args, "; ", fn {k, v} -> "#{k}=#{v}" end) end diff --git a/lib/something_erlang/grover.ex b/lib/something_erlang/grover.ex index fd17686..8a907a6 100644 --- a/lib/something_erlang/grover.ex +++ b/lib/something_erlang/grover.ex @@ -5,10 +5,17 @@ defmodule SomethingErlang.Grover do require Logger def mount(user) do - DynamicSupervisor.start_child( - SomethingErlang.Supervisor.Grovers, - {__MODULE__, [self(), user]} - ) + grover = + DynamicSupervisor.start_child( + SomethingErlang.Supervisor.Grovers, + {__MODULE__, [self(), user]} + ) + + case grover do + {:ok, pid} -> pid + {:error, {:already_started, pid}} -> pid + {:error, error} -> {:error, error} + end end def get_thread!(thread_id, page_number) do diff --git a/lib/something_erlang_web/components/core_components.ex b/lib/something_erlang_web/components/core_components.ex index 192dad2..0ee17d1 100644 --- a/lib/something_erlang_web/components/core_components.ex +++ b/lib/something_erlang_web/components/core_components.ex @@ -2,12 +2,17 @@ defmodule SomethingErlangWeb.CoreComponents do @moduledoc """ Provides core UI components. - The components in this module use Tailwind CSS, a utility-first CSS framework. - See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to - customize the generated components in this module. + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as modals, tables, and + forms. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. - Icons are provided by [heroicons](https://heroicons.com), using the - [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. + The default components use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn + how to customize them or feel free to swap in another framework altogether. + + Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. """ use Phoenix.Component @@ -20,30 +25,21 @@ defmodule SomethingErlangWeb.CoreComponents do ## Examples <.modal id="confirm-modal"> - Are you sure? - <:confirm>OK - <:cancel>Cancel + This is a modal. - JS commands may be passed to the `:on_cancel` and `on_confirm` attributes - for the caller to react to each button press, for example: + JS commands may be passed to the `:on_cancel` to configure + the closing/cancel event, for example: - <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> - Are you sure you? - <:confirm>OK - <:cancel>Cancel + <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> + This is another modal. + """ attr :id, :string, required: true attr :show, :boolean, default: false attr :on_cancel, JS, default: %JS{} - attr :on_confirm, JS, default: %JS{} - slot :inner_block, required: true - slot :title - slot :subtitle - slot :confirm - slot :cancel def modal(assigns) do ~H""" @@ -51,9 +47,10 @@ defmodule SomethingErlangWeb.CoreComponents do id={@id} phx-mounted={@show && show_modal(@id)} phx-remove={hide_modal(@id)} + data-cancel={JS.exec(@on_cancel, "phx-remove")} class="relative z-50 hidden" > -
+- <%= render_slot(@subtitle) %> -
-
-
+ <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" /> <%= @title %>
-<%= msg %>
-