list.rb 10.6 KB
Newer Older
paz's avatar
init  
paz committed
1 2 3 4
module Schleuder
  class List < ActiveRecord::Base

    has_many :subscriptions, dependent: :destroy
5
    before_destroy :delete_listdirs
paz's avatar
init  
paz committed
6 7 8 9 10 11

    serialize :headers_to_meta, JSON
    serialize :bounces_drop_on_headers, JSON
    serialize :keywords_admin_only, JSON
    serialize :keywords_admin_notify, JSON

Nina's avatar
Nina committed
12
    validates :email, presence: true, uniqueness: true, email: true
Nina's avatar
Nina committed
13
    validates :fingerprint, presence: true, fingerprint: true
Nina's avatar
Nina committed
14
    validates :send_encrypted_only,
paz's avatar
paz committed
15 16 17 18 19 20 21
        :receive_encrypted_only,
        :receive_signed_only,
        :receive_authenticated_only,
        :receive_from_subscribed_emailaddresses_only,
        :receive_admin_only,
        :keep_msgid,
        :bounces_drop_all,
22
        :deliver_selfsent,
paz's avatar
paz committed
23 24
        :bounces_notify_admins,
        :include_list_headers,
paz's avatar
paz committed
25
        :include_openpgp_header,
Nina's avatar
Nina committed
26
        :forward_all_incoming_to_admins, boolean: true
paz's avatar
paz committed
27 28
    validates_each :headers_to_meta,
        :keywords_admin_only,
paz's avatar
paz committed
29
        :keywords_admin_notify do |record, attrib, value|
paz's avatar
paz committed
30 31
          value.each do |word|
            if word !~ /\A[a-z_-]+\z/i
32
              record.errors.add(attrib, I18n.t("errors.invalid_characters"))
paz's avatar
paz committed
33 34 35
            end
          end
        end
paz's avatar
paz committed
36 37 38
    validates_each :bounces_drop_on_headers do |record, attrib, value|
          value.each do |key, val|
            if key.to_s !~ /\A[a-z-]+\z/i || val.to_s !~ /\A[[:graph:]]+\z/i
39
              record.errors.add(attrib, I18n.t("errors.invalid_characters"))
paz's avatar
paz committed
40 41 42
            end
          end
        end
Nina's avatar
Nina committed
43
    validates :subject_prefix,
paz's avatar
paz committed
44
        :subject_prefix_in,
Nina's avatar
Nina committed
45 46
        :subject_prefix_out,
        no_line_breaks: true
paz's avatar
paz committed
47 48 49 50 51
    validates :openpgp_header_preference,
                presence: true,
                inclusion: {
                  in: %w(sign encrypt signencrypt unprotected none),
                }
Nina's avatar
Nina committed
52
    validates :max_message_size_kb, :logfiles_to_keep, greater_than_zero: true
paz's avatar
paz committed
53 54 55 56 57 58 59 60 61 62 63
    validates :log_level,
              presence: true,
              inclusion: {
                in: %w(debug info warn error),
              }
    validates :language,
              presence: true,
              inclusion: {
                # TODO: find out why we break translations and available_locales if we use I18n.available_locales here.
                in: %w(de en),
              }
paz's avatar
paz committed
64
    validates :public_footer, :internal_footer,
paz's avatar
paz committed
65
              allow_blank: true,
66
              format: {
67
                with: /\A[[:graph:]\s]*\z/i,
paz's avatar
paz committed
68
              }
paz's avatar
init  
paz committed
69

paz's avatar
paz committed
70 71
    default_scope { order(:email) }

72 73 74 75 76 77 78
    def self.configurable_attributes
      @configurable_attributes ||= begin
        all = self.validators.map(&:attributes).flatten.uniq.compact.sort
        all - [:email, :fingerprint]
      end
    end

paz's avatar
paz committed
79
    def logfile
paz's avatar
paz committed
80
      @logfile ||= File.join(Conf.listlogs_dir, self.email.split('@').reverse, 'list.log')
paz's avatar
paz committed
81 82
    end

paz's avatar
paz committed
83
    def logger
paz's avatar
paz committed
84
      @logger ||= Listlogger.new(self)
paz's avatar
paz committed
85 86
    end

paz's avatar
init  
paz committed
87 88 89 90 91 92 93 94
    def to_s
      email
    end

    def admins
      subscriptions.where(admin: true)
    end

ng's avatar
ng committed
95 96 97 98
    def subscriptions_without_fingerprint
      subscriptions.without_fingerprint
    end

99
    def key(fingerprint=self.fingerprint)
paz's avatar
init  
paz committed
100 101 102
      keys(fingerprint).first
    end

103
    def secret_key
paz's avatar
paz committed
104
      keys(self.fingerprint, true).first
105 106
    end

paz's avatar
paz committed
107 108
    def keys(identifier=nil, secret_only=nil)
      gpg.find_keys(identifier, secret_only)
