Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add S3 integration for file uploads with CarrierWave #1

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.development.template
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ REDIS_URL=redis://localhost:6379/0
# ScoutApm
SCOUT_KEY=
SCOUT_NAME=SandboxPlayground

# S3 - AWS
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
S3_BUCKET_NAME=
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ Style/StringLiterals:

Metrics/MethodLength:
Max: 15

RSpec/MultipleExpectations:
Max: 4
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ gem "tzinfo-data", platforms: %i[windows jruby]
# Authentication
gem "devise"

# S3 Integration
gem "carrierwave", "~> 3.0"
gem "fog-aws"

group :development, :test do
gem "byebug"
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
Expand Down
39 changes: 39 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
carrierwave (3.0.7)
activemodel (>= 6.0.0)
activesupport (>= 6.0.0)
addressable (~> 2.6)
image_processing (~> 1.1)
marcel (~> 1.0.0)
ssrf_filter (~> 1.0)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
Expand Down Expand Up @@ -136,6 +143,7 @@ GEM
ruby2_keywords
drb (2.2.1)
erubi (1.13.0)
excon (0.111.0)
execjs (2.9.1)
factory_bot (6.5.0)
activesupport (>= 5.0.0)
Expand All @@ -150,12 +158,32 @@ GEM
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fog-aws (3.27.0)
base64 (~> 0.2.0)
fog-core (~> 2.1)
fog-json (~> 1.1)
fog-xml (~> 0.1)
fog-core (2.5.0)
builder
excon (~> 0.71)
formatador (>= 0.2, < 2.0)
mime-types
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-xml (0.1.4)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
font-awesome-sass (6.5.2)
sassc (~> 2.0)
formatador (1.1.0)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.1)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
Expand All @@ -180,9 +208,14 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mime-types (3.5.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2024.0903)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.25.1)
msgpack (1.7.2)
multi_json (1.15.0)
net-imap (0.4.16)
date
net-protocol
Expand Down Expand Up @@ -330,6 +363,9 @@ GEM
rubocop-rspec (3.0.5)
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sassc (2.4.0)
Expand Down Expand Up @@ -373,6 +409,7 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
ssrf_filter (1.1.2)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
Expand Down Expand Up @@ -419,12 +456,14 @@ DEPENDENCIES
bundler-audit
byebug
capybara
carrierwave (~> 3.0)
debug
devise
dotenv-rails
draper
factory_bot_rails
faker
fog-aws
font-awesome-sass (~> 6.5.2)
importmap-rails
jbuilder
Expand Down
24 changes: 24 additions & 0 deletions app/controllers/photos_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

class PhotosController < ApplicationController
def index
@photos = Photo.all
end

def new
@photo = Photo.new
end

def create
photo = UploadService.new(Photo, photo_params).call

notice_message = photo ? "Photo uploaded successfully." : "Photo upload failed."
redirect_to photos_path, notice: notice_message
end

private

def photo_params
params.require(:photo).permit(:title, :image)
end
end
7 changes: 7 additions & 0 deletions app/models/photo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class Photo < ApplicationRecord
mount_uploader :image, ImageUploader

validates :title, presence: true
end
15 changes: 15 additions & 0 deletions app/services/upload_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class UploadService
def initialize(model_class, model_params)
@model_class = model_class
@model_params = model_params
end

def call
model_instance = @model_class.new(@model_params)
return unless model_instance.save

model_instance
end
end
17 changes: 17 additions & 0 deletions app/uploaders/base_uploader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

class BaseUploader < CarrierWave::Uploader::Base
include CarrierWave::MiniMagick

def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end

def extension_allowlist
%w[jpg jpeg gif png]
end

def size_range
(1.byte)..(5.megabytes)
end
end
7 changes: 7 additions & 0 deletions app/uploaders/image_uploader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class ImageUploader < BaseUploader
version :thumb do
process resize_to_fill: [200, 200]
end
end
3 changes: 3 additions & 0 deletions app/views/layouts/partials/_header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<li class="nav-item active">
<%= link_to "Home", root_path, class: "nav-link" %>
</li>
<li class="nav-item">
<%= link_to "Photos", photos_path, class: "nav-link" %>
</li>
</ul>

