diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..ef8840c --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..220aa05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# 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"). +gc_index_relay-*.tar + +# Language servers +.elixir_ls/ +.expert/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c5e13f4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,190 @@ +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 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 + + + +## Ecto Guidelines + +- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email` +- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs` +- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string` +- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed +- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields +- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct +- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied + + + +## Phoenix HTML guidelines + +- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` +- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated +- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` +- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) +- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) + +- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. + + **Never do this (invalid)**: + + <%= if condition do %> + ... + <% else if other_condition %> + ... + <% end %> + + Instead **always** do this: + + <%= cond do %> + <% condition -> %> + ... + <% condition2 -> %> + ... + <% true -> %> + ... + <% end %> + +- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
+
+      
+        let obj = {key: "val"}
+      
+
+  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
+
+- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
+
+      Text
+
+  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
+
+  and **never** do this, since it's invalid (note the missing `[` and `]`):
+
+       ...
+      => Raises compile syntax error on invalid HEEx attr syntax
+
+- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
+- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
+- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
+
+  **Always** do this:
+
+      
+ {@my_assign} + <%= if @some_block_condition do %> + {@another_assign} + <% end %> +
+ + and **Never** do this – the program will terminate with a syntax error: + + <%!-- THIS IS INVALID NEVER EVER DO THIS --%> +
+ {if @invalid_block_construct do} + {end} +
+ + + \ No newline at end of file diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..afce6ea --- /dev/null +++ b/config/config.exs @@ -0,0 +1,44 @@ +# 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 :gc_index_relay, + ecto_repos: [GcIndexRelay.Repo], + generators: [timestamp_type: :utc_datetime] + +# Configure the endpoint +config :gc_index_relay, GcIndexRelayWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: GcIndexRelayWeb.ErrorHTML, json: GcIndexRelayWeb.ErrorJSON], + layout: false + ], + pubsub_server: GcIndexRelay.PubSub, + live_view: [signing_salt: "CiEK2Shl"] + +# 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 :gc_index_relay, GcIndexRelay.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/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..3b616d1 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,90 @@ +import Config + +# Configure your database +config :gc_index_relay, GcIndexRelay.Repo, + port: 5455, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "gc_index_relay_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# 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 :gc_index_relay, GcIndexRelayWeb.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: {127, 0, 0, 1}], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "HVVv9F/JxNDvzGsdBrBVJDjNMfN6SECwb5O5GOIb8nWijxntDNFiUYRTJjh0w+M7", + 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. + +# Reload browser tabs when matching files change. +config :gc_index_relay, GcIndexRelayWeb.Endpoint, + live_reload: [ + web_console_logger: true, + patterns: [ + # Static assets, except user uploads + ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$", + # Gettext translations + ~r"priv/gettext/.*\.po$", + # Router, Controllers, LiveViews and LiveComponents + ~r"lib/gc_index_relay_web/router\.ex$", + ~r"lib/gc_index_relay_web/(controllers|live|components)/.*\.(ex|heex)$" + ] + ] + +# Enable dev routes for dashboard and mailbox +config :gc_index_relay, dev_routes: true + +# 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 + +config :phoenix_live_view, + # Include debug annotations and locations in rendered markup. + # Changing this configuration will require mix clean and a full recompile. + debug_heex_annotations: true, + debug_attributes: true, + # Enable helpful, but potentially expensive runtime checks + enable_expensive_runtime_checks: true + +# Disable swoosh api client as it is only required for production adapters. +config :swoosh, :api_client, false diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..8e61144 --- /dev/null +++ b/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 :gc_index_relay, GcIndexRelayWeb.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/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..730bfe1 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,120 @@ +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/gc_index_relay 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 :gc_index_relay, GcIndexRelayWeb.Endpoint, server: true +end + +config :gc_index_relay, GcIndexRelayWeb.Endpoint, + http: [port: String.to_integer(System.get_env("PORT", "4000"))] + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :gc_index_relay, GcIndexRelay.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + # For machines with several cores, consider starting multiple pools of `pool_size` + # pool_count: 4, + socket_options: maybe_ipv6 + + # 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 :gc_index_relay, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :gc_index_relay, GcIndexRelayWeb.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 :gc_index_relay, GcIndexRelayWeb.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 :gc_index_relay, GcIndexRelayWeb.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 :gc_index_relay, GcIndexRelay.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/config/test.exs b/config/test.exs new file mode 100644 index 0000000..793350c --- /dev/null +++ b/config/test.exs @@ -0,0 +1,41 @@ +import Config + +# 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 :gc_index_relay, GcIndexRelay.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "gc_index_relay_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :gc_index_relay, GcIndexRelayWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "gSUz4Ek3rc6PKcY/imWwjsMbwk8g4+aS5HmD1/MyAmqlbSw+r0V83NjR7H0jnwI6", + server: false + +# In test we don't send emails +config :gc_index_relay, GcIndexRelay.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 + +# Enable helpful, but potentially expensive runtime checks +config :phoenix_live_view, + enable_expensive_runtime_checks: true + +# Sort query params output of verified routes for robust url comparisons +config :phoenix, + sort_verified_routes_query_params: true diff --git a/db/Dockerfile b/db/Dockerfile new file mode 100644 index 0000000..0f277ec --- /dev/null +++ b/db/Dockerfile @@ -0,0 +1,3 @@ +FROM apache/age:release_PG17_1.6.0 + +EXPOSE 5432 diff --git a/lib/gc_index_relay.ex b/lib/gc_index_relay.ex new file mode 100644 index 0000000..97dd6a8 --- /dev/null +++ b/lib/gc_index_relay.ex @@ -0,0 +1,9 @@ +defmodule GcIndexRelay do + @moduledoc """ + GcIndexRelay 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/lib/gc_index_relay/application.ex b/lib/gc_index_relay/application.ex new file mode 100644 index 0000000..45e65f4 --- /dev/null +++ b/lib/gc_index_relay/application.ex @@ -0,0 +1,34 @@ +defmodule GcIndexRelay.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 = [ + GcIndexRelayWeb.Telemetry, + GcIndexRelay.Repo, + {DNSCluster, query: Application.get_env(:gc_index_relay, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: GcIndexRelay.PubSub}, + # Start a worker by calling: GcIndexRelay.Worker.start_link(arg) + # {GcIndexRelay.Worker, arg}, + # Start to serve requests, typically the last entry + GcIndexRelayWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: GcIndexRelay.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 + GcIndexRelayWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/gc_index_relay/mailer.ex b/lib/gc_index_relay/mailer.ex new file mode 100644 index 0000000..e68acca --- /dev/null +++ b/lib/gc_index_relay/mailer.ex @@ -0,0 +1,3 @@ +defmodule GcIndexRelay.Mailer do + use Swoosh.Mailer, otp_app: :gc_index_relay +end diff --git a/lib/gc_index_relay/nostr.ex b/lib/gc_index_relay/nostr.ex new file mode 100644 index 0000000..c3be2b8 --- /dev/null +++ b/lib/gc_index_relay/nostr.ex @@ -0,0 +1,50 @@ +defmodule GcIndexRelay.Nostr do + @moduledoc """ + The Nostr context. + + ## Supported Operations + + The Nostr context module supports create, read, and delete operations on events, as well as a + number of query types to find collections of events. Update operations are not supported, sincafter + Nostr event, once signed, is immutable. + """ + + import Ecto.Query, warn: false + alias GcIndexRelay.Nostr.PubEvent + alias GcIndexRelay.Repo + alias GcIndexRelay.Nostr.Event + + @doc """ + Gets a single Nostr event from the database. + + Returns: `GcIndexRelay.Nostr.PubEvent` + """ + def get_event!(id) when is_binary(id) do + Event + |> Repo.get(id) + |> Repo.preload(:tags) + |> PubEvent.from_db() + end + + @doc """ + Writes a `GcIndexRelay.Nostr.PubEvent` to the database. + """ + def create_event(event) when is_struct(event, PubEvent) do + db_event = PubEvent.to_db(event) + + %Event{} + |> Event.changeset(Map.from_struct(db_event)) + |> Repo.insert() + end + + @doc """ + Deletes a `GcIndexRelay.Nostr.PubEvent` from the database. + """ + def delete_event(event) when is_struct(event, PubEvent) do + db_event = PubEvent.to_db(event) + + %Event{} + |> Event.changeset(Map.from_struct(db_event)) + |> Repo.delete() + end +end diff --git a/lib/gc_index_relay/nostr/event.ex b/lib/gc_index_relay/nostr/event.ex new file mode 100644 index 0000000..5fef294 --- /dev/null +++ b/lib/gc_index_relay/nostr/event.ex @@ -0,0 +1,44 @@ +defmodule GcIndexRelay.Nostr.Event do + @moduledoc """ + The database representation of a Nostr event. + + ## Associations + + Events have a 1..N association with `GcIndexRelay.Nostr.Tag`. Tags are stored on a separate + table, but are always created and deleted in conjunction with their associated event. + + ## Notes + + Nostr's cryptographically-generated event IDs, since they are guaranteed to be unique, serve as + the Event table's primary key. Event IDs and signatures are validated by a separate module, as + the required cryptographic validations are not supported by Ecto. + """ + + use Ecto.Schema + import Ecto.Changeset + + alias GcIndexRelay.Nostr.Tag + + @primary_key {:id, :binary_id, autogenerate: false} + schema "events" do + # 32-bytes lowercase hex-encoded + field :pubkey, :binary + field :created_at, :utc_datetime + field :kind, :integer + field :content, :string + # 64 bytes lowercase hex-encoded + field :sig, :binary + has_many :tags, GcIndexRelay.Nostr.Tag + end + + @doc false + def changeset(event, attrs) do + event + |> cast(attrs, [:id, :pubkey, :created_at, :kind, :content, :sig]) + |> cast_assoc(:tags, with: &Tag.changeset/2) + |> validate_required([:id, :pubkey, :created_at, :kind, :sig]) + |> validate_number(:kind, greater_than_or_equal_to: 0) + |> validate_number(:kind, less_than: 40000) + |> unique_constraint(:id, name: :events_pkey) + end +end diff --git a/lib/gc_index_relay/nostr/pub_event.ex b/lib/gc_index_relay/nostr/pub_event.ex new file mode 100644 index 0000000..c2d82a7 --- /dev/null +++ b/lib/gc_index_relay/nostr/pub_event.ex @@ -0,0 +1,87 @@ +defmodule GcIndexRelay.Nostr.PubEvent do + @moduledoc """ + The application domain model for a Nostr event. + + Refer to `GcIndexRelay.Nostr.Event` for the in-database representation of a Nostr event, and + `GcIndexRelay.Nostr.Tag` for the in-database representation of a Nostr event tag. + """ + + alias GcIndexRelay.Nostr.Event + alias GcIndexRelay.Nostr.Tag + + @derive Jason.Encoder + defstruct [:id, :pubkey, :created_at, :kind, :tags, :content, :sig] + + @doc """ + Converts a `GcIndexRelay.Nostr.PubEvent` to its corresponding `GcIndexRelay.Nostr.Event` and + `GcIndexRelay.Nostr.Tag` representations. + + Returns an Ecto changeset for `GcIndexRelay.Nostr.Event`. + """ + def to_db(%__MODULE__{tags: tags} = pub_event) do + %Event{to_event(pub_event) | tags: to_tags(tags)} + end + + defp to_event(%__MODULE__{ + id: id, + pubkey: pubkey, + created_at: epoch, + kind: kind, + content: content, + sig: signature + }) + when is_binary(id) and is_binary(pubkey) and is_integer(epoch) and is_integer(kind) and + is_binary(signature) do + %Event{ + id: Base.decode16(id, case: :lower), + pubkey: Base.decode16(pubkey, case: :lower), + created_at: DateTime.from_unix!(epoch), + kind: kind, + content: content, + sig: Base.decode16(signature, case: :lower) + } + end + + defp to_tags(tags) when is_list(tags) do + for t <- tags, do: to_tag(t) + end + + defp to_tag(tag) when is_list(tag) do + [name | values] = tag + [value | rest] = values + + %Tag{ + name: name, + value: value, + additional_values: rest + } + end + + @doc """ + Converts the DB representations of `GcIndexRelay.Nostr.Event` and `GcIndexRelay.Nostr.Tag` to the + domain representation `GcIndexRelay.Nostr.PubEvent`. + """ + def from_db(%Event{tags: tags} = event) when is_struct(event, Event) and is_list(tags) do + %{from_event(event) | tags: from_tags(tags)} + end + + defp from_event(%Event{} = event) when is_struct(event, Event) do + %__MODULE__{ + id: Base.encode16(event.id, case: :lower), + pubkey: Base.encode16(event.pubkey, case: :lower), + created_at: DateTime.to_unix(event.created_at), + kind: event.kind, + content: event.content, + sig: Base.encode16(event.sig, case: :lower) + } + end + + defp from_tags(tags) when is_list(tags) do + for t <- tags, do: from_tag(t) + end + + defp from_tag(%Tag{name: name, value: value, additional_values: rest}) + when is_binary(name) and is_binary(value) and is_list(rest) do + [name | [value | rest]] + end +end diff --git a/lib/gc_index_relay/nostr/tag.ex b/lib/gc_index_relay/nostr/tag.ex new file mode 100644 index 0000000..5501c00 --- /dev/null +++ b/lib/gc_index_relay/nostr/tag.ex @@ -0,0 +1,18 @@ +defmodule GcIndexRelay.Nostr.Tag do + use Ecto.Schema + import Ecto.Changeset + + schema "tags" do + field :name, :string + field :value, :string + field :additional_values, {:array, :string} + belongs_to :event, GcIndexRelay.Nostr.Event + end + + @doc false + def changeset(tag, attrs) do + tag + |> cast(attrs, [:name, :value, :additional_values]) + |> validate_required([:name, :value]) + end +end diff --git a/lib/gc_index_relay/repo.ex b/lib/gc_index_relay/repo.ex new file mode 100644 index 0000000..16cbe0b --- /dev/null +++ b/lib/gc_index_relay/repo.ex @@ -0,0 +1,5 @@ +defmodule GcIndexRelay.Repo do + use Ecto.Repo, + otp_app: :gc_index_relay, + adapter: Ecto.Adapters.Postgres +end diff --git a/lib/gc_index_relay_web.ex b/lib/gc_index_relay_web.ex new file mode 100644 index 0000000..9fc27a3 --- /dev/null +++ b/lib/gc_index_relay_web.ex @@ -0,0 +1,114 @@ +defmodule GcIndexRelayWeb 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 GcIndexRelayWeb, :controller + use GcIndexRelayWeb, :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 + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, formats: [:html, :json] + + use Gettext, backend: GcIndexRelayWeb.Gettext + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + # Import convenience functions from controllers + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + # Include general helpers for rendering HTML + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + # Translation + use Gettext, backend: GcIndexRelayWeb.Gettext + + # HTML escaping functionality + import Phoenix.HTML + # Core UI components + import GcIndexRelayWeb.CoreComponents + + # Common modules used in templates + alias Phoenix.LiveView.JS + alias GcIndexRelayWeb.Layouts + + # Routes generation with the ~p sigil + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: GcIndexRelayWeb.Endpoint, + router: GcIndexRelayWeb.Router, + statics: GcIndexRelayWeb.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/lib/gc_index_relay_web/components/core_components.ex b/lib/gc_index_relay_web/components/core_components.ex new file mode 100644 index 0000000..53f72bc --- /dev/null +++ b/lib/gc_index_relay_web/components/core_components.ex @@ -0,0 +1,473 @@ +defmodule GcIndexRelayWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. + + """ + use Phoenix.Component + use Gettext, backend: GcIndexRelayWeb.Gettext + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back! + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" +