diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
index 5643e6449..2609b96ff 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.js
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
import classNames from 'classnames';
import { decode } from 'blurhash';
@@ -88,8 +89,10 @@ export default class MediaItem extends ImmutablePureComponent {
const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
const height = width;
const status = attachment.get('status');
+ const title = status.get('spoiler_text') || attachment.get('description');
let thumbnail = '';
+ let icon;
if (attachment.get('type') === 'unknown') {
// Skip
@@ -131,11 +134,20 @@ export default class MediaItem extends ImmutablePureComponent {
);
}
+ if (!visible) {
+ icon = (
+
+
+
+ );
+ }
+
return (
);
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index b8efea48b..b043ce43a 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -21,7 +21,7 @@ const mapStateToProps = state => ({
focusDate: state.getIn(['compose', 'focusDate']),
caretPosition: state.getIn(['compose', 'caretPosition']),
preselectDate: state.getIn(['compose', 'preselectDate']),
- is_submitting: state.getIn(['compose', 'is_submitting']),
+ isSubmitting: state.getIn(['compose', 'is_submitting']),
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
index 50612b086..7073f76c2 100644
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { changeComposeSensitivity } from 'mastodon/actions/compose';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import Icon from 'mastodon/components/icon';
const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@@ -38,9 +37,19 @@ class SensitiveButton extends React.PureComponent {
return (
-
+
);
}
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 306a068b7..c4642344f 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -71,11 +71,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
const params = [ `stream=${stream}` ];
- if (accessToken !== null) {
- params.push(`access_token=${accessToken}`);
- }
-
- const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`);
+ const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
ws.onopen = connected;
ws.onmessage = e => received(JSON.parse(e.data));
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index 8429103b8..f78e60597 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -67,3 +67,11 @@
text-decoration: none;
}
}
+
+.nothing-here {
+ color: $darker-text-color;
+}
+
+.public-layout .public-account-header__tabs__tabs .counter.active::after {
+ border-bottom: 4px solid $ui-highlight-color;
+}
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index de03cf1a6..48236a286 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -162,7 +162,7 @@
.actions-modal ul li:not(:empty) a:focus button,
.actions-modal ul li:not(:empty) a:hover,
.actions-modal ul li:not(:empty) a:hover button,
-.admin-wrapper .sidebar ul ul a.selected,
+.admin-wrapper .sidebar ul li a.selected,
.simple_form .block-button,
.simple_form .button,
.simple_form button {
@@ -230,6 +230,7 @@
.empty-column-indicator,
.error-column {
color: $primary-text-color;
+ background: $white;
}
// Change the default colors used on some parts of the profile pages
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index cf8fa9392..cdf3b3b13 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -268,9 +268,34 @@
padding: 10px;
padding-top: 0;
- .icon-button {
- font-size: 14px;
- font-weight: 500;
+ font-size: 14px;
+ font-weight: 500;
+
+ &.active {
+ color: $highlight-text-color;
+ }
+
+ input[type=checkbox] {
+ display: none;
+ }
+
+ .checkbox {
+ display: inline-block;
+ position: relative;
+ border: 1px solid $ui-primary-color;
+ box-sizing: border-box;
+ width: 18px;
+ height: 18px;
+ flex: 0 0 auto;
+ margin-right: 10px;
+ top: -1px;
+ border-radius: 4px;
+ vertical-align: middle;
+
+ &.active {
+ border-color: $highlight-text-color;
+ background: $highlight-text-color;
+ }
}
}
@@ -1386,6 +1411,15 @@ a.account__display-name {
width: 48px;
}
+.status__expand {
+ width: 68px;
+ position: absolute;
+ left: 0;
+ top: 0;
+ height: 100%;
+ cursor: pointer;
+}
+
.muted {
.status__content p,
.status__content a {
@@ -4829,6 +4863,14 @@ a.status-card.compact:hover {
border-radius: 4px;
overflow: hidden;
margin: 2px;
+
+ &__icons {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ font-size: 24px;
+ }
}
.notification__filter-bar,
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index d8bc5473a..37c454a78 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -114,11 +114,14 @@
text-decoration: underline;
font-size: inherit;
- &:hover,
- &:focus,
- &:active {
+ &:hover {
text-decoration: none;
}
+
+ &:active,
+ &:focus {
+ background-color: rgba($dark-text-color, .1);
+ }
}
.button {
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 6b16c9986..2bc33c04b 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -267,7 +267,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def conversation_from_uri(uri)
return nil if uri.nil?
return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
- Conversation.find_by(uri: uri) || Conversation.create(uri: uri)
+ begin
+ Conversation.find_or_create_by!(uri: uri)
+ rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
+ retry
+ end
end
def visibility_from_audience
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 59dfc9004..8a1aad41a 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -187,7 +187,7 @@ class Formatter
end
def rewrite(text, entities)
- chars = text.to_s.to_char_a
+ text = text.to_s
# Sort by start index
entities = entities.sort_by do |entity|
@@ -199,12 +199,12 @@ class Formatter
last_index = entities.reduce(0) do |index, entity|
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
- result << encode(chars[index...indices.first].join)
+ result << encode(text[index...indices.first])
result << yield(entity)
indices.last
end
- result << encode(chars[last_index..-1].join)
+ result << encode(text[last_index..-1])
result.flatten.join
end
@@ -231,23 +231,14 @@ class Formatter
# Note: I couldn't obtain list_slug with @user/list-name format
# for mention so this requires additional check
special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
- # exactly one of :url, :hashtag, :screen_name, :cashtag keys is present
- key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first
-
new_indices = [
old_to_new_index.find_index(extract[:indices].first),
old_to_new_index.find_index(extract[:indices].last),
]
- has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key)
- value_indices = [
- new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $
- new_indices.last - 1,
- ]
-
next extract.merge(
- :indices => new_indices,
- key => text[value_indices.first..value_indices.last]
+ indices: new_indices,
+ url: text[new_indices.first..new_indices.last - 1]
)
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 9c10f5ca7..73673a1f2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -114,6 +114,10 @@ class User < ApplicationRecord
end
def invited?
+ invite_id.present?
+ end
+
+ def valid_invitation?
invite_id.present? && invite.valid_for_use?
end
@@ -274,7 +278,7 @@ class User < ApplicationRecord
private
def set_approved
- self.approved = open_registrations? || invited? || external?
+ self.approved = open_registrations? || valid_invitation? || external?
end
def open_registrations?
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index deaa0549e..77ec52ab8 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -18,7 +18,9 @@ class ReblogService < BaseService
return reblog unless reblog.nil?
- reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: options[:visibility] || account.user&.setting_default_privacy)
+ visibility = options[:visibility] || account.user&.setting_default_privacy
+ visibility = reblogged_status.visibility if reblogged_status.hidden?
+ reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility)
DistributionWorker.perform_async(reblog.id)
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index a288c20ef..0d01a1c47 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -2,7 +2,7 @@
class BlacklistedEmailValidator < ActiveModel::Validator
def validate(user)
- return if user.invited?
+ return if user.valid_invitation?
@email = user.email
diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml
index e1b977da3..67f9274b6 100644
--- a/app/views/stream_entries/_og_image.html.haml
+++ b/app/views/stream_entries/_og_image.html.haml
@@ -7,6 +7,8 @@
- unless media.file.meta.nil?
= opengraph 'og:image:width', media.file.meta.dig('original', 'width')
= opengraph 'og:image:height', media.file.meta.dig('original', 'height')
+ - if media.description.present?
+ = opengraph 'og:image:alt', media.description
- elsif media.video? || media.gifv?
- player_card = true
= opengraph 'og:image', full_asset_url(media.file.url(:small))
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index f9c385ea3..5e4c391f0 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -51,7 +51,7 @@ class ActivityPub::DeliveryWorker
end
def response_error_unsalvageable?(response)
- (400...500).cover?(response.code) && response.code != 429
+ (400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)
end
def failure_tracker
diff --git a/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb b/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb
new file mode 100644
index 000000000..d2d924239
--- /dev/null
+++ b/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb
@@ -0,0 +1,23 @@
+class RemoveBoostsWideningAudience < ActiveRecord::Migration[5.2]
+ disable_ddl_transaction!
+
+ def up
+ public_boosts = Status.find_by_sql(<<-SQL)
+ SELECT boost.id
+ FROM statuses AS boost
+ LEFT JOIN statuses AS boosted ON boost.reblog_of_id = boosted.id
+ WHERE
+ boost.id > 101746055577600000
+ AND (boost.local = TRUE OR boost.uri IS NULL)
+ AND boost.visibility IN (0, 1)
+ AND boost.reblog_of_id IS NOT NULL
+ AND boosted.visibility = 2
+ SQL
+
+ RemovalWorker.push_bulk(public_boosts.pluck(:id))
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8be67002e..f892eb729 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: 2019_04_20_025523) do
+ActiveRecord::Schema.define(version: 2019_05_19_130537) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 91f45e45d..59ded05f7 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -13,7 +13,7 @@ module Mastodon
end
def patch
- 2
+ 4
end
def pre
diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb
index 9e66c6643..9d84c41d5 100644
--- a/spec/services/reblog_service_spec.rb
+++ b/spec/services/reblog_service_spec.rb
@@ -4,10 +4,9 @@ RSpec.describe ReblogService, type: :service do
let(:alice) { Fabricate(:account, username: 'alice') }
context 'creates a reblog with appropriate visibility' do
- let(:bob) { Fabricate(:account, username: 'bob') }
let(:visibility) { :public }
let(:reblog_visibility) { :public }
- let(:status) { Fabricate(:status, account: bob, visibility: visibility) }
+ let(:status) { Fabricate(:status, account: alice, visibility: visibility) }
subject { ReblogService.new }
@@ -22,6 +21,15 @@ RSpec.describe ReblogService, type: :service do
expect(status.reblogs.first.visibility).to eq 'private'
end
end
+
+ describe 'public reblogs of private toots should remain private' do
+ let(:visibility) { :private }
+ let(:reblog_visibility) { :public }
+
+ it 'reblogs privately' do
+ expect(status.reblogs.first.visibility).to eq 'private'
+ end
+ end
end
context 'OStatus' do
diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb
index 84b0107dd..ccc5dc0f4 100644
--- a/spec/validators/blacklisted_email_validator_spec.rb
+++ b/spec/validators/blacklisted_email_validator_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
let(:errors) { double(add: nil) }
before do
- allow(user).to receive(:invited?) { false }
+ allow(user).to receive(:valid_invitation?) { false }
allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
described_class.new.validate(user)
end
diff --git a/streaming/index.js b/streaming/index.js
index d4fb8cad3..6a5922f5e 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -195,14 +195,14 @@ const startWorker = (workerId) => {
next();
};
- const accountFromToken = (token, req, next) => {
+ const accountFromToken = (token, allowedScopes, req, next) => {
pgPool.connect((err, client, done) => {
if (err) {
next(err);
return;
}
- client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
+ client.query('SELECT oauth_access_tokens.resource_owner_id, users.account_id, users.chosen_languages, oauth_access_tokens.scopes FROM oauth_access_tokens INNER JOIN users ON oauth_access_tokens.resource_owner_id = users.id WHERE oauth_access_tokens.token = $1 AND oauth_access_tokens.revoked_at IS NULL LIMIT 1', [token], (err, result) => {
done();
if (err) {
@@ -218,18 +218,29 @@ const startWorker = (workerId) => {
return;
}
+ const scopes = result.rows[0].scopes.split(' ');
+
+ if (allowedScopes.size > 0 && !scopes.some(scope => allowedScopes.includes(scope))) {
+ err = new Error('Access token does not cover required scopes');
+ err.statusCode = 401;
+
+ next(err);
+ return;
+ }
+
req.accountId = result.rows[0].account_id;
req.chosenLanguages = result.rows[0].chosen_languages;
+ req.allowNotifications = scopes.some(scope => ['read', 'read:notifications'].includes(scope));
next();
});
});
};
- const accountFromRequest = (req, next, required = true) => {
+ const accountFromRequest = (req, next, required = true, allowedScopes = ['read']) => {
const authorization = req.headers.authorization;
const location = url.parse(req.url, true);
- const accessToken = location.query.access_token;
+ const accessToken = location.query.access_token || req.headers['sec-websocket-protocol'];
if (!authorization && !accessToken) {
if (required) {
@@ -246,7 +257,7 @@ const startWorker = (workerId) => {
const token = authorization ? authorization.replace(/^Bearer /, '') : accessToken;
- accountFromToken(token, req, next);
+ accountFromToken(token, allowedScopes, req, next);
};
const PUBLIC_STREAMS = [
@@ -261,6 +272,16 @@ const startWorker = (workerId) => {
const wsVerifyClient = (info, cb) => {
const location = url.parse(info.req.url, true);
const authRequired = !PUBLIC_STREAMS.some(stream => stream === location.query.stream);
+ const allowedScopes = [];
+
+ if (authRequired) {
+ allowedScopes.push('read');
+ if (location.query.stream === 'user:notification') {
+ allowedScopes.push('read:notifications');
+ } else {
+ allowedScopes.push('read:statuses');
+ }
+ }
accountFromRequest(info.req, err => {
if (!err) {
@@ -269,7 +290,7 @@ const startWorker = (workerId) => {
log.error(info.req.requestId, err.toString());
cb(false, 401, 'Unauthorized');
}
- }, authRequired);
+ }, authRequired, allowedScopes);
};
const PUBLIC_ENDPOINTS = [
@@ -286,7 +307,18 @@ const startWorker = (workerId) => {
}
const authRequired = !PUBLIC_ENDPOINTS.some(endpoint => endpoint === req.path);
- accountFromRequest(req, next, authRequired);
+ const allowedScopes = [];
+
+ if (authRequired) {
+ allowedScopes.push('read');
+ if (req.path === '/api/v1/streaming/user/notification') {
+ allowedScopes.push('read:notifications');
+ } else {
+ allowedScopes.push('read:statuses');
+ }
+ }
+
+ accountFromRequest(req, next, authRequired, allowedScopes);
};
const errorMiddleware = (err, req, res, {}) => {
@@ -339,6 +371,10 @@ const startWorker = (workerId) => {
return;
}
+ if (event === 'notification' && !req.allowNotifications) {
+ return;
+ }
+
// Only send local-only statuses to logged-in users
if (payload.local_only && !req.accountId) {
log.silly(req.requestId, `Message ${payload.id} filtered because it was local-only`);