diff --git a/config/config.exs b/config/config.exs index f77402a..c75f9ce 100644 --- a/config/config.exs +++ b/config/config.exs @@ -18,7 +18,8 @@ config :lokal, LokalWeb.Endpoint, secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", render_errors: [view: LokalWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: Lokal.PubSub, - live_view: [signing_salt: "zOLgd3lr"] + live_view: [signing_salt: "zOLgd3lr"], + registration: System.get_env("REGISTRATION") || "invite" config :lokal, Lokal.Application, automigrate: false @@ -39,6 +40,15 @@ config :lokal, Lokal.Mailer, adapter: Swoosh.Adapters.Local # Swoosh API client is needed for adapters other than SMTP. config :swoosh, :api_client, false +# Gettext +config :gettext, :default_locale, "en_US" + +# Configure Oban +config :lokal, Oban, + repo: Lokal.Repo, + plugins: [Oban.Plugins.Pruner], + queues: [default: 10, mailers: 20] + # Configure esbuild (the version is required) # config :esbuild, # version: "0.14.0", diff --git a/config/dev.exs b/config/dev.exs index 3b8a5d8..e454a92 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -2,9 +2,6 @@ import Config # Configure your database config :lokal, Lokal.Repo, - url: - System.get_env("DATABASE_URL") || - "ecto://postgres:postgres@localhost/lokal_dev", show_sensitive_data_on_connection_error: true, pool_size: 10 @@ -15,13 +12,10 @@ config :lokal, Lokal.Repo, # watchers to your application. For example, we use it # with esbuild to bundle .js and .css sources. config :lokal, LokalWeb.Endpoint, - # Binding to loopback ipv4 address prevents access from other machines. - # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. - http: [ip: {0, 0, 0, 0}, port: 4000], check_origin: false, code_reloader: true, debug_errors: true, - secret_key_base: "cSLRa17z1D1qLwQuaw73DMT7BX8oDMkru/rJIsmCdlFypLGRQW3bpqJRrZQtoZJQ", + secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW", watchers: [ # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) # esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]} diff --git a/config/prod.exs b/config/prod.exs index 8b6f640..6bdebd1 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -9,16 +9,7 @@ import Config # manifest is generated by the `mix phx.digest` task, # which you should run after static files are built and # before starting your production server. -config :lokal, LokalWeb.Endpoint, - url: [host: "localhost"], - http: [port: 4000], - cache_static_manifest: "priv/static/cache_manifest.json" - -config :lokal, Lokal.Repo, - url: "ecto://postgres:postgres@localhost/lokal", - pool_size: 10 - -config :lokal, Lokal.Application, automigrate: true +config :lokal, LokalWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" # Do not print debug messages in production config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs index e2e6c53..3fbb035 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -12,19 +12,49 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do config :lokal, LokalWeb.Endpoint, server: true end -if config_env() == :prod do - database_url = +config :lokal, LokalWeb.ViewHelpers, shibao_mode: System.get_env("SHIBAO_MODE") == "true" + +# Set locale +Gettext.put_locale(System.get_env("LOCALE") || "en_US") + +maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: [] + +database_url = + if config_env() == :test do + System.get_env("TEST_DATABASE_URL") || + "ecto://postgres:postgres@localhost/lokal_test#{System.get_env("MIX_TEST_PARTITION")}" + else System.get_env("DATABASE_URL") || "ecto://postgres:postgres@lokal-db/lokal" + end - maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] +host = + System.get_env("HOST") || + raise "No hostname set! Must be the domain and tld like `lokal.bubbletea.dev`." - config :lokal, Lokal.Repo, - # ssl: true, - url: database_url, - pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), - socket_options: maybe_ipv6 +interface = + if config_env() in [:dev, :test], + do: {0, 0, 0, 0}, + else: {0, 0, 0, 0, 0, 0, 0, 0} +config :lokal, Lokal.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + +config :lokal, LokalWeb.Endpoint, + url: [scheme: "https", host: host, port: 443], + http: [ + # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: interface, + port: String.to_integer(System.get_env("PORT") || "4000") + ], + server: true, + registration: System.get_env("REGISTRATION") || "invite" + +if config_env() == :prod do # The secret key base is used to sign/encrypt cookies and other secrets. # A default value is used in config/dev.exs and config/test.exs but you # want to use a different value for prod and you most likely don't want @@ -37,20 +67,21 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ - host = System.get_env("HOST") || "localhost" + config :lokal, LokalWeb.Endpoint, secret_key_base: secret_key_base - config :lokal, LokalWeb.Endpoint, - ururl: [scheme: "https", host: host, port: 443], - http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. - # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0}, - port: String.to_integer(System.get_env("PORT") || "4000") - ], - secret_key_base: secret_key_base, - server: true + # Automatically apply migrations + config :lokal, Lokal.Application, automigrate: true + + # Set up SMTP settings + config :lokal, Lokal.Mailer, + adapter: Swoosh.Adapters.SMTP, + relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"), + port: System.get_env("SMTP_PORT") || 587, + username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"), + password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"), + ssl: System.get_env("SMTP_SSL") == "true", + email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}", + email_name: System.get_env("EMAIL_NAME") || "Lokal" # ## Using releases # @@ -61,22 +92,4 @@ if config_env() == :prod do # # Then you can assemble a release by calling `mix release`. # See `mix help release` for more information. - - # ## Configuring the mailer - # - # In production you need to configure the mailer to use a different adapter. - # Also, you may need to configure the Swoosh API client of your choice if you - # are not using SMTP. Here is an example of the configuration: - # - # config :lokal, Lokal.Mailer, - # adapter: Swoosh.Adapters.Mailgun, - # api_key: System.get_env("MAILGUN_API_KEY"), - # domain: System.get_env("MAILGUN_DOMAIN") - # - # For this example you need include a HTTP client required by Swoosh API client. - # Swoosh supports Hackney and Finch out of the box: - # - # config :swoosh, :api_client, Swoosh.ApiClient.Hackney - # - # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end diff --git a/config/test.exs b/config/test.exs index 5835c36..d8fd1d2 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,14 +1,14 @@ 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 # to provide built-in test partitioning in CI environment. # Run `mix help test` for more information. config :lokal, Lokal.Repo, - url: - System.get_env("TEST_DATABASE_URL") || - "ecto://postgres:postgres@localhost/lokal_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: 10 @@ -16,7 +16,7 @@ config :lokal, Lokal.Repo, # you can enable the server option below. config :lokal, LokalWeb.Endpoint, http: [ip: {0, 0, 0, 0}, port: 4002], - secret_key_base: "T4DkRImgeMNCcPcTWBCZyKYp3KQ8yyPD33VT4wj6ogbP8fIGUsqmOTNX3clTMrLo", + secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK", server: false # In test we don't send emails. @@ -27,3 +27,6 @@ config :logger, level: :warn # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime + +# Disable Oban +config :lokal, Oban, queues: false, plugins: false diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..f8cb007 --- /dev/null +++ b/contributing.md @@ -0,0 +1,124 @@ +# Contribution Guide + +## Style Tips + +- In order to keep code concise and improve readability, please try to make your + functions as short as possible while keeping variable names descriptive! For + instance, use inline `do:` blocks for short functions and make your aliases as + short as possible without introducing ambiguity. + - I.e. since there's only one `Changeset` in the app, please alias + `Changeset.t(Type.t())` instead of using `Ecto.Changeset.t(Long.Type.t())` +- Use pipelines when possible. If only calling a single method, a pipeline isn't + strictly necessary but still encouraged for future modification. +- Please add typespecs to your functions! Even your private functions may be + used by others later down the line, and typespecs will be able to help + document your code just a little bit better, and improve the debugging + process. + - Typespec arguments can be named like `@spec function(arg_name :: type()) :: + return_type()`. Please use these for generic types, such as `map()` when the + input data isn't immediately obvious. + - Please define all typespecs for a function together in one place, instead of + each function header. +- When making new models, please take inspiration from the existing models in + regards to layout of sections, typespec design, and formatting. +- With Elixir convention, for methods that raise on error please name them like + `function_that_raises!()`, and functions that return a boolean like + `function_that_returns_boolean?()`. For other methods, it's encouraged to use + status tuples for other functions like `{:ok, result}` or `{:error, + reason_or_changeset}` instead of just returning `result` or `nil` for easy + pattern matching. +- Instead of using the `.` operator, try to use pattern matching instead, + especially for function headers. `.` in templates is fine to keep things + concise. +- Use `Enum` functions over comprehensions whenever possible for clarity. + However, comprehensions in templates are fine for legibility. +- When adding text, please use `gettext` macros to enable things to be + translated in the future. After adding `gettext` macros, run `mix format` in + order to add your new text strings to the files in `priv/gettext`. + - Existing domains: `"default"` (for anything general), `"prompts"` + (informational messages as a result of the user doing an action, i.e. in + flashes), `"actions"` (actions that the user can take), `"emails"`, and + `"errors"`. Using these domains accurately will let translators know which + translations are higher and lower priority. Thank you! +- Before submitting a PR, please make sure all tests are passing using `mix + test`. + +# Technical Information + +- Created using the [Phoenix Framework](https://www.phoenixframework.org) +- User Registration/Sign in via + [`phx_gen_auth`](https://hexdocs.pm/phx_gen_auth/). +- `Dockerfile` and example `docker-compose.yml` +- Automatic migrations in `MIX_ENV=prod` or Docker image +- JS linting with [standard.js](https://standardjs.com), HEEx linting with + [heex_formatter](https://github.com/feliperenan/heex_formatter) + +## Docs + +More information can be found in the documentation generated by `mix docs`. +These are located in the `/docs` folder, and are generated in HTML and ePub. +Check them out! + +# Instructions + +1. Clone the repo +1. Install the elixir and erlang binaries. I recommend using [asdf version + manager](https://asdf-vm.com/guide/getting-started.html#_1-install-dependencies), + which will use the `.tool-versions` file to install the correct versions of + Erlang, Elixir and npm for this project! +1. Run `mix deps.get` and `mix compile` to fetch all dependencies +1. Run `mix setup` to initialize your database. You can reset your database at + any time with `mix ecto.reset`. +1. Run migrations with `mix ecto.migrate` or rollback with `mix ecto.rollback`. +1. Run `mix phx.server` to start the development server. + +# Configuration + +For development, I recommend setting environment variables with +[direnv](https://direnv.net). + +By default, Lokal will always bind to all external IPv4 and IPv6 addresses in +`dev` and `prod` mode, respectively. If you would like to use different values, +they will need to be overridden in `config/dev.exs` and `config/runtime.exs` for +`dev` and `prod` modes, respectively. + +## `MIX_ENV=dev` + +In `dev` mode, Lokal will listen for these environment variables at runtime. + +- `HOST`: External url to generate links with. Set this especially if you're + behind a reverse proxy. Defaults to `localhost`. External URLs will always be + generated with `https://` and port `443`. +- `PORT`: Internal port to bind to. Defaults to `4000`. +- `DATABASE_URL`: Controls the database url to connect to. Defaults to + `ecto://postgres:postgres@localhost/lokal_dev`. +- `ECTO_IPV6`: Controls if Ecto should use IPv6 to connect to PostgreSQL. + Defaults to `false`. +- `POOL_SIZE`: Controls the pool size to use with PostgreSQL. Defaults to `10`. +- `REGISTRATION`: Controls if user sign-up should be invite only or set to public. Set to `public` to enable public registration. Defaults to `invite`. +- `LOCALE`: Sets a custom locale. Defaults to `en_US`. + +## `MIX_ENV=test` + +In `test` mode (or in the Docker container), Lokal will listen for the same environment variables as dev mode, but also include the following at runtime: + +- `TEST_DATABASE_URL`: REPLACES `DATABASE_URL`. Controls the database url to + connect to. Defaults to `ecto://postgres:postgres@localhost/lokal_test`. +- `MIX_TEST_PARTITION`: Only used if `TEST_DATABASE_URL` is not specified. + Appended to the default database url if you would like to partition your test + databases. Defaults to not set. + +## `MIX_ENV=prod` + +In `prod` mode (or in the Docker container), Lokal will listen for the same environment variables as dev mode, but also include the following at runtime: + +- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated + with `docker run -it shibaobun/lokal mix phx.gen.secret` and set for server to start. +- `SMTP_HOST`: The url for your SMTP email provider. Must be set +- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`. +- `SMTP_USERNAME`: The username for your SMTP relay. Must be set! +- `SMTP_PASSWORD`: The password for your SMTP relay. Must be set! +- `SMTP_SSL`: Set to `true` to enable SSL for emails. Defaults to `false`. +- `EMAIL_FROM`: Sets the sender email in sent emails. Defaults to + `no-reply@HOST` where `HOST` was previously defined. +- `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Lokal". diff --git a/lib/lokal/accounts/email.ex b/lib/lokal/accounts/email.ex new file mode 100644 index 0000000..af2c37d --- /dev/null +++ b/lib/lokal/accounts/email.ex @@ -0,0 +1,48 @@ +defmodule Lokal.Email do + @moduledoc """ + Emails that can be sent using Swoosh. + + You can find the base email templates at + `lib/Lokal_web/templates/layout/email.html.heex` for html emails and + `lib/Lokal_web/templates/layout/email.txt.heex` for text emails. + """ + + use Phoenix.Swoosh, view: LokalWeb.EmailView, layout: {LokalWeb.LayoutView, :email} + import LokalWeb.Gettext + alias Lokal.Accounts.User + alias LokalWeb.EmailView + + @typedoc """ + Represents an HTML and text body email that can be sent + """ + @type t() :: Swoosh.Email.t() + + @spec base_email(User.t(), String.t()) :: t() + defp base_email(%User{email: email}, subject) do + from = Application.get_env(:Lokal, Lokal.Mailer)[:email_from] || "noreply@localhost" + name = Application.get_env(:Lokal, Lokal.Mailer)[:email_name] + new() |> to(email) |> from({name, from}) |> subject(subject) + end + + @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() + def generate_email("welcome", user, %{"url" => url}) do + user + |> base_email(dgettext("emails", "Confirm your %{name} account", name: "Lokal")) + |> render_body("confirm_email.html", %{user: user, url: url}) + |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) + end + + def generate_email("reset_password", user, %{"url" => url}) do + user + |> base_email(dgettext("emails", "Reset your %{name} password", name: "Lokal")) + |> render_body("reset_password.html", %{user: user, url: url}) + |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) + end + + def generate_email("update_email", user, %{"url" => url}) do + user + |> base_email(dgettext("emails", "Update your %{name} email", name: "Lokal")) + |> render_body("update_email.html", %{user: user, url: url}) + |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) + end +end diff --git a/lib/lokal/accounts/email_worker.ex b/lib/lokal/accounts/email_worker.ex new file mode 100644 index 0000000..fc94144 --- /dev/null +++ b/lib/lokal/accounts/email_worker.ex @@ -0,0 +1,13 @@ +defmodule Lokal.EmailWorker do + @moduledoc """ + Oban worker that dispatches emails + """ + + use Oban.Worker, queue: :mailers, tags: ["email"] + alias Lokal.{Accounts, Email, Mailer} + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"email" => email, "user_id" => user_id, "attrs" => attrs}}) do + Email.generate_email(email, user_id |> Accounts.get_user!(), attrs) |> Mailer.deliver() + end +end diff --git a/lib/lokal/mailer.ex b/lib/lokal/mailer.ex index 51fd1dc..792696f 100644 --- a/lib/lokal/mailer.ex +++ b/lib/lokal/mailer.ex @@ -1,7 +1,42 @@ defmodule Lokal.Mailer do @moduledoc """ - Mailer, currently uses Swoosh + Mailer adapter for emails + + Since emails are loaded as Oban jobs, the `:attrs` map must be serializable to + json with Jason, which restricts the use of structs. """ use Swoosh.Mailer, otp_app: :lokal + alias Lokal.{Accounts.User, EmailWorker} + alias Oban.Job + + @doc """ + Deliver instructions to confirm account. + """ + @spec deliver_confirmation_instructions(User.t(), String.t()) :: Job.t() + def deliver_confirmation_instructions(%User{id: user_id}, url) do + %{email: :welcome, user_id: user_id, attrs: %{url: url}} + |> EmailWorker.new() + |> Oban.insert!() + end + + @doc """ + Deliver instructions to reset a user password. + """ + @spec deliver_reset_password_instructions(User.t(), String.t()) :: Job.t() + def deliver_reset_password_instructions(%User{id: user_id}, url) do + %{email: :reset_password, user_id: user_id, attrs: %{url: url}} + |> EmailWorker.new() + |> Oban.insert!() + end + + @doc """ + Deliver instructions to update a user email. + """ + @spec deliver_update_email_instructions(User.t(), String.t()) :: Job.t() + def deliver_update_email_instructions(%User{id: user_id}, url) do + %{email: :update_email, user_id: user_id, attrs: %{url: url}} + |> EmailWorker.new() + |> Oban.insert!() + end end diff --git a/lib/lokal_web/templates/email/confirm_email.html.eex b/lib/lokal_web/templates/email/confirm_email.html.eex new file mode 100644 index 0000000..99aee4e --- /dev/null +++ b/lib/lokal_web/templates/email/confirm_email.html.eex @@ -0,0 +1,25 @@ +