Skip to content

Commit d305862

Browse files
Preview PDFs and videos
1 parent f7b4be4 commit d305862

36 files changed

+444
-68
lines changed

.travis.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ services:
1414

1515
addons:
1616
postgresql: "9.6"
17+
apt:
18+
sources:
19+
- sourceline: "ppa:mc3man/trusty-media"
20+
- sourceline: "ppa:ubuntuhandbook1/apps"
21+
packages:
22+
- ffmpeg
23+
- mupdf
24+
- mupdf-tools
1725

1826
bundler_args: --without test --jobs 3 --retry 3
1927
before_install:

activestorage/app/controllers/active_storage/blobs_controller.rb

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,11 @@
66
# authenticated redirection controller.
77
class ActiveStorage::BlobsController < ActionController::Base
88
def show
9-
if blob = find_signed_blob
10-
expires_in 5.minutes # service_url defaults to 5 minutes
11-
redirect_to blob.service_url(disposition: disposition_param)
9+
if blob = ActiveStorage::Blob.find_signed(params[:signed_id])
10+
expires_in ActiveStorage::Blob.service.url_expires_in
11+
redirect_to blob.service_url(disposition: params[:disposition])
1212
else
1313
head :not_found
1414
end
1515
end
16-
17-
private
18-
def find_signed_blob
19-
ActiveStorage::Blob.find_signed(params[:signed_id])
20-
end
21-
22-
def disposition_param
23-
params[:disposition].presence_in(%w( inline attachment )) || "inline"
24-
end
2516
end

activestorage/app/controllers/active_storage/disk_controller.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class ActiveStorage::DiskController < ActionController::Base
88
def show
99
if key = decode_verified_key
1010
send_data disk_service.download(key),
11-
disposition: disposition_param, content_type: params[:content_type]
11+
disposition: params[:disposition], content_type: params[:content_type]
1212
else
1313
head :not_found
1414
end
@@ -38,10 +38,6 @@ def decode_verified_key
3838
ActiveStorage.verifier.verified(params[:encoded_key], purpose: :blob_key)
3939
end
4040

41-
def disposition_param
42-
params[:disposition].presence || "inline"
43-
end
44-
4541

4642
def decode_verified_token
4743
ActiveStorage.verifier.verified(params[:encoded_token], purpose: :blob_token)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
class ActiveStorage::PreviewsController < ActionController::Base
4+
def show
5+
if blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id])
6+
expires_in ActiveStorage::Blob.service.url_expires_in
7+
redirect_to ActiveStorage::Preview.new(blob, params[:variation_key]).processed.service_url(disposition: params[:disposition])
8+
else
9+
head :not_found
10+
end
11+
end
12+
end

activestorage/app/controllers/active_storage/variants_controller.rb

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,11 @@
66
# authenticated redirection controller.
77
class ActiveStorage::VariantsController < ActionController::Base
88
def show
9-
if blob = find_signed_blob
10-
expires_in 5.minutes # service_url defaults to 5 minutes
11-
redirect_to ActiveStorage::Variant.new(blob, decoded_variation).processed.service_url(disposition: disposition_param)
9+
if blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id])
10+
expires_in ActiveStorage::Blob.service.url_expires_in
11+
redirect_to ActiveStorage::Variant.new(blob, params[:variation_key]).processed.service_url(disposition: params[:disposition])
1212
else
1313
head :not_found
1414
end
1515
end
16-
17-
private
18-
def find_signed_blob
19-
ActiveStorage::Blob.find_signed(params[:signed_blob_id])
20-
end
21-
22-
def decoded_variation
23-
ActiveStorage::Variation.decode(params[:variation_key])
24-
end
25-
26-
def disposition_param
27-
params[:disposition].presence_in(%w( inline attachment )) || "inline"
28-
end
2916
end

activestorage/app/models/active_storage/blob.rb

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414
# update a blob's metadata on a subsequent pass, but you should not update the key or change the uploaded file.
1515
# If you need to create a derivative or otherwise change the blob, simply create a new blob and purge the old one.
1616
class ActiveStorage::Blob < ActiveRecord::Base
17+
class UnpreviewableError < StandardError; end
18+
1719
self.table_name = "active_storage_blobs"
1820

1921
has_secure_token :key
2022
store :metadata, coder: JSON
2123

2224
class_attribute :service
2325

26+
has_one_attached :preview_image
27+
2428
class << self
2529
# You can used the signed ID of a blob to refer to it on the client side without fear of tampering.
2630
# This is particularly helpful for direct uploads where the client-side needs to refer to the blob
@@ -101,19 +105,18 @@ def text?
101105
content_type.start_with?("text")
102106
end
103107

