about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb78
-rw-r--r--app/controllers/custom_emojis_controller.rb97
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/custom_emoji.rb8
-rw-r--r--app/models/custom_emoji_filter.rb9
-rw-r--r--app/models/form/custom_emoji_batch.rb39
-rw-r--r--app/policies/custom_emoji_policy.rb36
-rw-r--r--app/views/custom_emojis/_custom_emoji.html.haml (renamed from app/views/admin/custom_emojis/_custom_emoji.html.haml)1
-rw-r--r--app/views/custom_emojis/index.html.haml (renamed from app/views/admin/custom_emojis/index.html.haml)41
-rw-r--r--app/views/custom_emojis/new.html.haml (renamed from app/views/admin/custom_emojis/new.html.haml)2
-rw-r--r--config/locales/en-MP.yml9
-rw-r--r--config/navigation.rb2
-rw-r--r--config/routes.rb12
-rw-r--r--db/migrate/20200919234917_add_account_to_custom_emoji.rb7
-rw-r--r--db/migrate/20200920084007_backfill_custom_emoji_ownership.rb12
-rw-r--r--db/schema.rb5
16 files changed, 247 insertions, 114 deletions
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
deleted file mode 100644
index 71efb543e..000000000
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ /dev/null
@@ -1,78 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class CustomEmojisController < BaseController
-    def index
-      authorize :custom_emoji, :index?
-
-      @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
-      @form          = Form::CustomEmojiBatch.new
-    end
-
-    def new
-      authorize :custom_emoji, :create?
-
-      @custom_emoji = CustomEmoji.new
-    end
-
-    def create
-      authorize :custom_emoji, :create?
-
-      @custom_emoji = CustomEmoji.new(resource_params)
-
-      if @custom_emoji.save
-        log_action :create, @custom_emoji
-        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
-      else
-        render :new
-      end
-    end
-
-    def batch
-      @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
-      @form.save
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.accounts.no_account_selected')
-    rescue Mastodon::NotPermittedError
-      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
-    ensure
-      redirect_to admin_custom_emojis_path(filter_params)
-    end
-
-    private
-
-    def resource_params
-      params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
-    end
-
-    def filtered_custom_emojis
-      CustomEmojiFilter.new(filter_params).results
-    end
-
-    def filter_params
-      params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS)
-    end
-
-    def action_from_button
-      if params[:update]
-        'update'
-      elsif params[:list]
-        'list'
-      elsif params[:unlist]
-        'unlist'
-      elsif params[:enable]
-        'enable'
-      elsif params[:disable]
-        'disable'
-      elsif params[:copy]
-        'copy'
-      elsif params[:delete]
-        'delete'
-      end
-    end
-
-    def form_custom_emoji_batch_params
-      params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
-    end
-  end
-end
diff --git a/app/controllers/custom_emojis_controller.rb b/app/controllers/custom_emojis_controller.rb
new file mode 100644
index 000000000..0ef8d0a50
--- /dev/null
+++ b/app/controllers/custom_emojis_controller.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+class CustomEmojisController < ApplicationController
+  include Authorization
+  include AccountableConcern
+
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :set_pack
+  before_action :set_body_classes
+
+  def index
+    authorize :custom_emoji, :index?
+
+    @custom_emojis = filtered_custom_emojis.eager_load(:local_counterpart).page(params[:page])
+    @form          = Form::CustomEmojiBatch.new
+  end
+
+  def new
+    authorize :custom_emoji, :create?
+
+    @custom_emoji = CustomEmoji.new(account: current_account)
+  end
+
+  def create
+    authorize :custom_emoji, :create?
+
+    @custom_emoji = CustomEmoji.new(resource_params.merge(account: current_account))
+
+    if @custom_emoji.save
+      log_action :create, @custom_emoji
+      redirect_to custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
+    else
+      render :new
+    end
+  end
+
+  def batch
+    @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button))
+    @form.save
+  rescue ActionController::ParameterMissing
+    flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+  rescue Mastodon::NotPermittedError
+    flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+  ensure
+    redirect_to custom_emojis_path(filter_params)
+  end
+
+  private
+
+  def resource_params
+    params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
+  end
+
+  def filtered_custom_emojis
+    CustomEmojiFilter.new(filter_params, current_account).results
+  end
+
+  def filter_params
+    params.slice(:page, *CustomEmojiFilter::KEYS).permit(:page, *CustomEmojiFilter::KEYS)
+  end
+
+  def action_from_button
+    if params[:update]
+      'update'
+    elsif params[:list]
+      'list'
+    elsif params[:unlist]
+      'unlist'
+    elsif params[:enable]
+      'enable'
+    elsif params[:disable]
+      'disable'
+    elsif params[:copy]
+      'copy'
+    elsif params[:delete]
+      'delete'
+    elsif params[:claim]
+      'claim'
+    elsif params[:unclaim]
+      'unclaim'
+    end
+  end
+
+  def form_custom_emoji_batch_params
+    params.require(:form_custom_emoji_batch).permit(:action, :category_id, :category_name, custom_emoji_ids: [])
+  end
+
+  def set_pack
+    use_pack 'settings'
+  end
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index a8b024346..71947fc22 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -78,5 +78,8 @@ module AccountAssociations
 
     # Collection items
     has_many :collection_items, inverse_of: :account, dependent: :destroy
