diff --git a/Gemfile b/Gemfile index bd7ef2466d..2e7e6d544e 100644 --- a/Gemfile +++ b/Gemfile @@ -128,6 +128,10 @@ gem 'rack-cors' gem 'icalendar' +# for web push notifications +gem 'web-push' +gem 'serviceworker-rails' + # for signups as requested by email service gem 'recaptcha' diff --git a/Gemfile.lock b/Gemfile.lock index 2509c7f345..553364a08d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -364,6 +364,8 @@ GEM concurrent-ruby railties (>= 4.1) jsonapi-swagger (0.8.1) + jwt (3.1.2) + base64 kgio (2.11.4) kramdown (2.4.0) rexml @@ -449,6 +451,7 @@ GEM oauth omniauth (~> 1.0) open-uri (0.1.0) + openssl (3.3.0) orm_adapter (0.5.0) ostruct (0.6.3) parallel (1.27.0) @@ -667,6 +670,8 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + serviceworker-rails (0.6.0) + railties (>= 3.1) sidekiq (7.3.9) base64 connection_pool (>= 2.3.0) @@ -717,6 +722,9 @@ GEM descendants_tracker (~> 0.0, >= 0.0.3) warden (1.2.9) rack (>= 2.0.9) + web-push (3.0.2) + jwt (~> 3.0) + openssl (~> 3.0) webrat (0.7.3) nokogiri (>= 1.2.0) rack (>= 1.0) @@ -837,6 +845,7 @@ DEPENDENCIES scout_apm searchkick selenium-webdriver + serviceworker-rails sidekiq sprockets (< 4) terser @@ -844,6 +853,7 @@ DEPENDENCIES unicorn validate_url vcr + web-push webrat will_paginate will_paginate-bootstrap-style diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index f95a49f1bc..518c080cfa 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -1,3 +1,4 @@ // = link_tree ../images +// = link serviceworker.js // = link_directory ../javascripts .js // = link_directory ../stylesheets .css diff --git a/app/assets/javascripts/push_notifications.js b/app/assets/javascripts/push_notifications.js new file mode 100644 index 0000000000..827de88841 --- /dev/null +++ b/app/assets/javascripts/push_notifications.js @@ -0,0 +1,59 @@ +// +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, or any plugin's +// vendor/assets/javascripts directory can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// compiled file. JavaScript code in this file should be added after the last require_* statement. +// +// Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details +// about supported directives. +// +//= require rails-ujs +//= require activestorage +//= require_tree . + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +document.addEventListener('DOMContentLoaded', () => { + const pushButton = document.getElementById('enable-push-notifications'); + if (pushButton) { + pushButton.addEventListener('click', () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + const vapidPublicKey = document.querySelector('meta[name="vapid-public-key"]').content; + const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey); + + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: convertedVapidKey + }).then(subscription => { + fetch('/push_subscriptions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: JSON.stringify({ subscription: subscription.toJSON() }) + }); + }); + }); + } + }); + } +}); diff --git a/app/assets/javascripts/serviceworker.js.erb b/app/assets/javascripts/serviceworker.js.erb new file mode 100644 index 0000000000..cab6ff3e6d --- /dev/null +++ b/app/assets/javascripts/serviceworker.js.erb @@ -0,0 +1,13 @@ +self.addEventListener('push', function(event) { + const data = event.data.json(); + const title = data.title || 'Growstuff'; + const options = { + body: data.body, + icon: '/assets/growstuff-apple-touch-icon-precomposed.png', + badge: '/assets/growstuff-apple-touch-icon-precomposed.png' + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); +}); diff --git a/app/controllers/push_subscriptions_controller.rb b/app/controllers/push_subscriptions_controller.rb new file mode 100644 index 0000000000..b2e46af8e8 --- /dev/null +++ b/app/controllers/push_subscriptions_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PushSubscriptionsController < ApplicationController + before_action :authenticate_member! + + def create + subscription = current_member.push_subscriptions.find_or_initialize_by(endpoint: params[:subscription][:endpoint]) + subscription.update( + p256dh: params[:subscription][:keys][:p256dh], + auth: params[:subscription][:keys][:auth] + ) + head :ok + end + + def destroy + subscription = current_member.push_subscriptions.find_by(endpoint: params[:endpoint]) + subscription&.destroy + head :ok + end +end diff --git a/app/jobs/push_notification_job.rb b/app/jobs/push_notification_job.rb new file mode 100644 index 0000000000..7176279204 --- /dev/null +++ b/app/jobs/push_notification_job.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class PushNotificationJob < ApplicationJob + queue_as :default + + def perform(*args) + Member.where.not(timezone: nil).pluck(:timezone).uniq.each do |timezone| + Time.use_zone(timezone) do + if Time.zone.now.hour == 8 + Member.where(timezone: timezone).each do |member| + send_planting_notifications(member) + send_activity_notifications(member) + end + end + end + end + end + + private + + def send_planting_notifications(member) + member.plantings.active.annual.each do |planting| + if planting.finish_is_predicatable? && (planting.late? || planting.super_late?) + PushNotificationService.new(member, "Your #{planting.crop_name} planting is ready to be marked as finished.").send + end + end + end + + def send_activity_notifications(member) + due_activities = member.activities.where(due_date: Date.today, finished: false) + due_activities.each do |activity| + PushNotificationService.new(member, "Activity due: #{activity.name}").send + end + end +end diff --git a/app/models/push_subscription.rb b/app/models/push_subscription.rb new file mode 100644 index 0000000000..ac3406fc7a --- /dev/null +++ b/app/models/push_subscription.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class PushSubscription < ApplicationRecord + belongs_to :member +end diff --git a/app/services/push_notification_service.rb b/app/services/push_notification_service.rb new file mode 100644 index 0000000000..43d4e6bbd6 --- /dev/null +++ b/app/services/push_notification_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class PushNotificationService + def initialize(member, message) + @member = member + @message = message + end + + def send + @member.push_subscriptions.each do |subscription| + begin + WebPush.payload_send( + message: JSON.generate(title: 'Growstuff', body: @message), + endpoint: subscription.endpoint, + p256dh: subscription.p256dh, + auth: subscription.auth, + vapid: { + subject: "mailto:#{ENV.fetch('GROWSTUFF_EMAIL', 'noreply@growstuff.org')}", + public_key: ENV['GROWSTUFF_VAPID_PUBLIC_KEY'], + private_key: ENV['GROWSTUFF_VAPID_PRIVATE_KEY'] + } + ) + rescue WebPush::InvalidSubscription => e + # A subscription can become invalid if the user revokes the permission. + # In this case, we should delete the subscription. + subscription.destroy + Rails.logger.info "Subscription deleted because it was invalid: #{e.message}" + end + end + end +end diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index e3058ad555..543dd688dd 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,8 +28,10 @@ %title = content_for?(:title) ? yield(:title) + " - #{ENV['GROWSTUFF_SITE_NAME']} " : ENV['GROWSTUFF_SITE_NAME'] = csrf_meta_tags + %meta{name: "vapid-public-key", content: ENV['GROWSTUFF_VAPID_PUBLIC_KEY']} = stylesheet_link_tag "application", media: "all" %link{ href: path_to_image("growstuff-apple-touch-icon-precomposed.png"), rel: "apple-touch-icon-precomposed" } %link{ href: "https://fonts.googleapis.com/css?family=Modak|Raleway&display=swap", rel: "stylesheet" } = favicon_link_tag 'favicon.ico' + = serviceworker_js_tag diff --git a/app/views/members/_notifications.html.haml b/app/views/members/_notifications.html.haml new file mode 100644 index 0000000000..9e30f89bd9 --- /dev/null +++ b/app/views/members/_notifications.html.haml @@ -0,0 +1,8 @@ +.card.mt-3 + .card-body + %h5.card-title Notifications + %p + Install Growstuff as a Progressive Web App (PWA) to get notifications on your device. + Look for the "Add to Home Screen" option in your browser's menu. + %button.btn.btn-primary#enable-push-notifications + Enable Push Notifications diff --git a/app/views/members/show.html.haml b/app/views/members/show.html.haml index d4654b8f89..d105737a7a 100644 --- a/app/views/members/show.html.haml +++ b/app/views/members/show.html.haml @@ -68,6 +68,8 @@ = render 'members/follow_buttons', member: @member + = render "notifications", member: @member if can?(:update, @member) + - if can?(:destroy, @member) %hr/ = link_to admin_member_path(slug: @member.slug), method: :delete, data: { confirm: 'Are you sure?' }, class: 'btn btn-block btn-light text-danger' do diff --git a/config/routes.rb b/config/routes.rb index 084cf98e0b..d2e4201ad6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,7 @@ match '/members/:id/finish_signup' => 'members#finish_signup', via: %i(get patch), as: :finish_signup resources :authentications, only: %i(create destroy) + resources :push_subscriptions, only: %i(create destroy) get "home/index" root to: 'home#index' diff --git a/db/migrate/20240929041436_add_timezone_to_members.rb b/db/migrate/20240929041436_add_timezone_to_members.rb new file mode 100644 index 0000000000..aca9541b73 --- /dev/null +++ b/db/migrate/20240929041436_add_timezone_to_members.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTimezoneToMembers < ActiveRecord::Migration[7.2] + def change + add_column :members, :timezone, :string + end +end diff --git a/db/migrate/20240929041437_create_push_subscriptions.rb b/db/migrate/20240929041437_create_push_subscriptions.rb new file mode 100644 index 0000000000..272bf36e87 --- /dev/null +++ b/db/migrate/20240929041437_create_push_subscriptions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreatePushSubscriptions < ActiveRecord::Migration[7.2] + def change + create_table :push_subscriptions do |t| + t.references :member, null: false, foreign_key: true + t.string :endpoint, null: false + t.string :p256dh, null: false + t.string :auth, null: false + + t.timestamps + end + add_index :push_subscriptions, :endpoint, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b09d3956bc..716f4e2d3f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -466,6 +466,7 @@ t.string "facebook_handle" t.string "bluesky_handle" t.string "other_url" + t.string "timezone" t.index ["confirmation_token"], name: "index_members_on_confirmation_token", unique: true t.index ["discarded_at"], name: "index_members_on_discarded_at" t.index ["email"], name: "index_members_on_email", unique: true @@ -576,6 +577,7 @@ t.integer "harvests_count", default: 0 t.integer "likes_count", default: 0 t.boolean "failed", default: false, null: false + t.boolean "from_other_source" t.integer "overall_rating" t.index ["crop_id"], name: "index_plantings_on_crop_id" t.index ["garden_id"], name: "index_plantings_on_garden_id" @@ -599,6 +601,43 @@ t.index ["slug"], name: "index_posts_on_slug", unique: true end + create_table "problem_posts", force: :cascade do |t| + t.bigint "problem_id" + t.bigint "post_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id"], name: "index_problem_posts_on_post_id" + t.index ["problem_id", "post_id"], name: "index_problem_posts_on_problem_id_and_post_id", unique: true + t.index ["problem_id"], name: "index_problem_posts_on_problem_id" + end + + create_table "problems", force: :cascade do |t| + t.string "name" + t.string "reason_for_rejection" + t.string "rejection_notes" + t.string "approval_status", default: "pending", null: false + t.bigint "requester_id" + t.bigint "creator_id" + t.string "slug" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_problems_on_creator_id" + t.index ["name"], name: "index_problems_on_name" + t.index ["requester_id"], name: "index_problems_on_requester_id" + t.index ["slug"], name: "index_problems_on_slug" + end + + create_table "push_subscriptions", force: :cascade do |t| + t.bigint "member_id", null: false + t.string "endpoint", null: false + t.string "p256dh", null: false + t.string "auth", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint", unique: true + t.index ["member_id"], name: "index_push_subscriptions_on_member_id" + end + create_table "roles", id: :serial, force: :cascade do |t| t.string "name", null: false t.text "description" @@ -658,5 +697,10 @@ add_foreign_key "photo_associations", "crops" add_foreign_key "photo_associations", "photos" add_foreign_key "plantings", "seeds", column: "parent_seed_id", name: "parent_seed", on_delete: :nullify + add_foreign_key "problem_posts", "posts" + add_foreign_key "problem_posts", "problems" + add_foreign_key "problems", "members", column: "creator_id" + add_foreign_key "problems", "members", column: "requester_id" + add_foreign_key "push_subscriptions", "members" add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify end diff --git a/env-example b/env-example index 6a3e34f068..7c78dcaebc 100644 --- a/env-example +++ b/env-example @@ -65,3 +65,9 @@ MAILGUN_SMTP_SERVER="" # In production, replace them with real ones RECAPTCHA_SITE_KEY="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI" RECAPTCHA_SECRET_KEY="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" + +# VAPID keys for web push notifications +# These are insecure and should be replaced with real keys in production +# Generate new keys with `bundle exec rake webpush:generate_keys` +GROWSTUFF_VAPID_PUBLIC_KEY="BFf_pM3_3q0g1hIUiWf_nQdYj524I4E-mp3jW_j_7X-B-xWpW-j_8X_8X_8X_8X_8X_8X_8X_8X_8" +GROWSTUFF_VAPID_PRIVATE_KEY="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"