Allow using multiple domains for WebFinger

Signed-off-by: Marcin Mikołajczak <git@mkljczk.pl>
This commit is contained in:
Marcin Mikołajczak 2023-11-07 00:07:18 +01:00
parent 4c5b45ed73
commit c2c7c23aab
16 changed files with 381 additions and 27 deletions

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

@ -0,0 +1,53 @@
# 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
use Ecto.Schema
import Ecto.Changeset
alias Pleroma.Repo
schema "domains" do
field(:domain, :string, default: "")
field(:public, :boolean, default: false)
timestamps()
end
def changeset(%__MODULE__{} = domain, params \\ %{}) do
domain
|> cast(params, [:domain, :public])
|> validate_required([:domain])
end
def update_changeset(%__MODULE__{} = domain, params \\ %{}) do
domain
|> cast(params, [:domain])
end
def get(id), do: Repo.get(__MODULE__, id)
def create(params) do
{:ok, domain} =
%__MODULE__{}
|> changeset(params)
|> Repo.insert()
domain
end
def update(params, id) do
{:ok, domain} =
get(id)
|> update_changeset(params)
|> Repo.update()
domain
end
def delete(id) do
get(id)
|> Repo.delete()
end
end

View file

@ -14,6 +14,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
@ -157,6 +158,8 @@ defmodule Pleroma.User do
field(:show_birthday, :boolean, default: false)
field(:language, :string)
belongs_to(:domain, Domain)
embeds_one(
:notification_settings,
Pleroma.User.NotificationSetting,
@ -788,16 +791,18 @@ defmodule Pleroma.User 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)
@ -811,6 +816,26 @@ defmodule Pleroma.User do
|> put_private_key()
end
defp fix_nickname(changeset, domain_id, from_admin) when is_binary(domain_id) do
with {:domain, domain} <- {:domain, Pleroma.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? =
@ -871,7 +896,16 @@ defmodule Pleroma.User 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})
ap_id =
if String.contains?(nickname, ".") do
ap_id <> ".json"
else
ap_id
end
put_change(changeset, :ap_id, ap_id)
end
@ -1278,6 +1312,13 @@ defmodule Pleroma.User 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

@ -66,7 +66,8 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
end
def user(conn, %{"nickname" => nickname}) do
with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do
with %User{local: true} = user <-
nickname |> URI.decode() |> User.get_cached_by_nickname_or_id() do
conn
|> put_resp_content_type("application/activity+json")
|> put_view(UserView)

View file

@ -95,14 +95,21 @@ defmodule Pleroma.Web.ActivityPub.UserView do
do: Date.to_iso8601(user.birthday),
else: nil
ap_id =
if String.ends_with?(user.ap_id, ".json") do
String.slice(user.ap_id, 0..-6)
else
user.ap_id
end
%{
"id" => user.ap_id,
"type" => user.actor_type,
"following" => "#{user.ap_id}/following",
"followers" => "#{user.ap_id}/followers",
"inbox" => "#{user.ap_id}/inbox",
"outbox" => "#{user.ap_id}/outbox",
"featured" => "#{user.ap_id}/collections/featured",
"following" => "#{ap_id}/following",
"followers" => "#{ap_id}/followers",
"inbox" => "#{ap_id}/inbox",
"outbox" => "#{ap_id}/outbox",
"featured" => "#{ap_id}/collections/featured",
"preferredUsername" => user.nickname,
"name" => user.name,
"summary" => user.bio,

View file

@ -0,0 +1,62 @@
# 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
use Pleroma.Web, :controller
alias Pleroma.Repo
alias Pleroma.Domain
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
|> Repo.all()
render(conn, "index.json", domains: domains)
end
def create(%{body_params: params} = conn, _) do
domain =
params
|> Domain.create()
render(conn, "show.json", domain: domain)
end
def update(%{body_params: params} = conn, %{id: id}) do
domain =
params
|> Domain.update(id)
render(conn, "show.json", domain: domain)
end
def delete(conn, %{id: id}) do
with {:ok, _} <- Domain.delete(id) 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
@ -127,17 +128,20 @@ defmodule Pleroma.Web.AdminAPI.UserController do
def create(%{assigns: %{user: admin}, body_params: %{users: users}} = conn, _) do
changesets =
users
|> Enum.map(fn %{nickname: nickname, email: email, password: password} ->
|> Enum.map(fn %{nickname: nickname, email: email, password: password, domain: domain} ->
domain = Domain.get(domain)
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,21 @@
# 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
def render("index.json", %{domains: domains}) do
render_many(domains, __MODULE__, "show.json")
end
def render("show.json", %{domain: %Domain{id: id, domain: domain, public: public}}) do
%{
id: id |> to_string(),
domain: domain,
public: public
}
end
end

View file

@ -585,7 +585,8 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation 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,111 @@
# 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},
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},
public: %Schema{type: :boolean}
}
}
end
end

