diff --git a/backend b/backend deleted file mode 160000 index f4e3732..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f4e3732f5dc4a7ddbc4f409d778bc5b2c784261a diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..6090094 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore + +# Mix artifacts +/_build +/deps +*.ez + +# Build artifacts +erl_crash.dump + +# Static artifacts +/priv/static/ + +# Environment files +.env +.env.local + +# Editor files +.vscode +.idea +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Test files +/cover +/test + +# Documentation +/doc + +# Node modules (if any frontend code was in here) +node_modules diff --git a/backend/.formatter.exs b/backend/.formatter.exs new file mode 100644 index 0000000..4761678 --- /dev/null +++ b/backend/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:phoenix], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..3c2e085 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +backend-*.tar + diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..c6eaee7 --- /dev/null +++ b/backend/AGENTS.md @@ -0,0 +1,99 @@ +This is a web application written using the Phoenix web framework. + +## Project guidelines + +- Use `mix precommit` alias when you are done with all changes and fix any pending issues +- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps + +### Phoenix v1.8 guidelines + +- **Always** begin your LiveView templates with `` which wraps all inner content +- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again +- Anytime you run into errors with no `current_scope` assign: + - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` + - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed +- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module +- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar +- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors +- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your +custom classes must fully style the input + + + + + +## Elixir guidelines + +- Elixir lists **do not support index based access via the access syntax** + + **Never do this (invalid)**: + + i = 0 + mylist = ["blue", "green"] + mylist[i] + + Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: + + i = 0 + mylist = ["blue", "green"] + Enum.at(mylist, i) + +- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc + you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: + + # INVALID: we are rebinding inside the `if` and the result never gets assigned + if connected?(socket) do + socket = assign(socket, :val, val) + end + + # VALID: we rebind the result of the `if` to a new variable + socket = + if connected?(socket) do + assign(socket, :val, val) + end + +- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors +- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets +- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards +- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` +- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option + +## Mix guidelines + +- Read the docs and options before using tasks (by using `mix help task_name`) +- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` +- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason + +## Test guidelines + +- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests +- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests + - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: + + ref = Process.monitor(pid) + assert_receive {:DOWN, ^ref, :process, ^pid, :normal} + + - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages + + + +## Phoenix guidelines + +- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. + +- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: + + scope "/admin", AppWeb.Admin do + pipe_through :browser + + live "/users", UserLive, :index + end + + the UserLive route would point to the `AppWeb.Admin.UserLive` module + +- `Phoenix.View` no longer is needed or included with Phoenix, don't use it + + + \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..883e897 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,39 @@ +FROM hexpm/elixir:1.15.7-erlang-26.1.2-alpine-3.18.4 AS build + +RUN apk add --no-cache build-base git +WORKDIR /app +RUN mix local.hex --force && \ + mix local.rebar --force +ENV MIX_ENV=prod +COPY mix.exs mix.lock ./ +RUN mix deps.get --only $MIX_ENV +RUN mkdir config + +COPY config/config.exs config/${MIX_ENV}.exs config/ +RUN mix deps.compile +COPY lib lib +RUN mix compile +COPY config/runtime.exs config/ + +COPY priv priv +RUN mix release + +FROM alpine:3.18.4 AS app + +RUN apk add --no-cache libstdc++ openssl ncurses-libs bash +ENV USER="elixir" +WORKDIR /app +RUN addgroup -g 1000 $USER && \ + adduser -D -u 1000 -G $USER $USER + +COPY --from=build --chown=elixir:elixir /app/_build/prod/rel/backend ./ + +USER elixir + +ENV HOME=/app +ENV MIX_ENV=prod +ENV PHX_SERVER=true + +EXPOSE 4000 4369 9000-9100 + +CMD ["/app/bin/backend", "start"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..cc0fa76 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,18 @@ +# Backend + +To start your Phoenix server: + +* Run `mix setup` to install and setup dependencies +* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + +* Official website: https://www.phoenixframework.org/ +* Guides: https://hexdocs.pm/phoenix/overview.html +* Docs: https://hexdocs.pm/phoenix +* Forum: https://elixirforum.com/c/phoenix-forum +* Source: https://github.com/phoenixframework/phoenix diff --git a/backend/config/config.exs b/backend/config/config.exs new file mode 100644 index 0000000..a36a868 --- /dev/null +++ b/backend/config/config.exs @@ -0,0 +1,43 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :backend, + generators: [timestamp_type: :utc_datetime] + +# Configure the endpoint +config :backend, BackendWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [json: BackendWeb.ErrorJSON], + layout: false + ], + pubsub_server: Backend.PubSub, + live_view: [signing_salt: "E+VIFYOy"] + +# Configure the mailer +# +# By default it uses the "Local" adapter which stores the emails +# locally. You can see the emails in your browser, at "/dev/mailbox". +# +# For production it's recommended to configure a different adapter +# at the `config/runtime.exs`. +config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Local + +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/backend/config/dev.exs b/backend/config/dev.exs new file mode 100644 index 0000000..bf1e529 --- /dev/null +++ b/backend/config/dev.exs @@ -0,0 +1,68 @@ +import Config + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :backend, BackendWeb.Endpoint, + # Binding to all interfaces to allow access from other containers + http: [ip: {0, 0, 0, 0}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "Obmkur6EwKlnJYJ98U+3LvnaqroiK5RYYyKFbCjhBZyu1HDjahzvzySHaOsiU7QX", + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Enable dev routes for dashboard and mailbox +config :backend, dev_routes: true + +# OpenTelemetry configuration +config :opentelemetry, + resource: [ + service: [ + name: "backend", + namespace: "websocket_game" + ] + ] + +config :opentelemetry_exporter, + otlp_protocol: :http_protobuf, + otlp_endpoint: System.get_env("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + +# Do not include metadata nor timestamps in development logs +config :logger, :default_formatter, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/backend/config/prod.exs b/backend/config/prod.exs new file mode 100644 index 0000000..9db893a --- /dev/null +++ b/backend/config/prod.exs @@ -0,0 +1,23 @@ +import Config + +# Force using SSL in production. This also sets the "strict-security-transport" header, +# known as HSTS. If you have a health check endpoint, you may want to exclude it below. +# Note `:force_ssl` is required to be set at compile-time. +config :backend, BackendWeb.Endpoint, + force_ssl: [rewrite_on: [:x_forwarded_proto]], + exclude: [ + # paths: ["/health"], + hosts: ["localhost", "127.0.0.1"] + ] + +# Configure Swoosh API Client +config :swoosh, api_client: Swoosh.ApiClient.Req + +# Disable Swoosh Local Memory Storage +config :swoosh, local: false + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/backend/config/releases.exs b/backend/config/releases.exs new file mode 100644 index 0000000..f7acbc1 --- /dev/null +++ b/backend/config/releases.exs @@ -0,0 +1,6 @@ +import Config + +# Configure the release +config :backend, Backend.Repo, + # Will be overridden in runtime.exs + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10") diff --git a/backend/config/runtime.exs b/backend/config/runtime.exs new file mode 100644 index 0000000..65134f4 --- /dev/null +++ b/backend/config/runtime.exs @@ -0,0 +1,103 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/backend start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :backend, BackendWeb.Endpoint, server: true +end + +config :backend, BackendWeb.Endpoint, + http: [port: String.to_integer(System.get_env("PORT", "4000"))] + +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 + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + + config :backend, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :backend, BackendWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + 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/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0} + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :backend, BackendWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :backend, BackendWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. + + # ## Configuring the mailer + # + # In production you need to configure the mailer to use a different adapter. + # Here is an example configuration for Mailgun: + # + # config :backend, Backend.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: + # + # config :swoosh, :api_client, Swoosh.ApiClient.Req + # + # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. +end diff --git a/backend/config/test.exs b/backend/config/test.exs new file mode 100644 index 0000000..9309e0d --- /dev/null +++ b/backend/config/test.exs @@ -0,0 +1,24 @@ +import Config + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :backend, BackendWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "K4i8bHJci8dPJ6LuZHJo147F1h8Wq+SaK4trNGLxJ1cRGkZPZvvm1zk5wWEYY+o2", + server: false + +# In test we don't send emails +config :backend, Backend.Mailer, adapter: Swoosh.Adapters.Test + +# Disable swoosh api client as it is only required for production adapters +config :swoosh, :api_client, false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime + +# Sort query params output of verified routes for robust url comparisons +config :phoenix, + sort_verified_routes_query_params: true diff --git a/backend/dev.Dockerfile b/backend/dev.Dockerfile new file mode 100644 index 0000000..32dd005 --- /dev/null +++ b/backend/dev.Dockerfile @@ -0,0 +1,14 @@ +FROM hexpm/elixir:1.15.7-erlang-26.1.2-alpine-3.18.4 +RUN apk add --no-cache build-base git bash wget +WORKDIR /app + +ENV USER="elixir" +RUN addgroup -g 1000 $USER && \ + adduser -D -u 1000 -G $USER $USER + +RUN mkdir -p /app/_build && \ + chown -R elixir:elixir /app +USER elixir + +RUN mix local.hex --force && \ + mix local.rebar --force diff --git a/backend/lib/backend.ex b/backend/lib/backend.ex new file mode 100644 index 0000000..d059a26 --- /dev/null +++ b/backend/lib/backend.ex @@ -0,0 +1,9 @@ +defmodule Backend do + @moduledoc """ + Backend keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/backend/lib/backend/application.ex b/backend/lib/backend/application.ex new file mode 100644 index 0000000..1b070fe --- /dev/null +++ b/backend/lib/backend/application.ex @@ -0,0 +1,37 @@ +defmodule Backend.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + BackendWeb.Telemetry, + {DNSCluster, query: Application.get_env(:backend, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Backend.PubSub}, + # Start the Cluster manager for distributed Erlang + Backend.Cluster, + # Use GlobalSingleton supervisor for instant failover + {Backend.GlobalSingleton, Backend.GameState}, + # Start a worker by calling: Backend.Worker.start_link(arg) + # {Backend.Worker, arg}, + # Start to serve requests, typically the last entry + BackendWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Backend.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + BackendWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/backend/lib/backend/cluster.ex b/backend/lib/backend/cluster.ex new file mode 100644 index 0000000..4a77c8f --- /dev/null +++ b/backend/lib/backend/cluster.ex @@ -0,0 +1,78 @@ +defmodule Backend.Cluster do + @moduledoc """ + Manages Erlang clustering using native :net_kernel functionality. + Automatically connects to other nodes in the cluster. + """ + use GenServer + require Logger + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(_opts) do + # Connect to cluster nodes on startup + Process.send_after(self(), :connect_nodes, 1000) + # Periodically retry connection to handle network issues + schedule_connect() + {:ok, %{}} + end + + @impl true + def handle_info(:connect_nodes, state) do + connect_to_cluster() + {:noreply, state} + end + + @impl true + def handle_info(:periodic_connect, state) do + connect_to_cluster() + schedule_connect() + {:noreply, state} + end + + defp connect_to_cluster do + # Get all cluster nodes and exclude ourselves + all_nodes = + System.get_env("CLUSTER_NODES", "") + |> String.split(",", trim: true) + |> Enum.map(&String.to_atom/1) + + cluster_nodes = Enum.reject(all_nodes, &(&1 == node())) + + failures = + Enum.filter(cluster_nodes, fn node_name -> + case Node.connect(node_name) do + true -> + # Logger.debug("Connected to node: #{node_name}") + false + + false -> + Logger.warning("Failed to connect to node: #{node_name}") + true + + :ignored -> + # Already connected, not a failure + false + end + end) + + connected_nodes = Node.list() + + # Expected count is other nodes (not including ourselves) + expected_count = length(cluster_nodes) + actual_count = length(connected_nodes) + + if failures != [] or actual_count < expected_count do + Logger.warning( + "Cluster status: #{actual_count}/#{expected_count} nodes connected: #{inspect(connected_nodes)}" + ) + end + end + + defp schedule_connect do + # Retry connection every 10 seconds + Process.send_after(self(), :periodic_connect, 10_000) + end +end diff --git a/backend/lib/backend/game_state.ex b/backend/lib/backend/game_state.ex new file mode 100644 index 0000000..0a13738 --- /dev/null +++ b/backend/lib/backend/game_state.ex @@ -0,0 +1,118 @@ +defmodule Backend.GameState do + @moduledoc """ + GenServer to track all players and their positions in the game. + Uses :global registry for distributed singleton pattern - only one instance + runs across the entire cluster, with automatic failover. + """ + use GenServer + require Logger + + @name {:global, __MODULE__} + @pubsub Backend.PubSub + @topic "game_state" + + # Client API + + def start_link(_opts) do + case GenServer.start_link(__MODULE__, %{}, name: @name) do + {:ok, pid} -> + {:ok, pid} + + {:error, {:already_started, pid}} -> + # Another instance is already running globally + :ignore + end + end + + def add_player(player_id) do + Logger.info("Player #{player_id} connected") + GenServer.call(@name, {:add_player, player_id}) + end + + def remove_player(player_id) do + Logger.info("Player #{player_id} removed") + GenServer.cast(@name, {:remove_player, player_id}) + end + + def move_player(player_id, directions) do + Logger.info("Player #{player_id} moved #{inspect(directions)}") + GenServer.cast(@name, {:move_player, player_id, directions}) + end + + def get_state do + GenServer.call(@name, :get_state) + end + + # Server Callbacks + + @impl true + def init(_) do + Logger.info("GameState starting on node: #{node()}") + {:ok, %{}} + end + + @impl true + def handle_call({:add_player, player_id}, _from, state) do + # Start players at random position + x = :rand.uniform(800) + y = :rand.uniform(600) + + new_state = Map.put(state, player_id, %{x: x, y: y}) + broadcast_state(new_state) + {:reply, new_state, new_state} + end + + @impl true + def handle_cast({:move_player, player_id, directions}, state) do + Logger.info("Processing player move") + + player = + case Map.get(state, player_id) do + nil -> + Logger.warning( + "Move attempted for non-existent player: #{player_id}, creating at random position" + ) + + %{x: :rand.uniform(800), y: :rand.uniform(600)} + + existing_player -> + existing_player + end + + step = 10 + + # Apply all directions to calculate final position + new_position = + Enum.reduce(directions, player, fn direction, acc -> + case direction do + "w" -> %{acc | y: max(0, acc.y - step)} + "a" -> %{acc | x: max(0, acc.x - step)} + "s" -> %{acc | y: min(600, acc.y + step)} + "d" -> %{acc | x: min(800, acc.x + step)} + _ -> acc + end + end) + + new_state = Map.put(state, player_id, new_position) + broadcast_state(new_state) + {:noreply, new_state} + end + + @impl true + def handle_call(:get_state, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast({:remove_player, player_id}, state) do + new_state = Map.delete(state, player_id) + broadcast_state(new_state) + {:noreply, new_state} + end + + # Private Functions + + defp broadcast_state(state) do + Phoenix.PubSub.broadcast(@pubsub, @topic, {:game_state_updated, state}) + end +end diff --git a/backend/lib/backend/global_singleton.ex b/backend/lib/backend/global_singleton.ex new file mode 100644 index 0000000..5b82ce8 --- /dev/null +++ b/backend/lib/backend/global_singleton.ex @@ -0,0 +1,54 @@ +defmodule Backend.GlobalSingleton do + @moduledoc """ + Supervisor that ensures a global singleton process runs across the cluster. + If the node running it crashes, another node will take over. + """ + use Supervisor + require Logger + + def start_link(module) do + Supervisor.start_link(__MODULE__, module, name: :"#{module}.GlobalSingleton") + end + + @impl true + def init(module) do + children = [ + %{ + id: :monitor_task, + start: {Task, :start_link, [fn -> monitor_loop(module) end]}, + restart: :permanent + } + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + defp monitor_loop(module) do + case :global.whereis_name(module) do + :undefined -> + Logger.info("#{module} not running, attempting to start on #{node()}") + + case module.start_link([]) do + {:ok, _pid} -> + Logger.info("#{module} started on #{node()}") + + {:error, {:already_started, _pid}} -> + Logger.debug("#{module} already started by another node") + + _ -> + :ok + end + + Process.sleep(100) + monitor_loop(module) + + pid when is_pid(pid) -> + ref = Process.monitor(pid) + receive do + {:DOWN, ^ref, :process, ^pid, _reason} -> + Logger.warning("#{module} went down, attempting takeover") + monitor_loop(module) + end + end + end +end diff --git a/backend/lib/backend/mailer.ex b/backend/lib/backend/mailer.ex new file mode 100644 index 0000000..c67ac2c --- /dev/null +++ b/backend/lib/backend/mailer.ex @@ -0,0 +1,3 @@ +defmodule Backend.Mailer do + use Swoosh.Mailer, otp_app: :backend +end diff --git a/backend/lib/backend_web.ex b/backend/lib/backend_web.ex new file mode 100644 index 0000000..ede48c2 --- /dev/null +++ b/backend/lib/backend_web.ex @@ -0,0 +1,63 @@ +defmodule BackendWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use BackendWeb, :controller + use BackendWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: BackendWeb.Endpoint, + router: BackendWeb.Router, + statics: BackendWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/backend/lib/backend_web/channels/game_channel.ex b/backend/lib/backend_web/channels/game_channel.ex new file mode 100644 index 0000000..b07e6a0 --- /dev/null +++ b/backend/lib/backend_web/channels/game_channel.ex @@ -0,0 +1,60 @@ +defmodule BackendWeb.GameChannel do + @moduledoc """ + Channel for handling game events and player movements. + """ + use BackendWeb, :channel + require Logger + + @impl true + def join("game:lobby", _payload, socket) do + Logger.info("WebSocket connected to #{node()}, waiting for name") + Phoenix.PubSub.subscribe(Backend.PubSub, "game_state") + + current_state = Backend.GameState.get_state() + {:ok, %{players: current_state}, socket} + end + + @impl true + def handle_info({:game_state_updated, state}, socket) do + push(socket, "game_state", %{players: state}) + {:noreply, socket} + end + + @impl true + def handle_in("join_game", %{"name" => name}, socket) do + Logger.info("Player '#{name}' joining game on #{node()}") + + socket = assign(socket, :player_name, name) + Backend.GameState.add_player(name) + + {:reply, :ok, socket} + end + + @impl true + def handle_in("move", %{"directions" => directions}, socket) do + case socket.assigns[:player_name] do + nil -> + Logger.warning("Move attempted without joining game") + {:noreply, socket} + + player_name -> + Logger.debug("Player '#{player_name}' moved #{inspect(directions)} on #{node()}") + Backend.GameState.move_player(player_name, directions) + {:noreply, socket} + end + end + + @impl true + def terminate(_reason, socket) do + case socket.assigns[:player_name] do + nil -> + Logger.info("WebSocket disconnected from #{node()}") + + player_name -> + Logger.info("Player '#{player_name}' disconnected from #{node()}") + Backend.GameState.remove_player(player_name) + end + + :ok + end +end diff --git a/backend/lib/backend_web/channels/user_socket.ex b/backend/lib/backend_web/channels/user_socket.ex new file mode 100644 index 0000000..34c7955 --- /dev/null +++ b/backend/lib/backend_web/channels/user_socket.ex @@ -0,0 +1,36 @@ +defmodule BackendWeb.UserSocket do + use Phoenix.Socket + + # A Socket handler + # + # It's possible to control the websocket connection and + # assign values that can be accessed by your channel topics. + + ## Channels + channel("game:*", BackendWeb.GameChannel) + + # Socket params are passed from the client and can + # be used to verify and authenticate a user. After + # verification, you can put default assigns into + # the socket that will be set for all channels, ie + # + # {:ok, assign(socket, :user_id, verified_user_id)} + # + # To deny connection, return `:error` or `{:error, term}`. + @impl true + def connect(_params, socket, _connect_info) do + {:ok, socket} + end + + # Socket id's are topics that allow you to identify all sockets for a given user: + # + # def id(socket), do: "user_socket:#{socket.assigns.user_id}" + # + # Would allow you to broadcast a "disconnect" event and terminate + # all active sockets and channels for a given user: + # + # + # Returning `nil` makes this socket anonymous. + @impl true + def id(_socket), do: nil +end diff --git a/backend/lib/backend_web/controllers/error_json.ex b/backend/lib/backend_web/controllers/error_json.ex new file mode 100644 index 0000000..1d25294 --- /dev/null +++ b/backend/lib/backend_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule BackendWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/backend/lib/backend_web/controllers/health_controller.ex b/backend/lib/backend_web/controllers/health_controller.ex new file mode 100644 index 0000000..aeea32a --- /dev/null +++ b/backend/lib/backend_web/controllers/health_controller.ex @@ -0,0 +1,15 @@ +defmodule BackendWeb.HealthController do + use BackendWeb, :controller + + # Disable logging for health checks + plug(:disable_logging) + + def check(conn, _params) do + json(conn, %{status: "ok", node: node()}) + end + + defp disable_logging(conn, _opts) do + Logger.metadata(plug_log_level: :none) + conn + end +end diff --git a/backend/lib/backend_web/endpoint.ex b/backend/lib/backend_web/endpoint.ex new file mode 100644 index 0000000..ed4c23e --- /dev/null +++ b/backend/lib/backend_web/endpoint.ex @@ -0,0 +1,61 @@ +defmodule BackendWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :backend + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_backend_key", + signing_salt: "AnIFBp4s", + same_site: "Lax" + ] + + socket("/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + ) + + socket("/socket", BackendWeb.UserSocket, + websocket: true, + longpoll: false + ) + + # Serve at "/" the static files from "priv/static" directory. + # + # When code reloading is disabled (e.g., in production), + # the `gzip` option is enabled to serve compressed + # static files generated by running `phx.digest`. + plug(Plug.Static, + at: "/", + from: :backend, + gzip: not code_reloading?, + only: BackendWeb.static_paths(), + raise_on_missing_only: code_reloading? + ) + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug(Phoenix.CodeReloader) + end + + plug(Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + ) + + plug(Plug.RequestId) + plug(BackendWeb.Plugs.Telemetry, event_prefix: [:phoenix, :endpoint]) + + plug(Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + ) + + plug(Plug.MethodOverride) + plug(Plug.Head) + plug(Plug.Session, @session_options) + plug(BackendWeb.Router) +end diff --git a/backend/lib/backend_web/health_check_filter.ex b/backend/lib/backend_web/health_check_filter.ex new file mode 100644 index 0000000..90b2363 --- /dev/null +++ b/backend/lib/backend_web/health_check_filter.ex @@ -0,0 +1,17 @@ +defmodule BackendWeb.HealthCheckFilter do + @moduledoc """ + Custom Phoenix logger that filters out health check requests. + """ + require Logger + + def phoenix_filter(level, msg, ts, meta) do + # Filter out health check logs + case meta do + %{request_path: "/api/health"} -> + :ignore + + _ -> + Phoenix.Logger.log(level, msg, ts, meta) + end + end +end diff --git a/backend/lib/backend_web/plugs/telemetry.ex b/backend/lib/backend_web/plugs/telemetry.ex new file mode 100644 index 0000000..e050451 --- /dev/null +++ b/backend/lib/backend_web/plugs/telemetry.ex @@ -0,0 +1,14 @@ +defmodule BackendWeb.Plugs.Telemetry do + @behaviour Plug + + @impl true + def init(opts), do: Plug.Telemetry.init(opts) + + @impl true + def call(%{path_info: ["api", "health"]} = conn, {start_event, stop_event, opts}) do + # Suppress logs for health check endpoint + Plug.Telemetry.call(conn, {start_event, stop_event, Keyword.put(opts, :log, false)}) + end + + def call(conn, args), do: Plug.Telemetry.call(conn, args) +end diff --git a/backend/lib/backend_web/router.ex b/backend/lib/backend_web/router.ex new file mode 100644 index 0000000..2b5c196 --- /dev/null +++ b/backend/lib/backend_web/router.ex @@ -0,0 +1,30 @@ +defmodule BackendWeb.Router do + use BackendWeb, :router + + pipeline :api do + plug(:accepts, ["json"]) + end + + scope "/api", BackendWeb do + pipe_through(:api) + + get("/health", HealthController, :check) + end + + # Enable LiveDashboard and Swoosh mailbox preview in development + if Application.compile_env(:backend, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through([:fetch_session, :protect_from_forgery]) + + live_dashboard("/dashboard", metrics: BackendWeb.Telemetry) + forward("/mailbox", Plug.Swoosh.MailboxPreview) + end + end +end diff --git a/backend/lib/backend_web/telemetry.ex b/backend/lib/backend_web/telemetry.ex new file mode 100644 index 0000000..b7e058d --- /dev/null +++ b/backend/lib/backend_web/telemetry.ex @@ -0,0 +1,70 @@ +defmodule BackendWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {BackendWeb, :count_users, []} + ] + end +end diff --git a/backend/mix.exs b/backend/mix.exs new file mode 100644 index 0000000..411619a --- /dev/null +++ b/backend/mix.exs @@ -0,0 +1,72 @@ +defmodule Backend.MixProject do + use Mix.Project + + def project do + [ + app: :backend, + version: "0.1.0", + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + listeners: [Phoenix.CodeReloader] + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Backend.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.8.4"}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:swoosh, "~> 1.16"}, + {:req, "~> 0.5"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:gettext, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.2.0"}, + {:bandit, "~> 1.5"}, + # OpenTelemetry + {:opentelemetry, "~> 1.5"}, + {:opentelemetry_exporter, "~> 1.8"}, + {:opentelemetry_phoenix, "~> 2.0"}, + {:opentelemetry_telemetry, "~> 1.1"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + [ + setup: ["deps.get"], + precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] + ] + end +end diff --git a/backend/mix.lock b/backend/mix.lock new file mode 100644 index 0000000..c7f1e46 --- /dev/null +++ b/backend/mix.lock @@ -0,0 +1,47 @@ +%{ + "acceptor_pool": {:hex, :acceptor_pool, "1.0.1", "d88c2e8a0be9216cf513fbcd3e5a4beb36bee3ff4168e85d6152c6f899359cdb", [:rebar3], [], "hexpm", "f172f3d74513e8edd445c257d596fc84dbdd56d2c6fa287434269648ae5a421e"}, + "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, + "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"}, + "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"}, + "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, + "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, + "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [: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", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, + "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, + "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"}, + "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"}, + "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"}, + "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"}, + "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"}, + "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"}, + "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"}, + "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"}, + "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"}, + "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"}, + "phoenix": {:hex, :phoenix, "1.8.4", "0387f84f00071cba8d71d930b9121b2fb3645197a9206c31b908d2e7902a4851", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c988b1cd3b084eebb13e6676d572597d387fa607dab258526637b4e6c4c08543"}, + "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.24", "1a000a048d5971b61a9efe29a3c4144ca955afd42224998d841c5011a5354838", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0c724e6c65f197841cac49d73be4e0f9b93a7711eaa52d2d4d1b9f859c329267"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "swoosh": {:hex, :swoosh, "1.22.0", "0d65a95f89aedb5011af13295742294e309b4b4aaca556858d81e3b372b58abc", [: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]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {: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.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c01ced23d8786d1ee1a03e4c16574290b2ccd6267beb8c81d081c4a34574ef6e"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "tls_certificate_check": {:hex, :tls_certificate_check, "1.31.0", "9a910b54d8cb96cc810cabf4c0129f21360f82022b20180849f1442a25ccbb04", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "9d2b41b128d5507bd8ad93e1a998e06d0ab2f9a772af343f4c00bf76c6be1532"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +} diff --git a/backend/priv/gettext/en/LC_MESSAGES/errors.po b/backend/priv/gettext/en/LC_MESSAGES/errors.po new file mode 100644 index 0000000..cdec3a1 --- /dev/null +++ b/backend/priv/gettext/en/LC_MESSAGES/errors.po @@ -0,0 +1,11 @@ +## `msgid`s in this file come from POT (.pot) files. +## +## Do not add, change, or remove `msgid`s manually here as +## they're tied to the ones in the corresponding POT file +## (with the same domain). +## +## Use `mix gettext.extract --merge` or `mix gettext.merge` +## to merge POT files into PO files. +msgid "" +msgstr "" +"Language: en\n" diff --git a/backend/priv/gettext/errors.pot b/backend/priv/gettext/errors.pot new file mode 100644 index 0000000..d6f47fa --- /dev/null +++ b/backend/priv/gettext/errors.pot @@ -0,0 +1,10 @@ +## This is a PO Template file. +## +## `msgid`s here are often extracted from source code. +## Add new translations manually only if they're dynamic +## translations that can't be statically extracted. +## +## Run `mix gettext.extract` to bring this file up to +## date. Leave `msgstr`s empty as changing them here has no +## effect: edit them in PO (`.po`) files instead. + diff --git a/backend/priv/static/favicon.ico b/backend/priv/static/favicon.ico new file mode 100644 index 0000000..7f372bf Binary files /dev/null and b/backend/priv/static/favicon.ico differ diff --git a/backend/priv/static/robots.txt b/backend/priv/static/robots.txt new file mode 100644 index 0000000..26e06b5 --- /dev/null +++ b/backend/priv/static/robots.txt @@ -0,0 +1,5 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file +# +# To ban all spiders from the entire site uncomment the next two lines: +# User-agent: * +# Disallow: / diff --git a/backend/test/backend_web/controllers/error_json_test.exs b/backend/test/backend_web/controllers/error_json_test.exs new file mode 100644 index 0000000..675c5c1 --- /dev/null +++ b/backend/test/backend_web/controllers/error_json_test.exs @@ -0,0 +1,12 @@ +defmodule BackendWeb.ErrorJSONTest do + use BackendWeb.ConnCase, async: true + + test "renders 404" do + assert BackendWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} + end + + test "renders 500" do + assert BackendWeb.ErrorJSON.render("500.json", %{}) == + %{errors: %{detail: "Internal Server Error"}} + end +end diff --git a/backend/test/support/conn_case.ex b/backend/test/support/conn_case.ex new file mode 100644 index 0000000..2e4daa8 --- /dev/null +++ b/backend/test/support/conn_case.ex @@ -0,0 +1,37 @@ +defmodule BackendWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use BackendWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint BackendWeb.Endpoint + + use BackendWeb, :verified_routes + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import BackendWeb.ConnCase + end + end + + setup _tags do + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/backend/test/test_helper.exs b/backend/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/backend/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()