Merge tag 'v4.1.18'

This commit is contained in:
Mike Barnes 2024-07-05 12:36:20 +10:00
commit 306cd1ecad
30 changed files with 496 additions and 93 deletions

View file

@ -3,6 +3,28 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.1.18] - 2024-07-04
### Security
- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7))
- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3))
- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx))
- Update dependencies
### Changed
- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854))
- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865))
- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691))
- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377))
### Fixed
- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584))
- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780))
- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819))
## [4.1.17] - 2024-05-30 ## [4.1.17] - 2024-05-30
### Security ### Security

View file

@ -10,40 +10,40 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (6.1.7.7) actioncable (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.7) actionmailbox (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
activejob (= 6.1.7.7) activejob (= 6.1.7.8)
activerecord (= 6.1.7.7) activerecord (= 6.1.7.8)
activestorage (= 6.1.7.7) activestorage (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
mail (>= 2.7.1) mail (>= 2.7.1)
actionmailer (6.1.7.7) actionmailer (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
actionview (= 6.1.7.7) actionview (= 6.1.7.8)
activejob (= 6.1.7.7) activejob (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (6.1.7.7) actionpack (6.1.7.8)
actionview (= 6.1.7.7) actionview (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
rack (~> 2.0, >= 2.0.9) rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.7) actiontext (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
activerecord (= 6.1.7.7) activerecord (= 6.1.7.8)
activestorage (= 6.1.7.7) activestorage (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (6.1.7.7) actionview (6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -54,22 +54,22 @@ GEM
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (6.1.7.7) activejob (6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (6.1.7.7) activemodel (6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
activerecord (6.1.7.7) activerecord (6.1.7.8)
activemodel (= 6.1.7.7) activemodel (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
activestorage (6.1.7.7) activestorage (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
activejob (= 6.1.7.7) activejob (= 6.1.7.8)
activerecord (= 6.1.7.7) activerecord (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (6.1.7.7) activesupport (6.1.7.8)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@ -105,6 +105,7 @@ GEM
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
bcrypt (3.1.17) bcrypt (3.1.17)
better_errors (2.9.1) better_errors (2.9.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
@ -395,7 +396,7 @@ GEM
net-smtp net-smtp
makara (0.5.1) makara (0.5.1)
activerecord (>= 5.2.0) activerecord (>= 5.2.0)
marcel (1.0.2) marcel (1.0.4)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
matrix (0.4.2) matrix (0.4.2)
@ -405,7 +406,7 @@ GEM
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2022.0105) mime-types-data (3.2022.0105)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.5) mini_portile2 (2.8.7)
minitest (5.17.0) minitest (5.17.0)
msgpack (1.6.0) msgpack (1.6.0)
multi_json (1.15.0) multi_json (1.15.0)
@ -424,7 +425,7 @@ GEM
net-protocol net-protocol
net-ssh (7.0.1) net-ssh (7.0.1)
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.16.5) nokogiri (1.16.6)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.2.8) nsa (0.2.8)
@ -497,7 +498,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.7.3) racc (1.7.3)
rack (2.2.8.1) rack (2.2.9)
rack-attack (6.6.1) rack-attack (6.6.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
@ -512,20 +513,20 @@ GEM
rack rack
rack-test (2.0.2) rack-test (2.0.2)
rack (>= 1.3) rack (>= 1.3)
rails (6.1.7.7) rails (6.1.7.8)
actioncable (= 6.1.7.7) actioncable (= 6.1.7.8)
actionmailbox (= 6.1.7.7) actionmailbox (= 6.1.7.8)
actionmailer (= 6.1.7.7) actionmailer (= 6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
actiontext (= 6.1.7.7) actiontext (= 6.1.7.8)
actionview (= 6.1.7.7) actionview (= 6.1.7.8)
activejob (= 6.1.7.7) activejob (= 6.1.7.8)
activemodel (= 6.1.7.7) activemodel (= 6.1.7.8)
activerecord (= 6.1.7.7) activerecord (= 6.1.7.8)
activestorage (= 6.1.7.7) activestorage (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 6.1.7.7) railties (= 6.1.7.8)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
@ -541,9 +542,9 @@ GEM
railties (>= 6.0.0, < 7) railties (>= 6.0.0, < 7)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (6.1.7.7) railties (6.1.7.8)
actionpack (= 6.1.7.7) actionpack (= 6.1.7.8)
activesupport (= 6.1.7.7) activesupport (= 6.1.7.8)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@ -664,7 +665,8 @@ GEM
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sprockets (3.7.2) sprockets (3.7.3)
base64
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.4.2) sprockets-rails (3.4.2)
@ -755,7 +757,7 @@ GEM
xorcist (1.1.3) xorcist (1.1.3)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.13) zeitwerk (2.6.16)
PLATFORMS PLATFORMS
ruby ruby

View file

@ -6,6 +6,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy] before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy]
before_action :require_user!
before_action :set_statuses, only: :index before_action :set_statuses, only: :index
before_action :set_status, except: :index before_action :set_status, except: :index

View file

@ -4,6 +4,7 @@ class Api::V1::Statuses::TranslationsController < Api::BaseController
include Authorization include Authorization
before_action -> { doorkeeper_authorize! :read, :'read:statuses' } before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :require_user!
before_action :set_status before_action :set_status
before_action :set_translation before_action :set_translation

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Timelines::PublicController < Api::BaseController class Api::V1::Timelines::PublicController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :require_user!, only: [:show], if: :require_auth? before_action :require_user!, only: [:show], if: :require_auth?
after_action :insert_pagination_headers, unless: -> { @statuses.empty? } after_action :insert_pagination_headers, unless: -> { @statuses.empty? }

View file

@ -1,7 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Timelines::TagController < Api::BaseController class Api::V1::Timelines::TagController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth? before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :require_user!, if: :require_auth?
before_action :load_tag before_action :load_tag
after_action :insert_pagination_headers, unless: -> { @statuses.empty? } after_action :insert_pagination_headers, unless: -> { @statuses.empty? }

View file

@ -16,6 +16,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
def destroy def destroy
Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner) Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner)
super super
end end

View file

@ -102,7 +102,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def find_existing_status def find_existing_status
status = status_from_uri(object_uri) status = status_from_uri(object_uri)
status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present? status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
status status if status&.account_id == @account.id
end end
def process_status_params def process_status_params

View file

@ -14,17 +14,19 @@ module ApplicationExtension
# dependent: delete_all, which means the ActiveRecord callback in # dependent: delete_all, which means the ActiveRecord callback in
# AccessTokenExtension is not run, so instead we manually announce to # AccessTokenExtension is not run, so instead we manually announce to
# streaming that these tokens are being deleted. # streaming that these tokens are being deleted.
before_destroy :push_to_streaming_api, prepend: true before_destroy :close_streaming_sessions, prepend: true
end end
def confirmation_redirect_uri def confirmation_redirect_uri
redirect_uri.lines.first.strip redirect_uri.lines.first.strip
end end
def push_to_streaming_api def close_streaming_sessions(resource_owner = nil)
# TODO: #28793 Combine into a single topic # TODO: #28793 Combine into a single topic
payload = Oj.dump(event: :kill) payload = Oj.dump(event: :kill)
access_tokens.in_batches do |tokens| scope = access_tokens
scope = scope.where(resource_owner_id: resource_owner.id) unless resource_owner.nil?
scope.in_batches do |tokens|
redis.pipelined do |pipeline| redis.pipelined do |pipeline|
tokens.ids.each do |id| tokens.ids.each do |id|
pipeline.publish("timeline:access_token:#{id}", payload) pipeline.publish("timeline:access_token:#{id}", payload)

View file

@ -255,16 +255,21 @@ class LinkDetailsExtractor
end end
def document def document
@document ||= Nokogiri::HTML(@html, nil, encoding) @document ||= detect_encoding_and_parse_document
end end
def encoding def detect_encoding_and_parse_document
@encoding ||= begin [detect_encoding, nil, @html_charset, 'UTF-8'].uniq.each do |encoding|
guess = detector.detect(@html, @html_charset) document = Nokogiri::HTML(@html, nil, encoding)
guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil return document if document.to_s.valid_encoding?
end end
end end
def detect_encoding
guess = detector.detect(@html, @html_charset)
guess&.fetch(:confidence, 0).to_i > 60 ? guess&.fetch(:encoding, nil) : nil
end
def detector def detector
@detector ||= CharlockHolmes::EncodingDetector.new.tap do |detector| @detector ||= CharlockHolmes::EncodingDetector.new.tap do |detector|
detector.strip_tags = true detector.strip_tags = true

View file

@ -101,7 +101,7 @@ class SearchQueryTransformer < Parslet::Transform
end end
rule(clause: subtree(:clause)) do rule(clause: subtree(:clause)) do
prefix = clause[:prefix][:term].to_s if clause[:prefix] prefix = clause[:prefix][:term].to_s.downcase if clause[:prefix]
operator = clause[:operator]&.to_s operator = clause[:operator]&.to_s
if clause[:prefix] if clause[:prefix]

View file

@ -41,7 +41,7 @@ class StatusEdit < ApplicationRecord
default_scope { order(id: :asc) } default_scope { order(id: :asc) }
delegate :local?, :application, :edited?, :edited_at, delegate :local?, :application, :edited?, :edited_at,
:discarded?, :visibility, to: :status :discarded?, :visibility, :language, to: :status
def emojis def emojis
return @emojis if defined?(@emojis) return @emojis if defined?(@emojis)

View file

@ -16,11 +16,18 @@ class ManifestSerializer < ActiveModel::Serializer
512 512
).freeze ).freeze
attributes :name, :short_name, attributes :id, :name, :short_name,
:icons, :theme_color, :background_color, :icons, :theme_color, :background_color,
:display, :start_url, :scope, :display, :start_url, :scope,
:share_target, :shortcuts :share_target, :shortcuts
def id
# This is set to `/home` because that was the old value of `start_url` and
# thus the fallback ID computed by Chrome:
# https://developer.chrome.com/blog/pwa-manifest-id/
'/home'
end
def name def name
object.title object.title
end end
@ -53,7 +60,7 @@ class ManifestSerializer < ActiveModel::Serializer
end end
def start_url def start_url
'/home' '/'
end end
def scope def scope

View file

@ -15,6 +15,9 @@ class FetchLinkCardService < BaseService
) )
}iox }iox
# URL size limit to safely store in PosgreSQL's unique indexes
BYTESIZE_LIMIT = 2692
def call(status) def call(status)
@status = status @status = status
@original_url = parse_urls @original_url = parse_urls
@ -85,7 +88,7 @@ class FetchLinkCardService < BaseService
def bad_url?(uri) def bad_url?(uri)
# Avoid local instance URLs and invalid URLs # Avoid local instance URLs and invalid URLs
uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme) uri.host.blank? || TagManager.instance.local_url?(uri.to_s) || !%w(http https).include?(uri.scheme) || uri.to_s.bytesize > BYTESIZE_LIMIT
end end
def mention_link?(anchor) def mention_link?(anchor)

