Merge branch 'multitenancy' into fork

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-03-14 23:29:24 +01:00
commit 53a73aada2
43 changed files with 1263 additions and 49 deletions

View file

@ -0,0 +1 @@
Allow using multiple domains for WebFinger

View file

@ -252,7 +252,10 @@
birthday_required: false,
birthday_min_age: 0,
max_media_attachments: 1_000,
migration_cooldown_period: 30
migration_cooldown_period: 30,
multitenancy: %{
enabled: false
}
config :pleroma, :welcome,
direct_message: [
@ -587,10 +590,12 @@
attachments_cleanup: 1,
new_users_digest: 1,
mute_expire: 5,
search_indexing: 10
search_indexing: 10,
check_domain_resolve: 1
],
plugins: [Oban.Plugins.Pruner],
crontab: [
{"0 0 * * 0", Pleroma.Workers.Cron.CheckDomainsResolveWorker},
{"0 0 * * 0", Pleroma.Workers.Cron.DigestEmailsWorker},
{"0 0 * * *", Pleroma.Workers.Cron.NewUsersDigestWorker}
]

View file

@ -1096,6 +1096,23 @@
suggestions: [
"en"
]
},
%{
key: :multitenancy,
type: :map,
description: "Multitenancy support",
children: [
%{
key: :enabled,
type: :boolean,
description: "Enables allowing multiple Webfinger domains"
},
%{
key: :separate_timelines,
type: :boolean,
description: "Only display posts from own domain on local timeline"
}
]
}
]
},

View file

@ -0,0 +1,30 @@
# How to serve multiple domains for Pleroma user identifiers
It is possible to use multiple domains for WebFinger identifiers. If configured, users can select from the available domains during registration. Domains can be set by instance administrator and can be marked as either public (everyone can choose it) or private (only available when admin creates a user)
## Configuring
### Configuring Pleroma
To enable using multiple domains, append the following to your `prod.secret.exs` or `dev.secret.exs`:
```elixir
config :pleroma, :instance, :multitenancy, enabled: true
```
Creating, updating and deleting domains is available from the admin API.
### Configuring WebFinger domains
If you recall how webfinger queries work, the first step is to query `https://example.org/.well-known/host-meta`, which will contain an URL template.
Therefore, the easiest way to configure the additional domains is to redirect `/.well-known/host-meta` to the domain used by Pleroma.
With nginx, it would be as simple as adding:
```nginx
location = /.well-known/host-meta {
return 301 https://pleroma.example.org$request_uri;
}
```
in the additional domain's server block.

View file

@ -1840,3 +1840,70 @@ Note that this differs from the Mastodon API variant: Mastodon API only returns
- Params:
- `id`: **string** Webhook ID
- Response: A webhook
## `GET /api/v1/pleroma/admin/domains`
### List of domains
- Response: JSON, list of domains
```json
[
{
"id": "1",
"domain": "example.org",
"public": false,
"resolves": true,
"last_checked_at": "2023-11-17T12:13:05"
}
]
```
## `POST /api/v1/pleroma/admin/domains`
### Create a domain
- Params:
- `domain`: string, required, domain name
- `public`: boolean, optional, defaults to false, whether it is possible to register an account under the domain by everyone
- Response: JSON, created announcement
```json
{
"id": "1",
"domain": "example.org",
"public": true,
"resolves": false,
"last_checked_at": null
}
```
## `POST /api/v1/pleroma/admin/domains/:id`
### Change domain publicity
- Params:
- `public`: boolean, whether it is possible to register an account under the domain by everyone
- Response: JSON, updated domain
```json
{
"id": "1",
"domain": "example.org",
"public": false,
"resolves": true,
"last_checked_at": "2023-11-17T12:13:05"
}
```
## `DELETE /api/v1/pleroma/admin/domains/:id`
### Delete a domain
- Response: JSON, empty object
```json
{}
```

View file

@ -352,6 +352,7 @@ Has these additional parameters (which are the same as in Pleroma-API):
- `captcha_answer_data`: optional, contains provider-specific captcha data
- `token`: invite token required when the registrations aren't public.
- `language`: optional, user's preferred language for receiving emails (digest, confirmation, etc.), default to the language set in the `userLanguage` cookies or `Accept-Language` header.
- `domain`: optional, domain id, if multitenancy is enabled.
## Instance

View file

@ -188,7 +188,8 @@ defp cachex_children do
build_cachex("anti_duplication_mrf", limit: 5_000),
build_cachex("translations", default_ttl: :timer.hours(24), limit: 5_000),
build_cachex("rel_me", default_ttl: :timer.minutes(30), limit: 2_500),
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5000)
build_cachex("host_meta", default_ttl: :timer.minutes(120), limit: 5000),
build_cachex("domain", limit: 2500)
]
end

90
lib/pleroma/domain.ex Normal file
View file

