diff --git a/Dockerfile b/Dockerfile index f33e136928..6620f4c096 100644 --- a/Dockerfile +++ b/Dockerfile @@ -186,7 +186,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.16.0 +ARG VIPS_VERSION=8.16.1 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download diff --git a/Gemfile.lock b/Gemfile.lock index 1bfb0ddbc3..753b70b31f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,23 +93,24 @@ GEM annotaterb (4.14.0) ast (2.4.2) attr_required (1.0.2) - aws-eventstream (1.3.0) - aws-partitions (1.1032.0) - aws-sdk-core (3.214.1) + aws-eventstream (1.3.2) + aws-partitions (1.1066.0) + aws-sdk-core (3.220.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-kms (1.99.0) + aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.177.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.11.0) aws-eventstream (~> 1, >= 1.0.2) - azure-blob (0.5.4) + azure-blob (0.5.7) rexml base64 (0.2.0) bcp47_spec (0.2.1) @@ -168,7 +169,7 @@ GEM bigdecimal rexml crass (1.0.6) - css_parser (1.21.0) + css_parser (1.21.1) addressable csv (3.3.2) database_cleaner-active_record (2.2.0) @@ -222,7 +223,8 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - excon (1.2.3) + excon (1.2.5) + logger fabrication (2.31.0) faker (3.5.1) i18n (>= 1.8.11, < 2) @@ -265,7 +267,9 @@ GEM raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - google-protobuf (3.25.6) + google-protobuf (4.30.1) + bigdecimal + rake (>= 13) googleapis-common-protos-types (1.18.0) google-protobuf (>= 3.18, < 5.a) haml (6.3.0) @@ -302,7 +306,8 @@ GEM domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m httplog (1.7.0) rack (>= 2.0) rainbow (>= 2.0.0) @@ -378,7 +383,7 @@ GEM mime-types terrapin (>= 0.6.0, < 2.0) language_server-protocol (3.17.0.4) - launchy (3.1.0) + launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) logger (~> 1.6) @@ -391,7 +396,7 @@ GEM rexml link_header (0.0.8) lint_roller (1.1.0) - llhttp-ffi (0.5.0) + llhttp-ffi (0.5.1) ffi-compiler (~> 1.0) rake (~> 13.0) logger (1.6.6) @@ -416,10 +421,10 @@ GEM mime-types (3.6.0) logger mime-types-data (~> 3.2015) - mime-types-data (3.2025.0220) + mime-types-data (3.2025.0304) mini_mime (1.1.5) mini_portile2 (2.8.8) - minitest (5.25.4) + minitest (5.25.5) msgpack (1.8.0) multi_json (1.15.0) mutex_m (0.3.0) @@ -729,7 +734,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.2) - rubocop (1.73.2) + rubocop (1.74.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -1069,4 +1074,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.6.5 + 2.6.6 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index f7d3de7f94..ae8df69a28 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -119,7 +119,7 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth) end def invite diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 6e34b6b627..0b6f5b3af4 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |user_params| - user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) + user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth) end end diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb new file mode 100644 index 0000000000..131234b02e --- /dev/null +++ b/app/inputs/date_of_birth_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class DateOfBirthInput < SimpleForm::Inputs::Base + OPTIONS = [ + { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze, + { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze, + { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze, + ].freeze + + def input(wrapper_options = nil) + merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) + merged_input_options[:inputmode] = 'numeric' + + values = (object.public_send(attribute_name) || '').split('.') + + safe_join(Array.new(3) do |index| + options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index] + @builder.text_field("#{attribute_name}(#{index + 1}i)", options) + end) + end + + def label_target + "#{attribute_name}_1i" + end + + private + + def generate_id(index) + "#{object_name}_#{attribute_name}_#{index + 1}i" + end +end diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 937790ebcf..199fdf70e7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9288,6 +9288,7 @@ noscript { border: 1px solid $highlight-text-color; background: rgba($highlight-text-color, 0.15); overflow: hidden; + flex-shrink: 0; &__background-image { width: 125%; diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 7df7e14b2b..73043842a4 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -353,6 +353,22 @@ code { } } + .input.date_of_birth .label_input { + display: flex; + gap: 8px; + align-items: center; + + input { + box-sizing: content-box; + width: 32px; + flex: 0; + + &:last-child { + width: 64px; + } + } + } + .input.select.select--languages { min-width: 32ch; } diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 5e84293111..fc7d8e778b 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -46,12 +46,14 @@ class Form::AdminSettings authorized_fetch app_icon favicon + min_age ).freeze INTEGER_KEYS = %i( media_cache_retention_period content_cache_retention_period backups_retention_period + min_age ).freeze BOOLEAN_KEYS = %i( @@ -103,6 +105,7 @@ class Form::AdminSettings validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) } validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) } validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } + validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) } validates :status_page_url, url: true, allow_blank: true validate :validate_site_uploads diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index b51236a032..5150898984 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -115,7 +115,7 @@ class MediaAttachment < ApplicationRecord VIDEO_PASSTHROUGH_OPTIONS = { video_codecs: ['h264'].freeze, audio_codecs: ['aac', nil].freeze, - colorspaces: ['yuv420p'].freeze, + colorspaces: ['yuv420p', 'yuvj420p'].freeze, options: { format: 'mp4', convert_options: { diff --git a/app/models/user.rb b/app/models/user.rb index 33a5ed152a..292dada6b2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -5,41 +5,42 @@ # Table name: users # # id :bigint(8) not null, primary key -# email :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null -# encrypted_password :string default(""), not null -# reset_password_token :string -# reset_password_sent_at :datetime -# sign_in_count :integer default(0), not null -# current_sign_in_at :datetime -# last_sign_in_at :datetime +# age_verified_at :datetime +# approved :boolean default(TRUE), not null +# chosen_languages :string is an Array +# confirmation_sent_at :datetime # confirmation_token :string # confirmed_at :datetime -# confirmation_sent_at :datetime -# unconfirmed_email :string -# locale :string +# consumed_timestep :integer +# current_sign_in_at :datetime +# disabled :boolean default(FALSE), not null +# email :string default(""), not null # encrypted_otp_secret :string # encrypted_otp_secret_iv :string # encrypted_otp_secret_salt :string -# consumed_timestep :integer -# otp_required_for_login :boolean default(FALSE), not null +# encrypted_password :string default(""), not null # last_emailed_at :datetime +# last_sign_in_at :datetime +# locale :string # otp_backup_codes :string is an Array -# account_id :bigint(8) not null -# disabled :boolean default(FALSE), not null -# invite_id :bigint(8) -# chosen_languages :string is an Array -# created_by_application_id :bigint(8) -# approved :boolean default(TRUE), not null +# otp_required_for_login :boolean default(FALSE), not null +# otp_secret :string +# reset_password_sent_at :datetime +# reset_password_token :string +# settings :text +# sign_in_count :integer default(0), not null # sign_in_token :string # sign_in_token_sent_at :datetime -# webauthn_id :string # sign_up_ip :inet -# role_id :bigint(8) -# settings :text # time_zone :string -# otp_secret :string +# unconfirmed_email :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint(8) not null +# created_by_application_id :bigint(8) +# invite_id :bigint(8) +# role_id :bigint(8) +# webauthn_id :string # class User < ApplicationRecord @@ -111,6 +112,7 @@ class User < ApplicationRecord validates_with RegistrationFormTimeValidator, on: :create validates :website, absence: true, on: :create validates :confirm_password, absence: true, on: :create + validates :date_of_birth, presence: true, date_of_birth: true, on: :create, if: -> { Setting.min_age.present? } validate :validate_role_elevation scope :account_not_suspended, -> { joins(:account).merge(Account.without_suspended) } @@ -129,6 +131,7 @@ class User < ApplicationRecord before_validation :sanitize_role before_create :set_approved + before_create :set_age_verified_at after_commit :send_pending_devise_notifications after_create_commit :trigger_webhooks @@ -140,7 +143,7 @@ class User < ApplicationRecord delegate :can?, to: :role - attr_reader :invite_code + attr_reader :invite_code, :date_of_birth attr_writer :external, :bypass_invite_request_check, :current_account def self.those_who_can(*any_of_privileges) @@ -157,6 +160,17 @@ class User < ApplicationRecord Rails.env.local? end + def date_of_birth=(hash_or_string) + @date_of_birth = begin + if hash_or_string.is_a?(Hash) + day, month, year = hash_or_string.values_at(1, 2, 3) + "#{day}.#{month}.#{year}" + else + hash_or_string + end + end + end + def role if role_id.nil? UserRole.everyone @@ -432,6 +446,10 @@ class User < ApplicationRecord end end + def set_age_verified_at + self.age_verified_at = Time.now.utc if Setting.min_age.present? + end + def grant_approval_on_confirmation? # Re-check approval on confirmation if the server has switched to open registrations open_registrations? && !sign_up_from_ip_requires_approval? && !sign_up_email_requires_approval? diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 1436f2bb26..87aa430174 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -108,6 +108,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer enabled: registrations_enabled?, approval_required: Setting.registrations_mode == 'approved', message: registrations_enabled? ? nil : registrations_message, + min_age: Setting.min_age.presence, url: ENV.fetch('SSO_ACCOUNT_SIGN_UP', nil), } end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index 7665880115..a4399efd65 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -41,7 +41,7 @@ class AppSignUpService < BaseService end def user_params - @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code) + @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code, :date_of_birth) end def account_params diff --git a/app/validators/date_of_birth_validator.rb b/app/validators/date_of_birth_validator.rb new file mode 100644 index 0000000000..79119d2c4c --- /dev/null +++ b/app/validators/date_of_birth_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class DateOfBirthValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :below_limit) if value.present? && value.to_date > min_age.ago + rescue Date::Error + record.errors.add(attribute, :invalid) + end + + private + + def min_age + Setting.min_age.to_i.years + end +end diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml index 4dbc5fbecf..cb5a3eb6ba 100644 --- a/app/views/admin/settings/registrations/show.html.haml +++ b/app/views/admin/settings/registrations/show.html.haml @@ -12,6 +12,9 @@ .flash-message= t('admin.settings.registrations.moderation_recommandation') + .fields-group + = f.input :min_age, as: :string, wrapper: :with_block_label, input_html: { inputmode: 'numeric' } + .fields-row .fields-row__column.fields-row__column-6.fields-group = f.input :registrations_mode, diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 5e9aa02d68..25479104ee 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -21,20 +21,19 @@ = f.simple_fields_for :account do |ff| = ff.input :username, append: "@#{site_hostname}", - input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT }, - label: false, + input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT, placeholder: ' ' }, required: true, wrapper: :with_label = f.input :email, hint: false, - input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' }, - placeholder: t('simple_form.labels.defaults.email'), - required: true + input_html: { autocomplete: 'username', placeholder: ' ' }, + required: true, + wrapper: :with_label = f.input :password, hint: false, - input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, - placeholder: t('simple_form.labels.defaults.password'), - required: true + input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last, placeholder: ' ' }, + required: true, + wrapper: :with_label = f.input :password_confirmation, hint: false, input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password', maxlength: User.password_length.last }, @@ -53,6 +52,14 @@ required: false, wrapper: :with_label + - if Setting.min_age.present? + .fields-group + = f.input :date_of_birth, + as: :date_of_birth, + hint: t('simple_form.hints.user.date_of_birth', age: Setting.min_age.to_i), + required: true, + wrapper: :with_block_label + - if approved_registrations? && @invite.blank? %p.lead= t('auth.sign_up.manual_review', domain: site_hostname) diff --git a/config/initializers/prometheus_exporter.rb b/config/initializers/prometheus_exporter.rb index fab095658f..fab08ceebb 100644 --- a/config/initializers/prometheus_exporter.rb +++ b/config/initializers/prometheus_exporter.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true' + require 'prometheus_exporter' + require 'prometheus_exporter/middleware' + if ENV['MASTODON_PROMETHEUS_EXPORTER_LOCAL'] == 'true' - require 'prometheus_exporter' require 'prometheus_exporter/server' require 'prometheus_exporter/client' @@ -17,9 +19,11 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true' if ENV['MASTODON_PROMETHEUS_EXPORTER_WEB_DETAILED_METRICS'] == 'true' # Optional, as those metrics might generate extra overhead and be redundant with what OTEL provides - require 'prometheus_exporter/middleware' - # Per-action/controller request stats like HTTP status and timings Rails.application.middleware.unshift PrometheusExporter::Middleware + else + # Include stripped down version of PrometheusExporter::Middleware that only collects queue time + require 'mastodon/middleware/prometheus_queue_time' + Rails.application.middleware.unshift Mastodon::Middleware::PrometheusQueueTime end end diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index ed389c1323..6940d589ca 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -55,6 +55,8 @@ en: too_soon: is too soon, must be later than %{date} user: attributes: + date_of_birth: + below_limit: is below the age limit email: blocked: uses a disallowed e-mail provider unreachable: does not seem to exist diff --git a/config/locales/simple_form.bg.yml b/config/locales/simple_form.bg.yml index 7a4fea91b2..bff9860005 100644 --- a/config/locales/simple_form.bg.yml +++ b/config/locales/simple_form.bg.yml @@ -136,6 +136,7 @@ bg: text: Може да се структурира със синтаксиса на Markdown. terms_of_service_generator: admin_email: Правните бележки включват насрещни известия, постановления на съда, заявки за сваляне и заявки от правоохранителните органи. + arbitration_address: Може да е същото като физическия адрес горе или "неприложимо", ако се употребява имейл. choice_of_law: Град, регион, територия, щат или държава, чиито вътрешни материални права ще уреждат всички искове. domain: Неповторимо идентифициране на онлайн услугата, която предоставяте. jurisdiction: Впишете държавата, където живее всеки, който плаща сметките. Ако е дружество или друго образувание, то впишете държавата, в която е регистрирано, и градът, регионът, територията или щатът според случая. diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 682a8179b2..cfb3adf13a 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -88,6 +88,7 @@ en: favicon: WEBP, PNG, GIF or JPG. Overrides the default Mastodon favicon with a custom icon. mascot: Overrides the illustration in the advanced web interface. media_cache_retention_period: Media files from posts made by remote users are cached on your server. When set to a positive value, media will be deleted after the specified number of days. If the media data is requested after it is deleted, it will be re-downloaded, if the source content is still available. Due to restrictions on how often link preview cards poll third-party sites, it is recommended to set this value to at least 14 days, or link preview cards will not be updated on demand before that time. + min_age: Users will be asked to confirm their date of birth during sign-up peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. profile_directory: The profile directory lists all users who have opted-in to be discoverable. require_invite_text: When sign-ups require manual approval, make the “Why do you want to join?” text input mandatory rather than optional @@ -146,6 +147,7 @@ en: min_age: Should not be below the minimum age required by the laws of your jurisdiction. user: chosen_languages: When checked, only posts in selected languages will be displayed in public timelines + date_of_birth: We have to make sure you're at least %{age} to use Mastodon. We won't store this. role: The role controls which permissions the user has. user_role: color: Color to be used for the role throughout the UI, as RGB in hex format @@ -271,6 +273,7 @@ en: favicon: Favicon mascot: Custom mascot (legacy) media_cache_retention_period: Media cache retention period + min_age: Minimum age requirement peers_api_enabled: Publish list of discovered servers in the API profile_directory: Enable profile directory registrations_mode: Who can sign-up @@ -349,6 +352,9 @@ en: jurisdiction: Legal jurisdiction min_age: Minimum age user: + date_of_birth_1i: Day + date_of_birth_2i: Month + date_of_birth_3i: Year role: Role time_zone: Time zone user_role: diff --git a/db/migrate/20250313123400_add_age_verified_at_to_users.rb b/db/migrate/20250313123400_add_age_verified_at_to_users.rb new file mode 100644 index 0000000000..c6cd6120ef --- /dev/null +++ b/db/migrate/20250313123400_add_age_verified_at_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAgeVerifiedAtToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :age_verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index c3c15c30fa..e05b7a0361 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_03_05_074104) do +ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1191,6 +1191,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_074104) do t.text "settings" t.string "time_zone" t.string "otp_secret" + t.datetime "age_verified_at" 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/lib/mastodon/middleware/prometheus_queue_time.rb b/lib/mastodon/middleware/prometheus_queue_time.rb new file mode 100644 index 0000000000..fae171612d --- /dev/null +++ b/lib/mastodon/middleware/prometheus_queue_time.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mastodon + module Middleware + class PrometheusQueueTime < ::PrometheusExporter::Middleware + # Overwrite to only collect the queue time metric + def call(env) + queue_time = measure_queue_time(env) + + result = @app.call(env) + + result + ensure + obj = { + type: 'web', + queue_time: queue_time, + default_labels: default_labels(env, result), + } + labels = custom_labels(env) + obj = obj.merge(custom_labels: labels) if labels + + @client.send_json(obj) + end + end + end +end diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index 739cb455e8..4e43592a4e 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -342,6 +342,42 @@ RSpec.describe Auth::RegistrationsController do end end + context 'when age verification is enabled' do + subject { post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', agreement: 'true' }.merge(date_of_birth) } } + + before do + Setting.min_age = 16 + end + + let(:date_of_birth) { {} } + + context 'when date of birth is below age limit' do + let(:date_of_birth) { 13.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } } + + it 'does not create user' do + subject + user = User.find_by(email: 'test@example.com') + expect(user).to be_nil + end + end + + context 'when date of birth is above age limit' do + let(:date_of_birth) { 17.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } } + + it 'redirects to setup and creates user' do + subject + + expect(response).to redirect_to auth_setup_path + + expect(User.find_by(email: 'test@example.com')) + .to be_present + .and have_attributes( + age_verified_at: not_eq(nil) + ) + end + end + end + include_examples 'checks for enabled registrations', :create end diff --git a/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb b/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb new file mode 100644 index 0000000000..eaab93772d --- /dev/null +++ b/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'prometheus_exporter' +require 'prometheus_exporter/middleware' +require 'mastodon/middleware/prometheus_queue_time' + +RSpec.describe Mastodon::Middleware::PrometheusQueueTime do + subject { described_class.new(app, client:) } + + let(:app) do + proc { |_env| [200, {}, 'OK'] } + end + let(:client) do + instance_double(PrometheusExporter::Client, send_json: true) + end + + describe '#call' do + let(:env) do + { + 'HTTP_X_REQUEST_START' => "t=#{(Time.now.to_f * 1000).to_i}", + } + end + + it 'reports a queue time to the client' do + subject.call(env) + + expect(client).to have_received(:send_json) + .with(hash_including(queue_time: instance_of(Float))) + end + end +end diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb index 16010ae2e7..9fe5b3d491 100644 --- a/spec/requests/api/v1/accounts_spec.rb +++ b/spec/requests/api/v1/accounts_spec.rb @@ -74,12 +74,45 @@ RSpec.describe '/api/v1/accounts' do describe 'POST /api/v1/accounts' do subject do - post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement } + post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement, date_of_birth: date_of_birth } end let(:client_app) { Fabricate(:application) } let(:token) { Doorkeeper::AccessToken.find_or_create_for(application: client_app, resource_owner: nil, scopes: 'read write', use_refresh_token: false) } let(:agreement) { nil } + let(:date_of_birth) { nil } + + context 'when age verification is enabled' do + before do + Setting.min_age = 16 + end + + let(:agreement) { 'true' } + + context 'when date of birth is below age limit' do + let(:date_of_birth) { 13.years.ago.strftime('%d.%m.%Y') } + + it 'returns http unprocessable entity' do + subject + + expect(response).to have_http_status(422) + expect(response.content_type) + .to start_with('application/json') + end + end + + context 'when date of birth is over age limit' do + let(:date_of_birth) { 17.years.ago.strftime('%d.%m.%Y') } + + it 'creates a user', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(response.content_type) + .to start_with('application/json') + end + end + end context 'when given truthy agreement' do let(:agreement) { 'true' } diff --git a/spec/validators/date_of_birth_validator_spec.rb b/spec/validators/date_of_birth_validator_spec.rb new file mode 100644 index 0000000000..33e69e811b --- /dev/null +++ b/spec/validators/date_of_birth_validator_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DateOfBirthValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + + attr_accessor :date_of_birth + + validates :date_of_birth, date_of_birth: true + end + end + + let(:record) { record_class.new } + + before do + Setting.min_age = 16 + end + + describe '#validate_each' do + context 'with an invalid date' do + it 'adds errors' do + record.date_of_birth = '76.830.10' + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:date_of_birth) + expect(record.errors.first.type).to eq(:invalid) + end + end + + context 'with a date below age limit' do + it 'adds errors' do + record.date_of_birth = 13.years.ago.strftime('%d.%m.%Y') + + expect(record).to_not be_valid + expect(record.errors.first.attribute).to eq(:date_of_birth) + expect(record.errors.first.type).to eq(:below_limit) + end + end + + context 'with a date above age limit' do + it 'does not add errors' do + record.date_of_birth = 16.years.ago.strftime('%d.%m.%Y') + + expect(record).to be_valid + end + end + end +end diff --git a/yarn.lock b/yarn.lock index 8963bea54c..277fcfa138 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14950,15 +14950,15 @@ __metadata: linkType: hard "react-textarea-autosize@npm:^8.4.1": - version: 8.5.7 - resolution: "react-textarea-autosize@npm:8.5.7" + version: 8.5.8 + resolution: "react-textarea-autosize@npm:8.5.8" dependencies: "@babel/runtime": "npm:^7.20.13" use-composed-ref: "npm:^1.3.0" use-latest: "npm:^1.2.1" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/ff004797ea28faca442460c42b30042d4c34a140f324eeeddee74508688dbc0f98966d21282c945630655006ad28a87edbcb59e6da7f9e762f4f3042c72f9f24 + checksum: 10c0/3d7add9773fd3dc189a6668efb82c1d2d5238ee5b7e933204f5f7da9df8daef81df50b36ca573a57beaa31b2727c6176ea806422790ad23be6cf7f5a5f71bbb9 languageName: node linkType: hard