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