From 31c126a39413c0f35bd8b0cd36e3eccc2f9ef91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BCdiger=20Diedrich?= Date: Sun, 2 Jun 2024 14:44:53 +0200 Subject: [PATCH] lv upgrade done; home.html form needs changed; test migrations --- .gitignore | 25 +- assets/css/app.css | 41 +++ assets/tailwind.config.js | 4 + lib/something_erlang/accounts.ex | 114 +++++++++ lib/something_erlang/accounts/user.ex | 16 ++ .../accounts/user_notifier.ex | 79 ++++++ lib/something_erlang/accounts/user_token.ex | 179 +++++++++++++ lib/something_erlang/application.ex | 2 + lib/something_erlang/awful_api/awful_api.ex | 25 ++ lib/something_erlang/awful_api/bookmarks.ex | 59 +++++ lib/something_erlang/awful_api/client.ex | 95 +++++++ lib/something_erlang/awful_api/thread.ex | 170 ++++++++++++ lib/something_erlang/grover.ex | 76 ++++++ .../components/layouts/app.html.heex | 26 +- .../components/layouts/root.html.heex | 2 +- .../controllers/page_html/home.html.heex | 230 +---------------- .../controllers/user_session_controller.ex | 42 +++ lib/something_erlang_web/live/thread_live.ex | 163 ++++++++++++ .../live/user_login_live.ex | 43 ++++ lib/something_erlang_web/router.ex | 54 +++- lib/something_erlang_web/user_auth.ex | 242 ++++++++++++++++++ mix.exs | 6 +- mix.lock | 1 + ...0230118110156_create_users_auth_tables.exs | 27 ++ .../20240329091549_add_bbuserid.exs | 15 ++ 25 files changed, 1452 insertions(+), 284 deletions(-) create mode 100644 lib/something_erlang/accounts.ex create mode 100644 lib/something_erlang/accounts/user.ex create mode 100644 lib/something_erlang/accounts/user_notifier.ex create mode 100644 lib/something_erlang/accounts/user_token.ex create mode 100644 lib/something_erlang/awful_api/awful_api.ex create mode 100644 lib/something_erlang/awful_api/bookmarks.ex create mode 100644 lib/something_erlang/awful_api/client.ex create mode 100644 lib/something_erlang/awful_api/thread.ex create mode 100644 lib/something_erlang/grover.ex create mode 100644 lib/something_erlang_web/controllers/user_session_controller.ex create mode 100644 lib/something_erlang_web/live/thread_live.ex create mode 100644 lib/something_erlang_web/live/user_login_live.ex create mode 100644 lib/something_erlang_web/user_auth.ex create mode 100644 priv/repo/migrations/20230118110156_create_users_auth_tables.exs create mode 100644 priv/repo/migrations/20240329091549_add_bbuserid.exs diff --git a/.gitignore b/.gitignore index a90274c..35b927d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,14 @@ -# The directory Mix will write compiled artifacts to. /_build/ - -# If you run "mix test --cover", coverage assets end up here. /cover/ - -# The directory Mix downloads your dependencies sources to. /deps/ - -# Where 3rd-party dependencies like ExDoc output generated docs. /doc/ - -# Ignore .fetch files in case you like to edit your project deps locally. /.fetch - -# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). *.ez - -# Temporary files, for example, from tests. /tmp/ - -# Ignore package tarball (built via "mix hex.build"). something_erlang-*.tar - -# Ignore assets that are produced by build tools. /priv/static/assets/ - -# Ignore digested assets cache. /priv/static/cache_manifest.json - -# In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ - +/.lexical/ \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 378c8f9..6216043 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -3,3 +3,44 @@ @import "tailwindcss/utilities"; /* This file is for your main application CSS */ + +body { + @apply bg-base-300 text-[14pt] leading-8 overflow-x-hidden; +} + +.post { + @apply bg-base-200 shadow-md rounded-md mb-4; + @apply grid grid-cols-[1fr] grid-rows-[min-content_1fr_auto]; + @apply sm:grid-cols-[13em_auto] sm:grid-rows-[1fr_auto]; +} +.post :where(article, .userinfo) { + @apply p-4 pb-0 sm:pb-4; +} + +.post .bbc-block { + @apply bg-base-300 p-4 py-2 border-l-2 border-secondary rounded w-full; +} +.post .bbc-block h4 { + @apply text-sm mb-2; +} +.post .bbc-spoiler { @apply bg-black text-black; } +.post .bbc-spoiler img { @apply invisible; } +.post .bbc-spoiler:hover { @apply text-inherit bg-inherit; } +.post .bbc-spoiler:hover img { @apply visible; } +.post .sa-smilie { @apply inline; } +.post iframe { + @apply w-full bg-[brown]; +} +.post .code { @apply mockup-code border-l-0; } +.post .code:before { @apply -ml-[2ch]; } +.post .code pre:before { @apply mr-0; } +.post .code h5 { @apply hidden; } +.post a[href] { @apply link; } +.post .editedby { @apply text-sm italic opacity-70 mt-4; } +.post .title :where(img[src*="gangtags"]) + * { + @apply mb-1; +} + +.pagination a svg { + @apply h-5; +} diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index 96ac40d..2848abd 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -18,6 +18,10 @@ module.exports = { } }, }, + daisyui: { + themes: ["winter", "night"], + darkTheme: "night" + }, plugins: [ require("@tailwindcss/forms"), require("daisyui"), diff --git a/lib/something_erlang/accounts.ex b/lib/something_erlang/accounts.ex new file mode 100644 index 0000000..c6c03df --- /dev/null +++ b/lib/something_erlang/accounts.ex @@ -0,0 +1,114 @@ +defmodule SomethingErlang.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias SomethingErlang.Repo + + alias SomethingErlang.AwfulApi.Client + alias SomethingErlang.Accounts.{User, UserToken} + + ## Database getters + + def get_user_by_bbuserid(bbuserid) when is_binary(bbuserid) do + Repo.get_by(User, :bbuserid, bbuserid) + end + + 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 + + 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) + + _ -> + nil + end + end + + @doc """ + Gets a single user. + + Raises `Ecto.NoResultsError` if the User does not exist. + + ## Examples + + iex> get_user!(123) + %User{} + + iex> get_user!(456) + ** (Ecto.NoResultsError) + + """ + def get_user!(id), do: Repo.get!(User, id) + + ## User registration + + @doc """ + Registers a user. + + ## Examples + + iex> register_user(%{field: value}) + {:ok, %User{}} + + iex> register_user(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def register_user(attrs) do + %User{} + |> User.registration_changeset(attrs) + |> Repo.insert() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking user changes. + + ## Examples + + iex> change_user_registration(user) + %Ecto.Changeset{data: %User{}} + + """ + def change_user_registration(%User{} = user, attrs \\ %{}) do + User.registration_changeset(user, attrs) + end + + ## Session + + @doc """ + Generates a session token. + """ + def generate_user_session_token(user) do + {token, user_token} = UserToken.build_session_token(user) + Repo.insert!(user_token) + token + end + + @doc """ + Gets the user with the given signed token. + """ + def get_user_by_session_token(token) do + {:ok, query} = UserToken.verify_session_token_query(token) + Repo.one(query) + end + + @doc """ + Deletes the signed token with the given context. + """ + def delete_user_session_token(token) do + Repo.delete_all(UserToken.token_and_context_query(token, "session")) + :ok + end +end diff --git a/lib/something_erlang/accounts/user.ex b/lib/something_erlang/accounts/user.ex new file mode 100644 index 0000000..d88fdc0 --- /dev/null +++ b/lib/something_erlang/accounts/user.ex @@ -0,0 +1,16 @@ +defmodule SomethingErlang.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + schema "users" do + field :bbuserid, :string + + timestamps() + end + + def registration_changeset(user, attrs, _opts \\ []) do + user + |> cast(attrs, [:bbuserid]) + |> validate_required([:bbuserid]) + end +end diff --git a/lib/something_erlang/accounts/user_notifier.ex b/lib/something_erlang/accounts/user_notifier.ex new file mode 100644 index 0000000..4da35d0 --- /dev/null +++ b/lib/something_erlang/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule SomethingErlang.Accounts.UserNotifier do + import Swoosh.Email + + alias SomethingErlang.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"SomethingErlang", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/something_erlang/accounts/user_token.ex b/lib/something_erlang/accounts/user_token.ex new file mode 100644 index 0000000..43de28f --- /dev/null +++ b/lib/something_erlang/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule SomethingErlang.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias SomethingErlang.Accounts.UserToken + + @hash_algorithm :sha256 + @rand_size 32 + + # It is very important to keep the reset password token expiry short, + # since someone with access to the email may take over the account. + @reset_password_validity_in_days 1 + @confirm_validity_in_days 7 + @change_email_validity_in_days 7 + @session_validity_in_days 60 + + schema "users_tokens" do + field :token, :binary + field :context, :string + field :sent_to, :string + belongs_to :user, SomethingErlang.Accounts.User + + timestamps(updated_at: false) + end + + @doc """ + Generates a token that will be stored in a signed place, + such as session or cookie. As they are signed, those + tokens do not need to be hashed. + + The reason why we store session tokens in the database, even + though Phoenix already provides a session cookie, is because + Phoenix' default session cookies are not persisted, they are + simply signed and potentially encrypted. This means they are + valid indefinitely, unless you change the signing/encryption + salt. + + Therefore, storing them allows individual user + sessions to be expired. The token system can also be extended + to store additional data, such as the device used for logging in. + You could then use this information to display all valid sessions + and devices in the UI and allow users to explicitly expire any + session they deem invalid. + """ + def build_session_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "session", user_id: user.id}} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The token is valid if it matches the value in the database and it has + not expired (after @session_validity_in_days). + """ + def verify_session_token_query(token) do + query = + from token in token_and_context_query(token, "session"), + join: user in assoc(token, :user), + where: token.inserted_at > ago(@session_validity_in_days, "day"), + select: user + + {:ok, query} + end + + @doc """ + Builds a token and its hash to be delivered to the user's email. + + The non-hashed token is sent to the user email while the + hashed part is stored in the database. The original token cannot be reconstructed, + which means anyone with read-only access to the database cannot directly use + the token in the application to gain access. Furthermore, if the user changes + their email in the system, the tokens sent to the previous email are no longer + valid. + + Users can easily adapt the existing code to provide other types of delivery methods, + for example, by phone numbers. + """ + def build_email_token(user, context) do + build_hashed_token(user, context, user.email) + end + + defp build_hashed_token(user, context, sent_to) do + token = :crypto.strong_rand_bytes(@rand_size) + hashed_token = :crypto.hash(@hash_algorithm, token) + + {Base.url_encode64(token, padding: false), + %UserToken{ + token: hashed_token, + context: context, + sent_to: sent_to, + user_id: user.id + }} + end + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + The given token is valid if it matches its hashed counterpart in the + database and the user email has not changed. This function also checks + if the token is being used within a certain period, depending on the + context. The default contexts supported by this function are either + "confirm", for account confirmation emails, and "reset_password", + for resetting the password. For verifying requests to change the email, + see `verify_change_email_token_query/2`. + """ + def verify_email_token_query(token, context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + days = days_for_context(context) + + query = + from token in token_and_context_query(hashed_token, context), + join: user in assoc(token, :user), + where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, + select: user + + {:ok, query} + + :error -> + :error + end + end + + defp days_for_context("confirm"), do: @confirm_validity_in_days + defp days_for_context("reset_password"), do: @reset_password_validity_in_days + + @doc """ + Checks if the token is valid and returns its underlying lookup query. + + The query returns the user found by the token, if any. + + This is used to validate requests to change the user + email. It is different from `verify_email_token_query/2` precisely because + `verify_email_token_query/2` validates the email has not changed, which is + the starting point by this function. + + The given token is valid if it matches its hashed counterpart in the + database and if it has not expired (after @change_email_validity_in_days). + The context must always start with "change:". + """ + def verify_change_email_token_query(token, "change:" <> _ = context) do + case Base.url_decode64(token, padding: false) do + {:ok, decoded_token} -> + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) + + query = + from token in token_and_context_query(hashed_token, context), + where: token.inserted_at > ago(@change_email_validity_in_days, "day") + + {:ok, query} + + :error -> + :error + end + end + + @doc """ + Returns the token struct for the given token value and context. + """ + def token_and_context_query(token, context) do + from UserToken, where: [token: ^token, context: ^context] + end + + @doc """ + Gets all tokens for the given user for the given contexts. + """ + def user_and_contexts_query(user, :all) do + from t in UserToken, where: t.user_id == ^user.id + end + + def user_and_contexts_query(user, [_ | _] = contexts) do + from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts + end +end diff --git a/lib/something_erlang/application.ex b/lib/something_erlang/application.ex index bc009b7..8d7268a 100644 --- a/lib/something_erlang/application.ex +++ b/lib/something_erlang/application.ex @@ -8,6 +8,8 @@ defmodule SomethingErlang.Application do @impl true def start(_type, _args) do children = [ + {Registry, [name: SomethingErlang.Registry.Grovers, keys: :unique]}, + {DynamicSupervisor, [name: SomethingErlang.Supervisor.Grovers, strategy: :one_for_one]}, SomethingErlangWeb.Telemetry, SomethingErlang.Repo, {DNSCluster, query: Application.get_env(:something_erlang, :dns_cluster_query) || :ignore}, diff --git a/lib/something_erlang/awful_api/awful_api.ex b/lib/something_erlang/awful_api/awful_api.ex new file mode 100644 index 0000000..e70cb9d --- /dev/null +++ b/lib/something_erlang/awful_api/awful_api.ex @@ -0,0 +1,25 @@ +defmodule SomethingErlang.AwfulApi do + require Logger + + alias SomethingErlang.AwfulApi.Thread + alias SomethingErlang.AwfulApi.Bookmarks + + @doc """ + Returns a list of all posts on page of a thread. + + ## Examples + + iex> t = AwfulApi.parsed_thread(3945300, 1) + iex> length(t.posts) + 42 + iex> t.page_count + 12 + """ + def parsed_thread(id, page, user) do + Thread.compile(id, page, user) + end + + def bookmarks(user) do + Bookmarks.compile(1, user) + end +end diff --git a/lib/something_erlang/awful_api/bookmarks.ex b/lib/something_erlang/awful_api/bookmarks.ex new file mode 100644 index 0000000..d0097bd --- /dev/null +++ b/lib/something_erlang/awful_api/bookmarks.ex @@ -0,0 +1,59 @@ +defmodule SomethingErlang.AwfulApi.Bookmarks do + require Logger + + alias SomethingErlang.AwfulApi.Client + + def compile(page, user) do + doc = Client.bookmarks_doc(page, user) + html = Floki.parse_document!(doc) + + for thread <- Floki.find(html, "tr.thread") do + parse(thread) + end + end + + def parse(thread) do + %{ + title: Floki.find(thread, "td.title") |> inner_html() |> Floki.raw_html(), + icon: Floki.find(thread, "td.icon") |> inner_html() |> Floki.raw_html(), + author: Floki.find(thread, "td.author") |> inner_html() |> Floki.text(), + replies: Floki.find(thread, "td.replies") |> inner_html() |> Floki.text(), + views: Floki.find(thread, "td.views") |> inner_html() |> Floki.text(), + rating: Floki.find(thread, "td.rating") |> inner_html() |> Floki.raw_html(), + lastpost: Floki.find(thread, "td.lastpost") |> inner_html() |> Floki.raw_html() + } + + for {"td", [{"class", class} | _attrs], children} <- Floki.find(thread, "td"), + String.starts_with?(class, "star") == false, + into: %{} do + case class do + <<"title", _rest::binary>> -> + {:title, children |> Floki.raw_html()} + + <<"icon", _rest::binary>> -> + {:icon, children |> Floki.raw_html()} + + <<"author", _rest::binary>> -> + {:author, children |> Floki.text()} + + <<"replies", _rest::binary>> -> + {:replies, children |> Floki.text() |> String.to_integer()} + + <<"views", _rest::binary>> -> + {:views, children |> Floki.text() |> String.to_integer()} + + <<"rating", _rest::binary>> -> + {:rating, children |> Floki.raw_html()} + + <<"lastpost", _rest::binary>> -> + {:lastpost, children |> Floki.raw_html()} + end + end + end + + defp inner_html(node) do + node + |> List.first() + |> Floki.children() + end +end diff --git a/lib/something_erlang/awful_api/client.ex b/lib/something_erlang/awful_api/client.ex new file mode 100644 index 0000000..9847778 --- /dev/null +++ b/lib/something_erlang/awful_api/client.ex @@ -0,0 +1,95 @@ +defmodule SomethingErlang.AwfulApi.Client do + @base_url "https://forums.somethingawful.com/" + @user_agent "SomethingErlangClient/0.1" + + require Logger + + def thread_doc(id, page, user) do + resp = new(user) |> get_thread(id, page) + Logger.debug("Client reply in #{resp.private.time}ms ") + :unicode.characters_to_binary(resp.body, :latin1) + end + + def thread_lastseen_page(id, user) do + 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) + page |> String.to_integer() + end + + def bookmarks_doc(page, user) do + resp = new(user) |> get_bookmarks(page) + :unicode.characters_to_binary(resp.body, :latin1) + end + + defp get_thread(req, id, page) do + url = "showthread.php" + params = [threadid: id, pagenumber: page] + Req.get!(req, url: url, params: params) + end + + defp get_thread_newpost(req, id) do + url = "showthread.php" + params = [threadid: id, goto: "newpost"] + Req.get!(req, url: url, params: params, follow_redirects: false) + end + + defp get_bookmarks(req, page) do + url = "bookmarkthreads.php" + params = [pagenumber: page] + Req.get!(req, url: url, params: params) + end + + 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, + cache: true, + headers: [cookie: [cookies(%{bbuserid: user.id, bbpassword: user.hash})]] + ) + |> Req.Request.append_request_steps( + time: fn req -> Req.Request.put_private(req, :time, Time.utc_now()) end + ) + |> Req.Request.prepend_response_steps( + time: fn {req, res} -> + start = req.private.time + diff = Time.diff(Time.utc_now(), start, :millisecond) + {req, Req.Response.put_private(res, :time, diff)} + end + ) + end + + defp new() do + Req.new( + base_url: @base_url, + user_agent: @user_agent, + redirect: false + ) + end + + defp cookies(args) when is_map(args) do + Enum.map_join(args, "; ", fn {k, v} -> "#{k}=#{v}" end) + end +end diff --git a/lib/something_erlang/awful_api/thread.ex b/lib/something_erlang/awful_api/thread.ex new file mode 100644 index 0000000..8d00102 --- /dev/null +++ b/lib/something_erlang/awful_api/thread.ex @@ -0,0 +1,170 @@ +defmodule SomethingErlang.AwfulApi.Thread do + require Logger + + alias SomethingErlang.AwfulApi.Client + + def compile(id, page, user) do + doc = Client.thread_doc(id, page, user) + html = Floki.parse_document!(doc) + thread = Floki.find(html, "#thread") |> Floki.filter_out("table.post.ignored") + + title = Floki.find(html, "title") |> Floki.text() + title = title |> String.replace(" - The Something Awful Forums", "") + + page_count = + case Floki.find(html, "#content .pages.top option:last-of-type") |> Floki.text() do + "" -> 1 + s -> String.to_integer(s) + end + + posts = + for post <- Floki.find(thread, "table.post") do + %{ + userinfo: post |> userinfo(), + postdate: post |> postdate(), + postbody: post |> postbody() + } + end + + %{id: id, title: title, page: page, page_count: page_count, posts: posts} + end + + defp userinfo(post) do + user = Floki.find(post, "dl.userinfo") + name = user |> Floki.find("dt") |> Floki.text() + regdate = user |> Floki.find("dd.registered") |> Floki.text() + title = user |> Floki.find("dd.title") |> List.first() |> Floki.children() |> Floki.raw_html() + + %{ + name: name, + regdate: regdate, + title: title + } + end + + defp postdate(post) do + date = Floki.find(post, "td.postdate") |> Floki.find("td.postdate") |> Floki.text() + + [month_text, day, year, hours, minutes] = + date + |> String.split(~r{[\s,:]}, trim: true) + |> Enum.drop(1) + + month = + 1 + + Enum.find_index( + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + fn m -> m == month_text end + ) + + NaiveDateTime.new!( + year |> String.to_integer(), + month, + day |> String.to_integer(), + hours |> String.to_integer(), + minutes |> String.to_integer(), + 0 + ) + end + + defp postbody(post) do + body = + Floki.find(post, "td.postbody") + |> List.first() + |> Floki.filter_out(:comment) + + Floki.traverse_and_update(body, fn + {"img", attrs, []} -> transform(:img, attrs) + {"a", attrs, children} -> transform(:a, attrs, children) + other -> other + end) + |> Floki.children() + |> Floki.raw_html() + end + + defp transform(elem, attr, children \\ []) + + defp transform(:img, attrs, _children) do + {"class", class} = List.keyfind(attrs, "class", 0, {"class", ""}) + + if class == "sa-smilie" do + {"img", attrs, []} + else + t_attrs = List.keyreplace(attrs, "class", 0, {"class", "img-responsive"}) + {"img", [{"loading", "lazy"} | t_attrs], []} + end + end + + defp transform(:a, attrs, children) do + {"href", href} = List.keyfind(attrs, "href", 0, {"href", ""}) + + cond do + # skip internal links + String.starts_with?(href, "/") -> + {"a", [{"href", href}], children} + + # mp4 + String.ends_with?(href, ".mp4") -> + transform_link(:mp4, href) + + # gifv + String.ends_with?(href, ".gifv") -> + transform_link(:gifv, href) + + # youtube + String.starts_with?(href, "https://www.youtube.com/watch") -> + transform_link(:ytlong, href) + + String.starts_with?(href, "https://youtu.be/") -> + transform_link(:ytshort, href) + + true -> + Logger.debug("no transform for #{href}") + {"a", [{"href", href}], children} + end + end + + defp transform_link(:mp4, href), + do: + {"div", [{"class", "responsive-embed"}], + [ + {"video", [{"class", "img-responsive"}, {"controls", ""}], + [{"source", [{"src", href}, {"type", "video/mp4"}], []}]} + ]} + + defp transform_link(:gifv, href), + do: + {"div", [{"class", "responsive-embed"}], + [ + {"video", [{"class", "img-responsive"}, {"controls", ""}], + [ + {"source", [{"src", String.replace(href, ".gifv", ".webm")}, {"type", "video/webm"}], + []}, + {"source", [{"src", String.replace(href, ".gifv", ".mp4")}, {"type", "video/mp4"}], + []} + ]} + ]} + + defp transform_link(:ytlong, href) do + String.replace(href, "/watch?v=", "/embed/") + |> youtube_iframe() + end + + defp transform_link(:ytshort, href) do + String.replace(href, "youtu.be/", "www.youtube.com/embed/") + |> youtube_iframe() + end + + defp youtube_iframe(src), + do: + {"div", [{"class", "responsive-embed"}], + [ + {"iframe", + [ + {"class", "youtube-player"}, + {"loading", "lazy"}, + {"allow", "fullscreen"}, + {"src", src} + ], []} + ]} +end diff --git a/lib/something_erlang/grover.ex b/lib/something_erlang/grover.ex new file mode 100644 index 0000000..8a907a6 --- /dev/null +++ b/lib/something_erlang/grover.ex @@ -0,0 +1,76 @@ +defmodule SomethingErlang.Grover do + use GenServer + + alias SomethingErlang.AwfulApi + require Logger + + def mount(user) do + 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 + GenServer.call(via(self()), {:show_thread, thread_id, page_number}) + end + + def get_bookmarks!(page_number) do + GenServer.call(via(self()), {:show_bookmarks, page_number}) + end + + def start_link([lv_pid, user]) do + GenServer.start_link( + __MODULE__, + [lv_pid, user], + name: via(lv_pid) + ) + end + + @impl true + def init([pid, user]) do + %{bbuserid: userid, bbpassword: userhash} = user + + initial_state = %{ + lv_pid: pid, + user: %{id: userid, hash: userhash} + } + + Logger.debug("init #{userid} #{inspect(pid)}") + Process.monitor(pid) + {:ok, initial_state} + end + + @impl true + def handle_call({:show_thread, thread_id, page_number}, _from, state) do + thread = AwfulApi.parsed_thread(thread_id, page_number, state.user) + {:reply, thread, state} + end + + @impl true + def handle_call({:show_bookmarks, _page_number}, _from, state) do + bookmarks = AwfulApi.bookmarks(state.user) + {:reply, bookmarks, state} + end + + @impl true + def handle_info({:DOWN, _ref, :process, _object, reason}, state) do + Logger.debug("received :DOWN from: #{inspect(state.lv_pid)} reason: #{inspect(reason)}") + + case reason do + {:shutdown, _} -> {:stop, :normal, state} + :killed -> {:stop, :normal, state} + _ -> {:noreply, state} + end + end + + defp via(lv_pid), + do: {:via, Registry, {SomethingErlang.Registry.Grovers, lv_pid}} +end diff --git a/lib/something_erlang_web/components/layouts/app.html.heex b/lib/something_erlang_web/components/layouts/app.html.heex index e23bfc8..9705cb4 100644 --- a/lib/something_erlang_web/components/layouts/app.html.heex +++ b/lib/something_erlang_web/components/layouts/app.html.heex @@ -1,31 +1,9 @@
-
- - - -

