antispam 0.1.7 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +20 -20
  3. data/README.md +129 -113
  4. data/Rakefile +18 -18
  5. data/app/assets/config/antispam_manifest.js +1 -1
  6. data/app/assets/stylesheets/antispam/application.css +15 -15
  7. data/app/assets/stylesheets/antispam/blocks.css +4 -4
  8. data/app/assets/stylesheets/antispam/challenges.css +4 -4
  9. data/app/assets/stylesheets/antispam/clears.css +4 -4
  10. data/app/assets/stylesheets/scaffold.css +80 -80
  11. data/app/controllers/antispam/application_controller.rb +11 -11
  12. data/app/controllers/antispam/blocks_controller.rb +28 -28
  13. data/app/controllers/antispam/challenges_controller.rb +50 -50
  14. data/app/controllers/antispam/clears_controller.rb +28 -28
  15. data/app/controllers/antispam/validate_controller.rb +12 -12
  16. data/app/helpers/antispam/application_helper.rb +4 -4
  17. data/app/helpers/antispam/blocks_helper.rb +4 -4
  18. data/app/helpers/antispam/challenges_helper.rb +4 -4
  19. data/app/helpers/antispam/clears_helper.rb +4 -4
  20. data/app/jobs/antispam/application_job.rb +4 -4
  21. data/app/mailers/antispam/application_mailer.rb +6 -6
  22. data/app/models/antispam/application_record.rb +5 -5
  23. data/app/models/antispam/block.rb +4 -4
  24. data/app/models/antispam/challenge.rb +26 -26
  25. data/app/models/antispam/clear.rb +4 -4
  26. data/app/models/antispam/ip.rb +11 -6
  27. data/app/views/antispam/blocks/index.html.erb +38 -38
  28. data/app/views/antispam/blocks/show.html.erb +24 -24
  29. data/app/views/antispam/challenges/_form.html.erb +32 -32
  30. data/app/views/antispam/challenges/edit.html.erb +6 -6
  31. data/app/views/antispam/challenges/index.html.erb +31 -31
  32. data/app/views/antispam/challenges/new.html.erb +5 -5
  33. data/app/views/antispam/challenges/show.html.erb +19 -19
  34. data/app/views/antispam/clears/index.html.erb +32 -32
  35. data/app/views/antispam/clears/show.html.erb +29 -29
  36. data/app/views/antispam/validate/index.html.erb +16 -14
  37. data/app/views/layouts/antispam/application.html.erb +25 -15
  38. data/config/routes.rb +7 -7
  39. data/db/migrate/20210130213708_create_antispam_ips.rb +12 -12
  40. data/db/migrate/20210130214835_create_antispam_challenges.rb +11 -11
  41. data/db/migrate/20210130234107_create_antispam_blocks.rb +12 -12
  42. data/db/migrate/20210130235537_create_antispam_clears.rb +13 -13
  43. data/db/migrate/20210131165122_add_threat_to_antispam_blocks.rb +5 -5
  44. data/lib/antispam/blacklists/httpbl.rb +49 -48
  45. data/lib/antispam/checker.rb +30 -19
  46. data/lib/antispam/engine.rb +5 -5
  47. data/lib/antispam/results.rb +18 -10
  48. data/lib/antispam/spamcheckers/defendium.rb +29 -28
  49. data/lib/antispam/tools.rb +59 -57
  50. data/lib/antispam/version.rb +3 -3
  51. data/lib/antispam.rb +21 -17
  52. data/lib/tasks/antispam_tasks.rake +4 -4
  53. metadata +6 -6
