From 1b5d26735e46719eba4dac4b79719460d7bbba63 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Tue, 8 Aug 2017 08:49:56 +0900 Subject: [PATCH 001/201] Revert "Set false to animated options for thumbnail processor" (#4550) * Revert "Adjust tags and accounts page (#4534)" This reverts commit a3e53bd442752f210db2025f2dfc45e7599354c2. * Revert "feat: Cache status height to avoid expensive renders (#4439)" This reverts commit 8eb6d171e690e013eb2881478cfa1fd50b4ba705. * Revert "Refactor Avatar and AvatarOverlay to have 'account' as prop instead of src and staticSrc (#4526)" This reverts commit 594234740788a51fa528152343eb50dc1c6ca093. * Revert "Update dependencies for Ruby (#4543)" This reverts commit 22db9472253f6ffcfed254f7a406a58b53e80cfe. * Revert "[Docker] Add multicore support to "make" and "bundler" (#4544)" This reverts commit 5d408fd9aa76f9ef3d559a377bccbcece998fbfa. * Revert "It makes no sense to try using invalid or expired link again (#4521)" This reverts commit 47579ec58ce984acc9f194f8cb145abd88d0ea19. * Revert "i18n: Update Polish translation (#4545)" This reverts commit 3363a05539c0c774fca4651755e6423fe131d205. * Revert "Set false to animated options for thumbnail processor (#4547)" This reverts commit 87f10d476cf7a05bc60851c93e3c051eca8c357e. --- app/models/concerns/account_avatar.rb | 2 +- app/models/concerns/account_header.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb index b0ec689a736..a6527a85bc2 100644 --- a/app/models/concerns/account_avatar.rb +++ b/app/models/concerns/account_avatar.rb @@ -8,7 +8,7 @@ module AccountAvatar class_methods do def avatar_styles(file) styles = { original: '120x120#' } - styles[:static] = { animated: false } if file.content_type == 'image/gif' + styles[:static] = { format: 'png' } if file.content_type == 'image/gif' styles end diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb index 542e25abe22..4ba9212a25d 100644 --- a/app/models/concerns/account_header.rb +++ b/app/models/concerns/account_header.rb @@ -8,7 +8,7 @@ module AccountHeader class_methods do def header_styles(file) styles = { original: '700x335#' } - styles[:static] = { animated: false } if file.content_type == 'image/gif' + styles[:static] = { format: 'png' } if file.content_type == 'image/gif' styles end From c9fd6f386c5ae0938f1f9c2d1134508e66231e23 Mon Sep 17 00:00:00 2001 From: TheInventrix Date: Mon, 7 Aug 2017 17:50:15 -0600 Subject: [PATCH 002/201] unify short description styling (#4548) apply same style class to the About description on both the landing page and the about/more page --- app/javascript/styles/about.scss | 2 +- app/views/about/more.html.haml | 9 +++++---- app/views/about/show.html.haml | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index d409c821495..62143246f62 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -484,7 +484,7 @@ padding: 0; } - .learn-more-cta { + .about-short { background: darken($ui-base-color, 4%); padding: 50px 0; } diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 6342c724815..189b3bf1e9c 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -35,9 +35,10 @@ %i.fa.fa-external-link{ style: 'padding-left: 5px;' } .container.hero - .heading - %h3= t('about.description_headline', domain: site_hostname) - %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) + .about-short + .heading + %h3= t('about.description_headline', domain: site_hostname) + %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) .information-board .container @@ -64,4 +65,4 @@ .container %p = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' - = " (#{@instance_presenter.version_number})" + = " (#{@instance_presenter.version_number})" \ No newline at end of file diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index d0c9a66506a..acdb12ad72c 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -58,7 +58,7 @@ = @instance_presenter.closed_registrations_message.html_safe = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block' - .learn-more-cta + .about-short .container %h3= t('about.description_headline', domain: site_hostname) %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) @@ -77,4 +77,4 @@ .container %p = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' - = " (#{@instance_presenter.version_number})" + = " (#{@instance_presenter.version_number})" \ No newline at end of file From b42c018bb8f86e27af43d84bf558d669e18def3b Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 8 Aug 2017 22:47:35 +0900 Subject: [PATCH 003/201] Add Content-Type header on throttled response to fix mojibake (#4558) application/json only allows Unicode, so this prevents from wrong charset detection. --- config/initializers/rack_attack.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index d5cd77b3461..53cb106cac4 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -33,6 +33,7 @@ class Rack::Attack match_data = env['rack.attack.match_data'] headers = { + 'Content-Type' => 'application/json', 'X-RateLimit-Limit' => match_data[:limit].to_s, 'X-RateLimit-Remaining' => '0', 'X-RateLimit-Reset' => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6), From ec3be87a2b75b6a84a013bab9bf4d80b0f6452a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=A8=E5=AE=AE=E7=BE=8E=E7=BE=BD?= Date: Tue, 8 Aug 2017 08:48:19 -0500 Subject: [PATCH 004/201] improve zh-CN translations (#4557) --- app/javascript/mastodon/locales/zh-CN.json | 28 +++++++++++----------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index d0c4b3d1bc7..e7c43145477 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -5,7 +5,7 @@ "account.edit_profile": "修改个人资料", "account.follow": "关注", "account.followers": "关注者", - "account.follows": "正关注", + "account.follows": "正在关注", "account.follows_you": "关注你", "account.media": "Media", "account.mention": "提及 @{name}", @@ -13,19 +13,19 @@ "account.posts": "嘟文", "account.report": "举报 @{name}", "account.requested": "等待审批", - "account.share": "Share @{name}'s profile", + "account.share": "分享 @{name}的个人资料", "account.unblock": "解除对 @{name} 的屏蔽", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "解除封锁 {domain}", "account.unfollow": "取消关注", "account.unmute": "取消 @{name} 的静音", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "查看完整资料", "boost_modal.combo": "如你想在下次路过时显示,请按{combo},", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", + "bundle_column_error.body": "载入组件出错。", + "bundle_column_error.retry": "再次尝试", + "bundle_column_error.title": "网络错误", + "bundle_modal_error.close": "关闭", + "bundle_modal_error.message": "载入组件出错。", + "bundle_modal_error.retry": "再次尝试", "column.blocks": "屏蔽用户", "column.community": "本站时间轴", "column.favourites": "赞过的嘟文", @@ -34,7 +34,7 @@ "column.mutes": "被静音的用户", "column.notifications": "通知", "column.public": "跨站公共时间轴", - "column_back_button.label": "Back", + "column_back_button.label": "返回", "column_header.hide_settings": "Hide settings", "column_header.moveLeft_settings": "Move column to the left", "column_header.moveRight_settings": "Move column to the right", @@ -61,8 +61,8 @@ "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", "confirmations.mute.confirm": "静音", "confirmations.mute.message": "想好了,真的要静音 {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmations.unfollow.confirm": "取消关注", + "confirmations.unfollow.message": "确定要取消关注 {name}吗?", "emoji_button.activity": "活动", "emoji_button.flags": "旗帜", "emoji_button.food": "食物和饮料", @@ -86,7 +86,7 @@ "getting_started.faq": "FAQ", "getting_started.heading": "开始使用", "getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。", - "getting_started.userguide": "User Guide", + "getting_started.userguide": "用户指南", "home.column_settings.advanced": "高端", "home.column_settings.basic": "基本", "home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤", From 81c41d86814a3cc07baea2e1ff458aca6c374954 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Tue, 8 Aug 2017 22:49:14 +0900 Subject: [PATCH 005/201] Add coalesce option to avatar and header convert processor (#4552) Resolve #3199 --- app/models/concerns/account_avatar.rb | 2 +- app/models/concerns/account_header.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb index a6527a85bc2..8a5c9a22c3e 100644 --- a/app/models/concerns/account_avatar.rb +++ b/app/models/concerns/account_avatar.rb @@ -8,7 +8,7 @@ module AccountAvatar class_methods do def avatar_styles(file) styles = { original: '120x120#' } - styles[:static] = { format: 'png' } if file.content_type == 'image/gif' + styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif' styles end diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb index 4ba9212a25d..aff2aa3f90c 100644 --- a/app/models/concerns/account_header.rb +++ b/app/models/concerns/account_header.rb @@ -8,7 +8,7 @@ module AccountHeader class_methods do def header_styles(file) styles = { original: '700x335#' } - styles[:static] = { format: 'png' } if file.content_type == 'image/gif' + styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif' styles end From dcbc1af38a3ddc289783d9e9021690692bc1438e Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Tue, 8 Aug 2017 22:49:32 +0900 Subject: [PATCH 006/201] Fix short description in about/more page (#4554) --- app/views/about/more.html.haml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 189b3bf1e9c..a6fd265fabc 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -35,10 +35,9 @@ %i.fa.fa-external-link{ style: 'padding-left: 5px;' } .container.hero - .about-short - .heading - %h3= t('about.description_headline', domain: site_hostname) - %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) + .heading + %h3= t('about.description_headline', domain: site_hostname) + %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) .information-board .container From dd7ef0dc41584089a97444d8192bc61505108e6c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Aug 2017 21:52:15 +0200 Subject: [PATCH 007/201] Add ActivityPub inbox (#4216) * Add ActivityPub inbox * Handle ActivityPub deletes * Handle ActivityPub creates * Handle ActivityPub announces * Stubs for handling all activities that need to be handled * Add ActivityPub actor resolving * Handle conversation URI passing in ActivityPub * Handle content language in ActivityPub * Send accept header when fetching actor, handle JSON parse errors * Test for ActivityPub::FetchRemoteAccountService * Handle public key and icon/image when embedded/as array/as resolvable URI * Implement ActivityPub::FetchRemoteStatusService * Add stubs for more interactions * Undo activities implemented * Handle out of order activities * Hook up ActivityPub to ResolveRemoteAccountService, handle Update Account activities * Add fragment IDs to all transient activity serializers * Add tests and fixes * Add stubs for missing tests * Add more tests * Add more tests --- .../activitypub/inboxes_controller.rb | 30 +++ app/helpers/jsonld_helper.rb | 31 +++ app/lib/activitypub/activity.rb | 109 +++++++++ app/lib/activitypub/activity/announce.rb | 14 ++ app/lib/activitypub/activity/block.rb | 12 + app/lib/activitypub/activity/create.rb | 148 ++++++++++++ app/lib/activitypub/activity/delete.rb | 13 ++ app/lib/activitypub/activity/follow.rb | 12 + app/lib/activitypub/activity/like.rb | 12 + app/lib/activitypub/activity/undo.rb | 69 ++++++ app/lib/activitypub/activity/update.rb | 17 ++ app/lib/activitypub/adapter.rb | 2 +- app/lib/activitypub/tag_manager.rb | 25 ++ app/models/concerns/remotable.rb | 2 + .../activitypub/accept_follow_serializer.rb | 6 +- .../activitypub/actor_serializer.rb | 37 ++- .../activitypub/block_serializer.rb | 6 +- .../activitypub/delete_serializer.rb | 6 +- .../activitypub/follow_serializer.rb | 6 +- .../activitypub/like_serializer.rb | 6 +- .../activitypub/reject_follow_serializer.rb | 6 +- .../activitypub/undo_block_serializer.rb | 6 +- .../activitypub/undo_follow_serializer.rb | 6 +- .../activitypub/undo_like_serializer.rb | 6 +- .../activitypub/update_serializer.rb | 6 +- .../fetch_remote_account_service.rb | 57 +++++ .../fetch_remote_status_service.rb | 36 +++ .../activitypub/process_account_service.rb | 84 +++++++ .../activitypub/process_collection_service.rb | 38 +++ .../resolve_remote_account_service.rb | 49 +++- app/workers/activitypub/processing_worker.rb | 11 + .../activitypub/thread_resolve_worker.rb | 17 ++ config/initializers/inflections.rb | 1 + config/routes.rb | 1 + .../activitypub/inboxes_controller_spec.rb | 7 + .../activitypub/outboxes_controller_spec.rb | 19 ++ spec/helpers/jsonld_helper_spec.rb | 35 +++ .../lib/activitypub/activity/announce_spec.rb | 29 +++ spec/lib/activitypub/activity/block_spec.rb | 28 +++ spec/lib/activitypub/activity/create_spec.rb | 221 ++++++++++++++++++ spec/lib/activitypub/activity/delete_spec.rb | 28 +++ spec/lib/activitypub/activity/follow_spec.rb | 28 +++ spec/lib/activitypub/activity/like_spec.rb | 29 +++ spec/lib/activitypub/activity/undo_spec.rb | 107 +++++++++ spec/lib/activitypub/activity/update_spec.rb | 41 ++++ spec/lib/activitypub/tag_manager_spec.rb | 99 ++++++++ .../fetch_remote_account_service_spec.rb | 96 ++++++++ .../fetch_remote_status_service_spec.rb | 5 + .../process_account_service_spec.rb | 5 + .../process_collection_service_spec.rb | 9 + 50 files changed, 1652 insertions(+), 21 deletions(-) create mode 100644 app/controllers/activitypub/inboxes_controller.rb create mode 100644 app/helpers/jsonld_helper.rb create mode 100644 app/lib/activitypub/activity.rb create mode 100644 app/lib/activitypub/activity/announce.rb create mode 100644 app/lib/activitypub/activity/block.rb create mode 100644 app/lib/activitypub/activity/create.rb create mode 100644 app/lib/activitypub/activity/delete.rb create mode 100644 app/lib/activitypub/activity/follow.rb create mode 100644 app/lib/activitypub/activity/like.rb create mode 100644 app/lib/activitypub/activity/undo.rb create mode 100644 app/lib/activitypub/activity/update.rb create mode 100644 app/services/activitypub/fetch_remote_account_service.rb create mode 100644 app/services/activitypub/fetch_remote_status_service.rb create mode 100644 app/services/activitypub/process_account_service.rb create mode 100644 app/services/activitypub/process_collection_service.rb create mode 100644 app/workers/activitypub/processing_worker.rb create mode 100644 app/workers/activitypub/thread_resolve_worker.rb create mode 100644 spec/controllers/activitypub/inboxes_controller_spec.rb create mode 100644 spec/controllers/activitypub/outboxes_controller_spec.rb create mode 100644 spec/helpers/jsonld_helper_spec.rb create mode 100644 spec/lib/activitypub/activity/announce_spec.rb create mode 100644 spec/lib/activitypub/activity/block_spec.rb create mode 100644 spec/lib/activitypub/activity/create_spec.rb create mode 100644 spec/lib/activitypub/activity/delete_spec.rb create mode 100644 spec/lib/activitypub/activity/follow_spec.rb create mode 100644 spec/lib/activitypub/activity/like_spec.rb create mode 100644 spec/lib/activitypub/activity/undo_spec.rb create mode 100644 spec/lib/activitypub/activity/update_spec.rb create mode 100644 spec/lib/activitypub/tag_manager_spec.rb create mode 100644 spec/services/activitypub/fetch_remote_account_service_spec.rb create mode 100644 spec/services/activitypub/fetch_remote_status_service_spec.rb create mode 100644 spec/services/activitypub/process_account_service_spec.rb create mode 100644 spec/services/activitypub/process_collection_service_spec.rb diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb new file mode 100644 index 00000000000..0f49b439995 --- /dev/null +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class ActivityPub::InboxesController < Api::BaseController + include SignatureVerification + + before_action :set_account + + def create + if signed_request_account + process_payload + head 201 + else + head 202 + end + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def body + @body ||= request.body.read + end + + def process_payload + ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) + end +end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb new file mode 100644 index 00000000000..b0db025bc52 --- /dev/null +++ b/app/helpers/jsonld_helper.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module JsonLdHelper + def equals_or_includes?(haystack, needle) + haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle + end + + def first_of_value(value) + value.is_a?(Array) ? value.first : value + end + + def supported_context?(json) + equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + end + + def fetch_resource(uri) + response = build_request(uri).perform + return if response.code != 200 + Oj.load(response.to_s, mode: :strict) + rescue Oj::ParseError + nil + end + + private + + def build_request(uri) + request = Request.new(:get, uri) + request.add_headers('Accept' => 'application/activity+json') + request + end +end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb new file mode 100644 index 00000000000..d1b81a58274 --- /dev/null +++ b/app/lib/activitypub/activity.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class ActivityPub::Activity + include JsonLdHelper + + def initialize(json, account) + @json = json + @account = account + @object = @json['object'] + end + + def perform + raise NotImplementedError + end + + class << self + def factory(json, account) + @json = json + klass&.new(json, account) + end + + private + + def klass + case @json['type'] + when 'Create' + ActivityPub::Activity::Create + when 'Announce' + ActivityPub::Activity::Announce + when 'Delete' + ActivityPub::Activity::Delete + when 'Follow' + ActivityPub::Activity::Follow + when 'Like' + ActivityPub::Activity::Like + when 'Block' + ActivityPub::Activity::Block + when 'Update' + ActivityPub::Activity::Update + when 'Undo' + ActivityPub::Activity::Undo + end + end + end + + protected + + def status_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Status) + end + + def account_from_uri(uri) + ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + end + + def object_uri + @object_uri ||= @object.is_a?(String) ? @object : @object['id'] + end + + def redis + Redis.current + end + + def distribute(status) + notify_about_reblog(status) if reblog_of_local_account?(status) + notify_about_mentions(status) + crawl_links(status) + distribute_to_followers(status) + end + + def reblog_of_local_account?(status) + status.reblog? && status.reblog.account.local? + end + + def notify_about_reblog(status) + NotifyService.new.call(status.reblog.account, status) + end + + def notify_about_mentions(status) + status.mentions.includes(:account).each do |mention| + next unless mention.account.local? && audience_includes?(mention.account) + NotifyService.new.call(mention.account, mention) + end + end + + def crawl_links(status) + return if status.spoiler_text? + LinkCrawlWorker.perform_async(status.id) + end + + def distribute_to_followers(status) + DistributionWorker.perform_async(status.id) + end + + def delete_arrived_first?(uri) + key = "delete_upon_arrival:#{@account.id}:#{uri}" + + if redis.exists(key) + redis.del(key) + true + else + false + end + end + + def delete_later!(uri) + redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) + end +end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb new file mode 100644 index 00000000000..decf8f9601c --- /dev/null +++ b/app/lib/activitypub/activity/announce.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Announce < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil? + + return if original_status.nil? || delete_arrived_first?(@json['id']) + + status = Status.create!(account: @account, reblog: original_status, uri: @json['id']) + distribute(status) + status + end +end diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb new file mode 100644 index 00000000000..e6b6c837ba9 --- /dev/null +++ b/app/lib/activitypub/activity/block.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Block < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + + UnfollowService.new.call(target_account, @account) if target_account.following?(@account) + @account.block!(target_account) + end +end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb new file mode 100644 index 00000000000..4c4049bc697 --- /dev/null +++ b/app/lib/activitypub/activity/create.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Create < ActivityPub::Activity + def perform + return if delete_arrived_first?(object_uri) || unsupported_object_type? + + status = Status.find_by(uri: object_uri) + + return status unless status.nil? + + ApplicationRecord.transaction do + status = Status.create!(status_params) + + process_tags(status) + process_attachments(status) + end + + resolve_thread(status) + distribute(status) + + status + end + + private + + def status_params + { + uri: @object['id'], + url: @object['url'], + account: @account, + text: text_from_content || '', + language: language_from_content, + spoiler_text: @object['summary'] || '', + created_at: @object['published'] || Time.now.utc, + reply: @object['inReplyTo'].present?, + sensitive: @object['sensitive'] || false, + visibility: visibility_from_audience, + thread: replied_to_status, + conversation: conversation_from_uri(@object['_:conversation']), + } + end + + def process_tags(status) + return unless @object['tag'].is_a?(Array) + + @object['tag'].each do |tag| + case tag['type'] + when 'Hashtag' + process_hashtag tag, status + when 'Mention' + process_mention tag, status + end + end + end + + def process_hashtag(tag, status) + hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase + hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag) + + status.tags << hashtag + end + + def process_mention(tag, status) + account = account_from_uri(tag['href']) + account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil? + return if account.nil? + account.mentions.create(status: status) + end + + def process_attachments(status) + return unless @object['attachment'].is_a?(Array) + + @object['attachment'].each do |attachment| + next if unsupported_media_type?(attachment['mediaType']) + + href = Addressable::URI.parse(attachment['url']).normalize.to_s + media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) + + next if skip_download? + + media_attachment.file_remote_url = href + media_attachment.save + end + end + + def resolve_thread(status) + return unless status.reply? && status.thread.nil? + ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + end + + def conversation_from_uri(uri) + return nil if uri.nil? + return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri) + Conversation.find_by(uri: uri) || Conversation.create!(uri: uri) + end + + def visibility_from_audience + if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public]) + :public + elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public]) + :unlisted + elsif equals_or_includes?(@object['to'], @account.followers_url) + :private + else + :direct + end + end + + def audience_includes?(account) + uri = ActivityPub::TagManager.instance.uri_for(account) + equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri) + end + + def replied_to_status + return if @object['inReplyTo'].blank? + @replied_to_status ||= status_from_uri(@object['inReplyTo']) + end + + def text_from_content + if @object['content'].present? + @object['content'] + elsif language_map? + @object['contentMap'].values.first + end + end + + def language_from_content + return nil unless language_map? + @object['contentMap'].keys.first + end + + def language_map? + @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty? + end + + def unsupported_object_type? + @object.is_a?(String) || !%w(Article Note).include?(@object['type']) + end + + def unsupported_media_type?(mime_type) + mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type) + end + + def skip_download? + return @skip_download if defined?(@skip_download) + @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media? + end +end diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb new file mode 100644 index 00000000000..23f3430fb6e --- /dev/null +++ b/app/lib/activitypub/activity/delete.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Delete < ActivityPub::Activity + def perform + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb new file mode 100644 index 00000000000..7918b5108a8 --- /dev/null +++ b/app/lib/activitypub/activity/follow.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Follow < ActivityPub::Activity + def perform + target_account = account_from_uri(object_uri) + + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + + follow = @account.follow!(target_account) + NotifyService.new.call(target_account, follow) + end +end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb new file mode 100644 index 00000000000..c24527597f0 --- /dev/null +++ b/app/lib/activitypub/activity/like.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Like < ActivityPub::Activity + def perform + original_status = status_from_uri(object_uri) + + return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) + + favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account) + NotifyService.new.call(original_status.account, favourite) + end +end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb new file mode 100644 index 00000000000..078e97ed491 --- /dev/null +++ b/app/lib/activitypub/activity/undo.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Undo < ActivityPub::Activity + def perform + case @object['type'] + when 'Announce' + undo_announce + when 'Follow' + undo_follow + when 'Like' + undo_like + when 'Block' + undo_block + end + end + + private + + def undo_announce + status = Status.find_by(uri: object_uri, account: @account) + + if status.nil? + delete_later!(object_uri) + else + RemoveStatusService.new.call(status) + end + end + + def undo_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.following?(target_account) + @account.unfollow!(target_account) + else + delete_later!(object_uri) + end + end + + def undo_like + status = status_from_uri(target_uri) + + return if status.nil? || !status.account.local? + + if @account.favourited?(status) + favourite = status.favourites.where(account: @account).first + favourite&.destroy + else + delete_later!(object_uri) + end + end + + def undo_block + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + if @account.blocking?(target_account) + UnblockService.new.call(@account, target_account) + else + delete_later!(object_uri) + end + end + + def target_uri + @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb new file mode 100644 index 00000000000..0134b4015ff --- /dev/null +++ b/app/lib/activitypub/activity/update.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Update < ActivityPub::Activity + def perform + case @object['type'] + when 'Person' + update_account + end + end + + private + + def update_account + return if @account.uri != object_uri + ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object) + end +end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index 0a70207bc47..e038136c088 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -7,7 +7,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) + serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) self.class.transform_key_casing!(serialized_hash, instance_options) end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index ec42bcad30f..96e610b6da1 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -6,6 +6,8 @@ class ActivityPub::TagManager include Singleton include RoutingHelper + CONTEXT = 'https://www.w3.org/ns/activitystreams' + COLLECTIONS = { public: 'https://www.w3.org/ns/activitystreams#Public', }.freeze @@ -66,4 +68,27 @@ class ActivityPub::TagManager cc end + + def local_uri?(uri) + host = Addressable::URI.parse(uri).normalized_host + ::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host) + end + + def uri_to_local_id(uri, param = :id) + path_params = Rails.application.routes.recognize_path(uri) + path_params[param] + end + + def uri_to_resource(uri, klass) + if local_uri?(uri) + case klass.name + when 'Account' + klass.find_local(uri_to_local_id(uri, :username)) + else + klass.find_by(id: uri_to_local_id(uri)) + end + else + klass.find_by(uri: uri) + end + end end diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index 1bd87a642ef..270043a9ef2 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -10,6 +10,8 @@ module Remotable alt_method_name = "reset_#{attachment_name}!".to_sym define_method method_name do |url| + return if url.blank? + begin parsed_url = Addressable::URI.parse(url).normalize rescue Addressable::URI::InvalidURIError diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb index ce900bc7803..3e23591a527 100644 --- a/app/serializers/activitypub/accept_follow_serializer.rb +++ b/app/serializers/activitypub/accept_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join + end + def type 'Accept' end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index f5e626d7370..8a119603d06 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -5,10 +5,27 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer attributes :id, :type, :following, :followers, :inbox, :outbox, :preferred_username, - :name, :summary, :icon, :image + :name, :summary, :url has_one :public_key, serializer: ActivityPub::PublicKeySerializer + class ImageSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :url + + def type + 'Image' + end + + def url + full_asset_url(object.url(:original)) + end + end + + has_one :icon, serializer: ImageSerializer, if: :avatar_exists? + has_one :image, serializer: ImageSerializer, if: :header_exists? + def id account_url(object) end @@ -26,7 +43,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def inbox - nil + account_inbox_url(object) end def outbox @@ -46,14 +63,26 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer end def icon - full_asset_url(object.avatar.url(:original)) + object.avatar end def image - full_asset_url(object.header.url(:original)) + object.header end def public_key object end + + def url + short_account_url(object) + end + + def avatar_exists? + object.avatar.exists? + end + + def header_exists? + object.header.exists? + end end diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb index a001b213b83..b3bd9f868ec 100644 --- a/app/serializers/activitypub/block_serializer.rb +++ b/app/serializers/activitypub/block_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::BlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join + end + def type 'Block' end diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index 77098b1b0c6..b49268d72a3 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::DeleteSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join + end + def type 'Delete' end diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb index 1953a2d7b82..86c9992fe32 100644 --- a/app/serializers/activitypub/follow_serializer.rb +++ b/app/serializers/activitypub/follow_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::FollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join + end + def type 'Follow' end diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb index 4226913f509..c1a7ff6f637 100644 --- a/app/serializers/activitypub/like_serializer.rb +++ b/app/serializers/activitypub/like_serializer.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class ActivityPub::LikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor attribute :virtual_object, key: :object + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join + end + def type 'Like' end diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb index 28584d6271f..7814f4f5759 100644 --- a/app/serializers/activitypub/reject_follow_serializer.rb +++ b/app/serializers/activitypub/reject_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join + end + def type 'Reject' end diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb index f71faa72962..2f43d8402a3 100644 --- a/app/serializers/activitypub/undo_block_serializer.rb +++ b/app/serializers/activitypub/undo_block_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::BlockSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb index fe91f5f1c4d..e5b7f143d79 100644 --- a/app/serializers/activitypub/undo_follow_serializer.rb +++ b/app/serializers/activitypub/undo_follow_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::FollowSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb index db9cd1d0de4..25f4ccaaed5 100644 --- a/app/serializers/activitypub/undo_like_serializer.rb +++ b/app/serializers/activitypub/undo_like_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::LikeSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join + end + def type 'Undo' end diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb index 322305da8c7..ebc667d9630 100644 --- a/app/serializers/activitypub/update_serializer.rb +++ b/app/serializers/activitypub/update_serializer.rb @@ -1,10 +1,14 @@ # frozen_string_literal: true class ActivityPub::UpdateSerializer < ActiveModel::Serializer - attributes :type, :actor + attributes :id, :type, :actor has_one :object, serializer: ActivityPub::ActorSerializer + def id + [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join + end + def type 'Update' end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb new file mode 100644 index 00000000000..e443b94638a --- /dev/null +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteAccountService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + # Does a WebFinger roundtrip on each call + def call(uri) + @json = fetch_resource(uri) + + return unless supported_context? && expected_type? + + @uri = @json['id'] + @username = @json['preferredUsername'] + @domain = Addressable::URI.parse(uri).normalized_host + + return unless verified_webfinger? + + ActivityPub::ProcessAccountService.new.call(@username, @domain, @json) + rescue Oj::ParseError + nil + end + + private + + def verified_webfinger? + webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + + return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + + webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}") + confirmed_username, confirmed_domain = split_acct(webfinger.subject) + self_reference = webfinger.link('self') + + return false if self_reference&.href != @uri + + @username = confirmed_username + @domain = confirmed_domain + + true + rescue Goldfinger::Error + false + end + + def split_acct(acct) + acct.gsub(/\Aacct:/, '').split('@') + end + + def supported_context? + super(@json) + end + + def expected_type? + @json['type'] == 'Person' + end +end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb new file mode 100644 index 00000000000..80305c53dce --- /dev/null +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteStatusService < BaseService + include JsonLdHelper + + # Should be called when uri has already been checked for locality + def call(uri) + @json = fetch_resource(uri) + + return unless supported_context? && expected_type? + + attributed_to = first_of_value(@json['attributedTo']) + attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash) + + return unless trustworthy_attribution?(uri, attributed_to) + + actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account) + actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil? + + ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform + end + + private + + def trustworthy_attribution?(uri, attributed_to) + Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero? + end + + def supported_context? + super(@json) + end + + def expected_type? + %w(Note Article).include? @json['type'] + end +end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb new file mode 100644 index 00000000000..92e2dbb30fa --- /dev/null +++ b/app/services/activitypub/process_account_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessAccountService < BaseService + include JsonLdHelper + + # Should be called with confirmed valid JSON + # and WebFinger-resolved username and domain + def call(username, domain, json) + @json = json + @uri = @json['id'] + @username = username + @domain = domain + @account = Account.find_by(uri: @uri) + + create_account if @account.nil? + update_account + + @account + rescue Oj::ParseError + nil + end + + private + + def create_account + @account = Account.new + @account.username = @username + @account.domain = @domain + @account.uri = @uri + @account.suspended = true if auto_suspend? + @account.silenced = true if auto_silence? + @account.private_key = nil + @account.save! + end + + def update_account + @account.last_webfingered_at = Time.now.utc + @account.protocol = :activitypub + @account.inbox_url = @json['inbox'] || '' + @account.outbox_url = @json['outbox'] || '' + @account.shared_inbox_url = @json['sharedInbox'] || '' + @account.followers_url = @json['followers'] || '' + @account.url = @json['url'] || @uri + @account.display_name = @json['name'] || '' + @account.note = @json['summary'] || '' + @account.avatar_remote_url = image_url('icon') + @account.header_remote_url = image_url('image') + @account.public_key = public_key || '' + @account.save! + end + + def image_url(key) + value = first_of_value(@json[key]) + + return if value.nil? + return @json[key]['url'] if @json[key].is_a?(Hash) + + image = fetch_resource(value) + image['url'] if image + end + + def public_key + value = first_of_value(@json['publicKey']) + + return if value.nil? + return value['publicKeyPem'] if value.is_a?(Hash) + + key = fetch_resource(value) + key['publicKeyPem'] if key + end + + def auto_suspend? + domain_block && domain_block.suspend? + end + + def auto_silence? + domain_block && domain_block.silence? + end + + def domain_block + return @domain_block if defined?(@domain_block) + @domain_block = DomainBlock.find_by(domain: @domain) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb new file mode 100644 index 00000000000..cd861c07530 --- /dev/null +++ b/app/services/activitypub/process_collection_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessCollectionService < BaseService + include JsonLdHelper + + def call(body, account) + @account = account + @json = Oj.load(body, mode: :strict) + + return if @account.suspended? || !supported_context? + + case @json['type'] + when 'Collection', 'CollectionPage' + process_items @json['items'] + when 'OrderedCollection', 'OrderedCollectionPage' + process_items @json['orderedItems'] + else + process_items [@json] + end + rescue Oj::ParseError + nil + end + + private + + def process_items(items) + items.reverse_each.map { |item| process_item(item) }.compact + end + + def supported_context? + super(@json) + end + + def process_item(item) + activity = ActivityPub::Activity.factory(item, @account) + activity&.perform + end +end diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index e0e2ebc8310..220ef043cba 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -2,6 +2,7 @@ class ResolveRemoteAccountService < BaseService include OStatus2::MagicKey + include JsonLdHelper DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' @@ -12,6 +13,7 @@ class ResolveRemoteAccountService < BaseService # @return [Account] def call(uri, update_profile = true, redirected = nil) @username, @domain = uri.split('@') + @update_profile = update_profile return Account.find_local(@username) if TagManager.instance.local_domain?(@domain) @@ -42,10 +44,11 @@ class ResolveRemoteAccountService < BaseService if lock.acquired? @account = Account.find_remote(@username, @domain) - create_account if @account.nil? - update_account - - update_account_profile if update_profile + if activitypub_ready? + handle_activitypub + else + handle_ostatus + end end end @@ -58,18 +61,47 @@ class ResolveRemoteAccountService < BaseService private def links_missing? - @webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || + !(activitypub_ready? || ostatus_ready?) + end + + def ostatus_ready? + !(@webfinger.link('http://schemas.google.com/g/2010#updates-from').nil? || @webfinger.link('salmon').nil? || @webfinger.link('http://webfinger.net/rel/profile-page').nil? || @webfinger.link('magic-public-key').nil? || canonical_uri.nil? || - hub_url.nil? + hub_url.nil?) end def webfinger_update_due? @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago end + def activitypub_ready? + !@webfinger.link('self').nil? && + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) + end + + def handle_ostatus + create_account if @account.nil? + update_account + update_account_profile if update_profile? + end + + def update_profile? + @update_profile + end + + def handle_activitypub + json = fetch_resource(actor_url) + + return unless supported_context?(json) && json['type'] == 'Person' + + @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, json) + rescue Oj::ParseError + nil + end + def create_account Rails.logger.debug "Creating new remote account for #{@username}@#{@domain}" @@ -81,6 +113,7 @@ class ResolveRemoteAccountService < BaseService def update_account @account.last_webfingered_at = Time.now.utc + @account.protocol = :ostatus @account.remote_url = atom_url @account.salmon_url = salmon_url @account.url = url @@ -111,6 +144,10 @@ class ResolveRemoteAccountService < BaseService @salmon_url ||= @webfinger.link('salmon').href end + def actor_url + @actor_url ||= @webfinger.link('self').href + end + def url @url ||= @webfinger.link('http://webfinger.net/rel/profile-page').href end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb new file mode 100644 index 00000000000..7656ab56a38 --- /dev/null +++ b/app/workers/activitypub/processing_worker.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessingWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(account_id, body) + ProcessCollectionService.new.call(body, Account.find(account_id)) + end +end diff --git a/app/workers/activitypub/thread_resolve_worker.rb b/app/workers/activitypub/thread_resolve_worker.rb new file mode 100644 index 00000000000..4ef762d0642 --- /dev/null +++ b/app/workers/activitypub/thread_resolve_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ActivityPub::ThreadResolveWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: false + + def perform(child_status_id, parent_uri) + child_status = Status.find(child_status_id) + parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri) + + return if parent_status.nil? + + child_status.thread = parent_status + child_status.save! + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 44e54c9f369..bf0cb52a30a 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'ActivityPub' inflect.acronym 'PubSubHubbub' inflect.acronym 'ActivityStreams' + inflect.acronym 'JsonLd' end diff --git a/config/routes.rb b/config/routes.rb index 400fa88c53d..a1b20671643 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,7 @@ Rails.application.routes.draw do resource :follow, only: [:create], controller: :account_follow resource :unfollow, only: [:create], controller: :account_unfollow resource :outbox, only: [:show], module: :activitypub + resource :inbox, only: [:create], module: :activitypub end get '/@:username', to: 'accounts#show', as: :short_account diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb new file mode 100644 index 00000000000..5c12fea7df8 --- /dev/null +++ b/spec/controllers/activitypub/inboxes_controller_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::InboxesController, type: :controller do + describe 'POST #create' do + pending + end +end diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb new file mode 100644 index 00000000000..f98e4a8c38c --- /dev/null +++ b/spec/controllers/activitypub/outboxes_controller_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::OutboxesController, type: :controller do + let!(:account) { Fabricate(:account) } + + before do + Fabricate(:status, account: account) + end + + describe 'GET #show' do + before do + get :show, params: { account_username: account.username } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb new file mode 100644 index 00000000000..7d3912e6c43 --- /dev/null +++ b/spec/helpers/jsonld_helper_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe JsonLdHelper do + describe '#equals_or_includes?' do + it 'returns true when value equals' do + expect(helper.equals_or_includes?('foo', 'foo')).to be true + end + + it 'returns false when value does not equal' do + expect(helper.equals_or_includes?('foo', 'bar')).to be false + end + + it 'returns true when value is included' do + expect(helper.equals_or_includes?(%w(foo baz), 'foo')).to be true + end + + it 'returns false when value is not included' do + expect(helper.equals_or_includes?(%w(foo baz), 'bar')).to be false + end + end + + describe '#first_of_value' do + pending + end + + describe '#supported_context?' do + pending + end + + describe '#fetch_resource' do + pending + end +end diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb new file mode 100644 index 00000000000..54dd52a6049 --- /dev/null +++ b/spec/lib/activitypub/activity/announce_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Announce do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/block_spec.rb b/spec/lib/activitypub/activity/block_spec.rb new file mode 100644 index 00000000000..23c8cc31cf7 --- /dev/null +++ b/spec/lib/activitypub/activity/block_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Block do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a block from sender to recipient' do + expect(sender.blocking?(recipient)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb new file mode 100644 index 00000000000..fcb044ebcbf --- /dev/null +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -0,0 +1,221 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Create do + let(:sender) { Fabricate(:account, followers_url: 'http://example.com/followers') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + before do + stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) + end + + describe '#perform' do + before do + subject.perform + end + + context 'standalone' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + + it 'missing to/cc defaults to direct privacy' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'public' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public' + end + end + + context 'unlisted' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + cc: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'unlisted' + end + end + + context 'private' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: 'http://example.com/followers', + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'private' + end + end + + context 'direct' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + to: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'direct' + end + end + + context 'as a reply' do + let(:original_status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + inReplyTo: ActivityPub::TagManager.instance.uri_for(original_status), + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.thread).to eq original_status + expect(status.reply?).to be true + expect(status.in_reply_to_account).to eq original_status.account + expect(status.conversation).to eq original_status.conversation + end + end + + context 'with mentions' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(recipient), + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.mentions.map(&:account)).to include(recipient) + end + end + + context 'with media attachments' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + attachment: [ + { + type: 'Document', + mime_type: 'image/png', + url: 'http://example.com/attachment.png', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.media_attachments.map(&:remote_url)).to include('http://example.com/attachment.png') + end + end + + context 'with hashtags' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum', + tag: [ + { + type: 'Hashtag', + href: 'http://example.com/blah', + name: '#test', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.tags.map(&:name)).to include('test') + end + end + end +end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb new file mode 100644 index 00000000000..398669b485f --- /dev/null +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Delete do + let(:sender) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: sender, uri: 'foobar') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Delete', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + end +end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb new file mode 100644 index 00000000000..7c0e447f35a --- /dev/null +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Follow do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/like_spec.rb b/spec/lib/activitypub/activity/like_spec.rb new file mode 100644 index 00000000000..b69615a9d1b --- /dev/null +++ b/spec/lib/activitypub/activity/like_spec.rb @@ -0,0 +1,29 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Like do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: recipient) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a favourite from sender to status' do + expect(sender.favourited?(status)).to be true + end + end +end diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb new file mode 100644 index 00000000000..4629a033f28 --- /dev/null +++ b/spec/lib/activitypub/activity/undo_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Undo do + let(:sender) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Undo', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: object_json, + }.with_indifferent_access + end + + subject { described_class.new(json, sender) } + + describe '#perform' do + context 'with Announce' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:status, reblog: status, account: sender, uri: 'bar') + end + + it 'deletes the reblog' do + subject.perform + expect(sender.reblogged?(status)).to be false + end + end + + context 'with Block' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Block', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.block!(recipient) + end + + it 'deletes block from sender to recipient' do + subject.perform + expect(sender.blocking?(recipient)).to be false + end + end + + context 'with Follow' do + let(:recipient) { Fabricate(:account) } + + let(:object_json) do + { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(recipient), + } + end + + before do + sender.follow!(recipient) + end + + it 'deletes follow from sender to recipient' do + subject.perform + expect(sender.following?(recipient)).to be false + end + end + + context 'with Like' do + let(:status) { Fabricate(:status) } + + let(:object_json) do + { + id: 'bar', + type: 'Like', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + before do + Fabricate(:favourite, account: sender, status: status) + end + + it 'deletes favourite from sender to status' do + subject.perform + expect(sender.favourited?(status)).to be false + end + end + end +end diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb new file mode 100644 index 00000000000..0bd6d00d9ca --- /dev/null +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Update do + let!(:sender) { Fabricate(:account) } + + before do + sender.update!(uri: ActivityPub::TagManager.instance.uri_for(sender)) + end + + let(:modified_sender) do + sender.dup.tap do |modified_sender| + modified_sender.display_name = 'Totally modified now' + end + end + + let(:actor_json) do + ActiveModelSerializers::SerializableResource.new(modified_sender, serializer: ActivityPub::ActorSerializer, key_transform: :camel_lower).as_json + end + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Update', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: actor_json, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'updates profile' do + expect(sender.reload.display_name).to eq 'Totally modified now' + end + end +end diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb new file mode 100644 index 00000000000..8f7662e24a3 --- /dev/null +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -0,0 +1,99 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::TagManager do + include RoutingHelper + + subject { described_class.instance } + + describe '#url_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.url_for(account)).to be_a String + end + end + + describe '#uri_for' do + it 'returns a string' do + account = Fabricate(:account) + expect(subject.uri_for(account)).to be_a String + end + end + + describe '#to' do + it 'returns public collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.to(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns followers collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns followers collection for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.to(status)).to eq [account_followers_url(status.account)] + end + + it 'returns URIs of mentions for direct status' do + status = Fabricate(:status, visibility: :direct) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.to(status)).to eq [subject.uri_for(mentioned)] + end + end + + describe '#cc' do + it 'returns followers collection for public status' do + status = Fabricate(:status, visibility: :public) + expect(subject.cc(status)).to eq [account_followers_url(status.account)] + end + + it 'returns public collection for unlisted status' do + status = Fabricate(:status, visibility: :unlisted) + expect(subject.cc(status)).to eq ['https://www.w3.org/ns/activitystreams#Public'] + end + + it 'returns empty array for private status' do + status = Fabricate(:status, visibility: :private) + expect(subject.cc(status)).to eq [] + end + + it 'returns empty array for direct status' do + status = Fabricate(:status, visibility: :direct) + expect(subject.cc(status)).to eq [] + end + + it 'returns URIs of mentions for non-direct status' do + status = Fabricate(:status, visibility: :public) + mentioned = Fabricate(:account) + status.mentions.create(account: mentioned) + expect(subject.cc(status)).to include(subject.uri_for(mentioned)) + end + end + + describe '#local_uri?' do + it 'returns false for non-local URI' do + expect(subject.local_uri?('http://example.com/123')).to be false + end + + it 'returns true for local URIs' do + account = Fabricate(:account) + expect(subject.local_uri?(subject.uri_for(account))).to be true + end + end + + describe '#uri_to_local_id' do + it 'returns the local ID' do + account = Fabricate(:account) + expect(subject.uri_to_local_id(subject.uri_for(account), :username)).to eq account.username + end + end + + describe '#uri_to_resource' do + it 'returns the local resource' do + account = Fabricate(:account) + expect(subject.uri_to_resource(subject.uri_for(account), Account)).to eq account + end + end +end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb new file mode 100644 index 00000000000..786d7f7f2b0 --- /dev/null +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteAccountService do + subject { ActivityPub::FetchRemoteAccountService.new } + + let!(:actor) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://example.com/alice', + type: 'Person', + preferredUsername: 'alice', + name: 'Alice', + summary: 'Foo bar', + } + end + + describe '#call' do + let(:account) { subject.call('https://example.com/alice') } + + shared_examples 'sets profile data' do + it 'returns an account' do + expect(account).to be_an Account + end + + it 'sets display name' do + expect(account.display_name).to eq 'Alice' + end + + it 'sets note' do + expect(account.note).to eq 'Foo bar' + end + + it 'sets URL' do + expect(account.url).to eq 'https://example.com/alice' + end + end + + context 'when URI and WebFinger share the same host' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'sets username and domain from webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'example.com' + end + + include_examples 'sets profile data' + end + + context 'when WebFinger presents different domain than URI' do + let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'looks up "redirected" webfinger' do + account + expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once + end + + it 'sets username and domain from final webfinger' do + expect(account.username).to eq 'alice' + expect(account.domain).to eq 'iscool.af' + end + + include_examples 'sets profile data' + end + end +end diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb new file mode 100644 index 00000000000..47a33b6cb6d --- /dev/null +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::FetchRemoteStatusService do + pending +end diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb new file mode 100644 index 00000000000..84a74c231f0 --- /dev/null +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessAccountService do + pending +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb new file mode 100644 index 00000000000..6486483f65b --- /dev/null +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessCollectionService do + subject { ActivityPub::ProcessCollectionService.new } + + describe '#call' do + pending + end +end From 61bfce5aa939add6829a216b4ae8e3c660d85791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Tue, 8 Aug 2017 22:13:04 +0200 Subject: [PATCH 008/201] add missing @ to the onboarding modal (#4560) --- .../mastodon/features/ui/components/onboarding_modal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 3d59785e2f7..7905bca2eea 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -30,7 +30,7 @@ const PageOne = ({ acct, domain }) => (

-

{acct}@{domain} }} />

+

@{acct}@{domain} }} />

); From 2ebe4ff5685ea022f8626e8917f5d6ae8b09d6e9 Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Tue, 8 Aug 2017 22:18:12 +0200 Subject: [PATCH 009/201] api/instances: Include the stats from the /about/more page (#4074) To be able to pull some basic statistics from a Mastodon instance, include the user, status and connected domain counters in the `/api/v1/instance` response. Fixes #3570. Signed-off-by: Gergely Nagy --- app/serializers/rest/instance_serializer.rb | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index 8e32f9cb35e..a971379094f 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -2,7 +2,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer attributes :uri, :title, :description, :email, - :version, :urls + :version, :urls, :stats def uri Rails.configuration.x.local_domain @@ -24,7 +24,21 @@ class REST::InstanceSerializer < ActiveModel::Serializer Mastodon::Version.to_s end + def stats + { + user_count: instance_presenter.user_count, + status_count: instance_presenter.status_count, + domain_count: instance_presenter.domain_count, + } + end + def urls { streaming_api: Rails.configuration.x.streaming_api_base_url } end + + private + + def instance_presenter + @instance_presenter ||= InstancePresenter.new + end end From 820099813fe4ff824b939cc60c690be369997c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Wed, 9 Aug 2017 00:21:58 +0200 Subject: [PATCH 010/201] add scrollTop to ui/components/column (#4563) --- .../mastodon/features/ui/components/column.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index 9031c16fc29..15538ea387a 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -25,6 +25,17 @@ export default class Column extends React.PureComponent { this._interruptScrollAnimation = scrollTop(scrollable); } + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleScroll = debounce(() => { if (typeof this._interruptScrollAnimation !== 'undefined') { this._interruptScrollAnimation(); From b1c8a702a457ea04c3800ddb915032a0800a2048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Wed, 9 Aug 2017 00:22:26 +0200 Subject: [PATCH 011/201] Add favourited toot to favourites column (#4562) * Add faved toot to faves column * renamed append to prepend for clarity --- app/javascript/mastodon/reducers/status_lists.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index bbc9733027a..580cc17d2b0 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -3,6 +3,7 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { FAVOURITE_SUCCESS } from '../actions/interactions'; const initialState = ImmutableMap({ favourites: ImmutableMap({ @@ -27,12 +28,20 @@ const appendToList = (state, listType, statuses, next) => { })); }; +const prependOneToList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').unshift(status.get('id'))); + })); +}; + export default function statusLists(state = initialState, action) { switch(action.type) { case FAVOURITED_STATUSES_FETCH_SUCCESS: return normalizeList(state, 'favourites', action.statuses, action.next); case FAVOURITED_STATUSES_EXPAND_SUCCESS: return appendToList(state, 'favourites', action.statuses, action.next); + case FAVOURITE_SUCCESS: + return prependOneToList(state, 'favourites', action.status); default: return state; } From 4e1bf082ce83ca941f20993fcfb8b6f9597624c6 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Wed, 9 Aug 2017 00:46:21 +0200 Subject: [PATCH 012/201] i18n: Improve admin panel translation (pl) (#4559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- config/locales/pl.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 415c3b9931a..b8b5ace14b8 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -65,12 +65,12 @@ pl: title: Położenie media_attachments: Załączniki multimedialne moderation: - all: Wszystko + all: Wszystkie silenced: Wyciszone suspended: Zawieszone title: Moderacja most_recent_activity: Najnowsza aktywność - most_recent_ip: Najnowsze IP + most_recent_ip: Ostatnie IP not_subscribed: Nie zasubskrybowano order: alphabetic: Alfabetycznie @@ -88,9 +88,9 @@ pl: search: Szukaj show: created_reports: Zgłoszenia tego użytkownika - report: zgłoszenie + report: zgłoszeń targeted_reports: Zgłoszenia dotyczące tego użytkownika - silence: Cisza + silence: Wycisz statuses: Statusy subscribe: Subskrybuj title: Konta @@ -123,13 +123,14 @@ pl: show: affected_accounts: one: Dotyczy jednego konta w bazie danych + many: Dotyczy %{count} kont w bazie danych other: Dotyczy %{count} kont w bazie danych retroactive: silence: Odwołaj wyciszenie wszystkich kont w tej domenie suspend: Odwołaj zawieszenie wszystkich kont w tej domenie title: Odwołaj blokadę dla domeny %{domain} undo: Cofnij - title: Blokady domen + title: Zablokowane domeny undo: Cofnij instances: account_count: Znane konta @@ -142,7 +143,7 @@ pl: label: Komentarz none: Brak delete: Usuń - id: Identyfikator + id: ID mark_as_resolved: Oznacz jako rozwiązane nsfw: 'false': Nie oznaczaj jako NSFW @@ -150,8 +151,8 @@ pl: report: 'Zgłoszenie #%{id}' report_contents: Zawartość reported_account: Zgłoszone konto - reported_by: Zgłoszone przez - resolved: Rozwiązano + reported_by: Zgłaszający + resolved: Rozwiązane silence_account: Wycisz konto status: Status suspend_account: Zawieś konto @@ -180,7 +181,7 @@ pl: desc_html: Dobre miejsce na zasady użytkowania, wprowadzenie i inne rzeczy, które wyróżniają tę instancję. Możesz korzystać z tagów HTML title: Niestandardowy opis strony site_terms: - desc_html: Miejsce na własną politykę prywatności, zasady użytkowania i inne unormowania prawne. Możesz używać tagów HTML + desc_html: Miejsce na własną politykę prywatności, zasady użytkowania i inne unormowania prawne. Możesz korzystać z tagów HTML title: Niestandardowe zasady użytkowania site_title: Nazwa instancji timeline_preview: @@ -204,7 +205,7 @@ pl: with_media: Z zawartością multimedialną subscriptions: callback_url: URL zwrotny - confirmed: Potwierdzono + confirmed: Potwierdzone expires_in: Wygasa last_delivery: Ostatnio doręczono title: WebSub From fdea173237cfcd3a6b36f6ebccb0cb1a21cf9294 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 9 Aug 2017 23:54:14 +0200 Subject: [PATCH 013/201] Add Digest header to requests with body, handle acct and URI keyId (#4565) --- .../concerns/signature_verification.rb | 23 +++++- app/lib/request.rb | 24 +++++- .../concerns/signature_verification_spec.rb | 78 ++++++++++++++----- 3 files changed, 100 insertions(+), 25 deletions(-) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index abe845d9374..aeb8da879e7 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -31,7 +31,7 @@ module SignatureVerification return end - account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + account = account_from_key_id(signature_params['keyId']) if account.nil? @signed_request_account = nil @@ -49,6 +49,10 @@ module SignatureVerification end end + def request_body + @request_body ||= request.raw_post + end + private def build_signed_string(signed_headers) @@ -57,6 +61,8 @@ module SignatureVerification signed_headers.split(' ').map do |signed_header| if signed_header == Request::REQUEST_TARGET "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + elsif signed_header == 'digest' + "digest: #{body_digest}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -73,6 +79,10 @@ module SignatureVerification (Time.now.utc - time_sent).abs <= 30 end + def body_digest + "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + end + def to_header_name(name) name.split(/-/).map(&:capitalize).join('-') end @@ -81,7 +91,14 @@ module SignatureVerification signature_params['keyId'].blank? || signature_params['signature'].blank? || signature_params['algorithm'].blank? || - signature_params['algorithm'] != 'rsa-sha256' || - !signature_params['keyId'].start_with?('acct:') + signature_params['algorithm'] != 'rsa-sha256' + end + + def account_from_key_id(key_id) + if key_id.start_with?('acct:') + ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) + elsif !ActivityPub::TagManager.instance.local_uri?(key_id) + ActivityPub::FetchRemoteAccountService.new.call(key_id) + end end end diff --git a/app/lib/request.rb b/app/lib/request.rb index e73c5ac20b8..c01e079259e 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -12,15 +12,21 @@ class Request @headers = {} set_common_headers! + set_digest! if options.key?(:body) end - def on_behalf_of(account) + def on_behalf_of(account, key_id_format = :acct) raise ArgumentError unless account.local? - @account = account + + @account = account + @key_id_format = key_id_format + + self end def add_headers(new_headers) @headers.merge!(new_headers) + self end def perform @@ -40,8 +46,11 @@ class Request @headers['Date'] = Time.now.utc.httpdate end + def set_digest! + @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}" + end + def signature - key_id = @account.to_webfinger_s algorithm = 'rsa-sha256' signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string)) @@ -60,6 +69,15 @@ class Request @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})" end + def key_id + case @key_id_format + when :acct + @account.to_webfinger_s + when :uri + [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join + end + end + def timeout { write: 10, connect: 10, read: 10 } end diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb index b371795ab06..64648621e04 100644 --- a/spec/controllers/concerns/signature_verification_spec.rb +++ b/spec/controllers/concerns/signature_verification_spec.rb @@ -16,7 +16,7 @@ describe ApplicationController, type: :controller do end before do - routes.draw { get 'success' => 'anonymous#success' } + routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' } end context 'without signature header' do @@ -40,34 +40,74 @@ describe ApplicationController, type: :controller do context 'with signature header' do let!(:author) { Fabricate(:account) } - before do - get :success + context 'without body' do + before do + get :success - fake_request = Request.new(:get, request.url) - fake_request.on_behalf_of(author) + fake_request = Request.new(:get, request.url) + fake_request.on_behalf_of(author) - request.headers.merge!(fake_request.headers) - end + request.headers.merge!(fake_request.headers) + end - describe '#signed_request?' do - it 'returns true' do - expect(controller.signed_request?).to be true + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end + end + + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + post :success + expect(controller.signed_request_account).to be_nil + end end end - describe '#signed_request_account' do - it 'returns an account' do - expect(controller.signed_request_account).to eq author + context 'with body' do + before do + post :success, body: 'Hello world' + + fake_request = Request.new(:post, request.url, body: 'Hello world') + fake_request.on_behalf_of(author) + + request.headers.merge!(fake_request.headers) end - it 'returns nil when path does not match' do - request.path = '/alternative-path' - expect(controller.signed_request_account).to be_nil + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end end - it 'returns nil when method does not match' do - post :success - expect(controller.signed_request_account).to be_nil + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + get :success + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when body has been tampered' do + request.headers['RAW_POST_DATA'] = 'doo doo doo' + expect(controller.signed_request_account).to be_nil + end end end end From d9a1fb134a407aaa7fcff5be285d7414a0cb43ed Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Thu, 10 Aug 2017 20:41:12 +0900 Subject: [PATCH 014/201] Fix emoji picker scrollbar style (#4572) --- app/javascript/styles/components.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 77b06b2d0da..f66be511189 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -394,6 +394,11 @@ bottom: -1px; right: 8px; } + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } } } From 10cdad3e7d9be948bf7dab2c84d2a803224fdda8 Mon Sep 17 00:00:00 2001 From: spla Date: Thu, 10 Aug 2017 14:52:40 +0200 Subject: [PATCH 015/201] Added new catalan strings (#4574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Catalan language * Add Catalan language * Update ca.json * Update ca.json * Update ca.json * Update ca.json * Update ca.json * Update ca.json * Update settings_helper.rb * Update mastodon.js * Update index.js * Update application.rb * Update ca.yml * removed extra spaces at line 225 * Catalan translation update added activerecord.ca.yml * Update activerecord.ca.yml Done * Updated activerecord.ca.yml * Catalan language updated * Catalan language updated * Catalan language updated * Catalan language updated * Catalan language updated * Update ca.json Removed : <<<<<<< HEAD "getting_started.support": "{faq} • {userguide} • {apps}", ======= >>>>>>> upstream/master * Syncing to master * Added new Catalan strings * removed config.secret_key line * Corrected tag to Line 515 * Removed extra line * Reverted * yarn.lock reverted --- config/locales/ca.yml | 194 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 3 deletions(-) diff --git a/config/locales/ca.yml b/config/locales/ca.yml index a9f9e4c9327..58d7a66389e 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -5,15 +5,34 @@ ca: about_this: Sobre aquesta instància closed_registrations: Els registres estan actualment tancats en aquesta instància. contact: Contacte + contact_missing: No configurat + contact_unavailable: N/A description_headline: Què es %{domain}? domain_count_after: altres instàncies domain_count_before: Connectat a + extended_description_html: | +

Un bon lloc per les regles

+

Encara no s'ha configurat la descripció ampliada.

+ features: + humane_approach_body: Aprenent dels errors d'altres xarxes, Mastodon té com a objectiu fer ètiques eleccions de disseny per combatre el mal ús de les xarxes socials. + humane_approach_title: Un enfocament més humà + not_a_product_body: Mastodon no és una xarxa comercial. Sense publicitat, sense mineria de dades, sense jardins amurallats. No hi ha autoritat central. + not_a_product_title: Ets una persona, no un producte + real_conversation_body: Amb 500 caràcters a la teva disposició i suport per a continguts granulars i avisos multimèdia, pots expressar-te de la manera que vulguis. + real_conversation_title: Construït per a converses reals + within_reach_body: Diverses aplicacions per a iOS, Android i altres plataformes gràcies a un ecosistema API amable amb el desenvolupador, et permet mantenir-te al dia amb els teus amics en qualsevol lloc.. + within_reach_title: Sempre a l'abast + find_another_instance: Troba altres instàncies + generic_description: "%{domain} és un servidor a la xarxa" + hosted_on: Mastodon allotjat a %{domain} + learn_more: Aprèn més other_instances: Altres instàncies source_code: Codi font status_count_after: estats status_count_before: Que han escrit user_count_after: usuaris registrats user_count_before: Tenim + what_is_mastodon: Què és Mastodon? accounts: follow: Seguir followers: Seguidors @@ -90,12 +109,14 @@ ca: hint: El bloqueig de domini no impedirà la creació de nous comptes en la base de dades, però s´aplicaran mètodes de moderació específics sobre aquests comptes severity: desc_html: "Silenci farà les publicacions del compte invisibles a tothom que no l'estigui seguint. Suspendre eliminarà tots els continguts, multimèdia i les dades del perfil del compte." + noop: Cap silence: Silenci suspend: Suspendre title: Nou bloqueig de domini reject_media: Rebutjar arxius multimèdia reject_media_hint: Elimina arxius multimèdia emmagatzamats localment i impideix descarregar cap en el futur. Irrellevant per suspensions severities: + noop: Cap silence: Silenci suspend: Suspendre severity: Severitat @@ -146,16 +167,41 @@ ca: closed_message: desc_html: Apareix en la primera pàgina quan es tanquen els registres
Pot utilitzar etiquetes HTML title: Missatge de registre tancat + deletion: + desc_html: Permet a qualsevol esborrar el seu compte + title: Obrir la supressió del compte open: + desc_html: Permet que qualsevol pugui crear un compte title: Registre obert site_description: desc_html: Es mostra com un paràgraf a la pàgina principal i s'utilitza com una etiqueta meta.
Pots utilitzar etiquetes HTML, en particular <a> i <em>. title: Descripció del lloc site_description_extended: - desc_html: Apareix a la pàgina d'informació estesa
Pot utilitzar etiquetes HTML + desc_html: Un bon lloc per al vostre codi de conducta, regles, directrius i altres coses que distingeixen la vostra instància. Podeu utilitzar etiquetes HTML title: Descripció estesa del lloc + site_terms: + desc_html: Pots escriure la teva pròpia política de privadesa, els termes del servei o d'altres normes legals. Pots utilitzar etiquetes HTML + title: Termes del servei personalitzats site_title: Títol del lloc + timeline_preview: + desc_html: Mostra la línia de temps pública a la pàgina inicial + title: Vista prèvia de la línia de temps title: Configuració del lloc + statuses: + back_to_account: Torna a la pàgina del compte + batch: + delete: Esborra + nsfw_off: NSFW OFF + nsfw_on: NSFW ON + execute: Executa + failed_to_execute: No s'ha pogut executar + media: + hide: Amaga multimèdia + show: Mostra multimèdia + title: Multimèdia + no_media: Sense multimèdia + title: Estats del compte + with_media: Amb multimèdia subscriptions: callback_url: Callback URL confirmed: Confirmat @@ -164,18 +210,25 @@ ca: title: WebSub topic: Tòpic title: Administració + admin_mailer: + new_report: + body: "%{reporter} ha informat de %{target}" + subject: Nou informe per a %{instance} (#%{id}) application_mailer: + salutation: '%{name},' settings: 'Canviar preferències de correu: %{link}' signature: Notificacions de Mastodon desde %{instance} view: 'Vista:' applications: invalid_url: La URL proporcionada es incorrecte auth: + agreement_html: En inscriure't, acceptes les nostres termes del servei i la nostra política de privadesa. change_password: Canviar contrasenya delete_account: Esborrar el compte delete_account_html: Si vols esborrar el teu compte pots fer-ho aquí. S'et demanarà confirmació. didnt_get_confirmation: No vas rebre el correu de confirmació? forgot_password: Has oblidat la contrasenya? + invalid_reset_password_token: L'enllaç de restabliment de la contrasenya no és vàlid o caducat. Siusplau torna-ho a provar.. login: Iniciar sessió logout: Tancar sessió register: Enregistrarse @@ -185,6 +238,12 @@ ca: authorize_follow: error: Malauradament, ha ocorregut un error buscant el compte remot follow: Seguir + follow_request: 'Heu enviat una sol·licitud de seguiment a:' + following: 'Èxit! Ara segueixes:' + post_follow: + close: O bé, pots tancar aquesta finestra. + return: Torna al perfil de l'usuari + web: Anar a la web title: Seguir %{acct} datetime: distance_in_words: @@ -254,7 +313,7 @@ ca: landing_strip_signup_html: Si no en tens, pots registrar-te aquí. media_attachments: validations: - images_and_video: No es pot adjuntar un vídeo a un estat que ja contingui imatges + images_and_video: No es pot adjuntar un vídeo a una publicació que ja contingui imatges too_many: No es poden adjuntar més de 4 arxius notification_mailer: digest: @@ -285,11 +344,67 @@ ca: next: Pròxim prev: Anterior truncate: "…" + push_notifications: + favourite: + title: "%{name} favourited your status" + follow: + title: "%{name} is now following you" + group: + title: "%{count} notifications" + mention: + action_boost: Boost + action_expand: Mostra més + action_favourite: Favorit + title: "%{name} t'ha mencionat" + reblog: + title: "%{name} t'ha retootejat" + subscribed: + body: Ara pots rebre notificacions push. + title: Subscripció registrada remote_follow: acct: Escriu el usuari@domini de la persona que vols seguir missing_resource: No s'ha pogut trobar la URL de redirecció necessaria per el compte. proceed: Procedir a seguir prompt: 'Seguiràs a:' + sessions: + activity: Última activitat + browser: Navegador + browsers: + alipay: Alipay + blackberry: Blackberry + chrome: Chrome + edge: Microsoft Edge + firefox: Firefox + generic: Navegador desconegut + ie: Internet Explorer + micro_messenger: MicroMessenger + nokia: Nokia S40 Ovi Browser + opera: Opera + phantom_js: PhantomJS + qq: QQ Browser + safari: Safari + uc_browser: UCBrowser + weibo: Weibo + current_session: Sessió actual + description: "%{browser} de %{platform}" + explanation: Aquests són els navegadors web que actualment han iniciat la sessió al teu compte de Mastodon. + ip: IP + platforms: + adobe_air: Adobe Air + android: Android + blackberry: Blackberry + chrome_os: ChromeOS + firefox_os: Firefox OS + ios: iOS + linux: Linux + mac: Mac + other: plataforma desconeguda + windows: Windows + windows_mobile: Windows Mobile + windows_phone: Windows Phone + revoke: Revoca + revoke_success: S'ha revocat la sessió amb èxit + title: Sessions settings: authorized_apps: Aplicacions autoritzades back: Tornar al inici @@ -316,19 +431,91 @@ ca: click_to_show: Clic per mostrar reblogged: retooteado sensitive_content: Contingut sensible + terms: + body_html: | +

Política de privacitat

+ +

Quina informació recollim?

+ +

Recopilem informació teva quan et registres en aquesta instància i recopilem dades quan participes en el fòrum llegint, escrivint i avaluant el contingut aquí compartit.

+ +

En registrar-te en aquesta instància, se't pot demanar que introduexisu el teu nom i l'adreça de correu electrònic. També pots visitar el nostre lloc sense registrar-te. La teva adreça de correu electrònic es verificarà mitjançant un correu electrònic que conté un enllaç únic. Si es visita aquest enllaç, sabem que controles l'adreça de correu electrònic.

+ +

Quan es registra i publica, registrem l'adreça IP de la qual es va originar la publicació. També podrem conservar els registres del servidor que inclouen l'adreça IP de cada sol·licitud al nostre servidor.

+ +

Per a què utilitzem la teva informació?

+ +

Qualsevol de la informació que recopilem de tu pot utilitzar-se d'una de les maneres següents:

+ +
    +
  • Per a personalitzar la teva experiència — la teva informació ens ajuda a respondre millor a les teves necessitats individuals.
  • +
  • Per millorar el nostre lloc — ens esforcem contínuament per millorar les nostres ofertes de llocs basats en la informació i els comentaris que rebem de tu.
  • +
  • Per millorar el servei al client — la teva informació ens ajuda a respondre més eficaçment a les teves sol·licituds de servei al client i a les necessitats de suport.
  • +
  • Per enviar correus electrònics periòdics — l'adreça electrònica que proporcionis es pot utilitzar per enviar-te informació, notificacions que sol·licitis sobre canvis en temes o en resposta al teu nom d'usuari, respondre a les consultes i/o altres sol·licituds o preguntes.
  • +
+ +

Com protegim la teva informació?

+ +

Implementem diverses mesures de seguretat per mantenir la seguretat de la teva informació personal quan introdueixes, envies o accedeixes a la teva informació personal.

+ +

Quina és la nostre política de retenció de dades?

+ +

Farem un esforç de bona fe per a:

+ +
    +
  • Conserva els registres de servidor que continguin l'adreça IP de totes les sol·licituds a aquest servidor no més de 90 dies.
  • +
  • Conserva les adreces IP associades als usuaris registrats i les seves publicacions no més de 5 anys.
  • +
+ +

Utilitzem galetes?

+ +

Sí. Les cookies són fitxers petits que un lloc o el proveïdor de serveis transfereix al disc dur del vostre ordinador a través del navegador web (si ho permet). Aquestes galetes permeten al lloc reconèixer el vostre navegador i, si teniu un compte registrat, associar-lo al vostre compte registrat.

+ +

Utilitzem cookies per comprendre i desar les vostres preferències per a futures visites i compilar dades agregades sobre el trànsit del lloc i la interacció del lloc, de manera que podrem oferir millors experiències i eines del lloc en el futur. Podem contractar amb proveïdors de serveis de tercers per ajudar-nos a comprendre millor els visitants del nostre lloc. Aquests proveïdors de serveis no estan autoritzats a utilitzar la informació recollida en nom nostre, excepte per ajudar-nos a dur a terme i millorar el nostre negoci.

+ +

Publiquem informació al exterior?

+ +

No venem, comercialitzem ni transmetem a tercers la vostra informació d'identificació personal. Això no inclou tercers de confiança que ens ajudin a operar el nostre lloc, a dur a terme el nostre negoci o a fer-ho, sempre que aquestes parts acceptin mantenir confidencial aquesta informació. També podem publicar la vostra informació quan creiem que l'alliberament és apropiat per complir amb la llei, fer complir les polítiques del nostre lloc o protegir els nostres drets o altres drets, propietat o seguretat. No obstant això, la informació de visitant que no sigui personalment identificable es pot proporcionar a altres parts per a la comercialització, la publicitat o altres usos.

+ +

Vincles de tercers

+ +

De tant en tant, segons el nostre criteri, podem incloure o oferir productes o serveis de tercers al nostre lloc. Aquests llocs de tercers tenen polítiques de privadesa separades i independents. Per tant, no tenim responsabilitat ni responsabilitat civil pel contingut i les activitats d'aquests llocs enllaçats. No obstant això, busquem protegir la integritat del nostre lloc i donem la benvinguda a qualsevol comentari sobre aquests llocs.

+ +

Compliment de la Llei de protecció de la privacitat en línia dels nens

+ +

El nostre lloc, productes i serveis estan dirigits a persones que tenen almenys 13 anys. Si aquest servidor es troba als EUA, i teniu menys de 13 anys, segons els requisits de COPPA (Children's Online Privacy Protection Act) no feu servir aquest lloc.

+ +

Només la política de privacitat en línia

+ +

Aquesta política de privacitat en línia només s'aplica a la informació recopilada a través del nostre lloc i no a la informació recopilada fora de línia.

+ + + +

En utilitzar el nostre lloc, accepta la política de privadesa del nostre lloc web.

+ +

Canvis a la nostra política de privacitat

+ +

Si decidim canviar la nostra política de privadesa, publicarem aquests canvis en aquesta pàgina.

+ +

Aquest document és CC-BY-SA. Es va actualitzar per última vegada el 31 de maig de 2013.

+ +

Originalment adaptat a la política de privadesa del Discurs.

+ title: "%{instance} Condicions del servei i política de privadesa" time: formats: default: "%b %d, %Y, %H:%M" two_factor_authentication: code_hint: Introdueix el codi generat per l'aplicació autenticadora per a confirmar description_html: Si habilites la autenticació de dos factors, et caldrà tenir el teu telèfon, que generarà tokens per a que puguis iniciar sessió. - disable: Deshabilitar + disable: Deshabilitarr enable: Habilitar + enabled: Two-factor authentication is enabled enabled_success: Autenticació de dos factors activada amb èxit generate_recovery_codes: Generar codis de recuperació instructions_html: "Escaneja aquest codi QR desde Google Authenticator o una aplicació similar del teu telèfon. Desde ara, aquesta aplicació generarà tokens que tens que ingresar quan volguis iniciar sessió." lost_recovery_codes: Els codis de recuperació et permeten recuperar l'accés al teu compte si perds el telèfon. Si has perdut els teus codis de recuperació els pots regenerar aquí. Els codis de recuperació anteriors seran anul·lats. manual_instructions: 'Si no pots escanejar el codi QR code i necessites introduir-lo manualment, aquí tens el secret en text plà:' + recovery_codes: Backup recovery codes recovery_codes_regenerated: Codis de recuperació regenerats amb èxit recovery_instructions_html: Si alguna vegada perds l'accéss al telèfon pots utilitzar un dels codis de recuperació a continuació per recuperar l'accés al teu compte. Cal mantenir els codis de recuperació en lloc segur, per exemple imprimint-los i guardar-los amb altres documents importants. setup: Establir @@ -336,3 +523,4 @@ ca: users: invalid_email: La direcció de correu es incorrecte invalid_otp_token: Codi de dos factors incorrecte + signed_in_as: 'Sessió iniciada com a:' From 4b8e4dca26666c7c0709bf5aa765764023da3bdf Mon Sep 17 00:00:00 2001 From: Quent-in Date: Thu, 10 Aug 2017 22:15:26 +0200 Subject: [PATCH 016/201] l10n Update OC #4521 (#4577) * l10n Update OC #4521 Link => token provider => provesidor + more generalized way of using present participle * Update oc.yml --- config/locales/oc.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 6c3f95823a5..b49e4c4b890 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -135,7 +135,7 @@ oc: domain_name: Domeni title: Instàncias conegudas reports: - action_taken_by: Accion menada per + action_taken_by: Mesura menada per are_you_sure: Es segur ? comment: label: Comentari @@ -233,7 +233,7 @@ oc: resend_confirmation: Tornar mandar las instruccions de confirmacion reset_password: Reïnicializar lo senhal set_new_password: Picar un nòu senhal - invalid_reset_password_token: Ligam de reïnicializacion invalid o acabat. Tornatz ensajar se vos plai. + invalid_reset_password_token: Lo geton de reïnicializacion es invalid o acabat. Tornatz demandar un geton se vos plai. authorize_follow: error: O planhèm, i a agut una error al moment de cercar lo compte follow: Sègre @@ -333,7 +333,7 @@ oc: content: Verificacion de seguretat fracassada. Blocatz los cookies ? title: Verificacion de seguretat fracassada '429': Lo servidor mòla (subrecargada) - noscript: Per utilizar l’aplicacion web de Mastodon, mercés d’activar JavaScript. O podètz utilizar una aplicacion per vòstra plataforma coma alernativa. + noscript: Per utilizar l’aplicacion Mastodon, mercés d’activar JavaScript. Autrament podètz utilizar una aplicacion nativa Mastodon per vòstra plataforma. exports: blocks: Personas que blocatz csv: CSV @@ -493,9 +493,9 @@ oc: body_html: |

Politica de confidencialitat

-

Quinas informacions collectem ?

+

Quinas informacions reculhèm ?

-

Collectem informacions sus vos quand vos marcatz sus nòstre site e juntem las donadas quand participatz a nòstre forum en legissent, escrivent e notant lo contengut partejat aquí.

+

Collectem informacions sus vos quand vos marcatz sus nòstre site e juntem las donadas quand participatz a nòstre forum en legir, escriure e notar lo contengut partejat aquí.

Pendent l’inscripcion podèm vos demandar vòstre nom e adreça de corrièl. Podètz çaquelà visitar nòstre site sens vos marcar. Verificarem vòstra adreça amb un messatge donant un ligam unic. Se clicatz sul ligam sauprem qu’avètz lo contraròtle de l’adreça.

@@ -527,9 +527,9 @@ oc:

Empleguem de cookies ?

-

Òc-ben. Los cookies son de pichons fichièrs qu’un site o sos forneires de servicis plaçan dins lo disc dur de vòstre ordenador via lo navigator Web (Se los acceptatz). Aqueles cookies permeton al site de reconéisser vòstre navigator e se tenètz un compte enregistrat de l’associar a vòstre compte.

+

Òc-ben. Los cookies son de pichons fichièrs qu’un site o sos provesidors de servicis plaçan dins lo disc dur de vòstre ordenador via lo navigator Web (Se los acceptatz). Aqueles cookies permeton al site de reconéisser vòstre navigator e se tenètz un compte enregistrat de l’associar a vòstre compte.

-

Empleguem de cookies per comprendre e enregistrar vòstras preferéncias per vòstras visitas venentas, per recampar de donadas sul trafic del site e las interaccions per dire que posquem ofrir una melhora experiéncia del site e de las aisinas pel futur. Pòt arribar que contractèssem amb de forneires de servicis tèrces per nos ajudar a comprendre melhor nòstres visitors. Aqueles forneires an pas lo drech que d’utilizar las donadas collectadas per nos ajudar a menar e melhorar nòstre afar.

+

Empleguem de cookies per comprendre e enregistrar vòstras preferéncias per vòstras visitas venentas, per recampar de donadas sul trafic del site e las interaccions per dire que posquem ofrir una melhora experiéncia del site e de las aisinas pel futur. Pòt arribar que contractèssem amb de provesidors de servicis tèrces per nos ajudar a comprendre melhor nòstres visitors. Aqueles provesidors an pas lo drech que d’utilizar las donadas collectadas per nos ajudar a menar e melhorar nòstre afar.

Divulguem d’informacions a de tèrces ?

From 81c1303cd6137c8c90794cc0bfdc1a0479eb2153 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 10 Aug 2017 22:33:12 +0200 Subject: [PATCH 017/201] Handle ActivityPub follows correctly (#4571) * Handle ActivityPub follows correctly ActivityPub follows are follow-requests. Always require an Accept. If account is not locked, auto-accept. * Handle ActivityPub Accept/Reject-Follow * Fix wrong method * Fix wrong class --- app/lib/activitypub/activity.rb | 4 ++++ app/lib/activitypub/activity/accept.rb | 25 +++++++++++++++++++++++++ app/lib/activitypub/activity/follow.rb | 10 ++++++++-- app/lib/activitypub/activity/reject.rb | 25 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 app/lib/activitypub/activity/accept.rb create mode 100644 app/lib/activitypub/activity/reject.rb diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index d1b81a58274..5debe023a33 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -39,6 +39,10 @@ class ActivityPub::Activity ActivityPub::Activity::Update when 'Undo' ActivityPub::Activity::Undo + when 'Accept' + ActivityPub::Activity::Accept + when 'Reject' + ActivityPub::Activity::Reject end end end diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb new file mode 100644 index 00000000000..f5880937a0d --- /dev/null +++ b/app/lib/activitypub/activity/accept.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Accept < ActivityPub::Activity + def perform + case @object['type'] + when 'Follow' + accept_follow + end + end + + private + + def accept_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + follow_request = FollowRequest.find_by(account: target_account, target_account: @account) + follow_request&.authorize! + end + + def target_uri + @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 7918b5108a8..3fb698d1d52 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -6,7 +6,13 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) - follow = @account.follow!(target_account) - NotifyService.new.call(target_account, follow) + follow_request = FollowRequest.create!(account: @account, target_account: target_account) + + if target_account.locked? + NotifyService.new.call(target_account, follow_request) + else + AuthorizeFollowService.new.call(@account, target_account) + NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account)) + end end end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb new file mode 100644 index 00000000000..78dbfd1e596 --- /dev/null +++ b/app/lib/activitypub/activity/reject.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Reject < ActivityPub::Activity + def perform + case @object['type'] + when 'Follow' + reject_follow + end + end + + private + + def reject_follow + target_account = account_from_uri(target_uri) + + return if target_account.nil? || !target_account.local? + + follow_request = FollowRequest.find_by(account: target_account, target_account: @account) + follow_request&.reject! + end + + def target_uri + @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + end +end From d0a217eb92aec7278685e17b04a1e109081785db Mon Sep 17 00:00:00 2001 From: Sylvhem Date: Sat, 12 Aug 2017 01:33:30 +0200 Subject: [PATCH 018/201] Minor fixes in the French translation (#4580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Ajout de traductions manquantes Ajoute des traductions pour les chaînes n’en ayant pas en version 1.5.1. Add translations for the strings that are missing them in 1.5.1. * Remplace « ' » par « ’ » Retire de la traduction les apostrophes droites « ' » (U+0027) au profit des apostrophes typographiques « ’ » (U+2019). En typographie française, les apostrophes typographiques sont utilisées à la place des apostrophes droites. La traduction était incohérente et utilisait les deux. Remove from the translation all the vertical apostrophes (U+0027) in favor of the curly ones (U+2019). In French typography, typographic apostrophes are used instead of vertical ones. The translation was incoherent and used both. * Ajout d’espaces insécables Ajoute des espaces insécables suivant les régles nécessaires en typographie française. Add non-breaking spaces following rules of French typography. * Remplace « status » par « statut » Remplace le mot anglais « status » par sa traduction française « statut ». Replace the English word "status" by its French translation "statut". * Correction de la politique de confidentialité Apporte diverses corrections à la traduction de la politique de confidentialité. Add various fixes to the privacy policy's translation. * Remplace « mentionné » par « mentionné·e » Harmonise la traduction en remplaçant « mentionné » par sa forme épicène. Harmonize the translation by replacing "mentionné" (sure) by its epicene form. * Remplace « Coup d’œil » par « Jeter un coup d’œil… » Remplace la première traduction par une forme plus proche de la version originelle. Replace the first translation by something closer to the original version. * Remplace « Bon Appétoot ! » par « Bon appouetit ! » Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot. Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun. * Remplace « Bon Appétoot ! » par « Bon appouetit ! » (2) Remplace « Bon Appétoot ! » par « Bon appouetit ! » pour essayer de conserver le jeu de mot. Replace « Bon Appétoot ! » by « Bon appouetit ! » to keep the pun.f * Corrections Corrige des fautes d’orthographe et change « appouetit » pour « appouétit ». Correct some mistakes and change "appouetit" to "appouétit". --- app/javascript/mastodon/locales/fr.json | 14 ++++----- config/locales/fr.yml | 40 ++++++++++++------------- config/locales/simple_form.fr.yml | 5 ++++ 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index f3f0d046355..34a89a69f1c 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -20,11 +20,11 @@ "account.unmute": "Ne plus masquer", "account.view_full_profile": "Afficher le profil complet", "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois", - "bundle_column_error.body": "Une erreur s'est produite lors du chargement de ce composant.", + "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.", "bundle_column_error.retry": "Réessayer", "bundle_column_error.title": "Erreur réseau", "bundle_modal_error.close": "Fermer", - "bundle_modal_error.message": "Une erreur s'est produite lors du chargement de ce composant.", + "bundle_modal_error.message": "Une erreur s’est produite lors du chargement de ce composant.", "bundle_modal_error.retry": "Réessayer", "column.blocks": "Comptes bloqués", "column.community": "Fil public local", @@ -48,7 +48,7 @@ "compose_form.placeholder": "Qu’avez-vous en tête ?", "compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.", "compose_form.publish": "Pouet ", - "compose_form.publish_loud": "{publish}!", + "compose_form.publish_loud": "{publish} !", "compose_form.sensitive": "Marquer le média comme sensible", "compose_form.spoiler": "Masquer le texte derrière un avertissement", "compose_form.spoiler_placeholder": "Écrivez ici votre avertissement", @@ -62,7 +62,7 @@ "confirmations.mute.confirm": "Masquer", "confirmations.mute.message": "Confirmez vous le masquage de {name} ?", "confirmations.unfollow.confirm": "Ne plus suivre", - "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?", + "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?", "emoji_button.activity": "Activités", "emoji_button.flags": "Drapeaux", "emoji_button.food": "Boire et manger", @@ -134,8 +134,8 @@ "onboarding.page_one.welcome": "Bienvenue sur Mastodon !", "onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}", "onboarding.page_six.almost_done": "Nous y sommes presque…", - "onboarding.page_six.appetoot": "Bon Appétoot!", - "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appétoot!", + "onboarding.page_six.appetoot": "Bon appouétit !", + "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon appouétit !", "onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.", "onboarding.page_six.guidelines": "règles de la communauté", "onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !", @@ -159,7 +159,7 @@ "report.target": "Signalement", "search.placeholder": "Rechercher", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", - "standalone.public_title": "Coup d'œil", + "standalone.public_title": "Jeter un coup d’œil…", "status.cannot_reblog": "Cette publication ne peut être boostée", "status.delete": "Effacer", "status.favourite": "Ajouter aux favoris", diff --git a/config/locales/fr.yml b/config/locales/fr.yml index d7aa414971a..38be6dce88b 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -187,7 +187,7 @@ fr: nsfw_off: NSFW OFF nsfw_on: NSFW ON execute: Exécuter - failed_to_execute: Erreur d'exécution + failed_to_execute: Erreur d’exécution media: hide: Masquer les médias show: Montrer les médias @@ -231,11 +231,11 @@ fr: error: Malheureusement, il y a eu une erreur en cherchant les détails du compte distant follow: Suivre follow_request: 'Vous avez demandé à suivre:' - following: 'Youpi! Vous suivez :' + following: 'Youpi ! Vous suivez :' post_follow: close: Ou bien, vous pouvez fermer cette fenêtre. - return: Retour au profil de l'utilisateur⋅trice - web: Retour à l'interface web + return: Retour au profil de l’utilisateur⋅trice + web: Retour à l’interface web title: Suivre %{acct} datetime: distance_in_words: @@ -282,7 +282,7 @@ fr: storage: Médias stockés followers: domain: Domaine - explanation_html: Si vous voulez être sûr⋅e que vos status restent privés, vous devez savoir qui vous suit. Vos status privés seront diffusés à toutes les instances des utilisateur⋅ice⋅s qui vous suivent. Vous voudrez peut-être les passer en revue et les supprimer si vous n’êtes pas sûr⋅e que votre vie privée sera respectée par l’administration ou le logiciel de ces instances. + explanation_html: Si vous voulez être sûr⋅e que vos statuts restent privés, vous devez savoir qui vous suit. Vos statuts privés seront diffusés à toutes les instances des utilisateur⋅ice⋅s qui vous suivent. Vous voudrez peut-être les passer en revue et les supprimer si vous n’êtes pas sûr⋅e que votre vie privée sera respectée par l’administration ou le logiciel de ces instances. followers_count: Nombre d’abonné⋅es lock_link: Rendez votre compte privé purge: Retirer de la liste d’abonné⋅es @@ -290,7 +290,7 @@ fr: one: Suppression des abonné⋅es venant d’un domaine en cours… other: Suppression des abonné⋅es venant de %{count} domaines en cours… true_privacy_html: Soyez conscient⋅es qu’une vraie confidentialité ne peut être atteinte que par un chiffrement de bout-en-bout. - unlocked_warning_html: N’importe qui peut vous suivre et voir vos status privés. %{lock_link} afin de pouvoir vérifier et rejeter des abonné⋅es. + unlocked_warning_html: N’importe qui peut vous suivre et voir vos statuts privés. %{lock_link} afin de pouvoir vérifier et rejeter des abonné⋅es. unlocked_warning_title: Votre compte n’est pas privé generic: changes_saved_msg: Les modifications ont été enregistrées avec succès ! @@ -311,7 +311,7 @@ fr: landing_strip_signup_html: Si ce n’est pas le cas, vous pouvez en créer un ici. media_attachments: validations: - images_and_video: Impossible de joindre une vidéo à un status contenant déjà des images + images_and_video: Impossible de joindre une vidéo à un statut contenant déjà des images too_many: Impossible de joindre plus de 4 fichiers notification_mailer: digest: @@ -334,30 +334,30 @@ fr: subject: 'Abonné⋅es en attente : %{name}' mention: body: "%{name} vous a mentionné⋅e dans :" - subject: "%{name} vous a mentionné" + subject: "%{name} vous a mentionné·e" reblog: - body: "%{name} a partagé votre status :" - subject: "%{name} a partagé votre status" + body: "%{name} a partagé votre statut :" + subject: "%{name} a partagé votre statut" pagination: next: Suivant prev: Précédent push_notifications: favourite: - title: "%{name} à mis votre status en favori" + title: "%{name} à mis votre statut en favori" follow: title: "%{name} vous suit" mention: action_boost: Partager action_expand: Montrer plus action_favourite: Ajouter aux favoris - title: "%{name} vous a mentionné" + title: "%{name} vous a mentionné·e" reblog: - title: "%{name} a partagé⋅e votre status" + title: "%{name} a partagé⋅e votre statut" subscribed: body: Vous pouvez désormais recevoir des notifications push. title: Abonnements aux notifications push remote_follow: - acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅trice + acct: Entrez votre pseudo@instance depuis lequel vous voulez suivre ce⋅tte utilisateur⋅rice missing_resource: L’URL de redirection n’a pas pu être trouvée proceed: Continuez pour suivre prompt: 'Vous allez suivre :' @@ -417,18 +417,18 @@ fr: show_more: Afficher plus visibilities: private: Abonné⋅es uniquement - private_long: Seul⋅es vos abonné⋅es verront vos status + private_long: Seul⋅es vos abonné⋅es verront vos statuts public: Public - public_long: Tout le monde peut voir vos status + public_long: Tout le monde peut voir vos statuts unlisted: Public sans être affiché sur le fil public - unlisted_long: Tout le monde peut voir vos status mais ils ne seront pas sur listés sur les fils publics + unlisted_long: Tout le monde peut voir vos statuts mais ils ne seront pas sur listés sur les fils publics stream_entries: click_to_show: Cliquer pour afficher reblogged: partagé sensitive_content: Contenu sensible terms: - body_html: "

Politique de confidentialité

\n\n

Quelles données collectons-nous?

\n\n

Nous collectons des données lorsque vous vous enregistrez sur notre site et les récoltons lorsque vous participez dans le forum en lisant, écrivant, et évaluant le contenu partagé ici.

\n\n

Lors de l'enregistrement sur notre site, il peut vous être demandé de renseigner votre nom et adresse e-mail. Vous pouvez, cependant, visiter notre site sans inscription. Votre adresse e-mail devra être vérifiée grâce à un e-mail contenant un lien unique. Si ce lien est visité, nous savons que vous contrôlez cette adresse e-mail.

\n\n

Lors de l'inscription et de la publication de statuts, nous enregistrons l'adresse IP de laquelle le(s) status viennent. Nous pouvons également conserver des historiques serveurs qui contiendront l'adresse IP de chaque requête adressée à notre serveur.

\n\n

Que faisons-nous avec vos données?

\n\n

Toute information que nous collectons pourra être utilisée d'une des manières suivantes :

\n\n
    \n
  • Pour personnaliser votre expérience — vos données nous aident à mieux répondre à vos besoins individuels.
  • \n
  • Pour améliorer notre site — nous faisons tout notre possible pour améliorer notre site en fonction des données, retours et suggestions que nous recevons.
  • \n
  • Afin d'améliorer le support client — vos données nous aident à mieux répondre à vos requêtes et demandes de support.
  • \n
  • Afin d'envoyer des e-mails à intervalles réguliers — l'adresse e-mail que vous renseignez peut être utilisée pour vous envoyer des données et notifications concernant des changements ou en réponse à votre nom d'utilisateur⋅trice, en réponse à vos demandes et/ou autres requêtes ou questions
  • \n
\n\n

Comment protégeons-nous vos données?

\n \n

Nous appliquons une multitude de mesures afin de maintenir la sécurité de vos données personnelles lorsque vous entrez, soumettez, ou accédez à ces dernières.

\n\n

Quelle est notre politique de conservation des données?

\n\n

Nous nous efforçons de:

\n\n
    \n
  • Ne pas garder les historiques serveurs contenant l'adresse IP de chaque requête adressée à ce serveur plus de 90 jours.
  • \n
  • Ne pas conserver les adresses IP associées aux utilisateur⋅trices et leur contenu plus de 5 ans.
  • \n
\n\n

Utilisons nous des \"cookies\"?

\n\n

Oui. Les cookies sont de petits fichiers qu'un site ou prestataires de services transfèrent sur le disque dur de votre ordinateur par le biais de votre navigateur Web (si ce dernier le permet). Ces cookies permettent au site de reconnaître votre navigateur et, si vous disposez d'un compte, l'associer à votre compte.

\n\n

Nous utilisons les cookies pour enregistrer vos préférences pour de futures visites, compiler des données agrégées à propos du trafic et des interactions effectuées sur le site afin de proposer une meilleure expérience dans le futur. Nous pouvons contracter les services d'acteurs tiers afin de nous aider à mieux comprendre les visiteurs de notre site. Ces acteurs ont l'autorisation d'utiliser ces données seulement à des fins d'améliorations.

\n\n

Divulguons-nous des données à des acteurs tiers ?

\n\n

Nous n'échangeons pas, ne vendons pas ni effectuons de quelconques transferts avec des acteurs tiers d'informations permettant de vous identifier personnellement. Cela n'inclut pas les acteurs de confiance qui nous aident à gérer notre entreprise et à vous servir tant que ces acteurs s'accordent à garder lesdites informations confidentielles. Nous pouvons être amenés à délivrer vos informations lorsque jugé adéquat afin de respecter la loi, d'appliquer la politique de notre site, ou afin de protéger nos droits, ceux des autres, notre propriété ou sécurité. Cependant, aucune information permettant l'identification de nos visiteurs ne sera divulguée à des fins publicitaires, commerciales ou tout autre usage.

\n\n

Liens vers des acteurs tiers

\n\n

Nous pouvons être amenés à inclure ou offrir les services ou produits d'acteurs tiers sur notre site. Ces acteurs tiers possèdent leur propre politique de confidentialité. Nous ne sommes donc pas responsables du contenu ou activités desdits acteurs. Néanmoins, nous cherchons à protéger l'intégrité de notre site et sommes ouverts à toute remarque concernant ces acteurs.

\n\n

Children's Online Privacy Protection Act

\n\n

Notre site, nos produits et services sont tous dirigés à l'usage de personnes étant âgés de 13 ans ou plus. Si ce serveur est hébergé aux États-Unis et que vous êtes âgé⋅e de moins de 13 ans, au vu du COPPA (Children's Online Privacy Protection Act) n'utilisez pas ce site.

\n\n

Votre consentement

\n\n

En utilisant notre site, vous consentez à la politique de confiedentialité de notre site Web.

\n\n

Changements de notre politique de confidentialité

\n\n

Si nous décidons d'apporter des changements à notre politique de confidentialité, nous les mettrons à disposition sur cette page.

\n\n

Ce document est distribué sous licence CC-BY-SA. Il a été mis à jour pour la dernière fois le 31 Mai 2013. Il a été traduit en français en Juillet 2017.

\n\n

Originellement adapté à partir de la politique de confidentialité de Discourse

.\n" - title: "%{instance} Conditions d'utilisations et Politique de confidentialité" + body_html: "

Politique de confidentialité

\n\n

Quelles données collectons-nous ?

\n\n

Nous collectons des données lorsque vous vous enregistrez sur notre site et les récoltons lorsque vous participez dans le forum en lisant, écrivant, et évaluant le contenu partagé ici.

\n\n

Lors de l’enregistrement sur notre site, il peut vous être demandé de renseigner votre nom et adresse électronique. Vous pouvez, cependant, visiter notre site sans inscription. Votre adresse électronique devra être vérifiée grâce à un courriel contenant un lien unique. Si ce lien est visité, nous savons que vous contrôlez cette adresse.

\n\n

Lors de l’inscription et de la publication de statuts, nous enregistrons l’adresse IP de laquelle les statuts proviennent. Nous pouvons également conserver des historiques serveurs qui contiendront l’adresse IP de chaque requête adressée à notre serveur.

\n\n

Que faisons-nous avec vos données ?

\n\n

Toute information que nous collectons pourra être utilisée d’une des manières suivantes :

\n\n
    \n
  • Pour personnaliser votre expérience — vos données nous aident à mieux répondre à vos besoins individuels.
  • \n
  • Pour améliorer notre site — nous faisons tout notre possible pour améliorer notre site en fonction des données, retours et suggestions que nous recevons.
  • \n
  • Afin d’améliorer le support client — vos données nous aident à mieux répondre à vos requêtes et demandes de support.
  • \n
  • Afin d’envoyer des courriels à intervalles réguliers — l’adresse électronique que vous renseignez peut être utilisée pour vous envoyer des données et notifications concernant des changements ou en réponse à votre nom d’utilisateur⋅trice, en réponse à vos demandes et/ou autres requêtes ou questions
  • \n
\n\n

Comment protégeons-nous vos données ?

\n \n

Nous appliquons une multitude de mesures afin de maintenir la sécurité de vos données personnelles lorsque vous entrez, soumettez, ou accédez à ces dernières.

\n\n

Quelle est notre politique de conservation des données ?

\n\n

Nous nous efforçons de :

\n\n
    \n
  • ne pas garder les historiques serveurs contenant l’adresse IP de chaque requête adressée à ce serveur plus de 90 jours ;
  • \n
  • ne pas conserver les adresses IP associées aux utilisateur⋅trices et leur contenu plus de 5 ans.
  • \n
\n\n

Utilisons nous des « cookies » ?

\n\n

Oui. Les cookies sont de petits fichiers qu’un site ou prestataires de services transfèrent sur le disque dur de votre ordinateur par le biais de votre navigateur Web (si ce dernier le permet). Ces cookies permettent au site de reconnaître votre navigateur et, si vous disposez d’un compte, de l’associer à celui-ci.

\n\n

Nous utilisons les cookies pour enregistrer vos préférences pour de futures visites, compiler des données agrégées à propos du trafic et des interactions effectuées sur le site afin de proposer une meilleure expérience dans le futur. Nous pouvons contracter les services d’acteurs tiers afin de nous aider à mieux comprendre les visiteurs de notre site. Ces acteurs ont l’autorisation d’utiliser ces données seulement à des fins d’améliorations.

\n\n

Divulguons-nous des données à des acteurs tiers ?

\n\n

Nous n’échangeons pas, ne vendons pas ni effectuons de quelconques transferts avec des acteurs tiers d’informations permettant de vous identifier personnellement. Cela n’inclut pas les acteurs de confiance qui nous aident à gérer notre entreprise et à vous servir tant que ces acteurs s’accordent à garder lesdites informations confidentielles. Nous pouvons être amenés à délivrer vos informations lorsque jugé adéquat afin de respecter la loi, d’appliquer la politique de notre site, ou afin de protéger nos droits, ceux des autres, notre propriété ou sécurité. Cependant, aucune information permettant l’identification de nos visiteurs ne sera divulguée à des fins publicitaires, commerciales ou tout autre usage.

\n\n

Liens vers des acteurs tiers

\n\n

Nous pouvons être amenés à inclure ou offrir les services ou produits d’acteurs tiers sur notre site. Ces acteurs tiers possèdent leur propre politique de confidentialité. Nous ne sommes donc pas responsables du contenu ou activités desdits acteurs. Néanmoins, nous cherchons à protéger l’intégrité de notre site et sommes ouverts à toute remarque concernant ces acteurs.

\n\n

Children's Online Privacy Protection Act

\n\n

Notre site, nos produits et services sont tous destinés à l’usage de personnes âgées de 13 ans ou plus. Si ce serveur est hébergé aux États-Unis et que vous êtes âgé⋅e de moins de 13 ans, au vu du COPPA (Children's Online Privacy Protection Act) n’utilisez pas ce site.

\n\n

Votre consentement

\n\n

En utilisant notre site, vous consentez à la présente politique de confidentialité.

\n\n

Changements de notre politique de confidentialité

\n\n

Si nous décidons d’apporter des changements à notre politique de confidentialité, nous les publierons sur cette page.

\n\n

Ce document est distribué sous licence CC-BY-SA. Il a été mis à jour pour la dernière fois le 31 mai 2013. Il a été traduit en français en juillet 2017.

\n\n

Originellement adapté à partir de la politique de confidentialité de Discourse.

\n" + title: "%{instance} Conditions d’utilisations et politique de confidentialité" time: formats: default: "%d %b %Y, %H:%M" @@ -451,4 +451,4 @@ fr: users: invalid_email: L’adresse courriel est invalide invalid_otp_token: Le code d’authentification à deux facteurs est invalide - signed_in_as: 'Connecté·e en tant que :' + signed_in_as: 'Connecté·e en tant que :' diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml index 8717a4abd58..adfb1a87509 100644 --- a/config/locales/simple_form.fr.yml +++ b/config/locales/simple_form.fr.yml @@ -12,6 +12,7 @@ fr: note: one: 1 caractère restant other: %{count} caractères restants + setting_noindex: Affecte votre profil public ainsi que vos statuts imports: data: Un fichier CSV généré par une autre instance de Mastodon sessions: @@ -27,6 +28,7 @@ fr: data: Données display_name: Nom public email: Adresse courriel + filtered_languages: Langues filtrées header: Image d’en-tête locale: Langue locked: Verrouiller le compte @@ -37,8 +39,11 @@ fr: setting_auto_play_gif: Lire automatiquement les GIFs animés setting_boost_modal: Afficher un dialogue de confirmation avant de partager setting_default_privacy: Confidentialité des statuts + setting_default_sensitive: Toujours marquer les médias comme sensibles setting_delete_modal: Afficher un dialogue de confirmation avant de supprimer un pouet + setting_noindex: Demander aux moteurs de recherche de ne pas indexer vos informations personnelles setting_system_font_ui: Utiliser la police par défaut du système + setting_unfollow_modal: Afficher un dialogue de confirmation avant de vous désabonner d’un compte severity: Séverité type: Type d’import username: Identifiant From 3d47154c206fcea8fc46e024ee11a40063a05023 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 12 Aug 2017 02:54:54 +0200 Subject: [PATCH 019/201] Only PuSH-resubscribe to OStatus accounts (#4583) --- app/workers/scheduler/subscriptions_scheduler.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb index 7bfd002f474..5ddfaed1887 100644 --- a/app/workers/scheduler/subscriptions_scheduler.rb +++ b/app/workers/scheduler/subscriptions_scheduler.rb @@ -8,13 +8,12 @@ class Scheduler::SubscriptionsScheduler def perform logger.info 'Queueing PuSH re-subscriptions' - Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id)) end private def expiring_accounts - Account.expiring(1.day.from_now).partitioned + Account.where(protocol: :ostatus).expiring(1.day.from_now).partitioned end end From 40be4ea239d567845ddd0af204141fa2b7e22b15 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 12 Aug 2017 16:30:59 +0200 Subject: [PATCH 020/201] Extend Devise remember_me longevity to 1 year instead of 2 weeks (#4587) Force SSL only cookies for remember_me, adjust confirmation expiration time to fit with the user cleanup scheduler --- config/initializers/devise.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index e6b0e90cbc5..64c4e12ff4c 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -154,7 +154,7 @@ Devise.setup do |config| # their account can't be confirmed with the token any more. # Default is nil, meaning there is no restriction on how long a user can take # before confirming their account. - # config.confirm_within = 3.days + config.confirm_within = 2.days # If true, requires any email changes to be confirmed (exactly the same way as # initial account confirmation) to be applied. Requires additional unconfirmed_email @@ -167,7 +167,7 @@ Devise.setup do |config| # ==> Configuration for :rememberable # The time the user will be remembered without asking for credentials again. - # config.remember_for = 2.weeks + config.remember_for = 1.year # Invalidates all the remember me tokens when the user signs out. config.expire_all_remember_me_on_sign_out = true @@ -177,7 +177,7 @@ Devise.setup do |config| # Options to be passed to the created cookie. For instance, you can set # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} + config.rememberable_options = { secure: true } # ==> Configuration for :validatable # Range for password length. From ccdd5a9576819cdc95946d98fea0e3c8bbd1d626 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 12 Aug 2017 17:41:03 +0200 Subject: [PATCH 021/201] Add serializing/unserializing of "locked" actor attribute (#4585) --- app/lib/activitypub/adapter.rb | 4 ++++ app/lib/activitypub/case_transform.rb | 24 +++++++++++++++++++ .../activitypub/actor_serializer.rb | 2 ++ .../activitypub/process_account_service.rb | 1 + 4 files changed, 31 insertions(+) create mode 100644 app/lib/activitypub/case_transform.rb diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index e038136c088..df132f01987 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -5,6 +5,10 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base :camel_lower end + def self.transform_key_casing!(value, _options) + ActivityPub::CaseTransform.camel_lower(value) + end + def serializable_hash(options = nil) options = serialization_options(options) serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb new file mode 100644 index 00000000000..7f716f86243 --- /dev/null +++ b/app/lib/activitypub/case_transform.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActivityPub::CaseTransform + class << self + def camel_lower_cache + @camel_lower_cache ||= {} + end + + def camel_lower(value) + case value + when Array then value.map { |item| camel_lower(item) } + when Hash then value.deep_transform_keys! { |key| camel_lower(key) } + when Symbol then camel_lower(value.to_s).to_sym + when String + camel_lower_cache[value] ||= if value.start_with?('_:') + '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower) + else + value.underscore.camelize(:lower) + end + else value + end + end + end +end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index 8a119603d06..b157368680a 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -9,6 +9,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer + attribute :locked, key: '_:locked' + class ImageSerializer < ActiveModel::Serializer include RoutingHelper diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 92e2dbb30fa..9fb7ebf9ef5 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -46,6 +46,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.avatar_remote_url = image_url('icon') @account.header_remote_url = image_url('image') @account.public_key = public_key || '' + @account.locked = @json['_:locked'] || false @account.save! end From b7370ac8baa643d93ea727699b3b11f9d3a55bea Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 13 Aug 2017 00:44:41 +0200 Subject: [PATCH 022/201] ActivityPub delivery (#4566) * Deliver ActivityPub Like * Deliver ActivityPub Undo-Like * Deliver ActivityPub Create/Announce activities * Deliver ActivityPub creates from mentions * Deliver ActivityPub Block/Undo-Block * Deliver ActivityPub Accept/Reject-Follow * Deliver ActivityPub Undo-Follow * Deliver ActivityPub Follow * Deliver ActivityPub Delete activities Incidentally fix #889 * Adjust BatchedRemoveStatusService for ActivityPub * Add tests for ActivityPub workers * Add tests for FollowService * Add tests for FavouriteService, UnfollowService and PostStatusService * Add tests for ReblogService, BlockService, UnblockService, ProcessMentionsService * Add tests for AuthorizeFollowService, RejectFollowService, RemoveStatusService * Add tests for BatchedRemoveStatusService * Deliver updates to a local account to ActivityPub followers * Minor adjustments --- .../api/v1/accounts/credentials_controller.rb | 3 +- .../settings/profiles_controller.rb | 1 + app/lib/activitypub/activity.rb | 2 +- app/models/account.rb | 4 ++ app/services/authorize_follow_service.rb | 19 +++++- app/services/batched_remove_status_service.rb | 43 ++++++++++-- app/services/block_service.rb | 19 +++++- app/services/favourite_service.rb | 28 ++++++-- app/services/follow_service.rb | 14 +++- app/services/post_status_service.rb | 1 + app/services/process_mentions_service.rb | 28 ++++++-- app/services/reblog_service.rb | 30 +++++++-- app/services/reject_follow_service.rb | 19 +++++- app/services/remove_status_service.rb | 49 ++++++++++++-- app/services/unblock_service.rb | 19 +++++- app/services/unfavourite_service.rb | 22 ++++++- app/services/unfollow_service.rb | 19 +++++- app/workers/activitypub/delivery_worker.rb | 37 +++++++++++ .../activitypub/distribution_worker.rb | 38 +++++++++++ app/workers/activitypub/processing_worker.rb | 2 +- .../activitypub/update_distribution_worker.rb | 31 +++++++++ .../accounts/credentials_controller_spec.rb | 6 ++ .../settings/profiles_controller_spec.rb | 2 + .../services/authorize_follow_service_spec.rb | 24 ++++++- .../batched_remove_status_service_spec.rb | 7 ++ spec/services/block_service_spec.rb | 19 +++++- spec/services/favourite_service_spec.rb | 22 ++++++- spec/services/follow_service_spec.rb | 25 +++++-- spec/services/post_status_service_spec.rb | 8 ++- .../services/process_mentions_service_spec.rb | 46 +++++++++---- spec/services/reblog_service_spec.rb | 49 ++++++++++---- spec/services/reject_follow_service_spec.rb | 24 ++++++- spec/services/remove_status_service_spec.rb | 8 +++ .../resolve_remote_account_service_spec.rb | 66 ++++++++++--------- spec/services/unblock_service_spec.rb | 22 ++++++- spec/services/unfollow_service_spec.rb | 22 ++++++- .../activitypub/delivery_worker_spec.rb | 23 +++++++ .../activitypub/distribution_worker_spec.rb | 48 ++++++++++++++ .../activitypub/processing_worker_spec.rb | 15 +++++ .../activitypub/thread_resolve_worker_spec.rb | 16 +++++ .../update_distribution_worker_spec.rb | 20 ++++++ 41 files changed, 786 insertions(+), 114 deletions(-) create mode 100644 app/workers/activitypub/delivery_worker.rb create mode 100644 app/workers/activitypub/distribution_worker.rb create mode 100644 app/workers/activitypub/update_distribution_worker.rb create mode 100644 spec/workers/activitypub/delivery_worker_spec.rb create mode 100644 spec/workers/activitypub/distribution_worker_spec.rb create mode 100644 spec/workers/activitypub/processing_worker_spec.rb create mode 100644 spec/workers/activitypub/thread_resolve_worker_spec.rb create mode 100644 spec/workers/activitypub/update_distribution_worker_spec.rb diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 073808532a5..90a580c3362 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -10,8 +10,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def update - current_account.update!(account_params) @account = current_account + @account.update!(account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0367e359364..c751c64ae97 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -15,6 +15,7 @@ class Settings::ProfilesController < ApplicationController def update if @account.update(account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 5debe023a33..f8de8060c4e 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -93,7 +93,7 @@ class ActivityPub::Activity end def distribute_to_followers(status) - DistributionWorker.perform_async(status.id) + ::DistributionWorker.perform_async(status.id) end def delete_arrived_first?(uri) diff --git a/app/models/account.rb b/app/models/account.rb index 163bd1c0eae..a7264353e75 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -171,6 +171,10 @@ class Account < ApplicationRecord reorder(nil).pluck('distinct accounts.domain') end + def inboxes + reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + end + def triadic_closures(account, limit: 5, offset: 0) sql = <<-SQL.squish WITH first_degree AS ( diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 41815a393e9..db35b6030bd 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -4,11 +4,28 @@ class AuthorizeFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.authorize! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::AcceptFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)) end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index ab810c628ed..e6c8c9208c8 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -15,9 +15,11 @@ class BatchedRemoveStatusService < BaseService @mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h @tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h - @stream_entry_batches = [] - @salmon_batches = [] - @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @stream_entry_batches = [] + @salmon_batches = [] + @activity_json_batches = [] + @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h + @activity_json = {} # Ensure that rendered XML reflects destroyed state Status.where(id: statuses.map(&:id)).in_batches.destroy_all @@ -27,7 +29,11 @@ class BatchedRemoveStatusService < BaseService account = account_statuses.first.account unpush_from_home_timelines(account_statuses) - batch_stream_entries(account_statuses) if account.local? + + if account.local? + batch_stream_entries(account_statuses) + batch_activity_json(account, account_statuses) + end end # Cannot be batched @@ -38,6 +44,7 @@ class BatchedRemoveStatusService < BaseService Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch } NotificationWorker.push_bulk(@salmon_batches) { |batch| batch } + ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch } end private @@ -50,6 +57,22 @@ class BatchedRemoveStatusService < BaseService end end + def batch_activity_json(account, statuses) + account.followers.inboxes.each do |inbox_url| + statuses.each do |status| + @activity_json_batches << [build_json(status), account.id, inbox_url] + end + end + + statuses.each do |status| + other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id) + + other_recipients.each do |target_account| + @activity_json_batches << [build_json(status), account.id, target_account.inbox_url] + end + end + end + def unpush_from_home_timelines(statuses) account = statuses.first.account recipients = account.followers.local.pluck(:id) @@ -79,7 +102,7 @@ class BatchedRemoveStatusService < BaseService return if @mentions[status.id].empty? payload = stream_entry_to_xml(status.stream_entry.reload) - recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id) + recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id) recipients.each do |recipient_id| @salmon_batches << [payload, status.account_id, recipient_id] @@ -111,4 +134,14 @@ class BatchedRemoveStatusService < BaseService def redis Redis.current end + + def build_json(status) + return @activity_json[status.id] if @activity_json.key?(status.id) + + @activity_json[status.id] = ActiveModelSerializers::SerializableResource.new( + status, + serializer: ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index 5d7bf6a3bc2..f2253226b55 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -12,11 +12,28 @@ class BlockService < BaseService block = account.block!(target_account) BlockWorker.perform_async(account.id, target_account.id) - NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local? + create_notification(block) unless target_account.local? + block end private + def create_notification(block) + if block.target_account.ostatus? + NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id) + elsif block.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url) + end + end + + def build_json(block) + ActiveModelSerializers::SerializableResource.new( + block, + serializer: ActivityPub::BlockSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block)) end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 291f9e56ef1..4aa935170ec 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -15,18 +15,32 @@ class FavouriteService < BaseService return favourite unless favourite.nil? favourite = Favourite.create!(account: account, status: status) - - if status.local? - NotifyService.new.call(favourite.status.account, favourite) - else - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) - end - + create_notification(favourite) favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.local? + NotifyService.new.call(status.account, favourite) + elsif status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::LikeSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite)) end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 3155feaa498..2be625cd858 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -14,7 +14,7 @@ class FollowService < BaseService return if source_account.following?(target_account) - if target_account.locked? + if target_account.locked? || target_account.activitypub? request_follow(source_account, target_account) else direct_follow(source_account, target_account) @@ -28,9 +28,11 @@ class FollowService < BaseService if target_account.local? NotifyService.new.call(target_account, follow_request) - else + elsif target_account.ostatus? NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id) AfterRemoteFollowRequestWorker.perform_async(follow_request.id) + elsif target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url) end follow_request @@ -63,4 +65,12 @@ class FollowService < BaseService def build_follow_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_salmon(follow)) end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::FollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 951a38e1957..5ff93f21ea8 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -39,6 +39,7 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(status.id) if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 438033d22b3..407fa8c182b 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -28,18 +28,32 @@ class ProcessMentionsService < BaseService end status.mentions.includes(:account).each do |mention| - mentioned_account = mention.account - - if mentioned_account.local? - NotifyService.new.call(mentioned_account, mention) - else - NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) - end + create_notification(status, mention) end end private + def create_notification(status, mention) + mentioned_account = mention.account + + if mentioned_account.local? + NotifyService.new.call(mentioned_account, mention) + elsif mentioned_account.ostatus? + NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) + elsif mentioned_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url) + end + end + + def build_json(status) + ActiveModelSerializers::SerializableResource.new( + status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def follow_remote_account_service @follow_remote_account_service ||= ResolveRemoteAccountService.new end diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index ba24b1f9d80..7f886af7c20 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -21,13 +21,31 @@ class ReblogService < BaseService DistributionWorker.perform_async(reblog.id) Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + ActivityPub::DistributionWorker.perform_async(reblog.id) - if reblogged_status.local? - NotifyService.new.call(reblog.reblog.account, reblog) - else - NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), account.id, reblog.reblog.account_id) - end - + create_notification(reblog) reblog end + + private + + def create_notification(reblog) + reblogged_status = reblog.reblog + + if reblogged_status.account.local? + NotifyService.new.call(reblogged_status.account, reblog) + elsif reblogged_status.account.ostatus? + NotificationWorker.perform_async(stream_entry_to_xml(reblog.stream_entry), reblog.account_id, reblogged_status.account_id) + elsif reblogged_status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url) + end + end + + def build_json(reblog) + ActiveModelSerializers::SerializableResource.new( + reblog, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json + end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index fd7e66c2373..a91266aa4b6 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -4,11 +4,28 @@ class RejectFollowService < BaseService def call(source_account, target_account) follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) follow_request.reject! - NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local? + create_notification(follow_request) unless source_account.local? + follow_request end private + def create_notification(follow_request) + if follow_request.account.ostatus? + NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id) + elsif follow_request.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url) + end + end + + def build_json(follow_request) + ActiveModelSerializers::SerializableResource.new( + follow_request, + serializer: ActivityPub::RejectFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow_request) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)) end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index a5281f5869d..fcccbaa24f3 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -22,8 +22,10 @@ class RemoveStatusService < BaseService return unless @account.local? - remove_from_mentioned(@stream_entry.reload) - Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) + @stream_entry = @stream_entry.reload + + remove_from_remote_followers + remove_from_remote_affected end private @@ -38,13 +40,46 @@ class RemoveStatusService < BaseService end end - def remove_from_mentioned(stream_entry) - salmon_xml = stream_entry_to_xml(stream_entry) - target_accounts = @mentions.map(&:account).reject(&:local?).uniq(&:domain) + def remove_from_remote_affected + # People who got mentioned in the status, or who + # reblogged it from someone else might not follow + # the author and wouldn't normally receive the + # delete notification - so here, we explicitly + # send it to them - NotificationWorker.push_bulk(target_accounts) do |target_account| - [salmon_xml, stream_entry.account_id, target_account.id] + target_accounts = (@mentions.map(&:account).reject(&:local?) + @reblogs.map(&:account).reject(&:local?)).uniq(&:id) + + # Ostatus + NotificationWorker.push_bulk(target_accounts.select(&:ostatus?).uniq(&:domain)) do |target_account| + [salmon_xml, @account.id, target_account.id] end + + # ActivityPub + ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url| + [activity_json, @account.id, inbox_url] + end + end + + def remove_from_remote_followers + # OStatus + Pubsubhubbub::DistributionWorker.perform_async(@stream_entry.id) + + # ActivityPub + ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| + [activity_json, @account.id, inbox_url] + end + end + + def salmon_xml + @salmon_xml ||= stream_entry_to_xml(@stream_entry) + end + + def activity_json + @activity_json ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::DeleteSerializer, + adapter: ActivityPub::Adapter + ).to_json end def remove_reblogs diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index ff15c727548..72fc5ab150d 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -5,11 +5,28 @@ class UnblockService < BaseService return unless account.blocking?(target_account) unblock = account.unblock!(target_account) - NotificationWorker.perform_async(build_xml(unblock), account.id, target_account.id) unless target_account.local? + create_notification(unblock) unless target_account.local? + unblock end private + def create_notification(unblock) + if unblock.target_account.ostatus? + NotificationWorker.perform_async(build_xml(unblock), unblock.account_id, unblock.target_account_id) + elsif unblock.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(unblock), unblock.account_id, unblock.target_account.inbox_url) + end + end + + def build_json(unblock) + ActiveModelSerializers::SerializableResource.new( + unblock, + serializer: ActivityPub::UndoBlockSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(block) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unblock_salmon(block)) end diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index 564aaee46a7..e53798e666f 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -4,14 +4,30 @@ class UnfavouriteService < BaseService def call(account, status) favourite = Favourite.find_by!(account: account, status: status) favourite.destroy! - - NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id) unless status.local? - + create_notification(favourite) unless status.local? favourite end private + def create_notification(favourite) + status = favourite.status + + if status.account.ostatus? + NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id) + elsif status.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) + end + end + + def build_json(favourite) + ActiveModelSerializers::SerializableResource.new( + favourite, + serializer: ActivityPub::UndoLikeSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(favourite) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfavourite_salmon(favourite)) end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 388909586b2..10af751464b 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -7,12 +7,29 @@ class UnfollowService < BaseService def call(source_account, target_account) follow = source_account.unfollow!(target_account) return unless follow - NotificationWorker.perform_async(build_xml(follow), source_account.id, target_account.id) unless target_account.local? + create_notification(follow) unless target_account.local? UnmergeWorker.perform_async(target_account.id, source_account.id) + follow end private + def create_notification(follow) + if follow.target_account.ostatus? + NotificationWorker.perform_async(build_xml(follow), follow.account_id, follow.target_account_id) + elsif follow.target_account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) + end + end + + def build_json(follow) + ActiveModelSerializers::SerializableResource.new( + follow, + serializer: ActivityPub::UndoFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow)) end diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb new file mode 100644 index 00000000000..cd67b67108e --- /dev/null +++ b/app/workers/activitypub/delivery_worker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class ActivityPub::DeliveryWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', retry: 5, dead: false + + HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze + + def perform(json, source_account_id, inbox_url) + @json = json + @source_account = Account.find(source_account_id) + @inbox_url = inbox_url + + perform_request + + raise Mastodon::UnexpectedResponseError, @response unless response_successful? + rescue => e + raise e.class, "Delivery failed for #{inbox_url}: #{e.message}" + end + + private + + def build_request + request = Request.new(:post, @inbox_url, body: @json) + request.on_behalf_of(@source_account, :uri) + request.add_headers(HEADERS) + end + + def perform_request + @response = build_request.perform + end + + def response_successful? + @response.code > 199 && @response.code < 300 + end +end diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb new file mode 100644 index 00000000000..004dd25d1cf --- /dev/null +++ b/app/workers/activitypub/distribution_worker.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(status_id) + @status = Status.find(status_id) + @account = @status.account + + return if skip_distribution? + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def skip_distribution? + @status.direct_visibility? + end + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @status, + serializer: ActivityPub::ActivitySerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb index 7656ab56a38..bb9adf64bde 100644 --- a/app/workers/activitypub/processing_worker.rb +++ b/app/workers/activitypub/processing_worker.rb @@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker sidekiq_options backtrace: true def perform(account_id, body) - ProcessCollectionService.new.call(body, Account.find(account_id)) + ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id)) end end diff --git a/app/workers/activitypub/update_distribution_worker.rb b/app/workers/activitypub/update_distribution_worker.rb new file mode 100644 index 00000000000..f3377dcec5a --- /dev/null +++ b/app/workers/activitypub/update_distribution_worker.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ActivityPub::UpdateDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(account_id) + @account = Account.find(account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end + + def payload + @payload ||= ActiveModelSerializers::SerializableResource.new( + @account, + serializer: ActivityPub::UpdateSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index 4a31003487e..bc89772b969 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -20,6 +20,8 @@ describe Api::V1::Accounts::CredentialsController do describe 'PATCH #update' do describe 'with valid data' do before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) + patch :update, params: { display_name: "Alice Isn't Dead", note: "Hi!\n\nToot toot!", @@ -40,6 +42,10 @@ describe Api::V1::Accounts::CredentialsController do expect(user.account.avatar).to exist expect(user.account.header).to exist end + + it 'queues up an account update distribution' do + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + end end describe 'with invalid data' do diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index e502dbda74e..ee3315be626 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -17,11 +17,13 @@ RSpec.describe Settings::ProfilesController, type: :controller do describe 'PUT #update' do it 'updates the user profile' do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) account = Fabricate(:account, user: @user, display_name: 'Old name') put :update, params: { account: { display_name: 'New name' } } expect(account.reload.display_name).to eq 'New name' expect(response).to redirect_to(settings_profile_path) + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) end end end diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb index 3f3a2bc5675..d74eb41a2c5 100644 --- a/spec/services/authorize_follow_service_spec.rb +++ b/spec/services/authorize_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe AuthorizeFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe AuthorizeFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'creates follow relation' do + expect(bob.following?(sender)).to be true + end + + it 'sends an accept activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index c20085e25e3..2484d4b58d7 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -6,6 +6,7 @@ RSpec.describe BatchedRemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } let(:status1) { PostStatusService.new.call(alice, 'Hello @bob@example.com') } let(:status2) { PostStatusService.new.call(alice, 'Another status') } @@ -15,9 +16,11 @@ RSpec.describe BatchedRemoveStatusService do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) status1 status2 @@ -58,4 +61,8 @@ RSpec.describe BatchedRemoveStatusService do xml.match(TagManager::VERBS[:delete]) }).to have_been_made.once end + + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.at_least_once + end end diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb index 2a54e032ea8..bd2ab3d53e3 100644 --- a/spec/services/block_service_spec.rb +++ b/spec/services/block_service_spec.rb @@ -17,7 +17,7 @@ RSpec.describe BlockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -36,4 +36,21 @@ RSpec.describe BlockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'creates a blocking relation' do + expect(sender.blocking?(bob)).to be true + end + + it 'sends a block activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb index 36f1b64d4a8..2ab1f32ca23 100644 --- a/spec/services/favourite_service_spec.rb +++ b/spec/services/favourite_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe FavouriteService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com:blahblah') } before do @@ -38,4 +38,22 @@ RSpec.describe FavouriteService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + let(:status) { Fabricate(:status, account: bob) } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, status) + end + + it 'creates a favourite' do + expect(status.favourites.first).to_not be_nil + end + + it 'sends a like activity' do + expect(a_request(:post, "http://example.com/inbox")).to have_been_made.once + end + end end diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index 32dedb3ad72..1e23780316e 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -44,9 +44,9 @@ RSpec.describe FollowService do end end - context 'remote account' do + context 'remote OStatus account' do describe 'locked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -66,7 +66,7 @@ RSpec.describe FollowService do end describe 'unlocked account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {}) @@ -91,7 +91,7 @@ RSpec.describe FollowService do end describe 'already followed account' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account } before do sender.follow!(bob) @@ -111,4 +111,21 @@ RSpec.describe FollowService do end end end + + context 'remote ActivityPub account' do + let(:bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {}) + subject.call(sender, bob.acct) + end + + it 'creates follow request' do + expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + end + + it 'sends a follow activity to the inbox' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 57876dcc2bf..4182c4e1fab 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -100,16 +100,18 @@ RSpec.describe PostStatusService do expect(hashtags_service).to have_received(:call).with(status) end - it 'pings PuSH hubs' do + it 'gets distributed' do allow(DistributionWorker).to receive(:perform_async) allow(Pubsubhubbub::DistributionWorker).to receive(:perform_async) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + account = Fabricate(:account) status = subject.call(account, "test status update") expect(DistributionWorker).to have_received(:perform_async).with(status.id) - expect(Pubsubhubbub::DistributionWorker). - to have_received(:perform_async).with(status.stream_entry.id) + expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id) + expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id) end it 'crawls links' do diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb index 984d1374651..09f8fa45b8d 100644 --- a/spec/services/process_mentions_service_spec.rb +++ b/spec/services/process_mentions_service_spec.rb @@ -1,22 +1,44 @@ require 'rails_helper' RSpec.describe ProcessMentionsService do - let(:account) { Fabricate(:account, username: 'alice') } - let(:remote_user) { Fabricate(:account, username: 'remote_user', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } + let(:account) { Fabricate(:account, username: 'alice') } + let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") } - subject { ProcessMentionsService.new } + context 'OStatus' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') } - before do - stub_request(:post, remote_user.salmon_url) - subject.(status) + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.salmon_url) + subject.call(status) + end + + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'posts to remote user\'s Salmon end point' do + expect(a_request(:post, remote_user.salmon_url)).to have_been_made.once + end end - it 'creates a mention' do - expect(remote_user.mentions.where(status: status).count).to eq 1 - end + context 'ActivityPub' do + let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } - it 'posts to remote user\'s Salmon end point' do - expect(a_request(:post, remote_user.salmon_url)).to have_been_made + subject { ProcessMentionsService.new } + + before do + stub_request(:post, remote_user.inbox_url) + subject.call(status) + end + + it 'creates a mention' do + expect(remote_user.mentions.where(status: status).count).to eq 1 + end + + it 'sends activity to the inbox' do + expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb index 5f89169e9be..0ad5c5f6ba5 100644 --- a/spec/services/reblog_service_spec.rb +++ b/spec/services/reblog_service_spec.rb @@ -2,22 +2,49 @@ require 'rails_helper' RSpec.describe ReblogService do let(:alice) { Fabricate(:account, username: 'alice') } - let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } - let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - subject { ReblogService.new } + context 'OStatus' do + let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') } + let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') } - before do - stub_request(:post, 'http://salmon.example.com') + subject { ReblogService.new } - subject.(alice, status) + before do + stub_request(:post, 'http://salmon.example.com') + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end + + it 'sends a Salmon slap for a remote reblog' do + expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + end end - it 'creates a reblog' do - expect(status.reblogs.count).to eq 1 - end + context 'ActivityPub' do + let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let(:status) { Fabricate(:status, account: bob) } - it 'sends a Salmon slap for a remote reblog' do - expect(a_request(:post, 'http://salmon.example.com')).to have_been_made + subject { ReblogService.new } + + before do + stub_request(:post, bob.inbox_url) + allow(ActivityPub::DistributionWorker).to receive(:perform_async) + subject.call(alice, status) + end + + it 'creates a reblog' do + expect(status.reblogs.count).to eq 1 + end + + it 'distributes to followers' do + expect(ActivityPub::DistributionWorker).to have_received(:perform_async) + end + + it 'sends an announce activity to the author' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end end end diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb index 50749b63361..2e06345b367 100644 --- a/spec/services/reject_follow_service_spec.rb +++ b/spec/services/reject_follow_service_spec.rb @@ -22,7 +22,7 @@ RSpec.describe RejectFollowService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -46,4 +46,26 @@ RSpec.describe RejectFollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account } + + before do + FollowRequest.create(account: bob, target_account: sender) + stub_request(:post, bob.inbox_url).to_return(status: 200) + subject.call(bob, sender) + end + + it 'removes follow request' do + expect(bob.requested?(sender)).to be false + end + + it 'does not create follow relation' do + expect(bob.following?(sender)).to be false + end + + it 'sends a reject activity' do + expect(a_request(:post, bob.inbox_url)).to have_been_made.once + end + end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index a3bce761339..dc6b350cb72 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -6,13 +6,17 @@ RSpec.describe RemoveStatusService do let!(:alice) { Fabricate(:account) } let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } + let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } before do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) + hank.follow!(alice) + @status = PostStatusService.new.call(alice, 'Hello @bob@example.com') subject.call(@status) end @@ -31,6 +35,10 @@ RSpec.describe RemoveStatusService do }).to have_been_made end + it 'sends delete activity to followers' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + it 'sends Salmon slap to previously mentioned users' do expect(a_request(:post, "http://example.com/salmon").with { |req| xml = OStatus2::Salmon.new.unpack(req.body) diff --git a/spec/services/resolve_remote_account_service_spec.rb b/spec/services/resolve_remote_account_service_spec.rb index c3b902b3442..d0eab2310bd 100644 --- a/spec/services/resolve_remote_account_service_spec.rb +++ b/spec/services/resolve_remote_account_service_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe ResolveRemoteAccountService do - subject { ResolveRemoteAccountService.new } + subject { described_class.new } before do stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt')) @@ -29,29 +29,6 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('catsrgr8@example.com')).to be_nil end - it 'returns an already existing remote account' do - old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') - returned_account = subject.call('gargron@quitter.no') - - expect(old_account.id).to eq returned_account.id - end - - it 'returns a new remote account' do - account = subject.call('gargron@quitter.no') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - - it 'follows a legitimate account redirection' do - account = subject.call('gargron@redirected.com') - - expect(account.username).to eq 'gargron' - expect(account.domain).to eq 'quitter.no' - expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' - end - it 'prevents hijacking existing accounts' do account = subject.call('hacker1@redirected.com') expect(account.salmon_url).to_not eq 'https://hacker.com/main/salmon/user/7477' @@ -61,12 +38,41 @@ RSpec.describe ResolveRemoteAccountService do expect(subject.call('hacker2@redirected.com')).to be_nil end - it 'returns a new remote account' do - account = subject.call('foo@localdomain.com') + context 'with an OStatus account' do + it 'returns an already existing remote account' do + old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no') + returned_account = subject.call('gargron@quitter.no') - expect(account.username).to eq 'foo' - expect(account.domain).to eq 'localdomain.com' - expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + expect(old_account.id).to eq returned_account.id + end + + it 'returns a new remote account' do + account = subject.call('gargron@quitter.no') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'follows a legitimate account redirection' do + account = subject.call('gargron@redirected.com') + + expect(account.username).to eq 'gargron' + expect(account.domain).to eq 'quitter.no' + expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom' + end + + it 'returns a new remote account' do + account = subject.call('foo@localdomain.com') + + expect(account.username).to eq 'foo' + expect(account.domain).to eq 'localdomain.com' + expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom' + end + end + + context 'with an ActivityPub account' do + pending end it 'processes one remote account at a time using locks' do @@ -78,7 +84,7 @@ RSpec.describe ResolveRemoteAccountService do Thread.new do true while wait_for_start begin - return_values << ResolveRemoteAccountService.new.call('foo@localdomain.com') + return_values << described_class.new.call('foo@localdomain.com') rescue ActiveRecord::RecordNotUnique fail_occurred = true end diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb index 1b9ae1239af..def4981e728 100644 --- a/spec/services/unblock_service_spec.rb +++ b/spec/services/unblock_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe UnblockService do end end - describe 'remote' do + describe 'remote OStatus' do let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do @@ -28,7 +28,7 @@ RSpec.describe UnblockService do end it 'destroys the blocking relation' do - expect(sender.following?(bob)).to be false + expect(sender.blocking?(bob)).to be false end it 'sends an unblock salmon slap' do @@ -38,4 +38,22 @@ RSpec.describe UnblockService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.block!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the blocking relation' do + expect(sender.blocking?(bob)).to be false + end + + it 'sends an unblock activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 8ec2148a1a2..29040431e52 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -18,8 +18,8 @@ RSpec.describe UnfollowService do end end - describe 'remote' do - let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } + describe 'remote OStatus' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account } before do sender.follow!(bob) @@ -38,4 +38,22 @@ RSpec.describe UnfollowService do }).to have_been_made.once end end + + describe 'remote ActivityPub' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account } + + before do + sender.follow!(bob) + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + subject.call(sender, bob) + end + + it 'destroys the following relation' do + expect(sender.following?(bob)).to be false + end + + it 'sends an unfollow activity' do + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once + end + end end diff --git a/spec/workers/activitypub/delivery_worker_spec.rb b/spec/workers/activitypub/delivery_worker_spec.rb new file mode 100644 index 00000000000..351be185cd6 --- /dev/null +++ b/spec/workers/activitypub/delivery_worker_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ActivityPub::DeliveryWorker do + subject { described_class.new } + + let(:sender) { Fabricate(:account) } + let(:payload) { 'test' } + + describe 'perform' do + it 'performs a request' do + stub_request(:post, 'https://example.com/api').to_return(status: 200) + subject.perform(payload, sender.id, 'https://example.com/api') + expect(a_request(:post, 'https://example.com/api')).to have_been_made.once + end + + it 'raises when request fails' do + stub_request(:post, 'https://example.com/api').to_return(status: 500) + expect { subject.perform(payload, sender.id, 'https://example.com/api') }.to raise_error Mastodon::UnexpectedResponseError + end + end +end diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb new file mode 100644 index 00000000000..368ca025a0d --- /dev/null +++ b/spec/workers/activitypub/distribution_worker_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +describe ActivityPub::DistributionWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(status.account) + end + + context 'with public status' do + before do + status.update(visibility: :public) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with private status' do + before do + status.update(visibility: :private) + end + + it 'delivers to followers' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end + + context 'with direct status' do + before do + status.update(visibility: :direct) + end + + it 'does nothing' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk) + end + end + end +end diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb new file mode 100644 index 00000000000..b42c0bdbc9f --- /dev/null +++ b/spec/workers/activitypub/processing_worker_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +describe ActivityPub::ProcessingWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + + describe '#perform' do + it 'delegates to ActivityPub::ProcessCollectionService' do + allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil)) + subject.perform(account.id, '') + expect(ActivityPub::ProcessCollectionService).to have_received(:new) + end + end +end diff --git a/spec/workers/activitypub/thread_resolve_worker_spec.rb b/spec/workers/activitypub/thread_resolve_worker_spec.rb new file mode 100644 index 00000000000..b954cb62caa --- /dev/null +++ b/spec/workers/activitypub/thread_resolve_worker_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +describe ActivityPub::ThreadResolveWorker do + subject { described_class.new } + + let(:status) { Fabricate(:status) } + let(:parent) { Fabricate(:status) } + + describe '#perform' do + it 'gets parent from ActivityPub::FetchRemoteStatusService and glues them together' do + allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(double(:service, call: parent)) + subject.perform(status.id, 'http://example.com/123') + expect(status.reload.in_reply_to_id).to eq parent.id + end + end +end diff --git a/spec/workers/activitypub/update_distribution_worker_spec.rb b/spec/workers/activitypub/update_distribution_worker_spec.rb new file mode 100644 index 00000000000..688a424d55e --- /dev/null +++ b/spec/workers/activitypub/update_distribution_worker_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +describe ActivityPub::UpdateDistributionWorker do + subject { described_class.new } + + let(:account) { Fabricate(:account) } + let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') } + + describe '#perform' do + before do + allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + follower.follow!(account) + end + + it 'delivers to followers' do + subject.perform(account.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com']) + end + end +end From a2aeacbfeed5dc7070c37a22bb2c4bac1a58a526 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 13 Aug 2017 00:45:04 +0200 Subject: [PATCH 023/201] Add alternate links to ActivityPub resources from HTML/HEAD variants (#4586) --- app/controllers/concerns/account_controller_concern.rb | 8 ++++++++ app/controllers/statuses_controller.rb | 7 ++++++- app/controllers/stream_entries_controller.rb | 7 ++++++- app/views/accounts/show.html.haml | 1 + app/views/stream_entries/show.html.haml | 1 + .../concerns/account_controller_concern_spec.rb | 2 +- spec/controllers/stream_entries_controller_spec.rb | 2 +- 7 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d36fc8c9362..5b9981aa26e 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -23,6 +23,7 @@ module AccountControllerConcern [ webfinger_account_link, atom_account_url_link, + actor_url_link, ] ) end @@ -41,6 +42,13 @@ module AccountControllerConcern ] end + def actor_url_link + [ + ActivityPub::TagManager.instance.uri_for(@account), + [%w(rel alternate), %w(type application/activity+json)], + ] + end + def webfinger_account_url webfinger_url(resource: @account.to_webfinger_s) end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 8e0ce0ec3b0..0cce2ba23c7 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -36,7 +36,12 @@ class StatusesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_status diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 3eb91d8305a..ccb15495e42 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -38,7 +38,12 @@ class StreamEntriesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_stream_entry diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 150c1479116..74e695fc3c7 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -7,6 +7,7 @@ %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ + %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ %meta{ property: 'og:type', content: 'profile' }/ = render 'og', account: @account, url: short_account_url(@account, only_path: false) diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 80ea30eb169..5ef72f80413 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -4,6 +4,7 @@ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/ + %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@stream_entry.activity) }/ %meta{ property: 'og:site_name', content: site_title }/ %meta{ property: 'og:type', content: 'article' }/ diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb index bdc181edcf5..ae46f9ba660 100644 --- a/spec/controllers/concerns/account_controller_concern_spec.rb +++ b/spec/controllers/concerns/account_controller_concern_spec.rb @@ -33,7 +33,7 @@ describe ApplicationController, type: :controller do it 'sets link headers' do account = Fabricate(:account, username: 'username') get 'success', params: { account_username: 'username' } - expect(response.headers['Link'].to_s).to eq '; rel="lrdd"; type="application/xrd+xml", ; rel="alternate"; type="application/atom+xml"' + expect(response.headers['Link'].to_s).to eq '; rel="lrdd"; type="application/xrd+xml", ; rel="alternate"; type="application/atom+xml", ; rel="alternate"; type="application/activity+json"' end it 'returns http success' do diff --git a/spec/controllers/stream_entries_controller_spec.rb b/spec/controllers/stream_entries_controller_spec.rb index 2cc428e0c1b..808cf667c83 100644 --- a/spec/controllers/stream_entries_controller_spec.rb +++ b/spec/controllers/stream_entries_controller_spec.rb @@ -21,7 +21,7 @@ RSpec.describe StreamEntriesController, type: :controller do get route, params: { account_username: alice.username, id: status.stream_entry.id } - expect(response.headers['Link'].to_s).to eq "; rel=\"alternate\"; type=\"application/atom+xml\"" + expect(response.headers['Link'].to_s).to eq "; rel=\"alternate\"; type=\"application/atom+xml\", ; rel=\"alternate\"; type=\"application/activity+json\"" end end From 4e75f0d88932511ad154773f4c77a485367ed36c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 02:29:36 +0200 Subject: [PATCH 024/201] Hook up URL-based resource look-up to ActivityPub (#4589) --- app/helpers/jsonld_helper.rb | 8 +- .../fetch_remote_account_service.rb | 4 +- .../fetch_remote_status_service.rb | 4 +- app/services/fetch_atom_service.rb | 78 ++++++++++++------- app/services/fetch_remote_account_service.rb | 15 ++-- app/services/fetch_remote_status_service.rb | 15 ++-- .../api/subscriptions_controller_spec.rb | 26 +++---- spec/services/process_feed_service_spec.rb | 5 +- 8 files changed, 95 insertions(+), 60 deletions(-) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index b0db025bc52..c750a703814 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -16,7 +16,11 @@ module JsonLdHelper def fetch_resource(uri) response = build_request(uri).perform return if response.code != 200 - Oj.load(response.to_s, mode: :strict) + body_to_json(response.to_s) + end + + def body_to_json(body) + body.nil? ? nil : Oj.load(body, mode: :strict) rescue Oj::ParseError nil end @@ -25,7 +29,7 @@ module JsonLdHelper def build_request(uri) request = Request.new(:get, uri) - request.add_headers('Accept' => 'application/activity+json') + request.add_headers('Accept' => 'application/activity+json, application/ld+json') request end end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index e443b94638a..3eeca585e83 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -5,8 +5,8 @@ class ActivityPub::FetchRemoteAccountService < BaseService # Should be called when uri has already been checked for locality # Does a WebFinger roundtrip on each call - def call(uri) - @json = fetch_resource(uri) + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) return unless supported_context? && expected_type? diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index 80305c53dce..993e5389c42 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -4,8 +4,8 @@ class ActivityPub::FetchRemoteStatusService < BaseService include JsonLdHelper # Should be called when uri has already been checked for locality - def call(uri) - @json = fetch_resource(uri) + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) return unless supported_context? && expected_type? diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 3ac441e3e81..c6a4dc2e98c 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -4,18 +4,10 @@ class FetchAtomService < BaseService def call(url) return if url.blank? - response = Request.new(:head, url).perform + @url = url - Rails.logger.debug "Remote status HEAD request returned code #{response.code}" - - response = Request.new(:get, url).perform if response.code == 405 - - Rails.logger.debug "Remote status GET request returned code #{response.code}" - - return nil if response.code != 200 - return [url, fetch(url)] if response.mime_type == 'application/atom+xml' - return process_headers(url, response) if response['Link'].present? - process_html(fetch(url)) + perform_request + process_response rescue OpenSSL::SSL::SSLError => e Rails.logger.debug "SSL error: #{e}" nil @@ -26,27 +18,57 @@ class FetchAtomService < BaseService private - def process_html(body) - Rails.logger.debug 'Processing HTML' - - page = Nokogiri::HTML(body) - alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } - - return nil if alternate_link.nil? - [alternate_link['href'], fetch(alternate_link['href'])] + def perform_request + @response = Request.new(:get, @url) + .add_headers('Accept' => 'application/activity+json, application/ld+json, application/atom+xml, text/html') + .perform end - def process_headers(url, response) - Rails.logger.debug 'Processing link header' + def process_response(terminal = false) + return nil if @response.code != 200 - link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link']) - alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) - - return process_html(fetch(url)) if alternate_link.nil? - [alternate_link.href, fetch(alternate_link.href)] + if @response.mime_type == 'application/atom+xml' + [@url, @response.to_s, :ostatus] + elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) + [@url, @response.to_s, :activitypub] + elsif @response['Link'] && !terminal + process_headers + elsif @response.mime_type == 'text/html' && !terminal + process_html + end end - def fetch(url) - Request.new(:get, url).perform.to_s + def process_html + page = Nokogiri::HTML(@response.to_s) + + json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } + atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } + + if !json_link.nil? + @url = json_link['href'] + perform_request + process_response(true) + elsif !atom_link.nil? + @url = atom_link['href'] + perform_request + process_response(true) + end + end + + def process_headers + link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link']) + + json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) + atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) + + if !json_link.nil? + @url = json_link.href + perform_request + process_response(true) + elsif !atom_link.nil? + @url = atom_link.href + perform_request + process_response(true) + end end end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index 8eed0d45429..41b5374b4fc 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -5,14 +5,19 @@ class FetchRemoteAccountService < BaseService def call(url, prefetched_body = nil) if prefetched_body.nil? - atom_url, body = FetchAtomService.new.call(url) + resource_url, body, protocol = FetchAtomService.new.call(url) else - atom_url = url - body = prefetched_body + resource_url = url + body = prefetched_body + protocol = :ostatus end - return nil if atom_url.nil? - process_atom(atom_url, body) + case protocol + when :ostatus + process_atom(resource_url, body) + when :activitypub + ActivityPub::FetchRemoteAccountService.new.call(resource_url, body) + end end private diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index b9f5f97b1a2..30d8d25386b 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -5,14 +5,19 @@ class FetchRemoteStatusService < BaseService def call(url, prefetched_body = nil) if prefetched_body.nil? - atom_url, body = FetchAtomService.new.call(url) + resource_url, body, protocol = FetchAtomService.new.call(url) else - atom_url = url - body = prefetched_body + resource_url = url + body = prefetched_body + protocol = :ostatus end - return nil if atom_url.nil? - process_atom(atom_url, body) + case protocol + when :ostatus + process_atom(resource_url, body) + when :activitypub + ActivityPub::FetchRemoteStatusService.new.call(resource_url, body) + end end private diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb index 76f9740ca5c..d90da9e3249 100644 --- a/spec/controllers/api/subscriptions_controller_spec.rb +++ b/spec/controllers/api/subscriptions_controller_spec.rb @@ -38,19 +38,19 @@ RSpec.describe Api::SubscriptionsController, type: :controller do before do stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {}) stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt')) - stub_request(:head, "https://quitter.no/notice/1269244").to_return(status: 404) - stub_request(:head, "https://quitter.no/notice/1265331").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/54411").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/53857").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/51852").to_return(status: 404) - stub_request(:head, "https://social.umeahackerspace.se/notice/424348").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/notice/50467").to_return(status: 404) - stub_request(:head, "https://quitter.no/notice/1243309").to_return(status: 404) - stub_request(:head, "https://quitter.no/user/7477").to_return(status: 404) - stub_request(:head, "https://community.highlandarrow.com/user/1").to_return(status: 404) - stub_request(:head, "https://social.umeahackerspace.se/user/2").to_return(status: 404) - stub_request(:head, "https://gs.kawa-kun.com/user/2").to_return(status: 404) - stub_request(:head, "https://mastodon.social/users/Gargron").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1269244").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1265331").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/54411").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/53857").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/51852").to_return(status: 404) + stub_request(:get, "https://social.umeahackerspace.se/notice/424348").to_return(status: 404) + stub_request(:get, "https://community.highlandarrow.com/notice/50467").to_return(status: 404) + stub_request(:get, "https://quitter.no/notice/1243309").to_return(status: 404) + stub_request(:get, "https://quitter.no/user/7477").to_return(status: 404) + stub_request(:any, "https://community.highlandarrow.com/user/1").to_return(status: 404) + stub_request(:any, "https://social.umeahackerspace.se/user/2").to_return(status: 404) + stub_request(:any, "https://gs.kawa-kun.com/user/2").to_return(status: 404) + stub_request(:any, "https://mastodon.social/users/Gargron").to_return(status: 404) request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{OpenSSL::HMAC.hexdigest('sha1', 'abc', feed)}" request.env['RAW_POST_DATA'] = feed diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb index 5e34370ee06..aca675dc67e 100644 --- a/spec/services/process_feed_service_spec.rb +++ b/spec/services/process_feed_service_spec.rb @@ -124,8 +124,7 @@ RSpec.describe ProcessFeedService do XML - stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, headers: { 'Content-Type' => 'application/atom+xml' }) - stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body) + stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body, headers: { 'Content-Type' => 'application/atom+xml' }) bad_actor = Fabricate(:account, username: 'sombra', domain: 'talon.xyz') @@ -168,7 +167,7 @@ XML end it 'ignores reblogs if it failed to retreive reblogged statuses' do - stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404) + stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404) actor = Fabricate(:account, username: 'tracer', domain: 'overwatch.com') From 6df8bd277b52b3ac025597ec08ee9e342a8eb32c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 04:16:43 +0200 Subject: [PATCH 025/201] Set correct content-type for ActivityPub JSON (#4592) --- app/controllers/accounts_controller.rb | 2 +- app/controllers/activitypub/outboxes_controller.rb | 2 +- app/controllers/follower_accounts_controller.rb | 2 +- app/controllers/following_accounts_controller.rb | 2 +- app/controllers/statuses_controller.rb | 4 ++-- app/controllers/tags_controller.rb | 2 +- config/initializers/mime_types.rb | 2 +- spec/controllers/accounts_controller_spec.rb | 4 ++++ spec/controllers/activitypub/outboxes_controller_spec.rb | 4 ++++ 9 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c270eb000c4..4dc0a783dda 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -17,7 +17,7 @@ class AccountsController < ApplicationController end format.json do - render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter + render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 30b91f37021..9f97ff6226d 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) - render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5edb4d67c8a..0e194989790 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 7cafe5fda50..d4593093ff6 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 0cce2ba23c7..aa24f23c944 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -20,13 +20,13 @@ class StatusesController < ApplicationController end format.json do - render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end def activity - render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2cd85e185e5..3001b2ee314 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -12,7 +12,7 @@ class TagsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 30e91ad63f5..58a6c0063ec 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,4 +1,4 @@ # Be sure to restart your server when you modify this file. -Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json) +Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json application/ld+json) Mime::Type.register 'text/xml', :xml, %w(application/xml application/atom+xml application/xrd+xml) diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index d61c8c9bd35..2c0df0ef3b5 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -48,6 +48,10 @@ RSpec.describe AccountsController, type: :controller do it 'returns http success with Activity Streams 2.0' do expect(response).to have_http_status(:success) end + + it 'returns application/activity+json' do + expect(response.content_type).to eq 'application/activity+json' + end end context 'html' do diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb index f98e4a8c38c..a2599802169 100644 --- a/spec/controllers/activitypub/outboxes_controller_spec.rb +++ b/spec/controllers/activitypub/outboxes_controller_spec.rb @@ -15,5 +15,9 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do it 'returns http success' do expect(response).to have_http_status(:success) end + + it 'returns application/activity+json' do + expect(response.content_type).to eq 'application/activity+json' + end end end From 96e9ed13ded6def1d96260178ee9d9e7ad3e5d23 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Mon, 14 Aug 2017 11:50:56 +0900 Subject: [PATCH 026/201] Fix search (regression from #4589) (#4594) --- app/services/fetch_remote_resource_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 2c1c1f05f55..6e5830b0dcc 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -31,7 +31,7 @@ class FetchRemoteResourceService < BaseService end def body - fetched_atom_feed.last + fetched_atom_feed.second end def xml_root From 3c6503038ecad20f1b8fa0c9ea7e46087c6e3f22 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 04:53:31 +0200 Subject: [PATCH 027/201] Add protocol handler. Handle follow intents (#4511) * Add protocol handler. Handle follow intents * Add share intent * Improve code in intents controller * Adjust share form CSS --- app/controllers/intents_controller.rb | 18 +++++++++ app/controllers/shares_controller.rb | 25 ++++++++++++ .../mastodon/containers/compose_container.js | 39 +++++++++++++++++++ .../mastodon/containers/mastodon.js | 5 +++ .../features/standalone/compose/index.js | 18 +++++++++ app/javascript/mastodon/reducers/compose.js | 12 +++++- app/javascript/packs/share.js | 24 ++++++++++++ app/javascript/styles/containers.scss | 15 +++++++ app/presenters/initial_state_presenter.rb | 3 +- app/serializers/initial_state_serializer.rb | 2 + app/views/shares/show.html.haml | 5 +++ config/routes.rb | 4 +- 12 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 app/controllers/intents_controller.rb create mode 100644 app/controllers/shares_controller.rb create mode 100644 app/javascript/mastodon/containers/compose_container.js create mode 100644 app/javascript/mastodon/features/standalone/compose/index.js create mode 100644 app/javascript/packs/share.js create mode 100644 app/views/shares/show.html.haml diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb new file mode 100644 index 00000000000..504befd1f13 --- /dev/null +++ b/app/controllers/intents_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class IntentsController < ApplicationController + def show + uri = Addressable::URI.parse(params[:uri]) + + if uri.scheme == 'web+mastodon' + case uri.host + when 'follow' + return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, '')) + when 'share' + return redirect_to share_path(text: uri.query_values['text']) + end + end + + not_found + end +end diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb new file mode 100644 index 00000000000..d70d66ff8f4 --- /dev/null +++ b/app/controllers/shares_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SharesController < ApplicationController + layout 'public' + + before_action :authenticate_user! + + def show + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end + + private + + def initial_state_params + { + settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), + current_account: current_account, + token: current_session.token, + admin: Account.find_local(Setting.site_contact_username), + text: params[:text], + } + end +end diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js new file mode 100644 index 00000000000..db452d03a45 --- /dev/null +++ b/app/javascript/mastodon/containers/compose_container.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from '../store/configureStore'; +import { hydrateStore } from '../actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import Compose from '../features/standalone/compose'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); +const initialStateContainer = document.getElementById('initial-state'); + +if (initialStateContainer !== null) { + const initialState = JSON.parse(initialStateContainer.textContent); + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 87ab6023c49..fe534d1c1c3 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -89,6 +89,11 @@ export default class Mastodon extends React.PureComponent { Notification.requestPermission(); } + if (typeof navigator.registerProtocolHandler !== 'undefined') { + const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; + navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'); + } + store.dispatch(showOnboardingOnce()); } diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js new file mode 100644 index 00000000000..96d07fefb8c --- /dev/null +++ b/app/javascript/mastodon/features/standalone/compose/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ComposeFormContainer from '../../compose/containers/compose_form_container'; +import NotificationsContainer from '../../ui/containers/notifications_container'; +import LoadingBarContainer from '../../ui/containers/loading_bar_container'; + +export default class Compose extends React.PureComponent { + + render () { + return ( +
+ + + +
+ ); + } + +} diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index e137b774ee1..34f5dab7fcf 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -141,10 +141,20 @@ const privacyPreference = (a, b) => { } }; +const hydrate = (state, hydratedState) => { + state = clearAll(state.merge(hydratedState)); + + if (hydratedState.has('text')) { + state = state.set('text', hydratedState.get('text')); + } + + return state; +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - return clearAll(state.merge(action.state.get('compose'))); + return hydrate(state, action.state.get('compose')); case COMPOSE_MOUNT: return state.set('mounted', true); case COMPOSE_UNMOUNT: diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js new file mode 100644 index 00000000000..51e4ae38b8a --- /dev/null +++ b/app/javascript/packs/share.js @@ -0,0 +1,24 @@ +import loadPolyfills from '../mastodon/load_polyfills'; + +require.context('../images/', true); + +function loaded() { + const ComposeContainer = require('../mastodon/containers/compose_container').default; + const React = require('react'); + const ReactDOM = require('react-dom'); + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode !== null) { + const props = JSON.parse(mountNode.getAttribute('data-props')); + ReactDOM.render(, mountNode); + } +} + +function main() { + const ready = require('../mastodon/ready').default; + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss index 536f4e5a156..063db44dbfb 100644 --- a/app/javascript/styles/containers.scss +++ b/app/javascript/styles/containers.scss @@ -44,6 +44,21 @@ } } +.compose-standalone { + .compose-form { + width: 400px; + margin: 0 auto; + padding: 20px 0; + margin-top: 40px; + box-sizing: border-box; + + @media screen and (max-width: 400px) { + margin-top: 0; + padding: 20px; + } + } +} + .account-header { width: 400px; margin: 0 auto; diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 9507aad4ab1..70c496be86b 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true class InitialStatePresenter < ActiveModelSerializers::Model - attributes :settings, :push_subscription, :token, :current_account, :admin + attributes :settings, :push_subscription, :token, + :current_account, :admin, :text end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 0191948b1c1..0ac5e83197a 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -34,6 +34,8 @@ class InitialStateSerializer < ActiveModel::Serializer store[:default_sensitive] = object.current_account.user.setting_default_sensitive end + store[:text] = object.text if object.text + store end diff --git a/app/views/shares/show.html.haml b/app/views/shares/show.html.haml new file mode 100644 index 00000000000..44b6f145f68 --- /dev/null +++ b/app/views/shares/show.html.haml @@ -0,0 +1,5 @@ +- content_for :header_tags do + %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) + = javascript_pack_tag 'share', integrity: true, crossorigin: 'anonymous' + +#mastodon-compose{ data: { props: Oj.dump(default_props) } } diff --git a/config/routes.rb b/config/routes.rb index a1b20671643..f75de530412 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,6 +20,7 @@ Rails.application.routes.draw do get '.well-known/host-meta', to: 'well_known/host_meta#show', as: :host_meta, defaults: { format: 'xml' } get '.well-known/webfinger', to: 'well_known/webfinger#show', as: :webfinger get 'manifest', to: 'manifests#show', defaults: { format: 'json' } + get 'intent', to: 'intents#show' devise_for :users, path: 'auth', controllers: { sessions: 'auth/sessions', @@ -86,12 +87,13 @@ Rails.application.routes.draw do # Remote follow resource :authorize_follow, only: [:show, :create] + resource :share, only: [:show, :create] namespace :admin do resources :subscriptions, only: [:index] resources :domain_blocks, only: [:index, :new, :create, :show, :destroy] resource :settings, only: [:edit, :update] - + resources :instances, only: [:index] do collection do post :resubscribe From 26d26644ac217d770ad2b3f6df6966501842f18b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 11:27:25 +0200 Subject: [PATCH 028/201] Require "inbox" to be set on actor to be ActivityPub-ready (#4595) --- app/services/resolve_remote_account_service.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 220ef043cba..7031c98f5ee 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -79,7 +79,8 @@ class ResolveRemoteAccountService < BaseService def activitypub_ready? !@webfinger.link('self').nil? && - ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) && + actor_json['inbox'].present? end def handle_ostatus @@ -93,11 +94,9 @@ class ResolveRemoteAccountService < BaseService end def handle_activitypub - json = fetch_resource(actor_url) + return if actor_json.nil? - return unless supported_context?(json) && json['type'] == 'Person' - - @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, json) + @account = ActivityPub::ProcessAccountService.new.call(@username, @domain, actor_json) rescue Oj::ParseError nil end @@ -186,6 +185,13 @@ class ResolveRemoteAccountService < BaseService @atom_body = response.to_s end + def actor_json + return @actor_json if defined?(@actor_json) + + json = fetch_resource(actor_url) + @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil + end + def atom return @atom if defined?(@atom) @atom = Nokogiri::XML(atom_body) From 5f22c0189d52383f0226622997d8282e9f387f3b Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Mon, 14 Aug 2017 21:08:34 +0900 Subject: [PATCH 029/201] Add support for searching AP users (#4599) * Add support for searching AP users * use JsonLdHelper --- app/services/fetch_remote_account_service.rb | 3 +- app/services/fetch_remote_resource_service.rb | 31 ++++++++++++++++--- app/services/fetch_remote_status_service.rb | 3 +- .../fetch_remote_resource_service_spec.rb | 4 +-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index 41b5374b4fc..7c618a0b0c9 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -3,13 +3,12 @@ class FetchRemoteAccountService < BaseService include AuthorExtractor - def call(url, prefetched_body = nil) + def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? resource_url, body, protocol = FetchAtomService.new.call(url) else resource_url = url body = prefetched_body - protocol = :ostatus end case protocol diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 6e5830b0dcc..341664272aa 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class FetchRemoteResourceService < BaseService + include JsonLdHelper + attr_reader :url def call(url) @@ -14,11 +16,11 @@ class FetchRemoteResourceService < BaseService private def process_url - case xml_root - when 'feed' - FetchRemoteAccountService.new.call(atom_url, body) - when 'entry' - FetchRemoteStatusService.new.call(atom_url, body) + case type + when 'Person' + FetchRemoteAccountService.new.call(atom_url, body, protocol) + when 'Note' + FetchRemoteStatusService.new.call(atom_url, body, protocol) end end @@ -34,6 +36,25 @@ class FetchRemoteResourceService < BaseService fetched_atom_feed.second end + def protocol + fetched_atom_feed.third + end + + def type + return json_data['type'] if protocol == :activitypub + + case xml_root + when 'feed' + 'Person' + when 'entry' + 'Note' + end + end + + def json_data + @_json_data ||= body_to_json(body) + end + def xml_root xml_data.root.name end diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 30d8d25386b..18af18059b6 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -3,13 +3,12 @@ class FetchRemoteStatusService < BaseService include AuthorExtractor - def call(url, prefetched_body = nil) + def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? resource_url, body, protocol = FetchAtomService.new.call(url) else resource_url = url body = prefetched_body - protocol = :ostatus end case protocol diff --git a/spec/services/fetch_remote_resource_service_spec.rb b/spec/services/fetch_remote_resource_service_spec.rb index 81b0e48e32e..c14fcfc4e6c 100644 --- a/spec/services/fetch_remote_resource_service_spec.rb +++ b/spec/services/fetch_remote_resource_service_spec.rb @@ -30,7 +30,7 @@ describe FetchRemoteResourceService do _result = subject.call(url) - expect(account_service).to have_received(:call).with(feed_url, feed_content) + expect(account_service).to have_received(:call).with(feed_url, feed_content, nil) end it 'fetches remote statuses for entry types' do @@ -47,7 +47,7 @@ describe FetchRemoteResourceService do _result = subject.call(url) - expect(account_service).to have_received(:call).with(feed_url, feed_content) + expect(account_service).to have_received(:call).with(feed_url, feed_content, nil) end end end From 5b9ae7981e2458a322f9e2fbeac9b334a15936bc Mon Sep 17 00:00:00 2001 From: unarist Date: Mon, 14 Aug 2017 21:09:00 +0900 Subject: [PATCH 030/201] Update /admin/accounts/:id view for ActivityPub (#4600) * Add protocol field * Switch protocol specific information according to active protocol * Hide PuSH subscription related buttons if ActivityPub is active --- app/views/admin/accounts/show.html.haml | 43 ++++++++++++++++--------- config/locales/en.yml | 3 ++ 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 5ad1fd6ee3f..5c781e81720 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -32,18 +32,30 @@ %th= t('admin.accounts.profile_url') %td= link_to @account.url, @account.url %tr - %th= t('admin.accounts.feed_url') - %td= link_to @account.remote_url, @account.remote_url - %tr - %th= t('admin.accounts.push_subscription_expires') - %td - - if @account.subscribed? - = l @account.subscription_expires_at - - else - = t('admin.accounts.not_subscribed') - %tr - %th= t('admin.accounts.salmon_url') - %td= link_to @account.salmon_url, @account.salmon_url + %th= t('admin.accounts.protocol') + %td= @account.protocol + + - if @account.ostatus? + %tr + %th= t('admin.accounts.feed_url') + %td= link_to @account.remote_url, @account.remote_url + %tr + %th= t('admin.accounts.push_subscription_expires') + %td + - if @account.subscribed? + = l @account.subscription_expires_at + - else + = t('admin.accounts.not_subscribed') + %tr + %th= t('admin.accounts.salmon_url') + %td= link_to @account.salmon_url, @account.salmon_url + - elsif @account.activitypub? + %tr + %th= t('admin.accounts.inbox_url') + %td= link_to @account.inbox_url, @account.inbox_url + %tr + %th= t('admin.accounts.outbox_url') + %td= link_to @account.outbox_url, @account.outbox_url %tr %th= t('admin.accounts.follows') @@ -74,9 +86,10 @@ - if @account.user&.otp_required_for_login? = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' - else - = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' - - if @account.subscribed? - = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' + - if @account.ostatus? + = link_to @account.subscribed? ? t('admin.accounts.resubscribe') : t('admin.accounts.subscribe'), subscribe_admin_account_path(@account.id), method: :post, class: 'button' + - if @account.subscribed? + = link_to t('admin.accounts.unsubscribe'), unsubscribe_admin_account_path(@account.id), method: :post, class: 'button negative' = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' %div{ style: 'float: left' } diff --git a/config/locales/en.yml b/config/locales/en.yml index 1fa0de90b2c..210bfc5b436 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -57,6 +57,7 @@ en: feed_url: Feed URL followers: Followers follows: Follows + inbox_url: Inbox URL ip: IP location: all: All @@ -76,8 +77,10 @@ en: alphabetic: Alphabetic most_recent: Most recent title: Order + outbox_url: Outbox URL perform_full_suspension: Perform full suspension profile_url: Profile URL + protocol: Protocol public: Public push_subscription_expires: PuSH subscription expires redownload: Refresh avatar From a855956185630742ad670f971337a3ff76fd8b32 Mon Sep 17 00:00:00 2001 From: unarist Date: Mon, 14 Aug 2017 23:57:46 +0900 Subject: [PATCH 031/201] Fix ActivityPub follow interaction and add more specs (#4601) --- app/lib/activitypub/activity/accept.rb | 2 +- app/lib/activitypub/activity/reject.rb | 2 +- spec/lib/activitypub/activity/accept_spec.rb | 38 ++++++++++++++++++++ spec/lib/activitypub/activity/follow_spec.rb | 29 ++++++++++++--- spec/lib/activitypub/activity/reject_spec.rb | 38 ++++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 spec/lib/activitypub/activity/accept_spec.rb create mode 100644 spec/lib/activitypub/activity/reject_spec.rb diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index f5880937a0d..44c432ae743 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= @object['actor'] end end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 78dbfd1e596..6a234994ef2 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= @object['actor'] end end diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb new file mode 100644 index 00000000000..6503c83e37a --- /dev/null +++ b/spec/lib/activitypub/activity/accept_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Accept do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Accept', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'creates a follow relationship' do + expect(recipient.following?(sender)).to be true + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb index 7c0e447f35a..6bbacdbe644 100644 --- a/spec/lib/activitypub/activity/follow_spec.rb +++ b/spec/lib/activitypub/activity/follow_spec.rb @@ -17,12 +17,33 @@ RSpec.describe ActivityPub::Activity::Follow do describe '#perform' do subject { described_class.new(json, sender) } - before do - subject.perform + context 'unlocked account' do + before do + subject.perform + end + + it 'creates a follow from sender to recipient' do + expect(sender.following?(recipient)).to be true + end + + it 'does not create a follow request' do + expect(sender.requested?(recipient)).to be false + end end - it 'creates a follow from sender to recipient' do - expect(sender.following?(recipient)).to be true + context 'locked account' do + before do + recipient.update(locked: true) + subject.perform + end + + it 'does not create a follow from sender to recipient' do + expect(sender.following?(recipient)).to be false + end + + it 'creates a follow request' do + expect(sender.requested?(recipient)).to be true + end end end end diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb new file mode 100644 index 00000000000..7fd95bcc648 --- /dev/null +++ b/spec/lib/activitypub/activity/reject_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Reject do + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Reject', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: { + id: 'bar', + type: 'Follow', + actor: ActivityPub::TagManager.instance.uri_for(recipient), + object: ActivityPub::TagManager.instance.uri_for(sender), + }, + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + Fabricate(:follow_request, account: recipient, target_account: sender) + subject.perform + end + + it 'does not create a follow relationship' do + expect(recipient.following?(sender)).to be false + end + + it 'removes the follow request' do + expect(recipient.requested?(sender)).to be false + end + end +end From 4fcbb1f8385dbcb0ca0e3b6127b75c9485243367 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 14 Aug 2017 21:37:21 +0200 Subject: [PATCH 032/201] Re-add missing transaction around status-from-OStatus creation (#4603) --- app/lib/ostatus/activity/creation.rb | 38 +++++++++++++++------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index e22f746f2c8..6ec2cdd561a 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -16,24 +16,28 @@ class OStatus::Activity::Creation < OStatus::Activity::Base return [status, false] unless status.nil? - status = Status.create!( - uri: id, - url: url, - account: @account, - reblog: reblog, - text: content, - spoiler_text: content_warning, - created_at: published, - reply: thread?, - language: content_language, - visibility: visibility_scope, - conversation: find_or_create_conversation, - thread: thread? ? find_status(thread.first) : nil - ) + cached_reblog = reblog - save_mentions(status) - save_hashtags(status) - save_media(status) + ApplicationRecord.transaction do + status = Status.create!( + uri: id, + url: url, + account: @account, + reblog: cached_reblog, + text: content, + spoiler_text: content_warning, + created_at: published, + reply: thread?, + language: content_language, + visibility: visibility_scope, + conversation: find_or_create_conversation, + thread: thread? ? find_status(thread.first) : nil + ) + + save_mentions(status) + save_hashtags(status) + save_media(status) + end if thread? && status.thread.nil? Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}" From e120d09c982d0108bdcdb3dd4d3800870b9e3f8c Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 15 Aug 2017 21:14:12 +0900 Subject: [PATCH 033/201] Fix require_user! behavior when not logged in (#4604) --- app/controllers/api/base_controller.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 105a2859d3e..6ede63c7909 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController end def require_user! - current_resource_owner - set_user_activity - rescue ActiveRecord::RecordNotFound - render json: { error: 'This method requires an authenticated user' }, status: 422 + if current_user + set_user_activity + else + render json: { error: 'This method requires an authenticated user' }, status: 422 + end end def render_empty From e33c28a6d8740b49d35a1ce23aa2ed84fa1a8690 Mon Sep 17 00:00:00 2001 From: Quent-in Date: Wed, 16 Aug 2017 10:21:34 +0200 Subject: [PATCH 034/201] Update ActivityPub (#4600) (#4609) Update: new string + more translations for the time in words --- config/locales/oc.yml | 48 +++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/config/locales/oc.yml b/config/locales/oc.yml index b49e4c4b890..49b72c5c1cd 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -57,6 +57,7 @@ oc: feed_url: Flux URL followers: Seguidors follows: Abonaments + inbox_url: URL de recepcion ip: IP location: all: Tot @@ -76,8 +77,10 @@ oc: alphabetic: Alfabetic most_recent: Mai recent title: Ordre + outbox_url: URL Outbox perform_full_suspension: Botar en tren la suspension complèta profile_url: URL del perfil + protocol: Protocòl public: Public push_subscription_expires: Fin de l’abonament PuSH redownload: Actualizar los avatars @@ -258,11 +261,11 @@ oc: - gen - feb - mar - - mai + - abr - mai - jun - jul - - ag + - ago - set - oct - nov @@ -299,24 +302,43 @@ oc: - :year datetime: distance_in_words: - about_x_hours: Fa %{count} oras - about_x_months: Fa %{count} meses + about_x_hours: + one: Fa una ora + other: Fa %{count} oras + about_x_months: + one: Fa un mes + other: Fa %{count} meses about_x_years: one: Fa un an other: Fa %{count} ans almost_x_years: - one: Fa un an - other: Fa %{count} ans + one: Fa quasi un an + other: Fa quasi %{count} ans half_a_minute: Ara - less_than_x_minutes: Fa %{count} minutas - less_than_x_seconds: Ara + less_than_x_minutes: + one: Fa mens d’una minuta + other: Fa mens de %{count} minutas + less_than_x_seconds: + one: Fa mens d’una segonda + other: Fa mens de %{count} segondas over_x_years: + one: Fa mai d’un an + other: Fa mai de %{count} ans + x_days: + one: Fa un jorn + other: Fa %{count} jorns + x_minutes: + one: Fa una minuta + other: Fa %{count} minutas + x_months: + one: Fa un mes + other: Fa %{count} meses + x_years: one: Fa un an other: Fa %{count} ans - x_days: Fa %{count} jorns - x_minutes: Fa %{count} minutas - x_months: Fa %{count} meses - x_seconds: Fa %{count} segondas + x_seconds: + one: Fa una segonda + other: Fa %{count} segondas deletes: bad_password_msg: Ben ensajat pirata ! Senhal incorrècte confirm_password: Picatz vòstre senhal actual per verificar vòstra identitat @@ -580,4 +602,4 @@ oc: users: invalid_email: L’adreça de corrièl es invalida invalid_otp_token: Còdi d’autentificacion en dos temps invalid - signed_in_as: 'Session a' + signed_in_as: Session a From f814661fcaed99221bf0b250dbe14349cb702833 Mon Sep 17 00:00:00 2001 From: Clworld Date: Wed, 16 Aug 2017 23:48:44 +0900 Subject: [PATCH 035/201] Make share intent modal to make "signed in as" shown. (#4611) * Make share intent modal to make "signed in as" shown. * fix glitch on mobile. --- app/controllers/shares_controller.rb | 7 ++++++- app/javascript/styles/containers.scss | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index d70d66ff8f4..994742c3df6 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true class SharesController < ApplicationController - layout 'public' + layout 'modal' before_action :authenticate_user! + before_action :set_body_classes def show serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) @@ -22,4 +23,8 @@ class SharesController < ApplicationController text: params[:text], } end + + def set_body_classes + @body_classes = 'compose-standalone' + end end diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss index 063db44dbfb..cfe8ea6438b 100644 --- a/app/javascript/styles/containers.scss +++ b/app/javascript/styles/containers.scss @@ -53,6 +53,7 @@ box-sizing: border-box; @media screen and (max-width: 400px) { + width: 100%; margin-top: 0; padding: 20px; } From ca7ea1aba92f97e93f3c49e972f686a78779fd71 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 16 Aug 2017 17:12:58 +0200 Subject: [PATCH 036/201] Redesign public profiles (#4608) * Redesign public profiles * Responsive design * Change public profile status filtering defaults and add options - No longer displays private/direct toots even if you are permitted access - By default omits replies - "With replies" option - "Media only" option * Redesign account grid cards * Fix style issues --- app/controllers/accounts_controller.rb | 41 +++- app/helpers/application_helper.rb | 4 + app/javascript/styles/accounts.scss | 234 ++++++++++++++++------ app/javascript/styles/landing_strip.scss | 13 ++ app/javascript/styles/stream_entries.scss | 17 ++ app/models/account.rb | 1 + app/views/accounts/_grid_card.html.haml | 11 +- app/views/accounts/_header.html.haml | 57 ++++-- app/views/accounts/show.html.haml | 7 +- app/views/shared/_landing_strip.html.haml | 9 +- config/locales/en.yml | 6 +- config/routes.rb | 2 + 12 files changed, 312 insertions(+), 90 deletions(-) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 4dc0a783dda..c6b98628e37 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,8 +7,14 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) + if current_account && @account.blocking?(current_account) + @statuses = [] + return + end + + @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) + @next_url = next_url unless @statuses.empty? end format.atom do @@ -24,7 +30,40 @@ class AccountsController < ApplicationController private + def filtered_statuses + default_statuses.tap do |statuses| + statuses.merge!(only_media_scope) if request.path.ends_with?('/media') + statuses.merge!(no_replies_scope) unless request.path.ends_with?('/with_replies') + end + end + + def default_statuses + @account.statuses.where(visibility: [:public, :unlisted]) + end + + def only_media_scope + Status.where(id: account_media_status_ids) + end + + def account_media_status_ids + @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + end + + def no_replies_scope + Status.without_replies + end + def set_account @account = Account.find_local!(params[:username]) end + + def next_url + if request.path.ends_with?('/media') + short_account_media_url(@account, max_id: @statuses.last.id) + elsif request.path.ends_with?('/with_replies') + short_account_with_replies_url(@account, max_id: @statuses.last.id) + else + short_account_url(@account, max_id: @statuses.last.id) + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f50d8bdb32..61d4442c12b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,10 @@ module ApplicationHelper current_page?(path) ? 'active' : '' end + def active_link_to(label, path, options = {}) + link_to label, path, options.merge(class: active_nav_class(path)) + end + def show_landing_strip? !user_signed_in? && !single_user_mode? end diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 66da75828f4..f1fbe873b42 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -1,21 +1,15 @@ .card { - background: $ui-base-color; + background-color: lighten($ui-base-color, 4%); background-size: cover; background-position: center; - padding: 60px 0; - padding-bottom: 0; border-radius: 4px 4px 0 0; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); overflow: hidden; position: relative; - - @media screen and (max-width: 740px) { - border-radius: 0; - box-shadow: none; - } + display: flex; &::after { - background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8)); + background: rgba(darken($ui-base-color, 8%), 0.5); display: block; content: ""; position: absolute; @@ -26,6 +20,31 @@ z-index: 1; } + @media screen and (max-width: 740px) { + border-radius: 0; + box-shadow: none; + } + + .card__illustration { + padding: 60px 0; + position: relative; + flex: 1 1 auto; + display: flex; + justify-content: center; + align-items: center; + } + + .card__bio { + max-width: 260px; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-between; + background: rgba(darken($ui-base-color, 8%), 0.8); + position: relative; + z-index: 2; + } + &.compact { padding: 30px 0; border-radius: 4px; @@ -44,11 +63,12 @@ font-size: 20px; line-height: 18px * 1.5; color: $primary-text-color; + padding: 10px 15px; + padding-bottom: 0; font-weight: 500; - text-align: center; position: relative; z-index: 2; - text-shadow: 0 0 2px $base-shadow-color; + margin-bottom: 30px; small { display: block; @@ -61,7 +81,6 @@ .avatar { width: 120px; margin: 0 auto; - margin-bottom: 15px; position: relative; z-index: 2; @@ -70,43 +89,68 @@ height: 120px; display: block; border-radius: 120px; + box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); } } .controls { position: absolute; - top: 10px; - right: 10px; + top: 15px; + left: 15px; z-index: 2; + + .icon-button { + color: rgba($white, 0.8); + text-decoration: none; + font-size: 13px; + line-height: 13px; + font-weight: 500; + + .fa { + font-weight: 400; + margin-right: 5px; + } + + &:hover, + &:active, + &:focus { + color: $white; + } + } } - .details { - display: flex; - margin-top: 30px; - position: relative; - z-index: 2; - flex-direction: row; + .roles { + margin-bottom: 30px; + padding: 0 15px; } .details-counters { + margin-top: 30px; display: flex; flex-direction: row; - order: 0; + width: 100%; } .counter { - width: 80px; + width: 33.3%; + box-sizing: border-box; + flex: 0 0 auto; color: $ui-primary-color; padding: 5px 10px 0; margin-bottom: 10px; - border-right: 1px solid $ui-primary-color; + border-right: 1px solid lighten($ui-base-color, 4%); cursor: default; + text-align: center; position: relative; a { display: block; } + &:last-child { + border-right: 0; + } + &::after { display: block; content: ""; @@ -116,7 +160,7 @@ width: 100%; border-bottom: 4px solid $ui-primary-color; opacity: 0.5; - transition: all 0.8s ease; + transition: all 400ms ease; } &.active { @@ -129,7 +173,7 @@ &:hover { &::after { opacity: 1; - transition-duration: 0.2s; + transition-duration: 100ms; } } @@ -140,44 +184,40 @@ .counter-label { font-size: 12px; - text-transform: uppercase; display: block; margin-bottom: 5px; - text-shadow: 0 0 2px $base-shadow-color; } .counter-number { font-weight: 500; font-size: 18px; color: $primary-text-color; + font-family: 'mastodon-font-display', sans-serif; } } .bio { - flex: 1; font-size: 14px; line-height: 18px; - padding: 5px 10px; + padding: 0 15px; color: $ui-secondary-color; - order: 1; } @media screen and (max-width: 480px) { - .details { - display: block; + display: block; + + .card__bio { + max-width: none; + } + + .name, + .roles { + text-align: center; + margin-bottom: 15px; } .bio { - text-align: center; - margin-bottom: 20px; - } - - .counter { - flex: 1 1 auto; - } - - .counter:last-child { - border-right: none; + margin-bottom: 15px; } } } @@ -264,13 +304,15 @@ .accounts-grid { box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); - background: $simple-background-color; + background: darken($simple-background-color, 8%); border-radius: 0 0 4px 4px; padding: 20px 10px; padding-bottom: 10px; overflow: hidden; display: flex; flex-wrap: wrap; + z-index: 2; + position: relative; @media screen and (max-width: 740px) { border-radius: 0; @@ -280,10 +322,11 @@ .account-grid-card { box-sizing: border-box; width: 335px; - border: 1px solid $ui-secondary-color; + background: $simple-background-color; border-radius: 4px; color: $ui-base-color; margin-bottom: 10px; + position: relative; &:nth-child(odd) { margin-right: 10px; @@ -291,26 +334,52 @@ .account-grid-card__header { overflow: hidden; - padding: 10px; - border-bottom: 1px solid $ui-secondary-color; + height: 100px; + border-radius: 4px 4px 0 0; + background-color: lighten($ui-base-color, 4%); + background-size: cover; + background-position: center; + position: relative; + + &::after { + background: rgba(darken($ui-base-color, 8%), 0.5); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } + } + + .account-grid-card__avatar { + box-sizing: border-box; + padding: 15px; + position: absolute; + z-index: 2; + top: 100px - (40px + 2px); + left: -2px; } .avatar { - width: 60px; - height: 60px; - float: left; - margin-right: 15px; + width: 80px; + height: 80px; img { display: block; - width: 60px; - height: 60px; - border-radius: 60px; + width: 80px; + height: 80px; + border-radius: 80px; + border: 2px solid $simple-background-color; } } .name { + padding: 15px; padding-top: 10px; + padding-left: 15px + 80px + 15px; a { display: block; @@ -318,6 +387,7 @@ text-decoration: none; text-overflow: ellipsis; overflow: hidden; + font-weight: 500; &:hover { .display_name { @@ -328,30 +398,36 @@ } .display_name { - font-size: 14px; + font-size: 16px; display: block; } .username { - color: $ui-highlight-color; + color: lighten($ui-base-color, 34%); + font-size: 14px; + font-weight: 400; } .note { - padding: 10px; + padding: 10px 15px; padding-top: 15px; - color: $ui-primary-color; + box-sizing: border-box; + color: lighten($ui-base-color, 26%); word-wrap: break-word; + min-height: 80px; } } } .nothing-here { + width: 100%; + display: block; color: $ui-primary-color; font-size: 14px; font-weight: 500; text-align: center; - padding: 15px 0; - padding-bottom: 25px; + padding: 60px 0; + padding-top: 55px; cursor: default; } @@ -416,3 +492,43 @@ color: $ui-base-color; } } + +.activity-stream-tabs { + background: $simple-background-color; + border-bottom: 1px solid $ui-secondary-color; + position: relative; + z-index: 2; + + a { + display: inline-block; + padding: 15px; + text-decoration: none; + color: $ui-highlight-color; + text-transform: uppercase; + font-weight: 500; + + &:hover, + &:active, + &:focus { + color: lighten($ui-highlight-color, 8%); + } + + &.active { + color: $ui-base-color; + cursor: default; + } + } +} + +.account-role { + display: inline-block; + padding: 4px 6px; + cursor: default; + border-radius: 3px; + font-size: 12px; + line-height: 12px; + font-weight: 500; + color: $success-green; + background-color: rgba($success-green, 0.1); + border: 1px solid rgba($success-green, 0.5); +} diff --git a/app/javascript/styles/landing_strip.scss b/app/javascript/styles/landing_strip.scss index d2ac5b8225b..15ff8491285 100644 --- a/app/javascript/styles/landing_strip.scss +++ b/app/javascript/styles/landing_strip.scss @@ -5,6 +5,8 @@ padding: 14px; border-radius: 4px; margin-bottom: 20px; + display: flex; + align-items: center; strong, a { @@ -15,4 +17,15 @@ color: inherit; text-decoration: underline; } + + .logo { + width: 30px; + height: 30px; + flex: 0 0 auto; + margin-right: 15px; + } + + @media screen and (max-width: 740px) { + margin-bottom: 0; + } } diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss index 9e062c57ea3..1192e2a806e 100644 --- a/app/javascript/styles/stream_entries.scss +++ b/app/javascript/styles/stream_entries.scss @@ -8,6 +8,7 @@ .detailed-status.light, .status.light { border-bottom: 1px solid $ui-secondary-color; + animation: none; } &:last-child { @@ -34,6 +35,14 @@ } } } + + @media screen and (max-width: 740px) { + &, + .detailed-status.light, + .status.light { + border-radius: 0 !important; + } + } } &.with-header { @@ -44,6 +53,14 @@ .status.light { border-radius: 0; } + + &:last-child { + &, + .detailed-status.light, + .status.light { + border-radius: 0 0 4px 4px; + } + } } } } diff --git a/app/models/account.rb b/app/models/account.rb index a7264353e75..c4c168160e0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -105,6 +105,7 @@ class Account < ApplicationRecord :current_sign_in_ip, :current_sign_in_at, :confirmed?, + :admin?, :locale, to: :user, prefix: true, diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml index 0571d1d5e2e..305eb2c44cc 100644 --- a/app/views/accounts/_grid_card.html.haml +++ b/app/views/accounts/_grid_card.html.haml @@ -1,8 +1,9 @@ .account-grid-card - .account-grid-card__header + .account-grid-card__header{ style: "background-image: url(#{account.header.url(:original)})" } + .account-grid-card__avatar .avatar= image_tag account.avatar.url(:original) - .name - = link_to TagManager.instance.url_for(account) do - %span.display_name.emojify= display_name(account) - %span.username @#{account.acct} + .name + = link_to TagManager.instance.url_for(account) do + %span.display_name.emojify= display_name(account) + %span.username @#{account.acct} %p.note.emojify= truncate(strip_tags(account.note), length: 150) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 6451a5573d4..8009e903ec1 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,34 +1,51 @@ .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" } - - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) - .controls - - if current_account.following?(account) - = link_to t('accounts.unfollow'), account_unfollow_path(account), data: { method: :post }, class: 'button' - - else - = link_to t('accounts.follow'), account_follow_path(account), data: { method: :post }, class: 'button' - - elsif !user_signed_in? - .controls - .remote-follow - = link_to t('accounts.remote_follow'), account_remote_follow_path(account), class: 'button' - .avatar= image_tag account.avatar.url(:original), class: 'u-photo' - %h1.name - %span.p-name.emojify= display_name(account) - %small - %span @#{account.username} - = fa_icon('lock') if account.locked? - .details + .card__illustration + - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account) + .controls + - if current_account.following?(account) + = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-times' + = t('accounts.unfollow') + - else + = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.follow') + - elsif !user_signed_in? + .controls + .remote-follow + = link_to account_remote_follow_path(account), class: 'icon-button' do + = fa_icon 'user-plus' + = t('accounts.remote_follow') + + .avatar= image_tag account.avatar.url(:original), class: 'u-photo' + + .card__bio + %h1.name + %span.p-name.emojify= display_name(account) + %small + %span @#{account.local_username_and_domain} + = fa_icon('lock') if account.locked? + + - if account.user_admin? + .roles + .account-role + = t 'accounts.roles.admin' + .bio .account__header__content.p-note.emojify= Formatter.instance.simplified_format(account) .details-counters .counter{ class: active_nav_class(short_account_url(account)) } = link_to short_account_url(account), class: 'u-url u-uid' do - %span.counter-label= t('accounts.posts') %span.counter-number= number_with_delimiter account.statuses_count + %span.counter-label= t('accounts.posts') + .counter{ class: active_nav_class(account_following_index_url(account)) } = link_to account_following_index_url(account) do - %span.counter-label= t('accounts.following') %span.counter-number= number_with_delimiter account.following_count + %span.counter-label= t('accounts.following') + .counter{ class: active_nav_class(account_followers_url(account)) } = link_to account_followers_url(account) do - %span.counter-label= t('accounts.followers') %span.counter-number= number_with_delimiter account.followers_count + %span.counter-label= t('accounts.followers') diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 74e695fc3c7..ec44f4c74bf 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -20,6 +20,11 @@ = render 'header', account: @account + .activity-stream-tabs + = active_link_to t('accounts.posts'), short_account_url(@account) + = active_link_to t('accounts.posts_with_replies'), short_account_with_replies_url(@account) + = active_link_to t('accounts.media'), short_account_media_url(@account) + - if @statuses.empty? .accounts-grid = render 'nothing_here' @@ -29,4 +34,4 @@ - if @statuses.size == 20 .pagination - = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), short_account_url(@account, max_id: @statuses.last.id), class: 'next', rel: 'next' + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), @next_url, class: 'next', rel: 'next' diff --git a/app/views/shared/_landing_strip.html.haml b/app/views/shared/_landing_strip.html.haml index 35461a8cb72..ae26fc1fff1 100644 --- a/app/views/shared/_landing_strip.html.haml +++ b/app/views/shared/_landing_strip.html.haml @@ -1,5 +1,8 @@ .landing-strip - = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path)) + = image_tag asset_pack_path('logo.svg'), class: 'logo' - - if open_registrations? - = t('landing_strip_signup_html', sign_up_path: new_user_registration_path) + %div + = t('landing_strip_html', name: content_tag(:span, display_name(account), class: :emojify), link_to_root_path: link_to(content_tag(:strong, site_hostname), root_path)) + + - if open_registrations? + = t('landing_strip_signup_html', sign_up_path: new_user_registration_path) diff --git a/config/locales/en.yml b/config/locales/en.yml index 210bfc5b436..97f46c3af02 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -40,7 +40,11 @@ en: nothing_here: There is nothing here! people_followed_by: People whom %{name} follows people_who_follow: People who follow %{name} - posts: Posts + posts: Toots + posts_with_replies: Toots with replies + media: Media + roles: + admin: Admin remote_follow: Remote follow reserved_username: The username is reserved unfollow: Unfollow diff --git a/config/routes.rb b/config/routes.rb index f75de530412..1a39dfeac7c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,8 @@ Rails.application.routes.draw do end get '/@:username', to: 'accounts#show', as: :short_account + get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies + get '/@:username/media', to: 'accounts#show', as: :short_account_media get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status namespace :settings do From 2a04bdc87a6b4ed662db1e3f6cbba25fa722e123 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Wed, 16 Aug 2017 22:14:23 +0200 Subject: [PATCH 037/201] i18n: Update Polish translation (#4613) * i18n: Update Polish translation * Update pl.json --- app/javascript/mastodon/locales/pl.json | 2 +- config/locales/pl.yml | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 542230f112b..dfa5c3f9091 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -85,7 +85,7 @@ "getting_started.appsshort": "Aplikacje", "getting_started.faq": "FAQ", "getting_started.heading": "Naucz się korzystać", - "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.", + "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.", "getting_started.userguide": "Podręcznik użytkownika", "home.column_settings.advanced": "Zaawansowane", "home.column_settings.basic": "Podstawowe", diff --git a/config/locales/pl.yml b/config/locales/pl.yml index b8b5ace14b8..97c1f05eda4 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -41,7 +41,11 @@ pl: people_followed_by: Konta śledzone przez %{name} people_who_follow: Osoby, które śledzą konto %{name} posts: Wpisy - remote_follow: Zdalne śledzenie + posts_with_replies: Wpisy + media: Zawartość multimedialna + roles: + admin: Administrator + remote_follow: Śledź zdalnie reserved_username: Ta nazwa użytkownika jest zarezerwowana. unfollow: Przestań śledzić admin: From 462c30e26cab017f185fa1fb464fca7ec73b6da8 Mon Sep 17 00:00:00 2001 From: Naoki Kosaka Date: Thu, 17 Aug 2017 06:19:37 +0900 Subject: [PATCH 038/201] Update Japanese Translation. (Redesign public profiles) (#4612) --- config/locales/ja.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 05c71223412..0f0b0ad4a94 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -40,7 +40,11 @@ ja: nothing_here: 何もありません people_followed_by: "%{name} さんがフォロー中のアカウント" people_who_follow: "%{name} さんをフォロー中のアカウント" - posts: 投稿 + posts: トゥート + posts_with_replies: トゥートと返信 + media: メディア + roles: + admin: Admin remote_follow: リモートフォロー reserved_username: このユーザー名は予約されています。 unfollow: フォロー解除 From 54a04e36587840e330a0a4df7c4e1855a3c92e03 Mon Sep 17 00:00:00 2001 From: nullkal Date: Thu, 17 Aug 2017 21:46:53 +0900 Subject: [PATCH 039/201] Update charlock_holmes to 0.7.5 (#4620) --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index f4182bff5f4..52ac43b9af7 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,7 @@ gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' gem 'bootsnap' gem 'browser' -gem 'charlock_holmes', '~> 0.7.3' +gem 'charlock_holmes', '~> 0.7.5' gem 'cld3', '~> 3.1' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 7a4dbab8515..9c830a7b002 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,7 +108,7 @@ GEM xpath (~> 2.0) case_transform (0.2) activesupport - charlock_holmes (0.7.3) + charlock_holmes (0.7.5) chunky_png (1.3.8) cld3 (3.1.3) ffi (>= 1.1.0, < 1.10.0) @@ -511,7 +511,7 @@ DEPENDENCIES capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) capybara (~> 2.14) - charlock_holmes (~> 0.7.3) + charlock_holmes (~> 0.7.5) cld3 (~> 3.1) climate_control (~> 0.2) devise (~> 4.2) From 075d6a1e13aa6477c656e9dbe03e6720cb4e2b32 Mon Sep 17 00:00:00 2001 From: nullkal Date: Fri, 18 Aug 2017 00:52:40 +0900 Subject: [PATCH 040/201] Show what protocol is used for accounts in admin/accounts#index (#4622) * Show what protocol used for in admin/accounts#index * Add frozen_string_literal --- app/helpers/account_helper.rb | 14 ++++++++++ app/views/admin/accounts/_account.html.haml | 3 +++ app/views/admin/accounts/index.html.haml | 1 + app/views/admin/accounts/show.html.haml | 2 +- spec/helpers/account_helper_spec.rb | 30 +++++++++++++++++++++ 5 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 app/helpers/account_helper.rb create mode 100644 spec/helpers/account_helper_spec.rb diff --git a/app/helpers/account_helper.rb b/app/helpers/account_helper.rb new file mode 100644 index 00000000000..00d4fc657ad --- /dev/null +++ b/app/helpers/account_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module AccountHelper + def protocol_for_display(protocol) + case protocol + when 'activitypub' + 'ActivityPub' + when 'ostatus' + 'OStatus' + else + protocol + end + end +end diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index c513776b7af..a7fca6b3ef4 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -4,6 +4,9 @@ %td.domain - unless account.local? = link_to account.domain, admin_accounts_path(by_domain: account.domain) + %td.protocol + - unless account.local? + = protocol_for_display(account.protocol) %td.confirmed - if account.local? - if account.user_confirmed? diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 07c8d16325c..1f36aeb312b 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -55,6 +55,7 @@ %tr %th= t('admin.accounts.username') %th= t('admin.accounts.domain') + %th= t('admin.accounts.protocol') %th= t('admin.accounts.confirmed') %th= fa_icon 'paper-plane-o' %th diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 5c781e81720..f0e4e303c17 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -33,7 +33,7 @@ %td= link_to @account.url, @account.url %tr %th= t('admin.accounts.protocol') - %td= @account.protocol + %td= protocol_for_display(@account.protocol) - if @account.ostatus? %tr diff --git a/spec/helpers/account_helper_spec.rb b/spec/helpers/account_helper_spec.rb new file mode 100644 index 00000000000..63e7c78b60f --- /dev/null +++ b/spec/helpers/account_helper_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the AccountHelper. For example: +# +# describe AccountHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe AccountHelper, type: :helper do + describe '#protocol_for_display' do + it "returns OStatus when the protocol is 'ostatus'" do + protocol = 'ostatus' + expect(protocol_for_display(protocol)).to eq 'OStatus' + end + + it "returns ActivityPub when the protocol is 'activitypub'" do + protocol = 'activitypub' + expect(protocol_for_display(protocol)).to eq 'ActivityPub' + end + + it "returns the same string when the protocol is unknown" do + protocol = 'wave' + expect(protocol_for_display(protocol)).to eq protocol + end + end +end From ad892dbc0c377d82b2cae349f189a41708a1e6af Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 17 Aug 2017 21:35:00 +0200 Subject: [PATCH 041/201] Add _:atomUri property for deduplicating OStatus/ActivityPub legacy records (#4593) --- app/lib/activitypub/activity/create.rb | 8 +++++++- app/serializers/activitypub/note_serializer.rb | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4c4049bc697..de9c47d5c99 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -4,7 +4,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def perform return if delete_arrived_first?(object_uri) || unsupported_object_type? - status = Status.find_by(uri: object_uri) + status = find_existing_status return status unless status.nil? @@ -23,6 +23,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private + def find_existing_status + status = Status.find_by(uri: object_uri) + status ||= Status.find_by(uri: @object['_:atomUri']) if @object['_:atomUri'].present? + status + end + def status_params { uri: @object['id'], diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4c13f8e596a..bc8eb8a3561 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -8,6 +8,8 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_many :media_attachments, key: :attachment has_many :virtual_tags, key: :tag + attribute :atom_uri, key: '_:atomUri', if: :local? + def id ActivityPub::TagManager.instance.uri_for(object) end @@ -52,6 +54,14 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer object.mentions + object.tags end + def atom_uri + ::TagManager.instance.uri_for(object) + end + + def local? + object.account.local? + end + class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper From 116b8a63633cfdff83582f1cc40559c2e3c2ae80 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 17 Aug 2017 22:15:37 +0200 Subject: [PATCH 042/201] Fix #4607 - Accept/reject activities use FollowRequest, which has inverse relations (#4616) --- .../activitypub/accept_follow_serializer.rb | 2 +- .../activitypub/inverse_follow_serializer.rb | 22 +++++++++++++++++++ .../activitypub/reject_follow_serializer.rb | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 app/serializers/activitypub/inverse_follow_serializer.rb diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb index 3e23591a527..f1c9d90238d 100644 --- a/app/serializers/activitypub/accept_follow_serializer.rb +++ b/app/serializers/activitypub/accept_follow_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer attributes :id, :type, :actor - has_one :object, serializer: ActivityPub::FollowSerializer + has_one :object, serializer: ActivityPub::InverseFollowSerializer def id [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join diff --git a/app/serializers/activitypub/inverse_follow_serializer.rb b/app/serializers/activitypub/inverse_follow_serializer.rb new file mode 100644 index 00000000000..58e0eaad0b7 --- /dev/null +++ b/app/serializers/activitypub/inverse_follow_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ActivityPub::InverseFollowSerializer < ActiveModel::Serializer + attributes :id, :type, :actor + attribute :virtual_object, key: :object + + def id + [ActivityPub::TagManager.instance.uri_for(object.target_account), '#follows/', object.id].join + end + + def type + 'Follow' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.target_account) + end + + def virtual_object + ActivityPub::TagManager.instance.uri_for(object.account) + end +end diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb index 7814f4f5759..9a98229143a 100644 --- a/app/serializers/activitypub/reject_follow_serializer.rb +++ b/app/serializers/activitypub/reject_follow_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer attributes :id, :type, :actor - has_one :object, serializer: ActivityPub::FollowSerializer + has_one :object, serializer: ActivityPub::InverseFollowSerializer def id [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join From efec02f1538adc7f75ba9ca3716ea25b3f2ef4df Mon Sep 17 00:00:00 2001 From: nightpool Date: Thu, 17 Aug 2017 17:20:50 -0400 Subject: [PATCH 043/201] use existing inflections instead of custom helper (#4624) * use existing inflections instead of custom helper * use ActiveSupport versions --- app/helpers/account_helper.rb | 14 ---------- app/views/admin/accounts/_account.html.haml | 2 +- app/views/admin/accounts/show.html.haml | 2 +- spec/helpers/account_helper_spec.rb | 30 --------------------- 4 files changed, 2 insertions(+), 46 deletions(-) delete mode 100644 app/helpers/account_helper.rb delete mode 100644 spec/helpers/account_helper_spec.rb diff --git a/app/helpers/account_helper.rb b/app/helpers/account_helper.rb deleted file mode 100644 index 00d4fc657ad..00000000000 --- a/app/helpers/account_helper.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module AccountHelper - def protocol_for_display(protocol) - case protocol - when 'activitypub' - 'ActivityPub' - when 'ostatus' - 'OStatus' - else - protocol - end - end -end diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml index a7fca6b3ef4..5265d77f66c 100644 --- a/app/views/admin/accounts/_account.html.haml +++ b/app/views/admin/accounts/_account.html.haml @@ -6,7 +6,7 @@ = link_to account.domain, admin_accounts_path(by_domain: account.domain) %td.protocol - unless account.local? - = protocol_for_display(account.protocol) + = account.protocol.humanize %td.confirmed - if account.local? - if account.user_confirmed? diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index f0e4e303c17..18bcd5e8edb 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -33,7 +33,7 @@ %td= link_to @account.url, @account.url %tr %th= t('admin.accounts.protocol') - %td= protocol_for_display(@account.protocol) + %td= @account.protocol.humanize - if @account.ostatus? %tr diff --git a/spec/helpers/account_helper_spec.rb b/spec/helpers/account_helper_spec.rb deleted file mode 100644 index 63e7c78b60f..00000000000 --- a/spec/helpers/account_helper_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'rails_helper' - -# Specs in this file have access to a helper object that includes -# the AccountHelper. For example: -# -# describe AccountHelper do -# describe "string concat" do -# it "concats two strings with spaces" do -# expect(helper.concat_strings("this","that")).to eq("this that") -# end -# end -# end -RSpec.describe AccountHelper, type: :helper do - describe '#protocol_for_display' do - it "returns OStatus when the protocol is 'ostatus'" do - protocol = 'ostatus' - expect(protocol_for_display(protocol)).to eq 'OStatus' - end - - it "returns ActivityPub when the protocol is 'activitypub'" do - protocol = 'activitypub' - expect(protocol_for_display(protocol)).to eq 'ActivityPub' - end - - it "returns the same string when the protocol is unknown" do - protocol = 'wave' - expect(protocol_for_display(protocol)).to eq protocol - end - end -end From 40c45f5dd958aa1319b4e8cb664e6b4cac029526 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 01:03:18 +0200 Subject: [PATCH 044/201] Put ActivityPub alternate link into Atom, prefer it when processing Atom (#4623) --- app/lib/activitypub/tag_manager.rb | 2 +- app/lib/ostatus/activity/base.rb | 14 +++++++++++++- app/lib/ostatus/activity/creation.rb | 5 +++++ app/lib/ostatus/activity/remote.rb | 6 +++++- app/lib/ostatus/atom_serializer.rb | 2 ++ spec/lib/ostatus/atom_serializer_spec.rb | 11 ++++++----- 6 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 96e610b6da1..bd5dddcac6f 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -71,7 +71,7 @@ class ActivityPub::TagManager def local_uri?(uri) host = Addressable::URI.parse(uri).normalized_host - ::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host) + !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)) end def uri_to_local_id(uri, param = :id) diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index e1477f0eb22..da9a0175947 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -29,16 +29,28 @@ class OStatus::Activity::Base end def url - link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS) + link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' } link.nil? ? nil : link['href'] end + def activitypub_uri + link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) } + link.nil? ? nil : link['href'] + end + + def activitypub_uri? + activitypub_uri.present? + end + private def find_status(uri) if TagManager.instance.local_id?(uri) local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status') return Status.find_by(id: local_id) + elsif ActivityPub::TagManager.instance.local_uri?(uri) + local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri) + return Status.find_by(id: local_id) end Status.find_by(uri: uri) diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 6ec2cdd561a..12488ab31bd 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -8,6 +8,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base end return [nil, false] if @account.suspended? + return perform_via_activitypub if activitypub_uri? Rails.logger.debug "Creating remote status #{id}" @@ -52,6 +53,10 @@ class OStatus::Activity::Creation < OStatus::Activity::Base [status, true] end + def perform_via_activitypub + [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false] + end + def content @xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content end diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb index ecec6886c0e..5b204b6d8f0 100644 --- a/app/lib/ostatus/activity/remote.rb +++ b/app/lib/ostatus/activity/remote.rb @@ -2,6 +2,10 @@ class OStatus::Activity::Remote < OStatus::Activity::Base def perform - find_status(id) || FetchRemoteStatusService.new.call(url) + if activitypub_uri? + find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url) + else + find_status(id) || FetchRemoteStatusService.new.call(url) + end end end diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 0d62361bec9..92a16d2280a 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -343,6 +343,8 @@ class OStatus::AtomSerializer end def serialize_status_attributes(entry, status) + append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local? + append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text? append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language) diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index b0cb8f019eb..301a0ce30dc 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -196,7 +196,7 @@ RSpec.describe OStatus::AtomSerializer do author = OStatus::AtomSerializer.new.author(account) - link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:rel]).to eq 'alternate' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' @@ -407,6 +407,7 @@ RSpec.describe OStatus::AtomSerializer do remote_status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z') entry = OStatus::AtomSerializer.new.entry(remote_status.stream_entry, true) + entry.nodes.delete_if { |node| node[:type] == 'application/activity+json' } # Remove ActivityPub link to simplify test xml = OStatus::AtomSerializer.render(entry).gsub('cb6e6126.ngrok.io', 'remote') remote_status.destroy! @@ -415,7 +416,7 @@ RSpec.describe OStatus::AtomSerializer do account = Account.create!( domain: 'remote', username: 'username', - last_webfingered_at: Time.now.utc, + last_webfingered_at: Time.now.utc ) ProcessFeedService.new.call(xml, account) @@ -529,7 +530,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.entry(status.stream_entry) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq "https://cb6e6126.ngrok.io/users/username/updates/#{status.stream_entry.id}" end @@ -642,7 +643,7 @@ RSpec.describe OStatus::AtomSerializer do feed = OStatus::AtomSerializer.new.feed(account, []) - link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username' end @@ -1509,7 +1510,7 @@ RSpec.describe OStatus::AtomSerializer do entry = OStatus::AtomSerializer.new.object(status) - link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' } + link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' } expect(link[:type]).to eq 'text/html' expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}" end From 10489b4e4ab75c72f3fb49761cb7e08151752a6e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 02:29:12 +0200 Subject: [PATCH 045/201] If url attribute not present in Note, fallback to id attribute (#4629) --- app/lib/activitypub/activity/create.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index de9c47d5c99..77d66fba3c5 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -32,7 +32,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def status_params { uri: @object['id'], - url: @object['url'], + url: @object['url'] || @object['id'], account: @account, text: text_from_content || '', language: language_from_content, From 4edf9d849f5241aed42042577c889549a5b6beaa Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 03:21:59 +0200 Subject: [PATCH 046/201] Make ActivityPub::TagManager#local_uri? recognize local URIs with ports (#4628) --- app/lib/activitypub/tag_manager.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index bd5dddcac6f..3d7f08cd382 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -70,7 +70,10 @@ class ActivityPub::TagManager end def local_uri?(uri) - host = Addressable::URI.parse(uri).normalized_host + uri = Addressable::URI.parse(uri) + host = uri.normalized_host + host = "#{host}:#{uri.port}" if uri.port + !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host)) end From 2edfdab6e6d70598a19d59f8a2f47ecae8add243 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 18 Aug 2017 17:42:59 +0900 Subject: [PATCH 047/201] Don't send Link header when don't know prev and next links (#4633) --- app/controllers/api/base_controller.rb | 2 +- spec/controllers/api/v1/favourites_controller_spec.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 6ede63c7909..7cfe8fe71cb 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController links = [] links << [next_path, [%w(rel next)]] if next_path links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) + response.headers['Link'] = LinkHeader.new(links) unless links.empty? end def limit_param(default_limit) diff --git a/spec/controllers/api/v1/favourites_controller_spec.rb b/spec/controllers/api/v1/favourites_controller_spec.rb index 3de045377fa..46cf70f4d27 100644 --- a/spec/controllers/api/v1/favourites_controller_spec.rb +++ b/spec/controllers/api/v1/favourites_controller_spec.rb @@ -70,8 +70,7 @@ RSpec.describe Api::V1::FavouritesController, type: :controller do it 'does not add pagination headers if not necessary' do get :index - expect(response.headers['Link'].find_link(['rel', 'next'])).to eq nil - expect(response.headers['Link'].find_link(['rel', 'prev'])).to eq nil + expect(response.headers['Link']).to eq nil end end end From c7d2619ab1eeb096cea4bde0c35a45eea794c5b0 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 18 Aug 2017 11:24:44 +0200 Subject: [PATCH 048/201] Parse OStatus tag URIs in ActivityPub handlers when those are local (#4631) --- app/lib/activitypub/tag_manager.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3d7f08cd382..855881612e4 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -90,6 +90,8 @@ class ActivityPub::TagManager else klass.find_by(id: uri_to_local_id(uri)) end + elsif ::TagManager.instance.local_id?(uri) + klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s)) else klass.find_by(uri: uri) end From 774b8661bc578c2e7eae7fff2be24466774fe858 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 19 Aug 2017 02:23:47 +0200 Subject: [PATCH 049/201] Revert #4616 (#4638) --- .../activitypub/accept_follow_serializer.rb | 2 +- .../activitypub/inverse_follow_serializer.rb | 22 ------------------- .../activitypub/reject_follow_serializer.rb | 2 +- 3 files changed, 2 insertions(+), 24 deletions(-) delete mode 100644 app/serializers/activitypub/inverse_follow_serializer.rb diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb index f1c9d90238d..3e23591a527 100644 --- a/app/serializers/activitypub/accept_follow_serializer.rb +++ b/app/serializers/activitypub/accept_follow_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer attributes :id, :type, :actor - has_one :object, serializer: ActivityPub::InverseFollowSerializer + has_one :object, serializer: ActivityPub::FollowSerializer def id [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join diff --git a/app/serializers/activitypub/inverse_follow_serializer.rb b/app/serializers/activitypub/inverse_follow_serializer.rb deleted file mode 100644 index 58e0eaad0b7..00000000000 --- a/app/serializers/activitypub/inverse_follow_serializer.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::InverseFollowSerializer < ActiveModel::Serializer - attributes :id, :type, :actor - attribute :virtual_object, key: :object - - def id - [ActivityPub::TagManager.instance.uri_for(object.target_account), '#follows/', object.id].join - end - - def type - 'Follow' - end - - def actor - ActivityPub::TagManager.instance.uri_for(object.target_account) - end - - def virtual_object - ActivityPub::TagManager.instance.uri_for(object.account) - end -end diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb index 9a98229143a..7814f4f5759 100644 --- a/app/serializers/activitypub/reject_follow_serializer.rb +++ b/app/serializers/activitypub/reject_follow_serializer.rb @@ -3,7 +3,7 @@ class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer attributes :id, :type, :actor - has_one :object, serializer: ActivityPub::InverseFollowSerializer + has_one :object, serializer: ActivityPub::FollowSerializer def id [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join From 412ea873060da4dc73236fdd63a2931d27dbfa40 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 19 Aug 2017 18:44:48 +0200 Subject: [PATCH 050/201] Improve ActivityPub/OStatus compatibility (#4632) *Note: OStatus URIs are invalid for ActivityPub. But we have them for as long as we want to keep old OStatus-sourced content and as long as we remain OStatus-compatible.* - In Announce handling, if object URI is not a URL, fallback to object URL - Do not use specialized ThreadResolveWorker, rely on generalized handling - When serializing notes, if parent's URI is not a URL, use parent's URL --- app/lib/activitypub/activity/announce.rb | 14 ++++++++++++-- app/lib/activitypub/activity/create.rb | 2 +- app/serializers/activitypub/note_serializer.rb | 8 +++++++- .../activitypub/thread_resolve_worker.rb | 17 ----------------- .../activitypub/thread_resolve_worker_spec.rb | 16 ---------------- 5 files changed, 20 insertions(+), 37 deletions(-) delete mode 100644 app/workers/activitypub/thread_resolve_worker.rb delete mode 100644 spec/workers/activitypub/thread_resolve_worker_spec.rb diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index decf8f9601c..09fec28a080 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -2,8 +2,8 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def perform - original_status = status_from_uri(object_uri) - original_status = ActivityPub::FetchRemoteStatusService.new.call(object_uri) if original_status.nil? + original_status = status_from_uri(object_uri) + original_status ||= fetch_remote_original_status return if original_status.nil? || delete_arrived_first?(@json['id']) @@ -11,4 +11,14 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity distribute(status) status end + + private + + def fetch_remote_original_status + if object_uri.start_with?('http') + ActivityPub::FetchRemoteStatusService.new.call(object_uri) + elsif @object['url'].present? + ::FetchRemoteStatusService.new.call(@object['url']) + end + end end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 77d66fba3c5..1541257590d 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -91,7 +91,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def resolve_thread(status) return unless status.reply? && status.thread.nil? - ActivityPub::ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) end def conversation_from_uri(uri) diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index bc8eb8a3561..4061b9ce456 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -27,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end def in_reply_to - ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply? + return unless object.reply? + + if object.thread.uri.nil? || object.thread.uri.start_with?('http') + ActivityPub::TagManager.instance.uri_for(object.thread) + else + object.thread.url + end end def published diff --git a/app/workers/activitypub/thread_resolve_worker.rb b/app/workers/activitypub/thread_resolve_worker.rb deleted file mode 100644 index 4ef762d0642..00000000000 --- a/app/workers/activitypub/thread_resolve_worker.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class ActivityPub::ThreadResolveWorker - include Sidekiq::Worker - - sidekiq_options queue: 'pull', retry: false - - def perform(child_status_id, parent_uri) - child_status = Status.find(child_status_id) - parent_status = ActivityPub::FetchRemoteStatusService.new.call(parent_uri) - - return if parent_status.nil? - - child_status.thread = parent_status - child_status.save! - end -end diff --git a/spec/workers/activitypub/thread_resolve_worker_spec.rb b/spec/workers/activitypub/thread_resolve_worker_spec.rb deleted file mode 100644 index b954cb62caa..00000000000 --- a/spec/workers/activitypub/thread_resolve_worker_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'rails_helper' - -describe ActivityPub::ThreadResolveWorker do - subject { described_class.new } - - let(:status) { Fabricate(:status) } - let(:parent) { Fabricate(:status) } - - describe '#perform' do - it 'gets parent from ActivityPub::FetchRemoteStatusService and glues them together' do - allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(double(:service, call: parent)) - subject.perform(status.id, 'http://example.com/123') - expect(status.reload.in_reply_to_id).to eq parent.id - end - end -end From d5acf4275f7033bf2997bfcbbbc3a63c9606f101 Mon Sep 17 00:00:00 2001 From: takayamaki Date: Sun, 20 Aug 2017 20:27:14 +0900 Subject: [PATCH 051/201] Improve about ja translation standalone.public_title (#4641) --- app/javascript/mastodon/locales/ja.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 4c98086bbaa..757190c90ec 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -159,7 +159,7 @@ "report.target": "{target} を通報する", "search.placeholder": "検索", "search_results.total": "{count, number}件の結果", - "standalone.public_title": "連合タイムライン", + "standalone.public_title": "今こんな話をしています", "status.cannot_reblog": "この投稿はブーストできません", "status.delete": "削除", "status.favourite": "お気に入り", From 93d4192a67fde9aaf0c4e420cb5ecb5fe921e97c Mon Sep 17 00:00:00 2001 From: Quent-in Date: Sun, 20 Aug 2017 14:49:12 +0200 Subject: [PATCH 052/201] l10n update OC : Redesign public profiles (#4608) (#4646) New strings added to be shown on the new profile page --- config/locales/oc.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 49b72c5c1cd..9038d887a2e 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -40,7 +40,11 @@ oc: nothing_here: I a pas res aquí ! people_followed_by: Lo mond que %{name} sèc people_who_follow: Lo mond que sègon %{name} - posts: Estatuts + posts: Tuts + posts_with_replies: Tuts amb responsas + media: Mèdias + roles: + admin: Admin remote_follow: Sègre a distància reserved_username: Aqueste nom d’utilizaire es reservat unfollow: Quitar de sègre From fe5b66aa0870212e27a6632fb9c83a2d16bd99ab Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 20 Aug 2017 16:53:47 +0200 Subject: [PATCH 053/201] Handle duplicate ActivityPub activities (#4639) * Handle duplicate ActivityPub activities Only perform side-effects when record processed for the first time * Fast-forward repeat follow requests --- app/lib/activitypub/activity/announce.rb | 4 ++++ app/lib/activitypub/activity/block.rb | 2 +- app/lib/activitypub/activity/follow.rb | 8 +++++++- app/lib/activitypub/activity/like.rb | 4 ++-- app/services/authorize_follow_service.rb | 11 ++++++++--- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 09fec28a080..c4da405c7fd 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -7,6 +7,10 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity return if original_status.nil? || delete_arrived_first?(@json['id']) + status = Status.find_by(account: @account, reblog: original_status) + + return status unless status.nil? + status = Status.create!(account: @account, reblog: original_status, uri: @json['id']) distribute(status) status diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb index e6b6c837ba9..f630d5db2a2 100644 --- a/app/lib/activitypub/activity/block.rb +++ b/app/lib/activitypub/activity/block.rb @@ -4,7 +4,7 @@ class ActivityPub::Activity::Block < ActivityPub::Activity def perform target_account = account_from_uri(object_uri) - return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account) UnfollowService.new.call(target_account, @account) if target_account.following?(@account) @account.block!(target_account) diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 3fb698d1d52..8adbbb9c33c 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -4,7 +4,13 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity def perform target_account = account_from_uri(object_uri) - return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) + return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account) + + # Fast-forward repeat follow requests + if @account.following?(target_account) + AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true) + return + end follow_request = FollowRequest.create!(account: @account, target_account: target_account) diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index c24527597f0..674d5fe4723 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -4,9 +4,9 @@ class ActivityPub::Activity::Like < ActivityPub::Activity def perform original_status = status_from_uri(object_uri) - return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) + return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) - favourite = original_status.favourites.where(account: @account).first_or_create!(account: @account) + favourite = original_status.favourites.create!(account: @account) NotifyService.new.call(original_status.account, favourite) end end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index db35b6030bd..6f036dc5a6c 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true class AuthorizeFollowService < BaseService - def call(source_account, target_account) - follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) - follow_request.authorize! + def call(source_account, target_account, options = {}) + if options[:skip_follow_request] + follow_request = FollowRequest.new(account: source_account, target_account: target_account) + else + follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account) + follow_request.authorize! + end + create_notification(follow_request) unless source_account.local? follow_request end From 23792f5a7cfc0ba2f3f9181f7e9b8aa7647e9cb3 Mon Sep 17 00:00:00 2001 From: abcang Date: Mon, 21 Aug 2017 00:12:06 +0900 Subject: [PATCH 054/201] Fix hasUnread on HashtagTimeline (#4644) --- app/javascript/mastodon/features/hashtag_timeline/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index b17e8e1a5b5..10b5c57e9f3 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -14,8 +14,8 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; import createStream from '../../stream'; -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), accessToken: state.getIn(['meta', 'access_token']), }); From f26758dc019a24cd7e87078e2a19350d0a2d083c Mon Sep 17 00:00:00 2001 From: unarist Date: Mon, 21 Aug 2017 03:45:44 +0900 Subject: [PATCH 055/201] Fix .information-board style for Safari (#4602) flex-basis: 0 allows make flexbox smaller than its contents on Safari <10. https://github.com/philipwalton/flexbugs#1-minimum-content-sizing-of-flex-items-not-honored --- app/javascript/styles/about.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 62143246f62..9477335bef4 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -171,7 +171,7 @@ } .section { - flex: 1 0 0; + flex: 1 0 auto; font: 16px/28px 'mastodon-font-sans-serif', sans-serif; text-align: right; padding: 10px 15px; From 110227ac5e77c2be51ef8be4bca614d357c0eb13 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Mon, 21 Aug 2017 06:23:05 +0900 Subject: [PATCH 056/201] Remove status from favorites list when unfavorited (#4597) --- app/javascript/mastodon/reducers/status_lists.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 580cc17d2b0..2ce27a45459 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -3,7 +3,10 @@ import { FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; -import { FAVOURITE_SUCCESS } from '../actions/interactions'; +import { + FAVOURITE_SUCCESS, + UNFAVOURITE_SUCCESS, +} from '../actions/interactions'; const initialState = ImmutableMap({ favourites: ImmutableMap({ @@ -34,6 +37,12 @@ const prependOneToList = (state, listType, status) => { })); }; +const removeOneFromList = (state, listType, status) => { + return state.update(listType, listMap => listMap.withMutations(map => { + map.set('items', map.get('items').filter(item => item !== status.get('id'))); + })); +}; + export default function statusLists(state = initialState, action) { switch(action.type) { case FAVOURITED_STATUSES_FETCH_SUCCESS: @@ -42,6 +51,8 @@ export default function statusLists(state = initialState, action) { return appendToList(state, 'favourites', action.statuses, action.next); case FAVOURITE_SUCCESS: return prependOneToList(state, 'favourites', action.status); + case UNFAVOURITE_SUCCESS: + return removeOneFromList(state, 'favourites', action.status); default: return state; } From 74e5078795cd5bc8a10e2c22355379ff5ca6d21c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 00:41:08 +0200 Subject: [PATCH 057/201] Fix #4637 - Re-add missing doorkeeper_authorize for /api/v1/verify_credentials (#4650) --- .../api/v1/accounts/credentials_controller.rb | 1 + .../accounts/credentials_controller_spec.rb | 98 ++++++++++++------- 2 files changed, 61 insertions(+), 38 deletions(-) diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 90a580c3362..bea83cd2ab5 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::CredentialsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, except: [:update] before_action -> { doorkeeper_authorize! :write }, only: [:update] before_action :require_user! diff --git a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb index bc89772b969..461b8b34bb6 100644 --- a/spec/controllers/api/v1/accounts/credentials_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/credentials_controller_spec.rb @@ -4,57 +4,79 @@ describe Api::V1::Accounts::CredentialsController do render_views let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') } - before do - allow(controller).to receive(:doorkeeper_token) { token } - end - - describe 'GET #show' do - it 'returns http success' do - get :show - expect(response).to have_http_status(:success) + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } end - end - - describe 'PATCH #update' do - describe 'with valid data' do - before do - allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - - patch :update, params: { - display_name: "Alice Isn't Dead", - note: "Hi!\n\nToot toot!", - avatar: fixture_file_upload('files/avatar.gif', 'image/gif'), - header: fixture_file_upload('files/attachment.jpg', 'image/jpeg'), - } - end + describe 'GET #show' do it 'returns http success' do + get :show expect(response).to have_http_status(:success) end + end - it 'updates account info' do - user.account.reload + describe 'PATCH #update' do + describe 'with valid data' do + before do + allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) - expect(user.account.display_name).to eq("Alice Isn't Dead") - expect(user.account.note).to eq("Hi!\n\nToot toot!") - expect(user.account.avatar).to exist - expect(user.account.header).to exist + patch :update, params: { + display_name: "Alice Isn't Dead", + note: "Hi!\n\nToot toot!", + avatar: fixture_file_upload('files/avatar.gif', 'image/gif'), + header: fixture_file_upload('files/attachment.jpg', 'image/jpeg'), + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates account info' do + user.account.reload + + expect(user.account.display_name).to eq("Alice Isn't Dead") + expect(user.account.note).to eq("Hi!\n\nToot toot!") + expect(user.account.avatar).to exist + expect(user.account.header).to exist + end + + it 'queues up an account update distribution' do + expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + end end - it 'queues up an account update distribution' do - expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(user.account_id) + describe 'with invalid data' do + before do + patch :update, params: { note: 'This is too long. ' * 10 } + end + + it 'returns http unprocessable entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + context 'without an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { nil } + end + + describe 'GET #show' do + it 'returns http unauthorized' do + get :show + expect(response).to have_http_status(:unauthorized) end end - describe 'with invalid data' do - before do - patch :update, params: { note: 'This is too long. ' * 10 } - end - - it 'returns http unprocessable entity' do - expect(response).to have_http_status(:unprocessable_entity) + describe 'PATCH #update' do + it 'returns http unauthorized' do + patch :update, params: { note: 'Foo' } + expect(response).to have_http_status(:unauthorized) end end end From 4c23544714c05258af8feab50da243039ddbefb6 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Mon, 21 Aug 2017 00:57:28 +0200 Subject: [PATCH 058/201] i18n: Minor changes in Polish translation (#4649) * i18n: Minor changes in Polish translation * i18n: pl --- app/javascript/mastodon/locales/pl.json | 10 +++++----- config/locales/pl.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index dfa5c3f9091..af38bbb6c67 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -70,7 +70,7 @@ "emoji_button.nature": "Natura", "emoji_button.objects": "Objekty", "emoji_button.people": "Ludzie", - "emoji_button.search": "Szukaj...", + "emoji_button.search": "Szukaj…", "emoji_button.symbols": "Symbole", "emoji_button.travel": "Podróże i miejsca", "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!", @@ -96,7 +96,7 @@ "lightbox.close": "Zamknij", "lightbox.next": "Następne", "lightbox.previous": "Poprzednie", - "loading_indicator.label": "Ładowanie...", + "loading_indicator.label": "Ładowanie…", "media_gallery.toggle_visible": "Przełącz widoczność", "missing_indicator.label": "Nie znaleziono", "navigation_bar.blocks": "Zablokowani użytkownicy", @@ -116,12 +116,12 @@ "notifications.clear": "Wyczyść powiadomienia", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", "notifications.column_settings.alert": "Powiadomienia na pulpicie", - "notifications.column_settings.favourite": "Ulubione:", + "notifications.column_settings.favourite": "Dodanie do ulubionych:", "notifications.column_settings.follow": "Nowi śledzący:", - "notifications.column_settings.mention": "Wspomniali:", + "notifications.column_settings.mention": "Wspomnienia:", "notifications.column_settings.push": "Powiadomienia push", "notifications.column_settings.push_meta": "To urządzenie", - "notifications.column_settings.reblog": "Podbili:", + "notifications.column_settings.reblog": "Podbicia:", "notifications.column_settings.show": "Pokaż w kolumnie", "notifications.column_settings.sound": "Odtwarzaj dźwięk", "onboarding.done": "Gotowe", diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 97c1f05eda4..c005cdb01db 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -41,7 +41,7 @@ pl: people_followed_by: Konta śledzone przez %{name} people_who_follow: Osoby, które śledzą konto %{name} posts: Wpisy - posts_with_replies: Wpisy + posts_with_replies: Wpisy z odpowiedziami media: Zawartość multimedialna roles: admin: Administrator From 6e9eda53319bc970b085c7c55277981320b2a835 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 01:14:40 +0200 Subject: [PATCH 059/201] ActivityPub migration procedure (#4617) * ActivityPub migration procedure Once one account is detected as going from OStatus to ActivityPub, invalidate WebFinger cache for other accounts from the same domain * Unsubscribe from PuSH updates once we receive an ActivityPub payload * Re-subscribe to PuSH unless already unsubscribed, regardless of protocol --- app/controllers/activitypub/inboxes_controller.rb | 6 ++++++ app/controllers/admin/accounts_controller.rb | 2 +- .../activitypub/process_account_service.rb | 8 +++++++- app/services/unsubscribe_service.rb | 2 +- app/workers/activitypub/post_upgrade_worker.rb | 15 +++++++++++++++ app/workers/pubsubhubbub/unsubscribe_worker.rb | 15 +++++++++++++++ app/workers/scheduler/subscriptions_scheduler.rb | 2 +- lib/tasks/mastodon.rake | 5 +---- 8 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 app/workers/activitypub/post_upgrade_worker.rb create mode 100644 app/workers/pubsubhubbub/unsubscribe_worker.rb diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index 0f49b439995..078494c2074 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -7,6 +7,7 @@ class ActivityPub::InboxesController < Api::BaseController def create if signed_request_account + upgrade_account process_payload head 201 else @@ -24,6 +25,11 @@ class ActivityPub::InboxesController < Api::BaseController @body ||= request.body.read end + def upgrade_account + return unless signed_request_account.subscribed? + Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) + end + def process_payload ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7bceee2cdc3..54c659e1b9f 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -17,7 +17,7 @@ module Admin end def unsubscribe - UnsubscribeService.new.call(@account) + Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 9fb7ebf9ef5..2f2dfd330e4 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -12,7 +12,8 @@ class ActivityPub::ProcessAccountService < BaseService @domain = domain @account = Account.find_by(uri: @uri) - create_account if @account.nil? + create_account if @account.nil? + upgrade_account if @account.ostatus? update_account @account @@ -24,6 +25,7 @@ class ActivityPub::ProcessAccountService < BaseService def create_account @account = Account.new + @account.protocol = :activitypub @account.username = @username @account.domain = @domain @account.uri = @uri @@ -50,6 +52,10 @@ class ActivityPub::ProcessAccountService < BaseService @account.save! end + def upgrade_account + ActivityPub::PostUpgradeWorker.perform_async(@account.domain) + end + def image_url(key) value = first_of_value(@json[key]) diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index c5e0e73feb1..865f783bc84 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -2,7 +2,7 @@ class UnsubscribeService < BaseService def call(account) - return unless account.ostatus? + return if account.hub_url.blank? @account = account @response = build_request.perform diff --git a/app/workers/activitypub/post_upgrade_worker.rb b/app/workers/activitypub/post_upgrade_worker.rb new file mode 100644 index 00000000000..4154b8582b0 --- /dev/null +++ b/app/workers/activitypub/post_upgrade_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ActivityPub::PostUpgradeWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(domain) + Account.where(domain: domain) + .where(protocol: :ostatus) + .where.not(last_webfingered_at: nil) + .in_batches + .update_all(last_webfingered_at: nil) + end +end diff --git a/app/workers/pubsubhubbub/unsubscribe_worker.rb b/app/workers/pubsubhubbub/unsubscribe_worker.rb new file mode 100644 index 00000000000..a271715b7a0 --- /dev/null +++ b/app/workers/pubsubhubbub/unsubscribe_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Pubsubhubbub::UnsubscribeWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push', retry: false, unique: :until_executed, dead: false + + def perform(account_id) + account = Account.find(account_id) + logger.debug "PuSH unsubscribing from #{account.acct}" + ::UnsubscribeService.new.call(account) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb index 5ddfaed1887..35ecda2db35 100644 --- a/app/workers/scheduler/subscriptions_scheduler.rb +++ b/app/workers/scheduler/subscriptions_scheduler.rb @@ -14,6 +14,6 @@ class Scheduler::SubscriptionsScheduler private def expiring_accounts - Account.where(protocol: :ostatus).expiring(1.day.from_now).partitioned + Account.expiring(1.day.from_now).partitioned end end diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake index 2265235548b..870fd7e4e68 100644 --- a/lib/tasks/mastodon.rake +++ b/lib/tasks/mastodon.rake @@ -111,10 +111,7 @@ namespace :mastodon do namespace :push do desc 'Unsubscribes from PuSH updates of feeds nobody follows locally' task clear: :environment do - Account.remote.without_followers.where.not(subscription_expires_at: nil).find_each do |a| - Rails.logger.debug "PuSH unsubscribing from #{a.acct}" - UnsubscribeService.new.call(a) - end + Pubsubhubbub::UnsubscribeWorker.push_bulk(Account.remote.without_followers.where.not(subscription_expires_at: nil).pluck(:id)) end desc 'Re-subscribes to soon expiring PuSH subscriptions (deprecated)' From 10e9a9a3f9969dc5d83238b24f46fa96b28c3c0b Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Mon, 21 Aug 2017 19:42:16 +0900 Subject: [PATCH 060/201] Use URI.join even when S3 enabled (#4652) --- app/helpers/routing_helper.rb | 4 ++- spec/helpers/routing_helper_spec.rb | 43 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 spec/helpers/routing_helper_spec.rb diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 8126176ba56..1fbf77ec310 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -12,6 +12,8 @@ module RoutingHelper end def full_asset_url(source, options = {}) - Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s + source = ActionController::Base.helpers.asset_url(source, options) unless Rails.configuration.x.use_s3 + + URI.join(root_url, source).to_s end end diff --git a/spec/helpers/routing_helper_spec.rb b/spec/helpers/routing_helper_spec.rb new file mode 100644 index 00000000000..940392c9b08 --- /dev/null +++ b/spec/helpers/routing_helper_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RoutingHelper, type: :helper do + describe '.full_asset_url' do + around do |example| + use_s3 = Rails.configuration.x.use_s3 + example.run + Rails.configuration.x.use_s3 = use_s3 + end + + shared_examples 'returns full path URL' do + it 'with host' do + url = helper.full_asset_url('https://example.com/avatars/000/000/002/original/icon.png') + + expect(url).to eq 'https://example.com/avatars/000/000/002/original/icon.png' + end + + it 'without host' do + url = helper.full_asset_url('/avatars/original/missing.png', skip_pipeline: true) + + expect(url).to eq 'http://test.host/avatars/original/missing.png' + end + end + + context 'Do not use S3' do + before do + Rails.configuration.x.use_s3 = false + end + + it_behaves_like 'returns full path URL' + end + + context 'Use S3' do + before do + Rails.configuration.x.use_s3 = true + end + + it_behaves_like 'returns full path URL' + end + end +end From ea958cae7f6c960bdb54214c12de2083ab0e25b0 Mon Sep 17 00:00:00 2001 From: abcang Date: Mon, 21 Aug 2017 22:04:34 +0900 Subject: [PATCH 061/201] Refactoring streaming connections (#4645) --- app/javascript/mastodon/actions/streaming.js | 94 +++++++++++++++++++ .../mastodon/containers/mastodon.js | 72 +------------- .../features/community_timeline/index.js | 50 ++-------- .../features/hashtag_timeline/index.js | 31 +----- .../features/public_timeline/index.js | 50 ++-------- 5 files changed, 116 insertions(+), 181 deletions(-) create mode 100644 app/javascript/mastodon/actions/streaming.js diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js new file mode 100644 index 00000000000..7802694a3c3 --- /dev/null +++ b/app/javascript/mastodon/actions/streaming.js @@ -0,0 +1,94 @@ +import createStream from '../stream'; +import { + updateTimeline, + deleteFromTimelines, + refreshHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, refreshNotifications } from './notifications'; +import { getLocale } from '../locales'; + +const { messages } = getLocale(); + +export function connectTimelineStream (timelineId, path, pollingRefresh = null) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const locale = getState().getIn(['meta', 'locale']); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = createStream(streamingAPIBaseURL, accessToken, path, { + + connected () { + if (pollingRefresh) { + clearPolling(); + } + dispatch(connectTimeline(timelineId)); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + dispatch(disconnectTimeline(timelineId)); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + } + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + dispatch(connectTimeline(timelineId)); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + +function refreshHomeTimelineAndNotification (dispatch) { + dispatch(refreshHomeTimeline()); + dispatch(refreshNotifications()); +} + +export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); +export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); +export const connectPublicStream = () => connectTimelineStream('public', 'public'); +export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index fe534d1c1c3..47180c506bd 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -2,21 +2,13 @@ import React from 'react'; import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; -import { - updateTimeline, - deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, - disconnectTimeline, -} from '../actions/timelines'; import { showOnboardingOnce } from '../actions/onboarding'; -import { updateNotifications, refreshNotifications } from '../actions/notifications'; import BrowserRouter from 'react-router-dom/BrowserRouter'; import Route from 'react-router-dom/Route'; import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; -import createStream from '../stream'; +import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; const { localeData, messages } = getLocale(); @@ -33,56 +25,7 @@ export default class Mastodon extends React.PureComponent { }; componentDidMount() { - const { locale } = this.props; - const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']); - const accessToken = store.getState().getIn(['meta', 'access_token']); - - const setupPolling = () => { - this.polling = setInterval(() => { - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, 20000); - }; - - const clearPolling = () => { - clearInterval(this.polling); - this.polling = undefined; - }; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', { - - connected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - }, - - disconnected () { - setupPolling(); - store.dispatch(disconnectTimeline('home')); - }, - - received (data) { - switch(data.event) { - case 'update': - store.dispatch(updateTimeline('home', JSON.parse(data.payload))); - break; - case 'delete': - store.dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); - break; - } - }, - - reconnected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, - - }); + this.disconnect = store.dispatch(connectUserStream()); // Desktop notifications if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { @@ -98,14 +41,9 @@ export default class Mastodon extends React.PureComponent { } componentWillUnmount () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; - } - - if (typeof this.polling !== 'undefined') { - clearInterval(this.polling); - this.polling = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 0e2300f8ce1..596a89412db 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshCommunityTimeline, expandCommunityTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectCommunityStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent { dispatch: PropTypes.func.isRequired, columnId: PropTypes.string, intl: PropTypes.object.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshCommunityTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { - - connected () { - dispatch(connectTimeline('community')); - }, - - reconnected () { - dispatch(connectTimeline('community')); - }, - - disconnected () { - dispatch(disconnectTimeline('community')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('community', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectCommunityStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index 10b5c57e9f3..5fe21ce90e2 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header'; import { refreshHashtagTimeline, expandHashtagTimeline, - updateTimeline, - deleteFromTimelines, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; -import createStream from '../../stream'; +import { connectHashtagStream } from '../../actions/streaming'; const mapStateToProps = (state, props) => ({ hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent { params: PropTypes.object.isRequired, columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent { } _subscribe (dispatch, id) { - const { streamingAPIBaseURL, accessToken } = this.props; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectHashtagStream(id)); } _unsubscribe () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index c6cad02d6de..193489c6305 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshPublicTimeline, expandPublicTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectPublicStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Federated timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshPublicTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { - - connected () { - dispatch(connectTimeline('public')); - }, - - reconnected () { - dispatch(connectTimeline('public')); - }, - - disconnected () { - dispatch(disconnectTimeline('public')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('public', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectPublicStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } From 3534e115e5127f12292a84442b46ce93643c6d0d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 17:32:41 +0200 Subject: [PATCH 062/201] Do not try to re-subscribe to unsubscribed accounts (#4653) --- app/models/account.rb | 6 +++--- app/services/block_domain_service.rb | 2 +- app/services/subscribe_service.rb | 2 +- spec/models/account_spec.rb | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index c4c168160e0..c3be975fb6f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -91,7 +91,7 @@ class Account < ApplicationRecord scope :local, -> { where(domain: nil) } scope :without_followers, -> { where(followers_count: 0) } scope :with_followers, -> { where('followers_count > 0') } - scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers } + scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) } scope :partitioned, -> { order('row_number() over (partition by domain)') } scope :silenced, -> { where(silenced: true) } scope :suspended, -> { where(suspended: true) } @@ -134,11 +134,11 @@ class Account < ApplicationRecord end def keypair - OpenSSL::PKey::RSA.new(private_key || public_key) + @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end def subscription(webhook_url) - OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url) + @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url) end def save_with_optional_media! diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index a6b3c4cdbfc..1473bc841fc 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -30,7 +30,7 @@ class BlockDomainService < BaseService def suspend_accounts! blocked_domain_accounts.where(suspended: false).find_each do |account| - account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed? + UnsubscribeService.new.call(account) if account.subscribed? SuspendAccountService.new.call(account) end end diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index d3e41e691d2..5617f98f4e8 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -2,7 +2,7 @@ class SubscribeService < BaseService def call(account) - return unless account.ostatus? + return if account.hub_url.blank? @account = account @account.secret = SecureRandom.hex diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index eeaebb77947..aef0c308217 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -642,7 +642,6 @@ RSpec.describe Account, type: :model do it 'returns remote accounts with followers whose subscription expiration date is past or not given' do local = Fabricate(:account, domain: nil) matches = [ - { domain: 'remote', subscription_expires_at: nil }, { domain: 'remote', subscription_expires_at: '2000-01-01T00:00:00Z' }, ].map(&method(:Fabricate).curry(2).call(:account)) matches.each(&local.method(:follow!)) From 4cbb6386049f4037c146ed3cf52c852cc3d8f9d5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 17:59:34 +0200 Subject: [PATCH 063/201] Fix visual line-break glitch with .invisible parts of links (#4655) --- app/javascript/styles/components.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index f66be511189..ef1797e7203 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -237,6 +237,8 @@ line-height: 0; display: inline-block; width: 0; + height: 0; + position: absolute; } .ellipsis { From 143b77e10d984d3790382758c0b797215850b024 Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Tue, 22 Aug 2017 04:59:03 +0900 Subject: [PATCH 064/201] Increase contrast in landing pages (#4567) * Increase contrast in about and about/more page * Lighten em color in landing pages * Increase contrast in landing pages Fix about.scss --- app/javascript/styles/about.scss | 16 ++++++++-------- app/javascript/styles/forms.scss | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 9477335bef4..020842f7dd6 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -139,7 +139,7 @@ font-size: 14px; line-height: 24px; font-weight: 500; - color: $ui-base-lighter-color; + color: $ui-primary-color; padding-bottom: 5px; margin-bottom: 15px; border-bottom: 1px solid lighten($ui-base-color, 4%); @@ -150,7 +150,7 @@ a, span { font-weight: 400; - color: lighten($ui-base-color, 34%); + color: darken($ui-primary-color, 10%); } a { @@ -262,11 +262,11 @@ .text { font-size: 16px; line-height: 30px; - color: $ui-base-lighter-color; + color: $ui-primary-color; h6 { font-weight: 500; - color: $ui-primary-color; + color: $ui-secondary-color; } } } @@ -516,7 +516,7 @@ font: 16px/28px 'mastodon-font-sans-serif', sans-serif; font-weight: 400; margin-bottom: 12px; - color: $ui-base-lighter-color; + color: $ui-primary-color; a { color: $ui-highlight-color; @@ -531,13 +531,13 @@ line-height: 24px; font-weight: 500; margin-bottom: 20px; - color: $ui-primary-color; + color: $ui-secondary-color; } p { font-size: 16px; line-height: 30px; - color: $ui-base-lighter-color; + color: $ui-primary-color; } .features { @@ -623,7 +623,7 @@ font-family: inherit; font-size: inherit; line-height: inherit; - color: $ui-primary-color; + color: lighten($ui-primary-color, 10%); } h1 { diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss index 62094e98ee0..8e41bb00232 100644 --- a/app/javascript/styles/forms.scss +++ b/app/javascript/styles/forms.scss @@ -32,10 +32,10 @@ code { line-height: 18px; margin-top: 15px; margin-bottom: 0; - color: $ui-base-lighter-color; + color: $ui-primary-color; a { - color: $ui-primary-color; + color: $ui-highlight-color; } } } From f391a4673adc6d79bb3a46b493d599a4bf6d558f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 22:56:33 +0200 Subject: [PATCH 065/201] Periodically remove expired PuSH subscribers (#4654) --- app/models/subscription.rb | 1 + app/workers/scheduler/feed_cleanup_scheduler.rb | 2 -- app/workers/scheduler/media_cleanup_scheduler.rb | 1 - .../scheduler/subscriptions_cleanup_scheduler.rb | 11 +++++++++++ app/workers/scheduler/subscriptions_scheduler.rb | 1 - config/sidekiq.yml | 3 +++ 6 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 app/workers/scheduler/subscriptions_cleanup_scheduler.rb diff --git a/app/models/subscription.rb b/app/models/subscription.rb index bf643c1f9ca..14f1a140ced 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -26,6 +26,7 @@ class Subscription < ApplicationRecord scope :confirmed, -> { where(confirmed: true) } scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) } + scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) } scope :active, -> { confirmed.future_expiration } def lease_seconds=(value) diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb index 402eed7c6f7..dbebaa2c38d 100644 --- a/app/workers/scheduler/feed_cleanup_scheduler.rb +++ b/app/workers/scheduler/feed_cleanup_scheduler.rb @@ -5,8 +5,6 @@ class Scheduler::FeedCleanupScheduler include Sidekiq::Worker def perform - logger.info 'Cleaning out home feeds of inactive users' - redis.pipelined do inactive_users.pluck(:account_id).each do |account_id| redis.del(FeedManager.instance.key(:home, account_id)) diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb index a95f512be58..ce32ce3147f 100644 --- a/app/workers/scheduler/media_cleanup_scheduler.rb +++ b/app/workers/scheduler/media_cleanup_scheduler.rb @@ -5,7 +5,6 @@ class Scheduler::MediaCleanupScheduler include Sidekiq::Worker def perform - logger.info 'Cleaning out unattached media attachments' unattached_media.find_each(&:destroy) end diff --git a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb new file mode 100644 index 00000000000..3b9211e8138 --- /dev/null +++ b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'sidekiq-scheduler' + +class Scheduler::SubscriptionsCleanupScheduler + include Sidekiq::Worker + + def perform + Subscription.expired.in_batches.delete_all + end +end diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb index 35ecda2db35..469a3d2a62d 100644 --- a/app/workers/scheduler/subscriptions_scheduler.rb +++ b/app/workers/scheduler/subscriptions_scheduler.rb @@ -7,7 +7,6 @@ class Scheduler::SubscriptionsScheduler include Sidekiq::Worker def perform - logger.info 'Queueing PuSH re-subscriptions' Pubsubhubbub::SubscribeWorker.push_bulk(expiring_accounts.pluck(:id)) end diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 8273c120192..a502f55933b 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -21,3 +21,6 @@ user_cleanup_scheduler: cron: '4 5 * * *' class: Scheduler::UserCleanupScheduler + subscriptions_cleanup_scheduler: + cron: '2 2 * * 0' + class: Scheduler::SubscriptionsCleanupScheduler From 72bb3e03fdf4d8c886d41f3459000b336a3a362b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 21 Aug 2017 22:57:34 +0200 Subject: [PATCH 066/201] Support more variations of ActivityPub keyId in signature (#4630) - Tries to avoid performing HTTP request if the keyId is an actor URI - Likewise if the URI is a fragment URI on top of actor URI - Resolves public key, returns owner if the owner links back to the key --- .../concerns/signature_verification.rb | 4 +- app/helpers/jsonld_helper.rb | 6 ++- app/lib/activitypub/activity.rb | 2 +- app/lib/activitypub/activity/accept.rb | 2 +- app/lib/activitypub/activity/reject.rb | 2 +- app/lib/activitypub/activity/undo.rb | 2 +- app/lib/activitypub/tag_manager.rb | 2 +- .../activitypub/fetch_remote_key_service.rb | 47 +++++++++++++++++++ 8 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 app/services/activitypub/fetch_remote_key_service.rb diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index aeb8da879e7..4211283ed73 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -98,7 +98,9 @@ module SignatureVerification if key_id.start_with?('acct:') ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) elsif !ActivityPub::TagManager.instance.local_uri?(key_id) - ActivityPub::FetchRemoteAccountService.new.call(key_id) + account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account end end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index c750a703814..d8b3ddf18c5 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -9,6 +9,10 @@ module JsonLdHelper value.is_a?(Array) ? value.first : value end + def value_or_id(value) + value.is_a?(String) ? value : value['id'] + end + def supported_context?(json) equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end @@ -20,7 +24,7 @@ module JsonLdHelper end def body_to_json(body) - body.nil? ? nil : Oj.load(body, mode: :strict) + body.is_a?(String) ? Oj.load(body, mode: :strict) : body rescue Oj::ParseError nil end diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index f8de8060c4e..14e3ca784b6 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -58,7 +58,7 @@ class ActivityPub::Activity end def object_uri - @object_uri ||= @object.is_a?(String) ? @object : @object['id'] + @object_uri ||= value_or_id(@object) end def redis diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb index 44c432ae743..bd90c901944 100644 --- a/app/lib/activitypub/activity/accept.rb +++ b/app/lib/activitypub/activity/accept.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity end def target_uri - @target_uri ||= @object['actor'] + @target_uri ||= value_or_id(@object['actor']) end end diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb index 6a234994ef2..d815feeb6c0 100644 --- a/app/lib/activitypub/activity/reject.rb +++ b/app/lib/activitypub/activity/reject.rb @@ -20,6 +20,6 @@ class ActivityPub::Activity::Reject < ActivityPub::Activity end def target_uri - @target_uri ||= @object['actor'] + @target_uri ||= value_or_id(@object['actor']) end end diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb index 078e97ed491..097b1dba481 100644 --- a/app/lib/activitypub/activity/undo.rb +++ b/app/lib/activitypub/activity/undo.rb @@ -64,6 +64,6 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity end def target_uri - @target_uri ||= @object['object'].is_a?(String) ? @object['object'] : @object['object']['id'] + @target_uri ||= value_or_id(@object['object']) end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 855881612e4..3c16006cb16 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -93,7 +93,7 @@ class ActivityPub::TagManager elsif ::TagManager.instance.local_id?(uri) klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s)) else - klass.find_by(uri: uri) + klass.find_by(uri: uri.split('#').first) end end end diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb new file mode 100644 index 00000000000..ebd64071e41 --- /dev/null +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemoteKeyService < BaseService + include JsonLdHelper + + # Returns account that owns the key + def call(uri, prefetched_json = nil) + @json = body_to_json(prefetched_json) || fetch_resource(uri) + + return unless supported_context?(@json) && expected_type? + return find_account(uri, @json) if person? + + @owner = fetch_resource(owner_uri) + + return unless supported_context?(@owner) && confirmed_owner? + + find_account(owner_uri, @owner) + end + + private + + def find_account(uri, prefetched_json) + account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json) + account + end + + def expected_type? + person? || public_key? + end + + def person? + @json['type'] == 'Person' + end + + def public_key? + @json['publicKeyPem'].present? && @json['owner'].present? + end + + def owner_uri + @owner_uri ||= value_or_id(@json['owner']) + end + + def confirmed_owner? + @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id'] + end +end From d63de55ef84eea883b72a121d680b8841af8e2c0 Mon Sep 17 00:00:00 2001 From: unarist Date: Wed, 23 Aug 2017 01:30:15 +0900 Subject: [PATCH 067/201] Fix bugs which OStatus accounts may detected as ActivityPub ready (#4662) * Fallback to OStatus in FetchAtomService * Skip activity+json link if that activity is Person without inbox * If unsupported activity was detected and all other URLs failed, retry with ActivityPub-less Accept header * Allow mention to OStatus account in ActivityPub * Don't update profile with inbox-less Person object --- app/lib/activitypub/activity/create.rb | 2 +- .../activitypub/process_account_service.rb | 2 + app/services/fetch_atom_service.rb | 60 ++++++++++++------- .../fetch_remote_account_service_spec.rb | 27 +++++++++ 4 files changed, 67 insertions(+), 24 deletions(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 1541257590d..5c59c4b2496 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -68,7 +68,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_mention(tag, status) account = account_from_uri(tag['href']) - account = ActivityPub::FetchRemoteAccountService.new.call(tag['href']) if account.nil? + account = FetchRemoteAccountService.new.call(tag['href']) if account.nil? return if account.nil? account.mentions.create(status: status) end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 2f2dfd330e4..99f9dbdc25a 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -6,6 +6,8 @@ class ActivityPub::ProcessAccountService < BaseService # Should be called with confirmed valid JSON # and WebFinger-resolved username and domain def call(username, domain, json) + return unless json['inbox'].present? + @json = json @uri = @json['id'] @username = username diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index c6a4dc2e98c..3cf39e006c1 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class FetchAtomService < BaseService + include JsonLdHelper + def call(url) return if url.blank? - @url = url + result = process(url) - perform_request - process_response + # retry without ActivityPub + result ||= process(url) if @unsupported_activity + + result rescue OpenSSL::SSL::SSLError => e Rails.logger.debug "SSL error: #{e}" nil @@ -18,9 +22,18 @@ class FetchAtomService < BaseService private + def process(url, terminal = false) + @url = url + perform_request + process_response(terminal) + end + def perform_request + accept = 'text/html' + accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity + @response = Request.new(:get, @url) - .add_headers('Accept' => 'application/activity+json, application/ld+json, application/atom+xml, text/html') + .add_headers('Accept' => accept) .perform end @@ -30,7 +43,12 @@ class FetchAtomService < BaseService if @response.mime_type == 'application/atom+xml' [@url, @response.to_s, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - [@url, @response.to_s, :activitypub] + if supported_activity?(@response.to_s) + [@url, @response.to_s, :activitypub] + else + @unsupported_activity = true + nil + end elsif @response['Link'] && !terminal process_headers elsif @response.mime_type == 'text/html' && !terminal @@ -44,15 +62,10 @@ class FetchAtomService < BaseService json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } - if !json_link.nil? - @url = json_link['href'] - perform_request - process_response(true) - elsif !atom_link.nil? - @url = atom_link['href'] - perform_request - process_response(true) - end + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? + + result end def process_headers @@ -61,14 +74,15 @@ class FetchAtomService < BaseService json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"']) atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml)) - if !json_link.nil? - @url = json_link.href - perform_request - process_response(true) - elsif !atom_link.nil? - @url = atom_link.href - perform_request - process_response(true) - end + result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link.href, terminal: true) unless atom_link.nil? + + result + end + + def supported_activity?(body) + json = body_to_json(body) + return false if json.nil? || !supported_context?(json) + json['type'] == 'Person' ? json['inbox'].present? : true end end diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb index 786d7f7f2b0..391d051c1a1 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -11,6 +11,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do preferredUsername: 'alice', name: 'Alice', summary: 'Foo bar', + inbox: 'http://example.com/alice/inbox', } end @@ -35,6 +36,32 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do end end + context 'when the account does not have a inbox' do + let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } + + before do + actor[:inbox] = nil + + stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor)) + stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' }) + end + + it 'fetches resource' do + account + expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once + end + + it 'looks up webfinger' do + account + expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once + end + + it 'returns nil' do + expect(account).to be_nil + end + + end + context 'when URI and WebFinger share the same host' do let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } } From 11a7507318ff9bffbed9e4423ef86ada8c43a992 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Wed, 23 Aug 2017 01:31:42 +0900 Subject: [PATCH 068/201] Add delete account link for French (#4659) --- config/locales/fr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 38be6dce88b..a7c5bf14425 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -217,7 +217,7 @@ fr: agreement_html: En vous inscrivant, vous souscrivez à nos conditions d’utilisation ainsi qu’à notre politique de confidentialité. change_password: Sécurité delete_account: Supprimer le compte - delete_account_html: Si vous désirez supprimer votre compte, vous pouvez cliquer ici. Il vous sera demandé de confirmer cette action. + delete_account_html: Si vous désirez supprimer votre compte, vous pouvez cliquer ici. Il vous sera demandé de confirmer cette action. didnt_get_confirmation: Vous n’avez pas reçu les consignes de confirmation ? forgot_password: Mot de passe oublié ? invalid_reset_password_token: Le lien de réinitialisation du mot de passe est invalide ou a expiré. Merci de réessayer. From 871c0d251a6d27c4591785ae446738a8d6c553ab Mon Sep 17 00:00:00 2001 From: Colin Mitchell Date: Tue, 22 Aug 2017 12:33:57 -0400 Subject: [PATCH 069/201] Application prefs section (#2758) * Add code for creating/managing apps to settings section * Add specs for app changes * Fix controller spec * Fix view file I pasted over by mistake * Add locale strings. Add 'my apps' to nav * Add Client ID/Secret to App page. Add some visual separation * Fix rubocop warnings * Fix embarrassing typo I lost an `end` statement while fixing a merge conflict. * Add code for creating/managing apps to settings section - Add specs for app changes - Add locale strings. Add 'my apps' to nav - Add Client ID/Secret to App page. Add some visual separation - Fix some bugs/warnings * Update to match code standards * Trigger notification * Add warning about not sharing API secrets * Tweak spec a bit * Cleanup fixture creation by using let! * Remove unused key * Add foreign key for application<->user --- .../settings/applications_controller.rb | 65 +++++++ app/models/user.rb | 13 ++ .../settings/applications/_fields.html.haml | 4 + .../settings/applications/index.html.haml | 20 +++ app/views/settings/applications/new.html.haml | 9 + .../settings/applications/show.html.haml | 28 +++ config/initializers/doorkeeper.rb | 2 +- config/locales/doorkeeper.en.yml | 7 +- config/locales/en.yml | 11 ++ config/navigation.rb | 1 + config/routes.rb | 5 + ...70427011934_re_add_owner_to_application.rb | 8 + db/schema.rb | 7 +- .../settings/applications_controller_spec.rb | 166 ++++++++++++++++++ spec/models/user_spec.rb | 20 +++ 15 files changed, 362 insertions(+), 4 deletions(-) create mode 100644 app/controllers/settings/applications_controller.rb create mode 100644 app/views/settings/applications/_fields.html.haml create mode 100644 app/views/settings/applications/index.html.haml create mode 100644 app/views/settings/applications/new.html.haml create mode 100644 app/views/settings/applications/show.html.haml create mode 100644 db/migrate/20170427011934_re_add_owner_to_application.rb create mode 100644 spec/controllers/settings/applications_controller_spec.rb diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb new file mode 100644 index 00000000000..b8f114455db --- /dev/null +++ b/app/controllers/settings/applications_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Settings::ApplicationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def index + @applications = current_user.applications.page(params[:page]) + end + + def new + @application = Doorkeeper::Application.new( + redirect_uri: Doorkeeper.configuration.native_redirect_uri, + scopes: 'read write follow' + ) + end + + def show + @application = current_user.applications.find(params[:id]) + end + + def create + @application = current_user.applications.build(application_params) + if @application.save + redirect_to settings_applications_path, notice: I18n.t('application.created') + else + render :new + end + end + + def update + @application = current_user.applications.find(params[:id]) + if @application.update_attributes(application_params) + redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + def destroy + @application = current_user.applications.find(params[:id]) + @application.destroy + redirect_to settings_applications_path, notice: t('application.destroyed') + end + + def regenerate + @application = current_user.applications.find(params[:application_id]) + @access_token = current_user.token_for_app(@application) + @access_token.destroy + + redirect_to settings_application_path(@application), notice: t('access_token.regenerated') + end + + private + + def application_params + params.require(:doorkeeper_application).permit( + :name, + :redirect_uri, + :scopes, + :website + ) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 96a2d09b778..02b1b26ee2b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -46,6 +46,8 @@ class User < ApplicationRecord belongs_to :account, inverse_of: :user, required: true accepts_nested_attributes_for :account + has_many :applications, class_name: 'Doorkeeper::Application', as: :owner + validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates_with BlacklistedEmailValidator, if: :email_changed? @@ -108,6 +110,17 @@ class User < ApplicationRecord settings.noindex end + def token_for_app(a) + return nil if a.nil? || a.owner != self + Doorkeeper::AccessToken + .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t| + + t.scopes = a.scopes + t.expires_in = Doorkeeper.configuration.access_token_expires_in + t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled? + end + end + def activate_session(request) session_activations.activate(session_id: SecureRandom.hex, user_agent: request.user_agent, diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml new file mode 100644 index 00000000000..9dbe23466a4 --- /dev/null +++ b/app/views/settings/applications/_fields.html.haml @@ -0,0 +1,4 @@ += f.input :name, hint: t('activerecord.attributes.doorkeeper/application.name') += f.input :website, hint: t('activerecord.attributes.doorkeeper/application.website') += f.input :redirect_uri, hint: t('activerecord.attributes.doorkeeper/application.redirect_uri') += f.input :scopes, hint: t('activerecord.attributes.doorkeeper/application.scopes') diff --git a/app/views/settings/applications/index.html.haml b/app/views/settings/applications/index.html.haml new file mode 100644 index 00000000000..17035f96cf1 --- /dev/null +++ b/app/views/settings/applications/index.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('doorkeeper.applications.index.title') + +%table.table + %thead + %tr + %th= t('doorkeeper.applications.index.application') + %th= t('doorkeeper.applications.index.scopes') + %th= t('doorkeeper.applications.index.created_at') + %th + %tbody + - @applications.each do |application| + %tr + %td= link_to application.name, settings_application_path(application) + %th= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('
').html_safe + %td= l application.created_at + %td= table_link_to 'show', t('doorkeeper.applications.index.show'), settings_application_path(application) + %td= table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') } += paginate @applications += link_to t('add_new'), new_settings_application_path, class: 'button' diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml new file mode 100644 index 00000000000..61406a31f58 --- /dev/null +++ b/app/views/settings/applications/new.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('doorkeeper.applications.new.title') + +.form-container + = simple_form_for @application, url: settings_applications_path do |f| + = render 'fields', f:f + + .actions + = f.button :button, t('.create'), type: :submit diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml new file mode 100644 index 00000000000..9f1a119861e --- /dev/null +++ b/app/views/settings/applications/show.html.haml @@ -0,0 +1,28 @@ +- content_for :page_title do + = t('doorkeeper.applications.show.title', name: @application.name) + + +%p.hint= t('application.warning') + +%div + %h3= t('application.uid') + %code= @application.uid + +%div + %h3= t('application.secret') + %code= @application.secret + +%div + %h3= t('access_token.your_token') + %code= current_user.token_for_app(@application).token + += link_to t('access_token.regenerate'), settings_application_regenerate_path(@application), method: :put, class: 'button' + +%hr + += simple_form_for @application, url: settings_application_path(@application), method: :put do |f| + = render 'fields', f:f + + .actions + = f.button :button, t('generic.save_changes'), type: :submit + diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 056a3651a69..689e2ac4a5b 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -50,7 +50,7 @@ Doorkeeper.configure do # Optional parameter :confirmation => true (default false) if you want to enforce ownership of # a registered application # Note: you must also run the rails g doorkeeper:application_owner generator to provide the necessary support - # enable_application_owner :confirmation => true + enable_application_owner # Define access token scopes for your provider # For more information go to diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 6412b8b48ba..fa0a7babf1c 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -3,8 +3,10 @@ en: activerecord: attributes: doorkeeper/application: - name: Name + name: Application Name + website: Application Website redirect_uri: Redirect URI + scopes: Scopes errors: models: doorkeeper/application: @@ -37,9 +39,12 @@ en: name: Name new: New Application title: Your applications + show: Show + delete: Delete new: title: New Application show: + title: 'Application: %{name}' actions: Actions application_id: Application Id callback_urls: Callback urls diff --git a/config/locales/en.yml b/config/locales/en.yml index 97f46c3af02..fbcef03bde4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -33,6 +33,10 @@ en: user_count_after: users user_count_before: Home to what_is_mastodon: What is Mastodon? + access_token: + your_token: Your Access Token + regenerate: Regenerate Access Token + regenerated: Access Token Regenerated accounts: follow: Follow followers: Followers @@ -226,6 +230,12 @@ en: settings: 'Change e-mail preferences: %{link}' signature: Mastodon notifications from %{instance} view: 'View:' + application: + created: Application Created + destroyed: Application Destroyed + uid: Client ID + secret: Client Secret + warning: Be very careful with this data. Never share it with anyone other than authorized applications! applications: invalid_url: The provided URL is invalid auth: @@ -423,6 +433,7 @@ en: preferences: Preferences settings: Settings two_factor_authentication: Two-factor Authentication + your_apps: Your applications statuses: open_in_web: Open in web over_character_limit: character limit of %{max} exceeded diff --git a/config/navigation.rb b/config/navigation.rb index 535d033f505..6e04843ecaf 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -12,6 +12,7 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url + settings.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url end diff --git a/config/routes.rb b/config/routes.rb index 1a39dfeac7c..e8bc968f4ed 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -79,6 +79,11 @@ Rails.application.routes.draw do end resource :follower_domains, only: [:show, :update] + + resources :applications do + put :regenerate + end + resource :delete, only: [:show, :destroy] resources :sessions, only: [:destroy] diff --git a/db/migrate/20170427011934_re_add_owner_to_application.rb b/db/migrate/20170427011934_re_add_owner_to_application.rb new file mode 100644 index 00000000000..a41d71d2a13 --- /dev/null +++ b/db/migrate/20170427011934_re_add_owner_to_application.rb @@ -0,0 +1,8 @@ +class ReAddOwnerToApplication < ActiveRecord::Migration[5.0] + def change + add_column :oauth_applications, :owner_id, :integer, null: true + add_column :oauth_applications, :owner_type, :string, null: true + add_index :oauth_applications, [:owner_id, :owner_type] + add_foreign_key :oauth_applications, :users, column: :owner_id, on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index 2501e451d0d..929a5fd01bb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -216,8 +216,11 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.string "scopes", default: "", null: false t.datetime "created_at" t.datetime "updated_at" - t.boolean "superapp", default: false, null: false - t.string "website" + t.boolean "superapp", default: false, null: false + t.string "website" + t.integer "owner_id" + t.string "owner_type" + t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb new file mode 100644 index 00000000000..fa27e6ec6b8 --- /dev/null +++ b/spec/controllers/settings/applications_controller_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +describe Settings::ApplicationsController do + render_views + + let!(:user) { Fabricate(:user) } + let!(:app) { Fabricate(:application, owner: user) } + + before do + sign_in user, scope: :user + end + + describe 'GET #index' do + let!(:other_app) { Fabricate(:application) } + + it 'shows apps' do + get :index + expect(response).to have_http_status(:success) + expect(assigns(:applications)).to include(app) + expect(assigns(:applications)).to_not include(other_app) + end + end + + + describe 'GET #show' do + it 'returns http success' do + get :show, params: { id: app.id } + expect(response).to have_http_status(:success) + expect(assigns[:application]).to eql(app) + end + + it 'returns 404 if you dont own app' do + app.update!(owner: nil) + + get :show, params: { id: app.id } + expect(response.status).to eq 404 + end + end + + describe 'GET #new' do + it 'works' do + get :new + expect(response).to have_http_status(:success) + end + end + + describe 'POST #create' do + context 'success' do + def call_create + post :create, params: { + doorkeeper_application: { + name: 'My New App', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + website: 'http://google.com', + scopes: 'read write follow' + } + } + response + end + + it 'creates an entry in the database' do + expect { call_create }.to change(Doorkeeper::Application, :count) + end + + it 'redirects back to applications page' do + expect(call_create).to redirect_to(settings_applications_path) + end + end + + context 'failure' do + before do + post :create, params: { + doorkeeper_application: { + name: '', + redirect_uri: '', + website: '', + scopes: '' + } + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'renders form again' do + expect(response).to render_template(:new) + end + end + end + + describe 'PATCH #update' do + context 'success' do + let(:opts) { + { + website: 'https://foo.bar/' + } + } + + def call_update + patch :update, params: { + id: app.id, + doorkeeper_application: opts + } + response + end + + it 'updates existing application' do + call_update + expect(app.reload.website).to eql(opts[:website]) + end + + it 'redirects back to applications page' do + expect(call_update).to redirect_to(settings_applications_path) + end + end + + context 'failure' do + before do + patch :update, params: { + id: app.id, + doorkeeper_application: { + name: '', + redirect_uri: '', + website: '', + scopes: '' + } + } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'renders form again' do + expect(response).to render_template(:show) + end + end + end + + describe 'destroy' do + before do + post :destroy, params: { id: app.id } + end + + it 'redirects back to applications page' do + expect(response).to redirect_to(settings_applications_path) + end + + it 'removes the app' do + expect(Doorkeeper::Application.find_by(id: app.id)).to be_nil + end + end + + describe 'regenerate' do + let(:token) { user.token_for_app(app) } + before do + expect(token).to_not be_nil + put :regenerate, params: { application_id: app.id } + end + + it 'should create new token' do + expect(user.token_for_app(app)).to_not eql(token) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index ef45818b979..99aeca01b27 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -286,4 +286,24 @@ RSpec.describe User, type: :model do Fabricate(:user) end end + + describe 'token_for_app' do + let(:user) { Fabricate(:user) } + let(:app) { Fabricate(:application, owner: user) } + + it 'returns a token' do + expect(user.token_for_app(app)).to be_a(Doorkeeper::AccessToken) + end + + it 'persists a token' do + t = user.token_for_app(app) + expect(user.token_for_app(app)).to eql(t) + end + + it 'is nil if user does not own app' do + app.update!(owner: nil) + + expect(user.token_for_app(app)).to be_nil + end + end end From 5927b43c0fc74e66cd3a882b565ea70236559c02 Mon Sep 17 00:00:00 2001 From: unarist Date: Wed, 23 Aug 2017 03:00:49 +0900 Subject: [PATCH 070/201] Ignore empty response in ActivityPub::FetchRemoteStatusService (#4661) * Ignore empty response in ActivityPub::FetchRemoteStatusService This fixes `NoMethodError: undefined method `[]' for nil:NilClass` error. * Check json.nil? in JsonLdHelper#supported_context? --- app/helpers/jsonld_helper.rb | 2 +- app/services/fetch_atom_service.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index d8b3ddf18c5..8355eb05567 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -14,7 +14,7 @@ module JsonLdHelper end def supported_context?(json) - equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end def fetch_resource(uri) diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 3cf39e006c1..afda50ae4f9 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -82,7 +82,7 @@ class FetchAtomService < BaseService def supported_activity?(body) json = body_to_json(body) - return false if json.nil? || !supported_context?(json) + return false unless supported_context?(json) json['type'] == 'Person' ? json['inbox'].present? : true end end From 696c2c6f2f3338df121cf17389478da9ecab11af Mon Sep 17 00:00:00 2001 From: Daigo 3 Dango Date: Tue, 22 Aug 2017 20:54:19 +0000 Subject: [PATCH 071/201] Add Mastodon::Source.url (#4643) * Add Mastodon::Source.url * Update spec * Refactor Move things frmo Mastodon::Source to Mastodon::Version --- app/presenters/instance_presenter.rb | 4 ++++ app/views/about/more.html.haml | 4 ++-- app/views/about/show.html.haml | 4 ++-- lib/mastodon/version.rb | 17 +++++++++++++++++ spec/views/about/show.html.haml_spec.rb | 1 + 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 5d5be58bae0..8104b753162 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -31,4 +31,8 @@ class InstancePresenter def version_number Mastodon::Version end + + def source_url + Mastodon::Version.source_url + end end diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index a6fd265fabc..094188472e0 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -63,5 +63,5 @@ .footer-links .container %p - = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' - = " (#{@instance_presenter.version_number})" \ No newline at end of file + = link_to t('about.source_code'), @instance_presenter.source_url + = " (#{@instance_presenter.version_number})" diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index acdb12ad72c..93270fe3d66 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -76,5 +76,5 @@ .footer-links .container %p - = link_to t('about.source_code'), 'https://github.com/tootsuite/mastodon' - = " (#{@instance_presenter.version_number})" \ No newline at end of file + = link_to t('about.source_code'), @instance_presenter.source_url + = " (#{@instance_presenter.version_number})" diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index 381e9aac92d..fcca875d9bc 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -31,5 +31,22 @@ module Mastodon def to_s [to_a.join('.'), flags].join end + + def source_base_url + 'https://github.com/tootsuite/mastodon' + end + + # specify git tag or commit hash here + def source_tag + nil + end + + def source_url + if source_tag + "#{source_base_url}/tree/#{source_tag}" + else + source_base_url + end + end end end diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index c0ead634912..aa151dd2778 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -13,6 +13,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do site_title: 'something', site_description: 'something', version_number: '1.0', + source_url: 'https://github.com/tootsuite/mastodon', open_registrations: false, closed_registrations_message: 'yes') assign(:instance_presenter, instance_presenter) From c1b086a538d128e9fbceab4fc6686611a4f2710f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 23 Aug 2017 00:59:35 +0200 Subject: [PATCH 072/201] Fix up the applications area (#4664) - Section it into "Development" area - Improve UI of application form, index, and details --- .../settings/applications_controller.rb | 21 +++++------ .../settings/applications/_fields.html.haml | 15 +++++--- .../settings/applications/index.html.haml | 11 +++--- app/views/settings/applications/new.html.haml | 11 +++--- .../settings/applications/show.html.haml | 36 ++++++++++--------- config/locales/doorkeeper.en.yml | 21 ++++++----- config/locales/en.yml | 23 ++++++------ config/locales/ja.yml | 6 ++-- config/locales/oc.yml | 16 ++++----- config/locales/pl.yml | 8 ++--- config/navigation.rb | 5 ++- config/routes.rb | 6 ++-- db/schema.rb | 11 +++--- .../settings/applications_controller_spec.rb | 2 +- 14 files changed, 101 insertions(+), 91 deletions(-) diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index b8f114455db..894222c2af4 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -4,6 +4,7 @@ class Settings::ApplicationsController < ApplicationController layout 'admin' before_action :authenticate_user! + before_action :set_application, only: [:show, :update, :destroy, :regenerate] def index @applications = current_user.applications.page(params[:page]) @@ -16,22 +17,20 @@ class Settings::ApplicationsController < ApplicationController ) end - def show - @application = current_user.applications.find(params[:id]) - end + def show; end def create @application = current_user.applications.build(application_params) + if @application.save - redirect_to settings_applications_path, notice: I18n.t('application.created') + redirect_to settings_applications_path, notice: I18n.t('applications.created') else render :new end end def update - @application = current_user.applications.find(params[:id]) - if @application.update_attributes(application_params) + if @application.update(application_params) redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg') else render :show @@ -39,21 +38,23 @@ class Settings::ApplicationsController < ApplicationController end def destroy - @application = current_user.applications.find(params[:id]) @application.destroy - redirect_to settings_applications_path, notice: t('application.destroyed') + redirect_to settings_applications_path, notice: I18n.t('applications.destroyed') end def regenerate - @application = current_user.applications.find(params[:application_id]) @access_token = current_user.token_for_app(@application) @access_token.destroy - redirect_to settings_application_path(@application), notice: t('access_token.regenerated') + redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated') end private + def set_application + @application = current_user.applications.find(params[:id]) + end + def application_params params.require(:doorkeeper_application).permit( :name, diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml index 9dbe23466a4..536f69e0479 100644 --- a/app/views/settings/applications/_fields.html.haml +++ b/app/views/settings/applications/_fields.html.haml @@ -1,4 +1,11 @@ -= f.input :name, hint: t('activerecord.attributes.doorkeeper/application.name') -= f.input :website, hint: t('activerecord.attributes.doorkeeper/application.website') -= f.input :redirect_uri, hint: t('activerecord.attributes.doorkeeper/application.redirect_uri') -= f.input :scopes, hint: t('activerecord.attributes.doorkeeper/application.scopes') +.fields-group + = f.input :name, placeholder: t('activerecord.attributes.doorkeeper/application.name') + = f.input :website, placeholder: t('activerecord.attributes.doorkeeper/application.website') + +.fields-group + = f.input :redirect_uri, wrapper: :with_block_label, label: t('activerecord.attributes.doorkeeper/application.redirect_uri'), hint: t('doorkeeper.applications.help.redirect_uri') + + %p.hint= t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: Doorkeeper.configuration.native_redirect_uri) + +.fields-group + = f.input :scopes, wrapper: :with_label, label: t('activerecord.attributes.doorkeeper/application.scopes'), hint: t('doorkeeper.applications.help.scopes') diff --git a/app/views/settings/applications/index.html.haml b/app/views/settings/applications/index.html.haml index 17035f96cf1..eea550388a9 100644 --- a/app/views/settings/applications/index.html.haml +++ b/app/views/settings/applications/index.html.haml @@ -6,15 +6,14 @@ %tr %th= t('doorkeeper.applications.index.application') %th= t('doorkeeper.applications.index.scopes') - %th= t('doorkeeper.applications.index.created_at') %th %tbody - @applications.each do |application| %tr %td= link_to application.name, settings_application_path(application) - %th= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join('
').html_safe - %td= l application.created_at - %td= table_link_to 'show', t('doorkeeper.applications.index.show'), settings_application_path(application) - %td= table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') } + %th= application.scopes + %td + = table_link_to 'times', t('doorkeeper.applications.index.delete'), settings_application_path(application), method: :delete, data: { confirm: t('doorkeeper.applications.confirmations.destroy') } + = paginate @applications -= link_to t('add_new'), new_settings_application_path, class: 'button' += link_to t('doorkeeper.applications.index.new'), new_settings_application_path, class: 'button' diff --git a/app/views/settings/applications/new.html.haml b/app/views/settings/applications/new.html.haml index 61406a31f58..5274a430c73 100644 --- a/app/views/settings/applications/new.html.haml +++ b/app/views/settings/applications/new.html.haml @@ -1,9 +1,8 @@ - content_for :page_title do = t('doorkeeper.applications.new.title') + += simple_form_for @application, url: settings_applications_path do |f| + = render 'fields', f: f -.form-container - = simple_form_for @application, url: settings_applications_path do |f| - = render 'fields', f:f - - .actions - = f.button :button, t('.create'), type: :submit + .actions + = f.button :button, t('doorkeeper.applications.buttons.submit'), type: :submit diff --git a/app/views/settings/applications/show.html.haml b/app/views/settings/applications/show.html.haml index 9f1a119861e..4d855511116 100644 --- a/app/views/settings/applications/show.html.haml +++ b/app/views/settings/applications/show.html.haml @@ -1,27 +1,29 @@ - content_for :page_title do = t('doorkeeper.applications.show.title', name: @application.name) +%p.hint= t('applications.warning') -%p.hint= t('application.warning') - -%div - %h3= t('application.uid') - %code= @application.uid +%table.table + %tbody + %tr + %th= t('doorkeeper.applications.show.application_id') + %td + %code= @application.uid + %tr + %th= t('doorkeeper.applications.show.secret') + %td + %code= @application.secret + %tr + %th{ rowspan: 2}= t('applications.your_token') + %td + %code= current_user.token_for_app(@application).token + %tr + %td= table_link_to 'refresh', t('applications.regenerate_token'), regenerate_settings_application_path(@application), method: :post -%div - %h3= t('application.secret') - %code= @application.secret - -%div - %h3= t('access_token.your_token') - %code= current_user.token_for_app(@application).token - -= link_to t('access_token.regenerate'), settings_application_regenerate_path(@application), method: :put, class: 'button' - -%hr +%hr/ = simple_form_for @application, url: settings_application_path(@application), method: :put do |f| - = render 'fields', f:f + = render 'fields', f: f .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index fa0a7babf1c..788d1bb406d 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -3,10 +3,10 @@ en: activerecord: attributes: doorkeeper/application: - name: Application Name - website: Application Website + name: Application name redirect_uri: Redirect URI scopes: Scopes + website: Application website errors: models: doorkeeper/application: @@ -36,20 +36,19 @@ en: scopes: Separate scopes with spaces. Leave blank to use the default scopes. index: callback_url: Callback URL - name: Name - new: New Application - title: Your applications - show: Show delete: Delete + name: Name + new: New application + show: Show + title: Your applications new: - title: New Application + title: New application show: - title: 'Application: %{name}' actions: Actions - application_id: Application Id - callback_urls: Callback urls + application_id: Client key + callback_urls: Callback URLs scopes: Scopes - secret: Secret + secret: Client secret title: 'Application: %{name}' authorizations: buttons: diff --git a/config/locales/en.yml b/config/locales/en.yml index fbcef03bde4..97bb141863f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -33,24 +33,20 @@ en: user_count_after: users user_count_before: Home to what_is_mastodon: What is Mastodon? - access_token: - your_token: Your Access Token - regenerate: Regenerate Access Token - regenerated: Access Token Regenerated accounts: follow: Follow followers: Followers following: Following + media: Media nothing_here: There is nothing here! people_followed_by: People whom %{name} follows people_who_follow: People who follow %{name} posts: Toots posts_with_replies: Toots with replies - media: Media - roles: - admin: Admin remote_follow: Remote follow reserved_username: The username is reserved + roles: + admin: Admin unfollow: Unfollow admin: accounts: @@ -230,14 +226,14 @@ en: settings: 'Change e-mail preferences: %{link}' signature: Mastodon notifications from %{instance} view: 'View:' - application: - created: Application Created - destroyed: Application Destroyed - uid: Client ID - secret: Client Secret - warning: Be very careful with this data. Never share it with anyone other than authorized applications! applications: + created: Application successfully created + destroyed: Application successfully deleted invalid_url: The provided URL is invalid + regenerate_token: Regenerate access token + token_regenerated: Access token successfully regenerated + warning: Be very careful with this data. Never share it with anyone! + your_token: Your access token auth: agreement_html: By signing up you agree to our terms of service and privacy policy. change_password: Security @@ -426,6 +422,7 @@ en: authorized_apps: Authorized apps back: Back to Mastodon delete: Account deletion + development: Development edit_profile: Edit profile export: Data export followers: Authorized followers diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 0f0b0ad4a94..2ee99db4564 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -37,16 +37,16 @@ ja: follow: フォロー followers: フォロワー following: フォロー中 + media: メディア nothing_here: 何もありません people_followed_by: "%{name} さんがフォロー中のアカウント" people_who_follow: "%{name} さんをフォロー中のアカウント" posts: トゥート posts_with_replies: トゥートと返信 - media: メディア - roles: - admin: Admin remote_follow: リモートフォロー reserved_username: このユーザー名は予約されています。 + roles: + admin: Admin unfollow: フォロー解除 admin: accounts: diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 9038d887a2e..65ea4525a64 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -37,16 +37,16 @@ oc: follow: Sègre followers: Seguidors following: Abonaments + media: Mèdias nothing_here: I a pas res aquí ! people_followed_by: Lo mond que %{name} sèc people_who_follow: Lo mond que sègon %{name} posts: Tuts posts_with_replies: Tuts amb responsas - media: Mèdias - roles: - admin: Admin remote_follow: Sègre a distància reserved_username: Aqueste nom d’utilizaire es reservat + roles: + admin: Admin unfollow: Quitar de sègre admin: accounts: @@ -221,7 +221,7 @@ oc: body: "%{reporter} a senhalat %{target}" subject: Novèl senhalament per %{instance} (#%{id}) application_mailer: - salutation: '%{name},' + salutation: "%{name}," settings: 'Cambiar las preferéncias de corrièl : %{link}' signature: Notificacion de Mastodon sus %{instance} view: 'Veire :' @@ -234,13 +234,13 @@ oc: delete_account_html: Se volètz suprimir vòstre compte, podètz o far aquí. Vos demandarem que confirmetz. didnt_get_confirmation: Avètz pas recebut las instruccions de confirmacion ? forgot_password: Senhal oblidat ? + invalid_reset_password_token: Lo geton de reïnicializacion es invalid o acabat. Tornatz demandar un geton se vos plai. login: Se connectar logout: Se desconnectar register: Se marcar resend_confirmation: Tornar mandar las instruccions de confirmacion reset_password: Reïnicializar lo senhal set_new_password: Picar un nòu senhal - invalid_reset_password_token: Lo geton de reïnicializacion es invalid o acabat. Tornatz demandar un geton se vos plai. authorize_follow: error: O planhèm, i a agut una error al moment de cercar lo compte follow: Sègre @@ -337,12 +337,12 @@ oc: x_months: one: Fa un mes other: Fa %{count} meses - x_years: - one: Fa un an - other: Fa %{count} ans x_seconds: one: Fa una segonda other: Fa %{count} segondas + x_years: + one: Fa un an + other: Fa %{count} ans deletes: bad_password_msg: Ben ensajat pirata ! Senhal incorrècte confirm_password: Picatz vòstre senhal actual per verificar vòstra identitat diff --git a/config/locales/pl.yml b/config/locales/pl.yml index c005cdb01db..b7f4898b097 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -37,16 +37,16 @@ pl: follow: Śledź followers: Śledzących following: Śledzi + media: Zawartość multimedialna nothing_here: Niczego tu nie ma! people_followed_by: Konta śledzone przez %{name} people_who_follow: Osoby, które śledzą konto %{name} posts: Wpisy posts_with_replies: Wpisy z odpowiedziami - media: Zawartość multimedialna - roles: - admin: Administrator remote_follow: Śledź zdalnie reserved_username: Ta nazwa użytkownika jest zarezerwowana. + roles: + admin: Administrator unfollow: Przestań śledzić admin: accounts: @@ -126,8 +126,8 @@ pl: severity: Priorytet show: affected_accounts: - one: Dotyczy jednego konta w bazie danych many: Dotyczy %{count} kont w bazie danych + one: Dotyczy jednego konta w bazie danych other: Dotyczy %{count} kont w bazie danych retroactive: silence: Odwołaj wyciszenie wszystkich kont w tej domenie diff --git a/config/navigation.rb b/config/navigation.rb index 6e04843ecaf..4b454b3fc2a 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -12,10 +12,13 @@ SimpleNavigation::Configuration.run do |navigation| settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url - settings.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url end + primary.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url do |development| + development.item :your_apps, safe_join([fa_icon('list fw'), t('settings.your_apps')]), settings_applications_url, highlights_on: %r{/settings/applications} + end + primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.admin? } do |admin| admin.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} admin.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts} diff --git a/config/routes.rb b/config/routes.rb index e8bc968f4ed..94a4ac88ee4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,8 +80,10 @@ Rails.application.routes.draw do resource :follower_domains, only: [:show, :update] - resources :applications do - put :regenerate + resources :applications, except: [:edit] do + member do + post :regenerate + end end resource :delete, only: [:show, :destroy] diff --git a/db/schema.rb b/db/schema.rb index 929a5fd01bb..98b07e28276 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -216,11 +216,11 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.string "scopes", default: "", null: false t.datetime "created_at" t.datetime "updated_at" - t.boolean "superapp", default: false, null: false - t.string "website" - t.integer "owner_id" - t.string "owner_type" - t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree + t.boolean "superapp", default: false, null: false + t.string "website" + t.integer "owner_id" + t.string "owner_type" + t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type" t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true end @@ -423,6 +423,7 @@ ActiveRecord::Schema.define(version: 20170720000000) do add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id", on_delete: :cascade add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id", on_delete: :cascade add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id", on_delete: :cascade + add_foreign_key "oauth_applications", "users", column: "owner_id", on_delete: :cascade add_foreign_key "preview_cards", "statuses", on_delete: :cascade add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", on_delete: :nullify add_foreign_key "reports", "accounts", column: "target_account_id", on_delete: :cascade diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb index fa27e6ec6b8..7902a433417 100644 --- a/spec/controllers/settings/applications_controller_spec.rb +++ b/spec/controllers/settings/applications_controller_spec.rb @@ -156,7 +156,7 @@ describe Settings::ApplicationsController do let(:token) { user.token_for_app(app) } before do expect(token).to_not be_nil - put :regenerate, params: { application_id: app.id } + post :regenerate, params: { id: app.id } end it 'should create new token' do From 760cfe328fe209a48f7f8476daddf5a2ec089862 Mon Sep 17 00:00:00 2001 From: unarist Date: Wed, 23 Aug 2017 19:25:57 +0900 Subject: [PATCH 073/201] Fix accessing to XML attribute in FetchAtomService (#4668) --- app/services/fetch_atom_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index afda50ae4f9..9c5777b5d5b 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -62,8 +62,8 @@ class FetchAtomService < BaseService json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) } atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' } - result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity - result ||= process(atom_link.href, terminal: true) unless atom_link.nil? + result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity + result ||= process(atom_link['href'], terminal: true) unless atom_link.nil? result end From 9846806cb59725c97d8fb87cd13303cdf3ffec43 Mon Sep 17 00:00:00 2001 From: unarist Date: Wed, 23 Aug 2017 20:07:29 +0900 Subject: [PATCH 074/201] Fix Japanese translation (#4669) --- config/locales/ja.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 2ee99db4564..17797d8d2bd 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -83,7 +83,7 @@ ja: perform_full_suspension: 完全に活動停止させる profile_url: プロフィールURL public: パブリック - push_subscription_expires: PuSH購読期限切れ + push_subscription_expires: PuSH購読期限 redownload: アバターの更新 reset: リセット reset_password: パスワード再設定 From 8d23667536cec65292302774b3816467ad427a32 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Wed, 23 Aug 2017 21:14:22 +0900 Subject: [PATCH 075/201] Add Japanese translations for #2758, #4506, #4521, #4600 and #4664 (#4665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Japanese translations for #2758, #4506, #4521, #4600 and #4664 * Do not translate Inbox URL and Outbox URL * Remove "あなたの" * Remove "あなたの" --- config/locales/doorkeeper.en.yml | 2 ++ config/locales/doorkeeper.ja.yml | 14 ++++++++++---- config/locales/ja.yml | 12 ++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 788d1bb406d..efbd81d43cf 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -35,10 +35,12 @@ en: redirect_uri: Use one line per URI scopes: Separate scopes with spaces. Leave blank to use the default scopes. index: + application: Application callback_url: Callback URL delete: Delete name: Name new: New application + scopes: Scopes show: Show title: Your applications new: diff --git a/config/locales/doorkeeper.ja.yml b/config/locales/doorkeeper.ja.yml index d3ea93789cd..9e3b727612e 100644 --- a/config/locales/doorkeeper.ja.yml +++ b/config/locales/doorkeeper.ja.yml @@ -3,8 +3,10 @@ ja: activerecord: attributes: doorkeeper/application: - name: 名前 + name: アプリの名前 redirect_uri: リダイレクトURI + scopes: アクセス権 + website: アプリのウェブサイト errors: models: doorkeeper/application: @@ -33,18 +35,22 @@ ja: redirect_uri: 一行に一つのURLを入力してください scopes: アクセス権は半角スペースで区切ることができます。 空白のままにするとデフォルトを使用します。 index: + application: アプリ callback_url: コールバックURL + delete: 削除 name: 名前 new: 新規アプリ + scopes: アクセス権 + show: 見る title: アプリ new: title: 新規アプリ show: actions: アクション - application_id: アクションId - callback_urls: コールバックurl + application_id: クライアントキー + callback_urls: コールバックURL scopes: アクセス権 - secret: 非公開 + secret: クライアントシークレット title: 'アプリ: %{name}' authorizations: buttons: diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 17797d8d2bd..b847708d3eb 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -61,6 +61,7 @@ ja: feed_url: フィードURL followers: フォロワー数 follows: フォロー数 + inbox_url: Inbox URL ip: IP location: all: すべて @@ -80,8 +81,10 @@ ja: alphabetic: アルファベット順 most_recent: 直近の活動順 title: 順序 + outbox_url: Outbox URL perform_full_suspension: 完全に活動停止させる profile_url: プロフィールURL + protocol: プロトコル public: パブリック push_subscription_expires: PuSH購読期限 redownload: アバターの更新 @@ -223,7 +226,13 @@ ja: signature: Mastodon %{instance} インスタンスからの通知 view: 'View:' applications: + created: アプリが作成されました + destroyed: アプリが削除されました invalid_url: URLが無効です + regenerate_token: アクセストークンの再生成 + token_regenerated: アクセストークンが再生成されました + warning: このデータは気をつけて取り扱ってください。不特定多数の人と共有しないでください! + your_token: アクセストークン auth: agreement_html: 登録すると 利用規約プライバシーポリシー に同意したことになります。 change_password: セキュリティ @@ -231,6 +240,7 @@ ja: delete_account_html: アカウントを削除したい場合、こちら から手続きが行えます。削除する前に、確認画面があります。 didnt_get_confirmation: 確認メールを受信できませんか? forgot_password: パスワードをお忘れですか? + invalid_reset_password_token: パスワードリセットトークンが正しくないか期限切れです。もう一度リクエストしてください。 login: ログイン logout: ログアウト register: 登録する @@ -411,6 +421,7 @@ ja: authorized_apps: 認証済みアプリ back: Mastodon に戻る delete: アカウントの削除 + development: 開発 edit_profile: プロフィールを編集 export: データのエクスポート followers: 信頼済みのインスタンス @@ -418,6 +429,7 @@ ja: preferences: ユーザー設定 settings: 設定 two_factor_authentication: 二段階認証 + your_apps: アプリ statuses: open_in_web: Webで開く over_character_limit: 上限は %{max}文字までです From 80393a23d0a0c296d4356a2a21cf8504435265bf Mon Sep 17 00:00:00 2001 From: nullkal Date: Wed, 23 Aug 2017 22:16:20 +0900 Subject: [PATCH 076/201] Use checkboxes for application scope setting (#4671) --- .../settings/applications_controller.rb | 6 ++++ .../settings/applications/_fields.html.haml | 14 +++++++-- .../settings/applications_controller_spec.rb | 30 ++++++++++++++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb index 894222c2af4..8fc9a0fa999 100644 --- a/app/controllers/settings/applications_controller.rb +++ b/app/controllers/settings/applications_controller.rb @@ -5,6 +5,7 @@ class Settings::ApplicationsController < ApplicationController before_action :authenticate_user! before_action :set_application, only: [:show, :update, :destroy, :regenerate] + before_action :prepare_scopes, only: [:create, :update] def index @applications = current_user.applications.page(params[:page]) @@ -63,4 +64,9 @@ class Settings::ApplicationsController < ApplicationController :website ) end + + def prepare_scopes + scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) + params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array + end end diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml index 536f69e0479..83297a1ae7e 100644 --- a/app/views/settings/applications/_fields.html.haml +++ b/app/views/settings/applications/_fields.html.haml @@ -7,5 +7,15 @@ %p.hint= t('doorkeeper.applications.help.native_redirect_uri', native_redirect_uri: Doorkeeper.configuration.native_redirect_uri) -.fields-group - = f.input :scopes, wrapper: :with_label, label: t('activerecord.attributes.doorkeeper/application.scopes'), hint: t('doorkeeper.applications.help.scopes') +.field-group + = f.input :scopes, + label: t('activerecord.attributes.doorkeeper/application.scopes'), + collection: Doorkeeper.configuration.scopes, + wrapper: :with_label, + include_blank: false, + selected: f.object.scopes.all, + required: false, + as: :check_boxes, + collection_wrapper_tag: 'ul', + item_wrapper_tag: 'li' + diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb index 7902a433417..ca66f8d2321 100644 --- a/spec/controllers/settings/applications_controller_spec.rb +++ b/spec/controllers/settings/applications_controller_spec.rb @@ -45,7 +45,7 @@ describe Settings::ApplicationsController do end describe 'POST #create' do - context 'success' do + context 'success (passed scopes as a String)' do def call_create post :create, params: { doorkeeper_application: { @@ -61,7 +61,29 @@ describe Settings::ApplicationsController do it 'creates an entry in the database' do expect { call_create }.to change(Doorkeeper::Application, :count) end - + + it 'redirects back to applications page' do + expect(call_create).to redirect_to(settings_applications_path) + end + end + + context 'success (passed scopes as an Array)' do + def call_create + post :create, params: { + doorkeeper_application: { + name: 'My New App', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + website: 'http://google.com', + scopes: [ 'read', 'write', 'follow' ] + } + } + response + end + + it 'creates an entry in the database' do + expect { call_create }.to change(Doorkeeper::Application, :count) + end + it 'redirects back to applications page' do expect(call_create).to redirect_to(settings_applications_path) end @@ -74,7 +96,7 @@ describe Settings::ApplicationsController do name: '', redirect_uri: '', website: '', - scopes: '' + scopes: [] } } end @@ -123,7 +145,7 @@ describe Settings::ApplicationsController do name: '', redirect_uri: '', website: '', - scopes: '' + scopes: [] } } end From 8a716c9e967fb952d34300674bf4bc806253ee7f Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Wed, 23 Aug 2017 15:21:00 +0200 Subject: [PATCH 077/201] Introduce CODEOWNERS file (#4670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- CODEOWNERS | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 CODEOWNERS diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000000..42fc73ded75 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,15 @@ +# CODEOWNERS for tootsuite/mastodon + +# Translators +# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address. +# /app/javascript/mastodon/locales/fr.json @żelipapą +# /app/views/user_mailer/*.fr.html.erb @żelipapą +# /app/views/user_mailer/*.fr.text.erb @żelipapą +# /config/locales/*.fr.yml @żelipapą +# /config/locales/fr.yml @żelipapą + +/app/javascript/mastodon/locales/pl.json @m4sk1n +/app/views/user_mailer/*.pl.html.erb @m4sk1n +/app/views/user_mailer/*.pl.text.erb @m4sk1n +/config/locales/*.pl.yml @m4sk1n +/config/locales/pl.yml @m4sk1n From 829e2e8c5d388e4e841dfd9a472340b35cc826c9 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Wed, 23 Aug 2017 17:45:29 +0200 Subject: [PATCH 078/201] Update Polish translation (#4674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- config/locales/doorkeeper.pl.yml | 6 +++++- config/locales/pl.yml | 10 ++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/config/locales/doorkeeper.pl.yml b/config/locales/doorkeeper.pl.yml index 72b967e3543..89b006eb488 100644 --- a/config/locales/doorkeeper.pl.yml +++ b/config/locales/doorkeeper.pl.yml @@ -3,8 +3,10 @@ pl: activerecord: attributes: doorkeeper/application: - name: Nazwa + name: Nazwa aplikacji redirect_uri: URI przekierowania + scopes: Zakres + website: Strona aplikacji errors: models: doorkeeper/application: @@ -34,8 +36,10 @@ pl: scopes: Rozdziel zakresy (scopes) spacjami. Zostaw puste, aby użyć domyślnych zakresów. index: callback_url: URL wywołania zwrotnego (callback) + delete: Usuń name: Nazwa new: Nowa aplikacja + show: Pokaż title: Twoje aplikacje new: title: Nowa aplikacja diff --git a/config/locales/pl.yml b/config/locales/pl.yml index b7f4898b097..182cbf65e06 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -226,6 +226,14 @@ pl: view: 'Zobacz:' applications: invalid_url: Ten URL jest nieprawidłowy + applications: + created: Pomyślnie utworzono aplikację + destroyed: Pomyślnie usunięto aplikację + invalid_url: Wprowadzony adres URL jest nieprawidłowy + regenerate_token: Wygeneruj nowy token dostępu + token_regenerated: Pomyślnie wygenerowano nowy token dostępu + warning: Przechowuj te dane ostrożnie. Nie udostępniaj ich nikomu! + your_token: Twój token dostępu auth: agreement_html: Rejestrując się, oświadczasz, że zapoznałeś się z naszymi zasadami użytkowania i polityką prywatności. change_password: Bezpieczeństwo @@ -418,6 +426,7 @@ pl: authorized_apps: Uwierzytelnione aplikacje back: Powrót do Mastodona delete: Usuń konto + development: Programowanie edit_profile: Edytuj profil export: Eksportuj dane followers: Autoryzowani śledzący @@ -425,6 +434,7 @@ pl: preferences: Preferencje settings: Ustawienia two_factor_authentication: Uwierzytelnianie dwuetapowe + your_apps: Twoje aplikacje statuses: open_in_web: Otwórz w przeglądarce over_character_limit: limit %{max} znaków przekroczony From 2c6a85832cb5188a4f2f6e521e1289098fef96b5 Mon Sep 17 00:00:00 2001 From: HIKARU KOBORI Date: Thu, 24 Aug 2017 11:03:52 +0900 Subject: [PATCH 079/201] Fix typo in admin/status_controller.rb (#4679) --- app/controllers/admin/statuses_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 50712f0dd09..b05000b16b0 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -9,7 +9,7 @@ module Admin before_action :set_account before_action :set_status, only: [:update, :destroy] - PAR_PAGE = 20 + PER_PAGE = 20 def index @statuses = @account.statuses @@ -17,7 +17,7 @@ module Admin account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct @statuses.merge!(Status.where(id: account_media_status_ids)) end - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) + @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) @form = Form::StatusBatch.new end From e4c761f902579c2122eb4d531d1596ff5376d96d Mon Sep 17 00:00:00 2001 From: Quent-in Date: Thu, 24 Aug 2017 09:16:32 +0200 Subject: [PATCH 080/201] l18n update OC new strings (#4664) (#4680) * New strings * Update Thin non breaking spaces * Update Thin non breaking spaces * Update Thin non breaking spaces --- app/javascript/mastodon/locales/oc.json | 42 ++++++------- config/locales/devise.oc.yml | 14 ++--- config/locales/doorkeeper.oc.yml | 8 +-- config/locales/oc.yml | 84 ++++++++++++++----------- 4 files changed, 78 insertions(+), 70 deletions(-) diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index e2a5d7c5981..5e5e28af0a8 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -45,24 +45,24 @@ "column_subheading.settings": "Paramètres", "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.", "compose_form.lock_disclaimer.lock": "clavat", - "compose_form.placeholder": "A de qué pensatz ?", - "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists", + "compose_form.placeholder": "A de qué pensatz ?", + "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists", "compose_form.publish": "Tut", - "compose_form.publish_loud": "{publish} !", + "compose_form.publish_loud": "{publish} !", "compose_form.sensitive": "Marcar lo mèdia coma sensible", "compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment", "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí", "confirmation_modal.cancel": "Anullar", "confirmations.block.confirm": "Blocar", - "confirmations.block.message": "Sètz segur de voler blocar {name} ?", + "confirmations.block.message": "Sètz segur de voler blocar {name} ?", "confirmations.delete.confirm": "Suprimir", - "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?", + "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?", "confirmations.domain_block.confirm": "Amagar tot lo domeni", - "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.", + "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.", "confirmations.mute.confirm": "Metre en silenci", - "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?", + "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?", "confirmations.unfollow.confirm": "Quitar de sègre", - "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?", + "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?", "emoji_button.activity": "Activitats", "emoji_button.flags": "Drapèus", "emoji_button.food": "Beure e manjar", @@ -73,13 +73,13 @@ "emoji_button.search": "Cercar…", "emoji_button.symbols": "Simbòls", "emoji_button.travel": "Viatges & lòcs", - "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !", + "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !", "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag", "empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.", "empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.", "empty_column.home.public_timeline": "lo flux public", "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.", - "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.", + "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.", "follow_request.authorize": "Autorizar", "follow_request.reject": "Regetar", "getting_started.appsshort": "Apps", @@ -109,19 +109,19 @@ "navigation_bar.mutes": "Personas rescondudas", "navigation_bar.preferences": "Preferéncias", "navigation_bar.public_timeline": "Flux public global", - "notification.favourite": "{name} a ajustat a sos favorits :", + "notification.favourite": "{name} a ajustat a sos favorits :", "notification.follow": "{name} vos sèc", - "notification.mention": "{name} vos a mencionat :", - "notification.reblog": "{name} a partejat vòstre estatut :", + "notification.mention": "{name} vos a mencionat :", + "notification.reblog": "{name} a partejat vòstre estatut :", "notifications.clear": "Escafar", - "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?", + "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?", "notifications.column_settings.alert": "Notificacions localas", - "notifications.column_settings.favourite": "Favorits :", - "notifications.column_settings.follow": "Nòus seguidors :", - "notifications.column_settings.mention": "Mencions :", + "notifications.column_settings.favourite": "Favorits :", + "notifications.column_settings.follow": "Nòus seguidors :", + "notifications.column_settings.mention": "Mencions :", "notifications.column_settings.push": "Notificacions", "notifications.column_settings.push_meta": "Aqueste periferic", - "notifications.column_settings.reblog": "Partatges :", + "notifications.column_settings.reblog": "Partatges :", "notifications.column_settings.show": "Mostrar dins la colomna", "notifications.column_settings.sound": "Emetre un son", "onboarding.done": "Fach", @@ -131,14 +131,14 @@ "onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos", "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum ma larg. Òm los apèla instàncias.", "onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}", - "onboarding.page_one.welcome": "Benvengut a Mastodon !", + "onboarding.page_one.welcome": "Benvengut a Mastodon !", "onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.", "onboarding.page_six.almost_done": "Gaireben acabat…", "onboarding.page_six.appetoot": "Bon Appetut!", "onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.", "onboarding.page_six.github": "Mastodon es un logicial liure e open-source. Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.", "onboarding.page_six.guidelines": "guida de la comunitat", - "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !", + "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !", "onboarding.page_six.various_app": "aplicacions per mobil", "onboarding.page_three.profile": "Modificatz vòstre perfil per cambiar vòstre avatar, bio e escais-nom. I a enlà totas las preferéncias.", "onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de mond e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complet.", @@ -169,7 +169,7 @@ "status.mute_conversation": "Rescondre la conversacion", "status.open": "Desplegar aqueste estatut", "status.reblog": "Partejar", - "status.reblogged_by": "{name} a partejat :", + "status.reblogged_by": "{name} a partejat :", "status.reply": "Respondre", "status.replyAll": "Respondre a la conversacion", "status.report": "Senhalar @{name}", diff --git a/config/locales/devise.oc.yml b/config/locales/devise.oc.yml index 77740f2308a..99e62a10e4e 100644 --- a/config/locales/devise.oc.yml +++ b/config/locales/devise.oc.yml @@ -19,11 +19,11 @@ oc: confirmation_instructions: subject: "Mercés de confirmar vòstra inscripcion sus %{instance}" password_change: - subject: 'Mastodon : senhal cambiat' + subject: 'Mastodon : senhal cambiat' reset_password_instructions: - subject: 'Mastodon : instruccions per reïnicializar lo senhal' + subject: 'Mastodon : instruccions per reïnicializar lo senhal' unlock_instructions: - subject: 'Mastodon : instuccions de desblocatge' + subject: 'Mastodon : instuccions de desblocatge' omniauth_callbacks: failure: Fracàs al moment de vos autentificar de %{kind} perque "%{reason}". success: Sètz ben autentificat dempuèi lo compte %{kind}. @@ -34,8 +34,8 @@ oc: updated: Vòstre senhal es ben estat cambiat. Sètz ara connectat. updated_not_active: Vòstre senhal es ben estat cambiat. registrations: - destroyed: Adiu ! Vòstra inscripcion es estada anullada amb succès. Esperem vos tornar veire lèu. - signed_up: La benvenguda ! Sètz ben marcat al malhum. + destroyed: Adiu ! Vòstra inscripcion es estada anullada amb succès. Esperem vos tornar veire lèu. + signed_up: La benvenguda ! Sètz ben marcat al malhum. signed_up_but_inactive: Sètz ben marcat. Pasmens, avèm pas pogut vos connectar perque vòstre compte es pas encara validat. signed_up_but_locked: Sètz ben marcat. Pasmens, avèm pas pogut vos connectar perque vòstre compte es pas encara blocat. signed_up_but_unconfirmed: Un messatge amb un ligam de confirmacion es estat enviat a vòstra adreça de corrièl. Clicatz sul ligam per activar vòstre compte. Mercés de verificar tanben vòstre dorsièr de corrièls indesirables. @@ -57,5 +57,5 @@ oc: not_found: pas trobat not_locked: èra pas blocat not_saved: - one: '1 error defend aquesta %{resource} d’èsser salvagardada :' - other: "%{count} errors defendon aquesta %{resource} d’èsser salvagardadas :" + one: '1 error defend aquesta %{resource} d’èsser salvagardada :' + other: "%{count} errors defendon aquesta %{resource} d’èsser salvagardadas :" diff --git a/config/locales/doorkeeper.oc.yml b/config/locales/doorkeeper.oc.yml index 9f5d3fe55f6..3d12c95881f 100644 --- a/config/locales/doorkeeper.oc.yml +++ b/config/locales/doorkeeper.oc.yml @@ -23,11 +23,11 @@ oc: edit: Modificar submit: Mandar confirmations: - destroy: Sètz segur ? + destroy: Sètz segur ? edit: title: Modificar l’aplicacion form: - error: Ops ! Verificatz vòstre formulari + error: Ops ! Verificatz vòstre formulari help: native_redirect_uri: Emplegatz %{native_redirect_uri} per d’ensages locales redirect_uri: Utilizatz una linha per URI @@ -45,7 +45,7 @@ oc: callback_urls: urls de rapèls scopes: Encastres secret: Secret - title: 'Aplicacion : %{name}' + title: 'Aplicacion : %{name}' authorizations: buttons: authorize: Autorizar @@ -62,7 +62,7 @@ oc: buttons: revoke: Revocar confirmations: - revoke: Ne sètz segur ? + revoke: Ne sètz segur ? index: application: Aplicacion created_at: Creada lo diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 65ea4525a64..35eb79b3315 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -7,7 +7,7 @@ oc: contact: Contacte contact_missing: Pas parametrat contact_unavailable: Pas disponible - description_headline: Qué es %{domain} ? + description_headline: Qué es %{domain} ? domain_count_after: autras instàncias domain_count_before: Connectat a extended_description_html: | @@ -32,13 +32,13 @@ oc: status_count_before: qu’an escrich user_count_after: personas user_count_before: Ostal de - what_is_mastodon: Qu’es Mastodon ? + what_is_mastodon: Qu’es Mastodon ? accounts: follow: Sègre followers: Seguidors following: Abonaments media: Mèdias - nothing_here: I a pas res aquí ! + nothing_here: I a pas res aquí ! people_followed_by: Lo mond que %{name} sèc people_who_follow: Lo mond que sègon %{name} posts: Tuts @@ -50,7 +50,7 @@ oc: unfollow: Quitar de sègre admin: accounts: - are_you_sure: Sètz segur ? + are_you_sure: Sètz segur ? confirm: Confirmar confirmed: Confirmat disable_two_factor_authentication: Desactivar 2FA @@ -143,7 +143,7 @@ oc: title: Instàncias conegudas reports: action_taken_by: Mesura menada per - are_you_sure: Es segur ? + are_you_sure: Es segur ? comment: label: Comentari none: Pas cap @@ -222,18 +222,24 @@ oc: subject: Novèl senhalament per %{instance} (#%{id}) application_mailer: salutation: "%{name}," - settings: 'Cambiar las preferéncias de corrièl : %{link}' + settings: 'Cambiar las preferéncias de corrièl : %{link}' signature: Notificacion de Mastodon sus %{instance} - view: 'Veire :' + view: 'Veire :' applications: + created: Aplicacion ben creada + destroyed: Aplication ben suprimida invalid_url: L’URL donada es invalida + regenerate_token: Tornar generar lo geton d’accès + token_regenerated: Geton d’accès ben regenerat + warning: Mèfi ! Agachatz de partejar aquela donada amb degun ! + your_token: Vòstre geton d’accès auth: agreement_html: En vos marcar acceptatz nòstres tèrmes de servici e politica de confidencialitat. change_password: Seguretat delete_account: Suprimir lo compte delete_account_html: Se volètz suprimir vòstre compte, podètz o far aquí. Vos demandarem que confirmetz. - didnt_get_confirmation: Avètz pas recebut las instruccions de confirmacion ? - forgot_password: Senhal oblidat ? + didnt_get_confirmation: Avètz pas recebut las instruccions de confirmacion ? + forgot_password: Senhal oblidat ? invalid_reset_password_token: Lo geton de reïnicializacion es invalid o acabat. Tornatz demandar un geton se vos plai. login: Se connectar logout: Se desconnectar @@ -244,8 +250,8 @@ oc: authorize_follow: error: O planhèm, i a agut una error al moment de cercar lo compte follow: Sègre - follow_request: 'Avètz demandat de sègre :' - following: 'Felicitacion ! Seguètz ara :' + follow_request: 'Avètz demandat de sègre :' + following: 'Felicitacion ! Seguètz ara :' post_follow: close: O podètz tampar aquesta fenèstra. return: Tornar al perfil @@ -344,7 +350,7 @@ oc: one: Fa un an other: Fa %{count} ans deletes: - bad_password_msg: Ben ensajat pirata ! Senhal incorrècte + bad_password_msg: Ben ensajat pirata ! Senhal incorrècte confirm_password: Picatz vòstre senhal actual per verificar vòstra identitat description_html: Aquò suprimirà definitivament e sens possibilitat de retorn lo contengut de vòstre compte e lo desactivarà. Lo nom d’utilizaire serà gardat per evitar una futura impostura. proceed: Suprimir lo compte @@ -356,7 +362,7 @@ oc: '404': La pagina que recercatz existís pas. '410': La pagina que cercatz existís pas mai. '422': - content: Verificacion de seguretat fracassada. Blocatz los cookies ? + content: Verificacion de seguretat fracassada. Blocatz los cookies ? title: Verificacion de seguretat fracassada '429': Lo servidor mòla (subrecargada) noscript: Per utilizar l’aplicacion Mastodon, mercés d’activar JavaScript. Autrament podètz utilizar una aplicacion nativa Mastodon per vòstra plataforma. @@ -373,18 +379,18 @@ oc: lock_link: Clavar vòstre compte purge: Tirar dels seguidors success: - one: Soi a blocar los seguidors d’un domeni... - other: Soi a blocar los seguidors de %{count} domenis... + one: Soi a blocar los seguidors d’un domeni… + other: Soi a blocar los seguidors de %{count} domenis… true_privacy_html: Mèfi que la vertadièra confidencialitat pòt solament èsser amb un chiframent del cap a la fin (end-to-end). unlocked_warning_html: Tot lo mond pòt vos sègre e veire sulpic vòstres estatuts privats. %{lock_link} per poder repassar e regetar los seguidors. unlocked_warning_title: Vòstre compte es pas clavat generic: - changes_saved_msg: Cambiaments ben realizats ! + changes_saved_msg: Cambiaments ben realizats ! powered_by: propulsat per %{link} save_changes: Salvagardar los cambiaments validation_errors: - one: I a quicòm que truca ! Mercés de corregir l’error çai-jos - other: I a quicòm que truca ! Mercés de corregir las %{count} errors çai-jos + one: I a quicòm que truca ! Mercés de corregir l’error çai-jos + other: I a quicòm que truca ! Mercés de corregir las %{count} errors çai-jos imports: preface: Podètz importar qualques donadas coma lo mond que seguètz o blocatz a-n aquesta instància d’un fichièr creat d’una autra instància. success: Vòstras donadas son ben estadas mandadas e seràn tractadas tre que possible @@ -402,27 +408,27 @@ oc: notification_mailer: digest: body: 'Trobatz aquí un resumit de çò qu’avètz mancat dempuèi vòstra darrièra visita lo %{since}:' - mention: "%{name} vos a mencionat dins :" + mention: "%{name} vos a mencionat dins :" new_followers_summary: - one: Avètz un nòu seguidor ! Ouà ! - other: Avètz %{count} nòus seguidors ! Qué crane ! + one: Avètz un nòu seguidor ! Ouà   + other: Avètz %{count} nòus seguidors ! Qué crane ! subject: one: "Una nòva notificacion dempuèi vòstra darrièra visita \U0001F418" other: "%{count} nòvas notificacions dempuèi vòstra darrièra visita \U0001F418" favourite: - body: "%{name} a mes vòstre estatut en favorit :" + body: "%{name} a mes vòstre estatut en favorit :" subject: "%{name} a mes vòstre estatut en favorit" follow: - body: "%{name} vos sèc ara !" + body: "%{name} vos sèc ara !" subject: "%{name} vos sèc ara" follow_request: body: "%{name} a demandat a vos sègre" - subject: 'Demanda d’abonament : %{name}' + subject: 'Demanda d’abonament : %{name}' mention: - body: "%{name} vos a mencionat dins :" + body: "%{name} vos a mencionat dins :" subject: "%{name} vos a mencionat" reblog: - body: "%{name} a tornat partejar vòstre estatut :" + body: "%{name} a tornat partejar vòstre estatut :" subject: "%{name} a tornat partejar vòstre estatut" pagination: next: Seguent @@ -444,12 +450,12 @@ oc: title: "%{name} a partejat vòstre estatut" subscribed: body: Podètz ara recebre las notificacions push. - title: Abonament enregistrat ! + title: Abonament enregistrat ! remote_follow: acct: Picatz vòstre utilizaire@instància que cal utilizar per sègre aqueste utilizaire missing_resource: URL de redireccion pas trobada proceed: Contunhatz per sègre - prompt: 'Sètz per sègre :' + prompt: 'Sètz per sègre :' sessions: activity: Darrièra activitat browser: Navigator @@ -493,6 +499,7 @@ oc: authorized_apps: Aplicacions autorizadas back: Tornar a Mastodon delete: Supression de compte + development: Desvolopament edit_profile: Modificar lo perfil export: Export donadas followers: Seguidors autorizats @@ -500,6 +507,7 @@ oc: preferences: Preferéncias settings: Paramètres two_factor_authentication: Autentificacion en dos temps + your_apps: Vòstras aplicacions statuses: open_in_web: Dobrir sul web over_character_limit: limit de %{max} caractèrs passat @@ -519,7 +527,7 @@ oc: body_html: |

Politica de confidencialitat

-

Quinas informacions reculhèm ?

+

Quinas informacions reculhèm ?

Collectem informacions sus vos quand vos marcatz sus nòstre site e juntem las donadas quand participatz a nòstre forum en legir, escriure e notar lo contengut partejat aquí.

@@ -527,7 +535,7 @@ oc:

Quand sètz marcat e que publicatz quicòm, enregistrem l’adreça IP d’origina. Podèm tanben salvagardar los jornals del servidor que tenon l’adreça IP de totas las demandas fachas al nòstre servidor.

-

Qué fasèm de vòstras informacions ?

+

Qué fasèm de vòstras informacions ?

Totas las informacions que collectem de vos pòdon servir dins los cases seguents :

@@ -538,26 +546,26 @@ oc:
  • Per enviar periodicament de corrièls — Podèm utilizar l’adreça qu’avètz donada per vos enviar d’informacions e de notificacions que demandatz tocant de cambiaments dins los subjèctes del forum o en responsa a vòstre nom d’utilizaire, en responsa a una demanda, e/o tota autra question.
  • -

    Cossí protegèm vòstras informacions ?

    +

    Cossí protegèm vòstras informacions ?

    Apliquem tota una mena de mesuras de seguretat per manténer la fisança de vòstras informacions personalas quand las picatz, mandatz, o i accedètz.

    -

    Quala es vòstra politica de conservacion de donadas ?

    +

    Quala es vòstra politica de conservacion de donadas ?

    -

    Farem esfòrces per :

    +

    Farem esfòrces per :

    • Gardar los jornals del servidor que contenon las adreças IP de totas las demandas al servidor pas mai de 90 jorns.
    • Gardar las adreças IP ligadas als utilizaires e lors publicacions pas mai de 5 ans.
    -

    Empleguem de cookies ?

    +

    Empleguem de cookies ?

    Òc-ben. Los cookies son de pichons fichièrs qu’un site o sos provesidors de servicis plaçan dins lo disc dur de vòstre ordenador via lo navigator Web (Se los acceptatz). Aqueles cookies permeton al site de reconéisser vòstre navigator e se tenètz un compte enregistrat de l’associar a vòstre compte.

    Empleguem de cookies per comprendre e enregistrar vòstras preferéncias per vòstras visitas venentas, per recampar de donadas sul trafic del site e las interaccions per dire que posquem ofrir una melhora experiéncia del site e de las aisinas pel futur. Pòt arribar que contractèssem amb de provesidors de servicis tèrces per nos ajudar a comprendre melhor nòstres visitors. Aqueles provesidors an pas lo drech que d’utilizar las donadas collectadas per nos ajudar a menar e melhorar nòstre afar.

    -

    Divulguem d’informacions a de tèrces ?

    +

    Divulguem d’informacions a de tèrces ?

    Vendèm pas, comercem o qualque transferiment que siasque a de tèrces vòstras informacions personalas identificablas. Aquò inclutz pas los tèrces partits de confisança que nos assiston a menar nòstre site, menar nòstre afar o vos servir, baste que son d’acòrd per gardar aquelas informacions confidencialas. Pòt tanben arribar que liberèssem vòstras informacions quand cresèm qu’es apropriat d’o far per se sometre a la lei, per refortir nòstras politicas, o per protegir los dreches, proprietats o seguritat de qualqu’un o de nosautres. Pasmens es possible que mandèssem d’informacions non-personalas e identificablas de nòstres visitors a d’autres partits per d’utilizacion en marketing, publicitat o un emplec mai.

    @@ -593,16 +601,16 @@ oc: description_html: S’activatz l’autentificacion two-factor, vos caldrà vòstre mobil per vos connectar perque generarà un geton per vos daissar dintrar. disable: Desactivar enable: Activar - enabled_success: Autentificacion en dos temps Two-factor ben activada + enabled: L’autentificacion en dos temps es activada generate_recovery_codes: Generar los còdis de recuperacion instructions_html: "Escanatz aqueste còdi QR amb Google Authenticator o una aplicacion similària sus vòstre mobil. A partir d’ara, aquesta aplicacion generarà un geton que vos caldrà picar per vos connectar." lost_recovery_codes: Los còdi de recuperacion vos permeton d’accedir a vòstre compte se perdètz vòstre mobil. S’avètz perdut vòstres còdis de recuperacion los podètz tornar generar aquí. Los ancians còdis seràn pas mai valides. - manual_instructions: 'Se podètz pas numerizar lo còdi QR e que vos cal picar lo còdi a la man, vaquí lo còdi en clar :' + manual_instructions: 'Se podètz pas numerizar lo còdi QR e que vos cal picar lo còdi a la man, vaquí lo còdi en clar :' recovery_codes: Salvar los còdis de recuperacion recovery_codes_regenerated: Los còdis de recuperacion son ben estats tornats generar recovery_instructions_html: Se vos arriba de perdre vòstre mobil, podètz utilizar un dels còdis de recuperacion cai-jos per poder tornar accedir a vòstre compte. Gardatz los còdis en seguretat, per exemple, imprimissètz los e gardatz los amb vòstres documents importants. setup: Paramètres - wrong_code: Lo còdi picat es invalid ! L’ora es la bona sul servidor e lo mobil ? + wrong_code: Lo còdi picat es invalid ! L’ora es la bona sul servidor e lo mobil ? users: invalid_email: L’adreça de corrièl es invalida invalid_otp_token: Còdi d’autentificacion en dos temps invalid From fbe1115114edb2d46c05b5065398b7234b9fb6a0 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Thu, 24 Aug 2017 19:15:36 +0900 Subject: [PATCH 081/201] Remove eslint-disable comments (#4681) Do not reject console.error and console.warn with ESLint rules. --- .eslintrc.yml | 1 + app/javascript/mastodon/features/ui/index.js | 2 +- app/javascript/mastodon/web_push_subscription.js | 4 ---- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index fd2ba46dd03..1c60cbdb3e5 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -49,6 +49,7 @@ rules: - warn - allow: - error + - warn no-fallthrough: error no-irregular-whitespace: error no-mixed-spaces-and-tabs: warn diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index a791f894739..44243d370a4 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -135,7 +135,7 @@ export default class UI extends React.PureComponent { if (data.type === 'navigate') { this.context.router.history.push(data.path); } else { - console.warn('Unknown message type:', data.type); // eslint-disable-line no-console + console.warn('Unknown message type:', data.type); } } diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js index 96ac63b52fa..3dbed09ea28 100644 --- a/app/javascript/mastodon/web_push_subscription.js +++ b/app/javascript/mastodon/web_push_subscription.js @@ -48,7 +48,6 @@ export function register () { if (supportsPushNotifications) { if (!getApplicationServerKey()) { - // eslint-disable-next-line no-console console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); return; } @@ -84,10 +83,8 @@ export function register () { }) .catch(error => { if (error.code === 20 && error.name === 'AbortError') { - // eslint-disable-next-line no-console console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); } else if (error.code === 5 && error.name === 'InvalidCharacterError') { - // eslint-disable-next-line no-console console.error('The VAPID public key seems to be invalid:', getApplicationServerKey()); } @@ -103,7 +100,6 @@ export function register () { } }); } else { - // eslint-disable-next-line no-console console.warn('Your browser does not support Web Push Notifications.'); } } From c66fe2aeba84af5ab47c20298ddc8dceaf0e179f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 24 Aug 2017 13:31:55 +0200 Subject: [PATCH 082/201] Minor performance improvement for test suite (#4678) --- app/models/account.rb | 2 +- .../accounts/relationships_controller_spec.rb | 4 ++-- spec/spec_helper.rb | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index c3be975fb6f..0c9c6aed457 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -268,7 +268,7 @@ class Account < ApplicationRecord def generate_keys return unless local? - keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048) + keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048) self.private_key = keypair.to_pem self.public_key = keypair.public_key.to_pem end diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 3a9607317c4..a9073b197ab 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -50,14 +50,14 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:id]).to be simon.id + expect(json.first[:id]).to eq simon.id expect(json.first[:following]).to be true expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false expect(json.first[:domain_blocking]).to be false - expect(json.second[:id]).to be lewis.id + expect(json.second[:id]).to eq lewis.id expect(json.second[:following]).to be false expect(json.second[:followed_by]).to be true expect(json.second[:muting]).to be false diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2bc462121b1..eecaec4acf6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,11 +1,15 @@ require 'simplecov' +GC.disable + SimpleCov.start 'rails' do add_group 'Services', 'app/services' add_group 'Presenters', 'app/presenters' add_group 'Validators', 'app/validators' end +gc_counter = -1 + RSpec.configure do |config| config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true @@ -22,8 +26,21 @@ RSpec.configure do |config| end config.after :suite do + gc_counter = 0 FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"]) end + + config.after :each do + gc_counter += 1 + + if gc_counter > 19 + GC.enable + GC.start + GC.disable + + gc_counter = 0 + end + end end def body_as_json From b01a19fe392e0dd16d6b3da3f0b56369f7837cc9 Mon Sep 17 00:00:00 2001 From: unarist Date: Thu, 24 Aug 2017 23:21:42 +0900 Subject: [PATCH 083/201] Fetch reblogs as Announce activity instead of Note object (#4672) * Process Create / Announce activity in FetchRemoteStatusService * Use activity URL in ActivityPub for reblogs * Redirect to the original status on StatusesController#show --- app/controllers/statuses_controller.rb | 5 ++ app/lib/activitypub/tag_manager.rb | 8 +++ .../activitypub/activity_serializer.rb | 2 +- .../fetch_remote_status_service.rb | 30 +++++--- spec/controllers/statuses_controller_spec.rb | 12 ++++ .../fetch_remote_status_service_spec.rb | 72 ++++++++++++++++++- 6 files changed, 118 insertions(+), 11 deletions(-) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index aa24f23c944..a9768d09298 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -9,6 +9,7 @@ class StatusesController < ApplicationController before_action :set_status before_action :set_link_headers before_action :check_account_suspension + before_action :redirect_to_original, only: [:show] def show respond_to do |format| @@ -58,4 +59,8 @@ class StatusesController < ApplicationController def check_account_suspension gone if @account.suspended? end + + def redirect_to_original + redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + end end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index 3c16006cb16..de575d9e6f6 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -19,6 +19,7 @@ class ActivityPub::TagManager when :person short_account_url(target) when :note, :comment, :activity + return activity_account_status_url(target.account, target) if target.reblog? short_account_status_url(target.account, target) end end @@ -30,10 +31,17 @@ class ActivityPub::TagManager when :person account_url(target) when :note, :comment, :activity + return activity_account_status_url(target.account, target) if target.reblog? account_status_url(target.account, target) end end + def activity_uri_for(target) + return nil unless %i(note comment activity).include?(target.object_type) && target.local? + + activity_account_status_url(target.account, target) + end + # Primary audience of a status # Public statuses go out to primarily the public collection # Unlisted and private statuses go out primarily to the followers collection diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index 69e2160c5d4..d20ee99206a 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -6,7 +6,7 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer def id - [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join + [ActivityPub::TagManager.instance.activity_uri_for(object)].join end def type diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index 993e5389c42..c114515cdd4 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -7,21 +7,33 @@ class ActivityPub::FetchRemoteStatusService < BaseService def call(uri, prefetched_json = nil) @json = body_to_json(prefetched_json) || fetch_resource(uri) - return unless supported_context? && expected_type? + return unless supported_context? - attributed_to = first_of_value(@json['attributedTo']) - attributed_to = attributed_to['id'] if attributed_to.is_a?(Hash) + activity = activity_json + actor_id = value_or_id(activity['actor']) - return unless trustworthy_attribution?(uri, attributed_to) + return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id) - actor = ActivityPub::TagManager.instance.uri_to_resource(attributed_to, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(attributed_to) if actor.nil? + actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil? - ActivityPub::Activity::Create.new({ 'object' => @json }, actor).perform + ActivityPub::Activity.factory(activity, actor).perform end private + def activity_json + if %w(Note Article).include? @json['type'] + { + 'type' => 'Create', + 'actor' => first_of_value(@json['attributedTo']), + 'object' => @json, + } + else + @json + end + end + def trustworthy_attribution?(uri, attributed_to) Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero? end @@ -30,7 +42,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService super(@json) end - def expected_type? - %w(Note Article).include? @json['type'] + def expected_type?(json) + %w(Create Announce).include? json['type'] end end diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index 88d365624e3..95fb4d5945f 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -30,6 +30,18 @@ describe StatusesController do end end + context 'status is a reblog' do + it 'redirects to the original status' do + original_account = Fabricate(:account, domain: 'example.com') + original_status = Fabricate(:status, account: original_account, uri: 'tag:example.com,2017:foo', url: 'https://example.com/123') + status = Fabricate(:status, reblog: original_status) + + get :show, params: { account_username: status.account.username, id: status.id } + + expect(response).to redirect_to(original_status.url) + end + end + context 'account is not suspended and status is permitted' do it 'assigns @account' do status = Fabricate(:status) diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 47a33b6cb6d..3b22257ed15 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -1,5 +1,75 @@ require 'rails_helper' RSpec.describe ActivityPub::FetchRemoteStatusService do - pending + let(:sender) { Fabricate(:account) } + let(:recipient) { Fabricate(:account) } + let(:valid_domain) { Rails.configuration.x.local_domain } + + let(:note) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234", + type: 'Note', + content: 'Lorem ipsum', + attributedTo: ActivityPub::TagManager.instance.uri_for(sender), + } + end + + let(:create) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Create', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: note, + } + end + + subject { described_class.new } + + describe '#call' do + before do + subject.call(object[:id], Oj.dump(object)) + end + + context 'with Note object' do + let(:object) { note } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Create activity' do + let(:object) { create } + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.text).to eq 'Lorem ipsum' + end + end + + context 'with Announce activity' do + let(:status) { Fabricate(:status, account: recipient) } + + let(:object) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "https://#{valid_domain}/@foo/1234/activity", + type: 'Announce', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: ActivityPub::TagManager.instance.uri_for(status), + } + end + + it 'creates a reblog by sender of status' do + expect(sender.reblogged?(status)).to be true + end + end + end end From cf615abbf9323f3b73681306090de48f9e13a6b9 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 24 Aug 2017 17:51:32 +0200 Subject: [PATCH 084/201] Add configuration to disable private status federation over PuSH (#4582) --- app/services/process_mentions_service.rb | 2 +- .../pubsubhubbub/distribution_worker.rb | 2 +- config/initializers/ostatus.rb | 3 +- .../pubsubhubbub/distribution_worker_spec.rb | 64 +++++++++++++++---- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 407fa8c182b..2b8a7714721 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -39,7 +39,7 @@ class ProcessMentionsService < BaseService if mentioned_account.local? NotifyService.new.call(mentioned_account, mention) - elsif mentioned_account.ostatus? + elsif mentioned_account.ostatus? && (Rails.configuration.x.use_ostatus_privacy || !status.stream_entry.hidden?) NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id) elsif mentioned_account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url) diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index ea246128d2c..2a5e60fa0e4 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -14,7 +14,7 @@ class Pubsubhubbub::DistributionWorker @subscriptions = active_subscriptions.to_a distribute_public!(stream_entries.reject(&:hidden?)) - distribute_hidden!(stream_entries.select(&:hidden?)) + distribute_hidden!(stream_entries.select(&:hidden?)) if Rails.configuration.x.use_ostatus_privacy end private diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb index 342996dcd27..a885545f868 100644 --- a/config/initializers/ostatus.rb +++ b/config/initializers/ostatus.rb @@ -5,7 +5,7 @@ host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } web_host = ENV.fetch('WEB_DOMAIN') { host } https = ENV['LOCAL_HTTPS'] == 'true' -alternate_domains = ENV.fetch('ALTERNATE_DOMAINS') { "" } +alternate_domains = ENV.fetch('ALTERNATE_DOMAINS') { '' } Rails.application.configure do config.x.local_domain = host @@ -17,6 +17,7 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: web_host, protocol: https ? 'https://' : 'http://', trailing_slash: false } config.x.streaming_api_base_url = 'ws://localhost:4000' + config.x.use_ostatus_privacy = true if Rails.env.production? config.x.streaming_api_base_url = ENV.fetch('STREAMING_API_BASE_URL') { "ws#{https ? 's' : ''}://#{web_host}" } diff --git a/spec/workers/pubsubhubbub/distribution_worker_spec.rb b/spec/workers/pubsubhubbub/distribution_worker_spec.rb index 89191c084e2..5c22e7fa8e3 100644 --- a/spec/workers/pubsubhubbub/distribution_worker_spec.rb +++ b/spec/workers/pubsubhubbub/distribution_worker_spec.rb @@ -22,24 +22,62 @@ describe Pubsubhubbub::DistributionWorker do end end - describe 'with private status' do - let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + context 'when OStatus privacy is used' do + around do |example| + before_val = Rails.configuration.x.use_ostatus_privacy + Rails.configuration.x.use_ostatus_privacy = true + example.run + Rails.configuration.x.use_ostatus_privacy = before_val + end - it 'delivers payload only to subscriptions with followers' do - allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) - subject.perform(status.stream_entry.id) - expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower]) - expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription]) + describe 'with private status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + + it 'delivers payload only to subscriptions with followers' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower]) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription]) + end + end + + describe 'with direct status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + + it 'does not deliver payload' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end end end - describe 'with direct status' do - let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + context 'when OStatus privacy is not used' do + around do |example| + before_val = Rails.configuration.x.use_ostatus_privacy + Rails.configuration.x.use_ostatus_privacy = false + example.run + Rails.configuration.x.use_ostatus_privacy = before_val + end - it 'does not deliver payload' do - allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) - subject.perform(status.stream_entry.id) - expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + describe 'with private status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) } + + it 'does not deliver anything' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end + end + + describe 'with direct status' do + let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) } + + it 'does not deliver payload' do + allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk) + subject.perform(status.stream_entry.id) + expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk) + end end end end From da172a8b1b24a19a3838d3e59f453d1b17a2a4aa Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 25 Aug 2017 02:27:52 +0900 Subject: [PATCH 085/201] Disable babel-loader cache when development environment (#4684) --- config/webpack/loaders/babel.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/webpack/loaders/babel.js b/config/webpack/loaders/babel.js index 3177d964ac9..1416191c067 100644 --- a/config/webpack/loaders/babel.js +++ b/config/webpack/loaders/babel.js @@ -1,5 +1,7 @@ const { resolve } = require('path'); +const env = process.env.NODE_ENV || 'development'; + module.exports = { test: /\.js$/, // include react-intl because transform-react-remove-prop-types needs to apply to it @@ -9,7 +11,7 @@ module.exports = { }, loader: 'babel-loader', options: { - forceEnv: process.env.NODE_ENV || 'development', - cacheDirectory: resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'), + forceEnv: env, + cacheDirectory: env === 'development' ? false : resolve(__dirname, '..', '..', '..', 'tmp', 'cache', 'babel-loader'), }, }; From f72ed21cd62541881c0ad19bac1b78e82f514413 Mon Sep 17 00:00:00 2001 From: Damien Erambert Date: Thu, 24 Aug 2017 10:28:49 -0700 Subject: [PATCH 086/201] Don't load Roboto webfont when system font is used in the app (#4591) * Don't load Roboto webfont when system font is used in the app * remove trailing whitespace --- app/javascript/styles/basics.scss | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss index 4e51b555ca5..e524b7f26b3 100644 --- a/app/javascript/styles/basics.scss +++ b/app/javascript/styles/basics.scss @@ -1,5 +1,4 @@ body { - font-family: 'mastodon-font-sans-serif', sans-serif; background: $ui-base-color; background-size: cover; background-attachment: fixed; @@ -14,6 +13,11 @@ body { -webkit-tap-highlight-color: rgba(0,0,0,0); -webkit-tap-highlight-color: transparent; + // This is done because we want to use mastodon-font-sans-serif (a.k.a Roboto) on the `.ui` element in the app UI + &:not(.app-body) { + font-family: 'mastodon-font-sans-serif', sans-serif; + } + &.app-body { position: fixed; width: 100%; @@ -69,7 +73,7 @@ button { justify-content: center; } -.system-font { +.ui.system-font { // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+) // -apple-system => Safari <11 specific // BlinkMacSystemFont => Chrome <56 on macOS specific @@ -83,3 +87,7 @@ button { // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0) font-family: system-ui, -apple-system,BlinkMacSystemFont, "Segoe UI","Oxygen", "Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",mastodon-font-sans-serif, sans-serif; } + +.ui:not(.system-font) { + font-family: 'mastodon-font-sans-serif', sans-serif; +} From c5157ef07bbae5c3a307d6a005aef0f1c0452af3 Mon Sep 17 00:00:00 2001 From: Ratmir Karabut Date: Fri, 25 Aug 2017 01:11:06 +0300 Subject: [PATCH 087/201] Update Russian translation (#4685) * Add Russian translation (ru) * Fix a missing comma * Fix the wording for better consistency * Update Russian translation * Arrange Russian setting alphabetically * Fix syntax error * Update Russian translation * Fix formatting error * Update Russian translation * Update Russian translation * Update ru.jsx * Fix syntax error * Remove two_factor_auth.warning (appears obsolete) * Add missing strings in ru.yml A lot of new strings translated, especially for the newly added admin section * Fix translation consistency * Update Russian translation * Update Russian translation (pluralizations) * Update Russian translation * Update Russian translation * Update Russian translation (pin) * Update Russian translation (account deletion) * Fix extra line * Update Russian translation (sessions) * Update Russian translation * Update Russian translation * Fix merge conflicts (revert) --- app/javascript/mastodon/locales/ru.json | 38 ++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 1abfb43701a..af38fc72312 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -1,7 +1,7 @@ { "account.block": "Блокировать", "account.block_domain": "Блокировать все с {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.", "account.edit_profile": "Изменить профиль", "account.follow": "Подписаться", "account.followers": "Подписаны", @@ -13,19 +13,19 @@ "account.posts": "Посты", "account.report": "Пожаловаться", "account.requested": "Ожидает подтверждения", - "account.share": "Share @{name}'s profile", + "account.share": "Поделиться профилем @{name}", "account.unblock": "Разблокировать", "account.unblock_domain": "Разблокировать {domain}", "account.unfollow": "Отписаться", "account.unmute": "Снять глушение", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "Показать полный профиль", "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз", - "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", - "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", - "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", + "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.", + "bundle_column_error.retry": "Попробовать снова", + "bundle_column_error.title": "Ошибка сети", + "bundle_modal_error.close": "Закрыть", + "bundle_modal_error.message": "Что-то пошло не так при загрузке этого компонента.", + "bundle_modal_error.retry": "Попробовать снова", "column.blocks": "Список блокировки", "column.community": "Локальная лента", "column.favourites": "Понравившееся", @@ -35,11 +35,11 @@ "column.notifications": "Уведомления", "column.public": "Глобальная лента", "column_back_button.label": "Назад", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", + "column_header.hide_settings": "Скрыть настройки", + "column_header.moveLeft_settings": "Передвинуть колонку влево", + "column_header.moveRight_settings": "Передвинуть колонку вправо", "column_header.pin": "Закрепить", - "column_header.show_settings": "Show settings", + "column_header.show_settings": "Показать настройки", "column_header.unpin": "Открепить", "column_subheading.navigation": "Навигация", "column_subheading.settings": "Настройки", @@ -61,8 +61,8 @@ "confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.", "confirmations.mute.confirm": "Заглушить", "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmations.unfollow.confirm": "Отписаться", + "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?", "emoji_button.activity": "Занятия", "emoji_button.flags": "Флаги", "emoji_button.food": "Еда и напитки", @@ -94,8 +94,8 @@ "home.column_settings.show_replies": "Показывать ответы", "home.settings": "Настройки колонки", "lightbox.close": "Закрыть", - "lightbox.next": "Next", - "lightbox.previous": "Previous", + "lightbox.next": "Далее", + "lightbox.previous": "Назад", "loading_indicator.label": "Загрузка...", "media_gallery.toggle_visible": "Показать/скрыть", "missing_indicator.label": "Не найдено", @@ -119,8 +119,8 @@ "notifications.column_settings.favourite": "Нравится:", "notifications.column_settings.follow": "Новые подписчики:", "notifications.column_settings.mention": "Упоминания:", - "notifications.column_settings.push": "Push notifications", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push": "Push-уведомления", + "notifications.column_settings.push_meta": "Это устройство", "notifications.column_settings.reblog": "Продвижения:", "notifications.column_settings.show": "Показывать в колонке", "notifications.column_settings.sound": "Проигрывать звук", From 9caa90025fd9f1ef46a74f31cefd19335e291e76 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 25 Aug 2017 01:41:18 +0200 Subject: [PATCH 088/201] Pinned statuses (#4675) * Pinned statuses * yarn manage:translations --- app/controllers/accounts_controller.rb | 25 ++++-- .../api/v1/accounts/statuses_controller.rb | 5 ++ .../api/v1/statuses/pins_controller.rb | 28 +++++++ .../mastodon/actions/interactions.js | 78 +++++++++++++++++++ app/javascript/mastodon/components/status.js | 1 + .../mastodon/components/status_action_bar.js | 11 +++ .../mastodon/containers/status_container.js | 10 +++ .../features/status/components/action_bar.js | 11 +++ .../mastodon/features/status/index.js | 11 +++ app/javascript/mastodon/locales/ar.json | 2 + app/javascript/mastodon/locales/bg.json | 2 + app/javascript/mastodon/locales/ca.json | 2 + app/javascript/mastodon/locales/de.json | 2 + .../mastodon/locales/defaultMessages.json | 16 ++++ app/javascript/mastodon/locales/en.json | 2 + app/javascript/mastodon/locales/eo.json | 2 + app/javascript/mastodon/locales/es.json | 2 + app/javascript/mastodon/locales/fa.json | 2 + app/javascript/mastodon/locales/fi.json | 2 + app/javascript/mastodon/locales/fr.json | 2 + app/javascript/mastodon/locales/he.json | 2 + app/javascript/mastodon/locales/hr.json | 2 + app/javascript/mastodon/locales/hu.json | 2 + app/javascript/mastodon/locales/id.json | 2 + app/javascript/mastodon/locales/io.json | 2 + app/javascript/mastodon/locales/it.json | 2 + app/javascript/mastodon/locales/ja.json | 2 + app/javascript/mastodon/locales/ko.json | 2 + app/javascript/mastodon/locales/nl.json | 2 + app/javascript/mastodon/locales/no.json | 2 + app/javascript/mastodon/locales/oc.json | 2 + app/javascript/mastodon/locales/pl.json | 2 + app/javascript/mastodon/locales/pt-BR.json | 2 + app/javascript/mastodon/locales/pt.json | 2 + app/javascript/mastodon/locales/ru.json | 2 + app/javascript/mastodon/locales/th.json | 2 + app/javascript/mastodon/locales/tr.json | 2 + app/javascript/mastodon/locales/uk.json | 2 + app/javascript/mastodon/locales/zh-CN.json | 2 + app/javascript/mastodon/locales/zh-HK.json | 2 + app/javascript/mastodon/locales/zh-TW.json | 2 + app/javascript/mastodon/reducers/statuses.js | 4 + app/models/account.rb | 4 + app/models/concerns/account_interactions.rb | 4 + app/models/status.rb | 4 + app/models/status_pin.rb | 16 ++++ .../status_relationships_presenter.rb | 19 +++-- app/serializers/rest/status_serializer.rb | 16 ++++ app/validators/status_pin_validator.rb | 9 +++ app/views/accounts/show.html.haml | 3 + app/views/stream_entries/_status.html.haml | 7 ++ config/locales/en.yml | 5 ++ config/routes.rb | 13 +++- .../20170823162448_create_status_pins.rb | 10 +++ db/schema.rb | 12 ++- .../v1/accounts/statuses_controller_spec.rb | 36 ++++++--- .../api/v1/statuses/pins_controller_spec.rb | 57 ++++++++++++++ spec/fabricators/status_pin_fabricator.rb | 4 + spec/models/status_pin_spec.rb | 41 ++++++++++ 59 files changed, 493 insertions(+), 29 deletions(-) create mode 100644 app/controllers/api/v1/statuses/pins_controller.rb create mode 100644 app/models/status_pin.rb create mode 100644 app/validators/status_pin_validator.rb create mode 100644 db/migrate/20170823162448_create_status_pins.rb create mode 100644 spec/controllers/api/v1/statuses/pins_controller_spec.rb create mode 100644 spec/fabricators/status_pin_fabricator.rb create mode 100644 spec/models/status_pin_spec.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c6b98628e37..f4ca239badb 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,14 +7,17 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do + @pinned_statuses = [] + if current_account && @account.blocking?(current_account) @statuses = [] return end - @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) - @statuses = cache_collection(@statuses, Status) - @next_url = next_url unless @statuses.empty? + @pinned_statuses = cache_collection(@account.pinned_statuses.limit(1), Status) unless media_requested? + @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = cache_collection(@statuses, Status) + @next_url = next_url unless @statuses.empty? end format.atom do @@ -32,8 +35,8 @@ class AccountsController < ApplicationController def filtered_statuses default_statuses.tap do |statuses| - statuses.merge!(only_media_scope) if request.path.ends_with?('/media') - statuses.merge!(no_replies_scope) unless request.path.ends_with?('/with_replies') + statuses.merge!(only_media_scope) if media_requested? + statuses.merge!(no_replies_scope) unless replies_requested? end end @@ -58,12 +61,20 @@ class AccountsController < ApplicationController end def next_url - if request.path.ends_with?('/media') + if media_requested? short_account_media_url(@account, max_id: @statuses.last.id) - elsif request.path.ends_with?('/with_replies') + elsif replies_requested? short_account_with_replies_url(@account, max_id: @statuses.last.id) else short_account_url(@account, max_id: @statuses.last.id) end end + + def media_requested? + request.path.ends_with?('/media') + end + + def replies_requested? + request.path.ends_with?('/with_replies') + end end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index d9ae5c0896b..095f6937b00 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses default_statuses.tap do |statuses| statuses.merge!(only_media_scope) if params[:only_media] + statuses.merge!(pinned_scope) if params[:pinned] statuses.merge!(no_replies_scope) if params[:exclude_replies] end end @@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @account.media_attachments.attached.reorder(nil).select(:status_id).distinct end + def pinned_scope + @account.pinned_statuses + end + def no_replies_scope Status.without_replies end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb new file mode 100644 index 00000000000..3de1009b842 --- /dev/null +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::PinsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + before_action :set_status + + respond_to :json + + def create + StatusPin.create!(account: current_account, status: @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + pin = StatusPin.find_by(account: current_account, status: @status) + pin&.destroy! + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 36eec4934f0..7b5f4bd9c18 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) { error, }; }; + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(pinSuccess(status, response.data)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +}; + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + }; +}; + +export function pinSuccess(status, response) { + return { + type: PIN_SUCCESS, + status, + response, + }; +}; + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + }; +}; + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(unpinSuccess(status, response.data)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +}; + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + }; +}; + +export function unpinSuccess(status, response) { + return { + type: UNPIN_SUCCESS, + status, + response, + }; +}; + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + }; +}; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 38a4aafc181..b4f523f725e 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -31,6 +31,7 @@ export default class Status extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0d8c9add42c..6436d6ebeda 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -21,6 +21,8 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, }); @injectIntl @@ -41,6 +43,7 @@ export default class StatusActionBar extends ImmutablePureComponent { onBlock: PropTypes.func, onReport: PropTypes.func, onMuteConversation: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -77,6 +80,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -121,6 +128,10 @@ export default class StatusActionBar extends ImmutablePureComponent { } if (status.getIn(['account', 'id']) === me) { + if (['public', 'unlisted'].indexOf(status.get('visibility')) !== -1) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index b150165aaef..c488b6ce7f0 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -11,6 +11,8 @@ import { favourite, unreblog, unfavourite, + pin, + unpin, } from '../actions/interactions'; import { blockAccount, @@ -72,6 +74,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + onDelete (status) { if (!this.deleteModal) { dispatch(deleteStatus(status.get('id'))); diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 91ac64de27e..c4a61467765 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -14,6 +14,8 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, }); @injectIntl @@ -31,6 +33,7 @@ export default class ActionBar extends React.PureComponent { onDelete: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number.isRequired, intl: PropTypes.object.isRequired, }; @@ -59,6 +62,10 @@ export default class ActionBar extends React.PureComponent { this.props.onReport(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleShare = () => { navigator.share({ text: this.props.status.get('search_index'), @@ -72,6 +79,10 @@ export default class ActionBar extends React.PureComponent { let menu = []; if (me === status.getIn(['account', 'id'])) { + if (['public', 'unlisted'].indexOf(status.get('visibility')) !== -1) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index cbabdd5bc7d..84e717a1217 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -12,6 +12,8 @@ import { unfavourite, reblog, unreblog, + pin, + unpin, } from '../../actions/interactions'; import { replyCompose, @@ -87,6 +89,14 @@ export default class Status extends ImmutablePureComponent { } } + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + handleReplyClick = (status) => { this.props.dispatch(replyCompose(status, this.context.router.history)); } @@ -187,6 +197,7 @@ export default class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} + onPin={this.handlePin} /> {descendants} diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index f5cf77f9221..fa8cda97d1c 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -168,6 +168,7 @@ "status.mention": "أذكُر @{name}", "status.mute_conversation": "Mute conversation", "status.open": "وسع هذه المشاركة", + "status.pin": "Pin on profile", "status.reblog": "رَقِّي", "status.reblogged_by": "{name} رقى", "status.reply": "ردّ", @@ -179,6 +180,7 @@ "status.show_less": "إعرض أقلّ", "status.show_more": "أظهر المزيد", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "تحرير", "tabs_bar.federated_timeline": "الموحَّد", "tabs_bar.home": "الرئيسية", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index e6788f9eb50..4aa097d31d6 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -168,6 +168,7 @@ "status.mention": "Споменаване", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Споделяне", "status.reblogged_by": "{name} сподели", "status.reply": "Отговор", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Съставяне", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Начало", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 95b3c60bfc8..d9cb7c7a320 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -168,6 +168,7 @@ "status.mention": "Esmentar @{name}", "status.mute_conversation": "Silenciar conversació", "status.open": "Ampliar aquest estat", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} ha retootejat", "status.reply": "Respondre", @@ -179,6 +180,7 @@ "status.show_less": "Mostra menys", "status.show_more": "Mostra més", "status.unmute_conversation": "Activar conversació", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compondre", "tabs_bar.federated_timeline": "Federada", "tabs_bar.home": "Inici", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 67a99b76512..a5232552f8b 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -168,6 +168,7 @@ "status.mention": "Erwähnen", "status.mute_conversation": "Mute conversation", "status.open": "Öffnen", + "status.pin": "Pin on profile", "status.reblog": "Teilen", "status.reblogged_by": "{name} teilte", "status.reply": "Antworten", @@ -179,6 +180,7 @@ "status.show_less": "Weniger anzeigen", "status.show_more": "Mehr anzeigen", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Schreiben", "tabs_bar.federated_timeline": "Föderation", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index ef76f6e5bcd..fdb8aefe1ad 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -189,6 +189,14 @@ { "defaultMessage": "Unmute conversation", "id": "status.unmute_conversation" + }, + { + "defaultMessage": "Pin on profile", + "id": "status.pin" + }, + { + "defaultMessage": "Unpin from profile", + "id": "status.unpin" } ], "path": "app/javascript/mastodon/components/status_action_bar.json" @@ -1035,6 +1043,14 @@ { "defaultMessage": "Share", "id": "status.share" + }, + { + "defaultMessage": "Pin on profile", + "id": "status.pin" + }, + { + "defaultMessage": "Unpin from profile", + "id": "status.unpin" } ], "path": "app/javascript/mastodon/features/status/components/action_bar.json" diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 2ea2062d395..5950638884c 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -168,6 +168,7 @@ "status.mention": "Mention @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boosted", "status.reply": "Reply", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compose", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 960d747ec98..ed323f4062a 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -168,6 +168,7 @@ "status.mention": "Mencii @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Diskonigi", "status.reblogged_by": "{name} diskonigita", "status.reply": "Respondi", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Ekskribi", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Hejmo", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 212d16639b5..2fee2914851 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -168,6 +168,7 @@ "status.mention": "Mencionar", "status.mute_conversation": "Mute conversation", "status.open": "Expandir estado", + "status.pin": "Pin on profile", "status.reblog": "Retoot", "status.reblogged_by": "Retooteado por {name}", "status.reply": "Responder", @@ -179,6 +180,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar más", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Redactar", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Inicio", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 5ada62f934f..89fa014e4de 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -168,6 +168,7 @@ "status.mention": "نام‌بردن از @{name}", "status.mute_conversation": "بی‌صداکردن گفتگو", "status.open": "این نوشته را باز کن", + "status.pin": "Pin on profile", "status.reblog": "بازبوقیدن", "status.reblogged_by": "‫{name}‬ بازبوقید", "status.reply": "پاسخ", @@ -179,6 +180,7 @@ "status.show_less": "نهفتن", "status.show_more": "نمایش", "status.unmute_conversation": "باصداکردن گفتگو", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "بنویسید", "tabs_bar.federated_timeline": "همگانی", "tabs_bar.home": "خانه", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index cb9e9c2a699..1c1334899ff 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -168,6 +168,7 @@ "status.mention": "Mainitse @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Buustaa", "status.reblogged_by": "{name} buustasi", "status.reply": "Vastaa", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Luo", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Koti", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 34a89a69f1c..479b8de7da4 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -168,6 +168,7 @@ "status.mention": "Mentionner", "status.mute_conversation": "Masquer la conversation", "status.open": "Déplier ce statut", + "status.pin": "Pin on profile", "status.reblog": "Partager", "status.reblogged_by": "{name} a partagé :", "status.reply": "Répondre", @@ -179,6 +180,7 @@ "status.show_less": "Replier", "status.show_more": "Déplier", "status.unmute_conversation": "Ne plus masquer la conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Composer", "tabs_bar.federated_timeline": "Fil public global", "tabs_bar.home": "Accueil", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 34266d8e104..1e221af9c5d 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -168,6 +168,7 @@ "status.mention": "פניה אל @{name}", "status.mute_conversation": "השתקת שיחה", "status.open": "הרחבת הודעה", + "status.pin": "Pin on profile", "status.reblog": "הדהוד", "status.reblogged_by": "הודהד על ידי {name}", "status.reply": "תגובה", @@ -179,6 +180,7 @@ "status.show_less": "הראה פחות", "status.show_more": "הראה יותר", "status.unmute_conversation": "הסרת השתקת שיחה", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "חיבור", "tabs_bar.federated_timeline": "ציר זמן בין-קהילתי", "tabs_bar.home": "בבית", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index f69b096d46d..2effecb1eaa 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -168,6 +168,7 @@ "status.mention": "Spomeni @{name}", "status.mute_conversation": "Utišaj razgovor", "status.open": "Proširi ovaj status", + "status.pin": "Pin on profile", "status.reblog": "Podigni", "status.reblogged_by": "{name} je podigao", "status.reply": "Odgovori", @@ -179,6 +180,7 @@ "status.show_less": "Pokaži manje", "status.show_more": "Pokaži više", "status.unmute_conversation": "Poništi utišavanje razgovora", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Sastavi", "tabs_bar.federated_timeline": "Federalni", "tabs_bar.home": "Dom", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 4d2a509630a..59a7b8debf8 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -168,6 +168,7 @@ "status.mention": "Említés", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Reblog", "status.reblogged_by": "{name} reblogolta", "status.reply": "Válasz", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Összeállítás", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Kezdőlap", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 532739e3ccd..9dd66b6cdac 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -168,6 +168,7 @@ "status.mention": "Balasan @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Tampilkan status ini", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "di-boost {name}", "status.reply": "Balas", @@ -179,6 +180,7 @@ "status.show_less": "Tampilkan lebih sedikit", "status.show_more": "Tampilkan semua", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Tulis", "tabs_bar.federated_timeline": "Gabungan", "tabs_bar.home": "Beranda", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index a5e363e409e..07184ae811f 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -168,6 +168,7 @@ "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Detaligar ca mesajo", + "status.pin": "Pin on profile", "status.reblog": "Repetar", "status.reblogged_by": "{name} repetita", "status.reply": "Respondar", @@ -179,6 +180,7 @@ "status.show_less": "Montrar mine", "status.show_more": "Montrar plue", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Kompozar", "tabs_bar.federated_timeline": "Federata", "tabs_bar.home": "Hemo", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 329eb82cad5..369ae7f320a 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -168,6 +168,7 @@ "status.mention": "Nomina @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Espandi questo post", + "status.pin": "Pin on profile", "status.reblog": "Condividi", "status.reblogged_by": "{name} ha condiviso", "status.reply": "Rispondi", @@ -179,6 +180,7 @@ "status.show_less": "Mostra meno", "status.show_more": "Mostra di più", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Scrivi", "tabs_bar.federated_timeline": "Federazione", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 757190c90ec..c35b0def3f7 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -168,6 +168,7 @@ "status.mention": "返信", "status.mute_conversation": "会話をミュート", "status.open": "詳細を表示", + "status.pin": "Pin on profile", "status.reblog": "ブースト", "status.reblogged_by": "{name}さんにブーストされました", "status.reply": "返信", @@ -179,6 +180,7 @@ "status.show_less": "隠す", "status.show_more": "もっと見る", "status.unmute_conversation": "会話のミュートを解除", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "投稿", "tabs_bar.federated_timeline": "連合", "tabs_bar.home": "ホーム", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 47d0d4087a5..52ba1e70f6d 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -168,6 +168,7 @@ "status.mention": "답장", "status.mute_conversation": "이 대화를 뮤트", "status.open": "상세 정보 표시", + "status.pin": "Pin on profile", "status.reblog": "부스트", "status.reblogged_by": "{name}님이 부스트 했습니다", "status.reply": "답장", @@ -179,6 +180,7 @@ "status.show_less": "숨기기", "status.show_more": "더 보기", "status.unmute_conversation": "이 대화의 뮤트 해제하기", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "포스트", "tabs_bar.federated_timeline": "연합", "tabs_bar.home": "홈", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 4d68c799257..fb4127831b5 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -168,6 +168,7 @@ "status.mention": "Vermeld @{name}", "status.mute_conversation": "Negeer conversatie", "status.open": "Toot volledig tonen", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boostte", "status.reply": "Reageren", @@ -179,6 +180,7 @@ "status.show_less": "Minder tonen", "status.show_more": "Meer tonen", "status.unmute_conversation": "Conversatie niet meer negeren", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Schrijven", "tabs_bar.federated_timeline": "Globaal", "tabs_bar.home": "Start", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 9453e65ff64..2d6224c482d 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -168,6 +168,7 @@ "status.mention": "Nevn @{name}", "status.mute_conversation": "Demp samtale", "status.open": "Utvid denne statusen", + "status.pin": "Pin on profile", "status.reblog": "Fremhev", "status.reblogged_by": "Fremhevd av {name}", "status.reply": "Svar", @@ -179,6 +180,7 @@ "status.show_less": "Vis mindre", "status.show_more": "Vis mer", "status.unmute_conversation": "Ikke demp samtale", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Komponer", "tabs_bar.federated_timeline": "Felles", "tabs_bar.home": "Hjem", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 5e5e28af0a8..34e1a8c47f8 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -168,6 +168,7 @@ "status.mention": "Mencionar", "status.mute_conversation": "Rescondre la conversacion", "status.open": "Desplegar aqueste estatut", + "status.pin": "Pin on profile", "status.reblog": "Partejar", "status.reblogged_by": "{name} a partejat :", "status.reply": "Respondre", @@ -179,6 +180,7 @@ "status.show_less": "Tornar plegar", "status.show_more": "Desplegar", "status.unmute_conversation": "Conversacions amb silenci levat", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compausar", "tabs_bar.federated_timeline": "Flux public global", "tabs_bar.home": "Acuèlh", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index af38bbb6c67..8a8d0f38a4a 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -168,6 +168,7 @@ "status.mention": "Wspomnij o @{name}", "status.mute_conversation": "Wycisz konwersację", "status.open": "Rozszerz ten status", + "status.pin": "Pin on profile", "status.reblog": "Podbij", "status.reblogged_by": "{name} podbił", "status.reply": "Odpowiedz", @@ -179,6 +180,7 @@ "status.show_less": "Pokaż mniej", "status.show_more": "Pokaż więcej", "status.unmute_conversation": "Cofnij wyciszenie konwersacji", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Napisz", "tabs_bar.federated_timeline": "Globalne", "tabs_bar.home": "Strona główna", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 55d2f05de26..8a299e27221 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -168,6 +168,7 @@ "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expandir", + "status.pin": "Pin on profile", "status.reblog": "Partilhar", "status.reblogged_by": "{name} partilhou", "status.reply": "Responder", @@ -179,6 +180,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar mais", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Criar", "tabs_bar.federated_timeline": "Global", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 55d2f05de26..8a299e27221 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -168,6 +168,7 @@ "status.mention": "Mencionar @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expandir", + "status.pin": "Pin on profile", "status.reblog": "Partilhar", "status.reblogged_by": "{name} partilhou", "status.reply": "Responder", @@ -179,6 +180,7 @@ "status.show_less": "Mostrar menos", "status.show_more": "Mostrar mais", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Criar", "tabs_bar.federated_timeline": "Global", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index af38fc72312..822f116c743 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -168,6 +168,7 @@ "status.mention": "Упомянуть @{name}", "status.mute_conversation": "Заглушить тред", "status.open": "Развернуть статус", + "status.pin": "Pin on profile", "status.reblog": "Продвинуть", "status.reblogged_by": "{name} продвинул(а)", "status.reply": "Ответить", @@ -179,6 +180,7 @@ "status.show_less": "Свернуть", "status.show_more": "Развернуть", "status.unmute_conversation": "Снять глушение с треда", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Написать", "tabs_bar.federated_timeline": "Глобальная", "tabs_bar.home": "Главная", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index aa0929f8216..9c985eec958 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -168,6 +168,7 @@ "status.mention": "Mention @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Expand this status", + "status.pin": "Pin on profile", "status.reblog": "Boost", "status.reblogged_by": "{name} boosted", "status.reply": "Reply", @@ -179,6 +180,7 @@ "status.show_less": "Show less", "status.show_more": "Show more", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Compose", "tabs_bar.federated_timeline": "Federated", "tabs_bar.home": "Home", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 37ce8597e4d..41c9d44a77f 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -168,6 +168,7 @@ "status.mention": "Bahset @{name}", "status.mute_conversation": "Mute conversation", "status.open": "Bu gönderiyi genişlet", + "status.pin": "Pin on profile", "status.reblog": "Boost'la", "status.reblogged_by": "{name} boost etti", "status.reply": "Cevapla", @@ -179,6 +180,7 @@ "status.show_less": "Daha azı", "status.show_more": "Daha fazlası", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Oluştur", "tabs_bar.federated_timeline": "Federe", "tabs_bar.home": "Ana sayfa", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index fea7bd94e6c..6087e3a1e01 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -168,6 +168,7 @@ "status.mention": "Згадати", "status.mute_conversation": "Заглушити діалог", "status.open": "Розгорнути допис", + "status.pin": "Pin on profile", "status.reblog": "Передмухнути", "status.reblogged_by": "{name} передмухнув(-ла)", "status.reply": "Відповісти", @@ -179,6 +180,7 @@ "status.show_less": "Згорнути", "status.show_more": "Розгорнути", "status.unmute_conversation": "Зняти глушення з діалогу", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "Написати", "tabs_bar.federated_timeline": "Глобальна", "tabs_bar.home": "Головна", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index e7c43145477..2e3b4b0b897 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -168,6 +168,7 @@ "status.mention": "提及 @{name}", "status.mute_conversation": "Mute conversation", "status.open": "展开嘟文", + "status.pin": "Pin on profile", "status.reblog": "转嘟", "status.reblogged_by": "{name} 转嘟", "status.reply": "回应", @@ -179,6 +180,7 @@ "status.show_less": "减少显示", "status.show_more": "显示更多", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "撰写", "tabs_bar.federated_timeline": "跨站", "tabs_bar.home": "主页", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 7312aae82a9..1ab3b3f9d83 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -168,6 +168,7 @@ "status.mention": "提及 @{name}", "status.mute_conversation": "Mute conversation", "status.open": "展開文章", + "status.pin": "Pin on profile", "status.reblog": "轉推", "status.reblogged_by": "{name} 轉推", "status.reply": "回應", @@ -179,6 +180,7 @@ "status.show_less": "減少顯示", "status.show_more": "顯示更多", "status.unmute_conversation": "Unmute conversation", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "撰寫", "tabs_bar.federated_timeline": "跨站", "tabs_bar.home": "主頁", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 1c2e3527293..571a2383d75 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -168,6 +168,7 @@ "status.mention": "提到 @{name}", "status.mute_conversation": "消音對話", "status.open": "展開這個狀態", + "status.pin": "Pin on profile", "status.reblog": "轉推", "status.reblogged_by": "{name} 轉推了", "status.reply": "回應", @@ -179,6 +180,7 @@ "status.show_less": "看少點", "status.show_more": "看更多", "status.unmute_conversation": "不消音對話", + "status.unpin": "Unpin from profile", "tabs_bar.compose": "編輯", "tabs_bar.federated_timeline": "聯盟", "tabs_bar.home": "家", diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 3e40b0b424f..38691dc4306 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -7,6 +7,8 @@ import { FAVOURITE_SUCCESS, FAVOURITE_FAIL, UNFAVOURITE_SUCCESS, + PIN_SUCCESS, + UNPIN_SUCCESS, } from '../actions/interactions'; import { STATUS_FETCH_SUCCESS, @@ -114,6 +116,8 @@ export default function statuses(state = initialState, action) { case UNREBLOG_SUCCESS: case FAVOURITE_SUCCESS: case UNFAVOURITE_SUCCESS: + case PIN_SUCCESS: + case UNPIN_SUCCESS: return normalizeStatus(state, action.response); case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); diff --git a/app/models/account.rb b/app/models/account.rb index 0c9c6aed457..b83aa115946 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -77,6 +77,10 @@ class Account < ApplicationRecord has_many :mentions, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy + # Pinned statuses + has_many :status_pins, inverse_of: :account, dependent: :destroy + has_many :pinned_statuses, through: :status_pins, class_name: 'Status', source: :status + # Media has_many :media_attachments, dependent: :destroy diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 9ffed29101c..b26520f5bd5 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -138,4 +138,8 @@ module AccountInteractions def reblogged?(status) status.proper.reblogs.where(account: self).exists? end + + def pinned?(status) + status_pins.where(status: status).exists? + end end diff --git a/app/models/status.rb b/app/models/status.rb index 24eaf7071f7..3dc83ad1f85 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -164,6 +164,10 @@ class Status < ApplicationRecord ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h end + def pins_map(status_ids, account_id) + StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h + end + def reload_stale_associations!(cached_items) account_ids = [] diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb new file mode 100644 index 00000000000..c9a6693441e --- /dev/null +++ b/app/models/status_pin.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: status_pins +# +# id :integer not null, primary key +# account_id :integer not null +# status_id :integer not null +# + +class StatusPin < ApplicationRecord + belongs_to :account, required: true + belongs_to :status, required: true + + validates_with StatusPinValidator +end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 03294015f37..10b44950487 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -1,19 +1,24 @@ # frozen_string_literal: true class StatusRelationshipsPresenter - attr_reader :reblogs_map, :favourites_map, :mutes_map + attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map - def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {}) + def initialize(statuses, current_account_id = nil, options = {}) if current_account_id.nil? @reblogs_map = {} @favourites_map = {} @mutes_map = {} + @pins_map = {} else - status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq - conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq - @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map) - @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(favourites_map) - @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map) + statuses = statuses.compact + status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq + conversation_ids = statuses.map(&:conversation_id).compact.uniq + pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id) + + @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {}) + @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) + @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {}) + @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {}) end end end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 246b12a909c..298a3bb40f3 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? + attribute :pinned, if: :pinnable? belongs_to :reblog, serializer: REST::StatusSerializer belongs_to :application @@ -57,6 +58,21 @@ class REST::StatusSerializer < ActiveModel::Serializer end end + def pinned + if instance_options && instance_options[:relationships] + instance_options[:relationships].pins_map[object.id] || false + else + current_user.account.pinned?(object) + end + end + + def pinnable? + current_user? && + current_user.account_id == object.account_id && + !object.reblog? && + %w(public unlisted).include?(object.visibility) + end + class ApplicationSerializer < ActiveModel::Serializer attributes :name, :website end diff --git a/app/validators/status_pin_validator.rb b/app/validators/status_pin_validator.rb new file mode 100644 index 00000000000..f557df6af66 --- /dev/null +++ b/app/validators/status_pin_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class StatusPinValidator < ActiveModel::Validator + def validate(pin) + pin.errors.add(:status, I18n.t('statuses.pin_errors.reblog')) if pin.status.reblog? + pin.errors.add(:status, I18n.t('statuses.pin_errors.ownership')) if pin.account_id != pin.status.account_id + pin.errors.add(:status, I18n.t('statuses.pin_errors.private')) unless %w(public unlisted).include?(pin.status.visibility) + end +end diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index ec44f4c74bf..e0f9f869ace 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -30,6 +30,9 @@ = render 'nothing_here' - else .activity-stream.with-header + - if params[:page].to_i.zero? + = render partial: 'stream_entries/status', collection: @pinned_statuses, as: :status, locals: { pinned: true } + = render partial: 'stream_entries/status', collection: @statuses, as: :status - if @statuses.size == 20 diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index 50a3737436a..e2e1fdd1216 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -1,4 +1,5 @@ :ruby + pinned ||= false include_threads ||= false is_predecessor ||= false is_successor ||= false @@ -25,6 +26,12 @@ = link_to TagManager.instance.url_for(status.account), class: 'status__display-name muted' do %strong.emojify= display_name(status.account) = t('stream_entries.reblogged') + - elsif pinned + .pre-header + .pre-header__icon + = fa_icon('thumb-tack fw') + %span + = t('stream_entries.pinned') = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper diff --git a/config/locales/en.yml b/config/locales/en.yml index 97bb141863f..96d08e6b2ea 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -434,6 +434,10 @@ en: statuses: open_in_web: Open in web over_character_limit: character limit of %{max} exceeded + pin_errors: + ownership: Someone else's toot cannot be pinned + private: Non-public toot cannot be pinned + reblog: A boost cannot be pinned show_more: Show more visibilities: private: Followers-only @@ -444,6 +448,7 @@ en: unlisted_long: Everyone can see, but not listed on public timelines stream_entries: click_to_show: Click to show + pinned: Pinned toot reblogged: boosted sensitive_content: Sensitive content terms: diff --git a/config/routes.rb b/config/routes.rb index 94a4ac88ee4..7588805c0d1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -162,6 +162,9 @@ Rails.application.routes.draw do resource :mute, only: :create post :unmute, to: 'mutes#destroy' + + resource :pin, only: :create + post :unpin, to: 'pins#destroy' end member do @@ -175,7 +178,8 @@ Rails.application.routes.draw do resource :public, only: :show, controller: :public resources :tag, only: :show end - resources :streaming, only: [:index] + + resources :streaming, only: [:index] get '/search', to: 'search#index', as: :search @@ -210,6 +214,7 @@ Rails.application.routes.draw do resource :search, only: :show, controller: :search resources :relationships, only: :index end + resources :accounts, only: [:show] do resources :statuses, only: :index, controller: 'accounts/statuses' resources :followers, only: :index, controller: 'accounts/follower_accounts' @@ -245,7 +250,7 @@ Rails.application.routes.draw do root 'home#index' match '*unmatched_route', - via: :all, - to: 'application#raise_not_found', - format: false + via: :all, + to: 'application#raise_not_found', + format: false end diff --git a/db/migrate/20170823162448_create_status_pins.rb b/db/migrate/20170823162448_create_status_pins.rb new file mode 100644 index 00000000000..9a6d4a7b9bc --- /dev/null +++ b/db/migrate/20170823162448_create_status_pins.rb @@ -0,0 +1,10 @@ +class CreateStatusPins < ActiveRecord::Migration[5.1] + def change + create_table :status_pins do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :status, foreign_key: { on_delete: :cascade }, null: false + end + + add_index :status_pins, [:account_id, :status_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 98b07e28276..d0e72be0fc0 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.define(version: 20170720000000) do +ActiveRecord::Schema.define(version: 20170823162448) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -282,6 +282,14 @@ ActiveRecord::Schema.define(version: 20170720000000) do t.index ["thing_type", "thing_id", "var"], name: "index_settings_on_thing_type_and_thing_id_and_var", unique: true end + create_table "status_pins", force: :cascade do |t| + t.bigint "account_id", null: false + t.bigint "status_id", null: false + t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true + t.index ["account_id"], name: "index_status_pins_on_account_id" + t.index ["status_id"], name: "index_status_pins_on_status_id" + end + create_table "statuses", force: :cascade do |t| t.string "uri" t.integer "account_id", null: false @@ -430,6 +438,8 @@ ActiveRecord::Schema.define(version: 20170720000000) do add_foreign_key "reports", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade add_foreign_key "session_activations", "users", on_delete: :cascade + add_foreign_key "status_pins", "accounts", on_delete: :cascade + add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", on_delete: :nullify add_foreign_key "statuses", "accounts", on_delete: :cascade add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb index 8b4fd6a5bc3..c49a77ac34a 100644 --- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb @@ -18,21 +18,37 @@ describe Api::V1::Accounts::StatusesController do expect(response).to have_http_status(:success) expect(response.headers['Link'].links.size).to eq(2) end - end - describe 'GET #index with only media' do - it 'returns http success' do - get :index, params: { account_id: user.account.id, only_media: true } + context 'with only media' do + it 'returns http success' do + get :index, params: { account_id: user.account.id, only_media: true } - expect(response).to have_http_status(:success) + expect(response).to have_http_status(:success) + end end - end - describe 'GET #index with exclude replies' do - it 'returns http success' do - get :index, params: { account_id: user.account.id, exclude_replies: true } + context 'with exclude replies' do + before do + Fabricate(:status, account: user.account, thread: Fabricate(:status)) + end - expect(response).to have_http_status(:success) + it 'returns http success' do + get :index, params: { account_id: user.account.id, exclude_replies: true } + + expect(response).to have_http_status(:success) + end + end + + context 'with only pinned' do + before do + Fabricate(:status_pin, account: user.account, status: Fabricate(:status, account: user.account)) + end + + it 'returns http success' do + get :index, params: { account_id: user.account.id, pinned: true } + + expect(response).to have_http_status(:success) + end end end end diff --git a/spec/controllers/api/v1/statuses/pins_controller_spec.rb b/spec/controllers/api/v1/statuses/pins_controller_spec.rb new file mode 100644 index 00000000000..2e170da2409 --- /dev/null +++ b/spec/controllers/api/v1/statuses/pins_controller_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::V1::Statuses::PinsController do + render_views + + let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write', application: app) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'POST #create' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + post :create, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the pinned attribute' do + expect(user.account.pinned?(status)).to be true + end + + it 'return json with updated attributes' do + hash_body = body_as_json + + expect(hash_body[:id]).to eq status.id + expect(hash_body[:pinned]).to be true + end + end + + describe 'POST #destroy' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Fabricate(:status_pin, status: status, account: user.account) + post :destroy, params: { status_id: status.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'updates the pinned attribute' do + expect(user.account.pinned?(status)).to be false + end + end + end +end diff --git a/spec/fabricators/status_pin_fabricator.rb b/spec/fabricators/status_pin_fabricator.rb new file mode 100644 index 00000000000..6a9006c9fb2 --- /dev/null +++ b/spec/fabricators/status_pin_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:status_pin) do + account + status +end diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb new file mode 100644 index 00000000000..6f54f80f9f7 --- /dev/null +++ b/spec/models/status_pin_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe StatusPin, type: :model do + describe 'validations' do + it 'allows pins of own statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account) + + expect(StatusPin.new(account: account, status: status).save).to be true + end + + it 'does not allow pins of statuses by someone else' do + account = Fabricate(:account) + status = Fabricate(:status) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + + it 'does not allow pins of reblogs' do + account = Fabricate(:account) + status = Fabricate(:status, account: account) + reblog = Fabricate(:status, reblog: status) + + expect(StatusPin.new(account: account, status: reblog).save).to be false + end + + it 'does not allow pins of private statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account, visibility: :private) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + + it 'does not allow pins of direct statuses' do + account = Fabricate(:account) + status = Fabricate(:status, account: account, visibility: :direct) + + expect(StatusPin.new(account: account, status: status).save).to be false + end + end +end From 409051c22c033160a62997831660d9dda2db7459 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Fri, 25 Aug 2017 10:58:31 +0200 Subject: [PATCH 089/201] i18n: Update Polish translation #4675 (#4692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcin Mikołajczak --- app/javascript/mastodon/locales/pl.json | 4 ++-- config/locales/pl.yml | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 8a8d0f38a4a..555b76f8ed2 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -168,7 +168,7 @@ "status.mention": "Wspomnij o @{name}", "status.mute_conversation": "Wycisz konwersację", "status.open": "Rozszerz ten status", - "status.pin": "Pin on profile", + "status.pin": "Przypnij do profilu", "status.reblog": "Podbij", "status.reblogged_by": "{name} podbił", "status.reply": "Odpowiedz", @@ -180,7 +180,7 @@ "status.show_less": "Pokaż mniej", "status.show_more": "Pokaż więcej", "status.unmute_conversation": "Cofnij wyciszenie konwersacji", - "status.unpin": "Unpin from profile", + "status.unpin": "Odepnij z profilu", "tabs_bar.compose": "Napisz", "tabs_bar.federated_timeline": "Globalne", "tabs_bar.home": "Strona główna", diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 182cbf65e06..9f0d9bb29a6 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -438,6 +438,10 @@ pl: statuses: open_in_web: Otwórz w przeglądarce over_character_limit: limit %{max} znaków przekroczony + pin_errors: + ownership: Nie możesz przypiąć cudzego wpisu + private: Nie możesz przypiąć niepublicznego wpisu + reblog: Nie możesz przypiąć podbicia wpisu show_more: Pokaż więcej visibilities: private: Tylko dla śledzących @@ -448,6 +452,7 @@ pl: unlisted_long: Widoczne dla wszystkich, ale nie wyświetlane na publicznych osiach czasu stream_entries: click_to_show: Naciśnij aby wyświetlić + pinned: Przypięty wpis reblogged: podbił sensitive_content: Wrażliwa zawartość terms: From b88635202f49c9d6f2770c70984cb232b508de52 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 25 Aug 2017 20:03:26 +0900 Subject: [PATCH 090/201] Add label for application scopes (#4691) * Add label for application scopes * hint --- app/views/settings/applications/_fields.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml index 83297a1ae7e..b21f3cca62b 100644 --- a/app/views/settings/applications/_fields.html.haml +++ b/app/views/settings/applications/_fields.html.haml @@ -13,9 +13,9 @@ collection: Doorkeeper.configuration.scopes, wrapper: :with_label, include_blank: false, + label_method: lambda { |scope| safe_join([scope, content_tag(:span, t("doorkeeper.scopes.#{scope}"), class: 'hint')]) }, selected: f.object.scopes.all, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - From 7c03e59338913be69683e07f4d7830c94c5621cd Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 25 Aug 2017 21:17:08 +0900 Subject: [PATCH 091/201] Update addressable to version 2.5.2 (#4686) --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 9c830a7b002..adc37f7de04 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -44,8 +44,8 @@ GEM i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.5.1) - public_suffix (~> 2.0, >= 2.0.2) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) annotate (2.7.2) @@ -298,7 +298,7 @@ GEM slop (~> 3.4) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (2.0.5) + public_suffix (3.0.0) puma (3.9.1) pundit (1.1.0) activesupport (>= 3.0.0) @@ -590,4 +590,4 @@ RUBY VERSION ruby 2.4.1p111 BUNDLED WITH - 1.15.3 + 1.15.4 From 04c3fb2189315d03e87f000a40da973cf863cd30 Mon Sep 17 00:00:00 2001 From: Quent-in Date: Fri, 25 Aug 2017 16:04:52 +0200 Subject: [PATCH 092/201] i18n Updated strings (#4675 - pinned toot) (#4695) * Added string for pinned toots * Pinned toot #4675 + missing string Somehow I deleted it "enabled_success" * update after advice --- app/javascript/mastodon/locales/oc.json | 4 ++-- config/locales/oc.yml | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 34e1a8c47f8..44e200d687e 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -168,7 +168,7 @@ "status.mention": "Mencionar", "status.mute_conversation": "Rescondre la conversacion", "status.open": "Desplegar aqueste estatut", - "status.pin": "Pin on profile", + "status.pin": "Penjar al perfil", "status.reblog": "Partejar", "status.reblogged_by": "{name} a partejat :", "status.reply": "Respondre", @@ -180,7 +180,7 @@ "status.show_less": "Tornar plegar", "status.show_more": "Desplegar", "status.unmute_conversation": "Conversacions amb silenci levat", - "status.unpin": "Unpin from profile", + "status.unpin": "Despenjar del perfil", "tabs_bar.compose": "Compausar", "tabs_bar.federated_timeline": "Flux public global", "tabs_bar.home": "Acuèlh", diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 35eb79b3315..99c377f1849 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -511,6 +511,10 @@ oc: statuses: open_in_web: Dobrir sul web over_character_limit: limit de %{max} caractèrs passat + pin_errors: + ownership: Se pòt pas penjar lo tut de qualqu’un mai + private: Se pòt pas penjar los tuts pas publics + reblog: Se pòt pas penjar un tut partejat show_more: Ne veire mai visibilities: private: Seguidors solament @@ -520,7 +524,8 @@ oc: unlisted: Pas listat unlisted_long: Tot lo mond pòt veire mai serà pas visible sul flux public stream_entries: - click_to_show: Clicatz per afichar + click_to_show: Clicatz per veire + pinned: Tut penjat reblogged: a partejat sensitive_content: Contengut sensible terms: @@ -601,7 +606,8 @@ oc: description_html: S’activatz l’autentificacion two-factor, vos caldrà vòstre mobil per vos connectar perque generarà un geton per vos daissar dintrar. disable: Desactivar enable: Activar - enabled: L’autentificacion en dos temps es activada + enabled: Autentificacion en dos temps activada + enabled_success: L’autentificacion en dos temps es ben activada generate_recovery_codes: Generar los còdis de recuperacion instructions_html: "Escanatz aqueste còdi QR amb Google Authenticator o una aplicacion similària sus vòstre mobil. A partir d’ara, aquesta aplicacion generarà un geton que vos caldrà picar per vos connectar." lost_recovery_codes: Los còdi de recuperacion vos permeton d’accedir a vòstre compte se perdètz vòstre mobil. S’avètz perdut vòstres còdis de recuperacion los podètz tornar generar aquí. Los ancians còdis seràn pas mai valides. From 18f69fb964f73dce1e921b3fd1a391167d638e8a Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Sat, 26 Aug 2017 00:19:35 +0900 Subject: [PATCH 093/201] Adjust styles of landing pages. (#4682) * Adjust about.scss * Delete trailing whitespace. --- app/javascript/styles/about.scss | 930 ++++++++++++++----------------- 1 file changed, 432 insertions(+), 498 deletions(-) diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 020842f7dd6..28924738a27 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -1,52 +1,96 @@ -.about-body { - .wrapper { - max-width: 600px; - margin: 0 auto; +.landing-page { + p, + li { + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + margin-bottom: 12px; color: $ui-primary-color; - padding-top: 50px; - padding-bottom: 50px; - &.thicc { - max-width: 800px; + a { + color: $ui-highlight-color; + text-decoration: underline; } } - h1 { - font: 46px/52px 'mastodon-font-sans-serif', sans-serif; - font-weight: 600; - margin-bottom: 20px; - color: $ui-highlight-color; - padding: 20px 0; + em { + display: inline; + margin: 0; + padding: 0; + font-weight: 500; + background: transparent; + font-family: inherit; + font-size: inherit; + line-height: inherit; + color: lighten($ui-primary-color, 10%); + } - img { - margin-bottom: -5px; - margin-right: 5px; - width: 46px; - height: 46px; + h1 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 26px; + line-height: 30px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + + small { + font-family: 'mastodon-font-sans-serif', sans-serif; + display: block; + font-size: 18px; + font-weight: 400; + color: $ui-base-lighter-color; } } h2 { font-family: 'mastodon-font-display', sans-serif; - font-size: 24px; - line-height: 28px; - font-weight: 400; + font-size: 22px; + line-height: 26px; + font-weight: 500; margin-bottom: 20px; - color: $primary-text-color; + color: $ui-secondary-color; } h3 { font-family: 'mastodon-font-display', sans-serif; - font-size: 20px; - line-height: 28px; - font-weight: 400; + font-size: 18px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h4 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h5 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + margin-bottom: 20px; + color: $ui-secondary-color; + } + + h6 { + font-family: 'mastodon-font-display', sans-serif; + font-size: 12px; + line-height: 24px; + font-weight: 500; margin-bottom: 20px; color: $ui-secondary-color; } ul, ol { - list-style: inherit; margin-left: 20px; &[type='a'] { @@ -58,220 +102,30 @@ } } + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + li > ol, li > ul { - margin-top: 20px; + margin-top: 6px; } - p, - li { - font: 16px/28px 'mastodon-font-sans-serif', sans-serif; - font-weight: 400; - margin-bottom: 12px; - - a { - color: $ui-highlight-color; - text-decoration: underline; - } - } - - em { - display: inline-block; - padding: 7px 7px 5px; - margin: 0 2px; - background: $ui-primary-color; - color: $ui-base-color; - font: 16px/16px 'mastodon-font-sans-serif', sans-serif; - font-weight: 300; - } - - .screenshot { - box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); - margin-bottom: 26px; - - img { - max-width: 100%; - height: auto; - display: block; - } - } - - .actions { - overflow: hidden; - margin-bottom: 20px; - - .info { - float: right; - text-align: right; - line-height: 36px; - - a { - color: $ui-primary-color; - text-decoration: underline; - } - } - } - - @media screen and (max-width: 625px) { - .wrapper { - padding: 20px; - } - } -} - -.information-board { - background: darken($ui-base-color, 4%); - padding: 20px 0; - - .panel { - position: absolute; - width: 280px; - box-sizing: border-box; - background: darken($ui-base-color, 8%); - padding: 20px; - padding-top: 10px; - border-radius: 4px 4px 0 0; - right: 0; - bottom: -40px; - - .panel-header { - font-family: 'mastodon-font-display', sans-serif; - font-size: 14px; - line-height: 24px; - font-weight: 500; - color: $ui-primary-color; - padding-bottom: 5px; - margin-bottom: 15px; - border-bottom: 1px solid lighten($ui-base-color, 4%); - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - - a, - span { - font-weight: 400; - color: darken($ui-primary-color, 10%); - } - - a { - text-decoration: none; - } - } + hr { + border-color: rgba($ui-base-lighter-color, .6); } .container { - position: relative; - padding-right: 280px + 15px; - } - - .information-board-sections { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - } - - .section { - flex: 1 0 auto; - font: 16px/28px 'mastodon-font-sans-serif', sans-serif; - text-align: right; - padding: 10px 15px; - - span, - strong { - display: block; - } - - span { - font-size: 16px; - - &:last-child { - color: $ui-secondary-color; - } - } - - strong { - font-weight: 500; - font-size: 32px; - line-height: 48px; - color: $primary-text-color; - } - } -} - -.owner { - text-align: center; - - .avatar { - width: 80px; - height: 80px; + width: 100%; + box-sizing: border-box; + max-width: 800px; margin: 0 auto; - margin-bottom: 15px; - - img { - display: block; - width: 80px; - height: 80px; - border-radius: 48px; - } } - .name { - font-size: 14px; - - a { - display: block; - color: $primary-text-color; - text-decoration: none; - - &:hover { - .display_name { - text-decoration: underline; - } - } - } - - .username { - display: block; - color: $ui-primary-color; - } - } -} - -.features-list__row { - display: flex; - padding: 10px 0; - justify-content: space-between; - - &:first-child { - padding-top: 0; - } - - .visual { - flex: 0 0 auto; - display: flex; - align-items: center; - margin-left: 15px; - - .fa { - display: block; - color: $ui-primary-color; - font-size: 48px; - } - } - - .text { - font-size: 16px; - line-height: 30px; - color: $ui-primary-color; - - h6 { - font-weight: 500; - color: $ui-secondary-color; - } - } -} - -.landing-page { .header-wrapper { padding-top: 15px; background: $ui-base-color; @@ -284,6 +138,17 @@ .hero .heading { padding-bottom: 30px; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } } } @@ -307,17 +172,6 @@ } } - p, - li { - font: inherit; - font-weight: inherit; - margin-bottom: 0; - } - - hr { - border-color: rgba($ui-base-lighter-color, .6); - } - .header { line-height: 30px; overflow: hidden; @@ -327,6 +181,62 @@ justify-content: space-between; } + .links { + position: relative; + z-index: 4; + + a { + display: flex; + justify-content: center; + align-items: center; + color: $ui-primary-color; + text-decoration: none; + padding: 12px 16px; + line-height: 32px; + font-family: 'mastodon-font-display', sans-serif; + font-weight: 500; + font-size: 14px; + + &:hover { + color: $ui-secondary-color; + } + } + + .brand { + a { + padding-left: 0; + padding-right: 0; + color: $white; + } + + img { + height: 32px; + position: relative; + top: 4px; + left: -10px; + } + } + + ul { + list-style: none; + margin: 0; + + li { + display: inline-block; + vertical-align: bottom; + margin: 0; + + &:first-child a { + padding-left: 0; + } + + &:last-child a { + padding-right: 0; + } + } + } + } + .hero { margin-top: 50px; align-items: center; @@ -379,6 +289,12 @@ } } + .heading { + position: relative; + z-index: 4; + padding-bottom: 150px; + } + .simple_form, .closed-registrations-message { background: darken($ui-base-color, 4%); @@ -400,12 +316,6 @@ } } - .heading { - position: relative; - z-index: 4; - padding-bottom: 150px; - } - .closed-registrations-message { min-height: 330px; display: flex; @@ -413,131 +323,140 @@ justify-content: space-between; } } - - ul { - list-style: none; - margin: 0; - - li { - display: inline-block; - vertical-align: bottom; - margin: 0; - - &:first-child a { - padding-left: 0; - } - - &:last-child a { - padding-right: 0; - } - } - } - - .links { - position: relative; - z-index: 4; - - a { - display: flex; - justify-content: center; - align-items: center; - color: $ui-primary-color; - text-decoration: none; - padding: 12px 16px; - line-height: 32px; - font-family: 'mastodon-font-display', sans-serif; - font-weight: 500; - font-size: 14px; - - &:hover { - color: $ui-secondary-color; - } - } - - .brand { - a { - padding-left: 0; - padding-right: 0; - color: $white; - } - - img { - height: 32px; - position: relative; - top: 4px; - left: -10px; - } - } - } - } - - .container { - width: 100%; - box-sizing: border-box; - max-width: 800px; - margin: 0 auto; - } - - .wrapper { - max-width: 800px; - margin: 0 auto; - padding: 0; } .about-short { background: darken($ui-base-color, 4%); padding: 50px 0; - } - - .extended-description { - padding: 50px 0; - - ul, - ol { - list-style: inherit; - margin-left: 20px; - - &[type='a'] { - list-style-type: lower-alpha; - } - - &[type='i'] { - list-style-type: lower-roman; - } - } - - li > ol, - li > ul { - margin-top: 20px; - } - - p, - li { - font: 16px/28px 'mastodon-font-sans-serif', sans-serif; - font-weight: 400; - margin-bottom: 12px; - color: $ui-primary-color; - - a { - color: $ui-highlight-color; - text-decoration: underline; - } - } - } - - h3 { - font-family: 'mastodon-font-display', sans-serif; + font-family: 'mastodon-font-sans-serif', sans-serif; font-size: 16px; - line-height: 24px; - font-weight: 500; - margin-bottom: 20px; - color: $ui-secondary-color; - } - - p { + font-weight: 400; font-size: 16px; line-height: 30px; color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; + } + } + + .information-board { + background: darken($ui-base-color, 4%); + padding: 20px 0; + + .container { + position: relative; + padding-right: 280px + 15px; + } + + .information-board-sections { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + } + + .section { + flex: 1 0 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + line-height: 28px; + color: $primary-text-color; + text-align: right; + padding: 10px 15px; + + span, + strong { + display: block; + } + + span { + &:last-child { + color: $ui-secondary-color; + } + } + + strong { + font-weight: 500; + font-size: 32px; + line-height: 48px; + } + } + + .panel { + position: absolute; + width: 280px; + box-sizing: border-box; + background: darken($ui-base-color, 8%); + padding: 20px; + padding-top: 10px; + border-radius: 4px 4px 0 0; + right: 0; + bottom: -40px; + + .panel-header { + font-family: 'mastodon-font-display', sans-serif; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: $ui-primary-color; + padding-bottom: 5px; + margin-bottom: 15px; + border-bottom: 1px solid lighten($ui-base-color, 4%); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + + a, + span { + font-weight: 400; + color: darken($ui-primary-color, 10%); + } + + a { + text-decoration: none; + } + } + } + + .owner { + text-align: center; + + .avatar { + width: 80px; + height: 80px; + margin: 0 auto; + margin-bottom: 15px; + + img { + display: block; + width: 80px; + height: 80px; + border-radius: 48px; + } + } + + .name { + font-size: 14px; + + a { + display: block; + color: $primary-text-color; + text-decoration: none; + + &:hover { + .display_name { + text-decoration: underline; + } + } + } + + .username { + display: block; + color: $ui-primary-color; + } + } + } } .features { @@ -546,100 +465,121 @@ .container { display: flex; } - } - #mastodon-timeline { - display: flex; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - font-family: 'mastodon-font-sans-serif', sans-serif; - font-size: 13px; - line-height: 18px; - font-weight: 400; - color: $primary-text-color; - width: 330px; - margin-right: 30px; - flex: 0 0 auto; - background: $ui-base-color; - overflow: hidden; - box-shadow: 0 0 6px rgba($black, 0.1); - - .column-header { - color: inherit; - font-family: inherit; - font-size: 16px; - line-height: inherit; - font-weight: inherit; - margin: 0; - padding: 15px; - } - - .column { - padding: 0; - border-radius: 4px; - overflow: hidden; - } - - .scrollable { - height: 400px; - } - - p { - font-size: inherit; - line-height: inherit; - font-weight: inherit; - color: $primary-text-color; - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } - - a { - color: $ui-secondary-color; - text-decoration: none; - } - } - } - - .about-mastodon { - max-width: 675px; - - p { - margin-bottom: 20px; - } - - .features-list { - margin-top: 20px; - } - } - - em { - display: inline; - margin: 0; - padding: 0; - font-weight: 500; - background: transparent; - font-family: inherit; - font-size: inherit; - line-height: inherit; - color: lighten($ui-primary-color, 10%); - } - - h1 { - font-family: 'mastodon-font-display', sans-serif; - font-size: 26px; - line-height: 30px; - margin-bottom: 0; - font-weight: 500; - color: $ui-secondary-color; - - small { + #mastodon-timeline { + display: flex; + -webkit-overflow-scrolling: touch; + -ms-overflow-style: -ms-autohiding-scrollbar; font-family: 'mastodon-font-sans-serif', sans-serif; - display: block; - font-size: 18px; + font-size: 13px; + line-height: 18px; font-weight: 400; - color: $ui-base-lighter-color; + color: $primary-text-color; + width: 330px; + margin-right: 30px; + flex: 0 0 auto; + background: $ui-base-color; + overflow: hidden; + box-shadow: 0 0 6px rgba($black, 0.1); + + .column-header { + color: inherit; + font-family: inherit; + font-size: 16px; + line-height: inherit; + font-weight: inherit; + margin: 0; + padding: 15px; + } + + .column { + padding: 0; + border-radius: 4px; + overflow: hidden; + } + + .scrollable { + height: 400px; + } + + p { + font-size: inherit; + line-height: inherit; + font-weight: inherit; + color: $primary-text-color; + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + a { + color: $ui-secondary-color; + text-decoration: none; + } + } + } + + .about-mastodon { + max-width: 675px; + + p { + margin-bottom: 20px; + } + + .features-list { + margin-top: 20px; + + .features-list__row { + display: flex; + padding: 10px 0; + justify-content: space-between; + + &:first-child { + padding-top: 0; + } + + .visual { + flex: 0 0 auto; + display: flex; + align-items: center; + margin-left: 15px; + + .fa { + display: block; + color: $ui-primary-color; + font-size: 48px; + } + } + + .text { + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + h6 { + font-size: inherit; + line-height: inherit; + margin-bottom: 0; + } + } + } + } + } + } + + .extended-description { + padding: 50px 0; + font-family: 'mastodon-font-sans-serif', sans-serif; + font-size: 16px; + font-weight: 400; + font-size: 16px; + line-height: 30px; + color: $ui-primary-color; + + a { + color: $ui-highlight-color; + text-decoration: underline; } } @@ -663,8 +603,15 @@ padding: 0 20px; } - .information-board .container { - padding-right: 20px; + .information-board { + + .container { + padding-right: 20px; + } + + .section { + text-align: center; + } .panel { position: static; @@ -678,10 +625,6 @@ } } - .information-board .section { - text-align: center; - } - .header-wrapper .mascot { left: 20px; } @@ -699,6 +642,7 @@ &.compact .hero .heading { padding-bottom: 20px; + text-align: initial; } } @@ -707,51 +651,41 @@ display: block; } - .links { - padding-top: 15px; - background: darken($ui-base-color, 4%); - } - .header { + .links { + padding-top: 15px; + background: darken($ui-base-color, 4%); + + a { + padding: 12px 8px; + } + + .nav { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + } + + .brand img { + left: 0; + top: 0; + } + } + .hero { margin-top: 30px; padding: 0; - .heading { - padding: 0 20px 20px; + .floats { + display: none; } - } - .floats { - display: none; - } + .heading { + padding: 30px 20px; + text-align: center; + } - .heading, - .nav { - text-align: center; - } - - .nav { - display: flex; - flex-flow: row wrap; - justify-content: space-around; - } - - .links a { - padding: 12px 8px; - } - - .heading h1 { - padding: 30px 0; - } - - .links .brand img { - left: 0; - top: 0; - } - - .hero { .simple_form, .closed-registrations-message { background: darken($ui-base-color, 8%); @@ -762,7 +696,7 @@ } } - #mastodon-timeline { + .features #mastodon-timeline { height: 70vh; width: 100%; margin-bottom: 50px; From 00f9f16f94feeaf2fae9a5a1a7bcb3a06b86a036 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sat, 26 Aug 2017 00:21:00 +0900 Subject: [PATCH 094/201] Change timezone of the datetime to what browser specifies (#4688) --- app/views/admin/accounts/show.html.haml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 18bcd5e8edb..85841d7a138 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -24,7 +24,8 @@ %th= t('admin.accounts.most_recent_activity') %td - if @account.user_current_sign_in_at - = l @account.user_current_sign_in_at + %time.formatted{ datetime: @account.user_current_sign_in_at.iso8601, title: l(@account.user_current_sign_in_at) } + = l @account.user_current_sign_in_at - else Never - else @@ -43,7 +44,8 @@ %th= t('admin.accounts.push_subscription_expires') %td - if @account.subscribed? - = l @account.subscription_expires_at + %time.formatted{ datetime: account.subscription_expires_at.iso8601, title: l(account.subscription_expires_at) } + = l @account.subscription_expires_at - else = t('admin.accounts.not_subscribed') %tr From fb8aa2b3bac122f819b07c7f9461b26cc369c621 Mon Sep 17 00:00:00 2001 From: unarist Date: Sat, 26 Aug 2017 00:21:16 +0900 Subject: [PATCH 095/201] Apply user timezone for the title attribute of .time-ago (#4693) --- app/javascript/packs/public.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index da1f550fc22..d8a0f4eeeb1 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -35,6 +35,7 @@ function main() { [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => { const datetime = new Date(content.getAttribute('datetime')); + content.title = dateTimeFormat.format(datetime); content.textContent = relativeFormat.format(datetime); }); }); From c2af1381130caae0acc02db853580a2bdab61078 Mon Sep 17 00:00:00 2001 From: nullkal Date: Sat, 26 Aug 2017 01:50:52 +0900 Subject: [PATCH 096/201] =?UTF-8?q?Allow=20multiple=20pinned=20statuses=20?= =?UTF-8?q?to=20be=20shown=20and=20make=20them=20be=20ordered=20b=E2=80=A6?= =?UTF-8?q?=20(#4690)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow multiple pinned statuses to be shown and make them be ordered by pinned date * Set timestamps NOT NULL * Make single-line pinned_statuses * Spec for pinned_statuses * Remove redundant empty line --- app/controllers/accounts_controller.rb | 2 +- app/models/account.rb | 2 +- app/models/status_pin.rb | 2 ++ ...0170824103029_add_timestamps_to_status_pins.rb | 5 +++++ db/schema.rb | 4 +++- spec/controllers/accounts_controller_spec.rb | 15 +++++++++++++++ 6 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20170824103029_add_timestamps_to_status_pins.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index f4ca239badb..8dad12f115c 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -14,7 +14,7 @@ class AccountsController < ApplicationController return end - @pinned_statuses = cache_collection(@account.pinned_statuses.limit(1), Status) unless media_requested? + @pinned_statuses = cache_collection(@account.pinned_statuses, Status) unless media_requested? @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) @next_url = next_url unless @statuses.empty? diff --git a/app/models/account.rb b/app/models/account.rb index b83aa115946..52933455947 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -79,7 +79,7 @@ class Account < ApplicationRecord # Pinned statuses has_many :status_pins, inverse_of: :account, dependent: :destroy - has_many :pinned_statuses, through: :status_pins, class_name: 'Status', source: :status + has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status # Media has_many :media_attachments, dependent: :destroy diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb index c9a6693441e..a72c19750eb 100644 --- a/app/models/status_pin.rb +++ b/app/models/status_pin.rb @@ -6,6 +6,8 @@ # id :integer not null, primary key # account_id :integer not null # status_id :integer not null +# created_at :datetime not null +# updated_at :datetime not null # class StatusPin < ApplicationRecord diff --git a/db/migrate/20170824103029_add_timestamps_to_status_pins.rb b/db/migrate/20170824103029_add_timestamps_to_status_pins.rb new file mode 100644 index 00000000000..09f0fbeaf27 --- /dev/null +++ b/db/migrate/20170824103029_add_timestamps_to_status_pins.rb @@ -0,0 +1,5 @@ +class AddTimestampsToStatusPins < ActiveRecord::Migration[5.1] + def change + add_timestamps :status_pins, null: false, default: -> { 'CURRENT_TIMESTAMP' } + end +end diff --git a/db/schema.rb b/db/schema.rb index d0e72be0fc0..7754894bc1b 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.define(version: 20170823162448) do +ActiveRecord::Schema.define(version: 20170824103029) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -285,6 +285,8 @@ ActiveRecord::Schema.define(version: 20170823162448) do create_table "status_pins", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false + t.datetime "created_at", default: -> { "now()" }, null: false + t.datetime "updated_at", default: -> { "now()" }, null: false t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true t.index ["account_id"], name: "index_status_pins_on_account_id" t.index ["status_id"], name: "index_status_pins_on_status_id" diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index 2c0df0ef3b5..4e37b1b5fce 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -10,6 +10,13 @@ RSpec.describe AccountsController, type: :controller do let!(:status2) { Status.create!(account: alice, text: 'Boop', thread: status1) } let!(:status3) { Status.create!(account: alice, text: 'Picture!') } let!(:status4) { Status.create!(account: alice, text: 'Mentioning @alice') } + let!(:status5) { Status.create!(account: alice, text: 'Kitsune') } + let!(:status6) { Status.create!(account: alice, text: 'Neko') } + let!(:status7) { Status.create!(account: alice, text: 'Tanuki') } + + let!(:status_pin1) { StatusPin.create!(account: alice, status: status5, created_at: 5.days.ago) } + let!(:status_pin2) { StatusPin.create!(account: alice, status: status6, created_at: 2.years.ago) } + let!(:status_pin3) { StatusPin.create!(account: alice, status: status7, created_at: 10.minutes.ago) } before do status3.media_attachments.create!(account: alice, file: fixture_file_upload('files/attachment.jpg', 'image/jpeg')) @@ -70,6 +77,14 @@ RSpec.describe AccountsController, type: :controller do expect(statuses[1]).to eq status2 end + it 'assigns @pinned_statuses' do + pinned_statuses = assigns(:pinned_statuses).to_a + expect(pinned_statuses.size).to eq 3 + expect(pinned_statuses[0]).to eq status7 + expect(pinned_statuses[1]).to eq status5 + expect(pinned_statuses[2]).to eq status6 + end + it 'returns http success' do expect(response).to have_http_status(:success) end From 21bb4a6c3b21f3d2a60d2ebf6475fc0b0e9fee5d Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Sat, 26 Aug 2017 03:02:44 +0900 Subject: [PATCH 097/201] Fix ar.json (#4699) Remove ! from compose_form.publish --- app/javascript/mastodon/locales/ar.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index fa8cda97d1c..2efa1fba331 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -47,7 +47,7 @@ "compose_form.lock_disclaimer.lock": "مقفل", "compose_form.placeholder": "فيمَ تفكّر؟", "compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.", - "compose_form.publish": "بوّق !", + "compose_form.publish": "بوّق", "compose_form.publish_loud": "{publish}!", "compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس", "compose_form.spoiler": "أخفِ النص واعرض تحذيرا", From 3ac7b353f8ba74bce9e20974bcc1832930424ef2 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Sat, 26 Aug 2017 19:39:26 +0900 Subject: [PATCH 098/201] Fix missing at-sign (regression from #4688) (#4705) --- app/views/admin/accounts/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 85841d7a138..dc2f16cc99c 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -44,7 +44,7 @@ %th= t('admin.accounts.push_subscription_expires') %td - if @account.subscribed? - %time.formatted{ datetime: account.subscription_expires_at.iso8601, title: l(account.subscription_expires_at) } + %time.formatted{ datetime: @account.subscription_expires_at.iso8601, title: l(@account.subscription_expires_at) } = l @account.subscription_expires_at - else = t('admin.accounts.not_subscribed') From 649a20ab46eadf9ae3bfc30782ae62379383bd72 Mon Sep 17 00:00:00 2001 From: masarakki Date: Sat, 26 Aug 2017 19:40:03 +0900 Subject: [PATCH 099/201] authorize-follow-requests-after-unlocking (#4658) --- .../api/v1/accounts/credentials_controller.rb | 2 +- .../settings/profiles_controller.rb | 2 +- app/services/update_account_service.rb | 21 +++++++++++++++++++ app/workers/authorize_follow_worker.rb | 14 +++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 app/services/update_account_service.rb create mode 100644 app/workers/authorize_follow_worker.rb diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index bea83cd2ab5..da534d960e5 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController def update @account = current_account - @account.update!(account_params) + UpdateAccountService.new.call(@account, account_params, raise_error: true) ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index c751c64ae97..28f78a4fb85 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -14,7 +14,7 @@ class Settings::ProfilesController < ApplicationController def show; end def update - if @account.update(account_params) + if UpdateAccountService.new.call(@account, account_params) ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb new file mode 100644 index 00000000000..09ea377e731 --- /dev/null +++ b/app/services/update_account_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UpdateAccountService < BaseService + def call(account, params, raise_error: false) + was_locked = account.locked + update_method = raise_error ? :update! : :update + account.send(update_method, params).tap do |ret| + next unless ret + authorize_all_follow_requests(account) if was_locked && !account.locked + end + end + + private + + def authorize_all_follow_requests(account) + follow_requests = FollowRequest.where(target_account: account) + AuthorizeFollowWorker.push_bulk(follow_requests) do |req| + [req.account_id, req.target_account_id] + end + end +end diff --git a/app/workers/authorize_follow_worker.rb b/app/workers/authorize_follow_worker.rb new file mode 100644 index 00000000000..0d501462463 --- /dev/null +++ b/app/workers/authorize_follow_worker.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AuthorizeFollowWorker + include Sidekiq::Worker + + def perform(source_account_id, target_account_id) + source_account = Account.find(source_account_id) + target_account = Account.find(target_account_id) + + AuthorizeFollowService.new.call(source_account, target_account) + rescue ActiveRecord::RecordNotFound + true + end +end From 1cebfed23e03b9d31796cdc139acde1b6dccd9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20e=20s=C3=B3?= Date: Sat, 26 Aug 2017 08:45:35 -0300 Subject: [PATCH 100/201] Added new translations of error messages, block and mute domains and users, privacy disclaimers, etc (#4700) * Added new translations of error messages, block and mute domains and users * Added new translations of error messages, block and mute domains and users --- app/javascript/mastodon/locales/pt-BR.json | 76 +++++++++++----------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 8a299e27221..5f3797feedd 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -1,68 +1,68 @@ { "account.block": "Bloquear @{name}", - "account.block_domain": "Hide everything from {domain}", - "account.disclaimer_full": "Information below may reflect the user's profile incompletely.", + "account.block_domain": "Esconder tudo de {domain}", + "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.", "account.edit_profile": "Editar perfil", "account.follow": "Seguir", "account.followers": "Seguidores", "account.follows": "Segue", - "account.follows_you": "É teu seguidor", - "account.media": "Media", + "account.follows_you": "É seu seguidor", + "account.media": "Mídia", "account.mention": "Mencionar @{name}", "account.mute": "Silenciar @{name}", "account.posts": "Posts", "account.report": "Denunciar @{name}", - "account.requested": "A aguardar aprovação", - "account.share": "Share @{name}'s profile", + "account.requested": "Aguardando aprovação", + "account.share": "Compartilhar perfil de @{name}", "account.unblock": "Não bloquear @{name}", - "account.unblock_domain": "Unhide {domain}", + "account.unblock_domain": "Desbloquear {domain}", "account.unfollow": "Deixar de seguir", "account.unmute": "Não silenciar @{name}", - "account.view_full_profile": "View full profile", + "account.view_full_profile": "Ver perfil completo", "boost_modal.combo": "Pode clicar {combo} para não voltar a ver", "bundle_column_error.body": "Something went wrong while loading this component.", - "bundle_column_error.retry": "Try again", + "bundle_column_error.retry": "Tente novamente", "bundle_column_error.title": "Network error", - "bundle_modal_error.close": "Close", + "bundle_modal_error.close": "Fechar", "bundle_modal_error.message": "Something went wrong while loading this component.", - "bundle_modal_error.retry": "Try again", - "column.blocks": "Utilizadores Bloqueados", + "bundle_modal_error.retry": "Tente novamente", + "column.blocks": "Usuários bloqueados", "column.community": "Local", "column.favourites": "Favoritos", - "column.follow_requests": "Seguidores Pendentes", - "column.home": "Home", - "column.mutes": "Utilizadores silenciados", + "column.follow_requests": "Seguidores pendentes", + "column.home": "Página inicial", + "column.mutes": "Usuários silenciados", "column.notifications": "Notificações", "column.public": "Global", "column_back_button.label": "Voltar", - "column_header.hide_settings": "Hide settings", - "column_header.moveLeft_settings": "Move column to the left", - "column_header.moveRight_settings": "Move column to the right", - "column_header.pin": "Pin", - "column_header.show_settings": "Show settings", - "column_header.unpin": "Unpin", - "column_subheading.navigation": "Navigation", - "column_subheading.settings": "Settings", - "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", + "column_header.hide_settings": "Esconder configurações", + "column_header.moveLeft_settings": "Mover coluna para a esquerda", + "column_header.moveRight_settings": "Mover coluna para a direita", + "column_header.pin": "Fixar", + "column_header.show_settings": "Mostrar configurações", + "column_header.unpin": "Desafixar", + "column_subheading.navigation": "Navegação", + "column_subheading.settings": "Configurações", + "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar as suas postagens só para seguidores.", "compose_form.lock_disclaimer.lock": "locked", - "compose_form.placeholder": "Em que estás a pensar?", - "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.", + "compose_form.placeholder": "No que você está pensando?", + "compose_form.privacy_disclaimer": "O seu conteúdo privado será compartilhado com os usuários do {domains}. Você confia {domainsCount, plural, one {neste servidor} other {nestes servidores}}? As configurações de privacidade só funcionam em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não há como garantir a privacidade de suas postagens, e elas podem ser compartilhadas com outros.", "compose_form.publish": "Publicar", "compose_form.publish_loud": "{publish}!", - "compose_form.sensitive": "Marcar media como conteúdo sensível", + "compose_form.sensitive": "Marcar mídia como conteúdo sensível", "compose_form.spoiler": "Esconder texto com aviso", "compose_form.spoiler_placeholder": "Aviso de conteúdo", - "confirmation_modal.cancel": "Cancel", - "confirmations.block.confirm": "Block", - "confirmations.block.message": "Are you sure you want to block {name}?", - "confirmations.delete.confirm": "Delete", - "confirmations.delete.message": "Are you sure you want to delete this status?", - "confirmations.domain_block.confirm": "Hide entire domain", - "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.", - "confirmations.mute.confirm": "Mute", - "confirmations.mute.message": "Are you sure you want to mute {name}?", - "confirmations.unfollow.confirm": "Unfollow", - "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmation_modal.cancel": "Cancelar", + "confirmations.block.confirm": "Bloquear", + "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?", + "confirmations.delete.confirm": "Excluir", + "confirmations.delete.message": "Você tem certeza de que quer excluir este status?", + "confirmations.domain_block.confirm": "Esconder o domínio inteiro", + "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado.", + "confirmations.mute.confirm": "Silenciar", + "confirmations.mute.message": "Você tem certeza de que quer silenciar {name}?", + "confirmations.unfollow.confirm": "Deixar de seguir", + "confirmations.unfollow.message": "Você tem certeza de que quer deixar de seguir {name}?", "emoji_button.activity": "Activity", "emoji_button.flags": "Flags", "emoji_button.food": "Food & Drink", From 00840f4f2edb8d1d46638ccbc90a1f4462d0867a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 13:47:38 +0200 Subject: [PATCH 101/201] Add handling of Linked Data Signatures in payloads (#4687) * Add handling of Linked Data Signatures in payloads * Add a way to sign JSON, fix canonicalization of signature options * Fix signatureValue encoding, send out signed JSON when distributing * Add missing security context --- .rubocop.yml | 1 + Gemfile | 3 + Gemfile.lock | 16 ++++ app/helpers/jsonld_helper.rb | 13 +++ app/lib/activitypub/adapter.rb | 2 +- app/lib/activitypub/linked_data_signature.rb | 56 ++++++++++++ .../activitypub/process_collection_service.rb | 11 +++ app/services/authorize_follow_service.rb | 4 +- app/services/batched_remove_status_service.rb | 8 +- app/services/block_service.rb | 4 +- app/services/favourite_service.rb | 4 +- app/services/follow_service.rb | 4 +- app/services/process_mentions_service.rb | 4 +- app/services/reblog_service.rb | 4 +- app/services/reject_follow_service.rb | 4 +- app/services/remove_status_service.rb | 10 ++- app/services/unblock_service.rb | 4 +- app/services/unfavourite_service.rb | 4 +- app/services/unfollow_service.rb | 4 +- .../activitypub/distribution_worker.rb | 8 +- config/initializers/json_ld.rb | 4 + lib/json_ld/identity.rb | 86 +++++++++++++++++++ lib/json_ld/security.rb | 50 +++++++++++ .../activitypub/linked_data_signature_spec.rb | 86 +++++++++++++++++++ .../process_collection_service_spec.rb | 5 +- 25 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 app/lib/activitypub/linked_data_signature.rb create mode 100644 config/initializers/json_ld.rb create mode 100644 lib/json_ld/identity.rb create mode 100644 lib/json_ld/security.rb create mode 100644 spec/lib/activitypub/linked_data_signature_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index ae369717474..a36aa5caefa 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' + - 'lib/json_ld/*' Bundler/OrderedGems: Enabled: false diff --git a/Gemfile b/Gemfile index 52ac43b9af7..ae90697f1d4 100644 --- a/Gemfile +++ b/Gemfile @@ -68,6 +68,9 @@ gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' gem 'webpush' +gem 'json-ld-preloaded', '~> 2.2.1' +gem 'rdf-normalize', '~> 0.3.1' + group :development, :test do gem 'fabrication', '~> 2.16' gem 'fuubar', '~> 2.2' diff --git a/Gemfile.lock b/Gemfile.lock index adc37f7de04..cd457363727 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -179,6 +179,8 @@ GEM activesupport (>= 4.0.1) hamlit (>= 1.2.0) railties (>= 4.0.1) + hamster (3.0.0) + concurrent-ruby (~> 1.0) hashdiff (0.3.5) highline (1.7.8) hiredis (0.6.1) @@ -211,6 +213,13 @@ GEM idn-ruby (0.1.0) jmespath (1.3.1) json (2.1.0) + json-ld (2.1.5) + multi_json (~> 1.12) + rdf (~> 2.2) + json-ld-preloaded (2.2.1) + json-ld (~> 2.1, >= 2.1.5) + multi_json (~> 1.11) + rdf (~> 2.2) jsonapi-renderer (0.1.3) jwt (1.5.6) kaminari (1.0.1) @@ -348,6 +357,11 @@ GEM rainbow (2.2.2) rake rake (12.0.0) + rdf (2.2.8) + hamster (~> 3.0) + link_header (~> 0.0, >= 0.0.8) + rdf-normalize (0.3.2) + rdf (~> 2.0) redis (3.3.3) redis-actionpack (5.0.1) actionpack (>= 4.0, < 6) @@ -531,6 +545,7 @@ DEPENDENCIES httplog (~> 0.99) i18n-tasks (~> 0.9) idn-ruby + json-ld-preloaded (~> 2.2.1) kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) @@ -560,6 +575,7 @@ DEPENDENCIES rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) + rdf-normalize (~> 0.3.1) redis (~> 3.3) redis-namespace (~> 1.5) redis-rails (~> 5.0) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 8355eb05567..09446c8be28 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -17,6 +17,11 @@ module JsonLdHelper !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end + def canonicalize(json) + graph = RDF::Graph.new << JSON::LD::API.toRdf(json) + graph.dump(:normalize) + end + def fetch_resource(uri) response = build_request(uri).perform return if response.code != 200 @@ -29,6 +34,14 @@ module JsonLdHelper nil end + def merge_context(context, new_context) + if context.is_a?(Array) + context << new_context + else + [context, new_context] + end + end + private def build_request(uri) diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index df132f01987..92210579e80 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -11,7 +11,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base def serializable_hash(options = nil) options = serialization_options(options) - serialized_hash = { '@context': ActivityPub::TagManager::CONTEXT }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) + serialized_hash = { '@context': [ActivityPub::TagManager::CONTEXT, 'https://w3id.org/security/v1'] }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options)) self.class.transform_key_casing!(serialized_hash, instance_options) end end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb new file mode 100644 index 00000000000..7173aed19d4 --- /dev/null +++ b/app/lib/activitypub/linked_data_signature.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class ActivityPub::LinkedDataSignature + include JsonLdHelper + + CONTEXT = 'https://w3id.org/identity/v1' + + def initialize(json) + @json = json + end + + def verify_account! + return unless @json['signature'].is_a?(Hash) + + type = @json['signature']['type'] + creator_uri = @json['signature']['creator'] + signature = @json['signature']['signatureValue'] + + return unless type == 'RsaSignature2017' + + creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account) + creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri) + + return if creator.nil? + + options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) + document_hash = hash(@json.without('signature')) + to_be_verified = options_hash + document_hash + + if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified) + creator + end + end + + def sign!(creator) + options = { + 'type' => 'RsaSignature2017', + 'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join, + 'created' => Time.now.utc.iso8601, + } + + options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT)) + document_hash = hash(@json.without('signature')) + to_be_signed = options_hash + document_hash + + signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed)) + + @json.merge('@context' => merge_context(@json['@context'], CONTEXT), 'signature' => options.merge('signatureValue' => signature)) + end + + private + + def hash(obj) + Digest::SHA256.hexdigest(canonicalize(obj)) + end +end diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index cd861c07530..2cf15553d21 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -9,6 +9,8 @@ class ActivityPub::ProcessCollectionService < BaseService return if @account.suspended? || !supported_context? + verify_account! if different_actor? + case @json['type'] when 'Collection', 'CollectionPage' process_items @json['items'] @@ -23,6 +25,10 @@ class ActivityPub::ProcessCollectionService < BaseService private + def different_actor? + @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present? + end + def process_items(items) items.reverse_each.map { |item| process_item(item) }.compact end @@ -35,4 +41,9 @@ class ActivityPub::ProcessCollectionService < BaseService activity = ActivityPub::Activity.factory(item, @account) activity&.perform end + + def verify_account! + account = ActivityPub::LinkedDataSignature.new(@json).verify_account! + @account = account unless account.nil? + end end diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index 6f036dc5a6c..b1bff89627e 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -24,11 +24,11 @@ class AuthorizeFollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::AcceptFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.target_account)) end def build_xml(follow_request) diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index e6c8c9208c8..c90f4401dfb 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -138,10 +138,14 @@ class BatchedRemoveStatusService < BaseService def build_json(status) return @activity_json[status.id] if @activity_json.key?(status.id) - @activity_json[status.id] = ActiveModelSerializers::SerializableResource.new( + @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new( status, serializer: ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json) + end + + def sign_json(status, json) + Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account)) end end diff --git a/app/services/block_service.rb b/app/services/block_service.rb index f2253226b55..b39c3eef286 100644 --- a/app/services/block_service.rb +++ b/app/services/block_service.rb @@ -27,11 +27,11 @@ class BlockService < BaseService end def build_json(block) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( block, serializer: ActivityPub::BlockSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(block.account)) end def build_xml(block) diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 4aa935170ec..44df3ed13d4 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -34,11 +34,11 @@ class FavouriteService < BaseService end def build_json(favourite) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, serializer: ActivityPub::LikeSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(favourite.account)) end def build_xml(favourite) diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 2be625cd858..a92eb6b8881 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -67,10 +67,10 @@ class FollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::FollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.account)) end end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 2b8a7714721..f123bf8697d 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -47,11 +47,11 @@ class ProcessMentionsService < BaseService end def build_json(status) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(status.account)) end def follow_remote_account_service diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 7f886af7c20..5ed16c64b88 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -42,10 +42,10 @@ class ReblogService < BaseService end def build_json(reblog) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( reblog, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(reblog.account)) end end diff --git a/app/services/reject_follow_service.rb b/app/services/reject_follow_service.rb index a91266aa4b6..c1f7bcb6069 100644 --- a/app/services/reject_follow_service.rb +++ b/app/services/reject_follow_service.rb @@ -19,11 +19,11 @@ class RejectFollowService < BaseService end def build_json(follow_request) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow_request, serializer: ActivityPub::RejectFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow_request.target_account)) end def build_xml(follow_request) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index fcccbaa24f3..62eea677f60 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -56,7 +56,7 @@ class RemoveStatusService < BaseService # ActivityPub ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url| - [activity_json, @account.id, inbox_url] + [signed_activity_json, @account.id, inbox_url] end end @@ -66,7 +66,7 @@ class RemoveStatusService < BaseService # ActivityPub ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url| - [activity_json, @account.id, inbox_url] + [signed_activity_json, @account.id, inbox_url] end end @@ -74,12 +74,16 @@ class RemoveStatusService < BaseService @salmon_xml ||= stream_entry_to_xml(@stream_entry) end + def signed_activity_json + @signed_activity_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(activity_json).sign!(@account)) + end + def activity_json @activity_json ||= ActiveModelSerializers::SerializableResource.new( @status, serializer: ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json end def remove_reblogs diff --git a/app/services/unblock_service.rb b/app/services/unblock_service.rb index 72fc5ab150d..869f62d1c53 100644 --- a/app/services/unblock_service.rb +++ b/app/services/unblock_service.rb @@ -20,11 +20,11 @@ class UnblockService < BaseService end def build_json(unblock) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( unblock, serializer: ActivityPub::UndoBlockSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(unblock.account)) end def build_xml(block) diff --git a/app/services/unfavourite_service.rb b/app/services/unfavourite_service.rb index e53798e666f..2fda11bd660 100644 --- a/app/services/unfavourite_service.rb +++ b/app/services/unfavourite_service.rb @@ -21,11 +21,11 @@ class UnfavouriteService < BaseService end def build_json(favourite) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( favourite, serializer: ActivityPub::UndoLikeSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(favourite.account)) end def build_xml(favourite) diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 10af751464b..bf151ee28ab 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -23,11 +23,11 @@ class UnfollowService < BaseService end def build_json(follow) - ActiveModelSerializers::SerializableResource.new( + Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new( follow, serializer: ActivityPub::UndoFollowSerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json).sign!(follow.account)) end def build_xml(follow) diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 004dd25d1cf..14bb933c006 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -12,7 +12,7 @@ class ActivityPub::DistributionWorker return if skip_distribution? ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @account.id, inbox_url] + [signed_payload, @account.id, inbox_url] end rescue ActiveRecord::RecordNotFound true @@ -28,11 +28,15 @@ class ActivityPub::DistributionWorker @inboxes ||= @account.followers.inboxes end + def signed_payload + @signed_payload ||= Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account)) + end + def payload @payload ||= ActiveModelSerializers::SerializableResource.new( @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter - ).to_json + ).as_json end end diff --git a/config/initializers/json_ld.rb b/config/initializers/json_ld.rb new file mode 100644 index 00000000000..408e6490dee --- /dev/null +++ b/config/initializers/json_ld.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative '../../lib/json_ld/identity' +require_relative '../../lib/json_ld/security' diff --git a/lib/json_ld/identity.rb b/lib/json_ld/identity.rb new file mode 100644 index 00000000000..cfe50b9562f --- /dev/null +++ b/lib/json_ld/identity.rb @@ -0,0 +1,86 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/identity/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/identity/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "Credential" => TermDefinition.new("Credential", id: "https://w3id.org/credentials#Credential", simple: true), + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "CryptographicKeyCredential" => TermDefinition.new("CryptographicKeyCredential", id: "https://w3id.org/credentials#CryptographicKeyCredential", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true), + "Identity" => TermDefinition.new("Identity", id: "https://w3id.org/identity#Identity", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "Organization" => TermDefinition.new("Organization", id: "http://schema.org/Organization", simple: true), + "Person" => TermDefinition.new("Person", id: "http://schema.org/Person", simple: true), + "PostalAddress" => TermDefinition.new("PostalAddress", id: "http://schema.org/PostalAddress", simple: true), + "about" => TermDefinition.new("about", id: "http://schema.org/about", type_mapping: "@id"), + "accessControl" => TermDefinition.new("accessControl", id: "https://w3id.org/permissions#accessControl", type_mapping: "@id"), + "address" => TermDefinition.new("address", id: "http://schema.org/address", type_mapping: "@id"), + "addressCountry" => TermDefinition.new("addressCountry", id: "http://schema.org/addressCountry", simple: true), + "addressLocality" => TermDefinition.new("addressLocality", id: "http://schema.org/addressLocality", simple: true), + "addressRegion" => TermDefinition.new("addressRegion", id: "http://schema.org/addressRegion", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "claim" => TermDefinition.new("claim", id: "https://w3id.org/credentials#claim", type_mapping: "@id"), + "comment" => TermDefinition.new("comment", id: "http://www.w3.org/2000/01/rdf-schema#comment", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "cred" => TermDefinition.new("cred", id: "https://w3id.org/credentials#", simple: true, prefix: true), + "credential" => TermDefinition.new("credential", id: "https://w3id.org/credentials#credential", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "description" => TermDefinition.new("description", id: "http://schema.org/description", simple: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "email" => TermDefinition.new("email", id: "http://schema.org/email", simple: true), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "familyName" => TermDefinition.new("familyName", id: "http://schema.org/familyName", simple: true), + "givenName" => TermDefinition.new("givenName", id: "http://schema.org/givenName", simple: true), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "identity" => TermDefinition.new("identity", id: "https://w3id.org/identity#", simple: true, prefix: true), + "identityService" => TermDefinition.new("identityService", id: "https://w3id.org/identity#identityService", type_mapping: "@id"), + "idp" => TermDefinition.new("idp", id: "https://w3id.org/identity#idp", type_mapping: "@id"), + "image" => TermDefinition.new("image", id: "http://schema.org/image", type_mapping: "@id"), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "issued" => TermDefinition.new("issued", id: "https://w3id.org/credentials#issued", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "issuer" => TermDefinition.new("issuer", id: "https://w3id.org/credentials#issuer", type_mapping: "@id"), + "label" => TermDefinition.new("label", id: "http://www.w3.org/2000/01/rdf-schema#label", simple: true), + "member" => TermDefinition.new("member", id: "http://schema.org/member", type_mapping: "@id"), + "memberOf" => TermDefinition.new("memberOf", id: "http://schema.org/memberOf", type_mapping: "@id"), + "name" => TermDefinition.new("name", id: "http://schema.org/name", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "paymentProcessor" => TermDefinition.new("paymentProcessor", id: "https://w3id.org/payswarm#processor", simple: true), + "perm" => TermDefinition.new("perm", id: "https://w3id.org/permissions#", simple: true, prefix: true), + "postalCode" => TermDefinition.new("postalCode", id: "http://schema.org/postalCode", simple: true), + "preferences" => TermDefinition.new("preferences", id: "https://w3id.org/payswarm#preferences", type_mapping: "@vocab"), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "ps" => TermDefinition.new("ps", id: "https://w3id.org/payswarm#", simple: true, prefix: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "rdf" => TermDefinition.new("rdf", id: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", simple: true, prefix: true), + "rdfs" => TermDefinition.new("rdfs", id: "http://www.w3.org/2000/01/rdf-schema#", simple: true, prefix: true), + "recipient" => TermDefinition.new("recipient", id: "https://w3id.org/credentials#recipient", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "schema" => TermDefinition.new("schema", id: "http://schema.org/", simple: true, prefix: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signatureAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "streetAddress" => TermDefinition.new("streetAddress", id: "http://schema.org/streetAddress", simple: true), + "title" => TermDefinition.new("title", id: "http://purl.org/dc/terms/title", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "url" => TermDefinition.new("url", id: "http://schema.org/url", type_mapping: "@id"), + "writePermission" => TermDefinition.new("writePermission", id: "https://w3id.org/permissions#writePermission", type_mapping: "@id"), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/lib/json_ld/security.rb b/lib/json_ld/security.rb new file mode 100644 index 00000000000..1230206f035 --- /dev/null +++ b/lib/json_ld/security.rb @@ -0,0 +1,50 @@ +# -*- encoding: utf-8 -*- +# frozen_string_literal: true +# This file generated automatically from https://w3id.org/security/v1 +require 'json/ld' +class JSON::LD::Context + add_preloaded("https://w3id.org/security/v1") do + new(processingMode: "json-ld-1.0", term_definitions: { + "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), + "EcdsaKoblitzSignature2016" => TermDefinition.new("EcdsaKoblitzSignature2016", id: "https://w3id.org/security#EcdsaKoblitzSignature2016", simple: true), + "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), + "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), + "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), + "LinkedDataSignature2016" => TermDefinition.new("LinkedDataSignature2016", id: "https://w3id.org/security#LinkedDataSignature2016", simple: true), + "authenticationTag" => TermDefinition.new("authenticationTag", id: "https://w3id.org/security#authenticationTag", simple: true), + "canonicalizationAlgorithm" => TermDefinition.new("canonicalizationAlgorithm", id: "https://w3id.org/security#canonicalizationAlgorithm", simple: true), + "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), + "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), + "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), + "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), + "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), + "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), + "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), + "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), + "encryptionKey" => TermDefinition.new("encryptionKey", id: "https://w3id.org/security#encryptionKey", simple: true), + "expiration" => TermDefinition.new("expiration", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "id" => TermDefinition.new("id", id: "@id", simple: true), + "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), + "iterationCount" => TermDefinition.new("iterationCount", id: "https://w3id.org/security#iterationCount", simple: true), + "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), + "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), + "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), + "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), + "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), + "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), + "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), + "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), + "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), + "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), + "salt" => TermDefinition.new("salt", id: "https://w3id.org/security#salt", simple: true), + "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), + "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), + "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signingAlgorithm", simple: true), + "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), + "type" => TermDefinition.new("type", id: "@type", simple: true), + "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) + }) + end +end diff --git a/spec/lib/activitypub/linked_data_signature_spec.rb b/spec/lib/activitypub/linked_data_signature_spec.rb new file mode 100644 index 00000000000..ee4b68028a3 --- /dev/null +++ b/spec/lib/activitypub/linked_data_signature_spec.rb @@ -0,0 +1,86 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::LinkedDataSignature do + include JsonLdHelper + + let!(:sender) { Fabricate(:account, uri: 'http://example.com/alice') } + + let(:raw_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'http://example.com/hello-world', + } + end + + let(:json) { raw_json.merge('signature' => signature) } + + subject { described_class.new(json) } + + describe '#verify_account!' do + context 'when signature matches' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => sign(sender, raw_signature, raw_json)) } + + it 'returns creator' do + expect(subject.verify_account!).to eq sender + end + end + + context 'when signature is missing' do + let(:signature) { nil } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + + context 'when signature is tampered' do + let(:raw_signature) do + { + 'creator' => 'http://example.com/alice', + 'created' => '2017-09-23T20:21:34Z', + } + end + + let(:signature) { raw_signature.merge('type' => 'RsaSignature2017', 'signatureValue' => 's69F3mfddd99dGjmvjdjjs81e12jn121Gkm1') } + + it 'returns nil' do + expect(subject.verify_account!).to be_nil + end + end + end + + describe '#sign!' do + subject { described_class.new(raw_json).sign!(sender) } + + it 'returns a hash' do + expect(subject).to be_a Hash + end + + it 'contains signature context' do + expect(subject['@context']).to include('https://www.w3.org/ns/activitystreams', 'https://w3id.org/identity/v1') + end + + it 'contains signature' do + expect(subject['signature']).to be_a Hash + expect(subject['signature']['signatureValue']).to be_present + end + + it 'can be verified again' do + expect(described_class.new(subject).verify_account!).to eq sender + end + end + + def sign(from_account, options, document) + options_hash = Digest::SHA256.hexdigest(canonicalize(options.merge('@context' => ActivityPub::LinkedDataSignature::CONTEXT))) + document_hash = Digest::SHA256.hexdigest(canonicalize(document)) + to_be_verified = options_hash + document_hash + Base64.strict_encode64(from_account.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_verified)) + end +end diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb index 6486483f65b..bf3bc82aa5f 100644 --- a/spec/services/activitypub/process_collection_service_spec.rb +++ b/spec/services/activitypub/process_collection_service_spec.rb @@ -1,9 +1,10 @@ require 'rails_helper' RSpec.describe ActivityPub::ProcessCollectionService do - subject { ActivityPub::ProcessCollectionService.new } + subject { described_class.new } describe '#call' do - pending + context 'when actor is the sender' + context 'when actor differs from sender' end end From 8ecfdd8795624b74d14df27d5468580734e5aede Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Sat, 26 Aug 2017 21:23:20 +0900 Subject: [PATCH 102/201] Set margin between character-counter and compose-form__buttons (#4698) For some languages publish translation is long. --- app/javascript/styles/components.scss | 2 +- app/javascript/styles/rtl.scss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index ef1797e7203..32cf450b725 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1872,7 +1872,7 @@ .character-counter__wrapper { line-height: 36px; - margin-right: 16px; + margin: 0 16px 0 8px; padding-top: 10px; } diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index 4966fbc216f..c8872c732af 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -8,7 +8,7 @@ body.rtl { } .character-counter__wrapper { - margin-right: 0; + margin-right: 8px; margin-left: 16px; } From 2a2698e4501094ca52b8b166c2c51fab26ce7427 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 15:32:40 +0200 Subject: [PATCH 103/201] Add ActivityPub serializer for Undo of Announce (#4703) --- .../activitypub/undo_announce_serializer.rb | 19 +++++++++++++++++++ app/services/batched_remove_status_service.rb | 2 +- app/services/remove_status_service.rb | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 app/serializers/activitypub/undo_announce_serializer.rb diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb new file mode 100644 index 00000000000..839847e22c0 --- /dev/null +++ b/app/serializers/activitypub/undo_announce_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActivityPub::UndoAnnounceSerializer < ActiveModel::Serializer + attributes :id, :type, :actor + + has_one :object, serializer: ActivityPub::ActivitySerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join + end + + def type + 'Undo' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end +end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index c90f4401dfb..e9e22298d59 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -140,7 +140,7 @@ class BatchedRemoveStatusService < BaseService @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new( status, - serializer: ActivityPub::DeleteSerializer, + serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter ).as_json) end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 62eea677f60..434c9de8478 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -81,7 +81,7 @@ class RemoveStatusService < BaseService def activity_json @activity_json ||= ActiveModelSerializers::SerializableResource.new( @status, - serializer: ActivityPub::DeleteSerializer, + serializer: @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, adapter: ActivityPub::Adapter ).as_json end From 884b085f534b02c664bcddd3a2fefcad6d4e5cc4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 16:10:35 +0200 Subject: [PATCH 104/201] Use Tombstone and _:atomUri in Delete activities as fallback (#4704) --- app/lib/activitypub/activity/delete.rb | 3 ++- .../activitypub/delete_serializer.rb | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 23f3430fb6e..a1598dfd32a 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -2,7 +2,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def perform - status = Status.find_by(uri: object_uri, account: @account) + status = Status.find_by(uri: object_uri, account: @account) + status ||= Status.find_by(uri: @object['_:atomUri'], account: @account) if @object.is_a?(Hash) && @object['_:atomUri'].present? if status.nil? delete_later!(object_uri) diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb index b49268d72a3..a041c577b3f 100644 --- a/app/serializers/activitypub/delete_serializer.rb +++ b/app/serializers/activitypub/delete_serializer.rb @@ -1,8 +1,26 @@ # frozen_string_literal: true class ActivityPub::DeleteSerializer < ActiveModel::Serializer + class TombstoneSerializer < ActiveModel::Serializer + attributes :id, :type + attribute :atom_uri, key: '_:atomUri' + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Tombstone' + end + + def atom_uri + ::TagManager.instance.uri_for(object) + end + end + attributes :id, :type, :actor - attribute :virtual_object, key: :object + + has_one :object, serializer: TombstoneSerializer def id [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join @@ -15,8 +33,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer def actor ActivityPub::TagManager.instance.uri_for(object.account) end - - def virtual_object - ActivityPub::TagManager.instance.uri_for(object) - end end From 0397c58b61d8a364ff488fc5631bfc751903c242 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 18:52:53 +0200 Subject: [PATCH 105/201] Forward ActivityPub deletes to followers of rebloggers (#4706) --- app/lib/activitypub/activity/delete.rb | 19 ++++++++++++++- .../activitypub/raw_distribution_worker.rb | 23 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 app/workers/activitypub/raw_distribution_worker.rb diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index a1598dfd32a..e7eb53a0207 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -8,7 +8,24 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity if status.nil? delete_later!(object_uri) else - RemoveStatusService.new.call(status) + forward_for_reblogs(status) + delete_now!(status) end end + + private + + def forward_for_reblogs(status) + ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account| + [payload, account.id] + end + end + + def delete_now!(status) + RemoveStatusService.new.call(status) + end + + def payload + @payload ||= Oj.dump(@json) + end end diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb new file mode 100644 index 00000000000..d73466f6e5c --- /dev/null +++ b/app/workers/activitypub/raw_distribution_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ActivityPub::RawDistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(json, source_account_id) + @account = Account.find(source_account_id) + + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [json, @account.id, inbox_url] + end + rescue ActiveRecord::RecordNotFound + true + end + + private + + def inboxes + @inboxes ||= @account.followers.inboxes + end +end From 0d5d11eeff048a5022a6eef68d299856f5bb9860 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 26 Aug 2017 19:55:10 +0200 Subject: [PATCH 106/201] Add _:inReplyToAtomUri to ActivityPub (#4702) --- app/helpers/jsonld_helper.rb | 2 +- app/lib/activitypub/activity/create.rb | 17 ++++++++++++++--- .../activitypub/activity_serializer.rb | 6 +++++- app/serializers/activitypub/note_serializer.rb | 7 +++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 09446c8be28..d82a073320d 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -10,7 +10,7 @@ module JsonLdHelper end def value_or_id(value) - value.is_a?(String) ? value : value['id'] + value.is_a?(String) || value.nil? ? value : value['id'] end def supported_context?(json) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 5c59c4b2496..114aed84f9d 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -91,7 +91,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def resolve_thread(status) return unless status.reply? && status.thread.nil? - ThreadResolveWorker.perform_async(status.id, @object['inReplyTo']) + ThreadResolveWorker.perform_async(status.id, in_reply_to_uri) end def conversation_from_uri(uri) @@ -118,8 +118,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def replied_to_status - return if @object['inReplyTo'].blank? - @replied_to_status ||= status_from_uri(@object['inReplyTo']) + return @replied_to_status if defined?(@replied_to_status) + + if in_reply_to_uri.blank? + @replied_to_status = nil + else + @replied_to_status = status_from_uri(in_reply_to_uri) + @replied_to_status ||= status_from_uri(@object['_:inReplyToAtomUri']) if @object['_:inReplyToAtomUri'].present? + @replied_to_status + end + end + + def in_reply_to_uri + value_or_id(@object['inReplyTo']) end def text_from_content diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb index d20ee99206a..349495e84eb 100644 --- a/app/serializers/activitypub/activity_serializer.rb +++ b/app/serializers/activitypub/activity_serializer.rb @@ -10,7 +10,7 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer end def type - object.reblog? ? 'Announce' : 'Create' + announce? ? 'Announce' : 'Create' end def actor @@ -24,4 +24,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer def cc ActivityPub::TagManager.instance.cc(object) end + + def announce? + object.reblog? + end end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4061b9ce456..15031dfdc44 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -9,6 +9,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer has_many :virtual_tags, key: :tag attribute :atom_uri, key: '_:atomUri', if: :local? + attribute :in_reply_to_atom_uri, key: '_:inReplyToAtomUri' def id ActivityPub::TagManager.instance.uri_for(object) @@ -64,6 +65,12 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer ::TagManager.instance.uri_for(object) end + def in_reply_to_atom_uri + return unless object.reply? + + ::TagManager.instance.uri_for(object.thread) + end + def local? object.account.local? end From f095a9f8a5788aaf4205e9bdca5845a844f8c0d3 Mon Sep 17 00:00:00 2001 From: unarist Date: Sun, 27 Aug 2017 20:35:01 +0900 Subject: [PATCH 107/201] Allow Symbol keyed Hash in LinkedDataSignature (#4715) SerializarbleResource#as_json serializes to Symbol keyed Hash, but current implementation of LinkedDataSignature expects String keyed Hash. So it generates broken payload. --- app/lib/activitypub/linked_data_signature.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index 7173aed19d4..4483339a96b 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -6,7 +6,7 @@ class ActivityPub::LinkedDataSignature CONTEXT = 'https://w3id.org/identity/v1' def initialize(json) - @json = json + @json = json.with_indifferent_access end def verify_account! From 26402ee2cb57b4c0695c584fa24acfe429c14547 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 27 Aug 2017 13:35:18 +0200 Subject: [PATCH 108/201] Adjust RTL styles (#4712) --- app/javascript/styles/rtl.scss | 127 ++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index c8872c732af..6c003d69ad9 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -32,6 +32,11 @@ body.rtl { right: auto; } + .column-header__back-button { + padding-left: 5px; + padding-right: 0; + } + .column-header__setting-arrows { float: left; } @@ -54,25 +59,64 @@ body.rtl { right: 10px; } - .status { + .status, + .activity-stream .status.light { padding-left: 10px; padding-right: 68px; } - .status__info .status__display-name { + .status__info .status__display-name, + .activity-stream .status.light .status__display-name { padding-left: 25px; padding-right: 0; } + .activity-stream .pre-header { + padding-right: 68px; + padding-left: 0; + } + + .status__prepend { + margin-left: 0; + margin-right: 68px; + } + + .status__prepend-icon-wrapper { + left: auto; + right: -26px; + } + + .activity-stream .pre-header .pre-header__icon { + left: auto; + right: 42px; + } + + .account__avatar-overlay-overlay { + right: auto; + left: 0; + } + .column-back-button--slim-button { right: auto; left: 0; } - .status__relative-time { + .status__relative-time, + .activity-stream .status.light .status__header .status__meta { float: left; } + .activity-stream .detailed-status.light .detailed-status__display-name > div { + float: right; + margin-right: 0; + margin-left: 10px; + } + + .activity-stream .detailed-status.light .detailed-status__meta span > span { + margin-left: 0; + margin-right: 6px; + } + .status__action-bar-button { float: right; margin-right: 0; @@ -129,6 +173,78 @@ body.rtl { right: -2.14285714em; } + .admin-wrapper .sidebar ul a i.fa, + a.table-action-link i.fa { + margin-right: 0; + margin-left: 5px; + } + + .simple_form .check_boxes .checkbox label, + .simple_form .input.with_label.boolean label.checkbox { + padding-left: 0; + padding-right: 25px; + } + + .simple_form .check_boxes .checkbox input[type="checkbox"], + .simple_form .input.boolean input[type="checkbox"] { + left: auto; + right: 0; + } + + .simple_form .input-with-append .input input { + padding-left: 127px; + padding-right: 0; + } + + .simple_form .input-with-append .append { + right: auto; + left: 0; + } + + .table th, + .table td { + text-align: right; + } + + .filters .filter-subset { + margin-right: 0; + margin-left: 45px; + } + + .landing-page .header-wrapper .mascot { + right: 60px; + left: auto; + } + + .landing-page .header .hero .floats .float-1 { + left: -120px; + right: auto; + } + + .landing-page .header .hero .floats .float-2 { + left: 210px; + right: auto; + } + + .landing-page .header .hero .floats .float-3 { + left: 110px; + right: auto; + } + + .landing-page .header .links .brand img { + left: 0; + } + + .landing-page .fa-external-link { + padding-right: 5px; + padding-left: 0 !important; + } + + .landing-page .features #mastodon-timeline { + margin-right: 0; + margin-left: 30px; + } + @media screen and (min-width: 1025px) { .column, .drawer { @@ -139,11 +255,6 @@ body.rtl { padding-left: 5px; padding-right: 10px; } - - &:last-child { - padding-right: 0; - padding-left: 10px; - } } .columns-area > div { From f92d991e529686a3bd402b957a2c3727a0094b85 Mon Sep 17 00:00:00 2001 From: mayaeh Date: Mon, 28 Aug 2017 00:03:27 +0900 Subject: [PATCH 109/201] Add japanese translations for Pinned statuses based on pawoo. (#4717) Add japanese translations for pin_errors. --- app/javascript/mastodon/locales/ja.json | 4 ++-- config/locales/ja.yml | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index c35b0def3f7..4877490f008 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -168,7 +168,7 @@ "status.mention": "返信", "status.mute_conversation": "会話をミュート", "status.open": "詳細を表示", - "status.pin": "Pin on profile", + "status.pin": "プロフィールに固定表示", "status.reblog": "ブースト", "status.reblogged_by": "{name}さんにブーストされました", "status.reply": "返信", @@ -180,7 +180,7 @@ "status.show_less": "隠す", "status.show_more": "もっと見る", "status.unmute_conversation": "会話のミュートを解除", - "status.unpin": "Unpin from profile", + "status.unpin": "プロフィールの固定表示を解除", "tabs_bar.compose": "投稿", "tabs_bar.federated_timeline": "連合", "tabs_bar.home": "ホーム", diff --git a/config/locales/ja.yml b/config/locales/ja.yml index b847708d3eb..f671f594fef 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -433,6 +433,10 @@ ja: statuses: open_in_web: Webで開く over_character_limit: 上限は %{max}文字までです + pin_errors: + ownership: 他人のトゥートを固定することはできません + private: 非公開のトゥートを固定することはできません + reblog: ブーストされたトゥートを固定することはできません show_more: もっと見る visibilities: private: 非公開 @@ -443,6 +447,7 @@ ja: unlisted_long: 誰でも見ることができますが、公開タイムラインには表示されません stream_entries: click_to_show: クリックして表示 + pinned: 固定されたトゥート reblogged: さんにブーストされました sensitive_content: 閲覧注意 terms: From 15093f9113e0eda8a66a7c12bbd92e7e8670da15 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 27 Aug 2017 17:04:45 +0200 Subject: [PATCH 110/201] Shorten display of large numbers on public profiles (#4711) --- app/views/accounts/_header.html.haml | 6 +++--- config/i18n-tasks.yml | 4 ++-- config/locales/en.yml | 11 +++++++++++ 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 8009e903ec1..a6366b0f3e9 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -37,15 +37,15 @@ .details-counters .counter{ class: active_nav_class(short_account_url(account)) } = link_to short_account_url(account), class: 'u-url u-uid' do - %span.counter-number= number_with_delimiter account.statuses_count + %span.counter-number= number_to_human account.statuses_count %span.counter-label= t('accounts.posts') .counter{ class: active_nav_class(account_following_index_url(account)) } = link_to account_following_index_url(account) do - %span.counter-number= number_with_delimiter account.following_count + %span.counter-number= number_to_human account.following_count %span.counter-label= t('accounts.following') .counter{ class: active_nav_class(account_followers_url(account)) } = link_to account_followers_url(account) do - %span.counter-number= number_with_delimiter account.followers_count + %span.counter-number= number_to_human account.followers_count %span.counter-label= t('accounts.followers') diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 849e8116aa5..b51cf46df95 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -36,7 +36,7 @@ ignore_missing: - 'activerecord.attributes.*' - 'activerecord.errors.*' - '{devise,pagination,doorkeeper}.*' - - '{date,datetime,time}.*' + - '{date,datetime,time,number}.*' - 'simple_form.{yes,no}' - 'simple_form.{placeholders,hints,labels}.*' - 'simple_form.{error_notification,required}.:' @@ -50,7 +50,7 @@ ignore_unused: - 'activerecord.attributes.*' - 'activerecord.errors.*' - '{devise,pagination,doorkeeper}.*' - - '{date,datetime,time}.*' + - '{date,datetime,time,number}.*' - 'simple_form.{yes,no}' - 'simple_form.{placeholders,hints,labels}.*' - 'simple_form.{error_notification,required}.:' diff --git a/config/locales/en.yml b/config/locales/en.yml index 96d08e6b2ea..1a9926edd8a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -353,6 +353,17 @@ en: reblog: body: 'Your status was boosted by %{name}:' subject: "%{name} boosted your status" + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: Next prev: Prev From bab9afaa092e0eb8b3f11fdcdaf7a75d3ce94566 Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Mon, 28 Aug 2017 06:59:51 +0900 Subject: [PATCH 111/201] Adjust public profile pages (#4713) * Adjust account-grid in public profiles Full-width card on mobile UI. Set break-word for long name and ID. Fix margin. * Reduce padding-bottom of public profiles * Revive next prev buttons in mobile public profiles In followers followees pages. * Revert break-word for username * Fix overflow of display_name Need re-setting text-overflow and overflow in display: block; --- app/javascript/styles/accounts.scss | 14 +++++++++----- app/javascript/styles/basics.scss | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index f1fbe873b42..744650554c4 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -296,7 +296,9 @@ } .next, - .prev { + .prev, + .next a, + .prev a { display: inline-block; } } @@ -306,7 +308,7 @@ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); background: darken($simple-background-color, 8%); border-radius: 0 0 4px 4px; - padding: 20px 10px; + padding: 20px 5px; padding-bottom: 10px; overflow: hidden; display: flex; @@ -325,11 +327,11 @@ background: $simple-background-color; border-radius: 4px; color: $ui-base-color; - margin-bottom: 10px; + margin: 0 5px 10px; position: relative; - &:nth-child(odd) { - margin-right: 10px; + @media screen and (max-width: 740px) { + width: calc(100% - 10px); } .account-grid-card__header { @@ -400,6 +402,8 @@ .display_name { font-size: 16px; display: block; + text-overflow: ellipsis; + overflow: hidden; } .username { diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss index e524b7f26b3..6e87157ec39 100644 --- a/app/javascript/styles/basics.scss +++ b/app/javascript/styles/basics.scss @@ -6,7 +6,7 @@ body { line-height: 18px; font-weight: 400; color: $primary-text-color; - padding-bottom: 140px; + padding-bottom: 40px; text-rendering: optimizelegibility; font-feature-settings: "kern"; text-size-adjust: none; From 07994eed002375025f0c377079500d25e87cb641 Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Mon, 28 Aug 2017 07:01:07 +0900 Subject: [PATCH 112/201] Adjust "signed in as" pages (#4720) * Adjust "signed in as" pages Fix min-width Set width of .account-header .name To apply text-overflow and overflow settings Set overflow for detailed-status__display-name * Remove trailing whitespace --- app/javascript/styles/components.scss | 2 ++ app/javascript/styles/containers.scss | 6 +++++- app/javascript/styles/forms.scss | 4 ++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 32cf450b725..3a6672b9f18 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1057,6 +1057,8 @@ strong, span { display: block; + text-overflow: ellipsis; + overflow: hidden; } strong { diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss index cfe8ea6438b..af2589e23c3 100644 --- a/app/javascript/styles/containers.scss +++ b/app/javascript/styles/containers.scss @@ -72,7 +72,7 @@ margin-bottom: -30px; margin-top: 40px; - @media screen and (max-width: 400px) { + @media screen and (max-width: 440px) { width: 100%; margin: 0; margin-bottom: 10px; @@ -97,10 +97,13 @@ .name { flex: 1 1 auto; color: $ui-secondary-color; + width: calc(100% - 88px); .username { display: block; font-weight: 500; + text-overflow: ellipsis; + overflow: hidden; } } @@ -108,5 +111,6 @@ display: block; font-size: 32px; line-height: 40px; + margin-left: 8px; } } diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss index 8e41bb00232..78f13270a72 100644 --- a/app/javascript/styles/forms.scss +++ b/app/javascript/styles/forms.scss @@ -359,6 +359,10 @@ code { color: $ui-secondary-color; font-weight: 500; } + + @media screen and (max-width: 740px) and (min-width: 441px) { + margin-top: 40px; + } } .qr-wrapper { From 8f527bd5884ccb8bfc7f1ff583fa35ae4fa6618c Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Mon, 28 Aug 2017 08:16:49 +0900 Subject: [PATCH 113/201] Add japanese translations for shorten display of large numbers (#4722) --- config/locales/ja.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/locales/ja.yml b/config/locales/ja.yml index f671f594fef..e4b18529cee 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -352,6 +352,17 @@ ja: reblog: body: 'あなたのトゥートが %{name} さんにブーストされました:' subject: あなたのトゥートが %{name} さんにブーストされました + number: + human: + decimal_units: + format: "%n%u" + units: + billion: B + million: M + quadrillion: Q + thousand: K + trillion: T + unit: '' pagination: next: 次 prev: 前 From ce9a5f358ed1ea5039c1de0bc896dc60622852f7 Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 29 Aug 2017 02:12:09 +0900 Subject: [PATCH 114/201] rescue HTTP::ConnectionError in RemoteFollowController#create (#4726) --- app/models/remote_follow.rb | 2 +- spec/controllers/remote_follow_controller_spec.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 8366d43c5bc..c3f86774335 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -42,7 +42,7 @@ class RemoteFollow def acct_resource @_acct_resource ||= Goldfinger.finger("acct:#{acct}") - rescue Goldfinger::Error + rescue Goldfinger::Error, HTTP::ConnectionError nil end diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb index 915c86f8ea5..86b1eb8d0fd 100644 --- a/spec/controllers/remote_follow_controller_spec.rb +++ b/spec/controllers/remote_follow_controller_spec.rb @@ -87,6 +87,14 @@ describe RemoteFollowController do expect(response).to render_template(:new) expect(response.body).to include(I18n.t('remote_follow.missing_resource')) end + + it 'renders new when occur HTTP::ConnectionError' do + allow(Goldfinger).to receive(:finger).with('acct:user@unknown').and_raise(HTTP::ConnectionError) + post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@unknown' } } + + expect(response).to render_template(:new) + expect(response.body).to include(I18n.t('remote_follow.missing_resource')) + end end end From 7876aed134be16d04cf7d177299ae4fda690c219 Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 29 Aug 2017 04:38:59 +0900 Subject: [PATCH 115/201] Fix deletion of status which has been reblogged (#4728) --- app/services/remove_status_service.rb | 4 ++-- spec/services/remove_status_service_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 434c9de8478..7ddbd89067d 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -55,8 +55,8 @@ class RemoveStatusService < BaseService end # ActivityPub - ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |inbox_url| - [signed_activity_json, @account.id, inbox_url] + ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account| + [signed_activity_json, @account.id, target_account.inbox_url] end end diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb index dc6b350cb72..8b34bdb6b65 100644 --- a/spec/services/remove_status_service_spec.rb +++ b/spec/services/remove_status_service_spec.rb @@ -7,17 +7,20 @@ RSpec.describe RemoveStatusService do let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') } let!(:jeff) { Fabricate(:account) } let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + let!(:bill) { Fabricate(:account, username: 'bill', protocol: :activitypub, domain: 'example2.com', inbox_url: 'http://example2.com/inbox') } before do stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {}) stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + stub_request(:post, 'http://example2.com/inbox').to_return(status: 200) Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now) jeff.follow!(alice) hank.follow!(alice) @status = PostStatusService.new.call(alice, 'Hello @bob@example.com') + Fabricate(:status, account: bill, reblog: @status, uri: 'hoge') subject.call(@status) end @@ -45,4 +48,8 @@ RSpec.describe RemoveStatusService do xml.match(TagManager::VERBS[:delete]) }).to have_been_made.once end + + it 'sends delete activity to rebloggers' do + expect(a_request(:post, 'http://example2.com/inbox')).to have_been_made + end end From 938cd2875b14db3655a6c9f82f672f4baf7720a3 Mon Sep 17 00:00:00 2001 From: unarist Date: Tue, 29 Aug 2017 05:08:11 +0900 Subject: [PATCH 116/201] Fix Delete activity handling when the status has been reblogged (#4729) --- app/lib/activitypub/activity/delete.rb | 4 ++-- spec/lib/activitypub/activity/delete_spec.rb | 24 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index e7eb53a0207..789ed58f16a 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -16,8 +16,8 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity private def forward_for_reblogs(status) - ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account| - [payload, account.id] + ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id| + [payload, account_id] end end diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb index 398669b485f..6601f7262c5 100644 --- a/spec/lib/activitypub/activity/delete_spec.rb +++ b/spec/lib/activitypub/activity/delete_spec.rb @@ -25,4 +25,28 @@ RSpec.describe ActivityPub::Activity::Delete do expect(Status.find_by(id: status.id)).to be_nil end end + + context 'when the status has been reblogged' do + describe '#perform' do + subject { described_class.new(json, sender) } + let(:reblogger) { Fabricate(:account) } + let(:follower) { Fabricate(:account, username: 'follower', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + follower.follow!(reblogger) + Fabricate(:status, account: reblogger, reblog: status) + subject.perform + end + + it 'deletes sender\'s status' do + expect(Status.find_by(id: status.id)).to be_nil + end + + it 'sends delete activity to followers of rebloggers' do + # one for Delete original post, and one for Undo reblog (normal delivery) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice + end + end + end end From 0827c09c448ea8d61e62534dd3547719e148a4ae Mon Sep 17 00:00:00 2001 From: abcang Date: Tue, 29 Aug 2017 05:23:44 +0900 Subject: [PATCH 117/201] Generalized the infinite scrollable list (#4697) --- app/javascript/mastodon/components/account.js | 12 +- .../intersection_observer_article.js | 122 ++++++++++++ .../mastodon/components/scrollable_list.js | 179 ++++++++++++++++++ app/javascript/mastodon/components/status.js | 112 ++--------- .../mastodon/components/status_list.js | 153 ++------------- .../features/favourited_statuses/index.js | 5 +- .../notifications/components/notification.js | 10 +- .../mastodon/features/notifications/index.js | 103 +++------- 8 files changed, 376 insertions(+), 320 deletions(-) create mode 100644 app/javascript/mastodon/components/intersection_observer_article.js create mode 100644 app/javascript/mastodon/components/scrollable_list.js diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 69cc63d106e..6456c12baa0 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent { onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, }; handleFollow = () => { @@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent { } render () { - const { account, me, intl } = this.props; + const { account, me, intl, hidden } = this.props; if (!account) { return
    ; } + if (hidden) { + return ( +
    + {account.get('display_name')} + {account.get('username')} +
    + ); + } + let buttons; if (account.get('id') !== me && account.get('relationship', null) !== null) { diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js new file mode 100644 index 00000000000..34776781880 --- /dev/null +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; +import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; + +export default class IntersectionObserverArticle extends ImmutablePureComponent { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + if (!nextState.isIntersecting && nextState.isHidden) { + // It's only if we're not intersecting (i.e. offscreen) and isHidden is true + // that either "isIntersecting" or "isHidden" matter, and then they're + // the only things that matter (and updated ARIA attributes). + return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { + // If we're going from a non-intersecting state to an intersecting state, + // (i.e. offscreen to onscreen), then we definitely need to re-render + return true; + } + // Otherwise, diff based on "updateOnProps" and "updateOnStates" + return super.shouldComponentUpdate(nextProps, nextState); + } + + componentDidMount () { + if (!this.props.intersectionObserverWrapper) { + // TODO: enable IntersectionObserver optimization for notification statuses. + // These are managed in notifications/index.js rather than status_list.js + return; + } + this.props.intersectionObserverWrapper.observe( + this.props.id, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + if (this.props.intersectionObserverWrapper) { + this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); + } + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + if (this.node && this.node.children.length !== 0) { + // save the height of the fully-rendered element + this.height = getRectFromEntry(entry).height; + + if (this.props.onHeightChange) { + this.props.onHeightChange(this.props.status, this.height); + } + } + + this.setState((prevState) => { + if (prevState.isIntersecting && !entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: entry.isIntersecting, + isHidden: false, + }; + }); + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength } = this.props; + const { isIntersecting, isHidden } = this.state; + + if (!isIntersecting && isHidden) { + return ( +
    + {children && React.cloneElement(children, { hidden: true })} +
    + ); + } + + return ( +
    + {children && React.cloneElement(children, { hidden: false })} +
    + ); + } + +} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js new file mode 100644 index 00000000000..1a122dbe58a --- /dev/null +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -0,0 +1,179 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticle from './intersection_observer_article'; +import LoadMore from './load_more'; +import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; + +export default class ScrollableList extends PureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + children: PropTypes.node, + }; + + static defaultProps = { + trackScroll: true, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + }, 150, { + trailing: true, + }); + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + componentDidUpdate (prevProps) { + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (React.Children.count(prevProps.children) < React.Children.count(this.props.children) && this._oldScrollPosition && this.node.scrollTop > 0) { + if (this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props)) { + const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; + if (this.node.scrollTop !== newScrollTop) { + this.node.scrollTop = newScrollTop; + } + } else { + this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; + } + } + } + + componentWillUnmount () { + this.detachScrollListener(); + this.detachIntersectionObserver(); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + getFirstChildKey (props) { + const { children } = props; + const firstChild = Array.isArray(children) ? children[0] : children; + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + handleKeyDown = (e) => { + if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { + const article = (() => { + switch (e.key) { + case 'PageDown': + return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; + case 'PageUp': + return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; + case 'End': + return this.node.querySelector('[role="feed"] > article:last-of-type'); + case 'Home': + return this.node.querySelector('[role="feed"] > article:first-of-type'); + default: + return null; + } + })(); + + + if (article) { + e.preventDefault(); + article.focus(); + article.scrollIntoView(); + } + } + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const childrenCount = React.Children.count(children); + + const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; + let scrollableArea = null; + + if (isLoading || childrenCount > 0 || !emptyMessage) { + scrollableArea = ( +
    +
    + {prepend} + + {React.Children.map(this.props.children, (child, index) => ( + + {child} + + ))} + + {loadMore} +
    +
    + ); + } else { + scrollableArea = ( +
    + {emptyMessage} +
    + ); + } + + if (trackScroll) { + return ( + + {scrollableArea} + + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index b4f523f725e..4ab40d6bf35 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -9,13 +9,11 @@ import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress import Bundle from '../features/ui/components/bundle'; -import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; export default class Status extends ImmutablePureComponent { @@ -26,7 +24,6 @@ export default class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, - wrapped: PropTypes.bool, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, @@ -40,14 +37,11 @@ export default class Status extends ImmutablePureComponent { boostModal: PropTypes.bool, autoPlayGif: PropTypes.bool, muted: PropTypes.bool, - intersectionObserverWrapper: PropTypes.object, - index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hidden: PropTypes.bool, }; state = { isExpanded: false, - isHidden: false, // set to true in requestIdleCallback to trigger un-render } // Avoid checking props that are functions (and whose equality will always @@ -55,91 +49,15 @@ export default class Status extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', - 'wrapped', 'me', 'boostModal', 'autoPlayGif', 'muted', - 'listLength', + 'hidden', ] updateOnStates = ['isExpanded'] - shouldComponentUpdate (nextProps, nextState) { - if (!nextState.isIntersecting && nextState.isHidden) { - // It's only if we're not intersecting (i.e. offscreen) and isHidden is true - // that either "isIntersecting" or "isHidden" matter, and then they're - // the only things that matter (and updated ARIA attributes). - return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; - } else if (nextState.isIntersecting && !this.state.isIntersecting) { - // If we're going from a non-intersecting state to an intersecting state, - // (i.e. offscreen to onscreen), then we definitely need to re-render - return true; - } - // Otherwise, diff based on "updateOnProps" and "updateOnStates" - return super.shouldComponentUpdate(nextProps, nextState); - } - - componentDidMount () { - if (!this.props.intersectionObserverWrapper) { - // TODO: enable IntersectionObserver optimization for notification statuses. - // These are managed in notifications/index.js rather than status_list.js - return; - } - this.props.intersectionObserverWrapper.observe( - this.props.id, - this.node, - this.handleIntersection - ); - - this.componentMounted = true; - } - - componentWillUnmount () { - if (this.props.intersectionObserverWrapper) { - this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); - } - - this.componentMounted = false; - } - - handleIntersection = (entry) => { - if (this.node && this.node.children.length !== 0) { - // save the height of the fully-rendered element - this.height = getRectFromEntry(entry).height; - - if (this.props.onHeightChange) { - this.props.onHeightChange(this.props.status, this.height); - } - } - - this.setState((prevState) => { - if (prevState.isIntersecting && !entry.isIntersecting) { - scheduleIdleTask(this.hideIfNotIntersecting); - } - return { - isIntersecting: entry.isIntersecting, - isHidden: false, - }; - }); - } - - hideIfNotIntersecting = () => { - if (!this.componentMounted) { - return; - } - - // When the browser gets a chance, test if we're still not intersecting, - // and if so, set our isHidden to true to trigger an unrender. The point of - // this is to save DOM nodes and avoid using up too much memory. - // See: https://github.com/tootsuite/mastodon/issues/2900 - this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); - } - - handleRef = (node) => { - this.node = node; - } - handleClick = () => { if (!this.context.router) { return; @@ -173,25 +91,19 @@ export default class Status extends ImmutablePureComponent { let media = null; let statusAvatar; - // Exclude intersectionObserverWrapper from `other` variable - // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props; - const { isExpanded, isIntersecting, isHidden } = this.state; + const { status, account, hidden, ...other } = this.props; + const { isExpanded } = this.state; if (status === null) { return null; } - const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper; - const isHiddenForSure = isIntersecting === false && isHidden; - const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height'); - - if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) { + if (hidden) { return ( -
    +
    {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')} -
    +
    ); } @@ -199,14 +111,14 @@ export default class Status extends ImmutablePureComponent { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; return ( -
    +
    }} />
    - -
    + + ); } @@ -235,7 +147,7 @@ export default class Status extends ImmutablePureComponent { } return ( -
    +
    @@ -253,7 +165,7 @@ export default class Status extends ImmutablePureComponent { {media} -
    + ); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index ca443c286aa..cbae28afe35 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,9 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ScrollContainer } from 'react-router-scroll'; import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; -import LoadMore from './load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; -import { throttle } from 'lodash'; +import ScrollableList from './scrollable_list'; export default class StatusList extends ImmutablePureComponent { @@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; - intersectionObserverWrapper = new IntersectionObserverWrapper(); - - handleScroll = throttle(() => { - if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { - this.props.onScrollToBottom(); - } else if (scrollTop < 100 && this.props.onScrollToTop) { - this.props.onScrollToTop(); - } else if (this.props.onScroll) { - this.props.onScroll(); - } - } - }, 150, { - trailing: true, - }); - - componentDidMount () { - this.attachScrollListener(); - this.attachIntersectionObserver(); - - // Handle initial scroll posiiton - this.handleScroll(); - } - - componentDidUpdate (prevProps) { - // Reset the scroll position when a new toot comes in in order not to - // jerk the scrollbar around if you're already scrolled down the page. - if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) { - if (prevProps.statusIds.first() !== this.props.statusIds.first()) { - let newScrollTop = this.node.scrollHeight - this._oldScrollPosition; - if (this.node.scrollTop !== newScrollTop) { - this.node.scrollTop = newScrollTop; - } - } else { - this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; - } - } - } - - componentWillUnmount () { - this.detachScrollListener(); - this.detachIntersectionObserver(); - } - - attachIntersectionObserver () { - this.intersectionObserverWrapper.connect({ - root: this.node, - rootMargin: '300% 0px', - }); - } - - detachIntersectionObserver () { - this.intersectionObserverWrapper.disconnect(); - } - - attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - } - - setRef = (c) => { - this.node = c; - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.props.onScrollToBottom(); - } - - handleKeyDown = (e) => { - if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { - const article = (() => { - switch (e.key) { - case 'PageDown': - return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; - case 'PageUp': - return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; - case 'End': - return this.node.querySelector('[role="feed"] > article:last-of-type'); - case 'Home': - return this.node.querySelector('[role="feed"] > article:first-of-type'); - default: - return null; - } - })(); - - - if (article) { - e.preventDefault(); - article.focus(); - article.scrollIntoView(); - } - } - } - render () { - const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { statusIds, ...other } = this.props; + const { isLoading } = other; - const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; - let scrollableArea = null; + const scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId) => ( + + )) + ) : null; - if (isLoading || statusIds.size > 0 || !emptyMessage) { - scrollableArea = ( -
    -
    - {prepend} - - {statusIds.map((statusId, index) => { - return ; - })} - - {loadMore} -
    -
    - ); - } else { - scrollableArea = ( -
    - {emptyMessage} -
    - ); - } - - if (trackScroll) { - return ( - - {scrollableArea} - - ); - } else { - return scrollableArea; - } + return ( + + {scrollableContent} + + ); } } diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index d9ad9bc1f95..82b16b36986 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -16,6 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); @connect(mapStateToProps) @@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, }; componentWillMount () { @@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent { } render () { - const { intl, statusIds, columnId, multiColumn } = this.props; + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; return ( @@ -81,6 +83,7 @@ export default class Favourites extends ImmutablePureComponent { trackScroll={!pinned} statusIds={statusIds} scrollKey={`favourited_statuses-${columnId}`} + hasMore={hasMore} onScrollToBottom={this.handleScrollToBottom} /> diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 2992185fd1e..a608a5223dd 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; @@ -10,6 +11,7 @@ export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, }; renderFollow (account, link) { @@ -23,13 +25,13 @@ export default class Notification extends ImmutablePureComponent { - +