From c2c7c23aabc7085808a4576dc4eddc5d148b5ee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Miko=C5=82ajczak?= Date: Tue, 7 Nov 2023 00:07:18 +0100 Subject: [PATCH] Allow using multiple domains for WebFinger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- lib/pleroma/domain.ex | 53 +++++++++ lib/pleroma/user.ex | 49 +++++++- .../activity_pub/activity_pub_controller.ex | 3 +- .../web/activity_pub/views/user_view.ex | 17 ++- .../controllers/domain_controller.ex | 62 ++++++++++ .../admin_api/controllers/user_controller.ex | 10 +- .../web/admin_api/views/domain_view.ex | 21 ++++ .../api_spec/operations/account_operation.ex | 3 +- .../operations/admin/domain_operation.ex | 111 ++++++++++++++++++ .../operations/admin/user_operation.ex | 3 +- .../web/mastodon_api/views/account_view.ex | 3 +- .../web/mastodon_api/views/instance_view.ex | 23 +++- lib/pleroma/web/router.ex | 5 + lib/pleroma/web/twitter_api/twitter_api.ex | 1 + lib/pleroma/web/web_finger.ex | 26 ++-- .../20230618190919_create_domains.exs | 18 +++ 16 files changed, 381 insertions(+), 27 deletions(-) create mode 100644 lib/pleroma/domain.ex create mode 100644 lib/pleroma/web/admin_api/controllers/domain_controller.ex create mode 100644 lib/pleroma/web/admin_api/views/domain_view.ex create mode 100644 lib/pleroma/web/api_spec/operations/admin/domain_operation.ex create mode 100644 priv/repo/migrations/20230618190919_create_domains.exs diff --git a/lib/pleroma/domain.ex b/lib/pleroma/domain.ex new file mode 100644 index 0000000000..d708188eed --- /dev/null +++ b/lib/pleroma/domain.ex @@ -0,0 +1,53 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# 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 diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index ce125d6081..bb180517e1 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -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 @@ 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) @@ -811,6 +816,26 @@ def register_changeset(struct, params \\ %{}, opts \\ []) 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 @@ 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}) + + 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 @@ 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 diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 1357c379c2..52668776ff 100644 --- a/lib/pleroma/web/activity_pub/activity_pub_controller.ex +++ b/lib/pleroma/web/activity_pub/activity_pub_controller.ex @@ -66,7 +66,8 @@ defp relay_active?(conn, _) 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) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index f69fca0759..1afa11e546 100644 --- a/lib/pleroma/web/activity_pub/views/user_view.ex +++ b/lib/pleroma/web/activity_pub/views/user_view.ex @@ -95,14 +95,21 @@ def render("user.json", %{user: user}) 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, diff --git a/lib/pleroma/web/admin_api/controllers/domain_controller.ex b/lib/pleroma/web/admin_api/controllers/domain_controller.ex new file mode 100644 index 0000000000..46b0394601 --- /dev/null +++ b/lib/pleroma/web/admin_api/controllers/domain_controller.ex @@ -0,0 +1,62 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# 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 diff --git a/lib/pleroma/web/admin_api/controllers/user_controller.ex b/lib/pleroma/web/admin_api/controllers/user_controller.ex index 7b4ee46a4e..e08831d5a4 100644 --- a/lib/pleroma/web/admin_api/controllers/user_controller.ex +++ b/lib/pleroma/web/admin_api/controllers/user_controller.ex @@ -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 @@ def unfollow( 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) diff --git a/lib/pleroma/web/admin_api/views/domain_view.ex b/lib/pleroma/web/admin_api/views/domain_view.ex new file mode 100644 index 0000000000..0f91d44900 --- /dev/null +++ b/lib/pleroma/web/admin_api/views/domain_view.ex @@ -0,0 +1,21 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2023 Pleroma Authors +# 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 diff --git a/lib/pleroma/web/api_spec/operations/account_operation.ex b/lib/pleroma/web/api_spec/operations/account_operation.ex index f2897a3a36..3855d19889 100644 --- a/lib/pleroma/web/api_spec/operations/account_operation.ex +++ b/lib/pleroma/web/api_spec/operations/account_operation.ex @@ -585,7 +585,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", diff --git a/lib/pleroma/web/api_spec/operations/admin/domain_operation.ex b/lib/pleroma/web/api_spec/operations/admin/domain_operation.ex new file mode 100644 index 0000000000..d73c67b3d5 --- /dev/null +++ b/lib/pleroma/web/api_spec/operations/admin/domain_operation.ex @@ -0,0 +1,111 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2022 Pleroma Authors +# 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 diff --git a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex index a5179ac398..d2e76ff151 100644 --- a/lib/pleroma/web/api_spec/operations/admin/user_operation.ex +++ b/lib/pleroma/web/api_spec/operations/admin/user_operation.ex @@ -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} } } } diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index cc3e3582f6..c668ff1b70 100644 --- a/lib/pleroma/web/mastodon_api/views/account_view.ex +++ b/lib/pleroma/web/mastodon_api/views/account_view.ex @@ -297,7 +297,8 @@ defp do_render("show.json", %{user: user} = opts) 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]) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 1b01d73711..4ab4ccb74c 100644 --- a/lib/pleroma/web/mastodon_api/views/instance_view.ex +++ b/lib/pleroma/web/mastodon_api/views/instance_view.ex @@ -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 @@ def render("show.json", _) 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 @@ def fields_limits 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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 6b9e158a3f..0dbdd2c901 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -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) diff --git a/lib/pleroma/web/twitter_api/twitter_api.ex b/lib/pleroma/web/twitter_api/twitter_api.ex index ef2eb75f4c..485a105e64 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -27,6 +27,7 @@ def register_user(params, opts \\ []) 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) diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index f95dc24584..49c17e2c32 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -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:)?(?[a-z0-9A-Z_\.-]+)@(#{host}|#{webfinger_domain})/ - else - ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@#{host}/ - end + regex = ~r/(acct:)?(?[a-z0-9A-Z_\.-]+)@(?[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}@#{domain()}", + "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}@#{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 diff --git a/priv/repo/migrations/20230618190919_create_domains.exs b/priv/repo/migrations/20230618190919_create_domains.exs new file mode 100644 index 0000000000..a306c80ee0 --- /dev/null +++ b/priv/repo/migrations/20230618190919_create_domains.exs @@ -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