ruby-openid 2.0.4 → 2.1.2

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.

Potentially problematic release.


This version of ruby-openid might be problematic. Click here for more details.

Files changed (58) hide show
  1. data/CHANGELOG +65 -28
  2. data/LICENSE +4 -1
  3. data/README +19 -12
  4. data/UPGRADE +5 -0
  5. data/examples/README +8 -22
  6. data/examples/active_record_openid_store/XXX_add_open_id_store_to_db.rb +6 -6
  7. data/examples/active_record_openid_store/lib/association.rb +2 -1
  8. data/examples/active_record_openid_store/lib/openid_ar_store.rb +3 -3
  9. data/examples/rails_openid/app/controllers/consumer_controller.rb +11 -5
  10. data/lib/openid.rb +4 -0
  11. data/lib/openid/association.rb +7 -7
  12. data/lib/openid/consumer/checkid_request.rb +11 -0
  13. data/lib/openid/consumer/discovery.rb +12 -3
  14. data/lib/openid/consumer/idres.rb +35 -43
  15. data/lib/openid/extension.rb +9 -1
  16. data/lib/openid/extensions/pape.rb +22 -25
  17. data/lib/openid/extensions/sreg.rb +1 -0
  18. data/lib/openid/fetchers.rb +25 -5
  19. data/lib/openid/kvform.rb +8 -5
  20. data/lib/openid/kvpost.rb +6 -5
  21. data/lib/openid/message.rb +53 -34
  22. data/lib/openid/server.rb +87 -52
  23. data/lib/openid/trustroot.rb +25 -17
  24. data/lib/openid/util.rb +19 -4
  25. data/lib/openid/yadis/discovery.rb +3 -3
  26. data/lib/openid/yadis/htmltokenizer.rb +8 -5
  27. data/lib/openid/yadis/parsehtml.rb +22 -14
  28. data/lib/openid/yadis/xrds.rb +6 -9
  29. data/test/data/linkparse.txt +1 -1
  30. data/test/data/test1-parsehtml.txt +24 -0
  31. data/test/data/trustroot.txt +8 -2
  32. data/test/test_association.rb +7 -7
  33. data/test/test_associationmanager.rb +1 -1
  34. data/test/test_extension.rb +46 -0
  35. data/test/test_idres.rb +81 -21
  36. data/test/test_kvform.rb +5 -5
  37. data/test/test_message.rb +61 -3
  38. data/test/test_pape.rb +36 -22
  39. data/test/test_server.rb +190 -12
  40. data/test/test_sreg.rb +0 -1
  41. data/test/test_trustroot.rb +1 -0
  42. data/test/test_yadis_discovery.rb +13 -0
  43. metadata +3 -19
  44. data/examples/rails_openid/app/views/consumer/start.rhtml +0 -8
  45. data/examples/rails_openid_login_generator/USAGE +0 -23
  46. data/examples/rails_openid_login_generator/gemspec +0 -13
  47. data/examples/rails_openid_login_generator/openid_login_generator.rb +0 -36
  48. data/examples/rails_openid_login_generator/templates/README +0 -116
  49. data/examples/rails_openid_login_generator/templates/controller.rb +0 -113
  50. data/examples/rails_openid_login_generator/templates/controller_test.rb +0 -0
  51. data/examples/rails_openid_login_generator/templates/helper.rb +0 -2
  52. data/examples/rails_openid_login_generator/templates/openid_login_system.rb +0 -87
  53. data/examples/rails_openid_login_generator/templates/user.rb +0 -14
  54. data/examples/rails_openid_login_generator/templates/user_test.rb +0 -0
  55. data/examples/rails_openid_login_generator/templates/users.yml +0 -0
  56. data/examples/rails_openid_login_generator/templates/view_login.rhtml +0 -15
  57. data/examples/rails_openid_login_generator/templates/view_logout.rhtml +0 -10
  58. data/examples/rails_openid_login_generator/templates/view_welcome.rhtml +0 -9
data/lib/openid/server.rb CHANGED
@@ -26,7 +26,7 @@ module OpenID
26
26
  UNUSED = nil
27
27
 
28
28
  class OpenIDRequest
29
- attr_accessor :namespace, :message, :mode
29
+ attr_accessor :message, :mode
30
30
 
