mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 15:58:50 +00:00
Merge commit '5652ca613582df03e5b838626078981414f3b897' into glitch-soc/merge-upstream
This commit is contained in:
65
lib/active_record/with_recursive.rb
Normal file
65
lib/active_record/with_recursive.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Add support for writing recursive CTEs in ActiveRecord
|
||||
|
||||
# Initially from Lorin Thwaits (https://github.com/lorint) as per comment:
|
||||
# https://github.com/vlado/activerecord-cte/issues/16#issuecomment-1433043310
|
||||
|
||||
# Modified from the above code to change the signature to
|
||||
# `with_recursive(hash)` and extending CTE hash values to also includes arrays
|
||||
# of values that get turned into UNION ALL expressions.
|
||||
|
||||
# This implementation has been merged in Rails: https://github.com/rails/rails/pull/51601
|
||||
|
||||
module ActiveRecord
|
||||
module QueryMethodsExtensions
|
||||
def with_recursive(*args)
|
||||
@with_is_recursive = true
|
||||
check_if_method_has_arguments!(__callee__, args)
|
||||
spawn.with_recursive!(*args)
|
||||
end
|
||||
|
||||
# Like #with_recursive but modifies the relation in place.
|
||||
def with_recursive!(*args) # :nodoc:
|
||||
self.with_values += args
|
||||
@with_is_recursive = true
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_with(arel)
|
||||
return if with_values.empty?
|
||||
|
||||
with_statements = with_values.map do |with_value|
|
||||
raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}" unless with_value.is_a?(Hash)
|
||||
|
||||
build_with_value_from_hash(with_value)
|
||||
end
|
||||
|
||||
# Was: arel.with(with_statements)
|
||||
@with_is_recursive ? arel.with(:recursive, with_statements) : arel.with(with_statements)
|
||||
end
|
||||
|
||||
def build_with_value_from_hash(hash)
|
||||
hash.map do |name, value|
|
||||
Arel::Nodes::TableAlias.new(build_with_expression_from_value(value), name)
|
||||
end
|
||||
end
|
||||
|
||||
def build_with_expression_from_value(value)
|
||||
case value
|
||||
when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value)
|
||||
when ActiveRecord::Relation then value.arel
|
||||
when Arel::SelectManager then value
|
||||
when Array then value.map { |e| build_with_expression_from_value(e) }.reduce { |result, value| Arel::Nodes::UnionAll.new(result, value) }
|
||||
else
|
||||
raise ArgumentError, "Unsupported argument type: `#{value}` #{value.class}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
ActiveSupport.on_load(:active_record) do
|
||||
ActiveRecord::QueryMethods.prepend(ActiveRecord::QueryMethodsExtensions)
|
||||
end
|
||||
51
lib/arel/union_parenthesizing.rb
Normal file
51
lib/arel/union_parenthesizing.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Fix an issue with `LIMIT` ocurring on the left side of a `UNION` causing syntax errors.
|
||||
# See https://github.com/rails/rails/issues/40181
|
||||
|
||||
# The fix has been merged in ActiveRecord: https://github.com/rails/rails/pull/51549
|
||||
# TODO: drop this when available in ActiveRecord
|
||||
|
||||
# rubocop:disable all -- This is a mostly vendored file
|
||||
|
||||
module Arel
|
||||
module Visitors
|
||||
class ToSql
|
||||
private
|
||||
|
||||
def infix_value_with_paren(o, collector, value, suppress_parens = false)
|
||||
collector << "( " unless suppress_parens
|
||||
collector = if o.left.class == o.class
|
||||
infix_value_with_paren(o.left, collector, value, true)
|
||||
else
|
||||
select_parentheses o.left, collector, false # Changed from `visit o.left, collector`
|
||||
end
|
||||
collector << value
|
||||
collector = if o.right.class == o.class
|
||||
infix_value_with_paren(o.right, collector, value, true)
|
||||
else
|
||||
select_parentheses o.right, collector, false # Changed from `visit o.right, collector`
|
||||
end
|
||||
collector << " )" unless suppress_parens
|
||||
collector
|
||||
end
|
||||
|
||||
def select_parentheses(o, collector, always_wrap_selects = true)
|
||||
if o.is_a?(Nodes::SelectStatement) && (always_wrap_selects || require_parentheses?(o))
|
||||
collector << "("
|
||||
visit o, collector
|
||||
collector << ")"
|
||||
collector
|
||||
else
|
||||
visit o, collector
|
||||
end
|
||||
end
|
||||
|
||||
def require_parentheses?(o)
|
||||
!o.orders.empty? || o.limit || o.offset
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:enable all
|
||||
@@ -5,12 +5,26 @@ module Paperclip
|
||||
def make
|
||||
return @file unless options[:style] == :small || options[:blurhash]
|
||||
|
||||
pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
||||
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
||||
width, height, data = blurhash_params
|
||||
# Guard against segfaults if data has unexpected size
|
||||
raise RangeError("Invalid image data size (expected #{width * height * 3}, got #{data.size})") if data.size != width * height * 3 # TODO: should probably be another exception type
|
||||
|
||||
attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, **(options[:blurhash] || {}))
|
||||
attachment.instance.blurhash = Blurhash.encode(width, height, data, **(options[:blurhash] || {}))
|
||||
|
||||
@file
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blurhash_params
|
||||
if Rails.configuration.x.use_vips
|
||||
image = Vips::Image.thumbnail(@file.path, 100)
|
||||
[image.width, image.height, image.colourspace(:srgb).extract_band(0, n: 3).to_a.flatten]
|
||||
else
|
||||
pixels = convert(':source -depth 8 RGB:-', source: File.expand_path(@file.path)).unpack('C*')
|
||||
geometry = options.fetch(:file_geometry_parser).from_file(@file)
|
||||
[geometry.width, geometry.height, pixels]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,15 +7,10 @@ module Paperclip
|
||||
MIN_CONTRAST = 3.0
|
||||
ACCENT_MIN_CONTRAST = 2.0
|
||||
FREQUENCY_THRESHOLD = 0.01
|
||||
BINS = 10
|
||||
|
||||
def make
|
||||
depth = 8
|
||||
|
||||
# Determine background palette by getting colors close to the image's edge only
|
||||
background_palette = palette_from_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||
|
||||
# Determine foreground palette from the whole image
|
||||
foreground_palette = palette_from_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||
background_palette, foreground_palette = Rails.configuration.x.use_vips ? palettes_from_libvips : palettes_from_imagemagick
|
||||
|
||||
background_color = background_palette.first || foreground_palette.first
|
||||
foreground_colors = []
|
||||
@@ -78,6 +73,75 @@ module Paperclip
|
||||
|
||||
private
|
||||
|
||||
def palettes_from_libvips
|
||||
image = downscaled_image
|
||||
block_edge_dim = (image.height * 0.25).floor
|
||||
line_edge_dim = (image.width * 0.25).floor
|
||||
|
||||
edge_image = begin
|
||||
top = image.crop(0, 0, image.width, block_edge_dim)
|
||||
bottom = image.crop(0, image.height - block_edge_dim, image.width, block_edge_dim)
|
||||
left = image.crop(0, block_edge_dim, line_edge_dim, image.height - (block_edge_dim * 2))
|
||||
right = image.crop(image.width - line_edge_dim, block_edge_dim, line_edge_dim, image.height - (block_edge_dim * 2))
|
||||
top.join(bottom, :vertical).join(left, :horizontal).join(right, :horizontal)
|
||||
end
|
||||
|
||||
background_palette = palette_from_image(edge_image)
|
||||
foreground_palette = palette_from_image(image)
|
||||
[background_palette, foreground_palette]
|
||||
end
|
||||
|
||||
def palettes_from_imagemagick
|
||||
depth = 8
|
||||
|
||||
# Determine background palette by getting colors close to the image's edge only
|
||||
background_palette = palette_from_im_histogram(convert(':source -alpha set -gravity Center -region 75%x75% -fill None -colorize 100% -alpha transparent +region -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||
|
||||
# Determine foreground palette from the whole image
|
||||
foreground_palette = palette_from_im_histogram(convert(':source -format %c -colors :quantity -depth :depth histogram:info:', source: File.expand_path(@file.path), quantity: 10, depth: depth), 10)
|
||||
[background_palette, foreground_palette]
|
||||
end
|
||||
|
||||
def downscaled_image
|
||||
image = Vips::Image.new_from_file(@file.path, access: :random).thumbnail_image(100)
|
||||
|
||||
image.colourspace(:srgb).extract_band(0, n: 3)
|
||||
end
|
||||
|
||||
def palette_from_image(image)
|
||||
# `hist_find_ndim` will create a BINS×BINS×BINS 3D histogram of the image
|
||||
# represented as an image of size BINS×BINS with `BINS` bands.
|
||||
# The number of occurrences of a color (r, g, b) is thus encoded in band `b` at pixel position `(r, g)`
|
||||
histogram = image.hist_find_ndim(bins: BINS)
|
||||
|
||||
# `histogram.max` returns an array of maxima with their pixel positions, but we don't know in which
|
||||
# band they are
|
||||
_, colors = histogram.max(size: 10, out_array: true, x_array: true, y_array: true)
|
||||
|
||||
colors['out_array'].zip(colors['x_array'], colors['y_array']).map do |v, x, y|
|
||||
rgb_from_xyv(histogram, x, y, v)
|
||||
end.reverse
|
||||
end
|
||||
|
||||
# rubocop:disable Naming/MethodParameterName
|
||||
def rgb_from_xyv(image, x, y, v)
|
||||
pixel = image.getpoint(x, y)
|
||||
|
||||
# Unfortunately, we only have the first 2 dimensions, so try to
|
||||
# guess the third one by looking up the value
|
||||
|
||||
# NOTE: this means that if multiple bins with the same `r` and `g`
|
||||
# components have the same number of occurrences, we will always return
|
||||
# the one with the lowest `b` value. This means that in case of a tie,
|
||||
# we will return the same color twice and skip the ones it tied with.
|
||||
z = pixel.find_index(v)
|
||||
|
||||
r = (x + 0.5) * 256 / BINS
|
||||
g = (y + 0.5) * 256 / BINS
|
||||
b = (z + 0.5) * 256 / BINS
|
||||
ColorDiff::Color::RGB.new(r, g, b)
|
||||
end
|
||||
|
||||
def w3c_contrast(color1, color2)
|
||||
luminance1 = (color1.to_xyz.y * 0.01) + 0.05
|
||||
luminance2 = (color2.to_xyz.y * 0.01) + 0.05
|
||||
@@ -89,7 +153,6 @@ module Paperclip
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Naming/MethodParameterName
|
||||
def rgb_to_hsl(r, g, b)
|
||||
r /= 255.0
|
||||
g /= 255.0
|
||||
@@ -170,7 +233,7 @@ module Paperclip
|
||||
ColorDiff::Color::RGB.new(*hsl_to_rgb(hue, saturation, light))
|
||||
end
|
||||
|
||||
def palette_from_histogram(result, quantity)
|
||||
def palette_from_im_histogram(result, quantity)
|
||||
frequencies = result.scan(/([0-9]+):/).flatten.map(&:to_f)
|
||||
hex_values = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
|
||||
total_frequencies = frequencies.sum.to_f
|
||||
|
||||
141
lib/paperclip/vips_lazy_thumbnail.rb
Normal file
141
lib/paperclip/vips_lazy_thumbnail.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Paperclip
|
||||
class LazyThumbnail < Paperclip::Processor
|
||||
GIF_MAX_FPS = 60
|
||||
GIF_MAX_FRAMES = 3000
|
||||
GIF_PALETTE_COLORS = 32
|
||||
|
||||
ALLOWED_FIELDS = %w(
|
||||
icc-profile-data
|
||||
).freeze
|
||||
|
||||
class PixelGeometryParser
|
||||
def self.parse(current_geometry, pixels)
|
||||
width = Math.sqrt(pixels * (current_geometry.width.to_f / current_geometry.height)).round.to_i
|
||||
height = Math.sqrt(pixels * (current_geometry.height.to_f / current_geometry.width)).round.to_i
|
||||
|
||||
Paperclip::Geometry.new(width, height)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(file, options = {}, attachment = nil)
|
||||
super
|
||||
|
||||
@crop = options[:geometry].to_s[-1, 1] == '#'
|
||||
@current_geometry = options.fetch(:file_geometry_parser, Geometry).from_file(@file)
|
||||
@target_geometry = options[:pixels] ? PixelGeometryParser.parse(@current_geometry, options[:pixels]) : options.fetch(:string_geometry_parser, Geometry).parse(options[:geometry].to_s)
|
||||
@format = options[:format]
|
||||
@current_format = File.extname(@file.path)
|
||||
@basename = File.basename(@file.path, @current_format)
|
||||
|
||||
correct_current_format!
|
||||
end
|
||||
|
||||
def make
|
||||
return File.open(@file.path) unless needs_convert?
|
||||
|
||||
dst = TempfileFactory.new.generate([@basename, @format ? ".#{@format}" : @current_format].join)
|
||||
|
||||
if preserve_animation?
|
||||
if @target_geometry.nil? || (@current_geometry.width <= @target_geometry.width && @current_geometry.height <= @target_geometry.height)
|
||||
target_width = 'iw'
|
||||
target_height = 'ih'
|
||||
else
|
||||
scale = [@target_geometry.width.to_f / @current_geometry.width, @target_geometry.height.to_f / @current_geometry.height].min
|
||||
target_width = (@current_geometry.width * scale).round
|
||||
target_height = (@current_geometry.height * scale).round
|
||||
end
|
||||
|
||||
# The only situation where we use crop on GIFs is cropping them to a square
|
||||
# aspect ratio, such as for avatars, so this is the only special case we
|
||||
# implement. If cropping ever becomes necessary for other situations, this will
|
||||
# need to be expanded.
|
||||
crop_width = crop_height = [target_width, target_height].min if @target_geometry&.square?
|
||||
|
||||
filter = begin
|
||||
if @crop
|
||||
"scale=#{target_width}:#{target_height}:force_original_aspect_ratio=increase,crop=#{crop_width}:#{crop_height}"
|
||||
else
|
||||
"scale=#{target_width}:#{target_height}:force_original_aspect_ratio=decrease"
|
||||
end
|
||||
end
|
||||
|
||||
command = Terrapin::CommandLine.new(Rails.configuration.x.ffmpeg_binary, '-nostdin -i :source -map_metadata -1 -fpsmax :max_fps -frames:v :max_frames -filter_complex :filter -y :destination', logger: Paperclip.logger)
|
||||
command.run({ source: @file.path, filter: "#{filter},split[a][b];[a]palettegen=max_colors=#{GIF_PALETTE_COLORS}[p];[b][p]paletteuse=dither=bayer", max_fps: GIF_MAX_FPS, max_frames: GIF_MAX_FRAMES, destination: dst.path })
|
||||
else
|
||||
transformed_image.write_to_file(dst.path, **save_options)
|
||||
end
|
||||
|
||||
dst
|
||||
rescue Terrapin::ExitStatusError => e
|
||||
raise Paperclip::Error, "Error while optimizing #{@basename}: #{e}"
|
||||
rescue Terrapin::CommandNotFoundError
|
||||
raise Paperclip::Errors::CommandNotFoundError, 'Could not run the `ffmpeg` command. Please install ffmpeg.'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def correct_current_format!
|
||||
# If the attachment was uploaded through a base64 payload, the tempfile
|
||||
# will not have a file extension. It could also have the wrong file extension,
|
||||
# depending on what the uploaded file was named. We correct for this in the final
|
||||
# file name, which is however not yet physically in place on the temp file, so we
|
||||
# need to use it here. Mind that this only reliably works if this processor is
|
||||
# the first in line and we're working with the original, unmodified file.
|
||||
@current_format = File.extname(attachment.instance_read(:file_name))
|
||||
end
|
||||
|
||||
def transformed_image
|
||||
# libvips has some optimizations for resizing an image on load. If we don't need to
|
||||
# resize the image, we have to load it a different way.
|
||||
if @target_geometry.nil?
|
||||
Vips::Image.new_from_file(preserve_animation? ? "#{@file.path}[n=-1]" : @file.path, access: :sequential).copy.mutate do |mutable|
|
||||
(mutable.get_fields - ALLOWED_FIELDS).each do |field|
|
||||
mutable.remove!(field)
|
||||
end
|
||||
end
|
||||
else
|
||||
Vips::Image.thumbnail(@file.path, @target_geometry.width, height: @target_geometry.height, **thumbnail_options).mutate do |mutable|
|
||||
(mutable.get_fields - ALLOWED_FIELDS).each do |field|
|
||||
mutable.remove!(field)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def thumbnail_options
|
||||
@crop ? { crop: :centre } : { size: :down }
|
||||
end
|
||||
|
||||
def save_options
|
||||
case @format
|
||||
when 'jpg'
|
||||
{ Q: 90, interlace: true }
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def preserve_animation?
|
||||
@format == 'gif' || (@format.blank? && @current_format == '.gif')
|
||||
end
|
||||
|
||||
def needs_convert?
|
||||
needs_different_geometry? || needs_different_format? || needs_metadata_stripping?
|
||||
end
|
||||
|
||||
def needs_different_geometry?
|
||||
(options[:geometry] && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height) ||
|
||||
(options[:pixels] && @current_geometry.width * @current_geometry.height > options[:pixels])
|
||||
end
|
||||
|
||||
def needs_different_format?
|
||||
@format.present? && @current_format != ".#{@format}"
|
||||
end
|
||||
|
||||
def needs_metadata_stripping?
|
||||
@attachment.instance.respond_to?(:local?) && @attachment.instance.local?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -130,11 +130,20 @@ namespace :tests do
|
||||
# This is checking the attribute rather than the method, to avoid the legacy fallback
|
||||
# and ensure the data has been migrated
|
||||
unless Account.find_local('qcuser').user[:otp_secret] == 'anotpsecretthatshouldbeencrypted'
|
||||
puts "DEBUG: #{Account.find_local('qcuser').user.inspect}"
|
||||
puts 'OTP secret for user not preserved as expected'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
unless Doorkeeper::Application.find(2)[:scopes] == 'write:accounts profile'
|
||||
puts 'Application OAuth scopes not rewritten as expected'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
unless Doorkeeper::Application.find(2).access_tokens.first[:scopes] == 'write:accounts profile'
|
||||
puts 'OAuth access token scopes not rewritten as expected'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
puts 'No errors found. Database state is consistent with a successful migration process.'
|
||||
end
|
||||
|
||||
@@ -152,6 +161,23 @@ namespace :tests do
|
||||
VALUES
|
||||
(1, 'https://example.com/users/foobar', 'foobar@example.com', now(), now()),
|
||||
(1, 'https://example.com/users/foobar', 'foobar@example.com', now(), now());
|
||||
|
||||
/* Doorkeeper records
|
||||
While the `read:me` scope was technically not valid in 3.3.0,
|
||||
it is still useful for the purposes of testing the `ChangeReadMeScopeToProfile`
|
||||
migration.
|
||||
*/
|
||||
|
||||
INSERT INTO "oauth_applications"
|
||||
(id, name, uid, secret, redirect_uri, scopes, created_at, updated_at)
|
||||
VALUES
|
||||
(2, 'foo', 'foo', 'foo', 'https://example.com/#foo', 'write:accounts read:me', now(), now()),
|
||||
(3, 'bar', 'bar', 'bar', 'https://example.com/#bar', 'read:me', now(), now());
|
||||
|
||||
INSERT INTO "oauth_access_tokens"
|
||||
(token, application_id, scopes, resource_owner_id, created_at)
|
||||
VALUES
|
||||
('secret', 2, 'write:accounts read:me', 4, now());
|
||||
SQL
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user