add tests

This commit is contained in:
CJ_Clippy 2025-01-20 02:01:08 -08:00
parent 86960c2709
commit 4cf90d78a9
70 changed files with 1631 additions and 3450 deletions

View File

@ -59,6 +59,8 @@ COPY ./services/bright/lib lib
COPY ./services/bright/assets assets
COPY ./services/bright/test test
# compile assets
RUN mix assets.deploy
@ -75,7 +77,7 @@ RUN mix release
## dev target
FROM builder AS dev
RUN echo "balls. that is all."
COPY ./services/bright/config/test.exs config/test.exs
RUN ls -la ./contrib/
CMD [ "mix", "phx.server" ]

View File

@ -11,6 +11,8 @@ config :bright,
ecto_repos: [Bright.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :bright, BrightWeb.Endpoint,
url: [host: "localhost"],
@ -40,6 +42,13 @@ config :ueberauth, Ueberauth,
]
# These variables are required at runtime, but we must get them from system env here (not sure why)
# we don't raise here, in case mix is running some task like creating an ecto migration
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: System.get_env("GITHUB_CLIENT_ID"),
client_secret: System.get_env("GITHUB_CLIENT_SECRET")
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails

View File

@ -20,6 +20,28 @@ if System.get_env("PHX_SERVER") do
config :bright, BrightWeb.Endpoint, server: true
end
config :bright,
aws_bucket: System.get_env("AWS_BUCKET"),
aws_host: System.get_env("AWS_HOST"),
aws_access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
aws_secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"),
aws_region: System.get_env("AWS_REGION"),
superstreamer_url: System.get_env("SUPERSTREAMER_URL"),
superstreamer_auth_token: System.get_env("SUPERSTREAMER_AUTH_TOKEN"),
public_s3_endpoint: System.get_env("PUBLIC_S3_ENDPOINT"),
s3_cdn_endpoint: System.get_env("PUBLIC_S3_ENDPOINT")
# @see https://elixirforum.com/t/backblaze-and-ex-aws-ex-aws-s3-2-4-3-presign-url-issue/56805
config :ex_aws,
access_key_id: {:system, "AWS_ACCESS_KEY_ID"},
secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"},
s3: [
host: System.get_env("AWS_HOST"),
bucket: System.get_env("AWS_BUCKET")
]
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
@ -51,6 +73,7 @@ if config_env() == :prod do
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :bright, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :bright, BrightWeb.Endpoint,
@ -65,18 +88,23 @@ if config_env() == :prod do
],
secret_key_base: secret_key_base
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: System.get_env("GITHUB_CLIENT_ID") || raise("environment variable GITHUB_CLIENT_ID is missing."),
client_secret: System.get_env("GITHUB_CLIENT_SECRET") || raise("environment variable GITHUB_CLIENT_SECRET is missing.")
# We need to stop the program from running if OAuth client IDs and client secrets are not present in env.
# We also do this in config.exs, but we wait to raise until here otherwise mix wouldn't be able to run ecto migrations
System.get_env("GITHUB_CLIENT_ID") || raise("environment variable GITHUB_CLIENT_ID is missing.")
System.get_env("GITHUB_CLIENT_SECRET") || raise("environment variable GITHUB_CLIENT_SECRET is missing.")
# config :ueberauth, Ueberauth.Strategy.Patreon.OAuth,
# client_id: System.get_env("PATREON_CLIENT_ID"),
# client_secret: System.get_env("PATREON_CLIENT_SECRET")
# config :ueberauth, Ueberauth.Strategy.Github.OAuth,
# client_id: System.get_env("GITHUB_CLIENT_ID"),
# client_secret: System.get_env("GITHUB_CLIENT_SECRET")
# config :ueberauth, Ueberauth.Strategy.Github.OAuth,
# client_id: {:system, "GITHUB_CLIENT_ID"},

View File

@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1
@ -9,7 +12,9 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :bright, Bright.Repo,
url: "#{System.get_env("DATABASE_URL")}",
username: "postgres",
password: "password",
hostname: System.cmd("docker", ["inspect", "--format", "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", "futureporn-db"]) |> elem(0) |> String.trim(),
database: "bright_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
@ -22,7 +27,8 @@ config :bright, BrightWeb.Endpoint,
server: false
# Prevent Oban from running jobs and plugins during test runs
config :bright, Oban, testing: :inline
config :bright, Oban,
testing: :manual
# Have Superstreamer use mocks during testing
config :bright, superstreamer_api_client: ApiClientBehaviorMock
@ -42,3 +48,10 @@ config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true
# @see https://elixirforum.com/t/backblaze-and-ex-aws-ex-aws-s3-2-4-3-presign-url-issue/56805
# config :ex_aws, :s3,
# host: Application.get_env(:bright, :aws_host),
# access_key_id: Application.get_env(:bright, :aws_access_key_id),
# secret_access_key: Application.get_env(:bright, :aws_secret_access_key),
# bucket: Application.get_env(:bright, :aws_bucket)

View File

