- Not being able to pin unlisted posts
- Objects being re-embedded to activities after being updated (e.g faved/reposted). Running 'mix pleroma.database prune_objects' again is advised.
- Metadata rendering errors resulting in the entire page being inaccessible
+ - `federation_incoming_replies_max_depth` option being ignored in certain cases
- Federation/MediaProxy not working with instances that have wrong certificate order
- Mastodon API: Handling of search timeouts (`/api/v1/search` and `/api/v2/search`)
- Mastodon API: Embedded relationships not being properly rendered in the Account entity of Status entity
+ - Mastodon API: follower/following counters not being nullified, when `hide_follows`/`hide_followers` is set
+ - Mastodon API: `muted` in the Status entity, using author's account to determine if the tread was muted
- Mastodon API: Add `account_id`, `type`, `offset`, and `limit` to search API (`/api/v1/search` and `/api/v2/search`)
- Mastodon API, streaming: Fix filtering of notifications based on blocks/mutes/thread mutes
- ActivityPub C2S: follower/following collection pages being inaccessible even when authentifucated if `hide_followers`/ `hide_follows` was set
- Invalid SemVer version generation, when the current branch does not have commits ahead of tag/checked out on a tag
- Pleroma.Upload base_url was not automatically whitelisted by MediaProxy. Now your custom CDN or file hosting will be accessed directly as expected.
- Report email not being sent to admins when the reporter is a remote user
+ - MRF: ensure that subdomain_match calls are case-insensitive
### Added
+- Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data.
+ - **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo.
+ Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules.
- MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`)
- MRF: Support for excluding specific domains from Transparency.
- MRF: Support for filtering posts based on who they mention (`Pleroma.Web.ActivityPub.MRF.MentionPolicy`)
+ - MRF: Support for filtering posts based on ActivityStreams vocabulary (`Pleroma.Web.ActivityPub.MRF.VocabularyPolicy`)
- MRF (Simple Policy): Support for wildcard domains.
- Support for wildcard domains in user domain blocks setting.
- Configuration: `quarantined_instances` support wildcard domains.
- Added synchronization of following/followers counters for external users
- Configuration: `enabled` option for `Pleroma.Emails.Mailer`, defaulting to `false`.
- Configuration: Pleroma.Plugs.RateLimiter `bucket_name`, `params` options.
+ - Configuration: `user_bio_length` and `user_name_length` options.
- Addressable lists
- Twitter API: added rate limit for `/api/account/password_reset` endpoint.
- ActivityPub: Add an internal service actor for fetching ActivityPub objects.
- Admin API: Endpoint for fetching latest user's statuses
- Pleroma API: Add `/api/v1/pleroma/accounts/confirmation_resend?email=<email>` for resending account confirmation.
- Relays: Added a task to list relay subscriptions.
+ - Mix Tasks: `mix pleroma.database fix_likes_collections`
+ - Federation: Remove `likes` from objects.
### Changed
- Configuration: Filter.AnonymizeFilename added ability to retain file extension with custom text
### Removed
- Emoji: Remove longfox emojis.
- Remove `Reply-To` header from report emails for admins.
+ - ActivityPub: The `accept_blocks` configuration setting.
## [1.0.1] - 2019-07-14
### Security
- Rich media: Do not crawl private IP ranges
### Added
+ - Digest email for inactive users
- Add a generic settings store for frontends / clients to use.
- Explicit addressing option for posting.
- Optional SSH access mode. (Needs `erlang-ssh` package on some distributions).
- Configuration: `notify_email` option
- Configuration: Media proxy `whitelist` option
- Configuration: `report_uri` option
+ - Configuration: `email_notifications` option
- Configuration: `limit_to_local_content` option
- Pleroma API: User subscriptions
- Pleroma API: Healthcheck endpoint
field(:search_type, :integer, virtual: true)
field(:tags, {:array, :string}, default: [])
field(:last_refreshed_at, :naive_datetime_usec)
+ field(:last_digest_emailed_at, :naive_datetime)
has_many(:notifications, Notification)
has_many(:registrations, Registration)
embeds_one(:info, User.Info)
end
def remote_user_creation(params) do
- params =
- params
- |> Map.put(:info, params[:info] || %{})
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+ params = Map.put(params, :info, params[:info] || %{})
info_cng = User.Info.remote_user_creation(%User.Info{}, params[:info])
changes =
|> validate_required([:name, :ap_id])
|> unique_constraint(:nickname)
|> validate_format(:nickname, @email_regex)
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, max: name_limit)
|> put_change(:local, false)
|> put_embed(:info, info_cng)
end
def update_changeset(struct, params \\ %{}) do
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+
struct
|> cast(params, [:bio, :name, :avatar, :following])
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, min: 1, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
end
def upgrade_changeset(struct, params \\ %{}) do
- params =
- params
- |> Map.put(:last_refreshed_at, NaiveDateTime.utc_now())
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
- info_cng =
- struct.info
- |> User.Info.user_upgrade(params[:info])
+ params = Map.put(params, :last_refreshed_at, NaiveDateTime.utc_now())
+ info_cng = User.Info.user_upgrade(struct.info, params[:info])
struct
|> cast(params, [
])
|> unique_constraint(:nickname)
|> validate_format(:nickname, local_nickname_regex())
- |> validate_length(:bio, max: 5000)
- |> validate_length(:name, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, max: name_limit)
|> put_embed(:info, info_cng)
end
end
def register_changeset(struct, params \\ %{}, opts \\ []) do
+ bio_limit = Pleroma.Config.get([:instance, :user_bio_length], 5000)
+ name_limit = Pleroma.Config.get([:instance, :user_name_length], 100)
+
need_confirmation? =
if is_nil(opts[:need_confirmation]) do
Pleroma.Config.get([:instance, :account_activation_required])
|> validate_exclusion(:nickname, Pleroma.Config.get([User, :restricted_nicknames]))
|> validate_format(:nickname, local_nickname_regex())
|> validate_format(:email, @email_regex)
- |> validate_length(:bio, max: 1000)
- |> validate_length(:name, min: 1, max: 100)
+ |> validate_length(:bio, max: bio_limit)
+ |> validate_length(:name, min: 1, max: name_limit)
|> put_change(:info, info_change)
changeset =
Repo.get_by(User, ap_id: ap_id)
end
+ def get_all_by_ap_id(ap_ids) do
+ from(u in __MODULE__,
+ where: u.ap_id in ^ap_ids
+ )
+ |> Repo.all()
+ end
+
# This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
# of the ap_id and the domain and tries to get that user
def get_by_guessed_nickname(ap_id) do
target.ap_id not in user.info.muted_reblogs
end
+ @doc """
+ The function returns a query to get users with no activity for given interval of days.
+ Inactive users are those who didn't read any notification, or had any activity where
+ the user is the activity's actor, during `inactivity_threshold` days.
+ Deactivated users will not appear in this list.
+
+ ## Examples
+
+ iex> Pleroma.User.list_inactive_users()
+ %Ecto.Query{}
+ """
+ @spec list_inactive_users_query(integer()) :: Ecto.Query.t()
+ def list_inactive_users_query(inactivity_threshold \\ 7) do
+ negative_inactivity_threshold = -inactivity_threshold
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+ # Subqueries are not supported in `where` clauses, join gets too complicated.
+ has_read_notifications =
+ from(n in Pleroma.Notification,
+ where: n.seen == true,
+ group_by: n.id,
+ having: max(n.updated_at) > datetime_add(^now, ^negative_inactivity_threshold, "day"),
+ select: n.user_id
+ )
+ |> Pleroma.Repo.all()
+
+ from(u in Pleroma.User,
+ left_join: a in Pleroma.Activity,
+ on: u.ap_id == a.actor,
+ where: not is_nil(u.nickname),
+ where: fragment("not (?->'deactivated' @> 'true')", u.info),
+ where: u.id not in ^has_read_notifications,
+ group_by: u.id,
+ having:
+ max(a.inserted_at) < datetime_add(^now, ^negative_inactivity_threshold, "day") or
+ is_nil(max(a.inserted_at))
+ )
+ end
+
+ @doc """
+ Enable or disable email notifications for user
+
+ ## Examples
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => false}}}, "digest", true)
+ Pleroma.User{info: %{email_notifications: %{"digest" => true}}}
+
+ iex> Pleroma.User.switch_email_notifications(Pleroma.User{info: %{email_notifications: %{"digest" => true}}}, "digest", false)
+ Pleroma.User{info: %{email_notifications: %{"digest" => false}}}
+ """
+ @spec switch_email_notifications(t(), String.t(), boolean()) ::
+ {:ok, t()} | {:error, Ecto.Changeset.t()}
+ def switch_email_notifications(user, type, status) do
+ info = Pleroma.User.Info.update_email_notifications(user.info, %{type => status})
+
+ change(user)
+ |> put_embed(:info, info)
+ |> update_and_set_cache()
+ end
+
+ @doc """
+ Set `last_digest_emailed_at` value for the user to current time
+ """
+ @spec touch_last_digest_emailed_at(t()) :: t()
+ def touch_last_digest_emailed_at(user) do
+ now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
+
+ {:ok, updated_user} =
+ user
+ |> change(%{last_digest_emailed_at: now})
+ |> update_and_set_cache()
+
+ updated_user
+ end
+
@spec toggle_confirmation(User.t()) :: {:ok, User.t()} | {:error, Changeset.t()}
def toggle_confirmation(%User{} = user) do
need_confirmation? = !user.info.confirmation_pending
defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
use Pleroma.Web, :controller
- import Pleroma.Web.ControllerHelper, only: [json_response: 3]
+ import Pleroma.Web.ControllerHelper,
+ only: [json_response: 3, add_link_headers: 5, add_link_headers: 4, add_link_headers: 3]
alias Ecto.Changeset
alias Pleroma.Activity
json(conn, mastodon_emoji)
end
- defp add_link_headers(conn, method, activities, param \\ nil, params \\ %{}) do
- params =
- conn.params
- |> Map.drop(["since_id", "max_id", "min_id"])
- |> Map.merge(params)
-
- last = List.last(activities)
-
- if last do
- max_id = last.id
-
- limit =
- params
- |> Map.get("limit", "20")
- |> String.to_integer()
-
- min_id =
- if length(activities) <= limit do
- activities
- |> List.first()
- |> Map.get(:id)
- else
- activities
- |> Enum.at(limit * -1)
- |> Map.get(:id)
- end
-
- {next_url, prev_url} =
- if param do
- {
- mastodon_api_url(
- Pleroma.Web.Endpoint,
- method,
- param,
- Map.merge(params, %{max_id: max_id})
- ),
- mastodon_api_url(
- Pleroma.Web.Endpoint,
- method,
- param,
- Map.merge(params, %{min_id: min_id})
- )
- }
- else
- {
- mastodon_api_url(
- Pleroma.Web.Endpoint,
- method,
- Map.merge(params, %{max_id: max_id})
- ),
- mastodon_api_url(
- Pleroma.Web.Endpoint,
- method,
- Map.merge(params, %{min_id: min_id})
- )
- }
- end
-
- conn
- |> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
- else
- conn
- end
- end
-
def home_timeline(%{assigns: %{user: user}} = conn, params) do
params =
params
|> Map.put("local_only", local_only)
|> Map.put("blocking_user", user)
|> Map.put("muting_user", user)
+ |> Map.put("user", user)
|> ActivityPub.fetch_public_activities()
|> Enum.reverse()
activities <-
ActivityPub.fetch_activities_for_context(activity.data["context"], %{
"blocking_user" => user,
- "user" => user
+ "user" => user,
+ "exclude_id" => activity.id
}),
- activities <-
- activities |> Enum.filter(fn %{id: aid} -> to_string(aid) != to_string(id) end),
- activities <-
- activities |> Enum.filter(fn %{data: %{"type" => type}} -> type == "Create" end),
grouped_activities <- Enum.group_by(activities, fn %{id: id} -> id < activity.id end) do
result = %{
ancestors:
|> put_view(StatusView)
|> try_render("poll.json", %{object: object, for: user})
else
- nil -> render_error(conn, :not_found, "Record not found")
- false -> render_error(conn, :not_found, "Record not found")
+ error when is_nil(error) or error == false ->
+ render_error(conn, :not_found, "Record not found")
end
end
end
def favourited_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do
- with %Activity{data: %{"object" => object}} <- Activity.get_by_id(id),
- %Object{data: %{"likes" => likes}} <- Object.normalize(object) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ %Object{data: %{"likes" => likes}} <- Object.normalize(activity) do
q = from(u in User, where: u.ap_id in ^likes)
users =
end
def reblogged_by(%{assigns: %{user: user}} = conn, %{"id" => id}) do
- with %Activity{data: %{"object" => object}} <- Activity.get_by_id(id),
- %Object{data: %{"announcements" => announces}} <- Object.normalize(object) do
+ with %Activity{} = activity <- Activity.get_by_id_with_object(id),
+ %Object{data: %{"announcements" => announces}} <- Object.normalize(activity) do
q = from(u in User, where: u.ap_id in ^announces)
users =
|> Map.put("local_only", local_only)
|> Map.put("blocking_user", user)
|> Map.put("muting_user", user)
+ |> Map.put("user", user)
|> Map.put("tag", tags)
|> Map.put("tag_all", tag_all)
|> Map.put("tag_reject", tag_reject)
params
|> Map.put("type", "Create")
|> Map.put("blocking_user", user)
+ |> Map.put("user", user)
|> Map.put("muting_user", user)
# we must filter the following list for the user to avoid leaking statuses the user
|> String.replace("{{user}}", user)
with {:ok, %{status: 200, body: body}} <-
- HTTP.get(
- url,
- [],
- adapter: [
- recv_timeout: timeout,
- pool: :default
- ]
- ),
+ HTTP.get(url, [], adapter: [recv_timeout: timeout, pool: :default]),
{:ok, data} <- Jason.decode(body) do
data =
data
|> Enum.slice(0, limit)
|> Enum.map(fn x ->
- Map.put(
- x,
- "id",
- case User.get_or_fetch(x["acct"]) do
- {:ok, %User{id: id}} -> id
- _ -> 0
- end
- )
- end)
- |> Enum.map(fn x ->
- Map.put(x, "avatar", MediaProxy.url(x["avatar"]))
- end)
- |> Enum.map(fn x ->
- Map.put(x, "avatar_static", MediaProxy.url(x["avatar_static"]))
+ x
+ |> Map.put("id", fetch_suggestion_id(x))
+ |> Map.put("avatar", MediaProxy.url(x["avatar"]))
+ |> Map.put("avatar_static", MediaProxy.url(x["avatar_static"]))
end)
- conn
- |> json(data)
+ json(conn, data)
else
- e -> Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
+ e ->
+ Logger.error("Could not retrieve suggestions at fetch #{url}, #{inspect(e)}")
end
else
json(conn, [])
end
+ end
+
+ defp fetch_suggestion_id(attrs) do
+ case User.get_or_fetch(attrs["acct"]) do
+ {:ok, %User{id: id}} -> id
+ _ -> 0
+ end
end
def status_card(%{assigns: %{user: user}} = conn, %{"id" => status_id}) do
conversations =
Enum.map(participations, fn participation ->
- ConversationView.render("participation.json", %{participation: participation, user: user})
+ ConversationView.render("participation.json", %{participation: participation, for: user})
end)
conn
Repo.get_by(Participation, id: participation_id, user_id: user.id),
{:ok, participation} <- Participation.mark_as_read(participation) do
participation_view =
- ConversationView.render("participation.json", %{participation: participation, user: user})
+ ConversationView.render("participation.json", %{participation: participation, for: user})
conn
|> json(participation_view)
defmodule Pleroma.Web.MastodonAPI.StatusView do
use Pleroma.Web, :view
+ require Pleroma.Constants
+
alias Pleroma.Activity
+ alias Pleroma.Conversation
+ alias Pleroma.Conversation.Participation
alias Pleroma.HTML
alias Pleroma.Object
alias Pleroma.Repo
defp get_replied_to_activities(activities) do
activities
|> Enum.map(fn
- %{data: %{"type" => "Create", "object" => object}} ->
- object = Object.normalize(object)
- object.data["inReplyTo"] != "" && object.data["inReplyTo"]
+ %{data: %{"type" => "Create"}} = activity ->
+ object = Object.normalize(activity)
+ object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
_ ->
nil
end)
|> Enum.filter(& &1)
- |> Activity.create_by_object_ap_id()
+ |> Activity.create_by_object_ap_id_with_object()
|> Repo.all()
|> Enum.reduce(%{}, fn activity, acc ->
object = Object.normalize(activity)
- Map.put(acc, object.data["id"], activity)
+ if object, do: Map.put(acc, object.data["id"], activity), else: acc
end)
end
reblogged_activity =
Activity.create_by_object_ap_id(activity_object.data["id"])
|> Activity.with_preloaded_bookmark(opts[:for])
+ |> Activity.with_set_thread_muted_field(opts[:for])
|> Repo.one()
reblogged = render("status.json", Map.put(opts, :activity, reblogged_activity))
object = Object.normalize(activity)
user = get_user(activity.data["actor"])
+ user_follower_address = user.follower_address
like_count = object.data["like_count"] || 0
announcement_count = object.data["announcement_count"] || 0
mentions =
(object.data["to"] ++ tag_mentions)
|> Enum.uniq()
- |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
+ |> Enum.map(fn
+ Pleroma.Constants.as_public() -> nil
+ ^user_follower_address -> nil
+ ap_id -> User.get_cached_by_ap_id(ap_id)
+ end)
|> Enum.filter(& &1)
|> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
thread_muted? =
case activity.thread_muted? do
thread_muted? when is_boolean(thread_muted?) -> thread_muted?
- nil -> CommonAPI.thread_muted?(user, activity)
+ nil -> (opts[:for] && CommonAPI.thread_muted?(opts[:for], activity)) || false
end
attachment_data = object.data["attachment"] || []
object.data["url"] || object.data["external_url"] || object.data["id"]
end
+ direct_conversation_id =
+ with {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
+ {_, %User{} = for_user} <- {:for_user, opts[:for]},
+ %{data: %{"context" => context}} when is_binary(context) <- activity,
+ %Conversation{} = conversation <- Conversation.get_for_ap_id(context),
+ %Participation{id: participation_id} <-
+ Participation.for_user_and_conversation(for_user, conversation) do
+ participation_id
+ else
+ _e ->
+ nil
+ end
+
%{
id: to_string(activity.id),
uri: object.data["id"],
conversation_id: get_context_id(activity),
in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
content: %{"text/plain" => content_plaintext},
- spoiler_text: %{"text/plain" => summary_plaintext}
+ spoiler_text: %{"text/plain" => summary_plaintext},
+ direct_conversation_id: direct_conversation_id
}
}
end
end
end
+ scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
+ pipe_through(:authenticated_api)
+
+ scope [] do
+ pipe_through(:oauth_write)
+ get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
+ get("/conversations/:id", PleromaAPIController, :conversation)
+ patch("/conversations/:id", PleromaAPIController, :update_conversation)
+ end
+ end
+
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
post("/push/hub/:nickname", Websub.WebsubController, :websub_subscription_request)
get("/push/subscriptions/:id", Websub.WebsubController, :websub_subscription_confirmation)
post("/push/subscriptions/:id", Websub.WebsubController, :websub_incoming)
+
+ get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
end
pipeline :activitypub do