···11+# The directory Mix will write compiled artifacts to.
22+/_build/
33+44+# If you run "mix test --cover", coverage assets end up here.
55+/cover/
66+77+# The directory Mix downloads your dependencies sources to.
88+/deps/
99+1010+# Where 3rd-party dependencies like ExDoc output generated docs.
1111+/doc/
1212+1313+# Ignore .fetch files in case you like to edit your project deps locally.
1414+/.fetch
1515+1616+# If the VM crashes, it generates a dump, let's ignore it too.
1717+erl_crash.dump
1818+1919+# Also ignore archive artifacts (built via "mix archive.build").
2020+*.ez
2121+2222+# Temporary files, for example, from tests.
2323+/tmp/
2424+2525+# Ignore package tarball (built via "mix hex.build").
2626+comet-*.tar
2727+
+24
apps/backend/README.md
···11+# Comet AppView
22+33+[Phoenix](https://www.phoenixframework.org)-powered AppView for Comet.
44+55+---
66+77+To start your Phoenix server:
88+99+- Run `mix setup` to install and setup dependencies
1010+- Start Phoenix endpoint with `mix phx.server` or inside IEx with
1111+ `iex -S mix phx.server`
1212+1313+Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
1414+1515+Ready to run in production? Please
1616+[check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
1717+1818+## Learn more
1919+2020+- Official website: https://www.phoenixframework.org/
2121+- Guides: https://hexdocs.pm/phoenix/overview.html
2222+- Docs: https://hexdocs.pm/phoenix
2323+- Forum: https://elixirforum.com/c/phoenix-forum
2424+- Source: https://github.com/phoenixframework/phoenix
+35
apps/backend/config/config.exs
···11+# This file is responsible for configuring your application
22+# and its dependencies with the aid of the Config module.
33+#
44+# This configuration file is loaded before any dependency and
55+# is restricted to this project.
66+77+# General application configuration
88+import Config
99+1010+config :comet,
1111+ ecto_repos: [Comet.Repo],
1212+ generators: [timestamp_type: :utc_datetime, binary_id: true]
1313+1414+# Configures the endpoint
1515+config :comet, CometWeb.Endpoint,
1616+ url: [host: "localhost"],
1717+ adapter: Bandit.PhoenixAdapter,
1818+ render_errors: [
1919+ formats: [json: CometWeb.ErrorJSON],
2020+ layout: false
2121+ ],
2222+ pubsub_server: Comet.PubSub,
2323+ live_view: [signing_salt: "oq2xYeBj"]
2424+2525+# Configures Elixir's Logger
2626+config :logger, :console,
2727+ format: "$time $metadata[$level] $message\n",
2828+ metadata: [:request_id]
2929+3030+# Use Jason for JSON parsing in Phoenix
3131+config :phoenix, :json_library, Jason
3232+3333+# Import environment specific config. This must remain at the bottom
3434+# of this file so it overrides the configuration defined above.
3535+import_config "#{config_env()}.exs"
+63
apps/backend/config/dev.exs
···11+import Config
22+33+# Configure your database
44+config :comet, Comet.Repo,
55+ username: "postgres",
66+ password: "postgres",
77+ hostname: "localhost",
88+ database: "comet_dev",
99+ stacktrace: true,
1010+ show_sensitive_data_on_connection_error: true,
1111+ pool_size: 10
1212+1313+# For development, we disable any cache and enable
1414+# debugging and code reloading.
1515+#
1616+# The watchers configuration can be used to run external
1717+# watchers to your application. For example, we can use it
1818+# to bundle .js and .css sources.
1919+config :comet, CometWeb.Endpoint,
2020+ # Binding to loopback ipv4 address prevents access from other machines.
2121+ # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
2222+ http: [ip: {127, 0, 0, 1}, port: 4000],
2323+ check_origin: false,
2424+ code_reloader: true,
2525+ debug_errors: true,
2626+ secret_key_base: "Vw9UaVO8YBKiooaOlZ2Rhx7xJHydL9s2YIviOwiiQz8Cy24+mLBB3Fj+9jvOIdQE",
2727+ watchers: []
2828+2929+# ## SSL Support
3030+#
3131+# In order to use HTTPS in development, a self-signed
3232+# certificate can be generated by running the following
3333+# Mix task:
3434+#
3535+# mix phx.gen.cert
3636+#
3737+# Run `mix help phx.gen.cert` for more information.
3838+#
3939+# The `http:` config above can be replaced with:
4040+#
4141+# https: [
4242+# port: 4001,
4343+# cipher_suite: :strong,
4444+# keyfile: "priv/cert/selfsigned_key.pem",
4545+# certfile: "priv/cert/selfsigned.pem"
4646+# ],
4747+#
4848+# If desired, both `http:` and `https:` keys can be
4949+# configured to run both http and https servers on
5050+# different ports.
5151+5252+# Enable dev routes for dashboard and mailbox
5353+config :comet, dev_routes: true
5454+5555+# Do not include metadata nor timestamps in development logs
5656+config :logger, :console, format: "[$level] $message\n"
5757+5858+# Set a higher stacktrace during development. Avoid configuring such
5959+# in production as building large stacktraces may be expensive.
6060+config :phoenix, :stacktrace_depth, 20
6161+6262+# Initialize plugs at runtime for faster development compilation
6363+config :phoenix, :plug_init_mode, :runtime
+7
apps/backend/config/prod.exs
···11+import Config
22+33+# Do not print debug messages in production
44+config :logger, level: :info
55+66+# Runtime production configuration, including reading
77+# of environment variables, is done on config/runtime.exs.
+99
apps/backend/config/runtime.exs
···11+import Config
22+33+# config/runtime.exs is executed for all environments, including
44+# during releases. It is executed after compilation and before the
55+# system starts, so it is typically used to load production configuration
66+# and secrets from environment variables or elsewhere. Do not define
77+# any compile-time configuration in here, as it won't be applied.
88+# The block below contains prod specific runtime configuration.
99+1010+# ## Using releases
1111+#
1212+# If you use `mix release`, you need to explicitly enable the server
1313+# by passing the PHX_SERVER=true when you start it:
1414+#
1515+# PHX_SERVER=true bin/comet start
1616+#
1717+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
1818+# script that automatically sets the env var above.
1919+if System.get_env("PHX_SERVER") do
2020+ config :comet, CometWeb.Endpoint, server: true
2121+end
2222+2323+if config_env() == :prod do
2424+ database_url =
2525+ System.get_env("DATABASE_URL") ||
2626+ raise """
2727+ environment variable DATABASE_URL is missing.
2828+ For example: ecto://USER:PASS@HOST/DATABASE
2929+ """
3030+3131+ maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
3232+3333+ config :comet, Comet.Repo,
3434+ # ssl: true,
3535+ url: database_url,
3636+ pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
3737+ socket_options: maybe_ipv6
3838+3939+ # The secret key base is used to sign/encrypt cookies and other secrets.
4040+ # A default value is used in config/dev.exs and config/test.exs but you
4141+ # want to use a different value for prod and you most likely don't want
4242+ # to check this value into version control, so we use an environment
4343+ # variable instead.
4444+ secret_key_base =
4545+ System.get_env("SECRET_KEY_BASE") ||
4646+ raise """
4747+ environment variable SECRET_KEY_BASE is missing.
4848+ You can generate one by calling: mix phx.gen.secret
4949+ """
5050+5151+ host = System.get_env("PHX_HOST") || "example.com"
5252+ port = String.to_integer(System.get_env("PORT") || "4000")
5353+5454+ config :comet, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
5555+5656+ config :comet, CometWeb.Endpoint,
5757+ url: [host: host, port: 443, scheme: "https"],
5858+ http: [
5959+ # Enable IPv6 and bind on all interfaces.
6060+ # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
6161+ # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
6262+ # for details about using IPv6 vs IPv4 and loopback vs public addresses.
6363+ ip: {0, 0, 0, 0, 0, 0, 0, 0},
6464+ port: port
6565+ ],
6666+ secret_key_base: secret_key_base
6767+6868+ # ## SSL Support
6969+ #
7070+ # To get SSL working, you will need to add the `https` key
7171+ # to your endpoint configuration:
7272+ #
7373+ # config :comet, CometWeb.Endpoint,
7474+ # https: [
7575+ # ...,
7676+ # port: 443,
7777+ # cipher_suite: :strong,
7878+ # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
7979+ # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
8080+ # ]
8181+ #
8282+ # The `cipher_suite` is set to `:strong` to support only the
8383+ # latest and more secure SSL ciphers. This means old browsers
8484+ # and clients may not be supported. You can set it to
8585+ # `:compatible` for wider support.
8686+ #
8787+ # `:keyfile` and `:certfile` expect an absolute path to the key
8888+ # and cert in disk or a relative path inside priv, for example
8989+ # "priv/ssl/server.key". For all supported SSL configuration
9090+ # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
9191+ #
9292+ # We also recommend setting `force_ssl` in your config/prod.exs,
9393+ # ensuring no data is ever sent via http, always redirecting to https:
9494+ #
9595+ # config :comet, CometWeb.Endpoint,
9696+ # force_ssl: [hsts: true]
9797+ #
9898+ # Check `Plug.SSL` for all available options in `force_ssl`.
9999+end
+27
apps/backend/config/test.exs
···11+import Config
22+33+# Configure your database
44+#
55+# The MIX_TEST_PARTITION environment variable can be used
66+# to provide built-in test partitioning in CI environment.
77+# Run `mix help test` for more information.
88+config :comet, Comet.Repo,
99+ username: "postgres",
1010+ password: "postgres",
1111+ hostname: "localhost",
1212+ database: "comet_test#{System.get_env("MIX_TEST_PARTITION")}",
1313+ pool: Ecto.Adapters.SQL.Sandbox,
1414+ pool_size: System.schedulers_online() * 2
1515+1616+# We don't run a server during test. If one is required,
1717+# you can enable the server option below.
1818+config :comet, CometWeb.Endpoint,
1919+ http: [ip: {127, 0, 0, 1}, port: 4002],
2020+ secret_key_base: "eaG5CrPmVserxnUlu8DyG8I6i3m3TBDOi8fsKn2niwYUMhjps0YkWWMGRnoSXvGf",
2121+ server: false
2222+2323+# Print only warnings and errors during test
2424+config :logger, level: :warning
2525+2626+# Initialize plugs at runtime for faster test compilation
2727+config :phoenix, :plug_init_mode, :runtime
+169
apps/backend/lib/atproto/atproto.ex
···11+# AUTOGENERATED: This file was generated using the mix task `lexgen`.
22+defmodule Atproto do
33+ @default_pds_hostname Application.compile_env!(:comet, :default_pds_hostname)
44+55+ @typedoc """
66+ A type representing the names of the options that can be passed to `query/3` and `procedure/3`.
77+ """
88+ @type xrpc_opt :: :pds_hostname | :authorization
99+1010+ @typedoc """
1111+ A keyword list of options that can be passed to `query/3` and `procedure/3`.
1212+ """
1313+ @type xrpc_opts :: [{xrpc_opt(), any()}]
1414+1515+ @doc """
1616+ Converts a JSON string, or decoded JSON map, into a struct based on the given module.
1717+1818+ 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.
1919+ """
2020+ @spec decode_to_struct(module(), binary() | map()) :: map()
2121+ def decode_to_struct(module, json) when is_binary(json) do
2222+ decode_to_struct(module, Jason.decode!(json, keys: :atoms!))
2323+ end
2424+2525+ def decode_to_struct(module, map) when is_map(map) do
2626+ Map.merge(module.new(), map)
2727+ end
2828+2929+ @doc """
3030+ Raises an error if any required parameters are missing from the given map.
3131+ """
3232+ @spec ensure_required(map(), [String.t()]) :: map()
3333+ def ensure_required(params, required) do
3434+ if Enum.all?(required, fn key -> Map.has_key?(params, key) end) do
3535+ params
3636+ else
3737+ raise ArgumentError, "Missing one or more required parameters: #{Enum.join(required, ", ")}"
3838+ end
3939+ end
4040+4141+ @doc """
4242+ Executes a "GET" HTTP request and returns the response body as a map.
4343+4444+ If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
4545+ """
4646+ @spec query(map(), String.t(), xrpc_opts()) :: Req.Request.t()
4747+ def query(params, target, opts \\ []) do
4848+ target
4949+ |> endpoint(opts)
5050+ |> URI.new!()
5151+ |> URI.append_query(URI.encode_query(params))
5252+ |> Req.get(build_req_auth(opts))
5353+ |> handle_response(opts)
5454+ end
5555+5656+ @doc """
5757+ Executes a "POST" HTTP request and returns the response body as a map.
5858+5959+ If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
6060+ """
6161+ @spec procedure(map(), String.t(), xrpc_opts()) :: {:ok | :refresh | :error, map()}
6262+ def procedure(params, target, opts \\ []) do
6363+ req_opts =
6464+ opts
6565+ |> build_req_auth()
6666+ |> build_req_headers(opts, target)
6767+ |> build_req_body(params, target)
6868+6969+ target
7070+ |> endpoint(opts)
7171+ |> URI.new!()
7272+ |> Req.post(req_opts)
7373+ |> handle_response(opts)
7474+ end
7575+7676+ defp build_req_auth(opts) do
7777+ case Keyword.get(opts, :access_token) do
7878+ nil ->
7979+ case Keyword.get(opts, :admin_token) do
8080+ nil ->
8181+ []
8282+8383+ token ->
8484+ [auth: {:basic, "admin:#{token}"}]
8585+ end
8686+8787+ token ->
8888+ [auth: {:bearer, token}]
8989+ end
9090+ end
9191+9292+ defp build_req_headers(req_opts, opts, "com.atproto.repo.uploadBlob") do
9393+ [
9494+ {:headers,
9595+ [
9696+ {"Content-Type", Keyword.fetch!(opts, :content_type)},
9797+ {"Content-Length", Keyword.fetch!(opts, :content_length)}
9898+ ]}
9999+ | req_opts
100100+ ]
101101+ end
102102+103103+ defp build_req_headers(req_opts, _opts, _target), do: req_opts
104104+105105+ defp build_req_body(opts, blob, "com.atproto.repo.uploadBlob") do
106106+ [{:body, blob} | opts]
107107+ end
108108+109109+ defp build_req_body(opts, %{} = params, _target) when map_size(params) > 0 do
110110+ [{:json, params} | opts]
111111+ end
112112+113113+ defp build_req_body(opts, _params, _target), do: opts
114114+115115+ defp endpoint(target, opts) do
116116+ (Keyword.get(opts, :pds_hostname) || @default_pds_hostname) <> "/xrpc/" <> target
117117+ end
118118+119119+ defp handle_response({:ok, %Req.Response{} = response}, opts) do
120120+ case response.status do
121121+ x when x in 200..299 ->
122122+ {:ok, response.body}
123123+124124+ _ ->
125125+ if response.body["error"] == "ExpiredToken" do
126126+ {:ok, user} =
127127+ Com.Atproto.Server.RefreshSession.main(%{},
128128+ access_token: Keyword.get(opts, :refresh_token)
129129+ )
130130+131131+ {:refresh, user}
132132+ else
133133+ {:error, response.body}
134134+ end
135135+ end
136136+ end
137137+138138+ defp handle_response(error, _opts), do: error
139139+140140+ @doc """
141141+ Converts a "map-like" entity into a standard map. This will also omit any entries that have a `nil` value.
142142+143143+ This is useful for converting structs or schemas into regular maps before sending them over XRPC requests.
144144+145145+ You may optionally pass in an keyword list of options:
146146+147147+ - `:stringify` - `boolean` - If `true`, converts the keys to strings. Otherwise, converts keys to atoms. Default is `false`.
148148+ - *Note*: When `false`, this feature uses the `to_existing_atom/1` function to avoid reckless conversion of string keys.
149149+ """
150150+ @spec to_map(map() | struct()) :: map()
151151+ def to_map(%{__struct__: _} = m, opts \\ []) do
152152+ string_keys = Keyword.get(opts, :stringify, false)
153153+154154+ m
155155+ |> Map.drop([:__struct__, :__meta__])
156156+ |> Enum.map(fn
157157+ {_, nil} ->
158158+ nil
159159+160160+ {k, v} when is_atom(k) ->
161161+ if string_keys, do: {to_string(k), v}, else: {k, v}
162162+163163+ {k, v} when is_binary(k) ->
164164+ if string_keys, do: {k, v}, else: {String.to_existing_atom(k), v}
165165+ end)
166166+ |> Enum.reject(&is_nil/1)
167167+ |> Enum.into(%{})
168168+ end
169169+end
···11+defmodule Sh.Comet.V0.Actor.Profile do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ A user's Comet profile.
77+ """
88+ @primary_key {:id, :binary_id, autogenerate: false}
99+ schema "sh.comet.v0.actor.profile" do
1010+ field :avatar, :map
1111+ field :banner, :map
1212+ field :createdAt, :utc_datetime
1313+ field :description, :string
1414+ field :descriptionFacets, :map
1515+ field :displayName, :string
1616+ field :featuredItems, {:array, :string}
1717+1818+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1919+ # Ensure that you do not change this field via manual manipulation or changeset operations.
2020+ field :"$type", :string, default: "sh.comet.v0.actor.profile"
2121+ end
2222+2323+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2424+2525+ def changeset(struct, params \\ %{}) do
2626+ struct
2727+ |> cast(params, [:avatar, :banner, :createdAt, :description, :descriptionFacets, :displayName, :featuredItems])
2828+ |> validate_length(:featuredItems, max: 5)
2929+ end
3030+end
···11+defmodule Sh.Comet.V0.Feed.Comment do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ A comment on a piece of Comet media.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.comment" do
1010+ field :createdAt, :utc_datetime
1111+ field :facets, {:array, :map}
1212+ field :langs, {:array, :string}
1313+ field :reply, :string
1414+ field :subject, :string
1515+ field :text, :string
1616+1717+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1818+ # Ensure that you do not change this field via manual manipulation or changeset operations.
1919+ field :"$type", :string, default: "sh.comet.v0.feed.comment"
2020+ end
2121+2222+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2323+2424+ def changeset(struct, params \\ %{}) do
2525+ struct
2626+ |> cast(params, [:createdAt, :facets, :langs, :reply, :subject, :text])
2727+ |> validate_required([:createdAt, :subject, :text])
2828+ |> validate_length(:langs, max: 3)
2929+ end
3030+end
···11+defmodule Sh.Comet.V0.Feed.Like do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ Record representing a 'like' of some media. Weakly linked with just an at-uri.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.like" do
1010+ field :createdAt, :utc_datetime
1111+ field :subject, :string
1212+1313+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414+ # Ensure that you do not change this field via manual manipulation or changeset operations.
1515+ field :"$type", :string, default: "sh.comet.v0.feed.like"
1616+ end
1717+1818+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919+2020+ def changeset(struct, params \\ %{}) do
2121+ struct
2222+ |> cast(params, [:createdAt, :subject])
2323+ |> validate_required([:createdAt, :subject])
2424+ end
2525+end
···11+defmodule Sh.Comet.V0.Feed.Play do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ Record representing a 'play' of some media.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.play" do
1010+ field :createdAt, :utc_datetime
1111+ field :subject, :string
1212+1313+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414+ # Ensure that you do not change this field via manual manipulation or changeset operations.
1515+ field :"$type", :string, default: "sh.comet.v0.feed.play"
1616+ end
1717+1818+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919+2020+ def changeset(struct, params \\ %{}) do
2121+ struct
2222+ |> cast(params, [:createdAt, :subject])
2323+ |> validate_required([:createdAt, :subject])
2424+ end
2525+end
···11+defmodule Sh.Comet.V0.Feed.Playlist do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ A Comet playlist, containing many audio tracks.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.playlist" do
1010+ field :createdAt, :utc_datetime
1111+ field :description, :string
1212+ field :descriptionFacets, :map
1313+ field :image, :map
1414+ field :link, :map
1515+ field :tags, {:array, :string}
1616+ field :title, :string
1717+ field :type, :string
1818+1919+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
2020+ # Ensure that you do not change this field via manual manipulation or changeset operations.
2121+ field :"$type", :string, default: "sh.comet.v0.feed.playlist"
2222+ end
2323+2424+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525+2626+ def changeset(struct, params \\ %{}) do
2727+ struct
2828+ |> cast(params, [:createdAt, :description, :descriptionFacets, :image, :link, :tags, :title, :type])
2929+ |> validate_required([:createdAt, :title, :type])
3030+ |> validate_length(:tags, max: 8)
3131+ end
3232+end
···11+defmodule Sh.Comet.V0.Feed.PlaylistTrack do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ A link between a Comet track and a playlist.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.playlistTrack" do
1010+ field :playlist, :string
1111+ field :position, :integer
1212+ field :track, :string
1313+1414+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1515+ # Ensure that you do not change this field via manual manipulation or changeset operations.
1616+ field :"$type", :string, default: "sh.comet.v0.feed.playlistTrack"
1717+ end
1818+1919+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2020+2121+ def changeset(struct, params \\ %{}) do
2222+ struct
2323+ |> cast(params, [:playlist, :position, :track])
2424+ |> validate_required([:playlist, :position, :track])
2525+ |> validate_length(:position, min: 0)
2626+ end
2727+end
···11+defmodule Sh.Comet.V0.Feed.Repost do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ Record representing a 'repost' of some media. Weakly linked with just an at-uri.
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.repost" do
1010+ field :createdAt, :utc_datetime
1111+ field :subject, :string
1212+1313+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
1414+ # Ensure that you do not change this field via manual manipulation or changeset operations.
1515+ field :"$type", :string, default: "sh.comet.v0.feed.repost"
1616+ end
1717+1818+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
1919+2020+ def changeset(struct, params \\ %{}) do
2121+ struct
2222+ |> cast(params, [:createdAt, :subject])
2323+ |> validate_required([:createdAt, :subject])
2424+ end
2525+end
···11+defmodule Sh.Comet.V0.Feed.Track do
22+ use Ecto.Schema
33+ import Ecto.Changeset
44+55+ @doc """
66+ A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?
77+ """
88+ @primary_key {:id, :id, autogenerate: false}
99+ schema "sh.comet.v0.feed.track" do
1010+ field :audio, :map
1111+ field :createdAt, :utc_datetime
1212+ field :description, :string
1313+ field :descriptionFacets, :map
1414+ field :image, :map
1515+ field :link, :map
1616+ field :tags, {:array, :string}
1717+ field :title, :string
1818+1919+ # DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
2020+ # Ensure that you do not change this field via manual manipulation or changeset operations.
2121+ field :"$type", :string, default: "sh.comet.v0.feed.track"
2222+ end
2323+2424+ def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
2525+2626+ def changeset(struct, params \\ %{}) do
2727+ struct
2828+ |> cast(params, [:audio, :createdAt, :description, :descriptionFacets, :image, :link, :tags, :title])
2929+ |> validate_required([:audio, :createdAt, :title])
3030+ |> validate_length(:tags, max: 8)
3131+ end
3232+end
···11+22+defmodule Sh.Comet.V0.Richtext.Facet.Timestamp do
33+ @moduledoc """
44+ 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.
55+ """
66+77+ @derive Jason.Encoder
88+ defstruct [
99+ timestamp: 0
1010+ ]
1111+1212+ @type t() :: %__MODULE__{
1313+ timestamp: integer
1414+ }
1515+1616+ @spec new() :: t()
1717+ def new(), do: %__MODULE__{}
1818+1919+ @spec from(binary() | map()) :: t()
2020+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
2121+end
2222+2323+defmodule Sh.Comet.V0.Richtext.Facet.Tag do
2424+ @moduledoc """
2525+ 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').
2626+ """
2727+2828+ @derive Jason.Encoder
2929+ defstruct [
3030+ tag: nil
3131+ ]
3232+3333+ @type t() :: %__MODULE__{
3434+ tag: String.t()
3535+ }
3636+3737+ @spec new() :: t()
3838+ def new(), do: %__MODULE__{}
3939+4040+ @spec from(binary() | map()) :: t()
4141+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
4242+end
4343+4444+defmodule Sh.Comet.V0.Richtext.Facet.Mention do
4545+ @moduledoc """
4646+ Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.
4747+ """
4848+4949+ @derive Jason.Encoder
5050+ defstruct [
5151+ did: nil
5252+ ]
5353+5454+ @type t() :: %__MODULE__{
5555+ did: String.t()
5656+ }
5757+5858+ @spec new() :: t()
5959+ def new(), do: %__MODULE__{}
6060+6161+ @spec from(binary() | map()) :: t()
6262+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
6363+end
6464+6565+defmodule Sh.Comet.V0.Richtext.Facet.Main do
6666+ @moduledoc """
6767+ Annotation of a sub-string within rich text.
6868+ """
6969+7070+ @derive Jason.Encoder
7171+ defstruct [
7272+ features: [],
7373+ index: nil
7474+ ]
7575+7676+ @type t() :: %__MODULE__{
7777+ features: list(any),
7878+ index: Sh.Comet.V0.Richtext.Facet.ByteSlice.t()
7979+ }
8080+8181+ @spec new() :: t()
8282+ def new(), do: %__MODULE__{}
8383+8484+ @spec from(binary() | map()) :: t()
8585+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
8686+end
8787+8888+defmodule Sh.Comet.V0.Richtext.Facet.Link do
8989+ @moduledoc """
9090+ Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.
9191+ """
9292+9393+ @derive Jason.Encoder
9494+ defstruct [
9595+ uri: nil
9696+ ]
9797+9898+ @type t() :: %__MODULE__{
9999+ uri: String.t()
100100+ }
101101+102102+ @spec new() :: t()
103103+ def new(), do: %__MODULE__{}
104104+105105+ @spec from(binary() | map()) :: t()
106106+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
107107+end
108108+109109+defmodule Sh.Comet.V0.Richtext.Facet.ByteSlice do
110110+ @moduledoc """
111111+ 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.
112112+ """
113113+114114+ @derive Jason.Encoder
115115+ defstruct [
116116+ byteEnd: 0,
117117+ byteStart: 0
118118+ ]
119119+120120+ @type t() :: %__MODULE__{
121121+ byteEnd: integer,
122122+ byteStart: integer
123123+ }
124124+125125+ @spec new() :: t()
126126+ def new(), do: %__MODULE__{}
127127+128128+ @spec from(binary() | map()) :: t()
129129+ def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
130130+end
131131+
+105
apps/backend/lib/atproto/tid.ex
···11+defmodule Atproto.TID do
22+ @moduledoc """
33+ A module for encoding and decoding TIDs.
44+55+ [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.
66+77+ 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.
88+ """
99+1010+ import Bitwise
1111+1212+ @tid_char_set ~c(234567abcdefghijklmnopqrstuvwxyz)
1313+ @tid_char_set_length 32
1414+1515+ defstruct [
1616+ :timestamp,
1717+ :clock_id,
1818+ :string
1919+ ]
2020+2121+ @typedoc """
2222+ 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.
2323+ """
2424+ @type t() :: %__MODULE__{
2525+ timestamp: integer(),
2626+ clock_id: integer(),
2727+ string: binary()
2828+ }
2929+3030+ @doc """
3131+ Generates a random 10-bit clock identifier.
3232+ """
3333+ @spec random_clock_id() :: integer()
3434+ def random_clock_id(), do: :rand.uniform(1024) - 1
3535+3636+ @doc """
3737+ Generates a new TID for the current time.
3838+3939+ This is equivalent to calling `encode(nil)`.
4040+ """
4141+ @spec new() :: t()
4242+ def new(), do: encode(nil)
4343+4444+ @doc """
4545+ Encodes an integer or DateTime struct into a 13-character string that is "base32-sortable" encoded.
4646+4747+ If `timestamp` is nil, or not provided, the current time will be used as represented by `DateTime.utc_now()`.
4848+4949+ If `clock_id` is nil, or not provided, a random 10-bit integer will be used.
5050+5151+ If `timestamp` is an integer value, it *MUST* be a unix timestamp measured in microseconds. This function does not validate integer values.
5252+ """
5353+ @spec encode(nil | integer() | DateTime.t(), nil | integer()) :: t()
5454+ def encode(timestamp \\ nil, clock_id \\ nil)
5555+5656+ def encode(nil, clock_id), do: encode(DateTime.utc_now(), clock_id)
5757+5858+ def encode(timestamp, nil), do: encode(timestamp, random_clock_id())
5959+6060+ def encode(%DateTime{} = datetime, clock_id) do
6161+ datetime
6262+ |> DateTime.to_unix(:microsecond)
6363+ |> encode(clock_id)
6464+ end
6565+6666+ def encode(timestamp, clock_id) when is_integer(timestamp) and is_integer(clock_id) do
6767+ # Ensure we only use the lower 10 bit of clock_id
6868+ clock_id = clock_id &&& 1023
6969+ str =
7070+ timestamp
7171+ |> bsr(10)
7272+ |> bsl(10)
7373+ |> bxor(clock_id)
7474+ |> do_encode("")
7575+ %__MODULE__{timestamp: timestamp, clock_id: clock_id, string: str}
7676+ end
7777+7878+ defp do_encode(0, acc), do: acc
7979+8080+ defp do_encode(number, acc) do
8181+ c = rem(number, @tid_char_set_length)
8282+ number = div(number, @tid_char_set_length)
8383+ do_encode(number, <<Enum.at(@tid_char_set, c)>> <> acc)
8484+ end
8585+8686+ @doc """
8787+ Decodes a binary string into a TID struct.
8888+ """
8989+ @spec decode(binary()) :: t()
9090+ def decode(tid) do
9191+ num = do_decode(tid, 0)
9292+ %__MODULE__{timestamp: bsr(num, 10), clock_id: num &&& 1023, string: tid}
9393+ end
9494+9595+ defp do_decode(<<>>, acc), do: acc
9696+9797+ defp do_decode(<<char::utf8, rest::binary>>, acc) do
9898+ idx = Enum.find_index(@tid_char_set, fn x -> x == char end)
9999+ do_decode(rest, (acc * @tid_char_set_length) + idx)
100100+ end
101101+end
102102+103103+defimpl String.Chars, for: Atproto.TID do
104104+ def to_string(tid), do: tid.string
105105+end
+9
apps/backend/lib/comet.ex
···11+defmodule Comet do
22+ @moduledoc """
33+ Comet keeps the contexts that define your domain
44+ and business logic.
55+66+ Contexts are also responsible for managing your data, regardless
77+ if it comes from the database, an external API or others.
88+ """
99+end
+34
apps/backend/lib/comet/application.ex
···11+defmodule Comet.Application do
22+ # See https://hexdocs.pm/elixir/Application.html
33+ # for more information on OTP Applications
44+ @moduledoc false
55+66+ use Application
77+88+ @impl true
99+ def start(_type, _args) do
1010+ children = [
1111+ CometWeb.Telemetry,
1212+ Comet.Repo,
1313+ {DNSCluster, query: Application.get_env(:comet, :dns_cluster_query) || :ignore},
1414+ {Phoenix.PubSub, name: Comet.PubSub},
1515+ # Start a worker by calling: Comet.Worker.start_link(arg)
1616+ # {Comet.Worker, arg},
1717+ # Start to serve requests, typically the last entry
1818+ CometWeb.Endpoint
1919+ ]
2020+2121+ # See https://hexdocs.pm/elixir/Supervisor.html
2222+ # for other strategies and supported options
2323+ opts = [strategy: :one_for_one, name: Comet.Supervisor]
2424+ Supervisor.start_link(children, opts)
2525+ end
2626+2727+ # Tell Phoenix to update the endpoint configuration
2828+ # whenever the application is updated.
2929+ @impl true
3030+ def config_change(changed, _new, removed) do
3131+ CometWeb.Endpoint.config_change(changed, removed)
3232+ :ok
3333+ end
3434+end
+5
apps/backend/lib/comet/repo.ex
···11+defmodule Comet.Repo do
22+ use Ecto.Repo,
33+ otp_app: :comet,
44+ adapter: Ecto.Adapters.Postgres
55+end
+65
apps/backend/lib/comet_web.ex
···11+defmodule CometWeb do
22+ @moduledoc """
33+ The entrypoint for defining your web interface, such
44+ as controllers, components, channels, and so on.
55+66+ This can be used in your application as:
77+88+ use CometWeb, :controller
99+ use CometWeb, :html
1010+1111+ The definitions below will be executed for every controller,
1212+ component, etc, so keep them short and clean, focused
1313+ on imports, uses and aliases.
1414+1515+ Do NOT define functions inside the quoted expressions
1616+ below. Instead, define additional modules and import
1717+ those modules here.
1818+ """
1919+2020+ def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
2121+2222+ def router do
2323+ quote do
2424+ use Phoenix.Router, helpers: false
2525+2626+ # Import common connection and controller functions to use in pipelines
2727+ import Plug.Conn
2828+ import Phoenix.Controller
2929+ end
3030+ end
3131+3232+ def channel do
3333+ quote do
3434+ use Phoenix.Channel
3535+ end
3636+ end
3737+3838+ def controller do
3939+ quote do
4040+ use Phoenix.Controller,
4141+ formats: [:html, :json],
4242+ layouts: [html: CometWeb.Layouts]
4343+4444+ import Plug.Conn
4545+4646+ unquote(verified_routes())
4747+ end
4848+ end
4949+5050+ def verified_routes do
5151+ quote do
5252+ use Phoenix.VerifiedRoutes,
5353+ endpoint: CometWeb.Endpoint,
5454+ router: CometWeb.Router,
5555+ statics: CometWeb.static_paths()
5656+ end
5757+ end
5858+5959+ @doc """
6060+ When used, dispatch to the appropriate controller/live_view/etc.
6161+ """
6262+ defmacro __using__(which) when is_atom(which) do
6363+ apply(__MODULE__, which, [])
6464+ end
6565+end
···11+defmodule CometWeb.ErrorJSON do
22+ @moduledoc """
33+ This module is invoked by your endpoint in case of errors on JSON requests.
44+55+ See config/config.exs.
66+ """
77+88+ # If you want to customize a particular status code,
99+ # you may add your own clauses, such as:
1010+ #
1111+ # def render("500.json", _assigns) do
1212+ # %{errors: %{detail: "Internal Server Error"}}
1313+ # end
1414+1515+ # By default, Phoenix returns the status message from
1616+ # the template name. For example, "404.json" becomes
1717+ # "Not Found".
1818+ def render(template, _assigns) do
1919+ %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
2020+ end
2121+end
+51
apps/backend/lib/comet_web/endpoint.ex
···11+defmodule CometWeb.Endpoint do
22+ use Phoenix.Endpoint, otp_app: :comet
33+44+ # The session will be stored in the cookie and signed,
55+ # this means its contents can be read but not tampered with.
66+ # Set :encryption_salt if you would also like to encrypt it.
77+ @session_options [
88+ store: :cookie,
99+ key: "_comet_key",
1010+ signing_salt: "zgKytneJ",
1111+ same_site: "Lax"
1212+ ]
1313+1414+ socket "/live", Phoenix.LiveView.Socket,
1515+ websocket: [connect_info: [session: @session_options]],
1616+ longpoll: [connect_info: [session: @session_options]]
1717+1818+ # Serve at "/" the static files from "priv/static" directory.
1919+ #
2020+ # You should set gzip to true if you are running phx.digest
2121+ # when deploying your static files in production.
2222+ plug Plug.Static,
2323+ at: "/",
2424+ from: :comet,
2525+ gzip: false,
2626+ only: CometWeb.static_paths()
2727+2828+ # Code reloading can be explicitly enabled under the
2929+ # :code_reloader configuration of your endpoint.
3030+ if code_reloading? do
3131+ plug Phoenix.CodeReloader
3232+ plug Phoenix.Ecto.CheckRepoStatus, otp_app: :comet
3333+ end
3434+3535+ plug Phoenix.LiveDashboard.RequestLogger,
3636+ param_key: "request_logger",
3737+ cookie_key: "request_logger"
3838+3939+ plug Plug.RequestId
4040+ plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
4141+4242+ plug Plug.Parsers,
4343+ parsers: [:urlencoded, :multipart, :json],
4444+ pass: ["*/*"],
4545+ json_decoder: Phoenix.json_library()
4646+4747+ plug Plug.MethodOverride
4848+ plug Plug.Head
4949+ plug Plug.Session, @session_options
5050+ plug CometWeb.Router
5151+end
+27
apps/backend/lib/comet_web/router.ex
···11+defmodule CometWeb.Router do
22+ use CometWeb, :router
33+44+ pipeline :api do
55+ plug :accepts, ["json"]
66+ end
77+88+ scope "/api", CometWeb do
99+ pipe_through :api
1010+ end
1111+1212+ # Enable LiveDashboard in development
1313+ if Application.compile_env(:comet, :dev_routes) do
1414+ # If you want to use the LiveDashboard in production, you should put
1515+ # it behind authentication and allow only admins to access it.
1616+ # If your application does not have an admins-only section yet,
1717+ # you can use Plug.BasicAuth to set up some basic authentication
1818+ # as long as you are also using SSL (which you should anyway).
1919+ import Phoenix.LiveDashboard.Router
2020+2121+ scope "/dev" do
2222+ pipe_through [:fetch_session, :protect_from_forgery]
2323+2424+ live_dashboard "/dashboard", metrics: CometWeb.Telemetry
2525+ end
2626+ end
2727+end
+93
apps/backend/lib/comet_web/telemetry.ex
···11+defmodule CometWeb.Telemetry do
22+ use Supervisor
33+ import Telemetry.Metrics
44+55+ def start_link(arg) do
66+ Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
77+ end
88+99+ @impl true
1010+ def init(_arg) do
1111+ children = [
1212+ # Telemetry poller will execute the given period measurements
1313+ # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
1414+ {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
1515+ # Add reporters as children of your supervision tree.
1616+ # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
1717+ ]
1818+1919+ Supervisor.init(children, strategy: :one_for_one)
2020+ end
2121+2222+ def metrics do
2323+ [
2424+ # Phoenix Metrics
2525+ summary("phoenix.endpoint.start.system_time",
2626+ unit: {:native, :millisecond}
2727+ ),
2828+ summary("phoenix.endpoint.stop.duration",
2929+ unit: {:native, :millisecond}
3030+ ),
3131+ summary("phoenix.router_dispatch.start.system_time",
3232+ tags: [:route],
3333+ unit: {:native, :millisecond}
3434+ ),
3535+ summary("phoenix.router_dispatch.exception.duration",
3636+ tags: [:route],
3737+ unit: {:native, :millisecond}
3838+ ),
3939+ summary("phoenix.router_dispatch.stop.duration",
4040+ tags: [:route],
4141+ unit: {:native, :millisecond}
4242+ ),
4343+ summary("phoenix.socket_connected.duration",
4444+ unit: {:native, :millisecond}
4545+ ),
4646+ sum("phoenix.socket_drain.count"),
4747+ summary("phoenix.channel_joined.duration",
4848+ unit: {:native, :millisecond}
4949+ ),
5050+ summary("phoenix.channel_handled_in.duration",
5151+ tags: [:event],
5252+ unit: {:native, :millisecond}
5353+ ),
5454+5555+ # Database Metrics
5656+ summary("comet.repo.query.total_time",
5757+ unit: {:native, :millisecond},
5858+ description: "The sum of the other measurements"
5959+ ),
6060+ summary("comet.repo.query.decode_time",
6161+ unit: {:native, :millisecond},
6262+ description: "The time spent decoding the data received from the database"
6363+ ),
6464+ summary("comet.repo.query.query_time",
6565+ unit: {:native, :millisecond},
6666+ description: "The time spent executing the query"
6767+ ),
6868+ summary("comet.repo.query.queue_time",
6969+ unit: {:native, :millisecond},
7070+ description: "The time spent waiting for a database connection"
7171+ ),
7272+ summary("comet.repo.query.idle_time",
7373+ unit: {:native, :millisecond},
7474+ description:
7575+ "The time the connection spent waiting before being checked out for the query"
7676+ ),
7777+7878+ # VM Metrics
7979+ summary("vm.memory.total", unit: {:byte, :kilobyte}),
8080+ summary("vm.total_run_queue_lengths.total"),
8181+ summary("vm.total_run_queue_lengths.cpu"),
8282+ summary("vm.total_run_queue_lengths.io")
8383+ ]
8484+ end
8585+8686+ defp periodic_measurements do
8787+ [
8888+ # A module, function and arguments to be invoked periodically.
8989+ # This function must call :telemetry.execute/3 and a metric must be added above.
9090+ # {CometWeb, :count_users, []}
9191+ ]
9292+ end
9393+end
+68
apps/backend/mix.exs
···11+defmodule Comet.MixProject do
22+ use Mix.Project
33+44+ def project do
55+ [
66+ app: :comet,
77+ version: "0.1.0",
88+ elixir: "~> 1.14",
99+ elixirc_paths: elixirc_paths(Mix.env()),
1010+ start_permanent: Mix.env() == :prod,
1111+ aliases: aliases(),
1212+ deps: deps()
1313+ ]
1414+ end
1515+1616+ # Configuration for the OTP application.
1717+ #
1818+ # Type `mix help compile.app` for more information.
1919+ def application do
2020+ [
2121+ mod: {Comet.Application, []},
2222+ extra_applications: [:logger, :runtime_tools]
2323+ ]
2424+ end
2525+2626+ # Specifies which paths to compile per environment.
2727+ defp elixirc_paths(:test), do: ["lib", "test/support"]
2828+ defp elixirc_paths(_), do: ["lib"]
2929+3030+ # Specifies your project dependencies.
3131+ #
3232+ # Type `mix help deps` for examples and options.
3333+ defp deps do
3434+ [
3535+ {:phoenix, "~> 1.7.21"},
3636+ {:phoenix_ecto, "~> 4.5"},
3737+ {:ecto_sql, "~> 3.10"},
3838+ {:postgrex, ">= 0.0.0"},
3939+ {:phoenix_live_dashboard, "~> 0.8.3"},
4040+ {:telemetry_metrics, "~> 1.0"},
4141+ {:telemetry_poller, "~> 1.0"},
4242+ {:jason, "~> 1.2"},
4343+ {:dns_cluster, "~> 0.1.1"},
4444+ {:bandit, "~> 1.5"},
4545+ {:lexgen, "~> 1.0.0", only: [:dev]},
4646+ {:req, "~> 0.5.0"},
4747+ {:typedstruct, "~> 0.5"}
4848+ ]
4949+ end
5050+5151+ # Aliases are shortcuts or tasks specific to the current project.
5252+ # For example, to install project dependencies and perform other setup tasks, run:
5353+ #
5454+ # $ mix setup
5555+ #
5656+ # See the documentation for `Mix` for more info on aliases.
5757+ defp aliases do
5858+ lexicon_paths = Path.wildcard("../../packages/lexicons/defs/**/*.json")
5959+6060+ [
6161+ setup: ["deps.get", "ecto.setup"],
6262+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
6363+ "ecto.reset": ["ecto.drop", "ecto.setup"],
6464+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
6565+ "gen.lexicons": ["lexgen" | lexicon_paths] |> Enum.join(" ")
6666+ ]
6767+ end
6868+end
···11+# Script for populating the database. You can run it as:
22+#
33+# mix run priv/repo/seeds.exs
44+#
55+# Inside the script, you can read and write to any of your
66+# repositories directly:
77+#
88+# Comet.Repo.insert!(%Comet.SomeSchema{})
99+#
1010+# We recommend using the bang functions (`insert!`, `update!`
1111+# and so on) as they will fail if something goes wrong.
apps/backend/priv/static/favicon.ico
This is a binary file and will not be displayed.
+5
apps/backend/priv/static/robots.txt
···11+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
22+#
33+# To ban all spiders from the entire site uncomment the next two lines:
44+# User-agent: *
55+# Disallow: /
···11+defmodule CometWeb.ErrorJSONTest do
22+ use CometWeb.ConnCase, async: true
33+44+ test "renders 404" do
55+ assert CometWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
66+ end
77+88+ test "renders 500" do
99+ assert CometWeb.ErrorJSON.render("500.json", %{}) ==
1010+ %{errors: %{detail: "Internal Server Error"}}
1111+ end
1212+end
+38
apps/backend/test/support/conn_case.ex
···11+defmodule CometWeb.ConnCase do
22+ @moduledoc """
33+ This module defines the test case to be used by
44+ tests that require setting up a connection.
55+66+ Such tests rely on `Phoenix.ConnTest` and also
77+ import other functionality to make it easier
88+ to build common data structures and query the data layer.
99+1010+ Finally, if the test case interacts with the database,
1111+ we enable the SQL sandbox, so changes done to the database
1212+ are reverted at the end of every test. If you are using
1313+ PostgreSQL, you can even run database tests asynchronously
1414+ by setting `use CometWeb.ConnCase, async: true`, although
1515+ this option is not recommended for other databases.
1616+ """
1717+1818+ use ExUnit.CaseTemplate
1919+2020+ using do
2121+ quote do
2222+ # The default endpoint for testing
2323+ @endpoint CometWeb.Endpoint
2424+2525+ use CometWeb, :verified_routes
2626+2727+ # Import conveniences for testing with connections
2828+ import Plug.Conn
2929+ import Phoenix.ConnTest
3030+ import CometWeb.ConnCase
3131+ end
3232+ end
3333+3434+ setup tags do
3535+ Comet.DataCase.setup_sandbox(tags)
3636+ {:ok, conn: Phoenix.ConnTest.build_conn()}
3737+ end
3838+end
+58
apps/backend/test/support/data_case.ex
···11+defmodule Comet.DataCase do
22+ @moduledoc """
33+ This module defines the setup for tests requiring
44+ access to the application's data layer.
55+66+ You may define functions here to be used as helpers in
77+ your tests.
88+99+ Finally, if the test case interacts with the database,
1010+ we enable the SQL sandbox, so changes done to the database
1111+ are reverted at the end of every test. If you are using
1212+ PostgreSQL, you can even run database tests asynchronously
1313+ by setting `use Comet.DataCase, async: true`, although
1414+ this option is not recommended for other databases.
1515+ """
1616+1717+ use ExUnit.CaseTemplate
1818+1919+ using do
2020+ quote do
2121+ alias Comet.Repo
2222+2323+ import Ecto
2424+ import Ecto.Changeset
2525+ import Ecto.Query
2626+ import Comet.DataCase
2727+ end
2828+ end
2929+3030+ setup tags do
3131+ Comet.DataCase.setup_sandbox(tags)
3232+ :ok
3333+ end
3434+3535+ @doc """
3636+ Sets up the sandbox based on the test tags.
3737+ """
3838+ def setup_sandbox(tags) do
3939+ pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Comet.Repo, shared: not tags[:async])
4040+ on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
4141+ end
4242+4343+ @doc """
4444+ A helper that transforms changeset errors into a map of messages.
4545+4646+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
4747+ assert "password is too short" in errors_on(changeset).password
4848+ assert %{password: ["password is too short"]} = errors_on(changeset)
4949+5050+ """
5151+ def errors_on(changeset) do
5252+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
5353+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
5454+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
5555+ end)
5656+ end)
5757+ end
5858+end