View file

@ -148,7 +148,7 @@ class PostStatusService < BaseService
end end
def scheduled_in_the_past? def scheduled_in_the_past?
@scheduled_at.present? && @scheduled_at <= Time.now.utc + MIN_SCHEDULE_OFFSET @scheduled_at.present? && @scheduled_at <= Time.now.utc
end end
def bump_potential_friendship! def bump_potential_friendship!

View file

@ -3,13 +3,17 @@
if ENV['STATSD_ADDR'].present? if ENV['STATSD_ADDR'].present?
host, port = ENV['STATSD_ADDR'].split(':') host, port = ENV['STATSD_ADDR'].split(':')
$statsd = ::Statsd.new(host, port) begin
$statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') } statsd = Statsd.new(host, port)
statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
::NSA.inform_statsd($statsd) do |informant| NSA.inform_statsd(statsd) do |informant|
informant.collect(:action_controller, :web) informant.collect(:action_controller, :web)
informant.collect(:active_record, :db) informant.collect(:active_record, :db)
informant.collect(:active_support_cache, :cache) informant.collect(:active_support_cache, :cache)
informant.collect(:sidekiq, :sidekiq) informant.collect(:sidekiq, :sidekiq)
end
rescue
Rails.logger.warn("statsd address #{ENV['STATSD_ADDR']} not reachable, proceeding without statsd")
end end
end end

