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
4 changes: 4 additions & 0 deletions app/models/picture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ class Picture < ApplicationRecord
attachable.variant :large, resize_to_limit: [ 1500, 1500 ]
end

def large_image
image.variable? ? image.variant(:large) : image
end

def markable
caption
end
Expand Down
2 changes: 1 addition & 1 deletion app/views/leafables/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
action: "lightbox#open:prevent",
lightbox_target: "image",
lightbox_url_value: rails_blob_path(@leaf.picture.image, disposition: "attachment", only_path: true) } do %>
<%= image_tag @leaf.picture.image.variant(:large), loading: "lazy" %>
<%= image_tag @leaf.picture.large_image, loading: "lazy" %>
<% end %>
<% else %>
<%= image_tag "default-picture.webp", alt: "No image uploaded", loading: "lazy" %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/leaves/_edit.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<%= link_to leafable_path(leaf), class: "toc__thumbnail", data: { turbo_frame: "_top" } do %>
<%= leaf.section.body if leaf.section? %>
<%= sanitize_content(leaf.leafable.body.to_html) if leaf.page? %>
<%= image_tag leaf.leafable.image.variant(:large) if leaf.picture&.image&.attached? %>
<%= image_tag leaf.leafable.large_image if leaf.picture&.image&.attached? %>
<% end %>

<div class="toc__title flex align-center min-width">
Expand Down
2 changes: 1 addition & 1 deletion app/views/leaves/_leaf.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<%= sanitize_content leaf.page.html_preview if leaf.page? %>

<% if leaf.picture? %>
<%= image_tag leaf.leafable.image.attached? ? leaf.leafable.image.variant(:large) : "default-picture.webp" %>
<%= image_tag leaf.leafable.image.attached? ? leaf.leafable.large_image : "default-picture.webp" %>
<% end %>
</div>

Expand Down
2 changes: 1 addition & 1 deletion app/views/pictures/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<%= leafable_edit_form(picture, id: "leafable-editor") do |form| %>
<label class="input input--file input--picture unpad" data-controller="upload-preview">
<%= image_tag picture.image.attached? ? picture.image.variant(:large) : "default-picture.webp", alt: "Picture",
<%= image_tag picture.image.attached? ? picture.large_image : "default-picture.webp", alt: "Picture",
data: { upload_preview_target: "image" } %>
<%= form.file_field :image, class: "input", accept: "image/png, image/jpeg, image/jpg, image/webp", autofocus: true,
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
Expand Down
8 changes: 8 additions & 0 deletions config/initializers/vips.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Disable unfuzzed libvips operations.
#
# Reference the Active Storage transformer to ensure Vips is loaded before we block operations.
ActiveStorage::Transformers::Vips
Vips.block_untrusted(true)
Vips.block("VipsForeignLoadOpenslide", true) # prevent sqlite segfault in forked parallel workers
Rails.application.config.active_storage.variable_content_types -=
%w[ image/bmp image/vnd.microsoft.icon image/vnd.adobe.photoshop ]
10 changes: 10 additions & 0 deletions test/controllers/leafables_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ class LeafablesControllerTest < ActionDispatch::IntegrationTest
assert_in_body "title: \"Reading\""
end

test "show a picture whose image cannot be resized" do
leaves(:reading_picture).leafable.image.attach io: file_fixture("pixel.bmp").open,
filename: "pixel.bmp", content_type: "image/bmp"

get leafable_slug_path(leaves(:reading_picture))

assert_response :success
assert_select "figure img[src*=\"pixel.bmp\"]"
end

test "show with markdown format does not escape HTML entities" do
leaves(:welcome_page).leafable.update!(body: "This has <a href='http://example.com'>a link</a>")

Expand Down
Binary file added test/fixtures/files/pixel.bmp
Binary file not shown.
118 changes: 118 additions & 0 deletions test/lib/vips_loader_policy_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
require "test_helper"
require "vips"
require "tempfile"