@ -0,0 +1,90 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Domain do
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Pleroma.Repo
schema "domains" do
field(:domain, :string, default: "")
field(:service_domain, :string, default: "")
field(:public, :boolean, default: false)
field(:resolves, :boolean, default: false)
field(:last_checked_at, :naive_datetime)
timestamps(type: :utc_datetime)
end
def changeset(%__MODULE__{} = domain, params \\ %{}) do
domain
|> cast(params, [:domain, :public])
|> validate_required([:domain])
|> maybe_add_service_domain()
|> update_change(:domain, &String.downcase/1)
|> unique_constraint(:domain)
|> unique_constraint(:service_domain)
end
def update_changeset(%__MODULE__{} = domain, params \\ %{}) do
domain
|> cast(params, [:public])
end
def update_state_changeset(%__MODULE__{} = domain, resolves) do
domain
|> cast(
%{
resolves: resolves,
last_checked_at: NaiveDateTime.utc_now()
},
[:resolves, :last_checked_at]
)
end
defp maybe_add_service_domain(%{changes: %{service_domain: _}} = changeset), do: changeset
defp maybe_add_service_domain(%{changes: %{domain: domain}} = changeset) do
change(changeset, service_domain: domain)
end
def list do
__MODULE__
|> order_by(asc: :id)
|> Repo.all()
end
def get(id), do: Repo.get(__MODULE__, id)
def get_by_service_domain(domain) do
__MODULE__
|> where(service_domain: ^domain)
|> Repo.one()
end
def create(params) do
%__MODULE__{}
|> changeset(params)
|> Repo.insert()
end
def update(params, id) do
get(id)
|> update_changeset(params)
|> Repo.update()
end
def delete(id) do
get(id)
|> Repo.delete()
end
def cached_list do
@cachex.fetch!(:domain_cache, "domains_list", fn _ -> list() end)
end
end

View file

