From bf621c2bd8904568729bca75e705fd0f344acfc5 Mon Sep 17 00:00:00 2001 From: Ashlynne Mitchell Date: Sat, 24 May 2025 00:14:37 +1000 Subject: [PATCH] feat: backend scaffold & basic codegen for lexicons --- .gitignore | 1 + .vscode/settings.json | 1 + apps/backend/.formatter.exs | 5 + apps/backend/.gitignore | 27 +++ apps/backend/README.md | 24 +++ apps/backend/config/config.exs | 35 ++++ apps/backend/config/dev.exs | 63 +++++++ apps/backend/config/prod.exs | 7 + apps/backend/config/runtime.exs | 99 ++++++++++ apps/backend/config/test.exs | 27 +++ apps/backend/lib/atproto/atproto.ex | 169 ++++++++++++++++++ .../sh/comet/v0/actor/getProfile/xrpc.ex | 15 ++ .../sh/comet/v0/actor/getProfiles/xrpc.ex | 15 ++ .../sh/comet/v0/actor/profile/schema.ex | 30 ++++ .../sh/comet/v0/actor/profile/structs.ex | 104 +++++++++++ .../sh/comet/v0/feed/comment/schema.ex | 30 ++++ .../atproto/sh/comet/v0/feed/defs/structs.ex | 49 +++++ .../comet/v0/feed/getActorPlaylists/xrpc.ex | 17 ++ .../sh/comet/v0/feed/getActorTracks/xrpc.ex | 17 ++ .../atproto/sh/comet/v0/feed/like/schema.ex | 25 +++ .../atproto/sh/comet/v0/feed/play/schema.ex | 25 +++ .../sh/comet/v0/feed/playlist/schema.ex | 32 ++++ .../sh/comet/v0/feed/playlist/structs.ex | 44 +++++ .../sh/comet/v0/feed/playlistTrack/schema.ex | 27 +++ .../atproto/sh/comet/v0/feed/repost/schema.ex | 25 +++ .../atproto/sh/comet/v0/feed/track/schema.ex | 32 ++++ .../atproto/sh/comet/v0/feed/track/structs.ex | 44 +++++ .../sh/comet/v0/richtext/facet/structs.ex | 131 ++++++++++++++ apps/backend/lib/atproto/tid.ex | 105 +++++++++++ apps/backend/lib/comet.ex | 9 + apps/backend/lib/comet/application.ex | 34 ++++ apps/backend/lib/comet/repo.ex | 5 + apps/backend/lib/comet_web.ex | 65 +++++++ .../lib/comet_web/controllers/error_json.ex | 21 +++ apps/backend/lib/comet_web/endpoint.ex | 51 ++++++ apps/backend/lib/comet_web/router.ex | 27 +++ apps/backend/lib/comet_web/telemetry.ex | 93 ++++++++++ apps/backend/mix.exs | 68 +++++++ apps/backend/mix.lock | 35 ++++ .../priv/repo/migrations/.formatter.exs | 4 + apps/backend/priv/repo/seeds.exs | 11 ++ apps/backend/priv/static/favicon.ico | Bin 0 -> 152 bytes apps/backend/priv/static/robots.txt | 5 + .../comet_web/controllers/error_json_test.exs | 12 ++ apps/backend/test/support/conn_case.ex | 38 ++++ apps/backend/test/support/data_case.ex | 58 ++++++ apps/backend/test/test_helper.exs | 2 + flake.nix | 2 +- 48 files changed, 1764 insertions(+), 1 deletion(-) create mode 100644 apps/backend/.formatter.exs create mode 100644 apps/backend/.gitignore create mode 100644 apps/backend/README.md create mode 100644 apps/backend/config/config.exs create mode 100644 apps/backend/config/dev.exs create mode 100644 apps/backend/config/prod.exs create mode 100644 apps/backend/config/runtime.exs create mode 100644 apps/backend/config/test.exs create mode 100644 apps/backend/lib/atproto/atproto.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex create mode 100644 apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex create mode 100644 apps/backend/lib/atproto/tid.ex create mode 100644 apps/backend/lib/comet.ex create mode 100644 apps/backend/lib/comet/application.ex create mode 100644 apps/backend/lib/comet/repo.ex create mode 100644 apps/backend/lib/comet_web.ex create mode 100644 apps/backend/lib/comet_web/controllers/error_json.ex create mode 100644 apps/backend/lib/comet_web/endpoint.ex create mode 100644 apps/backend/lib/comet_web/router.ex create mode 100644 apps/backend/lib/comet_web/telemetry.ex create mode 100644 apps/backend/mix.exs create mode 100644 apps/backend/mix.lock create mode 100644 apps/backend/priv/repo/migrations/.formatter.exs create mode 100644 apps/backend/priv/repo/seeds.exs create mode 100644 apps/backend/priv/static/favicon.ico create mode 100644 apps/backend/priv/static/robots.txt create mode 100644 apps/backend/test/comet_web/controllers/error_json_test.exs create mode 100644 apps/backend/test/support/conn_case.ex create mode 100644 apps/backend/test/support/data_case.ex create mode 100644 apps/backend/test/test_helper.exs diff --git a/.gitignore b/.gitignore index 8df3824..99e9cea 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .wrangler .svelte-kit build +.elixir_ls # OS .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json index 6457cf7..ee4d1da 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "url": "https://gist.githubusercontent.com/mary-ext/6e428031c18799d1587048b456d118cb/raw/4322c492384ac5da33986dee9588938a88d922f1/schema.json" } ], + "elixirLS.projectDir": "./apps/backend", "search.exclude": { "**/node_modules": true, "**/bower_components": true, diff --git a/apps/backend/.formatter.exs b/apps/backend/.formatter.exs new file mode 100644 index 0000000..5971023 --- /dev/null +++ b/apps/backend/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix], + subdirectories: ["priv/*/migrations"], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"] +] diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..6cc3958 --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +comet-*.tar + diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..5b202e7 --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,24 @@ +# Comet AppView + +[Phoenix](https://www.phoenixframework.org)-powered AppView for Comet. + +--- + +To start your Phoenix server: + +- Run `mix setup` to install and setup dependencies +- Start Phoenix endpoint with `mix phx.server` or inside IEx with + `iex -S mix phx.server` + +Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. + +Ready to run in production? Please +[check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). + +## Learn more + +- Official website: https://www.phoenixframework.org/ +- Guides: https://hexdocs.pm/phoenix/overview.html +- Docs: https://hexdocs.pm/phoenix +- Forum: https://elixirforum.com/c/phoenix-forum +- Source: https://github.com/phoenixframework/phoenix diff --git a/apps/backend/config/config.exs b/apps/backend/config/config.exs new file mode 100644 index 0000000..2fb020e --- /dev/null +++ b/apps/backend/config/config.exs @@ -0,0 +1,35 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Config module. +# +# This configuration file is loaded before any dependency and +# is restricted to this project. + +# General application configuration +import Config + +config :comet, + ecto_repos: [Comet.Repo], + generators: [timestamp_type: :utc_datetime, binary_id: true] + +# Configures the endpoint +config :comet, CometWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [json: CometWeb.ErrorJSON], + layout: false + ], + pubsub_server: Comet.PubSub, + live_view: [signing_salt: "oq2xYeBj"] + +# Configures Elixir's Logger +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +# Use Jason for JSON parsing in Phoenix +config :phoenix, :json_library, Jason + +# Import environment specific config. This must remain at the bottom +# of this file so it overrides the configuration defined above. +import_config "#{config_env()}.exs" diff --git a/apps/backend/config/dev.exs b/apps/backend/config/dev.exs new file mode 100644 index 0000000..a5f0f1b --- /dev/null +++ b/apps/backend/config/dev.exs @@ -0,0 +1,63 @@ +import Config + +# Configure your database +config :comet, Comet.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "comet_dev", + stacktrace: true, + show_sensitive_data_on_connection_error: true, + pool_size: 10 + +# For development, we disable any cache and enable +# debugging and code reloading. +# +# The watchers configuration can be used to run external +# watchers to your application. For example, we can use it +# to bundle .js and .css sources. +config :comet, CometWeb.Endpoint, + # Binding to loopback ipv4 address prevents access from other machines. + # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "Vw9UaVO8YBKiooaOlZ2Rhx7xJHydL9s2YIviOwiiQz8Cy24+mLBB3Fj+9jvOIdQE", + watchers: [] + +# ## SSL Support +# +# In order to use HTTPS in development, a self-signed +# certificate can be generated by running the following +# Mix task: +# +# mix phx.gen.cert +# +# Run `mix help phx.gen.cert` for more information. +# +# The `http:` config above can be replaced with: +# +# https: [ +# port: 4001, +# cipher_suite: :strong, +# keyfile: "priv/cert/selfsigned_key.pem", +# certfile: "priv/cert/selfsigned.pem" +# ], +# +# If desired, both `http:` and `https:` keys can be +# configured to run both http and https servers on +# different ports. + +# Enable dev routes for dashboard and mailbox +config :comet, dev_routes: true + +# Do not include metadata nor timestamps in development logs +config :logger, :console, format: "[$level] $message\n" + +# Set a higher stacktrace during development. Avoid configuring such +# in production as building large stacktraces may be expensive. +config :phoenix, :stacktrace_depth, 20 + +# Initialize plugs at runtime for faster development compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/apps/backend/config/prod.exs b/apps/backend/config/prod.exs new file mode 100644 index 0000000..1fe2d9e --- /dev/null +++ b/apps/backend/config/prod.exs @@ -0,0 +1,7 @@ +import Config + +# Do not print debug messages in production +config :logger, level: :info + +# Runtime production configuration, including reading +# of environment variables, is done on config/runtime.exs. diff --git a/apps/backend/config/runtime.exs b/apps/backend/config/runtime.exs new file mode 100644 index 0000000..6e2128a --- /dev/null +++ b/apps/backend/config/runtime.exs @@ -0,0 +1,99 @@ +import Config + +# config/runtime.exs is executed for all environments, including +# during releases. It is executed after compilation and before the +# system starts, so it is typically used to load production configuration +# and secrets from environment variables or elsewhere. Do not define +# any compile-time configuration in here, as it won't be applied. +# The block below contains prod specific runtime configuration. + +# ## Using releases +# +# If you use `mix release`, you need to explicitly enable the server +# by passing the PHX_SERVER=true when you start it: +# +# PHX_SERVER=true bin/comet start +# +# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` +# script that automatically sets the env var above. +if System.get_env("PHX_SERVER") do + config :comet, CometWeb.Endpoint, server: true +end + +if config_env() == :prod do + database_url = + System.get_env("DATABASE_URL") || + raise """ + environment variable DATABASE_URL is missing. + For example: ecto://USER:PASS@HOST/DATABASE + """ + + maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] + + config :comet, Comet.Repo, + # ssl: true, + url: database_url, + pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), + socket_options: maybe_ipv6 + + # The secret key base is used to sign/encrypt cookies and other secrets. + # A default value is used in config/dev.exs and config/test.exs but you + # want to use a different value for prod and you most likely don't want + # to check this value into version control, so we use an environment + # variable instead. + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "example.com" + port = String.to_integer(System.get_env("PORT") || "4000") + + config :comet, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") + + config :comet, CometWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ + # Enable IPv6 and bind on all interfaces. + # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. + # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 + # for details about using IPv6 vs IPv4 and loopback vs public addresses. + ip: {0, 0, 0, 0, 0, 0, 0, 0}, + port: port + ], + secret_key_base: secret_key_base + + # ## SSL Support + # + # To get SSL working, you will need to add the `https` key + # to your endpoint configuration: + # + # config :comet, CometWeb.Endpoint, + # https: [ + # ..., + # port: 443, + # cipher_suite: :strong, + # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), + # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") + # ] + # + # The `cipher_suite` is set to `:strong` to support only the + # latest and more secure SSL ciphers. This means old browsers + # and clients may not be supported. You can set it to + # `:compatible` for wider support. + # + # `:keyfile` and `:certfile` expect an absolute path to the key + # and cert in disk or a relative path inside priv, for example + # "priv/ssl/server.key". For all supported SSL configuration + # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 + # + # We also recommend setting `force_ssl` in your config/prod.exs, + # ensuring no data is ever sent via http, always redirecting to https: + # + # config :comet, CometWeb.Endpoint, + # force_ssl: [hsts: true] + # + # Check `Plug.SSL` for all available options in `force_ssl`. +end diff --git a/apps/backend/config/test.exs b/apps/backend/config/test.exs new file mode 100644 index 0000000..51db87a --- /dev/null +++ b/apps/backend/config/test.exs @@ -0,0 +1,27 @@ +import Config + +# Configure your database +# +# The MIX_TEST_PARTITION environment variable can be used +# to provide built-in test partitioning in CI environment. +# Run `mix help test` for more information. +config :comet, Comet.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "comet_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2 + +# We don't run a server during test. If one is required, +# you can enable the server option below. +config :comet, CometWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4002], + secret_key_base: "eaG5CrPmVserxnUlu8DyG8I6i3m3TBDOi8fsKn2niwYUMhjps0YkWWMGRnoSXvGf", + server: false + +# Print only warnings and errors during test +config :logger, level: :warning + +# Initialize plugs at runtime for faster test compilation +config :phoenix, :plug_init_mode, :runtime diff --git a/apps/backend/lib/atproto/atproto.ex b/apps/backend/lib/atproto/atproto.ex new file mode 100644 index 0000000..8245138 --- /dev/null +++ b/apps/backend/lib/atproto/atproto.ex @@ -0,0 +1,169 @@ +# AUTOGENERATED: This file was generated using the mix task `lexgen`. +defmodule Atproto do + @default_pds_hostname Application.compile_env!(:comet, :default_pds_hostname) + + @typedoc """ + A type representing the names of the options that can be passed to `query/3` and `procedure/3`. + """ + @type xrpc_opt :: :pds_hostname | :authorization + + @typedoc """ + A keyword list of options that can be passed to `query/3` and `procedure/3`. + """ + @type xrpc_opts :: [{xrpc_opt(), any()}] + + @doc """ + Converts a JSON string, or decoded JSON map, into a struct based on the given module. + + This function uses `String.to_existing_atom/1` to convert the keys of the map to atoms, meaning this will throw an error if the input JSON contains keys which are not already defined as atoms in the existing structs or codebase. + """ + @spec decode_to_struct(module(), binary() | map()) :: map() + def decode_to_struct(module, json) when is_binary(json) do + decode_to_struct(module, Jason.decode!(json, keys: :atoms!)) + end + + def decode_to_struct(module, map) when is_map(map) do + Map.merge(module.new(), map) + end + + @doc """ + Raises an error if any required parameters are missing from the given map. + """ + @spec ensure_required(map(), [String.t()]) :: map() + def ensure_required(params, required) do + if Enum.all?(required, fn key -> Map.has_key?(params, key) end) do + params + else + raise ArgumentError, "Missing one or more required parameters: #{Enum.join(required, ", ")}" + end + end + + @doc """ + Executes a "GET" HTTP request and returns the response body as a map. + + If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used. + """ + @spec query(map(), String.t(), xrpc_opts()) :: Req.Request.t() + def query(params, target, opts \\ []) do + target + |> endpoint(opts) + |> URI.new!() + |> URI.append_query(URI.encode_query(params)) + |> Req.get(build_req_auth(opts)) + |> handle_response(opts) + end + + @doc """ + Executes a "POST" HTTP request and returns the response body as a map. + + If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used. + """ + @spec procedure(map(), String.t(), xrpc_opts()) :: {:ok | :refresh | :error, map()} + def procedure(params, target, opts \\ []) do + req_opts = + opts + |> build_req_auth() + |> build_req_headers(opts, target) + |> build_req_body(params, target) + + target + |> endpoint(opts) + |> URI.new!() + |> Req.post(req_opts) + |> handle_response(opts) + end + + defp build_req_auth(opts) do + case Keyword.get(opts, :access_token) do + nil -> + case Keyword.get(opts, :admin_token) do + nil -> + [] + + token -> + [auth: {:basic, "admin:#{token}"}] + end + + token -> + [auth: {:bearer, token}] + end + end + + defp build_req_headers(req_opts, opts, "com.atproto.repo.uploadBlob") do + [ + {:headers, + [ + {"Content-Type", Keyword.fetch!(opts, :content_type)}, + {"Content-Length", Keyword.fetch!(opts, :content_length)} + ]} + | req_opts + ] + end + + defp build_req_headers(req_opts, _opts, _target), do: req_opts + + defp build_req_body(opts, blob, "com.atproto.repo.uploadBlob") do + [{:body, blob} | opts] + end + + defp build_req_body(opts, %{} = params, _target) when map_size(params) > 0 do + [{:json, params} | opts] + end + + defp build_req_body(opts, _params, _target), do: opts + + defp endpoint(target, opts) do + (Keyword.get(opts, :pds_hostname) || @default_pds_hostname) <> "/xrpc/" <> target + end + + defp handle_response({:ok, %Req.Response{} = response}, opts) do + case response.status do + x when x in 200..299 -> + {:ok, response.body} + + _ -> + if response.body["error"] == "ExpiredToken" do + {:ok, user} = + Com.Atproto.Server.RefreshSession.main(%{}, + access_token: Keyword.get(opts, :refresh_token) + ) + + {:refresh, user} + else + {:error, response.body} + end + end + end + + defp handle_response(error, _opts), do: error + + @doc """ + Converts a "map-like" entity into a standard map. This will also omit any entries that have a `nil` value. + + This is useful for converting structs or schemas into regular maps before sending them over XRPC requests. + + You may optionally pass in an keyword list of options: + + - `:stringify` - `boolean` - If `true`, converts the keys to strings. Otherwise, converts keys to atoms. Default is `false`. + - *Note*: When `false`, this feature uses the `to_existing_atom/1` function to avoid reckless conversion of string keys. + """ + @spec to_map(map() | struct()) :: map() + def to_map(%{__struct__: _} = m, opts \\ []) do + string_keys = Keyword.get(opts, :stringify, false) + + m + |> Map.drop([:__struct__, :__meta__]) + |> Enum.map(fn + {_, nil} -> + nil + + {k, v} when is_atom(k) -> + if string_keys, do: {to_string(k), v}, else: {k, v} + + {k, v} when is_binary(k) -> + if string_keys, do: {k, v}, else: {String.to_existing_atom(k), v} + end) + |> Enum.reject(&is_nil/1) + |> Enum.into(%{}) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex b/apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex new file mode 100644 index 0000000..ab523ee --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex @@ -0,0 +1,15 @@ +defmodule Sh.Comet.V0.Actor.GetProfile do + + @doc """ + Get the profile view of an actor. + """ + @spec main(%{ + actor: String.t() + }, Atproto.xrpc_opts()) :: {:ok, Sh.Comet.V0.Actor.Profile.View.t()} | {:error, any} + def main(params \\ %{}, opts \\ []) do + params + |> Map.take([:actor]) + |> Atproto.ensure_required([:actor]) + |> Atproto.query("sh.comet.v0.actor.getProfile", opts) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex b/apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex new file mode 100644 index 0000000..0a9ecf4 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex @@ -0,0 +1,15 @@ +defmodule Sh.Comet.V0.Actor.GetProfiles do + + @doc """ + Get the profile views of multiple actors. + """ + @spec main(%{ + actors: list(String.t()) + }, Atproto.xrpc_opts()) :: {:ok, %{profiles: list(Sh.Comet.V0.Actor.Profile.View.t())}} | {:error, any} + def main(params \\ %{}, opts \\ []) do + params + |> Map.take([:actors]) + |> Atproto.ensure_required([:actors]) + |> Atproto.query("sh.comet.v0.actor.getProfiles", opts) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex new file mode 100644 index 0000000..fccf6cf --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex @@ -0,0 +1,30 @@ +defmodule Sh.Comet.V0.Actor.Profile do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + A user's Comet profile. + """ + @primary_key {:id, :binary_id, autogenerate: false} + schema "sh.comet.v0.actor.profile" do + field :avatar, :map + field :banner, :map + field :createdAt, :utc_datetime + field :description, :string + field :descriptionFacets, :map + field :displayName, :string + field :featuredItems, {:array, :string} + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.actor.profile" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:avatar, :banner, :createdAt, :description, :descriptionFacets, :displayName, :featuredItems]) + |> validate_length(:featuredItems, max: 5) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex b/apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex new file mode 100644 index 0000000..b465d51 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex @@ -0,0 +1,104 @@ + +defmodule Sh.Comet.V0.Actor.Profile.ViewerState do + @moduledoc """ + Metadata about the requesting account's relationship with the user. TODO: determine if we create our own graph or inherit bsky's. + """ + + @derive Jason.Encoder + defstruct [ + + ] + + @type t() :: %__MODULE__{ + + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Actor.Profile.ViewFull do + @moduledoc """ + + """ + + @derive Jason.Encoder + defstruct [ + avatar: nil, + banner: nil, + createdAt: nil, + description: nil, + descriptionFacets: nil, + did: nil, + displayName: nil, + featuredItems: [], + followersCount: 0, + followsCount: 0, + handle: nil, + indexedAt: nil, + playlistsCount: 0, + tracksCount: 0, + viewer: nil + ] + + @type t() :: %__MODULE__{ + avatar: String.t(), + banner: String.t(), + createdAt: DateTime.t(), + description: String.t(), + descriptionFacets: Sh.Comet.V0.Richtext.Facet.Main.t(), + did: String.t(), + displayName: String.t(), + featuredItems: list(String.t()), + followersCount: integer, + followsCount: integer, + handle: String.t(), + indexedAt: DateTime.t(), + playlistsCount: integer, + tracksCount: integer, + viewer: Sh.Comet.V0.Actor.Profile.ViewerState.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Actor.Profile.View do + @moduledoc """ + + """ + + @derive Jason.Encoder + defstruct [ + avatar: nil, + createdAt: nil, + did: nil, + displayName: nil, + handle: nil, + indexedAt: nil, + viewer: nil + ] + + @type t() :: %__MODULE__{ + avatar: String.t(), + createdAt: DateTime.t(), + did: String.t(), + displayName: String.t(), + handle: String.t(), + indexedAt: DateTime.t(), + viewer: Sh.Comet.V0.Actor.Profile.ViewerState.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex new file mode 100644 index 0000000..fbd2bf8 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex @@ -0,0 +1,30 @@ +defmodule Sh.Comet.V0.Feed.Comment do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + A comment on a piece of Comet media. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.comment" do + field :createdAt, :utc_datetime + field :facets, {:array, :map} + field :langs, {:array, :string} + field :reply, :string + field :subject, :string + field :text, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.comment" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:createdAt, :facets, :langs, :reply, :subject, :text]) + |> validate_required([:createdAt, :subject, :text]) + |> validate_length(:langs, max: 3) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex new file mode 100644 index 0000000..529af9f --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex @@ -0,0 +1,49 @@ + +defmodule Sh.Comet.V0.Feed.Defs.ViewerState do + @moduledoc """ + Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. + """ + + @derive Jason.Encoder + defstruct [ + featured: false, + like: nil, + repost: nil + ] + + @type t() :: %__MODULE__{ + featured: boolean, + like: String.t(), + repost: String.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Feed.Defs.Link do + @moduledoc """ + Link for the track. Usually to acquire it in some way, e.g. via free download or purchase. | TODO: multiple links? + """ + + @derive Jason.Encoder + defstruct [ + type: nil, + value: nil + ] + + @type t() :: %__MODULE__{ + type: String.t(), + value: String.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex new file mode 100644 index 0000000..2f90536 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex @@ -0,0 +1,17 @@ +defmodule Sh.Comet.V0.Feed.GetActorPlaylists do + + @doc """ + Get a list of an actor's playlists. + """ + @spec main(%{ + actor: String.t(), + cursor: String.t(), + limit: integer + }, Atproto.xrpc_opts()) :: {:ok, %{cursor: String.t(), playlists: list(Sh.Comet.V0.Feed.Playlist.View.t())}} | {:error, any} + def main(params \\ %{}, opts \\ []) do + params + |> Map.take([:actor, :cursor, :limit]) + |> Atproto.ensure_required([:actor]) + |> Atproto.query("sh.comet.v0.feed.getActorPlaylists", opts) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex new file mode 100644 index 0000000..3250d14 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex @@ -0,0 +1,17 @@ +defmodule Sh.Comet.V0.Feed.GetActorTracks do + + @doc """ + Get a list of an actor's tracks. + """ + @spec main(%{ + actor: String.t(), + cursor: String.t(), + limit: integer + }, Atproto.xrpc_opts()) :: {:ok, %{cursor: String.t(), tracks: list(Sh.Comet.V0.Feed.Track.View.t())}} | {:error, any} + def main(params \\ %{}, opts \\ []) do + params + |> Map.take([:actor, :cursor, :limit]) + |> Atproto.ensure_required([:actor]) + |> Atproto.query("sh.comet.v0.feed.getActorTracks", opts) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex new file mode 100644 index 0000000..5fabb1f --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex @@ -0,0 +1,25 @@ +defmodule Sh.Comet.V0.Feed.Like do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + Record representing a 'like' of some media. Weakly linked with just an at-uri. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.like" do + field :createdAt, :utc_datetime + field :subject, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.like" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:createdAt, :subject]) + |> validate_required([:createdAt, :subject]) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex new file mode 100644 index 0000000..525f987 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex @@ -0,0 +1,25 @@ +defmodule Sh.Comet.V0.Feed.Play do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + Record representing a 'play' of some media. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.play" do + field :createdAt, :utc_datetime + field :subject, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.play" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:createdAt, :subject]) + |> validate_required([:createdAt, :subject]) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex new file mode 100644 index 0000000..09d1c4f --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex @@ -0,0 +1,32 @@ +defmodule Sh.Comet.V0.Feed.Playlist do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + A Comet playlist, containing many audio tracks. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.playlist" do + field :createdAt, :utc_datetime + field :description, :string + field :descriptionFacets, :map + field :image, :map + field :link, :map + field :tags, {:array, :string} + field :title, :string + field :type, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.playlist" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:createdAt, :description, :descriptionFacets, :image, :link, :tags, :title, :type]) + |> validate_required([:createdAt, :title, :type]) + |> validate_length(:tags, max: 8) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex new file mode 100644 index 0000000..8a57359 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex @@ -0,0 +1,44 @@ + +defmodule Sh.Comet.V0.Feed.Playlist.View do + @moduledoc """ + + """ + + @derive Jason.Encoder + defstruct [ + author: nil, + cid: nil, + commentCount: 0, + image: nil, + indexedAt: nil, + likeCount: 0, + record: nil, + repostCount: 0, + trackCount: 0, + tracks: [], + uri: nil, + viewer: nil + ] + + @type t() :: %__MODULE__{ + author: Sh.Comet.V0.Actor.Profile.ViewFull.t(), + cid: String.t(), + commentCount: integer, + image: String.t(), + indexedAt: DateTime.t(), + likeCount: integer, + record: Sh.Comet.V0.Feed.Playlist.Main.t(), + repostCount: integer, + trackCount: integer, + tracks: list(Sh.Comet.V0.Feed.Track.View.t()), + uri: String.t(), + viewer: Sh.Comet.V0.Feed.Defs.ViewerState.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex new file mode 100644 index 0000000..a8ddc15 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex @@ -0,0 +1,27 @@ +defmodule Sh.Comet.V0.Feed.PlaylistTrack do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + A link between a Comet track and a playlist. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.playlistTrack" do + field :playlist, :string + field :position, :integer + field :track, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.playlistTrack" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:playlist, :position, :track]) + |> validate_required([:playlist, :position, :track]) + |> validate_length(:position, min: 0) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex new file mode 100644 index 0000000..f8a169d --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex @@ -0,0 +1,25 @@ +defmodule Sh.Comet.V0.Feed.Repost do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + Record representing a 'repost' of some media. Weakly linked with just an at-uri. + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.repost" do + field :createdAt, :utc_datetime + field :subject, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.repost" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:createdAt, :subject]) + |> validate_required([:createdAt, :subject]) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex new file mode 100644 index 0000000..2495824 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex @@ -0,0 +1,32 @@ +defmodule Sh.Comet.V0.Feed.Track do + use Ecto.Schema + import Ecto.Changeset + + @doc """ + A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob? + """ + @primary_key {:id, :id, autogenerate: false} + schema "sh.comet.v0.feed.track" do + field :audio, :map + field :createdAt, :utc_datetime + field :description, :string + field :descriptionFacets, :map + field :image, :map + field :link, :map + field :tags, {:array, :string} + field :title, :string + + # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon. + # Ensure that you do not change this field via manual manipulation or changeset operations. + field :"$type", :string, default: "sh.comet.v0.feed.track" + end + + def new(params \\ %{}), do: changeset(%__MODULE__{}, params) + + def changeset(struct, params \\ %{}) do + struct + |> cast(params, [:audio, :createdAt, :description, :descriptionFacets, :image, :link, :tags, :title]) + |> validate_required([:audio, :createdAt, :title]) + |> validate_length(:tags, max: 8) + end +end diff --git a/apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex b/apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex new file mode 100644 index 0000000..3b611dd --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex @@ -0,0 +1,44 @@ + +defmodule Sh.Comet.V0.Feed.Track.View do + @moduledoc """ + + """ + + @derive Jason.Encoder + defstruct [ + audio: nil, + author: nil, + cid: nil, + commentCount: 0, + image: nil, + indexedAt: nil, + likeCount: 0, + playCount: 0, + record: nil, + repostCount: 0, + uri: nil, + viewer: nil + ] + + @type t() :: %__MODULE__{ + audio: String.t(), + author: Sh.Comet.V0.Actor.Profile.ViewFull.t(), + cid: String.t(), + commentCount: integer, + image: String.t(), + indexedAt: DateTime.t(), + likeCount: integer, + playCount: integer, + record: Sh.Comet.V0.Feed.Track.Main.t(), + repostCount: integer, + uri: String.t(), + viewer: Sh.Comet.V0.Feed.Defs.ViewerState.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + diff --git a/apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex b/apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex new file mode 100644 index 0000000..7ab3908 --- /dev/null +++ b/apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex @@ -0,0 +1,131 @@ + +defmodule Sh.Comet.V0.Richtext.Facet.Timestamp do + @moduledoc """ + Facet feature for a timestamp in a track. The text usually is in the format of 'hh:mm:ss' with the hour section being omitted if unnecessary. + """ + + @derive Jason.Encoder + defstruct [ + timestamp: 0 + ] + + @type t() :: %__MODULE__{ + timestamp: integer + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Richtext.Facet.Tag do + @moduledoc """ + Facet feature for a hashtag. The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags'). + """ + + @derive Jason.Encoder + defstruct [ + tag: nil + ] + + @type t() :: %__MODULE__{ + tag: String.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Richtext.Facet.Mention do + @moduledoc """ + Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID. + """ + + @derive Jason.Encoder + defstruct [ + did: nil + ] + + @type t() :: %__MODULE__{ + did: String.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Richtext.Facet.Main do + @moduledoc """ + Annotation of a sub-string within rich text. + """ + + @derive Jason.Encoder + defstruct [ + features: [], + index: nil + ] + + @type t() :: %__MODULE__{ + features: list(any), + index: Sh.Comet.V0.Richtext.Facet.ByteSlice.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Richtext.Facet.Link do + @moduledoc """ + Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL. + """ + + @derive Jason.Encoder + defstruct [ + uri: nil + ] + + @type t() :: %__MODULE__{ + uri: String.t() + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + +defmodule Sh.Comet.V0.Richtext.Facet.ByteSlice do + @moduledoc """ + Specifies the sub-string range a facet feature applies to. Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text. NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets. + """ + + @derive Jason.Encoder + defstruct [ + byteEnd: 0, + byteStart: 0 + ] + + @type t() :: %__MODULE__{ + byteEnd: integer, + byteStart: integer + } + + @spec new() :: t() + def new(), do: %__MODULE__{} + + @spec from(binary() | map()) :: t() + def from(json), do: Atproto.decode_to_struct(__MODULE__, json) +end + diff --git a/apps/backend/lib/atproto/tid.ex b/apps/backend/lib/atproto/tid.ex new file mode 100644 index 0000000..17c222d --- /dev/null +++ b/apps/backend/lib/atproto/tid.ex @@ -0,0 +1,105 @@ +defmodule Atproto.TID do + @moduledoc """ + A module for encoding and decoding TIDs. + + [TID](https://atproto.com/specs/tid) stands for "Timestamp Identifier". It is a 13-character string calculated from 53 bits representing a unix timestamp, in microsecond precision, plus 10 bits for an arbitrary "clock identifier", to help with uniqueness in distributed systems. + + The string is encoded as "base32-sortable", meaning that the characters for the base 32 encoding are set up in such a way that string comparisons yield the same result as integer comparisons, i.e. if the integer representation of the timestamp that creates TID "A" is greater than the integer representation of the timestamp that creates TID "B", then "A" > "B" is also true, and vice versa. + """ + + import Bitwise + + @tid_char_set ~c(234567abcdefghijklmnopqrstuvwxyz) + @tid_char_set_length 32 + + defstruct [ + :timestamp, + :clock_id, + :string + ] + + @typedoc """ + TIDs are composed of two parts: a timestamp and a clock identifier. They also have a human-readable string representation as a "base32-sortable" encoded string. + """ + @type t() :: %__MODULE__{ + timestamp: integer(), + clock_id: integer(), + string: binary() + } + + @doc """ + Generates a random 10-bit clock identifier. + """ + @spec random_clock_id() :: integer() + def random_clock_id(), do: :rand.uniform(1024) - 1 + + @doc """ + Generates a new TID for the current time. + + This is equivalent to calling `encode(nil)`. + """ + @spec new() :: t() + def new(), do: encode(nil) + + @doc """ + Encodes an integer or DateTime struct into a 13-character string that is "base32-sortable" encoded. + + If `timestamp` is nil, or not provided, the current time will be used as represented by `DateTime.utc_now()`. + + If `clock_id` is nil, or not provided, a random 10-bit integer will be used. + + If `timestamp` is an integer value, it *MUST* be a unix timestamp measured in microseconds. This function does not validate integer values. + """ + @spec encode(nil | integer() | DateTime.t(), nil | integer()) :: t() + def encode(timestamp \\ nil, clock_id \\ nil) + + def encode(nil, clock_id), do: encode(DateTime.utc_now(), clock_id) + + def encode(timestamp, nil), do: encode(timestamp, random_clock_id()) + + def encode(%DateTime{} = datetime, clock_id) do + datetime + |> DateTime.to_unix(:microsecond) + |> encode(clock_id) + end + + def encode(timestamp, clock_id) when is_integer(timestamp) and is_integer(clock_id) do + # Ensure we only use the lower 10 bit of clock_id + clock_id = clock_id &&& 1023 + str = + timestamp + |> bsr(10) + |> bsl(10) + |> bxor(clock_id) + |> do_encode("") + %__MODULE__{timestamp: timestamp, clock_id: clock_id, string: str} + end + + defp do_encode(0, acc), do: acc + + defp do_encode(number, acc) do + c = rem(number, @tid_char_set_length) + number = div(number, @tid_char_set_length) + do_encode(number, <> <> acc) + end + + @doc """ + Decodes a binary string into a TID struct. + """ + @spec decode(binary()) :: t() + def decode(tid) do + num = do_decode(tid, 0) + %__MODULE__{timestamp: bsr(num, 10), clock_id: num &&& 1023, string: tid} + end + + defp do_decode(<<>>, acc), do: acc + + defp do_decode(<>, acc) do + idx = Enum.find_index(@tid_char_set, fn x -> x == char end) + do_decode(rest, (acc * @tid_char_set_length) + idx) + end +end + +defimpl String.Chars, for: Atproto.TID do + def to_string(tid), do: tid.string +end diff --git a/apps/backend/lib/comet.ex b/apps/backend/lib/comet.ex new file mode 100644 index 0000000..5a34e3a --- /dev/null +++ b/apps/backend/lib/comet.ex @@ -0,0 +1,9 @@ +defmodule Comet do + @moduledoc """ + Comet keeps the contexts that define your domain + and business logic. + + Contexts are also responsible for managing your data, regardless + if it comes from the database, an external API or others. + """ +end diff --git a/apps/backend/lib/comet/application.ex b/apps/backend/lib/comet/application.ex new file mode 100644 index 0000000..fca162f --- /dev/null +++ b/apps/backend/lib/comet/application.ex @@ -0,0 +1,34 @@ +defmodule Comet.Application do + # See https://hexdocs.pm/elixir/Application.html + # for more information on OTP Applications + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = [ + CometWeb.Telemetry, + Comet.Repo, + {DNSCluster, query: Application.get_env(:comet, :dns_cluster_query) || :ignore}, + {Phoenix.PubSub, name: Comet.PubSub}, + # Start a worker by calling: Comet.Worker.start_link(arg) + # {Comet.Worker, arg}, + # Start to serve requests, typically the last entry + CometWeb.Endpoint + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Comet.Supervisor] + Supervisor.start_link(children, opts) + end + + # Tell Phoenix to update the endpoint configuration + # whenever the application is updated. + @impl true + def config_change(changed, _new, removed) do + CometWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/apps/backend/lib/comet/repo.ex b/apps/backend/lib/comet/repo.ex new file mode 100644 index 0000000..69da969 --- /dev/null +++ b/apps/backend/lib/comet/repo.ex @@ -0,0 +1,5 @@ +defmodule Comet.Repo do + use Ecto.Repo, + otp_app: :comet, + adapter: Ecto.Adapters.Postgres +end diff --git a/apps/backend/lib/comet_web.ex b/apps/backend/lib/comet_web.ex new file mode 100644 index 0000000..669d73f --- /dev/null +++ b/apps/backend/lib/comet_web.ex @@ -0,0 +1,65 @@ +defmodule CometWeb do + @moduledoc """ + The entrypoint for defining your web interface, such + as controllers, components, channels, and so on. + + This can be used in your application as: + + use CometWeb, :controller + use CometWeb, :html + + The definitions below will be executed for every controller, + component, etc, so keep them short and clean, focused + on imports, uses and aliases. + + Do NOT define functions inside the quoted expressions + below. Instead, define additional modules and import + those modules here. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + # Import common connection and controller functions to use in pipelines + import Plug.Conn + import Phoenix.Controller + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: CometWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: CometWeb.Endpoint, + router: CometWeb.Router, + statics: CometWeb.static_paths() + end + end + + @doc """ + When used, dispatch to the appropriate controller/live_view/etc. + """ + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/apps/backend/lib/comet_web/controllers/error_json.ex b/apps/backend/lib/comet_web/controllers/error_json.ex new file mode 100644 index 0000000..f761dd0 --- /dev/null +++ b/apps/backend/lib/comet_web/controllers/error_json.ex @@ -0,0 +1,21 @@ +defmodule CometWeb.ErrorJSON do + @moduledoc """ + This module is invoked by your endpoint in case of errors on JSON requests. + + See config/config.exs. + """ + + # If you want to customize a particular status code, + # you may add your own clauses, such as: + # + # def render("500.json", _assigns) do + # %{errors: %{detail: "Internal Server Error"}} + # end + + # By default, Phoenix returns the status message from + # the template name. For example, "404.json" becomes + # "Not Found". + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/apps/backend/lib/comet_web/endpoint.ex b/apps/backend/lib/comet_web/endpoint.ex new file mode 100644 index 0000000..c51b865 --- /dev/null +++ b/apps/backend/lib/comet_web/endpoint.ex @@ -0,0 +1,51 @@ +defmodule CometWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :comet + + # The session will be stored in the cookie and signed, + # this means its contents can be read but not tampered with. + # Set :encryption_salt if you would also like to encrypt it. + @session_options [ + store: :cookie, + key: "_comet_key", + signing_salt: "zgKytneJ", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + # Serve at "/" the static files from "priv/static" directory. + # + # You should set gzip to true if you are running phx.digest + # when deploying your static files in production. + plug Plug.Static, + at: "/", + from: :comet, + gzip: false, + only: CometWeb.static_paths() + + # Code reloading can be explicitly enabled under the + # :code_reloader configuration of your endpoint. + if code_reloading? do + plug Phoenix.CodeReloader + plug Phoenix.Ecto.CheckRepoStatus, otp_app: :comet + end + + plug Phoenix.LiveDashboard.RequestLogger, + param_key: "request_logger", + cookie_key: "request_logger" + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug CometWeb.Router +end diff --git a/apps/backend/lib/comet_web/router.ex b/apps/backend/lib/comet_web/router.ex new file mode 100644 index 0000000..28c7904 --- /dev/null +++ b/apps/backend/lib/comet_web/router.ex @@ -0,0 +1,27 @@ +defmodule CometWeb.Router do + use CometWeb, :router + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/api", CometWeb do + pipe_through :api + end + + # Enable LiveDashboard in development + if Application.compile_env(:comet, :dev_routes) do + # If you want to use the LiveDashboard in production, you should put + # it behind authentication and allow only admins to access it. + # If your application does not have an admins-only section yet, + # you can use Plug.BasicAuth to set up some basic authentication + # as long as you are also using SSL (which you should anyway). + import Phoenix.LiveDashboard.Router + + scope "/dev" do + pipe_through [:fetch_session, :protect_from_forgery] + + live_dashboard "/dashboard", metrics: CometWeb.Telemetry + end + end +end diff --git a/apps/backend/lib/comet_web/telemetry.ex b/apps/backend/lib/comet_web/telemetry.ex new file mode 100644 index 0000000..a917d72 --- /dev/null +++ b/apps/backend/lib/comet_web/telemetry.ex @@ -0,0 +1,93 @@ +defmodule CometWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + # Telemetry poller will execute the given period measurements + # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + # Add reporters as children of your supervision tree. + # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.start.system_time", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.exception.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + tags: [:route], + unit: {:native, :millisecond} + ), + summary("phoenix.socket_connected.duration", + unit: {:native, :millisecond} + ), + sum("phoenix.socket_drain.count"), + summary("phoenix.channel_joined.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.channel_handled_in.duration", + tags: [:event], + unit: {:native, :millisecond} + ), + + # Database Metrics + summary("comet.repo.query.total_time", + unit: {:native, :millisecond}, + description: "The sum of the other measurements" + ), + summary("comet.repo.query.decode_time", + unit: {:native, :millisecond}, + description: "The time spent decoding the data received from the database" + ), + summary("comet.repo.query.query_time", + unit: {:native, :millisecond}, + description: "The time spent executing the query" + ), + summary("comet.repo.query.queue_time", + unit: {:native, :millisecond}, + description: "The time spent waiting for a database connection" + ), + summary("comet.repo.query.idle_time", + unit: {:native, :millisecond}, + description: + "The time the connection spent waiting before being checked out for the query" + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :kilobyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [ + # A module, function and arguments to be invoked periodically. + # This function must call :telemetry.execute/3 and a metric must be added above. + # {CometWeb, :count_users, []} + ] + end +end diff --git a/apps/backend/mix.exs b/apps/backend/mix.exs new file mode 100644 index 0000000..de48ec0 --- /dev/null +++ b/apps/backend/mix.exs @@ -0,0 +1,68 @@ +defmodule Comet.MixProject do + use Mix.Project + + def project do + [ + app: :comet, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + # Configuration for the OTP application. + # + # Type `mix help compile.app` for more information. + def application do + [ + mod: {Comet.Application, []}, + extra_applications: [:logger, :runtime_tools] + ] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Specifies your project dependencies. + # + # Type `mix help deps` for examples and options. + defp deps do + [ + {:phoenix, "~> 1.7.21"}, + {:phoenix_ecto, "~> 4.5"}, + {:ecto_sql, "~> 3.10"}, + {:postgrex, ">= 0.0.0"}, + {:phoenix_live_dashboard, "~> 0.8.3"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:dns_cluster, "~> 0.1.1"}, + {:bandit, "~> 1.5"}, + {:lexgen, "~> 1.0.0", only: [:dev]}, + {:req, "~> 0.5.0"}, + {:typedstruct, "~> 0.5"} + ] + end + + # Aliases are shortcuts or tasks specific to the current project. + # For example, to install project dependencies and perform other setup tasks, run: + # + # $ mix setup + # + # See the documentation for `Mix` for more info on aliases. + defp aliases do + lexicon_paths = Path.wildcard("../../packages/lexicons/defs/**/*.json") + + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + "gen.lexicons": ["lexgen" | lexicon_paths] |> Enum.join(" ") + ] + end +end diff --git a/apps/backend/mix.lock b/apps/backend/mix.lock new file mode 100644 index 0000000..b05bdcf --- /dev/null +++ b/apps/backend/mix.lock @@ -0,0 +1,35 @@ +%{ + "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, + "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "lexgen": {:hex, :lexgen, "1.0.0", "1ca22ba00b86f9fa97718651b77b87a5965b8a9f71109ac2c11cb573f17499aa", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ff64e0e192645208e7ce1b6468037a6d4ebfb98a506ab15d30fb46ca492ec275"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [: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.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, + "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, + "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.12", "a37134b9bb3602efbfa5a7a8cb51d50e796f7acff7075af9d9796f30de04c66a", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "058e06e59fd38f1feeca59bbf167bec5d44aacd9b745e4363e2ac342ca32e546"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, + "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{: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", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, + "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"}, + "thousand_island": {:hex, :thousand_island, "1.3.13", "d598c609172275f7b1648c9f6eddf900e42312b09bfc2f2020358f926ee00d39", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5a34bdf24ae2f965ddf7ba1a416f3111cfe7df50de8d66f6310e01fc2e80b02a"}, + "typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"}, +} diff --git a/apps/backend/priv/repo/migrations/.formatter.exs b/apps/backend/priv/repo/migrations/.formatter.exs new file mode 100644 index 0000000..49f9151 --- /dev/null +++ b/apps/backend/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/apps/backend/priv/repo/seeds.exs b/apps/backend/priv/repo/seeds.exs new file mode 100644 index 0000000..c34ff81 --- /dev/null +++ b/apps/backend/priv/repo/seeds.exs @@ -0,0 +1,11 @@ +# Script for populating the database. You can run it as: +# +# mix run priv/repo/seeds.exs +# +# Inside the script, you can read and write to any of your +# repositories directly: +# +# Comet.Repo.insert!(%Comet.SomeSchema{}) +# +# We recommend using the bang functions (`insert!`, `update!` +# and so on) as they will fail if something goes wrong. diff --git a/apps/backend/priv/static/favicon.ico b/apps/backend/priv/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0y~yU~m9o4mJh`hE Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/apps/backend/test/test_helper.exs b/apps/backend/test/test_helper.exs new file mode 100644 index 0000000..abc4a9e --- /dev/null +++ b/apps/backend/test/test_helper.exs @@ -0,0 +1,2 @@ +ExUnit.start() +Ecto.Adapters.SQL.Sandbox.mode(Comet.Repo, :manual) diff --git a/flake.nix b/flake.nix index 1763cce..f73a9cf 100644 --- a/flake.nix +++ b/flake.nix @@ -15,7 +15,7 @@ in { devShells = defaultForSystems (pkgs: pkgs.mkShell { - nativeBuildInputs = with pkgs; [nodejs_22 bun]; + nativeBuildInputs = with pkgs; [nodejs_22 bun elixir erlang]; }); }; } -- 2.51.2