From ddaf200c78a05f5bae0ff913a18ea88e5478e9c7 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Thu, 27 Jul 2023 15:38:18 +0200 Subject: [PATCH 01/12] Refactor streaming's filtering logic & improve documentation (#26213) --- streaming/index.js | 162 ++++++++++++++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 52 deletions(-) diff --git a/streaming/index.js b/streaming/index.js index 067a2d1675..3adf37c191 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -622,29 +622,39 @@ const startServer = async () => { log.verbose(req.requestId, `Starting stream from ${ids.join(', ')} for ${accountId}`); - // Currently message is of type string, soon it'll be Record + const transmit = (event, payload) => { + // TODO: Replace "string"-based delete payloads with object payloads: + const encodedPayload = typeof payload === 'object' ? JSON.stringify(payload) : payload; + + log.silly(req.requestId, `Transmitting for ${accountId}: ${event} ${encodedPayload}`); + output(event, encodedPayload); + }; + + // The listener used to process each message off the redis subscription, + // message here is an object with an `event` and `payload` property. Some + // events also include a queued_at value, but this is being removed shortly. const listener = message => { - const { event, payload, queued_at } = message; + const { event, payload } = message; - const transmit = (payload) => { - const now = new Date().getTime(); - const delta = now - queued_at; - const encodedPayload = typeof payload === 'object' ? JSON.stringify(payload) : payload; - - log.silly(req.requestId, `Transmitting for ${accountId}: ${event} ${encodedPayload} Delay: ${delta}ms`); - output(event, encodedPayload); - }; - - // Only messages that may require filtering are statuses, since notifications - // are already personalized and deletes do not matter - if (!needsFiltering || event !== 'update') { - transmit(payload); + // Streaming only needs to apply filtering to some channels and only to + // some events. This is because majority of the filtering happens on the + // Ruby on Rails side when producing the event for streaming. + // + // The only events that require filtering from the streaming server are + // `update` and `status.update`, all other events are transmitted to the + // client as soon as they're received (pass-through). + // + // The channels that need filtering are determined in the function + // `channelNameToIds` defined below: + if (!needsFiltering || (event !== 'update' && event !== 'status.update')) { + transmit(event, payload); return; } - const targetAccountIds = [payload.account.id].concat(payload.mentions.map(item => item.id)); - const accountDomain = payload.account.acct.split('@')[1]; + // The rest of the logic from here on in this function is to handle + // filtering of statuses: + // Filter based on language: if (Array.isArray(req.chosenLanguages) && payload.language !== null && req.chosenLanguages.indexOf(payload.language) === -1) { log.silly(req.requestId, `Message ${payload.id} filtered by language (${payload.language})`); return; @@ -652,11 +662,16 @@ const startServer = async () => { // When the account is not logged in, it is not necessary to confirm the block or mute if (!req.accountId) { - transmit(payload); + transmit(event, payload); return; } - pgPool.connect((err, client, done) => { + // Filter based on domain blocks, blocks, mutes, or custom filters: + const targetAccountIds = [payload.account.id].concat(payload.mentions.map(item => item.id)); + const accountDomain = payload.account.acct.split('@')[1]; + + // TODO: Move this logic out of the message handling loop + pgPool.connect((err, client, releasePgConnection) => { if (err) { log.error(err); return; @@ -683,28 +698,45 @@ const startServer = async () => { } Promise.all(queries).then(values => { - done(); + releasePgConnection(); + // Handling blocks & mutes and domain blocks: If one of those applies, + // then we don't transmit the payload of the event to the client if (values[0].rows.length > 0 || (accountDomain && values[1].rows.length > 0)) { return; } - if (!payload.filtered && !req.cachedFilters) { + // If the payload already contains the `filtered` property, it means + // that filtering has been applied on the ruby on rails side, as + // such, we don't need to construct or apply the filters in streaming: + if (Object.prototype.hasOwnProperty.call(payload, "filtered")) { + transmit(event, payload); + return; + } + + // Handling for constructing the custom filters and caching them on the request + // TODO: Move this logic out of the message handling lifecycle + if (!req.cachedFilters) { const filterRows = values[accountDomain ? 2 : 1].rows; - req.cachedFilters = filterRows.reduce((cache, row) => { - if (cache[row.id]) { - cache[row.id].keywords.push([row.keyword, row.whole_word]); + req.cachedFilters = filterRows.reduce((cache, filter) => { + if (cache[filter.id]) { + cache[filter.id].keywords.push([filter.keyword, filter.whole_word]); } else { - cache[row.id] = { - keywords: [[row.keyword, row.whole_word]], - expires_at: row.expires_at, - repr: { - id: row.id, - title: row.title, - context: row.context, - expires_at: row.expires_at, - filter_action: ['warn', 'hide'][row.filter_action], + cache[filter.id] = { + keywords: [[filter.keyword, filter.whole_word]], + expires_at: filter.expires_at, + filter: { + id: filter.id, + title: filter.title, + context: filter.context, + expires_at: filter.expires_at, + // filter.filter_action is the value from the + // custom_filters.action database column, it is an integer + // representing a value in an enum defined by Ruby on Rails: + // + // enum { warn: 0, hide: 1 } + filter_action: ['warn', 'hide'][filter.filter_action], }, }; } @@ -712,6 +744,10 @@ const startServer = async () => { return cache; }, {}); + // Construct the regular expressions for the custom filters: This + // needs to be done in a separate loop as the database returns one + // filterRow per keyword, so we need all the keywords before + // constructing the regular expression Object.keys(req.cachedFilters).forEach((key) => { req.cachedFilters[key].regexp = new RegExp(req.cachedFilters[key].keywords.map(([keyword, whole_word]) => { let expr = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -731,34 +767,56 @@ const startServer = async () => { }); } - // Check filters - if (req.cachedFilters && !payload.filtered) { - const mutatedPayload = { ...payload }; + // Apply cachedFilters against the payload, constructing a + // `filter_results` array of FilterResult entities + if (req.cachedFilters) { const status = payload; - const searchContent = ([status.spoiler_text || '', status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); - const searchIndex = JSDOM.fragment(searchContent).textContent; + // TODO: Calculate searchableContent in Ruby on Rails: + const searchableContent = ([status.spoiler_text || '', status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(//g, '\n').replace(/<\/p>

/g, '\n\n'); + const searchableTextContent = JSDOM.fragment(searchableContent).textContent; const now = new Date(); - mutatedPayload.filtered = []; - Object.values(req.cachedFilters).forEach((cachedFilter) => { - if ((cachedFilter.expires_at === null || cachedFilter.expires_at > now)) { - const keyword_matches = searchIndex.match(cachedFilter.regexp); - if (keyword_matches) { - mutatedPayload.filtered.push({ - filter: cachedFilter.repr, - keyword_matches, - }); - } + const filter_results = Object.values(req.cachedFilters).reduce((results, cachedFilter) => { + // Check the filter hasn't expired before applying: + if (cachedFilter.expires_at !== null && cachedFilter.expires_at < now) { + return; } - }); - transmit(mutatedPayload); + // Just in-case JSDOM fails to find textContent in searchableContent + if (!searchableTextContent) { + return; + } + + const keyword_matches = searchableTextContent.match(cachedFilter.regexp); + if (keyword_matches) { + // results is an Array of FilterResult; status_matches is always + // null as we only are only applying the keyword-based custom + // filters, not the status-based custom filters. + // https://docs.joinmastodon.org/entities/FilterResult/ + results.push({ + filter: cachedFilter.filter, + keyword_matches, + status_matches: null + }); + } + }, []); + + // Send the payload + the FilterResults as the `filtered` property + // to the streaming connection. To reach this code, the `event` must + // have been either `update` or `status.update`, meaning the + // `payload` is a Status entity, which has a `filtered` property: + // + // filtered: https://docs.joinmastodon.org/entities/Status/#filtered + transmit(event, { + ...payload, + filtered: filter_results + }); } else { - transmit(payload); + transmit(event, payload); } }).catch(err => { + releasePgConnection(); log.error(err); - done(); }); }); }; From 1e4ccc655acb0ac746656bed891d44b3a986384b Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Jul 2023 16:05:24 +0200 Subject: [PATCH 02/12] Add role badges to the WebUI (#25649) --- .../features/account/components/header.jsx | 35 +++++++++++++++---- app/javascript/styles/mastodon/accounts.scss | 35 +++++++++++++------ .../styles/mastodon/components.scss | 14 +++++--- .../custom_emojis/_custom_emoji.html.haml | 2 +- app/views/custom_css/show.css.erb | 2 -- .../authorized_applications/index.html.haml | 2 +- 6 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index aea7317577..51206c03bf 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -370,16 +370,33 @@ class Header extends ImmutablePureComponent { const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); const isIndexable = !account.get('noindex'); - let badge; + const badges = []; if (account.get('bot')) { - badge = (

); + badges.push( +
+ { ' ' } + +
+ ); } else if (account.get('group')) { - badge = (
); - } else { - badge = null; + badges.push( +
+ { ' ' } + +
+ ); } + account.get('roles', []).forEach((role) => { + badges.push( +
+ { ' ' } + {role.get('name')} ({domain}) +
+ ); + }); + return (
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && } @@ -414,13 +431,19 @@ class Header extends ImmutablePureComponent {

- {badge} + @{acct} {lockedIcon}

+ {badges.length > 0 && ( +
+ {badges} +
+ )} + {!(suspended || hidden) && (
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index b50306deda..babfbbbad0 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -188,30 +188,43 @@ } .account-role, +.information-badge, .simple_form .recommended, .simple_form .not_recommended { display: inline-block; padding: 4px 6px; cursor: default; - border-radius: 3px; + border-radius: 4px; font-size: 12px; line-height: 12px; font-weight: 500; - color: var(--user-role-accent, $ui-secondary-color); - background-color: var(--user-role-background, rgba($ui-secondary-color, 0.1)); - border: 1px solid var(--user-role-border, rgba($ui-secondary-color, 0.5)); + color: $ui-secondary-color; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} - &.moderator { +.information-badge, +.simple_form .recommended, +.simple_form .not_recommended { + background-color: rgba($ui-secondary-color, 0.1); + border: 1px solid rgba($ui-secondary-color, 0.5); +} + +.account-role { + border: 1px solid $highlight-text-color; + + .fa { + color: var(--user-role-accent, $highlight-text-color); + } +} + +.information-badge { + &.superapp { color: $success-green; background-color: rgba($success-green, 0.1); border-color: rgba($success-green, 0.5); } - - &.admin { - color: lighten($error-red, 12%); - background-color: rgba(lighten($error-red, 12%), 0.1); - border-color: rgba(lighten($error-red, 12%), 0.5); - } } .simple_form .not_recommended { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 2d1d4aa47c..4c9a2cfdd0 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -7331,6 +7331,16 @@ noscript { } } + &__badges { + display: flex; + flex-wrap: wrap; + gap: 8px; + + .account-role { + line-height: unset; + } + } + &__tabs { display: flex; align-items: flex-start; @@ -7369,10 +7379,6 @@ noscript { margin-top: 16px; margin-bottom: 16px; - .account-role { - vertical-align: top; - } - .emojione { width: 22px; height: 22px; diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index 8d34d38e58..c6789d4f89 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -9,7 +9,7 @@ %samp= ":#{custom_emoji.shortcode}:" - if custom_emoji.local? - %span.account-role.bot= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized') + %span.information-badge= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized') .batch-table__row__content__extra - if custom_emoji.local? diff --git a/app/views/custom_css/show.css.erb b/app/views/custom_css/show.css.erb index bcbe819621..9cd38fb371 100644 --- a/app/views/custom_css/show.css.erb +++ b/app/views/custom_css/show.css.erb @@ -5,8 +5,6 @@ <%- UserRole.where(highlighted: true).select { |role| role.color.present? }.each do |role| %> .user-role-<%= role.id %> { --user-role-accent: <%= role.color %>; - --user-role-background: <%= role.color + '19' %>; - --user-role-border: <%= role.color + '80' %>; } <%- end %> diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml index 689f051029..40b09d87f1 100644 --- a/app/views/oauth/authorized_applications/index.html.haml +++ b/app/views/oauth/authorized_applications/index.html.haml @@ -14,7 +14,7 @@ %strong.announcements-list__item__title = application.name - if application.superapp? - %span.account-role.moderator= t('doorkeeper.authorized_applications.index.superapp') + %span.information-badge.superapp= t('doorkeeper.authorized_applications.index.superapp') .announcements-list__item__action-bar .announcements-list__item__meta From b4e739ff0f64c601973762ac986c0e63092d2d7e Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 27 Jul 2023 16:11:17 +0200 Subject: [PATCH 03/12] Change interaction modal in web UI (#26075) Co-authored-by: Eugen Rochko --- app/chewy/instances_index.rb | 12 + .../api/v1/instances/peers_controller.rb | 2 +- .../api/v1/peers/search_controller.rb | 45 +++ .../authorize_interactions_controller.rb | 21 +- .../remote_interaction_helper_controller.rb | 43 +++ .../well_known/webfinger_controller.rb | 1 + .../mastodon/containers/status_container.jsx | 2 +- .../containers/header_container.jsx | 2 +- .../features/compose/components/search.jsx | 4 - .../features/interaction_modal/index.jsx | 300 ++++++++++++++---- .../picture_in_picture/components/footer.jsx | 6 +- .../mastodon/features/status/index.jsx | 6 +- app/javascript/mastodon/locales/en.json | 8 +- .../packs/remote_interaction_helper.ts | 172 ++++++++++ .../styles/mastodon/components.scss | 115 ++++--- app/javascript/styles/mastodon/variables.scss | 2 + app/javascript/types/resources.ts | 1 + app/lib/importer/instances_index_importer.rb | 26 ++ app/lib/webfinger_resource.rb | 9 + app/models/instance.rb | 1 + app/serializers/rest/account_serializer.rb | 6 +- app/serializers/webfinger_serializer.rb | 1 + .../_post_follow_actions.html.haml | 4 - .../authorize_interactions/error.html.haml | 3 - .../authorize_interactions/show.html.haml | 24 -- .../authorize_interactions/success.html.haml | 13 - app/views/layouts/helper_frame.html.haml | 8 + .../remote_interaction_helper/index.html.haml | 4 + .../scheduler/instance_refresh_scheduler.rb | 1 + config/locales/an.yml | 12 - config/locales/ar.yml | 12 - config/locales/ast.yml | 9 - config/locales/be.yml | 12 - config/locales/bg.yml | 12 - config/locales/br.yml | 5 - config/locales/ca.yml | 12 - config/locales/ckb.yml | 12 - config/locales/co.yml | 12 - config/locales/cs.yml | 12 - config/locales/cy.yml | 12 - config/locales/da.yml | 12 - config/locales/de.yml | 12 - config/locales/el.yml | 12 - config/locales/en-GB.yml | 12 - config/locales/en.yml | 12 - config/locales/eo.yml | 12 - config/locales/es-AR.yml | 12 - config/locales/es-MX.yml | 12 - config/locales/es.yml | 12 - config/locales/et.yml | 12 - config/locales/eu.yml | 12 - config/locales/fa.yml | 12 - config/locales/fi.yml | 12 - config/locales/fo.yml | 12 - config/locales/fr-QC.yml | 12 - config/locales/fr.yml | 12 - config/locales/fy.yml | 12 - config/locales/ga.yml | 5 - config/locales/gd.yml | 12 - config/locales/gl.yml | 12 - config/locales/he.yml | 12 - config/locales/hr.yml | 4 - config/locales/hu.yml | 12 - config/locales/hy.yml | 11 - config/locales/id.yml | 12 - config/locales/io.yml | 12 - config/locales/is.yml | 12 - config/locales/it.yml | 12 - config/locales/ja.yml | 12 - config/locales/ka.yml | 11 - config/locales/kab.yml | 8 - config/locales/kk.yml | 11 - config/locales/ko.yml | 12 - config/locales/ku.yml | 12 - config/locales/lt.yml | 11 - config/locales/lv.yml | 12 - config/locales/ml.yml | 10 - config/locales/ms.yml | 8 - config/locales/my.yml | 12 - config/locales/nl.yml | 12 - config/locales/nn.yml | 12 - config/locales/no.yml | 12 - config/locales/oc.yml | 11 - config/locales/pl.yml | 12 - config/locales/pt-BR.yml | 12 - config/locales/pt-PT.yml | 12 - config/locales/ro.yml | 12 - config/locales/ru.yml | 12 - config/locales/sc.yml | 12 - config/locales/sco.yml | 12 - config/locales/si.yml | 12 - config/locales/sk.yml | 11 - config/locales/sl.yml | 12 - config/locales/sq.yml | 12 - config/locales/sr-Latn.yml | 12 - config/locales/sr.yml | 12 - config/locales/sv.yml | 12 - config/locales/ta.yml | 2 - config/locales/th.yml | 12 - config/locales/tr.yml | 12 - config/locales/tt.yml | 6 - config/locales/uk.yml | 12 - config/locales/vi.yml | 12 - config/locales/zgh.yml | 3 - config/locales/zh-CN.yml | 12 - config/locales/zh-HK.yml | 12 - config/locales/zh-TW.yml | 12 - config/routes.rb | 5 +- config/routes/api.rb | 4 + lib/mastodon/cli/search.rb | 1 + .../authorize_interactions_controller_spec.rb | 51 +-- 111 files changed, 682 insertions(+), 1091 deletions(-) create mode 100644 app/chewy/instances_index.rb create mode 100644 app/controllers/api/v1/peers/search_controller.rb create mode 100644 app/controllers/remote_interaction_helper_controller.rb create mode 100644 app/javascript/packs/remote_interaction_helper.ts create mode 100644 app/lib/importer/instances_index_importer.rb delete mode 100644 app/views/authorize_interactions/_post_follow_actions.html.haml delete mode 100644 app/views/authorize_interactions/error.html.haml delete mode 100644 app/views/authorize_interactions/show.html.haml delete mode 100644 app/views/authorize_interactions/success.html.haml create mode 100644 app/views/layouts/helper_frame.html.haml create mode 100644 app/views/remote_interaction_helper/index.html.haml diff --git a/app/chewy/instances_index.rb b/app/chewy/instances_index.rb new file mode 100644 index 0000000000..2bce4043c9 --- /dev/null +++ b/app/chewy/instances_index.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class InstancesIndex < Chewy::Index + settings index: { refresh_interval: '30s' } + + index_scope ::Instance.searchable + + root date_detection: false do + field :domain, type: 'text', index_prefixes: { min_chars: 1 } + field :accounts_count, type: 'long' + end +end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 70281362a8..23096650e6 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Instances::PeersController < Api::BaseController def index cache_even_if_authenticated! - render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } + render_with_cache(expires_in: 1.day) { Instance.searchable.pluck(:domain) } end private diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb new file mode 100644 index 0000000000..50a342cde3 --- /dev/null +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Api::V1::Peers::SearchController < Api::BaseController + before_action :require_enabled_api! + before_action :set_domains + + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale + + vary_by '' + + def index + cache_even_if_authenticated! + render json: @domains + end + + private + + def require_enabled_api! + head 404 unless Setting.peers_api_enabled && !whitelist_mode? + end + + def set_domains + return if params[:q].blank? + + if Chewy.enabled? + @domains = InstancesIndex.query(function_score: { + query: { + prefix: { + domain: params[:q], + }, + }, + + field_value_factor: { + field: 'accounts_count', + modifier: 'log2p', + }, + }).limit(10).pluck(:domain) + else + domain = params[:q].strip + domain = TagManager.instance.normalize_domain(domain) + @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) + end + end +end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index bf28d18423..99eed018b0 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -3,32 +3,19 @@ class AuthorizeInteractionsController < ApplicationController include Authorization - layout 'modal' - before_action :authenticate_user! - before_action :set_body_classes before_action :set_resource def show if @resource.is_a?(Account) - render :show + redirect_to web_url("@#{@resource.pretty_acct}") elsif @resource.is_a?(Status) redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}") else - render :error + not_found end end - def create - if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true) - render :success - else - render :error - end - rescue ActiveRecord::RecordNotFound - render :error - end - private def set_resource @@ -61,8 +48,4 @@ class AuthorizeInteractionsController < ApplicationController def uri_param params[:uri] || params.fetch(:acct, '').delete_prefix('acct:') end - - def set_body_classes - @body_classes = 'modal-layout' - end end diff --git a/app/controllers/remote_interaction_helper_controller.rb b/app/controllers/remote_interaction_helper_controller.rb new file mode 100644 index 0000000000..90c853f47b --- /dev/null +++ b/app/controllers/remote_interaction_helper_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class RemoteInteractionHelperController < ApplicationController + vary_by '' + + skip_before_action :require_functional! + skip_around_action :set_locale + skip_before_action :update_user_sign_in + + content_security_policy do |p| + # We inherit the normal `script-src` + + # Set every directive that does not have a fallback + p.default_src :none + p.form_action :none + p.base_uri :none + + # Disable every directive with a fallback to cut on response size + p.base_uri false + p.font_src false + p.img_src false + p.style_src false + p.media_src false + p.frame_src false + p.manifest_src false + p.connect_src false + p.child_src false + p.worker_src false + + # Widen the directives that we do need + p.frame_ancestors :self + p.connect_src :https + end + + def index + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) + + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['Referrer-Policy'] = 'no-referrer' + + render layout: 'helper_frame' + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 0d897e8e24..4748940f7c 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -19,6 +19,7 @@ module WellKnown def set_account username = username_from_resource + @account = begin if username == Rails.configuration.x.local_domain Account.representative diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 536765e137..7a7cd9880f 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -278,7 +278,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ modalProps: { type, accountId: status.getIn(['account', 'id']), - url: status.get('url'), + url: status.get('uri'), }, })); }, diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index 2b3a66c55e..df5427c307 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -83,7 +83,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ modalProps: { type: 'follow', accountId: account.get('id'), - url: account.get('url'), + url: account.get('uri'), }, })); }, diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 7badb0774f..682f8d3c8c 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -139,10 +139,6 @@ class Search extends PureComponent { this.setState({ expanded: false, selectedOption: -1 }); }; - findTarget = () => { - return this.searchForm; - }; - handleHashtagClick = () => { const { router } = this.context; const { value, onClickSearchResult } = this.props; diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index 4722c130e7..6e17ab0194 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -1,95 +1,296 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { connect } from 'react-redux'; +import { throttle, escapeRegExp } from 'lodash'; + import { openModal, closeModal } from 'mastodon/actions/modal'; +import api from 'mastodon/api'; +import Button from 'mastodon/components/button'; import { Icon } from 'mastodon/components/icon'; import { registrationsOpen } from 'mastodon/initial_state'; +const messages = defineMessages({ + loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' }, +}); + const mapStateToProps = (state, { accountId }) => ({ displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), - signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', }); const mapDispatchToProps = (dispatch) => ({ onSignupClick() { - dispatch(closeModal({ - modalType: undefined, - ignoreFocus: false, - })); - dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); + dispatch(closeModal()); + dispatch(openModal('CLOSED_REGISTRATIONS')); }, }); -class Copypaste extends PureComponent { +const PERSISTENCE_KEY = 'mastodon_home'; + +const isValidDomain = value => { + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +const valueToDomain = value => { + // If the user starts typing an URL + if (/^https?:\/\//.test(value)) { + try { + const url = new URL(value); + + // Consider that if there is a path, the URL is more meaningful than a bare domain + if (url.pathname.length > 1) { + return ''; + } + + return url.host; + } catch { + return undefined; + } + // If the user writes their full handle including username + } else if (value.includes('@')) { + if (value.replace(/^@/, '').split('@').length > 2) { + return undefined; + } + return ''; + } + + return value; +}; + +const addInputToOptions = (value, options) => { + value = value.trim(); + + if (value.includes('.') && isValidDomain(value)) { + return [value].concat(options.filter((x) => x !== value)); + } + + return options; +}; + +class LoginForm extends React.PureComponent { static propTypes = { - value: PropTypes.string, + resourceUrl: PropTypes.string, + intl: PropTypes.object.isRequired, }; state = { - copied: false, + value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '', + expanded: false, + selectedOption: -1, + isLoading: false, + isSubmitting: false, + error: false, + options: [], + networkOptions: [], }; setRef = c => { this.input = c; }; - handleInputClick = () => { - this.setState({ copied: false }); - this.input.focus(); - this.input.select(); - this.input.setSelectionRange(0, this.input.value.length); + handleChange = ({ target }) => { + this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); }; - handleButtonClick = () => { - const { value } = this.props; - navigator.clipboard.writeText(value); - this.input.blur(); - this.setState({ copied: true }); - this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + handleMessage = (event) => { + const { resourceUrl } = this.props; + + if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) { + return; + } + + if (event.data?.type === 'fetchInteractionURL-failure') { + this.setState({ isSubmitting: false, error: true }); + } else if (event.data?.type === 'fetchInteractionURL-success') { + if (/^https?:\/\//.test(event.data.template)) { + if (localStorage) { + localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); + } + + window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)); + } else { + this.setState({ isSubmitting: false, error: true }); + } + } }; - componentWillUnmount () { - if (this.timeout) clearTimeout(this.timeout); + componentDidMount () { + window.addEventListener('message', this.handleMessage); } + componentWillUnmount () { + window.removeEventListener('message', this.handleMessage); + } + + handleSubmit = () => { + const { value } = this.state; + + this.setState({ isSubmitting: true }); + + this.iframeRef.contentWindow.postMessage({ + type: 'fetchInteractionURL', + uri_or_domain: value.trim(), + }, window.origin); + }; + + setIFrameRef = (iframe) => { + this.iframeRef = iframe; + } + + handleFocus = () => { + this.setState({ expanded: true }); + }; + + handleBlur = () => { + this.setState({ expanded: false }); + }; + + handleKeyDown = (e) => { + const { options, selectedOption } = this.state; + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + this.handleSubmit(); + } else if (options.length > 0) { + this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit()); + } + + break; + } + }; + + handleOptionClick = e => { + const index = Number(e.currentTarget.getAttribute('data-index')); + const option = this.state.options[index]; + + e.preventDefault(); + this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit()); + }; + + _loadOptions = throttle(() => { + const { value } = this.state; + + const domain = valueToDomain(value.trim()); + + if (typeof domain === 'undefined') { + this.setState({ options: [], networkOptions: [], isLoading: false, error: true }); + return; + } + + if (domain.length === 0) { + this.setState({ options: [], networkOptions: [], isLoading: false }); + return; + } + + api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => { + if (!data) { + data = []; + } + + this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false })); + }).catch(() => { + this.setState({ isLoading: false }); + }); + }, 200, { leading: true, trailing: true }); + render () { - const { value } = this.props; - const { copied } = this.state; + const { intl } = this.props; + const { value, expanded, options, selectedOption, error, isSubmitting } = this.state; + const domain = (valueToDomain(value) || '').trim(); + const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi'); + const hasPopOut = domain.length > 0 && options.length > 0; return ( -
- + +