@ -15,6 +15,7 @@ defmodule Pleroma.User do
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Delivery
alias Pleroma.Domain
alias Pleroma.EctoType.ActivityPub.ObjectValidators
alias Pleroma.Emoji
alias Pleroma.FollowingRelationship
@ -162,6 +163,8 @@ defmodule Pleroma.User do
field(:language, :string)
field(:last_move_at, :naive_datetime)
belongs_to(:domain, Domain)
embeds_one(
:notification_settings,
Pleroma.User.NotificationSetting,
@ -813,16 +816,18 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
:accepts_chat_messages,
:registration_reason,
:birthday,
:language
:language,
:domain_id
])
|> validate_required([:name, :nickname, :password, :password_confirmation])
|> validate_confirmation(:password)
|> unique_constraint(:email)
|> validate_format(:email, @email_regex)
|> validate_email_not_in_blacklisted_domain(:email)
|> unique_constraint(:nickname)
|> validate_not_restricted_nickname(:nickname)
|> validate_format(:nickname, local_nickname_regex())
|> fix_nickname(Map.get(params, :domain_id), opts[:from_admin])
|> validate_not_restricted_nickname(:nickname)
|> unique_constraint(:nickname)
|> validate_length(:bio, max: bio_limit)
|> validate_length(:name, min: 1, max: name_limit)
|> validate_length(:registration_reason, max: reason_limit)
@ -836,6 +841,26 @@ def register_changeset(struct, params \\ %{}, opts \\ []) do
|> put_private_key()
end
defp fix_nickname(changeset, domain_id, from_admin) when not is_nil(domain_id) do
with {:domain, domain} <- {:domain, Domain.get(domain_id)},
{:domain_allowed, true} <- {:domain_allowed, from_admin || domain.public} do
nickname = get_field(changeset, :nickname)
changeset
|> put_change(:nickname, nickname <> "@" <> domain.domain)
|> put_change(:domain, domain)
else
{:domain_allowed, false} ->
changeset
|> add_error(:domain, "not allowed to use this domain")
_ ->
changeset
end
end
defp fix_nickname(changeset, _, _), do: changeset
def validate_not_restricted_nickname(changeset, field) do
validate_change(changeset, field, fn _, value ->
valid? =
@ -896,7 +921,9 @@ defp validate_min_age(changeset) do
end
defp put_ap_id(changeset) do
ap_id = ap_id(%User{nickname: get_field(changeset, :nickname)})
nickname = get_field(changeset, :nickname)
ap_id = ap_id(%User{nickname: nickname})
put_change(changeset, :ap_id, ap_id)
end
@ -1309,6 +1336,13 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
restrict_to_local == :unauthenticated and match?(%User{}, opts[:for]) ->
get_cached_by_nickname(nickname_or_id)
String.contains?(nickname_or_id, "@") ->
with %User{local: true} = user <- get_cached_by_nickname(nickname_or_id) do
user
else
_ -> nil
end
true ->
nil
end

View file

@ -952,6 +952,24 @@ defp restrict_local(query, %{local_only: true}) do
defp restrict_local(query, _), do: query
defp restrict_domain(query, %{domain_id: 0}) do
query
|> join(:inner, [activity], u in User,
as: :domain_user,
on: activity.actor == u.ap_id and is_nil(u.domain_id)
)
end
defp restrict_domain(query, %{domain_id: domain_id}) do
query
|> join(:inner, [activity], u in User,
as: :domain_user,
on: activity.actor == u.ap_id and u.domain_id == ^domain_id
)
end
defp restrict_domain(query, _), do: query
defp restrict_remote(query, %{remote: true}) do
from(activity in query, where: activity.local == false)
end
@ -1468,6 +1486,7 @@ def fetch_activities_query(recipients, opts \\ %{}) do
|> restrict_replies(opts)
|> restrict_since(opts)
|> restrict_local(opts)
|> restrict_domain(opts)
|> restrict_remote(opts)
|> restrict_actor(opts)
|> restrict_type(opts)

View file

@ -52,6 +52,16 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
when action in [:activity, :object]
)
plug(
Pleroma.Web.Plugs.SetDomainPlug
when action in [:following, :followers, :pinned, :inbox, :outbox, :update_outbox]
)
plug(
Pleroma.Web.Plugs.SetNicknameWithDomainPlug
when action in [:following, :followers, :pinned, :inbox, :outbox, :update_outbox]
)
plug(:set_requester_reachable when action in [:inbox])
plug(:relay_active? when action in [:relay])
@ -525,7 +535,7 @@ def upload_media(%{assigns: %{user: %User{} = user}} = conn, %{"file" => file} =
with {:ok, object} <-
ActivityPub.upload(
file,
actor: User.ap_id(user),
actor: user.ap_id,
description: Map.get(data, "description")
) do
Logger.debug(inspect(object))

View file

@ -0,0 +1,78 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.DomainController do
@cachex Pleroma.Config.get([:cachex, :provider], Cachex)
use Pleroma.Web, :controller
alias Pleroma.Domain
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
import Pleroma.Web.ControllerHelper,
only: [
json_response: 3
]
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["admin:write"]}
when action in [:create, :update, :delete]
)
plug(OAuthScopesPlug, %{scopes: ["admin:read"]} when action == :index)
action_fallback(AdminAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.DomainOperation
def index(conn, _) do
domains = Domain.list()
render(conn, "index.json", domains: domains)
end
def create(%{body_params: params} = conn, _) do
with {:domain_not_used, true} <-
{:domain_not_used, params[:domain] !== Pleroma.Web.WebFinger.host()},
{:ok, domain} <- Domain.create(params),
_ <- @cachex.del(:domain, :domains_list) do
Pleroma.Workers.CheckDomainResolveWorker.enqueue("check_domain_resolve", %{
"id" => domain.id
})
render(conn, "show.json", domain: domain)
else
{:domain_not_used, false} ->
{:error, :invalid_domain}
{:error, changeset} ->
errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end)
{:errors, errors}
end
end
def update(%{body_params: params} = conn, %{id: id}) do
{:ok, domain} =
params
|> Domain.update(id)
@cachex.del(:domain, :domains_list)
render(conn, "show.json", domain: domain)
end
def delete(conn, %{id: id}) do
with {:ok, _} <- Domain.delete(id),
_ <- @cachex.del(:domain, :domains_list) do
json(conn, %{})
else
_ -> json_response(conn, :bad_request, "")
end
end
end

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.AdminAPI.UserController do
import Pleroma.Web.ControllerHelper,
only: [fetch_integer_param: 3]
alias Pleroma.Domain
alias Pleroma.ModerationLog
alias Pleroma.User
alias Pleroma.Web.ActivityPub.Builder
@ -165,17 +166,25 @@ def create(
) do
changesets =
users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
|> Enum.map(fn %{nickname: nickname, email: email, password: password} = user ->
domain_id = Map.get(user, :domain)
domain =
if domain_id do
Domain.get(domain_id)
end
user_data = %{
nickname: nickname,
name: nickname,
email: email,
password: password,
password_confirmation: password,
bio: "."
bio: ".",
domain: domain
}
User.register_changeset(%User{}, user_data, need_confirmation: false)
User.register_changeset(%User{}, user_data, need_confirmation: false, from_admin: true)
end)
|> Enum.reduce(Ecto.Multi.new(), fn changeset, multi ->
Ecto.Multi.insert(multi, Ecto.UUID.generate(), changeset)

View file

@ -0,0 +1,35 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.DomainView do
use Pleroma.Web, :view
alias Pleroma.Domain
alias Pleroma.Web.CommonAPI.Utils
def render("index.json", %{domains: domains} = assigns) do
render_many(domains, __MODULE__, "show.json", assigns |> Map.delete("domains"))
end
def render("show.json", %{domain: %Domain{} = domain} = assigns) do
%{
id: domain.id |> to_string(),
domain: domain.domain,
public: domain.public
}
|> maybe_put_resolve_information(domain, assigns)
end
defp maybe_put_resolve_information(map, _domain, %{admin: false}) do
map
end
defp maybe_put_resolve_information(map, domain, _assigns) do
map
|> Map.merge(%{
resolves: domain.resolves,
last_checked_at: Utils.to_masto_date(domain.last_checked_at)
})
end
end

View file

@ -106,14 +106,8 @@ def spec(opts \\ []) do
"User administration",
"Announcement management",
"Instance rule managment",
"Webhooks"
]
},
%{
"name" => "Administration (MastoAPI)",
"tags" => [
"User administration",
"Report methods"
"Webhooks",
"Domain managment"
]
},
%{

View file

@ -639,7 +639,8 @@ defp create_request do
type: :string,
nullable: true,
description: "User's preferred language for emails"
}
},
domain: %Schema{type: :string, nullable: true}
},
example: %{
"username" => "cofe",

View file

@ -0,0 +1,115 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Admin.DomainOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Domain managment"],
summary: "Retrieve list of domains",
operationId: "AdminAPI.DomainController.index",
security: [%{"oAuth" => ["admin:read"]}],
responses: %{
200 =>
Operation.response("Response", "application/json", %Schema{
type: :array,
items: domain()
}),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def create_operation do
%Operation{
tags: ["Domain managment"],
summary: "Create new domain",
operationId: "AdminAPI.DomainController.create",
security: [%{"oAuth" => ["admin:write"]}],
parameters: admin_api_params(),
requestBody: request_body("Parameters", create_request(), required: true),
responses: %{
200 => Operation.response("Response", "application/json", domain()),
400 => Operation.response("Bad Request", "application/json", ApiError),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def update_operation do
%Operation{
tags: ["Domain managment"],
summary: "Modify existing domain",
operationId: "AdminAPI.DomainController.update",
security: [%{"oAuth" => ["admin:write"]}],
parameters: [Operation.parameter(:id, :path, :string, "Domain ID")],
requestBody: request_body("Parameters", update_request(), required: true),
responses: %{
200 => Operation.response("Response", "application/json", domain()),
400 => Operation.response("Bad Request", "application/json", ApiError),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Domain managment"],
summary: "Delete domain",
operationId: "AdminAPI.DomainController.delete",
parameters: [Operation.parameter(:id, :path, :string, "Domain ID")],
security: [%{"oAuth" => ["admin:write"]}],
responses: %{
200 => empty_object_response(),
404 => Operation.response("Not Found", "application/json", ApiError),
403 => Operation.response("Forbidden", "application/json", ApiError)
}
}
end
defp create_request do
%Schema{
type: :object,
required: [:domain],
properties: %{
domain: %Schema{type: :string},
service_domain: %Schema{type: :string, nullable: true},
public: %Schema{type: :boolean, nullable: true}
}
}
end
defp update_request do
%Schema{
type: :object,
properties: %{
public: %Schema{type: :boolean, nullable: true}
}
}
end
defp domain do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :integer},
domain: %Schema{type: :string},
service_domain: %Schema{type: :string},
public: %Schema{type: :boolean},
resolves: %Schema{type: :boolean},
last_checked_at: %Schema{type: :string, format: "date-time", nullable: true}
}
}
end
end

View file

@ -82,7 +82,8 @@ def create_operation do
properties: %{
nickname: %Schema{type: :string},
email: %Schema{type: :string},
password: %Schema{type: :string}
password: %Schema{type: :string},
domain: %Schema{type: :string, nullable: true}
}
}
}