View file

@ -56,7 +56,7 @@ services:
web: web:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.1.17 image: ghcr.io/mastodon/mastodon:v4.1.18
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000" command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
@ -77,7 +77,7 @@ services:
streaming: streaming:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.1.17 image: ghcr.io/mastodon/mastodon:v4.1.18
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming command: node ./streaming
@ -95,7 +95,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: ghcr.io/mastodon/mastodon:v4.1.17 image: ghcr.io/mastodon/mastodon:v4.1.18
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View file

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
17 18
end end
def flags def flags

View file

@ -39,6 +39,21 @@ describe Admin::StatusesController do
end end
end end
describe 'GET #show' do
before do
status.media_attachments << Fabricate(:media_attachment, type: :image, account: status.account)
status.save!
status.snapshot!(at_time: status.created_at, rate_limit: false)
status.update!(text: 'Hello, this is an edited post')
status.snapshot!(rate_limit: false)
get :show, params: { account_id: account.id, id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
describe 'POST #batch' do describe 'POST #batch' do
subject { post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } } } subject { post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } } }

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::ScheduledStatusesController do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }
let(:account) { Fabricate(:account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
context 'with an application token' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read:statuses') }
it 'returns http unprocessable entity' do
get :index
expect(response)
.to have_http_status(422)
end
end
describe 'GET #index' do
it 'returns http success' do
get :index
expect(response).to have_http_status(200)
end
end
end

View file

@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'rails_helper'
describe Api::V1::Statuses::TranslationsController do
render_views
let(:user) { Fabricate(:user) }
let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses', application: app) }
context 'with an application token' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read:statuses', application: app) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST /api/v1/statuses/:status_id/translate' do
let(:status) { Fabricate(:status, account: user.account, text: 'Hola', language: 'es') }
before do
post :create, params: { status_id: status.id }
end
it 'returns http unprocessable entity' do
expect(response).to have_http_status(422)
end
end
end
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST #create' do
let(:status) { Fabricate(:status, account: user.account, text: 'Hola', language: 'es') }
before do
translation = TranslationService::Translation.new(text: 'Hello')
service = instance_double(TranslationService::DeepL, translate: translation)
allow(TranslationService).to receive_messages(configured?: true, configured: service)
Rails.cache.write('translation_service/languages', { 'es' => ['en'] })
post :create, params: { status_id: status.id }
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
end
end
end

View file

@ -163,6 +163,46 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
expect(response.headers['X-RateLimit-Remaining']).to eq '0' expect(response.headers['X-RateLimit-Remaining']).to eq '0'
end end
end end
context 'with missing thread' do
subject { post :create, params: params }
let(:params) { { status: 'Hello world', in_reply_to_id: 0 } }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'when scheduling a status' do
subject { post :create, params: params }
let(:params) { { status: 'Hello world', scheduled_at: 10.minutes.from_now } }
let(:account) { user.account }
it 'returns HTTP 200' do
subject
expect(response).to have_http_status(200)
end
it 'creates a scheduled status' do
expect { subject }.to change { account.scheduled_statuses.count }.from(0).to(1)
end
context 'when the scheduling time is less than 5 minutes' do
let(:params) { { status: 'Hello world', scheduled_at: 4.minutes.from_now } }
it 'does not create a scheduled status', :aggregate_failures do
subject
expect(response).to have_http_status(422)
expect(account.scheduled_statuses).to be_empty
end
end
end
end end
describe 'DELETE #destroy' do describe 'DELETE #destroy' do

View file

@ -12,7 +12,7 @@ describe Api::V1::Timelines::PublicController do
end end
context 'with a user context' do context 'with a user context' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }
describe 'GET #show' do describe 'GET #show' do
before do before do
@ -42,7 +42,7 @@ describe Api::V1::Timelines::PublicController do
end end
context 'without a user context' do context 'without a user context' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: 'read:statuses') }
describe 'GET #show' do describe 'GET #show' do
it 'returns http success' do it 'returns http success' do

