Browse Source

Get Phoenix server up and running with Nostr Ecto context

master
buttercat1791 3 months ago
parent
commit
9d85a37b75
  1. 6
      .formatter.exs
  2. 30
      .gitignore
  3. 190
      AGENTS.md
  4. 44
      config/config.exs
  5. 90
      config/dev.exs
  6. 23
      config/prod.exs
  7. 120
      config/runtime.exs
  8. 41
      config/test.exs
  9. 3
      db/Dockerfile
  10. 9
      lib/gc_index_relay.ex
  11. 34
      lib/gc_index_relay/application.ex
  12. 3
      lib/gc_index_relay/mailer.ex
  13. 50
      lib/gc_index_relay/nostr.ex
  14. 44
      lib/gc_index_relay/nostr/event.ex
  15. 87
      lib/gc_index_relay/nostr/pub_event.ex
  16. 18
      lib/gc_index_relay/nostr/tag.ex
  17. 5
      lib/gc_index_relay/repo.ex
  18. 114
      lib/gc_index_relay_web.ex
  19. 473
      lib/gc_index_relay_web/components/core_components.ex
  20. 90
      lib/gc_index_relay_web/components/layouts.ex
  21. 18
      lib/gc_index_relay_web/components/layouts/root.html.heex
  22. 25
      lib/gc_index_relay_web/controllers/changeset_json.ex
  23. 24
      lib/gc_index_relay_web/controllers/error_html.ex
  24. 21
      lib/gc_index_relay_web/controllers/error_json.ex
  25. 30
      lib/gc_index_relay_web/controllers/event_controller.ex
  26. 5
      lib/gc_index_relay_web/controllers/event_html.ex
  27. 6
      lib/gc_index_relay_web/controllers/event_html/new.html.heex
  28. 22
      lib/gc_index_relay_web/controllers/event_json.ex
  29. 24
      lib/gc_index_relay_web/controllers/fallback_controller.ex
  30. 7
      lib/gc_index_relay_web/controllers/page_controller.ex
  31. 10
      lib/gc_index_relay_web/controllers/page_html.ex
  32. 201
      lib/gc_index_relay_web/controllers/page_html/home.html.heex
  33. 55
      lib/gc_index_relay_web/endpoint.ex
  34. 25
      lib/gc_index_relay_web/gettext.ex
  35. 50
      lib/gc_index_relay_web/router.ex
  36. 93
      lib/gc_index_relay_web/telemetry.ex
  37. 78
      mix.exs
  38. 43
      mix.lock
  39. 112
      priv/gettext/en/LC_MESSAGES/errors.po
  40. 109
      priv/gettext/errors.pot
  41. 4
      priv/repo/migrations/.formatter.exs
  42. 31
      priv/repo/migrations/20260129043804_add_nostr_events.exs
  43. 11
      priv/repo/seeds.exs
  44. 1
      priv/static/assets/css/app.css
  45. 2590
      priv/static/assets/default.css
  46. 19
      priv/static/assets/js/app.js
  47. BIN
      priv/static/favicon.ico
  48. 6
      priv/static/images/logo.svg
  49. 5
      priv/static/robots.txt
  50. 19
      test/gc_index_relay/nostr_test.exs
  51. 15
      test/gc_index_relay_web/controllers/error_html_test.exs
  52. 12
      test/gc_index_relay_web/controllers/error_json_test.exs
  53. 100
      test/gc_index_relay_web/controllers/event_controller_test.exs
  54. 8
      test/gc_index_relay_web/controllers/page_controller_test.exs
  55. 38
      test/support/conn_case.ex
  56. 58
      test/support/data_case.ex
  57. 20
      test/support/fixtures/nostr_fixtures.ex
  58. 2
      test/test_helper.exs

6
.formatter.exs

@ -0,0 +1,6 @@ @@ -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"]
]

30
.gitignore vendored

@ -0,0 +1,30 @@ @@ -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/

190
AGENTS.md

