Music streaming on ATProto!

feat: backend scaffold & basic codegen for lexicons

ovyerus.com bf621c2b de8d20e2

verified
Changed files
+1764 -1
.vscode
apps
backend
config
lib
atproto
sh
comet
v0
actor
getProfile
getProfiles
profile
feed
comment
defs
getActorPlaylists
getActorTracks
like
play
playlist
playlistTrack
repost
track
richtext
facet
comet
comet_web
priv
test
+1
.gitignore
···
.wrangler
.svelte-kit
build
+
.elixir_ls
# OS
.DS_Store
+1
.vscode/settings.json
···
"url": "https://gist.githubusercontent.com/mary-ext/6e428031c18799d1587048b456d118cb/raw/4322c492384ac5da33986dee9588938a88d922f1/schema.json"
}
],
+
"elixirLS.projectDir": "./apps/backend",
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
+5
apps/backend/.formatter.exs
···
+
[
+
import_deps: [:ecto, :ecto_sql, :phoenix],
+
subdirectories: ["priv/*/migrations"],
+
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
+
]
+27
apps/backend/.gitignore
···
+
# 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
+
+24
apps/backend/README.md
···
+
# 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
+35
apps/backend/config/config.exs
···
+
# 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"
+63
apps/backend/config/dev.exs
···
+
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
+7
apps/backend/config/prod.exs
···
+
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.
+99
apps/backend/config/runtime.exs
···
+
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
+27
apps/backend/config/test.exs
···
+
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
+169
apps/backend/lib/atproto/atproto.ex
···
+
# 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
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex
···
+
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
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex
···
+
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
+30
apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex
···
+
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
+104
apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex
···
+
+
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
+
+30
apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex
···
+
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
+49
apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex
···
+
+
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
+
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex
···
+
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
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex
···
+
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
+25
apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex
···
+
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
+25
apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex
···
+
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
+32
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex
···
+
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
+44
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex
···
+
+
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
+
+27
apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex
···
+
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
+25
apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex
···
+
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
+32
apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex
···
+
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
+44
apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex
···
+
+
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
+
+131
apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex
···
+
+
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
+
+105
apps/backend/lib/atproto/tid.ex
···
+
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, <<Enum.at(@tid_char_set, c)>> <> 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(<<char::utf8, rest::binary>>, 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
+9
apps/backend/lib/comet.ex
···
+
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
+34
apps/backend/lib/comet/application.ex
···
+
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
+5
apps/backend/lib/comet/repo.ex
···
+
defmodule Comet.Repo do
+
use Ecto.Repo,
+
otp_app: :comet,
+
adapter: Ecto.Adapters.Postgres
+
end
+65
apps/backend/lib/comet_web.ex
···
+
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
+21
apps/backend/lib/comet_web/controllers/error_json.ex
···
+
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
+51
apps/backend/lib/comet_web/endpoint.ex
···
+
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
+27
apps/backend/lib/comet_web/router.ex
···
+
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
+93
apps/backend/lib/comet_web/telemetry.ex
···
+
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
+68
apps/backend/mix.exs
···
+
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
+35
apps/backend/mix.lock
···
+
%{
+
"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"},
+
}
+4
apps/backend/priv/repo/migrations/.formatter.exs
···
+
[
+
import_deps: [:ecto_sql],
+
inputs: ["*.exs"]
+
]
+11
apps/backend/priv/repo/seeds.exs
···
+
# 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.
apps/backend/priv/static/favicon.ico

This is a binary file and will not be displayed.

+5
apps/backend/priv/static/robots.txt
···
+
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+
#
+
# To ban all spiders from the entire site uncomment the next two lines:
+
# User-agent: *
+
# Disallow: /
+12
apps/backend/test/comet_web/controllers/error_json_test.exs
···
+
defmodule CometWeb.ErrorJSONTest do
+
use CometWeb.ConnCase, async: true
+
+
test "renders 404" do
+
assert CometWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+
end
+
+
test "renders 500" do
+
assert CometWeb.ErrorJSON.render("500.json", %{}) ==
+
%{errors: %{detail: "Internal Server Error"}}
+
end
+
end
+38
apps/backend/test/support/conn_case.ex
···
+
defmodule CometWeb.ConnCase do
+
@moduledoc """
+
This module defines the test case to be used by
+
tests that require setting up a connection.
+
+
Such tests rely on `Phoenix.ConnTest` and also
+
import other functionality to make it easier
+
to build common data structures and query the data layer.
+
+
Finally, if the test case interacts with the database,
+
we enable the SQL sandbox, so changes done to the database
+
are reverted at the end of every test. If you are using
+
PostgreSQL, you can even run database tests asynchronously
+
by setting `use CometWeb.ConnCase, async: true`, although
+
this option is not recommended for other databases.
+
"""
+
+
use ExUnit.CaseTemplate
+
+
using do
+
quote do
+
# The default endpoint for testing
+
@endpoint CometWeb.Endpoint
+
+
use CometWeb, :verified_routes
+
+
# Import conveniences for testing with connections
+
import Plug.Conn
+
import Phoenix.ConnTest
+
import CometWeb.ConnCase
+
end
+
end
+
+
setup tags do
+
Comet.DataCase.setup_sandbox(tags)
+
{:ok, conn: Phoenix.ConnTest.build_conn()}
+
end
+
end
+58
apps/backend/test/support/data_case.ex
···
+
defmodule Comet.DataCase do
+
@moduledoc """
+
This module defines the setup for tests requiring
+
access to the application's data layer.
+
+
You may define functions here to be used as helpers in
+
your tests.
+
+
Finally, if the test case interacts with the database,
+
we enable the SQL sandbox, so changes done to the database
+
are reverted at the end of every test. If you are using
+
PostgreSQL, you can even run database tests asynchronously
+
by setting `use Comet.DataCase, async: true`, although
+
this option is not recommended for other databases.
+
"""
+
+
use ExUnit.CaseTemplate
+
+
using do
+
quote do
+
alias Comet.Repo
+
+
import Ecto
+
import Ecto.Changeset
+
import Ecto.Query
+
import Comet.DataCase
+
end
+
end
+
+
setup tags do
+
Comet.DataCase.setup_sandbox(tags)
+
:ok
+
end
+
+
@doc """
+
Sets up the sandbox based on the test tags.
+
"""
+
def setup_sandbox(tags) do
+
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Comet.Repo, shared: not tags[:async])
+
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
+
end
+
+
@doc """
+
A helper that transforms changeset errors into a map of messages.
+
+
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
+
assert "password is too short" in errors_on(changeset).password
+
assert %{password: ["password is too short"]} = errors_on(changeset)
+
+
"""
+
def errors_on(changeset) do
+
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+
end)
+
end)
+
end
+
end
+2
apps/backend/test/test_helper.exs
···
+
ExUnit.start()
+
Ecto.Adapters.SQL.Sandbox.mode(Comet.Repo, :manual)
+1 -1
flake.nix
···
in {
devShells = defaultForSystems (pkgs:
pkgs.mkShell {
-
nativeBuildInputs = with pkgs; [nodejs_22 bun];
+
nativeBuildInputs = with pkgs; [nodejs_22 bun elixir erlang];
});
};
}