View file

@ -105,7 +105,7 @@ def reopen_operation do
def assign_to_self_operation do
%Operation{
tags: ["Report methods"],
tags: ["Report management (Mastodon API)"],
summary: "Assign report to self",
operationId: "MastodonAdmin.ReportController.assign_to_self",
description: "Claim the handling of this report to yourself.",
@ -123,7 +123,7 @@ def assign_to_self_operation do
def unassign_operation do
%Operation{
tags: ["Report methods"],
tags: ["Report management (Mastodon API)"],
summary: "Unassign report",
operationId: "MastodonAdmin.ReportController.unassign",
description: "Unassign a report so that someone else can claim it.",

View file

@ -116,7 +116,10 @@ defmodule Pleroma.Web.Endpoint do
plug(Phoenix.CodeReloader)
end
plug(Pleroma.Web.Plugs.TrailingFormatPlug)
plug(Pleroma.Web.Plugs.TrailingFormatPlug,
supported_formats: ["html", "xml", "rss", "atom", "activity+json", "json"]
)
plug(Plug.RequestId)
plug(Plug.Logger, log: :debug)

View file

@ -12,6 +12,8 @@ defmodule Pleroma.Web.Feed.UserController do
alias Pleroma.Web.Feed.FeedView
plug(Pleroma.Web.Plugs.SetFormatPlug when action in [:feed_redirect])
plug(Pleroma.Web.Plugs.SetDomainPlug when action in [:feed_redirect, :feed])
plug(Pleroma.Web.Plugs.SetNicknameWithDomainPlug when action in [:feed_redirect, :feed])
action_fallback(:errors)

View file

@ -6,7 +6,6 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
use Pleroma.Web, :controller
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.Plugs.OAuthScopesPlug
@ -28,7 +27,7 @@ def create(
with {:ok, object} <-
ActivityPub.upload(
file,
actor: User.ap_id(user),
actor: user.ap_id,
description: Map.get(data, :description)
) do
attachment_data = Map.put(object.data, "id", object.id)
@ -48,7 +47,7 @@ def create2(
with {:ok, object} <-
ActivityPub.upload(
file,
actor: User.ap_id(user),
actor: user.ap_id,
description: Map.get(data, :description)
) do
attachment_data = Map.put(object.data, "id", object.id)

View file

@ -36,6 +36,8 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
when action in [:public, :hashtag]
)
plug(Pleroma.Web.Plugs.SetDomainPlug when action in [:public, :hashtag])
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.TimelineOperation
# GET /api/v1/timelines/home
@ -114,6 +116,7 @@ def public(%{assigns: %{user: user}} = conn, params) do
|> Map.put(:instance, params[:instance])
# Restricts unfederated content to authenticated users
|> Map.put(:includes_local_public, not is_nil(user))
|> maybe_put_domain_id(conn)
|> ActivityPub.fetch_public_activities()
conn
@ -131,7 +134,7 @@ defp fail_on_bad_auth(conn) do
render_error(conn, :unauthorized, "authorization required for timeline view")
end
defp hashtag_fetching(params, user, local_only) do
defp hashtag_fetching(conn, params, user, local_only) do
# Note: not sanitizing tag options at this stage (may be mix-cased, have duplicates etc.)
tags_any =
[params[:tag], params[:any]]
@ -150,6 +153,7 @@ defp hashtag_fetching(params, user, local_only) do
|> Map.put(:tag, tags_any)
|> Map.put(:tag_all, tag_all)
|> Map.put(:tag_reject, tag_reject)
|> maybe_put_domain_id(conn)
|> ActivityPub.fetch_public_activities()
end
@ -160,7 +164,7 @@ def hashtag(%{assigns: %{user: user}} = conn, params) do
if is_nil(user) and restrict_unauthenticated?(local_only) do
fail_on_bad_auth(conn)
else
activities = hashtag_fetching(params, user, local_only)
activities = hashtag_fetching(conn, params, user, local_only)
conn
|> add_link_headers(activities, %{"local" => local_only})
@ -183,6 +187,7 @@ def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do
|> Map.put(:user, user)
|> Map.put(:muting_user, user)
|> Map.put(:local_only, params[:local])
|> maybe_put_domain_id(conn)
# we must filter the following list for the user to avoid leaking statuses the user
# does not actually have permission to see (for more info, peruse security issue #270).
@ -207,4 +212,19 @@ def list(%{assigns: %{user: user}} = conn, %{list_id: id} = params) do
_e -> render_error(conn, :forbidden, "Error.")
end
end
defp maybe_put_domain_id(%{local_only: true} = params, conn) do
separate_timelines = Config.get([:instance, :multitenancy, :separate_timelines])
if separate_timelines do
domain = Map.get(conn, :domain, %{id: 0})
params
|> Map.put(:domain_id, domain.id)
else
params
end
end
defp maybe_put_domain_id(params, _conn), do: params
end

View file

@ -323,7 +323,8 @@ defp do_render("show.json", %{user: user} = opts) do
background_image: image_url(user.background) |> MediaProxy.url(),
accepts_chat_messages: user.accepts_chat_messages,
favicon: favicon,
location: user.location
location: user.location,
is_local: user.local
}
}
|> maybe_put_role(user, opts[:for])

View file

@ -6,8 +6,10 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
use Pleroma.Web, :view
alias Pleroma.Config
alias Pleroma.Domain
alias Pleroma.User
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.AdminAPI.DomainView
alias Pleroma.Web.MastodonAPI
@mastodon_api_level "2.7.2"
@ -175,7 +177,8 @@ def features do
if Pleroma.Language.Translation.configured?() do
"translation"
end,
"events"
"events",
"multitenancy"
]
|> Enum.filter(& &1)
end
@ -310,7 +313,8 @@ defp pleroma_configuration(instance) do
migration_cooldown_period: Config.get([:instance, :migration_cooldown_period]),
restrict_unauthenticated: restrict_unauthenticated(),
translation: translation_configuration(),
markup: markup()
markup: markup(),
multitenancy: multitenancy()
},
stats: %{mau: Pleroma.User.active_user_count()},
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key),
@ -374,4 +378,21 @@ defp markup do
allow_tables: Config.get([:markup, :allow_tables])
}
end
defp multitenancy do
enabled = Config.get([:instance, :multitenancy, :enabled])
if enabled do
domains =
[%Domain{id: "", domain: Pleroma.Web.WebFinger.host(), public: true}] ++
Domain.cached_list()
%{
enabled: true,
domains: DomainView.render("index.json", domains: domains, admin: false)
}
else
nil
end
end
end

