diff --git a/Gemfile b/Gemfile index ccc2913..5de7f90 100644 --- a/Gemfile +++ b/Gemfile @@ -21,9 +21,13 @@ gem 'jbuilder' # Reduces boot times through caching; required in config/boot.rb gem 'bootsnap', '>= 1.4.2', require: false +gem 'lockbox' + +gem 'aws-sdk-s3', require: false +# gem 'airrecord' gem 'google-api-client' gem 'rack-cors' -gem "sentry-raven" +gem 'sentry-raven' group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console diff --git a/Gemfile.lock b/Gemfile.lock index 01fe03f..eabe759 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,22 @@ GEM zeitwerk (~> 2.2) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) + aws-eventstream (1.0.3) + aws-partitions (1.263.0) + aws-sdk-core (3.89.1) + aws-eventstream (~> 1.0, >= 1.0.2) + aws-partitions (~> 1, >= 1.239.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-kms (1.27.0) + aws-sdk-core (~> 3, >= 3.71.0) + aws-sigv4 (~> 1.1) + aws-sdk-s3 (1.60.1) + aws-sdk-core (~> 3, >= 3.83.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.1.0) + aws-eventstream (~> 1.0, >= 1.0.2) bootsnap (1.4.6) msgpack (~> 1.0) builder (3.2.4) @@ -96,10 +112,12 @@ GEM concurrent-ruby (~> 1.0) jbuilder (2.10.0) activesupport (>= 5.0.0) + jmespath (1.4.0) jwt (2.2.1) listen (3.2.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + lockbox (0.3.4) loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -218,12 +236,14 @@ PLATFORMS ruby DEPENDENCIES + aws-sdk-s3 bootsnap (>= 1.4.2) byebug dotenv-rails google-api-client jbuilder listen + lockbox pg puma rack-cors diff --git a/README.md b/README.md index 3868adf..71621b9 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ To use the application the Google API client needs to be configured using the fo You can get those from the [Google APIs Dashboard](https://console.developers.google.com/apis/dashboard) +Additionally an encryption master key needs to be configured. [lockbox](https://github.com/ankane/lockbox) is used to encrypt sensitive data (e.g. access_token) at rest. + +* LOCKBOX_MASTER_KEY + Store those in a `.env` file; see `env.example` for an example. ### Run the application diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 4e958d3..8c523df 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -14,22 +14,32 @@ *= require_self */ @import "bulma/sass/utilities/initial-variables"; +@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Lobster&family=Comfortaa:wght@400;500;600;700&display=swap'); -$family-sans-serif: "Helvetica", -"Arial", -sans-serif; -// https://coolors.co/06aed5-086788-f0c808-fff1d0-dd1c1a -$blue: #083d77; -$red: #dd1c1a; -$orange: #ee964b; -$yellow: #f4d35e; -$light: #f5fafe; // #ebebd3; +$family-sans-serif: 'Roboto', sans-serif; +$family-secondary: 'Comfortaa', cursive; +// // https://coolors.co/06aed5-086788-f0c808-fff1d0-dd1c1a +// $blue: #083d77; +// $red: #dd1c1a; +// $orange: #ee964b; +// $yellow: #f4d35e; +// $light: #f5fafe; // #ebebd3; +// $primary: $blue; +// $green: #007932; // hsl(141, 53%, 53%); +// $footer-background-color: $light; + +$blue: #4c82fc; $primary: $blue; -$green: #007932; // hsl(141, 53%, 53%); -$footer-background-color: $light; + +$text: $grey-dark; +$body-background-color: #FAFCFE; @import 'bulma/bulma'; -.card-height{ - min-height: 200px; +.is-font-logo { + font-family: 'Lobster', cursive; +} + +body { + min-height: 100vh; } \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e288f36..1be493f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base + helper_method :current_user, :logged_in? def require_login redirect_to login_url unless current_user.present? diff --git a/app/controllers/file_uploads_controller.rb b/app/controllers/file_uploads_controller.rb new file mode 100644 index 0000000..8ad47cc --- /dev/null +++ b/app/controllers/file_uploads_controller.rb @@ -0,0 +1,8 @@ +class FileUploadsController < ApplicationController + def show + @form = Form.find_by!(token: params[:form_id]) + @submission = @form.submissions.find(params[:submission_id]) + @file_upload = @submission.files_attachments.find(params[:id]) + redirect_to url_for(@file_upload) + end +end diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index a3d57c0..92c2ece 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -1,7 +1,7 @@ require 'google/apis/sheets_v4' require 'google/api_client/client_secrets' class FormsController < ApplicationController - before_action :require_login + before_action :require_login, except: [:form] def new @form = current_user.forms.build @@ -25,6 +25,10 @@ class FormsController < ApplicationController end end + def form + @form = Form.find_by!(token: params[:id]) + end + private def form_params diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 66412fa..518a3eb 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -5,14 +5,18 @@ class SubmissionsController < ApplicationController def create @form = Form.find_by!(token: params[:form_id]) - @submission = @form.submissions.build(data: data_params) + # create a new submission object. we need a persisted submission to be able to process + # potential the data - to be able to create URLs to uploads which is added as link to the table + @submission = @form.submissions.create(remote_ip: request.remote_ip, referrer: request.referer) + # processes the submitted data and saves the submission + @submission.process_data(data_params) respond_to do |format| if @submission.save format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? } format.json { render(json: { success: true, data: @submission.data }) } else - format.html + format.html { redirect_to(@form.thank_you_url) if @form.thank_you_url.present? } format.json { render(json: { error: @submission.errors }, status: 422) } end end diff --git a/app/javascript/burger_menu.js b/app/javascript/burger_menu.js new file mode 100644 index 0000000..a37539d --- /dev/null +++ b/app/javascript/burger_menu.js @@ -0,0 +1,17 @@ +document.addEventListener('DOMContentLoaded', () => { + const $navbarBurgers = document.querySelectorAll('.navbar-burger'); + // Check if there are any navbar burgers + if ($navbarBurgers.length > 0) { + // Add a click event on each of them + $navbarBurgers.forEach(el => { + el.addEventListener('click', () => { + // Get the target from the "data-target" attribute + const target = el.dataset.target; + const $target = document.getElementById(target); + // Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu" + el.classList.toggle('is-active'); + $target.classList.toggle('is-active'); + }); + }); + } +}); diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 9cd55d4..80561d9 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -8,10 +8,11 @@ require("turbolinks").start() require("@rails/activestorage").start() require("channels") +require('burger_menu'); // Uncomment to copy all static images under ../images to the output folder and reference // them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) // or the `imagePath` JavaScript helper below. // // const images = require.context('../images', true) -// const imagePath = (name) => images(name, true) +// const imagePath = (name) => images(name, true) \ No newline at end of file diff --git a/app/jobs/submission_append_job.rb b/app/jobs/submission_append_job.rb new file mode 100644 index 0000000..096e5b0 --- /dev/null +++ b/app/jobs/submission_append_job.rb @@ -0,0 +1,16 @@ +class SubmissionAppendJob < ApplicationJob + queue_as :default + + rescue_from(Signet::AuthorizationError, Google::Apis::AuthorizationError) do |exception| + submission_id = self.arguments.first + Rails.logger.error("AuthorizationError during SubmissionAppend: submission_id=#{submission_id}") + submission = Submission.find(submission_id) + submission.form.deactivate!('AuthorizationError') + end + + def perform(*args) + submission_id = args.first + submission = Submission.find(submission_id) + submission.append_to_spreadsheet + end +end diff --git a/app/models/authentication.rb b/app/models/authentication.rb index 4195dc4..58fe264 100644 --- a/app/models/authentication.rb +++ b/app/models/authentication.rb @@ -1,6 +1,9 @@ class Authentication < ApplicationRecord belongs_to :user + encrypts :access_token + encrypts :refresh_token + def expired? expires_at <= Time.current end diff --git a/app/models/form.rb b/app/models/form.rb index 9752094..c70d3bd 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -11,6 +11,14 @@ class Form < ApplicationRecord validates_presence_of :title + def deactivate!(reason = nil) + self.user.deactivate!(reason) + end + + def active? + self.user.active? + end + def google_spreadsheet_url "https://docs.google.com/spreadsheets/d/#{google_spreadsheet_id}/edit" if google_spreadsheet_id.present? end diff --git a/app/models/submission.rb b/app/models/submission.rb index db3d510..899469c 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -1,19 +1,37 @@ class Submission < ApplicationRecord belongs_to :form - after_create :append_to_spreadsheet - validates_presence_of :data + has_many_attached :files - def data=(value) - sanitized_data = {} - value.each do |key, value| - sanitized_data[key] = submission_value(value) + validates_presence_of :data, if: :appended_at? + + def process_data(submitted_data) + processed_data = {} + submitted_data.each do |key, value| + processed_data[key] = submission_value_for(value) end - write_attribute(:data, sanitized_data) + update_attribute(:data, processed_data) + SubmissionAppendJob.perform_later(self.id) end - def submission_value(value) - if value.to_s.downcase == 'tinyforms_now' + def submission_value_for(value) + case value + when Array + value.join(', ') + when Hash + JSON.dump(value) + when 'tinyforms_now' Time.now.utc.to_formatted_s(:rfc822) + when ActionDispatch::Http::UploadedFile + # manually create the ActiveStorage attachment because we need the ID of the Attachment to create the URL + # first the file needs to be uplaoded then we can create an Attachment + # The CreateOne mainly handles the uplaod and the creation of the blob for us + # `files` is the name from `has_many_attached :files` + create_one = ActiveStorage::Attached::Changes::CreateOne.new('files', self, value) + create_one.upload + attachment = ActiveStorage::Attachment.new(record: self, name: 'files', blob: create_one.blob) + attachment.save + # return the URL that we use to show in the Spreadsheet + Rails.application.routes.url_helpers.file_upload_url(form_id: form, submission_id: self, id: attachment.id, host: DEFAULT_HOST) else value.to_s end diff --git a/app/models/user.rb b/app/models/user.rb index a9e3a7d..2918dc3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,7 +8,12 @@ class User < ApplicationRecord user_info = oauth.get_userinfo if user = User.find_by(google_id: user_info.id) - return user, user.authentications.last + authentication = user.authentications.last + authentication.access_token = auth_client.access_token if auth_client.access_token.present? + authentication.refresh_token = auth_client.refresh_token if auth_client.refresh_token.present? + authentication.expires_at = Time.at(auth_client.expires_at) if auth_client.expires_at.present? + authentication.save + return user, authentication else user = User.create(name: user_info.name, email: user_info.email, google_id: user_info.id) authentication = user.authentications.create( @@ -20,6 +25,15 @@ class User < ApplicationRecord end end + def deactivate!(reason = nil) + # currently we only use deactivate if we get an authentication exception appending data to a spreadsheet + authentications.last&.update(expires_at: Time.current) + end + + def active? + authentications.last.present? && !authentications.last.expired? + end + def google_authorization authentications.last.google_authorization end diff --git a/app/views/forms/form.html.erb b/app/views/forms/form.html.erb new file mode 100644 index 0000000..b3f6ebb --- /dev/null +++ b/app/views/forms/form.html.erb @@ -0,0 +1,12 @@ +
+
+ <% end %> + + <%= submit_tag 'Send', name: nil %> +<% end %> diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index 2c2945f..5be9a9b 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -1,3 +1,12 @@ -+ Generate forms instantly +
+