schleuder 2.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. data.tar.gz.sig +0 -0
  2. data/LICENSE +339 -0
  3. data/README +32 -0
  4. data/bin/schleuder +96 -0
  5. data/bin/schleuder-fix-gem-dependencies +30 -0
  6. data/bin/schleuder-init-setup +37 -0
  7. data/bin/schleuder-migrate-v2.1-to-v2.2 +205 -0
  8. data/bin/schleuder-newlist +384 -0
  9. data/contrib/check-expired-keys.rb +59 -0
  10. data/contrib/mutt-schleuder-colors.rc +10 -0
  11. data/contrib/mutt-schleuder-resend.vim +24 -0
  12. data/contrib/smtpserver.rb +76 -0
  13. data/ext/default-list.conf +146 -0
  14. data/ext/default-members.conf +7 -0
  15. data/ext/list.conf.example +14 -0
  16. data/ext/schleuder.conf +62 -0
  17. data/lib/schleuder.rb +49 -0
  18. data/lib/schleuder/archiver.rb +46 -0
  19. data/lib/schleuder/crypt.rb +188 -0
  20. data/lib/schleuder/errors.rb +5 -0
  21. data/lib/schleuder/list.rb +177 -0
  22. data/lib/schleuder/list_config.rb +146 -0
  23. data/lib/schleuder/log/listlogger.rb +56 -0
  24. data/lib/schleuder/log/outputter/emailoutputter.rb +118 -0
  25. data/lib/schleuder/log/outputter/metaemailoutputter.rb +50 -0
  26. data/lib/schleuder/log/schleuderlogger.rb +23 -0
  27. data/lib/schleuder/mail.rb +861 -0
  28. data/lib/schleuder/mailer.rb +26 -0
  29. data/lib/schleuder/member.rb +69 -0
  30. data/lib/schleuder/plugin.rb +54 -0
  31. data/lib/schleuder/processor.rb +363 -0
  32. data/lib/schleuder/schleuder_config.rb +72 -0
  33. data/lib/schleuder/storage.rb +84 -0
  34. data/lib/schleuder/utils.rb +80 -0
  35. data/lib/schleuder/version.rb +3 -0
  36. data/man/schleuder-newlist.8 +191 -0
  37. data/man/schleuder.8 +400 -0
  38. data/plugins/README +20 -0
  39. data/plugins/manage_keys_plugin.rb +113 -0
  40. data/plugins/manage_members_plugin.rb +152 -0
  41. data/plugins/manage_self_plugin.rb +26 -0
  42. data/plugins/resend_plugin.rb +35 -0
  43. data/plugins/version_plugin.rb +12 -0
  44. metadata +178 -0
  45. metadata.gz.sig +2 -0
