diff --git a/config/config.exs b/config/config.exs index b8cf59b..8ab193c 100644 --- a/config/config.exs +++ b/config/config.exs @@ -22,6 +22,18 @@ config :nulla, NullaWeb.Endpoint, pubsub_server: Nulla.PubSub, live_view: [signing_salt: "rmaJ4fGm"] +# Snowflake configuration +config :nulla, :snowflake, worker_id: 1 + +# Instance configuration +config :nulla, :instance, + name: "Nulla", + description: "Freedom Social Network", + registration: false, + max_characters: 5000, + max_upload_size: 50, + api_limit: 100 + # Configures the mailer # # By default it uses the "Local" adapter which stores the emails diff --git a/config/test.exs b/config/test.exs index c0f45e2..8c70f7f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,5 +1,8 @@ import Config +# Only in tests, remove the complexity from the password hashing algorithm +config :bcrypt_elixir, :log_rounds, 1 + # Configure your database # # The MIX_TEST_PARTITION environment variable can be used diff --git a/lib/nulla/accounts.ex b/lib/nulla/accounts.ex new file mode 100644 index 0000000..b6e5d8b --- /dev/null +++ b/lib/nulla/accounts.ex @@ -0,0 +1,377 @@ +defmodule Nulla.Accounts do + @moduledoc """ + The Accounts context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.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 + + @doc """ + Creates a new api token for a user. + + The token returned must be saved somewhere safe. + This token cannot be recovered from the database. + """ + def create_user_api_token(user) do + {encoded_token, user_token} = UserToken.build_email_token(user, "api-token") + Repo.insert!(user_token) + encoded_token + end + + @doc """ + Fetches the user by API token. + """ + def fetch_user_by_api_token(token) do + with {:ok, query} <- UserToken.verify_email_token_query(token, "api-token"), + %User{} = user <- Repo.one(query) do + {:ok, user} + else + _ -> :error + end + end +end diff --git a/lib/nulla/accounts/user.ex b/lib/nulla/accounts/user.ex new file mode 100644 index 0000000..cace737 --- /dev/null +++ b/lib/nulla/accounts/user.ex @@ -0,0 +1,171 @@ +defmodule Nulla.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + 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 + + 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]) + |> maybe_put_id() + |> validate_email(opts) + |> validate_password(opts) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset + + defp validate_email(changeset, opts) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") + |> validate_length(:email, max: 160) + |> maybe_validate_unique_email(opts) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 12, max: 72) + # 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, Nulla.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?(%Nulla.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 diff --git a/lib/nulla/accounts/user_notifier.ex b/lib/nulla/accounts/user_notifier.ex new file mode 100644 index 0000000..682f899 --- /dev/null +++ b/lib/nulla/accounts/user_notifier.ex @@ -0,0 +1,79 @@ +defmodule Nulla.Accounts.UserNotifier do + import Swoosh.Email + + alias Nulla.Mailer + + # Delivers the email using the application mailer. + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from({"Nulla", "contact@example.com"}) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + @doc """ + Deliver instructions to confirm account. + """ + def deliver_confirmation_instructions(user, url) do + deliver(user.email, "Confirmation instructions", """ + + ============================== + + Hi #{user.email}, + + You can confirm your account by visiting the URL below: + + #{url} + + If you didn't create an account with us, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to reset a user password. + """ + def deliver_reset_password_instructions(user, url) do + deliver(user.email, "Reset password instructions", """ + + ============================== + + Hi #{user.email}, + + You can reset your password by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end + + @doc """ + Deliver instructions to update a user email. + """ + def deliver_update_email_instructions(user, url) do + deliver(user.email, "Update email instructions", """ + + ============================== + + Hi #{user.email}, + + You can change your email by visiting the URL below: + + #{url} + + If you didn't request this change, please ignore this. + + ============================== + """) + end +end diff --git a/lib/nulla/accounts/user_token.ex b/lib/nulla/accounts/user_token.ex new file mode 100644 index 0000000..d351435 --- /dev/null +++ b/lib/nulla/accounts/user_token.ex @@ -0,0 +1,179 @@ +defmodule Nulla.Accounts.UserToken do + use Ecto.Schema + import Ecto.Query + alias Nulla.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, Nulla.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 diff --git a/lib/nulla/activities.ex b/lib/nulla/activities.ex new file mode 100644 index 0000000..c6119fd --- /dev/null +++ b/lib/nulla/activities.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Activities do + @moduledoc """ + The Activities context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Activities.Activity + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + Repo.all(Activity) + end + + @doc """ + Gets a single activity. + + Raises `Ecto.NoResultsError` if the Activity does not exist. + + ## Examples + + iex> get_activity!(123) + %Activity{} + + iex> get_activity!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity!(id), do: Repo.get!(Activity, id) + + @doc """ + Creates a activity. + + ## Examples + + iex> create_activity(%{field: value}) + {:ok, %Activity{}} + + iex> create_activity(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity(attrs \\ %{}) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity. + + ## Examples + + iex> update_activity(activity, %{field: new_value}) + {:ok, %Activity{}} + + iex> update_activity(activity, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity(%Activity{} = activity, attrs) do + activity + |> Activity.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity. + + ## Examples + + iex> delete_activity(activity) + {:ok, %Activity{}} + + iex> delete_activity(activity) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity(%Activity{} = activity) do + Repo.delete(activity) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity changes. + + ## Examples + + iex> change_activity(activity) + %Ecto.Changeset{data: %Activity{}} + + """ + def change_activity(%Activity{} = activity, attrs \\ %{}) do + Activity.changeset(activity, attrs) + end +end diff --git a/lib/nulla/activities/activity.ex b/lib/nulla/activities/activity.ex new file mode 100644 index 0000000..23a35b7 --- /dev/null +++ b/lib/nulla/activities/activity.ex @@ -0,0 +1,34 @@ +defmodule Nulla.Activities.Activity do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Types.StringOrJson + + @derive {Jason.Encoder, only: [:ap_id, :type, :actor, :object]} + @primary_key {:id, :integer, autogenerate: false} + schema "activities" do + field :ap_id, :string + field :type, :string + field :actor, :string + field :object, StringOrJson + field :to, {:array, :string} + field :cc, {:array, :string} + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, [:ap_id, :type, :actor, :object, :to, :cc]) + |> maybe_put_id() + |> validate_required([:ap_id, :type, :actor, :object]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/actors.ex b/lib/nulla/actors.ex new file mode 100644 index 0000000..9064c46 --- /dev/null +++ b/lib/nulla/actors.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Actors do + @moduledoc """ + The Actors context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Actors.Actor + + @doc """ + Returns the list of actors. + + ## Examples + + iex> list_actors() + [%Actor{}, ...] + + """ + def list_actors do + Repo.all(Actor) + end + + @doc """ + Gets a single actor. + + Raises `Ecto.NoResultsError` if the Actor does not exist. + + ## Examples + + iex> get_actor!(123) + %Actor{} + + iex> get_actor!(456) + ** (Ecto.NoResultsError) + + """ + def get_actor!(id), do: Repo.get!(Actor, id) + + @doc """ + Creates a actor. + + ## Examples + + iex> create_actor(%{field: value}) + {:ok, %Actor{}} + + iex> create_actor(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_actor(attrs \\ %{}) do + %Actor{} + |> Actor.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a actor. + + ## Examples + + iex> update_actor(actor, %{field: new_value}) + {:ok, %Actor{}} + + iex> update_actor(actor, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_actor(%Actor{} = actor, attrs) do + actor + |> Actor.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a actor. + + ## Examples + + iex> delete_actor(actor) + {:ok, %Actor{}} + + iex> delete_actor(actor) + {:error, %Ecto.Changeset{}} + + """ + def delete_actor(%Actor{} = actor) do + Repo.delete(actor) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking actor changes. + + ## Examples + + iex> change_actor(actor) + %Ecto.Changeset{data: %Actor{}} + + """ + def change_actor(%Actor{} = actor, attrs \\ %{}) do + Actor.changeset(actor, attrs) + end +end diff --git a/lib/nulla/actors/actor.ex b/lib/nulla/actors/actor.ex new file mode 100644 index 0000000..67a71d1 --- /dev/null +++ b/lib/nulla/actors/actor.ex @@ -0,0 +1,95 @@ +defmodule Nulla.Actors.Actor do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + + @primary_key {:id, :integer, autogenerate: false} + schema "actors" do + field :acct, :string + field :ap_id, :string + field :type, :string + field :following, :string + field :followers, :string + field :inbox, :string + field :outbox, :string + field :featured, :string + field :featuredTags, :string + field :preferredUsername, :string + field :name, :string + field :summary, :string + field :url, :string + field :manuallyApprovesFollowers, :boolean, default: false + field :discoverable, :boolean, default: true + field :indexable, :boolean, default: true + field :published, :utc_datetime + field :memorial, :boolean, default: false + field :publicKey, :map + field :privateKeyPem, :string + field :tag, {:array, :map}, default: [] + field :attachment, {:array, :map}, default: [] + field :endpoints, :map + field :icon, :map + field :image, :map + field :vcard_bday, :date + field :vcard_Address, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(actor, attrs) do + actor + |> cast(attrs, [ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :name, + :summary, + :url, + :manuallyApprovesFollowers, + :discoverable, + :indexable, + :published, + :memorial, + :publicKey, + :privateKeyPem, + :tag, + :attachment, + :endpoints, + :icon, + :image, + :vcard_bday, + :vcard_Address + ]) + |> maybe_put_id() + |> validate_required([ + :acct, + :ap_id, + :type, + :following, + :followers, + :inbox, + :outbox, + :featured, + :featuredTags, + :preferredUsername, + :url, + :publicKey, + :endpoints + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/application.ex b/lib/nulla/application.ex index 7e86858..6832652 100644 --- a/lib/nulla/application.ex +++ b/lib/nulla/application.ex @@ -7,6 +7,8 @@ defmodule Nulla.Application do @impl true def start(_type, _args) do + worker_id = Application.fetch_env!(:nulla, :snowflake)[:worker_id] + children = [ NullaWeb.Telemetry, Nulla.Repo, @@ -17,7 +19,8 @@ defmodule Nulla.Application do # Start a worker by calling: Nulla.Worker.start_link(arg) # {Nulla.Worker, arg}, # Start to serve requests, typically the last entry - NullaWeb.Endpoint + NullaWeb.Endpoint, + {Nulla.Snowflake, worker_id} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/nulla/http_signature.ex b/lib/nulla/http_signature.ex new file mode 100644 index 0000000..35c3977 --- /dev/null +++ b/lib/nulla/http_signature.ex @@ -0,0 +1,111 @@ +defmodule Nulla.HTTPSignature do + import Plug.Conn + + def make_headers(body, inbox_url, publicKeyId, privateKeyPem) do + digest = "SHA-256=" <> (:crypto.hash(:sha256, body) |> Base.encode64()) + date = DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") + uri = URI.parse(inbox_url) + + signature_string = + "(request-target): post #{uri.path}\n" <> + "host: #{uri.host}\n" <> + "date: #{date}\n" <> + "digest: #{digest}" + + private_key = + case :public_key.pem_decode(privateKeyPem) do + [entry] -> :public_key.pem_entry_decode(entry) + _ -> raise "Invalid PEM format" + end + + signature = + :public_key.sign(signature_string, :sha256, private_key) + |> Base.encode64() + + signature_header = + """ + keyId="#{publicKeyId}", + algorithm="rsa-sha256", + headers="(request-target) host date digest", + signature="#{signature}" + """ + |> String.replace("\n", "") + |> String.trim() + + [ + {"Content-Type", "application/activity+json"}, + {"Date", date}, + {"Digest", digest}, + {"Signature", signature_header} + ] + end + + def verify(conn, public_key_pem) do + with [sig_header] <- get_req_header(conn, "signature"), + signature_map <- parse_signature_header(sig_header), + {:ok, signed_string} <- build_signature_string(signature_map["headers"], conn), + true <- verify_signature(public_key_pem, signed_string, signature_map["signature"]) do + :ok + else + _ -> {:error, :invalid_signature} + end + end + + defp parse_signature_header(header) do + header = + header + |> String.split(",") + |> Enum.map(fn pair -> + [k, v] = String.split(pair, "=", parts: 2) + {String.trim(k), String.trim(v, ~s("))} + end) + |> Enum.into(%{}) + + header + end + + defp build_signature_string(nil, _conn), do: {:error, :missing_headers} + + defp build_signature_string(headers_str, conn) do + headers = String.split(headers_str, " ") + + result = + Enum.map(headers, fn header -> + line = + case header do + "(request-target)" -> + method = String.downcase(conn.method) + + path = + conn.request_path <> + if conn.query_string != "", do: "?" <> conn.query_string, else: "" + + "(request-target): #{method} #{path}" + + "host" -> + "host: #{conn.host}" + + _ -> + value = get_req_header(conn, header) |> List.first() + if value, do: "#{header}: #{value}", else: nil + end + + line + end) + |> Enum.reject(&is_nil/1) + |> Enum.join("\n") + + {:ok, result} + end + + defp verify_signature(public_key_pem, signed_string, signature_base64) do + public_key = + :public_key.pem_decode(public_key_pem) + |> hd() + |> :public_key.pem_entry_decode() + + signature = Base.decode64!(signature_base64) + + :public_key.verify(signed_string, :sha256, signature, public_key) + end +end diff --git a/lib/nulla/key_gen.ex b/lib/nulla/key_gen.ex new file mode 100644 index 0000000..8a75cb5 --- /dev/null +++ b/lib/nulla/key_gen.ex @@ -0,0 +1,24 @@ +defmodule Nulla.KeyGen do + def gen do + key = :public_key.generate_key({:rsa, 2048, 65_537}) + entry = :public_key.pem_entry_encode(:RSAPrivateKey, key) + + private_pem = + :public_key.pem_encode([entry]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + [private_key_code] = :public_key.pem_decode(private_pem) + private_key = :public_key.pem_entry_decode(private_key_code) + {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key + public_key = {:RSAPublicKey, modulus, exponent} + public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + + public_pem = + :public_key.pem_encode([public_key]) + |> String.trim_trailing() + |> Kernel.<>("\n") + + {public_pem, private_pem} + end +end diff --git a/lib/nulla/media_attachments.ex b/lib/nulla/media_attachments.ex new file mode 100644 index 0000000..334319b --- /dev/null +++ b/lib/nulla/media_attachments.ex @@ -0,0 +1,104 @@ +defmodule Nulla.MediaAttachments do + @moduledoc """ + The MediaAttachments context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.MediaAttachments.MediaAttachment + + @doc """ + Returns the list of media_attachments. + + ## Examples + + iex> list_media_attachments() + [%MediaAttachment{}, ...] + + """ + def list_media_attachments do + Repo.all(MediaAttachment) + end + + @doc """ + Gets a single media_attachment. + + Raises `Ecto.NoResultsError` if the Media attachment does not exist. + + ## Examples + + iex> get_media_attachment!(123) + %MediaAttachment{} + + iex> get_media_attachment!(456) + ** (Ecto.NoResultsError) + + """ + def get_media_attachment!(id), do: Repo.get!(MediaAttachment, id) + + @doc """ + Creates a media_attachment. + + ## Examples + + iex> create_media_attachment(%{field: value}) + {:ok, %MediaAttachment{}} + + iex> create_media_attachment(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_media_attachment(attrs \\ %{}) do + %MediaAttachment{} + |> MediaAttachment.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a media_attachment. + + ## Examples + + iex> update_media_attachment(media_attachment, %{field: new_value}) + {:ok, %MediaAttachment{}} + + iex> update_media_attachment(media_attachment, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_media_attachment(%MediaAttachment{} = media_attachment, attrs) do + media_attachment + |> MediaAttachment.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a media_attachment. + + ## Examples + + iex> delete_media_attachment(media_attachment) + {:ok, %MediaAttachment{}} + + iex> delete_media_attachment(media_attachment) + {:error, %Ecto.Changeset{}} + + """ + def delete_media_attachment(%MediaAttachment{} = media_attachment) do + Repo.delete(media_attachment) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking media_attachment changes. + + ## Examples + + iex> change_media_attachment(media_attachment) + %Ecto.Changeset{data: %MediaAttachment{}} + + """ + def change_media_attachment(%MediaAttachment{} = media_attachment, attrs \\ %{}) do + MediaAttachment.changeset(media_attachment, attrs) + end +end diff --git a/lib/nulla/media_attachments/media_attachment.ex b/lib/nulla/media_attachments/media_attachment.ex new file mode 100644 index 0000000..6efd9e1 --- /dev/null +++ b/lib/nulla/media_attachments/media_attachment.ex @@ -0,0 +1,35 @@ +defmodule Nulla.MediaAttachments.MediaAttachment do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Notes.Note + + @primary_key {:id, :integer, autogenerate: false} + schema "media_attachments" do + field :type, :string + field :mediaType, :string + field :url, :string + field :name, :string + field :width, :integer + field :height, :integer + + belongs_to :note, Note + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(media_attachment, attrs) do + media_attachment + |> cast(attrs, [:type, :mediaType, :url, :name, :width, :height, :note_id]) + |> maybe_put_id() + |> validate_required([:type, :mediaType, :url, :note_id]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/notes.ex b/lib/nulla/notes.ex new file mode 100644 index 0000000..99c4999 --- /dev/null +++ b/lib/nulla/notes.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Notes do + @moduledoc """ + The Notes context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Notes.Note + + @doc """ + Returns the list of notes. + + ## Examples + + iex> list_notes() + [%Note{}, ...] + + """ + def list_notes do + Repo.all(Note) + end + + @doc """ + Gets a single note. + + Raises `Ecto.NoResultsError` if the Note does not exist. + + ## Examples + + iex> get_note!(123) + %Note{} + + iex> get_note!(456) + ** (Ecto.NoResultsError) + + """ + def get_note!(id), do: Repo.get!(Note, id) + + @doc """ + Creates a note. + + ## Examples + + iex> create_note(%{field: value}) + {:ok, %Note{}} + + iex> create_note(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_note(attrs \\ %{}) do + %Note{} + |> Note.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a note. + + ## Examples + + iex> update_note(note, %{field: new_value}) + {:ok, %Note{}} + + iex> update_note(note, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_note(%Note{} = note, attrs) do + note + |> Note.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a note. + + ## Examples + + iex> delete_note(note) + {:ok, %Note{}} + + iex> delete_note(note) + {:error, %Ecto.Changeset{}} + + """ + def delete_note(%Note{} = note) do + Repo.delete(note) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking note changes. + + ## Examples + + iex> change_note(note) + %Ecto.Changeset{data: %Note{}} + + """ + def change_note(%Note{} = note, attrs \\ %{}) do + Note.changeset(note, attrs) + end +end diff --git a/lib/nulla/notes/note.ex b/lib/nulla/notes/note.ex new file mode 100644 index 0000000..d4ba8b1 --- /dev/null +++ b/lib/nulla/notes/note.ex @@ -0,0 +1,60 @@ +defmodule Nulla.Notes.Note do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + alias Nulla.MediaAttachments.MediaAttachment + + @primary_key {:id, :integer, autogenerate: false} + schema "notes" do + field :inReplyTo, :string + field :published, :utc_datetime + field :url, :string + field :visibility, :string + field :to, {:array, :string} + field :cc, {:array, :string} + field :sensitive, :boolean, default: false + field :content, :string + field :language, :string + + belongs_to :actor, Actor + has_many :media_attachments, MediaAttachment + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(note, attrs) do + note + |> cast(attrs, [ + :inReplyTo, + :published, + :url, + :visibility, + :to, + :cc, + :sensitive, + :content, + :language, + :actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :published, + :url, + :visibility, + :to, + :cc, + :content, + :language, + :actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/relations.ex b/lib/nulla/relations.ex new file mode 100644 index 0000000..088d01a --- /dev/null +++ b/lib/nulla/relations.ex @@ -0,0 +1,104 @@ +defmodule Nulla.Relations do + @moduledoc """ + The Relations context. + """ + + import Ecto.Query, warn: false + alias Nulla.Repo + + alias Nulla.Relations.Relation + + @doc """ + Returns the list of relations. + + ## Examples + + iex> list_relations() + [%Relation{}, ...] + + """ + def list_relations do + Repo.all(Relation) + end + + @doc """ + Gets a single relation. + + Raises `Ecto.NoResultsError` if the Relation does not exist. + + ## Examples + + iex> get_relation!(123) + %Relation{} + + iex> get_relation!(456) + ** (Ecto.NoResultsError) + + """ + def get_relation!(id), do: Repo.get!(Relation, id) + + @doc """ + Creates a relation. + + ## Examples + + iex> create_relation(%{field: value}) + {:ok, %Relation{}} + + iex> create_relation(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_relation(attrs \\ %{}) do + %Relation{} + |> Relation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a relation. + + ## Examples + + iex> update_relation(relation, %{field: new_value}) + {:ok, %Relation{}} + + iex> update_relation(relation, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_relation(%Relation{} = relation, attrs) do + relation + |> Relation.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a relation. + + ## Examples + + iex> delete_relation(relation) + {:ok, %Relation{}} + + iex> delete_relation(relation) + {:error, %Ecto.Changeset{}} + + """ + def delete_relation(%Relation{} = relation) do + Repo.delete(relation) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking relation changes. + + ## Examples + + iex> change_relation(relation) + %Ecto.Changeset{data: %Relation{}} + + """ + def change_relation(%Relation{} = relation, attrs \\ %{}) do + Relation.changeset(relation, attrs) + end +end diff --git a/lib/nulla/relations/relation.ex b/lib/nulla/relations/relation.ex new file mode 100644 index 0000000..8ac7e38 --- /dev/null +++ b/lib/nulla/relations/relation.ex @@ -0,0 +1,71 @@ +defmodule Nulla.Relations.Relation do + use Ecto.Schema + import Ecto.Changeset + alias Ecto.Changeset + alias Nulla.Snowflake + alias Nulla.Actors.Actor + + schema "relations" do + field :following, :boolean, default: false + field :followed_by, :boolean, default: false + field :showing_replies, :boolean, default: true + field :showings_reblogs, :boolean, default: true + field :notifying, :boolean, default: false + field :muting, :boolean, default: false + field :muting_notifications, :boolean, default: false + field :blocking, :boolean, default: false + field :blocked_by, :boolean, default: false + field :domain_blocking, :boolean, default: false + field :requested, :boolean, default: false + field :note, :string + + belongs_to :local_actor, Actor + belongs_to :remote_actor, Actor + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(relation, attrs) do + relation + |> cast(attrs, [ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + |> maybe_put_id() + |> validate_required([ + :following, + :followed_by, + :showing_replies, + :showings_reblogs, + :notifying, + :muting, + :muting_notifications, + :blocking, + :blocked_by, + :domain_blocking, + :requested, + :note, + :local_actor_id, + :remote_actor_id + ]) + end + + defp maybe_put_id(%Changeset{data: %{id: nil}} = changeset) do + change(changeset, id: Snowflake.next_id()) + end + + defp maybe_put_id(changeset), do: changeset +end diff --git a/lib/nulla/sender.ex b/lib/nulla/sender.ex new file mode 100644 index 0000000..110fa2c --- /dev/null +++ b/lib/nulla/sender.ex @@ -0,0 +1,24 @@ +defmodule Nulla.Sender do + alias Nulla.HTTPSignature + alias NullaWeb.ActivityJSON + + def send_activity(method, inbox, activity, publicKeyId, privateKeyPem) do + body = Jason.encode!(ActivityJSON.activitypub(activity)) + headers = HTTPSignature.make_headers(body, inbox, publicKeyId, privateKeyPem) + request = Finch.build(method, inbox, headers, body) + + case Finch.request(request, Nulla.Finch) do + {:ok, %Finch.Response{status: code}} when code in 200..299 -> + IO.puts("Activity #{activity.id} delivered successfully") + :ok + + {:ok, %Finch.Response{status: code, body: resp}} -> + IO.inspect({:error, code, resp}, label: "Failed to deliver activity #{activity.id}") + {:error, {:http_error, code}} + + {:error, reason} -> + IO.inspect(reason, label: "Activity #{activity.id} delivery failed") + {:error, reason} + end + end +end diff --git a/lib/nulla/snowflake.ex b/lib/nulla/snowflake.ex new file mode 100644 index 0000000..5b7c88e --- /dev/null +++ b/lib/nulla/snowflake.ex @@ -0,0 +1,62 @@ +defmodule Nulla.Snowflake do + use GenServer + import Bitwise + + @epoch :calendar.datetime_to_gregorian_seconds({{2020, 1, 1}, {0, 0, 0}}) * 1000 + @max_sequence 4095 + @time_shift 22 + @worker_shift 12 + + def start_link(worker_id) when worker_id in 0..1023 do + GenServer.start_link(__MODULE__, worker_id, name: __MODULE__) + end + + def next_id do + GenServer.call(__MODULE__, :next_id) + end + + @impl true + def init(worker_id) do + {:ok, %{last_timestamp: -1, sequence: 0, worker_id: worker_id}} + end + + @impl true + def handle_call(:next_id, _from, %{worker_id: worker_id} = state) do + timestamp = current_time() + + {timestamp, sequence, state} = + cond do + timestamp < state.last_timestamp -> + raise "Clock moved backwards" + + timestamp == state.last_timestamp and state.sequence < @max_sequence -> + {timestamp, state.sequence + 1, %{state | sequence: state.sequence + 1}} + + timestamp == state.last_timestamp -> + wait_for_next_millisecond(timestamp) + new_timestamp = current_time() + {new_timestamp, 0, %{state | last_timestamp: new_timestamp, sequence: 0}} + + true -> + {timestamp, 0, %{state | last_timestamp: timestamp, sequence: 0}} + end + + raw_id = + ((timestamp - @epoch) <<< @time_shift) + |> bor(worker_id <<< @worker_shift) + |> bor(sequence) + + id = Bitwise.band(raw_id, 0x7FFFFFFFFFFFFFFF) + + {:reply, id, %{state | last_timestamp: timestamp, sequence: sequence}} + end + + defp current_time do + System.system_time(:millisecond) + end + + defp wait_for_next_millisecond(last_ts) do + :timer.sleep(1) + if current_time() <= last_ts, do: wait_for_next_millisecond(last_ts), else: :ok + end +end diff --git a/lib/nulla/types/string_or_json.ex b/lib/nulla/types/string_or_json.ex new file mode 100644 index 0000000..4463c32 --- /dev/null +++ b/lib/nulla/types/string_or_json.ex @@ -0,0 +1,46 @@ +defmodule Nulla.Types.StringOrJson do + @behaviour Ecto.Type + + @impl true + def type, do: :string + + @impl true + def cast(value) when is_map(value) or is_list(value), do: {:ok, value} + + @impl true + def cast(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def cast(_), do: :error + + @impl true + def dump(value) when is_map(value) or is_list(value), do: Jason.encode(value) + + @impl true + def dump(value) when is_binary(value), do: {:ok, value} + + @impl true + def dump(_), do: :error + + @impl true + def load(value) when is_binary(value) do + case Jason.decode(value) do + {:ok, decoded} -> {:ok, decoded} + _ -> {:ok, value} + end + end + + @impl true + def load(_), do: :error + + @impl true + def embed_as(_format), do: :self + + @impl true + def equal?(term1, term2), do: term1 == term2 +end diff --git a/lib/nulla_web/components/layouts/root.html.heex b/lib/nulla_web/components/layouts/root.html.heex index ea6a36d..8ca2517 100644 --- a/lib/nulla_web/components/layouts/root.html.heex +++ b/lib/nulla_web/components/layouts/root.html.heex @@ -12,6 +12,47 @@
+