+1
.gitignore
+1
.gitignore
+1
.vscode/settings.json
+1
.vscode/settings.json
+5
apps/backend/.formatter.exs
+5
apps/backend/.formatter.exs
+27
apps/backend/.gitignore
+27
apps/backend/.gitignore
···
1
+
# The directory Mix will write compiled artifacts to.
2
+
/_build/
3
+
4
+
# If you run "mix test --cover", coverage assets end up here.
5
+
/cover/
6
+
7
+
# The directory Mix downloads your dependencies sources to.
8
+
/deps/
9
+
10
+
# Where 3rd-party dependencies like ExDoc output generated docs.
11
+
/doc/
12
+
13
+
# Ignore .fetch files in case you like to edit your project deps locally.
14
+
/.fetch
15
+
16
+
# If the VM crashes, it generates a dump, let's ignore it too.
17
+
erl_crash.dump
18
+
19
+
# Also ignore archive artifacts (built via "mix archive.build").
20
+
*.ez
21
+
22
+
# Temporary files, for example, from tests.
23
+
/tmp/
24
+
25
+
# Ignore package tarball (built via "mix hex.build").
26
+
comet-*.tar
27
+
+24
apps/backend/README.md
+24
apps/backend/README.md
···
1
+
# Comet AppView
2
+
3
+
[Phoenix](https://www.phoenixframework.org)-powered AppView for Comet.
4
+
5
+
---
6
+
7
+
To start your Phoenix server:
8
+
9
+
- Run `mix setup` to install and setup dependencies
10
+
- Start Phoenix endpoint with `mix phx.server` or inside IEx with
11
+
`iex -S mix phx.server`
12
+
13
+
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
14
+
15
+
Ready to run in production? Please
16
+
[check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
17
+
18
+
## Learn more
19
+
20
+
- Official website: https://www.phoenixframework.org/
21
+
- Guides: https://hexdocs.pm/phoenix/overview.html
22
+
- Docs: https://hexdocs.pm/phoenix
23
+
- Forum: https://elixirforum.com/c/phoenix-forum
24
+
- Source: https://github.com/phoenixframework/phoenix
+35
apps/backend/config/config.exs
+35
apps/backend/config/config.exs
···
1
+
# This file is responsible for configuring your application
2
+
# and its dependencies with the aid of the Config module.
3
+
#
4
+
# This configuration file is loaded before any dependency and
5
+
# is restricted to this project.
6
+
7
+
# General application configuration
8
+
import Config
9
+
10
+
config :comet,
11
+
ecto_repos: [Comet.Repo],
12
+
generators: [timestamp_type: :utc_datetime, binary_id: true]
13
+
14
+
# Configures the endpoint
15
+
config :comet, CometWeb.Endpoint,
16
+
url: [host: "localhost"],
17
+
adapter: Bandit.PhoenixAdapter,
18
+
render_errors: [
19
+
formats: [json: CometWeb.ErrorJSON],
20
+
layout: false
21
+
],
22
+
pubsub_server: Comet.PubSub,
23
+
live_view: [signing_salt: "oq2xYeBj"]
24
+
25
+
# Configures Elixir's Logger
26
+
config :logger, :console,
27
+
format: "$time $metadata[$level] $message\n",
28
+
metadata: [:request_id]
29
+
30
+
# Use Jason for JSON parsing in Phoenix
31
+
config :phoenix, :json_library, Jason
32
+
33
+
# Import environment specific config. This must remain at the bottom
34
+
# of this file so it overrides the configuration defined above.
35
+
import_config "#{config_env()}.exs"
+63
apps/backend/config/dev.exs
+63
apps/backend/config/dev.exs
···
1
+
import Config
2
+
3
+
# Configure your database
4
+
config :comet, Comet.Repo,
5
+
username: "postgres",
6
+
password: "postgres",
7
+
hostname: "localhost",
8
+
database: "comet_dev",
9
+
stacktrace: true,
10
+
show_sensitive_data_on_connection_error: true,
11
+
pool_size: 10
12
+
13
+
# For development, we disable any cache and enable
14
+
# debugging and code reloading.
15
+
#
16
+
# The watchers configuration can be used to run external
17
+
# watchers to your application. For example, we can use it
18
+
# to bundle .js and .css sources.
19
+
config :comet, CometWeb.Endpoint,
20
+
# Binding to loopback ipv4 address prevents access from other machines.
21
+
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
22
+
http: [ip: {127, 0, 0, 1}, port: 4000],
23
+
check_origin: false,
24
+
code_reloader: true,
25
+
debug_errors: true,
26
+
secret_key_base: "Vw9UaVO8YBKiooaOlZ2Rhx7xJHydL9s2YIviOwiiQz8Cy24+mLBB3Fj+9jvOIdQE",
27
+
watchers: []
28
+
29
+
# ## SSL Support
30
+
#
31
+
# In order to use HTTPS in development, a self-signed
32
+
# certificate can be generated by running the following
33
+
# Mix task:
34
+
#
35
+
# mix phx.gen.cert
36
+
#
37
+
# Run `mix help phx.gen.cert` for more information.
38
+
#
39
+
# The `http:` config above can be replaced with:
40
+
#
41
+
# https: [
42
+
# port: 4001,
43
+
# cipher_suite: :strong,
44
+
# keyfile: "priv/cert/selfsigned_key.pem",
45
+
# certfile: "priv/cert/selfsigned.pem"
46
+
# ],
47
+
#
48
+
# If desired, both `http:` and `https:` keys can be
49
+
# configured to run both http and https servers on
50
+
# different ports.
51
+
52
+
# Enable dev routes for dashboard and mailbox
53
+
config :comet, dev_routes: true
54
+
55
+
# Do not include metadata nor timestamps in development logs
56
+
config :logger, :console, format: "[$level] $message\n"
57
+
58
+
# Set a higher stacktrace during development. Avoid configuring such
59
+
# in production as building large stacktraces may be expensive.
60
+
config :phoenix, :stacktrace_depth, 20
61
+
62
+
# Initialize plugs at runtime for faster development compilation
63
+
config :phoenix, :plug_init_mode, :runtime
+7
apps/backend/config/prod.exs
+7
apps/backend/config/prod.exs
+99
apps/backend/config/runtime.exs
+99
apps/backend/config/runtime.exs
···
1
+
import Config
2
+
3
+
# config/runtime.exs is executed for all environments, including
4
+
# during releases. It is executed after compilation and before the
5
+
# system starts, so it is typically used to load production configuration
6
+
# and secrets from environment variables or elsewhere. Do not define
7
+
# any compile-time configuration in here, as it won't be applied.
8
+
# The block below contains prod specific runtime configuration.
9
+
10
+
# ## Using releases
11
+
#
12
+
# If you use `mix release`, you need to explicitly enable the server
13
+
# by passing the PHX_SERVER=true when you start it:
14
+
#
15
+
# PHX_SERVER=true bin/comet start
16
+
#
17
+
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18
+
# script that automatically sets the env var above.
19
+
if System.get_env("PHX_SERVER") do
20
+
config :comet, CometWeb.Endpoint, server: true
21
+
end
22
+
23
+
if config_env() == :prod do
24
+
database_url =
25
+
System.get_env("DATABASE_URL") ||
26
+
raise """
27
+
environment variable DATABASE_URL is missing.
28
+
For example: ecto://USER:PASS@HOST/DATABASE
29
+
"""
30
+
31
+
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
32
+
33
+
config :comet, Comet.Repo,
34
+
# ssl: true,
35
+
url: database_url,
36
+
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
37
+
socket_options: maybe_ipv6
38
+
39
+
# The secret key base is used to sign/encrypt cookies and other secrets.
40
+
# A default value is used in config/dev.exs and config/test.exs but you
41
+
# want to use a different value for prod and you most likely don't want
42
+
# to check this value into version control, so we use an environment
43
+
# variable instead.
44
+
secret_key_base =
45
+
System.get_env("SECRET_KEY_BASE") ||
46
+
raise """
47
+
environment variable SECRET_KEY_BASE is missing.
48
+
You can generate one by calling: mix phx.gen.secret
49
+
"""
50
+
51
+
host = System.get_env("PHX_HOST") || "example.com"
52
+
port = String.to_integer(System.get_env("PORT") || "4000")
53
+
54
+
config :comet, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
55
+
56
+
config :comet, CometWeb.Endpoint,
57
+
url: [host: host, port: 443, scheme: "https"],
58
+
http: [
59
+
# Enable IPv6 and bind on all interfaces.
60
+
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
61
+
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
62
+
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
63
+
ip: {0, 0, 0, 0, 0, 0, 0, 0},
64
+
port: port
65
+
],
66
+
secret_key_base: secret_key_base
67
+
68
+
# ## SSL Support
69
+
#
70
+
# To get SSL working, you will need to add the `https` key
71
+
# to your endpoint configuration:
72
+
#
73
+
# config :comet, CometWeb.Endpoint,
74
+
# https: [
75
+
# ...,
76
+
# port: 443,
77
+
# cipher_suite: :strong,
78
+
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
79
+
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
80
+
# ]
81
+
#
82
+
# The `cipher_suite` is set to `:strong` to support only the
83
+
# latest and more secure SSL ciphers. This means old browsers
84
+
# and clients may not be supported. You can set it to
85
+
# `:compatible` for wider support.
86
+
#
87
+
# `:keyfile` and `:certfile` expect an absolute path to the key
88
+
# and cert in disk or a relative path inside priv, for example
89
+
# "priv/ssl/server.key". For all supported SSL configuration
90
+
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
91
+
#
92
+
# We also recommend setting `force_ssl` in your config/prod.exs,
93
+
# ensuring no data is ever sent via http, always redirecting to https:
94
+
#
95
+
# config :comet, CometWeb.Endpoint,
96
+
# force_ssl: [hsts: true]
97
+
#
98
+
# Check `Plug.SSL` for all available options in `force_ssl`.
99
+
end
+27
apps/backend/config/test.exs
+27
apps/backend/config/test.exs
···
1
+
import Config
2
+
3
+
# Configure your database
4
+
#
5
+
# The MIX_TEST_PARTITION environment variable can be used
6
+
# to provide built-in test partitioning in CI environment.
7
+
# Run `mix help test` for more information.
8
+
config :comet, Comet.Repo,
9
+
username: "postgres",
10
+
password: "postgres",
11
+
hostname: "localhost",
12
+
database: "comet_test#{System.get_env("MIX_TEST_PARTITION")}",
13
+
pool: Ecto.Adapters.SQL.Sandbox,
14
+
pool_size: System.schedulers_online() * 2
15
+
16
+
# We don't run a server during test. If one is required,
17
+
# you can enable the server option below.
18
+
config :comet, CometWeb.Endpoint,
19
+
http: [ip: {127, 0, 0, 1}, port: 4002],
20
+
secret_key_base: "eaG5CrPmVserxnUlu8DyG8I6i3m3TBDOi8fsKn2niwYUMhjps0YkWWMGRnoSXvGf",
21
+
server: false
22
+
23
+
# Print only warnings and errors during test
24
+
config :logger, level: :warning
25
+
26
+
# Initialize plugs at runtime for faster test compilation
27
+
config :phoenix, :plug_init_mode, :runtime
+169
apps/backend/lib/atproto/atproto.ex
+169
apps/backend/lib/atproto/atproto.ex
···
1
+
# AUTOGENERATED: This file was generated using the mix task `lexgen`.
2
+
defmodule Atproto do
3
+
@default_pds_hostname Application.compile_env!(:comet, :default_pds_hostname)
4
+
5
+
@typedoc """
6
+
A type representing the names of the options that can be passed to `query/3` and `procedure/3`.
7
+
"""
8
+
@type xrpc_opt :: :pds_hostname | :authorization
9
+
10
+
@typedoc """
11
+
A keyword list of options that can be passed to `query/3` and `procedure/3`.
12
+
"""
13
+
@type xrpc_opts :: [{xrpc_opt(), any()}]
14
+
15
+
@doc """
16
+
Converts a JSON string, or decoded JSON map, into a struct based on the given module.
17
+
18
+
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.
19
+
"""
20
+
@spec decode_to_struct(module(), binary() | map()) :: map()
21
+
def decode_to_struct(module, json) when is_binary(json) do
22
+
decode_to_struct(module, Jason.decode!(json, keys: :atoms!))
23
+
end
24
+
25
+
def decode_to_struct(module, map) when is_map(map) do
26
+
Map.merge(module.new(), map)
27
+
end
28
+
29
+
@doc """
30
+
Raises an error if any required parameters are missing from the given map.
31
+
"""
32
+
@spec ensure_required(map(), [String.t()]) :: map()
33
+
def ensure_required(params, required) do
34
+
if Enum.all?(required, fn key -> Map.has_key?(params, key) end) do
35
+
params
36
+
else
37
+
raise ArgumentError, "Missing one or more required parameters: #{Enum.join(required, ", ")}"
38
+
end
39
+
end
40
+
41
+
@doc """
42
+
Executes a "GET" HTTP request and returns the response body as a map.
43
+
44
+
If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
45
+
"""
46
+
@spec query(map(), String.t(), xrpc_opts()) :: Req.Request.t()
47
+
def query(params, target, opts \\ []) do
48
+
target
49
+
|> endpoint(opts)
50
+
|> URI.new!()
51
+
|> URI.append_query(URI.encode_query(params))
52
+
|> Req.get(build_req_auth(opts))
53
+
|> handle_response(opts)
54
+
end
55
+
56
+
@doc """
57
+
Executes a "POST" HTTP request and returns the response body as a map.
58
+
59
+
If the `:pds_hostname` option is not provided, the default PDS hostname as provided in the compile-time configuration will be used.
60
+
"""
61
+
@spec procedure(map(), String.t(), xrpc_opts()) :: {:ok | :refresh | :error, map()}
62
+
def procedure(params, target, opts \\ []) do
63
+
req_opts =
64
+
opts
65
+
|> build_req_auth()
66
+
|> build_req_headers(opts, target)
67
+
|> build_req_body(params, target)
68
+
69
+
target
70
+
|> endpoint(opts)
71
+
|> URI.new!()
72
+
|> Req.post(req_opts)
73
+
|> handle_response(opts)
74
+
end
75
+
76
+
defp build_req_auth(opts) do
77
+
case Keyword.get(opts, :access_token) do
78
+
nil ->
79
+
case Keyword.get(opts, :admin_token) do
80
+
nil ->
81
+
[]
82
+
83
+
token ->
84
+
[auth: {:basic, "admin:#{token}"}]
85
+
end
86
+
87
+
token ->
88
+
[auth: {:bearer, token}]
89
+
end
90
+
end
91
+
92
+
defp build_req_headers(req_opts, opts, "com.atproto.repo.uploadBlob") do
93
+
[
94
+
{:headers,
95
+
[
96
+
{"Content-Type", Keyword.fetch!(opts, :content_type)},
97
+
{"Content-Length", Keyword.fetch!(opts, :content_length)}
98
+
]}
99
+
| req_opts
100
+
]
101
+
end
102
+
103
+
defp build_req_headers(req_opts, _opts, _target), do: req_opts
104
+
105
+
defp build_req_body(opts, blob, "com.atproto.repo.uploadBlob") do
106
+
[{:body, blob} | opts]
107
+
end
108
+
109
+
defp build_req_body(opts, %{} = params, _target) when map_size(params) > 0 do
110
+
[{:json, params} | opts]
111
+
end
112
+
113
+
defp build_req_body(opts, _params, _target), do: opts
114
+
115
+
defp endpoint(target, opts) do
116
+
(Keyword.get(opts, :pds_hostname) || @default_pds_hostname) <> "/xrpc/" <> target
117
+
end
118
+
119
+
defp handle_response({:ok, %Req.Response{} = response}, opts) do
120
+
case response.status do
121
+
x when x in 200..299 ->
122
+
{:ok, response.body}
123
+
124
+
_ ->
125
+
if response.body["error"] == "ExpiredToken" do
126
+
{:ok, user} =
127
+
Com.Atproto.Server.RefreshSession.main(%{},
128
+
access_token: Keyword.get(opts, :refresh_token)
129
+
)
130
+
131
+
{:refresh, user}
132
+
else
133
+
{:error, response.body}
134
+
end
135
+
end
136
+
end
137
+
138
+
defp handle_response(error, _opts), do: error
139
+
140
+
@doc """
141
+
Converts a "map-like" entity into a standard map. This will also omit any entries that have a `nil` value.
142
+
143
+
This is useful for converting structs or schemas into regular maps before sending them over XRPC requests.
144
+
145
+
You may optionally pass in an keyword list of options:
146
+
147
+
- `:stringify` - `boolean` - If `true`, converts the keys to strings. Otherwise, converts keys to atoms. Default is `false`.
148
+
- *Note*: When `false`, this feature uses the `to_existing_atom/1` function to avoid reckless conversion of string keys.
149
+
"""
150
+
@spec to_map(map() | struct()) :: map()
151
+
def to_map(%{__struct__: _} = m, opts \\ []) do
152
+
string_keys = Keyword.get(opts, :stringify, false)
153
+
154
+
m
155
+
|> Map.drop([:__struct__, :__meta__])
156
+
|> Enum.map(fn
157
+
{_, nil} ->
158
+
nil
159
+
160
+
{k, v} when is_atom(k) ->
161
+
if string_keys, do: {to_string(k), v}, else: {k, v}
162
+
163
+
{k, v} when is_binary(k) ->
164
+
if string_keys, do: {k, v}, else: {String.to_existing_atom(k), v}
165
+
end)
166
+
|> Enum.reject(&is_nil/1)
167
+
|> Enum.into(%{})
168
+
end
169
+
end
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfile/xrpc.ex
···
1
+
defmodule Sh.Comet.V0.Actor.GetProfile do
2
+
3
+
@doc """
4
+
Get the profile view of an actor.
5
+
"""
6
+
@spec main(%{
7
+
actor: String.t()
8
+
}, Atproto.xrpc_opts()) :: {:ok, Sh.Comet.V0.Actor.Profile.View.t()} | {:error, any}
9
+
def main(params \\ %{}, opts \\ []) do
10
+
params
11
+
|> Map.take([:actor])
12
+
|> Atproto.ensure_required([:actor])
13
+
|> Atproto.query("sh.comet.v0.actor.getProfile", opts)
14
+
end
15
+
end
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex
+15
apps/backend/lib/atproto/sh/comet/v0/actor/getProfiles/xrpc.ex
···
1
+
defmodule Sh.Comet.V0.Actor.GetProfiles do
2
+
3
+
@doc """
4
+
Get the profile views of multiple actors.
5
+
"""
6
+
@spec main(%{
7
+
actors: list(String.t())
8
+
}, Atproto.xrpc_opts()) :: {:ok, %{profiles: list(Sh.Comet.V0.Actor.Profile.View.t())}} | {:error, any}
9
+
def main(params \\ %{}, opts \\ []) do
10
+
params
11
+
|> Map.take([:actors])
12
+
|> Atproto.ensure_required([:actors])
13
+
|> Atproto.query("sh.comet.v0.actor.getProfiles", opts)
14
+
end
15
+
end
+30
apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex
+30
apps/backend/lib/atproto/sh/comet/v0/actor/profile/schema.ex
···
1
+
defmodule Sh.Comet.V0.Actor.Profile do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
A user's Comet profile.
7
+
"""
8
+
@primary_key {:id, :binary_id, autogenerate: false}
9
+
schema "sh.comet.v0.actor.profile" do
10
+
field :avatar, :map
11
+
field :banner, :map
12
+
field :createdAt, :utc_datetime
13
+
field :description, :string
14
+
field :descriptionFacets, :map
15
+
field :displayName, :string
16
+
field :featuredItems, {:array, :string}
17
+
18
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
19
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
20
+
field :"$type", :string, default: "sh.comet.v0.actor.profile"
21
+
end
22
+
23
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
24
+
25
+
def changeset(struct, params \\ %{}) do
26
+
struct
27
+
|> cast(params, [:avatar, :banner, :createdAt, :description, :descriptionFacets, :displayName, :featuredItems])
28
+
|> validate_length(:featuredItems, max: 5)
29
+
end
30
+
end
+104
apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex
+104
apps/backend/lib/atproto/sh/comet/v0/actor/profile/structs.ex
···
1
+
2
+
defmodule Sh.Comet.V0.Actor.Profile.ViewerState do
3
+
@moduledoc """
4
+
Metadata about the requesting account's relationship with the user. TODO: determine if we create our own graph or inherit bsky's.
5
+
"""
6
+
7
+
@derive Jason.Encoder
8
+
defstruct [
9
+
10
+
]
11
+
12
+
@type t() :: %__MODULE__{
13
+
14
+
}
15
+
16
+
@spec new() :: t()
17
+
def new(), do: %__MODULE__{}
18
+
19
+
@spec from(binary() | map()) :: t()
20
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
21
+
end
22
+
23
+
defmodule Sh.Comet.V0.Actor.Profile.ViewFull do
24
+
@moduledoc """
25
+
26
+
"""
27
+
28
+
@derive Jason.Encoder
29
+
defstruct [
30
+
avatar: nil,
31
+
banner: nil,
32
+
createdAt: nil,
33
+
description: nil,
34
+
descriptionFacets: nil,
35
+
did: nil,
36
+
displayName: nil,
37
+
featuredItems: [],
38
+
followersCount: 0,
39
+
followsCount: 0,
40
+
handle: nil,
41
+
indexedAt: nil,
42
+
playlistsCount: 0,
43
+
tracksCount: 0,
44
+
viewer: nil
45
+
]
46
+
47
+
@type t() :: %__MODULE__{
48
+
avatar: String.t(),
49
+
banner: String.t(),
50
+
createdAt: DateTime.t(),
51
+
description: String.t(),
52
+
descriptionFacets: Sh.Comet.V0.Richtext.Facet.Main.t(),
53
+
did: String.t(),
54
+
displayName: String.t(),
55
+
featuredItems: list(String.t()),
56
+
followersCount: integer,
57
+
followsCount: integer,
58
+
handle: String.t(),
59
+
indexedAt: DateTime.t(),
60
+
playlistsCount: integer,
61
+
tracksCount: integer,
62
+
viewer: Sh.Comet.V0.Actor.Profile.ViewerState.t()
63
+
}
64
+
65
+
@spec new() :: t()
66
+
def new(), do: %__MODULE__{}
67
+
68
+
@spec from(binary() | map()) :: t()
69
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
70
+
end
71
+
72
+
defmodule Sh.Comet.V0.Actor.Profile.View do
73
+
@moduledoc """
74
+
75
+
"""
76
+
77
+
@derive Jason.Encoder
78
+
defstruct [
79
+
avatar: nil,
80
+
createdAt: nil,
81
+
did: nil,
82
+
displayName: nil,
83
+
handle: nil,
84
+
indexedAt: nil,
85
+
viewer: nil
86
+
]
87
+
88
+
@type t() :: %__MODULE__{
89
+
avatar: String.t(),
90
+
createdAt: DateTime.t(),
91
+
did: String.t(),
92
+
displayName: String.t(),
93
+
handle: String.t(),
94
+
indexedAt: DateTime.t(),
95
+
viewer: Sh.Comet.V0.Actor.Profile.ViewerState.t()
96
+
}
97
+
98
+
@spec new() :: t()
99
+
def new(), do: %__MODULE__{}
100
+
101
+
@spec from(binary() | map()) :: t()
102
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
103
+
end
104
+
+30
apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex
+30
apps/backend/lib/atproto/sh/comet/v0/feed/comment/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Comment do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
A comment on a piece of Comet media.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.comment" do
10
+
field :createdAt, :utc_datetime
11
+
field :facets, {:array, :map}
12
+
field :langs, {:array, :string}
13
+
field :reply, :string
14
+
field :subject, :string
15
+
field :text, :string
16
+
17
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
18
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
19
+
field :"$type", :string, default: "sh.comet.v0.feed.comment"
20
+
end
21
+
22
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
23
+
24
+
def changeset(struct, params \\ %{}) do
25
+
struct
26
+
|> cast(params, [:createdAt, :facets, :langs, :reply, :subject, :text])
27
+
|> validate_required([:createdAt, :subject, :text])
28
+
|> validate_length(:langs, max: 3)
29
+
end
30
+
end
+49
apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex
+49
apps/backend/lib/atproto/sh/comet/v0/feed/defs/structs.ex
···
1
+
2
+
defmodule Sh.Comet.V0.Feed.Defs.ViewerState do
3
+
@moduledoc """
4
+
Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.
5
+
"""
6
+
7
+
@derive Jason.Encoder
8
+
defstruct [
9
+
featured: false,
10
+
like: nil,
11
+
repost: nil
12
+
]
13
+
14
+
@type t() :: %__MODULE__{
15
+
featured: boolean,
16
+
like: String.t(),
17
+
repost: String.t()
18
+
}
19
+
20
+
@spec new() :: t()
21
+
def new(), do: %__MODULE__{}
22
+
23
+
@spec from(binary() | map()) :: t()
24
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
25
+
end
26
+
27
+
defmodule Sh.Comet.V0.Feed.Defs.Link do
28
+
@moduledoc """
29
+
Link for the track. Usually to acquire it in some way, e.g. via free download or purchase. | TODO: multiple links?
30
+
"""
31
+
32
+
@derive Jason.Encoder
33
+
defstruct [
34
+
type: nil,
35
+
value: nil
36
+
]
37
+
38
+
@type t() :: %__MODULE__{
39
+
type: String.t(),
40
+
value: String.t()
41
+
}
42
+
43
+
@spec new() :: t()
44
+
def new(), do: %__MODULE__{}
45
+
46
+
@spec from(binary() | map()) :: t()
47
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
48
+
end
49
+
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorPlaylists/xrpc.ex
···
1
+
defmodule Sh.Comet.V0.Feed.GetActorPlaylists do
2
+
3
+
@doc """
4
+
Get a list of an actor's playlists.
5
+
"""
6
+
@spec main(%{
7
+
actor: String.t(),
8
+
cursor: String.t(),
9
+
limit: integer
10
+
}, Atproto.xrpc_opts()) :: {:ok, %{cursor: String.t(), playlists: list(Sh.Comet.V0.Feed.Playlist.View.t())}} | {:error, any}
11
+
def main(params \\ %{}, opts \\ []) do
12
+
params
13
+
|> Map.take([:actor, :cursor, :limit])
14
+
|> Atproto.ensure_required([:actor])
15
+
|> Atproto.query("sh.comet.v0.feed.getActorPlaylists", opts)
16
+
end
17
+
end
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex
+17
apps/backend/lib/atproto/sh/comet/v0/feed/getActorTracks/xrpc.ex
···
1
+
defmodule Sh.Comet.V0.Feed.GetActorTracks do
2
+
3
+
@doc """
4
+
Get a list of an actor's tracks.
5
+
"""
6
+
@spec main(%{
7
+
actor: String.t(),
8
+
cursor: String.t(),
9
+
limit: integer
10
+
}, Atproto.xrpc_opts()) :: {:ok, %{cursor: String.t(), tracks: list(Sh.Comet.V0.Feed.Track.View.t())}} | {:error, any}
11
+
def main(params \\ %{}, opts \\ []) do
12
+
params
13
+
|> Map.take([:actor, :cursor, :limit])
14
+
|> Atproto.ensure_required([:actor])
15
+
|> Atproto.query("sh.comet.v0.feed.getActorTracks", opts)
16
+
end
17
+
end
+25
apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex
+25
apps/backend/lib/atproto/sh/comet/v0/feed/like/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Like do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
Record representing a 'like' of some media. Weakly linked with just an at-uri.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.like" do
10
+
field :createdAt, :utc_datetime
11
+
field :subject, :string
12
+
13
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
14
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
15
+
field :"$type", :string, default: "sh.comet.v0.feed.like"
16
+
end
17
+
18
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
19
+
20
+
def changeset(struct, params \\ %{}) do
21
+
struct
22
+
|> cast(params, [:createdAt, :subject])
23
+
|> validate_required([:createdAt, :subject])
24
+
end
25
+
end
+25
apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex
+25
apps/backend/lib/atproto/sh/comet/v0/feed/play/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Play do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
Record representing a 'play' of some media.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.play" do
10
+
field :createdAt, :utc_datetime
11
+
field :subject, :string
12
+
13
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
14
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
15
+
field :"$type", :string, default: "sh.comet.v0.feed.play"
16
+
end
17
+
18
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
19
+
20
+
def changeset(struct, params \\ %{}) do
21
+
struct
22
+
|> cast(params, [:createdAt, :subject])
23
+
|> validate_required([:createdAt, :subject])
24
+
end
25
+
end
+32
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex
+32
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Playlist do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
A Comet playlist, containing many audio tracks.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.playlist" do
10
+
field :createdAt, :utc_datetime
11
+
field :description, :string
12
+
field :descriptionFacets, :map
13
+
field :image, :map
14
+
field :link, :map
15
+
field :tags, {:array, :string}
16
+
field :title, :string
17
+
field :type, :string
18
+
19
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
20
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
21
+
field :"$type", :string, default: "sh.comet.v0.feed.playlist"
22
+
end
23
+
24
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
25
+
26
+
def changeset(struct, params \\ %{}) do
27
+
struct
28
+
|> cast(params, [:createdAt, :description, :descriptionFacets, :image, :link, :tags, :title, :type])
29
+
|> validate_required([:createdAt, :title, :type])
30
+
|> validate_length(:tags, max: 8)
31
+
end
32
+
end
+44
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex
+44
apps/backend/lib/atproto/sh/comet/v0/feed/playlist/structs.ex
···
1
+
2
+
defmodule Sh.Comet.V0.Feed.Playlist.View do
3
+
@moduledoc """
4
+
5
+
"""
6
+
7
+
@derive Jason.Encoder
8
+
defstruct [
9
+
author: nil,
10
+
cid: nil,
11
+
commentCount: 0,
12
+
image: nil,
13
+
indexedAt: nil,
14
+
likeCount: 0,
15
+
record: nil,
16
+
repostCount: 0,
17
+
trackCount: 0,
18
+
tracks: [],
19
+
uri: nil,
20
+
viewer: nil
21
+
]
22
+
23
+
@type t() :: %__MODULE__{
24
+
author: Sh.Comet.V0.Actor.Profile.ViewFull.t(),
25
+
cid: String.t(),
26
+
commentCount: integer,
27
+
image: String.t(),
28
+
indexedAt: DateTime.t(),
29
+
likeCount: integer,
30
+
record: Sh.Comet.V0.Feed.Playlist.Main.t(),
31
+
repostCount: integer,
32
+
trackCount: integer,
33
+
tracks: list(Sh.Comet.V0.Feed.Track.View.t()),
34
+
uri: String.t(),
35
+
viewer: Sh.Comet.V0.Feed.Defs.ViewerState.t()
36
+
}
37
+
38
+
@spec new() :: t()
39
+
def new(), do: %__MODULE__{}
40
+
41
+
@spec from(binary() | map()) :: t()
42
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
43
+
end
44
+
+27
apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex
+27
apps/backend/lib/atproto/sh/comet/v0/feed/playlistTrack/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.PlaylistTrack do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
A link between a Comet track and a playlist.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.playlistTrack" do
10
+
field :playlist, :string
11
+
field :position, :integer
12
+
field :track, :string
13
+
14
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
15
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
16
+
field :"$type", :string, default: "sh.comet.v0.feed.playlistTrack"
17
+
end
18
+
19
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
20
+
21
+
def changeset(struct, params \\ %{}) do
22
+
struct
23
+
|> cast(params, [:playlist, :position, :track])
24
+
|> validate_required([:playlist, :position, :track])
25
+
|> validate_length(:position, min: 0)
26
+
end
27
+
end
+25
apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex
+25
apps/backend/lib/atproto/sh/comet/v0/feed/repost/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Repost do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
Record representing a 'repost' of some media. Weakly linked with just an at-uri.
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.repost" do
10
+
field :createdAt, :utc_datetime
11
+
field :subject, :string
12
+
13
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
14
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
15
+
field :"$type", :string, default: "sh.comet.v0.feed.repost"
16
+
end
17
+
18
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
19
+
20
+
def changeset(struct, params \\ %{}) do
21
+
struct
22
+
|> cast(params, [:createdAt, :subject])
23
+
|> validate_required([:createdAt, :subject])
24
+
end
25
+
end
+32
apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex
+32
apps/backend/lib/atproto/sh/comet/v0/feed/track/schema.ex
···
1
+
defmodule Sh.Comet.V0.Feed.Track do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@doc """
6
+
A Comet audio track. TODO: should probably have some sort of pre-calculated waveform, or have a query to get one from a blob?
7
+
"""
8
+
@primary_key {:id, :id, autogenerate: false}
9
+
schema "sh.comet.v0.feed.track" do
10
+
field :audio, :map
11
+
field :createdAt, :utc_datetime
12
+
field :description, :string
13
+
field :descriptionFacets, :map
14
+
field :image, :map
15
+
field :link, :map
16
+
field :tags, {:array, :string}
17
+
field :title, :string
18
+
19
+
# DO NOT CHANGE! This field is required for all records and must be set to the NSID of the lexicon.
20
+
# Ensure that you do not change this field via manual manipulation or changeset operations.
21
+
field :"$type", :string, default: "sh.comet.v0.feed.track"
22
+
end
23
+
24
+
def new(params \\ %{}), do: changeset(%__MODULE__{}, params)
25
+
26
+
def changeset(struct, params \\ %{}) do
27
+
struct
28
+
|> cast(params, [:audio, :createdAt, :description, :descriptionFacets, :image, :link, :tags, :title])
29
+
|> validate_required([:audio, :createdAt, :title])
30
+
|> validate_length(:tags, max: 8)
31
+
end
32
+
end
+44
apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex
+44
apps/backend/lib/atproto/sh/comet/v0/feed/track/structs.ex
···
1
+
2
+
defmodule Sh.Comet.V0.Feed.Track.View do
3
+
@moduledoc """
4
+
5
+
"""
6
+
7
+
@derive Jason.Encoder
8
+
defstruct [
9
+
audio: nil,
10
+
author: nil,
11
+
cid: nil,
12
+
commentCount: 0,
13
+
image: nil,
14
+
indexedAt: nil,
15
+
likeCount: 0,
16
+
playCount: 0,
17
+
record: nil,
18
+
repostCount: 0,
19
+
uri: nil,
20
+
viewer: nil
21
+
]
22
+
23
+
@type t() :: %__MODULE__{
24
+
audio: String.t(),
25
+
author: Sh.Comet.V0.Actor.Profile.ViewFull.t(),
26
+
cid: String.t(),
27
+
commentCount: integer,
28
+
image: String.t(),
29
+
indexedAt: DateTime.t(),
30
+
likeCount: integer,
31
+
playCount: integer,
32
+
record: Sh.Comet.V0.Feed.Track.Main.t(),
33
+
repostCount: integer,
34
+
uri: String.t(),
35
+
viewer: Sh.Comet.V0.Feed.Defs.ViewerState.t()
36
+
}
37
+
38
+
@spec new() :: t()
39
+
def new(), do: %__MODULE__{}
40
+
41
+
@spec from(binary() | map()) :: t()
42
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
43
+
end
44
+
+131
apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex
+131
apps/backend/lib/atproto/sh/comet/v0/richtext/facet/structs.ex
···
1
+
2
+
defmodule Sh.Comet.V0.Richtext.Facet.Timestamp do
3
+
@moduledoc """
4
+
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.
5
+
"""
6
+
7
+
@derive Jason.Encoder
8
+
defstruct [
9
+
timestamp: 0
10
+
]
11
+
12
+
@type t() :: %__MODULE__{
13
+
timestamp: integer
14
+
}
15
+
16
+
@spec new() :: t()
17
+
def new(), do: %__MODULE__{}
18
+
19
+
@spec from(binary() | map()) :: t()
20
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
21
+
end
22
+
23
+
defmodule Sh.Comet.V0.Richtext.Facet.Tag do
24
+
@moduledoc """
25
+
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').
26
+
"""
27
+
28
+
@derive Jason.Encoder
29
+
defstruct [
30
+
tag: nil
31
+
]
32
+
33
+
@type t() :: %__MODULE__{
34
+
tag: String.t()
35
+
}
36
+
37
+
@spec new() :: t()
38
+
def new(), do: %__MODULE__{}
39
+
40
+
@spec from(binary() | map()) :: t()
41
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
42
+
end
43
+
44
+
defmodule Sh.Comet.V0.Richtext.Facet.Mention do
45
+
@moduledoc """
46
+
Facet feature for mention of another account. The text is usually a handle, including a '@' prefix, but the facet reference is a DID.
47
+
"""
48
+
49
+
@derive Jason.Encoder
50
+
defstruct [
51
+
did: nil
52
+
]
53
+
54
+
@type t() :: %__MODULE__{
55
+
did: String.t()
56
+
}
57
+
58
+
@spec new() :: t()
59
+
def new(), do: %__MODULE__{}
60
+
61
+
@spec from(binary() | map()) :: t()
62
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
63
+
end
64
+
65
+
defmodule Sh.Comet.V0.Richtext.Facet.Main do
66
+
@moduledoc """
67
+
Annotation of a sub-string within rich text.
68
+
"""
69
+
70
+
@derive Jason.Encoder
71
+
defstruct [
72
+
features: [],
73
+
index: nil
74
+
]
75
+
76
+
@type t() :: %__MODULE__{
77
+
features: list(any),
78
+
index: Sh.Comet.V0.Richtext.Facet.ByteSlice.t()
79
+
}
80
+
81
+
@spec new() :: t()
82
+
def new(), do: %__MODULE__{}
83
+
84
+
@spec from(binary() | map()) :: t()
85
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
86
+
end
87
+
88
+
defmodule Sh.Comet.V0.Richtext.Facet.Link do
89
+
@moduledoc """
90
+
Facet feature for a URL. The text URL may have been simplified or truncated, but the facet reference should be a complete URL.
91
+
"""
92
+
93
+
@derive Jason.Encoder
94
+
defstruct [
95
+
uri: nil
96
+
]
97
+
98
+
@type t() :: %__MODULE__{
99
+
uri: String.t()
100
+
}
101
+
102
+
@spec new() :: t()
103
+
def new(), do: %__MODULE__{}
104
+
105
+
@spec from(binary() | map()) :: t()
106
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
107
+
end
108
+
109
+
defmodule Sh.Comet.V0.Richtext.Facet.ByteSlice do
110
+
@moduledoc """
111
+
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.
112
+
"""
113
+
114
+
@derive Jason.Encoder
115
+
defstruct [
116
+
byteEnd: 0,
117
+
byteStart: 0
118
+
]
119
+
120
+
@type t() :: %__MODULE__{
121
+
byteEnd: integer,
122
+
byteStart: integer
123
+
}
124
+
125
+
@spec new() :: t()
126
+
def new(), do: %__MODULE__{}
127
+
128
+
@spec from(binary() | map()) :: t()
129
+
def from(json), do: Atproto.decode_to_struct(__MODULE__, json)
130
+
end
131
+
+105
apps/backend/lib/atproto/tid.ex
+105
apps/backend/lib/atproto/tid.ex
···
1
+
defmodule Atproto.TID do
2
+
@moduledoc """
3
+
A module for encoding and decoding TIDs.
4
+
5
+
[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.
6
+
7
+
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.
8
+
"""
9
+
10
+
import Bitwise
11
+
12
+
@tid_char_set ~c(234567abcdefghijklmnopqrstuvwxyz)
13
+
@tid_char_set_length 32
14
+
15
+
defstruct [
16
+
:timestamp,
17
+
:clock_id,
18
+
:string
19
+
]
20
+
21
+
@typedoc """
22
+
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.
23
+
"""
24
+
@type t() :: %__MODULE__{
25
+
timestamp: integer(),
26
+
clock_id: integer(),
27
+
string: binary()
28
+
}
29
+
30
+
@doc """
31
+
Generates a random 10-bit clock identifier.
32
+
"""
33
+
@spec random_clock_id() :: integer()
34
+
def random_clock_id(), do: :rand.uniform(1024) - 1
35
+
36
+
@doc """
37
+
Generates a new TID for the current time.
38
+
39
+
This is equivalent to calling `encode(nil)`.
40
+
"""
41
+
@spec new() :: t()
42
+
def new(), do: encode(nil)
43
+
44
+
@doc """
45
+
Encodes an integer or DateTime struct into a 13-character string that is "base32-sortable" encoded.
46
+
47
+
If `timestamp` is nil, or not provided, the current time will be used as represented by `DateTime.utc_now()`.
48
+
49
+
If `clock_id` is nil, or not provided, a random 10-bit integer will be used.
50
+
51
+
If `timestamp` is an integer value, it *MUST* be a unix timestamp measured in microseconds. This function does not validate integer values.
52
+
"""
53
+
@spec encode(nil | integer() | DateTime.t(), nil | integer()) :: t()
54
+
def encode(timestamp \\ nil, clock_id \\ nil)
55
+
56
+
def encode(nil, clock_id), do: encode(DateTime.utc_now(), clock_id)
57
+
58
+
def encode(timestamp, nil), do: encode(timestamp, random_clock_id())
59
+
60
+
def encode(%DateTime{} = datetime, clock_id) do
61
+
datetime
62
+
|> DateTime.to_unix(:microsecond)
63
+
|> encode(clock_id)
64
+
end
65
+
66
+
def encode(timestamp, clock_id) when is_integer(timestamp) and is_integer(clock_id) do
67
+
# Ensure we only use the lower 10 bit of clock_id
68
+
clock_id = clock_id &&& 1023
69
+
str =
70
+
timestamp
71
+
|> bsr(10)
72
+
|> bsl(10)
73
+
|> bxor(clock_id)
74
+
|> do_encode("")
75
+
%__MODULE__{timestamp: timestamp, clock_id: clock_id, string: str}
76
+
end
77
+
78
+
defp do_encode(0, acc), do: acc
79
+
80
+
defp do_encode(number, acc) do
81
+
c = rem(number, @tid_char_set_length)
82
+
number = div(number, @tid_char_set_length)
83
+
do_encode(number, <<Enum.at(@tid_char_set, c)>> <> acc)
84
+
end
85
+
86
+
@doc """
87
+
Decodes a binary string into a TID struct.
88
+
"""
89
+
@spec decode(binary()) :: t()
90
+
def decode(tid) do
91
+
num = do_decode(tid, 0)
92
+
%__MODULE__{timestamp: bsr(num, 10), clock_id: num &&& 1023, string: tid}
93
+
end
94
+
95
+
defp do_decode(<<>>, acc), do: acc
96
+
97
+
defp do_decode(<<char::utf8, rest::binary>>, acc) do
98
+
idx = Enum.find_index(@tid_char_set, fn x -> x == char end)
99
+
do_decode(rest, (acc * @tid_char_set_length) + idx)
100
+
end
101
+
end
102
+
103
+
defimpl String.Chars, for: Atproto.TID do
104
+
def to_string(tid), do: tid.string
105
+
end
+9
apps/backend/lib/comet.ex
+9
apps/backend/lib/comet.ex
+34
apps/backend/lib/comet/application.ex
+34
apps/backend/lib/comet/application.ex
···
1
+
defmodule Comet.Application do
2
+
# See https://hexdocs.pm/elixir/Application.html
3
+
# for more information on OTP Applications
4
+
@moduledoc false
5
+
6
+
use Application
7
+
8
+
@impl true
9
+
def start(_type, _args) do
10
+
children = [
11
+
CometWeb.Telemetry,
12
+
Comet.Repo,
13
+
{DNSCluster, query: Application.get_env(:comet, :dns_cluster_query) || :ignore},
14
+
{Phoenix.PubSub, name: Comet.PubSub},
15
+
# Start a worker by calling: Comet.Worker.start_link(arg)
16
+
# {Comet.Worker, arg},
17
+
# Start to serve requests, typically the last entry
18
+
CometWeb.Endpoint
19
+
]
20
+
21
+
# See https://hexdocs.pm/elixir/Supervisor.html
22
+
# for other strategies and supported options
23
+
opts = [strategy: :one_for_one, name: Comet.Supervisor]
24
+
Supervisor.start_link(children, opts)
25
+
end
26
+
27
+
# Tell Phoenix to update the endpoint configuration
28
+
# whenever the application is updated.
29
+
@impl true
30
+
def config_change(changed, _new, removed) do
31
+
CometWeb.Endpoint.config_change(changed, removed)
32
+
:ok
33
+
end
34
+
end
+5
apps/backend/lib/comet/repo.ex
+5
apps/backend/lib/comet/repo.ex
+65
apps/backend/lib/comet_web.ex
+65
apps/backend/lib/comet_web.ex
···
1
+
defmodule CometWeb do
2
+
@moduledoc """
3
+
The entrypoint for defining your web interface, such
4
+
as controllers, components, channels, and so on.
5
+
6
+
This can be used in your application as:
7
+
8
+
use CometWeb, :controller
9
+
use CometWeb, :html
10
+
11
+
The definitions below will be executed for every controller,
12
+
component, etc, so keep them short and clean, focused
13
+
on imports, uses and aliases.
14
+
15
+
Do NOT define functions inside the quoted expressions
16
+
below. Instead, define additional modules and import
17
+
those modules here.
18
+
"""
19
+
20
+
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21
+
22
+
def router do
23
+
quote do
24
+
use Phoenix.Router, helpers: false
25
+
26
+
# Import common connection and controller functions to use in pipelines
27
+
import Plug.Conn
28
+
import Phoenix.Controller
29
+
end
30
+
end
31
+
32
+
def channel do
33
+
quote do
34
+
use Phoenix.Channel
35
+
end
36
+
end
37
+
38
+
def controller do
39
+
quote do
40
+
use Phoenix.Controller,
41
+
formats: [:html, :json],
42
+
layouts: [html: CometWeb.Layouts]
43
+
44
+
import Plug.Conn
45
+
46
+
unquote(verified_routes())
47
+
end
48
+
end
49
+
50
+
def verified_routes do
51
+
quote do
52
+
use Phoenix.VerifiedRoutes,
53
+
endpoint: CometWeb.Endpoint,
54
+
router: CometWeb.Router,
55
+
statics: CometWeb.static_paths()
56
+
end
57
+
end
58
+
59
+
@doc """
60
+
When used, dispatch to the appropriate controller/live_view/etc.
61
+
"""
62
+
defmacro __using__(which) when is_atom(which) do
63
+
apply(__MODULE__, which, [])
64
+
end
65
+
end
+21
apps/backend/lib/comet_web/controllers/error_json.ex
+21
apps/backend/lib/comet_web/controllers/error_json.ex
···
1
+
defmodule CometWeb.ErrorJSON do
2
+
@moduledoc """
3
+
This module is invoked by your endpoint in case of errors on JSON requests.
4
+
5
+
See config/config.exs.
6
+
"""
7
+
8
+
# If you want to customize a particular status code,
9
+
# you may add your own clauses, such as:
10
+
#
11
+
# def render("500.json", _assigns) do
12
+
# %{errors: %{detail: "Internal Server Error"}}
13
+
# end
14
+
15
+
# By default, Phoenix returns the status message from
16
+
# the template name. For example, "404.json" becomes
17
+
# "Not Found".
18
+
def render(template, _assigns) do
19
+
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
20
+
end
21
+
end
+51
apps/backend/lib/comet_web/endpoint.ex
+51
apps/backend/lib/comet_web/endpoint.ex
···
1
+
defmodule CometWeb.Endpoint do
2
+
use Phoenix.Endpoint, otp_app: :comet
3
+
4
+
# The session will be stored in the cookie and signed,
5
+
# this means its contents can be read but not tampered with.
6
+
# Set :encryption_salt if you would also like to encrypt it.
7
+
@session_options [
8
+
store: :cookie,
9
+
key: "_comet_key",
10
+
signing_salt: "zgKytneJ",
11
+
same_site: "Lax"
12
+
]
13
+
14
+
socket "/live", Phoenix.LiveView.Socket,
15
+
websocket: [connect_info: [session: @session_options]],
16
+
longpoll: [connect_info: [session: @session_options]]
17
+
18
+
# Serve at "/" the static files from "priv/static" directory.
19
+
#
20
+
# You should set gzip to true if you are running phx.digest
21
+
# when deploying your static files in production.
22
+
plug Plug.Static,
23
+
at: "/",
24
+
from: :comet,
25
+
gzip: false,
26
+
only: CometWeb.static_paths()
27
+
28
+
# Code reloading can be explicitly enabled under the
29
+
# :code_reloader configuration of your endpoint.
30
+
if code_reloading? do
31
+
plug Phoenix.CodeReloader
32
+
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :comet
33
+
end
34
+
35
+
plug Phoenix.LiveDashboard.RequestLogger,
36
+
param_key: "request_logger",
37
+
cookie_key: "request_logger"
38
+
39
+
plug Plug.RequestId
40
+
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
41
+
42
+
plug Plug.Parsers,
43
+
parsers: [:urlencoded, :multipart, :json],
44
+
pass: ["*/*"],
45
+
json_decoder: Phoenix.json_library()
46
+
47
+
plug Plug.MethodOverride
48
+
plug Plug.Head
49
+
plug Plug.Session, @session_options
50
+
plug CometWeb.Router
51
+
end
+27
apps/backend/lib/comet_web/router.ex
+27
apps/backend/lib/comet_web/router.ex
···
1
+
defmodule CometWeb.Router do
2
+
use CometWeb, :router
3
+
4
+
pipeline :api do
5
+
plug :accepts, ["json"]
6
+
end
7
+
8
+
scope "/api", CometWeb do
9
+
pipe_through :api
10
+
end
11
+
12
+
# Enable LiveDashboard in development
13
+
if Application.compile_env(:comet, :dev_routes) do
14
+
# If you want to use the LiveDashboard in production, you should put
15
+
# it behind authentication and allow only admins to access it.
16
+
# If your application does not have an admins-only section yet,
17
+
# you can use Plug.BasicAuth to set up some basic authentication
18
+
# as long as you are also using SSL (which you should anyway).
19
+
import Phoenix.LiveDashboard.Router
20
+
21
+
scope "/dev" do
22
+
pipe_through [:fetch_session, :protect_from_forgery]
23
+
24
+
live_dashboard "/dashboard", metrics: CometWeb.Telemetry
25
+
end
26
+
end
27
+
end
+93
apps/backend/lib/comet_web/telemetry.ex
+93
apps/backend/lib/comet_web/telemetry.ex
···
1
+
defmodule CometWeb.Telemetry do
2
+
use Supervisor
3
+
import Telemetry.Metrics
4
+
5
+
def start_link(arg) do
6
+
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7
+
end
8
+
9
+
@impl true
10
+
def init(_arg) do
11
+
children = [
12
+
# Telemetry poller will execute the given period measurements
13
+
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14
+
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15
+
# Add reporters as children of your supervision tree.
16
+
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17
+
]
18
+
19
+
Supervisor.init(children, strategy: :one_for_one)
20
+
end
21
+
22
+
def metrics do
23
+
[
24
+
# Phoenix Metrics
25
+
summary("phoenix.endpoint.start.system_time",
26
+
unit: {:native, :millisecond}
27
+
),
28
+
summary("phoenix.endpoint.stop.duration",
29
+
unit: {:native, :millisecond}
30
+
),
31
+
summary("phoenix.router_dispatch.start.system_time",
32
+
tags: [:route],
33
+
unit: {:native, :millisecond}
34
+
),
35
+
summary("phoenix.router_dispatch.exception.duration",
36
+
tags: [:route],
37
+
unit: {:native, :millisecond}
38
+
),
39
+
summary("phoenix.router_dispatch.stop.duration",
40
+
tags: [:route],
41
+
unit: {:native, :millisecond}
42
+
),
43
+
summary("phoenix.socket_connected.duration",
44
+
unit: {:native, :millisecond}
45
+
),
46
+
sum("phoenix.socket_drain.count"),
47
+
summary("phoenix.channel_joined.duration",
48
+
unit: {:native, :millisecond}
49
+
),
50
+
summary("phoenix.channel_handled_in.duration",
51
+
tags: [:event],
52
+
unit: {:native, :millisecond}
53
+
),
54
+
55
+
# Database Metrics
56
+
summary("comet.repo.query.total_time",
57
+
unit: {:native, :millisecond},
58
+
description: "The sum of the other measurements"
59
+
),
60
+
summary("comet.repo.query.decode_time",
61
+
unit: {:native, :millisecond},
62
+
description: "The time spent decoding the data received from the database"
63
+
),
64
+
summary("comet.repo.query.query_time",
65
+
unit: {:native, :millisecond},
66
+
description: "The time spent executing the query"
67
+
),
68
+
summary("comet.repo.query.queue_time",
69
+
unit: {:native, :millisecond},
70
+
description: "The time spent waiting for a database connection"
71
+
),
72
+
summary("comet.repo.query.idle_time",
73
+
unit: {:native, :millisecond},
74
+
description:
75
+
"The time the connection spent waiting before being checked out for the query"
76
+
),
77
+
78
+
# VM Metrics
79
+
summary("vm.memory.total", unit: {:byte, :kilobyte}),
80
+
summary("vm.total_run_queue_lengths.total"),
81
+
summary("vm.total_run_queue_lengths.cpu"),
82
+
summary("vm.total_run_queue_lengths.io")
83
+
]
84
+
end
85
+
86
+
defp periodic_measurements do
87
+
[
88
+
# A module, function and arguments to be invoked periodically.
89
+
# This function must call :telemetry.execute/3 and a metric must be added above.
90
+
# {CometWeb, :count_users, []}
91
+
]
92
+
end
93
+
end
+68
apps/backend/mix.exs
+68
apps/backend/mix.exs
···
1
+
defmodule Comet.MixProject do
2
+
use Mix.Project
3
+
4
+
def project do
5
+
[
6
+
app: :comet,
7
+
version: "0.1.0",
8
+
elixir: "~> 1.14",
9
+
elixirc_paths: elixirc_paths(Mix.env()),
10
+
start_permanent: Mix.env() == :prod,
11
+
aliases: aliases(),
12
+
deps: deps()
13
+
]
14
+
end
15
+
16
+
# Configuration for the OTP application.
17
+
#
18
+
# Type `mix help compile.app` for more information.
19
+
def application do
20
+
[
21
+
mod: {Comet.Application, []},
22
+
extra_applications: [:logger, :runtime_tools]
23
+
]
24
+
end
25
+
26
+
# Specifies which paths to compile per environment.
27
+
defp elixirc_paths(:test), do: ["lib", "test/support"]
28
+
defp elixirc_paths(_), do: ["lib"]
29
+
30
+
# Specifies your project dependencies.
31
+
#
32
+
# Type `mix help deps` for examples and options.
33
+
defp deps do
34
+
[
35
+
{:phoenix, "~> 1.7.21"},
36
+
{:phoenix_ecto, "~> 4.5"},
37
+
{:ecto_sql, "~> 3.10"},
38
+
{:postgrex, ">= 0.0.0"},
39
+
{:phoenix_live_dashboard, "~> 0.8.3"},
40
+
{:telemetry_metrics, "~> 1.0"},
41
+
{:telemetry_poller, "~> 1.0"},
42
+
{:jason, "~> 1.2"},
43
+
{:dns_cluster, "~> 0.1.1"},
44
+
{:bandit, "~> 1.5"},
45
+
{:lexgen, "~> 1.0.0", only: [:dev]},
46
+
{:req, "~> 0.5.0"},
47
+
{:typedstruct, "~> 0.5"}
48
+
]
49
+
end
50
+
51
+
# Aliases are shortcuts or tasks specific to the current project.
52
+
# For example, to install project dependencies and perform other setup tasks, run:
53
+
#
54
+
# $ mix setup
55
+
#
56
+
# See the documentation for `Mix` for more info on aliases.
57
+
defp aliases do
58
+
lexicon_paths = Path.wildcard("../../packages/lexicons/defs/**/*.json")
59
+
60
+
[
61
+
setup: ["deps.get", "ecto.setup"],
62
+
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
63
+
"ecto.reset": ["ecto.drop", "ecto.setup"],
64
+
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
65
+
"gen.lexicons": ["lexgen" | lexicon_paths] |> Enum.join(" ")
66
+
]
67
+
end
68
+
end
+35
apps/backend/mix.lock
+35
apps/backend/mix.lock
···
1
+
%{
2
+
"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"},
3
+
"castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
4
+
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
5
+
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
6
+
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
7
+
"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"},
8
+
"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"},
9
+
"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"},
10
+
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
11
+
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
12
+
"lexgen": {:hex, :lexgen, "1.0.0", "1ca22ba00b86f9fa97718651b77b87a5965b8a9f71109ac2c11cb573f17499aa", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ff64e0e192645208e7ce1b6468037a6d4ebfb98a506ab15d30fb46ca492ec275"},
13
+
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
14
+
"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"},
15
+
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
16
+
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
17
+
"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"},
18
+
"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"},
19
+
"phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
20
+
"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"},
21
+
"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"},
22
+
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
23
+
"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"},
24
+
"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"},
25
+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
26
+
"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"},
27
+
"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"},
28
+
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
29
+
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
30
+
"telemetry_poller": {:hex, :telemetry_poller, "1.2.0", "ba82e333215aed9dd2096f93bd1d13ae89d249f82760fcada0850ba33bac154b", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7216e21a6c326eb9aa44328028c34e9fd348fb53667ca837be59d0aa2a0156e8"},
31
+
"thousand_island": {:hex, :thousand_island, "1.3.13", "d598c609172275f7b1648c9f6eddf900e42312b09bfc2f2020358f926ee00d39", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5a34bdf24ae2f965ddf7ba1a416f3111cfe7df50de8d66f6310e01fc2e80b02a"},
32
+
"typedstruct": {:hex, :typedstruct, "0.5.3", "d68ae424251a41b81a8d0c485328ab48edbd3858f3565bbdac21b43c056fc9b4", [:make, :mix], [], "hexpm", "b53b8186701417c0b2782bf02a2db5524f879b8488f91d1d83b97d84c2943432"},
33
+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
34
+
"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"},
35
+
}
+4
apps/backend/priv/repo/migrations/.formatter.exs
+4
apps/backend/priv/repo/migrations/.formatter.exs
+11
apps/backend/priv/repo/seeds.exs
+11
apps/backend/priv/repo/seeds.exs
···
1
+
# Script for populating the database. You can run it as:
2
+
#
3
+
# mix run priv/repo/seeds.exs
4
+
#
5
+
# Inside the script, you can read and write to any of your
6
+
# repositories directly:
7
+
#
8
+
# Comet.Repo.insert!(%Comet.SomeSchema{})
9
+
#
10
+
# We recommend using the bang functions (`insert!`, `update!`
11
+
# and so on) as they will fail if something goes wrong.
apps/backend/priv/static/favicon.ico
apps/backend/priv/static/favicon.ico
This is a binary file and will not be displayed.
+5
apps/backend/priv/static/robots.txt
+5
apps/backend/priv/static/robots.txt
+12
apps/backend/test/comet_web/controllers/error_json_test.exs
+12
apps/backend/test/comet_web/controllers/error_json_test.exs
···
1
+
defmodule CometWeb.ErrorJSONTest do
2
+
use CometWeb.ConnCase, async: true
3
+
4
+
test "renders 404" do
5
+
assert CometWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6
+
end
7
+
8
+
test "renders 500" do
9
+
assert CometWeb.ErrorJSON.render("500.json", %{}) ==
10
+
%{errors: %{detail: "Internal Server Error"}}
11
+
end
12
+
end
+38
apps/backend/test/support/conn_case.ex
+38
apps/backend/test/support/conn_case.ex
···
1
+
defmodule CometWeb.ConnCase do
2
+
@moduledoc """
3
+
This module defines the test case to be used by
4
+
tests that require setting up a connection.
5
+
6
+
Such tests rely on `Phoenix.ConnTest` and also
7
+
import other functionality to make it easier
8
+
to build common data structures and query the data layer.
9
+
10
+
Finally, if the test case interacts with the database,
11
+
we enable the SQL sandbox, so changes done to the database
12
+
are reverted at the end of every test. If you are using
13
+
PostgreSQL, you can even run database tests asynchronously
14
+
by setting `use CometWeb.ConnCase, async: true`, although
15
+
this option is not recommended for other databases.
16
+
"""
17
+
18
+
use ExUnit.CaseTemplate
19
+
20
+
using do
21
+
quote do
22
+
# The default endpoint for testing
23
+
@endpoint CometWeb.Endpoint
24
+
25
+
use CometWeb, :verified_routes
26
+
27
+
# Import conveniences for testing with connections
28
+
import Plug.Conn
29
+
import Phoenix.ConnTest
30
+
import CometWeb.ConnCase
31
+
end
32
+
end
33
+
34
+
setup tags do
35
+
Comet.DataCase.setup_sandbox(tags)
36
+
{:ok, conn: Phoenix.ConnTest.build_conn()}
37
+
end
38
+
end
+58
apps/backend/test/support/data_case.ex
+58
apps/backend/test/support/data_case.ex
···
1
+
defmodule Comet.DataCase do
2
+
@moduledoc """
3
+
This module defines the setup for tests requiring
4
+
access to the application's data layer.
5
+
6
+
You may define functions here to be used as helpers in
7
+
your tests.
8
+
9
+
Finally, if the test case interacts with the database,
10
+
we enable the SQL sandbox, so changes done to the database
11
+
are reverted at the end of every test. If you are using
12
+
PostgreSQL, you can even run database tests asynchronously
13
+
by setting `use Comet.DataCase, async: true`, although
14
+
this option is not recommended for other databases.
15
+
"""
16
+
17
+
use ExUnit.CaseTemplate
18
+
19
+
using do
20
+
quote do
21
+
alias Comet.Repo
22
+
23
+
import Ecto
24
+
import Ecto.Changeset
25
+
import Ecto.Query
26
+
import Comet.DataCase
27
+
end
28
+
end
29
+
30
+
setup tags do
31
+
Comet.DataCase.setup_sandbox(tags)
32
+
:ok
33
+
end
34
+
35
+
@doc """
36
+
Sets up the sandbox based on the test tags.
37
+
"""
38
+
def setup_sandbox(tags) do
39
+
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Comet.Repo, shared: not tags[:async])
40
+
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
41
+
end
42
+
43
+
@doc """
44
+
A helper that transforms changeset errors into a map of messages.
45
+
46
+
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
47
+
assert "password is too short" in errors_on(changeset).password
48
+
assert %{password: ["password is too short"]} = errors_on(changeset)
49
+
50
+
"""
51
+
def errors_on(changeset) do
52
+
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
53
+
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
54
+
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
55
+
end)
56
+
end)
57
+
end
58
+
end
+2
apps/backend/test/test_helper.exs
+2
apps/backend/test/test_helper.exs