Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/lib/bluesky.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
require 'mini_magick'

class Bluesky
MAX_POST_LENGTH = 300
MAX_PHOTOS = 4

# Bluesky rejects any post embed whose image blob exceeds this many bytes,
# reported as "blob too big" at $.record.embed.images[].image when the record
# is created (uploadBlob itself accepts it, so the failure surfaces later).
MAX_BLOB_SIZE = 2_000_000

# When we have to recompress an oversized image, aim comfortably under the hard
# limit so we don't land right on the edge.
BLOB_SIZE_TARGET = 1_950_000

# Cloudflare has no equivalent to Thumbor's old max_bytes filter, so a
# transformed JPEG can still come back over the limit. When it does, walk it
# down these steps — dropping JPEG quality first, then scaling the image — and
# upload the first result that fits. Without this, an oversized photo produces
# the same too-big blob on every retry and can never be shared.
BLOB_COMPRESSION_STEPS = [
{ quality: 70 },
{ quality: 60 },
{ quality: 50 },
{ quality: 40, resize: '85%' },
{ quality: 40, resize: '70%' },
{ quality: 40, resize: '55%' }
].freeze

# Who's allowed to reply to a post. Only followers and people the account follows,
# to keep drive-by replies from the popular feeds out of the thread.
THREADGATE_ALLOW_RULES = [
Expand Down Expand Up @@ -435,6 +460,13 @@ def upload_photo(url)
image_data = image_response.body
content_type = image_response.content_type || 'image/jpeg'

# If the image is over Bluesky's blob limit, recompress it to fit; otherwise
# createRecord rejects the post and every retry re-uploads the same too-big blob.
if image_data.bytesize > MAX_BLOB_SIZE
image_data = compress_under_blob_limit(image_data)
content_type = 'image/jpeg'
end

headers = {
"Authorization" => "Bearer #{access_token}",
"Content-Type" => content_type
Expand All @@ -449,6 +481,40 @@ def upload_photo(url)
end
end

# Recompresses an oversized image so its blob fits under Bluesky's limit,
# returning JPEG data. Walks through BLOB_COMPRESSION_STEPS and returns the
# first attempt at or under the target size; if none fit, returns the smallest
# (last) attempt, which is still far smaller than the original.
#
# @param image_data [String] the raw (binary) image data to compress.
# @return [String] the recompressed JPEG data.
def compress_under_blob_limit(image_data)
candidate = image_data
BLOB_COMPRESSION_STEPS.each do |step|
candidate = recompress_image(image_data, **step)
return candidate if candidate.bytesize <= BLOB_SIZE_TARGET
end
candidate
end

# Recompresses an image as a JPEG at the given quality, optionally scaling it
# down first.
#
# @param image_data [String] the raw (binary) image data to recompress.
# @param quality [Integer] the JPEG quality to encode at.
# @param resize [String, nil] an optional ImageMagick geometry (e.g. '70%') to scale by.
# @return [String] the recompressed JPEG data.
def recompress_image(image_data, quality:, resize: nil)
image = MiniMagick::Image.read(image_data)
image.format('jpeg')
image.combine_options do |img|
img.resize(resize) if resize
img.quality(quality.to_s)
img.strip
end
image.to_blob
end

# Constructs the reply object for a given post URL.
#
# @param post_url [String] the public URL of the post to reply to.
Expand Down
76 changes: 76 additions & 0 deletions spec/lib/bluesky_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,82 @@
.with(headers: { 'Content-Type' => 'image/png' })
end
end

context 'with an image over the blob size limit' do
let(:photos) do
[{ url: 'https://example.com/photo.jpg', alt_text: 'A photo', width: 4000, height: 3000 }]
end

# A body larger than Bluesky's 2,000,000-byte blob limit.
let(:oversized_body) { 'x' * (described_class::MAX_BLOB_SIZE + 1) }
# What recompression produces: comfortably under the limit.
let(:compressed_blob) { 'y' * 1_000_000 }

before do
stub_request(:post, "#{base_url}/xrpc/com.atproto.server.createSession")
.to_return(status: 200, body: session_response.to_json)

stub_request(:get, 'https://example.com/photo.jpg')
.to_return(status: 200, body: oversized_body, headers: { 'Content-Type' => 'image/jpeg' })

stub_request(:post, "#{base_url}/xrpc/com.atproto.repo.uploadBlob")
.to_return(status: 200, body: { blob: { ref: 'blob123' } }.to_json)

stub_request(:post, "#{base_url}/xrpc/com.atproto.repo.createRecord")
.to_return(status: 200, body: { uri: 'at://did:plc:abcd1234/app.bsky.feed.post/123' }.to_json)

image = instance_double(MiniMagick::Image)
allow(MiniMagick::Image).to receive(:read).and_return(image)
allow(image).to receive(:format)
allow(image).to receive(:combine_options)
allow(image).to receive(:to_blob).and_return(compressed_blob)
end

it 'recompresses the image before uploading it' do
bluesky.skeet(text: text, photos: photos)

expect(MiniMagick::Image).to have_received(:read).with(oversized_body)
expect(WebMock).to have_requested(:post, "#{base_url}/xrpc/com.atproto.repo.uploadBlob")
.with(body: compressed_blob, headers: { 'Content-Type' => 'image/jpeg' })
end

it 'does not upload the original oversized blob' do
bluesky.skeet(text: text, photos: photos)

expect(WebMock).not_to have_requested(:post, "#{base_url}/xrpc/com.atproto.repo.uploadBlob")
.with(body: oversized_body)
end
end

context 'with an image under the blob size limit' do
let(:photos) do
[{ url: 'https://example.com/photo.jpg', alt_text: 'A photo', width: 1920, height: 1080 }]
end

before do
stub_request(:post, "#{base_url}/xrpc/com.atproto.server.createSession")
.to_return(status: 200, body: session_response.to_json)

stub_request(:get, 'https://example.com/photo.jpg')
.to_return(status: 200, body: 'small image data', headers: { 'Content-Type' => 'image/jpeg' })

stub_request(:post, "#{base_url}/xrpc/com.atproto.repo.uploadBlob")
.to_return(status: 200, body: { blob: { ref: 'blob123' } }.to_json)

stub_request(:post, "#{base_url}/xrpc/com.atproto.repo.createRecord")
.to_return(status: 200, body: { uri: 'at://did:plc:abcd1234/app.bsky.feed.post/123' }.to_json)

allow(MiniMagick::Image).to receive(:read)
end

it 'uploads the image as-is without recompressing it' do
bluesky.skeet(text: text, photos: photos)

expect(MiniMagick::Image).not_to have_received(:read)
expect(WebMock).to have_requested(:post, "#{base_url}/xrpc/com.atproto.repo.uploadBlob")
.with(body: 'small image data')
end
end
end

describe 'resolve_handle error handling' do
Expand Down