@@ -1,19 +1,19 @@
1
- <p id="notice"><%= notice %></p>
2
-
3
- <p>
4
- <strong>Question:</strong>
5
- <%= @challenge.question %>
6
- </p>
7
-
8
- <p>
9
- <strong>Answer:</strong>
10
- <%= @challenge.answer %>
11
- </p>
12
-
13
- <p>
14
- <strong>Code:</strong>
15
- <%= @challenge.code %>
16
- </p>
17
-
18
- <%= link_to 'Edit', edit_challenge_path(@challenge) %> |
19
- <%= link_to 'Back', challenges_path %>
1
+ <p id="notice"><%= notice %></p>
2
+
3
+ <p>
4
+ <strong>Question:</strong>
5
+ <%= @challenge.question %>
6
+ </p>
7
+
8
+ <p>
9
+ <strong>Answer:</strong>
10
+ <%= @challenge.answer %>
11
+ </p>
12
+
13
+ <p>
14
+ <strong>Code:</strong>
15
+ <%= @challenge.code %>
16
+ </p>
17
+
18
+ <%= link_to 'Edit', edit_challenge_path(@challenge) %> |
19
+ <%= link_to 'Back', challenges_path %>
@@ -1,32 +1,32 @@
1
- <p id="notice"><%= notice %></p>
2
-
3
- <h1>Clears</h1>
4
-
5
- <table>
6
- <thead>
7
- <tr>
8
- <th>Ip</th>
9
- <th>Result</th>
10
- <th>Answer</th>
11
- <th>Threat before</th>
12
- <th>Threat after</th>
13
- <th colspan="3"></th>
14
- </tr>
15
- </thead>
16
-
17
- <tbody>
18
- <% Antispam::Clear.all.order(created_at: :desc).limit(50).each do |clear| %>
19
- <tr>
20
- <td><%= clear.ip %></td>
21
- <td><%= clear.result %></td>
22
- <td><%= clear.answer %></td>
23
- <td><%= clear.threat_before %></td>
24
- <td><%= clear.threat_after %></td>
25
- <td><%= time_ago_in_words clear.created_at %> ago</td>
26
- <!-- <td><%#= link_to 'Show', clear %></td>-->
27
- <!-- <td><%#= link_to 'Edit', edit_clear_path(clear) %></td>-->
28
- <!-- <td><%#= link_to 'Destroy', clear, method: :delete, data: { confirm: 'Are you sure?' } %></td>-->
29
- </tr>
30
- <% end %>
31
- </tbody>
32
- </table>
1
+ <p id="notice"><%= notice %></p>
2
+
3
+ <h1>Clears</h1>
4
+
5
+ <table>
6
+ <thead>
7
+ <tr>
8
+ <th>Ip</th>
9
+ <th>Result</th>
10
+ <th>Answer</th>
11
+ <th>Threat before</th>
12
+ <th>Threat after</th>
13
+ <th colspan="3"></th>
14
+ </tr>
15
+ </thead>
16
+
17
+ <tbody>
18
+ <% Antispam::Clear.all.order(created_at: :desc).limit(50).each do |clear| %>
19
+ <tr>
20
+ <td><%= clear.ip %></td>
21
+ <td><%= clear.result %></td>
22
+ <td><%= clear.answer %></td>
23
+ <td><%= clear.threat_before %></td>
24
+ <td><%= clear.threat_after %></td>
25
+ <td><%= time_ago_in_words clear.created_at %> ago</td>
26
+ <!-- <td><%#= link_to 'Show', clear %></td>-->
27
+ <!-- <td><%#= link_to 'Edit', edit_clear_path(clear) %></td>-->
28
+ <!-- <td><%#= link_to 'Destroy', clear, method: :delete, data: { confirm: 'Are you sure?' } %></td>-->
29
+ </tr>
30
+ <% end %>
31
+ </tbody>
32
+ </table>
@@ -1,29 +1,29 @@
1
- <p id="notice"><%= notice %></p>
2
-
3
- <p>
4
- <strong>Ip:</strong>
5
- <%= @clear.ip %>
6
- </p>
7
-
8
- <p>
9
- <strong>Result:</strong>
10
- <%= @clear.result %>
11
- </p>
12
-
13
- <p>
14
- <strong>Answer:</strong>
15
- <%= @clear.answer %>
16
- </p>
17
-
18
- <p>
19
- <strong>Threat before:</strong>
20
- <%= @clear.threat_before %>
21
- </p>
22
-
23
- <p>
24
- <strong>Threat after:</strong>
25
- <%= @clear.threat_after %>
26
- </p>
27
-
28
- <%= link_to 'Edit', edit_clear_path(@clear) %> |
29
- <%= link_to 'Back', clears_path %>
1
+ <p id="notice"><%= notice %></p>
2
+
3
+ <p>
4
+ <strong>Ip:</strong>
5
+ <%= @clear.ip %>
6
+ </p>
7
+
8
+ <p>
9
+ <strong>Result:</strong>
10
+ <%= @clear.result %>
11
+ </p>
12
+
13
+ <p>
14
+ <strong>Answer:</strong>
15
+ <%= @clear.answer %>
16
+ </p>
17
+
18
+ <p>
19
+ <strong>Threat before:</strong>
20
+ <%= @clear.threat_before %>
21
+ </p>
22
+
23
+ <p>
24
+ <strong>Threat after:</strong>
25
+ <%= @clear.threat_after %>
26
+ </p>
27
+
28
+ <%= link_to 'Edit', edit_clear_path(@clear) %> |
29
+ <%= link_to 'Back', clears_path %>
@@ -1,14 +1,16 @@
1
- <% @challenge = Antispam::Challenge.create %>
2
- <h1>Human Challenge</h1>
3
-
4
- <p>Please prove that you are human.</p>
5
-
6
- <img src="/antispam/challenges/<%= @challenge.id %>.jpg" width="200" height="40">
7
-
8
- <%= form_for @challenge do |f| %>
9
- <%= f.hidden_field :id, value: @challenge.id %>
10
- <%= f.text_field :answer, value: '' %>
11
- <%= submit_tag "Submit" %>
12
- <% end %>
13
-
14
- <% flash.each do |type, msg| %><div><%= msg %></div><% end %>
1
+ <% @challenge = Antispam::Challenge.create %>
2
+ <div class="centerblock">
3
+ <h1>Human Challenge</h1>
4
+
5
+ <p>Please prove that you are human.</p>
6
+
7
+ <img src="/antispam/challenges/<%= @challenge.id %>.jpg" width="200" height="40">
8
+
9
+ <%= form_for @challenge do |f| %>
10
+ <%= f.hidden_field :id, value: @challenge.id %>
11
+ <%= f.text_field :answer, value: '' %>
12
+ <%= submit_tag "Submit" %>
13
+ <% end %>
14
+
15
+ <% flash.each do |type, msg| %><div><%= msg %></div><% end %>
16
+ </div>
@@ -1,15 +1,25 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>Antispam</title>
5
- <%= csrf_meta_tags %>
6
- <%= csp_meta_tag %>
7
- <style>.row { width:100%;display: flex;} .cx { width: 50%; }</style>
8
- <%#= stylesheet_link_tag "antispam/application", media: "all" %>
9
- </head>
10
- <body>
11
-
12
- <%= yield %>
13
-
14
- </body>
15
- </html>
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Antispam</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+ <style>
8
+ .row { width:100%;display: flex;}
9
+ .cx { width: 50%; }
10
+ .centerblock {
11
+ box-shadow: 1px 1px 7px #000;
12
+ max-width: 300px;
13
+ margin: 3em auto;
14
+ padding: 0.5em 2em 2em;
15
+ width: 100%;
16
+ }
17
+ </style>
18
+ <%#= stylesheet_link_tag "antispam/application", media: "all" %>
19
+ </head>
20
+ <body>
21
+
22
+ <%= yield %>
23
+
24
+ </body>
25
+ </html>
data/config/routes.rb CHANGED
@@ -1,7 +1,7 @@
1
- Antispam::Engine.routes.draw do
2
- resources :clears
3
- resources :blocks
4
- resources :challenges
5
- root to: 'ips#index'
6
- get 'validate', to: 'validate#index'
7
- end
1
+ Antispam::Engine.routes.draw do
2
+ resources :clears
3
+ resources :blocks
4
+ resources :challenges
5
+ root to: 'ips#index'
6
+ get 'validate', to: 'validate#index'
7
+ end
@@ -1,12 +1,12 @@
1
- class CreateAntispamIps < ActiveRecord::Migration[6.1]
2
- def change
3
- create_table :antispam_ips do |t|
4
- t.string :address
5
- t.string :provider
6
- t.integer :threat
7
- t.datetime :expires_at
8
-
9
- t.timestamps
10
- end
11
- end
12
- end
1
+ class CreateAntispamIps < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :antispam_ips do |t|
4
+ t.string :address
5
+ t.string :provider
6
+ t.integer :threat
7
+ t.datetime :expires_at
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -1,11 +1,11 @@
1
- class CreateAntispamChallenges < ActiveRecord::Migration[6.1]
2
- def change
3
- create_table :antispam_challenges do |t|
4
- t.string :question
5
- t.string :answer
6
- t.string :code
7
-
8
- t.timestamps
9
- end
10
- end
11
- end
1
+ class CreateAntispamChallenges < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :antispam_challenges do |t|
4
+ t.string :question
5
+ t.string :answer
6
+ t.string :code
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -1,12 +1,12 @@
1
- class CreateAntispamBlocks < ActiveRecord::Migration[6.1]
2
- def change
3
- create_table :antispam_blocks do |t|
4
- t.string :ip
5
- t.string :provider
6
- t.string :controllername
7
- t.string :actionname
8
-
9
- t.timestamps
10
- end
11
- end
12
- end
1
+ class CreateAntispamBlocks < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :antispam_blocks do |t|
4
+ t.string :ip
5
+ t.string :provider
6
+ t.string :controllername
7
+ t.string :actionname
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -1,13 +1,13 @@
1
- class CreateAntispamClears < ActiveRecord::Migration[6.1]
2
- def change
3
- create_table :antispam_clears do |t|
4
- t.string :ip
5
- t.string :result
6
- t.string :answer
7
- t.integer :threat_before
8
- t.integer :threat_after
9
-
10
- t.timestamps
11
- end
12
- end
13
- end
1
+ class CreateAntispamClears < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :antispam_clears do |t|
4
+ t.string :ip
5
+ t.string :result
6
+ t.string :answer
7
+ t.integer :threat_before
8
+ t.integer :threat_after
9
+
10
+ t.timestamps
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
- class AddThreatToAntispamBlocks < ActiveRecord::Migration[6.1]
2
- def change
3
- add_column :antispam_blocks, :threat, :integer
4
- end
5
- end
1
+ class AddThreatToAntispamBlocks < ActiveRecord::Migration[6.1]
2
+ def change
3
+ add_column :antispam_blocks, :threat, :integer
4
+ end
5
+ end
@@ -1,48 +1,49 @@
1
- require 'resolv'
2
- module Antispam
3
- module Blacklists
4
- class Httpbl
5
- def self.check(ip, key, verbose)
6
- threat = 0
7
- begin
8
- old_result = get_old_result(ip)
9
- if old_result
10
- Rails.logger.info "Returning old result for #{ip}." if verbose
11
- return get_old_result(ip)
12
- end
13
- check = ip.split('.').reverse.join('.')
14
- host = key + '.' + check + ".dnsbl.httpbl.org"
15
- address = Resolv::getaddress(host)
16
- z,days,threat,iptype = address.split('.')
17
- Rails.logger.info "Spam located: #{iptype} type at #{threat} threat. (#{ip} - #{address})" if verbose
18
- threat = threat.to_i
19
- # Create or update
20
- if (threat > 30)
21
- Rails.logger.info "Spamcheck: Very high, over 30!" if verbose
22
- end
23
- rescue Exception => e
24
- case e
25
- when Resolv::ResolvError #Not spam! This blacklist gives an error when there's no spam threat.
26
- Rails.logger.info "Spamcheck: OK! Resolve error means the httpbl does not consider this spam." if verbose
27
- when Interrupt #Something broke while trying to check blacklist.
28
- Rails.logger.info "Spamcheck: Interrupt when trying to resolve http blacklist. Possible timeout?" if verbose
29
- else # Time Out
30
- Rails.logger.info "Spamcheck: There was an error, possibly a time out, when checking this IP." if verbose
31
- Rails.logger.info e.to_s if verbose
32
- end
33
- end
34
- update_old_result(ip, threat)
35
- return threat
36
- end
37
- def self.get_old_result(ip)
38
- result = Antispam::Ip.find_by(address: ip, provider: 'httpbl')
39
- return nil if (result.nil? || result.expired?)
40
- return result.threat
41
- end
42
- def self.update_old_result(ip, threat)
43
- result = Antispam::Ip.find_or_create_by(address: ip, provider: 'httpbl')
44
- result.update(threat: threat, expires_at: 24.hours.from_now)
45
- end
46
- end
47
- end
48
- end
1
+ require 'resolv'
2
+ module Antispam
3
+ module Blacklists
4
+ class Httpbl
5
+ # Returns a threat-level number, or 0 if no threat / no result.
6
+ def self.check(ip, key, verbose)
7
+ threat = 0
8
+ begin
9
+ old_result = get_old_result(ip)
10
+ if old_result
11
+ Rails.logger.info "Returning old result for #{ip}." if verbose
12
+ return get_old_result(ip)
13
+ end
14
+ check = ip.split('.').reverse.join('.')
15
+ host = key + '.' + check + ".dnsbl.httpbl.org"
16
+ address = Resolv::getaddress(host)
17
+ z,days,threat,iptype = address.split('.')
18
+ Rails.logger.info "Spam located: #{iptype} type at #{threat} threat. (#{ip} - #{address})" if verbose
19
+ threat = threat.to_i
20
+ # Create or update
21
+ if (threat > 30)
22
+ Rails.logger.info "Spamcheck: Very high, over 30!" if verbose
23
+ end
24
+ rescue Exception => e
25
+ case e
26
+ when Resolv::ResolvError #Not spam! This blacklist gives an error when there's no spam threat.
27
+ Rails.logger.info "Spamcheck: OK! Resolve error means the httpbl does not consider this spam." if verbose
28
+ when Interrupt #Something broke while trying to check blacklist.
29
+ Rails.logger.info "Spamcheck: Interrupt when trying to resolve http blacklist. Possible timeout?" if verbose
30
+ else # Time Out
31
+ Rails.logger.info "Spamcheck: There was an error, possibly a time out, when checking this IP." if verbose
32
+ Rails.logger.info e.to_s if verbose
33
+ end
34
+ end
35
+ update_old_result(ip, threat)
36
+ return threat
37
+ end
38
+ def self.get_old_result(ip)
39
+ result = Antispam::Ip.find_by(address: ip, provider: 'httpbl')
40
+ return nil if (result.nil? || result.expired?)
41
+ return result.threat
42
+ end
43
+ def self.update_old_result(ip, threat)
44
+ result = Antispam::Ip.find_or_create_by(address: ip, provider: 'httpbl')
45
+ result.update(threat: threat, expires_at: 24.hours.from_now)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,20 +1,31 @@
1
- module Antispam
2
- module Checker
3
- # Checks content for spam
4
- # check(options, spamcheck_providers)
5
- # Usage: check({content: "No spam here"}, {defendium: 'MY_API_KEY'}})
6
- def self.check(options = {}, spamcheck_providers = {defendium: 'YOUR_KEY'})
7
- Rails.logger.info "Content was nil for spamcheck." if options[:content].nil? && options[:verbose]
8
- return if options[:content].nil?
9
- Rails.logger.info "Spamcheckers should be a hash" if (!(options[:spamcheck_providers].is_a? Hash)) && options[:verbose]
10
- results = []
11
- spamcheck_providers.each do |spamchecker_name, spamchecker_api_key|
12
- if spamchecker_name == :defendium
13
- results.append Antispam::Spamcheckers::Defendium.check(options[:content], spamchecker_api_key, options[:verbose])
14
- end
15
- end
16
- result = Antispam::SpamcheckResult.new(results)
17
- return result
18
- end
19
- end
1
+ module Antispam
2
+ module Checker
3
+ # Checks content for spam
4
+ # check(options)
5
+ # Usage: check({content: "No spam here", providers: { defendium: 'MY_API_KEY'}})
6
+ def self.check(options = {})
7
+ # Default provider. 'YOUR_KEY' works temporarily, giving a warning but also giving results
8
+ # eventually add something to tell users to add their own keys
9
+ # or choose their preferred provider, when more provider options are added.
10
+ options[:providers] ||= {defendium: 'YOUR_KEY'}
11
+ Rails.logger.info "Content was nil for spamcheck." if options[:content].nil? && options[:verbose]
12
+ return if options[:content].nil?
13
+ Rails.logger.info "Spamcheckers should be a hash" if (!(options[:providers].is_a? Hash)) && options[:verbose]
14
+ results = []
15
+ options[:providers].each do |spamchecker_name, spamchecker_api_key|
16
+ results.append spamchecker(spamchecker_name).check(options[:content], spamchecker_api_key, options[:verbose])
17
+ # if spamchecker_name == :defendium
18
+ # results.append Antispam::Spamcheckers::Defendium.check(options[:content], spamchecker_api_key, options[:verbose])
19
+ # end
20
+ end
21
+ result = Antispam::SpamcheckResult.new(results)
22
+ return result
23
+ end
24
+ def self.spamchecker(provider)
25
+ class_name = provider.to_s.camelize
26
+ raise Antispam::NoSuchSpamcheckerError unless Antispam::Spamcheckers.const_defined? class_name
27
+ Antispam::Spamcheckers.const_get class_name
28
+ end
29
+ end
30
+ class NoSuchSpamcheckerError < StandardError; end
20
31
  end