View file

@ -0,0 +1,28 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.SetDomainPlug do
use Pleroma.Web, :plug
alias Pleroma.Domain
def init(opts), do: opts
@impl true
def perform(%{host: domain} = conn, _opts) do
with true <- Pleroma.Config.get([:instance, :multitenancy, :enabled], false),
false <-
domain in [
Pleroma.Config.get([__MODULE__, :domain]),
Pleroma.Web.Endpoint.host()
],
%Domain{} = domain <- Domain.get_by_service_domain(domain) do
Map.put(conn, :domain, domain)
else
_ -> conn
end
end
def perform(conn, _), do: conn
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.SetNicknameWithDomainPlug do
use Pleroma.Web, :plug
def init(opts), do: opts
@impl true
def perform(%{domain: domain, params: params} = conn, opts) do
with key <- Keyword.get(opts, :key, "nickname"),
nickname <- Map.get(params, key),
false <- String.contains?(nickname, "@"),
nickname <- nickname <> "@" <> domain.domain,
params <- Map.put(params, "nickname", nickname) do
Map.put(conn, :params, params)
else
_ -> conn
end
end
def perform(conn, _), do: conn
end

View file

@ -1,9 +1,12 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# Copyright © 2017-2024 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.TrailingFormatPlug do
@moduledoc "Calls TrailingFormatPlug for specific paths. Ideally we would just do this in the router, but TrailingFormatPlug needs to be called before Plug.Parsers."
@moduledoc """
This plug is adapted from [`TrailingFormatPlug`](https://github.com/mschae/trailing_format_plug/blob/master/lib/trailing_format_plug.ex).
Ideally we would just do this in the router, but TrailingFormatPlug needs to be called before Plug.Parsers."
"""
@behaviour Plug
@paths [
@ -29,14 +32,53 @@ defmodule Pleroma.Web.Plugs.TrailingFormatPlug do
]
def init(opts) do
TrailingFormatPlug.init(opts)
opts
end
for path <- @paths do
def call(%{request_path: unquote(path) <> _} = conn, opts) do
TrailingFormatPlug.call(conn, opts)
path = conn.path_info |> List.last() |> String.split(".") |> Enum.reverse()
supported_formats = Keyword.get(opts, :supported_formats, nil)
case path do
[_] ->
conn
[format | fragments] ->
if supported_formats == nil || format in supported_formats do
new_path = fragments |> Enum.reverse() |> Enum.join(".")
path_fragments = List.replace_at(conn.path_info, -1, new_path)
params =
Plug.Conn.fetch_query_params(conn).params
|> update_params(new_path, format)
|> Map.put("_format", format)
%{
conn
| path_info: path_fragments,
query_params: params,
params: params
}
else
conn
end
end
end
end
def call(conn, _opts), do: conn
defp update_params(params, new_path, format) do
wildcard = Enum.find(params, fn {_, v} -> v == "#{new_path}.#{format}" end)
case wildcard do
{key, _} ->
Map.put(params, key, new_path)
_ ->
params
end
end
end

View file

@ -306,6 +306,11 @@ defmodule Pleroma.Web.Router do
post("/webhooks/:id/enable", WebhookController, :enable)
post("/webhooks/:id/disable", WebhookController, :disable)
post("/webhooks/:id/rotate_secret", WebhookController, :rotate_secret)
get("/domains", DomainController, :index)
post("/domains", DomainController, :create)
patch("/domains/:id", DomainController, :update)
delete("/domains/:id", DomainController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)

View file

