refile 0.3.0 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
[![Gem Version](https://badge.fury.io/rb/refile.svg)](http://badge.fury.io/rb/refile)
|
3
3
|
[![Build Status](https://travis-ci.org/elabs/refile.svg?branch=master)](https://travis-ci.org/elabs/refile)
|
4
|
+
[![Code Climate](https://codeclimate.com/github/elabs/refile/badges/gpa.svg)](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
|