@@ -1,5 +1,5 @@
1
- module Antispam
2
- class Engine < ::Rails::Engine
3
- isolate_namespace Antispam
4
- end
5
- end
1
+ module Antispam
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Antispam
4
+ end
5
+ end
@@ -1,10 +1,18 @@
1
- module Antispam
2
- class SpamcheckResult
3
- def initialize(results)
4
- @results = results
5
- end
6
- def is_spam?
7
- @results.select{|x| x > 0}.present?
8
- end
9
- end
10
- end
1
+ module Antispam
2
+ class SpamcheckResult
3
+ def initialize(results)
4
+ @results = results
5
+ end
6
+ def is_spam?
7
+ @results.select{|x| x > 0}.present?
8
+ end
9
+ end
10
+ class BlacklistResult
11
+ def initialize(results)
12
+ @results = results
13
+ end
14
+ def is_bad?
15
+ @results.select{|x| x > 30}.present?
16
+ end
17
+ end
18
+ end
@@ -1,29 +1,30 @@
1
- #require 'resolv'
2
- module Antispam
3
- module Spamcheckers
4
- class Defendium
5
- def self.check(content, key, verbose)
6
- # nethttp2.rb
7
- require 'uri'
8
- require 'net/http'
9
-
10
- uri = URI('https://api.defendium.com/check')
11
- params = { secret_key: key, content: content }
12
- uri.query = URI.encode_www_form(params)
13
-
14
- res = Net::HTTP.get_response(uri)
15
- if res.is_a?(Net::HTTPSuccess)
16
- result = res.body.to_json
17
- if result["warnings"]
18
- Rails.logger.info result["warnings"]
19
- end
20
- if result["result"]
21
- return 1
22
- else
23
- return 0
24
- end
25
- end
26
- end
27
- end
28
- end
1
+ #require 'resolv'
2
+ module Antispam
3
+ module Spamcheckers
4
+ class Defendium
5
+ # Returns a boolean, 1 for spam, 0 for not spam.
6
+ def self.check(content, key, verbose)
7
+ # nethttp2.rb
8
+ require 'uri'
9
+ require 'net/http'
10
+
11
+ uri = URI('https://api.defendium.com/check')
12
+ params = { secret_key: key, content: content }
13
+ uri.query = URI.encode_www_form(params)
14
+
15
+ res = Net::HTTP.get_response(uri)
16
+ if res.is_a?(Net::HTTPSuccess)
17
+ result = res.body.to_json
18
+ if result["warnings"]
19
+ Rails.logger.info result["warnings"]
20
+ end
21
+ if result["result"]
22
+ return 1
23
+ else
24
+ return 0
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
29
30
  end