diff --git a/.env.example b/.env.example
index bcc1cda4..55daaea2 100644
--- a/.env.example
+++ b/.env.example
@@ -59,3 +59,33 @@ UPGRADES_TARGET=release # `release` or `commit`
GITHUB_REPO_OWNER=maybe-finance
GITHUB_REPO_NAME=maybe
GITHUB_REPO_BRANCH=main
+
+# ======================================================================================================
+# Active Storage Configuration - responsible for storing file uploads
+# ======================================================================================================
+#
+# * Defaults to disk storage but you can also use Amazon S3, Google Cloud Storage, or Microsoft Azure Storage.
+# * Set the appropriate environment variables to use these services.
+# * Ensure libvips is installed on your system for image processing - https://github.com/libvips/libvips
+#
+# Amazon S3
+# ==========
+# ACTIVE_STORAGE_SERVICE=amazon
+# S3_ACCESS_KEY_ID=
+# S3_SECRET_ACCESS_KEY=
+# S3_REGION= # defaults to `us-east-1` if not set
+# S3_BUCKET=
+
+# Google Cloud Storage
+# =====================
+# Save your JSON keyfile as `gcp-storage-keyfile.json` in the root of the project
+# ACTIVE_STORAGE_SERVICE=google
+# GCS_PROJECT=
+# GCS_BUCKET=
+
+# Microsoft Azure Storage
+# ========================
+# ACTIVE_STORAGE_SERVICE=azure
+# AZURE_STORAGE_ACCOUNT_NAME=
+# AZURE_STORAGE_ACCESS_KEY=
+# AZURE_STORAGE_CONTAINER=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 7e4402d4..60c70a2b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,6 @@
# Ignore .devcontainer files
compose-dev.yaml
+
+# Ignore GCP keyfile
+gcp-storage-keyfile.json
diff --git a/Gemfile b/Gemfile
index 718e0142..d52c306a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -36,6 +36,12 @@ gem "sentry-rails"
gem "rails-settings-cached"
gem "octokit"
+# Active Storage
+gem "aws-sdk-s3", require: false
+gem "azure-storage-blob", "~> 2.0", require: false
+gem "google-cloud-storage", "~> 1.11", require: false
+gem "image_processing", ">= 1.2"
+
# Other
gem "bcrypt", "~> 3.1.7"
gem "inline_svg"
@@ -51,6 +57,7 @@ group :development, :test do
gem "letter_opener"
gem "i18n-tasks"
gem "erb_lint"
+ gem "byebug"
end
group :development do
diff --git a/Gemfile.lock b/Gemfile.lock
index e753890f..a2f82e91 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -107,6 +107,30 @@ GEM
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
+ aws-eventstream (1.3.0)
+ aws-partitions (1.922.0)
+ aws-sdk-core (3.193.0)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.651.0)
+ aws-sigv4 (~> 1.8)
+ jmespath (~> 1, >= 1.6.1)
+ aws-sdk-kms (1.80.0)
+ aws-sdk-core (~> 3, >= 3.193.0)
+ aws-sigv4 (~> 1.1)
+ aws-sdk-s3 (1.148.0)
+ aws-sdk-core (~> 3, >= 3.193.0)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.8)
+ aws-sigv4 (1.8.0)
+ aws-eventstream (~> 1, >= 1.0.2)
+ azure-storage-blob (2.0.3)
+ azure-storage-common (~> 2.0)
+ nokogiri (~> 1, >= 1.10.8)
+ azure-storage-common (2.0.4)
+ faraday (~> 1.0)
+ faraday_middleware (~> 1.0, >= 1.0.0.rc1)
+ net-http-persistent (~> 4.0)
+ nokogiri (~> 1, >= 1.10.8)
base64 (0.2.0)
bcrypt (3.1.20)
better_html (2.0.2)
@@ -123,6 +147,7 @@ GEM
brakeman (6.1.2)
racc
builder (3.2.4)
+ byebug (11.1.3)
capybara (3.40.0)
addressable
matrix
@@ -143,6 +168,9 @@ GEM
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
+ declarative (0.0.20)
+ digest-crc (0.6.5)
+ rake (>= 12.0.0, < 14.0.0)
dotenv (3.1.0)
dotenv-rails (3.1.0)
dotenv (= 3.1.0)
@@ -158,10 +186,11 @@ GEM
erubi (1.12.0)
et-orbi (1.2.11)
tzinfo
- faraday (2.9.0)
- faraday-net_http (>= 2.0, < 3.2)
- faraday-net_http (3.1.0)
- net-http
+ faraday (1.2.0)
+ multipart-post (>= 1.2, < 3)
+ ruby2_keywords
+ faraday_middleware (1.2.0)
+ faraday (~> 1.0)
ffi (1.16.3)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
@@ -175,12 +204,47 @@ GEM
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
+ google-apis-core (0.14.1)
+ addressable (~> 2.5, >= 2.5.1)
+ googleauth (~> 1.9)
+ httpclient (>= 2.8.1, < 3.a)
+ mini_mime (~> 1.0)
+ representable (~> 3.0)
+ retriable (>= 2.0, < 4.a)
+ rexml
+ google-apis-iamcredentials_v1 (0.20.0)
+ google-apis-core (>= 0.14.0, < 2.a)
+ google-apis-storage_v1 (0.37.0)
+ google-apis-core (>= 0.14.0, < 2.a)
+ google-cloud-core (1.7.0)
+ google-cloud-env (>= 1.0, < 3.a)
+ google-cloud-errors (~> 1.0)
+ google-cloud-env (2.1.1)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-errors (1.4.0)
+ google-cloud-storage (1.51.0)
+ addressable (~> 2.8)
+ digest-crc (~> 0.4)
+ google-apis-core (~> 0.13)
+ google-apis-iamcredentials_v1 (~> 0.18)
+ google-apis-storage_v1 (~> 0.37)
+ google-cloud-core (~> 1.6)
+ googleauth (~> 1.9)
+ mini_mime (~> 1.0)
+ googleauth (1.11.0)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.1)
+ jwt (>= 1.4, < 3.0)
+ multi_json (~> 1.11)
+ os (>= 0.9, < 2.0)
+ signet (>= 0.16, < 2.a)
hashdiff (1.1.0)
highline (3.0.1)
hotwire-livereload (1.3.2)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
railties (>= 6.0.0)
+ httpclient (2.8.3)
i18n (1.14.4)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.13)
@@ -194,6 +258,9 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
+ image_processing (1.12.2)
+ 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)
@@ -205,7 +272,10 @@ GEM
irb (1.12.0)
rdoc
reline (>= 0.4.2)
+ jmespath (1.6.2)
json (2.7.1)
+ jwt (2.8.1)
+ base64
language_server-protocol (3.17.0.3)
launchy (3.0.0)
addressable (~> 2.8)
@@ -225,13 +295,16 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
+ mini_magick (4.12.0)
mini_mime (1.1.5)
minitest (5.21.2)
mocha (2.2.0)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
- net-http (0.4.1)
- uri
+ multi_json (1.15.0)
+ multipart-post (2.4.0)
+ net-http-persistent (4.0.2)
+ connection_pool (~> 2.2)
net-imap (0.4.10)
date
net-protocol
@@ -258,6 +331,7 @@ GEM
base64
faraday (>= 1, < 3)
sawyer (~> 0.9)
+ os (1.1.4)
pagy (8.3.0)
parallel (1.24.0)
parser (3.3.0.5)
@@ -316,6 +390,11 @@ GEM
regexp_parser (2.9.0)
reline (0.5.3)
io-console (~> 0.5)
+ representable (3.2.0)
+ declarative (< 0.1.0)
+ trailblazer-option (>= 0.1.1, < 0.2.0)
+ uber (< 0.2.0)
+ retriable (3.1.2)
rexml (3.2.6)
rubocop (1.60.2)
json (~> 2.3)
@@ -354,6 +433,8 @@ GEM
ruby-lsp (>= 0.16.0, < 0.17.0)
sorbet-runtime (>= 0.5.9897)
ruby-progressbar (1.13.0)
+ ruby-vips (2.2.1)
+ ffi (~> 1.12)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sawyer (0.9.2)
@@ -370,6 +451,11 @@ GEM
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
+ signet (0.19.0)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 3.0)
+ multi_json (~> 1.10)
smart_properties (1.17.0)
sorbet-runtime (0.5.11332)
stackprof (0.2.26)
@@ -392,14 +478,15 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
timeout (0.4.1)
+ trailblazer-option (0.1.2)
turbo-rails (2.0.5)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
+ uber (0.1.0)
unicode-display_width (2.5.0)
- uri (0.13.0)
useragent (0.16.10)
vcr (6.2.0)
web-console (4.2.1)
@@ -429,17 +516,22 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ aws-sdk-s3
+ azure-storage-blob (~> 2.0)
bcrypt (~> 3.1.7)
bootsnap
brakeman
+ byebug
capybara
debug
dotenv-rails
erb_lint
faraday
good_job
+ google-cloud-storage (~> 1.11)
hotwire-livereload
i18n-tasks
+ image_processing (>= 1.2)
importmap-rails
inline_svg
letter_opener
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 58c6f678..83aa94da 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -5,6 +5,10 @@ class Settings::ProfilesController < ApplicationController
def update
user_params_with_family = user_params
+ if params[:user][:delete_profile_image] == "true"
+ Current.user.profile_image.purge
+ end
+
if Current.family && user_params_with_family[:family_attributes]
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
user_params_with_family[:family_attributes] = family_attributes
@@ -13,7 +17,7 @@ class Settings::ProfilesController < ApplicationController
if Current.user.update(user_params_with_family)
redirect_to settings_profile_path, notice: t(".success")
else
- render :edit, status: :unprocessable_entity
+ redirect_to settings_profile_path, alert: t(".file_size_error")
end
end
@@ -29,7 +33,7 @@ class Settings::ProfilesController < ApplicationController
private
def user_params
- params.require(:user).permit(:first_name, :last_name,
+ params.require(:user).permit(:first_name, :last_name, :profile_image,
family_attributes: [ :name, :id ])
end
end
diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js
new file mode 100644
index 00000000..6118634b
--- /dev/null
+++ b/app/javascript/controllers/profile_image_preview_controller.js
@@ -0,0 +1,27 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["imagePreview", "fileField", "deleteField", "clearBtn", "template"]
+
+ preview(event) {
+ const file = event.target.files[0];
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ this.imagePreviewTarget.innerHTML = ``;
+ this.templateTarget.classList.add("hidden");
+ this.clearBtnTarget.classList.remove("hidden");
+ };
+ reader.readAsDataURL(file);
+ }
+ }
+
+ clear() {
+ this.deleteFieldTarget.value = true;
+ this.fileFieldTarget.value = null;
+ this.templateTarget.classList.remove("hidden");
+ this.imagePreviewTarget.innerHTML = this.templateTarget.innerHTML;
+ this.clearBtnTarget.classList.add("hidden");
+ this.element.submit();
+ }
+}
diff --git a/app/models/user.rb b/app/models/user.rb
index b6f2ffb3..0d553904 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -11,6 +11,12 @@ class User < ApplicationRecord
enum :role, { member: "member", admin: "admin" }, validate: true
+ has_one_attached :profile_image do |attachable|
+ attachable.variant :thumbnail, resize_to_limit: [ 150, 150 ], preprocessed: true
+ end
+
+ validate :profile_image_size
+
generates_token_for :password_reset, expires_in: 15.minutes do
password_salt&.last(10)
end
@@ -74,4 +80,10 @@ class User < ApplicationRecord
def deactivated_email
email.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
+
+ def profile_image_size
+ if profile_image.attached? && profile_image.byte_size > 5.megabytes
+ errors.add(:profile_image, "is too large. Maximum size is 5 MB.")
+ end
+ end
end
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb
index 43b0c342..3afab0f4 100644
--- a/app/views/layouts/_sidebar.html.erb
+++ b/app/views/layouts/_sidebar.html.erb
@@ -4,11 +4,24 @@
<% end %>