@ -1,357 +0,0 @@
defmodule Bright.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Bright.Repo
alias Bright.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)
end
@doc """
Gets a user by email and password.
## Examples
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
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, 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.by_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.by_user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
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.by_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.by_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.by_user_and_contexts_query(user, :all))
|> Repo.transaction()
|> case do
{:ok, %{user: user}} -> {:ok, user}
{:error, :user, changeset, _} -> {:error, changeset}
end
end
end

View File

@ -1,162 +0,0 @@
defmodule Bright.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime
field :provider, :string
timestamps(type: :utc_datetime)
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, :provider])
|> 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)
# Examples of additional password validation:
# |> 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)
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> 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, Bright.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 = DateTime.utc_now() |> DateTime.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?(%Bright.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
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password) do
changeset
else
add_error(changeset, :current_password, "is not valid")
end
end
end

View File

@ -1,79 +0,0 @@
defmodule Bright.Accounts.UserNotifier do
import Swoosh.Email
alias Bright.Mailer
# Delivers the email using the application mailer.
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"Bright", "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

View File

@ -1,179 +0,0 @@
defmodule Bright.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Bright.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, Bright.Accounts.User
timestamps(type: :utc_datetime, 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 by_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 by_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 by_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 by_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 by_user_and_contexts_query(user, :all) do
from t in UserToken, where: t.user_id == ^user.id
end
def by_user_and_contexts_query(user, [_ | _] = contexts) do
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@ -0,0 +1,72 @@
defmodule Bright.B2 do
@moduledoc """
The B2 context.
"""
import Ecto.Query, warn: false
require Logger
alias ExAws.S3
alias Bright.Repo
alias Bright.Cache
alias Bright.B2
@doc """
Put a file from local disk to Backblaze
"""
def put(local_file, object_key) do
bucket = Application.get_env(:bright, :aws_bucket)
if bucket === nil do
raise("bucket specification is missing")
end
local_file
|> S3.Upload.stream_file
|> S3.upload(bucket, object_key)
|> ExAws.request
|> case do
{:ok, %{status_code: 200}} -> {:ok, object_key}
{:error, reason} -> {:error, reason}
end
end
@doc """
Download a file from Backblaze to local disk
"""
def get(object_key, local_file) do
# B2.get("test/SampleVideo_1280x720_1mb.mp4", local_file)
bucket = Application.get_env(:bright, :aws_bucket)
S3.download_file(bucket, object_key, local_file)
|> ExAws.request
|> case do
{:ok, :done} -> {:ok, local_file}
{:error, reason} -> {:error, reason}
end
end
# defp upload_to_s3(body, key) do
# body
# |> S3.upload(@bucket_name, key)
# |> ExAws.request()
# |> case do
# {:ok, _response} -> :ok
# {:error, reason} -> {:error, reason}
# end
# end
end

View File

@ -0,0 +1,178 @@
defmodule Bright.Cache do
@moduledoc """
A simple caching module that saves files to the `/tmp` directory.
"""
alias Bright.Streams.Vod
@cache_dir "/tmp/bright_cache"
require Logger
def generate_filename(input) do
prefix = :crypto.strong_rand_bytes(6) |> Base.encode64(padding: false) |> String.replace(~r/[^a-zA-Z0-9]/, "")
base = Path.basename(input)
"#{prefix}-#{base}"
end
# Ensure the cache directory exists
defp ensure_cache_dir! do
unless File.exists?(@cache_dir) do
File.mkdir_p!(@cache_dir)
end
end
@doc """
Save data to the cache with a given key.
## Examples
iex> Bright.Cache.put("example_key", "example_data")
:ok
"""
def put(key, data) do
ensure_cache_dir!()
file_path = cache_file_path(key)
case File.write(file_path, data) do
:ok ->
Logger.debug("[Cache] Saved key #{key} to #{file_path}")
:ok
{:error, reason} ->
Logger.error("[Cache] Failed to save key #{key}: #{reason}")
{:error, reason}
end
end
@doc """
Save a VOD file to the cache using its origin_temp_input_url.
## Examples
iex> Bright.Cache.put(%Vod{key: "vod_key", origin_temp_input_url: "http://example.com/video.mp4"})
:ok
"""
def put(%Vod{origin_temp_input_url: url}) do
ensure_cache_dir!()
key = generate_filename(url)
file_path = cache_file_path(key)
case HTTPoison.get(url) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
case File.write(file_path, body) do
:ok ->
Logger.debug("[Cache] Downloaded and saved VOD #{key} from #{url} to #{file_path}")
:ok
{:error, reason} ->
Logger.error("[Cache] Failed to save VOD #{key}: #{reason}")
{:error, reason}
end
{:ok, %HTTPoison.Response{status_code: status}} ->
Logger.error("[Cache] Failed to download VOD #{key}: HTTP #{status}")
{:error, :http_error}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("[Cache] Failed to download VOD #{key}: #{reason}")
{:error, reason}
end
end
@doc """
Retrieve data from the cache for a given key.
## Examples
iex> Bright.Cache.get("example_key")
{:ok, "example_data"}
iex> Bright.Cache.get("nonexistent_key")
:error
"""
def get(key) do
ensure_cache_dir!()
file_path = cache_file_path(key)
case File.read(file_path) do
{:ok, data} ->
Logger.debug("[Cache] Retrieved key #{key} from #{file_path}")
{:ok, data}
{:error, :enoent} ->
Logger.debug("[Cache] Key #{key} not found in cache")
:error
{:error, reason} ->
Logger.error("[Cache] Failed to retrieve key #{key}: #{reason}")
{:error, reason}
end
end
@doc """
Delete a cached item by its key.
## Examples
iex> Bright.Cache.delete("example_key")
:ok
iex> Bright.Cache.delete("nonexistent_key")
:ok
"""
def delete(key) do
ensure_cache_dir!()
file_path = cache_file_path(key)
case File.rm(file_path) do
:ok ->
Logger.debug("[Cache] Deleted key #{key} from #{file_path}")
:ok
{:error, :enoent} ->
Logger.debug("[Cache] Key #{key} not found for deletion")
:ok
{:error, reason} ->
Logger.error("[Cache] Failed to delete key #{key}: #{reason}")
{:error, reason}
end
end
@doc """
Clear all cached data.
## Examples
iex> Bright.Cache.clear()
:ok
"""
def clear do
ensure_cache_dir!()
case File.rm_rf(@cache_dir) do
{:ok, _} ->
Logger.debug("[Cache] Cleared all cached data")
ensure_cache_dir!()
:ok
{:error, reason} ->
Logger.error("[Cache] Failed to clear cache: #{reason}")
{:error, reason}
end
end
# Helper function to generate the file path for a given key
defp cache_file_path(key) do
Path.join(@cache_dir, key)
end
end

View File

@ -0,0 +1,90 @@
defmodule Bright.Images do
@moduledoc """
Images context for image generation and processing
"""
import FFmpex
use FFmpex.Options
require Logger
def get_video_duration(file_path) do
case FFprobe.streams(file_path) do
{:ok, streams} ->
streams
|> Enum.find(fn stream -> stream["codec_type"] == "video" end)
|> case do
nil -> {:error, "No video stream found"}
video_stream ->
duration =
video_stream
|> Map.get("duration", %{})
case duration do
nil -> {:error, "Duration not found or could not be calculated"}
duration -> {:ok, duration}
end
end
{:error, reason} -> {:error, reason}
end
end
def get_video_framecount(file_path) do
case FFprobe.streams(file_path) do
{:ok, streams} ->
streams
|> Enum.find(fn stream -> stream["codec_type"] == "video" end)
|> case do
nil -> {:error, "No video stream found"}
video_stream ->
nb_frames =
video_stream
|> Map.get("nb_frames", %{})
case nb_frames do
nil -> {:error, "nb_frames not found"}
%{} -> {:error, "nb_frames not found. (empty map)"}
nb_frames ->
case Integer.parse(nb_frames) do
{number, _} -> {:ok, number}
end
end
end
{:error, reason} -> {:error, reason}
end
end
def create_thumbnail(input_file, output_file) do
case get_video_framecount(input_file) do
{:error, reason} -> {:error, reason}
{:ok, framecount} ->
frame_interval = div(framecount, 25)
scale_width = 160
tile_grid = "5x5"
# ffmpeg -y -i ~/Videos/moose-encounter_75.mp4 -frames:v 1 -vf 'select=not(mod(n\,257)),scale=160:-1,tile=5x5' -update 1 -fps_mode passthrough ~/Videos/thumb.jpg
command =
FFmpex.new_command
|> add_global_option(option_y())
|> add_input_file(input_file)
|> add_output_file(output_file)
|> add_file_option(option_vframes(1))
|> add_file_option(option_vf("select=not(mod(n\\,#{frame_interval})),scale=#{scale_width}:-1,tile=#{tile_grid}"))
|> add_file_option(option_vsync(1)) # -vsync is deprecated in ffmpeg but ffmpex doesn't have the modern replacement, -fps_mode
# |> add_file_option(option_update(1)) # ffmpeg complains but it doesn't necessarily need this. I'm omitting because ffmpex doesn't know this function
# |> add_file_option(option_fps_mode("passthrough")) # -fps_mode is the modern replacement for -vsync
execute(command)
end
end
end

View File

@ -1,138 +0,0 @@
defmodule Bright.Jobs.CreateHlsPlaylist do
alias Bright.Repo
alias Bright.Streams.Vod
use Oban.Worker,
queue: :default,
max_attempts: 3,
tags: ["video", "vod"]
require Logger
@auth_token System.get_env("SUPERSTREAMER_AUTH_TOKEN")
@api_url System.get_env("SUPERSTREAMER_API_URL")
@public_s3_endpoint System.get_env("PUBLIC_S3_ENDPOINT")
@impl Oban.Worker
def perform(%Oban.Job{args: %{"vod_id" => vod_id, "input_url" => input_url}}) do
Logger.info("Starting CreateHlsPlaylist job",
job_id: job.id,
vod_id: vod_id,
input_url: input_url
)
with {:ok, transcode_job_id} <- start_transcode(input_url),
{:ok, asset_id} <- wait_for_job(transcode_job_id),
{:ok, package_job_id} <- start_package(asset_id),
{:ok, _} <- wait_for_job(package_job_id) do
update_vod_playlist_url(vod_id, package_job_id)
end
end
defp start_transcode(input_url) do
payload = %{
inputs: [
%{type: "video", path: input_url},
%{type: "audio", path: input_url, language: "eng"}
],
streams: [
%{type: "video", codec: "h264", height: 360},
%{type: "video", codec: "h264", height: 144},
%{type: "audio", codec: "aac"}
],
packageAfter: false
}
case HTTPoison.post("#{@api_url}/transcode", Jason.encode!(payload), headers()) do
{:ok, %{status_code: 200, body: body}} ->
%{"jobId" => job_id} = Jason.decode!(body)
{:ok, job_id}
error ->
Logger.error("Failed to start transcode: #{inspect(error)}")
{:error, :transcode_failed}
end
end
defp start_package(asset_id) do
payload = %{
assetId: asset_id,
concurrency: 5,
public: false,
name: "vod_#{asset_id}"
}
case HTTPoison.post("#{@api_url}/package", Jason.encode!(payload), headers()) do
{:ok, %{status_code: 200, body: body}} ->
%{"jobId" => job_id} = Jason.decode!(body)
{:ok, job_id}
error ->
Logger.error("Failed to start package: #{inspect(error)}")
{:error, :package_failed}
end
end
defp wait_for_job(job_id) do
case poll_job_status(job_id) do
{:ok, %{"state" => "completed", "outputData" => output_data}} ->
case Jason.decode(output_data) do
{:ok, %{"assetId" => asset_id}} -> {:ok, asset_id}
_ -> {:ok, job_id} # For package jobs, we just need the job_id
end
{:ok, %{"state" => "failed", "stacktrace" => stacktrace}} ->
Logger.error("Job failed: #{job_id}, stacktrace: #{inspect(stacktrace)}")
{:error, :job_failed}
error ->
Logger.error("Error polling job status: #{inspect(error)}")
{:error, :polling_failed}
end
end
defp poll_job_status(job_id, attempts \\ 0) do
if attempts >= 360 do # 30 minutes maximum (5 seconds * 360)
{:error, :timeout}
else
case HTTPoison.get("#{@api_url}/jobs/#{job_id}", headers()) do
{:ok, %{status_code: 200, body: body}} ->
job = Jason.decode!(body)
case job do
%{"state" => state} when state in ["completed", "failed"] ->
{:ok, job}
_ ->
Process.sleep(5000) # Wait 5 seconds before next poll
poll_job_status(job_id, attempts + 1)
end
error ->
Logger.error("Failed to poll job status: #{inspect(error)}")
{:error, :polling_failed}
end
end
end
defp update_vod_playlist_url(vod_id, package_job_id) do
playlist_url = generate_playlist_url(package_job_id)
case Vod.update_vod(vod_id, %{playlist_url: playlist_url}) do
{:ok, _vod} -> :ok
error ->
Logger.error("Failed to update VOD playlist URL: #{inspect(error)}")
{:error, :update_failed}
end
end
defp generate_playlist_url(job_id), do: "#{@public_s3_endpoint}/package/#{job_id}/hls/master.m3u8"
defp headers do
[
{"Authorization", "Bearer #{@auth_token}"},
{"Content-Type", "application/json"}
]
end
end

View File

@ -1,31 +0,0 @@
defmodule Bright.Jobs.ProcessVod do
use Oban.Worker, queue: :default, max_attempts: 3
require Logger
alias Bright.Repo
alias Bright.Streams.Vod
alias Bright.Jobs.CreateHlsPlaylist
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => id}} = job) do
Logger.info("Performing job: #{inspect(job)}")
vod = Repo.get!(Vod, id)
cond do
vod.playlist_url == nil and vod.origin_temp_input_url != nil ->
queue_create_hls_playlist(vod)
:ok
true ->
:ok
end
end
defp queue_create_hls_playlist(%Vod{id: id, origin_temp_input_url: url}) do
job_args = %{vod_id: id, input_url: url}
Oban.insert!(CreateHlsPlaylist.new(job_args))
end
end

View File

@ -1,7 +1,7 @@
defmodule Bright.Jobs.CreateHlsPlaylist do
defmodule Bright.ObanWorkers.CreateHlsPlaylist do
use Oban.Worker, queue: :default, max_attempts: 1
alias Bright.Repo
@ -9,12 +9,16 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
require Logger
@auth_token System.get_env("SUPERSTREAMER_AUTH_TOKEN")
@api_url System.get_env("SUPERSTREAMER_URL")
@public_s3_endpoint System.get_env("PUBLIC_S3_ENDPOINT")
@auth_token Application.get_env(:bright, :superstreamer_auth_token)
@superstreamer_url Application.get_env(:bright, :superstreamer_url)
@public_s3_endpoint Application.get_env(:bright, :s3_cdn_endpoint)
# args: %{"vod_id" => 10, "input_url" => "http://38.242.193.246:8081/fixtures/2024-12-19T03-10-30Z.ts"}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"vod_id" => vod_id, "input_url" => input_url}}) do
Logger.info(">>>> create_hls_playlist is performing. input_url=#{input_url} vod_id=#{vod_id}")
vod = Repo.get!(Vod, vod_id)
payload = build_payload(input_url)
@ -39,12 +43,12 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
%{
"inputs" => [
%{"type" => "audio", "path" => input_url, "language" => "eng"},
%{"type" => "video", "path" => input_url}
# %{"type" => "video", "path" => input_url}
],
"streams" => [
%{"type" => "video", "codec" => "h264", "height" => 1080},
%{"type" => "video", "codec" => "h264", "height" => 720},
%{"type" => "video", "codec" => "h264", "height" => 144},
# %{"type" => "video", "codec" => "h264", "height" => 1080},
# %{"type" => "video", "codec" => "h264", "height" => 720},
# %{"type" => "video", "codec" => "h264", "height" => 144},
%{"type" => "audio", "codec" => "aac"}
],
"tag" => "create_hls_playlist"
@ -54,14 +58,19 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
defp start_transcode(payload) do
Logger.info("Starting transcode with payload: #{inspect(payload)}")
IO.puts "Starting transcode with payload: #{inspect(payload)}"
headers = auth_headers()
Logger.info("auth headers as follows")
Logger.info(inspect(headers))
Logger.info("now we will POST /transcode to api_url=#{@api_url}")
data = case HTTPoison.post("#{@api_url}/transcode", Jason.encode!(payload), headers) do
if is_nil(@superstreamer_url) do
Logger.error("The @superstreamer_url is nil. This must be set before proceeding.")
raise "The @superstreamer_url is not configured."
end
Logger.info("now we will POST /transcode to superstreamer_url=#{@superstreamer_url}")
data = case HTTPoison.post("#{@superstreamer_url}/transcode", Jason.encode!(payload), headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, Jason.decode!(body)}
@ -71,6 +80,9 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
[] ->
{:error, "We got an empty response from Superstreamer"}
failed ->
Logger.error("Failed to POST /transcode: #{inspect(failed)}")
{:error, :failed}
@ -104,8 +116,8 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
Logger.info("auth headers as follows")
Logger.info(inspect(headers))
Logger.info("now we will POST /package to api_url=#{@api_url}")
data = case HTTPoison.post("#{@api_url}/package", Jason.encode!(payload), headers) do
Logger.info("now we will POST /package to superstreamer_url=#{@superstreamer_url}")
data = case HTTPoison.post("#{@superstreamer_url}/package", Jason.encode!(payload), headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, Jason.decode!(body)}
@ -161,14 +173,14 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
Logger.info(">>>> formatted=#{inspect(formatted)}")
{:halt, {:ok, formatted["assetId"]}}
{:ok, "failed", _data} ->
{:halt, {:error, "superstreamer reports that the job failed."}}
{:ok, state, data} ->
Logger.info("Job ID #{job_id} #{state}. Re-polling in #{poll_interval}.")
:timer.sleep(poll_interval)
{:cont, acc}
{:ok, "failed", _data} ->
{:halt, {:error, "superstreamer reports that the job failed."}}
{:error, reason} ->
Logger.error("Error polling job ID #{job_id}: #{inspect(reason)}")
{:halt, {:error, reason}}
@ -179,7 +191,7 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
defp get_job_status(job_id) do
headers = auth_headers()
data = case HTTPoison.get("#{@api_url}/jobs/#{job_id}", headers) do
data = case HTTPoison.get("#{@superstreamer_url}/jobs/#{job_id}", headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, Jason.decode!(body)}
@ -208,7 +220,7 @@ defmodule Bright.Jobs.CreateHlsPlaylist do
defp update_vod_with_playlist_url(vod, asset_id) do
playlist_url = generate_playlist_url(asset_id)
Logger.info("playlist_url=#{playlist_url}")
vod
|> Ecto.Changeset.change(playlist_url: playlist_url)
|> Repo.update!()

View File

@ -0,0 +1,47 @@
defmodule Bright.ObanWorkers.CreateS3Asset do
use Oban.Worker, queue: :default, max_attempts: 3
alias ExAws.S3
require Logger
@bucket_name System.get_env("AWS_BUCKET")
@impl Oban.Worker
def perform(%Oban.Job{args: %{"input_url" => input_url, "vod_id" => vod_id}}) do
Logger.info("@todo implementation needed")
# random_string = for _ <- 1..12, into: "", do: <<Enum.random(~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")>>
# output_file = "/tmp/#{random_string}-#{key}"
# with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- fetch_file(input_url),
# {:ok, local_path} <- save_file(body, key),
# :ok <- upload_to_s3(local_path, key) do
# :ok
# else
# {:error, reason} ->
# Logger.error("Failed to process job: #{inspect(reason)}")
# {:error, reason}
# end
end
# defp fetch_file(url) do
# case HTTPoison.get(url, [], stream_to: self()) do
# {:ok, response} when response.status_code in 200..299 ->
# {:ok, response}
# {:ok, %HTTPoison.Response{status_code: status}} ->
# {:error, "HTTP request failed with status: #{status}"}
# {:error, %HTTPoison.Error{reason: reason}} ->
# {:error, reason}
# end
# end
# defp upload_to_s3(body, key) do
# body
# |> S3.upload(@bucket_name, key)
# |> ExAws.request()
# |> case do
# {:ok, _response} -> :ok
# {:error, reason} -> {:error, reason}
# end
# end
end

View File

@ -0,0 +1,46 @@
defmodule Bright.ObanWorkers.CreateThumbnail do
use Oban.Worker, queue: :default, max_attempts: 3
alias Bright.Cache
alias Bright.Images
alias Bright.B2
alias Bright.Streams.Vod
require Logger
@impl Oban.Worker
def perform(%Oban.Job{args: %{"vod_id" => vod_id, "origin_temp_input_url" => origin_temp_input_url}}) do
IO.puts ">>>> CreateThumbnail is performing. with vod_id=#{vod_id}"
vod = Repo.get!(Vod, vod_id)
with {:ok, cache_filename} <- Cache.put(origin_temp_input_url),
{:ok, thumb_filename} <- Images.create_thumbnail(cache_filename),
{:ok, s3Asset} <- B2.put(thumb_filename) do
update_vod_with_thumbnail_url(vod, s3Asset.cdn_url)
else
{:error, reason} ->
Logger.error("Failed to create HLS playlist for VOD ID #{vod_id}: #{inspect(reason)}")
{:error, reason}
end
end
# vod
# |> Cache.put
# |> Images.create_thumbnail
# |> B2.put
defp generate_thumbnail_url(basename), do: "#{@public_s3_endpoint}/#{basename}"
defp update_vod_with_thumbnail_url(vod, thumbnail_url) do
basename = Path.basename(thumbnail_url)
thumbnail_url = generate_thumbnail_url(basename)
Logger.info("thumbnail_url=#{thumbnail_url}")
vod
|> Ecto.Changeset.change(thumbnail_url: thumbnail_url)
|> Repo.update!()
end
end

View File

@ -0,0 +1,55 @@
defmodule Bright.ObanWorkers.ProcessVod do
use Oban.Worker, queue: :default, max_attempts: 3
require Logger
alias Bright.Repo
alias Bright.Streams.Vod
alias Bright.ObanWorkers.{
CreateHlsPlaylist,
CreateS3Asset,
CreateThumbnail
}
@impl Oban.Worker
def perform(%Oban.Job{args: %{"id" => id}} = job) do
Logger.info("Performing job: #{inspect(job)}")
vod = Repo.get!(Vod, id)
if vod.playlist_url == nil and vod.origin_temp_input_url != nil do
queue_create_hls_playlist(vod)
end
if vod.s3_key == nil do
queue_create_s3_asset(vod)
end
if vod.thumbnail_url == nil and vod.origin_temp_input_url != nil do
queue_create_thumbnail(vod)
end
:ok
end
defp queue_create_hls_playlist(%Vod{id: id, origin_temp_input_url: url}) do
job_args = %{vod_id: id, input_url: url}
Oban.insert!(CreateHlsPlaylist.new(job_args))
end
defp queue_create_s3_asset(%Vod{id: id, origin_temp_input_url: url}) do
job_args = %{vod_id: id, input_url: url}
Oban.insert!(CreateS3Asset.new(job_args))
end
defp queue_create_thumbnail(%Vod{id: id, origin_temp_input_url: url}) do
job_args = %{vod_id: id}
Oban.insert!(CreateThumbnail.new(job_args))
end
end

View File

@ -0,0 +1,27 @@
defmodule Bright.Regexp do
def cache_buster, do: ~r/\?v=.*\z/
def email, do: ~r/^[^@ ]+@[^ ]+\.[^ ]+$/
def email_message, do: "must be a valid email address"
def http, do: ~r/^https?:\/\//
def http_message, do: "must include http(s)://"
def tag, do: ~r/(?<open><\/?)(?<tag>.*?)(?<close>>)/
def tag(name), do: ~r/(?<open><\/?)#{name}(?<close>>)/
def social, do: ~r/\A[a-z|A-Z|0-9|_|-]+\z/
def social_message, do: "just the username, plz"
def slug, do: ~r/\A[a-z|0-9|_|-]+\z/
def slug_message, do: "valid chars: a-z, 0-9, -, _"
def name, do: ~r/\A[a-zA-Z0-9_\- ]+\z/
def name_message, do: "valid chars: a-z, A-Z, 0-9, -, _, (space)"
end

View File

@ -3,6 +3,7 @@ defmodule Bright.Streams do
The Streams context.
"""
require Logger
import Ecto.Query, warn: false
alias Bright.Repo
@ -208,10 +209,14 @@ defmodule Bright.Streams do
defp maybe_enqueue_process_vod(%Vod{id: id, origin_temp_input_url: origin_temp_input_url} = vod) do
if origin_temp_input_url do
%{id: id, origin_temp_input_url: origin_temp_input_url}
|> Bright.Jobs.ProcessVod.new()
|> Bright.ObanWorkers.ProcessVod.new()
|> Oban.insert()
end

