├── .dockerignore ├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── assets ├── css │ └── app.css ├── js │ └── app.js └── tailwind.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── fly.toml ├── lib ├── astro.ex ├── astro │ ├── application.ex │ ├── event_router.ex │ ├── events.ex │ ├── events │ │ ├── event.ex │ │ ├── filter.ex │ │ └── tag.ex │ ├── release.ex │ └── repo.ex ├── astro_web.ex ├── astro_web │ ├── components │ │ ├── layouts.ex │ │ └── layouts │ │ │ └── root.html.heex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ └── home_live.ex │ ├── router.ex │ ├── socket.ex │ └── telemetry.ex └── nostr │ └── socket.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20230201145411_add_events_table.exs │ └── seeds.exs └── static │ ├── favicon.ico │ └── robots.txt ├── rel └── overlays │ └── bin │ ├── migrate │ ├── migrate.bat │ ├── server │ └── server.bat └── test ├── astro ├── event_router_test.exs └── events_test.exs ├── support ├── conn_case.ex └── data_case.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .elixir_ls/.gitignore 2 | .elixir_ls/**/* 3 | 4 | # flyctl launch added from .gitignore 5 | # The directory Mix will write compiled artifacts to. 6 | _build 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | cover 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | deps 13 | 14 | # Where 3rd-party dependencies like ExDoc output generated docs. 15 | doc 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | .fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | **/erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | **/*.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | **/astro-*.tar 28 | 29 | # Ignore assets that are produced by build tools. 30 | priv/static/assets 31 | 32 | # Ignore digested assets cache. 33 | priv/static/cache_manifest.json 34 | 35 | # In case you use Node.js/npm, you want to ignore these. 36 | **/npm-debug.log 37 | assets/node_modules 38 | 39 | 40 | # flyctl launch added from deps/rustler/priv/templates/basic/.gitignore 41 | deps/rustler/priv/templates/basic/target 42 | fly.toml 43 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | env: 4 | MIX_ENV: test 5 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 6 | 7 | on: 8 | push: 9 | branches: [ main ] 10 | pull_request: 11 | 12 | jobs: 13 | build: 14 | 15 | name: Build and test 16 | runs-on: ubuntu-latest 17 | 18 | services: 19 | db: 20 | image: postgres:14 21 | env: 22 | POSTGRES_USER: postgres 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_DB: astro_test 25 | ports: ['5432:5432'] 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - name: Set up Elixir 32 | uses: erlef/setup-elixir@v1 33 | with: 34 | elixir-version: '1.14.3' 35 | otp-version: '24' 36 | 37 | - name: Restore dependencies cache 38 | uses: actions/cache@v2 39 | id: mix-cache 40 | with: 41 | path: deps 42 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 43 | restore-keys: ${{ runner.os }}-mix- 44 | 45 | - name: Restore build cache 46 | uses: actions/cache@v2 47 | id: build-cache 48 | with: 49 | path: _build 50 | key: ${{ runner.os }}-build-${{ hashFiles('**/mix.lock') }} 51 | restore-keys: ${{ runner.os }}-build- 52 | 53 | - name: Install dependencies 54 | run: | 55 | mix local.rebar --force 56 | mix local.hex --force 57 | mix deps.get 58 | 59 | - name: Compile Dependencies 60 | if: steps.build-cache.outputs.cache-hit != 'true' 61 | run: MIX_ENV=test mix compile 62 | 63 | - name: Run tests 64 | run: mix test 65 | 66 | # - name: Code Quality 67 | # run: mix code_quality 68 | 69 | - name: Ensure formatted 70 | run: mix format --check-formatted -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | astro-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | /.elixir_ls/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20221004-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.14.3-erlang-25.2-debian-bullseye-20221004-slim 13 | # 14 | ARG ELIXIR_VERSION=1.14.3 15 | ARG OTP_VERSION=25.2 16 | ARG DEBIAN_VERSION=bullseye-20221004-slim 17 | 18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 20 | 21 | FROM ${BUILDER_IMAGE} as builder 22 | 23 | # install build dependencies 24 | RUN apt-get update -y && apt-get install -y build-essential git \ 25 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 26 | 27 | # prepare build dir 28 | WORKDIR /app 29 | 30 | # install hex + rebar 31 | RUN mix local.hex --force && \ 32 | mix local.rebar --force 33 | 34 | # set build ENV 35 | ENV MIX_ENV="prod" 36 | 37 | # install mix dependencies 38 | COPY mix.exs mix.lock ./ 39 | RUN mix deps.get --only $MIX_ENV 40 | RUN mkdir config 41 | 42 | # copy compile-time config files before we compile dependencies 43 | # to ensure any relevant config change will trigger the dependencies 44 | # to be re-compiled. 45 | COPY config/config.exs config/${MIX_ENV}.exs config/ 46 | RUN mix deps.compile 47 | 48 | COPY priv priv 49 | 50 | COPY lib lib 51 | 52 | COPY assets assets 53 | 54 | # compile assets 55 | RUN mix assets.deploy 56 | 57 | # Compile the release 58 | RUN mix compile 59 | 60 | # Changes to config/runtime.exs don't require recompiling the code 61 | COPY config/runtime.exs config/ 62 | 63 | COPY rel rel 64 | RUN mix release 65 | 66 | # start a new build stage so that the final image will only contain 67 | # the compiled release and other runtime necessities 68 | FROM ${RUNNER_IMAGE} 69 | 70 | RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \ 71 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 72 | 73 | # Set the locale 74 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 75 | 76 | ENV LANG en_US.UTF-8 77 | ENV LANGUAGE en_US:en 78 | ENV LC_ALL en_US.UTF-8 79 | 80 | WORKDIR "/app" 81 | RUN chown nobody /app 82 | 83 | # set runner ENV 84 | ENV MIX_ENV="prod" 85 | 86 | # Only copy the final release from the build stage 87 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/astro ./ 88 | 89 | USER nobody 90 | 91 | CMD ["/app/bin/server"] 92 | 93 | # Appended by flyctl 94 | ENV ECTO_IPV6 true 95 | ENV ERL_AFLAGS "-proto_dist inet6_tcp" 96 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023-present Nostrology 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro 2 | An [Nostr](https://github.com/nostr-protocol/nostr) relay, built using Elixir. 3 | 4 | ### Implementation 5 | - [x] NIP-01: Basic protocol flow description 6 | - [ ] NIP-02: Contact List and Petnames 7 | - [ ] NIP-03: OpenTimestamps Attestations for Events 8 | - [ ] NIP-05: Mapping Nostr keys to DNS-based internet identifiers 9 | - [ ] NIP-09: Event Deletion 10 | - [x] NIP-11: Relay Information Document 11 | - [ ] NIP-12: Generic Tag Queries 12 | - [x] NIP-15: End of Stored Events Notice 13 | - [x] NIP-16: Event Treatment 14 | - [x] NIP-20: Command Results 15 | - [ ] NIP-22: Event created_at limits (future-dated events only) 16 | - [ ] NIP-26: Event Delegation (implemented, but currently disabled) 17 | - [ ] NIP-28: Public Chat 18 | - [ ] NIP-33: Parameterized Replaceable Events 19 | 20 | ## Development 21 | You can setup your own development / production environment of Astro easily by grabbing your dependencies, creating your database, and running the server. 22 | 23 | * Install dependencies with `mix deps.get` 24 | * Create and migrate your database with `mix ecto.setup` 25 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` 26 | 27 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. 28 | 29 | Ready to run in production? Please [check out the deployment guides](https://hexdocs.pm/phoenix/deployment.html). 30 | 31 | 32 | ### Contributing 33 | 1. [Fork it!](http://github.com/Nostrology/astro/fork) 34 | 2. Create your feature branch (`git checkout -b feature/my-new-feature`) 35 | 3. Commit your changes (`git commit -am 'Add some feature'`) 36 | 4. Push to the branch (`git push origin feature/my-new-feature`) 37 | 5. Create new Pull Request 38 | 39 | 40 | ## Testing 41 | Astro includes a comprehensive and very fast test suite, so you should be encouraged to run tests as frequently as possible. 42 | 43 | ```sh 44 | mix test 45 | ``` 46 | 47 | ## Help 48 | If you need help with anything, please feel free to open [a GitHub Issue](https://github.com/Nostrology/astro/issues/new). 49 | 50 | ## License 51 | WebSubHub is licensed under the [MIT License](LICENSE.md). 52 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | 6 | module.exports = { 7 | content: [ 8 | "./js/**/*.js", 9 | "../lib/*_web.ex", 10 | "../lib/*_web/**/*.*ex" 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | brand: "#FD4F00", 16 | } 17 | }, 18 | }, 19 | plugins: [ 20 | require("@tailwindcss/forms"), 21 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 22 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 23 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 24 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :astro, 11 | ecto_repos: [Astro.Repo], 12 | generators: [binary_id: true] 13 | 14 | config :astro, Astro.Repo, migration_primary_key: false 15 | 16 | config :mnesia, 17 | dir: '.mnesia/#{Mix.env()}/#{node()}' 18 | 19 | # Configures the endpoint 20 | config :astro, AstroWeb.Endpoint, 21 | url: [host: "localhost"], 22 | render_errors: [ 23 | formats: [json: AstroWeb.ErrorJSON], 24 | layout: false 25 | ], 26 | pubsub_server: Astro.PubSub, 27 | live_view: [signing_salt: "PmeQPlK3"] 28 | 29 | # Configure esbuild (the version is required) 30 | config :esbuild, 31 | version: "0.14.41", 32 | default: [ 33 | args: 34 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 35 | cd: Path.expand("../assets", __DIR__), 36 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 37 | ] 38 | 39 | # Configure tailwind (the version is required) 40 | config :tailwind, 41 | version: "3.2.4", 42 | default: [ 43 | args: ~w( 44 | --config=tailwind.config.js 45 | --input=css/app.css 46 | --output=../priv/static/assets/app.css 47 | ), 48 | cd: Path.expand("../assets", __DIR__) 49 | ] 50 | 51 | # Configures Elixir's Logger 52 | config :logger, :console, 53 | format: "$time $metadata[$level] $message\n", 54 | metadata: [:request_id] 55 | 56 | # Use Jason for JSON parsing in Phoenix 57 | config :phoenix, :json_library, Jason 58 | 59 | # Import environment specific config. This must remain at the bottom 60 | # of this file so it overrides the configuration defined above. 61 | import_config "#{config_env()}.exs" 62 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :astro, Astro.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "astro_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we use it 18 | # with esbuild to bundle .js and .css sources. 19 | config :astro, AstroWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {0, 0, 0, 0}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "Gu7/ZXdjU4Mf5xHd687Ig7AG5uavnVC+u3OlGcQSiOU/VPu+h9CDrNjaBjmLbJ/0", 27 | watchers: [ 28 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 29 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 30 | ] 31 | 32 | # ## SSL Support 33 | # 34 | # In order to use HTTPS in development, a self-signed 35 | # certificate can be generated by running the following 36 | # Mix task: 37 | # 38 | # mix phx.gen.cert 39 | # 40 | # Run `mix help phx.gen.cert` for more information. 41 | # 42 | # The `http:` config above can be replaced with: 43 | # 44 | # https: [ 45 | # port: 4001, 46 | # cipher_suite: :strong, 47 | # keyfile: "priv/cert/selfsigned_key.pem", 48 | # certfile: "priv/cert/selfsigned.pem" 49 | # ], 50 | # 51 | # If desired, both `http:` and `https:` keys can be 52 | # configured to run both http and https servers on 53 | # different ports. 54 | 55 | # Enable dev routes for dashboard and mailbox 56 | config :astro, dev_routes: true 57 | 58 | # Do not include metadata nor timestamps in development logs 59 | config :logger, :console, format: "[$level] $message\n" 60 | 61 | # Set a higher stacktrace during development. Avoid configuring such 62 | # in production as building large stacktraces may be expensive. 63 | config :phoenix, :stacktrace_depth, 20 64 | 65 | # Initialize plugs at runtime for faster development compilation 66 | config :phoenix, :plug_init_mode, :runtime 67 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :astro, AstroWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 13 | 14 | # Do not print debug messages in production 15 | config :logger, level: :info 16 | 17 | # Runtime production configuration, including reading 18 | # of environment variables, is done on config/runtime.exs. 19 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/astro start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :astro, AstroWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 32 | 33 | config :astro, Astro.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :astro, AstroWeb.Endpoint, 55 | url: [host: host, port: 443, scheme: "https"], 56 | http: [ 57 | # Enable IPv6 and bind on all interfaces. 58 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 59 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 60 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 61 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 62 | port: port 63 | ], 64 | secret_key_base: secret_key_base 65 | 66 | # ## SSL Support 67 | # 68 | # To get SSL working, you will need to add the `https` key 69 | # to your endpoint configuration: 70 | # 71 | # config :astro, AstroWeb.Endpoint, 72 | # https: [ 73 | # ..., 74 | # port: 443, 75 | # cipher_suite: :strong, 76 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 77 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 78 | # ] 79 | # 80 | # The `cipher_suite` is set to `:strong` to support only the 81 | # latest and more secure SSL ciphers. This means old browsers 82 | # and clients may not be supported. You can set it to 83 | # `:compatible` for wider support. 84 | # 85 | # `:keyfile` and `:certfile` expect an absolute path to the key 86 | # and cert in disk or a relative path inside priv, for example 87 | # "priv/ssl/server.key". For all supported SSL configuration 88 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 89 | # 90 | # We also recommend setting `force_ssl` in your endpoint, ensuring 91 | # no data is ever sent via http, always redirecting to https: 92 | # 93 | # config :astro, AstroWeb.Endpoint, 94 | # force_ssl: [hsts: true] 95 | # 96 | # Check `Plug.SSL` for all available options in `force_ssl`. 97 | end 98 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :astro, Astro.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "astro_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :astro, AstroWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "wnnlyY4TKPeFp8sqzLbKwR1dXuGaE2QHlVaZFJzVK9wgPgCOUARhM0KbHtOrF904", 21 | server: false 22 | 23 | # Print only warnings and errors during test 24 | config :logger, level: :warning 25 | 26 | # Initialize plugs at runtime for faster test compilation 27 | config :phoenix, :plug_init_mode, :runtime 28 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for astro-nostrology on 2023-02-05T14:44:38-05:00 2 | 3 | app = "astro-nostrology" 4 | kill_signal = "SIGTERM" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [deploy] 9 | release_command = "/app/bin/migrate" 10 | 11 | [env] 12 | PHX_HOST = "relay.nostrology.org" 13 | PORT = "8080" 14 | 15 | [experimental] 16 | auto_rollback = true 17 | 18 | [[services]] 19 | http_checks = [] 20 | internal_port = 8080 21 | processes = ["app"] 22 | protocol = "tcp" 23 | script_checks = [] 24 | [services.concurrency] 25 | hard_limit = 25 26 | soft_limit = 20 27 | type = "connections" 28 | 29 | [[services.ports]] 30 | force_https = true 31 | handlers = ["http"] 32 | port = 80 33 | 34 | [[services.ports]] 35 | handlers = ["tls", "http"] 36 | port = 443 37 | 38 | [[services.tcp_checks]] 39 | grace_period = "1s" 40 | interval = "15s" 41 | restart_limit = 0 42 | timeout = "2s" 43 | -------------------------------------------------------------------------------- /lib/astro.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro do 2 | @moduledoc """ 3 | Astro keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/astro/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | AstroWeb.Telemetry, 13 | # Start the Ecto repository 14 | Astro.Repo, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: Astro.PubSub}, 17 | # Start the Endpoint (http/https) 18 | AstroWeb.Endpoint, 19 | Astro.EventRouter 20 | # Start a worker by calling: Astro.Worker.start_link(arg) 21 | # {Astro.Worker, arg} 22 | ] 23 | 24 | :ets.new(:session_storage, [:named_table, :public, read_concurrency: true]) 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: Astro.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | AstroWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/astro/event_router.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.EventRouter do 2 | @doc """ 3 | EventRouter is responsible for handling _all_ events and seeing if they match against filter subscriptions registered on the server. 4 | 5 | Each server is responsible for their own event handling / filtering / re-dispatching. 6 | 7 | Phoenix.PubSub is used to route events to all servers. 8 | """ 9 | use GenServer 10 | 11 | require Logger 12 | 13 | alias Astro.Events.Event 14 | alias Phoenix.PubSub 15 | 16 | def start_link(_opts) do 17 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 18 | end 19 | 20 | def push_subscription(pid, subscription_id, list_of_filters) do 21 | Logger.info("Astro.EventRouter: Got push_subscription for #{subscription_id}") 22 | GenServer.cast(__MODULE__, {:push_subscription, pid, subscription_id, list_of_filters}) 23 | end 24 | 25 | def close_subscription(subscription_id) do 26 | Logger.info("Astro.EventRouter: Got close_subscription for #{subscription_id}") 27 | GenServer.cast(__MODULE__, {:close_subscription, subscription_id}) 28 | end 29 | 30 | def push_event(%Event{} = event) do 31 | PubSub.broadcast(Astro.PubSub, "events", {:new_event, event}) 32 | end 33 | 34 | @impl true 35 | def init(_) do 36 | Logger.info("Running Astro.EventRouter") 37 | PubSub.subscribe(Astro.PubSub, "events") 38 | 39 | {:ok, %{filters: %{}}} 40 | end 41 | 42 | @impl true 43 | def handle_cast({:push_subscription, pid, subscription_id, list_of_filters}, state) do 44 | filters = Map.put(state.filters, subscription_id, {pid, list_of_filters}) 45 | {:noreply, %{state | filters: filters}} 46 | end 47 | 48 | def handle_cast({:close_subscription, subscription_id}, state) do 49 | filters = Map.delete(state.filters, subscription_id) 50 | {:noreply, %{state | filters: filters}} 51 | end 52 | 53 | @impl true 54 | def handle_info({:new_event, event}, state) do 55 | {time, subscribers} = 56 | :timer.tc(fn -> 57 | Enum.map(state.filters, fn {subscription_id, {pid, list_of_filters}} -> 58 | Enum.each(list_of_filters, fn filters -> 59 | if Astro.Events.matches_filters(event, filters) do 60 | Logger.info( 61 | "Astro.EventRouter: Sending event to #{subscription_id} on #{inspect(pid)}" 62 | ) 63 | 64 | send(pid, {:new_event, event, subscription_id}) 65 | end 66 | end) 67 | end) 68 | |> length() 69 | end) 70 | 71 | :telemetry.execute( 72 | [:astro, :event_router, :event_filter_match], 73 | %{duration: time}, 74 | %{ 75 | subscribers: subscribers 76 | } 77 | ) 78 | 79 | {:noreply, state} 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/astro/events.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Events do 2 | import Ecto.Query 3 | 4 | alias Astro.Repo 5 | alias Astro.Events.Event 6 | alias Astro.Events.Tag 7 | alias Astro.Events.Filter 8 | 9 | def create_event(%{"tags" => input_tags} = event_map) do 10 | case Astro.Events.Event.changeset(%Astro.Events.Event{}, event_map) do 11 | %Ecto.Changeset{valid?: true} = changeset -> 12 | tags = transmute_tags(input_tags) 13 | changeset = Ecto.Changeset.put_assoc(changeset, :event_tags, tags) 14 | 15 | case process_event(changeset) do 16 | {:ok, event} -> 17 | Astro.EventRouter.push_event(event) 18 | {:ok, event} 19 | 20 | {:error, changeset} -> 21 | {:error, changeset_error_to_string(changeset)} 22 | end 23 | 24 | errored -> 25 | {:error, changeset_error_to_string(errored)} 26 | end 27 | end 28 | 29 | defp changeset_error_to_string(changeset) do 30 | Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> 31 | Enum.reduce(opts, msg, fn {key, value}, acc -> 32 | String.replace(acc, "%{#{key}}", to_string(value)) 33 | end) 34 | end) 35 | |> Enum.reduce("", fn {k, v}, acc -> 36 | joined_errors = Enum.join(v, "; ") 37 | "#{acc}#{k}: #{joined_errors}." 38 | end) 39 | end 40 | 41 | defp process_event(changeset) do 42 | %{kind: kind} = new_event = changeset.changes 43 | 44 | cond do 45 | kind >= 10000 and kind < 20000 -> 46 | # Replaceable Event 47 | # A replaceable event is defined as an event with a kind 10000 <= n < 20000. Upon a replaceable event 48 | # with a newer timestamp than the currently known latest replaceable event with the same kind being 49 | # received, and signed by the same key, the old event SHOULD be discarded and replaced with the newer event. 50 | 51 | # Ignore deletion error as we may not have the event yet 52 | from(e in Event, 53 | where: 54 | e.pubkey == ^new_event.pubkey and e.kind == ^new_event.kind and 55 | e.created_at < ^new_event.created_at 56 | ) 57 | |> Repo.delete() 58 | 59 | Astro.Repo.insert(changeset) 60 | 61 | kind >= 20000 and kind < 30000 -> 62 | # Ephemeral Event 63 | # An ephemeral event is defined as an event with a kind 20000 <= n < 30000. Upon an ephemeral event being 64 | # received, the relay SHOULD send it to all clients with a matching filter, and MUST NOT store it. 65 | 66 | {:ok, Ecto.Changeset.apply_changes(changeset.changes)} 67 | 68 | true -> 69 | # kind >= 1000 and kind < 10000 70 | # Regular Event 71 | # A regular event is defined as an event with a kind 1000 <= n < 10000. Upon a regular event being received, 72 | # the relay SHOULD send it to all clients with a matching filter, and SHOULD store it. New events of the 73 | # same kind do not affect previous events in any way. 74 | 75 | Astro.Repo.insert(changeset) 76 | end 77 | end 78 | 79 | def transmute_tags(tags) do 80 | Enum.map(tags, fn 81 | [key, value | params] -> 82 | params = %{ 83 | key: key, 84 | value: value, 85 | params: params 86 | } 87 | 88 | Astro.Events.Tag.changeset(%Astro.Events.Tag{}, params) 89 | 90 | params 91 | end) 92 | end 93 | 94 | def list_events_with_filters(unsafe_filters) do 95 | case Filter.changeset(%Filter{}, unsafe_filters) do 96 | %Ecto.Changeset{valid?: true, changes: safe_filters} -> 97 | limit = Map.get(safe_filters, :limit, 100) 98 | safe_filters = Map.delete(safe_filters, :limit) 99 | 100 | from(e in Event, 101 | join: t in Tag, 102 | as: :tags, 103 | on: e.id == t.event_id, 104 | where: ^build_and(safe_filters), 105 | order_by: [desc: e.created_at], 106 | limit: ^limit, 107 | preload: [:event_tags] 108 | ) 109 | |> Repo.all() 110 | 111 | _ -> 112 | # TODO Some kind of warning? 113 | [] 114 | end 115 | end 116 | 117 | def matches_filters(%Event{} = event, unsafe_filters) do 118 | case Filter.changeset(%Filter{}, unsafe_filters) do 119 | %Ecto.Changeset{valid?: true, changes: safe_filters} -> 120 | Enum.map(safe_filters, fn 121 | {:ids, values} -> 122 | Enum.map(values, fn value -> 123 | String.starts_with?(event.id, value) 124 | end) 125 | |> Enum.any?() 126 | 127 | {:authors, values} -> 128 | Enum.map(values, fn value -> 129 | String.starts_with?(event.pubkey, value) 130 | end) 131 | |> Enum.any?() 132 | 133 | {:kinds, kinds} -> 134 | event.kind in kinds 135 | 136 | {:since, since} -> 137 | event.created_at < since 138 | 139 | {:until, until} -> 140 | event.created_at > until 141 | 142 | _ -> 143 | false 144 | end) 145 | |> Enum.any?() 146 | 147 | _ -> 148 | # TODO Some kind of warning? 149 | :error 150 | end 151 | end 152 | 153 | @types %{ 154 | :ids => :id, 155 | :authors => :pubkey, 156 | :"#e" => [:tags, :"#e"], 157 | :"#p" => [:tags, :"#p"] 158 | } 159 | 160 | @tags %{ 161 | :"#e" => "e", 162 | :"#p" => "p" 163 | } 164 | 165 | def build_and(filters) do 166 | Enum.reduce(filters, nil, fn 167 | {k, v}, nil -> build_condition(k, v) 168 | {k, v}, conditions -> dynamic([e], ^build_condition(k, v) and ^conditions) 169 | end) 170 | end 171 | 172 | defp build_condition(:kinds, values) do 173 | dynamic([e], e.kind in ^values) 174 | end 175 | 176 | defp build_condition(:since, value) do 177 | dynamic([e], e.created_at < ^value) 178 | end 179 | 180 | defp build_condition(:until, value) do 181 | dynamic([e], e.created_at > ^value) 182 | end 183 | 184 | # Special condition for nested lists 185 | defp build_condition(key, values) when is_list(values) do 186 | Enum.reduce(values, nil, fn 187 | v, nil -> build_condition(key, v) 188 | v, conditions -> dynamic([e], ^build_condition(key, v) or ^conditions) 189 | end) 190 | end 191 | 192 | # Special condition for prefixable search values 193 | defp build_condition(key, value) when key in [:ids, :authors] do 194 | field = @types[key] 195 | 196 | if byte_size(value) < 64 do 197 | value = value <> "%" 198 | dynamic([e], ilike(field(e, ^field), ^value)) 199 | else 200 | dynamic([e], field(e, ^field) == ^value) 201 | end 202 | end 203 | 204 | # Special condition for tags 205 | defp build_condition(key, value) when key in [:"#e", :"#p"] do 206 | tag_field = @tags[key] 207 | 208 | if byte_size(value) < 64 do 209 | value = value <> "%" 210 | dynamic([tags: t], t.key == ^tag_field and ilike(t.value, ^value)) 211 | else 212 | dynamic([tags: t], t.key == ^tag_field and t.value == ^value) 213 | end 214 | end 215 | 216 | defp build_condition(key, value) do 217 | field = @types[key] 218 | dynamic([e], field(e, ^field) == ^value) 219 | end 220 | 221 | def json(%Event{} = event) do 222 | tags = Enum.map(event.event_tags, fn tag -> [tag.key, tag.value] ++ tag.params end) 223 | 224 | json(%{ 225 | pubkey: event.pubkey, 226 | created_at: event.created_at, 227 | kind: event.kind, 228 | tags: tags, 229 | content: event.content 230 | }) 231 | end 232 | 233 | def json(%{pubkey: pubkey, created_at: created_at, kind: kind, tags: tags, content: content}) do 234 | [0, pubkey, created_at, kind, tags, content] 235 | |> Jason.encode!() 236 | end 237 | 238 | def generate_id(event) do 239 | payload = json(event) 240 | 241 | :crypto.hash(:sha256, payload) 242 | |> Base.encode16(case: :lower) 243 | end 244 | 245 | def verify_signature(%Event{id: id, pubkey: pubkey, sig: sig}) do 246 | verify_signature(%{id: id, pubkey: pubkey, sig: sig}) 247 | end 248 | 249 | def verify_signature(%{id: id, pubkey: pubkey, sig: sig}) do 250 | id = Base.decode16!(id, case: :lower) 251 | pubkey = Base.decode16!(pubkey, case: :lower) 252 | sig = Base.decode16!(sig, case: :lower) 253 | 254 | # Curvy is native Elixir, but wasn't able to get it working appropriately. 255 | # Curvy.verify(sig, id, pubkey, hash: false) 256 | :ok == K256.Schnorr.verify_message_digest(id, sig, pubkey) 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /lib/astro/events/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Events.Event do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key false 6 | 7 | schema "events" do 8 | field :id, :string 9 | field :pubkey, :string 10 | field :created_at, :integer 11 | field :kind, :integer 12 | field :tags, {:array, {:array, :string}}, virtual: true 13 | field :content, :string 14 | field :sig, :string 15 | 16 | has_many :event_tags, Astro.Events.Tag, references: :id, foreign_key: :event_id 17 | end 18 | 19 | def changeset(event, params \\ %{}) do 20 | event 21 | |> cast(params, [ 22 | :id, 23 | :pubkey, 24 | :created_at, 25 | :kind, 26 | :content, 27 | :sig 28 | ]) 29 | # Tags must be cast to allow for empty values, eg ["e", "id", "", "root"] 30 | |> cast(params, [:tags], empty_values: []) 31 | |> validate_required([ 32 | :id, 33 | :pubkey, 34 | :created_at, 35 | :kind, 36 | :tags, 37 | :content, 38 | :sig 39 | ]) 40 | |> validate_length(:id, is: 64) 41 | |> validate_length(:pubkey, is: 64) 42 | |> validate_number(:created_at, greater_than: 0) 43 | |> validate_length(:sig, is: 128) 44 | |> validate_id() 45 | |> validate_signature() 46 | |> unique_constraint(:id, name: :events_pkey) 47 | end 48 | 49 | @doc """ 50 | Validates an ID by generating one from the event JSON 51 | """ 52 | def validate_id(changeset) do 53 | validate_change(changeset, :id, fn field, value -> 54 | generated_id = Astro.Events.generate_id(changeset.changes) 55 | 56 | if generated_id == value do 57 | [] 58 | else 59 | [{field, "invalid id"}] 60 | end 61 | end) 62 | end 63 | 64 | @doc """ 65 | Validates that the event is properly signed 66 | """ 67 | def validate_signature(changeset) do 68 | # TODO! 69 | validate_change(changeset, :sig, fn field, _sig -> 70 | case Astro.Events.verify_signature(changeset.changes) do 71 | true -> 72 | [] 73 | 74 | _ -> 75 | [{field, "invalid signature"}] 76 | end 77 | end) 78 | end 79 | end 80 | 81 | defimpl Jason.Encoder, for: Astro.Events.Event do 82 | @doc """ 83 | Special JSON encoding for the Event struct is required to pluck out the event_tags into tags 84 | and format them appropriately. 85 | """ 86 | def encode(%Astro.Events.Event{} = event, opts) do 87 | tags = Enum.map(event.event_tags, fn tag -> [tag.key, tag.value] ++ tag.params end) 88 | 89 | Jason.Encode.map( 90 | %{ 91 | id: event.id, 92 | pubkey: event.pubkey, 93 | created_at: event.created_at, 94 | kind: event.kind, 95 | tags: tags, 96 | content: event.content, 97 | sig: event.sig 98 | }, 99 | opts 100 | ) 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/astro/events/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Events.Filter do 2 | @moduledoc """ 3 | 4 | is a JSON object that determines what events will be sent in that subscription, it can have the following attributes: 5 | 6 | { 7 | "ids": , 8 | "authors": , 9 | "kinds": , 10 | "#e": , 11 | "#p": , 12 | "since": , 13 | "until": , 14 | "limit": 15 | } 16 | """ 17 | 18 | use Ecto.Schema 19 | import Ecto.Changeset 20 | 21 | embedded_schema do 22 | field :name, :string 23 | field :ids, {:array, :string} 24 | field :authors, {:array, :string} 25 | field :kinds, {:array, :integer} 26 | field :"#e", {:array, :string} 27 | field :"#p", {:array, :string} 28 | field :since, :integer 29 | field :until, :integer 30 | field :limit, :integer 31 | end 32 | 33 | def changeset(filter, params \\ %{}) do 34 | filter 35 | |> cast(params, [ 36 | :name, 37 | :ids, 38 | :authors, 39 | :kinds, 40 | :"#e", 41 | :"#p", 42 | :since, 43 | :until, 44 | :limit 45 | ]) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/astro/events/tag.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Events.Tag do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @primary_key false 6 | 7 | @derive Jason.Encoder 8 | schema "event_tags" do 9 | field :event_id, :string 10 | field :key, :string 11 | field :value, :string 12 | field :params, {:array, :string} 13 | end 14 | 15 | def changeset(event, params \\ %{}) do 16 | event 17 | |> cast(params, [ 18 | # :event_id, 19 | :key, 20 | :value, 21 | :params 22 | ]) 23 | |> validate_required([ 24 | # :event_id, 25 | :key, 26 | :value 27 | ]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/astro/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :astro 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/astro/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.Repo do 2 | use Ecto.Repo, 3 | otp_app: :astro, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/astro_web.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use AstroWeb, :controller 9 | use AstroWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: AstroWeb.Layouts] 44 | 45 | import Plug.Conn 46 | import AstroWeb.Gettext 47 | 48 | unquote(verified_routes()) 49 | end 50 | end 51 | 52 | def live_view do 53 | quote do 54 | use Phoenix.LiveView 55 | # layout: {AuthProjWeb.Layouts, :app} 56 | 57 | unquote(html_helpers()) 58 | end 59 | end 60 | 61 | def html do 62 | quote do 63 | use Phoenix.Component 64 | 65 | # Import convenience functions from controllers 66 | import Phoenix.Controller, 67 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 68 | 69 | # Include general helpers for rendering HTML 70 | unquote(html_helpers()) 71 | end 72 | end 73 | 74 | defp html_helpers do 75 | quote do 76 | # HTML escaping functionality 77 | import Phoenix.HTML 78 | # Core UI components and translation 79 | # import AstroWeb.CoreComponents 80 | import AstroWeb.Gettext 81 | 82 | # Shortcut for generating JS commands 83 | alias Phoenix.LiveView.JS 84 | 85 | # Routes generation with the ~p sigil 86 | unquote(verified_routes()) 87 | end 88 | end 89 | 90 | def verified_routes do 91 | quote do 92 | use Phoenix.VerifiedRoutes, 93 | endpoint: AstroWeb.Endpoint, 94 | router: AstroWeb.Router, 95 | statics: AstroWeb.static_paths() 96 | end 97 | end 98 | 99 | @doc """ 100 | When used, dispatch to the appropriate controller/view/etc. 101 | """ 102 | defmacro __using__(which) when is_atom(which) do 103 | apply(__MODULE__, which, []) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/astro_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Layouts do 2 | use AstroWeb, :html 3 | 4 | embed_templates "layouts/*" 5 | end 6 | -------------------------------------------------------------------------------- /lib/astro_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title> 8 | <%= assigns[:page_title] || "Astro" %> 9 | 10 | 11 | 13 | 14 | 15 | <%= @inner_content %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib/astro_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :astro 3 | 4 | @doc """ 5 | Funky function to manually handle websocket connections so we can intercept the upgrade request to send NIP-11 6 | """ 7 | def socket_dispatch(%{request_path: "/"} = conn, _opts) do 8 | case {Plug.Conn.get_req_header(conn, "accept"), Plug.Conn.get_req_header(conn, "upgrade")} do 9 | {["application/nostr+json"], _} -> 10 | conn 11 | |> Plug.Conn.resp( 12 | 200, 13 | Jason.encode!(%{ 14 | name: "astro", 15 | description: "Astro Nostr Relay", 16 | pubkey: "npub1hmrjq05azqwrfcrffr35w6037c6y9h6y8vd9k7tlngqw5s7h8x6qae9s57", 17 | contact: "mailto:luke@axxim.net", 18 | supported_nips: [1, 11, 15, 16, 20], 19 | software: "https://github.com/Nostrology/astro", 20 | version: "deadbeef" 21 | }) 22 | ) 23 | |> Plug.Conn.put_resp_content_type("application/json") 24 | |> Plug.Conn.send_resp() 25 | 26 | {_, ["websocket"]} -> 27 | Phoenix.Transports.WebSocket.call( 28 | conn, 29 | {AstroWeb.Endpoint, AstroWeb.Socket, 30 | [ 31 | path: "/", 32 | check_origin: false, 33 | timeout: :infinity 34 | ]} 35 | ) 36 | 37 | _ -> 38 | conn 39 | end 40 | end 41 | 42 | @session_options [ 43 | store: :ets, 44 | key: "_astro_key", 45 | table: :session_storage 46 | ] 47 | 48 | socket "/live", Phoenix.LiveView.Socket, 49 | websocket: [connect_info: [session: @session_options]], 50 | longpoll: true 51 | 52 | # Serve at "/" the static files from "priv/static" directory. 53 | # 54 | # You should set gzip to true if you are running phx.digest 55 | # when deploying your static files in production. 56 | plug Plug.Static, 57 | at: "/", 58 | from: :astro, 59 | gzip: false, 60 | only: AstroWeb.static_paths() 61 | 62 | # Code reloading can be explicitly enabled under the 63 | # :code_reloader configuration of your endpoint. 64 | if code_reloading? do 65 | plug Phoenix.CodeReloader 66 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :astro 67 | end 68 | 69 | plug Phoenix.LiveDashboard.RequestLogger, 70 | param_key: "request_logger", 71 | cookie_key: "request_logger" 72 | 73 | plug Plug.RequestId 74 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 75 | 76 | plug Plug.Parsers, 77 | parsers: [:urlencoded, :multipart, :json], 78 | pass: ["*/*"], 79 | json_decoder: Phoenix.json_library() 80 | 81 | plug Plug.MethodOverride 82 | plug Plug.Head 83 | plug Plug.Session, @session_options 84 | plug AstroWeb.Router 85 | end 86 | -------------------------------------------------------------------------------- /lib/astro_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import AstroWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :astro 24 | end 25 | -------------------------------------------------------------------------------- /lib/astro_web/live/home_live.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.HomeLive do 2 | use AstroWeb, :live_view 3 | 4 | def render(assigns) do 5 | ~H""" 6 |
7 | https://github.com/Nostrology/astro 8 |
9 | """ 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/astro_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Router do 2 | use AstroWeb, :router 3 | 4 | # pipeline :api do 5 | # plug :accepts, ["json"] 6 | # end 7 | 8 | # scope "/api", AstroWeb do 9 | # pipe_through :api 10 | # end 11 | 12 | pipeline :browser do 13 | plug :accepts, ["html"] 14 | plug :fetch_session 15 | plug :put_root_layout, {AstroWeb.Layouts, :root} 16 | plug :protect_from_forgery 17 | plug :put_secure_browser_headers 18 | end 19 | 20 | scope "/", AstroWeb do 21 | pipe_through :browser 22 | 23 | live "/", HomeLive 24 | end 25 | 26 | # Enable LiveDashboard in development 27 | if Application.compile_env(:astro, :dev_routes) do 28 | # If you want to use the LiveDashboard in production, you should put 29 | # it behind authentication and allow only admins to access it. 30 | # If your application does not have an admins-only section yet, 31 | # you can use Plug.BasicAuth to set up some basic authentication 32 | # as long as you are also using SSL (which you should anyway). 33 | import Phoenix.LiveDashboard.Router 34 | 35 | scope "/dev" do 36 | pipe_through [:fetch_session, :protect_from_forgery] 37 | 38 | live_dashboard "/dashboard", metrics: AstroWeb.Telemetry 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/astro_web/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Socket do 2 | use Nostr.Socket 3 | 4 | def connect(_, socket) do 5 | {:ok, socket} 6 | end 7 | 8 | def handle_event(%{"id" => event_id} = event_map, socket) do 9 | case Astro.Events.create_event(event_map) do 10 | {:ok, _event} -> 11 | # Created event will be send to the user via their subscription (if subscribed!) 12 | send_message(["OK", event_id, true, "OK!"]) 13 | 14 | {:error, errors} when is_binary(errors) -> 15 | send_message(["OK", event_id, false, "invalid: errors are: " <> errors]) 16 | 17 | _ -> 18 | send_message(["OK", event_id, false, "invalid: unexpected errors"]) 19 | end 20 | 21 | {:ok, socket} 22 | end 23 | 24 | def handle_request(subscription_id, list_of_filters, socket) do 25 | # Fetch the searched events and async them out to the client 26 | list_of_filters 27 | |> Enum.map(&Astro.Events.list_events_with_filters/1) 28 | |> List.flatten() 29 | |> Enum.map(fn event -> 30 | send_message(["EVENT", subscription_id, event]) 31 | end) 32 | 33 | send_message(["EOSE", subscription_id]) 34 | Astro.EventRouter.push_subscription(self(), subscription_id, list_of_filters) 35 | 36 | {:ok, socket} 37 | end 38 | 39 | def handle_other(_req, socket) do 40 | {:ok, socket} 41 | end 42 | 43 | def handle_close(subscription_id, socket) do 44 | Astro.EventRouter.close_subscription(subscription_id) 45 | {:ok, socket} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/astro_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Astro Metrics 25 | summary("astro.event_router.event_filter_match.duration", 26 | unit: {:native, :microsecond} 27 | ), 28 | 29 | # Phoenix Metrics 30 | summary("phoenix.endpoint.start.system_time", 31 | unit: {:native, :millisecond} 32 | ), 33 | summary("phoenix.endpoint.stop.duration", 34 | unit: {:native, :millisecond} 35 | ), 36 | summary("phoenix.router_dispatch.start.system_time", 37 | tags: [:route], 38 | unit: {:native, :millisecond} 39 | ), 40 | summary("phoenix.router_dispatch.exception.duration", 41 | tags: [:route], 42 | unit: {:native, :millisecond} 43 | ), 44 | summary("phoenix.router_dispatch.stop.duration", 45 | tags: [:route], 46 | unit: {:native, :millisecond} 47 | ), 48 | summary("phoenix.socket_connected.duration", 49 | unit: {:native, :millisecond} 50 | ), 51 | summary("phoenix.channel_join.duration", 52 | unit: {:native, :millisecond} 53 | ), 54 | summary("phoenix.channel_handled_in.duration", 55 | tags: [:event], 56 | unit: {:native, :millisecond} 57 | ), 58 | 59 | # Database Metrics 60 | summary("astro.repo.query.total_time", 61 | unit: {:native, :millisecond}, 62 | description: "The sum of the other measurements" 63 | ), 64 | summary("astro.repo.query.decode_time", 65 | unit: {:native, :millisecond}, 66 | description: "The time spent decoding the data received from the database" 67 | ), 68 | summary("astro.repo.query.query_time", 69 | unit: {:native, :millisecond}, 70 | description: "The time spent executing the query" 71 | ), 72 | summary("astro.repo.query.queue_time", 73 | unit: {:native, :millisecond}, 74 | description: "The time spent waiting for a database connection" 75 | ), 76 | summary("astro.repo.query.idle_time", 77 | unit: {:native, :millisecond}, 78 | description: 79 | "The time the connection spent waiting before being checked out for the query" 80 | ), 81 | 82 | # VM Metrics 83 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 84 | summary("vm.total_run_queue_lengths.total"), 85 | summary("vm.total_run_queue_lengths.cpu"), 86 | summary("vm.total_run_queue_lengths.io") 87 | ] 88 | end 89 | 90 | defp periodic_measurements do 91 | [ 92 | # A module, function and arguments to be invoked periodically. 93 | # This function must call :telemetry.execute/3 and a metric must be added above. 94 | # {AstroWeb, :count_users, []} 95 | ] 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/nostr/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Nostr.Socket do 2 | @moduledoc ~S""" 3 | A socket implementation for Nostr. 4 | 5 | Used for building a relay. 6 | """ 7 | 8 | require Logger 9 | require Phoenix.Endpoint 10 | 11 | alias Nostr.Socket 12 | 13 | @callback connect(params :: map, Socket.t()) :: {:ok, Socket.t()} | {:error, term} | :error 14 | @callback handle_event(event_map :: Map.t(), Socket.t()) :: 15 | {:ok, Socket.t()} | {:error, term} | :error 16 | @callback handle_request(subscription_id :: String.t(), filters :: map(), Socket.t()) :: 17 | {:ok, Socket.t()} | {:error, term} | :error 18 | @callback handle_other(request :: list(String.t()), Socket.t()) :: 19 | {:ok, Socket.t()} | {:error, term} | :error 20 | @callback handle_close(subscription_id :: String.t(), Socket.t()) :: 21 | {:ok, Socket.t()} | {:error, term} | :error 22 | 23 | defstruct assigns: %{}, 24 | endpoint: nil, 25 | handler: nil, 26 | id: nil, 27 | private: %{}, 28 | pubsub_server: nil, 29 | transport: nil, 30 | transport_pid: nil, 31 | serializer: nil 32 | 33 | @type t :: %Socket{ 34 | assigns: map, 35 | endpoint: atom, 36 | handler: atom, 37 | id: String.t() | nil, 38 | private: map, 39 | pubsub_server: atom, 40 | serializer: atom, 41 | transport: atom, 42 | transport_pid: pid 43 | } 44 | 45 | defmacro __using__(_opts) do 46 | quote do 47 | ## User API 48 | 49 | import Nostr.Socket 50 | @behaviour Nostr.Socket 51 | 52 | def send_message(message), do: Nostr.Socket.__send_message__(message) 53 | 54 | ## Callbacks 55 | 56 | @behaviour Phoenix.Socket.Transport 57 | 58 | @doc false 59 | def child_spec(opts) do 60 | Nostr.Socket.__child_spec__(__MODULE__, opts, []) 61 | end 62 | 63 | @doc false 64 | def connect(map), do: Nostr.Socket.__connect__(__MODULE__, map) 65 | 66 | @doc false 67 | def init(state), do: Nostr.Socket.__init__(state) 68 | 69 | @doc false 70 | def handle_in(message, state), do: Nostr.Socket.__in__(message, state) 71 | 72 | @doc false 73 | def handle_info(message, state), do: Nostr.Socket.__info__(message, state) 74 | 75 | @doc false 76 | def terminate(reason, state), do: Nostr.Socket.__terminate__(reason, state) 77 | end 78 | end 79 | 80 | def __send_message__(message) when is_list(message) do 81 | send(self(), {:socket_push, :text, Jason.encode!(message)}) 82 | end 83 | 84 | ## CALLBACKS IMPLEMENTATION 85 | 86 | def __child_spec__(handler, opts, socket_options) do 87 | endpoint = Keyword.fetch!(opts, :endpoint) 88 | opts = Keyword.merge(socket_options, opts) 89 | partitions = Keyword.get(opts, :partitions, System.schedulers_online()) 90 | args = {endpoint, handler, partitions} 91 | Supervisor.child_spec({Phoenix.Socket.PoolSupervisor, args}, id: handler) 92 | end 93 | 94 | def __connect__(user_socket, map) do 95 | {:ok, {map, user_socket}} 96 | end 97 | 98 | def __init__(state) do 99 | {:ok, state} 100 | end 101 | 102 | def __in__({message, [opcode: :text]}, {state, socket}) do 103 | handle_in(message, state, socket) 104 | end 105 | 106 | def __info__({:DOWN, _ref, _, _pid, _reason}, {state, socket}) do 107 | dbg("DOWN!") 108 | {:ok, {state, socket}} 109 | end 110 | 111 | # def __info__(%Broadcast{event: "disconnect"}, state) do 112 | # {:stop, {:shutdown, :disconnected}, state} 113 | # end 114 | 115 | def __info__({:socket_push, opcode, payload}, state) do 116 | Logger.info("SEND: #{payload}") 117 | {:push, {opcode, payload}, state} 118 | end 119 | 120 | def __info__({:new_event, event, subscription_id}, state) do 121 | {:push, {:text, Jason.encode!(["EVENT", subscription_id, event])}, state} 122 | end 123 | 124 | # def __info__({:socket_close, pid, _reason}, {state, socket}) do 125 | # dbg("socket closed?") 126 | # # socket_close(pid, {state, socket}) 127 | # {:ok, state} 128 | # end 129 | 130 | def __info__(:garbage_collect, state) do 131 | :erlang.garbage_collect(self()) 132 | {:ok, state} 133 | end 134 | 135 | def __info__(_, state) do 136 | {:ok, state} 137 | end 138 | 139 | def __terminate__(_reason, _state_socket) do 140 | :ok 141 | end 142 | 143 | defp handle_in( 144 | message, 145 | state, 146 | socket 147 | ) do 148 | Logger.info("RECV: #{message}") 149 | 150 | handler_response = 151 | case Jason.decode(message) do 152 | {:ok, request} -> 153 | case request do 154 | ["REQ", subscription_id | filters] -> 155 | socket.handle_request(subscription_id, filters, socket) 156 | 157 | ["EVENT", event_map] -> 158 | socket.handle_event(event_map, socket) 159 | 160 | ["CLOSE", subscription_id] -> 161 | socket.handle_close(subscription_id, socket) 162 | 163 | # ["AUTH", signed_event_json] -> 164 | # nil 165 | 166 | other -> 167 | Logger.warning("Unexpected Nostr request: #{inspect(other)}") 168 | socket.handle_other(request, socket) 169 | end 170 | 171 | {:error, _} -> 172 | Logger.warning("Invalid JSON request: #{inspect(message)}") 173 | :error 174 | end 175 | 176 | case handler_response do 177 | {:noreply, socket} -> 178 | {:ok, {state, socket}} 179 | 180 | {:ok, socket} -> 181 | {:ok, {state, socket}} 182 | 183 | :error -> 184 | :error 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Astro.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :astro, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Astro.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.0-rc.2", override: true}, 36 | {:phoenix_ecto, "~> 4.4"}, 37 | {:ecto_sql, "~> 3.6"}, 38 | {:postgrex, ">= 0.0.0"}, 39 | {:phoenix_live_dashboard, "~> 0.7.2"}, 40 | {:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, 41 | {:tailwind, "~> 0.1.8", runtime: Mix.env() == :dev}, 42 | {:telemetry_metrics, "~> 0.6"}, 43 | {:telemetry_poller, "~> 1.0"}, 44 | {:gettext, "~> 0.20"}, 45 | {:jason, "~> 1.2"}, 46 | {:plug_cowboy, "~> 2.5"}, 47 | # Data Layer 48 | {:memento, "~> 0.3.2"}, 49 | # Cryptography 50 | {:curvy, "~> 0.3"}, 51 | {:k256, "~> 0.0.7"}, 52 | # Websocket Interop 53 | {:mint_web_socket, "~> 1.0"} 54 | ] 55 | end 56 | 57 | # Aliases are shortcuts or tasks specific to the current project. 58 | # For example, to install project dependencies and perform other setup tasks, run: 59 | # 60 | # $ mix setup 61 | # 62 | # See the documentation for `Mix` for more info on aliases. 63 | defp aliases do 64 | [ 65 | setup: ["deps.get", "ecto.setup", "assets.setup"], 66 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 67 | "ecto.reset": ["ecto.drop", "ecto.setup"], 68 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 69 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 70 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"] 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.22", "4127549e411bedd012ca3a308dede574f43819fe9394254ca55ab4895abfa1a2", [:mix], [], "hexpm", "c17576df47eb5aa1ee40cc4134316a99f5cad3e215d5c77b8dd3cfef12a22cac"}, 3 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 4 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 5 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 6 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 7 | "curvy": {:hex, :curvy, "0.3.1", "2645a11452743a37de2393da4d2e60700632498b166413b4f73bc34c57a911e1", [:mix], [], "hexpm", "82df293452f7b751becabc29e8aad0f7d88ffdcd790ac7a2ea16ea1544681d8a"}, 8 | "db_connection": {:hex, :db_connection, "2.4.3", "3b9aac9f27347ec65b271847e6baeb4443d8474289bd18c1d6f4de655b70c94d", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c127c15b0fa6cfb32eed07465e05da6c815b032508d4ed7c116122871df73c12"}, 9 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 10 | "ecto": {:hex, :ecto, "3.9.4", "3ee68e25dbe0c36f980f1ba5dd41ee0d3eb0873bccae8aeaf1a2647242bffa35", [:mix], [{:decimal, "~> 1.6 or ~> 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", "de5f988c142a3aa4ec18b85a4ec34a2390b65b24f02385c1144252ff6ff8ee75"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.9.2", "34227501abe92dba10d9c3495ab6770e75e79b836d114c41108a4bf2ce200ad5", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 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", "1eb5eeb4358fdbcd42eac11c1fbd87e3affd7904e639d77903c1358b2abd3f70"}, 12 | "esbuild": {:hex, :esbuild, "0.6.0", "9ba6ead054abd43cb3d7b14946a0cdd1493698ccd8e054e0e5d6286d7f0f509c", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "30f9a05d4a5bab0d3e37398f312f80864e1ee1a081ca09149d06d474318fd040"}, 13 | "expo": {:hex, :expo, "0.3.0", "13127c1d5f653b2927f2616a4c9ace5ae372efd67c7c2693b87fd0fdc30c6feb", [:mix], [], "hexpm", "fb3cd4bf012a77bc1608915497dae2ff684a06f0fa633c7afa90c4d72b881823"}, 14 | "gettext": {:hex, :gettext, "0.22.0", "a25d71ec21b1848957d9207b81fd61cb25161688d282d58bdafef74c2270bdc4", [:mix], [{:expo, "~> 0.3.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "cb0675141576f73720c8e49b4f0fd3f2c69f0cd8c218202724d4aebab8c70ace"}, 15 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 16 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 17 | "k256": {:hex, :k256, "0.0.7", "d61766f36ac6c96f069dfb84f076c96d21d42a1a456cd3013cf395f4f6358682", [:mix], [{:rustler, "~> 0.26.0", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.5", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "b55c4aa649ba84c42d80aa3fb37eda769fbda327df2439bfaa357b7b7dcf0003"}, 18 | "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, 19 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 20 | "mint": {:hex, :mint, "1.4.2", "50330223429a6e1260b2ca5415f69b0ab086141bc76dc2fbf34d7c389a6675b2", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "ce75a5bbcc59b4d7d8d70f8b2fc284b1751ffb35c7b6a6302b5192f8ab4ddd80"}, 21 | "mint_web_socket": {:hex, :mint_web_socket, "1.0.2", "0933a4c82f2376e35569b2255cdce94f2e3f993c0d5b04c360460cb8beda7154", [:mix], [{:mint, "~> 1.4 and >= 1.4.1", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "067c5e15439be060f2ab57c468ee4ab29e39cb20b498ed990cb94f62db0efc3a"}, 22 | "phoenix": {:hex, :phoenix, "1.7.0-rc.2", "8faaff6f699aad2fe6a003c627da65d0864c868a4c10973ff90abfd7286c1f27", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {: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.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "71abde2f67330c55b625dcc0e42bf76662dbadc7553c4f545c2f3759f40f7487"}, 23 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"}, 24 | "phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"}, 25 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.7.2", "97cc4ff2dba1ebe504db72cb45098cb8e91f11160528b980bd282cc45c73b29c", [: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]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.18.3", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "0e5fdf063c7a3b620c566a30fcf68b7ee02e5e46fe48ee46a6ec3ba382dc05b7"}, 26 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.18.11", "c50eac83dae6b5488859180422dfb27b2c609de87f4aa5b9c926ecd0501cd44f", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [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]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "76c99a0ffb47cd95bf06a917e74f282a603f3e77b00375f3c2dd95110971b102"}, 27 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"}, 28 | "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, 29 | "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, 30 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, 31 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 32 | "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [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", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, 33 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 34 | "rustler": {:hex, :rustler, "0.26.0", "06a2773d453ee3e9109efda643cf2ae633dedea709e2455ac42b83637c9249bf", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "42961e9d2083d004d5a53e111ad1f0c347efd9a05cb2eb2ffa1d037cdc74db91"}, 35 | "rustler_precompiled": {:hex, :rustler_precompiled, "0.6.0", "61ba28a38cea1fc733b6033df99d9ed0cf23c9d411b8d56df6b06134f591cc8f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "fc3ac613101bb7bf171a992a2321d8850a9703bd96d578c156d96ad040cc7380"}, 36 | "tailwind": {:hex, :tailwind, "0.1.9", "25ba09d42f7bfabe170eb67683a76d6ec2061952dc9bd263a52a99ba3d24bd4d", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "9213f87709c458aaec313bb5f2df2b4d2cedc2b630e4ae821bf3c54c47a56d0b"}, 37 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 38 | "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, 39 | "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, 40 | "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, 41 | "websock": {:hex, :websock, "0.4.3", "184ac396bdcd3dfceb5b74c17d221af659dd559a95b1b92041ecb51c9b728093", [:mix], [], "hexpm", "5e4dd85f305f43fd3d3e25d70bec4a45228dfed60f0f3b072d8eddff335539cf"}, 42 | "websock_adapter": {:hex, :websock_adapter, "0.4.5", "30038a3715067f51a9580562c05a3a8d501126030336ffc6edb53bf57d6d2d26", [:mix], [{:bandit, "~> 0.6", [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.4", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "1d9812dc7e703c205049426fd4fe0852a247a825f91b099e53dc96f68bafe4c8"}, 43 | } 44 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should have %{count} item(s)" 52 | msgid_plural "should have %{count} item(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should be %{count} character(s)" 57 | msgid_plural "should be %{count} character(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be %{count} byte(s)" 62 | msgid_plural "should be %{count} byte(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at least %{count} character(s)" 72 | msgid_plural "should be at least %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should be at least %{count} byte(s)" 77 | msgid_plural "should be at least %{count} byte(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | msgid "should have at most %{count} item(s)" 82 | msgid_plural "should have at most %{count} item(s)" 83 | msgstr[0] "" 84 | msgstr[1] "" 85 | 86 | msgid "should be at most %{count} character(s)" 87 | msgid_plural "should be at most %{count} character(s)" 88 | msgstr[0] "" 89 | msgstr[1] "" 90 | 91 | msgid "should be at most %{count} byte(s)" 92 | msgid_plural "should be at most %{count} byte(s)" 93 | msgstr[0] "" 94 | msgstr[1] "" 95 | 96 | ## From Ecto.Changeset.validate_number/3 97 | msgid "must be less than %{number}" 98 | msgstr "" 99 | 100 | msgid "must be greater than %{number}" 101 | msgstr "" 102 | 103 | msgid "must be less than or equal to %{number}" 104 | msgstr "" 105 | 106 | msgid "must be greater than or equal to %{number}" 107 | msgstr "" 108 | 109 | msgid "must be equal to %{number}" 110 | msgstr "" 111 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230201145411_add_events_table.exs: -------------------------------------------------------------------------------- 1 | defmodule Astro.Repo.Migrations.AddEventsTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("events") do 6 | add :id, :string, size: 64, primary_key: true 7 | add :pubkey, :string, size: 64 8 | add :created_at, :integer 9 | add :kind, :integer 10 | add :content, :text 11 | add :sig, :string, size: 128 12 | end 13 | 14 | create table("event_tags") do 15 | add :event_id, references(:events, type: :string, on_delete: :delete_all) 16 | add :key, :string 17 | add :value, :string 18 | add :params, {:array, :string} 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Astro.Repo.insert!(%Astro.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nostrology/astro/e28f1d9905b6ae7018161c53b71989cb5c1e385f/priv/static/favicon.ico -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | exec ./astro eval Astro.Release.migrate 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/migrate.bat: -------------------------------------------------------------------------------- 1 | call "%~dp0\astro" eval Astro.Release.migrate 2 | -------------------------------------------------------------------------------- /rel/overlays/bin/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd -P -- "$(dirname -- "$0")" 3 | PHX_SERVER=true exec ./astro start 4 | -------------------------------------------------------------------------------- /rel/overlays/bin/server.bat: -------------------------------------------------------------------------------- 1 | set PHX_SERVER=true 2 | call "%~dp0\astro" start 3 | -------------------------------------------------------------------------------- /test/astro/event_router_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Astro.EventRouterTest do 2 | use Astro.DataCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/astro/events_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Astro.EventsTest do 2 | use Astro.DataCase, async: true 3 | 4 | import Astro.Events 5 | 6 | alias Astro.Events.Event 7 | alias Astro.Events.Tag 8 | 9 | @event %Event{ 10 | id: "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975", 11 | pubkey: "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 12 | created_at: 1_675_428_746, 13 | kind: 1, 14 | tags: [], 15 | content: "derp", 16 | sig: 17 | "21d32b2df6fc4f92557afca56200f462d969a935203fe5726bc202a2bab26a3d673f12b7d6d45cc7e06f98f75b0835689384b87066999e6a636f27511a9cff59" 18 | } 19 | 20 | describe "Create Event" do 21 | test "create_event/1 creates an event" do 22 | assert {:ok, _} = 23 | create_event(%{ 24 | "id" => "d2472b7bcd2490f82dfc06b6fad1695898581a21312a7aa7d4a3b4e1f06d358a", 25 | "pubkey" => "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 26 | "created_at" => 1_675_599_987, 27 | "kind" => 1, 28 | "tags" => [ 29 | [ 30 | "e", 31 | "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975" 32 | ] 33 | ], 34 | "content" => "hello world", 35 | "sig" => 36 | "74837ea05b3568b9979777cef2c3d0b3e587f113811cc7405230837aa15122e3645ecd2f162ca1e31618f1d335266c85a19cb0ec62e9b4654fabc9e9b8f5f917" 37 | }) 38 | end 39 | 40 | test "create_event/1 doesn't allow duplicates" do 41 | event = %{ 42 | "id" => "d2472b7bcd2490f82dfc06b6fad1695898581a21312a7aa7d4a3b4e1f06d358a", 43 | "pubkey" => "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 44 | "created_at" => 1_675_599_987, 45 | "kind" => 1, 46 | "tags" => [ 47 | [ 48 | "e", 49 | "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975" 50 | ] 51 | ], 52 | "content" => "hello world", 53 | "sig" => 54 | "74837ea05b3568b9979777cef2c3d0b3e587f113811cc7405230837aa15122e3645ecd2f162ca1e31618f1d335266c85a19cb0ec62e9b4654fabc9e9b8f5f917" 55 | } 56 | 57 | assert {:ok, _} = create_event(event) 58 | assert {:error, "id: has already been taken."} = create_event(event) 59 | end 60 | end 61 | 62 | describe "Filter Tests" do 63 | Enum.each( 64 | [ 65 | {%{"ids" => ["a8b2d39d300b5a3ff91fc7b94"]}, true}, 66 | {%{"ids" => ["foobar"]}, false}, 67 | {%{"since" => 1_675_428_747}, true} 68 | ], 69 | fn {filters, result} -> 70 | # escaped = Macro.escape(filters) 71 | 72 | @tag filters: filters, result: result 73 | test "match_filters/2 matches filters #{inspect(filters)} == #{result}", %{ 74 | filters: filters, 75 | result: result 76 | } do 77 | assert matches_filters(@event, filters) == result 78 | end 79 | end 80 | ) 81 | end 82 | 83 | describe "Event JSON" do 84 | test "generates valid json from an event struct" do 85 | event = %Event{ 86 | id: "d2472b7bcd2490f82dfc06b6fad1695898581a21312a7aa7d4a3b4e1f06d358a", 87 | pubkey: "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 88 | created_at: 1_675_599_987, 89 | kind: 1, 90 | content: "hello world", 91 | sig: 92 | "74837ea05b3568b9979777cef2c3d0b3e587f113811cc7405230837aa15122e3645ecd2f162ca1e31618f1d335266c85a19cb0ec62e9b4654fabc9e9b8f5f917", 93 | event_tags: [ 94 | %Tag{ 95 | event_id: "d2472b7bcd2490f82dfc06b6fad1695898581a21312a7aa7d4a3b4e1f06d358a", 96 | key: "e", 97 | value: "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975", 98 | params: [] 99 | } 100 | ] 101 | } 102 | 103 | assert Jason.encode!(event) == 104 | "{\"content\":\"hello world\",\"created_at\":1675599987,\"id\":\"d2472b7bcd2490f82dfc06b6fad1695898581a21312a7aa7d4a3b4e1f06d358a\",\"kind\":1,\"pubkey\":\"74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875\",\"sig\":\"74837ea05b3568b9979777cef2c3d0b3e587f113811cc7405230837aa15122e3645ecd2f162ca1e31618f1d335266c85a19cb0ec62e9b4654fabc9e9b8f5f917\",\"tags\":[[\"e\",\"a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975\"]]}" 105 | end 106 | 107 | test "handles when tags have empty values" do 108 | event = %Event{ 109 | content: 110 | "\nFree Airdrop for Damus verify users\n\n1. Join Telegram group\n2. Get your Damus verified and show proof\n3. Get free Sats\n\nJoin Group👉 https://t.me/zclub_app\n", 111 | created_at: 1_675_698_976, 112 | id: "eac9973a081c0b1c9189143bf76cb5dd3e58fb45c3470353b6f07e2ed4e137dd", 113 | kind: 42, 114 | pubkey: "4e0ebe6254074e8a0f7cfecce8ae504884ac7ee377ee2dff00b395664d162efd", 115 | sig: 116 | "99af079e9de9cfc0485d5f1439f23d3c17d55117008f85d3ed1e580740437fee2274e069cbe50c4caa613eeb0b827e9c5a62baf5d11b81566068fa8ccf01a4af", 117 | event_tags: [ 118 | %Tag{ 119 | event_id: "eac9973a081c0b1c9189143bf76cb5dd3e58fb45c3470353b6f07e2ed4e137dd", 120 | key: "e", 121 | value: "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", 122 | params: ["", "root"] 123 | } 124 | ] 125 | } 126 | 127 | assert Jason.encode!(event) == 128 | "{\"content\":\"\\nFree Airdrop for Damus verify users\\n\\n1. Join Telegram group\\n2. Get your Damus verified and show proof\\n3. Get free Sats\\n\\nJoin Group👉 https://t.me/zclub_app\\n\",\"created_at\":1675698976,\"id\":\"eac9973a081c0b1c9189143bf76cb5dd3e58fb45c3470353b6f07e2ed4e137dd\",\"kind\":42,\"pubkey\":\"4e0ebe6254074e8a0f7cfecce8ae504884ac7ee377ee2dff00b395664d162efd\",\"sig\":\"99af079e9de9cfc0485d5f1439f23d3c17d55117008f85d3ed1e580740437fee2274e069cbe50c4caa613eeb0b827e9c5a62baf5d11b81566068fa8ccf01a4af\",\"tags\":[[\"e\",\"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5\",\"\",\"root\"]]}" 129 | end 130 | end 131 | 132 | describe "Tags" do 133 | test "transmute_tags/1 keeps empty tags" do 134 | assert transmute_tags([ 135 | [ 136 | "e", 137 | "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", 138 | "", 139 | "root" 140 | ] 141 | ]) == 142 | [ 143 | %{ 144 | key: "e", 145 | value: "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", 146 | params: ["", "root"] 147 | } 148 | ] 149 | end 150 | end 151 | 152 | test "generate_id/1 generates the correct id" do 153 | event = %{ 154 | id: "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975", 155 | pubkey: "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 156 | created_at: 1_675_428_746, 157 | kind: 1, 158 | tags: [], 159 | content: "derp", 160 | sig: 161 | "21d32b2df6fc4f92557afca56200f462d969a935203fe5726bc202a2bab26a3d673f12b7d6d45cc7e06f98f75b0835689384b87066999e6a636f27511a9cff59" 162 | } 163 | 164 | assert generate_id(event) == 165 | "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975" 166 | end 167 | 168 | test "generate_id/1 works for a problem child" do 169 | event = %{ 170 | id: "eac9973a081c0b1c9189143bf76cb5dd3e58fb45c3470353b6f07e2ed4e137dd", 171 | pubkey: "4e0ebe6254074e8a0f7cfecce8ae504884ac7ee377ee2dff00b395664d162efd", 172 | created_at: 1_675_698_976, 173 | kind: 42, 174 | tags: [ 175 | ["e", "42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5", "", "root"] 176 | ], 177 | content: 178 | "\nFree Airdrop for Damus verify users\n\n1. Join Telegram group\n2. Get your Damus verified and show proof\n3. Get free Sats\n\nJoin Group👉 https://t.me/zclub_app\n", 179 | sig: 180 | "99af079e9de9cfc0485d5f1439f23d3c17d55117008f85d3ed1e580740437fee2274e069cbe50c4caa613eeb0b827e9c5a62baf5d11b81566068fa8ccf01a4af" 181 | } 182 | 183 | assert generate_id(event) == 184 | "eac9973a081c0b1c9189143bf76cb5dd3e58fb45c3470353b6f07e2ed4e137dd" 185 | end 186 | 187 | test "verify_signature/1 verifies signatures" do 188 | event = %Event{ 189 | id: "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975", 190 | pubkey: "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 191 | created_at: 1_675_428_746, 192 | kind: 1, 193 | tags: [], 194 | content: "derp", 195 | sig: 196 | "21d32b2df6fc4f92557afca56200f462d969a935203fe5726bc202a2bab26a3d673f12b7d6d45cc7e06f98f75b0835689384b87066999e6a636f27511a9cff59" 197 | } 198 | 199 | assert verify_signature(event) == true 200 | end 201 | 202 | test "filter matches prefixes and full ids" do 203 | # pubkey = 204 | # Base.decode16!("02" <> "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875", 205 | # case: :lower 206 | # ) 207 | # |> Curvy.Key.from_pubkey() 208 | 209 | # sig = 210 | # "02" <> 211 | # "21d32b2df6fc4f92557afca56200f462d969a935203fe5726bc202a2bab26a3d673f12b7d6d45cc7e06f98f75b0835689384b87066999e6a636f27511a9cff59" 212 | 213 | # id = 214 | # "a8b2d39d300b5a3ff91fc7b943944ebfd829a63ce2c0289431237473619a6975" 215 | # |> Base.decode16!(case: :lower) 216 | 217 | # Curvy.verify(sig, id, pubkey, encoding: :hex) |> dbg() 218 | 219 | list_events_with_filters(%{ 220 | "ids" => [ 221 | "05723332ff5169111c3dd58824f2d41fa87528c12d715ac3f8c5dd89b4aab927", 222 | "1234", 223 | "abcd" 224 | ], 225 | "authors" => [ 226 | "74fcb177b758df25487504a0bf9b69bdd7ec99ed3d422a18f932709974f80875" 227 | ], 228 | "foo" => "bar", 229 | "since" => "SQL DROP;", 230 | "#e" => 123 231 | }) 232 | 233 | assert_dynamic_match( 234 | build_and(%{ids: ["abcd"]}), 235 | "ilike(e.id, ^\"abcd%\")" 236 | ) 237 | 238 | assert_dynamic_match( 239 | build_and(%{ 240 | ids: [ 241 | "05723332ff5169111c3dd58824f2d41fa87528c12d715ac3f8c5dd89b4aab927", 242 | "1234" 243 | ] 244 | }), 245 | "ilike(e.id, ^\"1234%\") or e.id == ^\"05723332ff5169111c3dd58824f2d41fa87528c12d715ac3f8c5dd89b4aab927\"" 246 | ) 247 | 248 | assert_dynamic_match( 249 | build_and(%{ 250 | ids: [ 251 | "1234", 252 | "abcd" 253 | ] 254 | }), 255 | "ilike(e.id, ^\"abcd%\") or ilike(e.id, ^\"1234%\")" 256 | ) 257 | 258 | assert_dynamic_result( 259 | build_and(%{ 260 | "#e": [ 261 | "1234" 262 | ] 263 | }), 264 | "dynamic([tags: t], t.key == ^\"e\" and ilike(t.value, ^\"1234%\"))" 265 | ) 266 | end 267 | 268 | # defp match(dynamic) do 269 | # assert true 270 | # String.replace(inspect(dynamic), "\n ", "") 271 | # end 272 | 273 | defp assert_dynamic_result(dynamic, result) do 274 | assert String.replace(inspect(dynamic), "\n ", "") == result 275 | end 276 | 277 | defp assert_dynamic_match(dynamic, string) do 278 | assert String.replace(inspect(dynamic), "\n ", "") == "dynamic([e], #{string})" 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule AstroWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use AstroWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint AstroWeb.Endpoint 24 | 25 | use AstroWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import AstroWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Astro.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Astro.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Astro.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Astro.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Astro.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Astro.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Astro.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Astro.Repo, :manual) 3 | --------------------------------------------------------------------------------