# libvips selects a loader from a file's actual bytes, not from its declared content type. These
# tests pin which loader is selected for each file type under the app's configured loader policy
# (config/initializers/vips.rb).
class VipsLoaderPolicyTest < ActiveSupport::TestCase
# Header bytes are enough for libvips to identify a format; native types are encoded live, exotic
# ones are represented by their magic bytes.
FTYP_AVIF = "\x00\x00\x00\x1cftypavif\x00\x00\x00\x00avifmif1miaf".b
FTYP_HEIC = "\x00\x00\x00\x1cftypheic\x00\x00\x00\x00heicmif1miaf".b
BMP = "BM" + [ 0, 0, 54 ].pack("V3") + "\x00" * 40
PSD = "8BPS" + [ 1 ].pack("n") + "\x00" * 26
ICO = "\x00\x00\x01\x00\x01\x00" + "\x00" * 16
SVG = %q(<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>)

test "loads PNG" do
assert_equal "VipsForeignLoadPngFile", loader_for(encode("png"))
end

test "loads GIF" do
assert_equal "VipsForeignLoadNsgifFile", loader_for(encode("gif"))
end

test "loads JPEG" do
assert_equal "VipsForeignLoadJpegFile", loader_for(encode("jpg"))
end

test "loads TIFF" do
assert_equal "VipsForeignLoadTiffFile", loader_for(encode("tif"))
end

test "loads WebP" do
assert_equal "VipsForeignLoadWebpFile", loader_for(encode("webp"))
end

test "loads AVIF" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_AVIF)
end

test "loads HEIC" do
assert_equal "VipsForeignLoadHeifFile", loader_for(FTYP_HEIC)
end

test "denies BMP through magickload" do
assert_nil loader_for(BMP)
end

test "denies PSD through magickload" do
assert_nil loader_for(PSD)
end

test "denies ICO through magickload" do
assert_nil loader_for(ICO)
end

test "denies SVG through svgload" do
assert_nil loader_for(SVG)
end

test "denies OpenSlide files through openslideload" do
# OpenSlide files can segfault the embedded sqlite in forked parallel workers
assert_loader_blocked :openslideload, ".svs"
end

test "denies FITS files through fitsload" do
assert_loader_blocked :fitsload, ".fits"
end

test "denies MATLAB files through matload" do
assert_loader_blocked :matload, ".mat"
end

test "denies NIFTI files through niftiload" do
assert_loader_blocked :niftiload, ".nii"
end

test "denies RAW files through dcrawload" do
assert_loader_blocked :dcrawload, ".raw"
end

test "denies VIPS files through vipsload" do
assert_loader_blocked :vipsload, ".vips"
end

private
# Invoke a specific libvips loader directly and assert it is refused because the
# operation is blocked (rather than because the bytes are not a valid image).
def assert_loader_blocked(operation, extension)
Tempfile.create([ "blocked_loader", extension ], binmode: true) do |file|
file.write "not an image"
file.flush

error = assert_raises(Vips::Error) { Vips::Image.public_send(operation, file.path) }
actual = error.message.chomp

# note that exception message may include multiple errors on separate lines,
# so `^` and `$` anchors are used instead of `\A` and `\z`.
if actual =~ /^VipsOperation: class \"#{operation}\" not found$/
skip "libvips does not support #{operation} on this system"
end
assert_match(/^#{operation}: operation is blocked$/, actual)
end
end

def encode(ext)
Vips::Image.black(8, 8).add(128).cast("uchar").write_to_buffer(".#{ext}")
end

def loader_for(bytes)
Tempfile.create(%w[loader_probe .img], binmode: true) do |file|
file.write bytes
file.flush
Vips.vips_foreign_find_load(file.path)
end
end
end
11 changes: 11 additions & 0 deletions test/models/picture_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,15 @@ class PictureTest < ActiveSupport::TestCase

assert_nil picture.markable
end

test "large_image is the resized variant of a variable image" do
assert_kind_of ActiveStorage::VariantWithRecord, pictures(:reading).large_image
end

test "large_image is the original image when the image cannot be resized" do
picture = pictures(:reading)
picture.image.attach io: file_fixture("pixel.bmp").open, filename: "pixel.bmp", content_type: "image/bmp"

assert_equal picture.image, picture.large_image
end
end
Loading