View File

@ -14,6 +14,7 @@ defmodule Bright.Streams.Vod do
field :ipfs_cid, :string
field :torrent, :string
field :notes, :string
field :thumbnail_url, :string
belongs_to :stream, Bright.Streams.Stream
@ -23,7 +24,7 @@ defmodule Bright.Streams.Vod do
@doc false
def changeset(vod, attrs) do
vod
|> cast(attrs, [:s3_cdn_url, :s3_upload_id, :s3_key, :s3_bucket, :mux_asset_id, :mux_playback_id, :ipfs_cid, :torrent, :stream_id, :origin_temp_input_url, :playlist_url])
|> cast(attrs, [:s3_cdn_url, :s3_upload_id, :s3_key, :s3_bucket, :mux_asset_id, :mux_playback_id, :ipfs_cid, :torrent, :stream_id, :origin_temp_input_url, :playlist_url, :thumbnail_url])
|> validate_required([:stream_id])
end

View File

@ -0,0 +1,81 @@
defmodule Bright.Uploads do
@moduledoc """
Provides functions to download a file from a URL and upload a file to S3.
"""
require Logger
alias ExAws.S3
@doc """
Downloads a file from the given URL and saves it to the specified local path.
## Parameters
- `url` (string): The URL of the file to download.
- `destination_path` (string): The local path where the file will be saved.
## Examples
iex> Bright.Uploads.download_file("https://example.com/file.txt", "./file.txt")
:ok
"""
def download_file(url, destination_path) do
case HTTPoison.get(url) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
IO.puts "WE GOT A GOOD RESPONSE SO LETS WRITE"
case File.write(destination_path, body) do
:ok -> {:ok, "File downloaded successfully"}
{:error, reason} -> {:error, reason}
end
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, "Failed to download file. Status code: #{status_code}"}
{:error, :timeout} ->
{:error, "Timeout!?"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
@doc """
Uploads a file to an S3 bucket.
## Parameters
- `file_path` (string): The local path of the file to upload.
- `bucket` (string): The name of the S3 bucket.
- `key` (string): The key under which the file will be stored in S3.
## Examples
iex> Bright.Uploads.upload_file_to_s3("./file.txt", "my-bucket", "uploads/file.txt")
:ok
"""
def upload_file_to_s3(file_path, bucket, key) do
# IO.puts "upload_file_to_s3 with \nfile_path=#{file_path} \naws_ex region=#{Application.get_env(:bright, :aws_region)} \nbucket=#{bucket} \nkey=#{key} \naws_access_key_id=#{Application.get_env(:bright, :aws_access_key_id)} aws_secret_access_key=#{Application.get_env(:bright, :aws_secret_access_key)}"
# IO.puts "#{inspect(Application.get_all_env(:ex_aws))}"
# Throw if any S3 env vars are missing
# Application.get_env(:bright, :aws_bucket) || raise("aws_bucket is missing.")
# Application.get_env(:bright, :aws_host) || raise("aws_host is missing.")
# Application.get_env(:bright, :aws_access_key_id) || raise("aws_access_key_id is missing.")
# Application.get_env(:bright, :aws_secret_access_key) || raise("aws_secret_access_key is missing.")
# Application.get_env(:bright, :aws_region) || raise("aws_region is missing.")
case File.read(file_path) do
{:ok, file_content} ->
S3.put_object(bucket, key, file_content)
|> ExAws.request()
{:error, reason} ->
{:error, reason}
end
end
end

View File

@ -1,11 +1,10 @@
defmodule Bright.User do
use Ecto.Schema
import Ecto.Changeset
alias Bright.Repo
alias Bright.{Repo, Regexp}
schema "users" do
field :name, :string
field :email, :string
field :is_admin, :boolean
field :auth_token, :string
field :auth_token_expires_at, :utc_datetime
@ -17,11 +16,79 @@ defmodule Bright.User do
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :is_admin])
|> validate_required([:name, :email, :bio])
|> cast(attrs, [:name, :patreon_handle, :github_handle, :is_admin])
|> validate_required([:name, :patreon_handle])
end
defp changeset_with_allowed_attrs(user, attrs, allowed) do
user
|> cast(attrs, allowed)
|> validate_required([:name])
|> validate_format(:name, Regexp.name(), message: Regexp.name_message())
|> validate_length(:name, max: 40, message: "max 40 chars")
|> validate_format(:github_handle, Regexp.social(), message: Regexp.social_message())
|> validate_format(:patreon_handle, Regexp.social(), message: Regexp.social_message())
|> unique_constraint(:github_handle)
|> unique_constraint(:patreon_handle)
end
def auth_changeset(user, attrs \\ %{}),
do: cast(user, attrs, ~w(auth_token auth_token_expires_at)a)
def update_changeset(user, attrs \\ %{}) do
user
|> insert_changeset(attrs)
end
def refresh_auth_token(user, expires_in \\ 60 * 24) do
auth_token = Base.encode16(:crypto.strong_rand_bytes(8))
expires_at = Timex.add(Timex.now(), Timex.Duration.from_minutes(expires_in))
changeset =
auth_changeset(user, %{auth_token: auth_token, auth_token_expires_at: expires_at})
{:ok, user} = Repo.update(changeset)
user
end
def insert_changeset(user, attrs \\ %{}) do
allowed = ~w(name github_handle patreon_handle)a
changeset_with_allowed_attrs(user, attrs, allowed)
end
# def join(conn = %{method: "POST"}, params = %{"user" => user_params}) do
# changeset = User.insert_changeset(%User{}, user_params)
# case Repo.insert(changeset) do
# {:ok, user} ->
# welcome_community(conn, user)
# {:error, changeset} ->
# conn
# |> put_flash(:error, "Something went wrong. 😭")
# |> render(:join, changeset: changeset, user: nil)
# end
# end
# def create_from_ueberauth(%{provider: :github, info: %{nickname: handle}}) do
# changeset = User.insert_changeset(%User{}, %{github_handle: handle, patreon_handle: nil, name: handle})
# case Repo.insert(changeset) do
# {:ok, user} -> {:ok, user}
# {:error, changeset} -> {:error, changeset}
# end
# end
def get!(id) do
User
|> Repo.get(id)
end
@ -29,8 +96,8 @@ defmodule Bright.User do
Repo.get_by(__MODULE__, github_handle: handle)
end
def get_by_ueberauth(%{provider: :patreon, info: %{nickname: handle}}) do
Repo.get_by(__MODULE__, patreon_handle: handle)
def get_by_ueberauth(%{provider: :patreon, info: %{id: patreon_id}}) do
Repo.get_by(__MODULE__, patreon_handle: patreon_id)
end
def get_by_ueberauth(_), do: nil
@ -45,4 +112,34 @@ defmodule Bright.User do
end
defp now_in_seconds, do: Timex.now() |> DateTime.truncate(:second)
def vod_count(user) do
user
|> Vod.authored_by()
|> Vod.published()
|> Repo.count()
end
def tag_count(user) do
user
|> Tag.authored_by()
|> Tag.published()
|> Repo.count()
end
def timestamp_count(user) do
user
|> Timestamp.authored_by()
|> Timestamp.published()
|> Repo.count()
end
def stream_count(user) do
user
|> Stream.authored_by()
|> Stream.published()
|> Repo.count()
end
end

View File

@ -29,7 +29,7 @@ defmodule Bright.UserFromAuth do
# default case if nothing matches
defp avatar_from_auth(auth) do
Logger.warn("#{auth.provider} needs to find an avatar URL!")
Logger.warning("#{auth.provider} needs to find an avatar URL!")
Logger.debug(Jason.encode!(auth))
nil
end

View File

@ -102,4 +102,9 @@ defmodule Bright.Vtubers do
Vtuber.changeset(vtuber, attrs)
end
defimpl String.Chars, for: Bright.User do
def to_string(%Bright.User{name: name}) when not is_nil(name), do: name
def to_string(%Bright.User{}), do: "Anonymous"
end
end

View File