@ -0,0 +1,190 @@ @@ -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 `<Layouts.app flash={@flash} ...>` 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 `<Layouts.app>`
- **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
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## 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:elixir-end -->
<!-- phoenix:phoenix-start -->
## 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
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## 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:ecto-end -->
<!-- phoenix:html-start -->
## 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 `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
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**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
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 `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> 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:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this – the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- usage-rules-end -->

44
config/config.exs

@ -0,0 +1,44 @@ @@ -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"

90
config/dev.exs

@ -0,0 +1,90 @@ @@ -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

23
config/prod.exs

@ -0,0 +1,23 @@ @@ -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.

120
config/runtime.exs

@ -0,0 +1,120 @@ @@ -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

41
config/test.exs

@ -0,0 +1,41 @@ @@ -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

3
db/Dockerfile

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
FROM apache/age:release_PG17_1.6.0
EXPOSE 5432

9
lib/gc_index_relay.ex

@ -0,0 +1,9 @@ @@ -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

34
lib/gc_index_relay/application.ex

@ -0,0 +1,34 @@ @@ -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

3
lib/gc_index_relay/mailer.ex

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
defmodule GcIndexRelay.Mailer do
use Swoosh.Mailer, otp_app: :gc_index_relay
end

50
lib/gc_index_relay/nostr.ex

@ -0,0 +1,50 @@ @@ -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

44
lib/gc_index_relay/nostr/event.ex

@ -0,0 +1,44 @@ @@ -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

87
lib/gc_index_relay/nostr/pub_event.ex

@ -0,0 +1,87 @@ @@ -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

18
lib/gc_index_relay/nostr/tag.ex

@ -0,0 +1,18 @@ @@ -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

5
lib/gc_index_relay/repo.ex

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
defmodule GcIndexRelay.Repo do
use Ecto.Repo,
otp_app: :gc_index_relay,
adapter: Ecto.Adapters.Postgres
end

114
lib/gc_index_relay_web.ex

@ -0,0 +1,114 @@ @@ -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

473
lib/gc_index_relay_web/components/core_components.ex

@ -0,0 +1,473 @@ @@ -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!</.flash>
"""
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"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
data-flash
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :any
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as radio, are best
written directly in your templates.
## Examples
```heex
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
```
## Select type
When using `type="select"`, you must pass the `options` and optionally
a `value` to mark which option should be preselected.
```heex
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
```
For more information on what kind of data can be passed to `options` see
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week hidden)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label>
<input
type="hidden"
name={@name}
value="false"
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(GcIndexRelayWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(GcIndexRelayWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

90
lib/gc_index_relay_web/components/layouts.ex

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
defmodule GcIndexRelayWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use GcIndexRelayWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<img src={~p"/images/logo.svg"} width="36" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
</div>
"""
end
end

18
lib/gc_index_relay_web/components/layouts/root.html.heex

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="GcIndexRelay" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/default.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
</head>
<body>
{@inner_content}
</body>
</html>

25
lib/gc_index_relay_web/controllers/changeset_json.ex

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
defmodule GcIndexRelayWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
defp translate_error({msg, opts}) do
# You can make use of gettext to translate error messages by
# uncommenting and adjusting the following code:
# if count = opts[:count] do
# Gettext.dngettext(GcIndexRelayWeb.Gettext, "errors", msg, msg, count, opts)
# else
# Gettext.dgettext(GcIndexRelayWeb.Gettext, "errors", msg, opts)
# end
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

24
lib/gc_index_relay_web/controllers/error_html.ex

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
defmodule GcIndexRelayWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use GcIndexRelayWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/gc_index_relay_web/controllers/error_html/404.html.heex
# * lib/gc_index_relay_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

21
lib/gc_index_relay_web/controllers/error_json.ex

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
defmodule GcIndexRelayWeb.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

30
lib/gc_index_relay_web/controllers/event_controller.ex

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
defmodule GcIndexRelayWeb.EventController do
use GcIndexRelayWeb, :controller
alias GcIndexRelay.Nostr
alias GcIndexRelay.Nostr.Event
action_fallback GcIndexRelayWeb.FallbackController
def create(conn, %{"event" => event_params}) do
with {:ok, %Event{} = event} <- Nostr.create_event(event_params) do
conn
|> put_status(:created)
|> put_resp_header("location", ~p"/api/events/#{event.id}")
|> render(:show, event: event)
end
end
def show(conn, %{"id" => id}) do
event = Nostr.get_event!(id)
render(conn, :show, event: event)
end
def delete(conn, %{"id" => id}) do
event = Nostr.get_event!(id)
with {:ok, %Event{}} <- Nostr.delete_event(event) do
send_resp(conn, :no_content, "")
end
end
end

5
lib/gc_index_relay_web/controllers/event_html.ex

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
defmodule GcIndexRelayWeb.EventHTML do
use GcIndexRelayWeb, :html
embed_templates "event_html/*"
end

6
lib/gc_index_relay_web/controllers/event_html/new.html.heex

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<Layouts.app flash={@flash}>
<section>
<h2>New Event</h2>
<%!-- TODO: Display form for event details --%>
</section>
</Layouts.app>

22
lib/gc_index_relay_web/controllers/event_json.ex

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
defmodule GcIndexRelayWeb.EventJSON do
alias GcIndexRelay.Nostr.Event
@doc """
Renders a single event.
"""
def show(%{event: event}) do
%{data: data(event)}
end
defp data(%Event{} = event) do
%{
id: event.id,
pubkey: event.pubkey,
created_at: event.created_at,
kind: event.kind,
content: event.content,
sig: event.sig,
tags: event.tags
}
end
end

24
lib/gc_index_relay_web/controllers/fallback_controller.ex

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
defmodule GcIndexRelayWeb.FallbackController do
@moduledoc """
Translates controller action results into valid `Plug.Conn` responses.
See `Phoenix.Controller.action_fallback/1` for more details.
"""
use GcIndexRelayWeb, :controller
# This clause handles errors returned by Ecto's insert/update/delete.
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: GcIndexRelayWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
# This clause is an example of how to handle resources that cannot be found.
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(html: GcIndexRelayWeb.ErrorHTML, json: GcIndexRelayWeb.ErrorJSON)
|> render(:"404")
end
end

7
lib/gc_index_relay_web/controllers/page_controller.ex

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
defmodule GcIndexRelayWeb.PageController do
use GcIndexRelayWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end

10
lib/gc_index_relay_web/controllers/page_html.ex

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
defmodule GcIndexRelayWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use GcIndexRelayWeb, :html
embed_templates "page_html/*"
end

201
lib/gc_index_relay_web/controllers/page_html/home.html.heex

@ -0,0 +1,201 @@ @@ -0,0 +1,201 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1>
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 leading-7 text-base-content/70">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

55
lib/gc_index_relay_web/endpoint.ex

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
defmodule GcIndexRelayWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :gc_index_relay
# 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: "_gc_index_relay_key",
signing_salt: "60nW1HLa",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# 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: :gc_index_relay,
gzip: not code_reloading?,
only: GcIndexRelayWeb.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
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :gc_index_relay
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.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 GcIndexRelayWeb.Router
end

25
lib/gc_index_relay_web/gettext.ex

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
defmodule GcIndexRelayWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
that you can use in your application. To use this Gettext backend module,
call `use Gettext` and pass it as an option:
use Gettext, backend: GcIndexRelayWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext.Backend, otp_app: :gc_index_relay
end

50
lib/gc_index_relay_web/router.ex

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
defmodule GcIndexRelayWeb.Router do
use GcIndexRelayWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {GcIndexRelayWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
scope "/", GcIndexRelayWeb do
pipe_through :browser
get "/", PageController, :home
end
scope "/api", GcIndexRelayWeb do
pipe_through :api
resources "/events", EventController, only: [:show, :create, :delete]
end
# Other scopes may use custom stacks.
# scope "/api", GcIndexRelayWeb do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:gc_index_relay, :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 :browser
live_dashboard "/dashboard", metrics: GcIndexRelayWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

93
lib/gc_index_relay_web/telemetry.ex

@ -0,0 +1,93 @@ @@ -0,0 +1,93 @@
defmodule GcIndexRelayWeb.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}
),
# Database Metrics
summary("gc_index_relay.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("gc_index_relay.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("gc_index_relay.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("gc_index_relay.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("gc_index_relay.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# 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.
# {GcIndexRelayWeb, :count_users, []}
]
end
end

78
mix.exs

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
defmodule GcIndexRelay.MixProject do
use Mix.Project
def project do
[
app: :gc_index_relay,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {GcIndexRelay.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.3"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{: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"}
]
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", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

43
mix.lock

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
%{
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [: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", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"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"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"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"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [: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", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"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_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.20", "4f20850ee700b309b21906a0e510af1b916b454b4f810fb8581ada016eb42dfc", [: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", "c16abd605a21f778165cb0079946351ef20ef84eb1ef467a862fb9a173b1d27d"},
"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"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"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"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [: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", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"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"},
"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"},
}

112
priv/gettext/en/LC_MESSAGES/errors.po

@ -0,0 +1,112 @@ @@ -0,0 +1,112 @@
## `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"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

