diff options
Diffstat (limited to 'app/models/concerns/file_attachment.rb')
| -rw-r--r-- | app/models/concerns/file_attachment.rb | 139 |
1 files changed, 139 insertions, 0 deletions
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb new file mode 100644 index 0000000..0e99fa2 --- /dev/null +++ b/app/models/concerns/file_attachment.rb | |||
| @@ -0,0 +1,139 @@ | |||
| 1 | # FileAttachment — minimal drop-in replacement for Paperclip's has_attached_file. | ||
| 2 | # | ||
| 3 | # Provides the same interface used throughout this codebase: | ||
| 4 | # asset.upload.url -> "/system/uploads/:id/original/:filename" | ||
| 5 | # asset.upload.url(:thumb) -> "/system/uploads/:id/thumb/:filename" | ||
| 6 | # asset.upload.content_type -> string | ||
| 7 | # asset.upload.size -> integer (bytes) | ||
| 8 | # | ||
| 9 | # Files are stored at: | ||
| 10 | # Rails.root/public/system/uploads/:id/:style/:filename | ||
| 11 | # | ||
| 12 | # Image variants are generated via ImageMagick (convert) on upload. | ||
| 13 | # Non-image files get only an original, no variants. | ||
| 14 | # | ||
| 15 | # To replace an asset: assign a new file to asset.upload= and save. | ||
| 16 | # The filename is fixed on first upload and preserved on replacement, | ||
| 17 | # keeping all public URLs stable. | ||
| 18 | # | ||
| 19 | # Future: if more sophisticated asset management is needed (versioning, | ||
| 20 | # S3, on-demand resizing), replace this module and keep the interface. | ||
| 21 | |||
| 22 | module FileAttachment | ||
| 23 | extend ActiveSupport::Concern | ||
| 24 | |||
| 25 | STYLES = { | ||
| 26 | medium: { geometry: "300x300>", format: nil }, | ||
| 27 | thumb: { geometry: "100x100>", format: nil }, | ||
| 28 | headline: { geometry: "460x250!", format: nil } | ||
| 29 | }.freeze | ||
| 30 | |||
| 31 | IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze | ||
| 32 | VECTOR_CONTENT_TYPES = %w[image/svg+xml].freeze | ||
| 33 | DISPLAYABLE_AS_IMAGE = IMAGE_CONTENT_TYPES + VECTOR_CONTENT_TYPES | ||
| 34 | |||
| 35 | included do | ||
| 36 | attr_reader :upload | ||
| 37 | |||
| 38 | after_initialize :build_upload_proxy | ||
| 39 | after_save :process_upload | ||
| 40 | before_destroy :delete_upload_files | ||
| 41 | end | ||
| 42 | |||
| 43 | def upload=(uploaded_file) | ||
| 44 | return if uploaded_file.blank? | ||
| 45 | @pending_upload = uploaded_file | ||
| 46 | # Populate the database columns immediately so validations can use them | ||
| 47 | self.upload_file_name = sanitize_filename(uploaded_file.original_filename) | ||
| 48 | self.upload_content_type = uploaded_file.content_type.to_s.split(';').first.strip | ||
| 49 | self.upload_file_size = uploaded_file.size | ||
| 50 | self.upload_updated_at = Time.current | ||
| 51 | build_upload_proxy | ||
| 52 | end | ||
| 53 | |||
| 54 | private | ||
| 55 | |||
| 56 | def build_upload_proxy | ||
| 57 | @upload = UploadProxy.new(self) | ||
| 58 | end | ||
| 59 | |||
| 60 | def process_upload | ||
| 61 | return unless @pending_upload | ||
| 62 | uploaded_file = @pending_upload | ||
| 63 | @pending_upload = nil | ||
| 64 | |||
| 65 | old_dir = Rails.root.join("public", "system", "uploads", id.to_s) | ||
| 66 | FileUtils.rm_rf(old_dir) if Dir.exist?(old_dir) | ||
| 67 | |||
| 68 | original_path = file_path(:original) | ||
| 69 | FileUtils.mkdir_p(File.dirname(original_path)) | ||
| 70 | FileUtils.cp(uploaded_file.tempfile.path, original_path) | ||
| 71 | |||
| 72 | if IMAGE_CONTENT_TYPES.include?(upload_content_type) | ||
| 73 | generate_variants(original_path) | ||
| 74 | elsif VECTOR_CONTENT_TYPES.include?(upload_content_type) | ||
| 75 | generate_svg_variants(original_path) | ||
| 76 | end | ||
| 77 | end | ||
| 78 | |||
| 79 | def generate_variants(original_path) | ||
| 80 | STYLES.each do |style, options| | ||
| 81 | dest_path = file_path(style) | ||
| 82 | FileUtils.mkdir_p(File.dirname(dest_path)) | ||
| 83 | system("magick", original_path, "-resize", options[:geometry], dest_path) | ||
| 84 | end | ||
| 85 | end | ||
| 86 | |||
| 87 | def generate_svg_variants(original_path) | ||
| 88 | STYLES.each do |style, _| | ||
| 89 | dest_path = file_path(style) | ||
| 90 | FileUtils.mkdir_p(File.dirname(dest_path)) | ||
| 91 | FileUtils.cp(original_path, dest_path) | ||
| 92 | end | ||
| 93 | end | ||
| 94 | |||
| 95 | def delete_upload_files | ||
| 96 | dir = Rails.root.join("public", "system", "uploads", id.to_s) | ||
| 97 | FileUtils.rm_rf(dir) if Dir.exist?(dir) | ||
| 98 | end | ||
| 99 | |||
| 100 | def file_path(style) | ||
| 101 | Rails.root.join( | ||
| 102 | "public", "system", "uploads", | ||
| 103 | id.to_s, style.to_s, upload_file_name | ||
| 104 | ).to_s | ||
| 105 | end | ||
| 106 | |||
| 107 | def sanitize_filename(filename) | ||
| 108 | File.basename(filename).gsub(/[^\w\.\-]/, '_') | ||
| 109 | end | ||
| 110 | |||
| 111 | # Proxy object returned by asset.upload, providing the Paperclip-compatible | ||
| 112 | # interface used in views: .url, .url(:style), .content_type, .size | ||
| 113 | class UploadProxy | ||
| 114 | def initialize(record) | ||
| 115 | @record = record | ||
| 116 | end | ||
| 117 | |||
| 118 | def url(style = :original) | ||
| 119 | return "" if @record.upload_file_name.blank? | ||
| 120 | "/system/uploads/#{@record.id}/#{style}/#{@record.upload_file_name}" | ||
| 121 | end | ||
| 122 | |||
| 123 | def content_type | ||
| 124 | @record.upload_content_type.to_s | ||
| 125 | end | ||
| 126 | |||
| 127 | def size | ||
| 128 | @record.upload_file_size.to_i | ||
| 129 | end | ||
| 130 | |||
| 131 | def present? | ||
| 132 | @record.upload_file_name.present? | ||
| 133 | end | ||
| 134 | |||
| 135 | def blank? | ||
| 136 | !present? | ||
| 137 | end | ||
| 138 | end | ||
| 139 | end | ||
