diff --git a/Dockerfile b/Dockerfile index aefcb44bde..eca90094c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM node:8.15-alpine as node -FROM ruby:2.6-alpine3.8 +FROM ruby:2.6-alpine3.9 LABEL maintainer="https://github.com/tootsuite/mastodon" \ description="Your self-hosted, globally interconnected microblogging community" @@ -24,19 +24,18 @@ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules COPY --from=node /usr/local/bin/npm /usr/local/bin/npm COPY --from=node /opt/yarn-* /opt/yarn -RUN apk -U upgrade \ - && apk add -t build-dependencies \ +RUN apk add --no-cache -t build-dependencies \ build-base \ icu-dev \ libidn-dev \ - libressl \ + openssl \ libtool \ libxml2-dev \ libxslt-dev \ postgresql-dev \ protobuf-dev \ python \ - && apk add \ + && apk add --no-cache \ ca-certificates \ ffmpeg \ file \ @@ -64,7 +63,7 @@ RUN apk -U upgrade \ && make install \ && libtool --finish /usr/local/lib \ && cd /mastodon \ - && rm -rf /tmp/* /var/cache/apk/* + && rm -rf /tmp/* COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/ COPY stack-fix.c /lib diff --git a/Gemfile b/Gemfile index 51595a7589..fcf97b028c 100644 --- a/Gemfile +++ b/Gemfile @@ -108,15 +108,15 @@ group :production, :test do end group :test do - gem 'capybara', '~> 3.12' + gem 'capybara', '~> 3.13' gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.9' - gem 'microformats', '~> 4.0' + gem 'microformats', '~> 4.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.16', require: false gem 'webmock', '~> 3.5' - gem 'parallel_tests', '~> 2.27' + gem 'parallel_tests', '~> 2.28' end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 76bcdeda53..957ca70a62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -126,7 +126,7 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (3.12.0) + capybara (3.13.2) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -268,7 +268,7 @@ GEM domain_name (~> 0.5) http-form_data (2.1.1) http_accept_language (2.1.1) - httplog (1.2.0) + httplog (1.2.1) rack (>= 1.0) rainbow (>= 2.0.0) i18n (1.5.3) @@ -337,9 +337,9 @@ GEM redis (>= 3.0.5) memory_profiler (0.9.12) method_source (0.9.2) - microformats (4.0.7) - json - nokogiri + microformats (4.1.0) + json (~> 2.1) + nokogiri (~> 1.8, >= 1.8.3) mime-types (3.2.2) mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) @@ -392,7 +392,7 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.13.0) - parallel_tests (2.27.1) + parallel_tests (2.28.0) parallel parser (2.6.0.0) ast (~> 2.4.0) @@ -671,7 +671,7 @@ DEPENDENCIES capistrano-rails (~> 1.4) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 3.12) + capybara (~> 3.13) charlock_holmes (~> 0.7.6) chewy (~> 5.0) cld3 (~> 3.2.3) @@ -712,7 +712,7 @@ DEPENDENCIES makara (~> 0.4) mario-redis-lock (~> 1.2) memory_profiler - microformats (~> 4.0) + microformats (~> 4.1) mime-types (~> 3.2) net-ldap (~> 0.10) nokogiri (~> 1.10) @@ -725,7 +725,7 @@ DEPENDENCIES ox (~> 2.10) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel_tests (~> 2.27) + parallel_tests (~> 2.28) pg (~> 1.1) pghero (~> 2.2) pkg-config (~> 1.3) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 3a4382850a..442e99089e 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -53,11 +53,12 @@ class AccountsController < ApplicationController private def show_pinned_statuses? - [replies_requested?, media_requested?, params[:max_id].present?, params[:min_id].present?].none? + [replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none? end def filtered_statuses default_statuses.tap do |statuses| + statuses.merge!(hashtag_scope) if tag_requested? statuses.merge!(only_media_scope) if media_requested? statuses.merge!(no_replies_scope) unless replies_requested? end @@ -79,12 +80,15 @@ class AccountsController < ApplicationController Status.without_replies end + def hashtag_scope + Status.tagged_with(Tag.find_by(name: params[:tag].downcase)&.id) + end + def set_account @account = Account.find_local!(params[:username]) end def older_url - ::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}") pagination_url(max_id: @statuses.last.id) end @@ -93,7 +97,9 @@ class AccountsController < ApplicationController end def pagination_url(max_id: nil, min_id: nil) - if media_requested? + if tag_requested? + short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id) + elsif media_requested? short_account_media_url(@account, max_id: max_id, min_id: min_id) elsif replies_requested? short_account_with_replies_url(@account, max_id: max_id, min_id: min_id) @@ -110,6 +116,10 @@ class AccountsController < ApplicationController request.path.ends_with?('/with_replies') end + def tag_requested? + request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize) + end + def filtered_status_page(params) if params[:min_id].present? filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 6c2a5c1414..6fdc827cb2 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -33,6 +33,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController statuses.merge!(only_media_scope) if truthy_param?(:only_media) statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) + statuses.merge!(hashtag_scope) if params[:tagged].present? statuses end @@ -67,6 +68,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController Status.without_reblogs end + def hashtag_scope + Status.tagged_with(Tag.find_by(name: params[:tagged])&.id) + end + def pagination_params(core_params) params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params) end diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index 1e420b3e7f..4e45445df4 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio before_action :store_current_location before_action :authenticate_resource_owner! before_action :set_pack + before_action :set_body_classes include Localized @@ -16,6 +17,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio private + def set_body_classes + @body_classes = 'admin' + end + def store_current_location store_location_for(:user, request.url) end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb new file mode 100644 index 0000000000..3a3241425d --- /dev/null +++ b/app/controllers/settings/featured_tags_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class Settings::FeaturedTagsController < Settings::BaseController + layout 'admin' + + before_action :authenticate_user! + before_action :set_featured_tags, only: :index + before_action :set_featured_tag, except: [:index, :create] + before_action :set_most_used_tags, only: :index + + def index + @featured_tag = FeaturedTag.new + end + + def create + @featured_tag = current_account.featured_tags.new(featured_tag_params) + @featured_tag.reset_data + + if @featured_tag.save + redirect_to settings_featured_tags_path + else + set_featured_tags + set_most_used_tags + + render :index + end + end + + def destroy + @featured_tag.destroy! + redirect_to settings_featured_tags_path + end + + private + + def set_featured_tag + @featured_tag = current_account.featured_tags.find(params[:id]) + end + + def set_featured_tags + @featured_tags = current_account.featured_tags.order(statuses_count: :desc).reject(&:new_record?) + end + + def set_most_used_tags + @most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) + end + + def featured_tag_params + params.require(:featured_tag).permit(:name) + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 1a0b73d169..76d599f08d 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -29,6 +29,6 @@ class Settings::ProfilesController < Settings::BaseController end def set_account - @account = current_user.account + @account = current_account end end diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb index 780ea64b40..d74db6000e 100644 --- a/app/controllers/settings/sessions_controller.rb +++ b/app/controllers/settings/sessions_controller.rb @@ -2,6 +2,7 @@ # Intentionally does not inherit from BaseController class Settings::SessionsController < ApplicationController + before_action :authenticate_user! before_action :set_session, only: :destroy def destroy diff --git a/app/javascript/flavours/glitch/actions/alerts.js b/app/javascript/flavours/glitch/actions/alerts.js index f37fdeeb6a..50cd48a9ed 100644 --- a/app/javascript/flavours/glitch/actions/alerts.js +++ b/app/javascript/flavours/glitch/actions/alerts.js @@ -1,3 +1,10 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, + unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, +}); + export const ALERT_SHOW = 'ALERT_SHOW'; export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_CLEAR = 'ALERT_CLEAR'; @@ -15,10 +22,28 @@ export function clearAlert() { }; }; -export function showAlert(title, message) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { return { type: ALERT_SHOW, title, message, }; }; + +export function showAlertForError(error) { + if (error.response) { + const { data, status, statusText } = error.response; + + let message = statusText; + let title = `${status}`; + + if (data.error) { + message = data.error; + } + + return showAlert(title, message); + } else { + console.error(error); + return showAlert(); + } +} diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index 04e8577132..0dd1766bc7 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -1,5 +1,5 @@ import api from 'flavours/glitch/util/api'; -import { CancelToken } from 'axios'; +import { CancelToken, isCancel } from 'axios'; import { throttle } from 'lodash'; import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light'; import { useEmoji } from './emojis'; @@ -8,6 +8,9 @@ import { recoverHashtags } from 'flavours/glitch/util/hashtag'; import resizeImage from 'flavours/glitch/util/resize_image'; import { updateTimeline } from './timelines'; +import { showAlertForError } from './alerts'; +import { showAlert } from './alerts'; +import { defineMessages } from 'react-intl'; let cancelFetchComposeSuggestionsAccounts; @@ -52,6 +55,10 @@ export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET'; +const messages = defineMessages({ + uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, +}); + export function changeCompose(text) { return { type: COMPOSE_CHANGE, @@ -207,20 +214,32 @@ export function doodleSet(options) { export function uploadCompose(files) { return function (dispatch, getState) { - if (getState().getIn(['compose', 'media_attachments']).size > 3) { + const uploadLimit = 4; + const media = getState().getIn(['compose', 'media_attachments']); + const total = Array.from(files).reduce((a, v) => a + v.size, 0); + const progress = new Array(files.length).fill(0); + + if (files.length + media.size > uploadLimit) { + dispatch(showAlert(undefined, messages.uploadErrorLimit)); return; } - dispatch(uploadComposeRequest()); - resizeImage(files[0]).then(file => { - const data = new FormData(); - data.append('file', file); + for (const [i, f] of Array.from(files).entries()) { + if (media.size + i > 3) break; - return api(getState).post('/api/v1/media', data, { - onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)), - }).then(({ data }) => dispatch(uploadComposeSuccess(data))); - }).catch(error => dispatch(uploadComposeFail(error))); + resizeImage(f).then(file => { + const data = new FormData(); + data.append('file', file); + + return api(getState).post('/api/v1/media', data, { + onUploadProgress: function({ loaded }){ + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }, + }).then(({ data }) => dispatch(uploadComposeSuccess(data))); + }).catch(error => dispatch(uploadComposeFail(error))); + }; }; }; @@ -320,6 +339,10 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => }, }).then(response => { dispatch(readyComposeSuggestionsAccounts(token, response.data)); + }).catch(error => { + if (!isCancel(error)) { + dispatch(showAlertForError(error)); + } }); }, 200, { leading: true, trailing: true }); diff --git a/app/javascript/flavours/glitch/actions/lists.js b/app/javascript/flavours/glitch/actions/lists.js index 7d94ee950e..f29ca1e010 100644 --- a/app/javascript/flavours/glitch/actions/lists.js +++ b/app/javascript/flavours/glitch/actions/lists.js @@ -1,4 +1,5 @@ import api from 'flavours/glitch/util/api'; +import { showAlertForError } from './alerts'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; @@ -239,7 +240,8 @@ export const fetchListSuggestions = q => (dispatch, getState) => { }; api(getState).get('/api/v1/accounts/search', { params }) - .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))); + .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))) + .catch(error => dispatch(showAlertForError(error))); }; export const fetchListSuggestionsReady = (query, accounts) => ({ diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js index 91f442415a..8fdb239f72 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js +++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js @@ -109,14 +109,11 @@ export function register () { pushNotificationsSetting.remove(me); } - try { - getRegistration() - .then(getPushSubscription) - .then(unsubscribe); - } catch (e) { - - } - }); + return getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + }) + .catch(console.warn); } else { console.warn('Your browser does not support Web Push Notifications.'); } @@ -137,6 +134,6 @@ export function saveSettings() { if (me) { pushNotificationsSetting.set(me, data); } - }); + }).catch(console.warn); }; } diff --git a/app/javascript/flavours/glitch/actions/settings.js b/app/javascript/flavours/glitch/actions/settings.js index 87b2ae76d9..fb0bcc09ce 100644 --- a/app/javascript/flavours/glitch/actions/settings.js +++ b/app/javascript/flavours/glitch/actions/settings.js @@ -1,5 +1,6 @@ import api from 'flavours/glitch/util/api'; import { debounce } from 'lodash'; +import { showAlertForError } from './alerts'; export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_SAVE = 'SETTING_SAVE'; @@ -23,7 +24,9 @@ const debouncedSave = debounce((dispatch, getState) => { const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS(); - api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE })); + api(getState).put('/api/web/settings', { data }) + .then(() => dispatch({ type: SETTING_SAVE })) + .catch(error => dispatch(showAlertForError(error))); }, 5000, { trailing: true }); export function saveSettings() { diff --git a/app/javascript/flavours/glitch/containers/compose_container.js b/app/javascript/flavours/glitch/containers/compose_container.js index 60f6a9c9f2..74c411b7c1 100644 --- a/app/javascript/flavours/glitch/containers/compose_container.js +++ b/app/javascript/flavours/glitch/containers/compose_container.js @@ -7,6 +7,7 @@ import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from 'mastodon/locales'; import Compose from 'flavours/glitch/features/standalone/compose'; import initialState from 'flavours/glitch/util/initial_state'; +import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; const { localeData, messages } = getLocale(); addLocaleData(localeData); @@ -17,6 +18,8 @@ if (initialState) { store.dispatch(hydrateStore(initialState)); } +store.dispatch(fetchCustomEmojis()); + export default class TimelineContainer extends React.PureComponent { static propTypes = { diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index f28dce609e..f783878b00 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -25,6 +25,7 @@ import { openModal } from 'flavours/glitch/actions/modal'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; +import { showAlertForError } from '../actions/alerts'; const messages = defineMessages({ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, @@ -134,7 +135,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onEmbed (status) { - dispatch(openModal('EMBED', { url: status.get('url') })); + dispatch(openModal('EMBED', { + url: status.get('url'), + onError: error => dispatch(showAlertForError(error)), + })); }, onDelete (status, history, withRedraft = false) { diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js index 9fe3abc03e..5b4a7444c0 100644 --- a/app/javascript/flavours/glitch/features/composer/options/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/index.js @@ -214,6 +214,7 @@ export default class ComposerOptions extends React.PureComponent { onChange={handleChangeFiles} ref={handleRefFileElement} type='file' + multiple {...hiddenComponent} />