refile 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +56 -0
- data/.travis.yml +11 -0
- data/Gemfile +1 -1
- data/History.md +15 -0
- data/README.md +41 -5
- data/Rakefile +4 -1
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +4 -2
- data/lib/refile/app.rb +96 -71
- data/lib/refile/attacher.rb +115 -0
- data/lib/refile/attachment/active_record.rb +5 -5
- data/lib/refile/attachment.rb +50 -117
- data/lib/refile/backend/file_system.rb +3 -3
- data/lib/refile/backend/s3.rb +7 -12
- data/lib/refile/file.rb +1 -3
- data/lib/refile/image_processing.rb +6 -3
- data/lib/refile/rails/attachment_helper.rb +18 -21
- data/lib/refile/rails.rb +2 -1
- data/lib/refile/random_hasher.rb +9 -8
- data/lib/refile/signature.rb +16 -0
- data/lib/refile/version.rb +1 -1
- data/lib/refile.rb +59 -1
- data/refile.gemspec +10 -6
- data/spec/refile/app_spec.rb +28 -4
- data/spec/refile/attachment_spec.rb +134 -33
- data/spec/refile/backend_examples.rb +7 -11
- data/spec/refile/features/direct_upload_spec.rb +0 -1
- data/spec/refile/features/normal_upload_spec.rb +21 -0
- data/spec/refile/features/presigned_upload_spec.rb +0 -1
- data/spec/refile/rails/attachment_helper_spec.rb +61 -0
- data/spec/refile/spec_helper.rb +26 -20
- data/spec/refile/test_app/app/controllers/normal_posts_controller.rb +10 -1
- data/spec/refile/test_app/app/controllers/presigned_posts_controller.rb +1 -0
- data/spec/refile/test_app/app/models/post.rb +1 -1
- data/spec/refile/test_app/app/views/normal_posts/index.html +5 -0
- data/spec/refile/test_app/app/views/normal_posts/show.html.erb +2 -0
- data/spec/refile/test_app/config/routes.rb +1 -1
- data/spec/refile/test_app.rb +13 -6
- data/spec/refile_spec.rb +39 -0
- metadata +51 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 07f335bddd4f808d06e31efe59579f85ddaa635b
|
4
|
+
data.tar.gz: bb5c872f88e17f6756988f2c3cb0bd65ecac9843
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 277ff85d62eb22974311570c0f8245aec981ef1f9bc6e7f9086f8d0ee8890dd4af89154efba369acc52071d20f11641a0b54b2687cfd831b3139f6ef75d95a1a
|
7
|
+
data.tar.gz: 8f22cf639037cb0ed933f5a399abc1f9534dd8c4b6e60e76f9f55452653cb25679bd8e2c5e5655cabbae6d8fd00739526dfd55c9f60afd19b56c88228414bd8c
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
Metrics/LineLength:
|
2
|
+
Max: 150 # TODO: we should decrease this to 120
|
3
|
+
|
4
|
+
Metrics/ClassLength:
|
5
|
+
Max: 300
|
6
|
+
|
7
|
+
Metrics/MethodLength:
|
8
|
+
Max: 25
|
9
|
+
|
10
|
+
Metrics/ParameterLists:
|
11
|
+
Max: 8
|
12
|
+
|
13
|
+
Metrics/AbcSize:
|
14
|
+
Max: 20
|
15
|
+
|
16
|
+
Metrics/CyclomaticComplexity:
|
17
|
+
Max: 7 # TODO: reduce
|
18
|
+
|
19
|
+
Style/AlignParameters:
|
20
|
+
EnforcedStyle: with_fixed_indentation
|
21
|
+
|
22
|
+
Style/StringLiterals:
|
23
|
+
EnforcedStyle: double_quotes
|
24
|
+
|
25
|
+
Style/StringLiteralsInInterpolation:
|
26
|
+
EnforcedStyle: double_quotes
|
27
|
+
|
28
|
+
Style/AndOr:
|
29
|
+
Enabled: false
|
30
|
+
|
31
|
+
Style/Not:
|
32
|
+
Enabled: false
|
33
|
+
|
34
|
+
Documentation:
|
35
|
+
Enabled: false # TODO: Enable again once we have more docs
|
36
|
+
|
37
|
+
Style/CaseIndentation:
|
38
|
+
IndentWhenRelativeTo: case
|
39
|
+
SupportedStyles:
|
40
|
+
- case
|
41
|
+
- end
|
42
|
+
IndentOneStep: true
|
43
|
+
|
44
|
+
Style/PercentLiteralDelimiters:
|
45
|
+
PreferredDelimiters:
|
46
|
+
'%w': "[]"
|
47
|
+
'%W': "[]"
|
48
|
+
|
49
|
+
Style/AccessModifierIndentation:
|
50
|
+
EnforcedStyle: outdent
|
51
|
+
|
52
|
+
Style/SignalException:
|
53
|
+
Enabled: false
|
54
|
+
|
55
|
+
Lint/EndAlignment:
|
56
|
+
AlignWith: variable
|
data/.travis.yml
CHANGED
data/Gemfile
CHANGED
data/History.md
CHANGED
@@ -1,3 +1,18 @@
|
|
1
|
+
# 0.4.0
|
2
|
+
|
3
|
+
Release date: 2014-12-26
|
4
|
+
|
5
|
+
- [ADDED] Pass through additional args to S3
|
6
|
+
- [ADDED] Rack app sets far future expiry headers
|
7
|
+
- [ADDED] Sinatra app supports CORS preflight requests
|
8
|
+
- [ADDED] Helpers can take `host` option
|
9
|
+
- [ADDED] File type validations
|
10
|
+
- [ADDED] attachment_field accept attribute is set from file type restrictions
|
11
|
+
- [CHANGED] Dynamically generated methods in attachments are included via Module
|
12
|
+
- [CHANGED] Rack app replaced with Sinatra app
|
13
|
+
- [FIXED] Various content type fixes in Sinatra app
|
14
|
+
- [FIXED] Don't set id of record if it is frozen
|
15
|
+
|
1
16
|
# 0.3.0
|
2
17
|
|
3
18
|
Release date: 2014-12-14
|
data/README.md
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# Refile
|
2
2
|
[](http://badge.fury.io/rb/refile)
|
3
3
|
[](https://travis-ci.org/elabs/refile)
|
4
|
+
[](https://codeclimate.com/github/elabs/refile)
|
4
5
|
|
5
6
|
Refile is a modern file upload library for Ruby applications. It is simple, yet
|
6
7
|
powerful. Refile is an attempt by CarrierWave's original author to fix the
|
@@ -218,9 +219,9 @@ end
|
|
218
219
|
|
219
220
|
## 3. Rack Application
|
220
221
|
|
221
|
-
Refile includes a Rack application (an endpoint, not a middleware)
|
222
|
-
streams files from backends and can even accept file
|
223
|
-
backends.
|
222
|
+
Refile includes a Rack application (an endpoint, not a middleware), written in
|
223
|
+
Sinatra. This application streams files from backends and can even accept file
|
224
|
+
uploads and upload them to backends.
|
224
225
|
|
225
226
|
**Important:** Unlike other file upload solutions, Refile always streams your files through your
|
226
227
|
application. It cannot generate URLs to your files. This means that you should
|
@@ -341,7 +342,7 @@ With this helper you can specify an image which is used as a fallback in case
|
|
341
342
|
no file has been uploaded:
|
342
343
|
|
343
344
|
``` erb
|
344
|
-
<%= attachment_image_tag(@user, :profile_image, :fill, 300, 300, fallback: "
|
345
|
+
<%= attachment_image_tag(@user, :profile_image, :fill, 300, 300, fallback: "default.png") %>
|
345
346
|
```
|
346
347
|
|
347
348
|
## 5. JavaScript library
|
@@ -430,7 +431,7 @@ cross site AJAX requests from posting to buckets. Fixing this is easy though.
|
|
430
431
|
- Click "Add CORS Configuration"
|
431
432
|
|
432
433
|
The default configuration only allows "GET", you'll want to allow "POST" as
|
433
|
-
well. You'll also want to permit the "Content-Type"
|
434
|
+
well. You'll also want to permit the "Content-Type" and "Origin" headers.
|
434
435
|
|
435
436
|
It could look something like this:
|
436
437
|
|
@@ -443,6 +444,7 @@ It could look something like this:
|
|
443
444
|
<MaxAgeSeconds>3000</MaxAgeSeconds>
|
444
445
|
<AllowedHeader>Authorization</AllowedHeader>
|
445
446
|
<AllowedHeader>Content-Type</AllowedHeader>
|
447
|
+
<AllowedHeader>Origin</AllowedHeader>
|
446
448
|
</CORSRule>
|
447
449
|
</CORSConfiguration>
|
448
450
|
```
|
@@ -477,6 +479,40 @@ Refile's JavaScript library requires HTML5 features which are unavailable on
|
|
477
479
|
IE9 and earlier versions. All other major browsers are supported. Note though
|
478
480
|
that it has not yet been extensively tested.
|
479
481
|
|
482
|
+
## File type validations
|
483
|
+
|
484
|
+
Refile can check that attached files have a given content type or extension.
|
485
|
+
This allows you to warn users if they try to upload an invalid file.
|
486
|
+
|
487
|
+
**Important:** You should regard this as a convenience feature for your users,
|
488
|
+
not a security feature. Both file extension and content type can easily be
|
489
|
+
spoofed.
|
490
|
+
|
491
|
+
In order to limit attachments to an extension or content type, you can provide
|
492
|
+
them like this:
|
493
|
+
|
494
|
+
``` ruby
|
495
|
+
attachment :cv, extension: "pdf"
|
496
|
+
attachment :profile_image, content_type: "image/jpeg"
|
497
|
+
```
|
498
|
+
|
499
|
+
You can also provide a list of content type or extensions:
|
500
|
+
|
501
|
+
``` ruby
|
502
|
+
attachment :cv, extension: ["pdf", "doc"]
|
503
|
+
attachment :profile_image, content_type: ["image/jpeg", "image/png", "image/gif"]
|
504
|
+
```
|
505
|
+
|
506
|
+
Since the combination of JPEG, PNG and GIF is so common, you can also specify
|
507
|
+
this more succinctly like this:
|
508
|
+
|
509
|
+
``` ruby
|
510
|
+
attachment :profile_image, type: :image
|
511
|
+
```
|
512
|
+
|
513
|
+
When a user uploads a file with an invalid extension or content type and
|
514
|
+
submits the form, they'll be presented with a validation error.
|
515
|
+
|
480
516
|
## Removing attached files
|
481
517
|
|
482
518
|
File input fields unfortunately do not have the option of removing an already
|
data/Rakefile
CHANGED
@@ -4,6 +4,7 @@ require "bundler/gem_tasks"
|
|
4
4
|
require "refile/test_app"
|
5
5
|
require "rspec/core/rake_task"
|
6
6
|
require "yard"
|
7
|
+
require "rubocop/rake_task"
|
7
8
|
|
8
9
|
YARD::Rake::YardocTask.new do |t|
|
9
10
|
t.files = ["README.md", "lib/**/*.rb"]
|
@@ -11,6 +12,8 @@ end
|
|
11
12
|
|
12
13
|
RSpec::Core::RakeTask.new(:spec)
|
13
14
|
|
14
|
-
|
15
|
+
RuboCop::RakeTask.new
|
16
|
+
|
17
|
+
task default: [:spec, :rubocop]
|
15
18
|
|
16
19
|
Rails.application.load_tasks
|
data/config/locales/en.yml
CHANGED
data/config/routes.rb
CHANGED
data/lib/refile/app.rb
CHANGED
@@ -1,99 +1,124 @@
|
|
1
|
-
require "logger"
|
2
1
|
require "json"
|
2
|
+
require "sinatra/base"
|
3
3
|
|
4
4
|
module Refile
|
5
|
-
class App
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
class Proxy
|
14
|
-
def initialize(peek, file)
|
15
|
-
@peek = peek
|
16
|
-
@file = file
|
17
|
-
end
|
5
|
+
class App < Sinatra::Base
|
6
|
+
configure do
|
7
|
+
set :show_exceptions, false
|
8
|
+
set :raise_errors, false
|
9
|
+
set :sessions, false
|
10
|
+
set :logging, false
|
11
|
+
set :dump_errors, false
|
12
|
+
end
|
18
13
|
|
19
|
-
|
20
|
-
|
14
|
+
before do
|
15
|
+
content_type ::File.extname(request.path), default: "application/octet-stream"
|
16
|
+
if Refile.allow_origin
|
17
|
+
response["Access-Control-Allow-Origin"] = Refile.allow_origin
|
18
|
+
response["Access-Control-Allow-Headers"] = request.env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
|
19
|
+
response["Access-Control-Allow-Method"] = request.env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"].to_s
|
21
20
|
end
|
21
|
+
end
|
22
22
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
end
|
23
|
+
get "/:backend/:id/:filename" do
|
24
|
+
set_expires_header
|
25
|
+
stream_file(file)
|
27
26
|
end
|
28
27
|
|
29
|
-
|
30
|
-
|
28
|
+
get "/:backend/:processor/:id/:file_basename.:extension" do
|
29
|
+
set_expires_header
|
30
|
+
stream_file processor.call(file, format: params[:extension])
|
31
|
+
end
|
31
32
|
|
32
|
-
|
33
|
-
|
33
|
+
get "/:backend/:processor/:id/:filename" do
|
34
|
+
set_expires_header
|
35
|
+
stream_file processor.call(file)
|
36
|
+
end
|
34
37
|
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
+
get "/:backend/:processor/*/:id/:file_basename.:extension" do
|
39
|
+
set_expires_header
|
40
|
+
stream_file processor.call(file, *params[:splat].first.split("/"), format: params[:extension])
|
41
|
+
end
|
38
42
|
|
39
|
-
|
43
|
+
get "/:backend/:processor/*/:id/:filename" do
|
44
|
+
set_expires_header
|
45
|
+
stream_file processor.call(file, *params[:splat].first.split("/"))
|
46
|
+
end
|
40
47
|
|
41
|
-
|
48
|
+
options "/:backend" do
|
49
|
+
""
|
50
|
+
end
|
42
51
|
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
processor.call(file, *process_args, format: format)
|
52
|
-
else
|
53
|
-
processor.call(file, *process_args)
|
54
|
-
end
|
55
|
-
end
|
52
|
+
post "/:backend" do
|
53
|
+
backend = Refile.backends[params[:backend]]
|
54
|
+
halt 404 unless backend && Refile.direct_upload.include?(params[:backend])
|
55
|
+
tempfile = request.params.fetch("file").fetch(:tempfile)
|
56
|
+
file = backend.upload(tempfile)
|
57
|
+
content_type :json
|
58
|
+
{ id: file.id }.to_json
|
59
|
+
end
|
56
60
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
return not_found
|
62
|
-
end
|
61
|
+
not_found do
|
62
|
+
content_type :text
|
63
|
+
"not found"
|
64
|
+
end
|
63
65
|
|
64
|
-
|
65
|
-
|
66
|
+
error do |error_thrown|
|
67
|
+
log_error("Error -> #{error_thrown}")
|
68
|
+
error_thrown.backtrace.each do |line|
|
69
|
+
log_error(line)
|
70
|
+
end
|
71
|
+
content_type :text
|
72
|
+
"error"
|
73
|
+
end
|
66
74
|
|
67
|
-
|
68
|
-
elsif env["REQUEST_METHOD"] == "POST" and backend and args.empty? and Refile.direct_upload.include?(backend_name)
|
69
|
-
@logger.debug { "Refile: uploading to #{backend_name} backend which is of type #{backend.class}" }
|
75
|
+
private
|
70
76
|
|
71
|
-
|
72
|
-
|
77
|
+
def set_expires_header
|
78
|
+
expires Refile.content_max_age, :public, :must_revalidate
|
79
|
+
end
|
73
80
|
|
74
|
-
|
75
|
-
|
76
|
-
|
81
|
+
def logger
|
82
|
+
Refile.logger
|
83
|
+
end
|
84
|
+
|
85
|
+
def stream_file(file)
|
86
|
+
stream do |out|
|
87
|
+
file.each do |chunk|
|
88
|
+
out << chunk
|
89
|
+
end
|
77
90
|
end
|
78
|
-
rescue => e
|
79
|
-
log_error(e)
|
80
|
-
[500, {}, ["error"]]
|
81
91
|
end
|
82
92
|
|
83
|
-
|
93
|
+
def backend
|
94
|
+
backend = Refile.backends[params[:backend]]
|
95
|
+
unless backend
|
96
|
+
log_error("Could not find backend: #{params[:backend]}")
|
97
|
+
halt 404
|
98
|
+
end
|
99
|
+
backend
|
100
|
+
end
|
84
101
|
|
85
|
-
def
|
86
|
-
|
102
|
+
def file
|
103
|
+
file = backend.get(params[:id])
|
104
|
+
unless file.exists?
|
105
|
+
log_error("Could not find attachment by id: #{params[:id]}")
|
106
|
+
halt 404
|
107
|
+
end
|
108
|
+
file
|
87
109
|
end
|
88
110
|
|
89
|
-
def
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
@logger.debug " #{line}"
|
95
|
-
end
|
111
|
+
def processor
|
112
|
+
processor = Refile.processors[params[:processor]]
|
113
|
+
unless processor
|
114
|
+
log_error("Could not find processor: #{params[:processor]}")
|
115
|
+
halt 404
|
96
116
|
end
|
117
|
+
processor
|
118
|
+
end
|
119
|
+
|
120
|
+
def log_error(message)
|
121
|
+
logger.error "#{self.class.name}: #{message}"
|
97
122
|
end
|
98
123
|
end
|
99
124
|
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module Refile
|
2
|
+
# @api private
|
3
|
+
class Attacher
|
4
|
+
attr_reader :record, :name, :cache, :store, :cache_id, :options, :errors, :type, :extensions, :content_types
|
5
|
+
attr_accessor :remove
|
6
|
+
|
7
|
+
def initialize(record, name, cache:, store:, raise_errors: true, type: nil, extension: nil, content_type: nil)
|
8
|
+
@record = record
|
9
|
+
@name = name
|
10
|
+
@raise_errors = raise_errors
|
11
|
+
@cache = Refile.backends.fetch(cache.to_s)
|
12
|
+
@store = Refile.backends.fetch(store.to_s)
|
13
|
+
@type = type
|
14
|
+
@extensions = [extension].flatten if extension
|
15
|
+
@content_types = [content_type].flatten if content_type
|
16
|
+
@content_types ||= %w[image/jpeg image/gif image/png] if type == :image
|
17
|
+
@errors = []
|
18
|
+
end
|
19
|
+
|
20
|
+
def id
|
21
|
+
record.send(:"#{name}_id")
|
22
|
+
end
|
23
|
+
|
24
|
+
def id=(id)
|
25
|
+
record.send(:"#{name}_id=", id) unless record.frozen?
|
26
|
+
end
|
27
|
+
|
28
|
+
def get
|
29
|
+
if cached?
|
30
|
+
cache.get(cache_id)
|
31
|
+
elsif id and not id == ""
|
32
|
+
store.get(id)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def valid?(uploadable)
|
37
|
+
@errors = []
|
38
|
+
@errors << :invalid_extension if @extensions and not valid_extension?(uploadable)
|
39
|
+
@errors << :invalid_content_type if @content_types and not valid_content_type?(uploadable)
|
40
|
+
@errors << :too_large if cache.max_size and uploadable.size >= cache.max_size
|
41
|
+
@errors.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
def cache!(uploadable)
|
45
|
+
if valid?(uploadable)
|
46
|
+
@cache_file = cache.upload(uploadable)
|
47
|
+
@cache_id = @cache_file.id
|
48
|
+
elsif @raise_errors
|
49
|
+
raise Refile::Invalid, @errors.join(", ")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def download(url)
|
54
|
+
if url and not url == ""
|
55
|
+
cache!(RestClient::Request.new(method: :get, url: url, raw_response: true).execute.file)
|
56
|
+
end
|
57
|
+
rescue RestClient::Exception
|
58
|
+
@errors = [:download_failed]
|
59
|
+
raise if @raise_errors
|
60
|
+
end
|
61
|
+
|
62
|
+
def cache_id=(id)
|
63
|
+
@cache_id = id unless @cache_file
|
64
|
+
end
|
65
|
+
|
66
|
+
def store!
|
67
|
+
if remove?
|
68
|
+
delete!
|
69
|
+
elsif cached?
|
70
|
+
file = store.upload(cache.get(cache_id))
|
71
|
+
delete!
|
72
|
+
self.id = file.id
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def delete!
|
77
|
+
if cached?
|
78
|
+
cache.delete(cache_id)
|
79
|
+
@cache_id = nil
|
80
|
+
@cache_file = nil
|
81
|
+
end
|
82
|
+
store.delete(id) if id
|
83
|
+
self.id = nil
|
84
|
+
end
|
85
|
+
|
86
|
+
def remove?
|
87
|
+
remove and remove != "" and remove !~ /\A0|false$\z/
|
88
|
+
end
|
89
|
+
|
90
|
+
def accept
|
91
|
+
if content_types
|
92
|
+
content_types.join(",")
|
93
|
+
elsif extensions
|
94
|
+
extensions.map { |e| ".#{e}" }.join(",")
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def valid_content_type?(uploadable)
|
101
|
+
content_type = Refile.extract_content_type(uploadable) or return false
|
102
|
+
@content_types.include?(content_type)
|
103
|
+
end
|
104
|
+
|
105
|
+
def valid_extension?(uploadable)
|
106
|
+
filename = Refile.extract_filename(uploadable) or return false
|
107
|
+
extension = ::File.extname(filename).sub(/^\./, "")
|
108
|
+
@extensions.include?(extension)
|
109
|
+
end
|
110
|
+
|
111
|
+
def cached?
|
112
|
+
cache_id and not cache_id == ""
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -6,22 +6,22 @@ module Refile
|
|
6
6
|
# Attachment method which hooks into ActiveRecord models
|
7
7
|
#
|
8
8
|
# @see Refile::Attachment#attachment
|
9
|
-
def attachment(name,
|
9
|
+
def attachment(name, raise_errors: false, **)
|
10
10
|
super
|
11
11
|
|
12
|
-
|
12
|
+
attacher = "#{name}_attacher"
|
13
13
|
|
14
14
|
validate do
|
15
|
-
errors = send(
|
15
|
+
errors = send(attacher).errors
|
16
16
|
self.errors.add(name, *errors) unless errors.empty?
|
17
17
|
end
|
18
18
|
|
19
19
|
before_save do
|
20
|
-
send(
|
20
|
+
send(attacher).store!
|
21
21
|
end
|
22
22
|
|
23
23
|
after_destroy do
|
24
|
-
send(
|
24
|
+
send(attacher).delete!
|
25
25
|
end
|
26
26
|
end
|
27
27
|
end
|