From 3d9b8847d21d886886baae483304288139669795 Mon Sep 17 00:00:00 2001 From: abcang Date: Thu, 28 Sep 2017 22:04:32 +0900 Subject: [PATCH 001/137] Flush body when POST requests (#5128) --- app/services/send_interaction_service.rb | 2 +- app/services/subscribe_service.rb | 2 +- app/services/unsubscribe_service.rb | 2 +- app/workers/activitypub/delivery_worker.rb | 2 +- app/workers/pubsubhubbub/delivery_worker.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb index c11813abc6..af205c9c9d 100644 --- a/app/services/send_interaction_service.rb +++ b/app/services/send_interaction_service.rb @@ -12,7 +12,7 @@ class SendInteractionService < BaseService return if !target_account.ostatus? || block_notification? - delivery = build_request.perform + delivery = build_request.perform.flush raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300 end diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index bfa7ff8c8e..2d8af02030 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -6,7 +6,7 @@ class SubscribeService < BaseService @account = account @account.secret = SecureRandom.hex - @response = build_request.perform + @response = build_request.perform.flush if response_failed_permanently? # We're not allowed to subscribe. Fail and move on. diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index b99046712c..d84a5a530d 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -7,7 +7,7 @@ class UnsubscribeService < BaseService @account = account begin - @response = build_request.perform + @response = build_request.perform.flush Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? rescue HTTP::Error, OpenSSL::SSL::SSLError => e diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index a4e8293434..059c328134 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -28,7 +28,7 @@ class ActivityPub::DeliveryWorker end def perform_request - @response = build_request.perform + @response = build_request.perform.flush end def response_successful? diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 110b8bf162..c3506727b9 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -37,7 +37,7 @@ class Pubsubhubbub::DeliveryWorker def callback_post_payload request = Request.new(:post, subscription.callback_url, body: payload) request.add_headers(headers) - request.perform + request.perform.flush end def blocked_domain? From 4ec1771165ab8dd40e52804fd087eacfab25290b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 28 Sep 2017 15:31:31 +0200 Subject: [PATCH 002/137] Add ability to specify alternative text for media attachments (#5123) * Fix #117 - Add ability to specify alternative text for media attachments - POST /api/v1/media accepts `description` straight away - PUT /api/v1/media/:id to update `description` (only for unattached ones) - Serialized as `name` of Document object in ActivityPub - Uploads form adjusted for better performance and description input * Add tests * Change undo button blend mode to difference --- app/controllers/api/v1/media_controller.rb | 10 +- app/javascript/mastodon/actions/compose.js | 38 ++++ .../components/extended_video_player.js | 14 +- .../mastodon/components/media_gallery.js | 3 +- .../mastodon/components/video_player.js | 204 ------------------ .../features/compose/components/upload.js | 96 +++++++++ .../compose/components/upload_form.js | 44 +--- .../compose/containers/upload_container.js | 21 ++ .../containers/upload_form_container.js | 13 +- .../features/ui/components/media_modal.js | 5 +- .../features/ui/components/video_modal.js | 1 + .../features/ui/util/async-components.js | 4 - .../mastodon/features/video/index.js | 4 +- app/javascript/mastodon/reducers/compose.js | 19 +- app/javascript/styles/components.scss | 47 +++- app/lib/activitypub/activity/create.rb | 2 +- app/models/media_attachment.rb | 7 + .../activitypub/note_serializer.rb | 6 +- .../rest/media_attachment_serializer.rb | 3 +- config/routes.rb | 2 +- ...09_add_description_to_media_attachments.rb | 5 + db/schema.rb | 3 +- .../api/v1/media_controller_spec.rb | 29 +++ spec/models/media_attachment_spec.rb | 9 +- 24 files changed, 311 insertions(+), 278 deletions(-) delete mode 100644 app/javascript/mastodon/components/video_player.js create mode 100644 app/javascript/mastodon/features/compose/components/upload.js create mode 100644 app/javascript/mastodon/features/compose/containers/upload_container.js create mode 100644 db/migrate/20170927215609_add_description_to_media_attachments.rb diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 8a1992fca4..9f330f0dfe 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController respond_to :json def create - @media = current_account.media_attachments.create!(file: media_params[:file]) + @media = current_account.media_attachments.create!(media_params) render json: @media, serializer: REST::MediaAttachmentSerializer rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 @@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController render json: processing_error, status: 500 end + def update + @media = current_account.media_attachments.where(status_id: nil).find(params[:id]) + @media.update!(media_params) + render json: @media, serializer: REST::MediaAttachmentSerializer + end + private def media_params - params.permit(:file) + params.permit(:file, :description) end def file_type_error diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9f10a8c150..8be5b939f9 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -37,6 +37,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; +export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'; +export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS'; +export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -165,6 +169,40 @@ export function uploadCompose(files) { }; }; +export function changeUploadCompose(id, description) { + return (dispatch, getState) => { + dispatch(changeUploadComposeRequest()); + + api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { + dispatch(changeUploadComposeSuccess(response.data)); + }).catch(error => { + dispatch(changeUploadComposeFail(id, error)); + }); + }; +}; + +export function changeUploadComposeRequest() { + return { + type: COMPOSE_UPLOAD_CHANGE_REQUEST, + skipLoading: true, + }; +}; +export function changeUploadComposeSuccess(media) { + return { + type: COMPOSE_UPLOAD_CHANGE_SUCCESS, + media: media, + skipLoading: true, + }; +}; + +export function changeUploadComposeFail(error) { + return { + type: COMPOSE_UPLOAD_CHANGE_FAIL, + error: error, + skipLoading: true, + }; +}; + export function uploadComposeRequest() { return { type: COMPOSE_UPLOAD_REQUEST, diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index 5ab5e9e586..f8bd067e8e 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, time: PropTypes.number, @@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent { } render () { + const { src, muted, controls, alt } = this.props; + return (
); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index a81409871e..38b26b1fc7 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -136,7 +136,7 @@ class Item extends React.PureComponent { onClick={this.handleClick} target='_blank' > - + {attachment.get('description')} ); } else if (attachment.get('type') === 'gifv') { @@ -146,6 +146,7 @@ class Item extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index b8c5e885a3..ad5493f8c5 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -90,10 +90,6 @@ export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } -export function VideoPlayer () { - return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); -} - export function Video () { return import(/* webpackChunkName: "features/video" */'../../video'); } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index f228e434b3..069264ef53 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -104,6 +104,7 @@ export default class Video extends React.PureComponent { static propTypes = { preview: PropTypes.string, src: PropTypes.string.isRequired, + alt: PropTypes.string, width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, @@ -247,7 +248,7 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props; + const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props; const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; return ( @@ -260,6 +261,7 @@ export default class Video extends React.PureComponent { loop role='button' tabIndex='0' + aria-label={alt} width={width} height={height} onClick={this.togglePlay} diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 9d39584fc8..082d4d3705 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -22,6 +22,9 @@ import { COMPOSE_VISIBILITY_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, + COMPOSE_UPLOAD_CHANGE_REQUEST, + COMPOSE_UPLOAD_CHANGE_SUCCESS, + COMPOSE_UPLOAD_CHANGE_FAIL, } from '../actions/compose'; import { TIMELINE_DELETE } from '../actions/timelines'; import { STORE_HYDRATE } from '../actions/store'; @@ -220,15 +223,15 @@ export default function compose(state = initialState, action) { map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: + case COMPOSE_UPLOAD_CHANGE_REQUEST: return state.set('is_submitting', true); case COMPOSE_SUBMIT_SUCCESS: return clearAll(state); case COMPOSE_SUBMIT_FAIL: + case COMPOSE_UPLOAD_CHANGE_FAIL: return state.set('is_submitting', false); case COMPOSE_UPLOAD_REQUEST: - return state.withMutations(map => { - map.set('is_uploading', true); - }); + return state.set('is_uploading', true); case COMPOSE_UPLOAD_SUCCESS: return appendMedia(state, fromJS(action.media)); case COMPOSE_UPLOAD_FAIL: @@ -256,6 +259,16 @@ export default function compose(state = initialState, action) { } case COMPOSE_EMOJI_INSERT: return insertEmoji(state, action.position, action.emoji); + case COMPOSE_UPLOAD_CHANGE_SUCCESS: + return state + .set('is_submitting', false) + .update('media_attachments', list => list.map(item => { + if (item.get('id') === action.media.id) { + return item.set('description', action.media.description); + } + + return item; + })); default: return state; } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index da479347b5..631cd7a134 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -335,12 +335,52 @@ .compose-form__uploads-wrapper { display: flex; + flex-direction: row; padding: 5px; + flex-wrap: wrap; } .compose-form__upload { flex: 1 1 0; + min-width: 40%; margin: 5px; + + &-description { + position: absolute; + z-index: 2; + bottom: 0; + left: 0; + right: 0; + box-sizing: border-box; + background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent); + padding: 10px; + opacity: 0; + transition: opacity .1s ease; + + input { + background: transparent; + color: $ui-secondary-color; + border: 0; + padding: 0; + margin: 0; + width: 100%; + font-family: inherit; + font-size: 14px; + font-weight: 500; + + &:focus { + color: $white; + } + } + + &.active { + opacity: 1; + } + } + + .icon-button { + mix-blend-mode: difference; + } } .compose-form__upload-thumbnail { @@ -352,13 +392,6 @@ width: 100%; } -.compose-form__upload-cancel { - background-size: cover; - border-radius: 4px; - height: 100px; - width: 100px; -} - .compose-form__label { display: block; line-height: 24px; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 4e19b30964..55addd66ec 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -105,7 +105,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? href = Addressable::URI.parse(attachment['url']).normalize.to_s - media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href) + media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href, description: attachment['name'].presence) next if skip_download? diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index e4a974f968..25e41c209a 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -16,6 +16,7 @@ # shortcode :string # type :integer default("image"), not null # file_meta :json +# description :text # require 'mime/types' @@ -58,6 +59,7 @@ class MediaAttachment < ApplicationRecord validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true + validates :description, length: { maximum: 140 }, if: :local? scope :attached, -> { where.not(status_id: nil) } scope :unattached, -> { where(status_id: nil) } @@ -78,6 +80,7 @@ class MediaAttachment < ApplicationRecord shortcode end + before_create :prepare_description, unless: :local? before_create :set_shortcode before_post_process :set_type_and_extension before_save :set_meta @@ -136,6 +139,10 @@ class MediaAttachment < ApplicationRecord end end + def prepare_description + self.description = description.strip[0...140] unless description.nil? + end + def set_type_and_extension self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image extension = appropriate_extension diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index f94c3b9dc0..4dbf6a4444 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -89,12 +89,16 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer class MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper - attributes :type, :media_type, :url + attributes :type, :media_type, :url, :name def type 'Document' end + def name + object.description + end + def media_type object.file_content_type end diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb index f6e7c79d1f..e6e9c8e822 100644 --- a/app/serializers/rest/media_attachment_serializer.rb +++ b/app/serializers/rest/media_attachment_serializer.rb @@ -4,7 +4,8 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer include RoutingHelper attributes :id, :type, :url, :preview_url, - :remote_url, :text_url, :meta + :remote_url, :text_url, :meta, + :description def id object.id.to_s diff --git a/config/routes.rb b/config/routes.rb index cb7e84d7bd..ad2d8fca23 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,7 +193,7 @@ Rails.application.routes.draw do get '/search', to: 'search#index', as: :search resources :follows, only: [:create] - resources :media, only: [:create] + resources :media, only: [:create, :update] resources :apps, only: [:create] resources :blocks, only: [:index] resources :mutes, only: [:index] diff --git a/db/migrate/20170927215609_add_description_to_media_attachments.rb b/db/migrate/20170927215609_add_description_to_media_attachments.rb new file mode 100644 index 0000000000..db8d765664 --- /dev/null +++ b/db/migrate/20170927215609_add_description_to_media_attachments.rb @@ -0,0 +1,5 @@ +class AddDescriptionToMediaAttachments < ActiveRecord::Migration[5.1] + def change + add_column :media_attachments, :description, :text + end +end diff --git a/db/schema.rb b/db/schema.rb index e16599d323..90f8a56833 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: 20170924022025) do +ActiveRecord::Schema.define(version: 20170927215609) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20170924022025) do t.string "shortcode" t.integer "type", default: 0, null: false t.json "file_meta" + t.text "description" t.index ["account_id"], name: "index_media_attachments_on_account_id" t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true t.index ["status_id"], name: "index_media_attachments_on_status_id" diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb index baa22d7e48..0e494638f2 100644 --- a/spec/controllers/api/v1/media_controller_spec.rb +++ b/spec/controllers/api/v1/media_controller_spec.rb @@ -101,4 +101,33 @@ RSpec.describe Api::V1::MediaController, type: :controller do end end end + + describe 'PUT #update' do + context 'when somebody else\'s' do + let(:media) { Fabricate(:media_attachment, status: nil) } + + it 'returns http not found' do + put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } + expect(response).to have_http_status(:not_found) + end + end + + context 'when not attached to a status' do + let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) } + + it 'updates the description' do + put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } + expect(media.reload.description).to eq 'Lorem ipsum!!!' + end + end + + context 'when attached to a status' do + let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) } + + it 'returns http not found' do + put :update, params: { id: media.id, description: 'Lorem ipsum!!!' } + expect(response).to have_http_status(:not_found) + end + end + end end diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index f6717b7d5e..f20698c450 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -17,7 +17,6 @@ RSpec.describe MediaAttachment, type: :model do expect(media.file.meta["original"]["height"]).to eq 128 expect(media.file.meta["original"]["aspect"]).to eq 1.0 end - end describe 'non-animated gif non-conversion' do @@ -50,4 +49,12 @@ RSpec.describe MediaAttachment, type: :model do expect(media.file.meta["small"]["aspect"]).to eq 400.0/267 end end + + describe 'descriptions for remote attachments' do + it 'are cut off at 140 characters' do + media = Fabricate(:media_attachment, description: 'foo' * 100, remote_url: 'http://example.com/blah.jpg') + + expect(media.description.size).to be <= 140 + end + end end From a3202f61af7d4833808d429c79dfc21e74f06c99 Mon Sep 17 00:00:00 2001 From: Jakob Kramer <811907+gandaro@users.noreply.github.com> Date: Thu, 28 Sep 2017 17:38:39 +0200 Subject: [PATCH 003/137] Updated German translation (#5132) --- app/javascript/mastodon/locales/de.json | 82 ++++++++++++------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 461e7e3043..c892cc49b1 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -1,7 +1,7 @@ { "account.block": "@{name} blocken", "account.block_domain": "Alles von {domain} verstecken", - "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.", + "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.", "account.edit_profile": "Profil bearbeiten", "account.follow": "Folgen", "account.followers": "Folgende", @@ -18,11 +18,11 @@ "account.unblock_domain": "{domain} wieder anzeigen", "account.unfollow": "Entfolgen", "account.unmute": "@{name} nicht mehr stummschalten", - "account.view_full_profile": "Komplettes Profil anzeigen", + "account.view_full_profile": "Vollständiges Profil anzeigen", "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen", "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.", "bundle_column_error.retry": "Erneut versuchen", - "bundle_column_error.title": "Netzwerkfehlher", + "bundle_column_error.title": "Netzwerkfehler", "bundle_modal_error.close": "Schließen", "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.", "bundle_modal_error.retry": "Erneut versuchen", @@ -37,8 +37,8 @@ "column.public": "Gesamtes bekanntes Netz", "column_back_button.label": "Zurück", "column_header.hide_settings": "Einstellungen verbergen", - "column_header.moveLeft_settings": "Spalte links verschieben", - "column_header.moveRight_settings": "Spalte rechts verschieben", + "column_header.moveLeft_settings": "Spalte nach links verschieben", + "column_header.moveRight_settings": "Spalte nach rechts verschieben", "column_header.pin": "Anheften", "column_header.show_settings": "Einstellungen anzeigen", "column_header.unpin": "Lösen", @@ -56,14 +56,14 @@ "confirmations.block.confirm": "Blockieren", "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?", "confirmations.delete.confirm": "Löschen", - "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?", + "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?", "confirmations.domain_block.confirm": "Die ganze Domain verbergen", - "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.", + "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen reichen ein paar gezielte Blocks aus.", "confirmations.mute.confirm": "Stummschalten", - "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?", + "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?", "confirmations.unfollow.confirm": "Entfolgen", - "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?", - "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.", + "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchtest?", + "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.", "embed.preview": "So wird es aussehen:", "emoji_button.activity": "Aktivitäten", "emoji_button.custom": "Custom", @@ -71,18 +71,18 @@ "emoji_button.food": "Essen und Trinken", "emoji_button.label": "Emoji einfügen", "emoji_button.nature": "Natur", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", - "emoji_button.objects": "Dinge", + "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻", + "emoji_button.objects": "Gegenstände", "emoji_button.people": "Leute", - "emoji_button.recent": "Frequently used", - "emoji_button.search": "Suche…", - "emoji_button.search_results": "Search results", + "emoji_button.recent": "Häufig benutzt", + "emoji_button.search": "Suchen …", + "emoji_button.search_results": "Suchergebnisse", "emoji_button.symbols": "Symbole", - "emoji_button.travel": "Reise und Orte", + "emoji_button.travel": "Reisen und Orte", "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.", - "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.", + "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich neu erstellt.", "empty_column.home.public_timeline": "die öffentliche Zeitleiste", "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.", @@ -95,14 +95,14 @@ "getting_started.userguide": "Bedienungsanleitung", "home.column_settings.advanced": "Fortgeschritten", "home.column_settings.basic": "Einfach", - "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke", + "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", "home.column_settings.show_replies": "Antworten anzeigen", "home.settings": "Spalteneinstellungen", "lightbox.close": "Schließen", "lightbox.next": "Weiter", "lightbox.previous": "Zurück", - "loading_indicator.label": "Lade…", + "loading_indicator.label": "Lade …", "media_gallery.toggle_visible": "Sichtbarkeit einstellen", "missing_indicator.label": "Nicht gefunden", "navigation_bar.blocks": "Blockierte Profile", @@ -121,26 +121,26 @@ "notification.mention": "{name} erwähnte dich", "notification.reblog": "{name} teilte deinen Status", "notifications.clear": "Mitteilungen löschen", - "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?", + "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?", "notifications.column_settings.alert": "Desktop-Benachrichtigungen", "notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.mention": "Erwähnungen:", - "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push": "Push-Benachrichtigungen", "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.sound": "Ton abspielen", "onboarding.done": "Fertig", "onboarding.next": "Weiter", - "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.", + "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt. Durch sie kannst du viel Neues entdecken.", "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.", - "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.", + "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.", "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.", "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}", "onboarding.page_one.welcome": "Willkommen bei Mastodon!", "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.", - "onboarding.page_six.almost_done": "Fast fertig…", + "onboarding.page_six.almost_done": "Fast fertig …", "onboarding.page_six.appetoot": "Guten Appetröt!", "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.", "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", @@ -148,10 +148,10 @@ "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", "onboarding.page_six.various_app": "mobile Anwendungen", "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", - "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", - "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.", + "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", + "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.", "onboarding.skip": "Überspringen", - "privacy.change": "Privatsphäre des Status anpassen", + "privacy.change": "Sichtbarkeit des Status anpassen", "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", "privacy.private.long": "Beitrag nur an Folgende", @@ -166,7 +166,7 @@ "report.target": "Melden", "search.placeholder": "Suche", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", - "standalone.public_title": "Vorschau…", + "standalone.public_title": "Vorschau …", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.delete": "Löschen", "status.embed": "Einbetten", @@ -176,7 +176,7 @@ "status.mention": "Erwähnen", "status.mute_conversation": "Thread stummschalten", "status.open": "Öffnen", - "status.pin": "Auf dem Profil anheften", + "status.pin": "Im Profil anheften", "status.reblog": "Teilen", "status.reblogged_by": "{name} teilte", "status.reply": "Antworten", @@ -197,18 +197,18 @@ "upload_area.title": "Hereinziehen zum Hochladen", "upload_button.label": "Mediendatei hinzufügen", "upload_form.undo": "Entfernen", - "upload_progress.label": "Lade hoch…", - "video.close": "Close video", - "video.exit_fullscreen": "Exit full screen", - "video.expand": "Expand video", - "video.fullscreen": "Full screen", - "video.hide": "Hide video", - "video.mute": "Mute sound", + "upload_progress.label": "Lade hoch …", + "video.close": "Video schließen", + "video.exit_fullscreen": "Vollbild verlassen", + "video.expand": "Video vergrößern", + "video.fullscreen": "Vollbild", + "video.hide": "Video verbergen", + "video.mute": "Stummschalten", "video.pause": "Pause", - "video.play": "Play", - "video.unmute": "Unmute sound", - "video_player.expand": "Videoanzeige vergrößern", - "video_player.toggle_sound": "Ton umschalten", - "video_player.toggle_visible": "Sichtbarkeit umschalten", + "video.play": "Abspielen", + "video.unmute": "Ton einschalten", + "video_player.expand": "Video vergrößern", + "video_player.toggle_sound": "Ton an/aus", + "video_player.toggle_visible": "Video zeigen/verbergen", "video_player.video_error": "Video konnte nicht abgespielt werden" } From 76f360c625d6f7e1200a35430cced872fc6098ff Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 28 Sep 2017 17:50:14 +0200 Subject: [PATCH 004/137] If HTTP signature is wrong and webfinger cache is stale, retry with resolve (#5129) If the signature could not be verified and the webfinger of the account was last retrieved longer than the cache period, try re-resolving the account and then attempting to verify the signature again --- app/controllers/concerns/signature_verification.rb | 9 +++++++++ app/models/account.rb | 9 +++++++++ app/services/resolve_remote_account_service.rb | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4211283ed7..52a9cf2905 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -44,6 +44,15 @@ module SignatureVerification if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) @signed_request_account = account @signed_request_account + elsif account.possibly_stale? + account = account.refresh! + + if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + @signed_request_account = account + @signed_request_account + else + @signed_request_account = nil + end else @signed_request_account = nil end diff --git a/app/models/account.rb b/app/models/account.rb index 0b025d1be8..ce7773b4b7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -137,6 +137,15 @@ class Account < ApplicationRecord subscription_expires_at.present? end + def possibly_stale? + last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago + end + + def refresh! + return if local? + ResolveRemoteAccountService.new.call(acct) + end + def keypair @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 57c80fc82d..93ba07702a 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -74,7 +74,7 @@ class ResolveRemoteAccountService < BaseService end def webfinger_update_due? - @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago + @account.nil? || @account.possibly_stale? end def activitypub_ready? From 3406e305265e10c884c13f1d739cd2db5c1f18b6 Mon Sep 17 00:00:00 2001 From: JeanGauthier <32121978+JeanGauthier@users.noreply.github.com> Date: Thu, 28 Sep 2017 20:22:34 +0200 Subject: [PATCH 005/137] OC 500 error (#5110) --- config/locales/oc.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 584f4c6094..406de36f0f 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -388,6 +388,9 @@ oc: content: Verificacion de seguretat fracassada. Blocatz los cookies ? title: Verificacion de seguretat fracassada '429': Lo servidor mòla (subrecargada) + '500': + content: Un quicomet a pas foncionat coma caliá. + title: Aquesta pagina es incorrècta noscript_html: Per utilizar l’aplicacion web de Mastodon, mercés d’activar JavaScript. O podètz utilizar una aplicacion per vòstra plataforma coma alernativa. exports: blocks: Personas que blocatz From 6e0659c838bacfea54bbab5a4dd3501fbdf8b668 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Thu, 28 Sep 2017 11:43:18 -0700 Subject: [PATCH 006/137] Improve performance of modal and swipe animations (#5135) * Improve performance of modal and swipe animations * Fix eslint issues --- .../features/ui/components/media_modal.js | 7 +- .../features/ui/components/modal_root.js | 65 +++++++++---------- app/javascript/styles/components.scss | 9 ++- 3 files changed, 40 insertions(+), 41 deletions(-) diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index da2ceecb1f..705645b403 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -84,14 +84,17 @@ export default class MediaModal extends ImmutablePureComponent { return null; }).toArray(); + const containerStyle = { + alignItems: 'center', // center vertically + }; + return (
{leftNav}
- - + {content}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index a09c9d9b37..f420f0abf4 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -1,7 +1,5 @@ import React from 'react'; import PropTypes from 'prop-types'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; import BundleContainer from '../containers/bundle_container'; import BundleModalError from './bundle_modal_error'; import ModalLoading from './modal_loading'; @@ -35,6 +33,10 @@ export default class ModalRoot extends React.PureComponent { onClose: PropTypes.func.isRequired, }; + state = { + revealed: false, + }; + handleKeyUp = (e) => { if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) && !!this.props.type) { @@ -51,6 +53,8 @@ export default class ModalRoot extends React.PureComponent { this.activeElement = document.activeElement; this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); + } else if (!nextProps.type) { + this.setState({ revealed: false }); } } @@ -60,6 +64,11 @@ export default class ModalRoot extends React.PureComponent { this.activeElement.focus(); this.activeElement = null; } + if (this.props.type) { + requestAnimationFrame(() => { + this.setState({ revealed: true }); + }); + } } componentWillUnmount () { @@ -74,14 +83,6 @@ export default class ModalRoot extends React.PureComponent { this.node = ref; } - willEnter () { - return { opacity: 0, scale: 0.98 }; - } - - willLeave () { - return { opacity: spring(0), scale: spring(0.98) }; - } - renderLoading = modalId => () => { return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null; } @@ -94,38 +95,30 @@ export default class ModalRoot extends React.PureComponent { render () { const { type, props, onClose } = this.props; + const { revealed } = this.state; const visible = !!type; - const items = []; - if (visible) { - items.push({ - key: type, - data: { type, props }, - style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }, - }); + if (!visible) { + return ( +
+ ); } return ( - - {interpolatedStyles => -
- {interpolatedStyles.map(({ key, data: { type, props }, style }) => ( -
-
-
- - {(SpecificComponent) => } - -
-
- ))} +
+
+
+
+ { + visible ? + ( + {(SpecificComponent) => } + ) : + null + }
- } - +
+
); } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 631cd7a134..5ea0d134ef 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2983,14 +2983,18 @@ button.icon-button.active i.fa-retweet { } } +.modal-root { + transition: opacity 0.3s linear; + will-change: opacity; + z-index: 9999; +} + .modal-root__overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; - z-index: 9999; - opacity: 0; background: rgba($base-overlay-background, 0.7); transform: translateZ(0); } @@ -3007,7 +3011,6 @@ button.icon-button.active i.fa-retweet { justify-content: center; align-content: space-around; z-index: 9999; - opacity: 0; pointer-events: none; user-select: none; } From d0b4709b2a0bbd9579a3f115c3d200661ccb784a Mon Sep 17 00:00:00 2001 From: Jakob Kramer <811907+gandaro@users.noreply.github.com> Date: Thu, 28 Sep 2017 20:45:09 +0200 Subject: [PATCH 007/137] Update German translation (#5133) Create activerecord.de.yml (50%) Update devise.de.yml Update doorkeeper.de.yml (100%) Update simple_form.de.yml (100%) --- app/javascript/mastodon/locales/de.json | 2 +- config/locales/activerecord.de.yml | 13 ++++ config/locales/de.yml | 6 +- config/locales/devise.de.yml | 34 ++++----- config/locales/doorkeeper.de.yml | 98 +++++++++++++------------ config/locales/simple_form.de.yml | 35 ++++++--- 6 files changed, 111 insertions(+), 77 deletions(-) create mode 100644 config/locales/activerecord.de.yml diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index c892cc49b1..68bd79b484 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -103,7 +103,7 @@ "lightbox.next": "Weiter", "lightbox.previous": "Zurück", "loading_indicator.label": "Lade …", - "media_gallery.toggle_visible": "Sichtbarkeit einstellen", + "media_gallery.toggle_visible": "Sichtbarkeit umschalten", "missing_indicator.label": "Nicht gefunden", "navigation_bar.blocks": "Blockierte Profile", "navigation_bar.community_timeline": "Lokale Zeitleiste", diff --git a/config/locales/activerecord.de.yml b/config/locales/activerecord.de.yml new file mode 100644 index 0000000000..668abe2a3d --- /dev/null +++ b/config/locales/activerecord.de.yml @@ -0,0 +1,13 @@ +--- +de: + activerecord: + errors: + models: + account: + attributes: + username: + invalid: nur Buchstaben, Ziffern und Unterstriche + status: + attributes: + reblog: + taken: of status already exists diff --git a/config/locales/de.yml b/config/locales/de.yml index de6c86737b..b67808157e 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -277,9 +277,9 @@ de: public: Öffentlich unlisted: Öffentlich, aber nicht auf der öffentlichen Zeitleiste anzeigen stream_entries: - click_to_show: Klicken um zu zeigen + click_to_show: Klicken, um zu zeigen reblogged: teilte - sensitive_content: Sensible Inhalte + sensitive_content: Heikle Inhalte time: formats: default: "%d.%m.%Y %H:%M" @@ -290,7 +290,7 @@ de: enable: Aktivieren enabled_success: Zwei-Faktor-Authentisierung erfolgreich aktiviert generate_recovery_codes: Wiederherstellungscodes generieren - instructions_html: "Lese diesen QR-Code mit Google Authenticator oder einer ähnlichen TOTP-App auf deinem Telefon ein. Von nun an wird diese App Tokens generieren, die du beim Anmelden eingeben musst." + instructions_html: "Lese diesen QR-Code mit Google Authenticator oder einer ähnlichen TOTP-App auf deinem Telefon ein. Von nun an wird diese App Tokens generieren, die du beim Anmelden eingeben musst." lost_recovery_codes: Wiederherstellungscodes erlauben dir, wieder den Zugang zu deinem Konto zu erlangen, falls du dein Telefon verlierst. Wenn du deine Wiederherstellungscodes verloren hast, kannst du sie hier regenerieren. Deine alten Wiederherstellungscodes werden damit ungültig gemacht. manual_instructions: 'Wenn du den QR-Code nicht einlesen kannst und ihn manuell eingeben musst, ist hier das Klartext-Geheimnis:' recovery_codes_regenerated: Wiederherstellungscodes erfolgreich regeneriert diff --git a/config/locales/devise.de.yml b/config/locales/devise.de.yml index 035a4713cb..318263e051 100644 --- a/config/locales/devise.de.yml +++ b/config/locales/devise.de.yml @@ -2,31 +2,31 @@ de: devise: confirmations: - confirmed: Vielen Dank für deine Registrierung. Bitte melde dich jetzt an. - send_instructions: Du erhältst in wenigen Minuten eine E-Mail, mit der du deine Registrierung bestätigen kannst. - send_paranoid_instructions: Falls Deine E-Mail-Adresse in unserer Datenbank existiert, erhältst Du in wenigen Minuten eine E-Mail mit der du deine Registrierung bestätigen kannst. + confirmed: Deine E-Mail-Adresse wurde bestätigt. + send_instructions: Du erhältst in wenigen Minuten eine E-Mail, mit der du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! + send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank hinterlegt ist, erhältst du in wenigen Minuten eine E-Mail, mit der du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! failure: already_authenticated: Du bist bereits angemeldet. - inactive: Dein Account ist nicht aktiv. - invalid: Ungültige Anmeldedaten. - last_attempt: Du hast noch einen Versuch bevor dein Account gesperrt wird. - locked: Dein Account ist gesperrt. - not_found_in_database: E-Mail-Adresse oder Passwort ungültig. - timeout: Deine Sitzung ist abgelaufen, bitte melde dich erneut an. - unauthenticated: Du musst Dich anmelden oder registrieren, bevor du fortfahren kannst. - unconfirmed: Du musst deinen Account bestätigen, bevor du fortfahren kannst. + inactive: Dein Konto wurde noch nicht aktiviert. + invalid: '%{authentication_keys} oder Passwort ungültig.' + last_attempt: Du hast noch einen Versuch, bevor dein Konto gesperrt wird. + locked: Dein Konto ist gesperrt. + not_found_in_database: '%{authentication_keys} oder Passwort ungültig.' + timeout: Deine Sitzung ist abgelaufen. Bitte melde dich erneut an. + unauthenticated: Du musst dich anmelden oder registrieren, bevor du fortfahren kannst. + unconfirmed: Du musst deine E-Mail-Adresse bestätigen, bevor du fortfahren kannst. mailer: confirmation_instructions: - subject: 'Mastodon: Anleitung zur Bestätigung deines Accounts' + subject: 'Mastodon: Bestätigung deines Kontos bei %{instance}' password_change: - subject: 'Mastodon: Passwort wurde geändert' + subject: 'Mastodon: Passwort geändert' reset_password_instructions: - subject: 'Mastodon: Anleitung um dein Passwort zurückzusetzen' + subject: 'Mastodon: Passwort zurücksetzen' unlock_instructions: - subject: 'Mastodon: Anleitung um deinen Account freizuschalten' + subject: 'Mastodon: Konto entsperren' omniauth_callbacks: - failure: Du konntest nicht mit deinem %{kind}-Account angemeldet werden, weil '%{reason}'. - success: Du hast dich erfolgreich mit Deinem %{kind}-Account angemeldet. + failure: Du konntest nicht mit deinem %{kind}-Konto angemeldet werden, weil '%{reason}'. + success: Du hast dich erfolgreich mit deinem %{kind}-Account angemeldet. passwords: no_token: Du kannst diese Seite nur über den Link aus der E-Mail zum Passwort-Zurücksetzen aufrufen. Wenn du einen solchen Link aufgerufen hast, stelle bitte sicher, dass du die vollständige Adresse aufrufst. send_instructions: Du erhältst in wenigen Minuten eine E-Mail mit der Anleitung, wie du dein Passwort zurücksetzen kannst. diff --git a/config/locales/doorkeeper.de.yml b/config/locales/doorkeeper.de.yml index 1588e4f9ec..d7d98c6d69 100644 --- a/config/locales/doorkeeper.de.yml +++ b/config/locales/doorkeeper.de.yml @@ -3,17 +3,19 @@ de: activerecord: attributes: doorkeeper/application: - name: Name - redirect_uri: Redirect-URI + name: Name der Anwendung + redirect_uri: Weiterleitungs-URI + scopes: Befugnisse + website: Website der Anwendung errors: models: doorkeeper/application: attributes: redirect_uri: fragment_present: darf kein Fragment enthalten. - invalid_uri: muss ein valider URI (Identifier) sein. - relative_uri: muss ein absoluter URI (Identifier) sein. - secured_uri: muss ein HTTPS/SSL-URI (Identifier) sein. + invalid_uri: muss ein valider URI sein. + relative_uri: muss ein absoluter URI sein. + secured_uri: muss ein HTTPS/SSL-URI sein. doorkeeper: applications: buttons: @@ -25,27 +27,31 @@ de: confirmations: destroy: Bist du sicher? edit: - title: Applikation bearbeiten + title: Anwendung bearbeiten form: error: Hoppla! Bitte überprüfe das Formular auf Fehler! help: native_redirect_uri: "%{native_redirect_uri} für lokale Tests benutzen" redirect_uri: Bitte benutze eine Zeile pro URI - scopes: Bitte die "Scopes" mit Leerzeichen trennen. Bitte frei lassen für die Verwendung der Default-Werte. + scopes: Bitte die Befugnisse mit Leerzeichen trennen. Zur Verwendung der Standardwerte freilassen. index: + application: Anwendung callback_url: Callback-URL + delete: Löschen name: Name - new: Neue Applikation - title: Deine Applikationen + new: Neue Anwendung + scopes: Befugnisse + show: Zeigen + title: Deine Anwendungen new: - title: Neue Applikation + title: Neue Anwendung show: actions: Aktionen - application_id: Applikations-ID + application_id: Client-Schlüssel callback_urls: Callback-URLs - scopes: Scopes - secret: Secret - title: 'Applikation: %{name}' + scopes: Befugnisse + secret: Client-Secret + title: 'Anwendung: %{name}' authorizations: buttons: authorize: Autorisieren @@ -53,61 +59,61 @@ de: error: title: Ein Fehler ist aufgetreten new: - able_to: 'Diese Anwendung wird folgende Rechte haben:' - prompt: Soll %{client_name} für die Benutzung dieses Accounts autorisiert werden? + able_to: 'Sie wird folgende Befugnisse haben:' + prompt: Die Anwendung %{client_name} verlangt Zugriff auf dein Konto title: Autorisierung erforderlich show: - title: Copy this authorization code and paste it to the application. + title: Kopiere diesen Autorisierungs-Code und füge ihn in die Anwendung ein. authorized_applications: buttons: - revoke: Ungültig machen + revoke: Widerrufen confirmations: revoke: Bist du sicher? index: - application: Applikation - created_at: erstellt am - date_format: "%Y-%m-%d %H:%M:%S" - scopes: Scopes - title: Deine autorisierten Applikationen + application: Anwendung + created_at: autorisiert am + date_format: "%d.%m.%Y %H:%M:%S" + scopes: Befugnisse + title: Deine autorisierten Anwendungen errors: messages: - access_denied: Der Ressourcenbesitzer oder der Autorisierungs-Server hat die Anfrage verweigert. - credential_flow_not_configured: 'Die Prozedur "Resource Owner Password Credentials" ist fehlgeschlagen: Doorkeeper.configure.resource_owner_from_credentials ist nicht konfiguriert.' - invalid_client: 'Client-Autorisierung MKIM ist fehlgeschlagen: Unbekannter Client, keine Autorisierung mitgeliefert oder Autorisierungsmethode nicht unterstützt.' - invalid_grant: Die bereitgestellte Autorisierung ist inkorrekt, abgelaufen, widerrufen, ist mit einem anderen Client verknüpft oder der Redirection URI stimmt nicht mit der Autorisierungs-Anfrage überein. - invalid_redirect_uri: Der Redirect-URI in der Anfrage ist ungültig. - invalid_request: Die Anfrage enthält einen nicht-unterstützten Parameter, ein Parameter fehlt oder sie ist anderweitig fehlerhaft. - invalid_resource_owner: Die angegebenen Zugangsdaten für den "Resource Owner" sind inkorrekt oder dieses Profil existiert nicht. - invalid_scope: Der angeforderte Scope ist inkorrekt, unbekannt oder fehlerhaft. + access_denied: Der »resource owner« oder der Autorisierungs-Server hat die Anfrage verweigert. + credential_flow_not_configured: Die Prozedur »Resource Owner Password Credentials« schlug fehl, da Doorkeeper.configure.resource_owner_from_credentials nicht konfiguriert ist. + invalid_client: 'Client-Authentifizierung ist fehlgeschlagen: Client unbekannt, keine Authentisierung mitgeliefert oder Authentisierungsmethode wird nicht unterstützt.' + invalid_grant: Die beigefügte Autorisierung ist ungültig, abgelaufen, wurde widerrufen, einem anderen Client ausgestellt oder der Weiterleitungs-URI stimmt nicht mit der Autorisierungs-Anfrage überein. + invalid_redirect_uri: Der beigefügte Weiterleitungs-URI ist ungültig. + invalid_request: Die Anfrage enthält ein nicht-unterstütztes Argument, ein Parameter fehlt, oder sie ist anderweitig fehlerhaft. + invalid_resource_owner: Die angegebenen Zugangsdaten für den »resource owner« sind ungültig, oder dieses Profil existiert nicht. + invalid_scope: Die angeforderte Befugnis ist ungültig, unbekannt oder fehlerhaft. invalid_token: - expired: Der Zugriffstoken ist abgelaufen - revoked: Der Zugriffsoken wurde annuliert - unknown: Der Zugriffsoken ist ungültig - resource_owner_authenticator_not_configured: 'Die Prozedur "Resource Owner find" ist fehlgeschlagen: Doorkeeper.configure.resource_owner_authenticator ist nicht konfiguriert.' - server_error: Der Autorisierungs-Server hat ein unerwartetes Problem festgestellt und konnte die Anfrage nicht beenden. - temporarily_unavailable: Der Autorisierungs-Server ist derzeit auf Grund von temporärer Überlastung oder Wartungsarbeiten am Server nicht in der Lage, die Anfrage zu bearbeiten . - unauthorized_client: Der Client ist nicht autorisiert, diese Anfrage mit dieser Methode auszuführen. + expired: Der Zugriffs-Token ist abgelaufen + revoked: Der Zugriffs-Token wurde widerrufen + unknown: Der Zugriffs-Token ist ungültig + resource_owner_authenticator_not_configured: Die Prozedur »Resource Owner find« ist fehlgeschlagen, da Doorkeeper.configure.resource_owner_authenticator nicht konfiguriert ist. + server_error: Der Autorisierungs-Server hat ein unerwartetes Problem festgestellt und konnte die Anfrage nicht bearbeiten. + temporarily_unavailable: Der Autorisierungs-Server ist aufgrund von zwischenzeitlicher Überlastung oder Wartungsarbeiten derzeit nicht in der Lage, die Anfrage zu bearbeiten. + unauthorized_client: Der Client ist nicht dazu autorisiert, diese Anfrage mit dieser Methode auszuführen. unsupported_grant_type: Der Autorisierungs-Typ wird nicht vom Autorisierungs-Server unterstützt. unsupported_response_type: Der Autorisierungs-Server unterstützt diesen Antwort-Typ nicht. flash: applications: create: - notice: Applikation erstellt. + notice: Anwendung erstellt. destroy: - notice: Applikation gelöscht. + notice: Anwendung gelöscht. update: - notice: Applikation aktualisiert. + notice: Anwendung aktualisiert. authorized_applications: destroy: - notice: Applikation widerrufen. + notice: Anwendung widerrufen. layouts: admin: nav: - applications: Applikationen + applications: Anwendungen oauth2_provider: OAuth2-Anbieter application: title: OAuth-Autorisierung nötig scopes: - follow: Profil folgen, blocken, entblocken und entfolgen - read: deine Daten lesen - write: Beiträge von deinem Konto aus veröffentlichen + follow: Konten folgen, blocken, entblocken und entfolgen + read: deine Daten auslesen + write: Beiträge in deinem Namen veröffentlichen diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index a6ba839c61..0a820ff1e4 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -3,15 +3,23 @@ de: simple_form: hints: defaults: - avatar: PNG, GIF oder JPG. Maximal 2MB. Wird auf 120x120px herunterskaliert - display_name: %{count} Zeichen verbleiben - header: PNG, GIF oder JPG. Maximal 2MB. Wird auf 700x335px herunterskaliert - locked: Erlaubt dir, Profile zu überprüfen, bevor sie dir folgen können - note: %{count} Zeichen verbleiben + avatar: PNG, GIF oder JPG. Maximal 2 MB. Wird auf 120×120 px herunterskaliert + display_name: + one: 1 Zeichen verbleibt + other: %{count} Zeichen verbleiben + header: PNG, GIF oder JPG. Maximal 2 MB. Wird auf 700×335 px herunterskaliert + locked: Du musst zustimmen, bevor dir jemand folgen kann + note: + one: 1 Zeichen verbleibt + other: %{count} Zeichen verbleiben + setting_noindex: Betrifft dein öffentliches Profil und Status-Seiten + setting_theme: Wirkt sich darauf aus, wie Mastodon aussieht, egal auf welchem Gerät du eingeloggt bist. imports: - data: CSV-Datei, die von einer anderen Mastodon-Instanz exportiert wurde + data: CSV-Datei, die aus einer anderen Mastodon-Instanz exportiert wurde sessions: otp: Gib den Zwei-Faktor-Authentisierungs-Code von deinem Telefon ein oder benutze einen deiner Wiederherstellungscodes. + user: + filtered_languages: Ausgewählte Sprachen werden aus deinen öffentlichen Zeitleisten entfernt. labels: defaults: avatar: Profilbild @@ -20,7 +28,8 @@ de: current_password: Derzeitiges Passwort data: Daten display_name: Anzeigename - email: E-Mail-Addresse + email: E-Mail-Adresse + filtered_languages: Gefilterte Sprachen header: Kopfbild locale: Sprache locked: Gesperrtes Profil @@ -29,13 +38,19 @@ de: otp_attempt: Zwei-Faktor-Authentisierungs-Code password: Passwort setting_auto_play_gif: Animierte GIFs automatisch abspielen - setting_boost_modal: Zeige einen Bestätigungsdialog vor dem Teilen - setting_default_privacy: Beitragsprivatspäre + setting_boost_modal: Bestätigungsdialog anzeigen, bevor ein Tröt geteilt wird + setting_default_privacy: Beitragssichtbarkeit + setting_default_sensitive: Medien immer als heikel markieren + setting_delete_modal: Bestätigungsdialog anzeigen, bevor ein Tröt gelöscht wird + setting_noindex: Suchmaschinen-Indexierung verhindern + setting_system_font_ui: Standardschriftart des Systems verwenden + setting_theme: Theme der Website + setting_unfollow_modal: Bestätigungsdialog anzeigen, bevor jemand entfolgt wird severity: Gewichtung type: Importtyp username: Profilname interactions: - must_be_follower: Benachrichtigungen von Nicht-Folgern blockieren + must_be_follower: Benachrichtigungen von Nicht-Folgenden blockieren must_be_following: Benachrichtigungen von Profilen blockieren, denen ich nicht folge notification_emails: digest: Schicke Übersichts-E-Mails From d2f56d1cbc7ef985c2565ccc899a1a4f3c07e524 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 28 Sep 2017 23:20:08 +0200 Subject: [PATCH 008/137] Change max redirects followed to 2 (#5136) I see no reason to allow more than that. Usually a redirect is HTTP->HTTPS, then maybe URL structure changed, but more than that is highly unlikely to be a legitimate use case. --- app/lib/request.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/request.rb b/app/lib/request.rb index b083edaf77..61311df6e4 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -85,6 +85,6 @@ class Request end def http_client - HTTP.timeout(:per_operation, timeout).follow + HTTP.timeout(:per_operation, timeout).follow(max_hops: 2) end end From 887cd94e963f49523af80d845cfe0ea900f7dadf Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Sep 2017 02:30:00 +0200 Subject: [PATCH 009/137] Increase attachment descriptions to 420 characters (#5139) Blaze it --- app/javascript/mastodon/features/compose/components/upload.js | 2 +- app/models/media_attachment.rb | 4 ++-- spec/models/media_attachment_spec.rb | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js index c2bf3b72ef..cd9e08360f 100644 --- a/app/javascript/mastodon/features/compose/components/upload.js +++ b/app/javascript/mastodon/features/compose/components/upload.js @@ -79,7 +79,7 @@ export default class Upload extends ImmutablePureComponent { placeholder={intl.formatMessage(messages.description)} type='text' value={description} - maxLength={140} + maxLength={420} onFocus={this.handleInputFocus} onChange={this.handleInputChange} onBlur={this.handleInputBlur} diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 25e41c209a..60380198b3 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -59,7 +59,7 @@ class MediaAttachment < ApplicationRecord validates_attachment_size :file, less_than: 8.megabytes validates :account, presence: true - validates :description, length: { maximum: 140 }, if: :local? + validates :description, length: { maximum: 420 }, if: :local? scope :attached, -> { where.not(status_id: nil) } scope :unattached, -> { where(status_id: nil) } @@ -140,7 +140,7 @@ class MediaAttachment < ApplicationRecord end def prepare_description - self.description = description.strip[0...140] unless description.nil? + self.description = description.strip[0...420] unless description.nil? end def set_type_and_extension diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index f20698c450..9fce5bc4fb 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -52,9 +52,9 @@ RSpec.describe MediaAttachment, type: :model do describe 'descriptions for remote attachments' do it 'are cut off at 140 characters' do - media = Fabricate(:media_attachment, description: 'foo' * 100, remote_url: 'http://example.com/blah.jpg') + media = Fabricate(:media_attachment, description: 'foo' * 1000, remote_url: 'http://example.com/blah.jpg') - expect(media.description.size).to be <= 140 + expect(media.description.size).to be <= 420 end end end From 5b45c1646aa324d2eb4a3ecb8c12ed4df3cdf129 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Sep 2017 03:03:03 +0200 Subject: [PATCH 010/137] Remove dependency on db during assets:precompile (#5138) --- app/views/layouts/error.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml index 31f3220964..7d014dff4f 100644 --- a/app/views/layouts/error.html.haml +++ b/app/views/layouts/error.html.haml @@ -3,7 +3,7 @@ %head %meta{ content: 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type' }/ %meta{ charset: 'utf-8' }/ - %title= safe_join([yield(:page_title), title], ' - ') + %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ') %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/ = stylesheet_pack_tag 'common', media: 'all' = stylesheet_pack_tag Setting.default_settings['theme'], media: 'all' From f4ca116ea8f86057e91c99a1cd8e64e116c86746 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Sep 2017 03:16:20 +0200 Subject: [PATCH 011/137] After 7 days of repeated delivery failures, give up on inbox (#5131) - A successful delivery cancels it out - An incoming delivery from account of the inbox cancels it out --- .../activitypub/inboxes_controller.rb | 1 + app/lib/delivery_failure_tracker.rb | 56 +++++++++++++++ app/models/account.rb | 3 +- app/workers/activitypub/delivery_worker.rb | 7 ++ spec/lib/delivery_failure_tracker_spec.rb | 71 +++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/lib/delivery_failure_tracker.rb create mode 100644 spec/lib/delivery_failure_tracker_spec.rb diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index b37910b364..d0f8073edf 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController end Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? + DeliveryFailureTracker.track_inverse_success!(signed_request_account) end def process_payload diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb new file mode 100644 index 0000000000..8d3be35def --- /dev/null +++ b/app/lib/delivery_failure_tracker.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class DeliveryFailureTracker + FAILURE_DAYS_THRESHOLD = 7 + + def initialize(inbox_url) + @inbox_url = inbox_url + end + + def track_failure! + Redis.current.sadd(exhausted_deliveries_key, today) + Redis.current.sadd('unavailable_inboxes', @inbox_url) if reached_failure_threshold? + end + + def track_success! + Redis.current.del(exhausted_deliveries_key) + Redis.current.srem('unavailable_inboxes', @inbox_url) + end + + def days + Redis.current.scard(exhausted_deliveries_key) || 0 + end + + class << self + def filter(arr) + arr.reject(&method(:unavailable?)) + end + + def unavailable?(url) + Redis.current.sismember('unavailable_inboxes', url) + end + + def available?(url) + !unavailable?(url) + end + + def track_inverse_success!(from_account) + new(from_account.inbox_url).track_success! if from_account.inbox_url.present? + new(from_account.shared_inbox_url).track_success! if from_account.shared_inbox_url.present? + end + end + + private + + def exhausted_deliveries_key + "exhausted_deliveries:#{@inbox_url}" + end + + def today + Time.now.utc.strftime('%Y%m%d') + end + + def reached_failure_threshold? + days >= FAILURE_DAYS_THRESHOLD + end +end diff --git a/app/models/account.rb b/app/models/account.rb index ce7773b4b7..54035d94a8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -190,7 +190,8 @@ class Account < ApplicationRecord end def inboxes - reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + DeliveryFailureTracker.filter(urls) end def triadic_closures(account, limit: 5, offset: 0) diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb index 059c328134..7510b1739f 100644 --- a/app/workers/activitypub/delivery_worker.rb +++ b/app/workers/activitypub/delivery_worker.rb @@ -15,7 +15,10 @@ class ActivityPub::DeliveryWorker perform_request raise Mastodon::UnexpectedResponseError, @response unless response_successful? + + failure_tracker.track_success! rescue => e + failure_tracker.track_failure! raise e.class, "Delivery failed for #{inbox_url}: #{e.message}", e.backtrace[0] end @@ -34,4 +37,8 @@ class ActivityPub::DeliveryWorker def response_successful? @response.code > 199 && @response.code < 300 end + + def failure_tracker + @failure_tracker ||= DeliveryFailureTracker.new(@inbox_url) + end end diff --git a/spec/lib/delivery_failure_tracker_spec.rb b/spec/lib/delivery_failure_tracker_spec.rb new file mode 100644 index 0000000000..39c8c7aafc --- /dev/null +++ b/spec/lib/delivery_failure_tracker_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe DeliveryFailureTracker do + subject { described_class.new('http://example.com/inbox') } + + describe '#track_success!' do + before do + subject.track_failure! + subject.track_success! + end + + it 'marks URL as available again' do + expect(described_class.available?('http://example.com/inbox')).to be true + end + + it 'resets days to 0' do + expect(subject.days).to be_zero + end + end + + describe '#track_failure!' do + it 'marks URL as unavailable after 7 days of being called' do + 6.times { |i| Redis.current.sadd('exhausted_deliveries:http://example.com/inbox', i) } + subject.track_failure! + + expect(subject.days).to eq 7 + expect(described_class.unavailable?('http://example.com/inbox')).to be true + end + + it 'repeated calls on the same day do not count' do + subject.track_failure! + subject.track_failure! + + expect(subject.days).to eq 1 + end + end + + describe '.filter' do + before do + Redis.current.sadd('unavailable_inboxes', 'http://example.com/unavailable/inbox') + end + + it 'removes URLs that are unavailable' do + result = described_class.filter(['http://example.com/good/inbox', 'http://example.com/unavailable/inbox']) + + expect(result).to include('http://example.com/good/inbox') + expect(result).to_not include('http://example.com/unavailable/inbox') + end + end + + describe '.track_inverse_success!' do + let(:from_account) { Fabricate(:account, inbox_url: 'http://example.com/inbox', shared_inbox_url: 'http://example.com/shared/inbox') } + + before do + Redis.current.sadd('unavailable_inboxes', 'http://example.com/inbox') + Redis.current.sadd('unavailable_inboxes', 'http://example.com/shared/inbox') + + described_class.track_inverse_success!(from_account) + end + + it 'marks inbox URL as available again' do + expect(described_class.available?('http://example.com/inbox')).to be true + end + + it 'marks shared inbox URL as available again' do + expect(described_class.available?('http://example.com/shared/inbox')).to be true + end + end +end From 35a8cafa35c12d33f9f761bacab189397b34045f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 29 Sep 2017 03:16:44 +0200 Subject: [PATCH 012/137] Replace self-rolled statsd instrumention with localshred/nsa (#5118) --- Gemfile | 2 +- Gemfile.lock | 9 +++++++-- config/environments/production.rb | 5 ----- config/initializers/statsd.rb | 25 +++++++++++-------------- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/Gemfile b/Gemfile index 09b3b8effd..82ef492fce 100644 --- a/Gemfile +++ b/Gemfile @@ -42,6 +42,7 @@ gem 'kaminari', '~> 1.0' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.1' gem 'nokogiri', '~> 1.7' +gem 'nsa', '~> 0.2' gem 'oj', '~> 3.0' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.5' @@ -64,7 +65,6 @@ gem 'sidekiq-bulk', '~>0.1.1' gem 'simple-navigation', '~> 4.0' gem 'simple_form', '~> 3.4' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' -gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 73419fd28a..b95e52b37d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -289,6 +289,11 @@ GEM mini_portile2 (~> 2.2.0) nokogumbo (1.4.13) nokogiri + nsa (0.2.4) + activesupport (>= 4.2, < 6) + concurrent-ruby (~> 1.0.0) + sidekiq (>= 3.5.0) + statsd-ruby (~> 1.2.0) oj (3.3.5) openssl (2.0.5) orm_adapter (0.5.0) @@ -483,7 +488,7 @@ GEM sshkit (1.14.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - statsd-instrument (2.1.4) + statsd-ruby (1.2.1) strong_migrations (0.1.9) activerecord (>= 3.2.0) temple (0.8.0) @@ -578,6 +583,7 @@ DEPENDENCIES microformats (~> 4.0) mime-types (~> 3.1) nokogiri (~> 1.7) + nsa (~> 0.2) oj (~> 3.0) ostatus2 (~> 2.0) ox (~> 2.5) @@ -617,7 +623,6 @@ DEPENDENCIES simple_form (~> 3.4) simplecov (~> 0.14) sprockets-rails (~> 3.2) - statsd-instrument (~> 2.1) strong_migrations twitter-text (~> 1.14) tzinfo-data (~> 1.2017) diff --git a/config/environments/production.rb b/config/environments/production.rb index 397ea48dac..5705ffcfe9 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -90,11 +90,6 @@ Rails.application.configure do config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym - config.to_prepare do - StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank? - Sidekiq::Logging.logger.level = Logger::WARN - end - config.action_dispatch.default_headers = { 'Server' => 'Mastodon', 'X-Frame-Options' => 'DENY', diff --git a/config/initializers/statsd.rb b/config/initializers/statsd.rb index f00b1d4015..17a1761742 100644 --- a/config/initializers/statsd.rb +++ b/config/initializers/statsd.rb @@ -1,18 +1,15 @@ # frozen_string_literal: true -RESERVED_CHARACTERS_REGEX = /[\:\|\@]/ -StatsD.prefix = 'mastodon' -StatsD.default_sample_rate = 1 +if ENV['STATSD_ADDR'].present? + host, port = ENV['STATSD_ADDR'].split(':') -def clean_name(str) - str.gsub('::', '.').gsub(RESERVED_CHARACTERS_REGEX, '_') -end - -ActiveSupport::Notifications.subscribe(/performance/) do |name, _start, _finish, _id, payload| - action = payload[:action] || :increment - measurement = payload[:measurement] - value = payload[:value] - key_name = clean_name("#{name}.#{measurement}") - - StatsD.send(action.to_s, key_name, (value || 1)) + statsd = ::Statsd.new(host, port) + statsd.namespace = ['Mastodon', Rails.env].join('.') + + ::NSA.inform_statsd(statsd) do |informant| + informant.collect(:action_controller, :web) + informant.collect(:active_record, :db) + informant.collect(:cache, :cache) + informant.collect(:sidekiq, :sidekiq) + end end From c3f9c74719463be942b570c0a2e662775d33631c Mon Sep 17 00:00:00 2001 From: Yanaken Date: Fri, 29 Sep 2017 13:27:31 +0900 Subject: [PATCH 013/137] Better Japanese translations (#5142) --- config/locales/ja.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 5ffc09ab98..2ccc827dad 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -244,9 +244,10 @@ ja: body: "%{reporter} が %{target} を通報しました" subject: "%{instance} の新しい通報 (#%{id})" application_mailer: + salutation: "%{name} さん" settings: 'メール設定の変更: %{link}' signature: Mastodon %{instance} インスタンスからの通知 - view: 'View:' + view: 'リンク' applications: created: アプリが作成されました destroyed: アプリが削除されました From 1a72813b53b05420786cc70f94aefa178d9f43da Mon Sep 17 00:00:00 2001 From: Jakob Kramer <811907+gandaro@users.noreply.github.com> Date: Fri, 29 Sep 2017 21:11:28 +0200 Subject: [PATCH 014/137] Updated German translation (#5151) Translate "about" page, several settings pages, data export/import, sessions overview, authorized followers page, account deletion page. More consistent use of words: - A toot is a Beitrag. - An account is a Konto. Some small improvements. --- app/javascript/mastodon/locales/de.json | 12 +- config/locales/de.yml | 163 ++++++++++++++++++++---- config/locales/devise.de.yml | 26 ++-- config/locales/simple_form.de.yml | 8 +- 4 files changed, 163 insertions(+), 46 deletions(-) diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 68bd79b484..9b340b71c1 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -44,7 +44,7 @@ "column_header.unpin": "Lösen", "column_subheading.navigation": "Navigation", "column_subheading.settings": "Einstellungen", - "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.", + "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.", "compose_form.lock_disclaimer.lock": "gesperrt", "compose_form.placeholder": "Worüber möchtest du schreiben?", "compose_form.publish": "Tröt", @@ -116,10 +116,10 @@ "navigation_bar.pins": "Pinned toots", "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Föderierte Zeitleiste", - "notification.favourite": "{name} favorisierte deinen Status", + "notification.favourite": "{name} hat deinen Beitrag favorisiert", "notification.follow": "{name} folgt dir", - "notification.mention": "{name} erwähnte dich", - "notification.reblog": "{name} teilte deinen Status", + "notification.mention": "{name} hat dich erwähnt", + "notification.reblog": "{name} hat deinen Beitrag geteilt", "notifications.clear": "Mitteilungen löschen", "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?", "notifications.column_settings.alert": "Desktop-Benachrichtigungen", @@ -146,12 +146,12 @@ "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", "onboarding.page_six.guidelines": "Richtlinien", "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", - "onboarding.page_six.various_app": "mobile Anwendungen", + "onboarding.page_six.various_app": "Apps", "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.", "onboarding.skip": "Überspringen", - "privacy.change": "Sichtbarkeit des Status anpassen", + "privacy.change": "Sichtbarkeit des Beitrags anpassen", "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", "privacy.private.long": "Beitrag nur an Folgende", diff --git a/config/locales/de.yml b/config/locales/de.yml index b67808157e..06a535ba62 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1,39 +1,69 @@ --- de: about: - about_mastodon_html: Mastodon ist ein freier, quelloffener sozialer Netzwerkserver. Als dezentralisierte Alternative zu kommerziellen Plattformen verhindert es die Risiken, die entstehen, wenn eine einzelne Firma deine Kommunikation monopolisiert. Jeder kann Mastodon verwenden und ganz einfach am sozialen Netzwerk teilnehmen. + about_mastodon_html: Mastodon ist ein soziales Netzwerk. Es basiert auf offenen Web-Protokollen und freier, quelloffener Software. Es ist dezentral (so wie E-Mail!). about_this: Über diese Instanz - closed_registrations: Die Registrierung ist auf dieser Instanz momentan geschlossen. + closed_registrations: Die Registrierung auf dieser Instanz ist momentan geschlossen. Aber du kannst dein Konto auch auf einer anderen Instanz erstellen! Von dort hast du genauso Zugriff auf das Mastodon-Netzwerk. contact: Kontakt + contact_missing: Nicht angegeben + contact_unavailable: N/A description_headline: Was ist %{domain}? - domain_count_after: andere Instanzen - domain_count_before: Verbunden mit + domain_count_after: anderen Instanzen + domain_count_before: Vernetzt mit + extended_description_html: | +

Ein guter Platz für Regeln

+

Die erweiterte Beschreibung wurde noch nicht aufgesetzt.

+ features: + humane_approach_body: Mastodon hat von den Fehlern anderer Netzwerke gelernt und wurde mit dem Augenmerk darauf entwickelt, den Missbrauch sozialer Medien zu bekämpfen. + humane_approach_title: Ein menschlicherer Ansatz + not_a_product_body: Mastodon ist kein kommerzielles Netzwerk. Keine Werbung, kein Abgraben deiner Daten, keine geschlossene Plattform. Es gibt keine Zentrale. + not_a_product_title: Du bist ein Mensch und keine Ware + real_conversation_body: Mit 500 Zeichen pro Beitrag und der Ermöglichung präziser Inhalts- und Bilderwarnungen kannst du dich so ausdrücken, wie du es möchtest. + real_conversation_title: Für das echte Gespräch gemacht + within_reach_body: Verschiedene Apps für iOS, Android und andere Plattformen erlauben dir dank unserem blühenden API-Ökosystem, dich von überall auf dem Laufenden zu halten. + within_reach_title: Immer für dich da + find_another_instance: Eine andere Instanz finden + generic_description: "%{domain} ist ein Server im Netzwerk" + hosted_on: Mastodon, beherbergt auf %{domain} + learn_more: Mehr erfahren other_instances: Andere Instanzen source_code: Quellcode status_count_after: Beiträge verfassten status_count_before: die - user_count_after: Profile - user_count_before: Heimat für + user_count_after: Wesen + user_count_before: Zuhause für + what_is_mastodon: Was ist Mastodon? accounts: follow: Folgen followers: Folgende following: Folgt + media: Medien nothing_here: Hier gibt es nichts! people_followed_by: Profile, denen %{name} folgt people_who_follow: Profile, die %{name} folgen posts: Beiträge + posts_with_replies: Beiträge mit Antworten remote_follow: Folgen + reserved_username: Dieser Profilname ist belegt + roles: + admin: Admin unfollow: Entfolgen admin: accounts: are_you_sure: Bist du sicher? + confirm: Bestätigen + confirmed: Bestätigt + disable_two_factor_authentication: 2FA abschalten display_name: Anzeigename domain: Domain edit: Bearbeiten email: E-Mail feed_url: Feed-URL followers: Folgende + followers_url: Followers URL follows: Folgt + inbox_url: Inbox URL + ip: IP-Adresse location: all: Alle local: Lokal @@ -51,22 +81,31 @@ de: order: alphabetic: Alphabetisch most_recent: Neueste - title: Reihenfolge - perform_full_suspension: Führe vollständige Sperre durch + title: Sortierung + outbox_url: Outbox URL + perform_full_suspension: Vollständige Sperre durchführen profile_url: Profil-URL + protocol: Protokoll public: Öffentlich push_subscription_expires: PuSH-Abonnement läuft aus + redownload: Avatar neu laden + reset: Zurücksetzen reset_password: Passwort zurücksetzen + resubscribe: Wieder abonnieren salmon_url: Salmon-URL + search: Suche + shared_inbox_url: Shared Inbox URL show: created_reports: Meldungen durch dieses Konto report: Meldung targeted_reports: Meldungen über dieses Konto silence: Stummschalten statuses: Beiträge + subscribe: Abonnieren title: Konten undo_silenced: Stummschaltung zurücknehmen undo_suspension: Sperre zurücknehmen + unsubscribe: Abbestellen username: Profilname web: Web domain_blocks: @@ -76,9 +115,9 @@ de: domain: Domain new: create: Blockade einrichten - hint: Die Domain-Blockade wird nicht die Erstellung von Konteneinträgen in der Datenbank verhindern, aber rückwirkend und automatisch alle Moderationsmethoden auf diese Accounts anwenden. + hint: Die Domain-Blockade wird nicht die Erstellung von Konteneinträgen in der Datenbank verhindern, aber rückwirkend und automatisch alle Moderationsmethoden auf diese Konten anwenden. severity: - desc_html: "Stummschaltung wird die Beiträge dieses Accounts für alle, die ihm nicht folgen, unsichtbar machen. Eine Sperre wird alle Beiträge, Medien und Profildaten dieses Accounts entfernen." + desc_html: "Stummschaltung wird die Beiträge dieses Kontos für alle, die ihm nicht folgen, unsichtbar machen. Eine Sperre wird alle Beiträge, Medien und Profildaten dieses Kontos entfernen." silence: Stummschaltung suspend: Sperre title: Neue Domain-Blockade @@ -114,9 +153,9 @@ de: reported_account: Gemeldetes Konto reported_by: Gemeldet von resolved: Gelöst - silence_account: Account stummschalten + silence_account: Konto stummschalten status: Status - suspend_account: Account sperren + suspend_account: Konto sperren target: Ziel title: Meldungen unresolved: Ungelöst @@ -154,13 +193,17 @@ de: applications: invalid_url: Die angegebene URL ist ungültig auth: - change_password: Passwort ändern - didnt_get_confirmation: Keine Bestätigung bekommen? + agreement_html: Indem du dich registrierst, erklärst du dich mit unseren Geschäftsbedingungen und der Datenschutzerklärung einverstanden. + change_password: Sicherheit + delete_account: Konto löschen + delete_account_html: Falls du dein Konto löschen willst, kannst du hier damit fortfahren. Du wirst um Bestätigung gebeten werden. + didnt_get_confirmation: Keine Bestätigungs-Mail erhalten? forgot_password: Passwort vergessen? + invalid_reset_password_token: Das Token zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordere ein neues an. login: Anmelden logout: Abmelden register: Registrieren - resend_confirmation: Bestätigung nochmal versenden + resend_confirmation: Bestätigungs-Mail erneut versenden reset_password: Passwort zurücksetzen set_new_password: Neues Passwort setzen authorize_follow: @@ -181,6 +224,14 @@ de: x_minutes: "%{count}m" x_months: "%{count}mo" x_seconds: "%{count}s" + deletes: + bad_password_msg: Falsches Passwort + confirm_password: Gib dein derzeitiges Passwort ein, um deine Identität zu bestätigen + description_html: Hiermit wird dauerhaft und unwiederbringlich der Inhalt deines Kontos gelöscht und dein Konto deaktiviert. Dein Profilname wird reserviert, um künftige Imitationen zu verhindern. + proceed: Konto löschen + success_msg: Dein Konto wurde erfolgreich gelöscht + warning_html: Wir können nur dafür garantieren, dass die Inhalte auf dieser einen Instanz gelöscht werden. Bei Inhalten, die weit verbreitet wurden, ist es wahrscheinlich, dass Spuren bleiben werden. Server, die offline sind oder keine Benachrichtigungen von deinem Konto mehr empfangen, werden ihre Datenbanken nicht bereinigen. + warning_title: Verfügbarkeit verstreuter Inhalte errors: '404': Die Seite, die du gesucht hast, existiert nicht. '410': Die Seite, die du gesucht hast, existiert nicht mehr. @@ -188,11 +239,23 @@ de: content: Sicherheitsüberprüfung fehlgeschlagen. Blockierst du Cookies? title: Sicherheitsüberprüfung fehlgeschlagen exports: - blocks: Du blockierst + blocks: Du hast blockiert csv: CSV follows: Du folgst - mutes: Du schaltest stumm + mutes: Du hast stummgeschaltet storage: Medienspeicher + followers: + domain: Instanz + explanation_html: Wenn du sicherstellen willst, dass deine Beiträge privat sind, musst du wissen, wer dir folgt. Deine privaten Beiträge werden an alle Instanzen weitergegeben, auf denen Menschen registriert sind, die dir folgen. Wenn du den Betreibenden einer Instanz misstraust und du befürchtest, dass sie deine Privatsphäre missachten könnten, kannst du sie hier entfernen. + followers_count: Zahl der Folgenden + lock_link: dein Konto sperrst + purge: Von der Liste deiner Folgenden löschen + success: + one: Folgende von einer Domain werden soft-geblockt … + other: Folgende von %{count} Domains werden soft-geblockt … + true_privacy_html: Bitte beachte, dass wirklicher Schutz deiner Privatsphäre nur durch Ende-zu-Ende-Verschlüsselung erreicht werden kann.. + unlocked_warning_html: Wer dir folgen will, kann dies jederzeit ohne deine vorige Einverständnis tun und erhält damit automatisch Zugriff auf deine privaten Beiträge. Wenn du %{lock_link}, kannst du vorab entscheiden, wer dir folgen darf und wer nicht. + unlocked_warning_title: Dein Konto ist nicht gesperrt generic: changes_saved_msg: Änderungen gespeichert! powered_by: angetrieben von %{link} @@ -201,8 +264,8 @@ de: one: Etwas ist noch nicht ganz richtig! Bitte korrigiere den Fehler other: Etwas ist noch nicht ganz richtig! Bitte korrigiere %{count} Fehler imports: - preface: Du kannst bestimmte Daten wie die Leute, denen du folgst oder die du blockierst, in dein Konto auf dieser Instanz aus einem Export von einer anderen importieren. - success: Deine Daten wurden erfolgreich hochgeladen und werden in Kürze verabeitet + preface: Daten, die du aus einer anderen Instanz exportiert hast, kannst du hier importieren. Beispielsweise die Liste derjenigen, denen du folgst oder die du blockiert hast. + success: Deine Daten wurden erfolgreich hochgeladen und werden in Kürze verarbeitet types: blocking: Blockierliste following: Folgeliste @@ -259,33 +322,87 @@ de: missing_resource: Die erforderliche Weiterleitungs-URL konnte leider in deinem Profil nicht gefunden werden. proceed: Weiter prompt: 'Du wirst dieser Person folgen:' + sessions: + activity: Letzte Aktivität + browser: Browser + browsers: + alipay: Alipay + blackberry: Blackberry + chrome: Chrome + edge: Microsoft Edge + firefox: Firefox + generic: Unbekannter Browser + 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: Aktuelle Sitzung + description: "%{browser} auf %{platform}" + explanation: Dies sind die Webbrowser, die derzeit in dein Mastodon-Konto eingeloggt sind. + ip: IP-Adresse + platforms: + adobe_air: Adobe Air + android: Android + blackberry: Blackberry + chrome_os: ChromeOS + firefox_os: Firefox OS + ios: iOS + linux: Linux + mac: Mac + other: unbekannter Plattform + windows: Windows + windows_mobile: Windows Mobile + windows_phone: Windows Phone + revoke: Schließen + revoke_success: Sitzung erfolgreich geschlossen + title: Sitzungen settings: authorized_apps: Autorisierte Anwendungen back: Zurück zu Mastodon + delete: Konto löschen + development: Entwicklung edit_profile: Profil bearbeiten export: Datenexport + followers: Autorisierte Folgende import: Datenimport preferences: Einstellungen settings: Einstellungen two_factor_authentication: Zwei-Faktor-Authentisierung + your_apps: Deine Anwendungen statuses: open_in_web: Im Web öffnen over_character_limit: Zeichenlimit von %{max} überschritten + pin_errors: + limit: Du kannst nicht noch mehr Beiträge anheften + ownership: Du kannst nur eigene Beiträge anheften + private: Du kannst nur öffentliche Beiträge anheften + reblog: Du kannst keine geteilten Beiträge anheften show_more: Mehr anzeigen visibilities: - private: Nur Folgenden zeigen + private: Nur Folgende + private_long: Nur für Folgende sichtbar public: Öffentlich - unlisted: Öffentlich, aber nicht auf der öffentlichen Zeitleiste anzeigen + public_long: Für alle sichtbar + unlisted: Nicht gelistet + unlisted: Für alle sichtbar, aber nicht in öffentlichen Zeitleisten aufgelistet stream_entries: click_to_show: Klicken, um zu zeigen + pinned: Angehefteter Beitrag reblogged: teilte sensitive_content: Heikle Inhalte + themes: + default: Mastodon time: formats: default: "%d.%m.%Y %H:%M" two_factor_authentication: code_hint: Gib den Code, den deine Authenticator-App generiert hat, zur Bestätigung an - description_html: Wenn du Zwei-Faktor-Authentisierung aktivierst, wirst du dein Telefon zum Anmelden benötigen, welches Tokens für dich generiert, die du eingeben musst. + description_html: Wenn du Zwei-Faktor-Authentisierung (2FA) aktivierst, wirst du dein Telefon zum Anmelden benötigen. Darauf werden Tokens erzeugt, die du eingeben musst. disable: Deaktivieren enable: Aktivieren enabled_success: Zwei-Faktor-Authentisierung erfolgreich aktiviert @@ -298,5 +415,5 @@ de: setup: Einrichten wrong_code: Der eingegebene Code war ungültig! Sind die Server- und die Gerätezeit korrekt? users: - invalid_email: Ungültige E-Mail-Addresse + invalid_email: Ungültige E-Mail-Adresse invalid_otp_token: Ungültiger Zwei-Faktor-Authentisierungs-Code diff --git a/config/locales/devise.de.yml b/config/locales/devise.de.yml index 318263e051..b1e26f1e50 100644 --- a/config/locales/devise.de.yml +++ b/config/locales/devise.de.yml @@ -3,8 +3,8 @@ de: devise: confirmations: confirmed: Deine E-Mail-Adresse wurde bestätigt. - send_instructions: Du erhältst in wenigen Minuten eine E-Mail, mit der du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! - send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank hinterlegt ist, erhältst du in wenigen Minuten eine E-Mail, mit der du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! + send_instructions: Du erhältst in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! + send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank hinterlegt ist, erhältst du in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du deine E-Mail-Adresse bestätigen kannst. Schau bitte auch in deinen Spam-Ordner! failure: already_authenticated: Du bist bereits angemeldet. inactive: Dein Konto wurde noch nicht aktiviert. @@ -26,29 +26,29 @@ de: subject: 'Mastodon: Konto entsperren' omniauth_callbacks: failure: Du konntest nicht mit deinem %{kind}-Konto angemeldet werden, weil '%{reason}'. - success: Du hast dich erfolgreich mit deinem %{kind}-Account angemeldet. + success: Du hast dich erfolgreich mit deinem %{kind}-Konto angemeldet. passwords: no_token: Du kannst diese Seite nur über den Link aus der E-Mail zum Passwort-Zurücksetzen aufrufen. Wenn du einen solchen Link aufgerufen hast, stelle bitte sicher, dass du die vollständige Adresse aufrufst. - send_instructions: Du erhältst in wenigen Minuten eine E-Mail mit der Anleitung, wie du dein Passwort zurücksetzen kannst. - send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank existiert erhältst du in wenigen Minuten eine E-Mail mit der Anleitung, wie du dein Passwort zurücksetzen kannst. + send_instructions: Du erhältst in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Passwort zurücksetzen kannst. + send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank existiert, erhältst du in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Passwort zurücksetzen kannst. updated: Dein Passwort wurde geändert. Du bist jetzt angemeldet. updated_not_active: Dein Passwort wurde geändert. registrations: - destroyed: Dein Account wurde gelöscht. + destroyed: Dein Konto wurde gelöscht. signed_up: Du hast dich erfolgreich registriert. - signed_up_but_inactive: Du hast dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Account inaktiv ist. - signed_up_but_locked: Du hast dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Account gesperrt ist. - signed_up_but_unconfirmed: Du hast Dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Account noch nicht bestätigt ist. Du erhältst in Kürze eine E-Mail mit der Anleitung, wie Du Deinen Account freischalten kannst. - update_needs_confirmation: Deine Daten wurden aktualisiert, aber du musst deine neue E-Mail-Adresse bestätigen. Du erhälst in wenigen Minuten eine E-Mail, mit der du die Änderung deiner E-Mail-Adresse abschließen kannst. + signed_up_but_inactive: Du hast dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Konto inaktiv ist. + signed_up_but_locked: Du hast dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Konto gesperrt ist. + signed_up_but_unconfirmed: Du hast dich erfolgreich registriert. Wir konnten dich noch nicht anmelden, da dein Konto noch nicht bestätigt ist. Du erhältst in Kürze eine E-Mail. Darin ist erklärt, wie du dein Konto freischalten kannst. + update_needs_confirmation: Deine Daten wurden aktualisiert, aber du musst deine neue E-Mail-Adresse bestätigen. Du erhältst in wenigen Minuten eine E-Mail. Darin ist erklärt, wie du die Änderung deiner E-Mail-Adresse abschließen kannst. updated: Deine Daten wurden aktualisiert. sessions: already_signed_out: Erfolgreich abgemeldet. signed_in: Erfolgreich angemeldet. signed_out: Erfolgreich abgemeldet. unlocks: - send_instructions: Du erhältst in wenigen Minuten eine E-Mail mit der Anleitung, wie du deinen Account entsperren können. - send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank existiert erhältst du in wenigen Minuten eine E-Mail mit der Anleitung, wie du deinen Account entsperren kannst. - unlocked: Dein Account wurde entsperrt. Du bist jetzt angemeldet. + send_instructions: Du erhältst in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Konto entsperren kannst. + send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank hinterlegt ist, erhältst du in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Konto entsperren kannst. + unlocked: Dein Konto wurde entsperrt. Du bist jetzt angemeldet. errors: messages: already_confirmed: wurde bereits bestätigt. diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 0a820ff1e4..2fc353b6cb 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -8,11 +8,11 @@ de: one: 1 Zeichen verbleibt other: %{count} Zeichen verbleiben header: PNG, GIF oder JPG. Maximal 2 MB. Wird auf 700×335 px herunterskaliert - locked: Du musst zustimmen, bevor dir jemand folgen kann + locked: Wer dir folgen möchte, muss um deine Erlaubnis bitten note: one: 1 Zeichen verbleibt other: %{count} Zeichen verbleiben - setting_noindex: Betrifft dein öffentliches Profil und Status-Seiten + setting_noindex: Betrifft dein öffentliches Profil und deine Beiträge setting_theme: Wirkt sich darauf aus, wie Mastodon aussieht, egal auf welchem Gerät du eingeloggt bist. imports: data: CSV-Datei, die aus einer anderen Mastodon-Instanz exportiert wurde @@ -38,10 +38,10 @@ de: otp_attempt: Zwei-Faktor-Authentisierungs-Code password: Passwort setting_auto_play_gif: Animierte GIFs automatisch abspielen - setting_boost_modal: Bestätigungsdialog anzeigen, bevor ein Tröt geteilt wird + setting_boost_modal: Bestätigungsdialog anzeigen, bevor ein Beitrag geteilt wird setting_default_privacy: Beitragssichtbarkeit setting_default_sensitive: Medien immer als heikel markieren - setting_delete_modal: Bestätigungsdialog anzeigen, bevor ein Tröt gelöscht wird + setting_delete_modal: Bestätigungsdialog anzeigen, bevor ein Beitrag gelöscht wird setting_noindex: Suchmaschinen-Indexierung verhindern setting_system_font_ui: Standardschriftart des Systems verwenden setting_theme: Theme der Website From 0060f988478e54cb1b54b255d06376fd9fa265b1 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Fri, 29 Sep 2017 13:46:43 -0700 Subject: [PATCH 015/137] Remove react-sizeme (#5143) * Remove react-sizeme * Fix aspect ratio in "sensitive" mode --- .../mastodon/components/media_gallery.js | 41 ++++++++++++++----- package.json | 1 - yarn.lock | 18 -------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 38b26b1fc7..e7f14a7db5 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -6,7 +6,6 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from '../is_mobile'; import classNames from 'classnames'; -import sizeMe from 'react-sizeme'; const messages = defineMessages({ toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, @@ -172,7 +171,6 @@ class Item extends React.PureComponent { } @injectIntl -@sizeMe({}) export default class MediaGallery extends React.PureComponent { static propTypes = { @@ -209,21 +207,42 @@ export default class MediaGallery extends React.PureComponent { this.props.onOpenMedia(this.props.media, index); } + handleRef = (node) => { + if (node && this.isStandaloneEligible()) { + // offsetWidth triggers a layout, so only calculate when we need to + this.setState({ + width: node.offsetWidth, + }); + } + } + + isStandaloneEligible() { + const { media, standalone } = this.props; + return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); + } + render () { - const { media, intl, sensitive, height, standalone, size } = this.props; + const { media, intl, sensitive, height } = this.props; + const { width, visible } = this.state; let children; - const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']); const style = {}; - if (standaloneEligible) { - style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']); + if (this.isStandaloneEligible()) { + if (!visible && width) { + // only need to forcibly set the height in "sensitive" mode + style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); + } else { + // layout automatically, using image's natural aspect ratio + style.height = ''; + } } else { + // crop the image style.height = height; } - if (!this.state.visible) { + if (!visible) { let warning; if (sensitive) { @@ -233,7 +252,7 @@ export default class MediaGallery extends React.PureComponent { } children = ( - @@ -241,7 +260,7 @@ export default class MediaGallery extends React.PureComponent { } else { const size = media.take(4).size; - if (standaloneEligible) { + if (this.isStandaloneEligible()) { children = ; } else { children = media.take(4).map((attachment, i) => ); @@ -250,8 +269,8 @@ export default class MediaGallery extends React.PureComponent { return (
-
- +
+
{children} diff --git a/package.json b/package.json index 7835a04408..be9b908754 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "react-router-dom": "^4.1.1", "react-router-scroll": "ytase/react-router-scroll#build", "react-simple-dropdown": "^3.0.0", - "react-sizeme": "^2.3.5", "react-swipeable-views": "^0.12.3", "react-textarea-autosize": "^5.0.7", "react-toggle": "^4.0.1", diff --git a/yarn.lock b/yarn.lock index 640d06a102..7b83288057 100644 --- a/yarn.lock +++ b/yarn.lock @@ -982,10 +982,6 @@ base64-js@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.1.tgz#a91947da1f4a516ea38e5b4ec0ec3773675e0886" -batch-processor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/batch-processor/-/batch-processor-1.0.0.tgz#75c95c32b748e0850d10c2b168f6bdbe9891ace8" - batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -2057,12 +2053,6 @@ electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.14: version "1.3.15" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.15.tgz#08397934891cbcfaebbd18b82a95b5a481138369" -element-resize-detector@^1.1.12: - version "1.1.12" - resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.1.12.tgz#8b3fd6eedda17f9c00b360a0ea2df9927ae80ba2" - dependencies: - batch-processor "^1.0.0" - elliptic@^6.0.0: version "6.4.0" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" @@ -5423,14 +5413,6 @@ react-simple-dropdown@^3.0.0: classnames "^2.1.2" prop-types "^15.5.8" -react-sizeme@^2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.3.5.tgz#f14c0a15f9b24d7b8b6f196871b0af19aa01a422" - dependencies: - element-resize-detector "^1.1.12" - invariant "^2.2.2" - lodash "^4.17.4" - react-swipeable-views-core@^0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/react-swipeable-views-core/-/react-swipeable-views-core-0.11.1.tgz#61d046799f90725bbf91a0eb3abcab805c774cac" From ebb8c8920795a31a3188d39b926a5074bb8b69cf Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 30 Sep 2017 04:29:56 +0200 Subject: [PATCH 016/137] Upgrade to React 16 (#5119) * Upgrade to React 16.0.0 * Disable some uncritical tests while chai-enzyme remains incompatible --- .../mastodon/components/column_header.js | 4 +- .../mastodon/containers/mastodon.js | 2 +- app/javascript/mastodon/features/ui/index.js | 2 +- app/javascript/mastodon/performance.js | 4 +- package.json | 18 +- spec/javascript/components/avatar.test.js | 14 +- .../components/avatar_overlay.test.js | 10 +- spec/javascript/components/button.test.js | 25 +- .../components/display_name.test.js | 9 +- spec/javascript/setup.js | 8 +- yarn.lock | 1049 +++++++++++------ 11 files changed, 722 insertions(+), 423 deletions(-) diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 05e1de7051..e4fa8fa7a7 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -135,7 +135,7 @@ export default class ColumnHeader extends React.PureComponent { return (
-

+

{title} @@ -145,7 +145,7 @@ export default class ColumnHeader extends React.PureComponent {

-
+
{(!collapsed || animating) && collapsedContent}
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 884fc161af..31167cbd8e 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -5,7 +5,7 @@ import configureStore from '../store/configureStore'; import { showOnboardingOnce } from '../actions/onboarding'; import BrowserRouter from 'react-router-dom/BrowserRouter'; import Route from 'react-router-dom/Route'; -import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; +import { ScrollContext } from 'react-router-scroll'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 2a55cfb4c4..0e4796fcb5 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -48,7 +48,7 @@ const mapStateToProps = state => ({ @connect(mapStateToProps) @withRouter -export default class UI extends React.PureComponent { +export default class UI extends React.Component { static contextTypes = { router: PropTypes.object.isRequired, diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js index 396c605e4c..450a90626e 100644 --- a/app/javascript/mastodon/performance.js +++ b/app/javascript/mastodon/performance.js @@ -14,8 +14,8 @@ if (process.env.NODE_ENV === 'development') { } marky = require('marky'); // allows us to easily do e.g. ReactPerf.printWasted() while debugging - window.ReactPerf = require('react-addons-perf'); - window.ReactPerf.start(); + //window.ReactPerf = require('react-addons-perf'); + //window.ReactPerf.start(); } export function start(name) { diff --git a/package.json b/package.json index be9b908754..0b7f9128e1 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,7 @@ "css-loader": "^0.28.4", "detect-passive-events": "^1.0.2", "dotenv": "^4.0.0", - "emoji-mart": "^1.0.1", - "emojione": "^2.2.7", - "emojione-picker": "^2.2.1", + "emoji-mart": "^2.0.1", "es6-symbol": "^3.1.1", "escape-html": "^1.0.3", "express": "^4.15.2", @@ -80,10 +78,8 @@ "prop-types": "^15.5.10", "punycode": "^2.1.0", "rails-ujs": "^5.1.2", - "react": "^15.6.1", - "react-addons-perf": "^15.4.2", - "react-addons-shallow-compare": "^15.6.0", - "react-dom": "^15.6.1", + "react": "^16.0.0", + "react-dom": "^16.0.0", "react-immutable-proptypes": "^2.1.0", "react-immutable-pure-component": "^1.0.0", "react-intl": "^2.4.0", @@ -93,8 +89,7 @@ "react-redux": "^5.0.4", "react-redux-loading-bar": "^2.9.2", "react-router-dom": "^4.1.1", - "react-router-scroll": "ytase/react-router-scroll#build", - "react-simple-dropdown": "^3.0.0", + "react-router-scroll": "Gargron/react-router-scroll#build", "react-swipeable-views": "^0.12.3", "react-textarea-autosize": "^5.0.7", "react-toggle": "^4.0.1", @@ -124,14 +119,15 @@ "babel-eslint": "^7.2.3", "chai": "^4.1.0", "chai-enzyme": "^0.8.0", - "enzyme": "^2.9.1", + "enzyme": "^3.0.0", + "enzyme-adapter-react-16": "^1.0.0", "eslint": "^3.19.0", "eslint-plugin-jsx-a11y": "^4.0.0", "eslint-plugin-react": "^6.10.3", "jsdom": "^11.1.0", "mocha": "^3.4.1", "react-intl-translations-manager": "^5.0.0", - "react-test-renderer": "^15.6.1", + "react-test-renderer": "^16.0.0", "sinon": "^2.3.7", "webpack-dev-server": "^2.6.1", "yargs": "^8.0.2" diff --git a/spec/javascript/components/avatar.test.js b/spec/javascript/components/avatar.test.js index ee40812caf..34949f2b56 100644 --- a/spec/javascript/components/avatar.test.js +++ b/spec/javascript/components/avatar.test.js @@ -1,8 +1,9 @@ +import React from 'react'; +import Avatar from '../../../app/javascript/mastodon/components/avatar'; + import { expect } from 'chai'; import { render } from 'enzyme'; import { fromJS } from 'immutable'; -import React from 'react'; -import Avatar from '../../../app/javascript/mastodon/components/avatar'; describe('', () => { const account = fromJS({ @@ -12,27 +13,28 @@ describe('', () => { avatar: '/animated/alice.gif', avatar_static: '/static/alice.jpg', }); + const size = 100; const animated = render(); const still = render(); // Autoplay - it('renders a div element with the given src as background', () => { + xit('renders a div element with the given src as background', () => { expect(animated.find('div')).to.have.style('background-image', `url(${account.get('avatar')})`); }); - it('renders a div element of the given size', () => { + xit('renders a div element of the given size', () => { ['width', 'height'].map((attr) => { expect(animated.find('div')).to.have.style(attr, `${size}px`); }); }); // Still - it('renders a div element with the given static src as background if not autoplay', () => { + xit('renders a div element with the given static src as background if not autoplay', () => { expect(still.find('div')).to.have.style('background-image', `url(${account.get('avatar_static')})`); }); - it('renders a div element of the given size if not autoplay', () => { + xit('renders a div element of the given size if not autoplay', () => { ['width', 'height'].map((attr) => { expect(still.find('div')).to.have.style(attr, `${size}px`); }); diff --git a/spec/javascript/components/avatar_overlay.test.js b/spec/javascript/components/avatar_overlay.test.js index a8f0e13d52..fe1d3a0122 100644 --- a/spec/javascript/components/avatar_overlay.test.js +++ b/spec/javascript/components/avatar_overlay.test.js @@ -1,8 +1,9 @@ +import React from 'react'; +import AvatarOverlay from '../../../app/javascript/mastodon/components/avatar_overlay'; + import { expect } from 'chai'; import { render } from 'enzyme'; import { fromJS } from 'immutable'; -import React from 'react'; -import AvatarOverlay from '../../../app/javascript/mastodon/components/avatar_overlay'; describe('', () => { const account = fromJS({ @@ -12,6 +13,7 @@ describe('', () => { avatar: '/animated/alice.gif', avatar_static: '/static/alice.jpg', }); + const friend = fromJS({ username: 'eve', acct: 'eve@blackhat.lair', @@ -22,12 +24,12 @@ describe('', () => { const overlay = render(); - it('renders account static src as base of overlay avatar', () => { + xit('renders account static src as base of overlay avatar', () => { expect(overlay.find('.account__avatar-overlay-base')) .to.have.style('background-image', `url(${account.get('avatar_static')})`); }); - it('renders friend static src as overlay of overlay avatar', () => { + xit('renders friend static src as overlay of overlay avatar', () => { expect(overlay.find('.account__avatar-overlay-overlay')) .to.have.style('background-image', `url(${friend.get('avatar_static')})`); }); diff --git a/spec/javascript/components/button.test.js b/spec/javascript/components/button.test.js index 9cf8b1eed5..d2cd0b4e70 100644 --- a/spec/javascript/components/button.test.js +++ b/spec/javascript/components/button.test.js @@ -1,16 +1,17 @@ -import { expect } from 'chai'; -import { shallow } from 'enzyme'; -import sinon from 'sinon'; import React from 'react'; import Button from '../../../app/javascript/mastodon/components/button'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; + describe('); expect(wrapper.find('button')).to.contain(children); }); - it('renders the props.text instead of children', () => { + xit('renders the props.text instead of children', () => { const text = 'foo'; const children =

children

; const wrapper = shallow(); @@ -49,22 +50,22 @@ describe(' - - - - - + + + + + +
); } @@ -243,6 +242,7 @@ class EmojiPickerMenu extends React.PureComponent { i18n={this.getI18n()} onClick={this.handleClick} skin={modifier} + showPreview={false} backgroundImageFn={backgroundImageFn} /> From 0f699a4280e63b23d86c901a376c8a9e661ebc29 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 3 Oct 2017 02:01:54 +0200 Subject: [PATCH 042/137] When muting, clear web UI like for blocks (#5172) * When muting, clear web UI like for blocks * Fix style issue --- app/javascript/mastodon/reducers/notifications.js | 6 +++++- app/javascript/mastodon/reducers/statuses.js | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 0063d24e45..cccf00a1f7 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -9,7 +9,10 @@ import { NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, } from '../actions/notifications'; -import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from '../actions/accounts'; import { TIMELINE_DELETE } from '../actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; @@ -108,6 +111,7 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_EXPAND_SUCCESS: return appendNormalizedNotifications(state, action.notifications, action.next); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterNotifications(state, action.relationship); case NOTIFICATIONS_CLEAR: return state.set('items', ImmutableList()).set('next', null); diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 38b23504ed..2d72b12e88 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -24,6 +24,7 @@ import { } from '../actions/timelines'; import { ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, } from '../actions/accounts'; import { NOTIFICATIONS_UPDATE, @@ -138,6 +139,7 @@ export default function statuses(state = initialState, action) { case TIMELINE_DELETE: return deleteStatus(state, action.id, action.references); case ACCOUNT_BLOCK_SUCCESS: + case ACCOUNT_MUTE_SUCCESS: return filterStatuses(state, action.relationship); default: return state; From 395a57d03d4592df0ffe0d8ad7c6ea86510a202d Mon Sep 17 00:00:00 2001 From: Jakob Kramer <811907+gandaro@users.noreply.github.com> Date: Tue, 3 Oct 2017 02:53:18 +0200 Subject: [PATCH 043/137] Update German translation (#5189) --- app/javascript/mastodon/locales/de.json | 66 ++++++++++++------------- config/locales/de.yml | 26 ++++++++-- config/locales/devise.de.yml | 4 +- config/locales/simple_form.de.yml | 3 +- 4 files changed, 60 insertions(+), 39 deletions(-) diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 88859e49db..ba23b8dab1 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -33,7 +33,7 @@ "column.home": "Startseite", "column.mutes": "Stummgeschaltete Profile", "column.notifications": "Mitteilungen", - "column.pins": "Pinned toot", + "column.pins": "Angeheftete Beiträge", "column.public": "Gesamtes bekanntes Netz", "column_back_button.label": "Zurück", "column_header.hide_settings": "Einstellungen verbergen", @@ -66,34 +66,34 @@ "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.", "embed.preview": "So wird es aussehen:", "emoji_button.activity": "Aktivitäten", - "emoji_button.custom": "Custom", + "emoji_button.custom": "Eigene", "emoji_button.flags": "Flaggen", "emoji_button.food": "Essen und Trinken", "emoji_button.label": "Emoji einfügen", "emoji_button.nature": "Natur", "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "Gegenstände", - "emoji_button.people": "Leute", + "emoji_button.people": "Personen", "emoji_button.recent": "Häufig benutzt", - "emoji_button.search": "Suchen …", + "emoji_button.search": "Suchen", "emoji_button.search_results": "Suchergebnisse", "emoji_button.symbols": "Symbole", "emoji_button.travel": "Reisen und Orte", - "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!", - "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.", - "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.", - "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich neu erstellt.", + "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!", + "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.", + "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.", + "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv warst, wird sie für dich so schnell wie möglich neu erstellt.", "empty_column.home.public_timeline": "die öffentliche Zeitleiste", - "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.", - "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.", + "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.", + "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen", "follow_request.authorize": "Erlauben", "follow_request.reject": "Ablehnen", - "getting_started.appsshort": "Anwendungen", + "getting_started.appsshort": "Apps", "getting_started.faq": "Häufig gestellte Fragen", "getting_started.heading": "Erste Schritte", - "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen oder Probleme melden.", "getting_started.userguide": "Bedienungsanleitung", - "home.column_settings.advanced": "Fortgeschritten", + "home.column_settings.advanced": "Erweitert", "home.column_settings.basic": "Einfach", "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern", "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen", @@ -102,7 +102,7 @@ "lightbox.close": "Schließen", "lightbox.next": "Weiter", "lightbox.previous": "Zurück", - "loading_indicator.label": "Lade …", + "loading_indicator.label": "Wird geladen …", "media_gallery.toggle_visible": "Sichtbarkeit umschalten", "missing_indicator.label": "Nicht gefunden", "navigation_bar.blocks": "Blockierte Profile", @@ -110,10 +110,10 @@ "navigation_bar.edit_profile": "Profil bearbeiten", "navigation_bar.favourites": "Favoriten", "navigation_bar.follow_requests": "Folgeanfragen", - "navigation_bar.info": "Erweiterte Informationen", + "navigation_bar.info": "Über diese Instanz", "navigation_bar.logout": "Abmelden", "navigation_bar.mutes": "Stummgeschaltete Profile", - "navigation_bar.pins": "Pinned toots", + "navigation_bar.pins": "Angeheftete Beiträge", "navigation_bar.preferences": "Einstellungen", "navigation_bar.public_timeline": "Föderierte Zeitleiste", "notification.favourite": "{name} hat deinen Beitrag favorisiert", @@ -127,13 +127,13 @@ "notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.mention": "Erwähnungen:", "notifications.column_settings.push": "Push-Benachrichtigungen", - "notifications.column_settings.push_meta": "This device", + "notifications.column_settings.push_meta": "Auf diesem Gerät", "notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.sound": "Ton abspielen", "onboarding.done": "Fertig", "onboarding.next": "Weiter", - "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt. Durch sie kannst du viel Neues entdecken.", + "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten. In ihnen kannst du viel Neues entdecken!", "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.", "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.", "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.", @@ -142,20 +142,20 @@ "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.", "onboarding.page_six.almost_done": "Fast fertig …", "onboarding.page_six.appetoot": "Guten Appetröt!", - "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.", - "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.", + "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und weitere Plattformen.", + "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen, Probleme melden und Wünsche äußern.", "onboarding.page_six.guidelines": "Richtlinien", - "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!", + "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut.", "onboarding.page_six.various_app": "Apps", - "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.", - "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", - "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.", + "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen und deine Beschreibung anzupassen. Dort findest du auch weitere Einstellungen.", + "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute zu finden und mit Hashtags wie {illustration} oder {introductions} nach Beiträgen zu suchen. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.", + "onboarding.page_two.compose": "Schreibe deine Beiträge in der Schreiben-Spalte. Mit den Symbolen unter dem Eingabefeld kannst du Bilder hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.", "onboarding.skip": "Überspringen", "privacy.change": "Sichtbarkeit des Beitrags anpassen", "privacy.direct.long": "Beitrag nur an erwähnte Profile", "privacy.direct.short": "Direkt", "privacy.private.long": "Beitrag nur an Folgende", - "privacy.private.short": "Privat", + "privacy.private.short": "Nur Folgende", "privacy.public.long": "Beitrag an öffentliche Zeitleisten", "privacy.public.short": "Öffentlich", "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen", @@ -163,26 +163,26 @@ "reply_indicator.cancel": "Abbrechen", "report.placeholder": "Zusätzliche Kommentare", "report.submit": "Absenden", - "report.target": "Melden", + "report.target": "{target} melden", "search.placeholder": "Suche", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", - "standalone.public_title": "Vorschau …", + "standalone.public_title": "Ein kleiner Einblick …", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", "status.delete": "Löschen", "status.embed": "Einbetten", "status.favourite": "Favorisieren", "status.load_more": "Weitere laden", "status.media_hidden": "Medien versteckt", - "status.mention": "Erwähnen", + "status.mention": "@{name} erwähnen", "status.mute_conversation": "Thread stummschalten", - "status.open": "Öffnen", + "status.open": "Diesen Beitrag öffnen", "status.pin": "Im Profil anheften", "status.reblog": "Teilen", "status.reblogged_by": "{name} teilte", "status.reply": "Antworten", "status.replyAll": "Auf Thread antworten", "status.report": "@{name} melden", - "status.sensitive_toggle": "Klicke, um sie zu sehen", + "status.sensitive_toggle": "Zum Ansehen klicken", "status.sensitive_warning": "Heikle Inhalte", "status.share": "Teilen", "status.show_less": "Weniger anzeigen", @@ -194,11 +194,11 @@ "tabs_bar.home": "Startseite", "tabs_bar.local_timeline": "Lokal", "tabs_bar.notifications": "Mitteilungen", - "upload_area.title": "Hereinziehen zum Hochladen", + "upload_area.title": "Zum Hochladen hereinziehen", "upload_button.label": "Mediendatei hinzufügen", - "upload_form.description": "Describe for the visually impaired", + "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben", "upload_form.undo": "Entfernen", - "upload_progress.label": "Lade hoch …", + "upload_progress.label": "Wird hochgeladen …", "video.close": "Video schließen", "video.exit_fullscreen": "Vollbild verlassen", "video.expand": "Video vergrößern", diff --git a/config/locales/de.yml b/config/locales/de.yml index 1192a7b102..dce86409b1 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -189,7 +189,7 @@ de: application_mailer: settings: 'E-Mail-Einstellungen ändern: %{link}' signature: Mastodon-Benachrichtigungen von %{instance} - view: 'Darstellung:' + view: 'Ansehen:' applications: invalid_url: Die angegebene URL ist ungültig auth: @@ -317,9 +317,29 @@ de: next: Vorwärts prev: Zurück truncate: "…" + preferences: + languages: Sprachen + notifications: Benachrichtigungen + other: Weiteres + publishing: Beiträge + web: Web + push_notifications: + favourite: + title: "%{name} hat deinen Beitrag favorisiert" + follow: + title: "%{name} folgt dir nun" + group: + title: "%{count} Benachrichtigungen" + mention: + action_boost: Teilen + action_expand: Mehr anzeigen + action_favourite: Favorisieren + title: "%{name} hat dich erwähnt" + reblog: + title: "%{name} hat deinen Beitrag geteilt" remote_follow: - acct: Dein Profilname@Domain, von dem aus du dieser Person folgen möchtest. - missing_resource: Die erforderliche Weiterleitungs-URL konnte leider in deinem Profil nicht gefunden werden. + acct: Dein Profilname@Domain, von dem aus du dieser Person folgen möchtest + missing_resource: Die erforderliche Weiterleitungs-URL für dein Konto konnte nicht gefunden werden proceed: Weiter prompt: 'Du wirst dieser Person folgen:' sessions: diff --git a/config/locales/devise.de.yml b/config/locales/devise.de.yml index 0db946b9fb..6154231c7b 100644 --- a/config/locales/devise.de.yml +++ b/config/locales/devise.de.yml @@ -25,12 +25,12 @@ de: unlock_instructions: subject: 'Mastodon: Konto entsperren' omniauth_callbacks: - failure: Du konntest nicht mit deinem %{kind}-Konto angemeldet werden, weil '%{reason}'. + failure: Du konntest nicht mit deinem %{kind}-Konto angemeldet werden, weil »%{reason}«. success: Du hast dich erfolgreich mit deinem %{kind}-Konto angemeldet. passwords: no_token: Du kannst diese Seite nur über den Link aus der E-Mail zum Passwort-Zurücksetzen aufrufen. Wenn du einen solchen Link aufgerufen hast, stelle bitte sicher, dass du die vollständige Adresse aufrufst. send_instructions: Du erhältst in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Passwort zurücksetzen kannst. - send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank existiert, erhältst du in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Passwort zurücksetzen kannst. + send_paranoid_instructions: Falls deine E-Mail-Adresse in unserer Datenbank hinterlegt ist, erhältst du in wenigen Minuten eine E-Mail. Darin wird erklärt, wie du dein Passwort zurücksetzen kannst. updated: Dein Passwort wurde geändert. Du bist jetzt angemeldet. updated_not_active: Dein Passwort wurde geändert. registrations: diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 2fc353b6cb..4064aa5f27 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -4,6 +4,7 @@ de: hints: defaults: avatar: PNG, GIF oder JPG. Maximal 2 MB. Wird auf 120×120 px herunterskaliert + digest: Wenn du lange Zeit inaktiv bist, wird dir eine Zusammenfassung von Erwähnungen in deiner Abwesenheit zugeschickt display_name: one: 1 Zeichen verbleibt other: %{count} Zeichen verbleiben @@ -19,7 +20,7 @@ de: sessions: otp: Gib den Zwei-Faktor-Authentisierungs-Code von deinem Telefon ein oder benutze einen deiner Wiederherstellungscodes. user: - filtered_languages: Ausgewählte Sprachen werden aus deinen öffentlichen Zeitleisten entfernt. + filtered_languages: Ausgewählte Sprachen werden aus deinen öffentlichen Zeitleisten gefiltert labels: defaults: avatar: Profilbild From f303a954e68ef47d636c6af109e81caed33ef58c Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Tue, 3 Oct 2017 20:10:07 +0900 Subject: [PATCH 044/137] Remove aria-label of status content (#5195) aria-label contained body of status with content warning, which should be hidden by default. Remove the label for the case and other cases due to consistency. --- app/javascript/mastodon/components/status_content.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 2069f971cc..63ce258653 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -144,7 +144,7 @@ export default class StatusContent extends React.PureComponent { } return ( -
+
Date: Tue, 3 Oct 2017 20:10:26 +0900 Subject: [PATCH 045/137] Add missing Japanese translations (#5193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * yarn manage:translations * Add Japanese translations for #5170 * Add Japanese translations for #5123 * Add Japanese translations for #5046 * Add Japanese translations for #5099 * Add Japanese translations for #5161 * "項目" -> "絵文字" --- app/javascript/mastodon/locales/ar.json | 5 +++++ app/javascript/mastodon/locales/bg.json | 5 +++++ app/javascript/mastodon/locales/ca.json | 5 +++++ app/javascript/mastodon/locales/de.json | 5 +++++ .../mastodon/locales/defaultMessages.json | 20 +++++++++++++++++++ app/javascript/mastodon/locales/en.json | 5 +++++ app/javascript/mastodon/locales/eo.json | 5 +++++ app/javascript/mastodon/locales/es.json | 5 +++++ app/javascript/mastodon/locales/fa.json | 5 +++++ app/javascript/mastodon/locales/fi.json | 5 +++++ app/javascript/mastodon/locales/fr.json | 5 +++++ app/javascript/mastodon/locales/he.json | 5 +++++ app/javascript/mastodon/locales/hr.json | 5 +++++ app/javascript/mastodon/locales/hu.json | 5 +++++ app/javascript/mastodon/locales/id.json | 5 +++++ app/javascript/mastodon/locales/io.json | 5 +++++ app/javascript/mastodon/locales/it.json | 5 +++++ app/javascript/mastodon/locales/ja.json | 15 +++++++++----- app/javascript/mastodon/locales/ko.json | 5 +++++ app/javascript/mastodon/locales/nl.json | 5 +++++ app/javascript/mastodon/locales/no.json | 5 +++++ app/javascript/mastodon/locales/oc.json | 5 +++++ app/javascript/mastodon/locales/pl.json | 5 +++++ app/javascript/mastodon/locales/pt-BR.json | 5 +++++ app/javascript/mastodon/locales/pt.json | 5 +++++ app/javascript/mastodon/locales/ru.json | 5 +++++ app/javascript/mastodon/locales/th.json | 5 +++++ app/javascript/mastodon/locales/tr.json | 5 +++++ app/javascript/mastodon/locales/uk.json | 5 +++++ app/javascript/mastodon/locales/zh-CN.json | 5 +++++ app/javascript/mastodon/locales/zh-HK.json | 5 +++++ app/javascript/mastodon/locales/zh-TW.json | 5 +++++ config/locales/ja.yml | 9 +++++++++ 33 files changed, 189 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 6a34d39fe2..e2df4ffc99 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -165,6 +165,11 @@ "report.submit": "إرسال", "report.target": "إبلاغ", "search.placeholder": "ابحث", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "تعذرت ترقية هذا المنشور", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index aaf99a5f1b..240e3725e7 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Търсене", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 2829656c81..b5051a32d1 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Informes", "search.placeholder": "Cercar", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index ba23b8dab1..b79b1b2f0a 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -165,6 +165,11 @@ "report.submit": "Absenden", "report.target": "{target} melden", "search.placeholder": "Suche", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}", "standalone.public_title": "Ein kleiner Einblick …", "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 521fe9f20c..1e7fef6be6 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -607,6 +607,26 @@ { "defaultMessage": "Search", "id": "search.placeholder" + }, + { + "defaultMessage": "Advanced search format", + "id": "search_popout.search_format" + }, + { + "defaultMessage": "hashtag", + "id": "search_popout.tips.hashtag" + }, + { + "defaultMessage": "user", + "id": "search_popout.tips.user" + }, + { + "defaultMessage": "status", + "id": "search_popout.tips.status" + }, + { + "defaultMessage": "Simple text returns matching display names, usernames and hashtags", + "id": "search_popout.tips.text" } ], "path": "app/javascript/mastodon/features/compose/components/search.json" diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 278f330928..b0dbc46bdd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting {target}", "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index ef185a5e15..1ccd2b817f 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Serĉi", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 9dd22da95b..f6bfbb04d9 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -165,6 +165,11 @@ "report.submit": "Publicar", "report.target": "Reportando", "search.placeholder": "Buscar", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "Un pequeño vistazo...", "status.cannot_reblog": "Este toot no puede retootearse", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index 7b5709e735..13fb912781 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -165,6 +165,11 @@ "report.submit": "بفرست", "report.target": "گزارش‌دادن", "search.placeholder": "جستجو", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", "standalone.public_title": "نگاهی به کاربران این سرور...", "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 6c01db06b9..425b3d82a2 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Hae", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 76ab68f6f6..0dda5af9c7 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -165,6 +165,11 @@ "report.submit": "Envoyer", "report.target": "Signalement", "search.placeholder": "Rechercher", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", "standalone.public_title": "Jeter un coup d’œil…", "status.cannot_reblog": "Cette publication ne peut être boostée", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 64246e8932..beaa4fd3a1 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -165,6 +165,11 @@ "report.submit": "שליחה", "report.target": "דיווח", "search.placeholder": "חיפוש", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "לא ניתן להדהד הודעה זו", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index 39b5ede80d..cef61f15eb 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -165,6 +165,11 @@ "report.submit": "Pošalji", "report.target": "Prijavljivanje", "search.placeholder": "Traži", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Ovaj post ne može biti boostan", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index a52e0837c2..7b9c1b293c 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Keresés", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index ec4f7f3ecb..cc48aa9962 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -165,6 +165,11 @@ "report.submit": "Kirim", "report.target": "Melaporkan", "search.placeholder": "Pencarian", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index 9df2177e91..b484bebc70 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -165,6 +165,11 @@ "report.submit": "Sendar", "report.target": "Denuncante", "search.placeholder": "Serchez", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 0eab2f225b..4d73fbea87 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -165,6 +165,11 @@ "report.submit": "Invia", "report.target": "Invio la segnalazione", "search.placeholder": "Cerca", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 37bc8356a7..11356c6dbb 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -66,17 +66,17 @@ "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。", "embed.preview": "表示例:", "emoji_button.activity": "活動", - "emoji_button.custom": "Custom", + "emoji_button.custom": "カスタム絵文字", "emoji_button.flags": "国旗", "emoji_button.food": "食べ物", "emoji_button.label": "絵文字を追加", "emoji_button.nature": "自然", - "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻", + "emoji_button.not_found": "絵文字がない!! (╯°□°)╯︵ ┻━┻", "emoji_button.objects": "物", "emoji_button.people": "人々", - "emoji_button.recent": "Frequently used", + "emoji_button.recent": "よく使う絵文字", "emoji_button.search": "検索...", - "emoji_button.search_results": "Search results", + "emoji_button.search_results": "検索結果", "emoji_button.symbols": "記号", "emoji_button.travel": "旅行と場所", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", @@ -165,6 +165,11 @@ "report.submit": "通報する", "report.target": "{target} を通報する", "search.placeholder": "検索", + "search_popout.search_format": "高度な検索フォーマット", + "search_popout.tips.hashtag": "ハッシュタグ", + "search_popout.tips.status": "トゥート", + "search_popout.tips.text": "表示名やユーザー名、ハッシュタグに一致する単純なテキスト", + "search_popout.tips.user": "ユーザー", "search_results.total": "{count, number}件の結果", "standalone.public_title": "今こんな話をしています", "status.cannot_reblog": "この投稿はブーストできません", @@ -196,7 +201,7 @@ "tabs_bar.notifications": "通知", "upload_area.title": "ドラッグ&ドロップでアップロード", "upload_button.label": "メディアを追加", - "upload_form.description": "Describe for the visually impaired", + "upload_form.description": "視覚障害者のための説明", "upload_form.undo": "やり直す", "upload_progress.label": "アップロード中...", "video.close": "動画を閉じる", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index e593721d48..c1768cf8fe 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -165,6 +165,11 @@ "report.submit": "신고하기", "report.target": "문제가 된 사용자", "search.placeholder": "검색", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number}건의 결과", "standalone.public_title": "A look inside...", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index e223b4a3e6..bad2d78c5a 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -165,6 +165,11 @@ "report.submit": "Verzenden", "report.target": "Rapporteren van", "search.placeholder": "Zoeken", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", "standalone.public_title": "Een kijkje binnenin...", "status.cannot_reblog": "Deze toot kan niet geboost worden", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 0ad9919278..26556b2906 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -165,6 +165,11 @@ "report.submit": "Send inn", "report.target": "Rapporterer", "search.placeholder": "Søk", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Denne posten kan ikke fremheves", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 1ad7bf5920..87582cd066 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -165,6 +165,11 @@ "report.submit": "Mandar", "report.target": "Senhalar {target}", "search.placeholder": "Recercar", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", "standalone.public_title": "Una ulhada dedins…", "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index bca22d09dd..fe76284a98 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -165,6 +165,11 @@ "report.submit": "Wyślij", "report.target": "Zgłaszanie {target}", "search.placeholder": "Szukaj", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", "standalone.public_title": "Spojrzenie w głąb…", "status.cannot_reblog": "Ten wpis nie może zostać podbity", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index 80917393f5..61674b37eb 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Denunciar", "search.placeholder": "Pesquisar", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "Dê uma espiada...", "status.cannot_reblog": "Esta postagem não pode ser compartilhada", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 9f9da9f1ea..ecd0689dfd 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -165,6 +165,11 @@ "report.submit": "Enviar", "report.target": "Denunciar", "search.placeholder": "Pesquisar", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index 59491c62d4..bf32c820d0 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -165,6 +165,11 @@ "report.submit": "Отправить", "report.target": "Жалуемся на", "search.placeholder": "Поиск", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Этот статус не может быть продвинут", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index ff39b1b941..f3ec9c5324 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -165,6 +165,11 @@ "report.submit": "Submit", "report.target": "Reporting", "search.placeholder": "Search", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index e485396836..afc6383b4d 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -165,6 +165,11 @@ "report.submit": "Gönder", "report.target": "Raporlama", "search.placeholder": "Ara", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Bu gönderi boost edilemez", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index b72ea5b378..d0aae032b4 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -165,6 +165,11 @@ "report.submit": "Відправити", "report.target": "Скаржимося на", "search.placeholder": "Пошук", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}", "standalone.public_title": "A look inside...", "status.cannot_reblog": "Цей допис не може бути передмухнутий", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 595eec30b7..e0ffc16df7 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -165,6 +165,11 @@ "report.submit": "提交", "report.target": "Reporting", "search.placeholder": "搜索", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "大家都在干啥?", "status.cannot_reblog": "没法转嘟这条嘟文啦……", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 4fbfe7a8f0..053e971aa4 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -165,6 +165,11 @@ "report.submit": "提交", "report.target": "舉報", "search.placeholder": "搜尋", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} 項結果", "standalone.public_title": "站點一瞥…", "status.cannot_reblog": "這篇文章無法被轉推", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 11db0ea141..a22d66fa14 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -165,6 +165,11 @@ "report.submit": "送出", "report.target": "通報中", "search.placeholder": "搜尋", + "search_popout.search_format": "Advanced search format", + "search_popout.tips.hashtag": "hashtag", + "search_popout.tips.status": "status", + "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", + "search_popout.tips.user": "user", "search_results.total": "{count, number} 項結果", "standalone.public_title": "站點一瞥…", "status.cannot_reblog": "此貼文無法轉推", diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 364bfcfd6c..78465e1210 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -310,6 +310,9 @@ ja: content: セキュリティ認証に失敗しました。Cookieをブロックしていませんか? title: セキュリティ認証に失敗 '429': リクエストの制限に達しました。 + '500': + content: もうしわけありませんが、なにかが間違っています。 + title: このページは正しくありません noscript_html: Mastodonのウェブアプリケーションを利用する場合はJavaScriptを有効にしてください。またはあなたのプラットフォーム向けのMastodonネイティブアプリを探すことができます。 exports: blocks: ブロック @@ -390,6 +393,12 @@ ja: next: 次 prev: 前 truncate: "…" + preferences: + languages: 言語 + notifications: 通知 + other: その他 + publishing: 投稿 + web: ウェブ push_notifications: favourite: title: あなたのトゥートが %{name} さんにお気に入り登録されました From ecacb15cd50609fb3d749ecac89835a43255fb34 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Tue, 3 Oct 2017 20:10:57 +0900 Subject: [PATCH 046/137] Add placeholder text color to form of media attachments (#5196) --- 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 e83a22e00e..3e1b08e9fb 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -371,6 +371,11 @@ &:focus { color: $white; } + + &::placeholder { + opacity: 0.54; + color: $ui-secondary-color; + } } &.active { From 334a446313d504ef9bb80ce213be32729aa3d2b8 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 3 Oct 2017 04:11:22 -0700 Subject: [PATCH 047/137] Fix emoji sequence bug in substring-trie (#5191) Fixes #5188 --- package.json | 2 +- spec/javascript/components/emojify.test.js | 5 +++++ yarn.lock | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 0b7f9128e1..11de3c636f 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "sass-loader": "^6.0.6", "stringz": "^0.2.2", "style-loader": "^0.18.2", - "substring-trie": "^1.0.1", + "substring-trie": "^1.0.2", "throng": "^4.0.0", "tiny-queue": "^0.2.1", "uuid": "^3.1.0", diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js index 6e73c92514..4202e52e1f 100644 --- a/spec/javascript/components/emojify.test.js +++ b/spec/javascript/components/emojify.test.js @@ -44,4 +44,9 @@ describe('emojify', () => { it('ignores unicode inside of tags', () => { expect(emojify('

')).to.equal('

'); }); + + it('does multiple emoji properly (issue 5188)', () => { + expect(emojify('👌🌈💕')).to.equal('👌🌈💕'); + expect(emojify('👌 🌈 💕')).to.equal('👌 🌈 💕'); + }); }); diff --git a/yarn.lock b/yarn.lock index 95cd2b06ed..3aa39a4159 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6581,9 +6581,9 @@ style-loader@^0.18.2: loader-utils "^1.0.2" schema-utils "^0.3.0" -substring-trie@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/substring-trie/-/substring-trie-1.0.1.tgz#1a5f07f774a91524eb067cb318dd4f3a3037bee0" +substring-trie@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/substring-trie/-/substring-trie-1.0.2.tgz#7b42592391628b4f2cb17365c6cce4257c7b7af5" sugarss@^1.0.0: version "1.0.0" From 875d943c189afe9887200f357d916a9f8fd19fe8 Mon Sep 17 00:00:00 2001 From: PFM Date: Wed, 4 Oct 2017 00:11:22 +0900 Subject: [PATCH 048/137] Add pagination in media modal (#4343) * Add pagination in media modal * Change array name * Add an element class * Avoid nested class * Pull out the active class * Use map instead of forEach * Remove parentheses --- .../features/ui/components/media_modal.js | 21 ++++++++++++++- app/javascript/styles/components.scss | 27 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index 705645b403..f41a830891 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent { }; handleSwipe = (index) => { - this.setState({ index: (index) % this.props.media.size }); + this.setState({ index: index % this.props.media.size }); } handleNextClick = () => { @@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent { this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); } + handleChangeIndex = (e) => { + const index = Number(e.currentTarget.getAttribute('data-index')); + this.setState({ index: index % this.props.media.size }); + } + handleKeyUp = (e) => { switch(e.key) { case 'ArrowLeft': @@ -67,10 +72,21 @@ export default class MediaModal extends ImmutablePureComponent { const { media, intl, onClose } = this.props; const index = this.getIndex(); + let pagination = []; const leftNav = media.size > 1 && ; const rightNav = media.size > 1 && ; + if (media.size > 1) { + pagination = media.map((item, i) => { + const classes = ['media-modal__button']; + if (i === index) { + classes.push('media-modal__button--active'); + } + return (
  • ); + }); + } + const content = media.map((image) => { const width = image.getIn(['meta', 'original', 'width']) || null; const height = image.getIn(['meta', 'original', 'height']) || null; @@ -98,6 +114,9 @@ export default class MediaModal extends ImmutablePureComponent { {content}
    +
      + {pagination} +
    {rightNav}
    diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 3e1b08e9fb..6ef4e38663 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -3080,6 +3080,33 @@ button.icon-button.active i.fa-retweet { background: $base-overlay-background; } +.media-modal__pagination { + width: 100%; + text-align: center; + position: absolute; + left: 0; + bottom: -40px; +} + +.media-modal__page-dot { + display: inline-block; +} + +.media-modal__button { + background-color: $white; + height: 12px; + width: 12px; + border-radius: 6px; + margin: 10px; + padding: 0; + border: 0; + font-size: 0; +} + +.media-modal__button--active { + background-color: $ui-highlight-color; +} + .media-modal__close { position: absolute; right: 4px; From 82d9ade7a6abc663b30b3df4ae08a8980d61e233 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 3 Oct 2017 11:43:57 -0700 Subject: [PATCH 049/137] Compress emoji_data_light.js (#5201) --- .../mastodon/emoji_data_compressed.js | 22 +++++++++++++++ app/javascript/mastodon/emoji_data_light.js | 27 +++++++++---------- 2 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 app/javascript/mastodon/emoji_data_compressed.js diff --git a/app/javascript/mastodon/emoji_data_compressed.js b/app/javascript/mastodon/emoji_data_compressed.js new file mode 100644 index 0000000000..f69a3e46ad --- /dev/null +++ b/app/javascript/mastodon/emoji_data_compressed.js @@ -0,0 +1,22 @@ +// @preval +const data = require('emoji-mart/dist/data').default; +const pick = require('lodash/pick'); +const values = require('lodash/values'); + +const condensedEmojis = Object.keys(data.emojis).map(key => { + if (!data.emojis[key].short_names[0] === key) { + throw new Error('The condenser expects the first short_code to be the ' + + 'key. It may need to be rewritten if the emoji change such that this ' + + 'is no longer the case.'); + } + return values(pick(data.emojis[key], ['short_names', 'unified', 'search'])); +}); + +// JSON.parse/stringify is to emulate what @preval is doing and avoid any +// inconsistent behavior in dev mode +module.exports = JSON.parse(JSON.stringify({ + emojis: condensedEmojis, + skins: data.skins, + categories: data.categories, + short_names: data.short_names, +})); diff --git a/app/javascript/mastodon/emoji_data_light.js b/app/javascript/mastodon/emoji_data_light.js index f034424552..f91ee592e8 100644 --- a/app/javascript/mastodon/emoji_data_light.js +++ b/app/javascript/mastodon/emoji_data_light.js @@ -1,17 +1,16 @@ -// @preval -const data = require('emoji-mart/dist/data').default; -const pick = require('lodash/pick'); +const data = require('./emoji_data_compressed'); -const condensedEmojis = {}; -Object.keys(data.emojis).forEach(key => { - condensedEmojis[key] = pick(data.emojis[key], ['short_names', 'unified', 'search']); +// decompress +const emojis = {}; +data.emojis.forEach(compressedEmoji => { + const [ short_names, unified, search ] = compressedEmoji; + emojis[short_names[0]] = { + short_names, + unified, + search, + }; }); -// JSON.parse/stringify is to emulate what @preval is doing and avoid any -// inconsistent behavior in dev mode -module.exports = JSON.parse(JSON.stringify({ - emojis: condensedEmojis, - skins: data.skins, - categories: data.categories, - short_names: data.short_names, -})); +data.emojis = emojis; + +module.exports = data; From 813c5f2f5283ec21c65a7e8c21146c34664f21c3 Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Tue, 3 Oct 2017 11:54:38 -0700 Subject: [PATCH 050/137] Add spec for emoji_index_light.js (#5199) --- .../javascript/components/emoji_index.test.js | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 spec/javascript/components/emoji_index.test.js diff --git a/spec/javascript/components/emoji_index.test.js b/spec/javascript/components/emoji_index.test.js new file mode 100644 index 0000000000..8c6d2cedba --- /dev/null +++ b/spec/javascript/components/emoji_index.test.js @@ -0,0 +1,81 @@ +import { expect } from 'chai'; +import { search } from '../../../app/javascript/mastodon/emoji_index_light'; +import { emojiIndex } from 'emoji-mart'; +import { pick } from 'lodash'; + +const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']); + +// hack to fix https://github.com/chaijs/type-detect/issues/98 +// see: https://github.com/chaijs/type-detect/issues/98#issuecomment-325010785 +import jsdom from 'jsdom'; +global.window = new jsdom.JSDOM().window; +global.document = window.document; +global.HTMLElement = window.HTMLElement; + +describe('emoji_index', () => { + + it('should give same result for emoji_index_light and emoji-mart', () => { + let expected = [{ + id: 'pineapple', + unified: '1f34d', + native: '🍍', + }]; + expect(search('pineapple').map(trimEmojis)).to.deep.equal(expected); + expect(emojiIndex.search('pineapple').map(trimEmojis)).to.deep.equal(expected); + }); + + it('orders search results correctly', () => { + let expected = [{ + id: 'apple', + unified: '1f34e', + native: '🍎', + }, { + id: 'pineapple', + unified: '1f34d', + native: '🍍', + }, { + id: 'green_apple', + unified: '1f34f', + native: '🍏', + }, { + id: 'iphone', + unified: '1f4f1', + native: '📱', + }]; + expect(search('apple').map(trimEmojis)).to.deep.equal(expected); + expect(emojiIndex.search('apple').map(trimEmojis)).to.deep.equal(expected); + }); + + it('handles custom emoji', () => { + let custom = [{ + id: 'mastodon', + name: 'mastodon', + short_names: ['mastodon'], + text: '', + emoticons: [], + keywords: ['mastodon'], + imageUrl: 'http://example.com', + custom: true, + }]; + search('', { custom }); + emojiIndex.search('', { custom }); + let expected = [ { id: 'mastodon', custom: true } ]; + expect(search('masto').map(trimEmojis)).to.deep.equal(expected); + expect(emojiIndex.search('masto').map(trimEmojis)).to.deep.equal(expected); + }); + + it('should filter only emojis we care about, exclude pineapple', () => { + let emojisToShowFilter = (unified) => unified !== '1F34D'; + expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id)) + .not.to.contain('pineapple'); + expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id)) + .not.to.contain('pineapple'); + }); + + it('can include/exclude categories', () => { + expect(search('flag', { include: ['people'] })) + .to.deep.equal([]); + expect(emojiIndex.search('flag', { include: ['people'] })) + .to.deep.equal([]); + }); +}); From e6543d5fc4d4f6ec7020d104e4d2360ee9bd7679 Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Tue, 3 Oct 2017 21:15:41 +0200 Subject: [PATCH 051/137] i18n: Update Polish translation (#5202) --- app/javascript/mastodon/locales/pl.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index fe76284a98..c8228c0cbe 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -56,14 +56,14 @@ "confirmations.block.confirm": "Zablokuj", "confirmations.block.message": "Czy na pewno chcesz zablokować {name}?", "confirmations.delete.confirm": "Usuń", - "confirmations.delete.message": "Czy na pewno chcesz usunąć ten status?", + "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?", "confirmations.domain_block.confirm": "Ukryj wszysyko z domeny", "confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.", "confirmations.mute.confirm": "Wycisz", "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?", "confirmations.unfollow.confirm": "Przestań śledzić", "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?", - "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.", + "embed.instructions": "Osadź ten wpis na swojej stronie wklejając poniższy kod.", "embed.preview": "Tak będzie to wyglądać:", "emoji_button.activity": "Aktywność", "emoji_button.custom": "Niestandardowe", @@ -116,10 +116,10 @@ "navigation_bar.pins": "Przypięte wpisy", "navigation_bar.preferences": "Preferencje", "navigation_bar.public_timeline": "Oś czasu federacji", - "notification.favourite": "{name} dodał Twój status do ulubionych", + "notification.favourite": "{name} dodał Twój wpis do ulubionych", "notification.follow": "{name} zaczął Cię śledzić", "notification.mention": "{name} wspomniał o tobie", - "notification.reblog": "{name} podbił Twój status", + "notification.reblog": "{name} podbił Twój wpis", "notifications.clear": "Wyczyść powiadomienia", "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?", "notifications.column_settings.alert": "Powiadomienia na pulpicie", @@ -165,11 +165,11 @@ "report.submit": "Wyślij", "report.target": "Zgłaszanie {target}", "search.placeholder": "Szukaj", - "search_popout.search_format": "Advanced search format", + "search_popout.search_format": "Zaawansowane wyszukiwanie", "search_popout.tips.hashtag": "hashtag", - "search_popout.tips.status": "status", - "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", - "search_popout.tips.user": "user", + "search_popout.tips.status": "wpis", + "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów", + "search_popout.tips.user": "użytkownik", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", "standalone.public_title": "Spojrzenie w głąb…", "status.cannot_reblog": "Ten wpis nie może zostać podbity", @@ -180,7 +180,7 @@ "status.media_hidden": "Zawartość multimedialna ukryta", "status.mention": "Wspomnij o @{name}", "status.mute_conversation": "Wycisz konwersację", - "status.open": "Rozszerz ten status", + "status.open": "Rozszerz ten wpis", "status.pin": "Przypnij do profilu", "status.reblog": "Podbij", "status.reblogged_by": "{name} podbił", From dfaa219f8820224d37cd060d253a507111c63460 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 3 Oct 2017 23:21:19 +0200 Subject: [PATCH 052/137] Fix HTTP responses for salmon and ActivityPub inbox processing (#5200) * Return sensible HTTP status for ActivityPub inbox processing * Return sensible HTTP status for salmon slap processing * Return additional information to debug signature verification failures --- app/controllers/activitypub/inboxes_controller.rb | 4 ++-- app/controllers/api/salmon_controller.rb | 6 ++++-- app/controllers/concerns/signature_verification.rb | 9 +++++++++ spec/controllers/api/salmon_controller_spec.rb | 4 ++-- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb index d0f8073edf..76553a162a 100644 --- a/app/controllers/activitypub/inboxes_controller.rb +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController if signed_request_account upgrade_account process_payload - head 201 - else head 202 + else + [signature_verification_failure_reason, 401] end end diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb index e9e700b18d..143e9d3cdc 100644 --- a/app/controllers/api/salmon_controller.rb +++ b/app/controllers/api/salmon_controller.rb @@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController def update if verify_payload? process_salmon - head 201 - else head 202 + elsif payload.present? + [signature_verification_failure_reason, 401] + else + head 400 end end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 52a9cf2905..dc2d9a6782 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -9,10 +9,15 @@ module SignatureVerification request.headers['Signature'].present? end + def signature_verification_failure_reason + return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason) + end + def signed_request_account return @signed_request_account if defined?(@signed_request_account) unless signed_request? + @signature_verification_failure_reason = 'Request not signed' @signed_request_account = nil return end @@ -27,6 +32,7 @@ module SignatureVerification end if incompatible_signature?(signature_params) + @signature_verification_failure_reason = 'Incompatible request signature' @signed_request_account = nil return end @@ -34,6 +40,7 @@ module SignatureVerification account = account_from_key_id(signature_params['keyId']) if account.nil? + @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}" @signed_request_account = nil return end @@ -51,9 +58,11 @@ module SignatureVerification @signed_request_account = account @signed_request_account else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" @signed_request_account = nil end else + @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}" @signed_request_account = nil end end diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb index 3e4686200b..323d85b615 100644 --- a/spec/controllers/api/salmon_controller_spec.rb +++ b/spec/controllers/api/salmon_controller_spec.rb @@ -46,8 +46,8 @@ RSpec.describe Api::SalmonController, type: :controller do post :update, params: { id: account.id } end - it 'returns http success' do - expect(response).to have_http_status(202) + it 'returns http client error' do + expect(response).to have_http_status(400) end end end From c743b5e1fdb938d52d8c023bb6ef1bf9b397226c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 00:33:56 +0200 Subject: [PATCH 053/137] Fix possible acct: uri usurpation in ActivityPub account discovery (#5208) Signed-off-by: Eugen Rochko --- app/services/activitypub/fetch_remote_account_service.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 3eeca585e8..cb6e407480 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -30,14 +30,12 @@ class ActivityPub::FetchRemoteAccountService < BaseService 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) + @username, @domain = split_acct(webfinger.subject) self_reference = webfinger.link('self') + return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? return false if self_reference&.href != @uri - @username = confirmed_username - @domain = confirmed_domain - true rescue Goldfinger::Error false From cdd5ef691bcdb25f8c8367698de7e09301ee3528 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 00:39:32 +0200 Subject: [PATCH 054/137] Use separate workers to process imports, retry failures (#5207) --- app/workers/import/relationship_worker.rb | 25 ++++++++++ app/workers/import_worker.rb | 58 +++++------------------ 2 files changed, 38 insertions(+), 45 deletions(-) create mode 100644 app/workers/import/relationship_worker.rb diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb new file mode 100644 index 0000000000..ed4c962c19 --- /dev/null +++ b/app/workers/import/relationship_worker.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Import::RelationshipWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: 8, dead: false + + def perform(account_id, target_account_uri, relationship) + from_account = Account.find(account_id) + target_account = ResolveRemoteAccountService.new.call(target_account_uri) + + return if target_account.nil? + + case relationship + when 'follow' + FollowService.new.call(from_account, target_account.acct) + when 'block' + BlockService.new.call(from_account, target_account) + when 'mute' + MuteService.new.call(from_account, target_account) + end + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb index 27cc6b365d..d7c126f75b 100644 --- a/app/workers/import_worker.rb +++ b/app/workers/import_worker.rb @@ -12,13 +12,8 @@ class ImportWorker def perform(import_id) @import = Import.find(import_id) - case @import.type - when 'blocking' - process_blocks - when 'following' - process_follows - when 'muting' - process_mutes + Import::RelationshipWorker.push_bulk(import_rows) do |row| + [@import.account_id, row.first, relationship_type] end @import.destroy @@ -26,49 +21,22 @@ class ImportWorker private - def from_account - @import.account - end - def import_contents Paperclip.io_adapters.for(@import.data).read end + def relationship_type + case @import.type + when 'following' + 'follow' + when 'blocking' + 'block' + when 'muting' + 'mute' + end + end + def import_rows CSV.new(import_contents).reject(&:blank?) end - - def process_mutes - import_rows.each do |row| - begin - target_account = ResolveRemoteAccountService.new.call(row.first) - next if target_account.nil? - MuteService.new.call(from_account, target_account) - rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end - end - end - - def process_blocks - import_rows.each do |row| - begin - target_account = ResolveRemoteAccountService.new.call(row.first) - next if target_account.nil? - BlockService.new.call(from_account, target_account) - rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end - end - end - - def process_follows - import_rows.each do |row| - begin - FollowService.new.call(from_account, row.first) - rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError - next - end - end - end end From ec13cfa4f940e9f9441ceff1f7389bb0e1bd61fb Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 01:01:44 +0200 Subject: [PATCH 055/137] When a streaming API status arrives, sort it into conversations (#5206) --- app/javascript/mastodon/actions/timelines.js | 20 ++++++++++++++++++ app/javascript/mastodon/reducers/contexts.js | 22 +++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 5c0cd93c7e..cdaafd89c6 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; export const TIMELINE_CONNECT = 'TIMELINE_CONNECT'; export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; +export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE'; + export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { return { type: TIMELINE_REFRESH_SUCCESS, @@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) { export function updateTimeline(timeline, status) { return (dispatch, getState) => { const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : []; + const parents = []; + + if (status.in_reply_to_id) { + let parent = getState().getIn(['statuses', status.in_reply_to_id]); + + while (parent.get('in_reply_to_id')) { + parents.push(parent.get('id')); + parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); + } + } dispatch({ type: TIMELINE_UPDATE, @@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) { status, references, }); + + if (parents.length > 0) { + dispatch({ + type: TIMELINE_CONTEXT_UPDATE, + status, + references: parents, + }); + } }; }; diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js index 9bfc09aa75..d8924e908b 100644 --- a/app/javascript/mastodon/reducers/contexts.js +++ b/app/javascript/mastodon/reducers/contexts.js @@ -1,6 +1,6 @@ import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; -import { TIMELINE_DELETE } from '../actions/timelines'; -import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; +import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ ancestors: ImmutableMap(), @@ -8,8 +8,8 @@ const initialState = ImmutableMap({ }); const normalizeContext = (state, id, ancestors, descendants) => { - const ancestorsIds = ancestors.map(ancestor => ancestor.get('id')); - const descendantsIds = descendants.map(descendant => descendant.get('id')); + const ancestorsIds = ImmutableList(ancestors.map(ancestor => ancestor.id)); + const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id)); return state.withMutations(map => { map.setIn(['ancestors', id], ancestorsIds); @@ -31,12 +31,24 @@ const deleteFromContexts = (state, id) => { return state; }; +const updateContext = (state, status, references) => { + return state.update('descendants', map => { + references.forEach(parentId => { + map = map.update(parentId, ImmutableList(), list => list.push(status.id)); + }); + + return map; + }); +}; + export default function contexts(state = initialState, action) { switch(action.type) { case CONTEXT_FETCH_SUCCESS: - return normalizeContext(state, action.id, fromJS(action.ancestors), fromJS(action.descendants)); + return normalizeContext(state, action.id, action.ancestors, action.descendants); case TIMELINE_DELETE: return deleteFromContexts(state, action.id); + case TIMELINE_CONTEXT_UPDATE: + return updateContext(state, action.status, action.references); default: return state; } From 63f097979990bf5ba9db848b8a253056bad781af Mon Sep 17 00:00:00 2001 From: Akihiko Odaki Date: Wed, 4 Oct 2017 08:13:48 +0900 Subject: [PATCH 056/137] Validate id of ActivityPub representations (#5114) Additionally, ActivityPub::FetchRemoteStatusService no longer parses activities. OStatus::Activity::Creation no longer delegates to ActivityPub because the provided ActivityPub representations are not signed while OStatus representations are. --- .../concerns/signature_verification.rb | 2 +- app/helpers/jsonld_helper.rb | 13 +++++- app/lib/activitypub/activity/announce.rb | 2 +- app/lib/activitypub/activity/create.rb | 2 +- app/lib/activitypub/linked_data_signature.rb | 2 +- app/lib/ostatus/activity/creation.rb | 9 ---- .../fetch_remote_account_service.rb | 10 +++-- .../activitypub/fetch_remote_key_service.rb | 25 ++++++++--- .../fetch_remote_status_service.rb | 37 ++++++++--------- .../activitypub/process_account_service.rb | 6 +-- app/services/fetch_atom_service.rb | 13 ++---- app/services/fetch_remote_account_service.rb | 14 +++---- app/services/fetch_remote_status_service.rb | 16 ++++---- .../resolve_remote_account_service.rb | 2 +- spec/helpers/jsonld_helper_spec.rb | 35 +++++++++++++++- .../fetch_remote_account_service_spec.rb | 2 +- .../fetch_remote_status_service_spec.rb | 41 +------------------ 17 files changed, 118 insertions(+), 113 deletions(-) diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index dc2d9a6782..2baafb5bf5 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -117,7 +117,7 @@ module SignatureVerification ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) elsif !ActivityPub::TagManager.instance.local_uri?(key_id) account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) - account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false) account end end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index d82a073320..c23a2e0952 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -22,7 +22,18 @@ module JsonLdHelper graph.dump(:normalize) end - def fetch_resource(uri) + def fetch_resource(uri, id) + unless id + json = fetch_resource_without_id_validation(uri) + return unless json + uri = json['id'] + end + + json = fetch_resource_without_id_validation(uri) + json.present? && json['id'] == uri ? json : nil + end + + def fetch_resource_without_id_validation(uri) response = build_request(uri).perform return if response.code != 200 body_to_json(response.to_s) diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 4516454e18..1cf844281f 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -27,7 +27,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity if object_uri.start_with?('http') return if ActivityPub::TagManager.instance.local_uri?(object_uri) - ActivityPub::FetchRemoteStatusService.new.call(object_uri) + ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true) elsif @object['url'].present? ::FetchRemoteStatusService.new.call(@object['url']) end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 55addd66ec..be656de481 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -80,7 +80,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity return if tag['href'].blank? account = account_from_uri(tag['href']) - account = FetchRemoteAccountService.new.call(tag['href']) if account.nil? + account = FetchRemoteAccountService.new.call(tag['href'], id: false) if account.nil? return if account.nil? account.mentions.create(status: status) end diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb index adb8b6cdfd..16142a6ff4 100644 --- a/app/lib/activitypub/linked_data_signature.rb +++ b/app/lib/activitypub/linked_data_signature.rb @@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature return unless type == 'RsaSignature2017' creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account) - creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri) + creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false) return if creator.nil? diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 2687776f94..511c462d42 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -9,11 +9,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base return [nil, false] if @account.suspended? - if activitypub_uri? && [:public, :unlisted].include?(visibility_scope) - result = perform_via_activitypub - return result if result.first.present? - end - RedisLock.acquire(lock_options) do |lock| if lock.acquired? # Return early if status already exists in db @@ -66,10 +61,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base status 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: OStatus::TagManager::XMLNS).content end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index cb6e407480..e6c6338be5 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -5,14 +5,18 @@ 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, prefetched_json = nil) - @json = body_to_json(prefetched_json) || fetch_resource(uri) + def call(uri, id: true, prefetched_body: nil) + @json = if prefetched_body.nil? + fetch_resource(uri, id) + else + body_to_json(prefetched_body) + end return unless supported_context? && expected_type? @uri = @json['id'] @username = @json['preferredUsername'] - @domain = Addressable::URI.parse(uri).normalized_host + @domain = Addressable::URI.parse(@uri).normalized_host return unless verified_webfinger? diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index ebd64071e4..ce1048feeb 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -4,13 +4,26 @@ 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) + def call(uri, id: true, prefetched_body: nil) + if prefetched_body.nil? + if id + @json = fetch_resource_without_id_validation(uri) + if person? + @json = fetch_resource(@json['id'], true) + elsif uri != @json['id'] + return + end + else + @json = fetch_resource(uri, id) + end + else + @json = body_to_json(prefetched_body) + end return unless supported_context?(@json) && expected_type? - return find_account(uri, @json) if person? + return find_account(@json['id'], @json) if person? - @owner = fetch_resource(owner_uri) + @owner = fetch_resource(owner_uri, true) return unless supported_context?(@owner) && confirmed_owner? @@ -19,9 +32,9 @@ class ActivityPub::FetchRemoteKeyService < BaseService private - def find_account(uri, prefetched_json) + def find_account(uri, prefetched_body) account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) - account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body) account end diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb index a95931afe0..c7414f1617 100644 --- a/app/services/activitypub/fetch_remote_status_service.rb +++ b/app/services/activitypub/fetch_remote_status_service.rb @@ -4,36 +4,33 @@ class ActivityPub::FetchRemoteStatusService < BaseService include JsonLdHelper # Should be called when uri has already been checked for locality - def call(uri, prefetched_json = nil) - @json = body_to_json(prefetched_json) || fetch_resource(uri) + def call(uri, id: true, prefetched_body: nil) + @json = if prefetched_body.nil? + fetch_resource(uri, id) + else + body_to_json(prefetched_body) + end - return unless supported_context? + return unless expected_type? && supported_context? - activity = activity_json - actor_id = value_or_id(activity['actor']) - - return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id) + return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id) actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account) - actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil? + actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? return if actor.suspended? - ActivityPub::Activity.factory(activity, actor).perform + ActivityPub::Activity.factory(activity_json, 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 + { 'type' => 'Create', 'actor' => actor_id, 'object' => @json } + end + + def actor_id + first_of_value(@json['attributedTo']) end def trustworthy_attribution?(uri, attributed_to) @@ -44,7 +41,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService super(@json) end - def expected_type?(json) - %w(Create Announce).include? json['type'] + 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 index 8112095377..f93baf4b51 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -90,7 +90,7 @@ class ActivityPub::ProcessAccountService < BaseService return if value.nil? return value['url'] if value.is_a?(Hash) - image = fetch_resource(value) + image = fetch_resource_without_id_validation(value) image['url'] if image end @@ -100,7 +100,7 @@ class ActivityPub::ProcessAccountService < BaseService return if value.nil? return value['publicKeyPem'] if value.is_a?(Hash) - key = fetch_resource(value) + key = fetch_resource_without_id_validation(value) key['publicKeyPem'] if key end @@ -130,7 +130,7 @@ class ActivityPub::ProcessAccountService < BaseService return if @json[type].blank? return @collections[type] if @collections.key?(type) - collection = fetch_resource(@json[type]) + collection = fetch_resource_without_id_validation(@json[type]) @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil rescue HTTP::Error, OpenSSL::SSL::SSLError diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 9c5777b5d5..bcf516bc3b 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -41,10 +41,11 @@ class FetchAtomService < BaseService return nil if @response.code != 200 if @response.mime_type == 'application/atom+xml' - [@url, @response.to_s, :ostatus] + [@url, { prefetched_body: @response.to_s }, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - if supported_activity?(@response.to_s) - [@url, @response.to_s, :activitypub] + json = body_to_json(body) + if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? + [json['id'], { id: true }, :activitypub] else @unsupported_activity = true nil @@ -79,10 +80,4 @@ class FetchAtomService < BaseService result end - - def supported_activity?(body) - json = body_to_json(body) - return false unless supported_context?(json) - json['type'] == 'Person' ? json['inbox'].present? : true - end end diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb index bd98e70d1b..a0f031a445 100644 --- a/app/services/fetch_remote_account_service.rb +++ b/app/services/fetch_remote_account_service.rb @@ -5,24 +5,24 @@ class FetchRemoteAccountService < BaseService def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, body, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchAtomService.new.call(url) else - resource_url = url - body = prefetched_body + resource_url = url + resource_options = { prefetched_body: prefetched_body } end case protocol when :ostatus - process_atom(resource_url, body) + process_atom(resource_url, **resource_options) when :activitypub - ActivityPub::FetchRemoteAccountService.new.call(resource_url, body) + ActivityPub::FetchRemoteAccountService.new.call(resource_url, **resource_options) end end private - def process_atom(url, body) - xml = Nokogiri::XML(body) + def process_atom(url, prefetched_body:) + xml = Nokogiri::XML(prefetched_body) xml.encoding = 'utf-8' account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false) diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 1b90854c4a..cacf6ba513 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -5,26 +5,26 @@ class FetchRemoteStatusService < BaseService def call(url, prefetched_body = nil, protocol = :ostatus) if prefetched_body.nil? - resource_url, body, protocol = FetchAtomService.new.call(url) + resource_url, resource_options, protocol = FetchAtomService.new.call(url) else - resource_url = url - body = prefetched_body + resource_url = url + resource_options = { prefetched_body: prefetched_body } end case protocol when :ostatus - process_atom(resource_url, body) + process_atom(resource_url, **resource_options) when :activitypub - ActivityPub::FetchRemoteStatusService.new.call(resource_url, body) + ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options) end end private - def process_atom(url, body) + def process_atom(url, prefetched_body:) Rails.logger.debug "Processing Atom for remote status at #{url}" - xml = Nokogiri::XML(body) + xml = Nokogiri::XML(prefetched_body) xml.encoding = 'utf-8' account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS)) @@ -32,7 +32,7 @@ class FetchRemoteStatusService < BaseService return nil unless !account.nil? && confirmed_domain?(domain, account) - statuses = ProcessFeedService.new.call(body, account) + statuses = ProcessFeedService.new.call(prefetched_body, account) statuses.first rescue Nokogiri::XML::XPath::SyntaxError Rails.logger.debug 'Invalid XML or missing namespace' diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 93ba07702a..3d0a36f6c3 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -189,7 +189,7 @@ class ResolveRemoteAccountService < BaseService def actor_json return @actor_json if defined?(@actor_json) - json = fetch_resource(actor_url) + json = fetch_resource(actor_url, false) @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil end diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb index 7d3912e6c4..48bfdc3067 100644 --- a/spec/helpers/jsonld_helper_spec.rb +++ b/spec/helpers/jsonld_helper_spec.rb @@ -30,6 +30,39 @@ describe JsonLdHelper do end describe '#fetch_resource' do - pending + context 'when the second argument is false' do + it 'returns resource even if the retrieved ID and the given URI does not match' do + stub_request(:get, 'https://bob/').to_return body: '{"id": "https://alice/"}' + stub_request(:get, 'https://alice/').to_return body: '{"id": "https://alice/"}' + + expect(fetch_resource('https://bob/', false)).to eq({ 'id' => 'https://alice/' }) + end + + it 'returns nil if the object identified by the given URI and the object identified by the retrieved ID does not match' do + stub_request(:get, 'https://mallory/').to_return body: '{"id": "https://marvin/"}' + stub_request(:get, 'https://marvin/').to_return body: '{"id": "https://alice/"}' + + expect(fetch_resource('https://mallory/', false)).to eq nil + end + end + + context 'when the second argument is true' do + it 'returns nil if the retrieved ID and the given URI does not match' do + stub_request(:get, 'https://mallory/').to_return body: '{"id": "https://alice/"}' + expect(fetch_resource('https://mallory/', true)).to eq nil + end + end + end + + describe '#fetch_resource_without_id_validation' do + it 'returns nil if the status code is not 200' do + stub_request(:get, 'https://host/').to_return status: 400, body: '{}' + expect(fetch_resource_without_id_validation('https://host/')).to eq nil + end + + it 'returns hash' do + stub_request(:get, 'https://host/').to_return status: 200, body: '{}' + expect(fetch_resource_without_id_validation('https://host/')).to eq({}) + 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 index ed7e9bba83..c50d3fb971 100644 --- a/spec/services/activitypub/fetch_remote_account_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb @@ -16,7 +16,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do end describe '#call' do - let(:account) { subject.call('https://example.com/alice') } + let(:account) { subject.call('https://example.com/alice', id: true) } shared_examples 'sets profile data' do it 'returns an account' do diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb index 3b22257ed1..ebf4223928 100644 --- a/spec/services/activitypub/fetch_remote_status_service_spec.rb +++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb @@ -15,21 +15,11 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do } 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)) + subject.call(object[:id], prefetched_body: Oj.dump(object)) end context 'with Note object' do @@ -42,34 +32,5 @@ RSpec.describe ActivityPub::FetchRemoteStatusService do 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 291feba6f113588cce4f06206754b31eba60044b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 01:22:33 +0200 Subject: [PATCH 057/137] Follow up to #5172, clean up notifications after mute like after block (#5198) --- app/services/mute_service.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 92f92cc7d3..132369484d 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -3,7 +3,8 @@ class MuteService < BaseService def call(account, target_account) return if account.id == target_account.id - FeedManager.instance.clear_from_timeline(account, target_account) - account.mute!(target_account) + mute = account.mute!(target_account) + BlockWorker.perform_async(account.id, target_account.id) + mute end end From 632178d7543f48f493a63afce0d3c6243aac5fae Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 01:23:52 +0200 Subject: [PATCH 058/137] Use own, shorter relative timestamps (#5171) * Use own, shorter relative timestamps * Add acct to title tooltip of display name in statuses * Improve i18n of the relative times --- .../mastodon/components/relative_timestamp.js | 113 +++++++++++++++++- app/javascript/mastodon/components/status.js | 2 +- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js index 2717d2326d..534d83face 100644 --- a/app/javascript/mastodon/components/relative_timestamp.js +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -1,7 +1,15 @@ import React from 'react'; -import { injectIntl, FormattedRelative } from 'react-intl'; +import { injectIntl, defineMessages } from 'react-intl'; import PropTypes from 'prop-types'; +const messages = defineMessages({ + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, +}); + const dateFormatOptions = { hour12: false, year: 'numeric', @@ -11,6 +19,47 @@ const dateFormatOptions = { minute: '2-digit', }; +const shortDateFormatOptions = { + month: 'numeric', + day: 'numeric', +}; + +const SECOND = 1000; +const MINUTE = 1000 * 60; +const HOUR = 1000 * 60 * 60; +const DAY = 1000 * 60 * 60 * 24; + +const MAX_DELAY = 2147483647; + +const selectUnits = delta => { + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return 'second'; + } else if (absDelta < HOUR) { + return 'minute'; + } else if (absDelta < DAY) { + return 'hour'; + } + + return 'day'; +}; + +const getUnitDelay = units => { + switch (units) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + default: + return MAX_DELAY; + } +}; + @injectIntl export default class RelativeTimestamp extends React.Component { @@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component { timestamp: PropTypes.string.isRequired, }; - shouldComponentUpdate (nextProps) { + state = { + now: this.props.intl.now(), + }; + + shouldComponentUpdate (nextProps, nextState) { // As of right now the locale doesn't change without a new page load, // but we might as well check in case that ever changes. return this.props.timestamp !== nextProps.timestamp || - this.props.intl.locale !== nextProps.intl.locale; + this.props.intl.locale !== nextProps.intl.locale || + this.state.now !== nextState.now; + } + + componentWillReceiveProps (nextProps) { + if (this.props.timestamp !== nextProps.timestamp) { + this.setState({ now: this.props.intl.now() }); + } + } + + componentDidMount () { + this._scheduleNextUpdate(this.props, this.state); + } + + componentWillUpdate (nextProps, nextState) { + this._scheduleNextUpdate(nextProps, nextState); + } + + _scheduleNextUpdate (props, state) { + clearTimeout(this._timer); + + const { timestamp } = props; + const delta = (new Date(timestamp)).getTime() - state.now; + const unitDelay = getUnitDelay(selectUnits(delta)); + const unitRemainder = Math.abs(delta % unitDelay); + const updateInterval = 1000 * 10; + const delay = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder); + + this._timer = setTimeout(() => { + this.setState({ now: this.props.intl.now() }); + }, delay); } render () { const { timestamp, intl } = this.props; - const date = new Date(timestamp); + + const date = new Date(timestamp); + const delta = this.state.now - date.getTime(); + + let relativeTime; + + if (delta < 10 * SECOND) { + relativeTime = intl.formatMessage(messages.just_now); + } else if (delta < 3 * DAY) { + if (delta < MINUTE) { + relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) }); + } else if (delta < HOUR) { + relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) }); + } else if (delta < DAY) { + relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) }); + } else { + relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) }); + } + } else { + relativeTime = intl.formatDate(date, shortDateFormatOptions); + } return ( ); } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 3716d522ea..17482e57ae 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -165,7 +165,7 @@ export default class Status extends ImmutablePureComponent {
    - +
    {statusAvatar}
    From d40c9140e8c02c63b675d9c9a2a44ee20c5a9f31 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Wed, 4 Oct 2017 16:51:42 +0900 Subject: [PATCH 059/137] Fix undefined local variable (regression from #5114) (#5210) --- app/services/fetch_atom_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index bcf516bc3b..7c54714a22 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -43,7 +43,7 @@ class FetchAtomService < BaseService if @response.mime_type == 'application/atom+xml' [@url, { prefetched_body: @response.to_s }, :ostatus] elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) - json = body_to_json(body) + json = body_to_json(@response.to_s) if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? [json['id'], { id: true }, :activitypub] else From 2076c557c907e118dbafc92170fc9fb0cba597df Mon Sep 17 00:00:00 2001 From: aschmitz Date: Wed, 4 Oct 2017 02:52:11 -0500 Subject: [PATCH 060/137] Configure webpack to poll for changes in development (#5040) * Configure webpack to poll for changes in development Vagrant on Linux/macOS hosts shared files via NFS, which doens't support inotify-based watching of files. This tweak makes webpack check for changes every second, and rebuild if necessary. This removes the need to restart Foreman every time a frontend file changes. Note that rebuilding is still a relatively lengthy process. The polling frequency can be changed to taste. * Only poll in Vagrant This tests for the presence of the VAGRANT environment variable to determine whether or not we're in Vagrant. It is set in .env.vagrant, which is set up to be included in the Vagrantfile. --- config/webpack/development.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/config/webpack/development.js b/config/webpack/development.js index 830183c0d9..12670f5cd0 100644 --- a/config/webpack/development.js +++ b/config/webpack/development.js @@ -4,6 +4,17 @@ const merge = require('webpack-merge'); const sharedConfig = require('./shared.js'); const { settings, output } = require('./configuration.js'); +const watchOptions = { + ignored: /node_modules/, +}; + +if (process.env.VAGRANT) { + // If we are in Vagrant, we can't rely on inotify to update us with changed + // files, so we must poll instead. Here, we poll every second to see if + // anything has changed. + watchOptions.poll = 1000; +} + module.exports = merge(sharedConfig, { devtool: 'cheap-module-eval-source-map', @@ -26,8 +37,6 @@ module.exports = merge(sharedConfig, { headers: { 'Access-Control-Allow-Origin': '*' }, historyApiFallback: true, disableHostCheck: true, - watchOptions: { - ignored: /node_modules/, - }, + watchOptions: watchOptions, }, }); From 468523f4ad85f99d78fd341ca4f5fc96f561a533 Mon Sep 17 00:00:00 2001 From: aschmitz Date: Wed, 4 Oct 2017 02:56:37 -0500 Subject: [PATCH 061/137] Non-Serial ("Snowflake") IDs (#4801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use non-serial IDs This change makes a number of nontrivial tweaks to the data model in Mastodon: * All IDs are now 8 byte integers (rather than mixed 4- and 8-byte) * IDs are now assigned as: * Top 6 bytes: millisecond-resolution time from epoch * Bottom 2 bytes: serial (within the millisecond) sequence number * See /lib/tasks/db.rake's `define_timestamp_id` for details, but note that the purpose of these changes is to make it difficult to determine the number of objects in a table from the ID of any object. * The Redis sorted set used for the feed will have values used to look up toots, rather than scores. This is almost always the same as the existing behavior, except in the case of boosted toots. This change was made because Redis stores scores as double-precision floats, which cannot store the new ID format exactly. Note that this doesn't cause problems with sorting/pagination, because ZREVRANGEBYSCORE sorts lexicographically when scores are tied. (This will still cause sorting issues when the ID gains a new significant digit, but that's extraordinarily uncommon.) Note a couple of tradeoffs have been made in this commit: * lib/tasks/db.rake is used to enforce many/most column constraints, because this commit seems likely to take a while to bring upstream. Enforcing a post-migrate hook is an easier way to maintain the code in the interim. * Boosted toots will appear in the timeline as many times as they have been boosted. This is a tradeoff due to the way the feed is saved in Redis at the moment, but will be handled by a future commit. This would effectively close Mastodon's #1059, as it is a snowflake-like system of generating IDs. However, given how involved the changes were simply within Mastodon, it may have unexpected interactions with some clients, if they store IDs as doubles (or as 4-byte integers). This was a problem that Twitter ran into with their "snowflake" transition, particularly in JavaScript clients that treated IDs as JS integers, rather than strings. It therefore would be useful to test these changes at least in the web interface and popular clients before pushing them to all users. * Fix JavaScript interface with long IDs Somewhat predictably, the JS interface handled IDs as numbers, which in JS are IEEE double-precision floats. This loses some precision when working with numbers as large as those generated by the new ID scheme, so we instead handle them here as strings. This is relatively simple, and doesn't appear to have caused any problems, but should definitely be tested more thoroughly than the built-in tests. Several days of use appear to support this working properly. BREAKING CHANGE: The major(!) change here is that IDs are now returned as strings by the REST endpoints, rather than as integers. In practice, relatively few changes were required to make the existing JS UI work with this change, but it will likely hit API clients pretty hard: it's an entirely different type to consume. (The one API client I tested, Tusky, handles this with no problems, however.) Twitter ran into this issue when introducing Snowflake IDs, and decided to instead introduce an `id_str` field in JSON responses. I have opted to *not* do that, and instead force all IDs to 64-bit integers represented by strings in one go. (I believe Twitter exacerbated their problem by rolling out the changes three times: once for statuses, once for DMs, and once for user IDs, as well as by leaving an integer ID value in JSON. As they said, "If you’re using the `id` field with JSON in a Javascript-related language, there is a very high likelihood that the integers will be silently munged by Javascript interpreters. In most cases, this will result in behavior such as being unable to load or delete a specific direct message, because the ID you're sending to the API is different than the actual identifier associated with the message." [1]) However, given that this is a significant change for API users, alternatives or a transition time may be appropriate. 1: https://blog.twitter.com/developer/en_us/a/2011/direct-messages-going-snowflake-on-sep-30-2011.html * Restructure feed pushes/unpushes This was necessary because the previous behavior used Redis zset scores to identify statuses, but those are IEEE double-precision floats, so we can't actually use them to identify all 64-bit IDs. However, it leaves the code in a much better state for refactoring reblog handling / coalescing. Feed-management code has been consolidated in FeedManager, including: * BatchedRemoveStatusService no longer directly manipulates feed zsets * RemoveStatusService no longer directly manipulates feed zsets * PrecomputeFeedService has moved its logic to FeedManager#populate_feed (PrecomputeFeedService largely made lots of calls to FeedManager, but didn't follow the normal adding-to-feed process.) This has the effect of unifying all of the feed push/unpush logic in FeedManager, making it much more tractable to update it in the future. Due to some additional checks that must be made during, for example, batch status removals, some Redis pipelining has been removed. It does not appear that this should cause significantly increased load, but if necessary, some optimizations are possible in batch cases. These were omitted in the pursuit of simplicity, but a batch_push and batch_unpush would be possible in the future. Tests were added to verify that pushes happen under expected conditions, and to verify reblog behavior (both on pushing and unpushing). In the case of unpushing, this includes testing behavior that currently leads to confusion such as Mastodon's #2817, but this codifies that the behavior is currently expected. * Rubocop fixes I could swear I made these changes already, but I must have lost them somewhere along the line. * Address review comments This addresses the first two comments from review of this feature: https://github.com/tootsuite/mastodon/pull/4801#discussion_r139336735 https://github.com/tootsuite/mastodon/pull/4801#discussion_r139336931 This adds an optional argument to FeedManager#key, the subtype of feed key to generate. It also tests to ensure that FeedManager's settings are such that reblogs won't be tracked forever. * Hardcode IdToBigints migration columns This addresses a comment during review: https://github.com/tootsuite/mastodon/pull/4801#discussion_r139337452 This means we'll need to make sure that all _id columns going forward are bigints, but that should happen automatically in most cases. * Additional fixes for stringified IDs in JSON These should be the last two. These were identified using eslint to try to identify any plain casts to JavaScript numbers. (Some such casts are legitimate, but these were not.) Adding the following to .eslintrc.yml will identify casts to numbers: ~~~ no-restricted-syntax: - warn - selector: UnaryExpression[operator='+'] > :not(Literal) message: Avoid the use of unary + - selector: CallExpression[callee.name='Number'] message: Casting with Number() may coerce string IDs to numbers ~~~ The remaining three casts appear legitimate: two casts to array indices, one in a server to turn an environment variable into a number. * Only implement timestamp IDs for Status IDs Per discussion in #4801, this is only being merged in for Status IDs at this point. We do this in a migration, as there is no longer use for a post-migration hook. We keep the initialization of the timestamp_id function as a Rake task, as it is also needed after db:schema:load (as db/schema.rb doesn't store Postgres functions). * Change internal streaming payloads to stringified IDs as well This is equivalent to 591a9af356faf2d5c7e66e3ec715502796c875cd from #5019, with an extra change for the addition to FeedManager#unpush. * Ensure we have a status_id_seq sequence Apparently this is not a given when specifying a custom ID function, so now we ensure it gets created. This uses the generic version of this function to more easily support adding additional tables with timestamp IDs in the future, although it would be possible to cut this down to a less generic version if necessary. It is only run during db:schema:load or the relevant migration, so the overhead is extraordinarily minimal. * Transition reblogs to new Redis format This provides a one-way migration to transition old Redis reblog entries into the new format, with a separate tracking entry for reblogs. It is not invertible because doing so could (if timestamp IDs are used) require a database query for each status in each users' feed, which is likely to be a significant toll on major instances. * Address review comments from @akihikodaki No functional changes. * Additional review changes * Heredoc cleanup * Run db:schema:load hooks for test in development This matches the behavior in Rails' ActiveRecord::Tasks::DatabaseTasks.each_current_configuration, which would otherwise break `rake db:setup` in development. It also moves some functionality out to a library, which will be a good place to put additional related functionality in the near future. --- .../v1/accounts/relationships_controller.rb | 5 +- app/lib/feed_manager.rb | 128 ++++++++++++++---- app/models/feed.rb | 2 +- app/services/batched_remove_status_service.rb | 37 ++--- app/services/precompute_feed_service.rb | 38 +----- app/services/remove_status_service.rb | 8 +- ...70920024819_status_ids_to_timestamp_ids.rb | 32 +++++ .../20170920032311_fix_reblogs_in_feeds.rb | 63 +++++++++ db/schema.rb | 2 +- lib/mastodon/timestamp_ids.rb | 126 +++++++++++++++++ lib/tasks/db.rake | 56 ++++++++ spec/lib/feed_manager_spec.rb | 109 +++++++++++++++ spec/models/feed_spec.rb | 2 +- .../batched_remove_status_service_spec.rb | 3 +- spec/services/precompute_feed_service_spec.rb | 2 +- 15 files changed, 509 insertions(+), 104 deletions(-) create mode 100644 db/migrate/20170920024819_status_ids_to_timestamp_ids.rb create mode 100644 db/migrate/20170920032311_fix_reblogs_in_feeds.rb create mode 100644 lib/mastodon/timestamp_ids.rb diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index a88cf2021a..91a942d753 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController respond_to :json def index - @accounts = Account.where(id: account_ids).select('id') + accounts = Account.where(id: account_ids).select('id') + # .where doesn't guarantee that our results are in the same order + # we requested them, so return the "right" order to the requestor. + @accounts = accounts.index_by(&:id).values_at(*account_ids) render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index b1ae110840..c509c57026 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -7,8 +7,13 @@ class FeedManager MAX_ITEMS = 400 - def key(type, id) - "feed:#{type}:#{id}" + # Must be <= MAX_ITEMS or the tracking sets will grow forever + REBLOG_FALLOFF = 40 + + def key(type, id, subtype = nil) + return "feed:#{type}:#{id}" unless subtype + + "feed:#{type}:#{id}:#{subtype}" end def filter?(timeline_type, status, receiver_id) @@ -22,23 +27,36 @@ class FeedManager end def push(timeline_type, account, status) - timeline_key = key(timeline_type, account.id) + return false unless add_to_feed(timeline_type, account, status) - if status.reblog? - # If the original status is within 40 statuses from top, do not re-insert it into the feed - rank = redis.zrevrank(timeline_key, status.reblog_of_id) - return if !rank.nil? && rank < 40 - redis.zadd(timeline_key, status.id, status.reblog_of_id) - else - redis.zadd(timeline_key, status.id, status.id) - trim(timeline_type, account.id) - end + trim(timeline_type, account.id) PushUpdateWorker.perform_async(account.id, status.id) if push_update_required?(timeline_type, account.id) + + true + end + + def unpush(timeline_type, account, status) + return false unless remove_from_feed(timeline_type, account, status) + + payload = Oj.dump(event: :delete, payload: status.id.to_s) + Redis.current.publish("timeline:#{account.id}", payload) + + true end def trim(type, account_id) - redis.zremrangebyrank(key(type, account_id), '0', (-(FeedManager::MAX_ITEMS + 1)).to_s) + timeline_key = key(type, account_id) + reblog_key = key(type, account_id, 'reblogs') + # Remove any items past the MAX_ITEMS'th entry in our feed + redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s) + + # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop + # tracking anything after it for deduplication purposes. + falloff_rank = FeedManager::REBLOG_FALLOFF - 1 + falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true) + falloff_score = falloff_range&.first&.last&.to_i || 0 + redis.zremrangebyscore(reblog_key, 0, falloff_score) end def push_update_required?(timeline_type, account_id) @@ -54,11 +72,9 @@ class FeedManager query = query.where('id > ?', oldest_home_score) end - redis.pipelined do - query.each do |status| - next if status.direct_visibility? || filter?(:home, status, into_account) - redis.zadd(timeline_key, status.id, status.id) - end + query.each do |status| + next if status.direct_visibility? || filter?(:home, status, into_account) + add_to_feed(:home, into_account, status) end trim(:home, into_account.id) @@ -69,11 +85,8 @@ class FeedManager oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 from_account.statuses.select('id').where('id > ?', oldest_home_score).reorder(nil).find_in_batches do |statuses| - redis.pipelined do - statuses.each do |status| - redis.zrem(timeline_key, status.id) - redis.zremrangebyscore(timeline_key, status.id, status.id) - end + statuses.each do |status| + unpush(:home, into_account, status) end end end @@ -81,9 +94,20 @@ class FeedManager def clear_from_timeline(account, target_account) timeline_key = key(:home, account.id) timeline_status_ids = redis.zrange(timeline_key, 0, -1) - target_status_ids = Status.where(id: timeline_status_ids, account: target_account).ids + target_statuses = Status.where(id: timeline_status_ids, account: target_account) - redis.zrem(timeline_key, target_status_ids) if target_status_ids.present? + target_statuses.each do |status| + unpush(:home, account, status) + end + end + + def populate_feed(account) + prepopulate_limit = FeedManager::MAX_ITEMS / 4 + statuses = Status.as_home_timeline(account).order(account_id: :desc).limit(prepopulate_limit) + statuses.reverse_each do |status| + next if filter_from_home?(status, account) + add_to_feed(:home, account, status) + end end private @@ -131,4 +155,58 @@ class FeedManager should_filter end + + # Adds a status to an account's feed, returning true if a status was + # added, and false if it was not added to the feed. Note that this is + # an internal helper: callers must call trim or push updates if + # either action is appropriate. + def add_to_feed(timeline_type, account, status) + timeline_key = key(timeline_type, account.id) + reblog_key = key(timeline_type, account.id, 'reblogs') + + if status.reblog? + # If the original status or a reblog of it is within + # REBLOG_FALLOFF statuses from the top, do not re-insert it into + # the feed + rank = redis.zrevrank(timeline_key, status.reblog_of_id) + return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF + + reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id) + return false unless reblog_rank.nil? + + redis.zadd(timeline_key, status.id, status.id) + redis.zadd(reblog_key, status.id, status.reblog_of_id) + else + redis.zadd(timeline_key, status.id, status.id) + end + + true + end + + # Removes an individual status from a feed, correctly handling cases + # with reblogs, and returning true if a status was removed. As with + # `add_to_feed`, this does not trigger push updates, so callers must + # do so if appropriate. + def remove_from_feed(timeline_type, account, status) + timeline_key = key(timeline_type, account.id) + reblog_key = key(timeline_type, account.id, 'reblogs') + + if status.reblog? + # 1. If the reblogging status is not in the feed, stop. + status_rank = redis.zrevrank(timeline_key, status.id) + return false if status_rank.nil? + + # 2. Remove the reblogged status from the `:reblogs` zset. + redis.zrem(reblog_key, status.reblog_of_id) + + # 3. Add the reblogged status to the feed using the reblogging + # status' ID as its score, and the reblogged status' ID as its + # value. + redis.zadd(timeline_key, status.id, status.reblog_of_id) + + # 4. Remove the reblogging status from the feed (as normal) + end + + redis.zrem(timeline_key, status.id) + end end diff --git a/app/models/feed.rb b/app/models/feed.rb index beb4a8de3f..5f7b7877a0 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -19,7 +19,7 @@ class Feed def from_redis(limit, max_id, since_id) max_id = '+inf' if max_id.blank? since_id = '-inf' if since_id.blank? - unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i) + unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i) Status.where(id: unhydrated).cache_ids end diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 2fd623922f..5d83771c9d 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -29,7 +29,7 @@ class BatchedRemoveStatusService < BaseService statuses.group_by(&:account_id).each do |_, account_statuses| account = account_statuses.first.account - unpush_from_home_timelines(account_statuses) + unpush_from_home_timelines(account, account_statuses) if account.local? batch_stream_entries(account, account_statuses) @@ -72,14 +72,15 @@ class BatchedRemoveStatusService < BaseService end end - def unpush_from_home_timelines(statuses) - account = statuses.first.account - recipients = account.followers.local.pluck(:id) + def unpush_from_home_timelines(account, statuses) + recipients = account.followers.local.to_a - recipients << account.id if account.local? + recipients << account if account.local? - recipients.each do |follower_id| - unpush(follower_id, statuses) + recipients.each do |follower| + statuses.each do |status| + FeedManager.instance.unpush(:home, follower, status) + end end end @@ -109,28 +110,6 @@ class BatchedRemoveStatusService < BaseService end end - def unpush(follower_id, statuses) - key = FeedManager.instance.key(:home, follower_id) - - originals = statuses.reject(&:reblog?) - reblogs = statuses.select(&:reblog?) - - # Quickly remove all originals - redis.pipelined do - originals.each do |status| - redis.zremrangebyscore(key, status.id, status.id) - redis.publish("timeline:#{follower_id}", @json_payloads[status.id]) - end - end - - # For reblogs, re-add original status to feed, unless the reblog - # was not in the feed in the first place - reblogs.each do |status| - redis.zadd(key, status.reblog_of_id, status.reblog_of_id) unless redis.zscore(key, status.reblog_of_id).nil? - redis.publish("timeline:#{follower_id}", @json_payloads[status.id]) - end - end - def redis Redis.current end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 85635a0082..36aabaa001 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -1,43 +1,7 @@ # frozen_string_literal: true class PrecomputeFeedService < BaseService - LIMIT = FeedManager::MAX_ITEMS / 4 - def call(account) - @account = account - populate_feed - end - - private - - attr_reader :account - - def populate_feed - pairs = statuses.reverse_each.lazy.reject(&method(:status_filtered?)).map(&method(:process_status)).to_a - - redis.pipelined do - redis.zadd(account_home_key, pairs) if pairs.any? - redis.del("account:#{@account.id}:regeneration") - end - end - - def process_status(status) - [status.id, status.reblog? ? status.reblog_of_id : status.id] - end - - def status_filtered?(status) - FeedManager.instance.filter?(:home, status, account.id) - end - - def account_home_key - FeedManager.instance.key(:home, account.id) - end - - def statuses - Status.as_home_timeline(account).order(account_id: :desc).limit(LIMIT) - end - - def redis - Redis.current + FeedManager.instance.populate_feed(account) end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 14f24908c8..96d9208cce 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -102,13 +102,7 @@ class RemoveStatusService < BaseService end def unpush(type, receiver, status) - if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil? - redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id) - else - redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id) - end - - Redis.current.publish("timeline:#{receiver.id}", @payload) + FeedManager.instance.unpush(type, receiver, status) end def remove_from_hashtags diff --git a/db/migrate/20170920024819_status_ids_to_timestamp_ids.rb b/db/migrate/20170920024819_status_ids_to_timestamp_ids.rb new file mode 100644 index 0000000000..5d15817bd8 --- /dev/null +++ b/db/migrate/20170920024819_status_ids_to_timestamp_ids.rb @@ -0,0 +1,32 @@ +class StatusIdsToTimestampIds < ActiveRecord::Migration[5.1] + def up + # Prepare the function we will use to generate IDs. + Rake::Task['db:define_timestamp_id'].execute + + # Set up the statuses.id column to use our timestamp-based IDs. + ActiveRecord::Base.connection.execute(<<~SQL) + ALTER TABLE statuses + ALTER COLUMN id + SET DEFAULT timestamp_id('statuses') + SQL + + # Make sure we have a sequence to use. + Rake::Task['db:ensure_id_sequences_exist'].execute + end + + def down + # Revert the column to the old method of just using the sequence + # value for new IDs. Set the current ID sequence to the maximum + # existing ID, such that the next sequence will be one higher. + + # We lock the table during this so that the ID won't get clobbered, + # but ID is indexed, so this should be a fast operation. + ActiveRecord::Base.connection.execute(<<~SQL) + LOCK statuses; + SELECT setval('statuses_id_seq', (SELECT MAX(id) FROM statuses)); + ALTER TABLE statuses + ALTER COLUMN id + SET DEFAULT nextval('statuses_id_seq');" + SQL + end +end diff --git a/db/migrate/20170920032311_fix_reblogs_in_feeds.rb b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb new file mode 100644 index 0000000000..c813ecd469 --- /dev/null +++ b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb @@ -0,0 +1,63 @@ +class FixReblogsInFeeds < ActiveRecord::Migration[5.1] + def up + redis = Redis.current + fm = FeedManager.instance + + # find_each is batched on the database side. + User.includes(:account).find_each do |user| + account = user.account + + # Old scheme: + # Each user's feed zset had a series of score:value entries, + # where "regular" statuses had the same score and value (their + # ID). Reblogs had a score of the reblogging status' ID, and a + # value of the reblogged status' ID. + + # New scheme: + # The feed contains only entries with the same score and value. + # Reblogs result in the reblogging status being added to the + # feed, with an entry in a reblog tracking zset (where the score + # is once again set to the reblogging status' ID, and the value + # is set to the reblogged status' ID). This is safe for Redis' + # float coersion because in this reblog tracking zset, we only + # need the rebloggging status' ID to be able to stop tracking + # entries after they have gotten too far down the feed, which + # does not require an exact value. + + # So, first, we iterate over the user's feed to find any reblogs. + timeline_key = fm.key(:home, account.id) + reblog_key = fm.key(:home, account.id, 'reblogs') + redis.zrange(timeline_key, 0, -1, with_scores: true).each do |entry| + next if entry[0] == entry[1] + + # The score and value don't match, so this is a reblog. + # (note that we're transitioning from IDs < 53 bits so we + # don't have to worry about the loss of precision) + + reblogged_id, reblogging_id = entry + + # Remove the old entry + redis.zrem(timeline_key, reblogged_id) + + # Add a new one for the reblogging status + redis.zadd(timeline_key, reblogging_id, reblogging_id) + + # Track the fact that this was a reblog + redis.zadd(reblog_key, reblogging_id, reblogged_id) + end + end + end + + def down + # We *deliberately* do nothing here. This means that reverting + # this and the associated changes to the FeedManager code could + # allow one superfluous reblog of any given status, but in the case + # where things have gone wrong and a revert is necessary, this + # appears preferable to requiring a database hit for every status + # in every users' feed simply to revert. + + # Note that this is operating under the assumption that entries + # with >53-bit IDs have already been entered. Otherwise, we could + # just use the data in Redis to reverse this transition. + end +end diff --git a/db/schema.rb b/db/schema.rb index 2cb1055536..00cc24baef 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -321,7 +321,7 @@ ActiveRecord::Schema.define(version: 20170927215609) do t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true end - create_table "statuses", force: :cascade do |t| + create_table "statuses", id: :bigint, default: -> { "timestamp_id('statuses'::text)" }, force: :cascade do |t| t.string "uri" t.text "text", default: "", null: false t.datetime "created_at", null: false diff --git a/lib/mastodon/timestamp_ids.rb b/lib/mastodon/timestamp_ids.rb new file mode 100644 index 0000000000..d49b5c1b5d --- /dev/null +++ b/lib/mastodon/timestamp_ids.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Mastodon + module TimestampIds + def self.define_timestamp_id + conn = ActiveRecord::Base.connection + + # Make sure we don't already have a `timestamp_id` function. + unless conn.execute(<<~SQL).values.first.first + SELECT EXISTS( + SELECT * FROM pg_proc WHERE proname = 'timestamp_id' + ); + SQL + # The function doesn't exist, so we'll define it. + conn.execute(<<~SQL) + CREATE OR REPLACE FUNCTION timestamp_id(table_name text) + RETURNS bigint AS + $$ + DECLARE + time_part bigint; + sequence_base bigint; + tail bigint; + BEGIN + -- Our ID will be composed of the following: + -- 6 bytes (48 bits) of millisecond-level timestamp + -- 2 bytes (16 bits) of sequence data + + -- The 'sequence data' is intended to be unique within a + -- given millisecond, yet obscure the 'serial number' of + -- this row. + + -- To do this, we hash the following data: + -- * Table name (if provided, skipped if not) + -- * Secret salt (should not be guessable) + -- * Timestamp (again, millisecond-level granularity) + + -- We then take the first two bytes of that value, and add + -- the lowest two bytes of the table ID sequence number + -- (`table_name`_id_seq). This means that even if we insert + -- two rows at the same millisecond, they will have + -- distinct 'sequence data' portions. + + -- If this happens, and an attacker can see both such IDs, + -- they can determine which of the two entries was inserted + -- first, but not the total number of entries in the table + -- (even mod 2**16). + + -- The table name is included in the hash to ensure that + -- different tables derive separate sequence bases so rows + -- inserted in the same millisecond in different tables do + -- not reveal the table ID sequence number for one another. + + -- The secret salt is included in the hash to ensure that + -- external users cannot derive the sequence base given the + -- timestamp and table name, which would allow them to + -- compute the table ID sequence number. + + time_part := ( + -- Get the time in milliseconds + ((date_part('epoch', now()) * 1000))::bigint + -- And shift it over two bytes + << 16); + + sequence_base := ( + 'x' || + -- Take the first two bytes (four hex characters) + substr( + -- Of the MD5 hash of the data we documented + md5(table_name || + '#{SecureRandom.hex(16)}' || + time_part::text + ), + 1, 4 + ) + -- And turn it into a bigint + )::bit(16)::bigint; + + -- Finally, add our sequence number to our base, and chop + -- it to the last two bytes + tail := ( + (sequence_base + nextval(table_name || '_id_seq')) + & 65535); + + -- Return the time part and the sequence part. OR appears + -- faster here than addition, but they're equivalent: + -- time_part has no trailing two bytes, and tail is only + -- the last two bytes. + RETURN time_part | tail; + END + $$ LANGUAGE plpgsql VOLATILE; + SQL + end + end + + def self.ensure_id_sequences_exist + conn = ActiveRecord::Base.connection + + # Find tables using timestamp IDs. + default_regex = /timestamp_id\('(?\w+)'/ + conn.tables.each do |table| + # We're only concerned with "id" columns. + next unless (id_col = conn.columns(table).find { |col| col.name == 'id' }) + + # And only those that are using timestamp_id. + next unless (data = default_regex.match(id_col.default_function)) + + seq_name = data[:seq_prefix] + '_id_seq' + # If we were on Postgres 9.5+, we could do CREATE SEQUENCE IF + # NOT EXISTS, but we can't depend on that. Instead, catch the + # possible exception and ignore it. + # Note that seq_name isn't a column name, but it's a + # relation, like a column, and follows the same quoting rules + # in Postgres. + conn.execute(<<~SQL) + DO $$ + BEGIN + CREATE SEQUENCE #{conn.quote_column_name(seq_name)}; + EXCEPTION WHEN duplicate_table THEN + -- Do nothing, we have the sequence already. + END + $$ LANGUAGE plpgsql; + SQL + end + end + end +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 7a055bf256..66468d9998 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -1,5 +1,36 @@ # frozen_string_literal: true +require Rails.root.join('lib', 'mastodon', 'timestamp_ids') + +def each_schema_load_environment + # If we're in development, also run this for the test environment. + # This is a somewhat hacky way to do this, so here's why: + # 1. We have to define this before we load the schema, or we won't + # have a timestamp_id function when we get to it in the schema. + # 2. db:setup calls db:schema:load_if_ruby, which calls + # db:schema:load, which we define above as having a prerequisite + # of this task. + # 3. db:schema:load ends up running + # ActiveRecord::Tasks::DatabaseTasks.load_schema_current, which + # calls a private method `each_current_configuration`, which + # explicitly also does the loading for the `test` environment + # if the current environment is `development`, so we end up + # needing to do the same, and we can't even use the same method + # to do it. + + if Rails.env == 'development' + test_conf = ActiveRecord::Base.configurations['test'] + if test_conf['database']&.present? + ActiveRecord::Base.establish_connection(:test) + yield + + ActiveRecord::Base.establish_connection(Rails.env.to_sym) + end + end + + yield +end + namespace :db do namespace :migrate do desc 'Setup the db or migrate depending on state of db' @@ -16,4 +47,29 @@ namespace :db do end end end + + # Before we load the schema, define the timestamp_id function. + # Idiomatically, we might do this in a migration, but then it + # wouldn't end up in schema.rb, so we'd need to figure out a way to + # get it in before doing db:setup as well. This is simpler, and + # ensures it's always in place. + Rake::Task['db:schema:load'].enhance ['db:define_timestamp_id'] + + # After we load the schema, make sure we have sequences for each + # table using timestamp IDs. + Rake::Task['db:schema:load'].enhance do + Rake::Task['db:ensure_id_sequences_exist'].invoke + end + + task :define_timestamp_id do + each_schema_load_environment do + Mastodon::TimestampIds.define_timestamp_id + end + end + + task :ensure_id_sequences_exist do + each_schema_load_environment do + Mastodon::TimestampIds.ensure_id_sequences_exist + end + end end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 22439cf35d..923894ccb3 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -1,6 +1,10 @@ require 'rails_helper' RSpec.describe FeedManager do + it 'tracks at least as many statuses as reblogs' do + expect(FeedManager::REBLOG_FALLOFF).to be <= FeedManager::MAX_ITEMS + end + describe '#key' do subject { FeedManager.instance.key(:home, 1) } @@ -150,5 +154,110 @@ RSpec.describe FeedManager do expect(Redis.current.zcard("feed:type:#{account.id}")).to eq FeedManager::MAX_ITEMS end + + it 'sends push updates for non-home timelines' do + account = Fabricate(:account) + status = Fabricate(:status) + allow(Redis.current).to receive_messages(publish: nil) + + FeedManager.instance.push('type', account, status) + + expect(Redis.current).to have_received(:publish).with("timeline:#{account.id}", any_args).at_least(:once) + end + + context 'reblogs' do + it 'saves reblogs of unseen statuses' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + reblog = Fabricate(:status, reblog: reblogged) + + expect(FeedManager.instance.push('type', account, reblog)).to be true + end + + it 'does not save a new reblog of a recent status' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + reblog = Fabricate(:status, reblog: reblogged) + + FeedManager.instance.push('type', account, reblogged) + + expect(FeedManager.instance.push('type', account, reblog)).to be false + end + + it 'saves a new reblog of an old status' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + reblog = Fabricate(:status, reblog: reblogged) + + FeedManager.instance.push('type', account, reblogged) + + # Fill the feed with intervening statuses + FeedManager::REBLOG_FALLOFF.times do + FeedManager.instance.push('type', account, Fabricate(:status)) + end + + expect(FeedManager.instance.push('type', account, reblog)).to be true + end + + it 'does not save a new reblog of a recently-reblogged status' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) } + + # The first reblog will be accepted + FeedManager.instance.push('type', account, reblogs.first) + + # The second reblog should be ignored + expect(FeedManager.instance.push('type', account, reblogs.last)).to be false + end + + it 'saves a new reblog of a long-ago-reblogged status' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) } + + # The first reblog will be accepted + FeedManager.instance.push('type', account, reblogs.first) + + # Fill the feed with intervening statuses + FeedManager::REBLOG_FALLOFF.times do + FeedManager.instance.push('type', account, Fabricate(:status)) + end + + # The second reblog should also be accepted + expect(FeedManager.instance.push('type', account, reblogs.last)).to be true + end + end + end + + describe '#unpush' do + it 'leaves a reblogged status when deleting the reblog' do + account = Fabricate(:account) + reblogged = Fabricate(:status) + status = Fabricate(:status, reblog: reblogged) + + FeedManager.instance.push('type', account, status) + + # The reblogging status should show up under normal conditions. + expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [status.id.to_s] + + FeedManager.instance.unpush('type', account, status) + + # Because we couldn't tell if the status showed up any other way, + # we had to stick the reblogged status in by itself. + expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [reblogged.id.to_s] + end + + it 'sends push updates' do + account = Fabricate(:account) + status = Fabricate(:status) + FeedManager.instance.push('type', account, status) + + allow(Redis.current).to receive_messages(publish: nil) + FeedManager.instance.unpush('type', account, status) + + deletion = Oj.dump(event: :delete, payload: status.id.to_s) + expect(Redis.current).to have_received(:publish).with("timeline:#{account.id}", deletion) + end end end diff --git a/spec/models/feed_spec.rb b/spec/models/feed_spec.rb index 1c377c17f3..5433f44bd8 100644 --- a/spec/models/feed_spec.rb +++ b/spec/models/feed_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Feed, type: :model do Fabricate(:status, account: account, id: 3) Fabricate(:status, account: account, id: 10) Redis.current.zadd(FeedManager.instance.key(:home, account.id), - [[4, 'deleted'], [3, 'val3'], [2, 'val2'], [1, 'val1']]) + [[4, 4], [3, 3], [2, 2], [1, 1]]) feed = Feed.new(:home, account) results = feed.get(3) diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index f5c9adfb54..c82c45e09b 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -5,7 +5,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!(:jeff) { Fabricate(:user).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') } @@ -19,6 +19,7 @@ RSpec.describe BatchedRemoveStatusService do 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.user.update(current_sign_in_at: Time.now) jeff.follow!(alice) hank.follow!(alice) diff --git a/spec/services/precompute_feed_service_spec.rb b/spec/services/precompute_feed_service_spec.rb index dbd08ac1b0..d1ef6c1843 100644 --- a/spec/services/precompute_feed_service_spec.rb +++ b/spec/services/precompute_feed_service_spec.rb @@ -16,7 +16,7 @@ RSpec.describe PrecomputeFeedService do subject.call(account) - expect(Redis.current.zscore(FeedManager.instance.key(:home, account.id), reblog.id)).to eq status.id + expect(Redis.current.zscore(FeedManager.instance.key(:home, account.id), reblog.id)).to eq status.id.to_f end it 'does not raise an error even if it could not find any status' do From 0e1b0f2747af373e3d51251337f40bfff13ef160 Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 4 Oct 2017 09:59:28 +0200 Subject: [PATCH 062/137] Check Webfinger-returned author URI even when not redirected (#5213) The whole point of verified_webfinger? is to check the WebFinger-discoverable URI maps back to the known author URI. This was not actually verified if the first Webfinger request was not a redirection. --- app/services/activitypub/fetch_remote_account_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index e6c6338be5..d6ba625a9a 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -31,7 +31,7 @@ class ActivityPub::FetchRemoteAccountService < BaseService 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? + return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}") @username, @domain = split_acct(webfinger.subject) From 178f718a9b1cab57fbd9df511abe56533f12e129 Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Wed, 4 Oct 2017 17:22:52 +0900 Subject: [PATCH 063/137] Separate notifications preferences from general preferences (#4447) * Separate notifications preferences from general preferences * Refine settings/notifications/show * remove preferences.notifications --- .../settings/notifications_controller.rb | 32 ++++++++++++++++ app/lib/user_settings_decorator.rb | 26 +++++++------ .../settings/notifications/show.html.haml | 25 +++++++++++++ app/views/settings/preferences/show.html.haml | 19 ---------- config/locales/de.yml | 2 +- config/locales/en.yml | 2 +- config/locales/ja.yml | 2 +- config/locales/ko.yml | 2 +- config/locales/oc.yml | 2 +- config/locales/pl.yml | 2 +- config/navigation.rb | 1 + config/routes.rb | 1 + .../settings/notifications_controller_spec.rb | 37 +++++++++++++++++++ .../settings/preferences_controller_spec.rb | 6 --- 14 files changed, 117 insertions(+), 42 deletions(-) create mode 100644 app/controllers/settings/notifications_controller.rb create mode 100644 app/views/settings/notifications/show.html.haml create mode 100644 spec/controllers/settings/notifications_controller_spec.rb diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb new file mode 100644 index 0000000000..09839f16ea --- /dev/null +++ b/app/controllers/settings/notifications_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Settings::NotificationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def show; end + + def update + user_settings.update(user_settings_params.to_h) + + if current_user.save + redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + private + + def user_settings + UserSettingsDecorator.new(current_user) + end + + def user_settings_params + params.require(:user).permit( + notification_emails: %i(follow follow_request reblog favourite mention digest), + interactions: %i(must_be_follower must_be_following) + ) + end +end diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index cb1b3c4a92..1053ec488b 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -15,17 +15,17 @@ class UserSettingsDecorator private def process_update - user.settings['notification_emails'] = merged_notification_emails - user.settings['interactions'] = merged_interactions - user.settings['default_privacy'] = default_privacy_preference - user.settings['default_sensitive'] = default_sensitive_preference - user.settings['unfollow_modal'] = unfollow_modal_preference - user.settings['boost_modal'] = boost_modal_preference - user.settings['delete_modal'] = delete_modal_preference - user.settings['auto_play_gif'] = auto_play_gif_preference - user.settings['system_font_ui'] = system_font_ui_preference - user.settings['noindex'] = noindex_preference - user.settings['theme'] = theme_preference + user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails') + user.settings['interactions'] = merged_interactions if change?('interactions') + user.settings['default_privacy'] = default_privacy_preference if change?('setting_default_privacy') + user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') + user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') + user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') + user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') + user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') + user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') + user.settings['noindex'] = noindex_preference if change?('setting_noindex') + user.settings['theme'] = theme_preference if change?('theme') end def merged_notification_emails @@ -83,4 +83,8 @@ class UserSettingsDecorator def coerce_values(params_hash) params_hash.transform_values { |x| x == '1' } end + + def change?(key) + !settings[key].nil? + end end diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml new file mode 100644 index 0000000000..80cd615c7e --- /dev/null +++ b/app/views/settings/notifications/show.html.haml @@ -0,0 +1,25 @@ +- content_for :page_title do + = t('settings.notifications') + += simple_form_for current_user, url: settings_notifications_path, html: { method: :put } do |f| + = render 'shared/error_messages', object: current_user + + .fields-group + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| + = ff.input :follow, as: :boolean, wrapper: :with_label + = ff.input :follow_request, as: :boolean, wrapper: :with_label + = ff.input :reblog, as: :boolean, wrapper: :with_label + = ff.input :favourite, as: :boolean, wrapper: :with_label + = ff.input :mention, as: :boolean, wrapper: :with_label + + .fields-group + = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| + = ff.input :digest, as: :boolean, wrapper: :with_label + + .fields-group + = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| + = ff.input :must_be_follower, as: :boolean, wrapper: :with_label + = ff.input :must_be_following, as: :boolean, wrapper: :with_label + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index ffb1bbf6ac..7475e3fd26 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -18,25 +18,6 @@ = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label - %h4= t 'preferences.notifications' - - .fields-group - = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| - = ff.input :follow, as: :boolean, wrapper: :with_label - = ff.input :follow_request, as: :boolean, wrapper: :with_label - = ff.input :reblog, as: :boolean, wrapper: :with_label - = ff.input :favourite, as: :boolean, wrapper: :with_label - = ff.input :mention, as: :boolean, wrapper: :with_label - - .fields-group - = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| - = ff.input :digest, as: :boolean, wrapper: :with_label - - .fields-group - = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff| - = ff.input :must_be_follower, as: :boolean, wrapper: :with_label - = ff.input :must_be_following, as: :boolean, wrapper: :with_label - %h4= t 'preferences.other' .fields-group diff --git a/config/locales/de.yml b/config/locales/de.yml index dce86409b1..d4a925d231 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -319,7 +319,6 @@ de: truncate: "…" preferences: languages: Sprachen - notifications: Benachrichtigungen other: Weiteres publishing: Beiträge web: Web @@ -390,6 +389,7 @@ de: export: Datenexport followers: Autorisierte Folgende import: Datenimport + notifications: Benachrichtigungen preferences: Einstellungen settings: Einstellungen two_factor_authentication: Zwei-Faktor-Authentisierung diff --git a/config/locales/en.yml b/config/locales/en.yml index 3049e0365b..4a6df8cb28 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -395,7 +395,6 @@ en: truncate: "…" preferences: languages: Languages - notifications: Notifications other: Other publishing: Publishing web: Web @@ -466,6 +465,7 @@ en: export: Data export followers: Authorized followers import: Import + notifications: Notifications preferences: Preferences settings: Settings two_factor_authentication: Two-factor Authentication diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 78465e1210..d637a99ea8 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -395,7 +395,6 @@ ja: truncate: "…" preferences: languages: 言語 - notifications: 通知 other: その他 publishing: 投稿 web: ウェブ @@ -466,6 +465,7 @@ ja: export: データのエクスポート followers: 信頼済みのインスタンス import: データのインポート + notifications: 通知 preferences: ユーザー設定 settings: 設定 two_factor_authentication: 二段階認証 diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 3a7636dbb7..73f3f3a371 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -393,7 +393,6 @@ ko: truncate: "…" preferences: languages: 언어 - notifications: 알림 other: 기타 publishing: 퍼블리싱 web: 웹 @@ -464,6 +463,7 @@ ko: export: 데이터 내보내기 followers: 신뢰 중인 인스턴스 import: 데이터 가져오기 + notifications: 알림 preferences: 사용자 설정 settings: 설정 two_factor_authentication: 2단계 인증 diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 0b53b6b2d6..1f25525a07 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -473,7 +473,6 @@ oc: truncate: "…" preferences: languages: Lengas - notifications: Notificacions other: Autre publishing: Publicar web: Interfàcia Web @@ -544,6 +543,7 @@ oc: export: Export donadas followers: Seguidors autorizats import: Importar + notifications: Notificacions preferences: Preferéncias settings: Paramètres two_factor_authentication: Autentificacion en dos temps diff --git a/config/locales/pl.yml b/config/locales/pl.yml index d49ecfbe69..26a8a9c693 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -396,7 +396,6 @@ pl: truncate: "…" preferences: languages: Języki - notifications: Powiadomienia other: Pozostałe publishing: Publikowanie web: Sieć @@ -467,6 +466,7 @@ pl: export: Eksportowanie danych followers: Autoryzowani śledzący import: Importowanie danych + notifications: Powiadomienia preferences: Preferencje settings: Ustawienia two_factor_authentication: Uwierzytelnianie dwuetapowe diff --git a/config/navigation.rb b/config/navigation.rb index 0a6ab6d3db..215d843b91 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation| primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings| settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url + settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete} settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication} settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url diff --git a/config/routes.rb b/config/routes.rb index de3c1e0f9c..8e80e15103 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,7 @@ Rails.application.routes.draw do namespace :settings do resource :profile, only: [:show, :update] resource :preferences, only: [:show, :update] + resource :notifications, only: [:show, :update] resource :import, only: [:show, :create] resource :export, only: [:show] diff --git a/spec/controllers/settings/notifications_controller_spec.rb b/spec/controllers/settings/notifications_controller_spec.rb new file mode 100644 index 0000000000..0bd9934486 --- /dev/null +++ b/spec/controllers/settings/notifications_controller_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +describe Settings::NotificationsController do + render_views + + let(:user) { Fabricate(:user) } + + before do + sign_in user, scope: :user + end + + describe 'GET #show' do + it 'returns http success' do + get :show + expect(response).to have_http_status(:success) + end + end + + describe 'PUT #update' do + it 'updates notifications settings' do + user.settings['notification_emails'] = user.settings['notification_emails'].merge('follow' => false) + user.settings['interactions'] = user.settings['interactions'].merge('must_be_follower' => true) + + put :update, params: { + user: { + notification_emails: { follow: '1' }, + interactions: { must_be_follower: '0' }, + } + } + + expect(response).to redirect_to(settings_notifications_path) + user.reload + expect(user.settings['notification_emails']['follow']).to be true + expect(user.settings['interactions']['must_be_follower']).to be false + end + end +end diff --git a/spec/controllers/settings/preferences_controller_spec.rb b/spec/controllers/settings/preferences_controller_spec.rb index 60fa423023..0f94316737 100644 --- a/spec/controllers/settings/preferences_controller_spec.rb +++ b/spec/controllers/settings/preferences_controller_spec.rb @@ -29,15 +29,11 @@ describe Settings::PreferencesController do it 'updates user settings' do user.settings['boost_modal'] = false user.settings['delete_modal'] = true - user.settings['notification_emails'] = user.settings['notification_emails'].merge('follow' => false) - user.settings['interactions'] = user.settings['interactions'].merge('must_be_follower' => true) put :update, params: { user: { setting_boost_modal: '1', setting_delete_modal: '0', - notification_emails: { follow: '1' }, - interactions: { must_be_follower: '0' }, } } @@ -45,8 +41,6 @@ describe Settings::PreferencesController do user.reload expect(user.settings['boost_modal']).to be true expect(user.settings['delete_modal']).to be false - expect(user.settings['notification_emails']['follow']).to be true - expect(user.settings['interactions']['must_be_follower']).to be false end end end From d5091387c6ddbe03b118b0cfb6d74cf821b84fb2 Mon Sep 17 00:00:00 2001 From: Ryo Kajiwara Date: Wed, 4 Oct 2017 20:25:24 +0900 Subject: [PATCH 064/137] Supply @instance variable in password reset instructions / password change mailer (#5215) --- app/mailers/user_mailer.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 1517c027e8..c475a9911b 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -18,6 +18,7 @@ class UserMailer < Devise::Mailer def reset_password_instructions(user, token, _opts = {}) @resource = user @token = token + @instance = Rails.configuration.x.local_domain I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') @@ -26,6 +27,7 @@ class UserMailer < Devise::Mailer def password_change(user, _opts = {}) @resource = user + @instance = Rails.configuration.x.local_domain I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') From b3af3f9f8cd5ed9c7ee06452e981b1b7734e1d89 Mon Sep 17 00:00:00 2001 From: utam0k Date: Wed, 4 Oct 2017 22:16:10 +0900 Subject: [PATCH 065/137] Implement EmailBlackList (#5109) * Implement BlacklistedEmailDomain * Use Faker::Internet.domain_name * Remove note column * Add frozen_string_literal comment * Delete unnecessary codes * Sort alphabetically * Change of wording * Rename BlacklistedEmailDomain to EmailDomainBlock --- .../admin/email_domain_blocks_controller.rb | 40 +++++++++++++ app/models/email_domain_block.rb | 17 ++++++ app/validators/blacklisted_email_validator.rb | 1 + .../_email_domain_block.html.haml | 5 ++ .../admin/email_domain_blocks/index.html.haml | 13 ++++ .../admin/email_domain_blocks/new.html.haml | 10 ++++ config/locales/en.yml | 10 ++++ config/locales/ja.yml | 10 ++++ config/navigation.rb | 1 + config/routes.rb | 1 + ...170928082043_create_email_domain_blocks.rb | 9 +++ db/schema.rb | 8 ++- .../email_domain_blocks_controller_spec.rb | 59 +++++++++++++++++++ .../email_domain_block_fabricator.rb | 3 + spec/models/email_domain_block_spec.rb | 21 +++++++ 15 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 app/controllers/admin/email_domain_blocks_controller.rb create mode 100644 app/models/email_domain_block.rb create mode 100644 app/views/admin/email_domain_blocks/_email_domain_block.html.haml create mode 100644 app/views/admin/email_domain_blocks/index.html.haml create mode 100644 app/views/admin/email_domain_blocks/new.html.haml create mode 100644 db/migrate/20170928082043_create_email_domain_blocks.rb create mode 100644 spec/controllers/admin/email_domain_blocks_controller_spec.rb create mode 100644 spec/fabricators/email_domain_block_fabricator.rb create mode 100644 spec/models/email_domain_block_spec.rb diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb new file mode 100644 index 0000000000..09275d5dc8 --- /dev/null +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Admin + class EmailDomainBlocksController < BaseController + before_action :set_email_domain_block, only: [:show, :destroy] + + def index + @email_domain_blocks = EmailDomainBlock.page(params[:page]) + end + + def new + @email_domain_block = EmailDomainBlock.new + end + + def create + @email_domain_block = EmailDomainBlock.new(resource_params) + + if @email_domain_block.save + redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') + else + render :new + end + end + + def destroy + @email_domain_block.destroy + redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg') + end + + private + + def set_email_domain_block + @email_domain_block = EmailDomainBlock.find(params[:id]) + end + + def resource_params + params.require(:email_domain_block).permit(:domain) + end + end +end diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb new file mode 100644 index 0000000000..839038bea6 --- /dev/null +++ b/app/models/email_domain_block.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: email_domain_blocks +# +# id :integer not null, primary key +# domain :string not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class EmailDomainBlock < ApplicationRecord + def self.block?(email) + domain = email.gsub(/.+@([^.]+)/, '\1') + where(domain: domain).exists? + end +end diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb index 0ba79694b3..3f203f49a6 100644 --- a/app/validators/blacklisted_email_validator.rb +++ b/app/validators/blacklisted_email_validator.rb @@ -12,6 +12,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator end def on_blacklist?(value) + return true if EmailDomainBlock.block?(value) return false if Rails.configuration.x.email_domains_blacklist.blank? domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.') diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml new file mode 100644 index 0000000000..61cff93955 --- /dev/null +++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml @@ -0,0 +1,5 @@ +%tr + %td.domain + %samp= email_domain_block.domain + %td + = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml new file mode 100644 index 0000000000..fbdb3b80bd --- /dev/null +++ b/app/views/admin/email_domain_blocks/index.html.haml @@ -0,0 +1,13 @@ +- content_for :page_title do + = t('admin.email_domain_blocks.title') + +%table.table + %thead + %tr + %th= t('admin.email_domain_blocks.domain') + %th + %tbody + = render @email_domain_blocks + += paginate @email_domain_blocks += link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button' diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml new file mode 100644 index 0000000000..bcae867d95 --- /dev/null +++ b/app/views/admin/email_domain_blocks/new.html.haml @@ -0,0 +1,10 @@ +- content_for :page_title do + = t('.title') + += simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f| + = render 'shared/error_messages', object: @email_domain_block + + = f.input :domain, placeholder: t('admin.email_domain_blocks.domain') + + .actions + = f.button :button, t('.create'), type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a6df8cb28..5d9557535c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -152,6 +152,16 @@ en: undo: Undo title: Domain Blocks undo: Undo + email_domain_blocks: + add_new: Add new + created_msg: Email domain block successfully created + delete: Delete + destroyed_msg: Email domain block successfully deleted + domain: Domain + new: + create: Create block + title: New email domain block + title: Email Domain Block instances: account_count: Known accounts domain_name: Domain diff --git a/config/locales/ja.yml b/config/locales/ja.yml index d637a99ea8..3d6f2fd0bc 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -152,6 +152,16 @@ ja: undo: 元に戻す title: ドメインブロック undo: 元に戻す + email_domain_blocks: + add_new: 新規追加 + created_msg: 処理を完了しました + delete: 消去 + destroyed_msg: 消去しました + domain: ドメイン + new: + create: ブロックを作成 + title: 新規メールドメインブロック + title: メールドメインブロック instances: account_count: 既知のアカウント数 domain_name: ドメイン名 diff --git a/config/navigation.rb b/config/navigation.rb index 215d843b91..50bfbd4801 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -26,6 +26,7 @@ SimpleNavigation::Configuration.run do |navigation| admin.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, highlights_on: %r{/admin/instances} admin.item :subscriptions, safe_join([fa_icon('paper-plane-o fw'), t('admin.subscriptions.title')]), admin_subscriptions_url admin.item :domain_blocks, safe_join([fa_icon('lock fw'), t('admin.domain_blocks.title')]), admin_domain_blocks_url, highlights_on: %r{/admin/domain_blocks} + admin.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks} admin.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' } admin.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' } admin.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url diff --git a/config/routes.rb b/config/routes.rb index 8e80e15103..959afc23f8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -108,6 +108,7 @@ Rails.application.routes.draw do namespace :admin do resources :subscriptions, only: [:index] resources :domain_blocks, only: [:index, :new, :create, :show, :destroy] + resources :email_domain_blocks, only: [:index, :new, :create, :destroy] resource :settings, only: [:edit, :update] resources :instances, only: [:index] do diff --git a/db/migrate/20170928082043_create_email_domain_blocks.rb b/db/migrate/20170928082043_create_email_domain_blocks.rb new file mode 100644 index 0000000000..1f0fb75875 --- /dev/null +++ b/db/migrate/20170928082043_create_email_domain_blocks.rb @@ -0,0 +1,9 @@ +class CreateEmailDomainBlocks < ActiveRecord::Migration[5.1] + def change + create_table :email_domain_blocks do |t| + t.string :domain, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 00cc24baef..337678c67f 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: 20170927215609) do +ActiveRecord::Schema.define(version: 20170928082043) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -110,6 +110,12 @@ ActiveRecord::Schema.define(version: 20170927215609) do t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true end + create_table "email_domain_blocks", force: :cascade do |t| + t.string "domain", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "favourites", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/spec/controllers/admin/email_domain_blocks_controller_spec.rb b/spec/controllers/admin/email_domain_blocks_controller_spec.rb new file mode 100644 index 0000000000..295de9073a --- /dev/null +++ b/spec/controllers/admin/email_domain_blocks_controller_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Admin::EmailDomainBlocksController, type: :controller do + render_views + + before do + sign_in Fabricate(:user, admin: true), scope: :user + end + + describe 'GET #index' do + around do |example| + default_per_page = EmailDomainBlock.default_per_page + EmailDomainBlock.paginates_per 1 + example.run + EmailDomainBlock.paginates_per default_per_page + end + + it 'renders email blacks' do + 2.times { Fabricate(:email_domain_block) } + + get :index, params: { page: 2 } + + assigned = assigns(:email_domain_blocks) + expect(assigned.count).to eq 1 + expect(assigned.klass).to be EmailDomainBlock + expect(response).to have_http_status(:success) + end + end + + describe 'GET #new' do + it 'assigns a new email black' do + get :new + + expect(assigns(:email_domain_block)).to be_instance_of(EmailDomainBlock) + expect(response).to have_http_status(:success) + end + end + + describe 'POST #create' do + it 'blocks the domain when succeeded to save' do + post :create, params: { email_domain_block: { domain: 'example.com'} } + + expect(flash[:notice]).to eq I18n.t('admin.email_domain_blocks.created_msg') + expect(response).to redirect_to(admin_email_domain_blocks_path) + end + end + + describe 'DELETE #destroy' do + it 'unblocks the domain' do + email_domain_block = Fabricate(:email_domain_block) + delete :destroy, params: { id: email_domain_block.id } + + expect(flash[:notice]).to eq I18n.t('admin.email_domain_blocks.destroyed_msg') + expect(response).to redirect_to(admin_email_domain_blocks_path) + end + end +end diff --git a/spec/fabricators/email_domain_block_fabricator.rb b/spec/fabricators/email_domain_block_fabricator.rb new file mode 100644 index 0000000000..d18af6433c --- /dev/null +++ b/spec/fabricators/email_domain_block_fabricator.rb @@ -0,0 +1,3 @@ +Fabricator(:email_domain_block) do + domain { sequence(:domain) { |i| "#{i}#{Faker::Internet.domain_name}" } } +end diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb new file mode 100644 index 0000000000..5f5d189d9d --- /dev/null +++ b/spec/models/email_domain_block_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe EmailDomainBlock, type: :model do + describe 'validations' do + it 'has a valid fabricator' do + email_domain_block = Fabricate.build(:email_domain_block) + expect(email_domain_block).to be_valid + end + end + + describe 'block?' do + it 'returns true if the domain is registed' do + Fabricate(:email_domain_block, domain: 'example.com') + expect(EmailDomainBlock.block?('nyarn@example.com')).to eq true + end + it 'returns true if the domain is not registed' do + Fabricate(:email_domain_block, domain: 'domain') + expect(EmailDomainBlock.block?('example')).to eq false + end + end +end From ecfa1c3f3bbe02fa619ac000da51eccd3acbdc8a Mon Sep 17 00:00:00 2001 From: MitarashiDango Date: Wed, 4 Oct 2017 23:28:39 +0900 Subject: [PATCH 066/137] fix error (When part of conversation has already been deleted.) (#5216) --- app/javascript/mastodon/actions/timelines.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index cdaafd89c6..09abe2702a 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -37,7 +37,7 @@ export function updateTimeline(timeline, status) { if (status.in_reply_to_id) { let parent = getState().getIn(['statuses', status.in_reply_to_id]); - while (parent.get('in_reply_to_id')) { + while (parent && parent.get('in_reply_to_id')) { parents.push(parent.get('id')); parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]); } From 43577e9f5966df5029167f8450afca5de782cebb Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Thu, 5 Oct 2017 01:16:30 +0900 Subject: [PATCH 067/137] Fix style of email domain block table (#5218) --- .../admin/email_domain_blocks/index.html.haml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml index fbdb3b80bd..7bb204e529 100644 --- a/app/views/admin/email_domain_blocks/index.html.haml +++ b/app/views/admin/email_domain_blocks/index.html.haml @@ -1,13 +1,14 @@ - content_for :page_title do = t('admin.email_domain_blocks.title') -%table.table - %thead - %tr - %th= t('admin.email_domain_blocks.domain') - %th - %tbody - = render @email_domain_blocks +.table-wrapper + %table.table + %thead + %tr + %th= t('admin.email_domain_blocks.domain') + %th + %tbody + = render @email_domain_blocks = paginate @email_domain_blocks = link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button' From b406e3cc4cfc1b51a276d9e0e8a9910f1ca529e2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 4 Oct 2017 19:06:23 +0200 Subject: [PATCH 068/137] Fix #5050 - Use summary_large_image only with media attachments (#5219) --- app/views/stream_entries/_og_image.html.haml | 2 ++ app/views/stream_entries/show.html.haml | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml index b5058583b3..1056c17444 100644 --- a/app/views/stream_entries/_og_image.html.haml +++ b/app/views/stream_entries/_og_image.html.haml @@ -17,7 +17,9 @@ - unless media.file.meta.nil? = opengraph 'og:video:width', media.file.meta['small']['width'] = opengraph 'og:video:height', media.file.meta['small']['height'] + = opengraph 'twitter:card', 'summary_large_image' - else = opengraph 'og:image', full_asset_url(account.avatar.url(:original)) = opengraph 'og:image:width', '120' = opengraph 'og:image:height','120' + = opengraph 'twitter:card', 'summary' diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index 1bb8a32b25..4280699313 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -14,8 +14,6 @@ = render 'stream_entries/og_description', activity: @stream_entry.activity = render 'stream_entries/og_image', activity: @stream_entry.activity, account: @account - = opengraph 'twitter:card', 'summary_large_image' - - if show_landing_strip? = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account } From 636acb57121e8f77eafea5b5607343729574d7ec Mon Sep 17 00:00:00 2001 From: Jakob Kramer <811907+gandaro@users.noreply.github.com> Date: Wed, 4 Oct 2017 20:03:41 +0200 Subject: [PATCH 069/137] Update German translation (#5221) --- config/locales/de.yml | 156 +++++++++++++++++++++++++++++++++--------- 1 file changed, 123 insertions(+), 33 deletions(-) diff --git a/config/locales/de.yml b/config/locales/de.yml index d4a925d231..ec48bd5ffa 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -108,6 +108,18 @@ de: unsubscribe: Abbestellen username: Profilname web: Web + custom_emojis: + created_msg: Emoji erstellt! + delete: Löschen + destroyed_msg: Emoji gelöscht! + emoji: Emoji + image_hint: PNG bis 50 kB + new: + title: Eigenes Emoji hinzufügen + shortcode: Shortcode + shortcode_hint: Mindestens 2 Zeichen, nur Buchstaben, Ziffern und Unterstriche + title: Eigene Emojis + upload: Hochladen domain_blocks: add_new: Neu hinzufügen created_msg: Die Domain-Blockade wird nun durchgeführt @@ -115,18 +127,20 @@ de: domain: Domain new: create: Blockade einrichten - hint: Die Domain-Blockade wird nicht die Erstellung von Konteneinträgen in der Datenbank verhindern, aber rückwirkend und automatisch alle Moderationsmethoden auf diese Konten anwenden. + hint: Die Domain-Blockade wird nicht verhindern, dass Konteneinträge in der Datenbank erstellt werden. Aber es werden rückwirkend und automatisch alle Moderationsmethoden auf diese Konten angewendet. severity: - desc_html: "Stummschaltung wird die Beiträge dieses Kontos für alle, die ihm nicht folgen, unsichtbar machen. Eine Sperre wird alle Beiträge, Medien und Profildaten dieses Kontos entfernen." + desc_html: "Stummschaltung wird die Beiträge dieses Kontos für alle, die ihm nicht folgen, unsichtbar machen. Eine Sperre wird alle Beiträge, Medien und Profildaten dieses Kontos entfernen. Verwende Kein, um nur Mediendateien abzulehnen." + noop: Kein silence: Stummschaltung suspend: Sperre title: Neue Domain-Blockade reject_media: Mediendateien ablehnen - reject_media_hint: Entfernt lokal gespeicherte Mediendateien und verweigert künftig deren Herunterladen. Irrelevant für Sperren + reject_media_hint: Entfernt lokal gespeicherte Mediendateien und verhindert deren künftiges Herunterladen. Für Sperren irrelevant severities: + none: Kein silence: Stummschaltung suspend: Sperren - severity: Gewichtung + severity: Schweregrad show: affected_accounts: one: Ein Konto in der Datenbank betroffen @@ -138,18 +152,36 @@ de: undo: Zurücknehmen title: Domain-Blockaden undo: Zurücknehmen + email_domain_blocks: + add_new: Neue hinzufügen + created_msg: E-Mail-Domain-Blockade erfolgreich erstellt + delete: Löschen + destroyed_msg: E-Mail-Domain-Blockade erfolgreich gelöscht + domain: Domain + new: + create: Blockade erstellen + title: Neue E-Mail-Domain-Blockade + title: E-Mail-Domain-Blockade instances: account_count: Bekannte Konten domain_name: Domain + reset: Zurücksetzen + search: Suchen title: Bekannte Instanzen reports: + action_taken_by: Maßnahme ergriffen durch + are_you_sure: Bist du dir sicher? comment: label: Kommentar none: Kein delete: Löschen id: ID mark_as_resolved: Als gelöst markieren - report: "#%{id} melden" + nsfw: + 'false': Medienanhänge wieder anzeigen + 'true': Medienanhänge verbergen + report: "Meldung #%{id}" + report_contents: Inhalt reported_account: Gemeldetes Konto reported_by: Gemeldet von resolved: Gelöst @@ -161,23 +193,54 @@ de: unresolved: Ungelöst view: Ansehen settings: + bootstrap_timeline_accounts: + desc_html: Mehrere Profilnamen durch Kommata trennen. Funktioniert nur mit lokalen und nicht gesperrten Konten. Standardwert bei freigelassenem Feld sind alle lokalen Admins. + title: Konten, denen Neu-Angemeldete automatisch folgen contact_information: - email: Eine öffentliche E-Mail-Adresse angeben - username: Einen Profilnamen angeben + email: Öffentliche E-Mail-Adresse + username: Profilname für die Kontaktaufnahme registrations: closed_message: - desc_html: Wird auf der Frontseite angezeigt, wenn die Registrierung geschlossen ist
    Du kannst HTML-Tags benutzen + desc_html: Wird auf der Frontseite angezeigt, wenn die Registrierung geschlossen ist. Du kannst HTML-Tags benutzen title: Nachricht über geschlossene Registrierung + deletion: + desc_html: Allen erlauben, ihr Konto eigenmächtig zu löschen + title: Kontolöschung erlauben open: - title: Offene Registrierung + desc_html: Allen erlauben, ein Konto zu erstellen + title: Registrierung öffnen site_description: - desc_html: Wird als Absatz auf der Frontseite angezeigt und als Meta-Tag benutzt.
    Du kannst HTML-Tags benutzen, insbesondere <a> und <em>. - title: Seitenbeschreibung + desc_html: Wird als Absatz auf der Frontseite angezeigt und als Meta-Tag benutzt. Du kannst HTML-Tags benutzen, insbesondere <a> und <em>. + title: Beschreibung der Instanz site_description_extended: - desc_html: Wird auf der erweiterten Informationsseite angezeigt
    Du kannst HTML-Tags benutzen - title: Erweiterte Seitenbeschreibung - site_title: Seitentitel - title: Seiteneinstellungen + desc_html: Bietet sich für Verhaltenskodizes, Regeln, Richtlinien und weiteres an, was deine Instanz auszeichnet. Du kannst HTML-Tags benutzen + title: Erweiterte Beschreibung der Instanz + site_terms: + desc_html: Hier kannst du deine eigenen Geschäftsbedingungen, Datenschutzerklärung und anderes rechtlich Relevante eintragen. Du kannst HTML-Tags benutzen + title: Eigene Geschäftsbedingungen + site_title: Name der Instanz + thumbnail: + desc_html: Wird für die Vorschau via OpenGraph und API verwendet. 1200×630 px wird empfohlen + title: Instanz-Thumbnail + timeline_preview: + desc_html: Auf der Frontseite die öffentliche Zeitleiste anzeigen + title: Zeitleisten-Vorschau + title: Instanz-Einstellungen + statuses: + back_to_account: Zurück zum Konto + batch: + delete: Löschen + nsfw_off: NSFW aus + nsfw_on: NSFW ein + execute: Ausführen + failed_to_execute: Ausführen fehlgeschlagen + media: + hide: Medien verbergen + show: Medien anzeigen + title: Medien + no_media: Keine Medien + title: Beiträge des Kontos + with_media: Mit Medien subscriptions: callback_url: Callback-URL confirmed: Bestätigt @@ -186,12 +249,23 @@ de: title: WebSub topic: Thema title: Administration + admin_mailer: + new_report: + body: "%{reporter} hat %{target} gemeldet" + subject: Neue Meldung auf %{instance} (#%{id}) application_mailer: + salutation: "%{name}," settings: 'E-Mail-Einstellungen ändern: %{link}' signature: Mastodon-Benachrichtigungen von %{instance} view: 'Ansehen:' applications: + created: Anwendung erstellt + destroyed: Anwendung gelöscht invalid_url: Die angegebene URL ist ungültig + regenerate_token: Zugangs-Token neu erstellen + token_regenerated: Zugangs-Token neu erstellt + warning: Sei mit diesen Daten sehr vorsichtig! Teile sie niemandem mit. + your_token: Dein Zugangs-Token auth: agreement_html: Indem du dich registrierst, erklärst du dich mit unseren
    Geschäftsbedingungen und der Datenschutzerklärung einverstanden. change_password: Sicherheit @@ -209,6 +283,12 @@ de: authorize_follow: error: Das Profil konnte nicht geladen werden follow: Folgen + follow_request: 'Du hast eine Folgeanfrage gesendet an:' + following: 'Erfolg! Du folgst nun:' + post_follow: + close: Oder du schließt einfach dieses Fenster. + return: Zurück zum Profil dieses Wesens + web: Das Web öffnen title: "%{acct} folgen" datetime: distance_in_words: @@ -233,11 +313,17 @@ de: warning_html: Wir können nur dafür garantieren, dass die Inhalte auf dieser einen Instanz gelöscht werden. Bei Inhalten, die weit verbreitet wurden, ist es wahrscheinlich, dass Spuren bleiben werden. Server, die offline sind oder keine Benachrichtigungen von deinem Konto mehr empfangen, werden ihre Datenbanken nicht bereinigen. warning_title: Verfügbarkeit verstreuter Inhalte errors: - '404': Die Seite, die du gesucht hast, existiert nicht. - '410': Die Seite, die du gesucht hast, existiert nicht mehr. + '403': Dir fehlt die Befugnis, diese Seite sehen zu können. + '404': Diese Seite existiert nicht. + '410': Diese Seite existiert nicht mehr. '422': content: Sicherheitsüberprüfung fehlgeschlagen. Blockierst du Cookies? title: Sicherheitsüberprüfung fehlgeschlagen + '429': Du wurdest gedrosselt + '500': + content: Bitte verzeih, etwas ist bei uns schief gegangen. + title: Diese Seite ist kaputt + noscript_html: Bitte aktiviere JavaScript, um die Mastodon-Web-Anwendung zu verwenden. Alternativ kannst du auch eine der nativen Mastodon-Anwendungen für deine Plattform probieren. exports: blocks: Du hast blockiert csv: CSV @@ -282,26 +368,26 @@ de: body: 'Hier ist eine kurze Zusammenfasung dessen, was du auf %{instance} seit deinem letzten Besuch am %{since} verpasst hast:' mention: "%{name} hat dich erwähnt:" new_followers_summary: - one: Du hast einen neuen Folgenden bekommen! Juhu! - other: Du hast %{count} neue Folgende bekommen! Großartig! + one: Ein weiteres Wesen folgt dir nun! Juhu! + other: "%{count} weitere Wesen folgen dir nun! Großartig!" subject: one: "1 neue Mitteilung seit deinem letzten Besuch \U0001F418" other: "%{count} neue Mitteilungen seit deinem letzten Besuch \U0001F418" favourite: body: 'Dein Beitrag wurde von %{name} favorisiert:' - subject: "%{name} hat deinen Beitrag favorisiert." + subject: "%{name} hat deinen Beitrag favorisiert" follow: body: "%{name} folgt dir jetzt!" - subject: "%{name} folgt dir jetzt." + subject: "%{name} folgt dir jetzt" follow_request: body: "%{name} möchte dir folgen:" - subject: "%{name} möchte dir folgen." + subject: "%{name} möchte dir folgen" mention: body: "%{name} hat dich erwähnt:" - subject: "%{name} hat dich erwähnt." + subject: "%{name} hat dich erwähnt" reblog: - body: 'Dein Beitrag wurde von %{name} geteilt:' - subject: "%{name} teilte deinen Beitrag." + body: '%{name} hat deinen Beitrag geteilt:' + subject: "%{name} hat deinen Beitrag geteilt" number: human: decimal_units: @@ -408,7 +494,8 @@ de: private_long: Nur für Folgende sichtbar public: Öffentlich public_long: Für alle sichtbar - unlisted: Für alle sichtbar, aber nicht in öffentlichen Zeitleisten aufgelistet + unlisted: Nicht gelistet + unlisted_long: Für alle sichtbar, aber nicht in öffentlichen Zeitleisten aufgelistet stream_entries: click_to_show: Klicken, um zu zeigen pinned: Angehefteter Beitrag @@ -420,19 +507,22 @@ de: formats: default: "%d.%m.%Y %H:%M" two_factor_authentication: - code_hint: Gib den Code, den deine Authenticator-App generiert hat, zur Bestätigung an - description_html: Wenn du Zwei-Faktor-Authentisierung (2FA) aktivierst, wirst du dein Telefon zum Anmelden benötigen. Darauf werden Tokens erzeugt, die du eingeben musst. + code_hint: Gib zur Bestätigung den Code ein, den deine Authenticator-App generiert hat + description_html: Wenn du Zwei-Faktor-Authentisierung (2FA) aktivierst, wirst du dein Telefon zum Anmelden benötigen. Darauf werden Tokens erzeugt, die du bei der Anmeldung eingeben musst. disable: Deaktivieren enable: Aktivieren + enabled: Zwei-Faktor-Authentisierung ist aktiviert enabled_success: Zwei-Faktor-Authentisierung erfolgreich aktiviert generate_recovery_codes: Wiederherstellungscodes generieren - instructions_html: "Lese diesen QR-Code mit Google Authenticator oder einer ähnlichen TOTP-App auf deinem Telefon ein. Von nun an wird diese App Tokens generieren, die du beim Anmelden eingeben musst." - lost_recovery_codes: Wiederherstellungscodes erlauben dir, wieder den Zugang zu deinem Konto zu erlangen, falls du dein Telefon verlierst. Wenn du deine Wiederherstellungscodes verloren hast, kannst du sie hier regenerieren. Deine alten Wiederherstellungscodes werden damit ungültig gemacht. + instructions_html: "Lies diesen QR-Code mit Google Authenticator oder einer ähnlichen TOTP-App auf deinem Telefon ein. Von nun an wird diese App Tokens generieren, die du beim Anmelden eingeben musst." + lost_recovery_codes: Wiederherstellungscodes erlauben dir, wieder den Zugang zu deinem Konto zu erlangen, falls du dein Telefon verlieren solltest. Wenn du deine Wiederherstellungscodes verloren hast, kannst du sie hier neu generieren. Deine alten Wiederherstellungscodes werden damit ungültig gemacht. manual_instructions: 'Wenn du den QR-Code nicht einlesen kannst und ihn manuell eingeben musst, ist hier das Klartext-Geheimnis:' - recovery_codes_regenerated: Wiederherstellungscodes erfolgreich regeneriert - recovery_instructions_html: Wenn du jemals den Zugang zu deinem Telefon verlierst, kannst du einen der Wiederherstellungscodes unten benutzen, um wieder auf dein Konto zugreifen zu können. Bewahre die Wiederherstellungscodes sicher auf, indem du sie beispielsweise ausdruckst und sie zusammen mit anderen wichtigen Dokumenten lagerst. + recovery_codes: Wiederherstellungs-Codes sichern + recovery_codes_regenerated: Wiederherstellungscodes erfolgreich neu generiert + recovery_instructions_html: Wenn du den Zugang zu deinem Telefon verlieren solltest, kannst du einen untenstehenden Wiederherstellungscodes benutzen, um wieder auf dein Konto zugreifen zu können. Bewahre die Wiederherstellungscodes gut auf. Du könntest sie beispielsweise ausdrucken und bei deinen restlichen wichtigen Dokumenten aufbewahren. setup: Einrichten - wrong_code: Der eingegebene Code war ungültig! Sind die Server- und die Gerätezeit korrekt? + wrong_code: Der eingegebene Code war ungültig! Stimmen Serverzeit und Gerätezeit? users: invalid_email: Ungültige E-Mail-Adresse invalid_otp_token: Ungültiger Zwei-Faktor-Authentisierungs-Code + signed_in_as: 'Angemeldet als:' From 32e8a87830f2b054f2a32ded4c41d91003503d14 Mon Sep 17 00:00:00 2001 From: Lynx Kotoura Date: Thu, 5 Oct 2017 05:49:36 +0900 Subject: [PATCH 070/137] adjust public profile pages 2 (#5223) --- app/javascript/styles/accounts.scss | 17 ++++++++--------- app/javascript/styles/forms.scss | 1 + config/initializers/kaminari_config.rb | 3 +-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 744650554c..30adf8cdc9 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -69,12 +69,16 @@ position: relative; z-index: 2; margin-bottom: 30px; + overflow: hidden; + text-overflow: ellipsis; small { display: block; font-size: 14px; color: $ui-highlight-color; font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; } } @@ -284,21 +288,15 @@ color: lighten($ui-base-color, 10%); } - @media screen and (max-width: 360px) { + @media screen and (max-width: 700px) { padding: 30px 20px; - a, - .current, - .next, - .prev, - .gap { + .page { display: none; } .next, - .prev, - .next a, - .prev a { + .prev { display: inline-block; } } @@ -375,6 +373,7 @@ height: 80px; border-radius: 80px; border: 2px solid $simple-background-color; + background: $simple-background-color; } } diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss index d241c9d26b..61fcf286ff 100644 --- a/app/javascript/styles/forms.scss +++ b/app/javascript/styles/forms.scss @@ -515,6 +515,7 @@ code { .action-pagination { display: flex; + flex-wrap: wrap; align-items: center; .actions, diff --git a/config/initializers/kaminari_config.rb b/config/initializers/kaminari_config.rb index 27b183eeb3..aa15172566 100644 --- a/config/initializers/kaminari_config.rb +++ b/config/initializers/kaminari_config.rb @@ -3,6 +3,5 @@ Kaminari.configure do |config| config.default_per_page = 40 config.window = 1 - config.left = 3 - config.right = 1 + config.outer_window = 1 end From 2559d9166cea24fceb9b72ca112804811d87a4a8 Mon Sep 17 00:00:00 2001 From: ThibG Date: Thu, 5 Oct 2017 00:21:44 +0200 Subject: [PATCH 071/137] Fix regression in FetchRemoteResourceService (#5217) * Fix regression in FetchRemoteResourceService * Update specs to match interface changes made in #5114 --- app/services/fetch_atom_service.rb | 2 +- app/services/fetch_remote_resource_service.rb | 2 +- spec/services/fetch_remote_resource_service_spec.rb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index 7c54714a22..1c47a22da4 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -45,7 +45,7 @@ class FetchAtomService < BaseService elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type) json = body_to_json(@response.to_s) if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present? - [json['id'], { id: true }, :activitypub] + [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub] else @unsupported_activity = true nil diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb index 341664272a..6d40796f24 100644 --- a/app/services/fetch_remote_resource_service.rb +++ b/app/services/fetch_remote_resource_service.rb @@ -33,7 +33,7 @@ class FetchRemoteResourceService < BaseService end def body - fetched_atom_feed.second + fetched_atom_feed.second[:prefetched_body] end def protocol diff --git a/spec/services/fetch_remote_resource_service_spec.rb b/spec/services/fetch_remote_resource_service_spec.rb index c14fcfc4e6..b80fb24750 100644 --- a/spec/services/fetch_remote_resource_service_spec.rb +++ b/spec/services/fetch_remote_resource_service_spec.rb @@ -22,7 +22,7 @@ describe FetchRemoteResourceService do allow(FetchAtomService).to receive(:new).and_return service feed_url = 'http://feed-url' feed_content = 'contents' - allow(service).to receive(:call).with(url).and_return([feed_url, feed_content]) + allow(service).to receive(:call).with(url).and_return([feed_url, { prefetched_body: feed_content }]) account_service = double allow(FetchRemoteAccountService).to receive(:new).and_return(account_service) @@ -39,7 +39,7 @@ describe FetchRemoteResourceService do allow(FetchAtomService).to receive(:new).and_return service feed_url = 'http://feed-url' feed_content = 'contents' - allow(service).to receive(:call).with(url).and_return([feed_url, feed_content]) + allow(service).to receive(:call).with(url).and_return([feed_url, { prefetched_body: feed_content }]) account_service = double allow(FetchRemoteStatusService).to receive(:new).and_return(account_service) From b9c76e2edbc372e1b472f6ba480631b79fe24722 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 5 Oct 2017 23:41:47 +0200 Subject: [PATCH 072/137] When processing custom emoji, ensure a non-animated version exists (#5230) Use the non-animated version in web UI, but return both in API --- app/javascript/mastodon/emoji.js | 2 +- app/javascript/mastodon/reducers/statuses.js | 2 +- app/lib/formatter.rb | 2 +- app/models/custom_emoji.rb | 2 +- app/serializers/rest/custom_emoji_serializer.rb | 6 +++++- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index 1df2373d9e..cf00779588 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -52,7 +52,7 @@ export const buildCustomEmojis = customEmojis => { customEmojis.forEach(emoji => { const shortcode = emoji.get('shortcode'); - const url = emoji.get('url'); + const url = emoji.get('static_url'); const name = shortcode.replace(':', ''); emojis.push({ diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 2d72b12e88..ed16e016f0 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -60,7 +60,7 @@ const normalizeStatus = (state, status) => { const searchContent = [status.spoiler_text, status.content].join(' ').replace(/
    /g, '\n').replace(/<\/p>

    /g, '\n\n'); const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { - obj[`:${emoji.shortcode}:`] = emoji.url; + obj[`:${emoji.shortcode}:`] = emoji.static_url; return obj; }, {}); diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 42cd729903..d7f6ec47b5 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -92,7 +92,7 @@ class Formatter def encode_custom_emojis(html, emojis) return html if emojis.empty? - emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h + emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h i = -1 inside_tag = false diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index e80c581553..9e9be5e12c 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -21,7 +21,7 @@ class CustomEmoji < ApplicationRecord :(#{SHORTCODE_RE_FRAGMENT}): (?=[^[:alnum:]:]|$)/x - has_attached_file :image + has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } } validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes } validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb index b744dd4ec5..b958e6a5db 100644 --- a/app/serializers/rest/custom_emoji_serializer.rb +++ b/app/serializers/rest/custom_emoji_serializer.rb @@ -3,9 +3,13 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer include RoutingHelper - attributes :shortcode, :url + attributes :shortcode, :url, :static_url def url full_asset_url(object.image.url) end + + def static_url + full_asset_url(object.image.url(:static)) + end end From 49cc0eb3e7d1521079e33a60216df46679082547 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 5 Oct 2017 23:42:05 +0200 Subject: [PATCH 073/137] Improve admin UI for custom emojis, add copy/disable/enable (#5231) --- .../admin/custom_emojis_controller.rb | 42 +++- .../api/v1/custom_emojis_controller.rb | 2 +- app/models/account_filter.rb | 2 + app/models/custom_emoji.rb | 11 +- app/models/custom_emoji_filter.rb | 34 ++++ .../custom_emojis/_custom_emoji.html.haml | 13 ++ app/views/admin/custom_emojis/index.html.haml | 20 ++ config/brakeman.ignore | 182 +++++++++++++++++- config/locales/de.yml | 6 +- config/locales/en.yml | 7 + config/routes.rb | 8 +- ...005171936_add_disabled_to_custom_emojis.rb | 15 ++ db/schema.rb | 3 +- 13 files changed, 330 insertions(+), 15 deletions(-) create mode 100644 app/models/custom_emoji_filter.rb create mode 100644 db/migrate/20171005171936_add_disabled_to_custom_emojis.rb diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index d70514d9a9..dba9f10123 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -2,8 +2,10 @@ module Admin class CustomEmojisController < BaseController + before_action :set_custom_emoji, except: [:index, :new, :create] + def index - @custom_emojis = CustomEmoji.local + @custom_emojis = filtered_custom_emojis.page(params[:page]) end def new @@ -21,14 +23,50 @@ module Admin end def destroy - CustomEmoji.find(params[:id]).destroy + @custom_emoji.destroy redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') end + def copy + emoji = @custom_emoji.dup + emoji.domain = nil + + if emoji.save + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.copied_msg') + else + redirect_to admin_custom_emojis_path, alert: I18n.t('admin.custom_emojis.copy_failed_msg') + end + end + + def enable + @custom_emoji.update!(disabled: false) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg') + end + + def disable + @custom_emoji.update!(disabled: true) + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg') + end + private + def set_custom_emoji + @custom_emoji = CustomEmoji.find(params[:id]) + end + def resource_params params.require(:custom_emoji).permit(:shortcode, :image) end + + def filtered_custom_emojis + CustomEmojiFilter.new(filter_params).results + end + + def filter_params + params.permit( + :local, + :remote + ) + end end end diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb index 4dd77fb550..f8cd64455a 100644 --- a/app/controllers/api/v1/custom_emojis_controller.rb +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -4,6 +4,6 @@ class Api::V1::CustomEmojisController < Api::BaseController respond_to :json def index - render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer + render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer end end diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index 1a8cc5192a..1898723682 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -9,9 +9,11 @@ class AccountFilter def results scope = Account.alphabetic + params.each do |key, value| scope.merge!(scope_for(key, value)) if value.present? end + scope end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 9e9be5e12c..258b50c82f 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -12,6 +12,7 @@ # image_updated_at :datetime # created_at :datetime not null # updated_at :datetime not null +# disabled :boolean default(FALSE), not null # class CustomEmoji < ApplicationRecord @@ -26,10 +27,16 @@ class CustomEmoji < ApplicationRecord validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes } validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } - scope :local, -> { where(domain: nil) } + scope :local, -> { where(domain: nil) } + scope :remote, -> { where.not(domain: nil) } + scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) } include Remotable + def local? + domain.nil? + end + class << self def from_text(text, domain) return [] if text.blank? @@ -38,7 +45,7 @@ class CustomEmoji < ApplicationRecord return [] if shortcodes.empty? - where(shortcode: shortcodes, domain: domain) + where(shortcode: shortcodes, domain: domain, disabled: false) end end end diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb new file mode 100644 index 0000000000..2d1394a597 --- /dev/null +++ b/app/models/custom_emoji_filter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class CustomEmojiFilter + attr_reader :params + + def initialize(params) + @params = params + end + + def results + scope = CustomEmoji.alphabetic + + params.each do |key, value| + scope.merge!(scope_for(key, value)) if value.present? + end + + scope + end + + private + + def scope_for(key, value) + case key.to_s + when 'local' + CustomEmoji.local + when 'remote' + CustomEmoji.remote + when 'by_domain' + CustomEmoji.where(domain: value) + else + raise "Unknown filter: #{key}" + end + end +end diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index ff1aa9925b..53263c43f2 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -3,5 +3,18 @@ = image_tag custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:" %td %samp= ":#{custom_emoji.shortcode}:" + %td + - if custom_emoji.local? + = t('admin.accounts.location.local') + - else + = custom_emoji.domain + %td + - unless custom_emoji.local? + = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji), method: :post + %td + - if custom_emoji.disabled? + = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } + - else + = table_link_to 'power-off', t('admin.custom_emojis.disable'), disable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } %td = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml index d5f32e84b4..20ffb85294 100644 --- a/app/views/admin/custom_emojis/index.html.haml +++ b/app/views/admin/custom_emojis/index.html.haml @@ -1,14 +1,34 @@ - content_for :page_title do = t('admin.custom_emojis.title') +.filters + .filter-subset + %strong= t('admin.accounts.location.title') + %ul + %li= filter_link_to t('admin.accounts.location.all'), local: nil, remote: nil + %li + - if selected? local: '1', remote: nil + = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil}, {local: '1', remote: nil} + - else + = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil + %li + - if selected? remote: '1', local: nil + = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil} + - else + = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil + .table-wrapper %table.table %thead %tr %th= t('admin.custom_emojis.emoji') %th= t('admin.custom_emojis.shortcode') + %th= t('admin.accounts.domain') + %th + %th %th %tbody = render @custom_emojis += paginate @custom_emojis = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button' diff --git a/config/brakeman.ignore b/config/brakeman.ignore index dbb59dd079..ed6e121d21 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,5 +1,81 @@ { "ignored_warnings": [ + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "0adbe361b91afff22ba51e5fc2275ec703cc13255a0cb3eecd8dab223ab9f61e", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 122, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).inbox_url, Account.find(params[:id]).inbox_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).inbox_url", + "confidence": "Weak", + "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "1fc29c578d0c89bf13bd5476829d272d54cd06b92ccf6df18568fa1f2674926e", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 128, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).shared_inbox_url, Account.find(params[:id]).shared_inbox_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).shared_inbox_url", + "confidence": "Weak", + "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "2129d4c1e63a351d28d8d2937ff0b50237809c3df6725c0c5ef82b881dbb2086", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 35, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).url, Account.find(params[:id]).url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).url", + "confidence": "Weak", + "note": "" + }, + { + "warning_type": "Dynamic Render Path", + "warning_code": 15, + "fingerprint": "3b0a20b08aef13cf8cf865384fae0cfd3324d8200a83262bf4abbc8091b5fec5", + "check_name": "Render", + "message": "Render path contains parameter value", + "file": "app/views/admin/custom_emojis/index.html.haml", + "line": 31, + "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", + "code": "render(action => filtered_custom_emojis.page(params[:page]), {})", + "render_path": [{"type":"controller","class":"Admin::CustomEmojisController","method":"index","line":9,"file":"app/controllers/admin/custom_emojis_controller.rb"}], + "location": { + "type": "template", + "template": "admin/custom_emojis/index" + }, + "user_input": "params[:page]", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -19,6 +95,44 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "64b5b2a02ede9c2b3598881eb5a466d63f7d27fe0946aa00d570111ec7338d2e", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 131, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).followers_url, Account.find(params[:id]).followers_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).followers_url", + "confidence": "Weak", + "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "82f7b0d09beb3ab68e0fa16be63cedf4e820f2490326e9a1cec05761d92446cd", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 106, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).salmon_url, Account.find(params[:id]).salmon_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).salmon_url", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -26,7 +140,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/admin/accounts/index.html.haml", - "line": 63, + "line": 64, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_accounts.page(params[:page]), {})", "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"index","line":10,"file":"app/controllers/admin/accounts_controller.rb"}], @@ -38,6 +152,25 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "bb0ad5c4a42e06e3846c2089ff5269c17f65483a69414f6ce65eecf2bb11fab7", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 95, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).remote_url, Account.find(params[:id]).remote_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).remote_url", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Redirect", "warning_code": 18, @@ -65,7 +198,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/admin/reports/index.html.haml", - "line": 24, + "line": 25, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(action => filtered_reports.page(params[:page]), {})", "render_path": [{"type":"controller","class":"Admin::ReportsController","method":"index","line":9,"file":"app/controllers/admin/reports_controller.rb"}], @@ -77,6 +210,45 @@ "confidence": "Weak", "note": "" }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "cd440d9d0bcb76225f4142030cec0bdec6ad119c537c108c9d514bf87bc34d29", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "lib/mastodon/timestamp_ids.rb", + "line": 69, + "link": "http://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "ActiveRecord::Base.connection.execute(\" CREATE OR REPLACE FUNCTION timestamp_id(table_name text)\\n RETURNS bigint AS\\n $$\\n DECLARE\\n time_part bigint;\\n sequence_base bigint;\\n tail bigint;\\n BEGIN\\n -- Our ID will be composed of the following:\\n -- 6 bytes (48 bits) of millisecond-level timestamp\\n -- 2 bytes (16 bits) of sequence data\\n\\n -- The 'sequence data' is intended to be unique within a\\n -- given millisecond, yet obscure the 'serial number' of\\n -- this row.\\n\\n -- To do this, we hash the following data:\\n -- * Table name (if provided, skipped if not)\\n -- * Secret salt (should not be guessable)\\n -- * Timestamp (again, millisecond-level granularity)\\n\\n -- We then take the first two bytes of that value, and add\\n -- the lowest two bytes of the table ID sequence number\\n -- (`table_name`_id_seq). This means that even if we insert\\n -- two rows at the same millisecond, they will have\\n -- distinct 'sequence data' portions.\\n\\n -- If this happens, and an attacker can see both such IDs,\\n -- they can determine which of the two entries was inserted\\n -- first, but not the total number of entries in the table\\n -- (even mod 2**16).\\n\\n -- The table name is included in the hash to ensure that\\n -- different tables derive separate sequence bases so rows\\n -- inserted in the same millisecond in different tables do\\n -- not reveal the table ID sequence number for one another.\\n\\n -- The secret salt is included in the hash to ensure that\\n -- external users cannot derive the sequence base given the\\n -- timestamp and table name, which would allow them to\\n -- compute the table ID sequence number.\\n\\n time_part := (\\n -- Get the time in milliseconds\\n ((date_part('epoch', now()) * 1000))::bigint\\n -- And shift it over two bytes\\n << 16);\\n\\n sequence_base := (\\n 'x' ||\\n -- Take the first two bytes (four hex characters)\\n substr(\\n -- Of the MD5 hash of the data we documented\\n md5(table_name ||\\n '#{SecureRandom.hex(16)}' ||\\n time_part::text\\n ),\\n 1, 4\\n )\\n -- And turn it into a bigint\\n )::bit(16)::bigint;\\n\\n -- Finally, add our sequence number to our base, and chop\\n -- it to the last two bytes\\n tail := (\\n (sequence_base + nextval(table_name || '_id_seq'))\\n & 65535);\\n\\n -- Return the time part and the sequence part. OR appears\\n -- faster here than addition, but they're equivalent:\\n -- time_part has no trailing two bytes, and tail is only\\n -- the last two bytes.\\n RETURN time_part | tail;\\n END\\n $$ LANGUAGE plpgsql VOLATILE;\\n\")", + "render_path": null, + "location": { + "type": "method", + "class": "Mastodon::TimestampIds", + "method": "s(:self).define_timestamp_id" + }, + "user_input": "SecureRandom.hex(16)", + "confidence": "Medium", + "note": "" + }, + { + "warning_type": "Cross-Site Scripting", + "warning_code": 4, + "fingerprint": "e04aafe1e06cf8317fb6ac0a7f35783e45aa1274272ee6eaf28d39adfdad489b", + "check_name": "LinkToHref", + "message": "Potentially unsafe model attribute in link_to href", + "file": "app/views/admin/accounts/show.html.haml", + "line": 125, + "link": "http://brakemanscanner.org/docs/warning_types/link_to_href", + "code": "link_to(Account.find(params[:id]).outbox_url, Account.find(params[:id]).outbox_url)", + "render_path": [{"type":"controller","class":"Admin::AccountsController","method":"show","line":13,"file":"app/controllers/admin/accounts_controller.rb"}], + "location": { + "type": "template", + "template": "admin/accounts/show" + }, + "user_input": "Account.find(params[:id]).outbox_url", + "confidence": "Weak", + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -84,7 +256,7 @@ "check_name": "Render", "message": "Render path contains parameter value", "file": "app/views/stream_entries/show.html.haml", - "line": 23, + "line": 21, "link": "http://brakemanscanner.org/docs/warning_types/dynamic_render_path/", "code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })", "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":20,"file":"app/controllers/statuses_controller.rb"}], @@ -97,6 +269,6 @@ "note": "" } ], - "updated": "2017-08-30 05:14:04 +0200", - "brakeman_version": "3.7.2" + "updated": "2017-10-05 20:06:40 +0200", + "brakeman_version": "4.0.1" } diff --git a/config/locales/de.yml b/config/locales/de.yml index ec48bd5ffa..7c0edff94e 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -137,7 +137,7 @@ de: reject_media: Mediendateien ablehnen reject_media_hint: Entfernt lokal gespeicherte Mediendateien und verhindert deren künftiges Herunterladen. Für Sperren irrelevant severities: - none: Kein + noop: Kein silence: Stummschaltung suspend: Sperren severity: Schweregrad @@ -180,7 +180,7 @@ de: nsfw: 'false': Medienanhänge wieder anzeigen 'true': Medienanhänge verbergen - report: "Meldung #%{id}" + report: 'Meldung #%{id}' report_contents: Inhalt reported_account: Gemeldetes Konto reported_by: Gemeldet von @@ -386,7 +386,7 @@ de: body: "%{name} hat dich erwähnt:" subject: "%{name} hat dich erwähnt" reblog: - body: '%{name} hat deinen Beitrag geteilt:' + body: "%{name} hat deinen Beitrag geteilt:" subject: "%{name} hat deinen Beitrag geteilt" number: human: diff --git a/config/locales/en.yml b/config/locales/en.yml index 5d9557535c..2059c5e2be 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -109,10 +109,17 @@ en: username: Username web: Web custom_emojis: + copied_msg: Successfully created local copy of the emoji + copy: Copy + copy_failed_msg: Could not make a local copy of that emoji created_msg: Emoji successfully created! delete: Delete destroyed_msg: Emojo successfully destroyed! + disable: Disable + disabled_msg: Successfully disabled that emoji emoji: Emoji + enable: Enable + enabled_msg: Successfully enabled that emoji image_hint: PNG up to 50KB new: title: Add new custom emoji diff --git a/config/routes.rb b/config/routes.rb index 959afc23f8..cc1f66e52a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -139,7 +139,13 @@ Rails.application.routes.draw do resource :two_factor_authentication, only: [:destroy] end - resources :custom_emojis, only: [:index, :new, :create, :destroy] + resources :custom_emojis, only: [:index, :new, :create, :destroy] do + member do + post :copy + post :enable + post :disable + end + end end get '/admin', to: redirect('/admin/settings/edit', status: 302) diff --git a/db/migrate/20171005171936_add_disabled_to_custom_emojis.rb b/db/migrate/20171005171936_add_disabled_to_custom_emojis.rb new file mode 100644 index 0000000000..067a7bee0c --- /dev/null +++ b/db/migrate/20171005171936_add_disabled_to_custom_emojis.rb @@ -0,0 +1,15 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddDisabledToCustomEmojis < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured { add_column_with_default :custom_emojis, :disabled, :bool, default: false } + end + + def down + remove_column :custom_emojis, :disabled + end +end diff --git a/db/schema.rb b/db/schema.rb index 337678c67f..3358e29979 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: 20170928082043) do +ActiveRecord::Schema.define(version: 20171005171936) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170928082043) do t.datetime "image_updated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "disabled", default: false, null: false t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true end From 7db0f8dcb2110b4ec8815bedc965cfbd01a59798 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 6 Oct 2017 01:07:59 +0200 Subject: [PATCH 074/137] Implement hotkeys for web UI (#5164) * Fix #2102 - Implement hotkeys Hotkeys on status list: - r to reply - m to mention author - f to favourite - b to boost - enter to open status - p to open author's profile - up or k to move up in the list - down or j to move down in the list - 1-9 to focus a status in one of the columns - n to focus the compose textarea - alt+n to start a brand new toot - backspace to navigate back * Add navigational hotkeys The key g followed by: - s: start - h: home - n: notifications - l: local timeline - t: federated timeline - f: favourites - u: own profile - p: pinned toots - b: blocked users - m: muted users * Add hotkey for focusing search, make escape un-focus compose/search * Fix focusing notifications column, fix hotkeys in compose textarea --- app/javascript/mastodon/actions/compose.js | 7 + .../components/autosuggest_textarea.js | 14 +- .../mastodon/components/scrollable_list.js | 28 +-- app/javascript/mastodon/components/status.js | 120 +++++++--- .../mastodon/components/status_list.js | 31 ++- .../features/compose/components/search.js | 2 + .../notifications/components/notification.js | 115 ++++++++-- .../containers/notification_container.js | 9 +- .../mastodon/features/notifications/index.js | 28 ++- .../mastodon/features/status/index.js | 149 ++++++++++-- app/javascript/mastodon/features/ui/index.js | 213 +++++++++++++++--- app/javascript/mastodon/reducers/compose.js | 2 + app/javascript/styles/basics.scss | 13 +- app/javascript/styles/components.scss | 24 +- package.json | 1 + yarn.lock | 21 ++ 16 files changed, 627 insertions(+), 150 deletions(-) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 7ac33bdd0a..ed4837ebdd 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -16,6 +16,7 @@ export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_MENTION = 'COMPOSE_MENTION'; +export const COMPOSE_RESET = 'COMPOSE_RESET'; export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST'; export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS'; export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL'; @@ -68,6 +69,12 @@ export function cancelReplyCompose() { }; }; +export function resetCompose() { + return { + type: COMPOSE_RESET, + }; +}; + export function mentionCompose(account, router) { return (dispatch, getState) => { dispatch({ diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 6f725885de..14a8d4c381 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { this.props.onKeyDown(e); } + onKeyUp = e => { + if (e.key === 'Escape' && this.state.suggestionsHidden) { + document.querySelector('.ui').parentElement.focus(); + } + + if (this.props.onKeyUp) { + this.props.onKeyUp(e); + } + } + onBlur = () => { this.setState({ suggestionsHidden: true }); } @@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; + const { value, suggestions, disabled, placeholder, autoFocus } = this.props; const { suggestionsHidden } = this.state; const style = { direction: 'ltr' }; @@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { value={value} onChange={this.onChange} onKeyDown={this.onKeyDown} - onKeyUp={onKeyUp} + onKeyUp={this.onKeyUp} onBlur={this.onBlur} onPaste={this.onPaste} style={style} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js index c6b588765d..ab9d485104 100644 --- a/app/javascript/mastodon/components/scrollable_list.js +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -145,32 +145,6 @@ export default class ScrollableList extends PureComponent { return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); } - 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 { fullscreen } = this.state; @@ -182,7 +156,7 @@ export default class ScrollableList extends PureComponent { if (isLoading || childrenCount > 0 || !emptyMessage) { scrollableArea = (

    -
    +
    {prepend} {React.Children.map(this.props.children, (child, index) => ( diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 17482e57ae..70005436b5 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -10,6 +10,8 @@ import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -39,6 +41,8 @@ export default class Status extends ImmutablePureComponent { autoPlayGif: PropTypes.bool, muted: PropTypes.bool, hidden: PropTypes.bool, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, }; state = { @@ -89,16 +93,62 @@ export default class Status extends ImmutablePureComponent { } handleOpenVideo = startTime => { - this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime); + } + + handleHotkeyReply = e => { + e.preventDefault(); + this.props.onReply(this._properStatus(), this.context.router.history); + } + + handleHotkeyFavourite = () => { + this.props.onFavourite(this._properStatus()); + } + + handleHotkeyBoost = e => { + this.props.onReblog(this._properStatus(), e); + } + + handleHotkeyMention = e => { + e.preventDefault(); + this.props.onMention(this._properStatus().get('account'), this.context.router.history); + } + + handleHotkeyOpen = () => { + this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`); + } + + handleHotkeyOpenProfile = () => { + this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`); + } + + handleHotkeyMoveUp = () => { + this.props.onMoveUp(this.props.status.get('id')); + } + + handleHotkeyMoveDown = () => { + this.props.onMoveDown(this.props.status.get('id')); + } + + _properStatus () { + const { status } = this.props; + + if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { + return status.get('reblog'); + } else { + return status; + } } render () { let media = null; - let statusAvatar; + let statusAvatar, prepend; - const { status, account, hidden, ...other } = this.props; + const { hidden } = this.props; const { isExpanded } = this.state; + let { status, account, ...other } = this.props; + if (status === null) { return null; } @@ -115,16 +165,15 @@ export default class Status extends ImmutablePureComponent { if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; - return ( -
    -
    -
    - }} /> -
    - - + prepend = ( +
    +
    + }} />
    ); + + account = status.get('account'); + status = status.get('reblog'); } if (status.get('media_attachments').size > 0 && !this.props.muted) { @@ -160,26 +209,43 @@ export default class Status extends ImmutablePureComponent { statusAvatar = ; } - return ( -
    -
    - + const handlers = this.props.muted ? {} : { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + }; - - + ); } diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index cbae28afe3..58a7b228a9 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; + handleMoveUp = id => { + const elementIndex = this.props.statusIds.indexOf(id) - 1; + this._selectChild(elementIndex); + } + + handleMoveDown = id => { + const elementIndex = this.props.statusIds.indexOf(id) + 1; + this._selectChild(elementIndex); + } + + _selectChild (index) { + const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); + + if (element) { + element.focus(); + } + } + + setRef = c => { + this.node = c; + } + render () { const { statusIds, ...other } = this.props; const { isLoading } = other; const scrollableContent = (isLoading || statusIds.size > 0) ? ( statusIds.map((statusId) => ( - + )) ) : null; return ( - + {scrollableContent} ); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 79abffad8d..4c3f0dcb55 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -74,6 +74,8 @@ export default class Search extends React.PureComponent { if (e.key === 'Enter') { e.preventDefault(); this.props.onSubmit(); + } else if (e.key === 'Escape') { + document.querySelector('.ui').parentElement.focus(); } } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index a608a5223d..9d170cad53 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -6,61 +6,126 @@ import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { HotKeys } from 'react-hotkeys'; export default class Notification extends ImmutablePureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { notification: ImmutablePropTypes.map.isRequired, hidden: PropTypes.bool, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onMention: PropTypes.func.isRequired, }; + handleMoveUp = () => { + const { notification, onMoveUp } = this.props; + onMoveUp(notification.get('id')); + } + + handleMoveDown = () => { + const { notification, onMoveDown } = this.props; + onMoveDown(notification.get('id')); + } + + handleOpen = () => { + const { notification } = this.props; + + if (notification.get('status')) { + this.context.router.history.push(`/statuses/${notification.get('status')}`); + } else { + this.handleOpenProfile(); + } + } + + handleOpenProfile = () => { + const { notification } = this.props; + this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`); + } + + handleMention = e => { + e.preventDefault(); + + const { notification, onMention } = this.props; + onMention(notification.get('account'), this.context.router.history); + } + + getHandlers () { + return { + moveUp: this.handleMoveUp, + moveDown: this.handleMoveDown, + open: this.handleOpen, + openProfile: this.handleOpenProfile, + mention: this.handleMention, + reply: this.handleMention, + }; + } + renderFollow (account, link) { return ( -
    -
    -
    - + +
    +
    +
    + +
    + +
    - +
    - -
    + ); } renderMention (notification) { - return