31
31
  # I represent an incoming OpenID request.
32
32
  #
@@ -34,6 +34,15 @@ module OpenID
34
34
  # mode:: The "openid.mode" of this request
35
35
  def initialize
36
36
  @mode = nil
37
+ @message = nil
38
+ end
39
+
40
+ def namespace
41
+ if @message.nil?
42
+ raise RuntimeError, "Request has no message"
43
+ else
44
+ return @message.get_openid_namespace
45
+ end
37
46
  end
38
47
  end
39
48
 
@@ -74,7 +83,6 @@ module OpenID
74
83
  @assoc_handle = assoc_handle
75
84
  @signed = signed
76
85
  @invalidate_handle = invalidate_handle
77
- @namespace = OPENID2_NS
78
86
  end
79
87
 
80
88
  # Construct me from an OpenID::Message.
@@ -93,7 +101,6 @@ module OpenID
93
101
 
94
102
  obj = self.new(assoc_handle, signed, invalidate_handle)
95
103
  obj.message = message
96
- obj.namespace = message.get_openid_namespace()
97
104
  obj.sig = message.get_arg(OPENID_NS, 'sig')
98
105
 
99
106
  if !obj.assoc_handle or
@@ -297,7 +304,6 @@ module OpenID
297
304
  super()
298
305
  @session = session
299
306
  @assoc_type = assoc_type
300
- @namespace = OPENID2_NS
301
307
 
302
308
  @mode = "associate"
303
309
  end
@@ -305,10 +311,10 @@ module OpenID
305
311
  # Construct me from an OpenID Message.
306
312
  def self.from_message(message, op_endpoint=UNUSED)
307
313
  if message.is_openid1()
308
- session_type = message.get_arg(OPENID1_NS, 'session_type')
314
+ session_type = message.get_arg(OPENID_NS, 'session_type')
309
315
  if session_type == 'no-encryption'
310
316
  Util.log('Received OpenID 1 request with a no-encryption ' +
311
- 'assocaition session type. Continuing anyway.')
317
+ 'association session type. Continuing anyway.')
312
318
  elsif !session_type
313
319
  session_type = 'no-encryption'
314
320
  end
@@ -345,7 +351,6 @@ module OpenID
345
351
 
346
352
  obj = self.new(session, assoc_type)
347
353
  obj.message = message
348
- obj.namespace = message.get_openid_namespace()
349
354
  return obj
350
355
  end
351
356
 
@@ -364,7 +369,8 @@ module OpenID
364
369
  })
365
370
  response.fields.update_args(OPENID_NS,
366
371
  @session.answer(assoc.secret))
367
- if @session.session_type != 'no-encryption'
372
+ unless (@session.session_type == 'no-encryption' and
373
+ @message.is_openid1)
368
374
  response.fields.set_arg(
369
375
  OPENID_NS, 'session_type', @session.session_type)
370
376
  end
@@ -440,13 +446,13 @@ module OpenID
440
446
  # a URL.
441
447
  def initialize(identity, return_to, op_endpoint, trust_root=nil,
442
448
  immediate=false, assoc_handle=nil)
443
- @namespace = OPENID2_NS
444
449
  @assoc_handle = assoc_handle
445
450
  @identity = identity
446
451
  @claimed_id = identity
447
452
  @return_to = return_to
448
453
  @trust_root = trust_root or return_to
449
454
  @op_endpoint = op_endpoint
455
+ @message = nil
450
456
 
451
457
  if immediate
452
458
  @immediate = true
@@ -484,7 +490,6 @@ module OpenID
484
490
  def self.from_message(message, op_endpoint)
485
491
  obj = self.allocate
486
492
  obj.message = message
487
- obj.namespace = message.get_openid_namespace()
488
493
  obj.op_endpoint = op_endpoint
489
494
  mode = message.get_arg(OPENID_NS, 'mode')
490
495
  if mode == "checkid_immediate"
@@ -496,44 +501,46 @@ module OpenID
496
501
  end
497
502
 
498
503
  obj.return_to = message.get_arg(OPENID_NS, 'return_to')
499
- if obj.namespace == OPENID1_NS and !obj.return_to
504
+ if message.is_openid1 and !obj.return_to
500
505
  msg = sprintf("Missing required field 'return_to' from %s",
501
506
  message)