+
+    # Custom emojis
+    has_many :custom_emojis, inverse_of: :account, dependent: :nullify
   end
 end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 7cb03b819..c819288ba 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -18,6 +18,7 @@
 #  visible_in_picker            :boolean          default(TRUE), not null
 #  category_id                  :bigint(8)
 #  image_storage_schema_version :integer
+#  account_id                   :bigint(8)
 #
 
 class CustomEmoji < ApplicationRecord
@@ -32,6 +33,7 @@ class CustomEmoji < ApplicationRecord
   IMAGE_MIME_TYPES = %w(image/png image/gif).freeze
 
   belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
+  belongs_to :account, inverse_of: :custom_emojis, optional: true
   has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
 
   has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
@@ -46,6 +48,7 @@ class CustomEmoji < ApplicationRecord
   scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :listed, -> { local.where(disabled: false).where(visible_in_picker: true) }
+  scope :owned_by, ->(account) { where(account: account) }
 
   remotable_attachment :image, LIMIT
 
@@ -61,8 +64,11 @@ class CustomEmoji < ApplicationRecord
     :emoji
   end
 
-  def copy!
+  def copy!(current_account = nil)
     copy = self.class.find_or_initialize_by(domain: nil, shortcode: shortcode)
+    return copy if copy.account_id.present? && copy.account_id != current_account&.id
+
+    copy.account = current_account
     copy.image = image
     copy.tap(&:save!)
   end
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
index 414e1fcdd..58c888518 100644
--- a/app/models/custom_emoji_filter.rb
+++ b/app/models/custom_emoji_filter.rb
@@ -5,13 +5,16 @@ class CustomEmojiFilter
     local
     remote
     by_domain
+    claimed
+    unclaimed
     shortcode
   ).freeze
 
   attr_reader :params
 
-  def initialize(params)
+  def initialize(params, account)
     @params = params
+    @account = account
   end
 
   def results
@@ -36,6 +39,10 @@ class CustomEmojiFilter
       CustomEmoji.remote
     when 'by_domain'
       CustomEmoji.where(domain: value.strip.downcase)
+    when 'claimed'
+      CustomEmoji.where(account: @account)
+    when 'unclaimed'
+      CustomEmoji.where(account: nil)
     when 'shortcode'
       CustomEmoji.search(value.strip)
     else
diff --git a/app/models/form/custom_emoji_batch.rb b/app/models/form/custom_emoji_batch.rb
index f4fa84c10..54a15dc18 100644
--- a/app/models/form/custom_emoji_batch.rb
+++ b/app/models/form/custom_emoji_batch.rb
@@ -24,13 +24,17 @@ class Form::CustomEmojiBatch
       copy!
     when 'delete'
       delete!
+    when 'claim'
+      claim!
+    when 'unclaim'
+      unclaim!
     end
   end
 
   private
 