View file

@ -6,7 +6,8 @@ describe Api::V1::Timelines::TagController do
render_views render_views
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') } let(:scopes) { 'read:statuses' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
before do before do
allow(controller).to receive(:doorkeeper_token) { token } allow(controller).to receive(:doorkeeper_token) { token }
@ -48,13 +49,23 @@ describe Api::V1::Timelines::TagController do
Form::AdminSettings.new(timeline_preview: false).save Form::AdminSettings.new(timeline_preview: false).save
end end
context 'when the user is not authenticated' do context 'without an access token' do
let(:token) { nil } let(:token) { nil }
it 'returns http unauthorized' do it 'returns http unprocessable entity' do
subject subject
expect(response).to have_http_status(401) expect(response).to have_http_status(422)
end
end
context 'with an application access token, not bound to a user' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end end
end end

View file

@ -45,9 +45,11 @@ describe Oauth::AuthorizedApplicationsController do
let!(:application) { Fabricate(:application) } let!(:application) { Fabricate(:application) }
let!(:access_token) { Fabricate(:accessible_access_token, application: application, resource_owner_id: user.id) } let!(:access_token) { Fabricate(:accessible_access_token, application: application, resource_owner_id: user.id) }
let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) } let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) }
before do before do
sign_in user, scope: :user sign_in user, scope: :user
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
post :destroy, params: { id: application.id } post :destroy, params: { id: application.id }
end end
@ -58,5 +60,13 @@ describe Oauth::AuthorizedApplicationsController do
it 'removes subscriptions for the application\'s access tokens' do it 'removes subscriptions for the application\'s access tokens' do
expect(Web::PushSubscription.where(user: user).count).to eq 0 expect(Web::PushSubscription.where(user: user).count).to eq 0
end end
it 'removes the web_push_subscription' do
expect { web_push_subscription.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'sends a session kill payload to the streaming server' do
expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}')
end
end end
end end