109
priv/gettext/errors.pot

@ -0,0 +1,109 @@ @@ -0,0 +1,109 @@
## 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.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

4
priv/repo/migrations/.formatter.exs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

31
priv/repo/migrations/20260129043804_add_nostr_events.exs

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
defmodule GcIndexRelay.Repo.Migrations.AddNostrEvents do
use Ecto.Migration
def change do
create table(:events, primary_key: false) do
add :id, :binary_id, primary_key: true
add :pubkey, :binary, null: false
add :created_at, :utc_datetime, null: false
add :kind, :integer, null: false
add :content, :text
add :sig, :binary, null: false
end
create index(:events, [:pubkey])
create index(:events, [:kind])
create table(:tags) do
add :name, :string, null: false
add :value, :string, null: false
add :additional_values, {:array, :string}
add :event_id, references(:events, type: :binary_id, on_delete: :delete_all), null: false
end
create index(:tags, [:event_id])
create index(:tags, [:name, :value],
where: "length(name) = 1 AND name ~ '^[a-zA-Z]$'",
name: :single_letter_tags_index
)
end
end

11
priv/repo/seeds.exs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# GcIndexRelay.Repo.insert!(%GcIndexRelay.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

1
priv/static/assets/css/app.css

@ -0,0 +1 @@ @@ -0,0 +1 @@
/* This file is for your main application CSS */