502
507
  raise ProtocolError.new(message, msg)
503
508
  end
504
509
 
505
510
  obj.identity = message.get_arg(OPENID_NS, 'identity')
506
- if obj.identity and message.is_openid2()
507
- obj.claimed_id = message.get_arg(OPENID_NS, 'claimed_id')
508
- if !obj.claimed_id
511
+ obj.claimed_id = message.get_arg(OPENID_NS, 'claimed_id')
512
+ if message.is_openid1()
513
+ if !obj.identity
514
+ s = "OpenID 1 message did not contain openid.identity"
515
+ raise ProtocolError.new(message, s)
516
+ end
517
+ else
518
+ if obj.identity and not obj.claimed_id
509
519
  s = ("OpenID 2.0 message contained openid.identity but not " +
510
520
  "claimed_id")
511
521
  raise ProtocolError.new(message, s)
522
+ elsif obj.claimed_id and not obj.identity
523
+ s = ("OpenID 2.0 message contained openid.claimed_id but not " +
524
+ "identity")
525
+ raise ProtocolError.new(message, s)
512
526
  end
513
- else
514
- obj.claimed_id = nil
515
- end
516
-
517
- if !obj.identity and obj.namespace == OPENID1_NS
518
- s = "OpenID 1 message did not contain openid.identity"
519
- raise ProtocolError.new(message, s)
520
527
  end
521
528
 
522
529
  # There's a case for making self.trust_root be a TrustRoot
523
530
  # here. But if TrustRoot isn't currently part of the "public"
524
531
  # API, I'm not sure it's worth doing.
525
- if obj.namespace == OPENID1_NS
526
- obj.trust_root = message.get_arg(
527
- OPENID_NS, 'trust_root', obj.return_to)
532
+ if message.is_openid1
533
+ trust_root_param = 'trust_root'
528
534
  else
529
- obj.trust_root = message.get_arg(
530
- OPENID_NS, 'realm', obj.return_to)
535
+ trust_root_param = 'realm'
536
+ end
537
+ trust_root = message.get_arg(OPENID_NS, trust_root_param)
538
+ trust_root = obj.return_to if (trust_root.nil? || trust_root.empty?)
539
+ obj.trust_root = trust_root
531
540
 
532
- if !obj.return_to and
533
- !obj.trust_root
534
- raise ProtocolError.new(message, "openid.realm required when " +
535
- "openid.return_to absent")
536
- end
541
+ if !message.is_openid1 and !obj.return_to and !obj.trust_root
542
+ raise ProtocolError.new(message, "openid.realm required when " +
543
+ "openid.return_to absent")
537
544
  end
538
545
 
539
546
  obj.assoc_handle = message.get_arg(OPENID_NS, 'assoc_handle')
@@ -642,15 +649,18 @@ module OpenID
642
649
  #
643
650
  # This parameter is new in OpenID 2.0.
644
651
  #
652
+ # Returns an OpenIDResponse object containing a OpenID id_res message.
653
+ #
654
+ # Raises NoReturnToError if the return_to is missing.
655
+ #
645
656
  # Version 2.0 deprecates +server_url+ and adds +claimed_id+.
646
657
  def answer(allow, server_url=nil, identity=nil, claimed_id=nil)
647
- # FIXME: undocumented exceptions
648
658
  if !@return_to
649
659
  raise NoReturnToError
650
660
  end
651
661
 
652
662
  if !server_url
653
- if @namespace != OPENID1_NS and !@op_endpoint
663
+ if @message.is_openid2 and !@op_endpoint
654
664
  # In other words, that warning I raised in
655
665
  # Server.__init__? You should pay attention to it now.
656
666
  raise RuntimeError, ("#{self} should be constructed with "\
@@ -663,7 +673,7 @@ module OpenID
663
673
 
664
674
  if allow
665
675
  mode = 'id_res'
666
- elsif @namespace == OPENID1_NS
676
+ elsif @message.is_openid1
667
677
  if @immediate
668
678
  mode = 'id_res'
669
679
  else
@@ -679,9 +689,9 @@ module OpenID
679
689
 
680
690
  response = OpenIDResponse.new(self)
681
691
 
682
- if claimed_id and @namespace == OPENID1_NS
692
+ if claimed_id and @message.is_openid1
683
693
  raise VersionError, ("claimed_id is new in OpenID 2.0 and not "\
684
- "available for #{@namespace}")
694
+ "available for #{@message.get_openid_namespace}")
685
695
  end
