diff --git a/.env.production.sample b/.env.production.sample index e022a84059..91fcce6ac4 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,5 +1,6 @@ # Service dependencies # You may set REDIS_URL instead for more advanced options +# You may also set REDIS_NAMESPACE to share Redis between multiple Mastodon servers REDIS_HOST=redis REDIS_PORT=6379 # You may set DATABASE_URL instead for more advanced options diff --git a/.gitignore b/.gitignore index 38ebc934f2..2f5f1e71ad 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ public/system public/assets public/packs public/packs-test +public/500.html .env .env.production node_modules/ diff --git a/Dockerfile b/Dockerfile index 3ad2ad7efe..431ef5bbeb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,8 @@ ENV UID=991 GID=991 \ RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production +ARG YARN_VERSION=1.1.0 +ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 @@ -19,6 +21,7 @@ RUN apk -U upgrade \ build-base \ icu-dev \ libidn-dev \ + libressl \ libtool \ postgresql-dev \ protobuf-dev \ @@ -32,16 +35,21 @@ RUN apk -U upgrade \ imagemagick \ libidn \ libpq \ - nodejs-npm \ nodejs \ + nodejs-npm \ protobuf \ su-exec \ tini \ - yarn \ && update-ca-certificates \ + && mkdir -p /tmp/src /opt \ + && wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \ + && echo "$YARN_DOWNLOAD_SHA256 *yarn.tar.gz" | sha256sum -c - \ + && tar -xzf yarn.tar.gz -C /tmp/src \ + && rm yarn.tar.gz \ + && mv /tmp/src/yarn-v$YARN_VERSION /opt/yarn \ + && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \ && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ - && mkdir -p /tmp/src \ && tar -xzf libiconv.tar.gz -C /tmp/src \ && rm libiconv.tar.gz \ && cd /tmp/src/libiconv-$LIBICONV_VERSION \ @@ -56,7 +64,7 @@ COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ - && yarn --ignore-optional --pure-lockfile + && yarn --pure-lockfile COPY . /mastodon diff --git a/Gemfile b/Gemfile index 4f4861913e..09b3b8effd 100644 --- a/Gemfile +++ b/Gemfile @@ -67,7 +67,7 @@ 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', '~> 2.0' +gem 'webpacker', '~> 3.0' gem 'webpush' gem 'json-ld-preloaded', '~> 2.2.1' @@ -102,9 +102,10 @@ group :development do gem 'letter_opener', '~> 1.4' gem 'letter_opener_web', '~> 1.3' gem 'rubocop', require: false - gem 'brakeman', '~> 3.6', require: false - gem 'bundler-audit', '~> 0.5', require: false + gem 'brakeman', '~> 4.0', require: false + gem 'bundler-audit', '~> 0.6', require: false gem 'scss_lint', '~> 0.53', require: false + gem 'strong_migrations' gem 'capistrano', '~> 3.8' gem 'capistrano-rails', '~> 1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 97db3aa9ac..73419fd28a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -74,7 +74,7 @@ GEM debug_inspector (>= 0.0.1) bootsnap (1.1.3) msgpack (~> 1.0) - brakeman (3.7.2) + brakeman (4.0.1) browser (2.5.1) builder (3.2.3) bullet (5.6.1) @@ -335,6 +335,8 @@ GEM rack-cors (0.4.1) rack-protection (2.0.0) rack + rack-proxy (0.6.2) + rack rack-test (0.7.0) rack (>= 1.0, < 3) rack-timeout (0.4.2) @@ -482,6 +484,8 @@ GEM net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-instrument (2.1.4) + strong_migrations (0.1.9) + activerecord (>= 3.2.0) temple (0.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) @@ -508,9 +512,9 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (2.0) + webpacker (3.0.1) activesupport (>= 4.2) - multi_json (~> 1.2) + rack-proxy (>= 0.6.1) railties (>= 4.2) webpush (0.3.2) hkdf (~> 0.2) @@ -533,10 +537,10 @@ DEPENDENCIES better_errors (~> 2.1) binding_of_caller (~> 0.7) bootsnap - brakeman (~> 3.6) + brakeman (~> 4.0) browser bullet (~> 5.5) - bundler-audit (~> 0.5) + bundler-audit (~> 0.6) capistrano (~> 3.8) capistrano-rails (~> 1.2) capistrano-rbenv (~> 2.1) @@ -614,11 +618,12 @@ DEPENDENCIES simplecov (~> 0.14) sprockets-rails (~> 3.2) statsd-instrument (~> 2.1) + strong_migrations twitter-text (~> 1.14) tzinfo-data (~> 1.2017) uglifier (~> 3.2) webmock (~> 3.0) - webpacker (~> 2.0) + webpacker (~> 3.0) webpush RUBY VERSION diff --git a/Procfile.dev b/Procfile.dev index 9084b4263c..e75a491c7b 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,4 +1,4 @@ web: PORT=3000 bundle exec puma -C config/puma.rb sidekiq: PORT=3000 bundle exec sidekiq stream: PORT=4000 yarn run start -webpack: ./bin/webpack-dev-server --host 0.0.0.0 +webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 572ad1ac2e..d70514d9a9 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -3,7 +3,7 @@ module Admin class CustomEmojisController < BaseController def index - @custom_emojis = CustomEmoji.where(domain: nil) + @custom_emojis = CustomEmoji.local end def new diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb new file mode 100644 index 0000000000..4dd77fb550 --- /dev/null +++ b/app/controllers/api/v1/custom_emojis_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Api::V1::CustomEmojisController < Api::BaseController + respond_to :json + + def index + render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 0e19498979..8eb4d28225 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -17,12 +17,29 @@ class FollowerAccountsController < ApplicationController private + def page_url(page) + account_followers_url(@account, page: page) unless page.nil? + end + def collection_presenter - ActivityPub::CollectionPresenter.new( - id: account_followers_url(@account), + page = ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account, page: params.fetch(:page, 1)), type: :ordered, size: @account.followers_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, + part_of: account_followers_url(@account), + next: page_url(@follows.next_page), + prev: page_url(@follows.prev_page) ) + if params[:page].present? + page + else + ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account), + type: :ordered, + size: @account.followers_count, + first: page + ) + end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index d4593093ff..1ca6f0fe7d 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -17,12 +17,29 @@ class FollowingAccountsController < ApplicationController private + def page_url(page) + account_following_index_url(@account, page: page) unless page.nil? + end + def collection_presenter - ActivityPub::CollectionPresenter.new( - id: account_following_index_url(@account), + page = ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account, page: params.fetch(:page, 1)), type: :ordered, size: @account.following_count, - items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, + part_of: account_following_index_url(@account), + next: page_url(@follows.next_page), + prev: page_url(@follows.prev_page) ) + if params[:page].present? + page + else + ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account), + type: :ordered, + size: @account.following_count, + first: page + ) + end end end diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb deleted file mode 100644 index 848c03fce7..0000000000 --- a/app/helpers/emoji_helper.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module EmojiHelper - def emojify(text) - return text if text.blank? - - text.gsub(emoji_pattern) do |match| - emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs - - if emoji - emoji - else - match - end - end - end - - def emoji_pattern - @emoji_pattern ||= - /(?<=[^[:alnum:]:]|\n|^) - (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')}) - (?=[^[:alnum:]:]|$)/x - end -end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 5f265a7501..20cb09f582 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,4 +1,5 @@ import api from '../api'; +import { emojiIndex } from 'emoji-mart'; import { updateTimeline, @@ -213,19 +214,33 @@ export function clearComposeSuggestions() { export function fetchComposeSuggestions(token) { return (dispatch, getState) => { + if (token[0] === ':') { + const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); + return; + } + api(getState).get('/api/v1/accounts/search', { params: { - q: token, + q: token.slice(1), resolve: false, limit: 4, }, }).then(response => { - dispatch(readyComposeSuggestions(token, response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); }); }; }; -export function readyComposeSuggestions(token, accounts) { +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, + }; +}; + +export function readyComposeSuggestionsAccounts(token, accounts) { return { type: COMPOSE_SUGGESTIONS_READY, token, @@ -233,13 +248,21 @@ export function readyComposeSuggestions(token, accounts) { }; }; -export function selectComposeSuggestion(position, token, accountId) { +export function selectComposeSuggestion(position, token, suggestion) { return (dispatch, getState) => { - const completion = getState().getIn(['accounts', accountId, 'acct']); + let completion, startPosition; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = suggestion.native || suggestion.colons; + startPosition = position - 1; + } else { + completion = getState().getIn(['accounts', suggestion, 'acct']); + startPosition = position; + } dispatch({ type: COMPOSE_SUGGESTION_SELECT, - position, + position: startPosition, token, completion, }); diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index 0597d265ec..a1db0fdd51 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -5,8 +5,7 @@ export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; const convertState = rawState => fromJS(rawState, (k, v) => - Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => - Number.isNaN(x * 1) ? x : x * 1)); + Iterable.isIndexed(v) ? v.toList() : v.toMap()); export function hydrateStore(rawState) { const state = convertState(rawState); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 762cea4f73..7cdb8c672d 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -23,7 +23,7 @@ export default class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, - me: PropTypes.number.isRequired, + me: PropTypes.string.isRequired, onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js new file mode 100644 index 0000000000..e2866e8e47 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { unicodeMapping } from '../emojione_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const [ filename ] = unicodeMapping[emoji.native]; + url = `${assetHost}/emoji/${filename}.svg`; + } + + return ( +
+ {emoji.native + + {emoji.colons} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 35b37600fe..6f725885de 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -1,10 +1,12 @@ import React from 'react'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 2 || word[0] !== '@') { + if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) { return [null, null]; } - word = word.trim().toLowerCase().slice(1); + word = word.trim().toLowerCase(); if (word.length > 0) { return [left + 1, word]; @@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } onSuggestionClick = (e) => { - const suggestion = Number(e.currentTarget.getAttribute('data-index')); + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); this.textarea.focus(); @@ -151,9 +153,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = ; + key = suggestion.id; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; - const { suggestionsHidden, selectedSuggestion } = this.state; + const { suggestionsHidden } = this.state; const style = { direction: 'ltr' }; if (isRtl(value)) { @@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {