rack-oauth2-server 2.0.0.beta4 → 2.0.0.beta5
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.
- data/CHANGELOG +1 -1
- data/README.rdoc +25 -1
- data/VERSION +1 -1
- data/lib/rack/oauth2/models/access_grant.rb +8 -4
- data/lib/rack/oauth2/models/access_token.rb +3 -3
- data/lib/rack/oauth2/models/auth_request.rb +3 -3
- data/lib/rack/oauth2/models/client.rb +2 -2
- data/lib/rack/oauth2/server/errors.rb +2 -2
- data/lib/rack/oauth2/server/railtie.rb +1 -2
- data/lib/rack/oauth2/server.rb +25 -22
- data/test/oauth/access_grant_test.rb +9 -0
- data/test/oauth/{server_test.rb → server_methods_test.rb} +44 -0
- data/test/rails2/log/test.log +8023 -0
- data/test/rails3/log/test.log +14122 -0
- metadata +5 -5
data/CHANGELOG
CHANGED
data/README.rdoc
CHANGED
@@ -332,7 +332,7 @@ I'll let you figure that one for yourself.
|
|
332
332
|
|
333
333
|
We haz it, and it's pretty rad:
|
334
334
|
|
335
|
-
http://
|
335
|
+
http://labnotes.org/wp-content/uploads/2010/11/OAuth-Admin-All-Clients.png
|
336
336
|
|
337
337
|
To get the Web admin running, you'll need to do the following. First, you'll
|
338
338
|
need to register a new client application that can access the OAuth Web admin,
|
@@ -479,6 +479,30 @@ server you +curl+. Useful for development, testing, just don't use it with any
|
|
479
479
|
production access tokens.
|
480
480
|
|
481
481
|
|
482
|
+
== Methods You'll Want To Use From Your App
|
483
|
+
|
484
|
+
You can use the Server module to create, fetch and otherwise work with access
|
485
|
+
tokens and grants. Available methods include:
|
486
|
+
|
487
|
+
- access_grant -- Creates and returns a new access grant. You can use that for
|
488
|
+
one-time token, e.g. users who forgot their password and need to login using
|
489
|
+
an email message.
|
490
|
+
- token_for -- Returns access token for particular identity. You can use that to
|
491
|
+
give access tokens to clients other than through the OAuth 2.0 protocol, e.g.
|
492
|
+
if you let users authenticate using Facebook Connect or Twitter OAuth.
|
493
|
+
- get_access_token -- Resolves access token (string) into access token
|
494
|
+
(AccessToken object).
|
495
|
+
- list_access_tokens -- Returns all access tokens for a given identity, which
|
496
|
+
you'll need if you offer a UI for uses to review and revoke access tokens they
|
497
|
+
previously granted.
|
498
|
+
- get_client -- Resolves client identifier into a Client object.
|
499
|
+
- register -- Registers a new client application. Can also be used to change
|
500
|
+
existing registration (if you know the client's ID and secret). Idempotent, so
|
501
|
+
perfect for running during setup and migration.
|
502
|
+
- get_auth_request -- Resolves authorization request handle into an AuthRequest
|
503
|
+
object. Could be useful during the authorization flow.
|
504
|
+
|
505
|
+
|
482
506
|
== Mandatory ASCII Diagram
|
483
507
|
|
484
508
|
This is briefly what the authorization flow looks like, how the workload is
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.0.0.
|
1
|
+
2.0.0.beta5
|
@@ -12,11 +12,13 @@ module Rack
|
|
12
12
|
end
|
13
13
|
|
14
14
|
# Create a new access grant.
|
15
|
-
def create(identity, client, scope, redirect_uri = nil)
|
15
|
+
def create(identity, client, scope, redirect_uri = nil, expires = nil)
|
16
16
|
scope = Utils.normalize_scope(scope) & client.scope # Only allowed scope
|
17
|
+
expires_at = Time.now.to_i + (expires || 300)
|
17
18
|
fields = { :_id=>Server.secure_random, :identity=>identity.to_s, :scope=>scope,
|
18
19
|
:client_id=>client.id, :redirect_uri=>client.redirect_uri || redirect_uri,
|
19
|
-
:created_at=>Time.now.
|
20
|
+
:created_at=>Time.now.to_i, :expires_at=>expires_at, :granted_at=>nil,
|
21
|
+
:access_token=>nil, :revoked=>nil }
|
20
22
|
collection.insert fields
|
21
23
|
Server.new_instance self, fields
|
22
24
|
end
|
@@ -41,6 +43,8 @@ module Rack
|
|
41
43
|
attr_reader :created_at
|
42
44
|
# Tells us when (and if) access token was created.
|
43
45
|
attr_accessor :granted_at
|
46
|
+
# Tells us when this grant expires.
|
47
|
+
attr_accessor :expires_at
|
44
48
|
# Access token created from this grant. Set and spent.
|
45
49
|
attr_accessor :access_token
|
46
50
|
# Timestamp if revoked.
|
@@ -57,7 +61,7 @@ module Rack
|
|
57
61
|
client = Client.find(client_id) or raise InvalidGrantError
|
58
62
|
access_token = AccessToken.get_token_for(identity, client, scope)
|
59
63
|
self.access_token = access_token.token
|
60
|
-
self.granted_at = Time.now.
|
64
|
+
self.granted_at = Time.now.to_i
|
61
65
|
self.class.collection.update({ :_id=>code, :access_token=>nil, :revoked=>nil }, { :$set=>{ :granted_at=>granted_at, :access_token=>access_token.token } }, :safe=>true)
|
62
66
|
reload = self.class.collection.find_one({ :_id=>code, :revoked=>nil }, { :fields=>%w{access_token} })
|
63
67
|
raise InvalidGrantError unless reload && reload["access_token"] == access_token.token
|
@@ -65,7 +69,7 @@ module Rack
|
|
65
69
|
end
|
66
70
|
|
67
71
|
def revoke!
|
68
|
-
self.revoked = Time.now.
|
72
|
+
self.revoked = Time.now.to_i
|
69
73
|
self.class.collection.update({ :_id=>code, :revoked=>nil }, { :$set=>{ :revoked=>revoked } })
|
70
74
|
end
|
71
75
|
|
@@ -19,7 +19,7 @@ module Rack
|
|
19
19
|
scope = Utils.normalize_scope(scope) & client.scope # Only allowed scope
|
20
20
|
unless token = collection.find_one({ :identity=>identity.to_s, :scope=>scope, :client_id=>client.id, :revoked=>nil })
|
21
21
|
token = { :_id=>Server.secure_random, :identity=>identity.to_s, :scope=>scope,
|
22
|
-
:client_id=>client.id, :created_at=>Time.now.
|
22
|
+
:client_id=>client.id, :created_at=>Time.now.to_i,
|
23
23
|
:expires_at=>nil, :revoked=>nil }
|
24
24
|
collection.insert token
|
25
25
|
end
|
@@ -48,7 +48,7 @@ module Rack
|
|
48
48
|
def count(filter = {})
|
49
49
|
select = {}
|
50
50
|
if filter[:days]
|
51
|
-
now = Time.now.
|
51
|
+
now = Time.now.to_i
|
52
52
|
range = { :$gt=>now - filter[:days] * 86400, :$lte=>now }
|
53
53
|
select[ filter[:revoked] ? :revoked : :created_at ] = range
|
54
54
|
elsif filter.has_key?(:revoked)
|
@@ -93,7 +93,7 @@ module Rack
|
|
93
93
|
|
94
94
|
# Revokes this access token.
|
95
95
|
def revoke!
|
96
|
-
self.revoked = Time.now.
|
96
|
+
self.revoked = Time.now.to_i
|
97
97
|
AccessToken.collection.update({ :_id=>token }, { :$set=>{ :revoked=>revoked } })
|
98
98
|
end
|
99
99
|
|
@@ -21,7 +21,7 @@ module Rack
|
|
21
21
|
fields = { :client_id=>client.id, :scope=>scope, :redirect_uri=>client.redirect_uri || redirect_uri,
|
22
22
|
:response_type=>response_type, :state=>state,
|
23
23
|
:grant_code=>nil, :authorized_at=>nil,
|
24
|
-
:created_at=>Time.now.
|
24
|
+
:created_at=>Time.now.to_i, :revoked=>nil }
|
25
25
|
fields[:_id] = collection.insert(fields)
|
26
26
|
Server.new_instance self, fields
|
27
27
|
end
|
@@ -60,7 +60,7 @@ module Rack
|
|
60
60
|
raise ArgumentError, "Must supply a identity" unless identity
|
61
61
|
return if revoked
|
62
62
|
client = Client.find(client_id) or return
|
63
|
-
self.authorized_at = Time.now.
|
63
|
+
self.authorized_at = Time.now.to_i
|
64
64
|
if response_type == "code" # Requested authorization code
|
65
65
|
access_grant = AccessGrant.create(identity, client, scope, redirect_uri)
|
66
66
|
self.grant_code = access_grant.code
|
@@ -75,7 +75,7 @@ module Rack
|
|
75
75
|
|
76
76
|
# Deny access.
|
77
77
|
def deny!
|
78
|
-
self.authorized_at = Time.now.
|
78
|
+
self.authorized_at = Time.now.to_i
|
79
79
|
self.class.collection.update({ :_id=>id }, { :$set=>{ :authorized_at=>authorized_at } })
|
80
80
|
end
|
81
81
|
|
@@ -30,7 +30,7 @@ module Rack
|
|
30
30
|
fields = { :display_name=>args[:display_name], :link=>args[:link],
|
31
31
|
:image_url=>args[:image_url], :redirect_uri=>redirect_uri,
|
32
32
|
:nodes=>args[:notes].to_s, :scope=>scope,
|
33
|
-
:created_at=>Time.now.
|
33
|
+
:created_at=>Time.now.to_i, :revoked=>nil }
|
34
34
|
if args[:id] && args[:secret]
|
35
35
|
fields[:_id], fields[:secret] = BSON::ObjectId(args[:id].to_s), args[:secret]
|
36
36
|
collection.insert(fields, :safe=>true)
|
@@ -95,7 +95,7 @@ module Rack
|
|
95
95
|
# Revoke all authorization requests, access grants and access tokens for
|
96
96
|
# this client. Ward off the evil.
|
97
97
|
def revoke!
|
98
|
-
self.revoked = Time.now.
|
98
|
+
self.revoked = Time.now.to_i
|
99
99
|
Client.collection.update({ :_id=>id }, { :$set=>{ :revoked=>revoked } })
|
100
100
|
AuthRequest.collection.update({ :client_id=>id }, { :$set=>{ :revoked=>revoked } })
|
101
101
|
AccessGrant.collection.update({ :client_id=>id }, { :$set=>{ :revoked=>revoked } })
|
@@ -42,8 +42,8 @@ module Rack
|
|
42
42
|
# assertion, expired authorization token, bad end-user password credentials,
|
43
43
|
# or mismatching authorization code and redirection URI).
|
44
44
|
class InvalidGrantError < OAuthError
|
45
|
-
def initialize
|
46
|
-
super :invalid_grant, "This access grant is no longer valid."
|
45
|
+
def initialize(message)
|
46
|
+
super :invalid_grant, message || "This access grant is no longer valid."
|
47
47
|
end
|
48
48
|
end
|
49
49
|
|
@@ -8,11 +8,10 @@ module Rack
|
|
8
8
|
# Rails 3.x integration.
|
9
9
|
class Railtie < ::Rails::Railtie # :nodoc:
|
10
10
|
config.oauth = Server::Options.new
|
11
|
-
config.oauth.logger = ::Rails.logger
|
12
11
|
|
13
12
|
initializer "rack-oauth2-server" do |app|
|
14
|
-
#app.config.extend ::Rack::OAuth2::Rails::Configuration
|
15
13
|
app.middleware.use ::Rack::OAuth2::Server, app.config.oauth
|
14
|
+
config.oauth.logger ||= ::Rails.logger
|
16
15
|
class ::ActionController::Base
|
17
16
|
helper ::Rack::OAuth2::Rails::Helpers
|
18
17
|
include ::Rack::OAuth2::Rails::Helpers
|
data/lib/rack/oauth2/server.rb
CHANGED
@@ -79,10 +79,12 @@ module Rack
|
|
79
79
|
# @param [String] identity User ID, account ID, etc
|
80
80
|
# @param [String] client_id Client identifier
|
81
81
|
# @param [Array, nil] scope Array of string, nil if you want 'em all
|
82
|
+
# @param [Integer, nil] expires How many seconds before access grant
|
83
|
+
# expires (default to 5 minutes)
|
82
84
|
# @return [String] Access grant authorization code
|
83
|
-
def access_grant(identity, client_id, scope = nil)
|
85
|
+
def access_grant(identity, client_id, scope = nil, expires = nil)
|
84
86
|
client = get_client(client_id) or fail "No such client"
|
85
|
-
AccessGrant.create(identity, client, scope || client.scope).code
|
87
|
+
AccessGrant.create(identity, client, scope || client.scope, nil, expires).code
|
86
88
|
end
|
87
89
|
|
88
90
|
# Returns AccessToken from token.
|
@@ -191,16 +193,16 @@ module Rack
|
|
191
193
|
begin
|
192
194
|
access_token = AccessToken.from_token(token)
|
193
195
|
raise InvalidTokenError if access_token.nil? || access_token.revoked
|
194
|
-
raise ExpiredTokenError if access_token.expires_at && access_token.expires_at <= Time.now.
|
196
|
+
raise ExpiredTokenError if access_token.expires_at && access_token.expires_at <= Time.now.to_i
|
195
197
|
request.env["oauth.access_token"] = token
|
196
198
|
request.env["oauth.identity"] = access_token.identity
|
197
|
-
logger.info "Authorized #{access_token.identity}" if logger
|
199
|
+
logger.info "RO2S: Authorized #{access_token.identity}" if logger
|
198
200
|
rescue OAuthError=>error
|
199
201
|
# 5.2. The WWW-Authenticate Response Header Field
|
200
|
-
logger.info "HTTP authorization failed #{error.code}" if logger
|
202
|
+
logger.info "RO2S: HTTP authorization failed #{error.code}" if logger
|
201
203
|
return unauthorized(request, error)
|
202
204
|
rescue =>ex
|
203
|
-
logger.info "HTTP authorization failed #{ex.message}" if logger
|
205
|
+
logger.info "RO2S: HTTP authorization failed #{ex.message}" if logger
|
204
206
|
return unauthorized(request)
|
205
207
|
end
|
206
208
|
|
@@ -246,13 +248,13 @@ module Rack
|
|
246
248
|
if request.GET["authorization"]
|
247
249
|
auth_request = self.class.get_auth_request(request.GET["authorization"]) rescue nil
|
248
250
|
if !auth_request || auth_request.revoked
|
249
|
-
logger.error "Invalid authorization request #{auth_request}" if logger
|
251
|
+
logger.error "RO2S: Invalid authorization request #{auth_request}" if logger
|
250
252
|
return bad_request("Invalid authorization request")
|
251
253
|
end
|
252
254
|
response_type = auth_request.response_type # Needed for error handling
|
253
255
|
client = self.class.get_client(auth_request.client_id)
|
254
256
|
# Pass back to application, watch for 403 (deny!)
|
255
|
-
logger.info "
|
257
|
+
logger.info "RO2S: Client #{client.display_name} requested #{auth_request.response_type} with scope #{auth_request.scope.join(" ")}" if logger
|
256
258
|
request.env["oauth.authorization"] = auth_request.id.to_s
|
257
259
|
response = @app.call(request.env)
|
258
260
|
raise AccessDeniedError if response[0] == 403
|
@@ -264,7 +266,7 @@ module Rack
|
|
264
266
|
begin
|
265
267
|
redirect_uri = Utils.parse_redirect_uri(request.GET["redirect_uri"])
|
266
268
|
rescue InvalidRequestError=>error
|
267
|
-
logger.error "Authorization request with invalid redirect_uri: #{request.GET["redirect_uri"]} #{error.message}" if logger
|
269
|
+
logger.error "RO2S: Authorization request with invalid redirect_uri: #{request.GET["redirect_uri"]} #{error.message}" if logger
|
268
270
|
return bad_request(error.message)
|
269
271
|
end
|
270
272
|
|
@@ -284,7 +286,7 @@ module Rack
|
|
284
286
|
return [303, { "Location"=>uri.to_s }, ["You are being redirected"]]
|
285
287
|
end
|
286
288
|
rescue OAuthError=>error
|
287
|
-
logger.error "Authorization request error
|
289
|
+
logger.error "RO2S: Authorization request error #{error.code}: #{error.message}" if logger
|
288
290
|
params = { :error=>error.code, :error_description=>error.message, :state=>state }
|
289
291
|
if response_type == "token"
|
290
292
|
redirect_uri.fragment = Rack::Utils.build_query(params)
|
@@ -311,20 +313,20 @@ module Rack
|
|
311
313
|
# 3.1. Authorization Response
|
312
314
|
if auth_request.response_type == "code"
|
313
315
|
if auth_request.grant_code
|
314
|
-
logger.info "
|
316
|
+
logger.info "RO2S: Client #{auth_request.client_id} granted access code #{auth_request.grant_code}" if logger
|
315
317
|
params = { :code=>auth_request.grant_code, :scope=>auth_request.scope.join(" "), :state=>auth_request.state }
|
316
318
|
else
|
317
|
-
logger.info "
|
319
|
+
logger.info "RO2S: Client #{auth_request.client_id} denied authorization" if logger
|
318
320
|
params = { :error=>:access_denied, :state=>auth_request.state }
|
319
321
|
end
|
320
322
|
params = Rack::Utils.parse_query(redirect_uri.query).merge(params)
|
321
323
|
redirect_uri.query = Rack::Utils.build_query(params)
|
322
324
|
else # response type if token
|
323
325
|
if auth_request.access_token
|
324
|
-
logger.info "
|
326
|
+
logger.info "RO2S: Client #{auth_request.client_id} granted access token #{auth_request.access_token}" if logger
|
325
327
|
params = { :access_token=>auth_request.access_token, :scope=>auth_request.scope.join(" "), :state=>auth_request.state }
|
326
328
|
else
|
327
|
-
logger.info "
|
329
|
+
logger.info "RO2S: Client #{auth_request.client_id} denied authorization" if logger
|
328
330
|
params = { :error=>:access_denied, :state=>auth_request.state }
|
329
331
|
end
|
330
332
|
redirect_uri.fragment = Rack::Utils.build_query(params)
|
@@ -342,35 +344,36 @@ module Rack
|
|
342
344
|
when "authorization_code"
|
343
345
|
# 4.1.1. Authorization Code
|
344
346
|
grant = AccessGrant.from_code(request.POST["code"])
|
345
|
-
raise InvalidGrantError unless grant && client.id == grant.client_id
|
346
|
-
raise InvalidGrantError unless grant.redirect_uri.nil? || grant.redirect_uri == Utils.parse_redirect_uri(request.POST["redirect_uri"]).to_s
|
347
|
+
raise InvalidGrantError, "Wrong client" unless grant && client.id == grant.client_id
|
348
|
+
raise InvalidGrantError, "Wrong redirect URI" unless grant.redirect_uri.nil? || grant.redirect_uri == Utils.parse_redirect_uri(request.POST["redirect_uri"]).to_s
|
349
|
+
raise InvalidGrantError, "This access grant expired" if grant.expires_at && grant.expires_at <= Time.now.to_i
|
347
350
|
access_token = grant.authorize!
|
348
351
|
when "password"
|
349
352
|
raise UnsupportedGrantType unless options.authenticator
|
350
353
|
# 4.1.2. Resource Owner Password Credentials
|
351
354
|
username, password = request.POST.values_at("username", "password")
|
352
|
-
raise InvalidGrantError unless username && password
|
355
|
+
raise InvalidGrantError, "Missing username/password" unless username && password
|
353
356
|
requested_scope = Utils.normalize_scope(request.POST["scope"])
|
354
357
|
allowed_scope = client.scope
|
355
358
|
raise InvalidScopeError unless (requested_scope - allowed_scope).empty?
|
356
359
|
args = [username, password]
|
357
360
|
args << client.id << requested_scope unless options.authenticator.arity == 2
|
358
361
|
identity = options.authenticator.call(*args)
|
359
|
-
raise InvalidGrantError unless identity
|
362
|
+
raise InvalidGrantError, "Username/password do not match" unless identity
|
360
363
|
access_token = AccessToken.get_token_for(identity, client, requested_scope)
|
361
364
|
else
|
362
365
|
raise UnsupportedGrantType
|
363
366
|
end
|
364
|
-
logger.info "Access token #{access_token.token} granted to client #{client.display_name}, identity #{access_token.identity}" if logger
|
367
|
+
logger.info "RO2S: Access token #{access_token.token} granted to client #{client.display_name}, identity #{access_token.identity}" if logger
|
365
368
|
response = { :access_token=>access_token.token }
|
366
369
|
response[:scope] = access_token.scope.join(" ")
|
367
|
-
return [200, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, response.to_json]
|
370
|
+
return [200, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" }, [response.to_json]]
|
368
371
|
# 4.3. Error Response
|
369
372
|
rescue OAuthError=>error
|
370
|
-
logger.error "Access token request error
|
373
|
+
logger.error "RO2S: Access token request error #{error.code}: #{error.message}" if logger
|
371
374
|
return unauthorized(request, error) if InvalidClientError === error && request.basic?
|
372
375
|
return [400, { "Content-Type"=>"application/json", "Cache-Control"=>"no-store" },
|
373
|
-
{ :error=>error.code, :error_description=>error.message }.to_json]
|
376
|
+
[{ :error=>error.code, :error_description=>error.message }.to_json]]
|
374
377
|
end
|
375
378
|
end
|
376
379
|
|
@@ -176,6 +176,15 @@ class AccessGrantTest < Test::Unit::TestCase
|
|
176
176
|
should_respond_with_access_token
|
177
177
|
end
|
178
178
|
|
179
|
+
context "access grant expired" do
|
180
|
+
setup do
|
181
|
+
Timecop.travel 300 do
|
182
|
+
request_access_token
|
183
|
+
end
|
184
|
+
end
|
185
|
+
should_return_error :invalid_grant
|
186
|
+
end
|
187
|
+
|
179
188
|
|
180
189
|
# 4.1.2. Resource Owner Password Credentials
|
181
190
|
|
@@ -166,6 +166,50 @@ class ServerTest < Test::Unit::TestCase
|
|
166
166
|
end
|
167
167
|
end
|
168
168
|
|
169
|
+
context "no expiration" do
|
170
|
+
setup do
|
171
|
+
@code = Server.access_grant("Batman", client.id)
|
172
|
+
end
|
173
|
+
|
174
|
+
should "not expire in a minute" do
|
175
|
+
Timecop.travel 60 do
|
176
|
+
basic_authorize client.id, client.secret
|
177
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
178
|
+
assert_equal 200, last_response.status
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
should "expire after 5 minutes" do
|
183
|
+
Timecop.travel 300 do
|
184
|
+
basic_authorize client.id, client.secret
|
185
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
186
|
+
assert_equal 400, last_response.status
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
context "expiration set" do
|
192
|
+
setup do
|
193
|
+
@code = Server.access_grant("Batman", client.id, nil, 1800)
|
194
|
+
end
|
195
|
+
|
196
|
+
should "not expire prematurely" do
|
197
|
+
Timecop.travel 1750 do
|
198
|
+
basic_authorize client.id, client.secret
|
199
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
200
|
+
assert_equal 200, last_response.status
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
should "expire after specified seconds" do
|
205
|
+
Timecop.travel 1800 do
|
206
|
+
basic_authorize client.id, client.secret
|
207
|
+
post "/oauth/access_token", :scope=>"read", :grant_type=>"authorization_code", :code=>@code, :redirect_uri=>client.redirect_uri
|
208
|
+
assert_equal 400, last_response.status
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
169
213
|
end
|
170
214
|
|
171
215
|
|