104-
# Returns an ActiveStorage::Variant instance with the set of +transformations+
105-
# passed in. This is only relevant for image files, and it allows any image to
106-
# be transformed for size, colors, and the like. Example:
108+
# Returns an ActiveStorage::Variant instance with the set of +transformations+ provided. This is only relevant for image
109+
# files, and it allows any image to be transformed for size, colors, and the like. Example:
107110
#
108111
# avatar.variant(resize: "100x100").processed.service_url
109112
#
110-
# This will create and process a variant of the avatar blob that's constrained to a height and width of 100.
113+
# This will create and process a variant of the avatar blob that's constrained to a height and width of 100px.
111114
# Then it'll upload said variant to the service according to a derivative key of the blob and the transformations.
112115
#
113116
# Frequently, though, you don't actually want to transform the variant right away. But rather simply refer to a
114117
# specific variant that can be created by a controller on-demand. Like so:
115118
#
116-
# <%= image_tag url_for(Current.user.avatar.variant(resize: "100x100")) %>
119+
# <%= image_tag Current.user.avatar.variant(resize: "100x100") %>
117120
#
118121
# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::VariantsController
119122
# can then produce on-demand.
@@ -122,17 +125,45 @@ def variant(transformations)
122125
end
123126

124127

128+
# Returns an ActiveStorage::Preview instance with the set of +transformations+ provided. A preview is an image generated
129+
# from a non-image blob. Active Storage comes with built-in previewers for videos and PDF documents. The video previewer
130+
# extracts the first frame from a video and the PDF previewer extracts the first page from a PDF document.
131+
#
132+
# blob.preview(resize: "100x100").processed.service_url
133+
#
134+
# Avoid processing previews synchronously in views. Instead, link to a controller action that processes them on demand.
135+
# Active Storage provides one, but you may want to create your own (for example, if you need authentication). Here’s
136+
# how to use the built-in version:
137+
#
138+
# <%= image_tag video.preview(resize: "100x100") %>
139+
#
140+
# This method raises ActiveStorage::Blob::UnpreviewableError if no previewer accepts the receiving blob. To determine
141+
# whether a blob is accepted by any previewer, call ActiveStorage::Blob#previewable?.
142+
def preview(transformations)
143+
if previewable?
144+
ActiveStorage::Preview.new(self, ActiveStorage::Variation.new(transformations))
145+
else
146+
raise UnpreviewableError
147+
end
148+
end
149+
150+
# Returns true if any registered previewer accepts the blob. By default, this will return true for videos and PDF documents.
151+
def previewable?
152+
ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
153+
end
154+
155+
125156
# Returns the URL of the blob on the service. This URL is intended to be short-lived for security and not used directly
126157
# with users. Instead, the +service_url+ should only be exposed as a redirect from a stable, possibly authenticated URL.
127158
# Hiding the +service_url+ behind a redirect also gives you the power to change services without updating all URLs. And
128159
# it allows permanent URLs that redirect to the +service_url+ to be cached in the view.
129-
def service_url(expires_in: 5.minutes, disposition: :inline)
130-
service.url key, expires_in: expires_in, disposition: "#{disposition}; #{filename.parameters}", filename: filename, content_type: content_type
160+
def service_url(expires_in: service.url_expires_in, disposition: "inline")
161+
service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type
131162
end
132163

133164
# Returns a URL that can be used to directly upload a file for this blob on the service. This URL is intended to be
134165
# short-lived for security and only generated on-demand by the client-side JavaScript responsible for doing the uploading.
135-
def service_url_for_direct_upload(expires_in: 5.minutes)
166+
def service_url_for_direct_upload(expires_in: service.url_expires_in)
136167
service.url_for_direct_upload key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum
137168
end
138169

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
# Some non-image blobs can be previewed: that is, they can be presented as images. A video blob can be previewed by
4+
# extracting its first frame, and a PDF blob can be previewed by extracting its first page.
5+
#
6+
# A previewer extracts a preview image from a blob. Active Storage provides previewers for videos and PDFs:
7+
# ActiveStorage::Previewer::VideoPreviewer and ActiveStorage::Previewer::PDFPreviewer. Build custom previewers by
8+
# subclassing ActiveStorage::Previewer and implementing the requisite methods. Consult the ActiveStorage::Previewer
9+
# documentation for more details on what's required of previewers.
10+
#
11+
# To choose the previewer for a blob, Active Storage calls +accept?+ on each registered previewer in order. It uses the
12+
# first previewer for which +accept?+ returns true when given the blob. In a Rails application, add or remove previewers
13+
# by manipulating +Rails.application.config.active_storage.previewers+ in an initializer:
14+
#
15+
# Rails.application.config.active_storage.previewers
16+
# # => [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
17+
#
18+
# # Add a custom previewer for Microsoft Office documents:
19+
# Rails.application.config.active_storage.previewers << DOCXPreviewer
20+
# # => [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer, DOCXPreviewer ]
21+
#
22+
# Outside of a Rails application, modify +ActiveStorage.previewers+ instead.
23+
#
24+
# The built-in previewers rely on third-party system libraries:
25+
#
26+
# * {ffmpeg}[https://www.ffmpeg.org]
27+
# * {mupdf}[https://mupdf.com]
28+
#
29+
# These libraries are not provided by Rails. You must install them yourself to use the built-in previewers. Before you
30+
# install and use third-party software, make sure you understand the licensing implications of doing so.
31+
class ActiveStorage::Preview
32+
class UnprocessedError < StandardError; end
33+
34+
attr_reader :blob, :variation
35+
36+
def initialize(blob, variation_or_variation_key)
37+
@blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key)
38+
end
39+
40+
# Processes the preview if it has not been processed yet. Returns the receiving Preview instance for convenience:
41+
#
42+
# blob.preview(resize: "100x100").processed.service_url
43+
#
44+
# Processing a preview generates an image from its blob and attaches the preview image to the blob. Because the preview
45+
# image is stored with the blob, it is only generated once.
46+
def processed
47+
process unless processed?
48+
self
49+
end
50+
51+
# Returns the blob's attached preview image.
52+
def image
53+
blob.preview_image
54+
end
55+
56+
# Returns the URL of the preview's variant on the service. Raises ActiveStorage::Preview::UnprocessedError if the
57+
# preview has not been processed yet.
58+
#
59+
# This method synchronously processes a variant of the preview image, so do not call it in views. Instead, generate
60+
# a stable URL that redirects to the short-lived URL returned by this method.
61+
def service_url(**options)
62+
if processed?
63+
variant.service_url(options)
64+
else
65+
raise UnprocessedError
66+
end
67+
end
68+
69+
private
70+
def processed?
71+
image.attached?
72+
end
73+
74+
def process
75+
previewer.preview { |attachable| image.attach(attachable) }
76+
end
77+
78+
def variant
79+
ActiveStorage::Variant.new(image, variation).processed
80+
end
81+
82+
83+
def previewer
84+
previewer_class.new(blob)
85+
end
86+
87+
def previewer_class
88+
ActiveStorage.previewers.detect { |klass| klass.accept?(blob) }
89+
end
90+
end

activestorage/app/models/active_storage/variant.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ class ActiveStorage::Variant
3838
attr_reader :blob, :variation
3939
delegate :service, to: :blob
4040

41-
def initialize(blob, variation)
42-
@blob, @variation = blob, variation
41+
def initialize(blob, variation_or_variation_key)
42+
@blob, @variation = blob, ActiveStorage::Variation.wrap(variation_or_variation_key)
4343
end
4444

4545
# Returns the variant instance itself after it's been processed or an existing processing has been found on the service.
@@ -61,7 +61,7 @@ def key
6161
# Use <tt>url_for(variant)</tt> (or the implied form, like +link_to variant+ or +redirect_to variant+) to get the stable URL
6262
# for a variant that points to the ActiveStorage::VariantsController, which in turn will use this +service_call+ method
6363
# for its redirection.
64-
def service_url(expires_in: 5.minutes, disposition: :inline)
64+
def service_url(expires_in: service.url_expires_in, disposition: :inline)
6565
service.url key, expires_in: expires_in, disposition: disposition, filename: blob.filename, content_type: blob.content_type
6666
end
6767

activestorage/app/models/active_storage/variation.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ class ActiveStorage::Variation
1313
attr_reader :transformations
1414

1515
class << self
16+
def wrap(variation_or_key)
17+
case variation_or_key
18+
when self
19+
variation_or_key
20+
else
21+
decode variation_or_key
22+
end
23+
end
24+
1625
# Returns a variation instance with the transformations that were encoded by +encode+.
1726
def decode(key)
1827
new ActiveStorage.verifier.verify(key, purpose: :variation)

activestorage/config/routes.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,19 @@
2424
resolve("ActiveStorage::Variant") { |variant| route_for(:rails_variant, variant) }
2525

2626

27+
get "/rails/active_storage/previews/:signed_blob_id/:variation_key/*filename" => "active_storage/previews#show", as: :rails_blob_preview, internal: true
28+
29+
direct :rails_preview do |preview|
30+
signed_blob_id = preview.blob.signed_id
31+
variation_key = preview.variation.key
32+
filename = preview.blob.filename
33+
34+
route_for(:rails_blob_preview, signed_blob_id, variation_key, filename)
35+
end
36+
37+
resolve("ActiveStorage::Preview") { |preview| route_for(:rails_preview, preview) }
38+
39+
2740
get "/rails/active_storage/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service, internal: true
2841
put "/rails/active_storage/disk/:encoded_token" => "active_storage/disk#update", as: :update_rails_disk_service, internal: true
2942
post "/rails/active_storage/direct_uploads" => "active_storage/direct_uploads#create", as: :rails_direct_uploads, internal: true

0 commit comments

Comments
 (0)