View file

@ -82,7 +82,8 @@ defmodule Pleroma.Web.ApiSpec.Admin.UserOperation 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

@ -297,7 +297,8 @@ defmodule Pleroma.Web.MastodonAPI.AccountView do
skip_thread_containment: user.skip_thread_containment,
background_image: image_url(user.background) |> MediaProxy.url(),
accepts_chat_messages: user.accepts_chat_messages,
favicon: favicon
favicon: favicon,
is_local: user.local
}
}
|> maybe_put_role(user, opts[:for])

View file

@ -6,7 +6,10 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
use Pleroma.Web, :view
alias Pleroma.Config
alias Pleroma.Domain
alias Pleroma.Repo
alias Pleroma.Web.ActivityPub.MRF
alias Pleroma.Web.AdminAPI.DomainView
@mastodon_api_level "2.7.2"
@ -49,7 +52,8 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
fields_limits: fields_limits(),
post_formats: Config.get([:instance, :allowed_post_formats]),
birthday_required: Config.get([:instance, :birthday_required]),
birthday_min_age: Config.get([:instance, :birthday_min_age])
birthday_min_age: Config.get([:instance, :birthday_min_age]),
multitenancy: multitenancy()
},
stats: %{mau: Pleroma.User.active_user_count()},
vapid_public_key: Keyword.get(Pleroma.Web.Push.vapid_config(), :public_key)
@ -141,4 +145,21 @@ defmodule Pleroma.Web.MastodonAPI.InstanceView do
value_length: Config.get([:instance, :account_field_value_length])
}
end
defp multitenancy do
enabled = Config.get([:multitenancy, :enabled])
if enabled do
domains =
[%Domain{id: "", domain: Pleroma.Web.WebFinger.domain(), public: true}] ++
Repo.all(Domain)
%{
enabled: true,
domains: DomainView.render("index.json", domains: domains)
}
else
nil
end
end
end

View file

@ -286,6 +286,11 @@ defmodule Pleroma.Web.Router do
post("/frontends/install", FrontendController, :install)
post("/backups", AdminAPIController, :create_backup)
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 @@ defmodule Pleroma.Web.TwitterAPI.TwitterAPI do
:language,
Pleroma.Web.Gettext.normalize_locale(params[:language]) || fallback_language
)
|> Map.put(:domain_id, params[:domain])
if Pleroma.Config.get([:instance, :registrations_open]) do
create_user(params, opts)

View file

@ -33,15 +33,13 @@ defmodule Pleroma.Web.WebFinger 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 @@ defmodule Pleroma.Web.WebFinger do
def represent_user(user, "JSON") do
%{
"subject" => "acct:#{user.nickname}@#{domain()}",
"subject" => get_subject(user),
"aliases" => gather_aliases(user),
"links" => gather_links(user)
}
@ -90,12 +88,20 @@ defmodule Pleroma.Web.WebFinger do
:XRD,
%{xmlns: "http://docs.oasis-open.org/ns/xri/xrd-1.0"},
[
{:Subject, "acct:#{user.nickname}@#{domain()}"}
{: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}@#{domain()}"
end
end
defp domain do
Pleroma.Config.get([__MODULE__, :domain]) || Pleroma.Web.Endpoint.host()
end

View file

@ -0,0 +1,18 @@
defmodule Pleroma.Repo.Migrations.CreateDomains do
use Ecto.Migration
def change do
create_if_not_exists table(:domains) do
add(:domain, :string)
add(:public, :boolean)
timestamps()
end
create_if_not_exists(unique_index(:domains, [:domain]))
alter table(:users) do
add(:domain_id, references(:domains))
end
end
end