diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb index ec2256aa9c..aa16270a4a 100644 --- a/app/controllers/concerns/web_app_controller_concern.rb +++ b/app/controllers/concerns/web_app_controller_concern.rb @@ -8,6 +8,7 @@ module WebAppControllerConcern before_action :redirect_unauthenticated_to_permalinks! before_action :set_referer_header + before_action :redirect_to_tos_interstitial! content_security_policy do |p| policy = ContentSecurityPolicy.new @@ -45,6 +46,12 @@ module WebAppControllerConcern protected + def redirect_to_tos_interstitial! + return unless current_user&.require_tos_interstitial + + redirect_to(terms_of_service_interstitial_url) + end + def set_referer_header response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'strict-origin-when-cross-origin' : 'same-origin') end diff --git a/app/controllers/terms_of_service_controller.rb b/app/controllers/terms_of_service_controller.rb index 672fb07915..35b9d45fe8 100644 --- a/app/controllers/terms_of_service_controller.rb +++ b/app/controllers/terms_of_service_controller.rb @@ -4,8 +4,19 @@ class TermsOfServiceController < ApplicationController include WebAppControllerConcern skip_before_action :require_functional! + skip_before_action :redirect_to_tos_interstitial! + + before_action :clear_redirect_interstitial! def show expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? end + + private + + def clear_redirect_interstitial! + return unless user_signed_in? + + current_user.update(require_tos_interstitial: false) + end end diff --git a/app/controllers/terms_of_service_interstitial_controller.rb b/app/controllers/terms_of_service_interstitial_controller.rb new file mode 100644 index 0000000000..081e58e2d7 --- /dev/null +++ b/app/controllers/terms_of_service_interstitial_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TermsOfServiceInterstitialController < ApplicationController + vary_by 'Accept-Language' + + def show + @terms_of_service = TermsOfService.published.first + + render 'terms_of_service_interstitial/show', layout: 'auth' + end +end diff --git a/app/models/terms_of_service.rb b/app/models/terms_of_service.rb index 3b69a40a1a..f29094036d 100644 --- a/app/models/terms_of_service.rb +++ b/app/models/terms_of_service.rb @@ -23,6 +23,8 @@ class TermsOfService < ApplicationRecord validate :effective_date_cannot_be_in_the_past + NOTIFICATION_ACTIVITY_CUTOFF = 1.year.freeze + def published? published_at.present? end @@ -39,8 +41,20 @@ class TermsOfService < ApplicationRecord notification_sent_at.present? end + def base_user_scope + User.confirmed.where(created_at: ..published_at).joins(:account) + end + + def email_notification_cutoff + published_at - NOTIFICATION_ACTIVITY_CUTOFF + end + + def scope_for_interstitial + base_user_scope.merge(Account.suspended).or(base_user_scope.where(current_sign_in_at: [nil, ...email_notification_cutoff])) + end + def scope_for_notification - User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at)) + base_user_scope.merge(Account.without_suspended).where(current_sign_in_at: email_notification_cutoff...) end private diff --git a/app/models/user.rb b/app/models/user.rb index 72f7490043..cc2d348052 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -25,6 +25,7 @@ # otp_backup_codes :string is an Array # otp_required_for_login :boolean default(FALSE), not null # otp_secret :string +# require_tos_interstitial :boolean default(FALSE), not null # reset_password_sent_at :datetime # reset_password_token :string # settings :text diff --git a/app/views/terms_of_service_interstitial/show.html.haml b/app/views/terms_of_service_interstitial/show.html.haml new file mode 100644 index 0000000000..cbb181a32d --- /dev/null +++ b/app/views/terms_of_service_interstitial/show.html.haml @@ -0,0 +1,14 @@ +- content_for :header_tags do + %meta{ name: 'robots', content: 'noindex, noarchive' }/ + +- content_for :body_classes, 'app-body' + +.simple_form + %h1.title= t('terms_of_service_interstitial.title', domain: site_hostname) + + %p.lead= t('terms_of_service_interstitial.preamble_html', date: l(@terms_of_service.effective_date || Time.zone.today)) + + %p.lead= t('user_mailer.terms_of_service_changed.agreement', domain: site_hostname) + + .stacked-actions + = link_to t('terms_of_service_interstitial.review_link'), terms_of_service_path, class: 'button' diff --git a/app/workers/admin/distribute_terms_of_service_notification_worker.rb b/app/workers/admin/distribute_terms_of_service_notification_worker.rb index 7370ee87e8..3e1f651060 100644 --- a/app/workers/admin/distribute_terms_of_service_notification_worker.rb +++ b/app/workers/admin/distribute_terms_of_service_notification_worker.rb @@ -6,6 +6,8 @@ class Admin::DistributeTermsOfServiceNotificationWorker def perform(terms_of_service_id) terms_of_service = TermsOfService.find(terms_of_service_id) + terms_of_service.scope_for_interstitial.in_batches.update_all(require_tos_interstitial: true) + terms_of_service.scope_for_notification.find_each do |user| UserMailer.terms_of_service_changed(user, terms_of_service).deliver_later! end diff --git a/config/locales/en.yml b/config/locales/en.yml index 63ef106d5c..a5ba589002 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1912,6 +1912,10 @@ en: does_not_match_previous_name: does not match the previous name terms_of_service: title: Terms of Service + terms_of_service_interstitial: + preamble_html: We're making some changes to our terms of service, effective %{date}. We encourage you to review the updated terms. + review_link: Review terms of service + title: The terms of service of %{domain} are changing themes: contrast: Mastodon (High contrast) default: Mastodon (Dark) diff --git a/config/routes.rb b/config/routes.rb index 2fff44851e..f60dc22621 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -208,6 +208,7 @@ Rails.application.routes.draw do get '/privacy-policy', to: 'privacy#show', as: :privacy_policy get '/terms-of-service', to: 'terms_of_service#show', as: :terms_of_service get '/terms-of-service/:date', to: 'terms_of_service#show', as: :terms_of_service_version + get '/terms-of-service-update', to: 'terms_of_service_interstitial#show', as: :terms_of_service_interstitial get '/terms', to: redirect('/terms-of-service') match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false diff --git a/db/migrate/20250428104538_add_require_tos_interstitial_to_users.rb b/db/migrate/20250428104538_add_require_tos_interstitial_to_users.rb new file mode 100644 index 0000000000..646ebe5d09 --- /dev/null +++ b/db/migrate/20250428104538_add_require_tos_interstitial_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddRequireTosInterstitialToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :require_tos_interstitial, :boolean, null: false, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index db1687ba99..cd7eca8c4b 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.0].define(version: 2025_04_28_095029) do +ActiveRecord::Schema[8.0].define(version: 2025_04_28_104538) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1222,6 +1222,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do t.string "time_zone" t.string "otp_secret" t.datetime "age_verified_at" + t.boolean "require_tos_interstitial", default: false, null: false t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)" diff --git a/spec/workers/admin/distribute_terms_of_service_notification_worker_spec.rb b/spec/workers/admin/distribute_terms_of_service_notification_worker_spec.rb index 27ddfb28bc..a09e581151 100644 --- a/spec/workers/admin/distribute_terms_of_service_notification_worker_spec.rb +++ b/spec/workers/admin/distribute_terms_of_service_notification_worker_spec.rb @@ -14,9 +14,10 @@ RSpec.describe Admin::DistributeTermsOfServiceNotificationWorker do context 'with valid terms' do let(:terms) { Fabricate(:terms_of_service) } - let!(:user) { Fabricate :user, confirmed_at: 3.days.ago } + let!(:user) { Fabricate(:user, confirmed_at: 3.days.ago) } + let!(:old_user) { Fabricate(:user, confirmed_at: 2.years.ago, current_sign_in_at: 2.years.ago) } - it 'sends the terms update via email', :inline_jobs do + it 'sends the terms update via email and change the old user to require an interstitial', :inline_jobs do emails = capture_emails { worker.perform(terms.id) } expect(emails.size) @@ -26,6 +27,9 @@ RSpec.describe Admin::DistributeTermsOfServiceNotificationWorker do to: [user.email], subject: I18n.t('user_mailer.terms_of_service_changed.subject') ) + + expect(user.reload.require_tos_interstitial).to be false + expect(old_user.reload.require_tos_interstitial).to be true end end end