rack-cors 1.1.1 → 2.0.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rubocop.yml +31 -0
- data/.travis.yml +6 -1
- data/CHANGELOG.md +6 -0
- data/Gemfile +2 -0
- data/README.md +43 -33
- data/Rakefile +5 -4
- data/lib/rack/cors/resource.rb +132 -0
- data/lib/rack/cors/resources/cors_misconfiguration_error.rb +14 -0
- data/lib/rack/cors/resources.rb +62 -0
- data/lib/rack/cors/result.rb +63 -0
- data/lib/rack/cors/version.rb +3 -1
- data/lib/rack/cors.rb +101 -354
- data/rack-cors.gemspec +20 -17
- data/test/.rubocop.yml +8 -0
- data/test/cors/test.cors.coffee +4 -2
- data/test/cors/test.cors.js +6 -2
- data/test/unit/cors_test.rb +164 -158
- data/test/unit/dsl_test.rb +30 -29
- data/test/unit/insecure.ru +2 -0
- data/test/unit/non_http.ru +2 -0
- data/test/unit/test.ru +24 -21
- metadata +49 -14
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c4c9bf1e801f0bb5f4c5abb111fbe9a0dcfbd1bcf0da1b2070fb1575de5bcdd7
|
4
|
+
data.tar.gz: 9a78208ad501ffa452ac0721eeb29ccf5fc00ad392bfe2be06c8d0c6a4a1c5c0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ef9c38f1c3f6c13609afcded7d3bd0c40e22e6a098fe7f565d5d6351c1ed0e3f199752912163bd1963005b3ed4d5ae20216b6f8c94736b5f941d9e038e089829
|
7
|
+
data.tar.gz: 633bf8887b580a52cd0f50359d8febffd609190cbb5084ae5e02233126c14a70d019bdfd2feddba7cf078cf5d820e86856ad867fcbc1ce90bd3ecb9c75b08f64
|
data/.rubocop.yml
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
---
|
2
|
+
|
3
|
+
AllCops:
|
4
|
+
Exclude:
|
5
|
+
- 'examples/**/*'
|
6
|
+
|
7
|
+
# Disables
|
8
|
+
Layout/LineLength:
|
9
|
+
Enabled: false
|
10
|
+
Style/Documentation:
|
11
|
+
Enabled: false
|
12
|
+
Metrics/ClassLength:
|
13
|
+
Enabled: false
|
14
|
+
Metrics/MethodLength:
|
15
|
+
Enabled: false
|
16
|
+
Metrics/BlockLength:
|
17
|
+
Enabled: false
|
18
|
+
Style/HashEachMethods:
|
19
|
+
Enabled: false
|
20
|
+
Style/HashTransformKeys:
|
21
|
+
Enabled: false
|
22
|
+
Style/HashTransformValues:
|
23
|
+
Enabled: false
|
24
|
+
Style/DoubleNegation:
|
25
|
+
Enabled: false
|
26
|
+
Metrics/CyclomaticComplexity:
|
27
|
+
Enabled: false
|
28
|
+
Metrics/PerceivedComplexity:
|
29
|
+
Enabled: false
|
30
|
+
Metrics/AbcSize:
|
31
|
+
Enabled: false
|
data/.travis.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,12 @@
|
|
1
1
|
# Change Log
|
2
2
|
All notable changes to this project will be documented in this file.
|
3
3
|
|
4
|
+
## 2.0.0 - 2022-09-11
|
5
|
+
### Changed
|
6
|
+
- Refactored codebase
|
7
|
+
- Support declaring custom protocols in origin
|
8
|
+
- Lowercased header names as defined by Rack spec
|
9
|
+
|
4
10
|
## 1.1.1 - 2019-12-29
|
5
11
|
### Changed
|
6
12
|
- Allow /<resource>/* to match /<resource>/ and /<resource> paths
|
data/Gemfile
CHANGED
data/README.md
CHANGED
@@ -20,41 +20,34 @@ gem 'rack-cors'
|
|
20
20
|
## Configuration
|
21
21
|
|
22
22
|
### Rails Configuration
|
23
|
-
|
23
|
+
For Rails, you'll need to add this middleware on application startup. A practical way to do this is with an initializer file. For example, the following will allow GET, POST, PATCH, or PUT requests from any origin on any resource:
|
24
24
|
|
25
25
|
```ruby
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
config.middleware.insert_before 0, Rack::Cors do
|
33
|
-
allow do
|
34
|
-
origins '*'
|
35
|
-
resource '*', headers: :any, methods: [:get, :post, :options]
|
36
|
-
end
|
37
|
-
end
|
38
|
-
|
39
|
-
# Rails 3/4
|
40
|
-
|
41
|
-
config.middleware.insert_before 0, "Rack::Cors" do
|
42
|
-
allow do
|
43
|
-
origins '*'
|
44
|
-
resource '*', headers: :any, methods: [:get, :post, :options]
|
45
|
-
end
|
46
|
-
end
|
26
|
+
# config/initializers/cors.rb
|
27
|
+
|
28
|
+
Rails.application.config.middleware.insert_before 0, Rack::Cors do
|
29
|
+
allow do
|
30
|
+
origins '*'
|
31
|
+
resource '*', headers: :any, methods: [:get, :post, :patch, :put]
|
47
32
|
end
|
48
33
|
end
|
49
34
|
```
|
50
35
|
|
51
|
-
We use `insert_before` to make sure `Rack::Cors` runs at the beginning of the stack to make sure it isn't interfered with by other middleware (see `Rack::Cache` note in **Common Gotchas** section).
|
36
|
+
We use `insert_before` to make sure `Rack::Cors` runs at the beginning of the stack to make sure it isn't interfered with by other middleware (see `Rack::Cache` note in **Common Gotchas** section). Basic setup examples for Rails 5 & Rails 6 can be found in the examples/ directory.
|
52
37
|
|
53
38
|
See The [Rails Guide to Rack](http://guides.rubyonrails.org/rails_on_rack.html) for more details on rack middlewares or watch the [railscast](http://railscasts.com/episodes/151-rack-middleware).
|
54
39
|
|
40
|
+
*Note about Rails 6*: Rails 6 has support for blocking requests from unknown hosts, so origin domains will need to be added there as well.
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
Rails.application.config.hosts << "product.com"
|
44
|
+
```
|
45
|
+
|
46
|
+
Read more about it here in the [Rails Guides](https://guides.rubyonrails.org/configuring.html#configuring-middleware)
|
47
|
+
|
55
48
|
### Rack Configuration
|
56
49
|
|
57
|
-
NOTE: If you're running Rails,
|
50
|
+
NOTE: If you're running Rails, adding `config/initializers/cors.rb` should be enough. There is no need to update `config.ru` as well.
|
58
51
|
|
59
52
|
In `config.ru`, configure `Rack::Cors` by passing a block to the `use` command:
|
60
53
|
|
@@ -96,14 +89,14 @@ end
|
|
96
89
|
#### Origin
|
97
90
|
Origins can be specified as a string, a regular expression, or as '\*' to allow all origins.
|
98
91
|
|
99
|
-
**\*SECURITY NOTE:** Be careful when using regular expressions to not accidentally be too inclusive. For example, the expression `/https:\/\/example\.com/` will match the domain *example.com.randomdomainname.co.uk*. It is recommended that any regular expression be enclosed with start & end string anchors
|
92
|
+
**\*SECURITY NOTE:** Be careful when using regular expressions to not accidentally be too inclusive. For example, the expression `/https:\/\/example\.com/` will match the domain *example.com.randomdomainname.co.uk*. It is recommended that any regular expression be enclosed with start & end string anchors, like `\Ahttps:\/\/example\.com\z`.
|
100
93
|
|
101
94
|
Additionally, origins can be specified dynamically via a block of the following form:
|
102
95
|
```ruby
|
103
96
|
origins { |source, env| true || false }
|
104
97
|
```
|
105
98
|
|
106
|
-
A Resource path can be specified as exact string match (`/path/to/file.txt`) or with a '\*' wildcard (`/all/files/in/*`).
|
99
|
+
A Resource path can be specified as exact string match (`/path/to/file.txt`) or with a '\*' wildcard (`/all/files/in/*`). A resource can take the following options:
|
107
100
|
|
108
101
|
* **methods** (string or array or `:any`): The HTTP methods allowed for the resource.
|
109
102
|
* **headers** (string or array or `:any`): The HTTP headers that will be allowed in the CORS resource request. Use `:any` to allow for any headers in the actual request.
|
@@ -116,24 +109,41 @@ A Resource path can be specified as exact string match (`/path/to/file.txt`) or
|
|
116
109
|
|
117
110
|
## Common Gotchas
|
118
111
|
|
119
|
-
|
112
|
+
### Origin Matching
|
120
113
|
|
121
|
-
|
114
|
+
When specifying an origin, make sure that it does not have a trailing slash.
|
122
115
|
|
123
|
-
|
116
|
+
### Testing Postman and/or cURL
|
124
117
|
|
125
|
-
*
|
118
|
+
* Make sure you're passing in an `Origin:` header. That header is required to trigger a CORS response. Here's [a good SO post](https://stackoverflow.com/questions/12173990/how-can-you-debug-a-cors-request-with-curl) about using cURL for testing CORS.
|
119
|
+
* Make sure your origin does not have a trailing slash.
|
126
120
|
|
127
|
-
|
121
|
+
### Positioning in the Middleware Stack
|
128
122
|
|
129
|
-
|
123
|
+
Positioning of `Rack::Cors` in the middleware stack is very important. In the Rails example above we put it above all other middleware which, in our experience, provides the most consistent results.
|
124
|
+
|
125
|
+
Here are some scenarios where incorrect positioning have created issues:
|
126
|
+
|
127
|
+
* **Serving static files.** Insert before `ActionDispatch::Static` so that static files are served with the proper CORS headers. **NOTE:** this might not work in production as static files are usually served from the web server (Nginx, Apache) and not the Rails container.
|
128
|
+
|
129
|
+
* **Caching in the middleware.** Insert before `Rack::Cache` so that the proper CORS headers are written and not cached ones.
|
130
|
+
|
131
|
+
* **Authentication via Warden** Warden will return immediately if a resource that requires authentication is accessed without authentication. If `Warden::Manager`is in the stack before `Rack::Cors`, it will return without the correct CORS headers being applied, resulting in a failed CORS request.
|
132
|
+
|
133
|
+
You can run the following command to see what the middleware stack looks like:
|
130
134
|
|
131
135
|
```bash
|
132
136
|
bundle exec rake middleware
|
133
137
|
```
|
134
138
|
|
135
|
-
|
139
|
+
Note that the middleware stack is different in production. For example, the `ActionDispatch::Static` middleware will not be part of the stack if `config.serve_static_assets = false`. You can run this to see what your middleware stack looks like in production:
|
136
140
|
|
137
141
|
```bash
|
138
142
|
RAILS_ENV=production bundle exec rake middleware
|
139
143
|
```
|
144
|
+
|
145
|
+
### Serving static files
|
146
|
+
|
147
|
+
If you trying to serve CORS headers on static assets (like CSS, JS, Font files), keep in mind that static files are usually served directly from web servers and never runs through the Rails container (including the middleware stack where `Rack::Cors` resides).
|
148
|
+
|
149
|
+
In Heroku, you can serve static assets through the Rails container by setting `config.serve_static_assets = true` in `production.rb`.
|
data/Rakefile
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
2
4
|
|
3
5
|
require 'rake/testtask'
|
4
6
|
Rake::TestTask.new(:test) do |test|
|
@@ -7,15 +9,14 @@ Rake::TestTask.new(:test) do |test|
|
|
7
9
|
test.verbose = true
|
8
10
|
end
|
9
11
|
|
10
|
-
task :
|
12
|
+
task default: :test
|
11
13
|
|
12
14
|
require 'rdoc/task'
|
13
15
|
Rake::RDocTask.new do |rdoc|
|
14
|
-
version = File.exist?('VERSION') ? File.read('VERSION') :
|
16
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ''
|
15
17
|
|
16
18
|
rdoc.rdoc_dir = 'rdoc'
|
17
19
|
rdoc.title = "rack-cors #{version}"
|
18
20
|
rdoc.rdoc_files.include('README*')
|
19
21
|
rdoc.rdoc_files.include('lib/**/*.rb')
|
20
22
|
end
|
21
|
-
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Cors
|
5
|
+
class Resource
|
6
|
+
# All CORS routes need to accept CORS simple headers at all times
|
7
|
+
# {https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers}
|
8
|
+
CORS_SIMPLE_HEADERS = %w[accept accept-language content-language content-type].freeze
|
9
|
+
|
10
|
+
attr_accessor :path, :methods, :headers, :expose, :max_age, :credentials, :pattern, :if_proc, :vary_headers
|
11
|
+
|
12
|
+
def initialize(public_resource, path, opts = {})
|
13
|
+
raise CorsMisconfigurationError if public_resource && opts[:credentials] == true
|
14
|
+
|
15
|
+
self.path = path
|
16
|
+
self.credentials = public_resource ? false : (opts[:credentials] == true)
|
17
|
+
self.max_age = opts[:max_age] || 7200
|
18
|
+
self.pattern = compile(path)
|
19
|
+
self.if_proc = opts[:if]
|
20
|
+
self.vary_headers = opts[:vary] && [opts[:vary]].flatten
|
21
|
+
@public_resource = public_resource
|
22
|
+
|
23
|
+
self.headers = case opts[:headers]
|
24
|
+
when :any then :any
|
25
|
+
when nil then nil
|
26
|
+
else
|
27
|
+
[opts[:headers]].flatten.collect(&:downcase)
|
28
|
+
end
|
29
|
+
|
30
|
+
self.methods = case opts[:methods]
|
31
|
+
when :any then %i[get head post put patch delete options]
|
32
|
+
else
|
33
|
+
ensure_enum(opts[:methods]) || [:get]
|
34
|
+
end.map(&:to_s)
|
35
|
+
|
36
|
+
self.expose = opts[:expose] ? [opts[:expose]].flatten : nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def matches_path?(path)
|
40
|
+
pattern =~ path
|
41
|
+
end
|
42
|
+
|
43
|
+
def match?(path, env)
|
44
|
+
matches_path?(path) && (if_proc.nil? || if_proc.call(env))
|
45
|
+
end
|
46
|
+
|
47
|
+
def process_preflight(env, result)
|
48
|
+
headers = {}
|
49
|
+
|
50
|
+
request_method = env[Rack::Cors::HTTP_ACCESS_CONTROL_REQUEST_METHOD]
|
51
|
+
result.miss(Result::MISS_NO_METHOD) && (return headers) if request_method.nil?
|
52
|
+
result.miss(Result::MISS_DENY_METHOD) && (return headers) unless methods.include?(request_method.downcase)
|
53
|
+
|
54
|
+
request_headers = env[Rack::Cors::HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
|
55
|
+
result.miss(Result::MISS_DENY_HEADER) && (return headers) if request_headers && !allow_headers?(request_headers)
|
56
|
+
|
57
|
+
result.hit = true
|
58
|
+
headers.merge(to_preflight_headers(env))
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_headers(env)
|
62
|
+
h = {
|
63
|
+
'access-control-allow-origin' => origin_for_response_header(env[Rack::Cors::HTTP_ORIGIN]),
|
64
|
+
'access-control-allow-methods' => methods.collect { |m| m.to_s.upcase }.join(', '),
|
65
|
+
'access-control-expose-headers' => expose.nil? ? '' : expose.join(', '),
|
66
|
+
'access-control-max-age' => max_age.to_s
|
67
|
+
}
|
68
|
+
h['access-control-allow-credentials'] = 'true' if credentials
|
69
|
+
h
|
70
|
+
end
|
71
|
+
|
72
|
+
protected
|
73
|
+
|
74
|
+
def public_resource?
|
75
|
+
@public_resource
|
76
|
+
end
|
77
|
+
|
78
|
+
def origin_for_response_header(origin)
|
79
|
+
return '*' if public_resource?
|
80
|
+
|
81
|
+
origin
|
82
|
+
end
|
83
|
+
|
84
|
+
def to_preflight_headers(env)
|
85
|
+
h = to_headers(env)
|
86
|
+
h.merge!('Access-Control-Allow-Headers' => env[Rack::Cors::HTTP_ACCESS_CONTROL_REQUEST_HEADERS]) if env[Rack::Cors::HTTP_ACCESS_CONTROL_REQUEST_HEADERS]
|
87
|
+
h
|
88
|
+
end
|
89
|
+
|
90
|
+
def allow_headers?(request_headers)
|
91
|
+
headers = self.headers || []
|
92
|
+
return true if headers == :any
|
93
|
+
|
94
|
+
request_headers = request_headers.split(/,\s*/) if request_headers.is_a?(String)
|
95
|
+
request_headers.all? do |header|
|
96
|
+
header = header.downcase
|
97
|
+
CORS_SIMPLE_HEADERS.include?(header) || headers.include?(header)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def ensure_enum(var)
|
102
|
+
return nil if var.nil?
|
103
|
+
|
104
|
+
[var].flatten
|
105
|
+
end
|
106
|
+
|
107
|
+
def compile(path)
|
108
|
+
if path.respond_to? :to_str
|
109
|
+
special_chars = %w[. + ( )]
|
110
|
+
pattern =
|
111
|
+
path.to_str.gsub(%r{((:\w+)|/\*|[\*#{special_chars.join}])}) do |match|
|
112
|
+
case match
|
113
|
+
when '/*'
|
114
|
+
'\\/?(.*?)'
|
115
|
+
when '*'
|
116
|
+
'(.*?)'
|
117
|
+
when *special_chars
|
118
|
+
Regexp.escape(match)
|
119
|
+
else
|
120
|
+
'([^/?&#]+)'
|
121
|
+
end
|
122
|
+
end
|
123
|
+
/^#{pattern}$/
|
124
|
+
elsif path.respond_to? :match
|
125
|
+
path
|
126
|
+
else
|
127
|
+
raise TypeError, path
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Cors
|
5
|
+
class Resource
|
6
|
+
class CorsMisconfigurationError < StandardError
|
7
|
+
def message
|
8
|
+
'Allowing credentials for wildcard origins is insecure.' \
|
9
|
+
" Please specify more restrictive origins or set 'credentials' to false in your CORS configuration."
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'resources/cors_misconfiguration_error'
|
4
|
+
|
5
|
+
module Rack
|
6
|
+
class Cors
|
7
|
+
class Resources
|
8
|
+
attr_reader :resources
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@origins = []
|
12
|
+
@resources = []
|
13
|
+
@public_resources = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def origins(*args, &blk)
|
17
|
+
@origins = args.flatten.reject { |s| s == '' }.map do |n|
|
18
|
+
case n
|
19
|
+
when Proc, Regexp, %r{^[a-z][a-z0-9.+-]*://}
|
20
|
+
n
|
21
|
+
when '*'
|
22
|
+
@public_resources = true
|
23
|
+
n
|
24
|
+
else
|
25
|
+
Regexp.compile("^[a-z][a-z0-9.+-]*:\\\/\\\/#{Regexp.quote(n)}$")
|
26
|
+
end
|
27
|
+
end.flatten
|
28
|
+
@origins.push(blk) if blk
|
29
|
+
end
|
30
|
+
|
31
|
+
def resource(path, opts = {})
|
32
|
+
@resources << Resource.new(public_resources?, path, opts)
|
33
|
+
end
|
34
|
+
|
35
|
+
def public_resources?
|
36
|
+
@public_resources
|
37
|
+
end
|
38
|
+
|
39
|
+
def allow_origin?(source, env = {})
|
40
|
+
return true if public_resources?
|
41
|
+
|
42
|
+
!!@origins.detect do |origin|
|
43
|
+
if origin.is_a?(Proc)
|
44
|
+
origin.call(source, env)
|
45
|
+
elsif origin.is_a?(Regexp)
|
46
|
+
source =~ origin
|
47
|
+
else
|
48
|
+
source == origin
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def match_resource(path, env)
|
54
|
+
@resources.detect { |r| r.match?(path, env) }
|
55
|
+
end
|
56
|
+
|
57
|
+
def resource_for_path(path)
|
58
|
+
@resources.detect { |r| r.matches_path?(path) }
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rack
|
4
|
+
class Cors
|
5
|
+
class Result
|
6
|
+
HEADER_KEY = 'x-rack-cors'
|
7
|
+
|
8
|
+
MISS_NO_ORIGIN = 'no-origin'
|
9
|
+
MISS_NO_PATH = 'no-path'
|
10
|
+
|
11
|
+
MISS_NO_METHOD = 'no-method'
|
12
|
+
MISS_DENY_METHOD = 'deny-method'
|
13
|
+
MISS_DENY_HEADER = 'deny-header'
|
14
|
+
|
15
|
+
attr_accessor :preflight, :hit, :miss_reason
|
16
|
+
|
17
|
+
def hit?
|
18
|
+
!!hit
|
19
|
+
end
|
20
|
+
|
21
|
+
def preflight?
|
22
|
+
!!preflight
|
23
|
+
end
|
24
|
+
|
25
|
+
def miss(reason)
|
26
|
+
self.hit = false
|
27
|
+
self.miss_reason = reason
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.hit(env)
|
31
|
+
r = Result.new
|
32
|
+
r.preflight = false
|
33
|
+
r.hit = true
|
34
|
+
env[Rack::Cors::ENV_KEY] = r
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.miss(env, reason)
|
38
|
+
r = Result.new
|
39
|
+
r.preflight = false
|
40
|
+
r.hit = false
|
41
|
+
r.miss_reason = reason
|
42
|
+
env[Rack::Cors::ENV_KEY] = r
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.preflight(env)
|
46
|
+
r = Result.new
|
47
|
+
r.preflight = true
|
48
|
+
env[Rack::Cors::ENV_KEY] = r
|
49
|
+
end
|
50
|
+
|
51
|
+
def append_header(headers)
|
52
|
+
headers[HEADER_KEY] = if hit?
|
53
|
+
preflight? ? 'preflight-hit' : 'hit'
|
54
|
+
else
|
55
|
+
[
|
56
|
+
(preflight? ? 'preflight-miss' : 'miss'),
|
57
|
+
miss_reason
|
58
|
+
].join('; ')
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|