-  def custom_emojis
-    @custom_emojis ||= CustomEmoji.where(id: custom_emoji_ids)
+  def custom_emojis(include_all = false)
+    @custom_emojis ||= (include_all || current_account&.user&.staff? ? CustomEmoji.where(id: custom_emoji_ids) : CustomEmoji.local.where(id: custom_emoji_ids, account: current_account))
   end
 
   def update!
@@ -40,10 +44,12 @@ class Form::CustomEmojiBatch
       if category_id.present?
         CustomEmojiCategory.find(category_id)
       elsif category_name.present?
-        CustomEmojiCategory.find_or_create_by!(name: category_name)
+        CustomEmojiCategory.find_or_create_by!(name: current_account&.user&.staff? ? category_name.strip : "(@#{current_account.username}) #{category_name}".rstrip)
       end
     end
 
+    return if category.name.start_with?('(@') && !category.name.start_with?("(@#{current_account.username}) ")
+
     custom_emojis.each do |custom_emoji|
       custom_emoji.update(category_id: category&.id)
       log_action :update, custom_emoji
@@ -87,10 +93,10 @@ class Form::CustomEmojiBatch
   end
 
   def copy!
-    custom_emojis.each { |custom_emoji| authorize(custom_emoji, :copy?) }
+    custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :copy?) }
 
     custom_emojis.each do |custom_emoji|
-      copied_custom_emoji = custom_emoji.copy!
+      copied_custom_emoji = custom_emoji.copy!(current_account)
       log_action :create, copied_custom_emoji
     end
   end
@@ -103,4 +109,27 @@ class Form::CustomEmojiBatch
       log_action :destroy, custom_emoji
     end
   end
+
+  def claim!
+    custom_emojis(true).each { |custom_emoji| authorize(custom_emoji, :claim?) }
+
+    custom_emojis.each do |custom_emoji|
+      if custom_emoji.local?
+        custom_emoji.update(account: current_account)
+        log_action :update, custom_emoji
+      else
+        copied_custom_emoji = custom_emoji.copy!(current_account)
+        log_action :create, copied_custom_emoji
+      end
+    end
+  end
+
+  def unclaim!
+    custom_emojis.each { |custom_emoji| authorize(custom_emoji, :unclaim?) }
+
+    custom_emojis.each do |custom_emoji|
+      custom_emoji.update(account: nil)
+      log_action :update, custom_emoji
+    end
+  end
 end
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index a8c3cbc73..7e585a3d6 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -2,30 +2,52 @@
 
 class CustomEmojiPolicy < ApplicationPolicy
   def index?
-    staff?
+    user_signed_in?
   end
 
   def create?
-    admin?
+    user_signed_in?
   end
 
   def update?
-    admin?
+    user_signed_in? && owned?
   end
 
   def copy?
-    admin?
+    staff? || (user_signed_in? && new_or_owned?)
   end
 
   def enable?
-    staff?
+    user_signed_in? && owned?
   end
 
   def disable?
-    staff?
+    user_signed_in? && owned?
   end
 
   def destroy?
-    admin?
+    user_signed_in? && owned?
+  end
+
+  def claim?
+    staff? || claimable?
+  end
+
+  def unclaim?
+    user_signed_in? && owned?
+  end
+
+  private
+
+  def owned?
+    staff? || (current_account.present? && record.account_id == current_account.id)
+  end
+
+  def new_or_owned?
+    !CustomEmoji.where(domain: nil, shortcode: record.shortcode).where('account_id IS NULL OR account_id != ?', current_account.id).exists?
+  end
+
+  def claimable?
+    record.local? ? record.account_id.blank? || record.account_id == current_account.id : new_or_owned?
   end
 end
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/custom_emojis/_custom_emoji.html.haml
index 526c844e9..e124373c6 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/custom_emojis/_custom_emoji.html.haml
@@ -7,6 +7,7 @@
 
     .batch-table__row__content__text
       %samp= ":#{custom_emoji.shortcode}:"
+      %p.hint.muted-hint{ title: t('admin.custom_emojis.owner') }= custom_emoji.account_id.present? ? "@#{custom_emoji.account.username}" : t('admin.custom_emojis.unclaimed') if custom_emoji.local?
 
       - if custom_emoji.local?
         %span.account-role.bot= custom_emoji.category&.name || t('admin.custom_emojis.uncategorized')
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/custom_emojis/index.html.haml
index b6cf7ba64..f81d91d53 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/custom_emojis/index.html.haml
@@ -3,7 +3,9 @@
 
 - if can?(:create, :custom_emoji)
   - content_for :heading_actions do
