diff --git a/Gemfile.lock b/Gemfile.lock index fcb1672..8fc41ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -12,6 +12,7 @@ PATH ruby-openai stimulus-rails turbo-rails + web-push (~> 3.0) GEM remote: https://rubygems.org/ @@ -109,8 +110,6 @@ GEM ast (2.4.3) base64 (0.3.0) bcrypt_pbkdf (1.1.2) - bcrypt_pbkdf (1.1.2-arm64-darwin) - bcrypt_pbkdf (1.1.2-x86_64-darwin) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.23.0) @@ -133,7 +132,6 @@ GEM commonmarker (2.6.3-aarch64-linux) commonmarker (2.6.3-arm-linux) commonmarker (2.6.3-arm64-darwin) - commonmarker (2.6.3-x86_64-darwin) commonmarker (2.6.3-x86_64-linux) commonmarker (2.6.3-x86_64-linux-musl) concurrent-ruby (1.3.6) @@ -175,7 +173,6 @@ GEM ffi (1.17.3-arm-linux-gnu) ffi (1.17.3-arm-linux-musl) ffi (1.17.3-arm64-darwin) - ffi (1.17.3-x86_64-darwin) ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-musl) formtastic (6.0.0) @@ -217,6 +214,8 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) json (2.18.1) + jwt (3.1.2) + base64 kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) @@ -289,12 +288,11 @@ GEM racc (~> 1.4) nokogiri (1.19.1-arm64-darwin) racc (~> 1.4) - nokogiri (1.19.1-x86_64-darwin) - racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) + openssl (4.0.1) ostruct (0.6.3) parallel (1.27.0) parser (3.3.10.2) @@ -471,7 +469,6 @@ GEM thruster (0.1.18) thruster (0.1.18-aarch64-linux) thruster (0.1.18-arm64-darwin) - thruster (0.1.18-x86_64-darwin) thruster (0.1.18-x86_64-linux) timeout (0.6.0) tsort (0.2.0) @@ -490,6 +487,9 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) + web-push (3.1.0) + jwt (~> 3.0) + openssl (>= 3.0) websocket (1.2.11) websocket-driver (0.8.0) base64 @@ -505,8 +505,7 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl - arm64-darwin - x86_64-darwin + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -566,8 +565,6 @@ CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 - bcrypt_pbkdf (1.1.2-arm64-darwin) sha256=afdd6feb6ed5a97b8e44caacb3f2d641b98af78e6a516d4a3520b69af5cf9fea - bcrypt_pbkdf (1.1.2-x86_64-darwin) sha256=35f5639d0058e6c2cc2f856f9c0b14080543268d3047abe6bc81c513093caa0e bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214 @@ -578,7 +575,6 @@ CHECKSUMS commonmarker (2.6.3-aarch64-linux) sha256=73795e80ab5ef1e4b5b83ada6f082bccb0ed7eae0b910232e13af1b2d71b14d6 commonmarker (2.6.3-arm-linux) sha256=62b9f32d7d3f85d47988a4a98a2e66e60ca42b894687047db8332f1e80caff7b commonmarker (2.6.3-arm64-darwin) sha256=d6c1e4955619da3f68fed22de99dec49a24925611770c039bf870823846c8b21 - commonmarker (2.6.3-x86_64-darwin) sha256=cd8ab974bb24f675a250ea91a811b3ff70405be1c219f0052446995db6ca90c6 commonmarker (2.6.3-x86_64-linux) sha256=e861ba1812721113725ebd8e46e4fee20dc732842f5555db2cfb8dcd74056583 commonmarker (2.6.3-x86_64-linux-musl) sha256=2c62d2dc0d5c4efc6dde39bc5c5fac292169206601a3daf75e562d70b795d49e concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab @@ -608,7 +604,6 @@ CHECKSUMS ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668 ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053 ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f - ffi (1.17.3-x86_64-darwin) sha256=1f211811eb5cfaa25998322cdd92ab104bfbd26d1c4c08471599c511f2c00bb5 ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f ffi (1.17.3-x86_64-linux-musl) sha256=086b221c3a68320b7564066f46fed23449a44f7a1935f1fe5a245bd89d9aea56 formtastic (6.0.0) sha256=c398906b65978fec3d045d6792f82cf9641f086ac9f17357b2b382f723126165 @@ -626,6 +621,7 @@ CHECKSUMS irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + jwt (3.1.2) sha256=af6991f19a6bb4060d618d9add7a66f0eeb005ac0bc017cd01f63b42e122d535 kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 kaminari (1.2.2) sha256=c4076ff9adccc6109408333f87b5c4abbda5e39dc464bd4c66d06d9f73442a3e kaminari-actionview (1.2.2) sha256=1330f6fc8b59a4a4ef6a549ff8a224797289ebf7a3a503e8c1652535287cc909 @@ -658,9 +654,9 @@ CHECKSUMS nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3 nokogiri (1.19.1-arm-linux-musl) sha256=3a18e559ee499b064aac6562d98daab3d39ba6cbb4074a1542781b2f556db47d nokogiri (1.19.1-arm64-darwin) sha256=dfe2d337e6700eac47290407c289d56bcf85805d128c1b5a6434ddb79731cb9e - nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 + openssl (4.0.1) sha256=e27974136b7b02894a1bce46c5397ee889afafe704a839446b54dc81cb9c5f7d ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357 @@ -717,7 +713,6 @@ CHECKSUMS thruster (0.1.18) sha256=f025103bc7c8e6747436bb9de058c366840d2871560574ea7070a9bc8608a889 thruster (0.1.18-aarch64-linux) sha256=16f3d49468d76a9a5de86b7bdedf535b7b80da7c16495ca8ec96cfdc256870e2 thruster (0.1.18-arm64-darwin) sha256=8b297797a354ec6a81ea156b44279b66bff8da2404112f70f4ec515c2f276cc2 - thruster (0.1.18-x86_64-darwin) sha256=355b6c0ee30ead7f7096448de4f0f9e8acc8454d2ef24b2d54965c5d813f1c67 thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f @@ -728,6 +723,7 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + web-push (3.1.0) sha256=501ab00340c58fb70dabda59f28a86b3cccb0930c6f1f30721b2a5b24de7187d websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 diff --git a/config/initializers/coplan.rb b/config/initializers/coplan.rb index 9ef658c..fedf72b 100644 --- a/config/initializers/coplan.rb +++ b/config/initializers/coplan.rb @@ -31,4 +31,12 @@ SlackNotificationJob.perform_later(comment_thread_id: payload[:comment_thread_id]) end } + + # Web Push (VAPID) keys for browser notifications. For local dev these are + # checked in; in production they should come from Rails encrypted credentials + # or your secrets manager. Generate fresh keys with: + # bundle exec rake coplan:web_push:generate_keys + config.vapid_public_key = ENV["COPLAN_VAPID_PUBLIC_KEY"] || "BPY5NsdGJ4vEmHHNz3SqK2XsmV93j-iR3-kqN-RMbl4JRd9jnKpzunwdXDwFwlzbRlPErn3x379e6Cz7DfdSS6o=" + config.vapid_private_key = ENV["COPLAN_VAPID_PRIVATE_KEY"] || "1HoYR1d8QIlf8RYTfugJQFTyLlBat3zd-EFkj5dO9WQ=" + config.vapid_subject = ENV["COPLAN_VAPID_SUBJECT"] || "mailto:dev@coplan.local" end diff --git a/db/migrate/20260508203305_create_coplan_web_push_subscriptions.co_plan.rb b/db/migrate/20260508203305_create_coplan_web_push_subscriptions.co_plan.rb new file mode 100644 index 0000000..a1276fc --- /dev/null +++ b/db/migrate/20260508203305_create_coplan_web_push_subscriptions.co_plan.rb @@ -0,0 +1,24 @@ +# This migration comes from co_plan (originally 20260508000000) +class CreateCoplanWebPushSubscriptions < ActiveRecord::Migration[8.0] + def change + create_table :coplan_web_push_subscriptions, id: :string, limit: 36 do |t| + t.string :user_id, null: false, limit: 36 + t.text :endpoint, null: false + # SHA256 hex digest of endpoint, used for unique constraint since the + # full endpoint can exceed indexable string limits (FCM endpoints can + # be 200-400 chars). + t.string :endpoint_digest, null: false, limit: 64 + t.string :p256dh_key, null: false, limit: 255 + t.string :auth_key, null: false, limit: 100 + t.string :user_agent + t.datetime :last_seen_at + t.datetime :last_delivered_at + t.integer :notifications_delivered_count, null: false, default: 0 + t.timestamps + end + + add_index :coplan_web_push_subscriptions, :user_id + add_index :coplan_web_push_subscriptions, :endpoint_digest, unique: true + add_foreign_key :coplan_web_push_subscriptions, :coplan_users, column: :user_id + end +end diff --git a/db/schema.rb b/db/schema.rb index a1ef96d..05614bf 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_29_191637) do +ActiveRecord::Schema[8.1].define(version: 2026_05_08_203305) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -261,6 +261,22 @@ t.index ["username"], name: "index_coplan_users_on_username", unique: true end + create_table "coplan_web_push_subscriptions", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "auth_key", limit: 100, null: false + t.datetime "created_at", null: false + t.text "endpoint", null: false + t.string "endpoint_digest", limit: 64, null: false + t.datetime "last_delivered_at" + t.datetime "last_seen_at" + t.integer "notifications_delivered_count", default: 0, null: false + t.string "p256dh_key", null: false + t.datetime "updated_at", null: false + t.string "user_agent" + t.string "user_id", limit: 36, null: false + t.index ["endpoint_digest"], name: "index_coplan_web_push_subscriptions_on_endpoint_digest", unique: true + t.index ["user_id"], name: "index_coplan_web_push_subscriptions_on_user_id" + end + add_foreign_key "coplan_api_tokens", "coplan_users", column: "user_id" add_foreign_key "coplan_comment_threads", "coplan_plan_versions", column: "addressed_in_plan_version_id" add_foreign_key "coplan_comment_threads", "coplan_plan_versions", column: "out_of_date_since_version_id" @@ -289,4 +305,5 @@ add_foreign_key "coplan_plans", "coplan_users", column: "created_by_user_id" add_foreign_key "coplan_references", "coplan_plans", column: "plan_id" add_foreign_key "coplan_references", "coplan_plans", column: "target_plan_id" + add_foreign_key "coplan_web_push_subscriptions", "coplan_users", column: "user_id" end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 8eeb5fe..bb8fd44 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -1428,11 +1428,17 @@ img.avatar { color: var(--color-text-muted); } +/* Honor [hidden] even on elements with explicit display rules (.btn etc.) */ +[hidden] { + display: none !important; +} + /* Settings row */ .settings-row { display: flex; align-items: center; justify-content: space-between; + gap: var(--space-md); } .settings-row__label { @@ -1440,6 +1446,81 @@ img.avatar { color: var(--color-text); } +.settings-row__main { + flex: 1 1 auto; + min-width: 0; +} + +.settings-row__hint { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-xs); + line-height: 1.5; +} + +.settings-row__action { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-xs); +} + +.settings-row__status { + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.settings-subsection { + margin-top: var(--space-md); + padding-top: var(--space-md); + border-top: 1px solid var(--color-border); +} + +.settings-subsection__heading { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-sm); +} + +.settings-subsection__hint { + font-size: var(--text-sm); + color: var(--color-text-muted); + margin-top: var(--space-sm); + margin-bottom: 0; +} + +.device-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.device-list__item { + padding: var(--space-sm) var(--space-md); + background: var(--color-surface-alt, var(--color-surface)); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.device-list__name { + font-weight: 500; + color: var(--color-text); + font-size: var(--text-sm); +} + +.device-list__meta { + font-size: var(--text-xs); + color: var(--color-text-muted); + margin-top: var(--space-xs); +} + /* Theme switcher (segmented control) */ .theme-switcher { display: inline-flex; diff --git a/engine/app/controllers/coplan/service_workers_controller.rb b/engine/app/controllers/coplan/service_workers_controller.rb new file mode 100644 index 0000000..29203c1 --- /dev/null +++ b/engine/app/controllers/coplan/service_workers_controller.rb @@ -0,0 +1,23 @@ +module CoPlan + class ServiceWorkersController < ApplicationController + # Service worker registration runs from the browser without our session + # cookie, and the SW URL needs to be public anyway. + skip_before_action :authenticate_coplan_user! + skip_before_action :verify_authenticity_token, raise: false + + SW_PATH = CoPlan::Engine.root.join("app/javascript/coplan_service_worker.js") + SW_BODY = SW_PATH.read.freeze + + def show + # No Service-Worker-Allowed header on purpose: default scope is the + # SW's own directory, which is the engine mount point. Push events + # fire regardless of scope, so this doesn't limit notification reach. + # + # Render inline rather than send_file: the JS lives inside the gem, + # so any reverse proxy that intercepts X-Sendfile (NGINX et al) won't + # reach it. The file is small and cached in memory at boot. + response.headers["Cache-Control"] = "no-cache" + render plain: SW_BODY, content_type: "application/javascript" + end + end +end diff --git a/engine/app/controllers/coplan/settings/settings_controller.rb b/engine/app/controllers/coplan/settings/settings_controller.rb index f67c7dc..f5604fd 100644 --- a/engine/app/controllers/coplan/settings/settings_controller.rb +++ b/engine/app/controllers/coplan/settings/settings_controller.rb @@ -3,6 +3,7 @@ module Settings class SettingsController < ApplicationController def index @api_tokens = current_user.api_tokens.order(created_at: :desc) + @web_push_subscriptions = current_user.web_push_subscriptions.order(created_at: :desc) end def update_theme diff --git a/engine/app/controllers/coplan/web_push/subscriptions_controller.rb b/engine/app/controllers/coplan/web_push/subscriptions_controller.rb new file mode 100644 index 0000000..5552856 --- /dev/null +++ b/engine/app/controllers/coplan/web_push/subscriptions_controller.rb @@ -0,0 +1,42 @@ +module CoPlan + module WebPush + class SubscriptionsController < ApplicationController + before_action :require_web_push_configured + + # POST /web_push/subscription + # Body: { subscription: { endpoint, keys: { p256dh, auth } } } + def create + sub_params = subscription_params + record = WebPushSubscription.upsert_for( + user: current_user, + endpoint: sub_params[:endpoint], + p256dh_key: sub_params.dig(:keys, :p256dh), + auth_key: sub_params.dig(:keys, :auth), + user_agent: request.user_agent&.truncate(255) + ) + render json: { id: record.id, created_at: record.created_at }, status: :created + end + + # DELETE /web_push/subscription + # Body: { subscription: { endpoint } } + def destroy + endpoint = subscription_params[:endpoint] + digest = WebPushSubscription.digest_for(endpoint) + # Scope to current_user so a leaked endpoint can't unsubscribe + # someone else. + deleted = current_user.web_push_subscriptions.where(endpoint_digest: digest).delete_all + head deleted.positive? ? :no_content : :not_found + end + + private + + def subscription_params + params.require(:subscription).permit(:endpoint, keys: [:p256dh, :auth]) + end + + def require_web_push_configured + head :service_unavailable unless CoPlan.configuration.web_push_configured? + end + end + end +end diff --git a/engine/app/javascript/controllers/coplan/web_push_settings_controller.js b/engine/app/javascript/controllers/coplan/web_push_settings_controller.js new file mode 100644 index 0000000..b15aa62 --- /dev/null +++ b/engine/app/javascript/controllers/coplan/web_push_settings_controller.js @@ -0,0 +1,75 @@ +import { Controller } from "@hotwired/stimulus" +import * as WebPush from "coplan/web_push" + +// Drives the Notifications card on the Settings page. Reflects the current +// browser's subscription state (which the server can't know — it's per-device). +export default class extends Controller { + static targets = ["enableButton", "disableButton", "status"] + + async connect() { + await this._refresh() + } + + async enable() { + this._setStatus("Requesting permission…") + try { + await WebPush.subscribe() + this._setStatus("Notifications enabled on this device.") + } catch (err) { + this._setStatus(this._friendlyError(err)) + } + await this._refresh() + } + + async disable() { + this._setStatus("Disabling…") + try { + await WebPush.unsubscribe() + this._setStatus("Notifications disabled on this device.") + } catch (err) { + this._setStatus(this._friendlyError(err)) + } + await this._refresh() + } + + // ---- internals ---- + + async _refresh() { + if (!WebPush.isSupported()) { + this._setStatus("This browser doesn't support web push notifications.") + this._show(this.enableButtonTarget, false) + this._show(this.disableButtonTarget, false) + return + } + + const perm = WebPush.permission() + if (perm === "denied") { + this._setStatus("Notifications are blocked. Allow them in your browser settings to enable.") + this._show(this.enableButtonTarget, false) + this._show(this.disableButtonTarget, false) + return + } + + const subscribed = await WebPush.isSubscribed() + this._show(this.enableButtonTarget, !subscribed) + this._show(this.disableButtonTarget, subscribed) + if (!this.statusTarget.textContent) { + this._setStatus(subscribed ? "Enabled on this device." : "") + } + } + + _show(el, visible) { + if (!el) return + el.hidden = !visible + } + + _setStatus(text) { + if (this.hasStatusTarget) this.statusTarget.textContent = text + } + + _friendlyError(err) { + const msg = err?.message || String(err) + if (/permission/i.test(msg)) return "Permission was not granted." + return msg + } +} diff --git a/engine/app/javascript/coplan/web_push.js b/engine/app/javascript/coplan/web_push.js new file mode 100644 index 0000000..241ff31 --- /dev/null +++ b/engine/app/javascript/coplan/web_push.js @@ -0,0 +1,138 @@ +// Web Push API helpers shared by Stimulus controllers (settings UI, banner). +// No DOM, no Stimulus — pure functions over navigator.serviceWorker / PushManager. + +export function isSupported() { + return ( + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window && + vapidPublicKey() !== null + ) +} + +export function permission() { + return ("Notification" in window) ? Notification.permission : "unsupported" +} + +export async function isSubscribed() { + if (!isSupported()) return false + const reg = await getRegistration() + if (!reg) return false + const sub = await reg.pushManager.getSubscription() + return !!sub +} + +// Prompt for permission, register the SW, subscribe via PushManager, +// and POST the subscription to the engine. Returns the subscription or throws. +export async function subscribe() { + if (!isSupported()) throw new Error("Web Push not supported in this browser") + + const perm = await Notification.requestPermission() + if (perm !== "granted") throw new Error(`Notification permission ${perm}`) + + const reg = await registerServiceWorker() + const existing = await reg.pushManager.getSubscription() + const subscription = existing || await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey()) + }) + + await postSubscription(subscription) + return subscription +} + +// Unsubscribe locally and tell the server to forget us. Returns true if +// anything was actually unsubscribed. +export async function unsubscribe() { + if (!("serviceWorker" in navigator)) return false + const reg = await getRegistration() + if (!reg) return false + const sub = await reg.pushManager.getSubscription() + if (!sub) return false + + await deleteSubscription(sub) + await sub.unsubscribe() + return true +} + +// ---- internals ---- + +function vapidPublicKey() { + return document.head.querySelector("meta[name='coplan-vapid-public-key']")?.content || null +} + +function serviceWorkerUrl() { + return document.head.querySelector("meta[name='coplan-service-worker-url']")?.content || null +} + +function csrfToken() { + return document.head.querySelector("meta[name='csrf-token']")?.content || "" +} + +async function getRegistration() { + const url = serviceWorkerUrl() + if (!url) return null + return await navigator.serviceWorker.getRegistration(url) || + await navigator.serviceWorker.getRegistration() +} + +async function registerServiceWorker() { + const url = serviceWorkerUrl() + if (!url) throw new Error("Service worker URL meta tag missing") + await navigator.serviceWorker.register(url) + // register() resolves as soon as the SW is *registered*, but PushManager + // needs it to be *active*. ready resolves with the registration once any + // SW for the current page's scope reaches active state. + return await navigator.serviceWorker.ready +} + +async function postSubscription(subscription) { + const json = subscription.toJSON() + const response = await fetch(endpointUrl(), { + method: "POST", + headers: headers(), + credentials: "same-origin", + body: JSON.stringify({ subscription: json }) + }) + if (!response.ok) throw new Error(`Subscription POST failed: ${response.status}`) +} + +async function deleteSubscription(subscription) { + const json = subscription.toJSON() + const response = await fetch(endpointUrl(), { + method: "DELETE", + headers: headers(), + credentials: "same-origin", + body: JSON.stringify({ subscription: { endpoint: json.endpoint } }) + }) + // 404 is fine — server already had nothing to delete. + if (!response.ok && response.status !== 404) { + throw new Error(`Subscription DELETE failed: ${response.status}`) + } +} + +function endpointUrl() { + // Engine mount point is the directory of the SW URL — works no matter + // where the host mounts CoPlan. + const swUrl = serviceWorkerUrl() + const base = swUrl.replace(/\/coplan_service_worker\.js$/, "") + return `${base}/web_push/subscription` +} + +function headers() { + return { + "Content-Type": "application/json", + "Accept": "application/json", + "X-CSRF-Token": csrfToken() + } +} + +// VAPID public key arrives as URL-safe base64; PushManager wants Uint8Array. +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") + const raw = atob(base64) + const output = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) output[i] = raw.charCodeAt(i) + return output +} diff --git a/engine/app/javascript/coplan_service_worker.js b/engine/app/javascript/coplan_service_worker.js new file mode 100644 index 0000000..3d19790 --- /dev/null +++ b/engine/app/javascript/coplan_service_worker.js @@ -0,0 +1,68 @@ +// CoPlan service worker. +// Served by Coplan::ServiceWorkersController#show with Cache-Control: no-cache. +// Default scope is the engine mount point. + +self.addEventListener("install", (event) => { + // Activate immediately on update so notifications use the latest handler. + self.skipWaiting() +}) + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()) +}) + +// Push payloads are JSON: { title, body, url, tag } +// `tag` groups/dedups notifications (e.g., "comment-thread-#{id}"). +// `url` may include a hash for deep-linking to a thread popover. +self.addEventListener("push", (event) => { + if (!event.data) return + + let payload + try { + payload = event.data.json() + } catch { + payload = { title: "CoPlan", body: event.data.text() } + } + + const title = payload.title || "CoPlan" + const options = { + body: payload.body || "", + tag: payload.tag, + data: { url: payload.url } + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener("notificationclick", (event) => { + event.notification.close() + const targetUrl = event.notification.data?.url + if (!targetUrl) return + + event.waitUntil((async () => { + const allClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true + }) + + // Prefer focusing an existing CoPlan tab and navigating it. We compare + // origins so a CoPlan tab on /plans/foo handles a notification for + // /plans/bar without spawning a new window. + const targetUrlObj = new URL(targetUrl, self.location.origin) + for (const client of allClients) { + const clientUrl = new URL(client.url) + if (clientUrl.origin !== targetUrlObj.origin) continue + if ("focus" in client) { + await client.focus() + if ("navigate" in client && client.url !== targetUrlObj.href) { + await client.navigate(targetUrlObj.href) + } + return + } + } + + if (self.clients.openWindow) { + await self.clients.openWindow(targetUrl) + } + })()) +}) diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 29bb1bc..832f150 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -6,6 +6,7 @@ class User < ApplicationRecord has_many :created_plans, class_name: "CoPlan::Plan", foreign_key: :created_by_user_id, dependent: :nullify, inverse_of: :created_by_user has_many :plan_collaborators, dependent: :destroy has_many :notifications, dependent: :destroy + has_many :web_push_subscriptions, class_name: "CoPlan::WebPushSubscription", dependent: :destroy validates :external_id, presence: true, uniqueness: true validates :name, presence: true diff --git a/engine/app/models/coplan/web_push_subscription.rb b/engine/app/models/coplan/web_push_subscription.rb new file mode 100644 index 0000000..94e6d88 --- /dev/null +++ b/engine/app/models/coplan/web_push_subscription.rb @@ -0,0 +1,83 @@ +require "digest" + +module CoPlan + class WebPushSubscription < ApplicationRecord + belongs_to :user, class_name: "CoPlan::User" + + validates :endpoint, presence: true + validates :p256dh_key, presence: true + validates :auth_key, presence: true + validates :endpoint_digest, presence: true, uniqueness: true + + before_validation :compute_endpoint_digest, if: :endpoint_changed? + + # Hash an endpoint into the form stored as endpoint_digest. Centralized + # so callers don't repeat the algorithm. + def self.digest_for(endpoint) + Digest::SHA256.hexdigest(endpoint.to_s) + end + + # Idempotent upsert from a browser PushSubscription payload. The same + # browser+device subscribing twice should overwrite (not duplicate) so + # we key on the endpoint digest. Concurrent inserts are tolerated by + # retrying the lookup after a unique-constraint collision. + def self.upsert_for(user:, endpoint:, p256dh_key:, auth_key:, user_agent: nil) + digest = digest_for(endpoint) + attrs = { + user: user, + endpoint: endpoint, + p256dh_key: p256dh_key, + auth_key: auth_key, + user_agent: user_agent, + last_seen_at: Time.current + } + + record = find_or_initialize_by(endpoint_digest: digest) + record.assign_attributes(attrs) + begin + record.save! + rescue ActiveRecord::RecordNotUnique + record = find_by!(endpoint_digest: digest) + record.update!(attrs) + end + record + end + + def record_delivery! + # Atomic increment so concurrent deliveries can't lose updates. + increment!(:notifications_delivered_count, touch: :last_delivered_at) + end + + # Best-effort friendly label like "Chrome on macOS" derived from the raw + # User-Agent. Falls back to the raw UA, then "Unknown browser". + def device_label + ua = user_agent.to_s + return "Unknown browser" if ua.blank? + + browser = case ua + when /Edg\// then "Edge" + when /OPR\// then "Opera" + when /Firefox\// then "Firefox" + when /Chrome\// then "Chrome" + when /Safari\// then "Safari" + end + + os = case ua + when /iPhone OS|iOS/ then "iOS" + when /iPad/ then "iPadOS" + when /Android/ then "Android" + when /Mac OS X|Macintosh/ then "macOS" + when /Windows NT/ then "Windows" + when /Linux/ then "Linux" + end + + return [browser, os].compact.join(" on ").presence || ua.truncate(80) + end + + private + + def compute_endpoint_digest + self.endpoint_digest = self.class.digest_for(endpoint) if endpoint.present? + end + end +end diff --git a/engine/app/views/coplan/settings/settings/_notifications.html.erb b/engine/app/views/coplan/settings/settings/_notifications.html.erb new file mode 100644 index 0000000..03ed2e4 --- /dev/null +++ b/engine/app/views/coplan/settings/settings/_notifications.html.erb @@ -0,0 +1,55 @@ +<% return unless CoPlan.configuration.web_push_configured? %> + +
+ To remove a device, sign in to CoPlan from that browser and click "Disable on this device". +
+