paz's avatar
paz committed
109 110
    end

paz's avatar
paz committed
111 112
    # TODO: find better name for this method. It does more than the current
    # name suggests (filtering for capability).
113 114 115 116 117 118 119 120 121
    def distinct_key(identifier)
      keys = keys(identifier).select { |key| key.usable_for?(:encrypt) }
      if keys.size == 1
        return keys.first
      else
        return nil
      end
    end

paz's avatar
init  
paz committed
122
    def import_key(importable)
paz's avatar
paz committed
123
      gpg.keyimport(importable)
124 125
    end

126 127
    def import_key_and_find_fingerprint(key_material)
      return nil if key_material.blank?
128

129
      import_result = import_key(key_material)
130
      gpg.interpret_import_result(import_result)
131 132
    end

133
    def delete_key(fingerprint)
paz's avatar
paz committed
134
      if key = keys(fingerprint).first
135 136 137 138 139
        key.delete!
        true
      else
        false
      end
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
    end

    def export_key(fingerprint=self.fingerprint)
      key = keys(fingerprint).first
      if key.blank?
        return false
      end
      key.armored
    end

    def check_keys
      now = Time.now
      checkdate = now + (60 * 60 * 24 * 14) # two weeks
      unusable = []
      expiring = []

      keys.each do |key|
        expiry = key.subkeys.first.expires
        if expiry && expiry > now && expiry < checkdate
          # key expires in the near future
Nina's avatar
Nina committed
160
          expdays = ((expiry - now)/86400).to_i
161 162 163
          expiring << [key, expdays]
        end

paz's avatar
paz committed
164 165
        if ! key.usable?
          unusable << [key, key.usability_issue]
166 167 168 169 170 171 172
        end
      end

      text = ''
      expiring.each do |key,days|
        text << I18n.t('key_expires', {
                          days: days,
173
                          key_oneline: key.oneline
174
                      })
175
        text << "\n"
176 177
      end

paz's avatar
paz committed
178
      unusable.each do |key,usability_issue|
179
        text << I18n.t('key_unusable', {
paz's avatar
paz committed
180
                          usability_issue: usability_issue,
181
                          key_oneline: key.oneline
182
                      })
183
        text << "\n"
184 185
      end
      text
paz's avatar
init  
paz committed
186 187
    end

paz's avatar
paz committed
188
    def refresh_keys
189
      gpg.refresh_keys(self.keys)
paz's avatar
paz committed
190 191
    end

paz's avatar
paz committed
192 193 194 195
    def fetch_keys(input)
      gpg.fetch_key(input)
    end

ng's avatar
ng committed
196 197 198 199 200 201 202 203 204 205 206 207 208
    def pin_keys
      updated_emails = subscriptions_without_fingerprint.collect do |subscription|
        key = distinct_key(subscription.email)
        if key
          subscription.update(fingerprint: key.fingerprint)
          "#{subscription.email}: #{key.fingerprint}"
        else
          nil
        end
      end
      updated_emails.compact.join("\n")
    end

paz's avatar
init  
paz committed
209
    def self.by_recipient(recipient)
paz's avatar
paz committed
210
      listname = recipient.gsub(/-(sendkey|request|owner|bounce)@/, '@')
paz's avatar
init  
paz committed
211 212 213
      where(email: listname).first
    end

paz's avatar
paz committed
214 215 216 217
    def sendkey_address
      @sendkey_address ||= email.gsub('@', '-sendkey@')
    end

218 219 220 221
    def request_address
      @request_address ||= email.gsub('@', '-request@')
    end

paz's avatar
paz committed
222 223 224 225
    def owner_address
      @owner_address ||= email.gsub('@', '-owner@')
    end

paz's avatar
paz committed
226 227 228 229
    def bounce_address
      @bounce_address ||= email.gsub('@', '-bounce@')
    end

paz's avatar
init  
paz committed
230 231
    def gpg
      @gpg_ctx ||= begin
paz's avatar
paz committed
232
        # TODO: figure out why set it again...
233
        # Set GNUPGHOME when list is created.
234
        set_gnupg_home
235
        GPGME::Ctx.new armor: true
paz's avatar
init  
paz committed
236 237 238 239 240 241 242 243 244 245 246 247 248 249
      end
    end

    # TODO: place this somewhere sensible.
    # Call cleanup when script finishes.
    #Signal.trap(0, proc { @list.cleanup })
    def cleanup
      if @gpg_agent_pid
        Process.kill('TERM', @gpg_agent_pid.to_i)
      end
    rescue => e
      $stderr.puts "Failed to kill gpg-agent: #{e}"
    end

paz's avatar
paz committed
250 251 252 253
    def gpg_sign_options
      {sign: true, sign_as: self.fingerprint}
    end

paz's avatar
init  
paz committed
254
    def fingerprint=(arg)
255
      if arg