-    = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
+    = link_to t('admin.custom_emojis.upload'), new_custom_emoji_path, class: 'button'
+
+%p= t('admin.custom_emojis.ownership_warning')
 
 .filters
   .filter-subset
@@ -11,17 +13,27 @@
     %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}
+        - if selected? local: '1', remote: nil, claimed: nil, unclaimed: nil
+          = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: nil}
+        - else
+          = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil, claimed: nil, unclaimed: nil
+      %li
+        - if selected? remote: '1', local: nil, claimed: nil, unclaimed: nil
+          = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil, claimed: nil, unclaimed: nil}, {remote: '1', local: nil, claimed: nil, unclaimed: nil}
         - else
-          = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil
+          = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil, claimed: nil, unclained: nil
       %li
-        - if selected? remote: '1', local: nil
-          = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil}
+        - if selected? local: '1', remote: nil, claimed: '1', unclaimed: nil
+          = filter_link_to t('admin.accounts.location.claimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: '1', unclaimed: nil}
         - else
-          = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil
+          = filter_link_to t('admin.accounts.location.claimed'), local: '1', remote: nil, claimed: '1', unclaimed: nil
+      %li
+        - if selected? local: '1', remote: nil, claimed: nil, unclaimed: '1'
+          = filter_link_to t('admin.accounts.location.unclaimed'), {local: '1', remote: nil, claimed: nil, unclaimed: nil}, {local: '1', remote: nil, claimed: nil, unclaimed: '1'}
+        - else
+          = filter_link_to t('admin.accounts.location.unclaimed'), local: '1', remote: nil, claimed: nil, unclaimed: '1'
 
-= form_tag admin_custom_emojis_url, method: 'GET', class: 'simple_form' do
+= form_tag custom_emojis_url, method: 'GET', class: 'simple_form' do
   .fields-group
     - CustomEmojiFilter::KEYS.each do |key|
       = hidden_field_tag key, params[key] if params[key].present?
@@ -32,9 +44,9 @@
 
     .actions
       %button.button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
+      = link_to t('admin.accounts.reset'), custom_emojis_path, class: 'button negative'
 
-= form_for(@form, url: batch_admin_custom_emojis_path) do |f|
+= form_for(@form, url: batch_custom_emojis_path) do |f|
   = hidden_field_tag :page, params[:page] || 1
 
   - CustomEmojiFilter::KEYS.each do |key|
@@ -45,6 +57,10 @@
       %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
       .batch-table__toolbar__actions
+        = f.button safe_join([fa_icon('lock'), t('admin.custom_emojis.claim')]), name: :claim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
+        = f.button safe_join([fa_icon('unlock'), t('admin.custom_emojis.unclaim')]), name: :unclaim, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+
         - if params[:local] == '1'
           = f.button safe_join([fa_icon('save'), t('generic.save_changes')]), name: :update, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
@@ -56,10 +72,9 @@
 
         = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        - if can?(:destroy, :custom_emoji)
-          = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        - if can?(:copy, :custom_emoji) && params[:local] != '1'
+        - if params[:local] != '1'
           = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
     - if params[:local] == '1'
diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/custom_emojis/new.html.haml
index e15a07cb8..fe9d8fc64 100644
--- a/app/views/admin/custom_emojis/new.html.haml
+++ b/app/views/custom_emojis/new.html.haml
@@ -1,7 +1,7 @@
 - content_for :page_title do
   = t('.title')
 
-= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f|
+= simple_form_for @custom_emoji, url: custom_emojis_path do |f|
   = render 'shared/error_messages', object: @custom_emoji
 
   .fields-group
diff --git a/config/locales/en-MP.yml b/config/locales/en-MP.yml
index d964e5c03..6c4d37c9f 100644
--- a/config/locales/en-MP.yml
+++ b/config/locales/en-MP.yml
@@ -14,6 +14,9 @@ en-MP:
       silenced: "Posts from these servers will be hidden in public timelines and no notifications will be generated from their users' interactions, unless you are following them or vise-versa.  These are typically set for curation purposes rather than "
   accounts:
     endorsements_hint: You can endorse creatures you follow from the web interface, and they will show up here.
+    location:
+      claimed: Claimed
+      unclaimed: Unclaimed
     people_followed_by: Creatures whom %{name} follows
     people_who_follow: Creatures who follow %{name}
     pin_errors:
@@ -34,6 +37,12 @@ en-MP:
       actions:
         update_status: "%{name} updated roar by %{target}"
       deleted_status: "(deleted roar)"
+    custom_emojis:
+      claim: Claim
+      owner: Contributor
+      ownership_warning: "NOTE: You can only make changes to custom emoji that you upload or copy unless you are a moderator."
+      unclaim: Unclaim
+      unclaimed: (unclaimed)
     dashboard:
       pending_users: creatures waiting for review
       feature_hcaptcha: hCaptcha
diff --git a/config/navigation.rb b/config/navigation.rb
index 7f292af3f..364747adb 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -38,6 +38,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
     end
 
+    n.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), custom_emojis_url, highlights_on: %r{/custom_emojis}
     n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? }
     n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
 