@ -27,6 +27,7 @@ def register_user(params, opts \\ []) do
:language,
Pleroma.Web.Gettext.normalize_locale(params[:language]) || fallback_language
)
|> maybe_put_domain_id(params[:domain])
if Pleroma.Config.get([:instance, :registrations_open]) do
create_user(params, opts)
@ -64,6 +65,14 @@ defp create_user(params, opts) do
end
end
defp maybe_put_domain_id(params, domain) do
if Pleroma.Config.get([:instance, :multitenancy, :enabled]) do
Map.put(params, :domain_id, domain)
else
params
end
end
def password_reset(nickname_or_email) do
with true <- is_binary(nickname_or_email),
%User{local: true, email: email, is_active: true} = user when is_binary(email) <-

View file

@ -33,15 +33,13 @@ def host_meta do
def webfinger(resource, fmt) when fmt in ["XML", "JSON"] do
host = Pleroma.Web.Endpoint.host()
regex =
if webfinger_domain = Pleroma.Config.get([__MODULE__, :domain]) do
~r/(acct:)?(?<username>[a-z0-9A-Z_\.-]+)@(#{host}|#{webfinger_domain})/
else
~r/(acct:)?(?<username>[a-z0-9A-Z_\.-]+)@#{host}/
end
regex = ~r/(acct:)?(?<username>[a-z0-9A-Z_\.-]+)@(?<domain>[a-z0-9A-Z_\.-]+)/
webfinger_domain = Pleroma.Config.get([__MODULE__, :domain])
with %{"username" => username} <- Regex.named_captures(regex, resource),
%User{} = user <- User.get_cached_by_nickname(username) do
with %{"username" => username, "domain" => domain} <- Regex.named_captures(regex, resource),
nickname <-
if(domain in [host, webfinger_domain], do: username, else: username <> "@" <> domain),
%User{local: true} = user <- User.get_cached_by_nickname(nickname) do
{:ok, represent_user(user, fmt)}
else
_e ->
@ -70,7 +68,7 @@ defp gather_aliases(%User{} = user) do
def represent_user(user, "JSON") do
%{
"subject" => "acct:#{user.nickname}@#{host()}",
"subject" => get_subject(user),
"aliases" => gather_aliases(user),
"links" => gather_links(user)
}
@ -90,12 +88,20 @@ def represent_user(user, "XML") do
:XRD,
%{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"},
[
{:Subject, "acct:#{user.nickname}@#{host()}"}
{:Subject, get_subject(user)}
] ++ aliases ++ links
}
|> XmlBuilder.to_doc()
end
defp get_subject(%User{nickname: nickname}) do
if String.contains?(nickname, "@") do
"acct:#{nickname}"
else
"acct:#{nickname}@#{host()}"
end
end
def host do
Pleroma.Config.get([__MODULE__, :domain]) || Pleroma.Web.Endpoint.host()
end

View file

@ -0,0 +1,36 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.CheckDomainResolveWorker do
use Pleroma.Workers.WorkerHelper, queue: "check_domain_resolve"
alias Pleroma.Domain
alias Pleroma.HTTP
alias Pleroma.Repo
alias Pleroma.Web.Endpoint
alias Pleroma.Web.WebFinger
@impl Oban.Worker
def perform(%Job{args: %{"id" => domain_id}}) do
domain = Domain.get(domain_id)
resolves =
with {:ok, %Tesla.Env{status: status, body: hostmeta_body}} when status in 200..299 <-
HTTP.get("https://" <> domain.domain <> "/.well-known/host-meta"),
{:ok, template} <- WebFinger.get_template_from_xml(hostmeta_body),
base_url <- Endpoint.url(),
true <- template == "#{base_url}/.well-known/webfinger?resource={uri}" do
true
else
_ -> false
end
domain
|> Domain.update_state_changeset(resolves)
|> Repo.update()
end
@impl Oban.Worker
def timeout(_job), do: :timer.seconds(5)
end

View file

@ -0,0 +1,34 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.Cron.CheckDomainsResolveWorker do
@moduledoc """
The worker to check if alternative domains resolve correctly.
"""
use Oban.Worker, queue: "check_domain_resolve"
alias Pleroma.Domain
alias Pleroma.Repo
import Ecto.Query
require Logger
@impl Oban.Worker
def perform(_job) do
domains =
Domain
|> select([d], d.id)
|> Repo.all()
Enum.each(domains, fn domain_id ->
Pleroma.Workers.CheckDomainResolveWorker.enqueue("check_domain_resolve", %{
"id" => domain_id
})
end)
:ok
end
end

View file

@ -134,7 +134,6 @@ defp deps do
{:oban, "~> 2.13.4"},
{:gettext, "~> 0.20"},
{:bcrypt_elixir, "~> 2.2"},
{:trailing_format_plug, "~> 0.0.7"},
{:fast_sanitize, "~> 0.2.0"},
{:html_entities, "~> 0.5", override: true},
{:calendar, "~> 1.0"},

View file

@ -0,0 +1,24 @@
defmodule Pleroma.Repo.Migrations.CreateDomains do
use Ecto.Migration
def change do
create_if_not_exists table(:domains) do
add(:domain, :citext)
add(:service_domain, :citext)
add(:public, :boolean)
add(:resolves, :boolean)
add(:last_checked_at, :naive_datetime)
timestamps(type: :utc_datetime)
end
create_if_not_exists(unique_index(:domains, [:domain]))
create_if_not_exists(unique_index(:domains, [:service_domain]))
alter table(:users) do
add(:domain_id, references(:domains))
end
create_if_not_exists(index(:users, [:domain_id]))
end
end

View file

@ -851,6 +851,55 @@ test "it fails when provided invalid birth date" do
end
end
describe "user registration, with custom domain" do
@full_user_data %{
bio: "A guy",
name: "my name",
nickname: "nick",
password: "test",
password_confirmation: "test",
email: "email@example.com"
}
setup do
clear_config([:instance, :multitenancy], %{enabled: true})
end
test "it registers on a given domain" do
{:ok, %{id: domain_id}} =
Pleroma.Domain.create(%{
domain: "pleroma.example.org",
public: true
})
params =
@full_user_data
|> Map.put(:domain_id, to_string(domain_id))
changeset = User.register_changeset(%User{}, params)
assert changeset.valid?
{:ok, user} = Repo.insert(changeset)
assert user.nickname == "nick@pleroma.example.org"
end
test "it fails when domain is private" do
{:ok, %{id: domain_id}} =
Pleroma.Domain.create(%{
domain: "private.example.org",
public: false
})
params =
@full_user_data
|> Map.put(:domain_id, to_string(domain_id))
changeset = User.register_changeset(%User{}, params)
refute changeset.valid?
end
end
describe "get_or_fetch/1" do
test "gets an existing user by nickname" do
user = insert(:user)

View file

@ -138,6 +138,24 @@ test "it returns a json representation of the user with accept application/ld+js
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
test "it returns a json representation of a local user domain different from host", %{
conn: conn
} do
user =
insert(:user, %{
nickname: "nick@example.org"
})
conn =
conn
|> put_req_header("accept", "application/json")
|> get("/users/#{user.nickname}.json")
user = User.get_cached_by_id(user.id)
assert json_response(conn, 200) == UserView.render("user.json", %{user: user})
end
test "it returns 404 for remote users", %{
conn: conn
} do

View file

@ -0,0 +1,166 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2023 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.DomainControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
alias Pleroma.Domain
setup do
clear_config([Pleroma.Web.WebFinger, :domain], "example.com")
admin = insert(:user, is_admin: true)
token = insert(:oauth_admin_token, user: admin)
conn =
build_conn()
|> assign(:user, admin)
|> assign(:token, token)
{:ok, %{admin: admin, token: token, conn: conn}}
end
describe "GET /api/pleroma/admin/domains" do
test "list created domains", %{conn: conn} do
_domain =
Domain.create(%{
domain: "pleroma.mkljczk.pl",
public: true
})
_domain =
Domain.create(%{
domain: "pleroma2.mkljczk.pl"
})
conn = get(conn, "/api/pleroma/admin/domains")
[
%{
"id" => _id,
"domain" => "pleroma.mkljczk.pl",
"public" => true
},
%{
"id" => _id2,
"domain" => "pleroma2.mkljczk.pl",
"public" => false
}
] = json_response_and_validate_schema(conn, 200)
end
end
describe "POST /api/pleroma/admin/domains" do
test "create a valid domain", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/domains", %{
domain: "pleroma.mkljczk.pl",
public: true
})
%{
"id" => _id,
"domain" => "pleroma.mkljczk.pl",
"public" => true
} = json_response_and_validate_schema(conn, 200)
end
test "create a domain the same as host", %{conn: conn} do
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/domains", %{
domain: "example.com",
public: false
})
%{"error" => "invalid_domain"} = json_response_and_validate_schema(conn, 400)
end
test "create duplicate domains", %{conn: conn} do
Domain.create(%{
domain: "pleroma.mkljczk.pl",
public: true
})
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/pleroma/admin/domains", %{
domain: "pleroma.mkljczk.pl",
public: false
})
assert json_response_and_validate_schema(conn, 400)
end
end
describe "PATCH /api/pleroma/admin/domains/:id" do
test "update domain privacy", %{conn: conn} do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "pleroma.mkljczk.pl",
public: true
})
conn =
conn
|> put_req_header("content-type", "application/json")
|> patch("/api/pleroma/admin/domains/#{domain_id}", %{
public: false
})
%{
"id" => _id,
"domain" => "pleroma.mkljczk.pl",
"public" => false
} = json_response_and_validate_schema(conn, 200)
end
test "doesn't update domain name", %{conn: conn} do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "plemora.mkljczk.pl",
public: true
})
conn =
conn
|> put_req_header("content-type", "application/json")
|> patch("/api/pleroma/admin/domains/#{domain_id}", %{
domain: "pleroma.mkljczk.pl"
})
%{
"id" => _id,
"domain" => "plemora.mkljczk.pl",
"public" => true
} = json_response_and_validate_schema(conn, 200)
end
end
describe "DELETE /api/pleroma/admin/domains/:id" do
test "delete a domain", %{conn: conn} do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "pleroma.mkljczk.pl",
public: true
})
conn =
conn
|> delete("/api/pleroma/admin/domains/#{domain_id}")
%{} = json_response_and_validate_schema(conn, 200)
domains = Domain.list()
assert length(domains) == 0
end
end
end

