mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 20:26:15 +00:00
Compare commits
6 Commits
1ac1e889a2
...
5cdbd122dc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5cdbd122dc | ||
![]() |
fbe9728f36 | ||
![]() |
3bbf3e9709 | ||
![]() |
79931bf3ae | ||
![]() |
26ac75dac4 | ||
![]() |
50a95ecfa2 |
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -2,9 +2,34 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.3.8] - 2025-05-06
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
|
||||
|
||||
### Added
|
||||
|
||||
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
|
||||
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
|
||||
|
||||
### Removed
|
||||
|
||||
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
|
||||
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
|
||||
|
||||
## [4.3.7] - 2025-04-02
|
||||
|
||||
### Add
|
||||
### Added
|
||||
|
||||
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
|
||||
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)
|
||||
|
|
|
@ -77,6 +77,17 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
|
||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
|
||||
normalStatus.url ||= normalStatus.uri;
|
||||
|
||||
normalStatus.media_attachments.forEach(item => {
|
||||
if (item.remote_url && !(item.remote_url.startsWith('http://') || item.remote_url.startsWith('https://')))
|
||||
item.remote_url = null;
|
||||
});
|
||||
}
|
||||
|
||||
if (normalOldStatus) {
|
||||
|
|
|
@ -144,5 +144,10 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
|
|||
),
|
||||
note_emojified: emojify(accountJSON.note, emojiMap),
|
||||
note_plain: unescapeHTML(accountJSON.note),
|
||||
url:
|
||||
accountJSON.url.startsWith('http://') ||
|
||||
accountJSON.url.startsWith('https://')
|
||||
? accountJSON.url
|
||||
: accountJSON.uri,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -15,13 +15,15 @@ class ActivityPub::Parser::MediaAttachmentParser
|
|||
end
|
||||
|
||||
def remote_url
|
||||
Addressable::URI.parse(@json['url'])&.normalize&.to_s
|
||||
url = Addressable::URI.parse(@json['url'])&.normalize&.to_s
|
||||
url unless unsupported_uri_scheme?(url)
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def thumbnail_remote_url
|
||||
Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
|
||||
url = Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
|
||||
url unless unsupported_uri_scheme?(url)
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
|
|
@ -29,7 +29,10 @@ class ActivityPub::Parser::StatusParser
|
|||
end
|
||||
|
||||
def url
|
||||
url_to_href(@object['url'], 'text/html') if @object['url'].present?
|
||||
return if @object['url'].blank?
|
||||
|
||||
url = url_to_href(@object['url'], 'text/html')
|
||||
url unless unsupported_uri_scheme?(url)
|
||||
end
|
||||
|
||||
def text
|
||||
|
|
|
@ -4,6 +4,7 @@ require 'singleton'
|
|||
|
||||
class ActivityPub::TagManager
|
||||
include Singleton
|
||||
include JsonLdHelper
|
||||
include RoutingHelper
|
||||
|
||||
CONTEXT = 'https://www.w3.org/ns/activitystreams'
|
||||
|
@ -17,7 +18,7 @@ class ActivityPub::TagManager
|
|||
end
|
||||
|
||||
def url_for(target)
|
||||
return target.url if target.respond_to?(:local?) && !target.local?
|
||||
return unsupported_uri_scheme?(target.url) ? nil : target.url if target.respond_to?(:local?) && !target.local?
|
||||
|
||||
return unless target.respond_to?(:object_type)
|
||||
|
||||
|
|
|
@ -289,18 +289,6 @@ class Status < ApplicationRecord
|
|||
end.take(MEDIA_ATTACHMENTS_LIMIT)
|
||||
end
|
||||
|
||||
def replies_count
|
||||
status_stat&.replies_count || 0
|
||||
end
|
||||
|
||||
def reblogs_count
|
||||
status_stat&.reblogs_count || 0
|
||||
end
|
||||
|
||||
def favourites_count
|
||||
status_stat&.favourites_count || 0
|
||||
end
|
||||
|
||||
# Reblogs count received from an external instance
|
||||
def untrusted_reblogs_count
|
||||
status_stat&.untrusted_reblogs_count unless local?
|
||||
|
@ -386,6 +374,7 @@ class Status < ApplicationRecord
|
|||
def status_stat
|
||||
super || build_status_stat
|
||||
end
|
||||
delegate :replies_count, :reblogs_count, :favourites_count, to: :status_stat
|
||||
|
||||
def discard_with_reblogs
|
||||
discard_time = Time.current
|
||||
|
|
135
app/models/status_tree.rb
Normal file
135
app/models/status_tree.rb
Normal file
|
@ -0,0 +1,135 @@
|
|||
class StatusTree < ActiveModelSerializers::Model
|
||||
include PreloadingConcern
|
||||
|
||||
MAX_COUNT = 4_096
|
||||
|
||||
attributes :status, :account, :tree
|
||||
|
||||
class Node < ActiveModelSerializers::Model
|
||||
attributes :status, :tree
|
||||
|
||||
delegate :id, to: :status
|
||||
|
||||
delegate_missing_to :status
|
||||
|
||||
def object_type = :status
|
||||
|
||||
def ancestors
|
||||
tree.ancestors_for(id)
|
||||
end
|
||||
|
||||
def descendants
|
||||
tree.descendants_for(id)
|
||||
end
|
||||
|
||||
def children
|
||||
tree.children_for(id)
|
||||
end
|
||||
|
||||
def ==(other)
|
||||
other.class.in?([Node, Status]) && id == other.id
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<StatusTree::Node id: #{id}, in_reply_to_id: #{in_reply_to_id || 'nil'}>"
|
||||
end
|
||||
end
|
||||
|
||||
def tree
|
||||
@tree ||= begin
|
||||
ancestors = preload_collection(status.in_reply_to_id.nil? ? [] : status.ancestors(ancestors_max_count, account), Status)
|
||||
descendants = preload_collection(status.descendants(descendants_max_count, account, descendants_max_depth), Status)
|
||||
all_nodes = (ancestors + [status] + descendants).map { |status| Node.new(status:, tree: self) }
|
||||
build_tree_from(all_nodes)
|
||||
end
|
||||
end
|
||||
|
||||
def subtree_for(id, subtree = tree)
|
||||
subtree.each do |node, children|
|
||||
return children if node.id == id
|
||||
|
||||
found = subtree_for(id, children)
|
||||
return found if found
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def flatten
|
||||
collect_descendants(tree)
|
||||
end
|
||||
|
||||
delegate :each, :flat_map, :keys, to: :tree
|
||||
|
||||
def inspect
|
||||
"#<StatusTree #{tree.inspect}>"
|
||||
end
|
||||
|
||||
def status_node
|
||||
find_node(status.id)
|
||||
end
|
||||
|
||||
def find_node(id, subtree = tree)
|
||||
subtree.each do |node, children|
|
||||
return node if node.id == id
|
||||
|
||||
result = find_node(id, children)
|
||||
return result if result
|
||||
end
|
||||
end
|
||||
|
||||
def ancestors_for(id)
|
||||
ancestors = []
|
||||
node = find_node(id)
|
||||
in_reply_to_id = node.in_reply_to_id
|
||||
|
||||
while in_reply_to_id
|
||||
parent_node = find_node(in_reply_to_id)
|
||||
break unless parent_node
|
||||
ancestors << parent_node
|
||||
in_reply_to_id = parent_node.in_reply_to_id
|
||||
end
|
||||
|
||||
ancestors.reverse
|
||||
end
|
||||
|
||||
def descendants_for(id)
|
||||
subtree = subtree_for(id)
|
||||
return [] unless subtree
|
||||
|
||||
collect_descendants(subtree)
|
||||
end
|
||||
|
||||
def children_for(id)
|
||||
subtree = subtree_for(id)
|
||||
|
||||
subtree.keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_tree_from(nodes, in_reply_to_id = nil)
|
||||
grouped_nodes = nodes.group_by(&:in_reply_to_id)
|
||||
|
||||
(grouped_nodes[in_reply_to_id] || []).each_with_object({}) do |node, tree|
|
||||
tree[node] = build_tree_from(nodes - [node], node.id)
|
||||
end
|
||||
end
|
||||
|
||||
def descendants_max_depth
|
||||
nil
|
||||
end
|
||||
|
||||
def descendants_max_count
|
||||
MAX_COUNT
|
||||
end
|
||||
|
||||
def ancestors_max_count
|
||||
MAX_COUNT
|
||||
end
|
||||
|
||||
def collect_descendants(subtree)
|
||||
subtree.flat_map do |node, children|
|
||||
[node] + collect_descendants(children)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -92,6 +92,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
object.untrusted_favourites_count || relationships&.attributes_map&.dig(object.id, :favourites_count) || object.favourites_count
|
||||
end
|
||||
|
||||
def replies_count
|
||||
StatusTree.new(status: object, account: current_user&.account).status_node.children.size
|
||||
end
|
||||
|
||||
def favourited
|
||||
if relationships
|
||||
relationships.favourites_map[object.id] || false
|
||||
|
|
|
@ -59,7 +59,7 @@ services:
|
|||
web:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.3.7
|
||||
image: ghcr.io/mastodon/mastodon:v4.3.8
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec puma -C config/puma.rb
|
||||
|
@ -83,7 +83,7 @@ services:
|
|||
# build:
|
||||
# dockerfile: ./streaming/Dockerfile
|
||||
# context: .
|
||||
image: ghcr.io/mastodon/mastodon-streaming:v4.3.7
|
||||
image: ghcr.io/mastodon/mastodon-streaming:v4.3.8
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: node ./streaming/index.js
|
||||
|
@ -102,7 +102,7 @@ services:
|
|||
sidekiq:
|
||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||
# build: .
|
||||
image: ghcr.io/mastodon/mastodon:v4.3.7
|
||||
image: ghcr.io/mastodon/mastodon:v4.3.8
|
||||
restart: always
|
||||
env_file: .env.production
|
||||
command: bundle exec sidekiq
|
||||
|
|
|
@ -17,7 +17,7 @@ module Mastodon
|
|||
end
|
||||
|
||||
def default_prerelease
|
||||
'alpha.4'
|
||||
'alpha.5'
|
||||
end
|
||||
|
||||
def prerelease
|
||||
|
|
|
@ -72,4 +72,77 @@ RSpec.describe REST::StatusSerializer do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#replies_count' do
|
||||
let(:author) { alice }
|
||||
let(:replier) { bob }
|
||||
let!(:status) { Fabricate(:status, account: author, visibility: :public) }
|
||||
|
||||
context 'when being presented to the account that posted the status' do
|
||||
let(:current_user) { Fabricate(:user, account: author) }
|
||||
|
||||
before do
|
||||
Fabricate(:follow, account: replier, target_account: author)
|
||||
Fabricate(:follow, account: author, target_account: replier)
|
||||
end
|
||||
|
||||
context 'when the status has follower-only replies' do
|
||||
let(:reply) { Fabricate(:status, in_reply_to_id: status.id, account: replier, visibility: :private) }
|
||||
|
||||
before do
|
||||
reply
|
||||
end
|
||||
|
||||
it 'counts 1 reply' do
|
||||
expect(subject['replies_count']).to eq(1)
|
||||
end
|
||||
|
||||
context 'when one of the replies has subsequent replies' do
|
||||
before do
|
||||
Fabricate(:status, in_reply_to_id: reply.id, account: author, visibility: :private)
|
||||
end
|
||||
|
||||
it 'does not count that reply' do
|
||||
expect(subject['replies_count']).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when being presented to a different account' do
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
|
||||
context 'when the status has follower-only replies from an unfollowed account' do
|
||||
before do
|
||||
Fabricate(:status, in_reply_to_id: status.id, account: replier, visibility: :direct)
|
||||
end
|
||||
|
||||
it 'counts 0 replies' do
|
||||
expect(subject['replies_count']).to be 0
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the replies are public' do
|
||||
before do
|
||||
Fabricate(:status, in_reply_to_id: status.id, account: replier, visibility: :public)
|
||||
end
|
||||
|
||||
it 'counts 1 reply' do
|
||||
expect(subject['replies_count']).to eq 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there is one public reply and one private' do
|
||||
before do
|
||||
%i[direct public].each do |visibility|
|
||||
Fabricate(:status, in_reply_to_id: status.id, account: replier, visibility: visibility)
|
||||
end
|
||||
end
|
||||
|
||||
it 'counts 1 reply' do
|
||||
expect(subject['replies_count']).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue
Block a user