Merge remote-tracking branch 'origin/develop' into global-status-expiration

This commit is contained in:
Egor Kislitsyn 2020-06-01 15:48:51 +04:00
commit a7627bdc7a
No known key found for this signature in database
GPG key ID: 1B49CB15B71E7805
921 changed files with 19145 additions and 8922 deletions

View file

@ -48,6 +48,7 @@ benchmark:
unit-testing: unit-testing:
stage: test stage: test
retry: 2
cache: &testing_cache_policy cache: &testing_cache_policy
<<: *global_cache_policy <<: *global_cache_policy
policy: pull policy: pull
@ -80,6 +81,7 @@ unit-testing:
unit-testing-rum: unit-testing-rum:
stage: test stage: test
retry: 2
cache: *testing_cache_policy cache: *testing_cache_policy
services: services:
- name: minibikini/postgres-with-rum:12 - name: minibikini/postgres-with-rum:12

View file

@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [unreleased] ## [unreleased]
### Changed ### Changed
- MFR policy to set global expiration for all local Create activities
<details> <details>
<summary>API Changes</summary> <summary>API Changes</summary>
- **Breaking:** Emoji API: changed methods and renamed routes. - **Breaking:** Emoji API: changed methods and renamed routes.
@ -15,14 +16,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- **Breaking:** removed `with_move` parameter from notifications timeline. - **Breaking:** removed `with_move` parameter from notifications timeline.
### Added ### Added
- ActivityPub: Added support for existing AP ids for instances migrated from Mastodon.
- Instance: Add `background_image` to configuration and `/api/v1/instance`
- Instance: Extend `/api/v1/instance` with Pleroma-specific information. - Instance: Extend `/api/v1/instance` with Pleroma-specific information.
- NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list. - NodeInfo: `pleroma:api/v1/notifications:include_types_filter` to the `features` list.
- NodeInfo: `pleroma_emoji_reactions` to the `features` list. - NodeInfo: `pleroma_emoji_reactions` to the `features` list.
- Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses. - Configuration: `:restrict_unauthenticated` setting, restrict access for unauthenticated users to timelines (public and federate), user profiles and statuses.
- Configuration: Add `:database_config_whitelist` setting to whitelist settings which can be configured from AdminFE.
- Configuration: `filename_display_max_length` option to set filename truncate limit, if filename display enabled (0 = no limit).
- New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma wont start. For hackney OTP update is not required. - New HTTP adapter [gun](https://github.com/ninenines/gun). Gun adapter requires minimum OTP version of 22.2 otherwise Pleroma wont start. For hackney OTP update is not required.
- Mix task to create trusted OAuth App. - Mix task to create trusted OAuth App.
- Notifications: Added `follow_request` notification type. - Notifications: Added `follow_request` notification type.
- Added `:reject_deletes` group to SimplePolicy - Added `:reject_deletes` group to SimplePolicy
- MRF (`EmojiStealPolicy`): New MRF Policy which allows to automatically download emojis from remote instances
<details> <details>
<summary>API Changes</summary> <summary>API Changes</summary>
- Mastodon API: Extended `/api/v1/instance`. - Mastodon API: Extended `/api/v1/instance`.
@ -30,6 +36,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint. - Mastodon API: Added `/api/v1/notifications/:id/dismiss` endpoint.
- Mastodon API: Add support for filtering replies in public and home timelines - Mastodon API: Add support for filtering replies in public and home timelines
- Admin API: endpoints for create/update/delete OAuth Apps. - Admin API: endpoints for create/update/delete OAuth Apps.
- Admin API: endpoint for status view.
</details> </details>
### Fixed ### Fixed
@ -37,27 +44,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again - **Breaking**: SimplePolicy `:reject` and `:accept` allow deletions again
- Fix follower/blocks import when nicknames starts with @ - Fix follower/blocks import when nicknames starts with @
- Filtering of push notifications on activities from blocked domains - Filtering of push notifications on activities from blocked domains
- Resolving Peertube accounts with Webfinger
- `blob:` urls not being allowed by connect-src CSP
### Changed ## [Unreleased (patch)]
- MFR policy to set global expiration for all local Create activities
### Fixed
- Healthcheck reporting the number of memory currently used, rather than allocated in total
- `InsertSkeletonsForDeletedUsers` failing on some instances
## [2.0.3] - 2020-05-02
## [unreleased-patch]
### Security ### Security
- Disallow re-registration of previously deleted users, which allowed viewing direct messages addressed to them - Disallow re-registration of previously deleted users, which allowed viewing direct messages addressed to them
- Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow - Mastodon API: Fix `POST /api/v1/follow_requests/:id/authorize` allowing to force a follow from a local user even if they didn't request to follow
- CSP: Sandbox uploads
### Fixed ### Fixed
- Logger configuration through AdminFE - Notifications from blocked domains
- Potential federation issues with Mastodon versions before 3.0.0
- HTTP Basic Authentication permissions issue - HTTP Basic Authentication permissions issue
- Follow/Block imports not being able to find the user if the nickname started with an `@`
- Instance stats counting internal users
- Inability to run a From Source release without git
- ObjectAgePolicy didn't filter out old messages - ObjectAgePolicy didn't filter out old messages
- `blob:` urls not being allowed by CSP
### Added ### Added
- NodeInfo: ObjectAgePolicy settings to the `federation` list. - NodeInfo: ObjectAgePolicy settings to the `federation` list.
- Follow request notifications
<details> <details>
<summary>API Changes</summary> <summary>API Changes</summary>
- Admin API: `GET /api/pleroma/admin/need_reboot`. - Admin API: `GET /api/pleroma/admin/need_reboot`.
</details> </details>
### Upgrade notes
1. Restart Pleroma
2. Run database migrations (inside Pleroma directory):
- OTP: `./bin/pleroma_ctl migrate`
- From Source: `mix ecto.migrate`
## [2.0.2] - 2020-04-08 ## [2.0.2] - 2020-04-08
### Added ### Added
- Support for Funkwhale's `Audio` activity - Support for Funkwhale's `Audio` activity
@ -156,6 +184,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: `pleroma.thread_muted` to the Status entity
- Mastodon API: Mark the direct conversation as read for the author when they send a new direct message - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message
- Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload.
- Mastodon API: Add `pleroma.unread_count` to the Marker entity
- Admin API: Render whole status in grouped reports - Admin API: Render whole status in grouped reports
- Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). - Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise).
- Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. - Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try.

View file

@ -123,7 +123,7 @@ def generate_tagged_activities(opts \\ []) do
Enum.each(1..activity_count, fn _ -> Enum.each(1..activity_count, fn _ ->
random = :rand.uniform() random = :rand.uniform()
i = Enum.find_index(intervals, fn {lower, upper} -> lower <= random && upper > random end) i = Enum.find_index(intervals, fn {lower, upper} -> lower <= random && upper > random end)
CommonAPI.post(Enum.random(users), %{"status" => "a post with the tag #tag_#{i}"}) CommonAPI.post(Enum.random(users), %{status: "a post with the tag #tag_#{i}"})
end) end)
end end
@ -137,8 +137,8 @@ defp generate_long_thread(visibility, user, friends, non_friends, _opts) do
{:ok, activity} = {:ok, activity} =
CommonAPI.post(user, %{ CommonAPI.post(user, %{
"status" => "Start of #{visibility} long thread", status: "Start of #{visibility} long thread",
"visibility" => visibility visibility: visibility
}) })
Agent.update(:benchmark_state, fn state -> Agent.update(:benchmark_state, fn state ->
@ -186,7 +186,7 @@ defp insert_activity("simple", visibility, group, user, friends, non_friends, _o
{:ok, _activity} = {:ok, _activity} =
group group
|> get_actor(user, friends, non_friends) |> get_actor(user, friends, non_friends)
|> CommonAPI.post(%{"status" => "Simple status", "visibility" => visibility}) |> CommonAPI.post(%{status: "Simple status", visibility: visibility})
end end
defp insert_activity("emoji", visibility, group, user, friends, non_friends, _opts) do defp insert_activity("emoji", visibility, group, user, friends, non_friends, _opts) do
@ -194,8 +194,8 @@ defp insert_activity("emoji", visibility, group, user, friends, non_friends, _op
group group
|> get_actor(user, friends, non_friends) |> get_actor(user, friends, non_friends)
|> CommonAPI.post(%{ |> CommonAPI.post(%{
"status" => "Simple status with emoji :firefox:", status: "Simple status with emoji :firefox:",
"visibility" => visibility visibility: visibility
}) })
end end
@ -213,8 +213,8 @@ defp insert_activity("mentions", visibility, group, user, friends, non_friends,
group group
|> get_actor(user, friends, non_friends) |> get_actor(user, friends, non_friends)
|> CommonAPI.post(%{ |> CommonAPI.post(%{
"status" => Enum.join(user_mentions, ", ") <> " simple status with mentions", status: Enum.join(user_mentions, ", ") <> " simple status with mentions",
"visibility" => visibility visibility: visibility
}) })
end end
@ -236,8 +236,8 @@ defp insert_activity("hell_thread", visibility, group, user, friends, non_friend
group group
|> get_actor(user, friends, non_friends) |> get_actor(user, friends, non_friends)
|> CommonAPI.post(%{ |> CommonAPI.post(%{
"status" => mentions <> " hell thread status", status: mentions <> " hell thread status",
"visibility" => visibility visibility: visibility
}) })
end end
@ -262,9 +262,9 @@ defp insert_activity("attachment", visibility, group, user, friends, non_friends
{:ok, _activity} = {:ok, _activity} =
CommonAPI.post(actor, %{ CommonAPI.post(actor, %{
"status" => "Post with attachment", status: "Post with attachment",
"visibility" => visibility, visibility: visibility,
"media_ids" => [object.id] media_ids: [object.id]
}) })
end end
@ -272,7 +272,7 @@ defp insert_activity("tag", visibility, group, user, friends, non_friends, _opts
{:ok, _activity} = {:ok, _activity} =
group group
|> get_actor(user, friends, non_friends) |> get_actor(user, friends, non_friends)
|> CommonAPI.post(%{"status" => "Status with #tag", "visibility" => visibility}) |> CommonAPI.post(%{status: "Status with #tag", visibility: visibility})
end end
defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do defp insert_activity("like", visibility, group, user, friends, non_friends, opts) do
@ -312,8 +312,7 @@ defp insert_activity("simple_thread", visibility, group, user, friends, non_frie
actor = get_actor(group, user, friends, non_friends) actor = get_actor(group, user, friends, non_friends)
tasks = get_reply_tasks(visibility, group) tasks = get_reply_tasks(visibility, group)
{:ok, activity} = {:ok, activity} = CommonAPI.post(user, %{status: "Simple status", visibility: visibility})
CommonAPI.post(user, %{"status" => "Simple status", "visibility" => visibility})
acc = {activity.id, ["@" <> actor.nickname, "reply to status"]} acc = {activity.id, ["@" <> actor.nickname, "reply to status"]}
insert_replies(tasks, visibility, user, friends, non_friends, acc) insert_replies(tasks, visibility, user, friends, non_friends, acc)
@ -336,8 +335,8 @@ defp insert_activity("simple_thread", "direct", group, user, friends, non_friend
{:ok, activity} = {:ok, activity} =
CommonAPI.post(actor, %{ CommonAPI.post(actor, %{
"status" => Enum.join(data, ", ") <> "simple status", status: Enum.join(data, ", ") <> "simple status",
"visibility" => "direct" visibility: "direct"
}) })
acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]} acc = {activity.id, ["@" <> user.nickname | data] ++ ["reply to status"]}
@ -527,9 +526,9 @@ defp insert_direct_replies(tasks, user, list, acc) do
defp insert_reply(actor, data, activity_id, visibility) do defp insert_reply(actor, data, activity_id, visibility) do
{:ok, reply} = {:ok, reply} =
CommonAPI.post(actor, %{ CommonAPI.post(actor, %{
"status" => Enum.join(data, ", "), status: Enum.join(data, ", "),
"visibility" => visibility, visibility: visibility,
"in_reply_to_status_id" => activity_id in_reply_to_status_id: activity_id
}) })
{reply.id, ["@" <> actor.nickname | data]} {reply.id, ["@" <> actor.nickname | data]}

View file

@ -387,56 +387,47 @@ defp render_timelines(user) do
favourites = ActivityPub.fetch_favourites(user) favourites = ActivityPub.fetch_favourites(user)
output_relationships =
!!Pleroma.Config.get([:extensions, :output_relationships_in_statuses_by_default])
Benchee.run( Benchee.run(
%{ %{
"Rendering home timeline" => fn -> "Rendering home timeline" => fn ->
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: home_activities, activities: home_activities,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: !output_relationships
}) })
end, end,
"Rendering direct timeline" => fn -> "Rendering direct timeline" => fn ->
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: direct_activities, activities: direct_activities,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: !output_relationships
}) })
end, end,
"Rendering public timeline" => fn -> "Rendering public timeline" => fn ->
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: public_activities, activities: public_activities,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: !output_relationships
}) })
end, end,
"Rendering tag timeline" => fn -> "Rendering tag timeline" => fn ->
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: tag_activities, activities: tag_activities,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: !output_relationships
}) })
end, end,
"Rendering notifications" => fn -> "Rendering notifications" => fn ->
Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{ Pleroma.Web.MastodonAPI.NotificationView.render("index.json", %{
notifications: notifications, notifications: notifications,
for: user, for: user
skip_relationships: !output_relationships
}) })
end, end,
"Rendering favourites timeline" => fn -> "Rendering favourites timeline" => fn ->
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: favourites, activities: favourites,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: !output_relationships
}) })
end end
}, },

View file

@ -55,7 +55,7 @@ defp generate_user(i) do
name: "Test テスト User #{i}", name: "Test テスト User #{i}",
email: "user#{i}@example.com", email: "user#{i}@example.com",
nickname: "nick#{i}", nickname: "nick#{i}",
password_hash: Comeonin.Pbkdf2.hashpwsalt("test"), password_hash: Pbkdf2.hash_pwd_salt("test"),
bio: "Tester Number #{i}", bio: "Tester Number #{i}",
local: !remote local: !remote
} }

View file

@ -71,7 +71,8 @@
follow_redirect: true, follow_redirect: true,
pool: :upload pool: :upload
] ]
] ],
filename_display_max_length: 30
config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads" config :pleroma, Pleroma.Uploaders.Local, uploads: "uploads"
@ -183,6 +184,7 @@
email: "example@example.com", email: "example@example.com",
notify_email: "noreply@example.com", notify_email: "noreply@example.com",
description: "A Pleroma instance, an alternative fediverse server", description: "A Pleroma instance, an alternative fediverse server",
background_image: "/images/city.jpg",
limit: 5_000, limit: 5_000,
chat_limit: 5_000, chat_limit: 5_000,
remote_limit: 100_000, remote_limit: 100_000,
@ -238,9 +240,18 @@
account_field_value_length: 2048, account_field_value_length: 2048,
external_user_synchronization: true, external_user_synchronization: true,
extended_nickname_format: true, extended_nickname_format: true,
cleanup_attachments: false cleanup_attachments: false,
multi_factor_authentication: [
config :pleroma, :extensions, output_relationships_in_statuses_by_default: true totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 5,
length: 16
]
]
config :pleroma, :feed, config :pleroma, :feed,
post_title: %{ post_title: %{
@ -262,20 +273,33 @@
config :pleroma, :frontend_configurations, config :pleroma, :frontend_configurations,
pleroma_fe: %{ pleroma_fe: %{
theme: "pleroma-dark", alwaysShowSubjectInput: true,
logo: "/static/logo.png",
background: "/images/city.jpg", background: "/images/city.jpg",
redirectRootNoLogin: "/main/all",
redirectRootLogin: "/main/friends",
showInstanceSpecificPanel: true,
scopeOptionsEnabled: false,
formattingOptionsEnabled: false,
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
disableChat: false,
greentext: false,
hideFilteredStatuses: false,
hideMutedPosts: false,
hidePostStats: false, hidePostStats: false,
hideSitename: false,
hideUserStats: false, hideUserStats: false,
loginMethod: "password",
logo: "/static/logo.png",
logoMargin: ".1em",
logoMask: true,
minimalScopesMode: false,
noAttachmentLinks: false,
nsfwCensorImage: "",
postContentType: "text/plain",
redirectRootLogin: "/main/friends",
redirectRootNoLogin: "/main/all",
scopeCopy: true, scopeCopy: true,
sidebarRight: false,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
subjectLineBehavior: "email", subjectLineBehavior: "email",
alwaysShowSubjectInput: true theme: "pleroma-dark",
webPushNotifications: false
}, },
masto_fe: %{ masto_fe: %{
showInstanceSpecificPanel: true showInstanceSpecificPanel: true
@ -369,6 +393,10 @@
config :pleroma, :media_proxy, config :pleroma, :media_proxy,
enabled: false, enabled: false,
invalidation: [
enabled: false,
provider: Pleroma.Web.MediaProxy.Invalidation.Script
],
proxy_opts: [ proxy_opts: [
redirect_on_failure: false, redirect_on_failure: false,
max_body_length: 25 * 1_048_576, max_body_length: 25 * 1_048_576,
@ -655,6 +683,8 @@
profiles: %{local: false, remote: false}, profiles: %{local: false, remote: false},
activities: %{local: false, remote: false} activities: %{local: false, remote: false}
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: false
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs" import_config "#{Mix.env()}.exs"

View file

@ -28,7 +28,8 @@
%{ %{
key: :filters, key: :filters,
type: {:list, :module}, type: {:list, :module},
description: "List of filter modules for uploads", description:
"List of filter modules for uploads. Module names are shortened (removed leading `Pleroma.Upload.Filter.` part), but on adding custom module you need to use full name.",
suggestions: suggestions:
Generator.list_modules_in_dir( Generator.list_modules_in_dir(
"lib/pleroma/upload/filter", "lib/pleroma/upload/filter",
@ -118,6 +119,11 @@
] ]
} }
] ]
},
%{
key: :filename_display_max_length,
type: :integer,
description: "Set max length of a filename to display. 0 = no limit. Default: 30"
} }
] ]
}, },
@ -678,14 +684,6 @@
7 7
] ]
}, },
%{
key: :federation_publisher_modules,
type: {:list, :module},
description: "List of modules for federation publishing",
suggestions: [
Pleroma.Web.ActivityPub.Publisher
]
},
%{ %{
key: :allow_relay, key: :allow_relay,
type: :boolean, type: :boolean,
@ -694,7 +692,8 @@
%{ %{
key: :rewrite_policy, key: :rewrite_policy,
type: [:module, {:list, :module}], type: [:module, {:list, :module}],
description: "A list of MRF policies enabled", description:
"A list of enabled MRF policies. Module names are shortened (removed leading `Pleroma.Web.ActivityPub.MRF.` part), but on adding custom module you need to use full name.",
suggestions: suggestions:
Generator.list_modules_in_dir( Generator.list_modules_in_dir(
"lib/pleroma/web/activity_pub/mrf", "lib/pleroma/web/activity_pub/mrf",
@ -712,7 +711,7 @@
key: :quarantined_instances, key: :quarantined_instances,
type: {:list, :string}, type: {:list, :string},
description: description:
"List of ActivityPub instances where private (DMs, followers-only) activities will not be send", "List of ActivityPub instances where private (DMs, followers-only) activities will not be sent",
suggestions: [ suggestions: [
"quarantined.com", "quarantined.com",
"*.quarantined.com" "*.quarantined.com"
@ -919,6 +918,69 @@
key: :external_user_synchronization, key: :external_user_synchronization,
type: :boolean, type: :boolean,
description: "Enabling following/followers counters synchronization for external users" description: "Enabling following/followers counters synchronization for external users"
},
%{
key: :multi_factor_authentication,
type: :keyword,
description: "Multi-factor authentication settings",
suggestions: [
[
totp: [digits: 6, period: 30],
backup_codes: [number: 5, length: 16]
]
],
children: [
%{
key: :totp,
type: :keyword,
description: "TOTP settings",
suggestions: [digits: 6, period: 30],
children: [
%{
key: :digits,
type: :integer,
suggestions: [6],
description:
"Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
},
%{
key: :period,
type: :integer,
suggestions: [30],
description:
"a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
}
]
},
%{
key: :backup_codes,
type: :keyword,
description: "MFA backup codes settings",
suggestions: [number: 5, length: 16],
children: [
%{
key: :number,
type: :integer,
suggestions: [5],
description: "number of backup codes to generate."
},
%{
key: :length,
type: :integer,
suggestions: [16],
description:
"Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
}
]
}
]
},
%{
key: :instance_thumbnail,
type: :string,
description:
"The instance thumbnail image. It will appear in [Pleroma Instances](http://distsn.org/pleroma-instances.html)",
suggestions: ["/instance/thumbnail.jpeg"]
} }
] ]
}, },
@ -1046,32 +1108,98 @@
description: "Settings for Pleroma FE", description: "Settings for Pleroma FE",
suggestions: [ suggestions: [
%{ %{
theme: "pleroma-dark",
logo: "/static/logo.png",
background: "/images/city.jpg",
redirectRootNoLogin: "/main/all",
redirectRootLogin: "/main/friends",
showInstanceSpecificPanel: true,
scopeOptionsEnabled: false,
formattingOptionsEnabled: false,
collapseMessageWithSubject: false,
hidePostStats: false,
hideUserStats: false,
scopeCopy: true,
subjectLineBehavior: "email",
alwaysShowSubjectInput: true, alwaysShowSubjectInput: true,
logoMask: false, background: "/static/aurora_borealis.jpg",
collapseMessageWithSubject: false,
disableChat: false,
greentext: false,
hideFilteredStatuses: false,
hideMutedPosts: false,
hidePostStats: false,
hideSitename: false,
hideUserStats: false,
loginMethod: "password",
logo: "/static/logo.png",
logoMargin: ".1em", logoMargin: ".1em",
stickers: false, logoMask: true,
enableEmojiPicker: false minimalScopesMode: false,
noAttachmentLinks: false,
nsfwCensorImage: "/static/img/nsfw.74818f9.png",
postContentType: "text/plain",
redirectRootLogin: "/main/friends",
redirectRootNoLogin: "/main/all",
scopeCopy: true,
sidebarRight: false,
showFeaturesPanel: true,
showInstanceSpecificPanel: false,
subjectLineBehavior: "email",
theme: "pleroma-dark",
webPushNotifications: false
} }
], ],
children: [ children: [
%{ %{
key: :theme, key: :alwaysShowSubjectInput,
label: "Always show subject input",
type: :boolean,
description: "When disabled, auto-hide the subject field if it's empty"
},
%{
key: :background,
type: :string, type: :string,
description: "Which theme to use, they are defined in styles.json", description:
suggestions: ["pleroma-dark"] "URL of the background, unless viewing a user profile with a background that is set",
suggestions: ["/images/city.jpg"]
},
%{
key: :collapseMessageWithSubject,
label: "Collapse message with subject",
type: :boolean,
description:
"When a message has a subject (aka Content Warning), collapse it by default"
},
%{
key: :disableChat,
label: "PleromaFE Chat",
type: :boolean,
description: "Disables PleromaFE Chat component"
},
%{
key: :greentext,
label: "Greentext",
type: :boolean,
description: "Enables green text on lines prefixed with the > character."
},
%{
key: :hideFilteredStatuses,
label: "Hide Filtered Statuses",
type: :boolean,
description: "Hides filtered statuses from timelines."
},
%{
key: :hideMutedPosts,
label: "Hide Muted Posts",
type: :boolean,
description: "Hides muted statuses from timelines."
},
%{
key: :hidePostStats,
label: "Hide post stats",
type: :boolean,
description: "Hide notices statistics (repeats, favorites, ...)"
},
%{
key: :hideSitename,
label: "Hide Sitename",
type: :boolean,
description: "Hides instance name from PleromaFE banner."
},
%{
key: :hideUserStats,
label: "Hide user stats",
type: :boolean,
description:
"Hide profile statistics (posts, posts per day, followers, followings, ...)"
}, },
%{ %{
key: :logo, key: :logo,
@ -1080,11 +1208,44 @@
suggestions: ["/static/logo.png"] suggestions: ["/static/logo.png"]
}, },
%{ %{
key: :background, key: :logoMargin,
label: "Logo margin",
type: :string, type: :string,
description: description:
"URL of the background, unless viewing a user profile with a background that is set", "Allows you to adjust vertical margins between logo boundary and navbar borders. " <>
suggestions: ["/images/city.jpg"] "The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.",
suggestions: [".1em"]
},
%{
key: :logoMask,
label: "Logo mask",
type: :boolean,
description:
"By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <>
"If you want a colorful logo you must disable logoMask."
},
%{
key: :minimalScopesMode,
label: "Minimal scopes mode",
type: :boolean,
description:
"Limit scope selection to Direct, User default, and Scope of post replying to. " <>
"Also prevents replying to a DM with a public post from PleromaFE."
},
%{
key: :nsfwCensorImage,
label: "NSFW Censor Image",
type: :string,
description:
"URL of the image to use for hiding NSFW media attachments in the timeline.",
suggestions: ["/static/img/nsfw.74818f9.png"]
},
%{
key: :postContentType,
label: "Post Content Type",
type: {:dropdown, :atom},
description: "Default post formatting option.",
suggestions: ["text/plain", "text/html", "text/markdown", "text/bbcode"]
}, },
%{ %{
key: :redirectRootNoLogin, key: :redirectRootNoLogin,
@ -1102,51 +1263,31 @@
"Relative URL which indicates where to redirect when a user is logged in", "Relative URL which indicates where to redirect when a user is logged in",
suggestions: ["/main/friends"] suggestions: ["/main/friends"]
}, },
%{
key: :showInstanceSpecificPanel,
label: "Show instance specific panel",
type: :boolean,
description: "Whenether to show the instance's specific panel"
},
%{
key: :scopeOptionsEnabled,
label: "Scope options enabled",
type: :boolean,
description: "Enable setting a notice visibility and subject/CW when posting"
},
%{
key: :formattingOptionsEnabled,
label: "Formatting options enabled",
type: :boolean,
description:
"Enable setting a formatting different than plain-text (ie. HTML, Markdown) when posting, relates to `:instance`, `allowed_post_formats`"
},
%{
key: :collapseMessageWithSubject,
label: "Collapse message with subject",
type: :boolean,
description:
"When a message has a subject (aka Content Warning), collapse it by default"
},
%{
key: :hidePostStats,
label: "Hide post stats",
type: :boolean,
description: "Hide notices statistics (repeats, favorites, ...)"
},
%{
key: :hideUserStats,
label: "Hide user stats",
type: :boolean,
description:
"Hide profile statistics (posts, posts per day, followers, followings, ...)"
},
%{ %{
key: :scopeCopy, key: :scopeCopy,
label: "Scope copy", label: "Scope copy",
type: :boolean, type: :boolean,
description: "Copy the scope (private/unlisted/public) in replies to posts by default" description: "Copy the scope (private/unlisted/public) in replies to posts by default"
}, },
%{
key: :sidebarRight,
label: "Sidebar on Right",
type: :boolean,
description: "Change alignment of sidebar and panels to the right."
},
%{
key: :showFeaturesPanel,
label: "Show instance features panel",
type: :boolean,
description:
"Enables panel displaying functionality of the instance on the About page."
},
%{
key: :showInstanceSpecificPanel,
label: "Show instance specific panel",
type: :boolean,
description: "Whether to show the instance's custom panel"
},
%{ %{
key: :subjectLineBehavior, key: :subjectLineBehavior,
label: "Subject line behavior", label: "Subject line behavior",
@ -1158,38 +1299,10 @@
suggestions: ["email", "masto", "noop"] suggestions: ["email", "masto", "noop"]
}, },
%{ %{
key: :alwaysShowSubjectInput, key: :theme,
label: "Always show subject input",
type: :boolean,
description: "When disabled, auto-hide the subject field if it's empty"
},
%{
key: :logoMask,
label: "Logo mask",
type: :boolean,
description:
"By default it assumes logo used will be monochrome with alpha channel to be compatible with both light and dark themes. " <>
"If you want a colorful logo you must disable logoMask."
},
%{
key: :logoMargin,
label: "Logo margin",
type: :string, type: :string,
description: description: "Which theme to use. Available themes are defined in styles.json",
"Allows you to adjust vertical margins between logo boundary and navbar borders. " <> suggestions: ["pleroma-dark"]
"The idea is that to have logo's image without any extra margins and instead adjust them to your need in layout.",
suggestions: [".1em"]
},
%{
key: :stickers,
type: :boolean,
description: "Enables stickers."
},
%{
key: :enableEmojiPicker,
label: "Emoji picker",
type: :boolean,
description: "Enables emoji picker."
} }
] ]
}, },
@ -1245,6 +1358,12 @@
suggestions: [ suggestions: [
:pleroma_fox_tan :pleroma_fox_tan
] ]
},
%{
key: :default_user_avatar,
type: :string,
description: "URL of the default user avatar.",
suggestions: ["/images/avi.png"]
} }
] ]
}, },
@ -1814,12 +1933,6 @@
(see https://github.com/sorentwo/oban/issues/52). (see https://github.com/sorentwo/oban/issues/52).
""", """,
children: [ children: [
%{
key: :repo,
type: :module,
description: "Application's Ecto repo",
suggestions: [Pleroma.Repo]
},
%{ %{
key: :verbose, key: :verbose,
type: {:dropdown, :atom}, type: {:dropdown, :atom},
@ -1990,7 +2103,8 @@
%{ %{
key: :parsers, key: :parsers,
type: {:list, :module}, type: {:list, :module},
description: "List of Rich Media parsers.", description:
"List of Rich Media parsers. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parsers.` part), but on adding custom module you need to use full name.",
suggestions: [ suggestions: [
Pleroma.Web.RichMedia.Parsers.MetaTagsParser, Pleroma.Web.RichMedia.Parsers.MetaTagsParser,
Pleroma.Web.RichMedia.Parsers.OEmbed, Pleroma.Web.RichMedia.Parsers.OEmbed,
@ -2002,7 +2116,8 @@
key: :ttl_setters, key: :ttl_setters,
label: "TTL setters", label: "TTL setters",
type: {:list, :module}, type: {:list, :module},
description: "List of rich media TTL setters.", description:
"List of rich media TTL setters. Module names are shortened (removed leading `Pleroma.Web.RichMedia.Parser.` part), but on adding custom module you need to use full name.",
suggestions: [ suggestions: [
Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl Pleroma.Web.RichMedia.Parser.TTL.AwsSignedUrl
] ]
@ -2592,18 +2707,6 @@
} }
] ]
}, },
%{
group: :http_signatures,
type: :group,
description: "HTTP Signatures settings",
children: [
%{
key: :adapter,
type: :module,
suggestions: [Pleroma.Signature]
}
]
},
%{ %{
group: :pleroma, group: :pleroma,
key: :http, key: :http,
@ -2676,6 +2779,8 @@
%{ %{
key: :scrub_policy, key: :scrub_policy,
type: {:list, :module}, type: {:list, :module},
description:
"Module names are shortened (removed leading `Pleroma.HTML.` part), but on adding custom module you need to use full name.",
suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default] suggestions: [Pleroma.HTML.Transform.MediaProxy, Pleroma.HTML.Scrubber.Default]
} }
] ]
@ -3210,5 +3315,19 @@
] ]
} }
] ]
},
%{
group: :pleroma,
key: Pleroma.Web.ApiSpec.CastAndValidate,
type: :group,
children: [
%{
key: :strict,
type: :boolean,
description:
"Enables strict input validation (useful in development, not recommended in production)",
suggestions: [false]
}
]
} }
] ]

View file

@ -52,6 +52,8 @@
hostname: "localhost", hostname: "localhost",
pool_size: 10 pool_size: 10
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
if File.exists?("./config/dev.secret.exs") do if File.exists?("./config/dev.secret.exs") do
import_config "dev.secret.exs" import_config "dev.secret.exs"
else else

View file

@ -56,6 +56,19 @@
ignore_hosts: [], ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"] ignore_tld: ["local", "localdomain", "lan"]
config :pleroma, :instance,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
config :web_push_encryption, :vapid_details, config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com", subject: "mailto:administrator@example.com",
public_key: public_key:
@ -96,6 +109,8 @@
config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false config :pleroma, Pleroma.Plugs.RemoteIp, enabled: false
config :pleroma, Pleroma.Web.ApiSpec.CastAndValidate, strict: true
if File.exists?("./config/test.secret.exs") do if File.exists?("./config/test.secret.exs") do
import_config "test.secret.exs" import_config "test.secret.exs"
else else

View file

@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
### Get a password reset token for a given nickname ### Get a password reset token for a given nickname
- Params: none - Params: none
- Response: - Response:
@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- `nicknames` - `nicknames`
- Response: none (code `204`) - Response: none (code `204`)
## PUT `/api/pleroma/admin/users/disable_mfa`
### Disable mfa for user's account.
- Params:
- `nickname`
- Response: Users nickname
## `GET /api/pleroma/admin/users/:nickname/credentials` ## `GET /api/pleroma/admin/users/:nickname/credentials`
### Get the user's email, password, display and settings-related fields ### Get the user's email, password, display and settings-related fields
@ -502,7 +511,23 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- `discoverable` - `discoverable`
- `actor_type` - `actor_type`
- Response: none (code `200`) - Response:
```json
{"status": "success"}
```
```json
{"errors":
{"actor_type": "is invalid"},
{"email": "has invalid format"},
...
}
```
```json
{"error": "Unable to update user."}
```
## `GET /api/pleroma/admin/reports` ## `GET /api/pleroma/admin/reports`
@ -755,6 +780,17 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- 400 Bad Request `"Invalid parameters"` when `status` is missing - 400 Bad Request `"Invalid parameters"` when `status` is missing
- On success: `204`, empty response - On success: `204`, empty response
## `GET /api/pleroma/admin/statuses/:id`
### Show status by id
- Params:
- `id`: required, status id
- Response:
- On failure:
- 404 Not Found `"Not Found"`
- On success: JSON, Mastodon Status entity
## `PUT /api/pleroma/admin/statuses/:id` ## `PUT /api/pleroma/admin/statuses/:id`
### Change the scope of an individual reported status ### Change the scope of an individual reported status

View file

@ -6,10 +6,6 @@ A Pleroma instance can be identified by "<Mastodon version> (compatible; Pleroma
Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings Pleroma uses 128-bit ids as opposed to Mastodon's 64 bits. However just like Mastodon's ids they are lexically sortable strings
## Attachment cap
Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting.
## Timelines ## Timelines
Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users. Adding the parameter `with_muted=true` to the timeline queries will also return activities by muted (not by blocked!) users.
@ -32,12 +28,20 @@ Has these additional fields under the `pleroma` object:
- `thread_muted`: true if the thread the post belongs to is muted - `thread_muted`: true if the thread the post belongs to is muted
- `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint. - `emoji_reactions`: A list with emoji / reaction maps. The format is `{name: "☕", count: 1, me: true}`. Contains no information about the reacting users, for that use the `/statuses/:id/reactions` endpoint.
## Attachments ## Media Attachments
Has these additional fields under the `pleroma` object: Has these additional fields under the `pleroma` object:
- `mime_type`: mime type of the attachment. - `mime_type`: mime type of the attachment.
### Attachment cap
Some apps operate under the assumption that no more than 4 attachments can be returned or uploaded. Pleroma however does not enforce any limits on attachment count neither when returning the status object nor when posting.
### Limitations
Pleroma does not process remote images and therefore cannot include fields such as `meta` and `blurhash`. It does not support focal points or aspect ratios. The frontend is expected to handle it.
## Accounts ## Accounts
The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc. The `id` parameter can also be the `nickname` of the user. This only works in these endpoints, not the deeper nested ones for following etc.
@ -61,6 +65,7 @@ Has these additional fields under the `pleroma` object:
- `deactivated`: boolean, true when the user is deactivated - `deactivated`: boolean, true when the user is deactivated
- `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts
- `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner.
- `unread_notifications_count`: The count of unread notifications. Only returned to the account owner.
### Source ### Source
@ -215,6 +220,13 @@ Has theses additional parameters (which are the same as in Pleroma-API):
- `avatar_upload_limit`: The same for avatars - `avatar_upload_limit`: The same for avatars
- `background_upload_limit`: The same for backgrounds - `background_upload_limit`: The same for backgrounds
- `banner_upload_limit`: The same for banners - `banner_upload_limit`: The same for banners
- `background_image`: A background image that frontends can use
- `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.features`: A list of supported features
- `pleroma.metadata.federation`: The federation restrictions of this instance - `pleroma.metadata.federation`: The federation restrictions of this instance
- `vapid_public_key`: The public key needed for push messages - `vapid_public_key`: The public key needed for push messages
## Markers
Has these additional fields under the `pleroma` object:
- `unread_count`: contains number unread notifications

View file

@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
* Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise * Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}` * Example response: `{"error": "Invalid password."}`
## `/api/pleroma/admin/` ## `/api/pleroma/accounts/mfa`
#### Gets current MFA settings
* method: `GET`
* Authentication: required
* OAuth scope: `read:security`
* Response: JSON. Returns `{"enabled": "false", "totp": false }`
## `/api/pleroma/accounts/mfa/setup/totp`
#### Pre-setup the MFA/TOTP method
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]" }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}`
## `/api/pleroma/accounts/mfa/confirm/totp`
#### Confirms & enables MFA/TOTP support for user account.
* method: `POST`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* `code`: token from TOTP App
* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
## `/api/pleroma/accounts/mfa/totp`
#### Disables MFA/TOTP method for user account.
* method: `DELETE`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}`
## `/api/pleroma/accounts/mfa/backup_codes`
#### Generstes backup codes MFA for user account.
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}`
## `/api/pleroma/admin/`
See [Admin-API](admin_api.md) See [Admin-API](admin_api.md)
## `/api/v1/pleroma/notifications/read` ## `/api/v1/pleroma/notifications/read`
@ -223,7 +265,7 @@ See [Admin-API](admin_api.md)
* Method `PUT` * Method `PUT`
* Authentication: required * Authentication: required
* Params: * Params:
* `image`: Multipart image * `file`: Multipart image
* Response: JSON. Returns a mastodon media attachment entity * Response: JSON. Returns a mastodon media attachment entity
when successful, otherwise returns HTTP 415 `{"error": "error_msg"}` when successful, otherwise returns HTTP 415 `{"error": "error_msg"}`
* Example response: * Example response:
@ -316,7 +358,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa
* `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though. * `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.
* Response: JSON, statuses (200 - healthy, 503 unhealthy) * Response: JSON, statuses (200 - healthy, 503 unhealthy)
## `GET /api/v1/pleroma/conversations/read` ## `POST /api/v1/pleroma/conversations/read`
### Marks all user's conversations as read. ### Marks all user's conversations as read.
* Method `POST` * Method `POST`
* Authentication: required * Authentication: required
@ -384,7 +426,7 @@ The status posting endpoint takes an additional parameter, `in_reply_to_conversa
* Authentication: required * Authentication: required
* Params: * Params:
* `file`: file needs to be uploaded with the multipart request or link to remote file. * `file`: file needs to be uploaded with the multipart request or link to remote file.
* `shortcode`: (*optional*) shortcode for new emoji, must be uniq for all emoji. If not sended, shortcode will be taken from original filename. * `shortcode`: (*optional*) shortcode for new emoji, must be unique for all emoji. If not sended, shortcode will be taken from original filename.
* `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename. * `filename`: (*optional*) new emoji file name. If not specified will be taken from original filename.
* Response: JSON, list of files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message. * Response: JSON, list of files for updated pack (hashmap -> shortcode => filename) with status 200, either error status with error message.
@ -494,7 +536,7 @@ Emoji reactions work a lot like favourites do. They make it possible to react to
``` ```
## `GET /api/v1/pleroma/statuses/:id/reactions/:emoji` ## `GET /api/v1/pleroma/statuses/:id/reactions/:emoji`
### Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji` ### Get an object of emoji to account mappings with accounts that reacted to the post for a specific emoji
* Method: `GET` * Method: `GET`
* Authentication: optional * Authentication: optional
* Params: None * Params: None

View file

@ -69,3 +69,32 @@ mix pleroma.database update_users_following_followers_counts
```sh tab="From Source" ```sh tab="From Source"
mix pleroma.database fix_likes_collections mix pleroma.database fix_likes_collections
``` ```
## Vacuum the database
### Analyze
Running an `analyze` vacuum job can improve performance by updating statistics used by the query planner. **It is safe to cancel this.**
```sh tab="OTP"
./bin/pleroma_ctl database vacuum analyze
```
```sh tab="From Source"
mix pleroma.database vacuum analyze
```
### Full
Running a `full` vacuum job rebuilds your entire database by reading all of the data and rewriting it into smaller
and more compact files with an optimized layout. This process will take a long time and use additional disk space as
it builds the files side-by-side the existing database files. It can make your database faster and use less disk space,
but should only be run if necessary. **It is safe to cancel this.**
```sh tab="OTP"
./bin/pleroma_ctl database vacuum full
```
```sh tab="From Source"
mix pleroma.database vacuum full
```

View file

@ -95,33 +95,33 @@ mix pleroma.user sign_out <nickname>
``` ```
## Deactivate or activate a user ## Deactivate or activate a user
```sh tab="OTP" ```sh tab="OTP"
./bin/pleroma_ctl user toggle_activated <nickname> ./bin/pleroma_ctl user toggle_activated <nickname>
``` ```
```sh tab="From Source" ```sh tab="From Source"
mix pleroma.user toggle_activated <nickname> mix pleroma.user toggle_activated <nickname>
``` ```
## Unsubscribe local users from a user and deactivate the user ## Deactivate a user and unsubscribes local users from the user
```sh tab="OTP" ```sh tab="OTP"
./bin/pleroma_ctl user unsubscribe NICKNAME ./bin/pleroma_ctl user deactivate NICKNAME
``` ```
```sh tab="From Source" ```sh tab="From Source"
mix pleroma.user unsubscribe NICKNAME mix pleroma.user deactivate NICKNAME
``` ```
## Unsubscribe local users from an instance and deactivate all accounts on it ## Deactivate all accounts from an instance and unsubscribe local users on it
```sh tab="OTP" ```sh tab="OTP"
./bin/pleroma_ctl user unsubscribe_all_from_instance <instance> ./bin/pleroma_ctl user deactivate_all_from_instance <instance>
``` ```
```sh tab="From Source" ```sh tab="From Source"
mix pleroma.user unsubscribe_all_from_instance <instance> mix pleroma.user deactivate_all_from_instance <instance>
``` ```
@ -177,4 +177,3 @@ mix pleroma.user untag <nickname> <tags>
```sh tab="From Source" ```sh tab="From Source"
mix pleroma.user toggle_confirmed <nickname> mix pleroma.user toggle_confirmed <nickname>
``` ```

View file

@ -8,6 +8,10 @@ For from source installations Pleroma configuration works by first importing the
To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted. To add configuration to your config file, you can copy it from the base config. The latest version of it can be viewed [here](https://git.pleroma.social/pleroma/pleroma/blob/develop/config/config.exs). You can also use this file if you don't know how an option is supposed to be formatted.
## :chat
* `enabled` - Enables the backend chat. Defaults to `true`.
## :instance ## :instance
* `name`: The instances name. * `name`: The instances name.
* `email`: Email used to reach an Administrator/Moderator of the instance. * `email`: Email used to reach an Administrator/Moderator of the instance.
@ -146,6 +150,11 @@ config :pleroma, :mrf_user_allowlist,
* `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines * `:strip_followers` removes followers from the ActivityPub recipient list, ensuring they won't be delivered to home timelines
* `:reject` rejects the message entirely * `:reject` rejects the message entirely
#### mrf_steal_emoji
* `hosts`: List of hosts to steal emojis from
* `rejected_shortcodes`: Regex-list of shortcodes to reject
* `size_limit`: File size limit (in bytes), checked before an emoji is saved to the disk
#### :mrf_activity_expiration #### :mrf_activity_expiration
* `days`: Default global expiration time for all local Create activities (in days) * `days`: Default global expiration time for all local Create activities (in days)
@ -250,6 +259,40 @@ This section describe PWA manifest instance-specific values. Currently this opti
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts. * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host/CDN fronts.
* `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`. * `proxy_opts`: All options defined in `Pleroma.ReverseProxy` documentation, defaults to `[max_body_length: (25*1_048_576)]`.
* `whitelist`: List of domains to bypass the mediaproxy * `whitelist`: List of domains to bypass the mediaproxy
* `invalidation`: options for remove media from cache after delete object:
* `enabled`: Enables purge cache
* `provider`: Which one of the [purge cache strategy](#purge-cache-strategy) to use.
### Purge cache strategy
#### Pleroma.Web.MediaProxy.Invalidation.Script
This strategy allow perform external bash script to purge cache.
Urls of attachments pass to script as arguments.
* `script_path`: path to external script.
Example:
```elixir
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Script,
script_path: "./installation/nginx-cache-purge.example"
```
#### Pleroma.Web.MediaProxy.Invalidation.Http
This strategy allow perform custom http request to purge cache.
* `method`: http method. default is `purge`
* `headers`: http headers. default is empty
* `options`: request options. default is empty
Example:
```elixir
config :pleroma, Pleroma.Web.MediaProxy.Invalidation.Http,
method: :purge,
headers: [],
options: []
```
## Link previews ## Link previews
@ -460,6 +503,7 @@ the source code is here: https://github.com/koto-bank/kocaptcha. The default end
* `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host. * `base_url`: The base URL to access a user-uploaded file. Useful when you want to proxy the media files via another host.
* `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it. * `proxy_remote`: If you're using a remote uploader, Pleroma will proxy media requests instead of redirecting to it.
* `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation. * `proxy_opts`: Proxy options, see `Pleroma.ReverseProxy` documentation.
* `filename_display_max_length`: Set max length of a filename to display. 0 = no limit. Default: 30.
!!! warning !!! warning
`strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`. `strip_exif` has been replaced by `Pleroma.Upload.Filter.Mogrify`.
@ -620,24 +664,6 @@ config :pleroma, :workers,
* `enabled: false` corresponds to `config :pleroma, :workers, retries: [federator_outgoing: 1]` * `enabled: false` corresponds to `config :pleroma, :workers, retries: [federator_outgoing: 1]`
* deprecated options: `max_jobs`, `initial_timeout` * deprecated options: `max_jobs`, `initial_timeout`
### Pleroma.Scheduler
Configuration for [Quantum](https://github.com/quantum-elixir/quantum-core) jobs scheduler.
See [Quantum readme](https://github.com/quantum-elixir/quantum-core#usage) for the list of supported options.
Example:
```elixir
config :pleroma, Pleroma.Scheduler,
global: true,
overlap: true,
timezone: :utc,
jobs: [{"0 */6 * * * *", {Pleroma.Web.Websub, :refresh_subscriptions, []}}]
```
The above example defines a single job which invokes `Pleroma.Web.Websub.refresh_subscriptions()` every 6 hours ("0 */6 * * * *", [crontab format](https://en.wikipedia.org/wiki/Cron)).
## :web_push_encryption, :vapid_details ## :web_push_encryption, :vapid_details
Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it. Web Push Notifications configuration. You can use the mix task `mix web_push.gen.keypair` to generate it.
@ -908,12 +934,33 @@ config :auto_linker,
* `runtime_dir`: A path to custom Elixir modules (such as MRF policies). * `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
## :configurable_from_database ## :configurable_from_database
Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information. Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information.
## :database_config_whitelist
List of valid configuration sections which are allowed to be configured from the
database. Settings stored in the database before the whitelist is configured are
still applied, so it is suggested to only use the whitelist on instances that
have not migrated the config to the database.
Example:
```elixir
config :pleroma, :database_config_whitelist, [
{:pleroma, :instance},
{:pleroma, Pleroma.Web.Metadata},
{:auto_linker}
]
```
### Multi-factor authentication - :two_factor_authentication
* `totp` - a list containing TOTP configuration
- `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
- `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
* `backup_codes` - a list containing backup codes configuration
- `number` - number of backup codes to generate.
- `length` - backup code length. Defaults to 16 characters.
## Restrict entities access for unauthenticated users ## Restrict entities access for unauthenticated users
@ -929,4 +976,9 @@ Restrict access for unauthenticated users to timelines (public and federate), us
* `remote` * `remote`
* `activities` - statuses * `activities` - statuses
* `local` * `local`
* `remote` * `remote`
## Pleroma.Web.ApiSpec.CastAndValidate
* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.

View file

@ -0,0 +1,31 @@
# Optimizing your PostgreSQL performance
Pleroma performance depends to a large extent on good database performance. The default PostgreSQL settings are mostly fine, but often you can get better performance by changing a few settings.
You can use [PGTune](https://pgtune.leopard.in.ua) to get recommendations for your setup. If you do, set the "Number of Connections" field to 20, as Pleroma will only use 10 concurrent connections anyway. If you don't, it will give you advice that might even hurt your performance.
We also recommend not using the "Network Storage" option.
## Example configurations
Here are some configuration suggestions for PostgreSQL 10+.
### 1GB RAM, 1 CPU
```
shared_buffers = 256MB
effective_cache_size = 768MB
maintenance_work_mem = 64MB
work_mem = 13107kB
```
### 2GB RAM, 2 CPU
```
shared_buffers = 512MB
effective_cache_size = 1536MB
maintenance_work_mem = 128MB
work_mem = 26214kB
max_worker_processes = 2
max_parallel_workers_per_gather = 1
max_parallel_workers = 2
```

View file

@ -0,0 +1,38 @@
# Storing Remote Media
Pleroma does not store remote/federated media by default. The best way to achieve this is to change Nginx to keep its reverse proxy cache
for a year and to activate the `MediaProxyWarmingPolicy` MRF policy in Pleroma which will automatically fetch all media through the proxy
as soon as the post is received by your instance.
## Nginx
```
proxy_cache_path /long/term/storage/path/pleroma-media-cache levels=1:2
keys_zone=pleroma_media_cache:10m inactive=1y use_temp_path=off;
location ~ ^/(media|proxy) {
proxy_cache pleroma_media_cache;
slice 1m;
proxy_cache_key $host$uri$is_args$args$slice_range;
proxy_set_header Range $slice_range;
proxy_http_version 1.1;
proxy_cache_valid 206 301 302 304 1h;
proxy_cache_valid 200 1y;
proxy_cache_use_stale error timeout invalid_header updating;
proxy_ignore_client_abort on;
proxy_buffering on;
chunked_transfer_encoding on;
proxy_ignore_headers Cache-Control Expires;
proxy_hide_header Cache-Control Expires;
proxy_pass http://127.0.0.1:4000;
}
```
## Pleroma
Add to your `prod.secret.exs`:
```
config :pleroma, :instance,
rewrite_policy: [Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy]
```

View file

@ -38,8 +38,8 @@ sudo apt install git build-essential postgresql postgresql-contrib
* Download and add the Erlang repository: * Download and add the Erlang repository:
```shell ```shell
wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb
``` ```
* Install Elixir and Erlang: * Install Elixir and Erlang:

View file

@ -40,8 +40,8 @@ sudo apt install git build-essential postgresql postgresql-contrib
* Erlangのリポジトリをダウンロードおよびインストールします。 * Erlangのリポジトリをダウンロードおよびインストールします。
``` ```
wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_1.0_all.deb wget -P /tmp/ https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
sudo dpkg -i /tmp/erlang-solutions_1.0_all.deb sudo dpkg -i /tmp/erlang-solutions_2.0_all.deb
``` ```
* ElixirとErlangをインストールします、 * ElixirとErlangをインストールします、

View file

@ -63,7 +63,7 @@ apt install postgresql-11-rum
``` ```
#### (Optional) Performance configuration #### (Optional) Performance configuration
For optimal performance, you may use [PGTune](https://pgtune.leopard.in.ua), don't forget to restart postgresql after editing the configuration It is encouraged to check [Optimizing your PostgreSQL performance](../configuration/postgresql.md) document, for tips on PostgreSQL tuning.
```sh tab="Alpine" ```sh tab="Alpine"
rc-service postgresql restart rc-service postgresql restart

View file

@ -1,21 +1,45 @@
#!/sbin/openrc-run #!/sbin/openrc-run
supervisor=supervise-daemon
# Requires OpenRC >= 0.35
directory=/opt/pleroma
command=/usr/bin/mix
command_args="phx.server"
command_user=pleroma:pleroma command_user=pleroma:pleroma
command_background=1 command_background=1
export PORT=4000
export MIX_ENV=prod
# Ask process to terminate within 30 seconds, otherwise kill it # Ask process to terminate within 30 seconds, otherwise kill it
retry="SIGTERM/30/SIGKILL/5" retry="SIGTERM/30/SIGKILL/5"
pidfile="/var/run/pleroma.pid" pidfile="/var/run/pleroma.pid"
directory=/opt/pleroma
healthcheck_delay=60
healthcheck_timer=30
: ${pleroma_port:-4000}
# Needs OpenRC >= 0.42
#respawn_max=0
#respawn_delay=5
# put pleroma_console=YES in /etc/conf.d/pleroma if you want to be able to
# connect to pleroma via an elixir console
if yesno "${pleroma_console}"; then
command=elixir
command_args="--name pleroma@127.0.0.1 --erl '-kernel inet_dist_listen_min 9001 inet_dist_listen_max 9001 inet_dist_use_interface {127,0,0,1}' -S mix phx.server"
start_post() {
einfo "You can get a console by using this command as pleroma's user:"
einfo "iex --name console@127.0.0.1 --remsh pleroma@127.0.0.1"
}
else
command=/usr/bin/mix
command_args="phx.server"
fi
export MIX_ENV=prod
depend() { depend() {
need nginx postgresql need nginx postgresql
}
healthcheck() {
# put pleroma_health=YES in /etc/conf.d/pleroma if you want healthchecking
# and make sure you have curl installed
yesno "$pleroma_health" || return 0
curl -q "localhost:${pleroma_port}/api/pleroma/healthcheck"
} }

View file

@ -0,0 +1,40 @@
#!/bin/sh
# A simple shell script to delete a media from the Nginx cache.
SCRIPTNAME=${0##*/}
# NGINX cache directory
CACHE_DIRECTORY="/tmp/pleroma-media-cache"
## Return the files where the items are cached.
## $1 - the filename, can be a pattern .
## $2 - the cache directory.
## $3 - (optional) the number of parallel processes to run for grep.
get_cache_files() {
local max_parallel=${3-16}
find $2 -maxdepth 2 -type d | xargs -P $max_parallel -n 1 grep -E Rl "^KEY:.*$1" | sort -u
}
## Removes an item from the given cache zone.
## $1 - the filename, can be a pattern .
## $2 - the cache directory.
purge_item() {
for f in $(get_cache_files $1 $2); do
echo "found file: $f"
[ -f $f ] || continue
echo "Deleting $f from $2."
rm $f
done
} # purge_item
purge() {
for url in "$@"
do
echo "$SCRIPTNAME delete \`$url\` from cache ($CACHE_DIRECTORY)"
purge_item $url $CACHE_DIRECTORY
done
}
purge $1

View file

@ -32,9 +32,8 @@ CustomLog ${APACHE_LOG_DIR}/access.log combined
<VirtualHost *:443> <VirtualHost *:443>
SSLEngine on SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/${servername}/cert.pem SSLCertificateFile /etc/letsencrypt/live/${servername}/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem SSLCertificateKeyFile /etc/letsencrypt/live/${servername}/privkey.pem
SSLCertificateChainFile /etc/letsencrypt/live/${servername}/fullchain.pem
# Mozilla modern configuration, tweak to your needs # Mozilla modern configuration, tweak to your needs
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1

View file

@ -67,8 +67,7 @@ def run(["render_timeline", nickname | _] = args) do
Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{ Pleroma.Web.MastodonAPI.StatusView.render("index.json", %{
activities: activities, activities: activities,
for: user, for: user,
as: :activity, as: :activity
skip_relationships: true
}) })
end end
}, },

View file

@ -4,6 +4,7 @@
defmodule Mix.Tasks.Pleroma.Database do defmodule Mix.Tasks.Pleroma.Database do
alias Pleroma.Conversation alias Pleroma.Conversation
alias Pleroma.Maintenance
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
@ -34,13 +35,7 @@ def run(["remove_embedded_objects" | args]) do
) )
if Keyword.get(options, :vacuum) do if Keyword.get(options, :vacuum) do
Logger.info("Runnning VACUUM FULL") Maintenance.vacuum("full")
Repo.query!(
"vacuum full;",
[],
timeout: :infinity
)
end end
end end
@ -94,13 +89,7 @@ def run(["prune_objects" | args]) do
|> Repo.delete_all(timeout: :infinity) |> Repo.delete_all(timeout: :infinity)
if Keyword.get(options, :vacuum) do if Keyword.get(options, :vacuum) do
Logger.info("Runnning VACUUM FULL") Maintenance.vacuum("full")
Repo.query!(
"vacuum full;",
[],
timeout: :infinity
)
end end
end end
@ -135,4 +124,10 @@ def run(["fix_likes_collections"]) do
end) end)
|> Stream.run() |> Stream.run()
end end
def run(["vacuum", args]) do
start_pleroma()
Maintenance.vacuum(args)
end
end end

View file

@ -1,5 +1,6 @@
defmodule Mix.Tasks.Pleroma.Digest do defmodule Mix.Tasks.Pleroma.Digest do
use Mix.Task use Mix.Task
import Mix.Pleroma
@shortdoc "Manages digest emails" @shortdoc "Manages digest emails"
@moduledoc File.read!("docs/administration/CLI_tasks/digest.md") @moduledoc File.read!("docs/administration/CLI_tasks/digest.md")
@ -22,12 +23,10 @@ def run(["test", nickname | opts]) do
with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do with %Swoosh.Email{} = email <- Pleroma.Emails.UserEmail.digest_email(patched_user) do
{:ok, _} = Pleroma.Emails.Mailer.deliver(email) {:ok, _} = Pleroma.Emails.Mailer.deliver(email)
Mix.shell().info("Digest email have been sent to #{nickname} (#{user.email})") shell_info("Digest email have been sent to #{nickname} (#{user.email})")
else else
_ -> _ ->
Mix.shell().info( shell_info("Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}")
"Cound't find any mentions for #{nickname} since #{last_digest_emailed_at}"
)
end end
end end
end end

View file

@ -15,7 +15,7 @@ def run(["ls-packs" | args]) do
{options, [], []} = parse_global_opts(args) {options, [], []} = parse_global_opts(args)
url_or_path = options[:manifest] || default_manifest() url_or_path = options[:manifest] || default_manifest()
manifest = fetch_manifest(url_or_path) manifest = fetch_and_decode(url_or_path)
Enum.each(manifest, fn {name, info} -> Enum.each(manifest, fn {name, info} ->
to_print = [ to_print = [
@ -42,12 +42,12 @@ def run(["get-packs" | args]) do
url_or_path = options[:manifest] || default_manifest() url_or_path = options[:manifest] || default_manifest()
manifest = fetch_manifest(url_or_path) manifest = fetch_and_decode(url_or_path)
for pack_name <- pack_names do for pack_name <- pack_names do
if Map.has_key?(manifest, pack_name) do if Map.has_key?(manifest, pack_name) do
pack = manifest[pack_name] pack = manifest[pack_name]
src_url = pack["src"] src = pack["src"]
IO.puts( IO.puts(
IO.ANSI.format([ IO.ANSI.format([
@ -57,11 +57,11 @@ def run(["get-packs" | args]) do
:normal, :normal,
" from ", " from ",
:underline, :underline,
src_url src
]) ])
) )
binary_archive = Tesla.get!(client(), src_url).body {:ok, binary_archive} = fetch(src)
archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16() archive_sha = :crypto.hash(:sha256, binary_archive) |> Base.encode16()
sha_status_text = ["SHA256 of ", :bright, pack_name, :normal, " source file is ", :bright] sha_status_text = ["SHA256 of ", :bright, pack_name, :normal, " source file is ", :bright]
@ -74,8 +74,8 @@ def run(["get-packs" | args]) do
raise "Bad SHA256 for #{pack_name}" raise "Bad SHA256 for #{pack_name}"
end end
# The url specified in files should be in the same directory # The location specified in files should be in the same directory
files_url = files_loc =
url_or_path url_or_path
|> Path.dirname() |> Path.dirname()
|> Path.join(pack["files"]) |> Path.join(pack["files"])
@ -88,11 +88,11 @@ def run(["get-packs" | args]) do
:normal, :normal,
" from ", " from ",
:underline, :underline,
files_url files_loc
]) ])
) )
files = Tesla.get!(client(), files_url).body |> Jason.decode!() files = fetch_and_decode(files_loc)
IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name])) IO.puts(IO.ANSI.format(["Unpacking ", :bright, pack_name]))
@ -237,16 +237,20 @@ def run(["gen-pack" | args]) do
end end
end end
defp fetch_manifest(from) do defp fetch_and_decode(from) do
Jason.decode!( with {:ok, json} <- fetch(from) do
if String.starts_with?(from, "http") do Jason.decode!(json)
Tesla.get!(client(), from).body end
else
File.read!(from)
end
)
end end
defp fetch("http" <> _ = from) do
with {:ok, %{body: body}} <- Tesla.get(client(), from) do
{:ok, body}
end
end
defp fetch(path), do: File.read(path)
defp parse_global_opts(args) do defp parse_global_opts(args) do
OptionParser.parse( OptionParser.parse(
args, args,

View file

@ -147,6 +147,7 @@ def run(["gen" | rest]) do
"What directory should media uploads go in (when using the local uploader)?", "What directory should media uploads go in (when using the local uploader)?",
Pleroma.Config.get([Pleroma.Uploaders.Local, :uploads]) Pleroma.Config.get([Pleroma.Uploaders.Local, :uploads])
) )
|> Path.expand()
static_dir = static_dir =
get_option( get_option(
@ -155,6 +156,7 @@ def run(["gen" | rest]) do
"What directory should custom public files be read from (custom emojis, frontend bundle overrides, robots.txt, etc.)?", "What directory should custom public files be read from (custom emojis, frontend bundle overrides, robots.txt, etc.)?",
Pleroma.Config.get([:instance, :static_dir]) Pleroma.Config.get([:instance, :static_dir])
) )
|> Path.expand()
Config.put([:instance, :static_dir], static_dir) Config.put([:instance, :static_dir], static_dir)
@ -204,7 +206,7 @@ def run(["gen" | rest]) do
shell_info("Writing the postgres script to #{psql_path}.") shell_info("Writing the postgres script to #{psql_path}.")
File.write(psql_path, result_psql) File.write(psql_path, result_psql)
write_robots_txt(indexable, template_dir) write_robots_txt(static_dir, indexable, template_dir)
shell_info( shell_info(
"\n All files successfully written! Refer to the installation instructions for your platform for next steps." "\n All files successfully written! Refer to the installation instructions for your platform for next steps."
@ -224,15 +226,13 @@ def run(["gen" | rest]) do
end end
end end
defp write_robots_txt(indexable, template_dir) do defp write_robots_txt(static_dir, indexable, template_dir) do
robots_txt = robots_txt =
EEx.eval_file( EEx.eval_file(
template_dir <> "/robots_txt.eex", template_dir <> "/robots_txt.eex",
indexable: indexable indexable: indexable
) )
static_dir = Pleroma.Config.get([:instance, :static_dir], "instance/static/")
unless File.exists?(static_dir) do unless File.exists?(static_dir) do
File.mkdir_p!(static_dir) File.mkdir_p!(static_dir)
end end

View file

@ -8,6 +8,8 @@ defmodule Mix.Tasks.Pleroma.User do
alias Ecto.Changeset alias Ecto.Changeset
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
@shortdoc "Manages Pleroma users" @shortdoc "Manages Pleroma users"
@moduledoc File.read!("docs/administration/CLI_tasks/user.md") @moduledoc File.read!("docs/administration/CLI_tasks/user.md")
@ -96,8 +98,9 @@ def run(["new", nickname, email | rest]) do
def run(["rm", nickname]) do def run(["rm", nickname]) do
start_pleroma() start_pleroma()
with %User{local: true} = user <- User.get_cached_by_nickname(nickname) do with %User{local: true} = user <- User.get_cached_by_nickname(nickname),
User.perform(:delete, user) {:ok, delete_data, _} <- Builder.delete(user, user.ap_id),
{:ok, _delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
shell_info("User #{nickname} deleted.") shell_info("User #{nickname} deleted.")
else else
_ -> shell_error("No local user #{nickname}") _ -> shell_error("No local user #{nickname}")
@ -141,28 +144,18 @@ def run(["reset_password", nickname]) do
end end
end end
def run(["unsubscribe", nickname]) do def run(["deactivate", nickname]) do
start_pleroma() start_pleroma()
with %User{} = user <- User.get_cached_by_nickname(nickname) do with %User{} = user <- User.get_cached_by_nickname(nickname) do
shell_info("Deactivating #{user.nickname}") shell_info("Deactivating #{user.nickname}")
User.deactivate(user) User.deactivate(user)
user
|> User.get_friends()
|> Enum.each(fn friend ->
user = User.get_cached_by_id(user.id)
shell_info("Unsubscribing #{friend.nickname} from #{user.nickname}")
User.unfollow(user, friend)
end)
:timer.sleep(500) :timer.sleep(500)
user = User.get_cached_by_id(user.id) user = User.get_cached_by_id(user.id)
if Enum.empty?(User.get_friends(user)) do if Enum.empty?(Enum.filter(User.get_friends(user), & &1.local)) do
shell_info("Successfully unsubscribed all followers from #{user.nickname}") shell_info("Successfully unsubscribed all local followers from #{user.nickname}")
end end
else else
_ -> _ ->
@ -170,7 +163,7 @@ def run(["unsubscribe", nickname]) do
end end
end end
def run(["unsubscribe_all_from_instance", instance]) do def run(["deactivate_all_from_instance", instance]) do
start_pleroma() start_pleroma()
Pleroma.User.Query.build(%{nickname: "@#{instance}"}) Pleroma.User.Query.build(%{nickname: "@#{instance}"})
@ -178,7 +171,7 @@ def run(["unsubscribe_all_from_instance", instance]) do
|> Stream.each(fn users -> |> Stream.each(fn users ->
users users
|> Enum.each(fn user -> |> Enum.each(fn user ->
run(["unsubscribe", user.nickname]) run(["deactivate", user.nickname])
end) end)
end) end)
|> Stream.run() |> Stream.run()

View file

@ -24,10 +24,7 @@ def by_ap_id(query \\ Activity, ap_id) do
@spec by_actor(query, String.t()) :: query @spec by_actor(query, String.t()) :: query
def by_actor(query \\ Activity, actor) do def by_actor(query \\ Activity, actor) do
from( from(a in query, where: a.actor == ^actor)
activity in query,
where: fragment("(?)->>'actor' = ?", activity.data, ^actor)
)
end end
@spec by_author(query, User.t()) :: query @spec by_author(query, User.t()) :: query

View file

@ -56,7 +56,7 @@ def start(_type, _args) do
if (major == 22 and minor < 2) or major < 22 do if (major == 22 and minor < 2) or major < 22 do
raise " raise "
!!!OTP VERSION WARNING!!! !!!OTP VERSION WARNING!!!
You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. You are using gun adapter with OTP version #{version}, which doesn't support correct handling of unordered certificates chains. Please update your Erlang/OTP to at least 22.2.
" "
end end
else else
@ -173,7 +173,14 @@ defp chat_enabled?, do: Config.get([:chat, :enabled])
defp streamer_child(env) when env in [:test, :benchmark], do: [] defp streamer_child(env) when env in [:test, :benchmark], do: []
defp streamer_child(_) do defp streamer_child(_) do
[Pleroma.Web.Streamer.supervisor()] [
{Registry,
[
name: Pleroma.Web.Streamer.registry(),
keys: :duplicate,
partitions: System.schedulers_online()
]}
]
end end
defp chat_child(_env, true) do defp chat_child(_env, true) do

View file

@ -4,7 +4,7 @@
defmodule Pleroma.BBS.Authenticator do defmodule Pleroma.BBS.Authenticator do
use Sshd.PasswordAuthenticator use Sshd.PasswordAuthenticator
alias Comeonin.Pbkdf2 alias Pleroma.Plugs.AuthenticationPlug
alias Pleroma.User alias Pleroma.User
def authenticate(username, password) do def authenticate(username, password) do
@ -12,7 +12,7 @@ def authenticate(username, password) do
password = to_string(password) password = to_string(password)
with %User{} = user <- User.get_by_nickname(username) do with %User{} = user <- User.get_by_nickname(username) do
Pbkdf2.checkpw(password, user.password_hash) AuthenticationPlug.checkpw(password, user.password_hash)
else else
_e -> false _e -> false
end end

View file

@ -66,7 +66,7 @@ def handle_command(%{user: user} = state, "r " <> text) do
with %Activity{} <- Activity.get_by_id(activity_id), with %Activity{} <- Activity.get_by_id(activity_id),
{:ok, _activity} <- {:ok, _activity} <-
CommonAPI.post(user, %{"status" => rest, "in_reply_to_status_id" => activity_id}) do CommonAPI.post(user, %{status: rest, in_reply_to_status_id: activity_id}) do
IO.puts("Replied!") IO.puts("Replied!")
else else
_e -> IO.puts("Could not reply...") _e -> IO.puts("Could not reply...")
@ -78,7 +78,7 @@ def handle_command(%{user: user} = state, "r " <> text) do
def handle_command(%{user: user} = state, "p " <> text) do def handle_command(%{user: user} = state, "p " <> text) do
text = String.trim(text) text = String.trim(text)
with {:ok, _activity} <- CommonAPI.post(user, %{"status" => text}) do with {:ok, _activity} <- CommonAPI.post(user, %{status: text}) do
IO.puts("Posted!") IO.puts("Posted!")
else else
_e -> IO.puts("Could not post...") _e -> IO.puts("Could not post...")

View file

@ -278,6 +278,8 @@ defp do_convert({:proxy_url, {type, host, port}}) do
} }
end end
defp do_convert({:partial_chain, entity}), do: %{"tuple" => [":partial_chain", inspect(entity)]}
defp do_convert(entity) when is_tuple(entity) do defp do_convert(entity) when is_tuple(entity) do
value = value =
entity entity
@ -321,6 +323,15 @@ defp do_transform(%{"tuple" => [":proxy_url", %{"tuple" => [type, host, port]}]}
{:proxy_url, {do_transform_string(type), parse_host(host), port}} {:proxy_url, {do_transform_string(type), parse_host(host), port}}
end end
defp do_transform(%{"tuple" => [":partial_chain", entity]}) do
{partial_chain, []} =
entity
|> String.replace(~r/[^\w|^{:,[|^,|^[|^\]^}|^\/|^\.|^"]^\s/, "")
|> Code.eval_string()
{:partial_chain, partial_chain}
end
defp do_transform(%{"tuple" => entity}) do defp do_transform(%{"tuple" => entity}) do
Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end) Enum.reduce(entity, {}, fn val, acc -> Tuple.append(acc, do_transform(val)) end)
end end

View file

@ -17,7 +17,8 @@ defmodule Pleroma.Constants do
"announcement_count", "announcement_count",
"emoji", "emoji",
"context_id", "context_id",
"deleted_activity_id" "deleted_activity_id",
"pleroma_internal"
] ]
) )

View file

@ -63,7 +63,7 @@ def create_or_bump_for(activity, opts \\ []) do
ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do ap_id when is_binary(ap_id) and byte_size(ap_id) > 0 <- object.data["context"] do
{:ok, conversation} = create_for_ap_id(ap_id) {:ok, conversation} = create_for_ap_id(ap_id)
users = User.get_users_from_set(activity.recipients, false) users = User.get_users_from_set(activity.recipients, local_only: false)
participations = participations =
Enum.map(users, fn user -> Enum.map(users, fn user ->

View file

@ -128,7 +128,7 @@ def for_user(user, params \\ %{}) do
|> Pleroma.Pagination.fetch_paginated(params) |> Pleroma.Pagination.fetch_paginated(params)
end end
def restrict_recipients(query, user, %{"recipients" => user_ids}) do def restrict_recipients(query, user, %{recipients: user_ids}) do
user_binary_ids = user_binary_ids =
[user.id | user_ids] [user.id | user_ids]
|> Enum.uniq() |> Enum.uniq()
@ -172,7 +172,7 @@ def for_user_with_last_activity_id(user, params \\ %{}) do
| last_activity_id: activity_id | last_activity_id: activity_id
} }
end) end)
|> Enum.filter(& &1.last_activity_id) |> Enum.reject(&is_nil(&1.last_activity_id))
end end
def get(_, _ \\ []) def get(_, _ \\ [])

View file

@ -18,7 +18,6 @@ def compile do
with config <- Pleroma.Config.Loader.read("config/description.exs") do with config <- Pleroma.Config.Loader.read("config/description.exs") do
config[:pleroma][:config_description] config[:pleroma][:config_description]
|> Pleroma.Docs.Generator.convert_to_strings() |> Pleroma.Docs.Generator.convert_to_strings()
|> Jason.encode!()
end end
end end
end end

View file

@ -14,8 +14,10 @@ def new_users(to, users_and_statuses) do
styling = Pleroma.Config.get([Pleroma.Emails.UserEmail, :styling]) styling = Pleroma.Config.get([Pleroma.Emails.UserEmail, :styling])
logo_url = logo_url =
Pleroma.Web.Endpoint.url() <> Pleroma.Helpers.UriHelper.maybe_add_base(
Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]) Pleroma.Config.get([:frontend_configurations, :pleroma_fe, :logo]),
Pleroma.Web.Endpoint.url()
)
new() new()
|> to({to.name, to.email}) |> to({to.name, to.email})

View file

@ -16,162 +16,78 @@ defmodule Pleroma.Emoji.Pack do
alias Pleroma.Emoji alias Pleroma.Emoji
@spec emoji_path() :: Path.t()
def emoji_path do
static = Pleroma.Config.get!([:instance, :static_dir])
Path.join(static, "emoji")
end
@spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values} @spec create(String.t()) :: :ok | {:error, File.posix()} | {:error, :empty_values}
def create(name) when byte_size(name) > 0 do def create(name) do
dir = Path.join(emoji_path(), name) with :ok <- validate_not_empty([name]),
dir <- Path.join(emoji_path(), name),
with :ok <- File.mkdir(dir) do :ok <- File.mkdir(dir) do
%__MODULE__{ %__MODULE__{pack_file: Path.join(dir, "pack.json")}
pack_file: Path.join(dir, "pack.json")
}
|> save_pack() |> save_pack()
end end
end end
def create(_), do: {:error, :empty_values} @spec show(String.t()) :: {:ok, t()} | {:error, atom()}
def show(name) do
@spec show(String.t()) :: {:ok, t()} | {:loaded, nil} | {:error, :empty_values} with :ok <- validate_not_empty([name]),
def show(name) when byte_size(name) > 0 do {:ok, pack} <- load_pack(name) do
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, {:ok, validate_pack(pack)}
{_, pack} <- validate_pack(pack) do
{:ok, pack}
end end
end end
def show(_), do: {:error, :empty_values}
@spec delete(String.t()) :: @spec delete(String.t()) ::
{:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values} {:ok, [binary()]} | {:error, File.posix(), binary()} | {:error, :empty_values}
def delete(name) when byte_size(name) > 0 do def delete(name) do
emoji_path() with :ok <- validate_not_empty([name]) do
|> Path.join(name) emoji_path()
|> File.rm_rf() |> Path.join(name)
end |> File.rm_rf()
def delete(_), do: {:error, :empty_values}
@spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values}
def add_file(name, shortcode, filename, file)
when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(filename) > 0 do
with {_, nil} <- {:exists, Emoji.get(shortcode)},
{_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)} do
file_path = Path.join(pack.path, filename)
create_subdirs(file_path)
case file do
%Plug.Upload{path: upload_path} ->
# Copy the uploaded file from the temporary directory
File.copy!(upload_path, file_path)
url when is_binary(url) ->
# Download and write the file
file_contents = Tesla.get!(url).body
File.write!(file_path, file_contents)
end
files = Map.put(pack.files, shortcode, filename)
updated_pack = %{pack | files: files}
case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}
e ->
e
end
end end
end end
def add_file(_, _, _, _), do: {:error, :empty_values} @spec add_file(String.t(), String.t(), Path.t(), Plug.Upload.t() | String.t()) ::
{:ok, t()} | {:error, File.posix() | atom()}
defp create_subdirs(file_path) do def add_file(name, shortcode, filename, file) do
if String.contains?(file_path, "/") do with :ok <- validate_not_empty([name, shortcode, filename]),
file_path :ok <- validate_emoji_not_exists(shortcode),
|> Path.dirname() {:ok, pack} <- load_pack(name),
|> File.mkdir_p!() :ok <- save_file(file, pack, filename),
{:ok, updated_pack} <- pack |> put_emoji(shortcode, filename) |> save_pack() do
Emoji.reload()
{:ok, updated_pack}
end end
end end
@spec delete_file(String.t(), String.t()) :: @spec delete_file(String.t(), String.t()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values} {:ok, t()} | {:error, File.posix() | atom()}
def delete_file(name, shortcode) when byte_size(name) > 0 and byte_size(shortcode) > 0 do def delete_file(name, shortcode) do
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, with :ok <- validate_not_empty([name, shortcode]),
{_, {filename, files}} when not is_nil(filename) <- {:ok, pack} <- load_pack(name),
{:exists, Map.pop(pack.files, shortcode)}, :ok <- remove_file(pack, shortcode),
emoji <- Path.join(pack.path, filename), {:ok, updated_pack} <- pack |> delete_emoji(shortcode) |> save_pack() do
{_, true} <- {:exists, File.exists?(emoji)} do Emoji.reload()
emoji_dir = Path.dirname(emoji) {:ok, updated_pack}
File.rm!(emoji)
if String.contains?(filename, "/") and File.ls!(emoji_dir) == [] do
File.rmdir!(emoji_dir)
end
updated_pack = %{pack | files: files}
case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}
e ->
e
end
end end
end end
def delete_file(_, _), do: {:error, :empty_values}
@spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) :: @spec update_file(String.t(), String.t(), String.t(), String.t(), boolean()) ::
{:ok, t()} | {:error, File.posix()} | {:error, :empty_values} {:ok, t()} | {:error, File.posix() | atom()}
def update_file(name, shortcode, new_shortcode, new_filename, force) def update_file(name, shortcode, new_shortcode, new_filename, force) do
when byte_size(name) > 0 and byte_size(shortcode) > 0 and byte_size(new_shortcode) > 0 and with :ok <- validate_not_empty([name, shortcode, new_shortcode, new_filename]),
byte_size(new_filename) > 0 do {:ok, pack} <- load_pack(name),
with {_, %__MODULE__{} = pack} <- {:loaded, load_pack(name)}, {:ok, filename} <- get_filename(pack, shortcode),
{_, {filename, files}} when not is_nil(filename) <- :ok <- validate_emoji_not_exists(new_shortcode, force),
{:exists, Map.pop(pack.files, shortcode)}, :ok <- rename_file(pack, filename, new_filename),
{_, true} <- {:not_used, force or is_nil(Emoji.get(new_shortcode))} do {:ok, updated_pack} <-
old_path = Path.join(pack.path, filename) pack
old_dir = Path.dirname(old_path) |> delete_emoji(shortcode)
new_path = Path.join(pack.path, new_filename) |> put_emoji(new_shortcode, new_filename)
|> save_pack() do
create_subdirs(new_path) Emoji.reload()
{:ok, updated_pack}
:ok = File.rename(old_path, new_path)
if String.contains?(filename, "/") and File.ls!(old_dir) == [] do
File.rmdir!(old_dir)
end
files = Map.put(files, new_shortcode, new_filename)
updated_pack = %{pack | files: files}
case save_pack(updated_pack) do
:ok ->
Emoji.reload()
{:ok, updated_pack}
e ->
e
end
end end
end end
def update_file(_, _, _, _, _), do: {:error, :empty_values} @spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, File.posix() | atom()}
@spec import_from_filesystem() :: {:ok, [String.t()]} | {:error, atom()}
def import_from_filesystem do def import_from_filesystem do
emoji_path = emoji_path() emoji_path = emoji_path()
@ -184,7 +100,7 @@ def import_from_filesystem do
File.dir?(path) and File.exists?(Path.join(path, "pack.json")) File.dir?(path) and File.exists?(Path.join(path, "pack.json"))
end) end)
|> Enum.map(&write_pack_contents/1) |> Enum.map(&write_pack_contents/1)
|> Enum.filter(& &1) |> Enum.reject(&is_nil/1)
{:ok, names} {:ok, names}
else else
@ -193,6 +109,117 @@ def import_from_filesystem do
end end
end end
@spec list_remote(String.t()) :: {:ok, map()} | {:error, atom()}
def list_remote(url) do
uri = url |> String.trim() |> URI.parse()
with :ok <- validate_shareable_packs_available(uri) do
uri
|> URI.merge("/api/pleroma/emoji/packs")
|> http_get()
end
end
@spec list_local() :: {:ok, map()}
def list_local do
with {:ok, results} <- list_packs_dir() do
packs =
results
|> Enum.map(fn name ->
case load_pack(name) do
{:ok, pack} -> pack
_ -> nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new(fn pack -> {pack.name, validate_pack(pack)} end)
{:ok, packs}
end
end
@spec get_archive(String.t()) :: {:ok, binary()} | {:error, atom()}
def get_archive(name) do
with {:ok, pack} <- load_pack(name),
:ok <- validate_downloadable(pack) do
{:ok, fetch_archive(pack)}
end
end
@spec download(String.t(), String.t(), String.t()) :: :ok | {:error, atom()}
def download(name, url, as) do
uri = url |> String.trim() |> URI.parse()
with :ok <- validate_shareable_packs_available(uri),
{:ok, remote_pack} <- uri |> URI.merge("/api/pleroma/emoji/packs/#{name}") |> http_get(),
{:ok, %{sha: sha, url: url} = pack_info} <- fetch_pack_info(remote_pack, uri, name),
{:ok, archive} <- download_archive(url, sha),
pack <- copy_as(remote_pack, as || name),
{:ok, _} = unzip(archive, pack_info, remote_pack, pack) do
# Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
# in it to depend on itself
if pack_info[:fallback] do
save_pack(pack)
else
{:ok, pack}
end
end
end
@spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()}
def save_metadata(metadata, %__MODULE__{} = pack) do
pack
|> Map.put(:pack, metadata)
|> save_pack()
end
@spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()}
def update_metadata(name, data) do
with {:ok, pack} <- load_pack(name) do
if fallback_sha_changed?(pack, data) do
update_sha_and_save_metadata(pack, data)
else
save_metadata(data, pack)
end
end
end
@spec load_pack(String.t()) :: {:ok, t()} | {:error, :not_found}
def load_pack(name) do
pack_file = Path.join([emoji_path(), name, "pack.json"])
if File.exists?(pack_file) do
pack =
pack_file
|> File.read!()
|> from_json()
|> Map.put(:pack_file, pack_file)
|> Map.put(:path, Path.dirname(pack_file))
|> Map.put(:name, name)
{:ok, pack}
else
{:error, :not_found}
end
end
@spec emoji_path() :: Path.t()
defp emoji_path do
[:instance, :static_dir]
|> Pleroma.Config.get!()
|> Path.join("emoji")
end
defp validate_emoji_not_exists(shortcode, force \\ false)
defp validate_emoji_not_exists(_shortcode, true), do: :ok
defp validate_emoji_not_exists(shortcode, _) do
case Emoji.get(shortcode) do
nil -> :ok
_ -> {:error, :already_exists}
end
end
defp write_pack_contents(path) do defp write_pack_contents(path) do
pack = %__MODULE__{ pack = %__MODULE__{
files: files_from_path(path), files: files_from_path(path),
@ -201,7 +228,7 @@ defp write_pack_contents(path) do
} }
case save_pack(pack) do case save_pack(pack) do
:ok -> Path.basename(path) {:ok, _pack} -> Path.basename(path)
_ -> nil _ -> nil
end end
end end
@ -216,7 +243,8 @@ defp files_from_path(path) do
# FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2 # FIXME: Copy-pasted from Pleroma.Emoji/load_from_file_stream/2
# Create a map of shortcodes to filenames from emoji.txt # Create a map of shortcodes to filenames from emoji.txt
File.read!(txt_path) txt_path
|> File.read!()
|> String.split("\n") |> String.split("\n")
|> Enum.map(&String.trim/1) |> Enum.map(&String.trim/1)
|> Enum.map(fn line -> |> Enum.map(fn line ->
@ -226,21 +254,18 @@ defp files_from_path(path) do
[name, file | _] -> [name, file | _] ->
file_dir_name = Path.dirname(file) file_dir_name = Path.dirname(file)
file = if String.ends_with?(path, file_dir_name) do
if String.ends_with?(path, file_dir_name) do {name, Path.basename(file)}
Path.basename(file) else
else {name, file}
file end
end
{name, file}
_ -> _ ->
nil nil
end end
end) end)
|> Enum.filter(& &1) |> Enum.reject(&is_nil/1)
|> Enum.into(%{}) |> Map.new()
else else
# If there's no emoji.txt, assume all files # If there's no emoji.txt, assume all files
# that are of certain extensions from the config are emojis and import them all # that are of certain extensions from the config are emojis and import them all
@ -249,60 +274,20 @@ defp files_from_path(path) do
end end
end end
@spec list_remote(String.t()) :: {:ok, map()}
def list_remote(url) do
uri =
url
|> String.trim()
|> URI.parse()
with {_, true} <- {:shareable, shareable_packs_available?(uri)} do
packs =
uri
|> URI.merge("/api/pleroma/emoji/packs")
|> to_string()
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
{:ok, packs}
end
end
@spec list_local() :: {:ok, map()}
def list_local do
emoji_path = emoji_path()
# Create the directory first if it does not exist. This is probably the first request made
# with the API so it should be sufficient
with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)},
{:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do
packs =
results
|> Enum.map(&load_pack/1)
|> Enum.filter(& &1)
|> Enum.map(&validate_pack/1)
|> Map.new()
{:ok, packs}
end
end
defp validate_pack(pack) do defp validate_pack(pack) do
if downloadable?(pack) do info =
archive = fetch_archive(pack) if downloadable?(pack) do
archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16() archive = fetch_archive(pack)
archive_sha = :crypto.hash(:sha256, archive) |> Base.encode16()
info =
pack.pack pack.pack
|> Map.put("can-download", true) |> Map.put("can-download", true)
|> Map.put("download-sha256", archive_sha) |> Map.put("download-sha256", archive_sha)
else
Map.put(pack.pack, "can-download", false)
end
{pack.name, Map.put(pack, :pack, info)} Map.put(pack, :pack, info)
else
info = Map.put(pack.pack, "can-download", false)
{pack.name, Map.put(pack, :pack, info)}
end
end end
defp downloadable?(pack) do defp downloadable?(pack) do
@ -315,26 +300,6 @@ defp downloadable?(pack) do
end) end)
end end
@spec get_archive(String.t()) :: {:ok, binary()}
def get_archive(name) do
with {_, %__MODULE__{} = pack} <- {:exists?, load_pack(name)},
{_, true} <- {:can_download?, downloadable?(pack)} do
{:ok, fetch_archive(pack)}
end
end
defp fetch_archive(pack) do
hash = :crypto.hash(:md5, File.read!(pack.pack_file))
case Cachex.get!(:emoji_packs_cache, pack.name) do
%{hash: ^hash, pack_data: archive} ->
archive
_ ->
create_archive_and_cache(pack, hash)
end
end
defp create_archive_and_cache(pack, hash) do defp create_archive_and_cache(pack, hash) do
files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)] files = ['pack.json' | Enum.map(pack.files, fn {_, file} -> to_charlist(file) end)]
@ -356,152 +321,221 @@ defp create_archive_and_cache(pack, hash) do
result result
end end
@spec download(String.t(), String.t(), String.t()) :: :ok defp save_pack(pack) do
def download(name, url, as) do with {:ok, json} <- Jason.encode(pack, pretty: true),
uri = :ok <- File.write(pack.pack_file, json) do
url
|> String.trim()
|> URI.parse()
with {_, true} <- {:shareable, shareable_packs_available?(uri)} do
remote_pack =
uri
|> URI.merge("/api/pleroma/emoji/packs/#{name}")
|> to_string()
|> Tesla.get!()
|> Map.get(:body)
|> Jason.decode!()
result =
case remote_pack["pack"] do
%{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
{:ok,
%{
sha: sha,
url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string()
}}
%{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
{:ok,
%{
sha: sha,
url: src,
fallback: true
}}
_ ->
{:error,
"The pack was not set as shared and there is no fallback src to download from"}
end
with {:ok, %{sha: sha, url: url} = pinfo} <- result,
%{body: archive} <- Tesla.get!(url),
{_, true} <- {:checksum, Base.decode16!(sha) == :crypto.hash(:sha256, archive)} do
local_name = as || name
path = Path.join(emoji_path(), local_name)
pack = %__MODULE__{
name: local_name,
path: path,
files: remote_pack["files"],
pack_file: Path.join(path, "pack.json")
}
File.mkdir_p!(pack.path)
files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end)
# Fallback cannot contain a pack.json file
files = if pinfo[:fallback], do: files, else: ['pack.json' | files]
{:ok, _} = :zip.unzip(archive, cwd: to_charlist(pack.path), file_list: files)
# Fallback can't contain a pack.json file, since that would cause the fallback-src-sha256
# in it to depend on itself
if pinfo[:fallback] do
save_pack(pack)
end
:ok
end
end
end
defp save_pack(pack), do: File.write(pack.pack_file, Jason.encode!(pack, pretty: true))
@spec save_metadata(map(), t()) :: {:ok, t()} | {:error, File.posix()}
def save_metadata(metadata, %__MODULE__{} = pack) do
pack = Map.put(pack, :pack, metadata)
with :ok <- save_pack(pack) do
{:ok, pack} {:ok, pack}
end end
end end
@spec update_metadata(String.t(), map()) :: {:ok, t()} | {:error, File.posix()}
def update_metadata(name, data) do
pack = load_pack(name)
fb_sha_changed? =
not is_nil(data["fallback-src"]) and data["fallback-src"] != pack.pack["fallback-src"]
with {_, true} <- {:update?, fb_sha_changed?},
{:ok, %{body: zip}} <- Tesla.get(data["fallback-src"]),
{:ok, f_list} <- :zip.unzip(zip, [:memory]),
{_, true} <- {:has_all_files?, has_all_files?(pack.files, f_list)} do
fallback_sha = :crypto.hash(:sha256, zip) |> Base.encode16()
data
|> Map.put("fallback-src-sha256", fallback_sha)
|> save_metadata(pack)
else
{:update?, _} -> save_metadata(data, pack)
e -> e
end
end
# Check if all files from the pack.json are in the archive
defp has_all_files?(files, f_list) do
Enum.all?(files, fn {_, from_manifest} ->
List.keyfind(f_list, to_charlist(from_manifest), 0)
end)
end
@spec load_pack(String.t()) :: t() | nil
def load_pack(name) do
pack_file = Path.join([emoji_path(), name, "pack.json"])
if File.exists?(pack_file) do
pack_file
|> File.read!()
|> from_json()
|> Map.put(:pack_file, pack_file)
|> Map.put(:path, Path.dirname(pack_file))
|> Map.put(:name, name)
end
end
defp from_json(json) do defp from_json(json) do
map = Jason.decode!(json) map = Jason.decode!(json)
struct(__MODULE__, %{files: map["files"], pack: map["pack"]}) struct(__MODULE__, %{files: map["files"], pack: map["pack"]})
end end
defp shareable_packs_available?(uri) do defp validate_shareable_packs_available(uri) do
uri with {:ok, %{"links" => links}} <- uri |> URI.merge("/.well-known/nodeinfo") |> http_get(),
|> URI.merge("/.well-known/nodeinfo") # Get the actual nodeinfo address and fetch it
|> to_string() {:ok, %{"metadata" => %{"features" => features}}} <-
|> Tesla.get!() links |> List.last() |> Map.get("href") |> http_get() do
|> Map.get(:body) if Enum.member?(features, "shareable_emoji_packs") do
|> Jason.decode!() :ok
|> Map.get("links") else
|> List.last() {:error, :not_shareable}
|> Map.get("href") end
# Get the actual nodeinfo address and fetch it end
|> Tesla.get!() end
|> Map.get(:body)
|> Jason.decode!() defp validate_not_empty(list) do
|> get_in(["metadata", "features"]) if Enum.all?(list, fn i -> is_binary(i) and i != "" end) do
|> Enum.member?("shareable_emoji_packs") :ok
else
{:error, :empty_values}
end
end
defp save_file(file, pack, filename) do
file_path = Path.join(pack.path, filename)
create_subdirs(file_path)
case file do
%Plug.Upload{path: upload_path} ->
# Copy the uploaded file from the temporary directory
with {:ok, _} <- File.copy(upload_path, file_path), do: :ok
url when is_binary(url) ->
# Download and write the file
file_contents = Tesla.get!(url).body
File.write(file_path, file_contents)
end
end
defp put_emoji(pack, shortcode, filename) do
files = Map.put(pack.files, shortcode, filename)
%{pack | files: files}
end
defp delete_emoji(pack, shortcode) do
files = Map.delete(pack.files, shortcode)
%{pack | files: files}
end
defp rename_file(pack, filename, new_filename) do
old_path = Path.join(pack.path, filename)
new_path = Path.join(pack.path, new_filename)
create_subdirs(new_path)
with :ok <- File.rename(old_path, new_path) do
remove_dir_if_empty(old_path, filename)
end
end
defp create_subdirs(file_path) do
if String.contains?(file_path, "/") do
file_path
|> Path.dirname()
|> File.mkdir_p!()
end
end
defp remove_file(pack, shortcode) do
with {:ok, filename} <- get_filename(pack, shortcode),
emoji <- Path.join(pack.path, filename),
:ok <- File.rm(emoji) do
remove_dir_if_empty(emoji, filename)
end
end
defp remove_dir_if_empty(emoji, filename) do
dir = Path.dirname(emoji)
if String.contains?(filename, "/") and File.ls!(dir) == [] do
File.rmdir!(dir)
else
:ok
end
end
defp get_filename(pack, shortcode) do
with %{^shortcode => filename} when is_binary(filename) <- pack.files,
true <- pack.path |> Path.join(filename) |> File.exists?() do
{:ok, filename}
else
_ -> {:error, :doesnt_exist}
end
end
defp http_get(%URI{} = url), do: url |> to_string() |> http_get()
defp http_get(url) do
with {:ok, %{body: body}} <- url |> Pleroma.HTTP.get() do
Jason.decode(body)
end
end
defp list_packs_dir do
emoji_path = emoji_path()
# Create the directory first if it does not exist. This is probably the first request made
# with the API so it should be sufficient
with {:create_dir, :ok} <- {:create_dir, File.mkdir_p(emoji_path)},
{:ls, {:ok, results}} <- {:ls, File.ls(emoji_path)} do
{:ok, results}
else
{:create_dir, {:error, e}} -> {:error, :create_dir, e}
{:ls, {:error, e}} -> {:error, :ls, e}
end
end
defp validate_downloadable(pack) do
if downloadable?(pack), do: :ok, else: {:error, :cant_download}
end
defp copy_as(remote_pack, local_name) do
path = Path.join(emoji_path(), local_name)
%__MODULE__{
name: local_name,
path: path,
files: remote_pack["files"],
pack_file: Path.join(path, "pack.json")
}
end
defp unzip(archive, pack_info, remote_pack, local_pack) do
with :ok <- File.mkdir_p!(local_pack.path) do
files = Enum.map(remote_pack["files"], fn {_, path} -> to_charlist(path) end)
# Fallback cannot contain a pack.json file
files = if pack_info[:fallback], do: files, else: ['pack.json' | files]
:zip.unzip(archive, cwd: to_charlist(local_pack.path), file_list: files)
end
end
defp fetch_pack_info(remote_pack, uri, name) do
case remote_pack["pack"] do
%{"share-files" => true, "can-download" => true, "download-sha256" => sha} ->
{:ok,
%{
sha: sha,
url: URI.merge(uri, "/api/pleroma/emoji/packs/#{name}/archive") |> to_string()
}}
%{"fallback-src" => src, "fallback-src-sha256" => sha} when is_binary(src) ->
{:ok,
%{
sha: sha,
url: src,
fallback: true
}}
_ ->
{:error, "The pack was not set as shared and there is no fallback src to download from"}
end
end
defp download_archive(url, sha) do
with {:ok, %{body: archive}} <- Tesla.get(url) do
if Base.decode16!(sha) == :crypto.hash(:sha256, archive) do
{:ok, archive}
else
{:error, :invalid_checksum}
end
end
end
defp fetch_archive(pack) do
hash = :crypto.hash(:md5, File.read!(pack.pack_file))
case Cachex.get!(:emoji_packs_cache, pack.name) do
%{hash: ^hash, pack_data: archive} -> archive
_ -> create_archive_and_cache(pack, hash)
end
end
defp fallback_sha_changed?(pack, data) do
is_binary(data[:"fallback-src"]) and data[:"fallback-src"] != pack.pack["fallback-src"]
end
defp update_sha_and_save_metadata(pack, data) do
with {:ok, %{body: zip}} <- Tesla.get(data[:"fallback-src"]),
:ok <- validate_has_all_files(pack, zip) do
fallback_sha = :sha256 |> :crypto.hash(zip) |> Base.encode16()
data
|> Map.put("fallback-src-sha256", fallback_sha)
|> save_metadata(pack)
end
end
defp validate_has_all_files(pack, zip) do
with {:ok, f_list} <- :zip.unzip(zip, [:memory]) do
# Check if all files from the pack.json are in the archive
pack.files
|> Enum.all?(fn {_, from_manifest} ->
List.keyfind(f_list, to_charlist(from_manifest), 0)
end)
|> if(do: :ok, else: {:error, :incomplete})
end
end end
end end

View file

@ -89,11 +89,10 @@ def delete(%Pleroma.Filter{id: filter_key} = filter) when is_nil(filter_key) do
|> Repo.delete() |> Repo.delete()
end end
def update(%Pleroma.Filter{} = filter) do def update(%Pleroma.Filter{} = filter, params) do
destination = Map.from_struct(filter) filter
|> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word])
Pleroma.Filter.get(filter.filter_id, %{id: filter.user_id}) |> validate_required([:phrase, :context])
|> cast(destination, [:phrase, :context, :hide, :expires_at, :whole_word])
|> Repo.update() |> Repo.update()
end end
end end

View file

@ -29,7 +29,7 @@ defmodule Pleroma.Healthcheck do
@spec system_info() :: t() @spec system_info() :: t()
def system_info do def system_info do
%Healthcheck{ %Healthcheck{
memory_used: Float.round(:erlang.memory(:total) / 1024 / 1024, 2) memory_used: Float.round(:recon_alloc.memory(:allocated) / 1024 / 1024, 2)
} }
|> assign_db_info() |> assign_db_info()
|> assign_job_queue_stats() |> assign_job_queue_stats()

View file

@ -24,4 +24,7 @@ def append_param_if_present(%{} = params, param_name, param_value) do
params params
end end
end end
def maybe_add_base("/" <> uri, base), do: Path.join([base, uri])
def maybe_add_base(uri, _base), do: uri
end end

View file

@ -22,22 +22,7 @@ def options(connection_opts \\ [], %URI{} = uri) do
|> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy) |> Pleroma.HTTP.AdapterHelper.maybe_add_proxy(proxy)
end end
defp add_scheme_opts(opts, %URI{scheme: "http"}), do: opts defp add_scheme_opts(opts, _), do: opts
defp add_scheme_opts(opts, %URI{scheme: "https", host: host}) do
ssl_opts = [
ssl_options: [
# Workaround for remote server certificate chain issues
partial_chain: &:hackney_connect.partial_chain/1,
# We don't support TLS v1.3 yet
versions: [:tlsv1, :"tlsv1.1", :"tlsv1.2"],
server_name_indication: to_charlist(host)
]
]
Keyword.merge(opts, ssl_opts)
end
def after_request(_), do: :ok def after_request(_), do: :ok
end end

View file

@ -0,0 +1,37 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Maintenance do
alias Pleroma.Repo
require Logger
def vacuum(args) do
case args do
"analyze" ->
Logger.info("Runnning VACUUM ANALYZE.")
Repo.query!(
"vacuum analyze;",
[],
timeout: :infinity
)
"full" ->
Logger.info("Runnning VACUUM FULL.")
Logger.warn(
"Re-packing your entire database may take a while and will consume extra disk space during the process."
)
Repo.query!(
"vacuum full;",
[],
timeout: :infinity
)
_ ->
Logger.error("Error: invalid vacuum argument.")
end
end
end

View file

@ -9,24 +9,34 @@ defmodule Pleroma.Marker do
import Ecto.Query import Ecto.Query
alias Ecto.Multi alias Ecto.Multi
alias Pleroma.Notification
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias __MODULE__
@timelines ["notifications"] @timelines ["notifications"]
@type t :: %__MODULE__{}
schema "markers" do schema "markers" do
field(:last_read_id, :string, default: "") field(:last_read_id, :string, default: "")
field(:timeline, :string, default: "") field(:timeline, :string, default: "")
field(:lock_version, :integer, default: 0) field(:lock_version, :integer, default: 0)
field(:unread_count, :integer, default: 0, virtual: true)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
timestamps() timestamps()
end end
@doc "Gets markers by user and timeline."
@spec get_markers(User.t(), list(String)) :: list(t())
def get_markers(user, timelines \\ []) do def get_markers(user, timelines \\ []) do
Repo.all(get_query(user, timelines)) user
|> get_query(timelines)
|> unread_count_query()
|> Repo.all()
end end
@spec upsert(User.t(), map()) :: {:ok | :error, any()}
def upsert(%User{} = user, attrs) do def upsert(%User{} = user, attrs) do
attrs attrs
|> Map.take(@timelines) |> Map.take(@timelines)
@ -45,6 +55,27 @@ def upsert(%User{} = user, attrs) do
|> Repo.transaction() |> Repo.transaction()
end end
@spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t()
def multi_set_last_read_id(multi, %User{} = user, "notifications") do
multi
|> Multi.run(:counters, fn _repo, _changes ->
{:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}}
end)
|> Multi.insert(
:marker,
fn %{counters: attrs} ->
%Marker{timeline: "notifications", user_id: user.id}
|> struct(attrs)
|> Ecto.Changeset.change()
end,
returning: true,
on_conflict: {:replace, [:last_read_id]},
conflict_target: [:user_id, :timeline]
)
end
def multi_set_last_read_id(multi, _, _), do: multi
defp get_marker(user, timeline) do defp get_marker(user, timeline) do
case Repo.find_resource(get_query(user, timeline)) do case Repo.find_resource(get_query(user, timeline)) do
{:ok, marker} -> %__MODULE__{marker | user: user} {:ok, marker} -> %__MODULE__{marker | user: user}
@ -71,4 +102,16 @@ defp get_query(user, timelines) do
|> by_user_id(user.id) |> by_user_id(user.id)
|> by_timeline(timelines) |> by_timeline(timelines)
end end
defp unread_count_query(query) do
from(
q in query,
left_join: n in "notifications",
on: n.user_id == q.user_id and n.seen == false,
group_by: [:id],
select_merge: %{
unread_count: fragment("count(?)", n.id)
}
)
end
end end

155
lib/pleroma/mfa.ex Normal file
View file

@ -0,0 +1,155 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA do
@moduledoc """
The MFA context.
"""
alias Pleroma.User
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.Changeset
alias Pleroma.MFA.Settings
alias Pleroma.MFA.TOTP
@doc """
Returns MFA methods the user has enabled.
## Examples
iex> Pleroma.MFA.supported_method(User)
"totp, u2f"
"""
@spec supported_methods(User.t()) :: String.t()
def supported_methods(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.reduce([], fn m, acc ->
if method_enabled?(m, settings) do
acc ++ [m]
else
acc
end
end)
|> Enum.join(",")
end
@doc "Checks that user enabled MFA"
def require?(user) do
fetch_settings(user).enabled
end
@doc """
Display MFA settings of user
"""
def mfa_settings(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.map(fn m -> [m, method_enabled?(m, settings)] end)
|> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end)
end
@doc false
def fetch_settings(%User{} = user) do
user.multi_factor_authentication_settings || %Settings{}
end
@doc "clears backup codes"
def invalidate_backup_code(%User{} = user, hash_code) do
%{backup_codes: codes} = fetch_settings(user)
user
|> Changeset.cast_backup_codes(codes -- [hash_code])
|> User.update_and_set_cache()
end
@doc "generates backup codes"
@spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()}
def generate_backup_codes(%User{} = user) do
with codes <- BackupCodes.generate(),
hashed_codes <- Enum.map(codes, &Pbkdf2.hash_pwd_salt/1),
changeset <- Changeset.cast_backup_codes(user, hashed_codes),
{:ok, _} <- User.update_and_set_cache(changeset) do
{:ok, codes}
else
{:error, msg} ->
%{error: msg}
end
end
@doc """
Generates secret key and set delivery_type to 'app' for TOTP method.
"""
@spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def setup_totp(user) do
user
|> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"})
|> User.update_and_set_cache()
end
@doc """
Confirms the TOTP method for user.
`attrs`:
`password` - current user password
`code` - TOTP token
"""
@spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
def confirm_totp(%User{} = user, attrs) do
with settings <- user.multi_factor_authentication_settings.totp,
{:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do
user
|> Changeset.confirm_totp()
|> User.update_and_set_cache()
end
end
@doc """
Disables the TOTP method for user.
`attrs`:
`password` - current user password
"""
@spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable_totp(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable()
|> User.update_and_set_cache()
end
@doc """
Force disables all MFA methods for user.
"""
@spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable(true)
|> User.update_and_set_cache()
end
@doc """
Checks if the user has MFA method enabled.
"""
def method_enabled?(method, settings) do
with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do
true
else
_ -> false
end
end
@doc """
Checks if the user has enabled at least one MFA method.
"""
def enabled?(settings) do
Settings.mfa_methods()
|> Enum.map(fn m -> method_enabled?(m, settings) end)
|> Enum.any?()
end
end

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.BackupCodes do
@moduledoc """
This module contains functions for generating backup codes.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :backup_codes]
@doc """
Generates backup codes.
"""
@spec generate(Keyword.t()) :: list(String.t())
def generate(opts \\ []) do
number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number())
code_length = Keyword.get(opts, :length, default_backup_codes_code_length())
Enum.map(1..number_of_codes, fn _ ->
:crypto.strong_rand_bytes(div(code_length, 2))
|> Base.encode16(case: :lower)
end)
end
defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5)
defp default_backup_codes_code_length,
do: Config.get(@config_ns ++ [:length], 16)
end

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Changeset do
alias Pleroma.MFA
alias Pleroma.MFA.Settings
alias Pleroma.User
def disable(%Ecto.Changeset{} = changeset, force \\ false) do
settings =
changeset
|> Ecto.Changeset.apply_changes()
|> MFA.fetch_settings()
if force || not MFA.enabled?(settings) do
put_change(changeset, %Settings{settings | enabled: false})
else
changeset
end
end
def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
user
|> put_change(%Settings{settings | totp: %Settings.TOTP{}})
end
def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
user
|> put_change(%Settings{settings | totp: totp_settings, enabled: true})
end
def setup_totp(%User{} = user, attrs) do
mfa_settings = MFA.fetch_settings(user)
totp_settings =
%Settings.TOTP{}
|> Ecto.Changeset.cast(attrs, [:secret, :delivery_type])
user
|> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)})
end
def cast_backup_codes(%User{} = user, codes) do
user
|> put_change(%Settings{
user.multi_factor_authentication_settings
| backup_codes: codes
})
end
defp put_change(%User{} = user, settings) do
user
|> Ecto.Changeset.change()
|> put_change(settings)
end
defp put_change(%Ecto.Changeset{} = changeset, settings) do
changeset
|> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings)
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Settings do
use Ecto.Schema
@primary_key false
@mfa_methods [:totp]
embedded_schema do
field(:enabled, :boolean, default: false)
field(:backup_codes, {:array, :string}, default: [])
embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do
field(:secret, :string)
# app | sms
field(:delivery_type, :string, default: "app")
field(:confirmed, :boolean, default: false)
end
end
def mfa_methods, do: @mfa_methods
end

106
lib/pleroma/mfa/token.ex Normal file
View file

@ -0,0 +1,106 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Token do
use Ecto.Schema
import Ecto.Query
import Ecto.Changeset
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token, as: OAuthToken
@expires 300
schema "mfa_tokens" do
field(:token, :string)
field(:valid_until, :naive_datetime_usec)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:authorization, Authorization)
timestamps()
end
def get_by_token(token) do
from(
t in __MODULE__,
where: t.token == ^token,
preload: [:user, :authorization]
)
|> Repo.find_resource()
end
def validate(token) do
with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)},
{:expired, false} <- {:expired, is_expired?(token)} do
{:ok, token}
else
{:expired, _} -> {:error, :expired_token}
{:fetch_token, _} -> {:error, :not_found}
error -> {:error, error}
end
end
def create_token(%User{} = user) do
%__MODULE__{}
|> change
|> assign_user(user)
|> put_token
|> put_valid_until
|> Repo.insert()
end
def create_token(user, authorization) do
%__MODULE__{}
|> change
|> assign_user(user)
|> assign_authorization(authorization)
|> put_token
|> put_valid_until
|> Repo.insert()
end
defp assign_user(changeset, user) do
changeset
|> put_assoc(:user, user)
|> validate_required([:user])
end
defp assign_authorization(changeset, authorization) do
changeset
|> put_assoc(:authorization, authorization)
|> validate_required([:authorization])
end
defp put_token(changeset) do
changeset
|> change(%{token: OAuthToken.Utils.generate_token()})
|> validate_required([:token])
|> unique_constraint(:token)
end
defp put_valid_until(changeset) do
expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires)
changeset
|> change(%{valid_until: expires_in})
|> validate_required([:valid_until])
end
def is_expired?(%__MODULE__{valid_until: valid_until}) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
end
def is_expired?(_), do: false
def delete_expired_tokens do
from(
q in __MODULE__,
where: fragment("?", q.valid_until) < ^Timex.now()
)
|> Repo.delete_all()
end
end

86
lib/pleroma/mfa/totp.ex Normal file
View file

@ -0,0 +1,86 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.TOTP do
@moduledoc """
This module represents functions to create secrets for
TOTP Application as well as validate them with a time based token.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :totp]
@doc """
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
"""
def provisioning_uri(secret, label, opts \\ []) do
query =
%{
secret: secret,
issuer: Keyword.get(opts, :issuer, default_issuer()),
digits: Keyword.get(opts, :digits, default_digits()),
period: Keyword.get(opts, :period, default_period())
}
|> Enum.filter(fn {_, v} -> not is_nil(v) end)
|> Enum.into(%{})
|> URI.encode_query()
%URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query}
|> URI.to_string()
end
defp default_period, do: Config.get(@config_ns ++ [:period])
defp default_digits, do: Config.get(@config_ns ++ [:digits])
defp default_issuer,
do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name]))
@doc "Creates a random Base 32 encoded string"
def generate_secret do
Base.encode32(:crypto.strong_rand_bytes(10))
end
@doc "Generates a valid token based on a secret"
def generate_token(secret) do
:pot.totp(secret)
end
@doc """
Validates a given token based on a secret.
optional parameters:
`token_length` default `6`
`interval_length` default `30`
`window` default 0
Returns {:ok, :pass} if the token is valid and
{:error, :invalid_token} if it is not.
"""
@spec validate_token(String.t(), String.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token)
when is_binary(secret) and is_binary(token) do
opts = [
token_length: default_digits(),
interval_length: default_period()
]
validate_token(secret, token, opts)
end
def validate_token(_, _), do: {:error, :invalid_secret_and_token}
@doc "See `validate_token/2`"
@spec validate_token(String.t(), String.t(), Keyword.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token, options)
when is_binary(secret) and is_binary(token) do
case :pot.valid_totp(token, secret, options) do
true -> {:ok, :pass}
false -> {:error, :invalid_token}
end
end
def validate_token(_, _, _), do: {:error, :invalid_secret_and_token}
end

View file

@ -5,8 +5,10 @@
defmodule Pleroma.Notification do defmodule Pleroma.Notification do
use Ecto.Schema use Ecto.Schema
alias Ecto.Multi
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.FollowingRelationship alias Pleroma.FollowingRelationship
alias Pleroma.Marker
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Pagination alias Pleroma.Pagination
@ -34,11 +36,30 @@ defmodule Pleroma.Notification do
timestamps() timestamps()
end end
@spec unread_notifications_count(User.t()) :: integer()
def unread_notifications_count(%User{id: user_id}) do
from(q in __MODULE__,
where: q.user_id == ^user_id and q.seen == false
)
|> Repo.aggregate(:count, :id)
end
def changeset(%Notification{} = notification, attrs) do def changeset(%Notification{} = notification, attrs) do
notification notification
|> cast(attrs, [:seen]) |> cast(attrs, [:seen])
end end
@spec last_read_query(User.t()) :: Ecto.Queryable.t()
def last_read_query(user) do
from(q in Pleroma.Notification,
where: q.user_id == ^user.id,
where: q.seen == true,
select: type(q.id, :string),
limit: 1,
order_by: [desc: :id]
)
end
defp for_user_query_ap_id_opts(user, opts) do defp for_user_query_ap_id_opts(user, opts) do
ap_id_relationships = ap_id_relationships =
[:block] ++ [:block] ++
@ -71,8 +92,9 @@ def for_user_query(user, opts \\ %{}) do
|> join(:left, [n, a], object in Object, |> join(:left, [n, a], object in Object,
on: on:
fragment( fragment(
"(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)", "(?->>'id') = COALESCE(?->'object'->>'id', ?->>'object')",
object.data, object.data,
a.data,
a.data a.data
) )
) )
@ -185,46 +207,41 @@ def for_user_since(user, date) do
|> Repo.all() |> Repo.all()
end end
def set_read_up_to(%{id: user_id} = _user, id) do def set_read_up_to(%{id: user_id} = user, id) do
query = query =
from( from(
n in Notification, n in Notification,
where: n.user_id == ^user_id, where: n.user_id == ^user_id,
where: n.id <= ^id, where: n.id <= ^id,
where: n.seen == false, where: n.seen == false,
update: [
set: [
seen: true,
updated_at: ^NaiveDateTime.utc_now()
]
],
# Ideally we would preload object and activities here # Ideally we would preload object and activities here
# but Ecto does not support preloads in update_all # but Ecto does not support preloads in update_all
select: n.id select: n.id
) )
{_, notification_ids} = Repo.update_all(query, []) {:ok, %{ids: {_, notification_ids}}} =
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
Notification for_user_query(user)
|> where([n], n.id in ^notification_ids) |> where([n], n.id in ^notification_ids)
|> join(:inner, [n], activity in assoc(n, :activity))
|> join(:left, [n, a], object in Object,
on:
fragment(
"(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
object.data,
a.data
)
)
|> preload([n, a, o], activity: {a, object: o})
|> Repo.all() |> Repo.all()
end end
@spec read_one(User.t(), String.t()) ::
{:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil
def read_one(%User{} = user, notification_id) do def read_one(%User{} = user, notification_id) do
with {:ok, %Notification{} = notification} <- get(user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do
notification Multi.new()
|> changeset(%{seen: true}) |> Multi.update(:update, changeset(notification, %{seen: true}))
|> Repo.update() |> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
|> case do
{:ok, %{update: notification}} -> {:ok, notification}
{:error, :update, changeset, _} -> {:error, changeset}
end
end end
end end
@ -316,8 +333,11 @@ defp do_create_notifications(%Activity{} = activity) do
# TODO move to sql, too. # TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do
unless skip?(activity, user) do unless skip?(activity, user) do
notification = %Notification{user_id: user.id, activity: activity} {:ok, %{notification: notification}} =
{:ok, notification} = Repo.insert(notification) Multi.new()
|> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity})
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
if do_send do if do_send do
Streamer.stream(["user", "user:notification"], notification) Streamer.stream(["user", "user:notification"], notification)
@ -339,15 +359,10 @@ def get_notified_from_activity(activity, local_only \\ true)
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only) def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do
potential_receiver_ap_ids = potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
[]
|> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_subscribers(activity)
|> Utils.maybe_notify_followers(activity)
|> Enum.uniq()
potential_receivers = User.get_users_from_set(potential_receiver_ap_ids, local_only) potential_receivers =
User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only)
notification_enabled_ap_ids = notification_enabled_ap_ids =
potential_receiver_ap_ids potential_receiver_ap_ids
@ -363,6 +378,27 @@ def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, lo
def get_notified_from_activity(_, _local_only), do: {[], []} def get_notified_from_activity(_, _local_only), do: {[], []}
# For some activities, only notify the author of the object
def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}})
when type in ~w{Like Announce EmojiReact} do
case Object.get_cached_by_ap_id(object_id) do
%Object{data: %{"actor" => actor}} ->
[actor]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(activity) do
[]
|> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_subscribers(activity)
|> Utils.maybe_notify_followers(activity)
|> Enum.uniq()
end
@doc "Filters out AP IDs domain-blocking and not following the activity's actor" @doc "Filters out AP IDs domain-blocking and not following the activity's actor"
def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ []) def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ [])

View file

@ -9,11 +9,13 @@ defmodule Pleroma.Object do
import Ecto.Changeset import Ecto.Changeset
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Object.Fetcher alias Pleroma.Object.Fetcher
alias Pleroma.ObjectTombstone alias Pleroma.ObjectTombstone
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Workers.AttachmentsCleanupWorker
require Logger require Logger
@ -138,12 +140,17 @@ def normalize(ap_id, true, options) when is_binary(ap_id) do
def normalize(_, _, _), do: nil def normalize(_, _, _), do: nil
# Owned objects can only be mutated by their owner # Owned objects can only be accessed by their owner
def authorize_mutation(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}), def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
do: actor == ap_id if actor == ap_id do
:ok
else
{:error, :forbidden}
end
end
# Legacy objects can be mutated by anybody # Legacy objects can be accessed by anybody
def authorize_mutation(%Object{}, %User{}), do: true def authorize_access(%Object{}, %User{}), do: :ok
@spec get_cached_by_ap_id(String.t()) :: Object.t() | nil @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
def get_cached_by_ap_id(ap_id) do def get_cached_by_ap_id(ap_id) do
@ -183,27 +190,37 @@ def swap_object_with_tombstone(object) do
def delete(%Object{data: %{"id" => id}} = object) do def delete(%Object{data: %{"id" => id}} = object) do
with {:ok, _obj} = swap_object_with_tombstone(object), with {:ok, _obj} = swap_object_with_tombstone(object),
deleted_activity = Activity.delete_all_by_object_ap_id(id), deleted_activity = Activity.delete_all_by_object_ap_id(id),
{:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), {:ok, _} <- invalid_object_cache(object) do
{:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do cleanup_attachments(
with true <- Pleroma.Config.get([:instance, :cleanup_attachments]) do Config.get([:instance, :cleanup_attachments]),
{:ok, _} = %{"object" => object}
Pleroma.Workers.AttachmentsCleanupWorker.enqueue("cleanup_attachments", %{ )
"object" => object
})
end
{:ok, object, deleted_activity} {:ok, object, deleted_activity}
end end
end end
def prune(%Object{data: %{"id" => id}} = object) do @spec cleanup_attachments(boolean(), %{required(:object) => map()}) ::
{:ok, Oban.Job.t() | nil}
def cleanup_attachments(true, %{"object" => _} = params) do
AttachmentsCleanupWorker.enqueue("cleanup_attachments", params)
end
def cleanup_attachments(_, _), do: {:ok, nil}
def prune(%Object{data: %{"id" => _id}} = object) do
with {:ok, object} <- Repo.delete(object), with {:ok, object} <- Repo.delete(object),
{:ok, true} <- Cachex.del(:object_cache, "object:#{id}"), {:ok, _} <- invalid_object_cache(object) do
{:ok, _} <- Cachex.del(:web_resp_cache, URI.parse(id).path) do
{:ok, object} {:ok, object}
end end
end end
def invalid_object_cache(%Object{data: %{"id" => id}}) do
with {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do
Cachex.del(:web_resp_cache, URI.parse(id).path)
end
end
def set_cache(%Object{data: %{"id" => ap_id}} = object) do def set_cache(%Object{data: %{"id" => ap_id}} = object) do
Cachex.put(:object_cache, "object:#{ap_id}", object) Cachex.put(:object_cache, "object:#{ap_id}", object)
{:ok, object} {:ok, object}

View file

@ -3,7 +3,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Plugs.AuthenticationPlug do defmodule Pleroma.Plugs.AuthenticationPlug do
alias Comeonin.Pbkdf2
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
@ -17,8 +16,13 @@ def checkpw(password, "$6" <> _ = password_hash) do
:crypt.crypt(password, password_hash) == password_hash :crypt.crypt(password, password_hash) == password_hash
end end
def checkpw(password, "$2" <> _ = password_hash) do
# Handle bcrypt passwords for Mastodon migration
Bcrypt.verify_pass(password, password_hash)
end
def checkpw(password, "$pbkdf2" <> _ = password_hash) do def checkpw(password, "$pbkdf2" <> _ = password_hash) do
Pbkdf2.checkpw(password, password_hash) Pbkdf2.verify_pass(password, password_hash)
end end
def checkpw(_password, _password_hash) do def checkpw(_password, _password_hash) do
@ -26,6 +30,25 @@ def checkpw(_password, _password_hash) do
false false
end end
def maybe_update_password(%User{password_hash: "$2" <> _} = user, password) do
do_update_password(user, password)
end
def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do
do_update_password(user, password)
end
def maybe_update_password(user, _), do: {:ok, user}
defp do_update_password(user, password) do
user
|> User.password_update_changeset(%{
"password" => password,
"password_confirmation" => password
})
|> Pleroma.Repo.update()
end
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call( def call(
@ -37,7 +60,9 @@ def call(
} = conn, } = conn,
_ _
) do ) do
if Pbkdf2.checkpw(password, password_hash) do if checkpw(password, password_hash) do
{:ok, auth_user} = maybe_update_password(auth_user, password)
conn conn
|> assign(:user, auth_user) |> assign(:user, auth_user)
|> OAuthScopesPlug.skip_plug() |> OAuthScopesPlug.skip_plug()
@ -47,7 +72,7 @@ def call(
end end
def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do
Pbkdf2.dummy_checkpw() Pbkdf2.no_user_verify()
conn conn
end end

View file

@ -15,26 +15,25 @@ def init(options) do
end end
@impl true @impl true
def perform(
%{
assigns: %{
auth_credentials: %{password: _},
user: %User{multi_factor_authentication_settings: %{enabled: true}}
}
} = conn,
_
) do
conn
|> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.")
|> halt()
end
def perform(%{assigns: %{user: %User{}}} = conn, _) do def perform(%{assigns: %{user: %User{}}} = conn, _) do
conn conn
end end
def perform(conn, options) do def perform(conn, _) do
perform =
cond do
options[:if_func] -> options[:if_func].()
options[:unless_func] -> !options[:unless_func].()
true -> true
end
if perform do
fail(conn)
else
conn
end
end
def fail(conn) do
conn conn
|> render_error(:forbidden, "Invalid credentials.") |> render_error(:forbidden, "Invalid credentials.")
|> halt() |> halt()

View file

@ -19,6 +19,9 @@ def call(conn, _opts) do
def federating?, do: Pleroma.Config.get([:instance, :federating]) def federating?, do: Pleroma.Config.get([:instance, :federating])
# Definition for the use in :if_func / :unless_func plug options
def federating?(_conn), do: federating?()
defp fail(conn) do defp fail(conn) do
conn conn
|> put_status(404) |> put_status(404)

View file

@ -31,7 +31,7 @@ defp headers do
{"x-content-type-options", "nosniff"}, {"x-content-type-options", "nosniff"},
{"referrer-policy", referrer_policy}, {"referrer-policy", referrer_policy},
{"x-download-options", "noopen"}, {"x-download-options", "noopen"},
{"content-security-policy", csp_string() <> ";"} {"content-security-policy", csp_string()}
] ]
if report_uri do if report_uri do
@ -43,23 +43,46 @@ defp headers do
] ]
} }
headers ++ [{"reply-to", Jason.encode!(report_group)}] [{"reply-to", Jason.encode!(report_group)} | headers]
else else
headers headers
end end
end end
static_csp_rules = [
"default-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"manifest-src 'self'"
]
@csp_start [Enum.join(static_csp_rules, ";") <> ";"]
defp csp_string do defp csp_string do
scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme] scheme = Config.get([Pleroma.Web.Endpoint, :url])[:scheme]
static_url = Pleroma.Web.Endpoint.static_url() static_url = Pleroma.Web.Endpoint.static_url()
websocket_url = Pleroma.Web.Endpoint.websocket_url() websocket_url = Pleroma.Web.Endpoint.websocket_url()
report_uri = Config.get([:http_security, :report_uri]) report_uri = Config.get([:http_security, :report_uri])
connect_src = "connect-src 'self' #{static_url} #{websocket_url}" img_src = "img-src 'self' data: blob:"
media_src = "media-src 'self'"
{img_src, media_src} =
if Config.get([:media_proxy, :enabled]) &&
!Config.get([:media_proxy, :proxy_opts, :redirect_on_failure]) do
sources = get_proxy_and_attachment_sources()
{[img_src, sources], [media_src, sources]}
else
{[img_src, " https:"], [media_src, " https:"]}
end
connect_src = ["connect-src 'self' blob: ", static_url, ?\s, websocket_url]
connect_src = connect_src =
if Pleroma.Config.get(:env) == :dev do if Pleroma.Config.get(:env) == :dev do
connect_src <> " http://localhost:3035/" [connect_src, " http://localhost:3035/"]
else else
connect_src connect_src
end end
@ -71,27 +94,46 @@ defp csp_string do
"script-src 'self'" "script-src 'self'"
end end
main_part = [ report = if report_uri, do: ["report-uri ", report_uri, ";report-to csp-endpoint"]
"default-src 'none'", insecure = if scheme == "https", do: "upgrade-insecure-requests"
"base-uri 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: blob: https:",
"media-src 'self' https:",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"manifest-src 'self'",
connect_src,
script_src
]
report = if report_uri, do: ["report-uri #{report_uri}; report-to csp-endpoint"], else: [] @csp_start
|> add_csp_param(img_src)
insecure = if scheme == "https", do: ["upgrade-insecure-requests"], else: [] |> add_csp_param(media_src)
|> add_csp_param(connect_src)
(main_part ++ report ++ insecure) |> add_csp_param(script_src)
|> Enum.join("; ") |> add_csp_param(insecure)
|> add_csp_param(report)
|> :erlang.iolist_to_binary()
end end
defp get_proxy_and_attachment_sources do
media_proxy_whitelist =
Enum.reduce(Config.get([:media_proxy, :whitelist]), [], fn host, acc ->
add_source(acc, host)
end)
upload_base_url =
if Config.get([Pleroma.Upload, :base_url]),
do: URI.parse(Config.get([Pleroma.Upload, :base_url])).host
s3_endpoint =
if Config.get([Pleroma.Upload, :uploader]) == Pleroma.Uploaders.S3,
do: URI.parse(Config.get([Pleroma.Uploaders.S3, :public_endpoint])).host
[]
|> add_source(upload_base_url)
|> add_source(s3_endpoint)
|> add_source(media_proxy_whitelist)
end
defp add_source(iodata, nil), do: iodata
defp add_source(iodata, source), do: [[?\s, source] | iodata]
defp add_csp_param(csp_iodata, nil), do: csp_iodata
defp add_csp_param(csp_iodata, param), do: [[param, ?;] | csp_iodata]
def warn_if_disabled do def warn_if_disabled do
unless Config.get([:http_security, :enabled]) do unless Config.get([:http_security, :enabled]) do
Logger.warn(" Logger.warn("

View file

@ -40,7 +40,7 @@ defp with_media_attachments(
%{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset %{changes: %{params: %{"media_ids" => media_ids} = params}} = changeset
) )
when is_list(media_ids) do when is_list(media_ids) do
media_attachments = Utils.attachments_from_ids(%{"media_ids" => media_ids}) media_attachments = Utils.attachments_from_ids(%{media_ids: media_ids})
params = params =
params params

View file

@ -91,7 +91,7 @@ def calculate_stat_data do
peers: peers, peers: peers,
stats: %{ stats: %{
domain_count: domain_count, domain_count: domain_count,
status_count: status_count, status_count: status_count || 0,
user_count: user_count user_count: user_count
} }
} }

View file

@ -134,7 +134,7 @@ defp prepare_upload(%Plug.Upload{} = file, opts) do
end end
end end
defp prepare_upload(%{"img" => "data:image/" <> image_data}, opts) do defp prepare_upload(%{img: "data:image/" <> image_data}, opts) do
parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data) parsed = Regex.named_captures(~r/(?<filetype>jpeg|png|gif);base64,(?<data>.*)/, image_data)
data = Base.decode64!(parsed["data"], ignore: :whitespace) data = Base.decode64!(parsed["data"], ignore: :whitespace)
hash = String.downcase(Base.encode16(:crypto.hash(:sha256, data))) hash = String.downcase(Base.encode16(:crypto.hash(:sha256, data)))

View file

@ -9,7 +9,6 @@ defmodule Pleroma.User do
import Ecto.Query import Ecto.Query
import Ecto, only: [assoc: 2] import Ecto, only: [assoc: 2]
alias Comeonin.Pbkdf2
alias Ecto.Multi alias Ecto.Multi
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config alias Pleroma.Config
@ -20,6 +19,7 @@ defmodule Pleroma.User do
alias Pleroma.Formatter alias Pleroma.Formatter
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.Keys alias Pleroma.Keys
alias Pleroma.MFA
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Registration alias Pleroma.Registration
@ -29,7 +29,9 @@ defmodule Pleroma.User do
alias Pleroma.UserRelationship alias Pleroma.UserRelationship
alias Pleroma.Web alias Pleroma.Web
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidators.Types alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils alias Pleroma.Web.CommonAPI.Utils, as: CommonUtils
@ -113,7 +115,6 @@ defmodule Pleroma.User do
field(:is_admin, :boolean, default: false) field(:is_admin, :boolean, default: false)
field(:show_role, :boolean, default: true) field(:show_role, :boolean, default: true)
field(:settings, :map, default: nil) field(:settings, :map, default: nil)
field(:magic_key, :string, default: nil)
field(:uri, Types.Uri, default: nil) field(:uri, Types.Uri, default: nil)
field(:hide_followers_count, :boolean, default: false) field(:hide_followers_count, :boolean, default: false)
field(:hide_follows_count, :boolean, default: false) field(:hide_follows_count, :boolean, default: false)
@ -189,6 +190,12 @@ defmodule Pleroma.User do
# `:subscribers` is deprecated (replaced with `subscriber_users` relation) # `:subscribers` is deprecated (replaced with `subscriber_users` relation)
field(:subscribers, {:array, :string}, default: []) field(:subscribers, {:array, :string}, default: [])
embeds_one(
:multi_factor_authentication_settings,
MFA.Settings,
on_replace: :delete
)
timestamps() timestamps()
end end
@ -298,8 +305,13 @@ def invisible?(_), do: false
def avatar_url(user, options \\ []) do def avatar_url(user, options \\ []) do
case user.avatar do case user.avatar do
%{"url" => [%{"href" => href} | _]} -> href %{"url" => [%{"href" => href} | _]} ->
_ -> !options[:no_default] && "#{Web.base_url()}/images/avi.png" href
_ ->
unless options[:no_default] do
Config.get([:assets, :default_user_avatar], "#{Web.base_url()}/images/avi.png")
end
end end
end end
@ -387,7 +399,6 @@ def remote_user_changeset(struct \\ %User{local: false}, params) do
:banner, :banner,
:locked, :locked,
:last_refreshed_at, :last_refreshed_at,
:magic_key,
:uri, :uri,
:follower_address, :follower_address,
:following_address, :following_address,
@ -527,9 +538,10 @@ def update_as_admin_changeset(struct, params) do
|> delete_change(:also_known_as) |> delete_change(:also_known_as)
|> unique_constraint(:email) |> unique_constraint(:email)
|> validate_format(:email, @email_regex) |> validate_format(:email, @email_regex)
|> validate_inclusion(:actor_type, ["Person", "Service"])
end end
@spec update_as_admin(%User{}, map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @spec update_as_admin(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def update_as_admin(user, params) do def update_as_admin(user, params) do
params = Map.put(params, "password_confirmation", params["password"]) params = Map.put(params, "password_confirmation", params["password"])
changeset = update_as_admin_changeset(user, params) changeset = update_as_admin_changeset(user, params)
@ -550,7 +562,7 @@ def password_update_changeset(struct, params) do
|> put_change(:password_reset_pending, false) |> put_change(:password_reset_pending, false)
end end
@spec reset_password(User.t(), map) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} @spec reset_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t()}
def reset_password(%User{} = user, params) do def reset_password(%User{} = user, params) do
reset_password(user, user, params) reset_password(user, user, params)
end end
@ -743,7 +755,19 @@ def unfollow(%User{ap_id: ap_id}, %User{ap_id: ap_id}) do
{:error, "Not subscribed!"} {:error, "Not subscribed!"}
end end
@spec unfollow(User.t(), User.t()) :: {:ok, User.t(), Activity.t()} | {:error, String.t()}
def unfollow(%User{} = follower, %User{} = followed) do def unfollow(%User{} = follower, %User{} = followed) do
case do_unfollow(follower, followed) do
{:ok, follower, followed} ->
{:ok, follower, Utils.fetch_latest_follow(follower, followed)}
error ->
error
end
end
@spec do_unfollow(User.t(), User.t()) :: {:ok, User.t(), User.t()} | {:error, String.t()}
defp do_unfollow(%User{} = follower, %User{} = followed) do
case get_follow_state(follower, followed) do case get_follow_state(follower, followed) do
state when state in [:follow_pending, :follow_accept] -> state when state in [:follow_pending, :follow_accept] ->
FollowingRelationship.unfollow(follower, followed) FollowingRelationship.unfollow(follower, followed)
@ -754,7 +778,7 @@ def unfollow(%User{} = follower, %User{} = followed) do
|> update_following_count() |> update_following_count()
|> set_cache() |> set_cache()
{:ok, follower, Utils.fetch_latest_follow(follower, followed)} {:ok, follower, followed}
nil -> nil ->
{:error, "Not subscribed!"} {:error, "Not subscribed!"}
@ -927,6 +951,7 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
end end
end end
@spec get_by_nickname(String.t()) :: User.t() | nil
def get_by_nickname(nickname) do def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname) || Repo.get_by(User, nickname: nickname) ||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do
@ -1184,8 +1209,9 @@ def increment_unread_conversation_count(conversation, %User{local: true} = user)
def increment_unread_conversation_count(_, user), do: {:ok, user} def increment_unread_conversation_count(_, user), do: {:ok, user}
@spec get_users_from_set([String.t()], boolean()) :: [User.t()] @spec get_users_from_set([String.t()], keyword()) :: [User.t()]
def get_users_from_set(ap_ids, local_only \\ true) do def get_users_from_set(ap_ids, opts \\ []) do
local_only = Keyword.get(opts, :local_only, true)
criteria = %{ap_id: ap_ids, deactivated: false} criteria = %{ap_id: ap_ids, deactivated: false}
criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria criteria = if local_only, do: Map.put(criteria, :local, true), else: criteria
@ -1197,7 +1223,9 @@ def get_users_from_set(ap_ids, local_only \\ true) do
def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do
to = [actor | to] to = [actor | to]
User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false}) query = User.Query.build(%{recipients_from_activity: to, local: true, deactivated: false})
query
|> Repo.all() |> Repo.all()
end end
@ -1393,15 +1421,13 @@ def deactivate(%User{} = user, status) do
user user
|> get_followers() |> get_followers()
|> Enum.filter(& &1.local) |> Enum.filter(& &1.local)
|> Enum.each(fn follower -> |> Enum.each(&set_cache(update_following_count(&1)))
follower |> update_following_count() |> set_cache()
end)
# Only update local user counts, remote will be update during the next pull. # Only update local user counts, remote will be update during the next pull.
user user
|> get_friends() |> get_friends()
|> Enum.filter(& &1.local) |> Enum.filter(& &1.local)
|> Enum.each(&update_follower_count/1) |> Enum.each(&do_unfollow(user, &1))
{:ok, user} {:ok, user}
end end
@ -1423,12 +1449,29 @@ def delete(%User{} = user) do
BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id}) BackgroundWorker.enqueue("delete_user", %{"user_id" => user.id})
end end
defp delete_and_invalidate_cache(%User{} = user) do
invalidate_cache(user)
Repo.delete(user)
end
defp delete_or_deactivate(%User{local: false} = user), do: delete_and_invalidate_cache(user)
defp delete_or_deactivate(%User{local: true} = user) do
status = account_status(user)
if status == :confirmation_pending do
delete_and_invalidate_cache(user)
else
user
|> change(%{deactivated: true, email: nil})
|> update_and_set_cache()
end
end
def perform(:force_password_reset, user), do: force_password_reset(user) def perform(:force_password_reset, user), do: force_password_reset(user)
@spec perform(atom(), User.t()) :: {:ok, User.t()} @spec perform(atom(), User.t()) :: {:ok, User.t()}
def perform(:delete, %User{} = user) do def perform(:delete, %User{} = user) do
{:ok, _user} = ActivityPub.delete(user)
# Remove all relationships # Remove all relationships
user user
|> get_followers() |> get_followers()
@ -1446,14 +1489,7 @@ def perform(:delete, %User{} = user) do
delete_user_activities(user) delete_user_activities(user)
if user.local do delete_or_deactivate(user)
user
|> change(%{deactivated: true, email: nil})
|> update_and_set_cache()
else
invalidate_cache(user)
Repo.delete(user)
end
end end
def perform(:deactivate_async, user, status), do: deactivate(user, status) def perform(:deactivate_async, user, status), do: deactivate(user, status)
@ -1538,37 +1574,42 @@ def follow_import(%User{} = follower, followed_identifiers)
}) })
end end
def delete_user_activities(%User{ap_id: ap_id}) do def delete_user_activities(%User{ap_id: ap_id} = user) do
ap_id ap_id
|> Activity.Queries.by_actor() |> Activity.Queries.by_actor()
|> RepoStreamer.chunk_stream(50) |> RepoStreamer.chunk_stream(50)
|> Stream.each(fn activities -> Enum.each(activities, &delete_activity/1) end) |> Stream.each(fn activities ->
Enum.each(activities, fn activity -> delete_activity(activity, user) end)
end)
|> Stream.run() |> Stream.run()
end end
defp delete_activity(%{data: %{"type" => "Create"}} = activity) do defp delete_activity(%{data: %{"type" => "Create", "object" => object}} = activity, user) do
activity with {_, %Object{}} <- {:find_object, Object.get_by_ap_id(object)},
|> Object.normalize() {:ok, delete_data, _} <- Builder.delete(user, object) do
|> ActivityPub.delete() Pipeline.common_pipeline(delete_data, local: user.local)
else
{:find_object, nil} ->
# We have the create activity, but not the object, it was probably pruned.
# Insert a tombstone and try again
with {:ok, tombstone_data, _} <- Builder.tombstone(user.ap_id, object),
{:ok, _tombstone} <- Object.create(tombstone_data) do
delete_activity(activity, user)
end
e ->
Logger.error("Could not delete #{object} created by #{activity.data["ap_id"]}")
Logger.error("Error: #{inspect(e)}")
end
end end
defp delete_activity(%{data: %{"type" => "Like"}} = activity) do defp delete_activity(%{data: %{"type" => type}} = activity, user)
object = Object.normalize(activity) when type in ["Like", "Announce"] do
{:ok, undo, _} = Builder.undo(user, activity)
activity.actor Pipeline.common_pipeline(undo, local: user.local)
|> get_cached_by_ap_id()
|> ActivityPub.unlike(object)
end end
defp delete_activity(%{data: %{"type" => "Announce"}} = activity) do defp delete_activity(_activity, _user), do: "Doing nothing"
object = Object.normalize(activity)
activity.actor
|> get_cached_by_ap_id()
|> ActivityPub.unannounce(object)
end
defp delete_activity(_activity), do: "Doing nothing"
def html_filter_policy(%User{no_rich_text: true}) do def html_filter_policy(%User{no_rich_text: true}) do
Pleroma.HTML.Scrubber.TwitterText Pleroma.HTML.Scrubber.TwitterText
@ -1579,12 +1620,19 @@ def html_filter_policy(_), do: Pleroma.Config.get([:markup, :scrub_policy])
def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id) def fetch_by_ap_id(ap_id), do: ActivityPub.make_user_from_ap_id(ap_id)
def get_or_fetch_by_ap_id(ap_id) do def get_or_fetch_by_ap_id(ap_id) do
user = get_cached_by_ap_id(ap_id) cached_user = get_cached_by_ap_id(ap_id)
if !is_nil(user) and !needs_update?(user) do maybe_fetched_user = needs_update?(cached_user) && fetch_by_ap_id(ap_id)
{:ok, user}
else case {cached_user, maybe_fetched_user} do
fetch_by_ap_id(ap_id) {_, {:ok, %User{} = user}} ->
{:ok, user}
{%User{} = user, _} ->
{:ok, user}
_ ->
{:error, :not_found}
end end
end end
@ -1915,7 +1963,7 @@ def get_ap_ids_by_nicknames(nicknames) do
defp put_password_hash( defp put_password_hash(
%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset %Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset
) do ) do
change(changeset, password_hash: Pbkdf2.hashpwsalt(password)) change(changeset, password_hash: Pbkdf2.hash_pwd_salt(password))
end end
defp put_password_hash(changeset), do: changeset defp put_password_hash(changeset), do: changeset

View file

@ -45,6 +45,7 @@ defmodule Pleroma.User.Query do
is_admin: boolean(), is_admin: boolean(),
is_moderator: boolean(), is_moderator: boolean(),
super_users: boolean(), super_users: boolean(),
exclude_service_users: boolean(),
followers: User.t(), followers: User.t(),
friends: User.t(), friends: User.t(),
recipients_from_activity: [String.t()], recipients_from_activity: [String.t()],
@ -88,6 +89,10 @@ defp compose_query({key, value}, query)
where(query, [u], ilike(field(u, ^key), ^"%#{value}%")) where(query, [u], ilike(field(u, ^key), ^"%#{value}%"))
end end
defp compose_query({:exclude_service_users, _}, query) do
where(query, [u], not like(u.ap_id, "%/relay") and not like(u.ap_id, "%/internal/fetch"))
end
defp compose_query({key, value}, query) defp compose_query({key, value}, query)
when key in @equal_criteria and not_empty_string(value) do when key in @equal_criteria and not_empty_string(value) do
where(query, [u], ^[{key, value}]) where(query, [u], ^[{key, value}])
@ -98,7 +103,7 @@ defp compose_query({key, values}, query) when key in @contains_criteria and is_l
end end
defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do defp compose_query({:tags, tags}, query) when is_list(tags) and length(tags) > 0 do
Enum.reduce(tags, query, &prepare_tag_criteria/2) where(query, [u], fragment("? && ?", u.tags, ^tags))
end end
defp compose_query({:is_admin, _}, query) do defp compose_query({:is_admin, _}, query) do
@ -162,20 +167,18 @@ defp compose_query({:friends, %User{id: id}}, query) do
end end
defp compose_query({:recipients_from_activity, to}, query) do defp compose_query({:recipients_from_activity, to}, query) do
query following_query =
|> join(:left, [u], r in FollowingRelationship, from(u in User,
as: :relationships, join: f in FollowingRelationship,
on: r.follower_id == u.id on: u.id == f.following_id,
where: f.state == ^:follow_accept,
where: u.follower_address in ^to,
select: f.follower_id
)
from(u in query,
where: u.ap_id in ^to or u.id in subquery(following_query)
) )
|> join(:left, [relationships: r], f in User,
as: :following,
on: f.id == r.following_id
)
|> where(
[u, following: f, relationships: r],
u.ap_id in ^to or (f.follower_address in ^to and r.state == ^:follow_accept)
)
|> distinct(true)
end end
defp compose_query({:order_by, key}, query) do defp compose_query({:order_by, key}, query) do
@ -192,10 +195,6 @@ defp compose_query({:limit, limit}, query) do
defp compose_query(_unsupported_param, query), do: query defp compose_query(_unsupported_param, query), do: query
defp prepare_tag_criteria(tag, query) do
or_where(query, [u], fragment("? = any(?)", ^tag, u.tags))
end
defp location_query(query, local) do defp location_query(query, local) do
where(query, [u], u.local == ^local) where(query, [u], u.local == ^local)
|> where([u], not is_nil(u.nickname)) |> where([u], not is_nil(u.nickname))

View file

@ -10,8 +10,8 @@ def post_welcome_message_to_user(user) do
with %User{} = sender_user <- welcome_user(), with %User{} = sender_user <- welcome_user(),
message when is_binary(message) <- welcome_message() do message when is_binary(message) <- welcome_message() do
CommonAPI.post(sender_user, %{ CommonAPI.post(sender_user, %{
"visibility" => "direct", visibility: "direct",
"status" => "@#{user.nickname}\n#{message}" status: "@#{user.nickname}\n#{message}"
}) })
else else
_ -> {:ok, nil} _ -> {:ok, nil}

View file

@ -87,6 +87,22 @@ def dictionary(
source_to_target_rel_types \\ nil, source_to_target_rel_types \\ nil,
target_to_source_rel_types \\ nil target_to_source_rel_types \\ nil
) )
def dictionary(
_source_users,
_target_users,
[] = _source_to_target_rel_types,
[] = _target_to_source_rel_types
) do
[]
end
def dictionary(
source_users,
target_users,
source_to_target_rel_types,
target_to_source_rel_types
)
when is_list(source_users) and is_list(target_users) do when is_list(source_users) and is_list(target_users) do
source_user_ids = User.binary_id(source_users) source_user_ids = User.binary_id(source_users)
target_user_ids = User.binary_id(target_users) target_user_ids = User.binary_id(target_users)
@ -138,11 +154,16 @@ def view_relationships_option(nil = _reading_user, _actors, _opts) do
def view_relationships_option(%User{} = reading_user, actors, opts) do def view_relationships_option(%User{} = reading_user, actors, opts) do
{source_to_target_rel_types, target_to_source_rel_types} = {source_to_target_rel_types, target_to_source_rel_types} =
if opts[:source_mutes_only] do case opts[:subset] do
# This option is used for rendering statuses (FE needs `muted` flag for each one anyways) :source_mutes ->
{[:mute], []} # Used for statuses rendering (FE needs `muted` flag for each status when statuses load)
else {[:mute], []}
{[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]}
nil ->
{[:block, :mute, :notification_mute, :reblog_mute], [:block, :inverse_subscription]}
unknown ->
raise "Unsupported :subset option value: #{inspect(unknown)}"
end end
user_relationships = user_relationships =
@ -153,7 +174,17 @@ def view_relationships_option(%User{} = reading_user, actors, opts) do
target_to_source_rel_types target_to_source_rel_types
) )
following_relationships = FollowingRelationship.all_between_user_sets([reading_user], actors) following_relationships =
case opts[:subset] do
:source_mutes ->
[]
nil ->
FollowingRelationship.all_between_user_sets([reading_user], actors)
unknown ->
raise "Unsupported :subset option value: #{inspect(unknown)}"
end
%{user_relationships: user_relationships, following_relationships: following_relationships} %{user_relationships: user_relationships, following_relationships: following_relationships}
end end

View file

@ -173,12 +173,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id}) BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id})
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, map["actor"])
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
{:ok, activity} {:ok, activity}
else else
%Activity{} = activity -> %Activity{} = activity ->
@ -201,6 +195,15 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
end end
end end
def notify_and_stream(activity) do
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, activity.actor)
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
end
defp maybe_create_activity_expiration({:ok, %{data: %{"expires_at" => expires_at}} = activity}) do defp maybe_create_activity_expiration({:ok, %{data: %{"expires_at" => expires_at}} = activity}) do
with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do with {:ok, _} <- ActivityExpiration.create(activity, expires_at) do
{:ok, activity} {:ok, activity}
@ -285,6 +288,7 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
_ <- increase_poll_votes_if_vote(create_data), _ <- increase_poll_votes_if_vote(create_data),
{:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity}, {:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
{:ok, _actor} <- increase_note_count_if_public(actor, activity), {:ok, _actor} <- increase_note_count_if_public(actor, activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -312,6 +316,7 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d
additional additional
), ),
{:ok, activity} <- insert(listen_data, local), {:ok, activity} <- insert(listen_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
@ -336,6 +341,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do
%{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object} %{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object}
|> Utils.maybe_put("id", activity_id), |> Utils.maybe_put("id", activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
@ -355,140 +361,12 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do
}, },
data <- Utils.maybe_put(data, "id", activity_id), data <- Utils.maybe_put(data, "id", activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
end end
end end
@spec react_with_emoji(User.t(), Object.t(), String.t(), keyword()) ::
{:ok, Activity.t(), Object.t()} | {:error, any()}
def react_with_emoji(user, object, emoji, options \\ []) do
with {:ok, result} <-
Repo.transaction(fn -> do_react_with_emoji(user, object, emoji, options) end) do
result
end
end
defp do_react_with_emoji(user, object, emoji, options) do
with local <- Keyword.get(options, :local, true),
activity_id <- Keyword.get(options, :activity_id, nil),
true <- Pleroma.Emoji.is_unicode_emoji?(emoji),
reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
{:ok, activity} <- insert(reaction_data, local),
{:ok, object} <- add_emoji_reaction_to_object(activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
false -> {:error, false}
{:error, error} -> Repo.rollback(error)
end
end
@spec unreact_with_emoji(User.t(), String.t(), keyword()) ::
{:ok, Activity.t(), Object.t()} | {:error, any()}
def unreact_with_emoji(user, reaction_id, options \\ []) do
with {:ok, result} <-
Repo.transaction(fn -> do_unreact_with_emoji(user, reaction_id, options) end) do
result
end
end
defp do_unreact_with_emoji(user, reaction_id, options) do
with local <- Keyword.get(options, :local, true),
activity_id <- Keyword.get(options, :activity_id, nil),
user_ap_id <- user.ap_id,
%Activity{actor: ^user_ap_id} = reaction_activity <- Activity.get_by_ap_id(reaction_id),
object <- Object.normalize(reaction_activity),
unreact_data <- make_undo_data(user, reaction_activity, activity_id),
{:ok, activity} <- insert(unreact_data, local),
{:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
{:error, error} -> Repo.rollback(error)
end
end
@spec unlike(User.t(), Object.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t(), Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
def unlike(%User{} = actor, %Object{} = object, activity_id \\ nil, local \\ true) do
with {:ok, result} <-
Repo.transaction(fn -> do_unlike(actor, object, activity_id, local) end) do
result
end
end
defp do_unlike(actor, object, activity_id, local) do
with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object),
unlike_data <- make_unlike_data(actor, like_activity, activity_id),
{:ok, unlike_activity} <- insert(unlike_data, local),
{:ok, _activity} <- Repo.delete(like_activity),
{:ok, object} <- remove_like_from_object(like_activity, object),
:ok <- maybe_federate(unlike_activity) do
{:ok, unlike_activity, like_activity, object}
else
nil -> {:ok, object}
{:error, error} -> Repo.rollback(error)
end
end
@spec announce(User.t(), Object.t(), String.t() | nil, boolean(), boolean()) ::
{:ok, Activity.t(), Object.t()} | {:error, any()}
def announce(
%User{ap_id: _} = user,
%Object{data: %{"id" => _}} = object,
activity_id \\ nil,
local \\ true,
public \\ true
) do
with {:ok, result} <-
Repo.transaction(fn -> do_announce(user, object, activity_id, local, public) end) do
result
end
end
defp do_announce(user, object, activity_id, local, public) do
with true <- is_announceable?(object, user, public),
object <- Object.get_by_id(object.id),
announce_data <- make_announce_data(user, object, activity_id, public),
{:ok, activity} <- insert(announce_data, local),
{:ok, object} <- add_announce_to_object(activity, object),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
false -> {:error, false}
{:error, error} -> Repo.rollback(error)
end
end
@spec unannounce(User.t(), Object.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t(), Object.t()} | {:ok, Object.t()} | {:error, any()}
def unannounce(
%User{} = actor,
%Object{} = object,
activity_id \\ nil,
local \\ true
) do
with {:ok, result} <-
Repo.transaction(fn -> do_unannounce(actor, object, activity_id, local) end) do
result
end
end
defp do_unannounce(actor, object, activity_id, local) do
with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- insert(unannounce_data, local),
:ok <- maybe_federate(unannounce_activity),
{:ok, _activity} <- Repo.delete(announce_activity),
{:ok, object} <- remove_announce_from_object(announce_activity, object) do
{:ok, unannounce_activity, object}
else
nil -> {:ok, object}
{:error, error} -> Repo.rollback(error)
end
end
@spec follow(User.t(), User.t(), String.t() | nil, boolean()) :: @spec follow(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} {:ok, Activity.t()} | {:error, any()}
def follow(follower, followed, activity_id \\ nil, local \\ true) do def follow(follower, followed, activity_id \\ nil, local \\ true) do
@ -501,6 +379,7 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do
defp do_follow(follower, followed, activity_id, local) do defp do_follow(follower, followed, activity_id, local) do
with data <- make_follow_data(follower, followed, activity_id), with data <- make_follow_data(follower, followed, activity_id),
{:ok, activity} <- insert(data, local), {:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -522,6 +401,7 @@ defp do_unfollow(follower, followed, activity_id, local) do
{:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"), {:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id), unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local), {:ok, activity} <- insert(unfollow_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -530,67 +410,6 @@ defp do_unfollow(follower, followed, activity_id, local) do
end end
end end
@spec delete(User.t() | Object.t(), keyword()) :: {:ok, User.t() | Object.t()} | {:error, any()}
def delete(entity, options \\ []) do
with {:ok, result} <- Repo.transaction(fn -> do_delete(entity, options) end) do
result
end
end
defp do_delete(%User{ap_id: ap_id, follower_address: follower_address} = user, _) do
with data <- %{
"to" => [follower_address],
"type" => "Delete",
"actor" => ap_id,
"object" => %{"type" => "Person", "id" => ap_id}
},
{:ok, activity} <- insert(data, true, true, true),
:ok <- maybe_federate(activity) do
{:ok, user}
end
end
defp do_delete(%Object{data: %{"id" => id, "actor" => actor}} = object, options) do
local = Keyword.get(options, :local, true)
activity_id = Keyword.get(options, :activity_id, nil)
actor = Keyword.get(options, :actor, actor)
user = User.get_cached_by_ap_id(actor)
to = (object.data["to"] || []) ++ (object.data["cc"] || [])
with create_activity <- Activity.get_create_by_object_ap_id(id),
data <-
%{
"type" => "Delete",
"actor" => actor,
"object" => id,
"to" => to,
"deleted_activity_id" => create_activity && create_activity.id
}
|> maybe_put("id", activity_id),
{:ok, activity} <- insert(data, local, false),
{:ok, object, _create_activity} <- Object.delete(object),
stream_out_participations(object, user),
_ <- decrease_replies_count_if_reply(object),
{:ok, _actor} <- decrease_note_count_if_public(user, object),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
{:error, error} ->
Repo.rollback(error)
end
end
defp do_delete(%Object{data: %{"type" => "Tombstone", "id" => ap_id}}, _) do
activity =
ap_id
|> Activity.Queries.by_object_id()
|> Activity.Queries.by_type("Delete")
|> Repo.one()
{:ok, activity}
end
@spec block(User.t(), User.t(), String.t() | nil, boolean()) :: @spec block(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} {:ok, Activity.t()} | {:error, any()}
def block(blocker, blocked, activity_id \\ nil, local \\ true) do def block(blocker, blocked, activity_id \\ nil, local \\ true) do
@ -601,7 +420,6 @@ def block(blocker, blocked, activity_id \\ nil, local \\ true) do
end end
defp do_block(blocker, blocked, activity_id, local) do defp do_block(blocker, blocked, activity_id, local) do
outgoing_blocks = Config.get([:activitypub, :outgoing_blocks])
unfollow_blocked = Config.get([:activitypub, :unfollow_blocked]) unfollow_blocked = Config.get([:activitypub, :unfollow_blocked])
if unfollow_blocked do if unfollow_blocked do
@ -609,9 +427,9 @@ defp do_block(blocker, blocked, activity_id, local) do
if follow_activity, do: unfollow(blocker, blocked, nil, local) if follow_activity, do: unfollow(blocker, blocked, nil, local)
end end
with true <- outgoing_blocks, with block_data <- make_block_data(blocker, blocked, activity_id),
block_data <- make_block_data(blocker, blocked, activity_id),
{:ok, activity} <- insert(block_data, local), {:ok, activity} <- insert(block_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity} {:ok, activity}
else else
@ -619,27 +437,6 @@ defp do_block(blocker, blocked, activity_id, local) do
end end
end end
@spec unblock(User.t(), User.t(), String.t() | nil, boolean()) ::
{:ok, Activity.t()} | {:error, any()} | nil
def unblock(blocker, blocked, activity_id \\ nil, local \\ true) do
with {:ok, result} <-
Repo.transaction(fn -> do_unblock(blocker, blocked, activity_id, local) end) do
result
end
end
defp do_unblock(blocker, blocked, activity_id, local) do
with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
{:ok, activity} <- insert(unblock_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
nil -> nil
{:error, error} -> Repo.rollback(error)
end
end
@spec flag(map()) :: {:ok, Activity.t()} | {:error, any()} @spec flag(map()) :: {:ok, Activity.t()} | {:error, any()}
def flag( def flag(
%{ %{
@ -666,6 +463,7 @@ def flag(
with flag_data <- make_flag_data(params, additional), with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local), {:ok, activity} <- insert(flag_data, local),
{:ok, stripped_activity} <- strip_report_status_data(activity), {:ok, stripped_activity} <- strip_report_status_data(activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(stripped_activity) do :ok <- maybe_federate(stripped_activity) do
User.all_superusers() User.all_superusers()
|> Enum.filter(fn user -> not is_nil(user.email) end) |> Enum.filter(fn user -> not is_nil(user.email) end)
@ -689,7 +487,8 @@ def move(%User{} = origin, %User{} = target, local \\ true) do
} }
with true <- origin.ap_id in target.also_known_as, with true <- origin.ap_id in target.also_known_as,
{:ok, activity} <- insert(params, local) do {:ok, activity} <- insert(params, local),
_ <- notify_and_stream(activity) do
maybe_federate(activity) maybe_federate(activity)
BackgroundWorker.enqueue("move_following", %{ BackgroundWorker.enqueue("move_following", %{
@ -750,14 +549,27 @@ def fetch_latest_activity_id_for_context(context, opts \\ %{}) do
|> Repo.one() |> Repo.one()
end end
@spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()] @spec fetch_public_or_unlisted_activities(map(), Pagination.type()) :: [Activity.t()]
def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do def fetch_public_or_unlisted_activities(opts \\ %{}, pagination \\ :keyset) do
opts = Map.drop(opts, ["user"]) opts = Map.drop(opts, ["user"])
[Constants.as_public()] query = fetch_activities_query([Constants.as_public()], opts)
|> fetch_activities_query(opts)
|> restrict_unlisted() query =
|> Pagination.fetch_paginated(opts, pagination) if opts["restrict_unlisted"] do
restrict_unlisted(query)
else
query
end
Pagination.fetch_paginated(query, opts, pagination)
end
@spec fetch_public_activities(map(), Pagination.type()) :: [Activity.t()]
def fetch_public_activities(opts \\ %{}, pagination \\ :keyset) do
opts
|> Map.put("restrict_unlisted", true)
|> fetch_public_or_unlisted_activities(pagination)
end end
@valid_visibilities ~w[direct unlisted public private] @valid_visibilities ~w[direct unlisted public private]
@ -1357,7 +1169,7 @@ def fetch_favourites(user, params \\ %{}, pagination \\ :keyset) do
|> Activity.with_joined_object() |> Activity.with_joined_object()
|> Object.with_joined_activity() |> Object.with_joined_activity()
|> select([_like, object, activity], %{activity | object: object}) |> select([_like, object, activity], %{activity | object: object})
|> order_by([like, _, _], desc: like.id) |> order_by([like, _, _], desc_nulls_last: like.id)
|> Pagination.fetch_paginated( |> Pagination.fetch_paginated(
Map.merge(params, %{"skip_order" => true}), Map.merge(params, %{"skip_order" => true}),
pagination, pagination,

View file

@ -21,6 +21,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
alias Pleroma.Web.ActivityPub.UserView alias Pleroma.Web.ActivityPub.UserView
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.Endpoint
alias Pleroma.Web.FederatingPlug alias Pleroma.Web.FederatingPlug
alias Pleroma.Web.Federator alias Pleroma.Web.Federator
@ -34,7 +35,7 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubController do
plug( plug(
EnsureAuthenticatedPlug, EnsureAuthenticatedPlug,
[unless_func: &FederatingPlug.federating?/0] when action not in @federating_only_actions [unless_func: &FederatingPlug.federating?/1] when action not in @federating_only_actions
) )
# Note: :following and :followers must be served even without authentication (as via :api) # Note: :following and :followers must be served even without authentication (as via :api)
@ -75,8 +76,8 @@ def user(conn, %{"nickname" => nickname}) do
end end
end end
def object(conn, %{"uuid" => uuid}) do def object(conn, _) do
with ap_id <- o_status_url(conn, :object, uuid), with ap_id <- Endpoint.url() <> conn.request_path,
%Object{} = object <- Object.get_cached_by_ap_id(ap_id), %Object{} = object <- Object.get_cached_by_ap_id(ap_id),
{_, true} <- {:public?, Visibility.is_public?(object)} do {_, true} <- {:public?, Visibility.is_public?(object)} do
conn conn
@ -101,8 +102,8 @@ def track_object_fetch(conn, object_id) do
conn conn
end end
def activity(conn, %{"uuid" => uuid}) do def activity(conn, _params) do
with ap_id <- o_status_url(conn, :activity, uuid), with ap_id <- Endpoint.url() <> conn.request_path,
%Activity{} = activity <- Activity.normalize(ap_id), %Activity{} = activity <- Activity.normalize(ap_id),
{_, true} <- {:public?, Visibility.is_public?(activity)} do {_, true} <- {:public?, Visibility.is_public?(activity)} do
conn conn
@ -396,7 +397,10 @@ def read_inbox(%{assigns: %{user: %User{nickname: as_nickname}}} = conn, %{
|> json(err) |> json(err)
end end
defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do defp handle_user_activity(
%User{} = user,
%{"type" => "Create", "object" => %{"type" => "Note"}} = params
) do
object = object =
params["object"] params["object"]
|> Map.merge(Map.take(params, ["to", "cc"])) |> Map.merge(Map.take(params, ["to", "cc"]))
@ -415,7 +419,8 @@ defp handle_user_activity(%User{} = user, %{"type" => "Create"} = params) do
defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do defp handle_user_activity(%User{} = user, %{"type" => "Delete"} = params) do
with %Object{} = object <- Object.normalize(params["object"]), with %Object{} = object <- Object.normalize(params["object"]),
true <- user.is_moderator || user.ap_id == object.data["actor"], true <- user.is_moderator || user.ap_id == object.data["actor"],
{:ok, delete} <- ActivityPub.delete(object) do {:ok, delete_data, _} <- Builder.delete(user, object.data["id"]),
{:ok, delete, _} <- Pipeline.common_pipeline(delete_data, local: true) do
{:ok, delete} {:ok, delete}
else else
_ -> {:error, dgettext("errors", "Can't delete object")} _ -> {:error, dgettext("errors", "Can't delete object")}

View file

@ -7,11 +7,115 @@ defmodule Pleroma.Web.ActivityPub.Builder do
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
require Pleroma.Constants
@spec emoji_react(User.t(), Object.t(), String.t()) :: {:ok, map(), keyword()}
def emoji_react(actor, object, emoji) do
with {:ok, data, meta} <- object_action(actor, object) do
data =
data
|> Map.put("content", emoji)
|> Map.put("type", "EmojiReact")
{:ok, data, meta}
end
end
@spec undo(User.t(), Activity.t()) :: {:ok, map(), keyword()}
def undo(actor, object) do
{:ok,
%{
"id" => Utils.generate_activity_id(),
"actor" => actor.ap_id,
"type" => "Undo",
"object" => object.data["id"],
"to" => object.data["to"] || [],
"cc" => object.data["cc"] || []
}, []}
end
@spec delete(User.t(), String.t()) :: {:ok, map(), keyword()}
def delete(actor, object_id) do
object = Object.normalize(object_id, false)
user = !object && User.get_cached_by_ap_id(object_id)
to =
case {object, user} do
{%Object{}, _} ->
# We are deleting an object, address everyone who was originally mentioned
(object.data["to"] || []) ++ (object.data["cc"] || [])
{_, %User{follower_address: follower_address}} ->
# We are deleting a user, address the followers of that user
[follower_address]
end
{:ok,
%{
"id" => Utils.generate_activity_id(),
"actor" => actor.ap_id,
"object" => object_id,
"to" => to,
"type" => "Delete"
}, []}
end
@spec tombstone(String.t(), String.t()) :: {:ok, map(), keyword()}
def tombstone(actor, id) do
{:ok,
%{
"id" => id,
"actor" => actor,
"type" => "Tombstone"
}, []}
end
@spec like(User.t(), Object.t()) :: {:ok, map(), keyword()} @spec like(User.t(), Object.t()) :: {:ok, map(), keyword()}
def like(actor, object) do def like(actor, object) do
with {:ok, data, meta} <- object_action(actor, object) do
data =
data
|> Map.put("type", "Like")
{:ok, data, meta}
end
end
@spec announce(User.t(), Object.t(), keyword()) :: {:ok, map(), keyword()}
def announce(actor, object, options \\ []) do
public? = Keyword.get(options, :public, false)
to =
cond do
actor.ap_id == Relay.relay_ap_id() ->
[actor.follower_address]
public? ->
[actor.follower_address, object.data["actor"], Pleroma.Constants.as_public()]
true ->
[actor.follower_address, object.data["actor"]]
end
{:ok,
%{
"id" => Utils.generate_activity_id(),
"actor" => actor.ap_id,
"object" => object.data["id"],
"to" => to,
"context" => object.data["context"],
"type" => "Announce",
"published" => Utils.make_date()
}, []}
end
@spec object_action(User.t(), Object.t()) :: {:ok, map(), keyword()}
defp object_action(actor, object) do
object_actor = User.get_cached_by_ap_id(object.data["actor"]) object_actor = User.get_cached_by_ap_id(object.data["actor"])
# Address the actor of the object, and our actor's follower collection if the post is public. # Address the actor of the object, and our actor's follower collection if the post is public.
@ -33,7 +137,6 @@ def like(actor, object) do
%{ %{
"id" => Utils.generate_activity_id(), "id" => Utils.generate_activity_id(),
"actor" => actor.ap_id, "actor" => actor.ap_id,
"type" => "Like",
"object" => object.data["id"], "object" => object.data["id"],
"to" => to, "to" => to,
"cc" => cc, "cc" => cc,

View file

@ -0,0 +1,97 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
require Logger
alias Pleroma.Config
@moduledoc "Detect new emojis by their shortcode and steals them"
@behaviour Pleroma.Web.ActivityPub.MRF
defp remote_host?(host), do: host != Config.get([Pleroma.Web.Endpoint, :url, :host])
defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], [])
defp steal_emoji({shortcode, url}) do
url = Pleroma.Web.MediaProxy.url(url)
{:ok, response} = Pleroma.HTTP.get(url)
size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000)
if byte_size(response.body) <= size_limit do
emoji_dir_path =
Config.get(
[:mrf_steal_emoji, :path],
Path.join(Config.get([:instance, :static_dir]), "emoji/stolen")
)
extension =
url
|> URI.parse()
|> Map.get(:path)
|> Path.basename()
|> Path.extname()
file_path = Path.join([emoji_dir_path, shortcode <> (extension || ".png")])
try do
:ok = File.write(file_path, response.body)
shortcode
rescue
e ->
Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}")
nil
end
else
Logger.debug(
"MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{
size_limit
} B)"
)
nil
end
rescue
e ->
Logger.warn("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}")
nil
end
@impl true
def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = message) do
host = URI.parse(actor).host
if remote_host?(host) and accept_host?(host) do
installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end)
new_emojis =
foreign_emojis
|> Enum.filter(fn {shortcode, _url} -> shortcode not in installed_emoji end)
|> Enum.filter(fn {shortcode, _url} ->
reject_emoji? =
Config.get([:mrf_steal_emoji, :rejected_shortcodes], [])
|> Enum.find(false, fn regex -> String.match?(shortcode, regex) end)
!reject_emoji?
end)
|> Enum.map(&steal_emoji(&1))
|> Enum.filter(& &1)
if !Enum.empty?(new_emojis) do
Logger.info("Stole new emojis: #{inspect(new_emojis)}")
Pleroma.Emoji.reload()
end
end
{:ok, message}
end
def filter(message), do: {:ok, message}
@impl true
def describe do
{:ok, %{}}
end
end

View file

@ -11,11 +11,36 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidator do
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator alias Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator
@spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()} @spec validate(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
def validate(object, meta) def validate(object, meta)
def validate(%{"type" => "Undo"} = object, meta) do
with {:ok, object} <-
object
|> UndoValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert) do
object = stringify_keys(object)
{:ok, object, meta}
end
end
def validate(%{"type" => "Delete"} = object, meta) do
with cng <- DeleteValidator.cast_and_validate(object),
do_not_federate <- DeleteValidator.do_not_federate?(cng),
{:ok, object} <- Ecto.Changeset.apply_action(cng, :insert) do
object = stringify_keys(object)
meta = Keyword.put(meta, :do_not_federate, do_not_federate)
{:ok, object, meta}
end
end
def validate(%{"type" => "Like"} = object, meta) do def validate(%{"type" => "Like"} = object, meta) do
with {:ok, object} <- with {:ok, object} <-
object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do object |> LikeValidator.cast_and_validate() |> Ecto.Changeset.apply_action(:insert) do
@ -24,14 +49,46 @@ def validate(%{"type" => "Like"} = object, meta) do
end end
end end
def validate(%{"type" => "EmojiReact"} = object, meta) do
with {:ok, object} <-
object
|> EmojiReactValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert) do
object = stringify_keys(object |> Map.from_struct())
{:ok, object, meta}
end
end
def validate(%{"type" => "Announce"} = object, meta) do
with {:ok, object} <-
object
|> AnnounceValidator.cast_and_validate()
|> Ecto.Changeset.apply_action(:insert) do
object = stringify_keys(object |> Map.from_struct())
{:ok, object, meta}
end
end
def stringify_keys(%{__struct__: _} = object) do
object
|> Map.from_struct()
|> stringify_keys
end
def stringify_keys(object) do def stringify_keys(object) do
object object
|> Map.new(fn {key, val} -> {to_string(key), val} end) |> Map.new(fn {key, val} -> {to_string(key), val} end)
end end
def fetch_actor(object) do
with {:ok, actor} <- Types.ObjectID.cast(object["actor"]) do
User.get_or_fetch_by_ap_id(actor)
end
end
def fetch_actor_and_object(object) do def fetch_actor_and_object(object) do
User.get_or_fetch_by_ap_id(object["actor"]) fetch_actor(object)
Object.normalize(object["object"]) Object.normalize(object["object"], true)
:ok :ok
end end
end end

View file

@ -0,0 +1,101 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.AnnounceValidator do
use Ecto.Schema
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
require Pleroma.Constants
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:object, Types.ObjectID)
field(:actor, Types.ObjectID)
field(:context, :string, autogenerate: {Utils, :generate_context_id, []})
field(:to, Types.Recipients, default: [])
field(:cc, Types.Recipients, default: [])
field(:published, Types.DateTime)
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
def cast_data(data) do
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
struct
|> cast(data, __schema__(:fields))
|> fix_after_cast()
end
def fix_after_cast(cng) do
cng
end
def validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["Announce"])
|> validate_required([:id, :type, :object, :actor, :to, :cc])
|> validate_actor_presence()
|> validate_object_presence()
|> validate_existing_announce()
|> validate_announcable()
end
def validate_announcable(cng) do
with actor when is_binary(actor) <- get_field(cng, :actor),
object when is_binary(object) <- get_field(cng, :object),
%User{} = actor <- User.get_cached_by_ap_id(actor),
%Object{} = object <- Object.get_cached_by_ap_id(object),
false <- Visibility.is_public?(object) do
same_actor = object.data["actor"] == actor.ap_id
is_public = Pleroma.Constants.as_public() in (get_field(cng, :to) ++ get_field(cng, :cc))
cond do
same_actor && is_public ->
cng
|> add_error(:actor, "can not announce this object publicly")
!same_actor ->
cng
|> add_error(:actor, "can not announce this object")
true ->
cng
end
else
_ -> cng
end
end
def validate_existing_announce(cng) do
actor = get_field(cng, :actor)
object = get_field(cng, :object)
if actor && object && Utils.get_existing_announce(actor, %{data: %{"id" => object}}) do
cng
|> add_error(:actor, "already announced this object")
|> add_error(:object, "already announced by this actor")
else
cng
end
end
end

View file

@ -5,10 +5,33 @@
defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do defmodule Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations do
import Ecto.Changeset import Ecto.Changeset
alias Pleroma.Activity
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
def validate_actor_presence(cng, field_name \\ :actor) do def validate_recipients_presence(cng, fields \\ [:to, :cc]) do
non_empty =
fields
|> Enum.map(fn field -> get_field(cng, field) end)
|> Enum.any?(fn
[] -> false
_ -> true
end)
if non_empty do
cng
else
fields
|> Enum.reduce(cng, fn field, cng ->
cng
|> add_error(field, "no recipients in any field")
end)
end
end
def validate_actor_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :actor)
cng cng
|> validate_change(field_name, fn field_name, actor -> |> validate_change(field_name, fn field_name, actor ->
if User.get_cached_by_ap_id(actor) do if User.get_cached_by_ap_id(actor) do
@ -19,14 +42,39 @@ def validate_actor_presence(cng, field_name \\ :actor) do
end) end)
end end
def validate_object_presence(cng, field_name \\ :object) do def validate_object_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :object)
allowed_types = Keyword.get(options, :allowed_types, false)
cng cng
|> validate_change(field_name, fn field_name, object -> |> validate_change(field_name, fn field_name, object_id ->
if Object.get_cached_by_ap_id(object) do object = Object.get_cached_by_ap_id(object_id) || Activity.get_by_ap_id(object_id)
[]
else cond do
[{field_name, "can't find object"}] !object ->
[{field_name, "can't find object"}]
object && allowed_types && object.data["type"] not in allowed_types ->
[{field_name, "object not in allowed types"}]
true ->
[]
end end
end) end)
end end
def validate_object_or_user_presence(cng, options \\ []) do
field_name = Keyword.get(options, :field_name, :object)
options = Keyword.put(options, :field_name, field_name)
actor_cng =
cng
|> validate_actor_presence(options)
object_cng =
cng
|> validate_object_presence(options)
if actor_cng.valid?, do: actor_cng, else: object_cng
end
end end

View file

@ -0,0 +1,100 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.DeleteValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:actor, Types.ObjectID)
field(:to, Types.Recipients, default: [])
field(:cc, Types.Recipients, default: [])
field(:deleted_activity_id, Types.ObjectID)
field(:object, Types.ObjectID)
end
def cast_data(data) do
%__MODULE__{}
|> cast(data, __schema__(:fields))
end
def add_deleted_activity_id(cng) do
object =
cng
|> get_field(:object)
with %Activity{id: id} <- Activity.get_create_by_object_ap_id(object) do
cng
|> put_change(:deleted_activity_id, id)
else
_ -> cng
end
end
@deletable_types ~w{
Answer
Article
Audio
Event
Note
Page
Question
Video
Tombstone
}
def validate_data(cng) do
cng
|> validate_required([:id, :type, :actor, :to, :cc, :object])
|> validate_inclusion(:type, ["Delete"])
|> validate_actor_presence()
|> validate_deletion_rights()
|> validate_object_or_user_presence(allowed_types: @deletable_types)
|> add_deleted_activity_id()
end
def do_not_federate?(cng) do
!same_domain?(cng)
end
defp same_domain?(cng) do
actor_uri =
cng
|> get_field(:actor)
|> URI.parse()
object_uri =
cng
|> get_field(:object)
|> URI.parse()
object_uri.host == actor_uri.host
end
def validate_deletion_rights(cng) do
actor = User.get_cached_by_ap_id(get_field(cng, :actor))
if User.superuser?(actor) || same_domain?(cng) do
cng
else
cng
|> add_error(:actor, "is not allowed to delete object")
end
end
def cast_and_validate(data) do
data
|> cast_data
|> validate_data
end
end

View file

@ -0,0 +1,81 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.EmojiReactValidator do
use Ecto.Schema
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:object, Types.ObjectID)
field(:actor, Types.ObjectID)
field(:context, :string)
field(:content, :string)
field(:to, {:array, :string}, default: [])
field(:cc, {:array, :string}, default: [])
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
def cast_data(data) do
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
struct
|> cast(data, __schema__(:fields))
|> fix_after_cast()
end
def fix_after_cast(cng) do
cng
|> fix_context()
end
def fix_context(cng) do
object = get_field(cng, :object)
with nil <- get_field(cng, :context),
%Object{data: %{"context" => context}} <- Object.get_cached_by_ap_id(object) do
cng
|> put_change(:context, context)
else
_ ->
cng
end
end
def validate_emoji(cng) do
content = get_field(cng, :content)
if Pleroma.Emoji.is_unicode_emoji?(content) do
cng
else
cng
|> add_error(:content, "must be a single character emoji")
end
end
def validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["EmojiReact"])
|> validate_required([:id, :type, :object, :actor, :context, :to, :cc, :content])
|> validate_actor_presence()
|> validate_object_presence()
|> validate_emoji()
end
end

View file

@ -20,8 +20,8 @@ defmodule Pleroma.Web.ActivityPub.ObjectValidators.LikeValidator do
field(:object, Types.ObjectID) field(:object, Types.ObjectID)
field(:actor, Types.ObjectID) field(:actor, Types.ObjectID)
field(:context, :string) field(:context, :string)
field(:to, {:array, :string}, default: []) field(:to, Types.Recipients, default: [])
field(:cc, {:array, :string}, default: []) field(:cc, Types.Recipients, default: [])
end end
def cast_and_validate(data) do def cast_and_validate(data) do

View file

@ -0,0 +1,34 @@
defmodule Pleroma.Web.ActivityPub.ObjectValidators.Types.Recipients do
use Ecto.Type
alias Pleroma.Web.ActivityPub.ObjectValidators.Types.ObjectID
def type, do: {:array, ObjectID}
def cast(object) when is_binary(object) do
cast([object])
end
def cast(data) when is_list(data) do
data
|> Enum.reduce({:ok, []}, fn element, acc ->
case {acc, ObjectID.cast(element)} do
{:error, _} -> :error
{_, :error} -> :error
{{:ok, list}, {:ok, id}} -> {:ok, [id | list]}
end
end)
end
def cast(_) do
:error
end
def dump(data) do
{:ok, data}
end
def load(data) do
{:ok, data}
end
end

View file

@ -0,0 +1,62 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ActivityPub.ObjectValidators.UndoValidator do
use Ecto.Schema
alias Pleroma.Activity
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
import Ecto.Changeset
import Pleroma.Web.ActivityPub.ObjectValidators.CommonValidations
@primary_key false
embedded_schema do
field(:id, Types.ObjectID, primary_key: true)
field(:type, :string)
field(:object, Types.ObjectID)
field(:actor, Types.ObjectID)
field(:to, {:array, :string}, default: [])
field(:cc, {:array, :string}, default: [])
end
def cast_and_validate(data) do
data
|> cast_data()
|> validate_data()
end
def cast_data(data) do
%__MODULE__{}
|> changeset(data)
end
def changeset(struct, data) do
struct
|> cast(data, __schema__(:fields))
end
def validate_data(data_cng) do
data_cng
|> validate_inclusion(:type, ["Undo"])
|> validate_required([:id, :type, :object, :actor, :to, :cc])
|> validate_actor_presence()
|> validate_object_presence()
|> validate_undo_rights()
end
def validate_undo_rights(cng) do
actor = get_field(cng, :actor)
object = get_field(cng, :object)
with %Activity{data: %{"actor" => object_actor}} <- Activity.get_by_ap_id(object),
true <- object_actor != actor do
cng
|> add_error(:actor, "not the same as object actor")
else
_ -> cng
end
end
end

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Web.ActivityPub.Pipeline do defmodule Pleroma.Web.ActivityPub.Pipeline do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -44,7 +45,9 @@ defp maybe_federate(%Object{}, _), do: {:ok, :not_federated}
defp maybe_federate(%Activity{} = activity, meta) do defp maybe_federate(%Activity{} = activity, meta) do
with {:ok, local} <- Keyword.fetch(meta, :local) do with {:ok, local} <- Keyword.fetch(meta, :local) do
if local do do_not_federate = meta[:do_not_federate] || !Config.get([:instance, :federating])
if !do_not_federate && local do
Federator.publish(activity) Federator.publish(activity)
{:ok, :federated} {:ok, :federated}
else else

View file

@ -4,9 +4,10 @@
defmodule Pleroma.Web.ActivityPub.Relay do defmodule Pleroma.Web.ActivityPub.Relay do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
require Logger require Logger
@relay_nickname "relay" @relay_nickname "relay"
@ -48,11 +49,11 @@ def unfollow(target_instance) do
end end
end end
@spec publish(any()) :: {:ok, Activity.t(), Object.t()} | {:error, any()} @spec publish(any()) :: {:ok, Activity.t()} | {:error, any()}
def publish(%Activity{data: %{"type" => "Create"}} = activity) do def publish(%Activity{data: %{"type" => "Create"}} = activity) do
with %User{} = user <- get_actor(), with %User{} = user <- get_actor(),
%Object{} = object <- Object.normalize(activity) do true <- Visibility.is_public?(activity) do
ActivityPub.announce(user, object, nil, true, false) CommonAPI.repeat(activity.id, user)
else else
error -> format_error(error) error -> format_error(error)
end end

View file

@ -5,8 +5,12 @@ defmodule Pleroma.Web.ActivityPub.SideEffects do
liked object, a `Follow` activity will add the user to the follower liked object, a `Follow` activity will add the user to the follower
collection, and so on. collection, and so on.
""" """
alias Pleroma.Activity
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
def handle(object, meta \\ []) def handle(object, meta \\ [])
@ -23,8 +27,125 @@ def handle(%{data: %{"type" => "Like"}} = object, meta) do
{:ok, object, meta} {:ok, object, meta}
end end
# Tasks this handles:
# - Add announce to object
# - Set up notification
# - Stream out the announce
def handle(%{data: %{"type" => "Announce"}} = object, meta) do
announced_object = Object.get_by_ap_id(object.data["object"])
user = User.get_cached_by_ap_id(object.data["actor"])
Utils.add_announce_to_object(object, announced_object)
if !User.is_internal_user?(user) do
Notification.create_notifications(object)
ActivityPub.stream_out(object)
end
{:ok, object, meta}
end
def handle(%{data: %{"type" => "Undo", "object" => undone_object}} = object, meta) do
with undone_object <- Activity.get_by_ap_id(undone_object),
:ok <- handle_undoing(undone_object) do
{:ok, object, meta}
end
end
# Tasks this handles:
# - Add reaction to object
# - Set up notification
def handle(%{data: %{"type" => "EmojiReact"}} = object, meta) do
reacted_object = Object.get_by_ap_id(object.data["object"])
Utils.add_emoji_reaction_to_object(object, reacted_object)
Notification.create_notifications(object)
{:ok, object, meta}
end
# Tasks this handles:
# - Delete and unpins the create activity
# - Replace object with Tombstone
# - Set up notification
# - Reduce the user note count
# - Reduce the reply count
# - Stream out the activity
def handle(%{data: %{"type" => "Delete", "object" => deleted_object}} = object, meta) do
deleted_object =
Object.normalize(deleted_object, false) || User.get_cached_by_ap_id(deleted_object)
result =
case deleted_object do
%Object{} ->
with {:ok, deleted_object, activity} <- Object.delete(deleted_object),
%User{} = user <- User.get_cached_by_ap_id(deleted_object.data["actor"]) do
User.remove_pinnned_activity(user, activity)
{:ok, user} = ActivityPub.decrease_note_count_if_public(user, deleted_object)
if in_reply_to = deleted_object.data["inReplyTo"] do
Object.decrease_replies_count(in_reply_to)
end
ActivityPub.stream_out(object)
ActivityPub.stream_out_participations(deleted_object, user)
:ok
end
%User{} ->
with {:ok, _} <- User.delete(deleted_object) do
:ok
end
end
if result == :ok do
Notification.create_notifications(object)
{:ok, object, meta}
else
{:error, result}
end
end
# Nothing to do # Nothing to do
def handle(object, meta) do def handle(object, meta) do
{:ok, object, meta} {:ok, object, meta}
end end
def handle_undoing(%{data: %{"type" => "Like"}} = object) do
with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_like_from_object(object, liked_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(%{data: %{"type" => "EmojiReact"}} = object) do
with %Object{} = reacted_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_emoji_reaction_from_object(object, reacted_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(%{data: %{"type" => "Announce"}} = object) do
with %Object{} = liked_object <- Object.get_by_ap_id(object.data["object"]),
{:ok, _} <- Utils.remove_announce_from_object(object, liked_object),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(
%{data: %{"type" => "Block", "actor" => blocker, "object" => blocked}} = object
) do
with %User{} = blocker <- User.get_cached_by_ap_id(blocker),
%User{} = blocked <- User.get_cached_by_ap_id(blocked),
{:ok, _} <- User.unblock(blocker, blocked),
{:ok, _} <- Repo.delete(object) do
:ok
end
end
def handle_undoing(object), do: {:error, ["don't know how to handle", object]}
end end

View file

@ -14,7 +14,9 @@ defmodule Pleroma.Web.ActivityPub.Transmogrifier do
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.ObjectValidator alias Pleroma.Web.ActivityPub.ObjectValidator
alias Pleroma.Web.ActivityPub.ObjectValidators.Types
alias Pleroma.Web.ActivityPub.Pipeline alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
@ -590,6 +592,9 @@ def handle_incoming(
{:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"), {:ok, follow_activity} <- Utils.update_follow_state_for_all(follow_activity, "accept"),
%User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]), %User{local: true} = follower <- User.get_cached_by_ap_id(follow_activity.data["actor"]),
{:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do {:ok, _relationship} <- FollowingRelationship.update(follower, followed, :follow_accept) do
User.update_follower_count(followed)
User.update_following_count(follower)
ActivityPub.accept(%{ ActivityPub.accept(%{
to: follow_activity.data["to"], to: follow_activity.data["to"],
type: "Accept", type: "Accept",
@ -599,7 +604,8 @@ def handle_incoming(
activity_id: id activity_id: id
}) })
else else
_e -> :error _e ->
:error
end end
end end
@ -656,7 +662,8 @@ def handle_incoming(
|> handle_incoming(options) |> handle_incoming(options)
end end
def handle_incoming(%{"type" => "Like"} = data, _options) do def handle_incoming(%{"type" => type} = data, _options)
when type in ["Like", "EmojiReact", "Announce"] do
with :ok <- ObjectValidator.fetch_actor_and_object(data), with :ok <- ObjectValidator.fetch_actor_and_object(data),
{:ok, activity, _meta} <- {:ok, activity, _meta} <-
Pipeline.common_pipeline(data, local: false) do Pipeline.common_pipeline(data, local: false) do
@ -666,42 +673,6 @@ def handle_incoming(%{"type" => "Like"} = data, _options) do
end end
end end
def handle_incoming(
%{
"type" => "EmojiReact",
"object" => object_id,
"actor" => _actor,
"id" => id,
"content" => emoji
} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
{:ok, activity, _object} <-
ActivityPub.react_with_emoji(actor, object, emoji, activity_id: id, local: false) do
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => id} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_embedded_obj_helper(object_id, actor),
public <- Visibility.is_public?(data),
{:ok, activity, _object} <- ActivityPub.announce(actor, object, id, false, public) do
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming( def handle_incoming(
%{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} = %{"type" => "Update", "object" => %{"type" => object_type} = object, "actor" => actor_id} =
data, data,
@ -735,55 +706,25 @@ def handle_incoming(
end end
end end
# TODO: We presently assume that any actor on the same origin domain as the object being
# deleted has the rights to delete that object. A better way to validate whether or not
# the object should be deleted is to refetch the object URI, which should return either
# an error or a tombstone. This would allow us to verify that a deletion actually took
# place.
def handle_incoming( def handle_incoming(
%{"type" => "Delete", "object" => object_id, "actor" => actor, "id" => id} = data, %{"type" => "Delete"} = data,
_options _options
) do ) do
object_id = Utils.get_ap_id(object_id) with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
:ok <- Containment.contain_origin(actor.ap_id, object.data),
{:ok, activity} <-
ActivityPub.delete(object, local: false, activity_id: id, actor: actor.ap_id) do
{:ok, activity} {:ok, activity}
else else
nil -> {:error, {:validate_object, _}} = e ->
case User.get_cached_by_ap_id(object_id) do # Check if we have a create activity for this
%User{ap_id: ^actor} = user -> with {:ok, object_id} <- Types.ObjectID.cast(data["object"]),
User.delete(user) %Activity{data: %{"actor" => actor}} <-
Activity.create_by_object_ap_id(object_id) |> Repo.one(),
nil -> # We have one, insert a tombstone and retry
:error {:ok, tombstone_data, _} <- Builder.tombstone(actor, object_id),
{:ok, _tombstone} <- Object.create(tombstone_data) do
handle_incoming(data)
else
_ -> e
end end
_e ->
:error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Announce", "object" => object_id},
"actor" => _actor,
"id" => id
} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
{:ok, activity, _} <- ActivityPub.unannounce(actor, object, id, false) do
{:ok, activity}
else
_e -> :error
end end
end end
@ -809,75 +750,13 @@ def handle_incoming(
def handle_incoming( def handle_incoming(
%{ %{
"type" => "Undo", "type" => "Undo",
"object" => %{"type" => "EmojiReact", "id" => reaction_activity_id}, "object" => %{"type" => type}
"actor" => _actor,
"id" => id
} = data, } = data,
_options _options
) do )
with actor <- Containment.get_actor(data), when type in ["Like", "EmojiReact", "Announce", "Block"] do
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor), with {:ok, activity, _} <- Pipeline.common_pipeline(data, local: false) do
{:ok, activity, _} <-
ActivityPub.unreact_with_emoji(actor, reaction_activity_id,
activity_id: id,
local: false
) do
{:ok, activity} {:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Block", "object" => blocked},
"actor" => blocker,
"id" => id
} = _data,
_options
) do
with %User{local: true} = blocked <- User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} <- User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.unblock(blocker, blocked, id, false) do
User.unblock(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data,
_options
) do
with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
User.unfollow(blocker, blocked)
User.block(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming(
%{
"type" => "Undo",
"object" => %{"type" => "Like", "object" => object_id},
"actor" => _actor,
"id" => id
} = data,
_options
) do
with actor <- Containment.get_actor(data),
{:ok, %User{} = actor} <- User.get_or_fetch_by_ap_id(actor),
{:ok, object} <- get_obj_helper(object_id),
{:ok, activity, _, _} <- ActivityPub.unlike(actor, object, id, false) do
{:ok, activity}
else
_e -> :error
end end
end end
@ -899,6 +778,21 @@ def handle_incoming(
end end
end end
def handle_incoming(
%{"type" => "Block", "object" => blocked, "actor" => blocker, "id" => id} = _data,
_options
) do
with %User{local: true} = blocked = User.get_cached_by_ap_id(blocked),
{:ok, %User{} = blocker} = User.get_or_fetch_by_ap_id(blocker),
{:ok, activity} <- ActivityPub.block(blocker, blocked, id, false) do
User.unfollow(blocker, blocked)
User.block(blocker, blocked)
{:ok, activity}
else
_e -> :error
end
end
def handle_incoming( def handle_incoming(
%{ %{
"type" => "Move", "type" => "Move",
@ -1151,10 +1045,14 @@ def add_hashtags(object) do
Map.put(object, "tag", tags) Map.put(object, "tag", tags)
end end
# TODO These should be added on our side on insertion, it doesn't make much
# sense to regenerate these all the time
def add_mention_tags(object) do def add_mention_tags(object) do
{enabled_receivers, disabled_receivers} = Utils.get_notified_from_object(object) to = object["to"] || []
potential_receivers = enabled_receivers ++ disabled_receivers cc = object["cc"] || []
mentions = Enum.map(potential_receivers, &build_mention_tag/1) mentioned = User.get_users_from_set(to ++ cc, local_only: false)
mentions = Enum.map(mentioned, &build_mention_tag/1)
tags = object["tag"] || [] tags = object["tag"] || []
Map.put(object, "tag", tags ++ mentions) Map.put(object, "tag", tags ++ mentions)
@ -1195,6 +1093,10 @@ def set_conversation(object) do
Map.put(object, "conversation", object["context"]) Map.put(object, "conversation", object["context"])
end end
def set_sensitive(%{"sensitive" => true} = object) do
object
end
def set_sensitive(object) do def set_sensitive(object) do
tags = object["tag"] || [] tags = object["tag"] || []
Map.put(object, "sensitive", "nsfw" in tags) Map.put(object, "sensitive", "nsfw" in tags)

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.ActivityPub.Utils do
alias Ecto.Changeset alias Ecto.Changeset
alias Ecto.UUID alias Ecto.UUID
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
@ -169,8 +170,11 @@ def create_context(context) do
Enqueues an activity for federation if it's local Enqueues an activity for federation if it's local
""" """
@spec maybe_federate(any()) :: :ok @spec maybe_federate(any()) :: :ok
def maybe_federate(%Activity{local: true} = activity) do def maybe_federate(%Activity{local: true, data: %{"type" => type}} = activity) do
if Pleroma.Config.get!([:instance, :federating]) do outgoing_blocks = Config.get([:activitypub, :outgoing_blocks])
with true <- Config.get!([:instance, :federating]),
true <- type != "Block" || outgoing_blocks do
Pleroma.Web.Federator.publish(activity) Pleroma.Web.Federator.publish(activity)
end end
@ -512,7 +516,7 @@ def get_latest_reaction(internal_activity_id, %{ap_id: ap_id}, emoji) do
#### Announce-related helpers #### Announce-related helpers
@doc """ @doc """
Retruns an existing announce activity if the notice has already been announced Returns an existing announce activity if the notice has already been announced
""" """
@spec get_existing_announce(String.t(), map()) :: Activity.t() | nil @spec get_existing_announce(String.t(), map()) :: Activity.t() | nil
def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do def get_existing_announce(actor, %{data: %{"id" => ap_id}}) do
@ -562,45 +566,6 @@ def make_announce_data(
|> maybe_put("id", activity_id) |> maybe_put("id", activity_id)
end end
@doc """
Make unannounce activity data for the given actor and object
"""
def make_unannounce_data(
%User{ap_id: ap_id} = user,
%Activity{data: %{"context" => context, "object" => object}} = activity,
activity_id
) do
object = Object.normalize(object)
%{
"type" => "Undo",
"actor" => ap_id,
"object" => activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => context
}
|> maybe_put("id", activity_id)
end
def make_unlike_data(
%User{ap_id: ap_id} = user,
%Activity{data: %{"context" => context, "object" => object}} = activity,
activity_id
) do
object = Object.normalize(object)
%{
"type" => "Undo",
"actor" => ap_id,
"object" => activity.data,
"to" => [user.follower_address, object.data["actor"]],
"cc" => [Pleroma.Constants.as_public()],
"context" => context
}
|> maybe_put("id", activity_id)
end
def make_undo_data( def make_undo_data(
%User{ap_id: actor, follower_address: follower_address}, %User{ap_id: actor, follower_address: follower_address},
%Activity{ %Activity{
@ -688,16 +653,6 @@ def make_block_data(blocker, blocked, activity_id) do
|> maybe_put("id", activity_id) |> maybe_put("id", activity_id)
end end
def make_unblock_data(blocker, blocked, block_activity, activity_id) do
%{
"type" => "Undo",
"actor" => blocker.ap_id,
"to" => [blocked.ap_id],
"object" => block_activity.data
}
|> maybe_put("id", activity_id)
end
#### Create-related helpers #### Create-related helpers
def make_create_data(params, additional) do def make_create_data(params, additional) do

View file

@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.ConfigDB alias Pleroma.ConfigDB
alias Pleroma.MFA
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.ReportNote alias Pleroma.ReportNote
@ -17,8 +18,11 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
alias Pleroma.Web.ActivityPub.Pipeline
alias Pleroma.Web.ActivityPub.Relay alias Pleroma.Web.ActivityPub.Relay
alias Pleroma.Web.ActivityPub.Utils alias Pleroma.Web.ActivityPub.Utils
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.AdminAPI.ConfigView alias Pleroma.Web.AdminAPI.ConfigView
alias Pleroma.Web.AdminAPI.ModerationLogView alias Pleroma.Web.AdminAPI.ModerationLogView
@ -27,14 +31,14 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Web.AdminAPI.Search alias Pleroma.Web.AdminAPI.Search
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Endpoint alias Pleroma.Web.Endpoint
alias Pleroma.Web.MastodonAPI
alias Pleroma.Web.MastodonAPI.AppView alias Pleroma.Web.MastodonAPI.AppView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.App
alias Pleroma.Web.Router alias Pleroma.Web.Router
require Logger require Logger
@descriptions_json Pleroma.Docs.JSON.compile() @descriptions Pleroma.Docs.JSON.compile()
@users_page_size 50 @users_page_size 50
plug( plug(
@ -59,6 +63,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
:right_add, :right_add,
:right_add_multiple, :right_add_multiple,
:right_delete, :right_delete,
:disable_mfa,
:right_delete_multiple, :right_delete_multiple,
:update_user_credentials :update_user_credentials
] ]
@ -93,13 +98,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:statuses"], admin: true} %{scopes: ["read:statuses"], admin: true}
when action in [:list_statuses, :list_user_statuses, :list_instance_statuses] when action in [:list_user_statuses, :list_instance_statuses]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"], admin: true}
when action in [:status_update, :status_delete]
) )
plug( plug(
@ -131,25 +130,22 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
] ]
) )
action_fallback(:errors) action_fallback(AdminAPI.FallbackController)
def user_delete(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do def user_delete(conn, %{"nickname" => nickname}) do
user = User.get_cached_by_nickname(nickname) user_delete(conn, %{"nicknames" => [nickname]})
User.delete(user)
ModerationLog.insert_log(%{
actor: admin,
subject: [user],
action: "delete"
})
conn
|> json(nickname)
end end
def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do def user_delete(%{assigns: %{user: admin}} = conn, %{"nicknames" => nicknames}) do
users = nicknames |> Enum.map(&User.get_cached_by_nickname/1) users =
User.delete(users) nicknames
|> Enum.map(&User.get_cached_by_nickname/1)
users
|> Enum.each(fn user ->
{:ok, delete_data, _} = Builder.delete(admin, user.ap_id)
Pipeline.common_pipeline(delete_data, local: true)
end)
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
actor: admin, actor: admin,
@ -279,8 +275,8 @@ def list_instance_statuses(conn, %{"instance" => instance} = params) do
}) })
conn conn
|> put_view(Pleroma.Web.AdminAPI.StatusView) |> put_view(AdminAPI.StatusView)
|> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) |> render("index.json", %{activities: activities, as: :activity})
end end
def list_user_statuses(conn, %{"nickname" => nickname} = params) do def list_user_statuses(conn, %{"nickname" => nickname} = params) do
@ -298,8 +294,8 @@ def list_user_statuses(conn, %{"nickname" => nickname} = params) do
}) })
conn conn
|> put_view(StatusView) |> put_view(MastodonAPI.StatusView)
|> render("index.json", %{activities: activities, as: :activity, skip_relationships: false}) |> render("index.json", %{activities: activities, as: :activity})
else else
_ -> {:error, :not_found} _ -> {:error, :not_found}
end end
@ -392,29 +388,12 @@ def list_users(conn, params) do
email: params["email"] email: params["email"]
} }
with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)), with {:ok, users, count} <- Search.user(Map.merge(search_params, filters)) do
{:ok, users, count} <- filter_service_users(users, count), json(
do: conn,
conn AccountView.render("index.json", users: users, count: count, page_size: page_size)
|> json( )
AccountView.render("index.json", end
users: users,
count: count,
page_size: page_size
)
)
end
defp filter_service_users(users, count) do
filtered_users = Enum.reject(users, &service_user?/1)
count = if Enum.any?(users, &service_user?/1), do: length(filtered_users), else: count
{:ok, filtered_users, count}
end
defp service_user?(user) do
String.match?(user.ap_id, ~r/.*\/relay$/) or
String.match?(user.ap_id, ~r/.*\/internal\/fetch$/)
end end
@filters ~w(local external active deactivated is_admin is_moderator) @filters ~w(local external active deactivated is_admin is_moderator)
@ -612,16 +591,10 @@ def email_invite(%{assigns: %{user: user}} = conn, %{"email" => email} = params)
json_response(conn, :no_content, "") json_response(conn, :no_content, "")
else else
{:registrations_open, _} -> {:registrations_open, _} ->
errors( {:error, "To send invites you need to set the `registrations_open` option to false."}
conn,
{:error, "To send invites you need to set the `registrations_open` option to false."}
)
{:invites_enabled, _} -> {:invites_enabled, _} ->
errors( {:error, "To send invites you need to set the `invites_enabled` option to true."}
conn,
{:error, "To send invites you need to set the `invites_enabled` option to true."}
)
end end
end end
@ -692,6 +665,18 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic
json_response(conn, :no_content, "") json_response(conn, :no_content, "")
end end
@doc "Disable mfa for user's account."
def disable_mfa(conn, %{"nickname" => nickname}) do
case User.get_by_nickname(nickname) do
%User{} = user ->
MFA.disable(user)
json(conn, nickname)
_ ->
{:error, :not_found}
end
end
@doc "Show a given user's credentials" @doc "Show a given user's credentials"
def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do
@ -708,7 +693,7 @@ def update_user_credentials(
%{assigns: %{user: admin}} = conn, %{assigns: %{user: admin}} = conn,
%{"nickname" => nickname} = params %{"nickname" => nickname} = params
) do ) do
with {_, user} <- {:user, User.get_cached_by_nickname(nickname)}, with {_, %User{} = user} <- {:user, User.get_cached_by_nickname(nickname)},
{:ok, _user} <- {:ok, _user} <-
User.update_as_admin(user, params) do User.update_as_admin(user, params) do
ModerationLog.insert_log(%{ ModerationLog.insert_log(%{
@ -730,11 +715,12 @@ def update_user_credentials(
json(conn, %{status: "success"}) json(conn, %{status: "success"})
else else
{:error, changeset} -> {:error, changeset} ->
{_, {error, _}} = Enum.at(changeset.errors, 0) errors = Map.new(changeset.errors, fn {key, {error, _}} -> {key, error} end)
json(conn, %{error: "New password #{error}."})
json(conn, %{errors: errors})
_ -> _ ->
json(conn, %{error: "Unable to change password."}) json(conn, %{error: "Unable to update user."})
end end
end end
@ -817,56 +803,6 @@ def report_notes_delete(%{assigns: %{user: user}} = conn, %{
end end
end end
def list_statuses(%{assigns: %{user: _admin}} = conn, params) do
godmode = params["godmode"] == "true" || params["godmode"] == true
local_only = params["local_only"] == "true" || params["local_only"] == true
with_reblogs = params["with_reblogs"] == "true" || params["with_reblogs"] == true
{page, page_size} = page_params(params)
activities =
ActivityPub.fetch_statuses(nil, %{
"godmode" => godmode,
"local_only" => local_only,
"limit" => page_size,
"offset" => (page - 1) * page_size,
"exclude_reblogs" => !with_reblogs && "true"
})
conn
|> put_view(Pleroma.Web.AdminAPI.StatusView)
|> render("index.json", %{activities: activities, as: :activity, skip_relationships: false})
end
def status_update(%{assigns: %{user: admin}} = conn, %{"id" => id} = params) do
with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do
{:ok, sensitive} = Ecto.Type.cast(:boolean, params["sensitive"])
ModerationLog.insert_log(%{
action: "status_update",
actor: admin,
subject: activity,
sensitive: sensitive,
visibility: params["visibility"]
})
conn
|> put_view(StatusView)
|> render("show.json", %{activity: activity})
end
end
def status_delete(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
ModerationLog.insert_log(%{
action: "status_delete",
actor: user,
subject_id: id
})
json(conn, %{})
end
end
def list_log(conn, params) do def list_log(conn, params) do
{page, page_size} = page_params(params) {page, page_size} = page_params(params)
@ -886,13 +822,13 @@ def list_log(conn, params) do
end end
def config_descriptions(conn, _params) do def config_descriptions(conn, _params) do
conn descriptions = Enum.filter(@descriptions, &whitelisted_config?/1)
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, @descriptions_json) json(conn, descriptions)
end end
def config_show(conn, %{"only_db" => true}) do def config_show(conn, %{"only_db" => true}) do
with :ok <- configurable_from_database(conn) do with :ok <- configurable_from_database() do
configs = Pleroma.Repo.all(ConfigDB) configs = Pleroma.Repo.all(ConfigDB)
conn conn
@ -902,7 +838,7 @@ def config_show(conn, %{"only_db" => true}) do
end end
def config_show(conn, _params) do def config_show(conn, _params) do
with :ok <- configurable_from_database(conn) do with :ok <- configurable_from_database() do
configs = ConfigDB.get_all_as_keyword() configs = ConfigDB.get_all_as_keyword()
merged = merged =
@ -941,9 +877,11 @@ def config_show(conn, _params) do
end end
def config_update(conn, %{"configs" => configs}) do def config_update(conn, %{"configs" => configs}) do
with :ok <- configurable_from_database(conn) do with :ok <- configurable_from_database() do
{_errors, results} = {_errors, results} =
Enum.map(configs, fn configs
|> Enum.filter(&whitelisted_config?/1)
|> Enum.map(fn
%{"group" => group, "key" => key, "delete" => true} = params -> %{"group" => group, "key" => key, "delete" => true} = params ->
ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]}) ConfigDB.delete(%{group: group, key: key, subkeys: params["subkeys"]})
@ -983,7 +921,7 @@ def config_update(conn, %{"configs" => configs}) do
end end
def restart(conn, _params) do def restart(conn, _params) do
with :ok <- configurable_from_database(conn) do with :ok <- configurable_from_database() do
Restarter.Pleroma.restart(Config.get(:env), 50) Restarter.Pleroma.restart(Config.get(:env), 50)
json(conn, %{}) json(conn, %{})
@ -994,17 +932,36 @@ def need_reboot(conn, _params) do
json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()}) json(conn, %{need_reboot: Restarter.Pleroma.need_reboot?()})
end end
defp configurable_from_database(conn) do defp configurable_from_database do
if Config.get(:configurable_from_database) do if Config.get(:configurable_from_database) do
:ok :ok
else else
errors( {:error, "To use this endpoint you need to enable configuration from database."}
conn,
{:error, "To use this endpoint you need to enable configuration from database."}
)
end end
end end
defp whitelisted_config?(group, key) do
if whitelisted_configs = Config.get(:database_config_whitelist) do
Enum.any?(whitelisted_configs, fn
{whitelisted_group} ->
group == inspect(whitelisted_group)
{whitelisted_group, whitelisted_key} ->
group == inspect(whitelisted_group) && key == inspect(whitelisted_key)
end)
else
true
end
end
defp whitelisted_config?(%{"group" => group, "key" => key}) do
whitelisted_config?(group, key)
end
defp whitelisted_config?(%{:group => group} = config) do
whitelisted_config?(group, config[:key])
end
def reload_emoji(conn, _params) do def reload_emoji(conn, _params) do
Pleroma.Emoji.reload() Pleroma.Emoji.reload()
@ -1123,30 +1080,6 @@ def stats(conn, _) do
|> json(%{"status_visibility" => count}) |> json(%{"status_visibility" => count})
end end
defp errors(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(dgettext("errors", "Not found"))
end
defp errors(conn, {:error, reason}) do
conn
|> put_status(:bad_request)
|> json(reason)
end
defp errors(conn, {:param_cast, _}) do
conn
|> put_status(:bad_request)
|> json(dgettext("errors", "Invalid parameters"))
end
defp errors(conn, _) do
conn
|> put_status(:internal_server_error)
|> json(dgettext("errors", "Something went wrong"))
end
defp page_params(params) do defp page_params(params) do
{get_page(params["page"]), get_page_size(params["page_size"])} {get_page(params["page"]), get_page_size(params["page_size"])}
end end

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.FallbackController do
use Pleroma.Web, :controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> json(%{error: dgettext("errors", "Not found")})
end
def call(conn, {:error, reason}) do
conn
|> put_status(:bad_request)
|> json(%{error: reason})
end
def call(conn, {:param_cast, _}) do
conn
|> put_status(:bad_request)
|> json(dgettext("errors", "Invalid parameters"))
end
def call(conn, _) do
conn
|> put_status(:internal_server_error)
|> json(%{error: dgettext("errors", "Something went wrong")})
end
end

View file

@ -0,0 +1,79 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.AdminAPI.StatusController do
use Pleroma.Web, :controller
alias Pleroma.Activity
alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI
require Logger
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(OAuthScopesPlug, %{scopes: ["read:statuses"], admin: true} when action in [:index, :show])
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"], admin: true} when action in [:update, :delete]
)
action_fallback(Pleroma.Web.AdminAPI.FallbackController)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.Admin.StatusOperation
def index(%{assigns: %{user: _admin}} = conn, params) do
activities =
ActivityPub.fetch_statuses(nil, %{
"godmode" => params.godmode,
"local_only" => params.local_only,
"limit" => params.page_size,
"offset" => (params.page - 1) * params.page_size,
"exclude_reblogs" => not params.with_reblogs
})
render(conn, "index.json", activities: activities, as: :activity)
end
def show(conn, %{id: id}) do
with %Activity{} = activity <- Activity.get_by_id(id) do
conn
|> put_view(MastodonAPI.StatusView)
|> render("show.json", %{activity: activity})
else
nil -> {:error, :not_found}
end
end
def update(%{assigns: %{user: admin}, body_params: params} = conn, %{id: id}) do
with {:ok, activity} <- CommonAPI.update_activity_scope(id, params) do
ModerationLog.insert_log(%{
action: "status_update",
actor: admin,
subject: activity,
sensitive: params[:sensitive],
visibility: params[:visibility]
})
conn
|> put_view(MastodonAPI.StatusView)
|> render("show.json", %{activity: activity})
end
end
def delete(%{assigns: %{user: user}} = conn, %{id: id}) do
with {:ok, %Activity{}} <- CommonAPI.delete(id, user) do
ModerationLog.insert_log(%{
action: "status_delete",
actor: user,
subject_id: id
})
json(conn, %{})
end
end
end

View file

@ -21,6 +21,7 @@ def user(params \\ %{}) do
query = query =
params params
|> Map.drop([:page, :page_size]) |> Map.drop([:page, :page_size])
|> Map.put(:exclude_service_users, true)
|> User.Query.build() |> User.Query.build()
|> order_by([u], u.nickname) |> order_by([u], u.nickname)

View file

@ -6,7 +6,9 @@ defmodule Pleroma.Web.AdminAPI.AccountView do
use Pleroma.Web, :view use Pleroma.Web, :view
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.AccountView alias Pleroma.Web.AdminAPI.AccountView
alias Pleroma.Web.MastodonAPI
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
def render("index.json", %{users: users, count: count, page_size: page_size}) do def render("index.json", %{users: users, count: count, page_size: page_size}) do
@ -119,6 +121,13 @@ def render("create-error.json", %{changeset: %Ecto.Changeset{changes: changes, e
} }
end end
def merge_account_views(%User{} = user) do
MastodonAPI.AccountView.render("show.json", %{user: user})
|> Map.merge(AdminAPI.AccountView.render("show.json", %{user: user}))
end
def merge_account_views(_), do: %{}
defp parse_error([]), do: "" defp parse_error([]), do: ""
defp parse_error(errors) do defp parse_error(errors) do

View file

@ -7,10 +7,13 @@ defmodule Pleroma.Web.AdminAPI.ReportView do
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.AdminAPI
alias Pleroma.Web.AdminAPI.Report alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.CommonAPI.Utils alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
defdelegate merge_account_views(user), to: AdminAPI.AccountView
def render("index.json", %{reports: reports}) do def render("index.json", %{reports: reports}) do
%{ %{
reports: reports:
@ -41,8 +44,7 @@ def render("show.json", %{report: report, user: user, account: account, statuses
statuses: statuses:
StatusView.render("index.json", %{ StatusView.render("index.json", %{
activities: statuses, activities: statuses,
as: :activity, as: :activity
skip_relationships: false
}), }),
state: report.data["state"], state: report.data["state"],
notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes}) notes: render(__MODULE__, "index_notes.json", %{notes: report.report_notes})
@ -70,11 +72,4 @@ def render("show_note.json", %{
created_at: Utils.to_masto_date(inserted_at) created_at: Utils.to_masto_date(inserted_at)
} }
end end
defp merge_account_views(%User{} = user) do
Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user})
|> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}))
end
defp merge_account_views(_), do: %{}
end end

View file

@ -7,24 +7,19 @@ defmodule Pleroma.Web.AdminAPI.StatusView do
require Pleroma.Constants require Pleroma.Constants
alias Pleroma.User alias Pleroma.Web.AdminAPI
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI
defdelegate merge_account_views(user), to: AdminAPI.AccountView
def render("index.json", opts) do def render("index.json", opts) do
safe_render_many(opts.activities, __MODULE__, "show.json", opts) safe_render_many(opts.activities, __MODULE__, "show.json", opts)
end end
def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
user = StatusView.get_user(activity.data["actor"]) user = MastodonAPI.StatusView.get_user(activity.data["actor"])
StatusView.render("show.json", opts) MastodonAPI.StatusView.render("show.json", opts)
|> Map.merge(%{account: merge_account_views(user)}) |> Map.merge(%{account: merge_account_views(user)})
end end
defp merge_account_views(%User{} = user) do
Pleroma.Web.MastodonAPI.AccountView.render("show.json", %{user: user})
|> Map.merge(Pleroma.Web.AdminAPI.AccountView.render("show.json", %{user: user}))
end
defp merge_account_views(_), do: %{}
end end

View file

@ -39,7 +39,12 @@ def spec do
password: %OpenApiSpex.OAuthFlow{ password: %OpenApiSpex.OAuthFlow{
authorizationUrl: "/oauth/authorize", authorizationUrl: "/oauth/authorize",
tokenUrl: "/oauth/token", tokenUrl: "/oauth/token",
scopes: %{"read" => "read", "write" => "write", "follow" => "follow"} scopes: %{
"read" => "read",
"write" => "write",
"follow" => "follow",
"push" => "push"
}
} }
} }
} }

View file

@ -0,0 +1,139 @@
# Pleroma: A lightweight social networking server
# Copyright © 2019-2020 Moxley Stratton, Mike Buhot <https://github.com/open-api-spex/open_api_spex>, MPL-2.0
# Copyright © 2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.CastAndValidate do
@moduledoc """
This plug is based on [`OpenApiSpex.Plug.CastAndValidate`]
(https://github.com/open-api-spex/open_api_spex/blob/master/lib/open_api_spex/plug/cast_and_validate.ex).
The main difference is ignoring unexpected query params instead of throwing
an error and a config option (`[Pleroma.Web.ApiSpec.CastAndValidate, :strict]`)
to disable this behavior. Also, the default rendering error module
is `Pleroma.Web.ApiSpec.RenderError`.
"""
@behaviour Plug
alias Plug.Conn
@impl Plug
def init(opts) do
opts
|> Map.new()
|> Map.put_new(:render_error, Pleroma.Web.ApiSpec.RenderError)
end
@impl Plug
def call(%{private: %{open_api_spex: private_data}} = conn, %{
operation_id: operation_id,
render_error: render_error
}) do
spec = private_data.spec
operation = private_data.operation_lookup[operation_id]
content_type =
case Conn.get_req_header(conn, "content-type") do
[header_value | _] ->
header_value
|> String.split(";")
|> List.first()
_ ->
nil
end
private_data = Map.put(private_data, :operation_id, operation_id)
conn = Conn.put_private(conn, :open_api_spex, private_data)
case cast_and_validate(spec, operation, conn, content_type, strict?()) do
{:ok, conn} ->
conn
{:error, reason} ->
opts = render_error.init(reason)
conn
|> render_error.call(opts)
|> Plug.Conn.halt()
end
end
def call(
%{
private: %{
phoenix_controller: controller,
phoenix_action: action,
open_api_spex: private_data
}
} = conn,
opts
) do
operation =
case private_data.operation_lookup[{controller, action}] do
nil ->
operation_id = controller.open_api_operation(action).operationId
operation = private_data.operation_lookup[operation_id]
operation_lookup =
private_data.operation_lookup
|> Map.put({controller, action}, operation)
OpenApiSpex.Plug.Cache.adapter().put(
private_data.spec_module,
{private_data.spec, operation_lookup}
)
operation
operation ->
operation
end
if operation.operationId do
call(conn, Map.put(opts, :operation_id, operation.operationId))
else
raise "operationId was not found in action API spec"
end
end
def call(conn, opts), do: OpenApiSpex.Plug.CastAndValidate.call(conn, opts)
defp cast_and_validate(spec, operation, conn, content_type, true = _strict) do
OpenApiSpex.cast_and_validate(spec, operation, conn, content_type)
end
defp cast_and_validate(spec, operation, conn, content_type, false = _strict) do
case OpenApiSpex.cast_and_validate(spec, operation, conn, content_type) do
{:ok, conn} ->
{:ok, conn}
# Remove unexpected query params and cast/validate again
{:error, errors} ->
query_params =
Enum.reduce(errors, conn.query_params, fn
%{reason: :unexpected_field, name: name, path: [name]}, params ->
Map.delete(params, name)
%{reason: :invalid_enum, name: nil, path: path, value: value}, params ->
path = path |> Enum.reverse() |> tl() |> Enum.reverse() |> list_items_to_string()
update_in(params, path, &List.delete(&1, value))
_, params ->
params
end)
conn = %Conn{conn | query_params: query_params}
OpenApiSpex.cast_and_validate(spec, operation, conn, content_type)
end
end
defp list_items_to_string(list) do
Enum.map(list, fn
i when is_atom(i) -> to_string(i)
i -> i
end)
end
defp strict?, do: Pleroma.Config.get([__MODULE__, :strict], false)
end

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.ApiSpec.Helpers do defmodule Pleroma.Web.ApiSpec.Helpers do
alias OpenApiSpex.Operation alias OpenApiSpex.Operation
alias OpenApiSpex.Schema alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
def request_body(description, schema_ref, opts \\ []) do def request_body(description, schema_ref, opts \\ []) do
media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"] media_types = ["application/json", "multipart/form-data", "application/x-www-form-urlencoded"]
@ -47,6 +48,15 @@ def pagination_params do
] ]
end end
def with_relationships_param do
Operation.parameter(
:with_relationships,
:query,
BooleanLike,
"Embed relationships into accounts."
)
end
def empty_object_response do def empty_object_response do
Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}}) Operation.response("Empty object", "application/json", %Schema{type: :object, example: %{}})
end end
@ -54,4 +64,8 @@ def empty_object_response do
def empty_array_response do def empty_array_response do
Operation.response("Empty array", "application/json", %Schema{type: :array, example: []}) Operation.response("Empty array", "application/json", %Schema{type: :array, example: []})
end end
def no_content_response do
Operation.response("No Content", "application/json", %Schema{type: :string, example: ""})
end
end end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.ApiSpec.AccountOperation do
alias Pleroma.Web.ApiSpec.Schemas.ActorType alias Pleroma.Web.ApiSpec.Schemas.ActorType
alias Pleroma.Web.ApiSpec.Schemas.ApiError alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.List
alias Pleroma.Web.ApiSpec.Schemas.Status alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
@ -154,8 +155,10 @@ def followers_operation do
security: [%{"oAuth" => ["read:accounts"]}], security: [%{"oAuth" => ["read:accounts"]}],
description: description:
"Accounts which follow the given account, if network is not hidden by the account owner.", "Accounts which follow the given account, if network is not hidden by the account owner.",
parameters: parameters: [
[%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), %Reference{"$ref": "#/components/parameters/accountIdOrNickname"},
with_relationships_param() | pagination_params()
],
responses: %{ responses: %{
200 => Operation.response("Accounts", "application/json", array_of_accounts()) 200 => Operation.response("Accounts", "application/json", array_of_accounts())
} }
@ -170,8 +173,10 @@ def following_operation do
security: [%{"oAuth" => ["read:accounts"]}], security: [%{"oAuth" => ["read:accounts"]}],
description: description:
"Accounts which the given account is following, if network is not hidden by the account owner.", "Accounts which the given account is following, if network is not hidden by the account owner.",
parameters: parameters: [
[%Reference{"$ref": "#/components/parameters/accountIdOrNickname"}] ++ pagination_params(), %Reference{"$ref": "#/components/parameters/accountIdOrNickname"},
with_relationships_param() | pagination_params()
],
responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())} responses: %{200 => Operation.response("Accounts", "application/json", array_of_accounts())}
} }
end end
@ -366,15 +371,18 @@ defp create_request do
title: "AccountCreateRequest", title: "AccountCreateRequest",
description: "POST body for creating an account", description: "POST body for creating an account",
type: :object, type: :object,
required: [:username, :password, :agreement],
properties: %{ properties: %{
reason: %Schema{ reason: %Schema{
type: :string, type: :string,
nullable: true,
description: description:
"Text that will be reviewed by moderators if registrations require manual approval" "Text that will be reviewed by moderators if registrations require manual approval"
}, },
username: %Schema{type: :string, description: "The desired username for the account"}, username: %Schema{type: :string, description: "The desired username for the account"},
email: %Schema{ email: %Schema{
type: :string, type: :string,
nullable: true,
description: description:
"The email address to be used for login. Required when `account_activation_required` is enabled.", "The email address to be used for login. Required when `account_activation_required` is enabled.",
format: :email format: :email
@ -385,29 +393,39 @@ defp create_request do
format: :password format: :password
}, },
agreement: %Schema{ agreement: %Schema{
type: :boolean, allOf: [BooleanLike],
description: description:
"Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE." "Whether the user agrees to the local rules, terms, and policies. These should be presented to the user in order to allow them to consent before setting this parameter to TRUE."
}, },
locale: %Schema{ locale: %Schema{
type: :string, type: :string,
nullable: true,
description: "The language of the confirmation email that will be sent" description: "The language of the confirmation email that will be sent"
}, },
# Pleroma-specific properties: # Pleroma-specific properties:
fullname: %Schema{type: :string, description: "Full name"}, fullname: %Schema{type: :string, nullable: true, description: "Full name"},
bio: %Schema{type: :string, description: "Bio", default: ""}, bio: %Schema{type: :string, description: "Bio", nullable: true, default: ""},
captcha_solution: %Schema{ captcha_solution: %Schema{
type: :string, type: :string,
nullable: true,
description: "Provider-specific captcha solution" description: "Provider-specific captcha solution"
}, },
captcha_token: %Schema{type: :string, description: "Provider-specific captcha token"}, captcha_token: %Schema{
captcha_answer_data: %Schema{type: :string, description: "Provider-specific captcha data"}, type: :string,
nullable: true,
description: "Provider-specific captcha token"
},
captcha_answer_data: %Schema{
type: :string,
nullable: true,
description: "Provider-specific captcha data"
},
token: %Schema{ token: %Schema{
type: :string, type: :string,
nullable: true,
description: "Invite token required when the registrations aren't public" description: "Invite token required when the registrations aren't public"
} }
}, },
required: [:username, :password, :agreement],
example: %{ example: %{
"username" => "cofe", "username" => "cofe",
"email" => "cofe@example.com", "email" => "cofe@example.com",
@ -445,29 +463,35 @@ defp update_creadentials_request do
type: :object, type: :object,
properties: %{ properties: %{
bot: %Schema{ bot: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "Whether the account has a bot flag." description: "Whether the account has a bot flag."
}, },
display_name: %Schema{ display_name: %Schema{
type: :string, type: :string,
nullable: true,
description: "The display name to use for the profile." description: "The display name to use for the profile."
}, },
note: %Schema{type: :string, description: "The account bio."}, note: %Schema{type: :string, description: "The account bio."},
avatar: %Schema{ avatar: %Schema{
type: :string, type: :string,
nullable: true,
description: "Avatar image encoded using multipart/form-data", description: "Avatar image encoded using multipart/form-data",
format: :binary format: :binary
}, },
header: %Schema{ header: %Schema{
type: :string, type: :string,
nullable: true,
description: "Header image encoded using multipart/form-data", description: "Header image encoded using multipart/form-data",
format: :binary format: :binary
}, },
locked: %Schema{ locked: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "Whether manual approval of follow requests is required." description: "Whether manual approval of follow requests is required."
}, },
fields_attributes: %Schema{ fields_attributes: %Schema{
nullable: true,
oneOf: [ oneOf: [
%Schema{type: :array, items: attribute_field()}, %Schema{type: :array, items: attribute_field()},
%Schema{type: :object, additionalProperties: %Schema{type: attribute_field()}} %Schema{type: :object, additionalProperties: %Schema{type: attribute_field()}}
@ -486,48 +510,66 @@ defp update_creadentials_request do
# Pleroma-specific fields # Pleroma-specific fields
no_rich_text: %Schema{ no_rich_text: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "html tags are stripped from all statuses requested from the API" description: "html tags are stripped from all statuses requested from the API"
}, },
hide_followers: %Schema{type: :boolean, description: "user's followers will be hidden"}, hide_followers: %Schema{
hide_follows: %Schema{type: :boolean, description: "user's follows will be hidden"}, allOf: [BooleanLike],
nullable: true,
description: "user's followers will be hidden"
},
hide_follows: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "user's follows will be hidden"
},
hide_followers_count: %Schema{ hide_followers_count: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "user's follower count will be hidden" description: "user's follower count will be hidden"
}, },
hide_follows_count: %Schema{ hide_follows_count: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "user's follow count will be hidden" description: "user's follow count will be hidden"
}, },
hide_favorites: %Schema{ hide_favorites: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "user's favorites timeline will be hidden" description: "user's favorites timeline will be hidden"
}, },
show_role: %Schema{ show_role: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "user's role (e.g admin, moderator) will be exposed to anyone in the description: "user's role (e.g admin, moderator) will be exposed to anyone in the
API" API"
}, },
default_scope: VisibilityScope, default_scope: VisibilityScope,
pleroma_settings_store: %Schema{ pleroma_settings_store: %Schema{
type: :object, type: :object,
nullable: true,
description: "Opaque user settings to be saved on the backend." description: "Opaque user settings to be saved on the backend."
}, },
skip_thread_containment: %Schema{ skip_thread_containment: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "Skip filtering out broken threads" description: "Skip filtering out broken threads"
}, },
allow_following_move: %Schema{ allow_following_move: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "Allows automatically follow moved following accounts" description: "Allows automatically follow moved following accounts"
}, },
pleroma_background_image: %Schema{ pleroma_background_image: %Schema{
type: :string, type: :string,
nullable: true,
description: "Sets the background image of the user.", description: "Sets the background image of the user.",
format: :binary format: :binary
}, },
discoverable: %Schema{ discoverable: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: description:
"Discovery of this account in search results and other services is allowed." "Discovery of this account in search results and other services is allowed."
}, },
@ -555,11 +597,12 @@ defp update_creadentials_request do
} }
end end
defp array_of_accounts do def array_of_accounts do
%Schema{ %Schema{
title: "ArrayOfAccounts", title: "ArrayOfAccounts",
type: :array, type: :array,
items: Account items: Account,
example: [Account.schema().example]
} }
end end
@ -622,7 +665,7 @@ defp follow_by_uri_request do
description: "POST body for muting an account", description: "POST body for muting an account",
type: :object, type: :object,
properties: %{ properties: %{
uri: %Schema{type: :string, format: :uri} uri: %Schema{type: :string, nullable: true, format: :uri}
}, },
required: [:uri] required: [:uri]
} }
@ -635,7 +678,8 @@ defp mute_request do
type: :object, type: :object,
properties: %{ properties: %{
notifications: %Schema{ notifications: %Schema{
type: :boolean, allOf: [BooleanLike],
nullable: true,
description: "Mute notifications in addition to statuses? Defaults to true.", description: "Mute notifications in addition to statuses? Defaults to true.",
default: true default: true
} }
@ -646,28 +690,12 @@ defp mute_request do
} }
end end
defp list do
%Schema{
title: "List",
description: "Response schema for a list",
type: :object,
properties: %{
id: %Schema{type: :string},
title: %Schema{type: :string}
},
example: %{
"id" => "123",
"title" => "my list"
}
}
end
defp array_of_lists do defp array_of_lists do
%Schema{ %Schema{
title: "ArrayOfLists", title: "ArrayOfLists",
description: "Response schema for lists", description: "Response schema for lists",
type: :array, type: :array,
items: list(), items: List,
example: [ example: [
%{"id" => "123", "title" => "my list"}, %{"id" => "123", "title" => "my list"},
%{"id" => "1337", "title" => "anotehr list"} %{"id" => "1337", "title" => "anotehr list"}

View file

@ -0,0 +1,165 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.Admin.StatusOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
import Pleroma.Web.ApiSpec.Helpers
import Pleroma.Web.ApiSpec.StatusOperation, only: [id_param: 0]
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Admin", "Statuses"],
operationId: "AdminAPI.StatusController.index",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [
Operation.parameter(
:godmode,
:query,
%Schema{type: :boolean, default: false},
"Allows to see private statuses"
),
Operation.parameter(
:local_only,
:query,
%Schema{type: :boolean, default: false},
"Excludes remote statuses"
),
Operation.parameter(
:with_reblogs,
:query,
%Schema{type: :boolean, default: false},
"Allows to see reblogs"
),
Operation.parameter(
:page,
:query,
%Schema{type: :integer, default: 1},
"Page"
),
Operation.parameter(
:page_size,
:query,
%Schema{type: :integer, default: 50},
"Number of statuses to return"
)
],
responses: %{
200 =>
Operation.response("Array of statuses", "application/json", %Schema{
type: :array,
items: status()
})
}
}
end
def show_operation do
%Operation{
tags: ["Admin", "Statuses"],
summary: "Show Status",
operationId: "AdminAPI.StatusController.show",
parameters: [id_param()],
security: [%{"oAuth" => ["read:statuses"]}],
responses: %{
200 => Operation.response("Status", "application/json", Status),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
def update_operation do
%Operation{
tags: ["Admin", "Statuses"],
summary: "Change the scope of an individual reported status",
operationId: "AdminAPI.StatusController.update",
parameters: [id_param()],
security: [%{"oAuth" => ["write:statuses"]}],
requestBody: request_body("Parameters", update_request(), required: true),
responses: %{
200 => Operation.response("Status", "application/json", Status),
400 => Operation.response("Error", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Admin", "Statuses"],
summary: "Delete an individual reported status",
operationId: "AdminAPI.StatusController.delete",
parameters: [id_param()],
security: [%{"oAuth" => ["write:statuses"]}],
responses: %{
200 => empty_object_response(),
404 => Operation.response("Not Found", "application/json", ApiError)
}
}
end
defp status do
%Schema{
anyOf: [
Status,
%Schema{
type: :object,
properties: %{
account: %Schema{allOf: [Account, admin_account()]}
}
}
]
}
end
defp admin_account do
%Schema{
type: :object,
properties: %{
id: FlakeID,
avatar: %Schema{type: :string},
nickname: %Schema{type: :string},
display_name: %Schema{type: :string},
deactivated: %Schema{type: :boolean},
local: %Schema{type: :boolean},
roles: %Schema{
type: :object,
properties: %{
admin: %Schema{type: :boolean},
moderator: %Schema{type: :boolean}
}
},
tags: %Schema{type: :string},
confirmation_pending: %Schema{type: :string}
}
}
end
defp update_request do
%Schema{
type: :object,
properties: %{
sensitive: %Schema{
type: :boolean,
description: "Mark status and attached media as sensitive?"
},
visibility: VisibilityScope
},
example: %{
"visibility" => "private",
"sensitive" => "false"
}
}
end
end

View file

@ -105,7 +105,11 @@ defp create_request do
description: "Space separated list of scopes", description: "Space separated list of scopes",
default: "read" default: "read"
}, },
website: %Schema{type: :string, description: "A URL to the homepage of your app"} website: %Schema{
type: :string,
nullable: true,
description: "A URL to the homepage of your app"
}
}, },
required: [:client_name, :redirect_uris], required: [:client_name, :redirect_uris],
example: %{ example: %{

View file

@ -0,0 +1,61 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.ConversationOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Conversation
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
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: ["Conversations"],
summary: "Show conversation",
security: [%{"oAuth" => ["read:statuses"]}],
operationId: "ConversationController.index",
parameters: [
Operation.parameter(
:recipients,
:query,
%Schema{type: :array, items: FlakeID},
"Only return conversations with the given recipients (a list of user ids)"
)
| pagination_params()
],
responses: %{
200 =>
Operation.response("Array of Conversation", "application/json", %Schema{
type: :array,
items: Conversation,
example: [Conversation.schema().example]
})
}
}
end
def mark_as_read_operation do
%Operation{
tags: ["Conversations"],
summary: "Mark as read",
operationId: "ConversationController.mark_as_read",
parameters: [
Operation.parameter(:id, :path, :string, "Conversation ID",
example: "123",
required: true
)
],
security: [%{"oAuth" => ["write:conversations"]}],
responses: %{
200 => Operation.response("Conversation", "application/json", Conversation)
}
}
end
end

View file

@ -0,0 +1,104 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.EmojiReactionOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Status
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Emoji Reactions"],
summary:
"Get an object of emoji to account mappings with accounts that reacted to the post",
parameters: [
Operation.parameter(:id, :path, FlakeID, "Status ID", required: true),
Operation.parameter(:emoji, :path, :string, "Filter by a single unicode emoji",
required: false
)
],
security: [%{"oAuth" => ["read:statuses"]}],
operationId: "EmojiReactionController.index",
responses: %{
200 => array_of_reactions_response()
}
}
end
def create_operation do
%Operation{
tags: ["Emoji Reactions"],
summary: "React to a post with a unicode emoji",
parameters: [
Operation.parameter(:id, :path, FlakeID, "Status ID", required: true),
Operation.parameter(:emoji, :path, :string, "A single character unicode emoji",
required: true
)
],
security: [%{"oAuth" => ["write:statuses"]}],
operationId: "EmojiReactionController.create",
responses: %{
200 => Operation.response("Status", "application/json", Status),
400 => Operation.response("Bad Request", "application/json", ApiError)
}
}
end
def delete_operation do
%Operation{
tags: ["Emoji Reactions"],
summary: "Remove a reaction to a post with a unicode emoji",
parameters: [
Operation.parameter(:id, :path, FlakeID, "Status ID", required: true),
Operation.parameter(:emoji, :path, :string, "A single character unicode emoji",
required: true
)
],
security: [%{"oAuth" => ["write:statuses"]}],
operationId: "EmojiReactionController.delete",
responses: %{
200 => Operation.response("Status", "application/json", Status)
}
}
end
defp array_of_reactions_response do
Operation.response("Array of Emoji Reactions", "application/json", %Schema{
type: :array,
items: emoji_reaction(),
example: [emoji_reaction().example]
})
end
defp emoji_reaction do
%Schema{
title: "EmojiReaction",
type: :object,
properties: %{
name: %Schema{type: :string, description: "Emoji"},
count: %Schema{type: :integer, description: "Count of reactions with this emoji"},
me: %Schema{type: :boolean, description: "Did I react with this emoji?"},
accounts: %Schema{
type: :array,
items: Account,
description: "Array of accounts reacted with this emoji"
}
},
example: %{
"name" => "😱",
"count" => 1,
"me" => false,
"accounts" => [Account.schema().example]
}
}
end
end

View file

@ -0,0 +1,230 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.FilterOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Helpers
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["apps"],
summary: "View all filters",
operationId: "FilterController.index",
security: [%{"oAuth" => ["read:filters"]}],
responses: %{
200 => Operation.response("Filters", "application/json", array_of_filters())
}
}
end
def create_operation do
%Operation{
tags: ["apps"],
summary: "Create a filter",
operationId: "FilterController.create",
requestBody: Helpers.request_body("Parameters", create_request(), required: true),
security: [%{"oAuth" => ["write:filters"]}],
responses: %{200 => Operation.response("Filter", "application/json", filter())}
}
end
def show_operation do
%Operation{
tags: ["apps"],
summary: "View all filters",
parameters: [id_param()],
operationId: "FilterController.show",
security: [%{"oAuth" => ["read:filters"]}],
responses: %{
200 => Operation.response("Filter", "application/json", filter())
}
}
end
def update_operation do
%Operation{
tags: ["apps"],
summary: "Update a filter",
parameters: [id_param()],
operationId: "FilterController.update",
requestBody: Helpers.request_body("Parameters", update_request(), required: true),
security: [%{"oAuth" => ["write:filters"]}],
responses: %{
200 => Operation.response("Filter", "application/json", filter())
}
}
end
def delete_operation do
%Operation{
tags: ["apps"],
summary: "Remove a filter",
parameters: [id_param()],
operationId: "FilterController.delete",
security: [%{"oAuth" => ["write:filters"]}],
responses: %{
200 =>
Operation.response("Filter", "application/json", %Schema{
type: :object,
description: "Empty object"
})
}
}
end
defp id_param do
Operation.parameter(:id, :path, :string, "Filter ID", example: "123", required: true)
end
defp filter do
%Schema{
title: "Filter",
type: :object,
properties: %{
id: %Schema{type: :string},
phrase: %Schema{type: :string, description: "The text to be filtered"},
context: %Schema{
type: :array,
items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
description: "The contexts in which the filter should be applied."
},
expires_at: %Schema{
type: :string,
format: :"date-time",
description:
"When the filter should no longer be applied. String (ISO 8601 Datetime), or null if the filter does not expire.",
nullable: true
},
irreversible: %Schema{
type: :boolean,
description:
"Should matching entities in home and notifications be dropped by the server?"
},
whole_word: %Schema{
type: :boolean,
description: "Should the filter consider word boundaries?"
}
},
example: %{
"id" => "5580",
"phrase" => "@twitter.com",
"context" => [
"home",
"notifications",
"public",
"thread"
],
"whole_word" => false,
"expires_at" => nil,
"irreversible" => true
}
}
end
defp array_of_filters do
%Schema{
title: "ArrayOfFilters",
description: "Array of Filters",
type: :array,
items: filter(),
example: [
%{
"id" => "5580",
"phrase" => "@twitter.com",
"context" => [
"home",
"notifications",
"public",
"thread"
],
"whole_word" => false,
"expires_at" => nil,
"irreversible" => true
},
%{
"id" => "6191",
"phrase" => ":eurovision2019:",
"context" => [
"home"
],
"whole_word" => true,
"expires_at" => "2019-05-21T13:47:31.333Z",
"irreversible" => false
}
]
}
end
defp create_request do
%Schema{
title: "FilterCreateRequest",
allOf: [
update_request(),
%Schema{
type: :object,
properties: %{
irreversible: %Schema{
allOf: [BooleanLike],
description:
"Should the server irreversibly drop matching entities from home and notifications?",
default: false
}
}
}
],
example: %{
"phrase" => "knights",
"context" => ["home"]
}
}
end
defp update_request do
%Schema{
title: "FilterUpdateRequest",
type: :object,
properties: %{
phrase: %Schema{type: :string, description: "The text to be filtered"},
context: %Schema{
type: :array,
items: %Schema{type: :string, enum: ["home", "notifications", "public", "thread"]},
description:
"Array of enumerable strings `home`, `notifications`, `public`, `thread`. At least one context must be specified."
},
irreversible: %Schema{
allOf: [BooleanLike],
nullable: true,
description:
"Should the server irreversibly drop matching entities from home and notifications?"
},
whole_word: %Schema{
allOf: [BooleanLike],
nullable: true,
description: "Consider word boundaries?",
default: true
}
# TODO: probably should implement filter expiration
# expires_in: %Schema{
# type: :string,
# format: :"date-time",
# description:
# "ISO 8601 Datetime for when the filter expires. Otherwise,
# null for a filter that doesn't expire."
# }
},
required: [:phrase, :context],
example: %{
"phrase" => "knights",
"context" => ["home"]
}
}
end
end

View file

@ -0,0 +1,65 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.FollowRequestOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.AccountRelationship
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Follow Requests"],
summary: "Pending Follows",
security: [%{"oAuth" => ["read:follows", "follow"]}],
operationId: "FollowRequestController.index",
responses: %{
200 =>
Operation.response("Array of Account", "application/json", %Schema{
type: :array,
items: Account,
example: [Account.schema().example]
})
}
}
end
def authorize_operation do
%Operation{
tags: ["Follow Requests"],
summary: "Accept Follow",
operationId: "FollowRequestController.authorize",
parameters: [id_param()],
security: [%{"oAuth" => ["follow", "write:follows"]}],
responses: %{
200 => Operation.response("Relationship", "application/json", AccountRelationship)
}
}
end
def reject_operation do
%Operation{
tags: ["Follow Requests"],
summary: "Reject Follow",
operationId: "FollowRequestController.reject",
parameters: [id_param()],
security: [%{"oAuth" => ["follow", "write:follows"]}],
responses: %{
200 => Operation.response("Relationship", "application/json", AccountRelationship)
}
}
end
defp id_param do
Operation.parameter(:id, :path, :string, "Conversation ID",
example: "123",
required: true
)
end
end

View file

@ -0,0 +1,175 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.InstanceOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def show_operation do
%Operation{
tags: ["Instance"],
summary: "Fetch instance",
description: "Information about the server",
operationId: "InstanceController.show",
responses: %{
200 => Operation.response("Instance", "application/json", instance())
}
}
end
def peers_operation do
%Operation{
tags: ["Instance"],
summary: "List of known hosts",
operationId: "InstanceController.peers",
responses: %{
200 => Operation.response("Array of domains", "application/json", array_of_domains())
}
}
end
defp instance do
%Schema{
type: :object,
properties: %{
uri: %Schema{type: :string, description: "The domain name of the instance"},
title: %Schema{type: :string, description: "The title of the website"},
description: %Schema{
type: :string,
description: "Admin-defined description of the Pleroma site"
},
version: %Schema{
type: :string,
description: "The version of Pleroma installed on the instance"
},
email: %Schema{
type: :string,
description: "An email that may be contacted for any inquiries",
format: :email
},
urls: %Schema{
type: :object,
description: "URLs of interest for clients apps",
properties: %{
streaming_api: %Schema{
type: :string,
description: "Websockets address for push streaming"
}
}
},
stats: %Schema{
type: :object,
description: "Statistics about how much information the instance contains",
properties: %{
user_count: %Schema{
type: :integer,
description: "Users registered on this instance"
},
status_count: %Schema{
type: :integer,
description: "Statuses authored by users on instance"
},
domain_count: %Schema{
type: :integer,
description: "Domains federated with this instance"
}
}
},
thumbnail: %Schema{
type: :string,
description: "Banner image for the website",
nullable: true
},
languages: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Primary langauges of the website and its staff"
},
registrations: %Schema{type: :boolean, description: "Whether registrations are enabled"},
# Extra (not present in Mastodon):
max_toot_chars: %Schema{
type: :integer,
description: ": Posts character limit (CW/Subject included in the counter)"
},
poll_limits: %Schema{
type: :object,
description: "A map with poll limits for local polls",
properties: %{
max_options: %Schema{
type: :integer,
description: "Maximum number of options."
},
max_option_chars: %Schema{
type: :integer,
description: "Maximum number of characters per option."
},
min_expiration: %Schema{
type: :integer,
description: "Minimum expiration time (in seconds)."
},
max_expiration: %Schema{
type: :integer,
description: "Maximum expiration time (in seconds)."
}
}
},
upload_limit: %Schema{
type: :integer,
description: "File size limit of uploads (except for avatar, background, banner)"
},
avatar_upload_limit: %Schema{type: :integer, description: "The title of the website"},
background_upload_limit: %Schema{type: :integer, description: "The title of the website"},
banner_upload_limit: %Schema{type: :integer, description: "The title of the website"},
background_image: %Schema{
type: :string,
format: :uri,
description: "The background image for the website"
}
},
example: %{
"avatar_upload_limit" => 2_000_000,
"background_upload_limit" => 4_000_000,
"background_image" => "/static/image.png",
"banner_upload_limit" => 4_000_000,
"description" => "A Pleroma instance, an alternative fediverse server",
"email" => "lain@lain.com",
"languages" => ["en"],
"max_toot_chars" => 5000,
"poll_limits" => %{
"max_expiration" => 31_536_000,
"max_option_chars" => 200,
"max_options" => 20,
"min_expiration" => 0
},
"registrations" => false,
"stats" => %{
"domain_count" => 2996,
"status_count" => 15_802,
"user_count" => 5
},
"thumbnail" => "https://lain.com/instance/thumbnail.jpeg",
"title" => "lain.com",
"upload_limit" => 16_000_000,
"uri" => "https://lain.com",
"urls" => %{
"streaming_api" => "wss://lain.com"
},
"version" => "2.7.2 (compatible; Pleroma 2.0.50-536-g25eec6d7-develop)"
}
}
end
defp array_of_domains do
%Schema{
type: :array,
items: %Schema{type: :string},
example: ["pleroma.site", "lain.com", "bikeshed.party"]
}
end
end

Some files were not shown because too many files have changed in this diff Show more