View file

@ -160,7 +160,11 @@ describe Settings::ApplicationsController do
end end
describe 'destroy' do describe 'destroy' do
let(:redis_pipeline_stub) { instance_double(Redis::Namespace, publish: nil) }
let!(:access_token) { Fabricate(:accessible_access_token, application: app) }
before do before do
allow(redis).to receive(:pipelined).and_yield(redis_pipeline_stub)
post :destroy, params: { id: app.id } post :destroy, params: { id: app.id }
end end
@ -171,6 +175,10 @@ describe Settings::ApplicationsController do
it 'removes the app' do it 'removes the app' do
expect(Doorkeeper::Application.find_by(id: app.id)).to be_nil expect(Doorkeeper::Application.find_by(id: app.id)).to be_nil
end end
it 'sends a session kill payload to the streaming server' do
expect(redis_pipeline_stub).to have_received(:publish).with("timeline:access_token:#{access_token.id}", '{"event":"kill"}')
end
end end
describe 'regenerate' do describe 'regenerate' do

View file

@ -0,0 +1,17 @@
HTTP/1.1 200 OK
server: nginx
date: Thu, 13 Jun 2024 14:33:13 GMT
content-type: text/html; charset=ISO-8859-1
content-length: 158
accept-ranges: bytes
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Tofu á l'orange</title>
</head>
<body>
<h2>Tofu á l'orange</h2>
</body>
</html>

View file

@ -0,0 +1,133 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Public' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'read:statuses' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
shared_examples 'forbidden for wrong scope' do |wrong_scope|
let(:scopes) { wrong_scope }
it 'returns http forbidden' do
subject
expect(response).to have_http_status(403)
end
end
shared_examples 'a successful request to the public timeline' do
it 'returns the expected statuses successfully', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json.pluck(:id)).to match_array(expected_statuses.map { |status| status.id.to_s })
end
end
describe 'GET /api/v1/timelines/public' do
subject do
get '/api/v1/timelines/public', headers: headers, params: params
end
let!(:private_status) { Fabricate(:status, visibility: :private) } # rubocop:disable RSpec/LetSetup
let!(:local_status) { Fabricate(:status, account: Fabricate.build(:account, domain: nil)) }
let!(:remote_status) { Fabricate(:status, account: Fabricate.build(:account, domain: 'example.com')) }
let!(:media_status) { Fabricate(:status, media_attachments: [Fabricate.build(:media_attachment)]) }
let(:params) { {} }
context 'when the instance allows public preview' do
let(:expected_statuses) { [local_status, remote_status, media_status] }
it_behaves_like 'forbidden for wrong scope', 'profile'
context 'with an authorized user' do
it_behaves_like 'a successful request to the public timeline'
end
context 'with an anonymous user' do
let(:headers) { {} }
it_behaves_like 'a successful request to the public timeline'
end
context 'with local param' do
let(:params) { { local: true } }
let(:expected_statuses) { [local_status, media_status] }
it_behaves_like 'a successful request to the public timeline'
end
context 'with remote param' do
let(:params) { { remote: true } }
let(:expected_statuses) { [remote_status] }
it_behaves_like 'a successful request to the public timeline'
end
context 'with only_media param' do
let(:params) { { only_media: true } }
let(:expected_statuses) { [media_status] }
it_behaves_like 'a successful request to the public timeline'
end
context 'with limit param' do
let(:params) { { limit: 1 } }
it 'returns only the requested number of statuses', :aggregate_failures do
subject
expect(response).to have_http_status(200)
expect(body_as_json.size).to eq(params[:limit])
end
it 'sets the correct pagination headers', :aggregate_failures do
subject
headers = response.headers['Link']
expect(headers.find_link(%w(rel prev)).href).to eq(api_v1_timelines_public_url(limit: 1, min_id: media_status.id.to_s))
expect(headers.find_link(%w(rel next)).href).to eq(api_v1_timelines_public_url(limit: 1, max_id: media_status.id.to_s))
end
end
end
context 'when the instance does not allow public preview' do
before do
Form::AdminSettings.new(timeline_preview: false).save
end
it_behaves_like 'forbidden for wrong scope', 'profile'
context 'without an authentication token' do
let(:headers) { {} }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end
end
context 'with an application access token, not bound to a user' do
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil, scopes: scopes) }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end
end
context 'with an authenticated user' do
let(:expected_statuses) { [local_status, remote_status, media_status] }
it_behaves_like 'a successful request to the public timeline'
end
end
end
end