@@ -55,7 +56,6 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url
       s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings}
       s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}
-      s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis}
       s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays}
       s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? }
       s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index 33343625c..4e36fe074 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -188,6 +188,12 @@ Rails.application.routes.draw do
   resources :filters, except: [:show]
   resource :relationships, only: [:show, :update]
 
+  resources :custom_emojis, only: [:index, :new, :create] do
+    collection do
+      post :batch
+    end
+  end
+
   get '/public', to: 'public_timelines#show', as: :public_timeline
   get '/media_proxy/:id/(*any)', to: 'media_proxy#show', as: :media_proxy
 
@@ -287,12 +293,6 @@ Rails.application.routes.draw do
       resource :two_factor_authentication, only: [:destroy]
     end
 
-    resources :custom_emojis, only: [:index, :new, :create] do
-      collection do
-        post :batch
-      end
-    end
-
     resources :account_moderation_notes, only: [:create, :destroy]
 
     resources :tags, only: [:index, :show, :update] do
diff --git a/db/migrate/20200919234917_add_account_to_custom_emoji.rb b/db/migrate/20200919234917_add_account_to_custom_emoji.rb
new file mode 100644
index 000000000..b4466ee30
--- /dev/null
+++ b/db/migrate/20200919234917_add_account_to_custom_emoji.rb
@@ -0,0 +1,7 @@
+class AddAccountToCustomEmoji < ActiveRecord::Migration[5.2]
+  def change
+    safety_assured do
+      add_reference :custom_emojis, :account, foreign_key: { on_delete: :nullify }, index: true
+    end
+  end
+end
diff --git a/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb b/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb
new file mode 100644
index 000000000..1542bdb5e
--- /dev/null
+++ b/db/migrate/20200920084007_backfill_custom_emoji_ownership.rb
@@ -0,0 +1,12 @@
+class BackfillCustomEmojiOwnership < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    site_contact = Account.site_contact
+    CustomEmoji.local.in_batches.update_all(account_id: site_contact.id)
+  end
+
+  def down
+    nil
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fbcf8e636..eb0605314 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: 2020_09_07_195410) do
+ActiveRecord::Schema.define(version: 2020_09_20_084007) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -357,6 +357,8 @@ ActiveRecord::Schema.define(version: 2020_09_07_195410) do
     t.boolean "visible_in_picker", default: true, null: false
     t.bigint "category_id"
     t.integer "image_storage_schema_version"
+    t.bigint "account_id"
+    t.index ["account_id"], name: "index_custom_emojis_on_account_id"
     t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
   end
 
@@ -1069,6 +1071,7 @@ ActiveRecord::Schema.define(version: 2020_09_07_195410) do
   add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
   add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
   add_foreign_key "conversations", "accounts"
+  add_foreign_key "custom_emojis", "accounts", on_delete: :nullify
   add_foreign_key "custom_filters", "accounts", on_delete: :cascade
   add_foreign_key "devices", "accounts", on_delete: :cascade
   add_foreign_key "devices", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade