diff --git a/lib/pleroma/domain.ex b/lib/pleroma/domain.ex new file mode 100644 index 000000000..d708188ee --- /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 ce125d608..bb180517e 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 @@ 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 diff --git a/lib/pleroma/web/activity_pub/activity_pub_controller.ex b/lib/pleroma/web/activity_pub/activity_pub_controller.ex index 1357c379c..52668776f 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 @@ 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) diff --git a/lib/pleroma/web/activity_pub/views/user_view.ex b/lib/pleroma/web/activity_pub/views/user_view.ex index f69fca075..1afa11e54 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 @@ 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, 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 000000000..46b039460 --- /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 7b4ee46a4..e08831d5a 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 @@ 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) 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 000000000..0f91d4490 --- /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 f2897a3a3..3855d1988 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 @@ 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", 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 000000000..d73c67b3d --- /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 a5179ac39..d2e76ff15 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 @@ 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} } } } diff --git a/lib/pleroma/web/mastodon_api/views/account_view.ex b/lib/pleroma/web/mastodon_api/views/account_view.ex index cc3e3582f..c668ff1b7 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 @@ 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]) diff --git a/lib/pleroma/web/mastodon_api/views/instance_view.ex b/lib/pleroma/web/mastodon_api/views/instance_view.ex index 1b01d7371..4ab4ccb74 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 @@ 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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 6b9e158a3..0dbdd2c90 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 ef2eb75f4..485a105e6 100644 --- a/lib/pleroma/web/twitter_api/twitter_api.ex +++ b/lib/pleroma/web/twitter_api/twitter_api.ex @@ -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) diff --git a/lib/pleroma/web/web_finger.ex b/lib/pleroma/web/web_finger.ex index f95dc2458..49c17e2c3 100644 --- a/lib/pleroma/web/web_finger.ex +++ b/lib/pleroma/web/web_finger.ex @@ -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:)?(?[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 @@ 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 diff --git a/priv/repo/migrations/20230618190919_create_domains.exs b/priv/repo/migrations/20230618190919_create_domains.exs new file mode 100644 index 000000000..a306c80ee --- /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