obscured-doorman 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.codeclimate.yml +28 -0
- data/.github/dependabot.yml +11 -0
- data/.github/workflows/publish.yml +44 -0
- data/.gitignore +4 -0
- data/.rubocop.yml +14 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.simplecov +6 -0
- data/.travis.yml +17 -0
- data/CHANGELOG.md +31 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +144 -0
- data/README.md +115 -0
- data/lib/obscured-doorman.rb +69 -0
- data/lib/obscured-doorman/base.rb +203 -0
- data/lib/obscured-doorman/configuration.rb +123 -0
- data/lib/obscured-doorman/errors.rb +44 -0
- data/lib/obscured-doorman/helpers.rb +66 -0
- data/lib/obscured-doorman/loggable.rb +51 -0
- data/lib/obscured-doorman/mailer.rb +46 -0
- data/lib/obscured-doorman/messages.rb +30 -0
- data/lib/obscured-doorman/models/token.rb +57 -0
- data/lib/obscured-doorman/models/user.rb +160 -0
- data/lib/obscured-doorman/providers/base/configuration.rb +69 -0
- data/lib/obscured-doorman/providers/bitbucket.rb +79 -0
- data/lib/obscured-doorman/providers/bitbucket/access_token.rb +27 -0
- data/lib/obscured-doorman/providers/bitbucket/configuration.rb +38 -0
- data/lib/obscured-doorman/providers/bitbucket/messages.rb +13 -0
- data/lib/obscured-doorman/providers/bitbucket/strategy.rb +53 -0
- data/lib/obscured-doorman/providers/github.rb +78 -0
- data/lib/obscured-doorman/providers/github/access_token.rb +23 -0
- data/lib/obscured-doorman/providers/github/configuration.rb +38 -0
- data/lib/obscured-doorman/providers/github/messages.rb +13 -0
- data/lib/obscured-doorman/providers/github/strategy.rb +53 -0
- data/lib/obscured-doorman/strategies/forgot_password.rb +157 -0
- data/lib/obscured-doorman/strategies/password.rb +38 -0
- data/lib/obscured-doorman/strategies/remember_me.rb +54 -0
- data/lib/obscured-doorman/utilities/roles.rb +11 -0
- data/lib/obscured-doorman/utilities/types.rb +14 -0
- data/lib/obscured-doorman/version.rb +7 -0
- data/obscured-doorman.gemspec +42 -0
- data/spec/config/mongoid.yml +11 -0
- data/spec/doorman_spec.rb +203 -0
- data/spec/errors_spec.rb +11 -0
- data/spec/factories/token_factory.rb +8 -0
- data/spec/factories/user_factory.rb +12 -0
- data/spec/helpers/application_helper.rb +52 -0
- data/spec/helpers/request_helper.rb +53 -0
- data/spec/loggable_spec.rb +27 -0
- data/spec/mailer_spec.rb +26 -0
- data/spec/matchers/time.rb +7 -0
- data/spec/setup.rb +58 -0
- data/spec/token_spec.rb +62 -0
- data/spec/user_spec.rb +151 -0
- metadata +361 -0
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Obscured
|
4
|
+
module Doorman
|
5
|
+
# Contains logging behavior.
|
6
|
+
module Loggable
|
7
|
+
# Get the logger.
|
8
|
+
#
|
9
|
+
# @note Will try to grab Rails' logger first before creating a new logger
|
10
|
+
# with stdout.
|
11
|
+
#
|
12
|
+
# @example Get the logger.
|
13
|
+
# Loggable.logger
|
14
|
+
#
|
15
|
+
# @return [ Logger ] The logger.
|
16
|
+
def logger
|
17
|
+
return @logger if defined?(@logger)
|
18
|
+
|
19
|
+
@logger = default_logger
|
20
|
+
end
|
21
|
+
|
22
|
+
# Set the logger.
|
23
|
+
#
|
24
|
+
# @example Set the logger.
|
25
|
+
# Loggable.logger = Logger.new($stdout)
|
26
|
+
#
|
27
|
+
# @param [ Logger ] logger The logger to set.
|
28
|
+
#
|
29
|
+
# @return [ Logger ] The new logger.
|
30
|
+
def logger=(logger)
|
31
|
+
@logger = logger
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
# Gets the default Mongoid logger - stdout.
|
37
|
+
#
|
38
|
+
# @api private
|
39
|
+
#
|
40
|
+
# @example Get the default logger.
|
41
|
+
# Loggable.default_logger
|
42
|
+
#
|
43
|
+
# @return [ Logger ] The default logger.
|
44
|
+
def default_logger
|
45
|
+
logger = Logger.new($stdout)
|
46
|
+
logger.level = Doorman.configuration.log_level
|
47
|
+
logger
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Obscured
|
4
|
+
module Doorman
|
5
|
+
class Mailer
|
6
|
+
def initialize(opts = {})
|
7
|
+
@to = opts[:to]
|
8
|
+
@from = "doorman@#{Doorman.configuration.smtp_domain}"
|
9
|
+
@subject = opts[:subject]
|
10
|
+
|
11
|
+
@text = opts[:text]
|
12
|
+
@html = opts[:html]
|
13
|
+
end
|
14
|
+
|
15
|
+
def deliver!
|
16
|
+
Doorman.logger.debug "Sending mail to #{@to}, from: #{@from}, with subject: #{@subject} and text #{@text}"
|
17
|
+
mail = Mail.new(to: @to, from: @from, subject: @subject) do
|
18
|
+
delivery_method :smtp,
|
19
|
+
address: Doorman.configuration.smtp_server,
|
20
|
+
port: Doorman.configuration.smtp_port,
|
21
|
+
domain: Doorman.configuration.smtp_domain,
|
22
|
+
enable_starttls_auto: true,
|
23
|
+
authentication: :plain,
|
24
|
+
user_name: Doorman.configuration.smtp_username,
|
25
|
+
password: Doorman.configuration.smtp_password
|
26
|
+
end
|
27
|
+
|
28
|
+
unless @text.blank?
|
29
|
+
text_part = Mail::Part.new(body: @text)
|
30
|
+
mail.text_part = text_part
|
31
|
+
end
|
32
|
+
|
33
|
+
unless @html.blank?
|
34
|
+
html_part = Mail::Part.new(body: @html) do
|
35
|
+
content_type 'text/html; charset=utf-8'
|
36
|
+
end
|
37
|
+
mail.html_part = html_part
|
38
|
+
end
|
39
|
+
|
40
|
+
mail.deliver
|
41
|
+
rescue => e
|
42
|
+
Doorman.logger.error e
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Obscured
|
4
|
+
module Doorman
|
5
|
+
MESSAGES = {
|
6
|
+
auth_required: 'You must be logged in to view this page.',
|
7
|
+
signup_disabled: 'Registration is disabled, contact team member for creation of account!',
|
8
|
+
signup_success: 'You have signed up successfully. A confirmation email has been sent to you.',
|
9
|
+
confirm_no_user: 'Invalid confirmation URL. Please make sure you have the correct link from the email, and are not already confirmed.',
|
10
|
+
confirm_success: 'You have successfully confirmed your account. Please log in.',
|
11
|
+
# Auto login upon confirmation?
|
12
|
+
login_bad_credentials: 'Invalid Login and Password. Please try again.',
|
13
|
+
login_not_confirmed: 'You must confirm your account before you can log in. Please click the confirmation link sent to you.',
|
14
|
+
# Note: resend confirmation link?
|
15
|
+
logout_success: 'You have been logged out.',
|
16
|
+
forgot_no_user: 'There is no user with that Username or Email. Please try again.',
|
17
|
+
forgot_success: 'An email with instructions to reset your password has been sent to you.',
|
18
|
+
reset_no_user: 'Invalid reset URL. Please make sure you have the correct link from the email, and have already reset the password.',
|
19
|
+
reset_system_user: 'Your trying to reset the password of a system user, unfortunate for you, this action is not allowed',
|
20
|
+
reset_unmatched_passwords: 'Password and confirmation do not match. Please try again.',
|
21
|
+
reset_success: 'Your password has been reset.',
|
22
|
+
# Registration
|
23
|
+
register_account_exists: 'Account already registered.',
|
24
|
+
# Token
|
25
|
+
token_used: 'The token has already been used, request a new token and try again.',
|
26
|
+
token_expired: 'The token has expired, request a new token and try again.',
|
27
|
+
token_not_found: 'The token was not found, request a new token and try again.'
|
28
|
+
}.freeze
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Obscured
|
4
|
+
module Doorman
|
5
|
+
class Token
|
6
|
+
include Mongoid::Document
|
7
|
+
include Mongoid::Timestamps
|
8
|
+
|
9
|
+
store_in database: Doorman.configuration.db_name,
|
10
|
+
client: Doorman.configuration.db_client,
|
11
|
+
collection: 'tokens'
|
12
|
+
|
13
|
+
field :type, type: Symbol
|
14
|
+
field :token, type: String
|
15
|
+
field :expires_at, type: DateTime, default: -> { DateTime.now + 2.hours }
|
16
|
+
field :used_at, type: DateTime
|
17
|
+
field :user_id, type: BSON::ObjectId
|
18
|
+
|
19
|
+
belongs_to :user, autosave: true, class_name: 'Obscured::Doorman::User', inverse_of: 'tokens'
|
20
|
+
|
21
|
+
index({ expires_at: 1 }, background: true, expire_after_seconds: 172_800)
|
22
|
+
index({ used_at: 1 }, background: true, expire_after_seconds: 345_600)
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def make(opts)
|
26
|
+
raise Doorman::Error.new(:already_exists, what: 'Token does already exists!') if Token.where(user: opts[:user], type: opts[:type]).exists?
|
27
|
+
|
28
|
+
token = new
|
29
|
+
token.user = opts[:user]
|
30
|
+
token.type = opts[:type]
|
31
|
+
token.token = opts[:token]
|
32
|
+
token.expires_at = opts[:expires] if opts[:expires]
|
33
|
+
token
|
34
|
+
end
|
35
|
+
|
36
|
+
def make!(opts)
|
37
|
+
token = make(opts)
|
38
|
+
token.save
|
39
|
+
token
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def use!
|
44
|
+
self.used_at = DateTime.now
|
45
|
+
save
|
46
|
+
end
|
47
|
+
|
48
|
+
def usable?
|
49
|
+
used_at.nil? && expires_at > DateTime.now
|
50
|
+
end
|
51
|
+
|
52
|
+
def used?
|
53
|
+
!used_at.nil?
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'obscured-timeline'
|
4
|
+
|
5
|
+
module Obscured
|
6
|
+
module Doorman
|
7
|
+
class User
|
8
|
+
include Mongoid::Document
|
9
|
+
include Mongoid::Timestamps
|
10
|
+
include Mongoid::Timeline::Tracker
|
11
|
+
|
12
|
+
store_in database: Doorman.configuration.db_name,
|
13
|
+
client: Doorman.configuration.db_client,
|
14
|
+
collection: 'users'
|
15
|
+
|
16
|
+
field :username, type: String
|
17
|
+
field :password, type: String
|
18
|
+
field :salt, type: String
|
19
|
+
field :first_name, type: String
|
20
|
+
field :last_name, type: String
|
21
|
+
field :mobile, type: String
|
22
|
+
field :role, type: Symbol, default: Doorman::Roles::ADMIN
|
23
|
+
field :confirmed, type: Boolean, default: false
|
24
|
+
|
25
|
+
has_many :tokens, autosave: true, class_name: 'Obscured::Doorman::Token', foreign_key: 'user_id'
|
26
|
+
|
27
|
+
index({ username: 1 }, background: true)
|
28
|
+
|
29
|
+
after_initialize :set_salt
|
30
|
+
|
31
|
+
alias email username
|
32
|
+
|
33
|
+
attr_accessor :confirmed
|
34
|
+
|
35
|
+
class << self
|
36
|
+
def make(opts)
|
37
|
+
raise Doorman::Error.new(:already_exists, what: 'User does already exists!') if User.where(username: opts[:username]).exists?
|
38
|
+
|
39
|
+
user = new
|
40
|
+
user.username = opts[:username]
|
41
|
+
user.set_password(opts[:password])
|
42
|
+
user.first_name = opts[:first_name] unless opts[:first_name].nil?
|
43
|
+
user.last_name = opts[:last_name] unless opts[:last_name].nil?
|
44
|
+
user.mobile = opts[:mobile] unless opts[:mobile].nil?
|
45
|
+
user.role = opts[:role] unless opts[:role].nil?
|
46
|
+
user.confirmed = opts[:confirmed] unless opts[:confirmed].nil?
|
47
|
+
user.add_event(type: :account, message: 'Account created', producer: opts[:producer].nil? ? user.username : opts[:producer])
|
48
|
+
user
|
49
|
+
end
|
50
|
+
|
51
|
+
def make!(opts)
|
52
|
+
user = make(opts)
|
53
|
+
user.save
|
54
|
+
user
|
55
|
+
end
|
56
|
+
|
57
|
+
def authenticate(username, password)
|
58
|
+
user = where(username: username).first
|
59
|
+
return user if user&.authenticated?(password)
|
60
|
+
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
|
64
|
+
def registered?(username)
|
65
|
+
where(username: username).exists?
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def name
|
70
|
+
"#{first_name} #{last_name}"
|
71
|
+
end
|
72
|
+
|
73
|
+
def name=(arguments)
|
74
|
+
self.first_name = arguments[:first_name]
|
75
|
+
self.last_name = arguments[:last_name]
|
76
|
+
end
|
77
|
+
|
78
|
+
def set_password(password)
|
79
|
+
self.password = BCrypt::Password.create(password)
|
80
|
+
end
|
81
|
+
|
82
|
+
def authenticated?(password)
|
83
|
+
(BCrypt::Password.new(self.password) == password)
|
84
|
+
end
|
85
|
+
alias password? authenticated?
|
86
|
+
|
87
|
+
def remember_me!
|
88
|
+
add_event(type: :remember, message: 'Account set to be remembered upon login', producer: username)
|
89
|
+
token = tokens.build(
|
90
|
+
type: :remember,
|
91
|
+
token: SecureRandom.uuid,
|
92
|
+
expires_at: (DateTime.now + Doorman.configuration.remember_for.days)
|
93
|
+
)
|
94
|
+
save
|
95
|
+
token
|
96
|
+
end
|
97
|
+
|
98
|
+
def forget_me!
|
99
|
+
add_event(type: :remember, message: 'Account set not to be remembered upon login', producer: username)
|
100
|
+
tokens.where(type: :remember).destroy
|
101
|
+
end
|
102
|
+
|
103
|
+
def confirm
|
104
|
+
add_event(type: :confirm, message: 'Confirmation token created', producer: username)
|
105
|
+
tokens.where(type: :confirm).destroy
|
106
|
+
token = tokens.build(
|
107
|
+
type: :confirm,
|
108
|
+
token: SecureRandom.uuid,
|
109
|
+
expires_at: (DateTime.now + 14.days)
|
110
|
+
)
|
111
|
+
save
|
112
|
+
token
|
113
|
+
end
|
114
|
+
|
115
|
+
def confirm!
|
116
|
+
add_event(type: :confirmation, message: 'Account was successfully confirmed', producer: username)
|
117
|
+
self.confirmed = true
|
118
|
+
tokens.where(type: :confirm).destroy
|
119
|
+
save
|
120
|
+
end
|
121
|
+
|
122
|
+
def forgot_password!
|
123
|
+
add_event(type: :password, message: 'Reset password procedure has been started', producer: username)
|
124
|
+
tokens.where(type: :password).destroy
|
125
|
+
token = tokens.build(
|
126
|
+
user: self,
|
127
|
+
type: :password,
|
128
|
+
token: SecureRandom.uuid,
|
129
|
+
expires_at: (DateTime.now + 2.hours)
|
130
|
+
)
|
131
|
+
save
|
132
|
+
token
|
133
|
+
end
|
134
|
+
|
135
|
+
def remembered_password!
|
136
|
+
add_event(type: :password, message: 'Reset password procedure has been cancelled since successful login was achieved', producer: username)
|
137
|
+
tokens.where(type: :password).destroy
|
138
|
+
end
|
139
|
+
|
140
|
+
def reset_password!(password, token)
|
141
|
+
token = tokens.find_by(token: token)
|
142
|
+
if token && token.type.eql?(:password)
|
143
|
+
set_password(password)
|
144
|
+
add_event(type: :password, message: 'Password was successfully reset', producer: username)
|
145
|
+
return save
|
146
|
+
end
|
147
|
+
false
|
148
|
+
end
|
149
|
+
|
150
|
+
protected
|
151
|
+
|
152
|
+
def set_salt
|
153
|
+
return unless salt.nil? || salt.empty?
|
154
|
+
|
155
|
+
secret = Digest::SHA1.hexdigest("--#{username}--")
|
156
|
+
self.salt = Digest::SHA1.hexdigest("--#{Time.now.utc}--#{secret}--")
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Obscured
|
4
|
+
module Doorman
|
5
|
+
module Providers
|
6
|
+
class BaseConfiguration
|
7
|
+
def self.config_option(name)
|
8
|
+
define_method(name) do
|
9
|
+
read_value(name)
|
10
|
+
end
|
11
|
+
|
12
|
+
define_method("#{name}=") do |value|
|
13
|
+
set_value(name, value)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# Name of the authentication provider
|
18
|
+
config_option :provider
|
19
|
+
# Enables/disables the provider
|
20
|
+
config_option :enabled
|
21
|
+
|
22
|
+
# Provider client id
|
23
|
+
config_option :client_id
|
24
|
+
# Provider client secret
|
25
|
+
config_option :client_secret
|
26
|
+
# Provider scopes
|
27
|
+
config_option :scopes
|
28
|
+
|
29
|
+
# Provider authentication endpoint
|
30
|
+
config_option :authorize_url
|
31
|
+
# Provider token endpoint
|
32
|
+
config_option :token_url
|
33
|
+
# Provider login endpoint
|
34
|
+
config_option :login_url
|
35
|
+
# Provider redirect endpoint
|
36
|
+
config_option :redirect_url
|
37
|
+
|
38
|
+
# Authentication domains to login
|
39
|
+
config_option :domains
|
40
|
+
# Authentication token
|
41
|
+
config_option :token
|
42
|
+
|
43
|
+
attr_reader :defaults
|
44
|
+
|
45
|
+
def [](key)
|
46
|
+
read_value(key)
|
47
|
+
end
|
48
|
+
|
49
|
+
def []=(key, value)
|
50
|
+
set_value(key, value)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def read_value(name)
|
56
|
+
if @config_values.key?(name)
|
57
|
+
@config_values[name]
|
58
|
+
else
|
59
|
+
@defaults.send(name)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def set_value(name, value)
|
64
|
+
@config_values[name] = value
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require File.expand_path('bitbucket/configuration', __dir__)
|
4
|
+
require File.expand_path('bitbucket/messages', __dir__)
|
5
|
+
require File.expand_path('bitbucket/access_token', __dir__)
|
6
|
+
require File.expand_path('bitbucket/strategy', __dir__)
|
7
|
+
|
8
|
+
module Obscured
|
9
|
+
module Doorman
|
10
|
+
module Providers
|
11
|
+
module Bitbucket
|
12
|
+
class << self
|
13
|
+
# Configuration Object (instance of Obscured::Doorman::Providers::Bitbucket::Configuration)
|
14
|
+
attr_writer :configuration
|
15
|
+
|
16
|
+
def setup
|
17
|
+
yield(configuration)
|
18
|
+
end
|
19
|
+
|
20
|
+
def configuration
|
21
|
+
@configuration ||= Bitbucket::Configuration.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def default_configuration
|
25
|
+
configuration.defaults
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.registered(app)
|
30
|
+
app.helpers Doorman::Base::Helpers
|
31
|
+
app.helpers Doorman::Helpers
|
32
|
+
|
33
|
+
Warden::Strategies.add(:bitbucket, Bitbucket::Strategy)
|
34
|
+
|
35
|
+
app.get '/doorman/oauth2/bitbucket' do
|
36
|
+
redirect("#{Bitbucket.configuration[:authorize_url]}?client_id=#{Bitbucket.configuration[:client_id]}&response_type=code&scopes=#{Bitbucket.configuration[:scopes]}")
|
37
|
+
end
|
38
|
+
|
39
|
+
app.get '/doorman/oauth2/bitbucket/callback/?' do
|
40
|
+
response = RestClient::Request.new(
|
41
|
+
method: :post,
|
42
|
+
url: Bitbucket.configuration[:token_url],
|
43
|
+
user: Bitbucket.configuration[:client_id],
|
44
|
+
password: Bitbucket.configuration[:client_secret],
|
45
|
+
payload: "code=#{params[:code]}&grant_type=authorization_code&scope=#{Bitbucket.configuration[:scopes]}",
|
46
|
+
headers: { Accept: 'application/json' }
|
47
|
+
).execute
|
48
|
+
|
49
|
+
json = JSON.parse(response.body)
|
50
|
+
token = Bitbucket::AccessToken.new(
|
51
|
+
access_token: json['access_token'],
|
52
|
+
refresh_token: json['refresh_token'],
|
53
|
+
scopes: json['scopes'],
|
54
|
+
expires_in: json['expires_in']
|
55
|
+
)
|
56
|
+
|
57
|
+
emails = RestClient.get 'https://api.bitbucket.org/2.0/user/emails', Authorization: "Bearer #{token.access_token}"
|
58
|
+
emails = JSON.parse(emails.body)
|
59
|
+
token.emails = emails.values[1].map { |e| e['email'] }
|
60
|
+
Bitbucket.configuration[:token] = token
|
61
|
+
|
62
|
+
# Authenticate with :bitbucket strategy
|
63
|
+
warden.authenticate!(:bitbucket)
|
64
|
+
rescue RestClient::ExceptionWithResponse => e
|
65
|
+
message = JSON.parse(e.response)
|
66
|
+
Doorman.logger.error e
|
67
|
+
notify :error, "#{message['error_description']} (#{message['error']})"
|
68
|
+
redirect(Doorman.configuration.paths[:login])
|
69
|
+
ensure
|
70
|
+
# Notify if there are any messages from Warden.
|
71
|
+
notify :error, warden.message unless warden.message.blank?
|
72
|
+
|
73
|
+
redirect(Doorman.configuration.use_referrer && session[:return_to] ? session.delete(:return_to) : Doorman.configuration.paths[:success])
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|