diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5d2adff0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# This file excludes paths from the Docker build context. +# +# By default, Docker's build context includes all files (and folders) in the +# current directory. Even if a file isn't copied into the container it is still sent to +# the Docker daemon. +# +# There are multiple reasons to exclude files from the build context: +# +# 1. Prevent nested folders from being copied into the container (ex: exclude +# /assets/node_modules when copying /assets) +# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) +# 3. Avoid sending files containing sensitive information +# +# More information on using .dockerignore is available here: +# https://docs.docker.com/engine/reference/builder/#dockerignore-file + +.dockerignore + +# Ignore git, but keep git HEAD and refs to access current commit hash if needed: +# +# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat +# d0b8727759e1e0e7aa3d41707d12376e373d5ecc +.git +!.git/HEAD +!.git/refs + +# Common development/test artifacts +/cover/ +/doc/ +/test/ +/tmp/ +.elixir_ls + +# Mix artifacts +/_build/ +/deps/ +*.ez + +# Generated on crash by the VM +erl_crash.dump + +# Static artifacts - These should be fetched and built inside the Docker image +# https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Release.html#module-docker +/assets/node_modules/ +/priv/static/assets/ +/priv/static/cache_manifest.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..0fdde280 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,106 @@ +# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian +# instead of Alpine to avoid DNS resolution issues in production. +# +# https://hub.docker.com/r/hexpm/elixir/tags?name=ubuntu +# https://hub.docker.com/_/ubuntu/tags +# +# This file is based on these images: +# +# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image +# - https://hub.docker.com/_/debian/tags?name=trixie-20251208-slim - for the release image +# - https://pkgs.org/ - resource for finding needed packages +# - Ex: docker.io/hexpm/elixir:1.15.7-erlang-26.2.1-debian-trixie-20251208-slim +# +ARG ELIXIR_VERSION=1.15.7 +ARG OTP_VERSION=26.2.1 +ARG DEBIAN_VERSION=trixie-20251208-slim + +ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" +ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" + +FROM ${BUILDER_IMAGE} AS builder + +# install build dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential git \ + && rm -rf /var/lib/apt/lists/* + +# prepare build dir +WORKDIR /app + +# install hex + rebar +RUN mix local.hex --force \ + && mix local.rebar --force + +# set build ENV +ENV MIX_ENV="prod" + +# install mix dependencies +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +# copy compile-time config files before we compile dependencies +# to ensure any relevant config change will trigger the dependencies +# to be re-compiled. +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile + +RUN mix assets.setup + +COPY priv priv + +COPY lib lib + +# Compile the release +RUN mix compile + +COPY assets assets + +# compile assets +RUN mix assets.deploy + +# Changes to config/runtime.exs don't require recompiling the code +COPY config/runtime.exs config/ + +COPY rel rel +RUN mix release + +# start a new build stage so that the final image will only contain +# the compiled release and other runtime necessities +FROM ${RUNNER_IMAGE} AS final + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# Set the locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ + && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LANGUAGE=en_US:en +ENV LC_ALL=en_US.UTF-8 + +WORKDIR "/app" +RUN chown nobody /app + +# set runner ENV +ENV MIX_ENV="prod" + +# Only copy the final release from the build stage +COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/ares ./ + +USER nobody + +# If using an environment that doesn't automatically reap zombie processes, it is +# advised to add an init process such as tini via `apt-get install` +# above and adding an entrypoint. See https://github.com/krallin/tini for details +# ENTRYPOINT ["/tini", "--"] + +EXPOSE 4000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD curl -f http://localhost:4000/ || exit 1 + +CMD ["sh", "-c", "/app/bin/migrate && /app/bin/server"] diff --git a/assets/package-lock.json b/assets/package-lock.json new file mode 100644 index 00000000..3ada4d7f --- /dev/null +++ b/assets/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "assets", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tailwindcss": "^4.1.18" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + } + } +} diff --git a/assets/package.json b/assets/package.json new file mode 100644 index 00000000..924b89f2 --- /dev/null +++ b/assets/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "tailwindcss": "^4.1.18" + } +} diff --git a/config/prod.exs b/config/prod.exs index 6ef8a0d0..f3b4388d 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -20,9 +20,9 @@ config :ex_aws, s3: [ scheme: "https://", host: {:system, "ASSET_HOST"}, - region: {:system, "AWS_REGION"}, - access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, - secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} + region: {:system, "AWS_S3_REGION"}, + access_key_id: {:system, "AWS_S3_ACCESS_KEY_ID"}, + secret_access_key: {:system, "AWS_S3_SECRET_ACCESS_KEY"} ] # Configures Swoosh API Client @@ -31,6 +31,8 @@ config :swoosh, api_client: Swoosh.ApiClient.Req # Disable Swoosh Local Memory Storage config :swoosh, local: false +config :ares, Ares.Mailer, adapter: Swoosh.Adapters.ExAwsAmazonSES + # Do not print debug messages in production config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs index 58340ad5..7e710b5e 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -20,6 +20,10 @@ if System.get_env("PHX_SERVER") do config :ares, AresWeb.Endpoint, server: true end +config :ares, + from_email_name: System.get_env("FROM_EMAIL_NAME") || "BugsByte", + from_email_address: System.get_env("FROM_EMAIL_ADDRESS") || "no-reply@bugsbyte.org" + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || @@ -102,18 +106,18 @@ if config_env() == :prod do # ## Configuring the mailer # # In production you need to configure the mailer to use a different adapter. - # Here is an example configuration for Mailgun: + # 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 :ares, Ares.Mailer, # adapter: Swoosh.Adapters.Mailgun, # api_key: System.get_env("MAILGUN_API_KEY"), # domain: System.get_env("MAILGUN_DOMAIN") # - # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney, - # and Finch out-of-the-box. This configuration is typically done at - # compile-time in your config/prod.exs: + # 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.Req + # config :swoosh, :api_client, Swoosh.ApiClient.Hackney # # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. end diff --git a/lib/ares/accounts/user_notifier.ex b/lib/ares/accounts/user_notifier.ex index ed0259df..9f049b18 100644 --- a/lib/ares/accounts/user_notifier.ex +++ b/lib/ares/accounts/user_notifier.ex @@ -7,38 +7,39 @@ defmodule Ares.Accounts.UserNotifier do alias Ares.Accounts.User alias Ares.Mailer - # Delivers the email using the application mailer. - defp deliver(recipient, subject, body) do - email = - new() - |> to(recipient) - |> from({"Ares", "contact@example.com"}) - |> subject(subject) - |> text_body(body) - - with {:ok, _metadata} <- Mailer.deliver(email) do - {:ok, email} - end + use Phoenix.Swoosh, view: AresWeb.EmailView + + defp base_html_email(recipient, subject) do + sender = {Mailer.get_sender_name(), Mailer.get_sender_address()} + + phx_host = + if System.get_env("PHX_HOST") != nil do + "https://" <> System.get_env("PHX_HOST") + else + "" + end + + new() + |> to(recipient) + |> from(sender) + |> subject("[#{elem(sender, 0)}] #{subject}") + |> assign(:phx_host, phx_host) 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. - - ============================== - """) + email = + base_html_email(user.email, "Update your email address") + |> assign(:user_name, user.name) + |> assign(:confirm_email_link, url) + |> render_body("confirm_email.html") + + case Mailer.deliver(email) do + {:ok, _metadata} -> {:ok, email} + {:error, reason} -> {:error, reason} + end end @doc """ @@ -52,36 +53,28 @@ defmodule Ares.Accounts.UserNotifier do end defp deliver_magic_link_instructions(user, url) do - deliver(user.email, "Log in instructions", """ - - ============================== - - Hi #{user.email}, - - You can log into your account by visiting the URL below: - - #{url} - - If you didn't request this email, please ignore this. - - ============================== - """) + email = + base_html_email(user.email, "Log in to your account") + |> assign(:user_name, user.name) + |> assign(:magic_link, url) + |> render_body("magic_link.html") + + case Mailer.deliver(email) do + {:ok, _metadata} -> {:ok, email} + {:error, reason} -> {:error, reason} + end end defp 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. - - ============================== - """) + email = + base_html_email(user.email, "Confirm your email") + |> assign(:user_name, user.name) + |> assign(:confirm_email_link, url) + |> render_body("confirm_email.html") + + case Mailer.deliver(email) do + {:ok, _metadata} -> {:ok, email} + {:error, reason} -> {:error, reason} + end end end diff --git a/lib/ares/mailer.ex b/lib/ares/mailer.ex index ae53f613..1a0c16ae 100644 --- a/lib/ares/mailer.ex +++ b/lib/ares/mailer.ex @@ -1,3 +1,11 @@ defmodule Ares.Mailer do use Swoosh.Mailer, otp_app: :ares + + def get_sender_name do + Application.get_env(:ares, :from_email_name) + end + + def get_sender_address do + Application.get_env(:ares, :from_email_address) + end end diff --git a/lib/ares/release.ex b/lib/ares/release.ex new file mode 100644 index 00000000..220de49a --- /dev/null +++ b/lib/ares/release.ex @@ -0,0 +1,30 @@ +defmodule Ares.Release do + @moduledoc """ + Used for executing DB release tasks when run in production without Mix + installed. + """ + @app :ares + + def migrate do + load_app() + + for repo <- repos() do + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) + end + end + + def rollback(repo, version) do + load_app() + {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) + end + + defp repos do + Application.fetch_env!(@app, :ecto_repos) + end + + defp load_app do + # Many platforms require SSL when connecting to the database + Application.ensure_all_started(:ssl) + Application.ensure_loaded(@app) + end +end diff --git a/lib/ares_web.ex b/lib/ares_web.ex index e9fe92e9..a323a24a 100644 --- a/lib/ares_web.ex +++ b/lib/ares_web.ex @@ -58,6 +58,21 @@ defmodule AresWeb do end end + def view do + quote do + use Phoenix.View, + root: "lib/ares_web/templates", + namespace: AresWeb + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [view_module: 1, view_template: 1] + + # Include shared imports and aliases for views + unquote(html_helpers()) + end + end + def live_component do quote do use Phoenix.LiveComponent diff --git a/lib/ares_web/components/core_components.ex b/lib/ares_web/components/core_components.ex index 25f730e0..9c758239 100644 --- a/lib/ares_web/components/core_components.ex +++ b/lib/ares_web/components/core_components.ex @@ -57,7 +57,7 @@ defmodule AresWeb.CoreComponents do id={@id} phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" - class="toast toast-top toast-end z-50" + class="toast toast-top toast-end z-100" {@rest} >
- <.live_title default="Ares" suffix=" · Phoenix Framework"> + <.live_title default="Ares" suffix=""> {assigns[:page_title]} @@ -30,8 +30,8 @@ })(); - - <.navbar user={if @current_scope, do: @current_scope.user} fixed /> + + <.navbar user={if @current_scope, do: @current_scope.user} />