@@ -0,0 +1,46 @@
1
+ module Schleuder
2
+ class Archiver
3
+ def archive(mail)
4
+ Schleuder.log.info "Archiving email"
5
+ mail2archive = mail.individualize_member(_receiver)
6
+
7
+ # TODO: wrap that duplicated code out into it's dedicated method
8
+ begin
9
+ encrypted, errmsg = mail2archive.encrypt!(_receiver)
10
+ rescue GPGME::Error::UnusablePublicKey => e
11
+ # This exception is thrown, if the public key of a certain list
12
+ # member is not usable (because it is revoked, expired, disabled or
13
+ # invalid).
14
+ k = e.keys.first
15
+ key = mail2archive.crypt.get_key(k.fpr).first
16
+ errmsg = "#{e.message}: (#{k.class})\n#{key.to_s}"
17
+ encrypted = false
18
+ rescue GPGME::Error::General => e
19
+ errmsg = e.message
20
+ encrypted = false
21
+ end
22
+
23
+ if encrypted
24
+ _dump(mail2archive)
25
+ else
26
+ Schleuder.log.error("Could not encrypt message with list's key to archive it. Skipping archiving of message...\n\nError Message: #{errmsg}")
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def _dump(mail)
33
+ now = Time.now
34
+ dump_dir = File.join(Schleuder.list.listdir,'archive',[:year,:month,:day].collect{|m| now.send(m).to_s })
35
+ require 'fileutils'
36
+ FileUtils.mkdir_p dump_dir unless File.directory? dump_dir
37
+ msg_file = File.join(dump_dir,"#{Time.now.strftime('%H%M%S')}-#{mail.message_id[1..-2]}")
38
+ Schleuder.log.info("Archiving message to #{msg_file}")
39
+ File.open(msg_file,"w") { |f| f << mail.to_s }
40
+ end
41
+
42
+ def _receiver
43
+ @receiver ||= Member.new('email' => Schleuder.list.config.myaddr)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,188 @@
1
+ module Schleuder
2
+ # Wrapper for ruby-gpgme. Method naming is not strictly logical, this might
3
+ # change but aliases will be set up then.
4
+ class Crypt
5
+ # Instantiates and stores password
6
+ def initialize(password)
7
+ # Check file permissions. ruby-gpgme unfortunately returns unspecific
8
+ # errors if it can't read files.
9
+ %w(pubring.gpg secring.gpg trustdb.gpg).each do |fn|
10
+ f = File.join(ENV['GNUPGHOME'], fn)
11
+ if ! File.readable?(f)
12
+ raise Errno::EACCES.new('%s is not readable' % f)
13
+ elsif ! File.writable?(f)
14
+ raise Errno::EACCES.new('%s is not writable' % f)
15
+ end
16
+ end
17
+
18
+ @password = password
19
+ if GPGME.respond_to? 'check_version'
20
+ GPGME::check_version('0.0.0')
21
+ end
22
+ @ctx = GPGME::Ctx.new
23
+ # feed the passphrase into the Context
24
+ @ctx.set_passphrase_cb(method(:passfunc))
25
+ end
26
+
27
+ # Verify a gpg-signature. Use +signed_string+ if the signature is
28
+ # detached. Returns a GPGME::SignatureResult
29
+ def verify(sig, signed_string='')
30
+ in_signed = ''
31
+ if signed_string.empty?
32
+ # verify +sig+ as cleartext (aka pgp/inline) signature
33
+ Schleuder.log.debug 'No extra signed_string, verifying cleartext signature'
34
+ output = GPGME.verify(sig) do |sig|
35
+ in_signed = sig
36
+ end
37
+ else
38
+ # verify detached signature
39
+ Schleuder.log.debug 'Verifying detached signature'
40
+ # Don't know why we need a GPGME::Data object this time but without gpgme throws exceptions
41
+ plain = GPGME::Data.new
42
+ GPGME.verify(sig, signed_string, plain) do |sig|
43
+ in_signed = sig
44
+ end
45
+ output = signed_string
46
+
47
+ end
48
+ Schleuder.log.debug 'verify_result: ' + in_signed.inspect
49
+
50
+ [output, in_signed]
51
+ end
52
+
53
+ # Decrypt a string.
54
+ def decrypt(str)
55
+ output = ""
56
+ in_encrypted = nil
57
+ in_signed = nil
58
+
59
+ # TODO: return ciphertext if missing key. Sensible e.g. if it is part
60
+ # of a nested MIME-message and encrypted to someone else on purpose.
61
+ # Breaking if even the whole message is not decryptable is a job for
62
+ # the processor.
63
+
64
+ # return input instead of empty String if not encrypted.
65
+ # String#content_type is provided by filemagic/ext.
66
+ unless str =~ /^-----BEGIN PGP MESSAGE-----/ || str.content_type.split('/').last.eql?('pgp')
67
+ output, in_signed = verify(str)
68
+ # match pgp-mime- and inline-pgp-signatures
69
+ if str =~ /^-----BEGIN PGP SIG/
70
+ Schleuder.log.debug 'found signed, not encrypted message, verifying'
71
+ output, in_signed = verify(str)
72
+ else
73
+ Schleuder.log.debug 'found not signed, not encrypted message, returning input'
74
+ output = str
75
+ end
76
+ else
77
+ Schleuder.log.debug 'found pgp content, decrypting and verifying with gpgme'
78
+ in_encrypted = true
79
+ output = GPGME.decrypt(str, :passphrase_callback => method(:passfunc)) do |sig|
80
+ in_signed = sig
81
+ end
82
+ if output.empty?
83
+ Exception.new("Output from GPGME.decrypt was empty!")
84
+ end
85
+ # TODO: return mailadresses or keys instead of signature-objects?
86
+ end
87
+ [output, in_encrypted, in_signed]
88
+ end
89
+
90
+ # Encrypt a string to a single receiver and sign it. +receiver+ must be a
91
+ # Schleuder::Member
92
+ def encrypt_str(str, receiver)
93
+ # encrypt and sign and return encrypted data as string
94
+ # For some reason sometimes the last two characters of str are stolen
95
+ # unless we append a blank or newline... Life is hard...
96
+ GPGME.encrypt([receiver.key],
97
+ "#{str} ",
98
+ {:passphrase_callback => method(:passfunc),
99
+ :armor => true,
100
+ :sign => true,
101
+ :always_trust => true
102
+ })
103
+ end
104
+
105
+ # Lists all public keys matching +pattern+. Returns an array of
106
+ # GPGME::GpgKey's
107
+ def list_keys(pattern='')
108
+ GPGME.list_keys(pattern)
109
+ end
110
+
111
+ # Returns the GPGME::GpgKey matching +pattern+. Log an error if more than
112
+ # one matches, because duplicated user-ids is a sensitive issue.
113
+ def get_key(pattern, only_valid_keys=false)
114
+ pattern = "<#{pattern}>" if pattern =~ /.*@.*/ && !(pattern =~ /^<.*>$/)
115
+ keys = list_keys(pattern)
116
+
117
+ keys.reject! { |key| [:revoked,:expired].include?(key.trust) } if only_valid_keys
118
+
119
+ if keys.empty?
120
+ [false, "no key found for #{pattern}."]
121
+ elsif keys.length > 1
122
+ Schleuder.log.warn "There's more than one key matching the pattern you gave me!"
123
+ Schleuder.log.debug { "Pattern: #{pattern.inspect}" }
124
+ Schleuder.log.debug { "Keys: #{keys.inspect}" }
125
+ [false, "no distinct key for #{pattern.inspect} found. Matching keys: #{key_infos(keys,only_valid_keys).join(', ')}"]
126
+ else
127
+ [keys.first]
128
+ end
129
+ end
130
+
131
+ # Signs +string+ with the private key of the list (aka detached signature)
132
+ def sign(string)
133
+ GPGME::detach_sign(string, {:armor => true, :passphrase_callback => method(:passfunc)})
134
+ end
135
+
136
+ # Clearsigns +string+ with the private key of the list
137
+ def clearsign(string)
138
+ GPGME::clearsign(string, {:armor => true, :passphrase_callback => method(:passfunc)})
139
+ end
140
+
141
+ # Exports the public key matching +keyid+ as ascii key block.
142
+ def export(keyid)
143
+ GPGME.export(keyid, :armor=>:true)
144
+ end
145
+
146
+ # Delete the public key matching +pattern+ from the public key ring of the
147
+ # list
148
+ def delete_key(key)
149
+ msg = nil
150
+ key, msg = get_key(key) if key.kind_of?(String)
151
+
152
+ if key
153
+ @ctx.delete_key(key)
154
+ return true
155
+ else
156
+ msg
157
+ end
158
+ rescue => e
159
+ return e
160
+ end
161
+
162
+ # Import +keydata+ into public key ring of the list
163
+ def add_key(keydata)
164
+ GPGME.import(keydata)
165
+ end
166
+
167
+ def add_key_from_file(keyfile)
168
+ add_key(File.read(keyfile))
169
+ end
170
+
171
+ def key_descr(key)
172
+ key.to_s.split("\n").first.split[1..2].join(' ')
173
+ end
174
+
175
+ private
176
+
177
+ def key_infos(keys, only_valid_keys=false)
178
+ keys.collect { |key| key_descr(key) + (!only_valid_keys && [:revoked, :expired].include?(key.trust)) ? " *#{key.trust}*" : '' }
179
+ end
180
+
181
+ def passfunc(hook, uid_hint, passphrase_info, prev_was_bad, fd)
182
+ io = IO.for_fd(fd, 'w')
183
+ io.puts @password
184
+ io.flush
185
+ end
186
+
187
+ end
188
+ end
@@ -0,0 +1,5 @@
1
+
2
+ class SchleuderError < RuntimeError
3
+ end
4
+ class NewListError < SchleuderError
5
+ end
@@ -0,0 +1,177 @@
1
+ module Schleuder
2
+ # Represents a single list: name, config, members.
3
+ class List
4
+ # Name of this list. Must match the name of the subdirectory in
5
+ # +SchleuderConfig::lists_dir+
6
+ attr_reader :listname
7
+
8
+ # Prepare some variables, set up the SchleuderLogger and set GNUPGHOME
9
+ def initialize(listname,newlist=false)
10
+ @listname = listname
11
+ @config = _load_config(false) if newlist
12
+ @log = ListLogger.new listname, listdir, config
13
+
14
+ # setting GNUPGHOME to list's home, to make use of the keys there
15
+ Schleuder.log.debug "setting ENV[GNUPGHOME] to #{listdir}"
16
+ ENV["GNUPGHOME"] = listdir
17
+ @members = nil
18
+ end
19
+
20
+ # Provides an array of Schleuder::Member's, read from +members.conf+
21
+ def members
22
+ unless @members
23
+ Schleuder.log.debug("reading #{members_file}")
24
+ @members = YAML::load_file(members_file).collect do |h|
25
+ h.kind_of?(Schleuder::Member) ? h : Schleuder::Member.new(h,false)
26
+ end
27
+ end
28
+ @members
29
+ end
30
+
31
+ def members_file
32
+ @members_file ||= File.join(listdir, Schleuder.config.lists_memberfile)
33
+ end
34
+
35
+ # Saves an array of Schleuder::Member's into +members.conf++
36
+ def members=(arr)
37
+ Schleuder.log.debug 'writing members'
38
+ Schleuder.log.info("writing #{members_file}")
39
+ @members = arr.collect { |m| m.kind_of?(Hash) ? Member.new(m,false) : m }
40
+ _write(YAML.dump(@members.collect { |m| m.to_hash }), members_file)
41
+ @members
42
+ end
43
+
44
+ # Finds a member by email address.
45
+ def find_member_by_email(addresses)
46
+ addresses = Array(addresses)
47
+ members.detect { |m| addresses.include?(m.email) } || false
48
+ end
49
+
50
+ def find_admin_by_email(addresses)
51
+ addresses = Array(addresses)
52
+ if admin_email = self.config.admins.detect { |a| addresses.include?(a.email) }
53
+ Member.new(:email => admin_email)
54
+ else
55
+ false
56
+ end
57
+ end
58
+
59
+ def find_member_by_key(key)
60
+ Schleuder.log.debug "Looking for member for key #{key}"
61
+ find_by_key(members, key)
62
+ end
63
+
64
+ def find_admin_by_key(key)
65
+ Schleuder.log.debug "Looking for admin for key #{key}"
66
+ find_by_key(config.admins, key)
67
+ end
68
+
69
+ def find_admin_by_address(address)
70
+ config.admins.detect { |admin| admin.email == address }
71
+ end
72
+
73
+ def find_member_by_address(address)
74
+ members.detect { |member| member.email == address }
75
+ end
76
+
77
+ # Provides the list config as Schleuder::ListConfig-object
78
+ def config
79
+ @config ||= _load_config
80
+ end
81
+
82
+ # Saves +data+ into the list-config-file (default: list.conf). +data+ must
83
+ # be a Schleuder::ListConfig or valid input to +ListConfig.new+
84
+ def config=(data)
85
+ Schleuder.log.info("writing list-config for: #{listname}")
86
+ if data.is_a?(ListConfig)
87
+ @config = data
88
+ else
89
+ @config = ListConfig.new(data)
90
+ end
91
+ _write(YAML::dump(@config.to_hash), File.join(listdir, Schleuder.config.lists_configfile))
92
+ @config
93
+ end
94
+
95
+ # Builds the bounce-address for the list
96
+ def bounce_addr
97
+ @bounce_addr ||= self.config.myaddr.gsub(/^(.*)@(.*)$/, '\1-bounce@\2')
98
+ end
99
+
100
+ # Builds the owner-address for the list
101
+ def owner_addr
102
+ @owner_addr ||= self.config.myaddr.gsub(/^(.*)@(.*)$/, '\1-owner@\2')
103
+ end
104
+
105
+ # Builds the request-address for the list
106
+ def request_addr
107
+ @request_addr ||= self.config.myaddr.gsub(/^(.*)@(.*)$/, '\1-request@\2')
108
+ end
109
+
110
+ # builds the send-key-command-address for the list
111
+ def sendkey_addr
112
+ self.config.myaddr.gsub(/^(.*)@(.*)$/, '\1-sendkey@\2')
113
+ end
114
+
115
+ def self.listdir(listname)
116
+ name = listname.split('@')
117
+ if name.size < 2
118
+ Schleuder.log.warn 'Listname should be a full email-address, using only the local part is deprecated. Please fix this!'
119
+ end
120
+ File.expand_path(File.join([Schleuder.config.lists_dir, name.reverse].flatten))
121
+ end
122
+
123
+ def listdir
124
+ @listdir ||= List.listdir(@listname)
125
+ end
126
+
127
+ def listid
128
+ @listid ||= config.myaddr.gsub(/@/, '.')
129
+ end
130
+
131
+ def key
132
+ @key ||= lookup_list_key
133
+ end
134
+
135
+ def key_fingerprint
136
+ key.subkeys.first.fingerprint
137
+ end
138
+
139
+ def archive(mail)
140
+ @list_archiver ||= Schleuder::Archiver.new
141
+ @list_archiver.archive(mail)
142
+ end
143
+
144
+ private
145
+
146
+ # Loads the configuration
147
+ # fromfile = Whether to load the config from file.
148
+ def _load_config(fromfile=true)
149
+ Schleuder.log.debug("reading list-config for: #{@listname}") unless Schleuder.log.nil?
150
+ @config = ListConfig.new(File.join(listdir, Schleuder.config.lists_configfile),fromfile)
151
+ end
152
+
153
+ def find_by_key(ary, key)
154
+ return false unless key.kind_of?(GPGME::Key)
155
+ res = ary.detect { |elem| elem.kind_of?(Member) && elem.uses_key?(key) }
156
+ Schleuder.log.debug "Found #{res} for #{key}" unless res.nil?
157
+ res || false
158
+ end
159
+
160
+ def _write(data,filename)
161
+ File.open(filename, 'w') { |f| f << data }
162
+ end
163
+
164
+ def lookup_list_key
165
+ key, msg = crypt.get_key(config.key_fingerprint||config.myaddr)
166
+ unless key
167
+ raise "Could not find a key for this List! Reason: #{msg}"
168
+ end
169
+ key
170
+ end
171
+
172
+ def crypt
173
+ @@crypt ||= Crypt.new(nil)
174
+ end
175
+
176
+ end
177
+ end
@@ -0,0 +1,146 @@
1
+ # the list config class - a simple container
2
+
3
+ module Schleuder
4
+ class ListConfig < Storage
5
+
6
+ # Options and their defaults
7
+ # If you want to change the defaults, edit conf/default-list.conf
8
+
9
+ # Emailaddress of the list
10
+ schleuder_attr :myaddr, ''
11
+
12
+ # Realname of this list address (mainly used for gpg key)
13
+ schleuder_attr :myname, ''
14
+
15
+ # Listadmin's emailaddress(es). Must be an array.
16
+ schleuder_attr :admins, []
17
+
18
+ # Default mime setting
19
+ schleuder_attr :default_mime, 'MIME'
20
+
21
+ # The gpg password
22
+ schleuder_attr :gpg_password, nil
23
+
24
+ # The fingerprint of the key used for this list
25
+ schleuder_attr :key_fingerprint, nil
26
+
27
+ # Wether sending emails in the clear is allowed or not.
28
+ schleuder_attr :send_encrypted_only, false
29
+
30
+ # Wether to accept only incoming emails that are encrypted
31
+ schleuder_attr :receive_encrypted_only, false
32
+
33
+ # Wether to accept only emails that are validly signed
34
+ schleuder_attr :receive_signed_only, false
35
+
36
+ # Wether to accept only emails that are validly signed by a list-member's key
37
+ schleuder_attr :receive_authenticated_only, false
38
+
39
+ # Wether to accept only emails that are validly signed by a list-admin's key
40
+ schleuder_attr :receive_admin_only, false
41
+
42
+ # Whether to accept only emails that are sent from a members address.
43
+ # NOTE: better rely on :receive_authenticated_only and ignore that option.
44
+ schleuder_attr :receive_from_member_emailaddresses_only, false
45
+
46
+ # Wether to keep the msgid or not
47
+ schleuder_attr :keep_msgid, true
48
+
49
+ # Footer for outgoing mails
50
+ schleuder_attr :public_footer, ''
51
+
52
+ # Subject prefix for incoming (signed) mails from listmembers
53
+ schleuder_attr :prefix, ''
54
+
55
+ # Subject prefix for incoming mails
56
+ schleuder_attr :prefix_in, ''
57
+
58
+ # Subject prefix for outgoing mails
59
+ schleuder_attr :prefix_out, ''
60
+
61
+ # The log_level (ERROR || WARN || INFO || DEBUG)
62
+ schleuder_attr :log_level, 'ERROR'
63
+
64
+ # Log to SYSLOG?
65
+ schleuder_attr :log_syslog, false
66
+
67
+ # Log to IO (writing into STDIN of another process/executable)
68
+ schleuder_attr :log_io, false
69
+
70
+ # Log to a file? If the path doesn't start with a slash the list-dir will
71
+ # be prefixed.
72
+ schleuder_attr :log_file, 'list.log'
73
+
74
+ # Which headers from original mail to include into the internal meta data
75
+ schleuder_attr :headers_to_meta, [:from, :to, :cc, :date]
76
+
77
+ # Restrict specific plugins to admin
78
+ schleuder_attr :keywords_admin_only, ['SAVE-MEMBERS', 'DEL-KEY']
79
+
80
+ # Notify admin if these keywords triggered commands.
81
+ schleuder_attr :keywords_admin_notify, [ 'X-ADD-KEY' ]
82
+
83
+ # Drop any bounces (incoming email not passing the receive_*_only-rules)
84
+ schleuder_attr :bounces_drop_all, false
85
+
86
+ # Drop bounces if they match one of these headers. Must be a hash, keys and values are case insensitive.
87
+ schleuder_attr :bounces_drop_on_headers, {'x-spam-flag' => 'yes'}
88
+
89
+ # Send a notice to admin(s) on bouncing or dropping
90
+ schleuder_attr :bounces_notify_admin, true
91
+
92
+ # Include RFC-compliant List-* Headers into member mails
93
+ schleuder_attr :include_list_headers, true
94
+
95
+ # Include OpenPGP-Header
96
+ schleuder_attr :include_openpgp_header, true
97
+ # Preferred way to receive emails to note in OpenPGP-Header ('sign'|'encrypt'|'signencrypt'|'unprotected'|'none')
98
+ # 'none' to not include a preference
99
+ # default: 'signencrypt'
100
+ schleuder_attr :openpgp_header_preference, 'signencrypt'
101
+
102
+ # If we want to dump the original incoming mail.
103
+ # ATTENTION: this stores the incoming e-mail on disk!
104
+ schleuder_attr :dump_incoming_mail, false
105
+
106
+ # Maximum size of message allowed on the list in kilobyte. All others will be bounced.
107
+ schleuder_attr :max_message_size, 10240 # 10MB
108
+
109
+ # Whether to archive messages sent to list members or not.
110
+ # default: false
111
+ schleuder_attr :archive, false
112
+
113
+ ### END OF CONFIG OPTIONS
114
+
115
+ def initialize(config_file, fromfile=true)
116
+ # First Overload with default-list.conf then load our config
117
+ overload_from_file!(Schleuder.config.lists_default_conf)
118
+ # overload with config_file
119
+ super(config_file, fromfile)
120
+
121
+ # load admins as members
122
+ self.admins = self.admins
123
+ # compress fingerprint
124
+ self.key_fingerprint = self.key_fingerprint
125
+ end
126
+
127
+ def key_fingerprint=(fpr)
128
+ schleuder_attributes['key_fingerprint'] = Schleuder::Utils.compress_fingerprint(fpr)
129
+ end
130
+
131
+ def admins=(ary)
132
+ schleuder_attributes['admins'] = Array(ary).collect { |mem|
133
+ if mem.kind_of?(Member)
134
+ mem
135
+ else
136
+ if mem.kind_of?(Hash) && mem.has_key?("email")
137
+ Member.new(mem)
138
+ else
139
+ Schleuder.log.error "Wrong input: #{mem.inspect} is not suitable data for a Member."
140
+ nil
141
+ end
142
+ end
143
+ }.compact
144
+ end
145
+ end
146
+ end