summaryrefslogtreecommitdiff
path: root/app/models/concerns/file_attachment.rb
blob: e577c7fac2c609914eec450db89b04533afb3740 (plain)
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
170
# FileAttachment — minimal drop-in replacement for Paperclip's has_attached_file.
#
# Provides the same interface used throughout this codebase:
#   asset.upload.url              -> "/system/uploads/:id/original/:filename"
#   asset.upload.url(:thumb)      -> "/system/uploads/:id/thumb/:filename"
#   asset.upload.content_type     -> string
#   asset.upload.size             -> integer (bytes)
#
# Files are stored at:
#   Rails.root/public/system/uploads/:id/:style/:filename
#
# Image variants are generated via ImageMagick (convert) on upload.
# Non-image files get only an original, no variants.
#
# To replace an asset: assign a new file to asset.upload= and save.
# The filename is fixed on first upload and preserved on replacement,
# keeping all public URLs stable.
#
# Future: if more sophisticated asset management is needed (versioning,
# S3, on-demand resizing), replace this module and keep the interface.

module FileAttachment
  extend ActiveSupport::Concern

  STYLES = {
    medium:   { args: ["-resize", "300x300>"] },
    thumb:    { args: ["-resize", "100x100>"] },
    headline: { args: ["-resize", "460x250^", "-gravity", "center", "-extent", "460x250"] },
    large:    { args: ["-resize", "1600x1600>"] }
  }.freeze

  IMAGE_CONTENT_TYPES    = %w[image/jpeg image/gif image/png image/webp].freeze
  VECTOR_CONTENT_TYPES   = %w[image/svg+xml].freeze
  RASTERIZED_CONTENT_TYPES = %w[application/pdf].freeze
  DISPLAYABLE_AS_IMAGE   = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES

  included do
    attr_reader :upload

    after_initialize :build_upload_proxy
    after_save       :process_upload
    before_destroy   :delete_upload_files
  end

  def upload=(uploaded_file)
    return if uploaded_file.blank?
    @pending_upload = uploaded_file
    # Populate the database columns immediately so validations can use them
    self.upload_file_name    = sanitize_filename(uploaded_file.original_filename)
    self.upload_content_type = uploaded_file.content_type.to_s.split(';').first.strip
    self.upload_file_size    = uploaded_file.size
    self.upload_updated_at   = Time.current
    build_upload_proxy
  end

  def has_variant?(style)
    upload_file_name.present? && File.exist?(file_path(style))
  end

  def previewable?
    has_variant?(:medium)
  end

  def variant_filename(style)
    return upload_file_name if style == :original || !RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
    File.basename(upload_file_name, ".*") + ".png"
  end

  private

  def build_upload_proxy
    @upload = UploadProxy.new(self)
  end

  class_methods do
    def upload_root
      Rails.env.test? ? Rails.root.join("tmp", "test_uploads") : Rails.root.join("public", "system", "uploads")
    end
  end

  def upload_root
    self.class.upload_root
  end

  def process_upload
    return unless @pending_upload
    uploaded_file = @pending_upload
    @pending_upload = nil

    old_dir = upload_root.join(id.to_s)

    FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir)

    original_path = file_path(:original)
    FileUtils.mkdir_p(File.dirname(original_path))
    FileUtils.cp(uploaded_file.tempfile.path, original_path)

    generate_all_variants(original_path)
  end

  def generate_variants(original_path, extra_args = [])
    STYLES.each do |style, options|
      dest_path = file_path(style)
      FileUtils.mkdir_p(File.dirname(dest_path))
      system("magick", original_path, *extra_args, *options[:args], dest_path)
    end
  end

  def generate_svg_variants(original_path)
    STYLES.each do |style, _|
      dest_path = file_path(style)
      FileUtils.mkdir_p(File.dirname(dest_path))
      FileUtils.cp(original_path, dest_path)
    end
  end

  def generate_all_variants(original_path)
    if IMAGE_CONTENT_TYPES.include?(upload_content_type)
      generate_variants(original_path)
    elsif VECTOR_CONTENT_TYPES.include?(upload_content_type)
      generate_svg_variants(original_path)
    elsif RASTERIZED_CONTENT_TYPES.include?(upload_content_type)
     generate_variants("#{original_path}[0]",
                        ["-background", "white", "-alpha", "remove", "-alpha", "off"])
    end
  end

  def delete_upload_files
    dir = upload_root.join(id.to_s)
    FileUtils.rm_rf(dir) if Dir.exist?(dir)
  end

  def file_path(style)
    upload_root.join(id.to_s, style.to_s, variant_filename(style)).to_s
  end

  def sanitize_filename(filename)
    name = File.basename(filename).unicode_normalize(:nfc)
    name.gsub(/(?u)[^\w\.\-]/, '_')
  end

  # Proxy object returned by asset.upload, providing the Paperclip-compatible
  # interface used in views: .url, .url(:style), .content_type, .size
  class UploadProxy
    def initialize(record)
      @record = record
    end

    def url(style = :original)
      return "" if @record.upload_file_name.blank?
      "/system/uploads/#{@record.id}/#{style}/#{@record.variant_filename(style)}"
    end

    def content_type
      @record.upload_content_type.to_s
    end

    def size
      @record.upload_file_size.to_i
    end

    def present?
      @record.upload_file_name.present?
    end

    def blank?
      !present?
    end
  end
end