Initial commit
This commit is contained in:
81
lib/barong/activity_logger.rb
Normal file
81
lib/barong/activity_logger.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
# admin activities log writer class
|
||||
class ActivityLogger
|
||||
ACTION = { post: 'create', put: 'update', get: 'read', delete: 'delete', patch: 'update' }.freeze
|
||||
|
||||
def self.async_write(options = {})
|
||||
@activities ||= Queue.new
|
||||
@activities.push(options)
|
||||
|
||||
@thread ||= Thread.new do
|
||||
begin
|
||||
loop do
|
||||
msg = @activities.pop
|
||||
params = format_params(msg)
|
||||
Rails.logger.info("Recording activity for user id: #{params[:user_id]}, topic: #{params[:topic]}," \
|
||||
" action: #{params[:action]}, result: #{params[:result]}, data: #{params[:data]}")
|
||||
Activity.create(params)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error { "Failed to create activity with params: #{params}\n" \
|
||||
"Inspect error: #{e.inspect}\n#{e.backtrace.join("\n")}" }
|
||||
|
||||
# If system catch Mysql2::Error::ConnectionError
|
||||
# System will reconnect to DB and push message again to the activities queue
|
||||
if e.is_a? (ActiveRecord::StatementInvalid)
|
||||
ActiveRecord::Base.connection.reconnect!
|
||||
sleep(0.1)
|
||||
@activities.push(options)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.sync_write(options = {})
|
||||
Activity.create(format_params(options))
|
||||
end
|
||||
|
||||
def self.format_params(params)
|
||||
topic = params[:topic].nil? && params[:path].split('admin/')[1].nil? ? 'general' : params[:topic] || params[:path].split('admin/')[1].split('/')[0]
|
||||
{
|
||||
user_id: params[:user_id],
|
||||
target_uid: target_user(params[:payload]) || '',
|
||||
user_ip: params[:user_ip],
|
||||
user_agent: params[:user_agent],
|
||||
topic: topic,
|
||||
action: ACTION[params[:verb].downcase.to_sym] || 'system',
|
||||
result: params[:result],
|
||||
category: 'admin',
|
||||
data: format_payload(params[:payload])
|
||||
}
|
||||
end
|
||||
|
||||
def self.format_payload(payload)
|
||||
return unless payload
|
||||
|
||||
return payload.to_json unless valid_json?(payload.keys.first)
|
||||
|
||||
payload.keys.first
|
||||
end
|
||||
|
||||
def self.target_user(payload)
|
||||
# in case payload is missing || empty POST body: payload => {"null" => nil }
|
||||
return if payload.nil? || payload.keys.first == "null"
|
||||
|
||||
if valid_json?(payload.keys.first)
|
||||
payload = JSON.parse(payload.keys.first)
|
||||
end
|
||||
|
||||
payload[:uid] || payload[:user_uid] || payload['uid'] || payload['user_uid']
|
||||
end
|
||||
|
||||
def self.valid_json?(json)
|
||||
JSON.parse(json)
|
||||
true
|
||||
rescue JSON::ParserError => e
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
61
lib/barong/amqp/config.rb
Normal file
61
lib/barong/amqp/config.rb
Normal file
@@ -0,0 +1,61 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AMQP
|
||||
class Config
|
||||
class <<self
|
||||
def data
|
||||
@data ||= Hashie::Mash.new(
|
||||
YAML.safe_load(
|
||||
ERB.new(File.read(Rails.root.join('config', 'amqp.yml'))).result
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def connect
|
||||
data[:connect]
|
||||
end
|
||||
|
||||
def binding_exchange_id(id)
|
||||
data[:binding][id][:exchange]
|
||||
end
|
||||
|
||||
def binding_exchange(id)
|
||||
eid = binding_exchange_id(id)
|
||||
eid && exchange(eid)
|
||||
end
|
||||
|
||||
def binding_queue(id)
|
||||
queue data[:binding][id][:queue]
|
||||
end
|
||||
|
||||
def binding_worker(id)
|
||||
::Workers::AMQP.const_get(id.to_s.camelize).new
|
||||
end
|
||||
|
||||
def routing_key(id)
|
||||
binding_queue(id).first
|
||||
end
|
||||
|
||||
def topics(id)
|
||||
data[:binding][id][:topics].split(',')
|
||||
end
|
||||
|
||||
def channel(id)
|
||||
(data[:channel] && data[:channel][id]) || {}
|
||||
end
|
||||
|
||||
def queue(id)
|
||||
name = data[:queue][id][:name]
|
||||
settings = { durable: data[:queue][id][:durable] }
|
||||
[name, settings]
|
||||
end
|
||||
|
||||
def exchange(id)
|
||||
type = data[:exchange][id][:type]
|
||||
name = data[:exchange][id][:name]
|
||||
[type, name]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
45
lib/barong/amqp/queue.rb
Normal file
45
lib/barong/amqp/queue.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# encoding: UTF-8
|
||||
# frozen_string_literal: true
|
||||
|
||||
module AMQP
|
||||
class Queue
|
||||
|
||||
class <<self
|
||||
def connection
|
||||
@connection ||= ::Bunny.new(AMQP::Config.connect).tap do |conn|
|
||||
conn.start
|
||||
end
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= connection.create_channel
|
||||
end
|
||||
|
||||
def exchanges
|
||||
@exchanges ||= { default: channel.default_exchange }
|
||||
end
|
||||
|
||||
def exchange(id)
|
||||
exchanges[id] ||= channel.send *AMQP::Config.exchange(id)
|
||||
end
|
||||
|
||||
def publish(eid, payload, attrs={})
|
||||
payload = JSON.dump payload
|
||||
exchange(eid).publish(payload, attrs)
|
||||
end
|
||||
|
||||
# enqueue = publish to direct exchange
|
||||
def enqueue(id, payload, attrs={})
|
||||
eid = ::AMQP::Config.binding_exchange_id(id) || :default
|
||||
attrs.merge!({routing_key: AMQP::Config.routing_key(id)})
|
||||
publish(eid, payload, attrs)
|
||||
end
|
||||
|
||||
def enqueue_event(type, id, event, payload, opts={})
|
||||
routing_key = [type, id, event].join('.')
|
||||
serialized_data = JSON.dump(payload)
|
||||
channel.exchange('peatio.events.ranger', type: 'topic').publish(serialized_data, routing_key: routing_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
91
lib/barong/app.rb
Normal file
91
lib/barong/app.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
class App
|
||||
include ActiveSupport::Configurable
|
||||
|
||||
class Error < ::StandardError; end
|
||||
|
||||
class << self
|
||||
def define
|
||||
yield self
|
||||
end
|
||||
|
||||
def set(key, default = nil, options = {})
|
||||
value = fetch!(key, default)
|
||||
|
||||
validate!(key, value, options)
|
||||
value = type!(key, value, options)
|
||||
|
||||
config[key] = value
|
||||
end
|
||||
|
||||
def write(key, value)
|
||||
config[key] = value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch!(key, default)
|
||||
if env(key)
|
||||
return env(key)
|
||||
|
||||
elsif Rails.application.credentials[key]
|
||||
return Rails.application.credentials[key]
|
||||
|
||||
elsif !default.nil?
|
||||
return default
|
||||
|
||||
else
|
||||
raise Error, "Config #{key} missing" if default.nil?
|
||||
end
|
||||
end
|
||||
|
||||
def env(key)
|
||||
ENV['BARONG_' + key.to_s.upcase]
|
||||
end
|
||||
|
||||
def validate!(key, value, options)
|
||||
regex!(key, value, options[:regex]) if options[:regex]
|
||||
values!(key, value, options[:values]) if options[:values]
|
||||
end
|
||||
|
||||
def type!(key, value, options)
|
||||
return value unless options[:type]
|
||||
|
||||
case options[:type]
|
||||
when :array
|
||||
return value.split(',').map { |v| v.squish }
|
||||
when :bool
|
||||
values!(key, value, %w(true false))
|
||||
return value == 'true'
|
||||
when :integer
|
||||
regex!(key, value, /^\d+$/)
|
||||
return value.to_i
|
||||
when :path
|
||||
return Rails.root.join(value).tap { |p| path!(key, p) }
|
||||
when :regexp
|
||||
return Regexp.new value
|
||||
end
|
||||
end
|
||||
|
||||
def path!(key, path)
|
||||
unless File.exists?(path)
|
||||
raise Error.new("#{key.to_s.upcase} path is invalid #{path.to_s}")
|
||||
end
|
||||
end
|
||||
|
||||
def regex!(key, value, regex)
|
||||
unless regex =~ value
|
||||
raise Error.new("#{key.to_s.upcase} does not match regex #{regex.inspect}")
|
||||
end
|
||||
end
|
||||
|
||||
def values!(key, value, values)
|
||||
unless values.include?(value)
|
||||
raise Error.new("#{key.to_s.upcase} invalid, enabled values: #{values.to_s}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
34
lib/barong/auth0/jwt.rb
Normal file
34
lib/barong/auth0/jwt.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
module Auth0
|
||||
class JWT
|
||||
def self.verify(token)
|
||||
::JWT.decode(token,
|
||||
nil,
|
||||
true, # Verify the signature of this token
|
||||
algorithms: 'RS256',
|
||||
iss: "https://#{Barong::App.config.auth0_domain}/",
|
||||
verify_iss: true,
|
||||
aud: Barong::App.config.auth0_client_id,
|
||||
verify_aud: true
|
||||
) do |header|
|
||||
jwks_hash[header['kid']]
|
||||
end
|
||||
end
|
||||
|
||||
def self.jwks_hash
|
||||
uri = "https://#{Barong::App.config.auth0_domain}/.well-known/jwks.json"
|
||||
jwks_raw = Net::HTTP.get URI(uri)
|
||||
jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
|
||||
Hash[jwks_keys.map do |k|
|
||||
[
|
||||
k['kid'],
|
||||
OpenSSL::X509::Certificate.new(Base64.decode64(k['x5c'].first)).public_key
|
||||
]
|
||||
end
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
259
lib/barong/authorize.rb
Normal file
259
lib/barong/authorize.rb
Normal file
@@ -0,0 +1,259 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'barong/activity_logger'
|
||||
|
||||
module Barong
|
||||
# AuthZ functionality
|
||||
class Authorize
|
||||
STATE_CHANGING_VERBS = %w[POST PUT PATCH DELETE TRACE].freeze
|
||||
# Custom Error class to support error status and message
|
||||
class AuthError < StandardError
|
||||
attr_reader :code
|
||||
|
||||
# init an error with status and text to return in api response
|
||||
def initialize(code)
|
||||
super
|
||||
@code = code
|
||||
end
|
||||
end
|
||||
|
||||
# init base request info, fetch black and white lists
|
||||
def initialize(request, path)
|
||||
@request = request
|
||||
@path = path
|
||||
@rules = lists['rules']
|
||||
end
|
||||
|
||||
# main: switch between cookie and api key logic, return bearer token
|
||||
def auth
|
||||
auth_type = 'cookie'
|
||||
auth_type = 'api_key' if api_key_headers?
|
||||
auth_owner = method("#{auth_type}_owner").call
|
||||
'Bearer ' + codec.encode(auth_owner.as_payload) # encoded user info
|
||||
end
|
||||
|
||||
# cookies validations
|
||||
def cookie_owner
|
||||
validate_csrf!
|
||||
|
||||
error!({ errors: ['authz.invalid_session'] }, 401) unless session[:uid]
|
||||
|
||||
user = User.find_by!(uid: session[:uid])
|
||||
Rails.logger.debug "User #{user} authorization via cookies"
|
||||
|
||||
validate_session!
|
||||
|
||||
unless user.state.in?(%w[active pending])
|
||||
error!({ errors: ['authz.user_not_active'] }, 401)
|
||||
end
|
||||
|
||||
validate_permissions!(user)
|
||||
|
||||
user # returns user(whose session is inside cookie)
|
||||
end
|
||||
|
||||
def validate_session!
|
||||
unless @request.env['HTTP_USER_AGENT'] == session[:user_agent] &&
|
||||
Time.now.to_i < session[:expire_time] &&
|
||||
find_ip.include?(remote_ip)
|
||||
session.destroy
|
||||
Rails.logger.debug("Session mismatch! Valid session is: { agent: #{session[:user_agent]}," \
|
||||
" expire_time: #{session[:expire_time]}, ip: #{session[:user_ip]} }," \
|
||||
" but request contains: { agent: #{@request.env['HTTP_USER_AGENT']}, ip: #{remote_ip} }")
|
||||
|
||||
error!({ errors: ['authz.client_session_mismatch'] }, 401)
|
||||
end
|
||||
|
||||
session[:expire_time] = Time.now.to_i + Barong::App.config.session_expire_time
|
||||
end
|
||||
|
||||
def find_ip
|
||||
ip_addr = IPAddr.new(session[:user_ip])
|
||||
if ip_addr.ipv4?
|
||||
ip_addr.mask(16)
|
||||
else
|
||||
ip_addr.mask(96)
|
||||
end
|
||||
end
|
||||
|
||||
# api key validations
|
||||
def api_key_owner
|
||||
api_key = APIKeysVerifier.new(api_key_params)
|
||||
|
||||
# validate that nonce is a positive integer
|
||||
error!({ errors: ['authz.nonce_not_valid_timestamp'] }, 401) if api_key_params[:nonce].to_i <= 0
|
||||
# timestamp_window is a difference between server_time and nonce creation time
|
||||
nonce_timestamp_window = ((Time.now.to_f * 1000).to_i - api_key_params[:nonce].to_i).abs
|
||||
Rails.logger.debug("Api key authorization via key: #{api_key_params[:kid]} to path #{@path} \
|
||||
with nonce: #{api_key_params[:nonce]} in a window of #{nonce_timestamp_window}")
|
||||
# (server_time - nonce) should not be more than nonce lifetime
|
||||
error!({ errors: ['authz.nonce_expired'] }, 401) if nonce_timestamp_window >= Barong::App.config.apikey_nonce_lifetime
|
||||
# signature should be valid
|
||||
error!({ errors: ['authz.invalid_signature'] }, 401) unless api_key.verify_hmac_payload?
|
||||
|
||||
current_api_key = APIKey.find_by_kid(api_key_params[:kid])
|
||||
# corresponding Api Key should be active
|
||||
error!({ errors: ['authz.apikey_not_active'] }, 401) unless current_api_key.active?
|
||||
|
||||
# here User is either User object or ServiceAccount object
|
||||
user = current_api_key.key_holder_account
|
||||
validate_user!(user)
|
||||
|
||||
validate_permissions!(user)
|
||||
|
||||
user # returns user(api key creator)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
error!({ errors: ['authz.unexistent_apikey'] }, 401)
|
||||
end
|
||||
|
||||
def validate_csrf!
|
||||
return unless Barong::App.config.csrf_protection && @request.env['REQUEST_METHOD'].in?(STATE_CHANGING_VERBS)
|
||||
|
||||
unless headers['X-CSRF-Token']
|
||||
Rails.logger.info("CSRF attack warning! Missing token for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}")
|
||||
error!({ errors: ['authz.missing_csrf_token'] }, 401)
|
||||
end
|
||||
|
||||
unless headers['X-CSRF-Token'] == session[:csrf_token]
|
||||
Rails.logger.info("CSRF attack warning! Token is not valid for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}")
|
||||
error!({ errors: ['authz.csrf_token_mismatch'] }, 401)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_permissions!(user)
|
||||
# Caches Permission.all result to optimize
|
||||
permissions = Rails.cache.fetch('permissions', expires_in: 5.minutes) { Permission.all.to_ary }
|
||||
|
||||
permissions.select! { |a| a.role == user.role && ( a.verb == @request.env['REQUEST_METHOD'] || a.verb == 'ALL' ) && @path.starts_with?(a.path) }
|
||||
actions = permissions.blank? ? [] : permissions.pluck(:action).uniq
|
||||
|
||||
if permissions.blank? || actions.include?('DROP') || !actions.include?('ACCEPT')
|
||||
log_activity(user.id, 'denied') if user.is_a?(User)
|
||||
error!({ errors: ['authz.invalid_permission'] }, 401)
|
||||
end
|
||||
|
||||
if actions.include?('AUDIT')
|
||||
topic = permissions.select { |a| a.action == 'AUDIT' }[0].topic
|
||||
log_activity(user.id, 'succeed', topic) if user.is_a?(User)
|
||||
end
|
||||
end
|
||||
|
||||
def log_activity(user_id, result, topic = nil)
|
||||
if Rails.env.test?
|
||||
ActivityLogger.sync_write(activity_params(user_id, result, topic))
|
||||
else
|
||||
ActivityLogger.async_write(activity_params(user_id, result, topic))
|
||||
end
|
||||
end
|
||||
|
||||
def activity_params(user_id, result, topic)
|
||||
{
|
||||
user_id: user_id,
|
||||
result: result,
|
||||
user_agent: @request.env['HTTP_USER_AGENT'],
|
||||
user_ip: remote_ip,
|
||||
path: @path,
|
||||
topic: topic,
|
||||
verb: @request.env['REQUEST_METHOD'],
|
||||
payload: @request.params
|
||||
}
|
||||
end
|
||||
|
||||
# black/white list validation. takes ['block', 'pass'] as a parameter
|
||||
def under_path_rules?(type)
|
||||
return false if @rules[type].nil? # if no authz rules provided
|
||||
|
||||
@rules[type].each do |t|
|
||||
return true if @path.starts_with?(t) # if request path is inside the rules list
|
||||
end
|
||||
false # default
|
||||
end
|
||||
|
||||
def remote_ip
|
||||
# default behaviour, IP from HTTP_X_FORWARDED_FOR
|
||||
ip = @request.remote_ip
|
||||
|
||||
if Barong::App.config.gateway == 'akamai'
|
||||
# custom header that contains only client IP
|
||||
true_client_ip = @request.env['HTTP_TRUE_CLIENT_IP']
|
||||
# take IP from TRUE_CLIENT_IP only if its not nil or empty
|
||||
ip = true_client_ip unless true_client_ip.nil? || true_client_ip.empty?
|
||||
end
|
||||
|
||||
return ip
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# encode helper method
|
||||
def codec
|
||||
@_codec ||= Barong::JWT.new(key: Barong::App.config.keystore.private_key)
|
||||
end
|
||||
|
||||
# fetch authz rules from yml
|
||||
def lists
|
||||
YAML.safe_load(
|
||||
ERB.new(
|
||||
File.read(
|
||||
Barong::App.config.authz_rules_file
|
||||
)
|
||||
).result
|
||||
)
|
||||
end
|
||||
|
||||
# checks if api key headers are present in request
|
||||
def api_key_headers?
|
||||
return false if headers['X-Auth-Apikey'].nil? &&
|
||||
headers['X-Auth-Nonce'].nil? &&
|
||||
headers['X-Auth-Signature'].nil?
|
||||
@api_key_headers = [headers['X-Auth-Apikey'], headers['X-Auth-Nonce'], headers['X-Auth-Signature']]
|
||||
validate_headers?
|
||||
end
|
||||
|
||||
def validate_user!(user)
|
||||
unless user.state.in?(%w[active pending])
|
||||
error!({ errors: ['authz.invalid_session'] }, 401)
|
||||
end
|
||||
|
||||
return if skip_api_key_2fa?
|
||||
|
||||
if user.is_a?(User) && !user.otp
|
||||
error!({ errors: ['authz.disabled_2fa'] }, 401)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_api_key_2fa?
|
||||
ENV['BARONG_SKIP_API_KEY_2FA'] == 'true'
|
||||
end
|
||||
|
||||
# api key headers nil, blank validation
|
||||
def validate_headers?
|
||||
@api_key_headers.each do |k|
|
||||
error!({ errors: ['authz.invalid_api_key_headers'] }, 422) if k.blank?
|
||||
end
|
||||
end
|
||||
|
||||
# converts header into hash of parameters
|
||||
def api_key_params
|
||||
{
|
||||
'kid': headers['X-Auth-Apikey'],
|
||||
'nonce': headers['X-Auth-Nonce'],
|
||||
'signature': headers['X-Auth-Signature']
|
||||
}
|
||||
end
|
||||
|
||||
# custom error, calls AuthError class
|
||||
def error!(text, code)
|
||||
Rails.logger.debug "Error raised with code #{code} and error message #{text.to_json}"
|
||||
raise AuthError.new(code), text.to_json
|
||||
end
|
||||
|
||||
def headers
|
||||
@request.headers
|
||||
end
|
||||
|
||||
def session
|
||||
@request.session
|
||||
end
|
||||
end
|
||||
end
|
||||
35
lib/barong/cors/validations.rb
Normal file
35
lib/barong/cors/validations.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides CORS variables validation.
|
||||
module Barong
|
||||
module CORS
|
||||
# Main CORS policy logic
|
||||
module Validations
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
class << self
|
||||
def validate_origins(origins)
|
||||
origins.split(',').each_with_object([]) do |origin, domains|
|
||||
if origin == '*'
|
||||
Rails.logger.info { "WARNING: API_CORS_ORIGIN is set to '*'" }
|
||||
return '*'
|
||||
elsif origin.match? %r{https?:\/\/([a-zA-Z0-9]+)(\.[a-zA-Z0-9]+)*(:^[0-9]*$+)?}
|
||||
domains << origin
|
||||
else
|
||||
raise CORS::Validations::Error, "Set right origin domain name instead of #{origin}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_max_age(max_age)
|
||||
if max_age.present? && max_age.match?(/^[0-9]*$/)
|
||||
max_age
|
||||
else
|
||||
Rails.logger.info { 'WARNING: Incorect or missing API_CORS_MAX_AGE value. Using default value: 3600' }
|
||||
'3600'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
235
lib/barong/event_api.rb
Normal file
235
lib/barong/event_api.rb
Normal file
@@ -0,0 +1,235 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'active_support/concern'
|
||||
require 'active_support/lazy_load_hooks'
|
||||
|
||||
# EventAPI provides interface to platform-wide notifications in RabbitMQ.
|
||||
#
|
||||
# Check docs/specs/event_api.md for more details.
|
||||
module EventAPI
|
||||
|
||||
MAPPING = {
|
||||
'sign-up' => 'system.user.email.confirmation.code',
|
||||
'reset-password' => 'system.user.password.reset.token',
|
||||
'enable-otp' => 'system.user.email.otp.enable',
|
||||
'disable-otp' => 'system.user.email.otp.disable',
|
||||
'change-password' => 'system.user.password.confirmation.code',
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def notify(event_name, event_payload)
|
||||
event_name = MAPPING.dig(event_name).present? ? MAPPING.dig(event_name) : event_name
|
||||
raise('Dalan mailer event name is unknown') unless event_name.present?
|
||||
|
||||
arguments = [event_name, event_payload]
|
||||
middlewares.each do |middleware|
|
||||
returned_value = middleware.call(*arguments)
|
||||
case returned_value
|
||||
when Array then arguments = returned_value
|
||||
else return returned_value
|
||||
end
|
||||
rescue StandardError => e
|
||||
report_exception(e)
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
def middlewares=(list)
|
||||
@middlewares = list
|
||||
end
|
||||
|
||||
def middlewares
|
||||
@middlewares ||= []
|
||||
end
|
||||
end
|
||||
|
||||
module ActiveRecord
|
||||
class Mediator
|
||||
attr_reader :record
|
||||
|
||||
def initialize(record)
|
||||
@record = record
|
||||
end
|
||||
|
||||
def notify(partial_event_name, event_payload)
|
||||
tokens = ['model']
|
||||
tokens << record.class.event_api_settings.fetch(:prefix) { record.class.name.underscore.gsub(/\//, '_') }
|
||||
tokens << partial_event_name.to_s
|
||||
full_event_name = tokens.join('.')
|
||||
|
||||
::EventAPI.notify(full_event_name, event_payload)
|
||||
end
|
||||
|
||||
def notify_record_created
|
||||
notify(:created, record: record.as_json_for_event_api.compact)
|
||||
end
|
||||
|
||||
def notify_record_updated
|
||||
return if record.previous_changes.blank?
|
||||
|
||||
current_record = record
|
||||
previous_record = record.dup
|
||||
record.previous_changes.each { |attribute, values| previous_record.send("#{attribute}=", values.first) }
|
||||
|
||||
# Guarantee timestamps.
|
||||
previous_record.created_at ||= current_record.created_at
|
||||
previous_record.updated_at ||= current_record.created_at
|
||||
|
||||
after = current_record.as_json_for_event_api.compact
|
||||
before = previous_record.as_json_for_event_api.compact.delete_if { |atr, val| after[atr] == val }
|
||||
|
||||
notify :updated, \
|
||||
record: after,
|
||||
changes: before.except(:updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
module Extension
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# We add «after_commit» callbacks immediately after inclusion.
|
||||
%i[create update].each do |event|
|
||||
after_commit on: event, prepend: true do
|
||||
if self.class.event_api_settings[:on]&.include?(event)
|
||||
event_api.public_send("notify_record_#{event}d")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
def acts_as_eventable(settings = {})
|
||||
settings[:on] = %i[create update] unless settings.key?(:on)
|
||||
@event_api_settings = event_api_settings.merge(settings)
|
||||
end
|
||||
|
||||
def event_api_settings
|
||||
@event_api_settings || superclass.instance_variable_get(:@event_api_settings) || {}
|
||||
end
|
||||
end
|
||||
|
||||
def event_api
|
||||
@event_api ||= Mediator.new(self)
|
||||
end
|
||||
|
||||
def as_json_for_event_api
|
||||
as_json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# To continue processing by further middlewares return array with event name and payload.
|
||||
# To stop processing event return any value which isn't an array.
|
||||
module Middlewares
|
||||
class << self
|
||||
def application_name
|
||||
Rails.application.class.name.split('::').first.underscore
|
||||
end
|
||||
|
||||
def application_version
|
||||
"#{application_name.camelize}::VERSION".constantize
|
||||
end
|
||||
end
|
||||
|
||||
class IncludeEventMetadata
|
||||
def call(event_name, event_payload)
|
||||
event_payload[:name] = event_name
|
||||
[event_name, event_payload]
|
||||
end
|
||||
end
|
||||
|
||||
class GenerateJWT
|
||||
def call(event_name, event_payload)
|
||||
jwt_payload = {
|
||||
iss: Middlewares.application_name,
|
||||
jti: SecureRandom.uuid,
|
||||
iat: Time.now.to_i,
|
||||
exp: (Time.now + 1.hour).to_i,
|
||||
event: event_payload
|
||||
}
|
||||
|
||||
private_key = Barong::App.config.keystore.private_key
|
||||
algorithm = 'RS256'
|
||||
|
||||
jwt = JWT::Multisig.generate_jwt jwt_payload, \
|
||||
{ Middlewares.application_name.to_sym => private_key },
|
||||
{ Middlewares.application_name.to_sym => algorithm }
|
||||
|
||||
[event_name, jwt]
|
||||
rescue KeyError
|
||||
raise 'No EVENT_API_JWT_PRIVATE_KEY found in env!'
|
||||
end
|
||||
end
|
||||
|
||||
class PrintToScreen
|
||||
def call(event_name, event_payload)
|
||||
Rails.logger.debug do
|
||||
['',
|
||||
'Produced new event at ' + Time.current.to_s + ': ',
|
||||
'name = ' + event_name,
|
||||
'payload = ' + event_payload.to_json,
|
||||
''].join("\n")
|
||||
end
|
||||
[event_name, event_payload]
|
||||
end
|
||||
end
|
||||
|
||||
class PublishToRabbitMQ
|
||||
extend Memoist
|
||||
|
||||
def call(event_name, event_payload)
|
||||
Rails.logger.debug do
|
||||
"\nPublishing #{routing_key(event_name)} (routing key) to #{exchange_name(event_name)} (exchange name).\n"
|
||||
end
|
||||
exchange = bunny_exchange(exchange_name(event_name))
|
||||
exchange.publish(event_payload.to_json, routing_key: routing_key(event_name))
|
||||
[event_name, event_payload]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bunny_session
|
||||
Bunny::Session.new(rabbitmq_credentials).tap do |session|
|
||||
session.start
|
||||
Kernel.at_exit { session.stop }
|
||||
end
|
||||
end
|
||||
memoize :bunny_session
|
||||
|
||||
def bunny_channel
|
||||
bunny_session.channel
|
||||
end
|
||||
memoize :bunny_channel
|
||||
|
||||
def bunny_exchange(name)
|
||||
bunny_channel.direct(name)
|
||||
end
|
||||
memoize :bunny_exchange
|
||||
|
||||
def rabbitmq_credentials
|
||||
return ENV['EVENT_API_RABBITMQ_URL'] if ENV['EVENT_API_RABBITMQ_URL'].present?
|
||||
|
||||
{
|
||||
host: Barong::App.config.event_api_rabbitmq_host,
|
||||
port: Barong::App.config.event_api_rabbitmq_port,
|
||||
username: Barong::App.config.event_api_rabbitmq_username,
|
||||
password: Barong::App.config.event_api_rabbitmq_password
|
||||
}
|
||||
end
|
||||
|
||||
def exchange_name(event_name)
|
||||
"#{Middlewares.application_name}.events.#{event_name.split('.').first}"
|
||||
end
|
||||
|
||||
def routing_key(event_name)
|
||||
event_name.split('.').drop(1).join('.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
middlewares << Middlewares::IncludeEventMetadata.new
|
||||
middlewares << Middlewares::GenerateJWT.new
|
||||
middlewares << Middlewares::PrintToScreen.new
|
||||
middlewares << Middlewares::PublishToRabbitMQ.new
|
||||
end
|
||||
29
lib/barong/geo_ip.rb
Normal file
29
lib/barong/geo_ip.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
# MaxmindDB reader adapter
|
||||
module GeoIP
|
||||
class << self
|
||||
attr_accessor :lang
|
||||
|
||||
# Usage: city = Barong::GeoIP.get(ip: ip, key: :city)
|
||||
def info(ip:, key:)
|
||||
record = reader.get(ip)
|
||||
return unless record
|
||||
|
||||
case key.to_sym
|
||||
when :country
|
||||
return record['country']['names'][lang] if record['country']
|
||||
when :continent
|
||||
return record['continent']['names'][lang] if record['continent']
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def reader
|
||||
@reader ||= MaxMind::DB.new(Barong::App.config.maxminddb_path, mode: MaxMind::DB::MODE_MEMORY)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
8
lib/barong/json_log_formatter.rb
Normal file
8
lib/barong/json_log_formatter.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# json formatter for logs
|
||||
class JSONLogFormatter < ::Logger::Formatter
|
||||
def call(severity, time, _progname, msg)
|
||||
JSON.dump(level: severity, time: time, message: msg) + "\n"
|
||||
end
|
||||
end
|
||||
50
lib/barong/jwt.rb
Normal file
50
lib/barong/jwt.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
module Barong
|
||||
class JWT
|
||||
|
||||
def initialize(options)
|
||||
raise "Missing private key" unless options[:key]
|
||||
@options = options.reverse_merge({
|
||||
algoritm: 'RS256',
|
||||
expire: Barong::App.config.jwt_expire_time,
|
||||
sub: 'session',
|
||||
iss: 'barong',
|
||||
aud: %w[peatio barong]
|
||||
})
|
||||
end
|
||||
|
||||
def encode(payload)
|
||||
::JWT.encode(merge_claims(payload),
|
||||
@options[:key], @options[:algoritm])
|
||||
end
|
||||
|
||||
def decode_and_verify(token, verify_options)
|
||||
@verify_options = verify_options.reverse_merge({
|
||||
verify_expiration: true,
|
||||
verify_not_before: true,
|
||||
iss: 'barong',
|
||||
verify_iss: true,
|
||||
verify_iat: true,
|
||||
verify_jti: true,
|
||||
aud: %w[peatio barong],
|
||||
verify_aud: true,
|
||||
sub: 'confirmation',
|
||||
verify_sub: true,
|
||||
algorithms: 'RS256'
|
||||
})
|
||||
payload, header = ::JWT.decode(token, @verify_options[:pub_key], true, @verify_options)
|
||||
payload.keys.each { |k| payload[k.to_sym] = payload.delete(k) }
|
||||
payload
|
||||
end
|
||||
|
||||
def merge_claims(payload)
|
||||
payload.reverse_merge({
|
||||
iat: Time.now.to_i,
|
||||
exp: (Time.now + @options[:expire]).to_i,
|
||||
sub: @options[:sub],
|
||||
iss: @options[:iss],
|
||||
aud: @options[:aud],
|
||||
jti: SecureRandom.hex(10)
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
49
lib/barong/keystore.rb
Normal file
49
lib/barong/keystore.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
class KeyStore
|
||||
|
||||
class Fatal < StandardError; end
|
||||
|
||||
def initialize(private_key)
|
||||
OpenSSL::PKey.read(private_key).tap do |key|
|
||||
@private_key = key
|
||||
@public_key = key.public_key
|
||||
end
|
||||
end
|
||||
|
||||
def public_key
|
||||
@public_key
|
||||
end
|
||||
|
||||
def private_key
|
||||
@private_key
|
||||
end
|
||||
|
||||
class << self
|
||||
def open!(private_key_path)
|
||||
pkeyio = File.open(private_key_path)
|
||||
return OpenSSL::PKey.read(pkeyio).to_pem
|
||||
rescue
|
||||
raise Barong::KeyStore::Fatal
|
||||
end
|
||||
|
||||
def read!(private_key)
|
||||
return OpenSSL::PKey.read(private_key).to_pem
|
||||
rescue
|
||||
raise Barong::KeyStore::Fatal
|
||||
end
|
||||
|
||||
def save!(key, path)
|
||||
File.open(path, 'w+') { |file| file.write(key) }
|
||||
rescue
|
||||
raise Barong::KeyStore::Fatal
|
||||
end
|
||||
|
||||
def generate
|
||||
OpenSSL::PKey::RSA.generate(2048)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
8
lib/barong/middleware.rb
Normal file
8
lib/barong/middleware.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'barong/middleware/jwt_authenticator'
|
||||
|
||||
module Barong
|
||||
# Barong Rack middlewares
|
||||
module Middleware; end
|
||||
end
|
||||
46
lib/barong/middleware/jwt_authenticator.rb
Normal file
46
lib/barong/middleware/jwt_authenticator.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
module Middleware
|
||||
# Authenticate a user by a bearer token
|
||||
class JWTAuthenticator < Grape::Middleware::Base
|
||||
def initialize(app, options)
|
||||
super(app, options)
|
||||
raise(Peatio::Auth::Error, 'Public key missing') unless options[:pubkey]
|
||||
|
||||
@keypub = options[:pubkey]
|
||||
end
|
||||
|
||||
def before
|
||||
return if request.path.include? 'swagger'
|
||||
|
||||
raise(Peatio::Auth::Error, 'Header Authorization missing') \
|
||||
unless authorization_present?
|
||||
|
||||
token = request.headers['Authorization']
|
||||
env[:current_payload] = authenticator.authenticate!(token)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# JWT Authenticator instance from peatio-core
|
||||
#
|
||||
# @return [Peatio::Auth::JWTAuthenticator]
|
||||
def authenticator
|
||||
@authenticator ||=
|
||||
Peatio::Auth::JWTAuthenticator.new(@keypub)
|
||||
end
|
||||
|
||||
def authorization_present?
|
||||
request.headers.key?('Authorization')
|
||||
end
|
||||
|
||||
# Request entity
|
||||
#
|
||||
# @return [Grape::Request]
|
||||
def request
|
||||
@request ||= Grape::Request.new(env)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
19
lib/barong/mock_sms.rb
Normal file
19
lib/barong/mock_sms.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Barong
|
||||
# empty sms service
|
||||
class MockSMS
|
||||
cattr_accessor :messages
|
||||
self.messages = []
|
||||
|
||||
def initialize(_account_sid, _auth_token) end
|
||||
|
||||
def messages
|
||||
self
|
||||
end
|
||||
|
||||
def create(params)
|
||||
self.class.messages << OpenStruct.new(params)
|
||||
end
|
||||
end
|
||||
end
|
||||
191
lib/barong/seed.rb
Normal file
191
lib/barong/seed.rb
Normal file
@@ -0,0 +1,191 @@
|
||||
module Barong
|
||||
class Seed
|
||||
class ConfigError < RuntimeError; end
|
||||
|
||||
def initialize
|
||||
@result = []
|
||||
end
|
||||
|
||||
def seeds
|
||||
YAML.safe_load(
|
||||
ERB.new(
|
||||
File.read(
|
||||
Barong::App.config.seeds_file
|
||||
)
|
||||
).result
|
||||
)
|
||||
end
|
||||
|
||||
def inspect
|
||||
str = "Seeded users:\n"
|
||||
str += @result.map do |user|
|
||||
"Email: #{user[:email]}, password: #{user[:password]}"
|
||||
end.join("\n")
|
||||
return str
|
||||
end
|
||||
|
||||
def logger
|
||||
@logger ||= Logger.new(STDERR, progname: "db:seed")
|
||||
end
|
||||
|
||||
def seed_provinces
|
||||
logger.info "Seeding provinces"
|
||||
seeds["provinces"].each_with_index do |province, index|
|
||||
logger.info "---"
|
||||
if Province.find_by(name: province["name"]).present?
|
||||
logger.info "province '#{province['name']}' already exists"
|
||||
next
|
||||
end
|
||||
province[:id] = index+1
|
||||
Province.create!(province)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_cities
|
||||
logger.info "Seeding cities"
|
||||
seeds["cities"].each_with_index do |city, index|
|
||||
logger.info "---"
|
||||
if City.find_by(name: city["name"]).present?
|
||||
logger.info "province '#{city['name']}' already exists"
|
||||
next
|
||||
end
|
||||
city[:id] = index+1
|
||||
City.create!(city)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_levels
|
||||
logger.info "Seeding levels"
|
||||
seeds["levels"].each_with_index do |level, index|
|
||||
logger.info "---"
|
||||
if Level.find_by(key: level["key"], value: level["value"]).present?
|
||||
logger.info "Level '#{level['key']}:#{level['value']}' already exists"
|
||||
next
|
||||
end
|
||||
level[:id] = index+1
|
||||
Level.create!(level)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_permissions
|
||||
logger.info "Seeding permissions"
|
||||
seeds["permissions"].each do |perm|
|
||||
logger.info "---"
|
||||
if Permission.find_by(role: perm["role"], verb: perm["verb"], path: perm["path"], action: perm["action"]).present?
|
||||
logger.info "Permission for '#{perm['role']}' : '#{perm['verb']} to #{perm['path']}' already exists"
|
||||
next
|
||||
end
|
||||
|
||||
permission = Permission.new(perm)
|
||||
|
||||
unless permission.save
|
||||
raise ConfigError.new("Can't create permission: #{permission.errors.full_messages.join('; ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def seed_users
|
||||
logger.info "Seeding users"
|
||||
seeds["users"]&.each do |seed|
|
||||
logger.info "---"
|
||||
|
||||
raise ConfigError.new("Email missing in users seed") if seed["email"].to_s.empty?
|
||||
raise ConfigError.new("Level is missing for user #{seed["email"]}") unless seed["level"].is_a?(Integer)
|
||||
|
||||
# Skip existing users
|
||||
if User.find_by(email: seed["email"]).present?
|
||||
logger.info "User '#{seed['email']}' already exists"
|
||||
@result.push(email: seed["email"])
|
||||
next
|
||||
end
|
||||
|
||||
user = User.new(seed)
|
||||
user.password ||= SecureRandom.base64(30)
|
||||
|
||||
if user.save
|
||||
logger.info "Created user for '#{user.email}'"
|
||||
|
||||
# Set correct level with labels
|
||||
# levels = Level.where(id: 1..user.level)
|
||||
levels = Level.where(id: 1..Level::LEVEL_ID_BOUNDS[user.level.to_s])
|
||||
raise ConfigError.new("No enough levels found in database to grant the user to level #{user.level}") if levels.count < user.level
|
||||
|
||||
levels.find_each do |level|
|
||||
user.labels.create(key: level.key, value: level.value, scope: 'private')
|
||||
end
|
||||
|
||||
@result.push(email: user.email, password: user.password, level: user.level)
|
||||
else
|
||||
logger.error "Can't create user '#{user.email}': #{user.errors.full_messages.join('; ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def seed_superadmin
|
||||
|
||||
logger.info "Seeding superadmin"
|
||||
seeds["superadmin"]&.each do |seed|
|
||||
logger.info "---"
|
||||
raise ConfigError.new("Email missing in users seed") if seed["email"].to_s.empty?
|
||||
raise ConfigError.new("Level is missing for user #{seed["email"]}") unless seed["level"].is_a?(Integer)
|
||||
|
||||
# Skip existing users
|
||||
if User.find_by(email: seed["email"]).present?
|
||||
logger.info "User '#{seed['email']}' already exists"
|
||||
@result.push(email: seed["email"])
|
||||
next
|
||||
end
|
||||
|
||||
user = User.new(seed)
|
||||
user.password ||= SecureRandom.base64(30)
|
||||
|
||||
if user.save
|
||||
logger.info "Created user for '#{user.email}'"
|
||||
|
||||
# Set correct level with labels
|
||||
# levels = Level.where(id: 1..user.level)
|
||||
levels = Level.where(id: 1..Level::LEVEL_ID_BOUNDS[user.level.to_s])
|
||||
raise ConfigError.new("No enough levels found in database to grant the user to level #{user.level}") if levels.count < user.level
|
||||
|
||||
levels.find_each do |level|
|
||||
user.labels.create(key: level.key, value: level.value, scope: 'private')
|
||||
end
|
||||
|
||||
@result.push(email: user.email, password: user.password, level: user.level)
|
||||
else
|
||||
logger.error "Can't create user '#{user.email}': #{user.errors.full_messages.join('; ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def seed_restrictions
|
||||
logger.info "Seeding restrictions"
|
||||
return logger.info "Restrictions seed is empty!" if seeds["restrictions"].empty?
|
||||
|
||||
seeds["restrictions"].each do |seed|
|
||||
logger.info "---"
|
||||
if Restriction.find_by(category: seed["category"], scope: seed["scope"], value: seed["value"], state: seed["state"]).present?
|
||||
logger.info "Restriction '#{seed['category']}' '#{seed['scope']} #{seed['value']}' #{seed['state']}' already exists"
|
||||
next
|
||||
end
|
||||
raise ConfigError.new("category missing in restrictions seed") if seed["category"].nil?
|
||||
raise ConfigError.new("scope is missing in restrictions seed") if seed["scope"].nil?
|
||||
raise ConfigError.new("value is missing in restrictions seed") if seed["value"].nil?
|
||||
raise ConfigError.new("state is missing in restrictions seed") if seed["state"].nil?
|
||||
|
||||
restriction = Restriction.new(
|
||||
category: seed["category"],
|
||||
scope: seed["scope"],
|
||||
value: seed["value"],
|
||||
state: seed["state"],
|
||||
code: seed["code"]
|
||||
)
|
||||
|
||||
unless restriction.save
|
||||
raise ConfigError.new("Can't create restriction: #{restriction.errors.full_messages.join('; ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
49
lib/barong/string.rb
Normal file
49
lib/barong/string.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class String
|
||||
CHARACTER_MAPPING = {
|
||||
'ك' => 'ک',
|
||||
'دِ' => 'د',
|
||||
'بِ' => 'ب',
|
||||
'زِ' => 'ز',
|
||||
'ذِ' => 'ذ',
|
||||
'شِ' => 'ش',
|
||||
'سِ' => 'س',
|
||||
'ى' => 'ی',
|
||||
'ي' => 'ی',
|
||||
'١' => '۱',
|
||||
'٢' => '۲',
|
||||
'٣' => '۳',
|
||||
'٤' => '۴',
|
||||
'٥' => '۵',
|
||||
'٦' => '۶',
|
||||
'٧' => '۷',
|
||||
'٨' => '۸',
|
||||
'٩' => '۹',
|
||||
'٠' => '۰'
|
||||
}.freeze
|
||||
|
||||
def to_persian
|
||||
gsub(Regexp.union(CHARACTER_MAPPING.keys), CHARACTER_MAPPING)
|
||||
end
|
||||
|
||||
def percent_match(mold)
|
||||
mold = mold.to_s.to_persian
|
||||
str = self.to_persian
|
||||
char_cost = (100.to_f / length).round(2)
|
||||
matching = 0
|
||||
str_index = 0
|
||||
mold_index = 0
|
||||
str.each_char.with_index do |_char, index|
|
||||
break if mold[mold_index].blank?
|
||||
|
||||
if mold[mold_index] != str[str_index]
|
||||
mold_index = index - 1 if mold.length < str.length
|
||||
str_index = index - 1 if mold.length > str.length
|
||||
else
|
||||
matching += char_cost
|
||||
end
|
||||
str_index += 1
|
||||
mold_index += 1
|
||||
end
|
||||
matching.round
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user