mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
This adds the `emoji_picker_pinned_groups` site setting for pinning emoji groups at the top of the emoji picker list. It accepts both custom & non custom emoji group names, and can be ordered to preference. If a custom emoji group is added, and the group is deleted, it will gracefully ignore the missing group. A validator is included to ensure the site setting is correct at time of save. <img width="912" height="228" alt="image" src="https://github.com/user-attachments/assets/0a832cd9-146d-4d46-b9d1-4e8d4ea3b30d" /> <img width="464" height="354" alt="image" src="https://github.com/user-attachments/assets/2512df0e-035c-457a-bed3-b5d0ac3edc2c" /> --------- Co-authored-by: Martin Brennan <martin@discourse.org>
153 lines
4.4 KiB
Ruby
Vendored
153 lines
4.4 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
require "zip"
|
|
|
|
module Compression
|
|
class SafeZipReader
|
|
Error = Class.new(StandardError)
|
|
EntryTooLargeError = Class.new(Error)
|
|
MissingEntryError = Class.new(Error)
|
|
TooManyEntriesError = Class.new(Error)
|
|
SuspiciousEntryError = Class.new(Error)
|
|
|
|
DEFAULT_READ_CHUNK_BYTES = 16 * 1024
|
|
|
|
attr_reader :zip_file, :remaining_total_bytes
|
|
|
|
def self.open(path, **kwargs)
|
|
::Zip::File.open(path) do |zip_file|
|
|
reader = new(zip_file, **kwargs)
|
|
reader.validate!
|
|
|
|
yield(reader)
|
|
end
|
|
end
|
|
|
|
def initialize(
|
|
zip_file,
|
|
max_entries: nil,
|
|
max_total_bytes: nil,
|
|
max_compression_ratio: nil,
|
|
read_chunk_bytes: DEFAULT_READ_CHUNK_BYTES
|
|
)
|
|
@zip_file = zip_file
|
|
@max_entries = max_entries
|
|
@remaining_total_bytes = max_total_bytes
|
|
@max_compression_ratio = max_compression_ratio
|
|
@read_chunk_bytes = read_chunk_bytes
|
|
end
|
|
|
|
def validate!
|
|
if @max_entries && entries.size > @max_entries
|
|
raise TooManyEntriesError, "Zip archive has too many entries"
|
|
end
|
|
|
|
self
|
|
end
|
|
|
|
def entries
|
|
zip_file.entries
|
|
end
|
|
|
|
def find_entry(name)
|
|
zip_file.find_entry(name) || find_prefixed_entry(name)
|
|
end
|
|
|
|
def read_entry(entry_or_name, max_bytes:, required: false)
|
|
entry = resolve_entry(entry_or_name)
|
|
if required && (entry.nil? || entry.directory?)
|
|
raise MissingEntryError, "Zip entry #{entry_name(entry_or_name)} is missing"
|
|
end
|
|
return if entry.nil? || entry.directory?
|
|
|
|
data = +""
|
|
stream_entry(entry, max_bytes: max_bytes) { |chunk| data << chunk }
|
|
data
|
|
end
|
|
|
|
def stream_entry_to_file(entry_or_name, file, max_bytes:, required: false)
|
|
entry = resolve_entry(entry_or_name)
|
|
if required && (entry.nil? || entry.directory?)
|
|
raise MissingEntryError, "Zip entry #{entry_name(entry_or_name)} is missing"
|
|
end
|
|
return if entry.nil? || entry.directory?
|
|
|
|
stream_entry(entry, max_bytes: max_bytes) { |chunk| file.write(chunk) }
|
|
end
|
|
|
|
private
|
|
|
|
# macOS Finder's "Compress" wraps the folder's contents in a root
|
|
# directory and adds __MACOSX metadata entries, so lookups fall back to
|
|
# resolving names through that wrapper directory.
|
|
def find_prefixed_entry(name)
|
|
root_prefix && zip_file.find_entry("#{root_prefix}#{name}")
|
|
end
|
|
|
|
def root_prefix
|
|
return @root_prefix if defined?(@root_prefix)
|
|
|
|
@root_prefix = compute_root_prefix
|
|
end
|
|
|
|
def compute_root_prefix
|
|
names = entries.map(&:name).reject { |name| metadata_entry?(name) }
|
|
first_segment, separator, _rest = names.first.to_s.partition("/")
|
|
return if separator.empty?
|
|
|
|
prefix = "#{first_segment}/"
|
|
prefix if names.all? { |name| name.start_with?(prefix) }
|
|
end
|
|
|
|
def metadata_entry?(name)
|
|
name.start_with?("__MACOSX/") || File.basename(name).start_with?(".")
|
|
end
|
|
|
|
def resolve_entry(entry_or_name)
|
|
entry_or_name.respond_to?(:get_input_stream) ? entry_or_name : find_entry(entry_or_name)
|
|
end
|
|
|
|
def entry_name(entry_or_name)
|
|
entry_or_name.respond_to?(:name) ? entry_or_name.name : entry_or_name
|
|
end
|
|
|
|
def stream_entry(entry, max_bytes:)
|
|
limit = entry_limit(max_bytes)
|
|
validate_entry_metadata!(entry, limit)
|
|
|
|
bytes_read = 0
|
|
entry.get_input_stream do |stream|
|
|
while (chunk = stream.read(@read_chunk_bytes))
|
|
bytes_read += chunk.bytesize
|
|
raise EntryTooLargeError, "Zip entry #{entry.name} is too large" if bytes_read > limit
|
|
|
|
yield chunk
|
|
end
|
|
end
|
|
|
|
@remaining_total_bytes -= bytes_read if @remaining_total_bytes
|
|
bytes_read
|
|
end
|
|
|
|
def entry_limit(max_bytes)
|
|
limit = [max_bytes, @remaining_total_bytes].compact.min
|
|
raise EntryTooLargeError, "Zip content is too large" if limit.nil? || limit <= 0
|
|
|
|
limit
|
|
end
|
|
|
|
def validate_entry_metadata!(entry, limit)
|
|
if entry.size && entry.size > limit
|
|
raise EntryTooLargeError, "Zip entry #{entry.name} is too large"
|
|
end
|
|
|
|
return if @max_compression_ratio.nil?
|
|
return if entry.compressed_size.nil? || entry.compressed_size <= 0
|
|
return if entry.size.nil?
|
|
|
|
if (entry.size.to_f / entry.compressed_size) > @max_compression_ratio
|
|
raise SuspiciousEntryError, "Zip entry #{entry.name} has a suspicious compression ratio"
|
|
end
|
|
end
|
|
end
|
|
end
|