256
        write_attribute(:fingerprint, arg.gsub(/\s*/, '').gsub(/^0x/, '').chomp.upcase)
257
      end
paz's avatar
init  
paz committed
258 259 260 261 262 263 264 265 266 267 268 269 270
    end

    def self.listdir(listname)
      File.join(
          Conf.lists_dir,
          listname.split('@').reverse
        )
    end

    def listdir
      @listdir ||= self.class.listdir(self.email)
    end

271
    # A convenience-method to simplify other code.
272 273
    def subscribe(email, fingerprint=nil, adminflag=nil, deliveryflag=nil, key_material=nil)
      messages = nil
274
      args = {
275
          list_id: self.id,
276
          email: email
277
      }
278 279 280 281
      if key_material.present?
        fingerprint, messages = import_key_and_find_fingerprint(key_material)
      end
      args[:fingerprint] = fingerprint
282 283 284 285 286 287 288 289 290 291
      # ActiveRecord does not treat nil as falsy for boolean columns, so we
      # have to avoid that in order to not receive an invalid object. The
      # database will use the column's default-value if no value is being
      # given. (I'd rather not duplicate the defaults here.)
      if ! adminflag.nil?
        args[:admin] = adminflag
      end
      if ! deliveryflag.nil?
        args[:delivery_enabled] = deliveryflag
      end
292 293
      subscription = Subscription.create(args)
      [subscription, messages]
294 295 296 297 298 299 300 301
    end

    def unsubscribe(email, delete_key=false)
      sub = subscriptions.where(email: email).first
      if sub.blank?
        false
      end

paz's avatar
paz committed
302 303 304 305 306 307
      if ! sub.destroy
        return sub
      end

      if delete_key
        sub.delete_key
308
      end
paz's avatar
init  
paz committed
309 310
    end

311 312 313 314
    def keywords_admin_notify
      Array(read_attribute(:keywords_admin_notify))
    end

315
    def keywords_admin_only
paz's avatar
paz committed
316
      Array(read_attribute(:keywords_admin_only))
317 318 319 320 321 322 323
    end

    def admin_only?(keyword)
      keywords_admin_only.include?(keyword)
    end

    def from_admin?(mail)
paz's avatar
paz committed
324
      return false if ! mail.was_validly_signed?
325
      admins.find do |admin|
326
        admin.fingerprint == mail.signing_key.fingerprint
327 328
      end.presence || false
    end
329

paz's avatar
paz committed
330 331 332 333
    def set_attribute(attrib, value)
      self.send("#{attrib}=", value)
    end

334 335 336 337 338 339 340 341 342 343 344 345
    def send_list_key_to_subscriptions
      mail = Mail.new
      mail.from = self.email
      mail.subject = I18n.t('list_public_key_subject')
      mail.body = I18n.t('list_public_key_attached')
      mail.attach_list_key!(self)
      send_to_subscriptions(mail)
      true
    end

    def send_to_subscriptions(mail)
      logger.debug "Sending to subscriptions."
paz's avatar
paz committed
346
      mail.add_internal_footer!
347 348
      self.subscriptions.each do |subscription|
        begin
349 350
          
          if ! subscription.delivery_enabled
351
            logger.info "Not sending to #{subscription.email}: delivery is disabled."
352
            next
353
          end
354
          
355
          if ! self.deliver_selfsent && mail.was_validly_signed? && ( subscription == mail.signer )
356 357 358 359 360 361
            logger.info "Not sending to #{subscription.email}: delivery of self sent is disabled."
            next
          end
          
          subscription.send_mail(mail)
          
362 363 364 365
        rescue => exc
          msg = I18n.t('errors.delivery_error',
                       { email: subscription.email, error: exc.to_s })
          logger.error msg
366
          logger.error exc
367 368 369 370
        end
      end
    end

371 372
    private

373
    def set_gnupg_home
Nina's avatar
Nina committed
374
      ENV['GNUPGHOME'] = listdir
375 376
    end

377
    def delete_listdirs
Nina's avatar
Nina committed
378
      if File.exists?(self.listdir)
paz's avatar
paz committed
379
        FileUtils.rm_rf(self.listdir, secure: true)
380 381 382 383 384 385
        Schleuder.logger.info "Deleted #{self.listdir}"
      end
      # If listlogs_dir is different from lists_dir, the logfile still exists
      # and needs to be deleted, too.
      logfile_dir = File.dirname(self.logfile)
      if File.exists?(logfile_dir)
paz's avatar
paz committed
386
        FileUtils.rm_rf(logfile_dir, secure: true)
387
        Schleuder.logger.info "Deleted #{logfile_dir}"
388
      end
Nina's avatar
Nina committed
389 390 391 392 393 394
      true
    rescue => exc
      # Don't use list-logger here — if the list-dir isn't present we can't log to it!
      Schleuder.logger.error "Error while deleting listdir: #{exc}"
      return false
    end
paz's avatar
init  
paz committed
395 396
  end
end