686
696
 
687
697
  if identity and !claimed_id
@@ -715,7 +725,7 @@ module OpenID
715
725
  response_identity = nil
716
726
  end
717
727
 
718
- if @namespace == OPENID1_NS and !response_identity
728
+ if @message.is_openid1 and !response_identity
719
729
  raise ArgumentError, ("Request was an OpenID 1 request, so "\
720
730
  "response must include an identifier.")
721
731
  end
@@ -729,7 +739,7 @@ module OpenID
729
739
 
730
740
  if response_identity
731
741
  response.fields.set_arg(OPENID_NS, 'identity', response_identity)
732
- if @namespace == OPENID2_NS
742
+ if @message.is_openid2
733
743
  response.fields.set_arg(OPENID_NS,
734
744
  'claimed_id', response_claimed_id)
735
745
  end
@@ -737,7 +747,7 @@ module OpenID
737
747
  else
738
748
  response.fields.set_arg(OPENID_NS, 'mode', mode)
739
749
  if @immediate
740
- if @namespace == OPENID1_NS and !server_url
750
+ if @message.is_openid1 and !server_url
741
751
  raise ArgumentError, ("setup_url is required for allow=false "\
742
752
  "in OpenID 1.x immediate mode.")
743
753
  end
@@ -747,6 +757,7 @@ module OpenID
747
757
  setup_request = self.class.new(@identity, @return_to,
748
758
  @op_endpoint, @trust_root, false,
749
759
  @assoc_handle)
760
+ setup_request.message = Message.new(@message.get_openid_namespace)
750
761
  setup_url = setup_request.encode_to_url(server_url)
751
762
  response.fields.set_arg(OPENID_NS, 'user_setup_url', setup_url)
752
763
  end
@@ -774,7 +785,7 @@ module OpenID
774
785
  'return_to' => @return_to}
775
786
 
776
787
  if @trust_root
777
- if @namespace == OPENID1_NS
788
+ if @message.is_openid1
778
789
  q['trust_root'] = @trust_root
779
790
  else
780
791
  q['realm'] = @trust_root
@@ -785,8 +796,8 @@ module OpenID
785
796
  q['assoc_handle'] = @assoc_handle
786
797
  end
787
798
 
788
- response = Message.new(@namespace)
789
- response.update_args(@namespace, q)
799
+ response = Message.new(@message.get_openid_namespace)
800
+ response.update_args(@message.get_openid_namespace, q)
790
801
  return response.to_url(server_url)
791
802
  end
792
803
 
@@ -810,7 +821,7 @@ module OpenID
810
821
  "immediate mode requests.")
811
822
  end
812
823
 
813
- response = Message.new(@namespace)
824
+ response = Message.new(@message.get_openid_namespace)
814
825
  response.set_arg(OPENID_NS, 'mode', 'cancel')
815
826
  return response.to_url(@return_to)
816
827
  end
@@ -859,10 +870,19 @@ module OpenID
859
870
  @fields)
860
871
  end
861
872
 
862
- def to_form_markup
863
- # Returns the form markup for this response.
864
- return @fields.to_form_markup(
865
- @fields.get_arg(OPENID_NS, 'return_to'))
873
+ # form_tag_attrs is a hash of attributes to be added to the form
874
+ # tag. 'accept-charset' and 'enctype' have defaults that can be
875
+ # overridden. If a value is supplied for 'action' or 'method',
876
+ # it will be replaced.
877
+ # Returns the form markup for this response.
878
+ def to_form_markup(form_tag_attrs=nil)
879
+ return @fields.to_form_markup(@request.return_to, form_tag_attrs)
880
+ end
881
+
882
+ # Wraps the form tag from to_form_markup in a complete HTML document
883
+ # that uses javascript to autosubmit the form.
884
+ def to_html(form_tag_attrs=nil)
885
+ return Util.auto_submit_html(to_form_markup(form_tag_attrs))
866
886
  end
867
887
 
868
888
  def render_as_form
@@ -882,7 +902,7 @@ module OpenID
882
902
  # How should I be encoded?
