diff --git a/.markdownlintrc b/.markdownlintrc index d0f2c22d0..8384db3e4 100644 --- a/.markdownlintrc +++ b/.markdownlintrc @@ -7,8 +7,8 @@ "style": "sublist" }, "MD013": { - "code_blocks": false, - "tables": false, + "line_length": 120, + "tables": false }, "MD024": { "allow_different_nesting": true diff --git a/Gemfile b/Gemfile index d062f24cb..71c1f49a3 100644 --- a/Gemfile +++ b/Gemfile @@ -26,6 +26,7 @@ gem 'aws-sdk-s3', require: false gem 'image_processing', '~> 1.2' # authentication +gem 'authy' gem 'devise' # assets: core asset dependencies @@ -58,10 +59,16 @@ gem 'telephone_number' # error tracking gem 'rollbar', github: 'rollbar/rollbar-gem' +gem 'slack-notifier' # permalinks gem 'friendly_id', '~> 5.2.0' +# sms +gem 'nexmo' +gem 'smstools' +gem 'twilio-ruby', '~> 5.21.2' + group :development, :test do gem 'brakeman', require: false gem 'bullet', github: 'flyerhzm/bullet' @@ -76,9 +83,8 @@ end group :test do gem 'capybara', '>= 2.15' - gem 'chromedriver-helper' gem 'codacy-coverage', require: false - gem 'selenium-webdriver' + gem 'webdrivers', '~> 3.0' gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index 2768aba95..aebacc2e9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: efb706daad0e2e1039c6abb4879c837ef8bf4d10 + revision: 4ec486145290efb4059b64e84a2bfd9e157bf787 specs: actioncable (6.0.0.beta3) actionpack (= 6.0.0.beta3) @@ -63,7 +63,7 @@ GIT i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - zeitwerk (~> 1.4) + zeitwerk (~> 1.4, >= 1.4.2) rails (6.0.0.beta3) actioncable (= 6.0.0.beta3) actionmailbox (= 6.0.0.beta3) @@ -129,11 +129,11 @@ GEM public_suffix (>= 2.0.2, < 4.0) ancestry (3.0.5) activerecord (>= 3.2.0) - archive-zip (0.12.0) - io-like (~> 0.3.0) ast (2.4.0) + authy (2.7.5) + httpclient (>= 2.5.3.3) aws-eventstream (1.0.2) - aws-partitions (1.145.0) + aws-partitions (1.146.0) aws-sdk-core (3.48.2) aws-eventstream (~> 1.0, >= 1.0.2) aws-partitions (~> 1.0) @@ -142,7 +142,7 @@ GEM aws-sdk-kms (1.16.0) aws-sdk-core (~> 3, >= 3.48.2) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.34.0) + aws-sdk-s3 (1.35.0) aws-sdk-core (~> 3, >= 3.48.2) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.0) @@ -177,9 +177,6 @@ GEM chartkick (3.0.2) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) - chromedriver-helper (2.1.0) - archive-zip (~> 0.10) - nokogiri (~> 1.8) cocoon (1.2.12) codacy-coverage (2.1.0) simplecov @@ -219,6 +216,8 @@ GEM smart_properties erubi (1.8.0) execjs (2.7.0) + faraday (0.15.4) + multipart-post (>= 1.2, < 3) ffi (1.10.0) fit-commit (3.8.1) swearjar (~> 1.3) @@ -232,18 +231,19 @@ GEM activesupport (>= 4.2) hashdiff (0.3.8) html_tokenizer (0.0.7) + httpclient (2.8.3) i18n (1.6.0) concurrent-ruby (~> 1.0) image_processing (1.8.0) mini_magick (>= 4.9.3, < 5) ruby-vips (>= 2.0.13, < 3) - io-like (0.3.0) jaro_winkler (1.5.2) jbuilder (2.8.0) activesupport (>= 4.2.0) multi_json (>= 1.2) jmespath (1.4.0) json (2.2.0) + jwt (2.1.0) launchy (2.4.3) addressable (~> 2.3) letter_opener (1.7.0) @@ -271,7 +271,11 @@ GEM minitest (5.11.3) msgpack (1.2.9) multi_json (1.13.1) + multipart-post (2.0.0) + net_http_ssl_fix (0.0.10) netaddr (2.0.3) + nexmo (5.6.0) + jwt (~> 2) nio4r (2.3.1) nokogiri (1.10.1) mini_portile2 (~> 2.4.0) @@ -365,7 +369,9 @@ GEM simplecov-html (0.10.2) simpleidn (0.1.1) unf (~> 0.1.4) + slack-notifier (2.3.2) smart_properties (1.13.1) + smstools (0.2.0) spring (2.0.2) activesupport (>= 4.2) spring-watcher-listen (2.0.1) @@ -383,6 +389,10 @@ GEM thor (0.20.3) thread_safe (0.3.6) tilt (2.0.9) + twilio-ruby (5.21.2) + faraday (~> 0.9) + jwt (>= 1.5, <= 2.5) + nokogiri (>= 1.6, < 2.0) tzinfo (1.2.5) thread_safe (~> 0.1) uglifier (4.1.20) @@ -394,6 +404,11 @@ GEM uniform_notifier (1.12.1) warden (1.2.8) rack (>= 2.0.6) + webdrivers (3.7.0) + net_http_ssl_fix + nokogiri (~> 1.6) + rubyzip (~> 1.0) + selenium-webdriver (~> 3.0) webmock (3.5.1) addressable (>= 2.3.6) crack (>= 0.3.2) @@ -409,13 +424,14 @@ GEM activesupport xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (1.4.0) + zeitwerk (1.4.2) PLATFORMS ruby DEPENDENCIES ancestry + authy aws-sdk-s3 bcrypt (~> 3.1.7) bootsnap (>= 1.4.0) @@ -425,7 +441,6 @@ DEPENDENCIES byebug capybara (>= 2.15) chartkick - chromedriver-helper cocoon codacy-coverage coffee-rails (~> 4.2) @@ -442,6 +457,7 @@ DEPENDENCIES letter_opener listen (>= 3.0.5, < 3.2) lol_dba + nexmo nokul-support! nokul-tenant! nokul-tenant-omu! @@ -461,15 +477,18 @@ DEPENDENCIES rubocop ruby-progressbar sassc-rails - selenium-webdriver sidekiq simple_form simplecov + slack-notifier + smstools spring spring-watcher-listen (~> 2.0.0) telephone_number + twilio-ruby (~> 5.21.2) uglifier (>= 1.3.0) web-console! + webdrivers (~> 3.0) webmock webpacker wicked_pdf diff --git a/README.md b/README.md index 2410b62a7..059179855 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ [![Security](https://hakiri.io/projects/cf546402fb7117/stacks/622121c74c17f4/622121c74c17f4.svg?repo_token=xo-yGdqDKXvCf-jypXd-)](https://hakiri.io/projects/cf546402fb7117/stacks/622121c74c17f4/shield) [![Known Vulnerabilities](https://snyk.io/test/github/omu/nokul/badge.svg)](https://snyk.io/test/github/omu/nokul) - [Nokul](https://github.com/omu/nokul) aims to become a complete solution for universities that face many challanges while trying to manage all the complex procedures of a university online. Dealing with ill-structured processes of a university is hard, especially when they backed with complex legal procedures. Many universities facing similar issues when it comes to automating things, and moving face-to-face operations to online. Nokul aims to remediate these wounds by taking a Turkish university with more than 70 web services, 50K students and 4K employees as a case. diff --git a/app/controllers/calendar_management/calendars_controller.rb b/app/controllers/calendar_management/calendars_controller.rb index 9dc245831..cecea014a 100644 --- a/app/controllers/calendar_management/calendars_controller.rb +++ b/app/controllers/calendar_management/calendars_controller.rb @@ -43,14 +43,13 @@ def destroy end def duplicate - @calendar = Calendar.find(params[:calendar_id]) - @duplicate_record = DuplicateService.new(@calendar, 'name').duplicate - - redirect_to(:calendars, alert: t('.warning')) && return unless @duplicate_record + calendar = Calendar.find(params[:calendar_id]) - AcademicCalendars::DuplicateEventsService.new(@calendar, @duplicate_record) + duplicate_record = AcademicCalendars::DuplicateCalendarService.new(calendar, 'name').duplicate + redirect_to(:calendars, alert: t('.warning')) && return unless duplicate_record - redirect_to([:edit, @duplicate_record], notice: t('.success')) + AcademicCalendars::DuplicateEventsService.new(calendar, duplicate_record) + redirect_to([:edit, duplicate_record], notice: t('.success')) end def units diff --git a/app/errors/concatenation_error.rb b/app/errors/concatenation_error.rb new file mode 100644 index 000000000..93e381856 --- /dev/null +++ b/app/errors/concatenation_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ConcatenationError < StandardError + def message + I18n.t('errors.can_not_be_concatenated') + end +end diff --git a/app/errors/encoding_mismatch_error.rb b/app/errors/encoding_mismatch_error.rb new file mode 100644 index 000000000..171776b48 --- /dev/null +++ b/app/errors/encoding_mismatch_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EncodingMismatchError < StandardError + def message + I18n.t('errors.encoding_mismatch') + end +end diff --git a/app/errors/id_number_error.rb b/app/errors/id_number_error.rb deleted file mode 100644 index b7b9f4fb6..000000000 --- a/app/errors/id_number_error.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class IdNumberError < StandardError - def message - I18n.t('errors.invalid_id') - end -end diff --git a/app/errors/invalid_phone_number_error.rb b/app/errors/invalid_phone_number_error.rb new file mode 100644 index 000000000..a9e1be762 --- /dev/null +++ b/app/errors/invalid_phone_number_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class InvalidPhoneNumberError < StandardError + def message + I18n.t('errors.invalid_phone_number') + end +end diff --git a/app/errors/unicode_support_error.rb b/app/errors/unicode_support_error.rb new file mode 100644 index 000000000..ff84126e6 --- /dev/null +++ b/app/errors/unicode_support_error.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UnicodeSupportError < StandardError + def message + I18n.t('errors.unicode_not_supported') + end +end diff --git a/app/services/academic_calendars/duplicate_calendar_service.rb b/app/services/academic_calendars/duplicate_calendar_service.rb new file mode 100644 index 000000000..57b8f2dfc --- /dev/null +++ b/app/services/academic_calendars/duplicate_calendar_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module AcademicCalendars + class DuplicateCalendarService + attr_reader :record, :prefixed_param + + def initialize(record, prefixed_param) + @record = record + @prefixed_param = prefixed_param + end + + def duplicate + clone_record = @record.dup + clone_record.send(@prefixed_param).prepend('[Kopyası] ') + clone_record.committee_decisions << @record.committee_decisions + clone_record.save + clone_record + end + end +end diff --git a/app/services/duplicate_service.rb b/app/services/duplicate_service.rb deleted file mode 100644 index 768ca4aec..000000000 --- a/app/services/duplicate_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class DuplicateService - attr_reader :record, :prefixed_param - - def initialize(record, prefixed_param) - @record = record - @prefixed_param = prefixed_param - end - - def duplicate - clone_record = @record.dup - clone_record.send(@prefixed_param).prepend('[Kopyası] ') - clone_record.save - clone_record - end -end diff --git a/app/services/nexmo/error_handler.rb b/app/services/nexmo/error_handler.rb new file mode 100644 index 000000000..0f5466091 --- /dev/null +++ b/app/services/nexmo/error_handler.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Nexmo + module ErrorHandler + SOFT_FAIL_CODES = { + '2' => 'Missing Parameters', + '3' => 'Invalid Parameters', + '6' => 'Invalid Message', + '12' => 'Message Too Long', + '22' => 'Invalid Network Code', + '33' => 'Number De-activated' + }.freeze + + HARD_FAIL_CODES = { + '1' => 'Throttled', + '4' => 'Invalid Credentials', + '5' => 'Internal Error', + '7' => 'Number Barred', + '8' => 'Partner Account Barred', + '9' => 'Partner Quota Violation', + '10' => 'Too Many Existing Binds', + '11' => 'Account Not Enabled For HTTP', + '14' => 'Invalid Signature', + '15' => 'Invalid Sender Address', + '23' => 'Invalid Callback Url', + '32' => 'Signature And API Secret Disallowed' + }.freeze + + def log_or_notify_admin(response) + status = response.status + notifier = Slack::Notifier.new Tenant.credentials.slack[:panik_hook] + + if status == '0' + Rails.logger.info "Sent message id=#{response.message_id}" + elsif SOFT_FAIL_CODES.key?(status) || HARD_FAIL_CODES.key?(status) + notifier.ping "Nexmo SMS Error (code: #{status}, text: #{response.error_text})" + end + end + end +end diff --git a/app/services/nexmo/sms.rb b/app/services/nexmo/sms.rb new file mode 100644 index 000000000..e9ea0e1e0 --- /dev/null +++ b/app/services/nexmo/sms.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'error_handler' + +module Nexmo + class Sms + include ErrorHandler + + # Some countries and operators do not support concatenation and unicode. + # Enable multipart and unicode type thoughtfully! + # Nexmo supports all the standard GSM characters + characters from the GSM extended table. + # Test GSM-7 encoding: http://chadselph.github.io/smssplit/ + def initialize(to_number, message, country = 'tr', multipart = false, type = 'text') + @to_number = to_number.to_s + @message = type.eql?('text') ? message.asciified.squish : message + @country = country + @multipart = multipart + @type = type + + process_message + end + + private + + def process_message + check_country + check_destination_number + check_multipart + check_encoding + send_message + end + + def check_country + @country = Country.find_by(alpha_2_code: @country.upcase) + + raise ActiveRecord::RecordNotFound, 'Country can not be found!' unless @country + end + + def check_destination_number + valid_number = TelephoneNumber.valid?(@to_number, @country.alpha_2_code.to_sym, [:mobile]) + + raise InvalidPhoneNumberError unless valid_number + + parser = TelephoneNumber.parse(@to_number, @country.alpha_2_code.to_sym) + @to_number = parser.country.country_code + parser.formatter.normalized_number + end + + def check_multipart + encoding = SmsTools::EncodingDetection.new(@message) + + raise ConcatenationError if @multipart.eql?(false) && encoding.concatenated? + raise ConcatenationError if @multipart.eql?(false) && !@country.sms_concatenation + end + + def check_encoding + encoding = SmsTools::EncodingDetection.new(@message).encoding.to_s + encoding = 'text' if encoding.eql?('gsm') || encoding.eql?('ascii') + + raise EncodingMismatchError unless encoding == @type + raise UnicodeSupportError if encoding.eql?('unicode') && !@country.sms_unicode + end + + def send_message + response = NEXMO_CLIENT.sms.send( + from: Tenant.credentials.dig(:nexmo, :from), + to: @to_number, + type: @type, + text: @message + ).messages.first + + log_or_notify_admin(response) + end + end +end diff --git a/app/services/twilio/lookup.rb b/app/services/twilio/lookup.rb new file mode 100644 index 000000000..22270784e --- /dev/null +++ b/app/services/twilio/lookup.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# See https://www.twilio.com/docs/lookup/api for options + +module Twilio + class Lookup + def initialize(number, **options) + @response = TWILIO_CLIENT.lookups.phone_numbers(number.to_s).fetch(options) + end + end +end diff --git a/app/services/twilio/sms.rb b/app/services/twilio/sms.rb new file mode 100644 index 000000000..267d31312 --- /dev/null +++ b/app/services/twilio/sms.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Twilio + class SMS + def initialize(to, body) + response = TWILIO_CLIENT.messages.create( + from: Tenant.credentials.twilio[:sender], + to: to, + body: body + ) + + response.sid + end + end +end diff --git a/app/services/twilio/verify.rb b/app/services/twilio/verify.rb new file mode 100644 index 000000000..cdfbb7034 --- /dev/null +++ b/app/services/twilio/verify.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# See https://www.twilio.com/verify and authy documentation for details. + +module Twilio + class Verify + # register user to authy! + def register_user(email, phone, country_code) + authy = Authy::API.register_user( + email: email, + cellphone: phone, + country_code: country_code + ) + + if authy.ok? + # store authy_id in database + user.authy_id = authy.id + else + authy.errors + end + end + + # verify authy token + def verify_token(user, token) + response = Authy::API.verify( + id: user.authy_id, + token: token + ) + + response.ok? ? 'ok' : 'invalid' + end + + # request authy code as SMS + def request_sms(user) + response = Authy::API.request_sms( + id: user.authy_id, + locale: user.preferred_language || 'tr' + ) + + response.ok? ? 'ok' : response.errors + end + + # request QR code for Google Authenticator etc. + def request_qr_code(user) + response = Authy::API.request_qr_code( + id: user.authy_id, + qr_size: 500, + label: Tenant.configuration.name + ) + + if response.ok? + response.qr_code # link of the QR image + else + response.errors + end + end + + def send_phone_verification_code(phone_number, locale = 'tr') + response = Authy::PhoneVerification.start( + via: 'sms', + country_code: parse_phone_number(phone_number)[:country_code], + phone_number: parse_phone_number(phone_number)[:normalized_number], + locale: locale + ) + + 'ok' if response.ok? + end + + def check_verification_code(phone_number, verification_code) + response = Authy::PhoneVerification.check( + verification_code: verification_code, + country_code: parse_phone_number(phone_number)[:country_code], + phone_number: parse_phone_number(phone_number)[:normalized_number] + ) + + 'ok' if response.ok? + end + + private + + def parse_phone_number(phone_number) + parsed_number = TelephoneNumber.parse(phone_number) + + { + country_code: parsed_number.country.country_code, + normalized_number: parsed_number.normalized_number + } + end + end +end diff --git a/app/views/committee/agenda_types/index.html.erb b/app/views/committee/agenda_types/index.html.erb index bb685a2ab..da1efa3da 100644 --- a/app/views/committee/agenda_types/index.html.erb +++ b/app/views/committee/agenda_types/index.html.erb @@ -1,12 +1,11 @@ -
- <%= link_to_new t('.new_agenda_type_link'), new_agenda_type_path %> -
-
<%= fa_icon 'tags', text: t('.card_header') %> +
+ <%= link_to_new t('.new_agenda_type_link'), new_agenda_type_path %> +
<%= render 'layouts/shared/smart_search_form', diff --git a/app/views/course_management/available_courses/index.html.erb b/app/views/course_management/available_courses/index.html.erb index c2d7c920f..cb586fda1 100644 --- a/app/views/course_management/available_courses/index.html.erb +++ b/app/views/course_management/available_courses/index.html.erb @@ -1,5 +1,3 @@ -<%= render 'search' %> -
@@ -10,6 +8,7 @@
+ <%= render 'search' %> @@ -26,7 +25,7 @@ <% @available_courses.each do |available_course| %> - + diff --git a/app/views/course_management/course_group_types/index.html.erb b/app/views/course_management/course_group_types/index.html.erb index 8abc975ef..afb68e58a 100644 --- a/app/views/course_management/course_group_types/index.html.erb +++ b/app/views/course_management/course_group_types/index.html.erb @@ -1,12 +1,11 @@ -
- <%= link_to_new t('.new_course_group_type_link'), new_course_group_type_path %> -
-
<%= fa_icon 'tags', text: t('.card_header') %> +
+ <%= link_to_new t('.new_course_group_type_link'), new_course_group_type_path %> +
<%= render 'layouts/shared/smart_search_form', diff --git a/app/views/course_management/course_groups/index.html.erb b/app/views/course_management/course_groups/index.html.erb index 70e0be59f..d3f35c35c 100644 --- a/app/views/course_management/course_groups/index.html.erb +++ b/app/views/course_management/course_groups/index.html.erb @@ -1,16 +1,14 @@ -
- <%= link_to_new t('.new_course_group_link'), new_course_group_path %> -
- -<%= render 'search' %> -
<%= fa_icon 'tags', text: t('.card_header') %> +
+ <%= link_to_new t('.new_course_group_link'), new_course_group_path %> +
+ <%= render 'search' %>
<%= available_course.unit.name %><%= available_course.unit.names_depth_cache %> <%= full_name(available_course.academic_term) %> <%= available_course.curriculum.name %> <%= available_course.course.code %>
@@ -26,7 +24,7 @@ - + diff --git a/app/views/course_management/course_types/index.html.erb b/app/views/course_management/course_types/index.html.erb index 90583d2ae..476eab3f1 100644 --- a/app/views/course_management/course_types/index.html.erb +++ b/app/views/course_management/course_types/index.html.erb @@ -1,12 +1,11 @@ -
- <%= link_to_new t('.new_course_type_link'), new_course_type_path %> -
-
<%= fa_icon 'tags', text: t('.card_header') %> +
+ <%= link_to_new t('.new_course_type_link'), new_course_type_path %> +
<%= render 'layouts/shared/smart_search_form', diff --git a/app/views/course_management/courses/index.html.erb b/app/views/course_management/courses/index.html.erb index 988de8e6d..9edf45c39 100644 --- a/app/views/course_management/courses/index.html.erb +++ b/app/views/course_management/courses/index.html.erb @@ -1,18 +1,16 @@ -
- <%= link_to_new t('.add_new_course'), new_course_path %> -
- -<%= render 'search' %> -
-
-
+
+
<%= fa_icon 'align-justify', text: t('.courses') %> +
+ <%= link_to_new t('.add_new_course'), new_course_path %> +
-
+
+ <%= render 'search' %>
<%= course_group.name %> <%= course_group.total_ects_condition %><%= course_group.unit.try(:name) %><%= course_group.unit.try(:names_depth_cache) %> <%= course_group.course_group_type.try(:name) %> <%= link_to_actions(course_group) %>
- + @@ -35,13 +33,13 @@ - + @@ -52,9 +50,9 @@
<%= t('.name') %> <%= t('.code') %>
<%= course.name %> <%= course.code %><%= course.unit.try(:name) %><%= course.unit.try(:names_depth_cache) %> <%= course.course_type.try(:name) %> - T: <%= course.theoric %> - P: <%= course.practice %> - L: <%= course.laboratory %> - K: <%= course.credit %> + T: <%= course.theoric %> + P: <%= course.practice %> + L: <%= course.laboratory %> + K: <%= course.credit %> <%= enum_t(course, :program_type) %> <%= course.language.try(:name) %>
-