2590
priv/static/assets/default.css

File diff suppressed because it is too large Load Diff

19
priv/static/assets/js/app.js

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
// For Phoenix.HTML support, including form and button helpers
// copy the following scripts into your javascript bundle:
// * deps/phoenix_html/priv/static/phoenix_html.js
// For Phoenix.Channels support, copy the following scripts
// into your javascript bundle:
// * deps/phoenix/priv/static/phoenix.js
// For Phoenix.LiveView support, copy the following scripts
// into your javascript bundle:
// * deps/phoenix_live_view/priv/static/phoenix_live_view.js
// Handle flash close
// (you can safely remove this if you don't use the default flash component)
document.querySelectorAll("[role=alert][data-flash]").forEach((el) => {
el.addEventListener("click", () => {
el.setAttribute("hidden", "");
});
});

BIN
priv/static/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

6
priv/static/images/logo.svg

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

5
priv/static/robots.txt

@ -0,0 +1,5 @@ @@ -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: /

19
test/gc_index_relay/nostr_test.exs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
defmodule GcIndexRelay.NostrTest do
use GcIndexRelay.DataCase
alias GcIndexRelay.Nostr
describe "events" do
alias GcIndexRelay.Nostr.Event
import GcIndexRelay.NostrFixtures
@invalid_attrs %{name: nil}
end
describe "tags" do
alias GcIndexRelay.Nostr.Tag
import GcIndexRelay.NostrFixtures
end
end