<% if user_signed_in? %>
Expand Down
5 changes: 5 additions & 0 deletions app/views/photos/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= simple_form_for @photo, html: { multipart: true } do |f| %>
<%= f.input :title, label: 'Title', input_html: { class: 'form-control' } %>
<%= f.input :image, as: :file, label: 'Image', input_html: { class: 'form-control' } %>
<%= f.button :submit, class: 'btn btn-primary' %>
<% end %>
2 changes: 2 additions & 0 deletions app/views/photos/create.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<h1>Photos#create</h1>
<p>Find me in app/views/photos/create.html.erb</p>
18 changes: 18 additions & 0 deletions app/views/photos/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<h1>Uploaded Photos</h1>

<%= link_to 'Upload Photo', new_photo_path, class: 'btn btn-primary' %>

<div class="row">
<% @photos.each do |photo| %>
<div class="col-md-4 mb-3">
<div class="card">
<% if photo.image? %>
<%= image_tag(photo.image.url, class: 'card-img-top') %>
<% end %>
<div class="card-body">
<h5 class="card-title"><%= photo.title %></h5>
</div>
</div>
</div>
<% end %>
</div>
3 changes: 3 additions & 0 deletions app/views/photos/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1>Upload Photo</h1>

<%= render 'form' %>
2 changes: 1 addition & 1 deletion config/environments/production.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX

# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
config.active_storage.service = :amazon

# Mount Action Cable outside main process or domain.
# config.action_cable.mount_path = nil
Expand Down
20 changes: 20 additions & 0 deletions config/initializers/carrierwave.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CarrierWave.configure do |config|
if Rails.env.production?
config.fog_credentials = {
provider: 'AWS',
aws_access_key_id: ENV['AWS_ACCESS_KEY_ID'],
aws_secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'],
region: ENV['AWS_REGION'],
}
config.fog_directory = ENV['S3_BUCKET_NAME']
config.fog_public = false
config.fog_attributes = { cache_control: "public, max-age=#{365.days.to_i}" }
config.storage = :fog
elsif Rails.env.test?
config.storage = :file
config.enable_processing = false
config.root = Rails.root.join('tmp/tests')
else
config.storage = :file
end
end
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

# Define root route
root "home#index"
resources :photos

# Health check route at /up for monitoring app status
get "up" => "rails/health#show", as: :rails_health_check
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20240828202843_create_photos.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class CreatePhotos < ActiveRecord::Migration[7.2]
def change
create_table :photos do |t|
t.string :title, null: false, default: ""
t.string :image

t.timestamps
end
end
end
9 changes: 8 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions spec/factories/photos.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

FactoryBot.define do
factory :photo do
title { "MyString" }
image { nil }
end
end
Binary file added spec/fixtures/files/sample.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions spec/models/photo_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Photo, type: :model do
let(:photo) { build(:photo) }

describe "validations" do
it { is_expected.to validate_presence_of(:title) }
end

describe "image uploader" do
it "mounts the image uploader" do
expect(photo.image).to be_an_instance_of(ImageUploader)
end
end
end
33 changes: 33 additions & 0 deletions spec/services/upload_service_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe UploadService, type: :service do
let(:photo_params) do
{ title: "Sample Photo",
image: Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/files/sample.jpg"), "image/jpeg") }
end

describe "#call" do
context "when the photo is valid" do
it "creates and saves a new photo" do
service = described_class.new(Photo, photo_params)
photo = service.call

expect(photo).to be_persisted
expect(photo.title).to eq("Sample Photo")
expect(photo.image.url).to be_present
end
end

context "when the photo is invalid" do
it "does not save the photo and returns nil" do
invalid_params = { title: "" }
service = described_class.new(Photo, invalid_params)
photo = service.call

expect(photo).to be_nil
end
end
end
end
7 changes: 7 additions & 0 deletions spec/support/rspec_custom_config.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

RSpec.configure do |config|
config.after(:each, type: :service) do
FileUtils.rm_rf(Dir[Rails.root.join("tmp/tests/uploads").to_s])
end
end