- v<%= Application.spec(:phoenix, :vsn) %> -

-
-
-
-
+
+
<.flash_group flash={@flash} /> <%= @inner_content %>
diff --git a/lib/something_erlang_web/components/layouts/root.html.heex b/lib/something_erlang_web/components/layouts/root.html.heex index add12ce..198491e 100644 --- a/lib/something_erlang_web/components/layouts/root.html.heex +++ b/lib/something_erlang_web/components/layouts/root.html.heex @@ -11,7 +11,7 @@ - + <%= @inner_content %> diff --git a/lib/something_erlang_web/controllers/page_html/home.html.heex b/lib/something_erlang_web/controllers/page_html/home.html.heex index dc1820b..e67d593 100644 --- a/lib/something_erlang_web/controllers/page_html/home.html.heex +++ b/lib/something_erlang_web/controllers/page_html/home.html.heex @@ -1,222 +1,10 @@ <.flash_group flash={@flash} /> - -
-
- -

- Phoenix Framework - - v<%= Application.spec(:phoenix, :vsn) %> - -

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
+<.form :let={f} for={@conn} action={~p"/"}> + + + + +
+	<%= inspect(@current_user) %>
+	<%= inspect(@conn.cookies) %>
+
diff --git a/lib/something_erlang_web/controllers/user_session_controller.ex b/lib/something_erlang_web/controllers/user_session_controller.ex new file mode 100644 index 0000000..8086df9 --- /dev/null +++ b/lib/something_erlang_web/controllers/user_session_controller.ex @@ -0,0 +1,42 @@ +defmodule SomethingErlangWeb.UserSessionController do + use SomethingErlangWeb, :controller + + alias SomethingErlang.Accounts + alias SomethingErlangWeb.UserAuth + + def create(conn, %{"_action" => "registered"} = params) do + create(conn, params, "Account created successfully!") + end + + def create(conn, %{"_action" => "password_updated"} = params) do + conn + |> put_session(:user_return_to, ~p"/users/settings") + |> create(params, "Password updated successfully!") + end + + def create(conn, params) do + create(conn, params, "Welcome back!") + end + + defp create(conn, %{"user" => user_params}, info) do + %{"username" => username, "password" => password} = user_params + + if user = Accounts.login_sa_user_and_get_cookies(username, password) do + conn + |> put_flash(:info, info) + |> put_session(:bbpassword, user.bbpassword) + |> UserAuth.log_in_user(user, user_params) + else + conn + |> put_flash(:error, "Login failed!") + |> put_flash(:email, String.slice(username, 0, 160)) + |> redirect(to: ~p"/users/log_in") + end + end + + def delete(conn, _params) do + conn + |> put_flash(:info, "Logged out successfully.") + |> UserAuth.log_out_user() + end +end diff --git a/lib/something_erlang_web/live/thread_live.ex b/lib/something_erlang_web/live/thread_live.ex new file mode 100644 index 0000000..77c0922 --- /dev/null +++ b/lib/something_erlang_web/live/thread_live.ex @@ -0,0 +1,163 @@ +defmodule SomethingErlangWeb.ThreadLive do + use SomethingErlangWeb, :live_view + + alias SomethingErlang.Grover + + def render(%{thread: _} = assigns) do + ~H""" +

+ <%= raw(@thread.title) %> +

+ +
+ <.pagination thread={@thread} /> + + <%= for %{userinfo: author, postdate: date, postbody: article} <- @thread.posts do %> + <.post author={author} date={date}> + <%= raw(article) %> + + <% end %> + + <.pagination thread={@thread} /> +
+ """ + end + + def render(assigns) do + ~H""" +

+ Threads! +

+
+      <%= inspect(@current_user) %>
+    
+ """ + end + + def post(assigns) do + ~H""" +
+ <.user info={@author} /> +
+ <%= render_slot(@inner_block) %> +
+ <.toolbar date={@date} /> +
+ """ + end + + def user(assigns) do + ~H""" + + """ + end + + def toolbar(assigns) do + ~H""" +
+ <%= @date |> Calendar.strftime("%A, %b %d %Y @ %H:%M") %> +
+ """ + end + + def pagination(assigns) do + ~H""" + + """ + end + + defp label_button(%{label: "«", page: page} = assigns), + do: ~H""" + <.icon name="hero-chevron-double-left-mini" /><%= page %> + """ + + defp label_button(%{label: "‹", page: page} = assigns), + do: ~H""" + <.icon name="hero-chevron-left-mini" /><%= page %> + """ + + defp label_button(%{label: "›", page: page} = assigns), + do: ~H""" + <%= page %><.icon name="hero-chevron-right-mini" /> + """ + + defp label_button(%{label: "»", page: page} = assigns), + do: ~H""" + <%= page %><.icon name="hero-chevron-double-right-mini" /> + """ + + defp label_button(%{page: page} = assigns), + do: ~H""" + <%= page %> + """ + + defp buttons(thread) do + %{page: page_number, page_count: page_count} = thread + + first_page_disabled_button = if page_number == 1, do: " btn-disabled", else: "" + last_page_disabled_button = if page_number == page_count, do: " btn-disabled", else: "" + active_page_button = " btn-active" + + prev_button_target = if page_number > 1, do: page_number - 1, else: 1 + next_button_target = if page_number < page_count, do: page_number + 1, else: page_count + + [ + %{label: "«", page: 1, special: "" <> first_page_disabled_button}, + %{label: "‹", page: prev_button_target, special: "" <> first_page_disabled_button}, + %{label: "#{page_number}", page: page_number, special: active_page_button}, + %{label: "›", page: next_button_target, special: "" <> last_page_disabled_button}, + %{label: "»", page: page_count, special: "" <> last_page_disabled_button} + ] + end + + def mount(_params, session, socket) do + user = + socket.assigns.current_user + |> Map.put(:bbpassword, session["bbpassword"]) + + Grover.mount(user) + + {:ok, socket} + end + + def handle_params(%{"id" => id, "page" => page}, _, socket) do + thread = Grover.get_thread!(id, page |> String.to_integer()) + + {:noreply, + socket + |> assign(:page_title, thread.title) + |> assign(:thread, thread)} + end + + def handle_params(%{"id" => id}, _, socket) do + params = %{page: 1} + + {:noreply, + push_patch( + socket, + to: ~p"/thread/#{id}?#{params}", + replace: true + )} + end + + def handle_params(%{}, _, socket) do + {:noreply, socket} + end +end diff --git a/lib/something_erlang_web/live/user_login_live.ex b/lib/something_erlang_web/live/user_login_live.ex new file mode 100644 index 0000000..7ea83ef --- /dev/null +++ b/lib/something_erlang_web/live/user_login_live.ex @@ -0,0 +1,43 @@ +defmodule SomethingErlangWeb.UserLoginLive do + use SomethingErlangWeb, :live_view + + def render(assigns) do + ~H""" +
+ <.header class="text-center text-neutral-content"> + Sign in to account + <:subtitle> + Don't have an account? + <.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline"> + Sign up + + for an account now. + + + + <.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore"> + <.input field={@form[:username]} type="text" label="Username" required /> + <.input field={@form[:password]} type="password" label="Password" required /> + + <:actions> + <.input field={@form[:remember_me]} type="checkbox" label="Keep me logged in" /> + <.link href={~p"/users/reset_password"} class="text-sm font-semibold"> + Forgot your password? + + + <:actions> + <.button phx-disable-with="Signing in..." class="w-full"> + Sign in + + + +
+ """ + end + + def mount(_params, _session, socket) do + email = live_flash(socket.assigns.flash, :email) + form = to_form(%{"email" => email}, as: "user") + {:ok, assign(socket, form: form), temporary_assigns: [form: form]} + end +end diff --git a/lib/something_erlang_web/router.ex b/lib/something_erlang_web/router.ex index d577c02..712109c 100644 --- a/lib/something_erlang_web/router.ex +++ b/lib/something_erlang_web/router.ex @@ -1,23 +1,53 @@ defmodule SomethingErlangWeb.Router do use SomethingErlangWeb, :router + import SomethingErlangWeb.UserAuth + pipeline :browser do - plug :accepts, ["html"] - plug :fetch_session - plug :fetch_live_flash - plug :put_root_layout, html: {SomethingErlangWeb.Layouts, :root} - plug :protect_from_forgery - plug :put_secure_browser_headers + plug(:accepts, ["html"]) + plug(:fetch_session) + plug(:fetch_live_flash) + plug(:put_root_layout, html: {SomethingErlangWeb.Layouts, :root}) + plug(:protect_from_forgery) + plug(:put_secure_browser_headers) + plug(:fetch_current_user) + plug(:load_bbcookie) end pipeline :api do - plug :accepts, ["json"] + plug(:accepts, ["json"]) end scope "/", SomethingErlangWeb do - pipe_through :browser + pipe_through(:browser) - get "/", PageController, :home + get("/", PageController, :home) + post("/", PageController, :to_forum_path) + + live_session :user_browsing, + on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do + live("/thread", ThreadLive) + live("/thread/:id", ThreadLive) + end + end + + ## Authentication routes + + scope "/", SomethingErlangWeb do + pipe_through([:browser, :redirect_if_user_is_authenticated]) + + live_session :redirect_if_user_is_authenticated, + on_mount: [{SomethingErlangWeb.UserAuth, :redirect_if_user_is_authenticated}] do + live("/users/log_in", UserLoginLive, :new) + end + + post("/users/log_in", UserSessionController, :create) + end + + scope "/", SomethingErlangWeb do + pipe_through([:browser]) + + delete("/users/log_out", UserSessionController, :delete) end # Other scopes may use custom stacks. @@ -35,10 +65,10 @@ defmodule SomethingErlangWeb.Router do import Phoenix.LiveDashboard.Router scope "/dev" do - pipe_through :browser + pipe_through(:browser) - live_dashboard "/dashboard", metrics: SomethingErlangWeb.Telemetry - forward "/mailbox", Plug.Swoosh.MailboxPreview + live_dashboard("/dashboard", metrics: SomethingErlangWeb.Telemetry) + forward("/mailbox", Plug.Swoosh.MailboxPreview) end end end diff --git a/lib/something_erlang_web/user_auth.ex b/lib/something_erlang_web/user_auth.ex new file mode 100644 index 0000000..c1cf867 --- /dev/null +++ b/lib/something_erlang_web/user_auth.ex @@ -0,0 +1,242 @@ +defmodule SomethingErlangWeb.UserAuth do + use SomethingErlangWeb, :verified_routes + + import Plug.Conn + import Phoenix.Controller + + alias SomethingErlang.Accounts + + # Make the remember me cookie valid for 60 days. + # If you want bump or reduce this value, also change + # the token expiry itself in UserToken. + @max_age 60 * 60 * 24 * 60 + @remember_me_cookie "_something_erlang_web_user_remember_me" + @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] + + @doc """ + Logs the user in. + + It renews the session ID and clears the whole session + to avoid fixation attacks. See the renew_session + function to customize this behaviour. + + It also sets a `:live_socket_id` key in the session, + so LiveView sessions are identified and automatically + disconnected on log out. The line can be safely removed + if you are not using LiveView. + """ + def log_in_user(conn, user, params \\ %{}) do + token = Accounts.generate_user_session_token(user) + user_return_to = get_session(conn, :user_return_to) + + conn + |> renew_session() + |> put_hashcookie_in_session(user.bbpassword) + |> put_token_in_session(token) + |> maybe_write_remember_me_cookie(token, params) + |> redirect(to: user_return_to || signed_in_path(conn)) + end + + defp put_hashcookie_in_session(conn, bbpassword) do + put_resp_cookie(conn, "bbpassword", bbpassword) + end + + defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do + put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) + end + + defp maybe_write_remember_me_cookie(conn, _token, _params) do + conn + end + + # This function renews the session ID and erases the whole + # session to avoid fixation attacks. If there is any data + # in the session you may want to preserve after log in/log out, + # you must explicitly fetch the session data before clearing + # and then immediately set it after clearing, for example: + # + # defp renew_session(conn) do + # preferred_locale = get_session(conn, :preferred_locale) + # + # conn + # |> configure_session(renew: true) + # |> clear_session() + # |> put_session(:preferred_locale, preferred_locale) + # end + # + defp renew_session(conn) do + conn + |> configure_session(renew: true) + |> clear_session() + end + + @doc """ + Logs the user out. + + It clears all session data for safety. See renew_session. + """ + def log_out_user(conn) do + user_token = get_session(conn, :user_token) + user_token && Accounts.delete_user_session_token(user_token) + + if live_socket_id = get_session(conn, :live_socket_id) do + SomethingErlangWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + end + + conn + |> renew_session() + |> delete_resp_cookie(@remember_me_cookie) + |> redirect(to: "/") + end + + def load_bbcookie(conn, _opts) do + conn + |> put_session(:bbpassword, conn.cookies["bbpassword"]) + end + + @doc """ + Authenticates the user by looking into the session + and remember me token. + """ + def fetch_current_user(conn, _opts) do + {user_token, conn} = ensure_user_token(conn) + user = user_token && Accounts.get_user_by_session_token(user_token) + + assign(conn, :current_user, user) + end + + defp ensure_user_token(conn) do + if token = get_session(conn, :user_token) do + {token, conn} + else + conn = fetch_cookies(conn, signed: [@remember_me_cookie]) + + if token = conn.cookies[@remember_me_cookie] do + {token, put_token_in_session(conn, token)} + else + {nil, conn} + end + end + end + + @doc """ + Handles mounting and authenticating the current_user in LiveViews. + + ## `on_mount` arguments + + * `:mount_current_user` - Assigns current_user + to socket assigns based on user_token, or nil if + there's no user_token or no matching user. + + * `:ensure_authenticated` - Authenticates the user from the session, + and assigns the current_user to socket assigns based + on user_token. + Redirects to login page if there's no logged user. + + * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. + Redirects to signed_in_path if there's a logged user. + + ## Examples + + Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate + the current_user: + + defmodule SomethingErlangWeb.PageLive do + use SomethingErlangWeb, :live_view + + on_mount {SomethingErlangWeb.UserAuth, :mount_current_user} + ... + end + + Or use the `live_session` of your router to invoke the on_mount callback: + + live_session :authenticated, on_mount: [{SomethingErlangWeb.UserAuth, :ensure_authenticated}] do + live "/profile", ProfileLive, :index + end + """ + def on_mount(:mount_current_user, _params, session, socket) do + {:cont, mount_current_user(session, socket)} + end + + def on_mount(:ensure_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:cont, socket} + else + socket = + socket + |> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.") + |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") + + {:halt, socket} + end + end + + def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do + socket = mount_current_user(session, socket) + + if socket.assigns.current_user do + {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} + else + {:cont, socket} + end + end + + defp mount_current_user(session, socket) do + case session do + %{"user_token" => user_token} -> + Phoenix.Component.assign_new(socket, :current_user, fn -> + Accounts.get_user_by_session_token(user_token) + end) + + %{} -> + Phoenix.Component.assign_new(socket, :current_user, fn -> nil end) + end + end + + @doc """ + Used for routes that require the user to not be authenticated. + """ + def redirect_if_user_is_authenticated(conn, _opts) do + if conn.assigns[:current_user] do + conn + |> redirect(to: signed_in_path(conn)) + |> halt() + else + conn + end + end + + @doc """ + Used for routes that require the user to be authenticated. + + If you want to enforce the user email is confirmed before + they use the application at all, here would be a good place. + """ + def require_authenticated_user(conn, _opts) do + if conn.assigns[:current_user] do + conn + else + conn + |> put_flash(:error, "You must log in to access this page.") + |> maybe_store_return_to() + |> redirect(to: ~p"/users/log_in") + |> halt() + end + end + + defp put_token_in_session(conn, token) do + conn + |> put_session(:user_token, token) + |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") + end + + defp maybe_store_return_to(%{method: "GET"} = conn) do + put_session(conn, :user_return_to, current_path(conn)) + end + + defp maybe_store_return_to(conn), do: conn + + defp signed_in_path(_conn), do: ~p"/" +end diff --git a/mix.exs b/mix.exs index 394c89f..15a0f66 100644 --- a/mix.exs +++ b/mix.exs @@ -39,7 +39,8 @@ defmodule SomethingErlang.MixProject do {:phoenix_html, "~> 4.0"}, {:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_view, "~> 0.20.2"}, - {:floki, ">= 0.30.0", only: :test}, + # {:floki, ">= 0.30.0", only: :test}, + {:floki, ">= 0.30.0"}, {:phoenix_live_dashboard, "~> 0.8.3"}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, @@ -57,7 +58,8 @@ defmodule SomethingErlang.MixProject do {:gettext, "~> 0.20"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.1.1"}, - {:bandit, "~> 1.2"} + {:bandit, "~> 1.2"}, + {:req, "~> 0.5.0"} ] end diff --git a/mix.lock b/mix.lock index c88b0e9..a1c37fe 100644 --- a/mix.lock +++ b/mix.lock @@ -30,6 +30,7 @@ "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"}, + "req": {:hex, :req, "0.5.0", "6d8a77c25cfc03e06a439fb12ffb51beade53e3fe0e2c5e362899a18b50298b3", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "dda04878c1396eebbfdec6db6f3d4ca609e5c8846b7ee88cc56eb9891406f7a3"}, "swoosh": {:hex, :swoosh, "1.16.9", "20c6a32ea49136a4c19f538e27739bb5070558c0fa76b8a95f4d5d5ca7d319a1", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "878b1a7a6c10ebbf725a3349363f48f79c5e3d792eb621643b0d276a38acc0a6"}, "tailwind": {:hex, :tailwind, "0.2.2", "9e27288b568ede1d88517e8c61259bc214a12d7eed271e102db4c93fcca9b2cd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "ccfb5025179ea307f7f899d1bb3905cd0ac9f687ed77feebc8f67bdca78565c4"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/priv/repo/migrations/20230118110156_create_users_auth_tables.exs b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs new file mode 100644 index 0000000..0cb6c00 --- /dev/null +++ b/priv/repo/migrations/20230118110156_create_users_auth_tables.exs @@ -0,0 +1,27 @@ +defmodule SomethingErlang.Repo.Migrations.CreateUsersAuthTables do + use Ecto.Migration + + def change do + execute "CREATE EXTENSION IF NOT EXISTS citext", "" + + create table(:users) do + add :email, :citext, null: false + add :hashed_password, :string, null: false + add :confirmed_at, :naive_datetime + timestamps() + end + + create unique_index(:users, [:email]) + + create table(:users_tokens) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token, :binary, null: false + add :context, :string, null: false + add :sent_to, :string + timestamps(updated_at: false) + end + + create index(:users_tokens, [:user_id]) + create unique_index(:users_tokens, [:context, :token]) + end +end diff --git a/priv/repo/migrations/20240329091549_add_bbuserid.exs b/priv/repo/migrations/20240329091549_add_bbuserid.exs new file mode 100644 index 0000000..80265e1 --- /dev/null +++ b/priv/repo/migrations/20240329091549_add_bbuserid.exs @@ -0,0 +1,15 @@ +defmodule SomethingErlang.Repo.Migrations.AddBbuserid do + use Ecto.Migration + + def change do + alter table(:users) do + remove :email + remove :hashed_password + remove :confirmed_at + add :bbuserid, :citext + end + + # drop index(:users, [:email]) + create unique_index(:users, [:bbuserid]) + end +end