View file

@ -216,4 +216,53 @@ test "restrict_unauthenticated", %{conn: conn} do
assert result["pleroma"]["metadata"]["restrict_unauthenticated"]["timelines"]["local"] ==
false
end
test "instance domains", %{conn: conn} do
clear_config([:instance, :multitenancy], %{enabled: true})
{:ok, %{id: domain_id}} =
Pleroma.Domain.create(%{
domain: "pleroma.example.org"
})
domain_id = to_string(domain_id)
assert %{
"pleroma" => %{
"metadata" => %{
"multitenancy" => %{
"enabled" => true,
"domains" => [
%{
"id" => "",
"domain" => _,
"public" => true
},
%{
"id" => ^domain_id,
"domain" => "pleroma.example.org",
"public" => false
}
]
}
}
}
} =
conn
|> get("/api/v1/instance")
|> json_response_and_validate_schema(200)
clear_config([:instance, :multitenancy, :enabled], false)
assert %{
"pleroma" => %{
"metadata" => %{
"multitenancy" => nil
}
}
} =
conn
|> get("/api/v1/instance")
|> json_response_and_validate_schema(200)
end
end

View file

@ -408,6 +408,43 @@ test "should not return local-only posts for anonymous users" do
assert [] = result
end
test "filtering local posts basing on domain", %{conn: conn} do
clear_config([:instance, :multitenancy], %{enabled: true, separate_timelines: false})
{:ok, domain} = Pleroma.Domain.create(%{domain: "pleroma.example.org"})
user1 = insert(:user)
user2 = insert(:user, %{domain_id: domain.id})
%{id: note1} = insert(:note_activity, user: user1)
%{id: note2} = insert(:note_activity, user: user2)
assert [
%{"id" => ^note2},
%{"id" => ^note1}
] =
conn
|> get("/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(200)
clear_config([:instance, :multitenancy, :separate_timelines], true)
assert [%{"id" => ^note1}] =
conn
|> get("/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^note1}] =
conn
|> get("/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(200)
assert [%{"id" => ^note2}] =
conn
|> get("http://pleroma.example.org/api/v1/timelines/public?local=true")
|> json_response_and_validate_schema(200)
end
end
defp local_and_remote_activities do

View file

@ -96,7 +96,8 @@ test "Represent a user account" do
relationship: %{},
skip_thread_containment: false,
accepts_chat_messages: nil,
location: nil
location: nil,
is_local: true
}
}
@ -304,7 +305,8 @@ test "Represent a Service(bot) account" do
relationship: %{},
skip_thread_containment: false,
accepts_chat_messages: nil,
location: nil
location: nil,
is_local: true
}
}

