zendesk_apps_support 4.29.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +176 -0
  3. data/README.md +39 -0
  4. data/config/locales/en.yml +163 -0
  5. data/config/locales/translations/zendesk_apps_support.yml +405 -0
  6. data/lib/zendesk_apps_support.rb +42 -0
  7. data/lib/zendesk_apps_support/app_file.rb +44 -0
  8. data/lib/zendesk_apps_support/app_requirement.rb +11 -0
  9. data/lib/zendesk_apps_support/app_version.rb +78 -0
  10. data/lib/zendesk_apps_support/assets/default_app_logo.svg +10 -0
  11. data/lib/zendesk_apps_support/assets/default_styles.scss +3 -0
  12. data/lib/zendesk_apps_support/assets/default_template.html.erb +10 -0
  13. data/lib/zendesk_apps_support/assets/installed.js.erb +21 -0
  14. data/lib/zendesk_apps_support/assets/src.js.erb +37 -0
  15. data/lib/zendesk_apps_support/build_translation.rb +51 -0
  16. data/lib/zendesk_apps_support/engine.rb +11 -0
  17. data/lib/zendesk_apps_support/finders.rb +36 -0
  18. data/lib/zendesk_apps_support/i18n.rb +31 -0
  19. data/lib/zendesk_apps_support/installation.rb +22 -0
  20. data/lib/zendesk_apps_support/installed.rb +27 -0
  21. data/lib/zendesk_apps_support/location.rb +71 -0
  22. data/lib/zendesk_apps_support/manifest.rb +197 -0
  23. data/lib/zendesk_apps_support/manifest/location_options.rb +35 -0
  24. data/lib/zendesk_apps_support/manifest/no_override_hash.rb +50 -0
  25. data/lib/zendesk_apps_support/manifest/parameter.rb +23 -0
  26. data/lib/zendesk_apps_support/package.rb +402 -0
  27. data/lib/zendesk_apps_support/product.rb +31 -0
  28. data/lib/zendesk_apps_support/sass_functions.rb +43 -0
  29. data/lib/zendesk_apps_support/stylesheet_compiler.rb +38 -0
  30. data/lib/zendesk_apps_support/validations/banner.rb +35 -0
  31. data/lib/zendesk_apps_support/validations/manifest.rb +412 -0
  32. data/lib/zendesk_apps_support/validations/marketplace.rb +31 -0
  33. data/lib/zendesk_apps_support/validations/mime.rb +41 -0
  34. data/lib/zendesk_apps_support/validations/requests.rb +47 -0
  35. data/lib/zendesk_apps_support/validations/requirements.rb +154 -0
  36. data/lib/zendesk_apps_support/validations/secrets.rb +77 -0
  37. data/lib/zendesk_apps_support/validations/secure_settings.rb +37 -0
  38. data/lib/zendesk_apps_support/validations/source.rb +25 -0
  39. data/lib/zendesk_apps_support/validations/stylesheets.rb +28 -0
  40. data/lib/zendesk_apps_support/validations/svg.rb +81 -0
  41. data/lib/zendesk_apps_support/validations/templates.rb +20 -0
  42. data/lib/zendesk_apps_support/validations/translations.rb +160 -0
  43. data/lib/zendesk_apps_support/validations/validation_error.rb +77 -0
  44. metadata +327 -0
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZendeskAppsSupport
4
+ class Product
5
+ extend ZendeskAppsSupport::Finders
6
+ attr_reader :code, :name, :legacy_name
7
+
8
+ def initialize(attrs)
9
+ @code = attrs.fetch(:code)
10
+ @name = attrs.fetch(:name)
11
+ @legacy_name = attrs.fetch(:legacy_name)
12
+ end
13
+
14
+ def self.all
15
+ PRODUCTS_AVAILABLE
16
+ end
17
+
18
+ # The product codes below match the values in the database, do not change them!
19
+ PRODUCTS_AVAILABLE = [
20
+ Product.new(code: 1, name: 'support', legacy_name: 'zendesk'),
21
+ Product.new(code: 2, name: 'chat', legacy_name: 'zopim'),
22
+ Product.new(code: 3, name: 'standalone_chat', legacy_name: 'lotus_box'),
23
+ Product.new(code: 4, name: 'sell', legacy_name: 'sell')
24
+ ].freeze
25
+
26
+ SUPPORT = find_by!(name: 'support')
27
+ CHAT = find_by!(name: 'chat')
28
+ STANDALONE_CHAT = find_by!(name: 'standalone_chat')
29
+ SELL = find_by!(name: 'sell')
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sass'
4
+
5
+ module Sass::Script::Functions
6
+ module AppAssetUrl
7
+ def app_asset_url(name)
8
+ assert_type name, :String
9
+ result = %{url("#{app_asset_url_helper(name)}")}
10
+ Sass::Script::String.new(result)
11
+ end
12
+
13
+ private
14
+
15
+ def app_asset_url_helper(name)
16
+ url_builder = options[:app_asset_url_builder]
17
+ url_builder.app_asset_url(name.value)
18
+ end
19
+ end
20
+
21
+ include AppAssetUrl
22
+ end
23
+
24
+ require 'sassc'
25
+
26
+ module SassC::Script::Functions
27
+ module AppAssetUrl
28
+ def app_asset_url(name)
29
+ raise ArgumentError, "Expected #{name} to be a string" unless name.is_a? Sass::Script::Value::String
30
+ result = %{url("#{app_asset_url_helper(name)}")}
31
+ SassC::Script::String.new(result)
32
+ end
33
+
34
+ private
35
+
36
+ def app_asset_url_helper(name)
37
+ url_builder = options[:app_asset_url_builder]
38
+ url_builder.app_asset_url(name.value)
39
+ end
40
+ end
41
+
42
+ include AppAssetUrl
43
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sass'
4
+ require 'sassc'
5
+ require 'zendesk_apps_support/sass_functions'
6
+
7
+ module ZendeskAppsSupport
8
+ class StylesheetCompiler
9
+ def initialize(source, app_id, url_prefix)
10
+ @source = source
11
+ @app_id = app_id
12
+ @url_prefix = url_prefix
13
+ end
14
+
15
+ def compile(sassc: false)
16
+ options = {
17
+ syntax: :scss, app_asset_url_builder: self
18
+ }
19
+ if sassc
20
+ compiler_class = SassC
21
+ options[:style] = :compressed
22
+ else
23
+ compiler_class = Sass
24
+ end
25
+ compiler_class::Engine.new(wrapped_source.dup, options).render
26
+ end
27
+
28
+ def app_asset_url(name)
29
+ "#{@url_prefix}#{name}"
30
+ end
31
+
32
+ private
33
+
34
+ def wrapped_source
35
+ ".app-#{@app_id} {#{@source}}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'image_size'
4
+
5
+ module ZendeskAppsSupport
6
+ module Validations
7
+ module Banner
8
+ BANNER_WIDTH = 830
9
+ BANNER_HEIGHT = 200
10
+
11
+ class << self
12
+ def call(package)
13
+ File.open(package.path_to('assets/banner.png'), 'rb') do |fh|
14
+ begin
15
+ image = ImageSize.new(fh)
16
+
17
+ unless image.format == :png
18
+ return [ValidationError.new('banner.invalid_format')]
19
+ end
20
+
21
+ unless (image.width == BANNER_WIDTH && image.height == BANNER_HEIGHT) ||
22
+ (image.width == 2 * BANNER_WIDTH && image.height == 2 * BANNER_HEIGHT)
23
+ return [ValidationError.new('banner.invalid_size', required_banner_width: BANNER_WIDTH,
24
+ required_banner_height: BANNER_HEIGHT)]
25
+ end
26
+ rescue
27
+ return [ValidationError.new('banner.invalid_format')]
28
+ end
29
+ end
30
+ []
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,412 @@
1
+ # rubocop:disable ModuleLength
2
+ # frozen_string_literal: true
3
+
4
+ require 'uri'
5
+
6
+ module ZendeskAppsSupport
7
+ module Validations
8
+ module Manifest
9
+ RUBY_TO_JSON = ZendeskAppsSupport::Manifest::RUBY_TO_JSON
10
+ REQUIRED_MANIFEST_FIELDS = RUBY_TO_JSON.select { |k| %i[author default_locale].include? k }.freeze
11
+ OAUTH_REQUIRED_FIELDS = %w[client_id client_secret authorize_uri access_token_uri].freeze
12
+ PARAMETER_TYPES = ZendeskAppsSupport::Manifest::Parameter::TYPES
13
+ OAUTH_MANIFEST_LINK = 'https://developer.zendesk.com/apps/docs/developer-guide/manifest#oauth'
14
+
15
+ class << self
16
+ def call(package)
17
+ unless package.has_file?('manifest.json')
18
+ nested_manifest = package.files.find { |file| file =~ %r{\A[^/]+?/manifest\.json\Z} }
19
+ if nested_manifest
20
+ return [ValidationError.new(:nested_manifest, found_path: nested_manifest.relative_path)]
21
+ end
22
+ return [ValidationError.new(:missing_manifest)]
23
+ end
24
+
25
+ collate_manifest_errors(package)
26
+ rescue JSON::ParserError => e
27
+ return [ValidationError.new(:manifest_not_json, errors: e)]
28
+ rescue ZendeskAppsSupport::Manifest::OverrideError => e
29
+ return [ValidationError.new(:duplicate_manifest_keys, errors: e.message)]
30
+ end
31
+
32
+ private
33
+
34
+ def collate_manifest_errors(package)
35
+ manifest = package.manifest
36
+
37
+ errors = [
38
+ missing_keys_error(manifest),
39
+ type_checks(manifest),
40
+ oauth_error(manifest),
41
+ default_locale_error(manifest, package),
42
+ validate_parameters(manifest),
43
+ if manifest.requirements_only? || manifest.marketing_only?
44
+ [ ban_location(manifest),
45
+ ban_framework_version(manifest) ]
46
+ else
47
+ [ validate_location(package),
48
+ missing_framework_version(manifest),
49
+ invalid_version_error(manifest) ]
50
+ end,
51
+ ban_no_template(manifest)
52
+ ]
53
+ errors.flatten.compact
54
+ end
55
+
56
+ def validate_location(package)
57
+ manifest = package.manifest
58
+ [
59
+ missing_location_error(package),
60
+ invalid_location_error(package),
61
+ invalid_v1_location(package),
62
+ location_framework_mismatch(manifest)
63
+ ]
64
+ end
65
+
66
+ def validate_parameters(manifest)
67
+ if manifest.marketing_only?
68
+ marketing_only_errors(manifest)
69
+ else
70
+ [
71
+ parameters_error(manifest),
72
+ invalid_hidden_parameter_error(manifest),
73
+ invalid_type_error(manifest),
74
+ too_many_oauth_parameters(manifest),
75
+ oauth_cannot_be_secure(manifest),
76
+ name_as_parameter_name_error(manifest)
77
+ ]
78
+ end
79
+ end
80
+
81
+ def oauth_cannot_be_secure(manifest)
82
+ manifest.parameters.map do |parameter|
83
+ if parameter.type == 'oauth' && parameter.secure
84
+ return ValidationError.new('oauth_parameter_cannot_be_secure')
85
+ end
86
+ end
87
+ end
88
+
89
+ def marketing_only_errors(manifest)
90
+ [
91
+ ban_parameters(manifest),
92
+ private_marketing_app_error(manifest)
93
+ ]
94
+ end
95
+
96
+ def type_checks(manifest)
97
+ errors = [
98
+ boolean_error(manifest),
99
+ string_error(manifest),
100
+ no_template_format_error(manifest)
101
+ ]
102
+ unless manifest.experiments.is_a?(Hash)
103
+ errors << ValidationError.new(
104
+ :unacceptable_hash,
105
+ field: 'experiments',
106
+ value: manifest.experiments.class.to_s
107
+ )
108
+ end
109
+ whitelist = manifest.domain_whitelist
110
+ unless whitelist.nil? || whitelist.is_a?(Array) && whitelist.all? { |dom| dom.is_a? String }
111
+ errors << ValidationError.new(:unacceptable_array_of_strings, field: 'domainWhitelist')
112
+ end
113
+ parameters = manifest.original_parameters
114
+ unless parameters.nil? || parameters.is_a?(Array)
115
+ errors << ValidationError.new(
116
+ :unacceptable_array,
117
+ field: 'parameters',
118
+ value: parameters.class.to_s
119
+ )
120
+ end
121
+ errors
122
+ end
123
+
124
+ def string_error(manifest)
125
+ manifest_strings = %i[
126
+ default_locale
127
+ version
128
+ framework_version
129
+ remote_installation_url
130
+ terms_conditions_url
131
+ google_analytics_code
132
+ ]
133
+ errors = manifest_strings.map do |field|
134
+ validate_string(manifest.public_send(field), field)
135
+ end
136
+
137
+ if manifest.author
138
+ author_strings = %w[name email url]
139
+ errors << (author_strings.map do |field|
140
+ validate_string(manifest.author[field], "author #{field}")
141
+ end)
142
+ end
143
+ errors
144
+ end
145
+
146
+ def boolean_error(manifest)
147
+ booleans = %i[requirements_only marketing_only single_install signed_urls private]
148
+ errors = []
149
+ RUBY_TO_JSON.each do |ruby, json|
150
+ if booleans.include? ruby
151
+ errors << validate_boolean(manifest.public_send(ruby), json)
152
+ end
153
+ end
154
+ errors.compact
155
+ end
156
+
157
+ def validate_string(value, label_for_error)
158
+ unless value.is_a?(String) || value.nil?
159
+ ValidationError.new(:unacceptable_string, field: label_for_error, value: value)
160
+ end
161
+ end
162
+
163
+ def validate_boolean(value, label_for_error)
164
+ unless [true, false].include? value
165
+ ValidationError.new(:unacceptable_boolean, field: label_for_error, value: value)
166
+ end
167
+ end
168
+
169
+ def check_errors(error_types, collector, *checked_objects)
170
+ error_types.each do |error_type|
171
+ collector << send(error_type, *checked_objects)
172
+ end
173
+ end
174
+
175
+ def ban_no_template(manifest)
176
+ return unless manifest.iframe_only?
177
+ no_template_migration_link = 'https://developer.zendesk.com/apps/docs/apps-v2/manifest#location'
178
+ if manifest.no_template? || !manifest.no_template_locations.empty?
179
+ ValidationError.new(:no_template_deprecated_in_v2, link: no_template_migration_link)
180
+ end
181
+ end
182
+
183
+ def ban_parameters(manifest)
184
+ ValidationError.new(:no_parameters_required) unless manifest.parameters.empty?
185
+ end
186
+
187
+ def ban_location(manifest)
188
+ ValidationError.new(:no_location_required) unless manifest.location_options.empty?
189
+ end
190
+
191
+ def ban_framework_version(manifest)
192
+ ValidationError.new(:no_framework_version_required) unless manifest.framework_version.nil?
193
+ end
194
+
195
+ def private_marketing_app_error(manifest)
196
+ ValidationError.new(:marketing_only_app_cant_be_private) if manifest.private?
197
+ end
198
+
199
+ def oauth_error(manifest)
200
+ return unless manifest.oauth
201
+ oauth_errors = []
202
+ missing = OAUTH_REQUIRED_FIELDS.select do |key|
203
+ manifest.oauth[key].nil? || manifest.oauth[key].empty?
204
+ end
205
+
206
+ if missing.any?
207
+ oauth_errors << \
208
+ ValidationError.new('oauth_keys.missing', missing_keys: missing.join(', '), count: missing.length)
209
+ end
210
+
211
+ unless manifest.parameters.any? { |param| param.type == 'oauth' }
212
+ oauth_errors << ValidationError.new('oauth_parameter_required',
213
+ link: OAUTH_MANIFEST_LINK)
214
+ end
215
+ oauth_errors
216
+ end
217
+
218
+ def parameters_error(manifest)
219
+ original = manifest.original_parameters
220
+ unless original.nil? || original.is_a?(Array)
221
+ return ValidationError.new(:parameters_not_an_array)
222
+ end
223
+
224
+ return unless manifest.parameters.any?
225
+
226
+ para_names = manifest.parameters.map(&:name)
227
+ duplicate_parameters = para_names.select { |name| para_names.count(name) > 1 }.uniq
228
+ unless duplicate_parameters.empty?
229
+ return ValidationError.new(:duplicate_parameters, duplicate_parameters: duplicate_parameters)
230
+ end
231
+
232
+ invalid_required = manifest.parameters.map do |parameter|
233
+ validate_boolean(parameter.required, "parameters.#{parameter.name}.required")
234
+ end.compact
235
+ return invalid_required if invalid_required.any?
236
+
237
+ invalid_secure = manifest.parameters.map do |parameter|
238
+ validate_boolean(parameter.secure, "parameters.#{parameter.name}.secure")
239
+ end.compact
240
+ return invalid_secure if invalid_secure.any?
241
+ end
242
+
243
+ def missing_keys_error(manifest)
244
+ missing = REQUIRED_MANIFEST_FIELDS.map do |ruby_method, manifest_value|
245
+ manifest_value if manifest.public_send(ruby_method).nil?
246
+ end.compact
247
+
248
+ missing_keys_validation_error(missing) if missing.any?
249
+ end
250
+
251
+ def default_locale_error(manifest, package)
252
+ default_locale = manifest.default_locale
253
+ unless default_locale.nil?
254
+ if default_locale !~ Translations::VALID_LOCALE
255
+ ValidationError.new(:invalid_default_locale, default_locale: default_locale)
256
+ elsif package.translation_files.detect { |f| f.relative_path == "translations/#{default_locale}.json" }.nil?
257
+ ValidationError.new(:missing_translation_file, default_locale: default_locale)
258
+ end
259
+ end
260
+ end
261
+
262
+ def missing_location_error(package)
263
+ missing_keys_validation_error(['location']) if package.manifest.location_options.empty?
264
+ end
265
+
266
+ def invalid_location_error(package)
267
+ errors = []
268
+ package.manifest.location_options.each do |location_options|
269
+ if location_options.url.is_a?(String) && !location_options.url.empty?
270
+ errors << invalid_location_uri_error(package, location_options)
271
+ elsif location_options.auto_load?
272
+ errors << ValidationError.new(:blank_location_uri, location: location_options.location.name)
273
+ end
274
+ end
275
+
276
+ Product::PRODUCTS_AVAILABLE.each do |product|
277
+ invalid_locations = package.manifest.unknown_locations(product.name)
278
+ next if invalid_locations.empty?
279
+ errors << ValidationError.new(:invalid_location,
280
+ invalid_locations: invalid_locations.join(', '),
281
+ host_name: product.name.capitalize,
282
+ count: invalid_locations.length)
283
+ end
284
+
285
+ package.manifest.unknown_hosts.each do |unknown_host|
286
+ errors << ValidationError.new(:invalid_host, host_name: unknown_host)
287
+ end
288
+
289
+ errors
290
+ end
291
+
292
+ def invalid_v1_location(package)
293
+ return unless package.manifest.framework_version &&
294
+ Gem::Version.new(package.manifest.framework_version) < Gem::Version.new('2')
295
+
296
+ invalid_locations = package.manifest.location_options
297
+ .map(&:location)
298
+ .compact
299
+ .select(&:v2_only)
300
+ .map(&:name)
301
+
302
+ unless invalid_locations.empty?
303
+ return ValidationError.new(:invalid_v1_location,
304
+ invalid_locations: invalid_locations.join(', '),
305
+ count: invalid_locations.length)
306
+ end
307
+ end
308
+
309
+ def invalid_location_uri_error(package, location_options)
310
+ path = location_options.url
311
+ return nil if path == ZendeskAppsSupport::Manifest::LEGACY_URI_STUB
312
+ return nil if path.include?('{{setting.')
313
+ validation_error = ValidationError.new(:invalid_location_uri, uri: path)
314
+ uri = URI.parse(path)
315
+ unless uri.absolute? ? valid_absolute_uri?(uri) : valid_relative_uri?(package, uri)
316
+ validation_error
317
+ end
318
+ rescue URI::InvalidURIError
319
+ validation_error
320
+ end
321
+
322
+ def valid_absolute_uri?(uri)
323
+ uri.scheme == 'https' || uri.host == 'localhost'
324
+ end
325
+
326
+ def valid_relative_uri?(package, uri)
327
+ uri.path.start_with?('assets/') && package.has_file?(uri.path)
328
+ end
329
+
330
+ def missing_framework_version(manifest)
331
+ missing_keys_validation_error([RUBY_TO_JSON[:framework_version]]) if manifest.framework_version.nil?
332
+ end
333
+
334
+ def invalid_version_error(manifest)
335
+ valid_to_serve = AppVersion::TO_BE_SERVED
336
+ target_version = manifest.framework_version
337
+
338
+ unless valid_to_serve.include?(target_version)
339
+ return ValidationError.new(:invalid_version,
340
+ target_version: target_version,
341
+ available_versions: valid_to_serve.join(', '))
342
+ end
343
+ end
344
+
345
+ def name_as_parameter_name_error(manifest)
346
+ if manifest.parameters.any? { |p| p.name == 'name' }
347
+ ValidationError.new(:name_as_parameter_name)
348
+ end
349
+ end
350
+
351
+ def invalid_hidden_parameter_error(manifest)
352
+ invalid_params = manifest.parameters.select { |p| p.type == 'hidden' && p.required }.map(&:name)
353
+
354
+ if invalid_params.any?
355
+ ValidationError.new(:invalid_hidden_parameter,
356
+ invalid_params: invalid_params.join(', '),
357
+ count: invalid_params.length)
358
+ end
359
+ end
360
+
361
+ def invalid_type_error(manifest)
362
+ invalid_types = []
363
+ manifest.parameters.each do |parameter|
364
+ parameter_type = parameter.type
365
+
366
+ invalid_types << parameter_type unless PARAMETER_TYPES.include?(parameter_type)
367
+ end
368
+
369
+ if invalid_types.any?
370
+ ValidationError.new(:invalid_type_parameter,
371
+ invalid_types: invalid_types.join(', '),
372
+ count: invalid_types.length)
373
+ end
374
+ end
375
+
376
+ def too_many_oauth_parameters(manifest)
377
+ oauth_parameters = manifest.parameters.select do |parameter|
378
+ parameter.type == 'oauth'
379
+ end
380
+
381
+ if oauth_parameters.count > 1
382
+ ValidationError.new(:too_many_oauth_parameters)
383
+ end
384
+ end
385
+
386
+ def missing_keys_validation_error(missing_keys)
387
+ ValidationError.new('manifest_keys.missing',
388
+ missing_keys: missing_keys.join(', '),
389
+ count: missing_keys.length)
390
+ end
391
+
392
+ def location_framework_mismatch(manifest)
393
+ legacy_locations, iframe_locations = manifest.location_options.partition(&:legacy?)
394
+ if manifest.iframe_only?
395
+ return ValidationError.new(:locations_must_be_urls) unless legacy_locations.empty?
396
+ elsif !iframe_locations.empty?
397
+ return ValidationError.new(:locations_cant_be_urls)
398
+ end
399
+ end
400
+
401
+ # TODO: check the app actually runs in included locations
402
+ def no_template_format_error(manifest)
403
+ no_template = manifest.no_template
404
+ return if [false, true].include? no_template
405
+ unless no_template.is_a?(Array) && manifest.no_template_locations.all? { |loc| Location.find_by(name: loc) }
406
+ ValidationError.new(:invalid_no_template)
407
+ end
408
+ end
409
+ end
410
+ end
411
+ end
412
+ end