15
test/gc_index_relay_web/controllers/error_html_test.exs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
defmodule GcIndexRelayWeb.ErrorHTMLTest do
use GcIndexRelayWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]
test "renders 404.html" do
assert render_to_string(GcIndexRelayWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(GcIndexRelayWeb.ErrorHTML, "500", "html", []) ==
"Internal Server Error"
end
end

12
test/gc_index_relay_web/controllers/error_json_test.exs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
defmodule GcIndexRelayWeb.ErrorJSONTest do
use GcIndexRelayWeb.ConnCase, async: true
test "renders 404" do
assert GcIndexRelayWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert GcIndexRelayWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

100
test/gc_index_relay_web/controllers/event_controller_test.exs

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
defmodule GcIndexRelayWeb.EventControllerTest do
use GcIndexRelayWeb.ConnCase
import GcIndexRelay.NostrFixtures
alias GcIndexRelay.Nostr.Event
@create_attrs %{
sig: "some sig",
kind: 42,
pubkey: "some pubkey",
created_at: ~U[2026-01-28 03:51:00Z],
content: "some content"
}
@update_attrs %{
sig: "some updated sig",
kind: 43,
pubkey: "some updated pubkey",
created_at: ~U[2026-01-29 03:51:00Z],
content: "some updated content"
}
@invalid_attrs %{sig: nil, kind: nil, pubkey: nil, created_at: nil, content: nil}
setup %{conn: conn} do
{:ok, conn: put_req_header(conn, "accept", "application/json")}
end
describe "index" do
test "lists all events", %{conn: conn} do
conn = get(conn, ~p"/api/events")
assert json_response(conn, 200)["data"] == []
end
end
describe "create event" do
test "renders event when data is valid", %{conn: conn} do
conn = post(conn, ~p"/api/events", event: @create_attrs)
assert %{"id" => id} = json_response(conn, 201)["data"]
conn = get(conn, ~p"/api/events/#{id}")
assert %{
"id" => ^id,
"content" => "some content",
"created_at" => "2026-01-28T03:51:00Z",
"kind" => 42,
"pubkey" => "some pubkey",
"sig" => "some sig"
} = json_response(conn, 200)["data"]
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/api/events", event: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "update event" do
setup [:create_event]
test "renders event when data is valid", %{conn: conn, event: %Event{id: id} = event} do
conn = put(conn, ~p"/api/events/#{event}", event: @update_attrs)
assert %{"id" => ^id} = json_response(conn, 200)["data"]
conn = get(conn, ~p"/api/events/#{id}")
assert %{
"id" => ^id,
"content" => "some updated content",
"created_at" => "2026-01-29T03:51:00Z",
"kind" => 43,
"pubkey" => "some updated pubkey",
"sig" => "some updated sig"
} = json_response(conn, 200)["data"]
end
test "renders errors when data is invalid", %{conn: conn, event: event} do
conn = put(conn, ~p"/api/events/#{event}", event: @invalid_attrs)
assert json_response(conn, 422)["errors"] != %{}
end
end
describe "delete event" do
setup [:create_event]
test "deletes chosen event", %{conn: conn, event: event} do
conn = delete(conn, ~p"/api/events/#{event}")
assert response(conn, 204)
assert_error_sent 404, fn ->
get(conn, ~p"/api/events/#{event}")
end
end
end
defp create_event(_) do
event = event_fixture()
%{event: event}
end
end

8
test/gc_index_relay_web/controllers/page_controller_test.exs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
defmodule GcIndexRelayWeb.PageControllerTest do
use GcIndexRelayWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
end
end

38
test/support/conn_case.ex

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
defmodule GcIndexRelayWeb.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 GcIndexRelayWeb.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 GcIndexRelayWeb.Endpoint
use GcIndexRelayWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import GcIndexRelayWeb.ConnCase
end
end
setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

58
test/support/data_case.ex

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
defmodule GcIndexRelay.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
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 GcIndexRelay.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias GcIndexRelay.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import GcIndexRelay.DataCase
end
end
setup tags do
GcIndexRelay.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(GcIndexRelay.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

20
test/support/fixtures/nostr_fixtures.ex

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
defmodule GcIndexRelay.NostrFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `GcIndexRelay.Nostr` context.
"""
@doc """
Generate a event.
"""
def event_fixture(attrs \\ %{}) do
{:ok, event} =
attrs
|> Enum.into(%{
name: "some name"
})
|> GcIndexRelay.Nostr.create_event()
event
end
end

2
test/test_helper.exs

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(GcIndexRelay.Repo, :manual)
Loading…
Cancel
Save