@ -15,9 +15,9 @@ defmodule BrightWeb.CoreComponents do
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
"""
use Phoenix.Component
use Gettext, backend: BrightWeb.Gettext
alias Phoenix.LiveView.JS
import BrightWeb.Gettext
@doc """
Renders a modal.
@ -233,10 +233,7 @@ def button(assigns) do
type={@type}
class={[
"button",
"is-primary",
"is-rounded",
"py-2", "px-3",
@class
"is-primary"
]}
{@rest}
>
@ -275,6 +272,7 @@ end
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :help, :string, default: nil
attr :value, :any
attr :type, :string,
@ -314,7 +312,7 @@ end
~H"""
<div class="field-label is-normal">
<label class="label">
<label class="checkbox">
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<input
type="checkbox"
@ -322,7 +320,6 @@ end
name={@name}
value="true"
checked={@checked}
class="input"
{@rest}
/>
<%= @label %>
@ -393,6 +390,7 @@ end
]}
{@rest}
/>
<.help for={@id}><%= @help %></.help>
<.error :for={msg <- @errors}><%= msg %></.error>
</div>
"""
@ -412,6 +410,19 @@ end
"""
end
@doc """
Renders help text, often found below a form input box
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def help(assigns) do
~H"""
<p for={@for} class="help">
<%= render_slot(@inner_block) %>
</p>
"""
end
@doc """
Generates a generic error message.
"""

View File

@ -11,10 +11,176 @@ defmodule BrightWeb.AuthController do
plug Ueberauth
alias Ueberauth.Strategy.Helpers
alias Bright.UserFromAuth
alias Bright.User
alias Bright.{Repo, User}
@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_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
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
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
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"/auth/github")
|> halt()
end
end
@doc """
Used for routes that require the user to be an administrator.
"""
def require_admin_user(conn, _opts) do
Logger.info("con.assigns[:current_user] as follows. #{inspect(conn.assigns)}")
case conn.assigns[:current_user] do
%User{is_admin: true} -> # Assuming the user struct has an `is_admin` field
conn
%User{} ->
conn
|> put_flash(:error, "You do not have permission to access this page.")
|> redirect(to: ~p"/")
|> halt()
nil ->
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/auth/github")
|> halt()
end
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
@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
BrightWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
# def fetch_current_user(conn) do
# conn
# |> get_session(:user_id)
# |> case do
# nil -> nil
# user_id -> User.get(user_id)
# end
# 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
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
def create(conn = %{method: "POST"}, %{"token" => token}) do
user = User.get_by_encoded_auth(token)
if user && Timex.before?(Timex.now(), user.auth_token_expires_at) do
sign_in_and_redirect(conn, user, ~p"/~")
else
conn
|> put_flash(:error, "Whoops!")
|> render("new.html", user: nil)
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "You have been logged out!")
@ -28,30 +194,59 @@ defmodule BrightWeb.AuthController do
|> redirect(to: "/")
end
# def callback(conn, %{"provider" => provider}) do
# end
def callback(conn = %{assigns: %{ueberauth_auth: auth}}, _params) do
if user = User.get_by_ueberauth(auth) do
sign_in_and_redirect(conn, user, ~p"/~")
else
user_params = %{
github_handle: Map.get(auth, "nickname", nil),
patreon_handle: Map.get(auth, "full_name", nil),
name: "test"
}
changeset = User.insert_changeset(%User{}, user_params)
case Repo.insert(changeset) do
{:ok, user} ->
UserAuth.log_in_user(conn, user)
{:error, changeset} ->
conn
|> put_flash(:success, "Almost there! Please complete your profile now.")
|> redirect(to: ~p"/join?#{params_from_ueberauth(auth)}")
end
|> put_flash(:error, "Something went wrong. 😭")
|> render(:join, changeset: changeset, user: nil)
end
# case User.get_by_ueberauth(auth) do
# %User{} = user ->
# UserAuth.log_in_user(conn, user, %{})
# nil ->
# case User.create_from_ueberauth(auth) do
# {:ok, %User{} = user} ->
# UserAuth.log_in_user(conn, user, %{})
# {:error, changeset} ->
# Logger.error("failed to create user. auth=#{inspect(auth)}")
# conn
# |> put_flash(:error, "Failed to create user")
# |> redirect(to: ~p"/")
# end
# end
end
defp sign_in_and_redirect(conn, user, route) do
Logger.info("sign_in_and_redirect with user=#{inspect(user)}")
user
|> User.sign_in_changes()
|> Repo.update()
conn
|> assign(:current_user, user)
|> put_flash(:success, "Welcome!")
|> put_flash(:success, "Welcome to Futureporn!")
|> put_session("id", user.id)
|> configure_session(renew: true)
|> redirect(to: route)
@ -59,34 +254,14 @@ defmodule BrightWeb.AuthController do
defp params_from_ueberauth(%{provider: :github, info: info}) do
%{name: info.name, handle: info.nickname, github_handle: info.nickname}
%{name: info.name, handle: info.nickname, github_handle: info.nickname, github_id: info.uid}
end
defp params_from_ueberauth(%{provider: :patreon, info: info}) do
%{name: info.name, handle: info.nickname, patreon_handle: info.nickname}
%{name: info.name, handle: info.nickname, patreon_handle: info.full_name, patreon_id: info.id}
end
# case Ueberauth.Auth.fetch!(conn) do
# {:ok, auth} ->
# Logger.info("auth is as follows: #{inspect(auth)}")
# {:error, reason} ->
# conn
# |> put_flash(:error, "Auth failed! #{reason}")
# |> redirect(to: ~p"/")
# end
# case UserFromAuth.find_or_create(auth) do
# {:ok, user} ->
# conn
# |> put_flash(:info, "Successfully authenticated #{inspect(user)}")
# |> put_session(:current_user, user)
# |> configure_session(renew: true)
# |> redirect(to: "/")
# {:error, reason} ->
# conn
# |> put_flash(:error, reason)
# |> redirect(to: "/")
# end
defp signed_in_path(_conn), do: ~p"/"
end

View File

@ -1,15 +1,59 @@
defmodule BrightWeb.UserController do
use BrightWeb, :controller
alias Bright.{User, Repo}
require Logger
def index(conn, _params) do
render(conn, :index)
end
# def show(conn) do
# conn
# |> render(:show)
# end
def show(conn) do
def show(conn = %{assigns: %{current_user: me}}, _params) do
Logger.info(">>> me=#{inspect(me)}")
render(conn, :show, changeset: User.update_changeset(me))
end
# def show(conn) do
# user = User.get_user!(id)
# render(conn, :show, user: user)
# end
def join(conn = %{method: "GET"}, params) do
user = %User{
name: Map.get(params, "name"),
github_handle: Map.get(params, "github_handle"),
patreon_handle: Map.get(params, "patreon_handle")
}
render(conn, :join, changeset: User.insert_changeset(user), user: nil)
end
def join(conn = %{method: "POST"}, params = %{"user" => user_params}) do
changeset = User.insert_changeset(%User{}, user_params)
case Repo.insert(changeset) do
{:ok, user} ->
welcome_community(conn, user)
{:error, changeset} ->
conn
|> render(:show)
|> put_flash(:error, "Something went wrong. 😭")
|> render(:join, changeset: changeset, user: nil)
end
end
defp welcome_community(conn, user) do
user = User.refresh_auth_token(user)
conn
|> put_flash(:success, "Welcome #{user}")
|> redirect(to: ~p"/")
end
end

View File

@ -0,0 +1,7 @@
<.header>
Join Futureporn
</.header>
<.user_form changeset={@changeset} action={~p"/join"} />

View File

@ -0,0 +1,12 @@
<.simple_form :let={f} for={@changeset} action={@action}>
<.error :if={@changeset.action}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={f[:name]} type="text" label="Name" help="This name is displayed publicly to credit you for any contributions" />
<:actions>
<.button>Save User Profile</.button>
</:actions>
</.simple_form>

View File

@ -1,42 +0,0 @@
defmodule BrightWeb.UserSessionController do
use BrightWeb, :controller
alias Bright.Accounts
alias BrightWeb.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
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 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

View File

@ -20,5 +20,5 @@ defmodule BrightWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext, otp_app: :bright
use Gettext.Backend, otp_app: :bright
end

View File

@ -1,51 +0,0 @@
defmodule BrightWeb.UserConfirmationInstructionsLive do
use BrightWeb, :live_view
alias Bright.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
No confirmation instructions received?
<:subtitle>We'll send a new confirmation link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Resend confirmation instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
end
info =
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View File

@ -1,58 +0,0 @@
defmodule BrightWeb.UserConfirmationLive do
use BrightWeb, :live_view
alias Bright.Accounts
def render(%{live_action: :edit} = assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Confirm Account</.header>
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<:actions>
<.button phx-disable-with="Confirming..." class="w-full">Confirm my account</.button>
</:actions>
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
form = to_form(%{"token" => token}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
end
# Do not log in the user after confirmation to avoid a
# leaked token giving the user access to the account.
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
case Accounts.confirm_user(token) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "User confirmed successfully.")
|> redirect(to: ~p"/")}
:error ->
# If there is a current user and the account was already confirmed,
# then odds are that the confirmation link was already visited, either
# by some automation or by the user themselves, so we redirect without
# a warning message.
case socket.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
{:noreply, redirect(socket, to: ~p"/")}
%{} ->
{:noreply,
socket
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|> redirect(to: ~p"/")}
end
end
end
end

View File

@ -1,50 +0,0 @@
defmodule BrightWeb.UserForgotPasswordLive do
use BrightWeb, :live_view
alias Bright.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Forgot your password?
<:subtitle>We'll send a password reset link to your inbox</:subtitle>
</.header>
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
<.input field={@form[:email]} type="email" placeholder="Email" required />
<:actions>
<.button phx-disable-with="Sending..." class="w-full">
Send password reset instructions
</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
end
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&url(~p"/users/reset_password/#{&1}")
)
end
info =
"If your email is in our system, you will receive instructions to reset your password shortly."
{:noreply,
socket
|> put_flash(:info, info)
|> redirect(to: ~p"/")}
end
end

View File

@ -1,43 +0,0 @@
defmodule BrightWeb.UserLoginLive do
use BrightWeb, :live_view
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Log in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
<.input field={@form[:email]} type="email" label="Email" 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?
</.link>
</:actions>
<:actions>
<.button phx-disable-with="Logging in..." class="w-full">
Log in <span aria-hidden="true"></span>
</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
email = Phoenix.Flash.get(socket.assigns.flash, :email)
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
end
end

View File

@ -1,87 +0,0 @@
defmodule BrightWeb.UserRegistrationLive do
use BrightWeb, :live_view
alias Bright.Accounts
alias Bright.Accounts.User
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
Log in
</.link>
to your account now.
</:subtitle>
</.header>
<.simple_form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
phx-trigger-action={@trigger_submit}
action={~p"/users/log_in?_action=registered"}
method="post"
>
<.error :if={@check_errors}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:email]} type="email" label="Email" required />
<.input field={@form[:password]} type="password" label="Password" required />
<:actions>
<.button phx-disable-with="Creating account..." class="w-full">Create an account</.button>
</:actions>
</.simple_form>
</div>
"""
end
def mount(_params, _session, socket) do
changeset = Accounts.change_user_registration(%User{})
socket =
socket
|> assign(trigger_submit: false, check_errors: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.register_user(user_params) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_user_confirmation_instructions(
user,
&url(~p"/users/confirm/#{&1}")
)
changeset = Accounts.change_user_registration(user)
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_registration(%User{}, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
if changeset.valid? do
assign(socket, form: form, check_errors: false)
else
assign(socket, form: form)
end
end
end

View File

@ -1,89 +0,0 @@
defmodule BrightWeb.UserResetPasswordLive do
use BrightWeb, :live_view
alias Bright.Accounts
def render(assigns) do
~H"""
<div class="mx-auto max-w-sm">
<.header class="text-center">Reset Password</.header>
<.simple_form
for={@form}
id="reset_password_form"
phx-submit="reset_password"
phx-change="validate"
>
<.error :if={@form.errors != []}>
Oops, something went wrong! Please check the errors below.
</.error>
<.input field={@form[:password]} type="password" label="New password" required />
<.input
field={@form[:password_confirmation]}
type="password"
label="Confirm new password"
required
/>
<:actions>
<.button phx-disable-with="Resetting..." class="w-full">Reset Password</.button>
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""
end
def mount(params, _session, socket) do
socket = assign_user_and_token(socket, params)
form_source =
case socket.assigns do
%{user: user} ->
Accounts.change_user_password(user)
_ ->
%{}
end
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
end
# Do not log in the user after reset password to avoid a
# leaked token giving the user access to the account.
def handle_event("reset_password", %{"user" => user_params}, socket) do
case Accounts.reset_user_password(socket.assigns.user, user_params) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Password reset successfully.")
|> redirect(to: ~p"/users/log_in")}
{:error, changeset} ->
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
end
end
def handle_event("validate", %{"user" => user_params}, socket) do
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
end
defp assign_user_and_token(socket, %{"token" => token}) do
if user = Accounts.get_user_by_reset_password_token(token) do
assign(socket, user: user, token: token)
else
socket
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|> redirect(to: ~p"/")
end
end
defp assign_form(socket, %{} = source) do
assign(socket, :form, to_form(source, as: "user"))
end
end

