|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# Handles file uploads to Supabase S3-compatible storage |
| 4 | +class S3UploadService |
| 5 | + ALLOWED_CONTENT_TYPES = %w[ |
| 6 | + image/jpeg |
| 7 | + image/png |
| 8 | + image/gif |
| 9 | + image/webp |
| 10 | + application/pdf |
| 11 | + text/plain |
| 12 | + text/csv |
| 13 | + ].freeze |
| 14 | + |
| 15 | + MAX_SIZE_MB = 10 |
| 16 | + MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024 |
| 17 | + SIGNED_URL_EXPIRY = 3600 # 1 hour |
| 18 | + |
| 19 | + def initialize |
| 20 | + @client = Aws::S3::Client.new( |
| 21 | + access_key_id: ENV.fetch('SUPABASE_S3_ACCESS_KEY'), |
| 22 | + secret_access_key: ENV.fetch('SUPABASE_S3_SECRET_KEY'), |
| 23 | + region: ENV.fetch('SUPABASE_S3_REGION', 'sa-east-1'), |
| 24 | + endpoint: ENV.fetch('SUPABASE_S3_ENDPOINT'), |
| 25 | + force_path_style: true |
| 26 | + ) |
| 27 | + @bucket = ENV.fetch('SUPABASE_S3_BUCKET') |
| 28 | + end |
| 29 | + |
| 30 | + # Upload a file and return its metadata (does not include signed URL) |
| 31 | + # |
| 32 | + # @param file [ActionDispatch::Http::UploadedFile] the uploaded file |
| 33 | + # @param prefix [String] S3 key prefix (e.g. "support/user-uuid") |
| 34 | + # @return [Hash] { key:, filename:, content_type:, size: } |
| 35 | + def upload(file, prefix: 'support') |
| 36 | + validate!(file) |
| 37 | + |
| 38 | + key = generate_key(file.original_filename, prefix) |
| 39 | + |
| 40 | + @client.put_object( |
| 41 | + bucket: @bucket, |
| 42 | + key: key, |
| 43 | + body: file.read, |
| 44 | + content_type: file.content_type, |
| 45 | + content_disposition: "inline; filename=\"#{file.original_filename}\"" |
| 46 | + ) |
| 47 | + |
| 48 | + { |
| 49 | + key: key, |
| 50 | + filename: file.original_filename, |
| 51 | + content_type: file.content_type, |
| 52 | + size: file.size |
| 53 | + } |
| 54 | + end |
| 55 | + |
| 56 | + # Generate a pre-signed GET URL for a stored object |
| 57 | + # |
| 58 | + # @param key [String] the S3 object key |
| 59 | + # @param expires_in [Integer] expiry in seconds |
| 60 | + # @return [String] signed URL |
| 61 | + def signed_url(key, expires_in: SIGNED_URL_EXPIRY) |
| 62 | + signer = Aws::S3::Presigner.new(client: @client) |
| 63 | + signer.presigned_url(:get_object, bucket: @bucket, key: key, expires_in: expires_in) |
| 64 | + rescue StandardError => e |
| 65 | + Rails.logger.error("[S3UploadService] Failed to generate signed URL for #{key}: #{e.message}") |
| 66 | + nil |
| 67 | + end |
| 68 | + |
| 69 | + private |
| 70 | + |
| 71 | + def validate!(file) |
| 72 | + unless ALLOWED_CONTENT_TYPES.include?(file.content_type) |
| 73 | + raise ArgumentError, "File type not allowed. Allowed: #{ALLOWED_CONTENT_TYPES.join(', ')}" |
| 74 | + end |
| 75 | + |
| 76 | + return unless file.size > MAX_SIZE_BYTES |
| 77 | + |
| 78 | + raise ArgumentError, "File too large. Maximum size is #{MAX_SIZE_MB}MB" |
| 79 | + end |
| 80 | + |
| 81 | + def generate_key(filename, prefix) |
| 82 | + ext = File.extname(filename).downcase |
| 83 | + "#{prefix}/#{SecureRandom.uuid}#{ext}" |
| 84 | + end |
| 85 | +end |
0 commit comments