View file

@ -37,6 +37,20 @@ test "works for ap_ids" do
{:ok, result} = WebFinger.webfinger(user.ap_id, "XML")
assert is_binary(result)
end
test "works for fqns with domains other than host" do
user = insert(:user, %{nickname: "nick@example.org"})
{:ok, result} = WebFinger.webfinger("#{user.nickname})}", "XML")
assert is_binary(result)
end
test "doesn't work for remote users" do
user = insert(:user, %{local: false})
assert {:error, _} = WebFinger.webfinger("#{user.nickname})}", "XML")
end
end
describe "fingering" do

View file

@ -0,0 +1,118 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.CheckDomainsResolveWorkerTest do
use Pleroma.DataCase
alias Pleroma.Domain
alias Pleroma.Workers.CheckDomainResolveWorker
setup do
Pleroma.Web.Endpoint.config_change(
[{Pleroma.Web.Endpoint, url: [host: "pleroma.example.org", scheme: "https", port: 443]}],
[]
)
clear_config([Pleroma.Web.Endpoint, :url, :host], "pleroma.example.org")
Tesla.Mock.mock_global(fn
%{url: "https://pleroma.example.org/.well-known/host-meta"} ->
%Tesla.Env{
status: 200,
body:
"test/fixtures/webfinger/pleroma-host-meta.xml"
|> File.read!()
|> String.replace("{{domain}}", "pleroma.example.org")
}
%{url: "https://example.org/.well-known/host-meta"} ->
%Tesla.Env{
status: 200,
body:
"test/fixtures/webfinger/pleroma-host-meta.xml"
|> File.read!()
|> String.replace("{{domain}}", "pleroma.example.org")
}
%{url: "https://social.example.org/.well-known/host-meta"} ->
%Tesla.Env{
status: 302,
headers: [{"location", "https://pleroma.example.org/.well-known/host-meta"}]
}
%{url: "https://notpleroma.example.org/.well-known/host-meta"} ->
%Tesla.Env{
status: 200,
body:
"test/fixtures/webfinger/pleroma-host-meta.xml"
|> File.read!()
|> String.replace("{{domain}}", "notpleroma.example.org")
}
%{url: "https://wrong.example.org/.well-known/host-meta"} ->
%Tesla.Env{
status: 302,
headers: [{"location", "https://notpleroma.example.org/.well-known/host-meta"}]
}
%{url: "https://bad.example.org/.well-known/host-meta"} ->
%Tesla.Env{status: 404}
end)
on_exit(fn ->
Pleroma.Web.Endpoint.config_change(
[{Pleroma.Web.Endpoint, url: [host: "localhost"]}],
[]
)
end)
end
test "verifies domain state" do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "example.org"
})
{:ok, domain} = CheckDomainResolveWorker.perform(%Oban.Job{args: %{"id" => domain_id}})
assert domain.resolves == true
assert domain.last_checked_at != nil
end
test "verifies domain state for a redirect" do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "social.example.org"
})
{:ok, domain} = CheckDomainResolveWorker.perform(%Oban.Job{args: %{"id" => domain_id}})
assert domain.resolves == true
assert domain.last_checked_at != nil
end
test "doesn't verify state for an incorrect redirect" do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "wrong.example.org"
})
{:ok, domain} = CheckDomainResolveWorker.perform(%Oban.Job{args: %{"id" => domain_id}})
assert domain.resolves == false
assert domain.last_checked_at != nil
end
test "doesn't verify state for unimplemented redirect" do
{:ok, %{id: domain_id}} =
Domain.create(%{
domain: "bad.example.org"
})
{:ok, domain} = CheckDomainResolveWorker.perform(%Oban.Job{args: %{"id" => domain_id}})
assert domain.resolves == false
assert domain.last_checked_at != nil
end
end