View file

@ -13,6 +13,7 @@ RSpec.describe FetchLinkCardService, type: :service do
stub_request(:get, 'http://example.com/test?data=file.gpx%5E1').to_return(status: 200) stub_request(:get, 'http://example.com/test?data=file.gpx%5E1').to_return(status: 200)
stub_request(:get, 'http://example.com/test-').to_return(request_fixture('idn.txt')) stub_request(:get, 'http://example.com/test-').to_return(request_fixture('idn.txt'))
stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt')) stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt'))
stub_request(:get, 'http://example.com/low_confidence_latin1').to_return(request_fixture('low_confidence_latin1.txt'))
subject.call(status) subject.call(status)
end end
@ -62,7 +63,15 @@ RSpec.describe FetchLinkCardService, type: :service do
end end
end end
context do context 'with a URL of a page in ISO-8859-1 encoding, that charlock_holmes cannot detect' do
let(:status) { Fabricate(:status, text: 'Check out http://example.com/low_confidence_latin1') }
it 'decodes the HTML' do
expect(status.preview_card.title).to eq("Tofu á l'orange")
end
end
context 'with a Japanese path URL' do
let(:status) { Fabricate(:status, text: 'テストhttp://example.com/日本語') } let(:status) { Fabricate(:status, text: 'テストhttp://example.com/日本語') }
it 'works with Japanese path string' do it 'works with Japanese path string' do
@ -95,6 +104,19 @@ RSpec.describe FetchLinkCardService, type: :service do
expect(a_request(:get, 'http://example.com/test?data=file.gpx%5E1')).to have_been_made.once expect(a_request(:get, 'http://example.com/test?data=file.gpx%5E1')).to have_been_made.once
end end
end end
context 'with an URL too long for PostgreSQL unique indexes' do
let(:url) { "http://example.com/#{'a' * 2674}" }
let(:status) { Fabricate(:status, text: url) }
it 'does not fetch the URL' do
expect(a_request(:get, url)).to_not have_been_made
end
it 'does not create a preview card' do
expect(status.preview_card).to be_nil
end
end
end end
context 'in a remote status' do context 'in a remote status' do

View file

@ -59,6 +59,16 @@ RSpec.describe PostStatusService, type: :service do
status2 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future) status2 = subject.call(account, text: 'test', idempotency: 'meepmeep', scheduled_at: future)
expect(status2.id).to eq status1.id expect(status2.id).to eq status1.id
end end
context 'when scheduled_at is less than min offset' do
let(:invalid_scheduled_time) { 4.minutes.from_now }
it 'raises invalid record error' do
expect do
subject.call(account, text: 'Hi future!', scheduled_at: invalid_scheduled_time)
end.to raise_error(ActiveRecord::RecordInvalid)
end
end
end end
it 'creates response to the original status of boost' do it 'creates response to the original status of boost' do