File: active_storage_extensions.rb

package info (click to toggle)
ruby-lockbox 2.1.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 224 kB
  • sloc: ruby: 1,447; makefile: 4
file content (169 lines) | stat: -rw-r--r-- 5,276 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# Ideally encryption and decryption would happen at the blob/service level.
# However, Active Storage < 6.1 only supports a single service (per environment).
# This means all attachments need to be encrypted or none of them,
# which is often not practical.
#
# Active Storage 6.1 adds support for multiple services, which changes this.
# We could have a Lockbox service:
#
# lockbox:
#   service: Lockbox
#   backend: local    # delegate to another service, like mirror service
#   key:     ...      # Lockbox options
#
# However, the checksum is computed *and stored on the blob*
# before the file is passed to the service.
# We don't want the MD5 checksum of the plaintext stored in the database.
#
# Instead, we encrypt and decrypt at the attachment level,
# and we define encryption settings at the model level.
module Lockbox
  module ActiveStorageExtensions
    module Attached
      protected

      def encrypted?
        # could use record_type directly
        # but record should already be loaded most of the time
        Utils.encrypted?(record, name)
      end

      def encrypt_attachable(attachable)
        Utils.encrypt_attachable(record, name, attachable)
      end
    end

    module AttachedOne
      def rotate_encryption!
        raise "Not encrypted" unless encrypted?

        attach(Utils.rebuild_attachable(self)) if attached?

        true
      end
    end

    module AttachedMany
      def rotate_encryption!
        raise "Not encrypted" unless encrypted?

        # must call to_a - do not change
        previous_attachments = attachments.to_a

        attachables =
          previous_attachments.map do |attachment|
            Utils.rebuild_attachable(attachment)
          end

        ActiveStorage::Attachment.transaction do
          attach(attachables)
          previous_attachments.each(&:purge)
        end

        attachments.reload

        true
      end
    end

    module CreateOne
      def initialize(name, record, attachable)
        # this won't encrypt existing blobs
        # ideally we'd check metadata for the encrypted flag
        # and disallow unencrypted blobs
        # since they'll raise an error on decryption
        # but earlier versions of Lockbox won't have it
        attachable = Lockbox::Utils.encrypt_attachable(record, name, attachable) if Lockbox::Utils.encrypted?(record, name) && !attachable.is_a?(ActiveStorage::Blob)
        super(name, record, attachable)
      end
    end

    module Attachment
      def download
        result = super(&nil)

        options = Utils.encrypted_options(record, name)
        # only trust the metadata when migrating
        # as earlier versions of Lockbox won't have it
        # and it's not a good practice to trust modifiable data
        encrypted = options && (!options[:migrating] || blob.metadata["encrypted"])
        if encrypted
          result = Utils.decrypt_result(record, name, options, result)
        end

        if block_given?
          io = StringIO.new(result)
          chunk_size = 5.megabytes
          while (chunk = io.read(chunk_size))
            yield chunk
          end
        else
          result
        end
      end

      def download_chunk(...)
        # TODO raise error in 3.0
        warn "[lockbox] WARNING: download_chunk not supported for encrypted files" if Utils.encrypted_options(record, name)
        super
      end

      def variant(...)
        raise Lockbox::Error, "Variant not supported for encrypted files" if Utils.encrypted_options(record, name)
        super
      end

      def preview(...)
        raise Lockbox::Error, "Preview not supported for encrypted files" if Utils.encrypted_options(record, name)
        super
      end

      if ActiveStorage::VERSION::STRING.to_f == 7.1 && ActiveStorage.version >= "7.1.4"
        def transform_variants_later
          blob.instance_variable_set(:@lockbox_encrypted, true) if Utils.encrypted_options(record, name)
          super
        end
      end

      def open(**options)
        blob.open(**options) do |file|
          options = Utils.encrypted_options(record, name)
          # only trust the metadata when migrating
          # as earlier versions of Lockbox won't have it
          # and it's not a good practice to trust modifiable data
          encrypted = options && (!options[:migrating] || blob.metadata["encrypted"])
          if encrypted
            result = Utils.decrypt_result(record, name, options, file.read)
            file.rewind
            # truncate may not be available on all platforms
            # according to the Ruby docs
            # may need to create a new temp file instead
            file.truncate(0)
            file.write(result)
            file.rewind
          end

          yield file
        end
      end
    end

    module Blob
      if ActiveStorage::VERSION::STRING.to_f == 7.1 && ActiveStorage.version >= "7.1.4"
        def preview_image_needed_before_processing_variants?
          !instance_variable_defined?(:@lockbox_encrypted) && super
        end
      end

      private

      def extract_content_type(io)
        if io.is_a?(Lockbox::IO) && io.extracted_content_type
          io.extracted_content_type
        else
          super
        end
      end
    end
  end
end