883
903
  # returns one of ENCODE_URL or ENCODE_KVFORM.
884
904
  if BROWSER_REQUEST_MODES.member?(@request.mode)
885
- if @fields.get_openid_namespace == OPENID2_NS and
905
+ if @fields.is_openid2 and
886
906
  encode_to_url.length > OPENID1_URL_LIMIT
887
907
  return ENCODE_HTML_FORM
888
908
  else
@@ -1044,7 +1064,11 @@ module OpenID
1044
1064
  assoc = create_association(true)
1045
1065
  end
1046
1066
 
1047
- signed_response.fields = assoc.sign_message(signed_response.fields)
1067
+ begin
1068
+ signed_response.fields = assoc.sign_message(signed_response.fields)
1069
+ rescue KVFormError => err
1070
+ raise EncodingError, err
1071
+ end
1048
1072
  return signed_response
1049
1073
  end
1050
1074
 
@@ -1220,7 +1244,14 @@ module OpenID
1220
1244
  return nil
1221
1245
  end
1222
1246
 
1223
- message = Message.from_post_args(query)
1247
+ begin
1248
+ message = Message.from_post_args(query)
1249
+ rescue InvalidOpenIDNamespace => e
1250
+ query = query.dup
1251
+ query['openid.ns'] = OPENID2_NS
1252
+ message = Message.from_post_args(query)
1253
+ raise ProtocolError.new(message, e.to_s)
1254
+ end
1224
1255
 
1225
1256
  mode = message.get_arg(OPENID_NS, 'mode')
1226
1257
  if !mode
@@ -1238,7 +1269,7 @@ module OpenID
1238
1269
  # This implementation always raises ProtocolError.
1239
1270
  def default_decoder(message, server)
1240
1271
  mode = message.get_arg(OPENID_NS, 'mode')
1241
- msg = sprintf("No decoder for mode %s", mode)
1272
+ msg = sprintf("Unrecognized OpenID mode %s", mode)
1242
1273
  raise ProtocolError.new(message, msg)
1243
1274
  end
1244
1275
  end
@@ -1415,6 +1446,10 @@ module OpenID
1415
1446
  return to_message().to_form_markup(get_return_to())
1416
1447
  end
1417
1448
 
1449
+ def to_html
1450
+ return Util.auto_submit_html(to_form_markup)
1451
+ end
1452
+
1418
1453
  # How should I be encoded?
1419
1454
  #
1420
1455
  # Returns one of ENCODE_URL, ENCODE_KVFORM, or None. If None,
@@ -1422,7 +1457,7 @@ module OpenID
1422
1457
  # displayed to the user.
1423
1458
  def which_encoding
1424
1459
  if has_return_to()
1425
- if @openid_message.get_openid_namespace() == OPENID2_NS and
1460
+ if @openid_message.is_openid2 and
1426
1461
  encode_to_url().length > OPENID1_URL_LIMIT
1427
1462
  return ENCODE_HTML_FORM
1428
1463
  else
@@ -18,19 +18,24 @@ module OpenID
18
18
 
19
19
  module TrustRoot
20
20
  TOP_LEVEL_DOMAINS = %w'