View File

@ -1,167 +0,0 @@
defmodule BrightWeb.UserSettingsLive do
use BrightWeb, :live_view
alias Bright.Accounts
def render(assigns) do
~H"""
<.header class="text-center">
Account Settings
<:subtitle>Manage your account email address and password settings</:subtitle>
</.header>
<div class="space-y-12 divide-y">
<div>
<.simple_form
for={@email_form}
id="email_form"
phx-submit="update_email"
phx-change="validate_email"
>
<.input field={@email_form[:email]} type="email" label="Email" required />
<.input
field={@email_form[:current_password]}
name="current_password"
id="current_password_for_email"
type="password"
label="Current password"
value={@email_form_current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Email</.button>
</:actions>
</.simple_form>
</div>
<div>
<.simple_form
for={@password_form}
id="password_form"
action={~p"/users/log_in?_action=password_updated"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
value={@current_email}
/>
<.input field={@password_form[:password]} type="password" label="New password" required />
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
/>
<.input
field={@password_form[:current_password]}
name="current_password"
type="password"
label="Current password"
id="current_password_for_password"
value={@current_password}
required
/>
<:actions>
<.button phx-disable-with="Changing...">Change Password</.button>
</:actions>
</.simple_form>
</div>
</div>
"""
end
def mount(%{"token" => token}, _session, socket) do
socket =
case Accounts.update_user_email(socket.assigns.current_user, token) do
:ok ->
put_flash(socket, :info, "Email changed successfully.")
:error ->
put_flash(socket, :error, "Email change link is invalid or it has expired.")
end
{:ok, push_navigate(socket, to: ~p"/users/settings")}
end
def mount(_params, _session, socket) do
user = socket.assigns.current_user
email_changeset = Accounts.change_user_email(user)
password_changeset = Accounts.change_user_password(user)
socket =
socket
|> assign(:current_password, nil)
|> assign(:email_form_current_password, nil)
|> assign(:current_email, user.email)
|> assign(:email_form, to_form(email_changeset))
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
def handle_event("validate_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
email_form =
socket.assigns.current_user
|> Accounts.change_user_email(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
end
def handle_event("update_email", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.apply_user_email(user, password, user_params) do
{:ok, applied_user} ->
Accounts.deliver_user_update_email_instructions(
applied_user,
user.email,
&url(~p"/users/settings/confirm_email/#{&1}")
)
info = "A link to confirm your email change has been sent to the new address."
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
{:error, changeset} ->
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
end
end
def handle_event("validate_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
password_form =
socket.assigns.current_user
|> Accounts.change_user_password(user_params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form, current_password: password)}
end
def handle_event("update_password", params, socket) do
%{"current_password" => password, "user" => user_params} = params
user = socket.assigns.current_user
case Accounts.update_user_password(user, password, user_params) do
{:ok, user} ->
password_form =
user
|> Accounts.change_user_password(user_params)
|> to_form()
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
{:error, changeset} ->
{:noreply, assign(socket, password_form: to_form(changeset))}
end
end
end

View File

@ -1,57 +1,56 @@
defmodule BrightWeb.Router do
use BrightWeb, :router
import BrightWeb.UserAuth
import BrightWeb.AuthController
pipeline :browser do
plug :accepts, ["html", "json"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {BrightWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_user
plug(:accepts, ["html", "json"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, html: {BrightWeb.Layouts, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(:fetch_current_user)
end
defp fetch_current_user(conn, _) do
if user_uuid = get_session(conn, :current_user) do
assign(conn, :current_user, user_uuid)
else
conn
|> assign(:current_user, nil)
|> put_session(:current_user, nil)
end
end
pipeline :api do
plug :accepts, ["json"]
plug(:accepts, ["json"])
end
scope "/" do
pipe_through [:browser, :require_authenticated_user, :require_admin_user]
pipe_through([:browser, :require_authenticated_user, :require_admin_user])
## !!! DANGER, platforms must only be writable by admins, (unless we implement SVG sanitizing)
get "/platforms/new", PlatformController, :new
post "/platforms", PlatformController, :create
get "/platforms/:id/edit", PlatformController, :edit
patch "/platforms/:id", PlatformController, :update
put "/platforms/:id", PlatformController, :update
get("/platforms/new", PlatformController, :new)
post("/platforms", PlatformController, :create)
get("/platforms/:id/edit", PlatformController, :edit)
patch("/platforms/:id", PlatformController, :update)
put("/platforms/:id", PlatformController, :update)
end
scope "/auth", BrightWeb do
pipe_through :browser
pipe_through(:browser)
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
post "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :delete
get("/:provider", AuthController, :request)
get("/:provider/callback", AuthController, :callback)
post("/:provider/callback", AuthController, :callback)
delete("/logout", AuthController, :delete)
end
scope "/" do
pipe_through [:browser, :require_authenticated_user]
pipe_through([:browser, :require_authenticated_user])
get "/streams/new", StreamController, :new
post "/streams", StreamController, :create
get("/streams/new", StreamController, :new)
post("/streams", StreamController, :create)
# get "/vods/new", VodController, :new
# post "/vods", VodController, :create
@ -69,65 +68,54 @@ defmodule BrightWeb.Router do
# get "/vtubers/:id/edit", VtuberController, :edit
# end
get "/profile", UserController, :show
get "/tags/new", TagController, :new
post "/tags", TagController, :create
get("/tags/new", TagController, :new)
post("/tags", TagController, :create)
end
scope "/", BrightWeb do
pipe_through :browser
pipe_through(:browser)
get "/", PageController, :home
get("/", PageController, :home)
get "/patrons", PatronController, :index
get "/about", PageController, :about
get "/api", PageController, :api
get("/profile", UserController, :show, as: :user)
get("/patrons", PatronController, :index)
get("/about", PageController, :about)
get("/api", PageController, :api)
resources "/orders", OrderController, only: [:create, :show]
get("/join", UserController, :join)
post("/join", UserController, :join)
post("/join", UserController, :join)
get "/streams", StreamController, :index
get "/streams/:id", StreamController, :show
resources("/orders", OrderController, only: [:create, :show])
get("/streams", StreamController, :index)
get("/streams/:id", StreamController, :show)
resources "/vods", VodController
get "/vods/:id", VodController, :show
get "/vods", VodController, :index
resources("/vods", VodController)
get("/vods/:id", VodController, :show)
get("/vods", VodController, :index)
get("/tags", TagController, :index)
get("/tags:id", TagController, :show)
get "/tags", TagController, :index
get "/tags:id", TagController, :show
get("/platforms", PlatformController, :index)
get("/platforms/:id", PlatformController, :show)
get "/platforms", PlatformController, :index
get "/platforms/:id", PlatformController, :show
get "/vtubers", VtuberController, :index
get "/vtubers/:id", VtuberController, :show
get("/vtubers", VtuberController, :index)
get("/vtubers/:id", VtuberController, :show)
resources "/vt", VtuberController do
get "/vods", VodController, :index
get "/vods/:id", VodController, :show
get("/vods", VodController, :index)
get("/vods/:id", VodController, :show)
end
end
# Other scopes may use custom stacks.
scope "/api", BrightWeb do
pipe_through :api
resources "/urls", UrlController, except: [:new, :edit]
get "/health", PageController, :health
pipe_through(:api)
resources("/urls", UrlController, except: [:new, :edit])
get("/health", PageController, :health)
end
# Enable LiveDashboard and Swoosh mailbox preview in development
@ -140,23 +128,19 @@ defmodule BrightWeb.Router do
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
pipe_through(:browser)
live_dashboard "/dashboard", metrics: BrightWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
live_dashboard("/dashboard", metrics: BrightWeb.Telemetry)
forward("/mailbox", Plug.Swoosh.MailboxPreview)
end
end
## Authentication routes
scope "/", BrightWeb do
pipe_through [:browser]
pipe_through([:browser])
end
## Authentication routes
# scope "/", BrightWeb do
@ -173,25 +157,25 @@ defmodule BrightWeb.Router do
# post "/users/log_in", UserSessionController, :create
# end
scope "/", BrightWeb do
pipe_through [:browser, :require_authenticated_user]
# scope "/", BrightWeb do
# pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{BrightWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end
end
# live_session :require_authenticated_user,
# on_mount: [{BrightWeb.UserAuth, :ensure_authenticated}] do
# live "/users/settings", UserSettingsLive, :edit
# live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
# end
# end
scope "/", BrightWeb do
pipe_through [:browser]
# scope "/", BrightWeb do
# pipe_through [:browser]
delete "/users/log_out", UserSessionController, :delete
# delete "/users/log_out", UserSessionController, :delete
live_session :current_user,
on_mount: [{BrightWeb.UserAuth, :mount_current_user}] do
live "/users/confirm/:token", UserConfirmationLive, :edit
live "/users/confirm", UserConfirmationInstructionsLive, :new
end
end
# live_session :current_user,
# on_mount: [{BrightWeb.UserAuth, :mount_current_user}] do
# live "/users/confirm/:token", UserConfirmationLive, :edit
# live "/users/confirm", UserConfirmationInstructionsLive, :new
# end
# end
end

View File

@ -1,256 +0,0 @@
defmodule BrightWeb.UserAuth do
use BrightWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Bright.Accounts
alias Bright.User
# 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 "_bright_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_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
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
delete_csrf_token()
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
BrightWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
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 BrightWeb.PageLive do
use BrightWeb, :live_view
on_mount {BrightWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{BrightWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
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(socket, session)
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(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
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"/auth/github")
|> halt()
end
end
@doc """
Used for routes that require the user to be an administrator.
"""
def require_admin_user(conn, _opts) do
Logger.info("con.assigns[:current_user] as follows. #{inspect(conn.assigns)}")
case conn.assigns[:current_user] do
%User{is_admin: true} -> # Assuming the user struct has an `is_admin` field
conn
%User{} ->
conn
|> put_flash(:error, "You do not have permission to access this page.")
|> redirect(to: ~p"/")
|> halt()
nil ->
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/auth/github")
|> 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

View File

@ -60,6 +60,10 @@ defmodule Bright.MixProject do
{:ueberauth, "~> 0.7.0"},
{:ueberauth_github, "~> 0.8"},
{:timex, "~> 3.0"},
{:ex_aws_s3, "~> 2.0"},
{:ex_aws, "~> 2.1"},
{:ffmpex, "~> 0.11.0"},
{:sweet_xml, "~> 0.6"}
]
end

View File

@ -14,7 +14,10 @@
"ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
"ex_aws": {:hex, :ex_aws, "2.5.8", "0393cfbc5e4a9e7017845451a015d836a670397100aa4c86901980e2a2c5f7d4", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f79777b7932168956c8cc3a6db41f5783aa816eb50de356aed3165a71e5f8c3"},
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.6", "d135983bbd8b6df6350dfd83999437725527c1bea151e5055760bfc9b2d17c20", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "9874e12847e469ca2f13a5689be04e546c16f63caf6380870b7f25bf7cb98875"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"ffmpex": {:hex, :ffmpex, "0.11.0", "70d2e211a70e1d8cc1a81d73208d5efedda59d82db4c91160c79e5461529d291", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:rambo, "~> 0.3.0", [hex: :rambo, repo: "hexpm", optional: false]}], "hexpm", "2429d67badc91957ace572b9169615619740904a58791289ba54d99e57a164eb"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
"floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
@ -46,9 +49,11 @@
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [: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", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"},
"rambo": {:hex, :rambo, "0.3.4", "8962ac3bd1a633ee9d0e8b44373c7913e3ce3d875b4151dcd060886092d2dce7", [:mix], [], "hexpm", "0cc54ed089fbbc84b65f4b8a774224ebfe60e5c80186fafc7910b3e379ad58f1"},
"redirect": {:hex, :redirect, "0.4.0", "98b46053504ee517bc3ad2fd04c064b64b48d339e1e18266355b30c4f8bb52b0", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.3 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dfa29a8ecbad066ed0b73b34611cf24c78101719737f37bdf750f39197d67b97"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"superstreamer_player": {:git, "https://github.com/superstreamerapp/superstreamer.git", "9e868acede851f396b3db98fb9799ab4bf712b02", [sparse: "packages/player"]},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.17.5", "14910d267a2633d4335917b37846e376e2067815601592629366c39845dad145", [: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.3", [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", "629113d477bc82c4c3bffd15a25e8becc1c7ccc0f0e67743b017caddebb06f04"},
"tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},

View File

@ -3,7 +3,7 @@ defmodule Bright.Repo.Migrations.AddPlatformToUser do
def change do
alter table(:users) do
add :is_admin, :boolean, default: false, null: false
add :platform, :string, null: false
end
end
end

View File

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.RemoveEmailFromUser do
use Ecto.Migration
def change do
alter table(:users) do
remove :email
end
end
end

View File

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.RemovePasswordFromUser do
use Ecto.Migration
def change do
alter table(:users) do
remove :hashed_password
end
end
end

View File

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.RemovePlatformFromUser do
use Ecto.Migration
def change do
alter table(:users) do
remove :platform
end
end
end

View File

@ -0,0 +1,9 @@
defmodule Bright.Repo.Migrations.AddThumbnailUrl do
use Ecto.Migration
def change do
alter table(:vods) do
add :thumbnail_url, :string
end
end
end

View File

@ -1,508 +0,0 @@
defmodule Bright.AccountsTest do
use Bright.DataCase
alias Bright.Accounts
import Bright.AccountsFixtures
alias Bright.Accounts.{User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!(-1)
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires email and password to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{
password: ["can't be blank"],
email: ["can't be blank"]
} = errors_on(changeset)
end
test "validates email and password when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid", password: "not valid"})
assert %{
email: ["must have the @ sign and no spaces"],
password: ["should be at least 12 character(s)"]
} = errors_on(changeset)
end
test "validates maximum values for email and password for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long, password: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Accounts.register_user(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the upper cased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users with a hashed password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_binary(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
end
end
describe "change_user_registration/2" do
test "returns a changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_registration(%User{})
assert changeset.required == [:password, :email]
end
test "allows fields to be set" do
email = unique_user_email()
password = valid_user_password()
changeset =
Accounts.change_user_registration(
%User{},
valid_user_attributes(email: email, password: password)
)
assert changeset.valid?
assert get_change(changeset, :email) == email
assert get_change(changeset, :password) == password
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "change_user_email/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
assert changeset.required == [:email]
end
end
describe "apply_user_email/3" do
setup do
%{user: user_fixture()}
end
test "requires email to change", %{user: user} do
{:error, changeset} = Accounts.apply_user_email(user, valid_user_password(), %{})
assert %{email: ["did not change"]} = errors_on(changeset)
end
test "validates email", %{user: user} do
{:error, changeset} =
Accounts.apply_user_email(user, valid_user_password(), %{email: "not valid"})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum value for email for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.apply_user_email(user, valid_user_password(), %{email: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness", %{user: user} do
%{email: email} = user_fixture()
password = valid_user_password()
{:error, changeset} = Accounts.apply_user_email(user, password, %{email: email})
assert "has already been taken" in errors_on(changeset).email
end
test "validates current password", %{user: user} do
{:error, changeset} =
Accounts.apply_user_email(user, "invalid", %{email: unique_user_email()})
assert %{current_password: ["is not valid"]} = errors_on(changeset)
end
test "applies the email without persisting it", %{user: user} do
email = unique_user_email()
{:ok, user} = Accounts.apply_user_email(user, valid_user_password(), %{email: email})
assert user.email == email
assert Accounts.get_user!(user.id).email != email
end
end
describe "deliver_user_update_email_instructions/3" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "change:current@example.com"
end
end
describe "update_user_email/2" do
setup do
user = user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{user: user, token: token, email: email}
end
test "updates the email with a valid token", %{user: user, token: token, email: email} do
assert Accounts.update_user_email(user, token) == :ok
changed_user = Repo.get!(User, user.id)
assert changed_user.email != user.email
assert changed_user.email == email
assert changed_user.confirmed_at
assert changed_user.confirmed_at != user.confirmed_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email with invalid token", %{user: user} do
assert Accounts.update_user_email(user, "oops") == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if user email changed", %{user: user, token: token} do
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.update_user_email(user, token) == :error
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "change_user_password/2" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(%User{}, %{
"password" => "new valid password"
})
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/3" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, valid_user_password(), %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "validates current password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, "invalid", %{password: valid_user_password()})
assert %{current_password: ["is not valid"]} = errors_on(changeset)
end
test "updates the password", %{user: user} do
{:ok, user} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password"
})
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, _} =
Accounts.update_user_password(user, valid_user_password(), %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_session_token(token)
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "deliver_user_confirmation_instructions/2" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "confirm"
end
end
describe "confirm_user/1" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
%{user: user, token: token}
end
test "confirms the email with a valid token", %{user: user, token: token} do
assert {:ok, confirmed_user} = Accounts.confirm_user(token)
assert confirmed_user.confirmed_at
assert confirmed_user.confirmed_at != user.confirmed_at
assert Repo.get!(User, user.id).confirmed_at
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm with invalid token", %{user: user} do
assert Accounts.confirm_user("oops") == :error
refute Repo.get!(User, user.id).confirmed_at
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not confirm email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.confirm_user(token) == :error
refute Repo.get!(User, user.id).confirmed_at
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "deliver_user_reset_password_instructions/2" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "reset_password"
end
end
describe "get_user_by_reset_password_token/1" do
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
%{user: user, token: token}
end
test "returns the user with valid token", %{user: %{id: id}, token: token} do
assert %User{id: ^id} = Accounts.get_user_by_reset_password_token(token)
assert Repo.get_by(UserToken, user_id: id)
end
test "does not return the user with invalid token", %{user: user} do
refute Accounts.get_user_by_reset_password_token("oops")
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not return the user if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_reset_password_token(token)
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "reset_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.reset_user_password(user, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.reset_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, updated_user} = Accounts.reset_user_password(user, %{password: "new valid password"})
assert is_nil(updated_user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, _} = Accounts.reset_user_password(user, %{password: "new valid password"})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end

View File

@ -0,0 +1,39 @@
defmodule Bright.B2Test do
use Bright.DataCase
describe "B2" do
alias Bright.B2
@tag :acceptance
test "put/1" do
local_file = Path.absname("test/fixtures/SampleVideo_1280x720_1mb.mp4")
object_key = "test/SampleVideo_1280x720_1mb.mp4"
{:ok, remote_file} = B2.put(local_file, object_key)
assert Regex.match?(~r/SampleVideo/, remote_file)
end
@tag :acceptance
test "get/2" do
local_file = "/tmp/SampleVideo_1280x720_1mb.mp4"
File.rm(local_file)
{:ok, filename} = B2.get("test/SampleVideo_1280x720_1mb.mp4", local_file)
assert File.exists?(local_file)
assert filename === local_file
{:ok, stat} = File.stat(local_file)
assert stat.size > 0, "File is empty"
File.rm!(local_file)
end
end
end

View File

@ -0,0 +1,26 @@
defmodule Bright.CacheTest do
use Bright.DataCase
alias Bright.Cache
describe "cache" do
@tag :unit
test "generate_filename generates a random alphanumeric prefix with the correct basename and extension" do
# Test with a URL
url = "https://example.com/my_video.mp4"
filename = Cache.generate_filename(url)
assert Regex.match?(~r/^[a-zA-Z0-9]+-my_video\.mp4$/, filename)
# Test with a file path
path = "/home/cj/Downloads/taco.mp4"
filename = Cache.generate_filename(path)
assert Regex.match?(~r/^[a-zA-Z0-9]+-taco\.mp4$/, filename)
end
end
end

View File

@ -0,0 +1,67 @@
defmodule Bright.ImagesTest do
use Bright.DataCase
alias Bright.Images
@test_fixture "./test/fixtures/SampleVideo_1280x720_1mb.mp4"
describe "thumbnails" do
test "should generate a 5x5 thumbnail using a local file" do
# ffmpeg -y -i ~/Videos/moose-encounter_75.mp4 -frames:v 1 -vf 'select=not(mod(n\,257)),scale=160:-1,tile=5x5' -update 1 -fps_mode passthrough ~/Videos/thumb.jpg
basename = "thumb.jpg"
random_string = for _ <- 1..12, into: "", do: <<Enum.random(~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")>>
output_file = "/tmp/#{random_string}-#{basename}"
IO.puts "output_file=#{inspect(output_file)} @test_fixture=#{inspect(@test_fixture)}"
Images.create_thumbnail(@test_fixture, output_file)
assert File.exists?(output_file)
{:ok, stat} = File.stat(output_file)
assert stat.size > 0, "File is empty"
end
# Feature creep! Download the image for now. Make it work, first. THen make it right. THEN make it fast.
# test "should generate a 5x5 thumbnail using a remote file" do
# basename = "thumb.jpg"
# random_string = for _ <- 1..12, into: "", do: <<Enum.random(~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")>>
# output_file = "/tmp/#{random_string}-#{basename}"
# input_url = "http://38.242.193.246:8081/fixtures/2024-12-19T03-14-03Z.ts"
# {:ok, output} = Images.create_thumbnail(input_url, output_file)
# assert Regex.match?("/tmp", output)
# end
end
describe "get_video_duration" do
@tag :integration
test "should get video stream duration" do
{:ok, duration} = Images.get_video_duration(@test_fixture)
assert duration === "5.280000"
end
end
describe "get_video_framecount" do
@tag :integration
test "should get video frame count" do
{:ok, nb_frames} = Images.get_video_framecount(@test_fixture)
assert nb_frames === 132
end
end
end

View File

@ -0,0 +1,130 @@
defmodule Bright.CreateHlsPlaylistTest do
use Bright.DataCase
use Oban.Testing, repo: Bright.Repo
require Logger
alias Bright.ObanWorkers.{ProcessVod, CreateHlsPlaylist}
alias Bright.Streams
alias Bright.Streams.Stream
describe "CreateHlsPlaylist" do
@tag :integration
test "sheduling upon vod creation" do
example_video = "http://example.com/video.ts"
stream_attrs = %{date: ~U[2024-12-28 03:31:00Z], title: "some title", notes: "some notes"}
{:ok, %Stream{} = stream} = Streams.create_stream(stream_attrs)
{:ok, _vod} = Streams.create_vod(%{stream_id: stream.id, origin_temp_input_url: example_video})
assert_enqueued worker: ProcessVod, queue: :default
assert %{success: 1} = Oban.drain_queue(queue: :default) # ProcessVod is what queues CreateThumbnail so we need to make it run
assert_enqueued [worker: CreateHlsPlaylist, queue: :default], 1000
end
@tag :integration
test "not scheduled when origin_temp_input_url is missing" do
stream_attrs = %{date: ~U[2024-12-28 03:31:00Z], title: "some title", notes: "some notes"}
{:ok, %Stream{} = stream} = Streams.create_stream(stream_attrs)
{:ok, _vod} = Streams.create_vod(%{stream_id: stream.id})
refute_enqueued worker: CreateHlsPlaylist
end
end
describe "perform/1" do
# test "throw when vod_id is missing" do
# job_args = %{"vod_id" => 1}
# {:error, reason} = CreateHlsPlaylist.perform(%Oban.Job{args: job_args})
# assert Regex.match?(~r/missing.*vod_id/i, reason)
# end
# test "evergreen?" do
# job_args = %{"vod_id" => 1, "input_url" => "https://example.com/my_video.mp4"}
# assert :ok = CreateHlsPlaylist.perform(%Oban.Job{args: job_args})
# end
# test "bypassing activation when sign up fails" do
# {:error, _reason} = CreateHlsPlaylist.perform(%Oban.Job{args: job_args})
# refute_enqueued worker: MyApp.ActivationWorker
# end
# test "successfully creates an HLS playlist and updates the VOD" do
# # Mock the VOD to be returned by Repo.get!/2
# vod = %Vod{id: 1, playlist_url: nil}
# expect(RepoMock, :get!, fn Vod, 1 -> vod end)
# # Mock the start_transcode function
# expect(HTTPoisonMock, :post, fn url, body, headers ->
# assert url == "#{System.get_env("SUPERSTREAMER_URL")}/transcode"
# assert headers == [
# {"authorization", "Bearer #{System.get_env("SUPERSTREAMER_AUTH_TOKEN")}"},
# {"content-type", "application/json"}
# ]
# {:ok, %HTTPoison.Response{status_code: 200, body: ~s({"jobId": "transcode-job-id"})}}
# end)
# # Mock the polling of the transcode job
# expect(HTTPoisonMock, :get, fn url, headers ->
# assert url == "#{System.get_env("SUPERSTREAMER_URL")}/jobs/transcode-job-id"
# {:ok, %HTTPoison.Response{
# status_code: 200,
# body: ~s({"state": "completed", "outputData": {"assetId": "asset-id"}})
# }}
# end)
# # Mock the start_package function
# expect(HTTPoisonMock, :post, fn url, body, headers ->
# assert url == "#{System.get_env("SUPERSTREAMER_URL")}/package"
# {:ok, %HTTPoison.Response{status_code: 200, body: ~s({"jobId": "package-job-id"})}}
# end)
# # Mock the polling of the package job
# expect(HTTPoisonMock, :get, fn url, headers ->
# assert url == "#{System.get_env("SUPERSTREAMER_URL")}/jobs/package-job-id"
# {:ok, %HTTPoison.Response{
# status_code: 200,
# body: ~s({"state": "completed", "outputData": {"assetId": "asset-id"}})
# }}
# end)
# # Mock the Repo.update!/1 call
# expect(RepoMock, :update!, fn changeset ->
# assert changeset.changes.playlist_url == "#{System.get_env("PUBLIC_S3_ENDPOINT")}/package/asset-id/hls/master.m3u8"
# {:ok, changeset}
# end)
# # Call the worker's perform function
# job_args = %{"vod_id" => 1, "input_url" => "https://example.com/input.mp4"}
# assert :ok = CreateHlsPlaylist.perform(%Oban.Job{args: job_args})
# end
# test "handles errors gracefully" do
# # Mock the VOD to be returned by Repo.get!/2
# vod = %Vod{id: 1, playlist_url: nil}
# expect(RepoMock, :get!, fn Vod, 1 -> vod end)
# # Mock the start_transcode function to return an error
# expect(HTTPoisonMock, :post, fn _url, _body, _headers ->
# {:error, %HTTPoison.Error{reason: "transcoding failed"}}
# end)
# # Call the worker's perform function
# job_args = %{"vod_id" => 1, "input_url" => "https://example.com/input.mp4"}
# assert {:error, _reason} = CreateHlsPlaylist.perform(%Oban.Job{args: job_args})
# end
end
end

View File

@ -0,0 +1,31 @@
defmodule Bright.ObanWorkers.CreateS3AssetTest do
use Bright.DataCase
use Oban.Testing, repo: Bright.Repo
alias Bright.ObanWorkers.CreateS3Asset
@tag :unit
test "creating a new s3 asset (unit test)" do
url = "https://example.com/video.ts"
key = "video.ts"
{:ok, asset} = perform_job(CreateS3Asset, %{url: url, key: key})
end
@tag :acceptance
test "creating a new s3 asset (acceptance test)" do
url = "http://38.242.193.246:8081/fixtures/2024-12-19T03-10-30Z.ts"
key = unique_filename
example_video = "http://example.com/video.ts"
stream_attrs = %{date: ~U[2024-12-28 03:31:00Z], title: "some title", notes: "some notes"}
{:ok, %Stream{} = stream} = Streams.create_stream(stream_attrs)
{:ok, _vod} = Streams.create_vod(%{stream_id: stream.id, origin_temp_input_url: example_video})
perform_job(CreateS3Asset, %{url: url, key: key})
{:ok, asset} = Oban.drain_queue(queue: :default)
end
end

View File

@ -0,0 +1,39 @@
defmodule Bright.ObanWorkers.CreateThumbnailTest do
use Bright.DataCase
use Oban.Testing, repo: Bright.Repo
alias Bright.ObanWorkers.CreateThumbnail
alias Bright.ObanWorkers.ProcessVod
alias Bright.Streams
alias Bright.Streams.Stream
describe "CreateThumbnail" do
@tag :integration
test "sheduling upon vod creation" do
example_video = "http://example.com/video.ts"
stream_attrs = %{date: ~U[2024-12-28 03:31:00Z], title: "some title", notes: "some notes"}
{:ok, %Stream{} = stream} = Streams.create_stream(stream_attrs)
{:ok, vod} = Streams.create_vod(%{stream_id: stream.id, origin_temp_input_url: example_video})
assert stream.notes === "some notes" # sanity check, making sure the stream gets created
assert vod.origin_temp_input_url === example_video
assert_enqueued worker: ProcessVod, queue: :default
Oban.drain_queue(queue: :default) # We need to run ProcessVod Job because that is what queues CreateThumbnail
assert_enqueued worker: CreateThumbnail, queue: :default
end
@tag :integration
test "not scheduled when origin_temp_input_url is missing" do
stream_attrs = %{date: ~U[2024-12-28 03:31:00Z], title: "some title", notes: "some notes"}
{:ok, %Stream{} = stream} = Streams.create_stream(stream_attrs)
{:ok, _vod} = Streams.create_vod(%{stream_id: stream.id})
refute_enqueued worker: CreateThumbnail
end
end
end

View File

@ -0,0 +1,62 @@
defmodule Bright.UploadsTest do
use ExUnit.Case, async: true
@test_url "https://futureporn-b2.b-cdn.net/projekt-melody.jpg"
@test_file_path "./test_download.txt"
@s3_bucket Application.compile_env(:ex_aws, [:s3, :bucket])
describe "download_file/2" do
@tag :acceptance
@tag timeout: :infinity
test "downloads a file successfully" do
# Ensure a valid file exists at the URL for testing purposes
assert {:ok, _res} = Bright.Uploads.download_file(@test_url, @test_file_path)
assert File.exists?(@test_file_path)
{:ok, stat} = File.stat(@test_file_path)
assert stat.size > 0, "File is empty"
File.rm!(@test_file_path)
end
test "returns an error for an invalid URL" do
invalid_url = "https://nonexistent.url/file.txt"
assert {:error, _reason} = Bright.Uploads.download_file(invalid_url, @test_file_path)
end
end
describe "upload_file_to_s3/3" do
setup do
File.write!(@test_file_path, "This is a test file.")
on_exit(fn -> File.rm!(@test_file_path) end)
:ok
end
@tag :acceptance
test "uploads a file to S3 successfully" do
basename = "random.txt"
random_string = for _ <- 1..12, into: "", do: <<Enum.random(~c"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")>>
unique_filename = "#{random_string}-#{basename}"
assert {:ok, %{status_code: 200}} = Bright.Uploads.upload_file_to_s3(@test_file_path, @s3_bucket, unique_filename)
# Optionally verify the file exists in S3 (requires an S3 client)
{:ok, %{status_code: 200}} =
ExAws.S3.head_object(@s3_bucket, unique_filename)
|> ExAws.request()
# Cleanup: Delete the uploaded file from S3
ExAws.S3.delete_object(@s3_bucket, unique_filename)
|> ExAws.request()
end
@tag :acceptance
test "returns an error for a nonexistent file" do
assert {:error, :enoent} = Bright.Uploads.upload_file_to_s3("nonexistent.txt", @s3_bucket, "test/nonexistant.txt")
end
end
end

View File

@ -52,9 +52,6 @@ defmodule Bright.VtubersTest do
assert vtuber.twitter_id == "some twitter_id"
end
test "create_vtuber/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Vtubers.create_vtuber(@invalid_attrs)
end
test "update_vtuber/2 with valid data updates the vtuber" do
vtuber = vtuber_fixture()
@ -105,57 +102,12 @@ defmodule Bright.VtubersTest do
vtuber = vtuber_fixture()
assert %Ecto.Changeset{} = Vtubers.change_vtuber(vtuber)
end
end
describe "vtubers" do
alias Bright.Vtubers.Vtuber
import Bright.VtubersFixtures
@invalid_attrs %{}
test "list_vtubers/0 returns all vtubers" do
vtuber = vtuber_fixture()
assert Vtubers.list_vtubers() == [vtuber]
end
test "get_vtuber!/1 returns the vtuber with given id" do
vtuber = vtuber_fixture()
assert Vtubers.get_vtuber!(vtuber.id) == vtuber
end
test "create_vtuber/1 with valid data creates a vtuber" do
valid_attrs = %{}
assert {:ok, %Vtuber{} = vtuber} = Vtubers.create_vtuber(valid_attrs)
end
test "create_vtuber/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Vtubers.create_vtuber(@invalid_attrs)
end
test "update_vtuber/2 with valid data updates the vtuber" do
vtuber = vtuber_fixture()
update_attrs = %{}
assert {:ok, %Vtuber{} = vtuber} = Vtubers.update_vtuber(vtuber, update_attrs)
end
test "update_vtuber/2 with invalid data returns error changeset" do
vtuber = vtuber_fixture()
assert {:error, %Ecto.Changeset{}} = Vtubers.update_vtuber(vtuber, @invalid_attrs)
assert vtuber == Vtubers.get_vtuber!(vtuber.id)
end
test "delete_vtuber/1 deletes the vtuber" do
vtuber = vtuber_fixture()
assert {:ok, %Vtuber{}} = Vtubers.delete_vtuber(vtuber)
assert_raise Ecto.NoResultsError, fn -> Vtubers.get_vtuber!(vtuber.id) end
end
test "change_vtuber/1 returns a vtuber changeset" do
vtuber = vtuber_fixture()
assert %Ecto.Changeset{} = Vtubers.change_vtuber(vtuber)
end
end
end

View File

@ -1,84 +0,0 @@
defmodule BrightWeb.ProductControllerTest do
use BrightWeb.ConnCase
import Bright.CatalogFixtures
@create_attrs %{description: "some description", title: "some title", price: "120.5", views: 42}
@update_attrs %{description: "some updated description", title: "some updated title", price: "456.7", views: 43}
@invalid_attrs %{description: nil, title: nil, price: nil, views: nil}
describe "index" do
test "lists all products", %{conn: conn} do
conn = get(conn, ~p"/products")
assert html_response(conn, 200) =~ "Listing Products"
end
end
describe "new product" do
test "renders form", %{conn: conn} do
conn = get(conn, ~p"/products/new")
assert html_response(conn, 200) =~ "New Product"
end
end
describe "create product" do
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, ~p"/products", product: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == ~p"/products/#{id}"
conn = get(conn, ~p"/products/#{id}")
assert html_response(conn, 200) =~ "Product #{id}"
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/products", product: @invalid_attrs)
assert html_response(conn, 200) =~ "New Product"
end
end
describe "edit product" do
setup [:create_product]
test "renders form for editing chosen product", %{conn: conn, product: product} do
conn = get(conn, ~p"/products/#{product}/edit")
assert html_response(conn, 200) =~ "Edit Product"
end
end
describe "update product" do
setup [:create_product]
test "redirects when data is valid", %{conn: conn, product: product} do
conn = put(conn, ~p"/products/#{product}", product: @update_attrs)
assert redirected_to(conn) == ~p"/products/#{product}"
conn = get(conn, ~p"/products/#{product}")
assert html_response(conn, 200) =~ "some updated description"
end
test "renders errors when data is invalid", %{conn: conn, product: product} do
conn = put(conn, ~p"/products/#{product}", product: @invalid_attrs)
assert html_response(conn, 200) =~ "Edit Product"
end
end
describe "delete product" do
setup [:create_product]
test "deletes chosen product", %{conn: conn, product: product} do
conn = delete(conn, ~p"/products/#{product}")
assert redirected_to(conn) == ~p"/products"
assert_error_sent 404, fn ->
get(conn, ~p"/products/#{product}")
end
end
end
defp create_product(_) do
product = product_fixture()
%{product: product}
end
end

View File

@ -1,113 +0,0 @@
defmodule BrightWeb.UserSessionControllerTest do
use BrightWeb.ConnCase, async: true
import Bright.AccountsFixtures
setup do
%{user: user_fixture()}
end
describe "POST /users/log_in" do
test "logs the user in", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log_in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log_out"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log_in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_bright_web_user_remember_me"]
assert redirected_to(conn) == ~p"/"
end
test "logs the user in with return to", %{conn: conn, user: user} do
conn =
conn
|> init_test_session(user_return_to: "/foo/bar")
|> post(~p"/users/log_in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == "/foo/bar"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
end
test "login following registration", %{conn: conn, user: user} do
conn =
conn
|> post(~p"/users/log_in", %{
"_action" => "registered",
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == ~p"/"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully"
end
test "login following password update", %{conn: conn, user: user} do
conn =
conn
|> post(~p"/users/log_in", %{
"_action" => "password_updated",
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully"
end
test "redirects to login page with invalid credentials", %{conn: conn} do
conn =
post(conn, ~p"/users/log_in", %{
"user" => %{"email" => "invalid@email.com", "password" => "invalid_password"}
})
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
assert redirected_to(conn) == ~p"/users/log_in"
end
end
describe "DELETE /users/log_out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> delete(~p"/users/log_out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/users/log_out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
end
end

View File

@ -1,67 +0,0 @@
defmodule BrightWeb.UserConfirmationInstructionsLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
alias Bright.Accounts
alias Bright.Repo
setup do
%{user: user_fixture()}
end
describe "Resend confirmation" do
test "renders the resend confirmation page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/confirm")
assert html =~ "Resend confirmation instructions"
end
test "sends a new confirmation token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm")
{:ok, conn} =
lv
|> form("#resend_confirmation_form", user: %{email: user.email})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm"
end
test "does not send confirmation token if user is confirmed", %{conn: conn, user: user} do
Repo.update!(Accounts.User.confirm_changeset(user))
{:ok, lv, _html} = live(conn, ~p"/users/confirm")
{:ok, conn} =
lv
|> form("#resend_confirmation_form", user: %{email: user.email})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
refute Repo.get_by(Accounts.UserToken, user_id: user.id)
end
test "does not send confirmation token if email is invalid", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm")
{:ok, conn} =
lv
|> form("#resend_confirmation_form", user: %{email: "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View File

@ -1,89 +0,0 @@
defmodule BrightWeb.UserConfirmationLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
alias Bright.Accounts
alias Bright.Repo
setup do
%{user: user_fixture()}
end
describe "Confirm user" do
test "renders confirmation page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/confirm/some-token")
assert html =~ "Confirm Account"
end
test "confirms the given token once", %{conn: conn, user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
"User confirmed successfully"
assert Accounts.get_user!(user.id).confirmed_at
refute get_session(conn, :user_token)
assert Repo.all(Accounts.UserToken) == []
# when not logged in
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"User confirmation link is invalid or it has expired"
# when logged in
conn =
build_conn()
|> log_in_user(user)
{:ok, lv, _html} = live(conn, ~p"/users/confirm/#{token}")
result =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, "/")
assert {:ok, conn} = result
refute Phoenix.Flash.get(conn.assigns.flash, :error)
end
test "does not confirm email with invalid token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/confirm/invalid-token")
{:ok, conn} =
lv
|> form("#confirmation_form")
|> render_submit()
|> follow_redirect(conn, ~p"/")
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"User confirmation link is invalid or it has expired"
refute Accounts.get_user!(user.id).confirmed_at
end
end
end

View File

@ -1,63 +0,0 @@
defmodule BrightWeb.UserForgotPasswordLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
alias Bright.Accounts
alias Bright.Repo
describe "Forgot password page" do
test "renders email page", %{conn: conn} do
{:ok, lv, html} = live(conn, ~p"/users/reset_password")
assert html =~ "Forgot your password?"
assert has_element?(lv, ~s|a[href="#{~p"/users/register"}"]|, "Register")
assert has_element?(lv, ~s|a[href="#{~p"/users/log_in"}"]|, "Log in")
end
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/users/reset_password")
|> follow_redirect(conn, ~p"/")
assert {:ok, _conn} = result
end
end
describe "Reset link" do
setup do
%{user: user_fixture()}
end
test "sends a new reset password token", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password")
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => user.email})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context ==
"reset_password"
end
test "does not send reset password token if email is invalid", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password")
{:ok, conn} =
lv
|> form("#reset_password_form", user: %{"email" => "unknown@example.com"})
|> render_submit()
|> follow_redirect(conn, "/")
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
assert Repo.all(Accounts.UserToken) == []
end
end
end

View File

@ -1,87 +0,0 @@
defmodule BrightWeb.UserLoginLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
describe "Log in page" do
test "renders log in page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/log_in")
assert html =~ "Log in"
assert html =~ "Register"
assert html =~ "Forgot your password?"
end
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/users/log_in")
|> follow_redirect(conn, "/")
assert {:ok, _conn} = result
end
end
describe "user login" do
test "redirects if user login with valid credentials", %{conn: conn} do
password = "123456789abcd"
user = user_fixture(%{password: password})
{:ok, lv, _html} = live(conn, ~p"/users/log_in")
form =
form(lv, "#login_form", user: %{email: user.email, password: password, remember_me: true})
conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/"
end
test "redirects to login page with a flash error if there are no valid credentials", %{
conn: conn
} do
{:ok, lv, _html} = live(conn, ~p"/users/log_in")
form =
form(lv, "#login_form",
user: %{email: "test@email.com", password: "123456", remember_me: true}
)
conn = submit_form(form, conn)
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
assert redirected_to(conn) == "/users/log_in"
end
end
describe "login navigation" do
test "redirects to registration page when the Register button is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/log_in")
{:ok, _login_live, login_html} =
lv
|> element(~s|main a:fl-contains("Sign up")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/register")
assert login_html =~ "Register"
end
test "redirects to forgot password page when the Forgot Password button is clicked", %{
conn: conn
} do
{:ok, lv, _html} = live(conn, ~p"/users/log_in")
{:ok, conn} =
lv
|> element(~s|main a:fl-contains("Forgot your password?")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/reset_password")
assert conn.resp_body =~ "Forgot your password?"
end
end
end

View File

@ -1,87 +0,0 @@
defmodule BrightWeb.UserRegistrationLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
describe "Registration page" do
test "renders registration page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/register")
assert html =~ "Register"
assert html =~ "Log in"
end
test "redirects if already logged in", %{conn: conn} do
result =
conn
|> log_in_user(user_fixture())
|> live(~p"/users/register")
|> follow_redirect(conn, "/")
assert {:ok, _conn} = result
end
test "renders errors for invalid data", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
result =
lv
|> element("#registration_form")
|> render_change(user: %{"email" => "with spaces", "password" => "too short"})
assert result =~ "Register"
assert result =~ "must have the @ sign and no spaces"
assert result =~ "should be at least 12 character"
end
end
describe "register user" do
test "creates account and logs the user in", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
email = unique_user_email()
form = form(lv, "#registration_form", user: valid_user_attributes(email: email))
render_submit(form)
conn = follow_trigger_action(form, conn)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, "/")
response = html_response(conn, 200)
assert response =~ email
assert response =~ "Settings"
assert response =~ "Log out"
end
test "renders errors for duplicated email", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
user = user_fixture(%{email: "test@email.com"})
result =
lv
|> form("#registration_form",
user: %{"email" => user.email, "password" => "valid_password"}
)
|> render_submit()
assert result =~ "has already been taken"
end
end
describe "registration navigation" do
test "redirects to login page when the Log in button is clicked", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/register")
{:ok, _login_live, login_html} =
lv
|> element(~s|main a:fl-contains("Log in")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/log_in")
assert login_html =~ "Log in"
end
end
end

View File

@ -1,118 +0,0 @@
defmodule BrightWeb.UserResetPasswordLiveTest do
use BrightWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
alias Bright.Accounts
setup do
user = user_fixture()
token =
extract_user_token(fn url ->
Accounts.deliver_user_reset_password_instructions(user, url)
end)
%{token: token, user: user}
end
describe "Reset password page" do
test "renders reset password with valid token", %{conn: conn, token: token} do
{:ok, _lv, html} = live(conn, ~p"/users/reset_password/#{token}")
assert html =~ "Reset Password"
end
test "does not render reset password with invalid token", %{conn: conn} do
{:error, {:redirect, to}} = live(conn, ~p"/users/reset_password/invalid")
assert to == %{
flash: %{"error" => "Reset password link is invalid or it has expired."},
to: ~p"/"
}
end
test "renders errors for invalid data", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
result =
lv
|> element("#reset_password_form")
|> render_change(
user: %{"password" => "secret12", "password_confirmation" => "secret123456"}
)
assert result =~ "should be at least 12 character"
assert result =~ "does not match password"
end
end
describe "Reset Password" do
test "resets password once", %{conn: conn, token: token, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
{:ok, conn} =
lv
|> form("#reset_password_form",
user: %{
"password" => "new valid password",
"password_confirmation" => "new valid password"
}
)
|> render_submit()
|> follow_redirect(conn, ~p"/users/log_in")
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password reset successfully"
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "does not reset password on invalid data", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
result =
lv
|> form("#reset_password_form",
user: %{
"password" => "too short",
"password_confirmation" => "does not match"
}
)
|> render_submit()
assert result =~ "Reset Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
end
describe "Reset password navigation" do
test "redirects to login page when the Log in button is clicked", %{conn: conn, token: token} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
{:ok, conn} =
lv
|> element(~s|main a:fl-contains("Log in")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/log_in")
assert conn.resp_body =~ "Log in"
end
test "redirects to registration page when the Register button is clicked", %{
conn: conn,
token: token
} do
{:ok, lv, _html} = live(conn, ~p"/users/reset_password/#{token}")
{:ok, conn} =
lv
|> element(~s|main a:fl-contains("Register")|)
|> render_click()
|> follow_redirect(conn, ~p"/users/register")
assert conn.resp_body =~ "Register"
end
end
end

View File

@ -1,210 +0,0 @@
defmodule BrightWeb.UserSettingsLiveTest do
use BrightWeb.ConnCase, async: true
alias Bright.Accounts
import Phoenix.LiveViewTest
import Bright.AccountsFixtures
describe "Settings page" do
test "renders settings page", %{conn: conn} do
{:ok, _lv, html} =
conn
|> log_in_user(user_fixture())
|> live(~p"/users/settings")
assert html =~ "Change Email"
assert html =~ "Change Password"
end
test "redirects if user is not logged in", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/users/settings")
assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log_in"
assert %{"error" => "You must log in to access this page."} = flash
end
end
describe "update email form" do
setup %{conn: conn} do
password = valid_user_password()
user = user_fixture(%{password: password})
%{conn: log_in_user(conn, user), user: user, password: password}
end
test "updates the user email", %{conn: conn, password: password, user: user} do
new_email = unique_user_email()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"current_password" => password,
"user" => %{"email" => new_email}
})
|> render_submit()
assert result =~ "A link to confirm your email"
assert Accounts.get_user_by_email(user.email)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#email_form")
|> render_change(%{
"action" => "update_email",
"current_password" => "invalid",
"user" => %{"email" => "with spaces"}
})
assert result =~ "Change Email"
assert result =~ "must have the @ sign and no spaces"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn, user: user} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#email_form", %{
"current_password" => "invalid",
"user" => %{"email" => user.email}
})
|> render_submit()
assert result =~ "Change Email"
assert result =~ "did not change"
assert result =~ "is not valid"
end
end
describe "update password form" do
setup %{conn: conn} do
password = valid_user_password()
user = user_fixture(%{password: password})
%{conn: log_in_user(conn, user), user: user, password: password}
end
test "updates the user password", %{conn: conn, user: user, password: password} do
new_password = valid_user_password()
{:ok, lv, _html} = live(conn, ~p"/users/settings")
form =
form(lv, "#password_form", %{
"current_password" => password,
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/users/settings"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> element("#password_form")
|> render_change(%{
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
assert result =~ "Change Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/settings")
result =
lv
|> form("#password_form", %{
"current_password" => "invalid",
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
|> render_submit()
assert result =~ "Change Password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
assert result =~ "is not valid"
end
end
describe "confirm email" do
setup %{conn: conn} do
user = user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{conn: log_in_user(conn, user), token: token, email: email, user: user}
end
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"info" => message} = flash
assert message == "Email changed successfully."
refute Accounts.get_user_by_email(user.email)
assert Accounts.get_user_by_email(email)
# use confirm token again
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"error" => message} = flash
assert message == "Email change link is invalid or it has expired."
end
test "does not update email with invalid token", %{conn: conn, user: user} do
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/oops")
assert {:live_redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/settings"
assert %{"error" => message} = flash
assert message == "Email change link is invalid or it has expired."
assert Accounts.get_user_by_email(user.email)
end
test "redirects if user is not logged in", %{token: token} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/users/settings/confirm_email/#{token}")
assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log_in"
assert %{"error" => message} = flash
assert message == "You must log in to access this page."
end
end
end

Binary file not shown.

BIN
services/bright/test/fixtures/thumb.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
services/bright/test/fixtures/thumb2.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -4,10 +4,12 @@ defmodule Bright.PlatformsFixtures do
entities via the `Bright.Platforms` context.
"""
def platform_fixture(attrs \\ %{})
@doc """
Generate a platform.
"""
def platform_fixture(attrs \\ %{}) do
def platform_fixture(attrs) do
{:ok, platform} =
attrs
|> Enum.into(%{
@ -20,6 +22,15 @@ defmodule Bright.PlatformsFixtures do
platform
end
def platform_fixture(attrs) do
{:ok, platform} =
attrs
|> valid_platform_attributes()
|> Bright.Platforms.register_platform()
platform
end
def unique_platform_email, do: "platform#{System.unique_integer()}@example.com"
def valid_platform_password, do: "hello world!"
@ -30,14 +41,7 @@ defmodule Bright.PlatformsFixtures do
})
end
def platform_fixture(attrs \\ %{}) do
{:ok, platform} =
attrs
|> valid_platform_attributes()
|> Bright.Platforms.register_platform()
platform
end
def extract_platform_token(fun) do
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")