21
- com edu gov int mil net org biz info name museum coop aero ac ad
22
- ae af ag ai al am an ao aq ar as at au aw az ba bb bd be bf bg
23
- bh bi bj bm bn bo br bs bt bv bw by bz ca cc cd cf cg ch ci ck
24
- cl cm cn co cr cu cv cx cy cz de dj dk dm do dz ec ee eg eh er
25
- es et eu fi fj fk fm fo fr ga gd ge gf gg gh gi gl gm gn gp gq
26
- gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in io iq ir is
27
- it je jm jo jp ke kg kh ki km kn kp kr kw ky kz la lb lc li lk
28
- lr ls lt lu lv ly ma mc md mg mh mk ml mm mn mo mp mq mr ms mt
29
- mu mv mw mx my mz na nc ne nf ng ni nl no np nr nu nz om pa pe
30
- pf pg ph pk pl pm pn pr ps pt pw py qa re ro ru rw sa sb sc sd
31
- se sg sh si sj sk sl sm sn so sr st sv sy sz tc td tf tg th tj
32
- tk tm tn to tp tr tt tv tw tz ua ug uk um us uy uz va vc ve vg
33
- vi vn vu wf ws ye yt yu za zm zw'
21
+ ac ad ae aero af ag ai al am an ao aq ar arpa as asia at
22
+ au aw ax az ba bb bd be bf bg bh bi biz bj bm bn bo br bs bt
23
+ bv bw by bz ca cat cc cd cf cg ch ci ck cl cm cn co com coop
24
+ cr cu cv cx cy cz de dj dk dm do dz ec edu ee eg er es et eu
25
+ fi fj fk fm fo fr ga gb gd ge gf gg gh gi gl gm gn gov gp gq
26
+ gr gs gt gu gw gy hk hm hn hr ht hu id ie il im in info int
27
+ io iq ir is it je jm jo jobs jp ke kg kh ki km kn kp kr kw
28
+ ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mg mh mil
29
+ mk ml mm mn mo mobi mp mq mr ms mt mu museum mv mw mx my mz
30
+ na name nc ne net nf ng ni nl no np nr nu nz om org pa pe pf
31
+ pg ph pk pl pm pn pr pro ps pt pw py qa re ro rs ru rw sa sb
32
+ sc sd se sg sh si sj sk sl sm sn so sr st su sv sy sz tc td
33
+ tel tf tg th tj tk tl tm tn to tp tr travel tt tv tw tz ua
34
+ ug uk us uy uz va vc ve vg vi vn vu wf ws xn--0zwm56d
35
+ xn--11b5bs3a9aj6g xn--80akhbyknj4f xn--9t4b11yi5a
36
+ xn--deba0ad xn--g6w251d xn--hgbk6aj7f53bba
37
+ xn--hlcj6aya9esc7a xn--jxalpdlp xn--kgbechtv xn--zckzah ye
38
+ yt yu za zm zw'
34
39
 
35
40
  ALLOWED_PROTOCOLS = ['http', 'https']
36
41
 
@@ -187,8 +192,6 @@ module OpenID
187
192
  end
188
193
 
189
194
  def TrustRoot.parse(trust_root)
190
- return nil unless trust_root.instance_of?(String)
191
-
192
195
  trust_root = trust_root.dup
193
196
  unparsed = trust_root.dup
194
197
 
@@ -217,8 +220,13 @@ module OpenID
217
220
  return new(unparsed, proto, wildcard, host, port, path)
218
221
  end
219
222
 
220
- def TrustRoot.check_sanity(trust_root)
221
- return TrustRoot.parse(trust_root).sane?
223
+ def TrustRoot.check_sanity(trust_root_string)
224
+ trust_root = TrustRoot.parse(trust_root_string)
225
+ if trust_root.nil?
226
+ return false
227
+ else
228
+ return trust_root.sane?
229
+ end
222
230
  end
223
231
 
224
232
  # quick func for validating a url against a trust root. See the
data/lib/openid/util.rb CHANGED
@@ -4,15 +4,11 @@ require "logger"
4
4
 
5
5
  require "openid/extras"
6
6
 
7
- srand(Time.now.to_f)
8
-
9
7
  # See OpenID::Consumer or OpenID::Server modules, as well as the store classes
10
8
  module OpenID
11
9
  class AssertionError < Exception
12
10
  end
13
11
 
14
- VERSION = "2.0.4"
15
-
16
12
  # Exceptions that are raised by the library are subclasses of this
17
13
  # exception type, so if you want to catch all exceptions raised by
18
14
  # the library, you can catch OpenIDError
@@ -90,6 +86,25 @@ module OpenID
90
86
  def Util.log(message)
91
87
  logger.info(message)
92
88
  end
89
+
90
+ def Util.auto_submit_html(form, title='OpenID transaction in progress')
91
+ return "
92
+ <html>
93
+ <head>
94
+ <title>#{title}</title>
95
+ </head>
96
+ <body onload='document.forms[0].submit();'>
97
+ #{form}
98
+ <script>
99
+ var elements = document.forms[0].elements;
100
+ for (var i = 0; i < elements.length; i++) {
101
+ elements[i].style.display = \"none\";
102
+ }
103
+ </script>
104
+ </body>
105
+ </html>
106
+ "
107
+ end
93
108
  end
94
109
 
95
110
  end