about summary refs log tree commit diff
path: root/config
diff options
context:
space:
mode:
Diffstat (limited to 'config')
-rw-r--r--config/environments/production.rb12
-rw-r--r--config/i18n-tasks.yml1
-rw-r--r--config/initializers/0_duplicate_migrations.rb52
-rw-r--r--config/initializers/content_security_policy.rb64
-rw-r--r--config/initializers/cors.rb4
-rw-r--r--config/initializers/locale.rb7
-rw-r--r--config/locales-glitch/en.yml25
-rw-r--r--config/locales-glitch/es.yml25
-rw-r--r--config/locales-glitch/ja.yml6
-rw-r--r--config/locales-glitch/pl.yml6
-rw-r--r--config/locales-glitch/simple_form.en.yml20
-rw-r--r--config/locales-glitch/simple_form.es.yml20
-rw-r--r--config/locales-glitch/simple_form.ja.yml6
-rw-r--r--config/locales-glitch/simple_form.pl.yml10
-rw-r--r--config/navigation.rb6
-rw-r--r--config/routes.rb6
-rw-r--r--config/settings.yml17
-rw-r--r--config/themes.yml3
-rw-r--r--config/webpack/configuration.js57
-rw-r--r--config/webpack/generateLocalePacks.js94
-rw-r--r--config/webpack/rules/babel.js1
-rw-r--r--config/webpack/rules/css.js3
-rw-r--r--config/webpack/shared.js75
-rw-r--r--config/webpacker.yml1
24 files changed, 408 insertions, 113 deletions
diff --git a/config/environments/production.rb b/config/environments/production.rb
index aaad2449f..c2e8210f8 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -105,10 +105,14 @@ Rails.application.configure do
   config.action_mailer.delivery_method = ENV.fetch('SMTP_DELIVERY_METHOD', 'smtp').to_sym
 
   config.action_dispatch.default_headers = {
-    'Server'                 => 'Mastodon',
-    'X-Frame-Options'        => 'DENY',
-    'X-Content-Type-Options' => 'nosniff',
-    'X-XSS-Protection'       => '1; mode=block',
+    'Server'                  => 'Mastodon',
+    'X-Frame-Options'         => 'DENY',
+    'X-Content-Type-Options'  => 'nosniff',
+    'X-XSS-Protection'        => '1; mode=block',
+    'Referrer-Policy'         => 'same-origin',
+    'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
+    'X-Clacks-Overhead' => 'GNU Natalie Nguyen'
+
   }
 
   config.x.otp_secret = ENV.fetch('OTP_SECRET')
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 2dc6f880b..e09f4262b 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -59,6 +59,7 @@ ignore_unused:
   - 'errors.429'
   - 'admin.accounts.roles.*'
   - 'admin.action_logs.actions.*'
+  - 'themes.*'
   - 'statuses.attached.*'
   - 'move_handler.carry_{mutes,blocks}_over_text'
 
diff --git a/config/initializers/0_duplicate_migrations.rb b/config/initializers/0_duplicate_migrations.rb
new file mode 100644
index 000000000..194aff70c
--- /dev/null
+++ b/config/initializers/0_duplicate_migrations.rb
@@ -0,0 +1,52 @@
+# Some migrations have been present in glitch-soc for a long time and have then
+# been merged in upstream Mastodon, under a different version number.
+#
+# This puts us in an uneasy situation in which if we remove upstream's
+# migration file, people migrating from upstream will end up having a conflict
+# with their already-ran migration.
+#
+# On the other hand, if we keep upstream's migration and remove our own,
+# any current glitch-soc user will have a conflict during migration.
+#
+# For lack of a better solution, as those migrations are indeed identical,
+# we decided monkey-patching Rails' Migrator to completely ignore the duplicate,
+# keeping only the one that has run, or an arbitrary one.
+
+ALLOWED_DUPLICATES = [20180410220657, 20180831171112].freeze
+
+module ActiveRecord
+  class Migrator
+    def self.new(direction, migrations, target_version = nil)
+      migrated = Set.new(Base.connection.migration_context.get_all_versions)
+
+      migrations.group_by(&:name).each do |name, duplicates|
+        if duplicates.length > 1 && duplicates.all? { |m| ALLOWED_DUPLICATES.include?(m.version) }
+          # We have a set of allowed duplicates. Keep the migrated one, if any.
+          non_migrated = duplicates.reject { |m| migrated.include?(m.version.to_i) }
+
+          if duplicates.length == non_migrated.length || non_migrated.length == 0
+            # There weren't any migrated one, so we have to pick one “canonical” migration
+            migrations = migrations - duplicates[1..-1]
+          else
+            # Just reject every duplicate which hasn't been migrated yet
+            migrations = migrations - non_migrated
+          end
+        end
+      end
+
+      super(direction, migrations, target_version)
+    end
+  end
+
+  class MigrationContext
+    def needs_migration?
+      # A set of duplicated migrations is considered migrated if at least one of
+      # them is migrated.
+      migrated = get_all_versions
+      migrations.group_by(&:name).each do |name, duplicates|
+        return true unless duplicates.any? { |m| migrated.include?(m.version.to_i) }
+      end
+      return false
+    end
+  end
+end
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index 98dc711e1..68d3751fc 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -2,43 +2,45 @@
 # For further information see the following documentation
 # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
 
-def host_to_url(str)
-  "http#{Rails.configuration.x.use_https ? 's' : ''}://#{str}" unless str.blank?
-end
+if Rails.env.production?
+  assets_host = Rails.configuration.action_controller.asset_host || "https://#{ENV['WEB_DOMAIN'] || ENV['LOCAL_DOMAIN']}"
+  data_hosts = [assets_host]
 
-base_host = Rails.configuration.x.web_domain
+  if ENV['S3_ENABLED'] == 'true'
+    attachments_host = "https://#{ENV['S3_ALIAS_HOST'] || ENV['S3_CLOUDFRONT_HOST'] || ENV['S3_HOSTNAME'] || "s3-#{ENV['S3_REGION'] || 'us-east-1'}.amazonaws.com"}"
+    attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}"
+  elsif ENV['SWIFT_ENABLED'] == 'true'
+    attachments_host = ENV['SWIFT_OBJECT_URL']
+    attachments_host = "https://#{Addressable::URI.parse(attachments_host).host}"
+  else
+    attachments_host = nil
+  end
 
-assets_host   = Rails.configuration.action_controller.asset_host
-assets_host ||= host_to_url(base_host)
+  data_hosts << attachments_host unless attachments_host.nil?
 
-media_host   = host_to_url(ENV['S3_ALIAS_HOST'])
-media_host ||= host_to_url(ENV['S3_CLOUDFRONT_HOST'])
-media_host ||= host_to_url(ENV['S3_HOSTNAME']) if ENV['S3_ENABLED'] == 'true'
-media_host ||= assets_host
+  if ENV['PAPERCLIP_ROOT_URL']
+    url = Addressable::URI.parse(assets_host) + ENV['PAPERCLIP_ROOT_URL']
+    data_hosts << "https://#{url.host}"
+  end
 
-Rails.application.config.content_security_policy do |p|
-  p.base_uri        :none
-  p.default_src     :none
-  p.frame_ancestors :none
-  p.font_src        :self, assets_host
-  p.img_src         :self, :https, :data, :blob, assets_host
-  p.style_src       :self, assets_host
-  p.media_src       :self, :https, :data, assets_host
-  p.frame_src       :self, :https
-  p.manifest_src    :self, assets_host
+  data_hosts.concat(ENV['EXTRA_DATA_HOSTS'].split('|')) if ENV['EXTRA_DATA_HOSTS']
 
-  if Rails.env.development?
-    webpacker_urls = %w(ws http).map { |protocol| "#{protocol}#{Webpacker.dev_server.https? ? 's' : ''}://#{Webpacker.dev_server.host_with_port}" }
+  data_hosts.uniq!
 
-    p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url, *webpacker_urls
-    p.script_src  :self, :unsafe_inline, :unsafe_eval, assets_host
-    p.child_src   :self, :blob, assets_host
-    p.worker_src  :self, :blob, assets_host
-  else
-    p.connect_src :self, :data, :blob, assets_host, media_host, Rails.configuration.x.streaming_api_base_url
-    p.script_src  :self, assets_host
-    p.child_src   :self, :blob, assets_host
-    p.worker_src  :self, :blob, assets_host
+  Rails.application.config.content_security_policy do |p|
+    p.base_uri        :none
+    p.default_src     :none
+    p.frame_ancestors :none
+    p.script_src      :self, assets_host
+    p.font_src        :self, assets_host
+    p.img_src         :self, :data, :blob, *data_hosts
+    p.style_src       :self, assets_host
+    p.media_src       :self, :data, *data_hosts
+    p.frame_src       :self, :https
+    p.child_src       :self, :blob, assets_host
+    p.worker_src      :self, :blob, assets_host
+    p.connect_src     :self, :blob, :data, Rails.configuration.x.streaming_api_base_url, *data_hosts
+    p.manifest_src    :self, assets_host
   end
 end
 
diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb
index 55f8c9c91..bc782bc76 100644
--- a/config/initializers/cors.rb
+++ b/config/initializers/cors.rb
@@ -30,5 +30,9 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
       headers: :any,
       methods: [:post],
       credentials: false
+    resource '/assets/*', headers: :any, methods: [:get, :head, :options]
+    resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options]
+    resource '/javascripts/*', headers: :any, methods: [:get, :head, :options]
+    resource '/packs/*', headers: :any, methods: [:get, :head, :options]
   end
 end
diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb
new file mode 100644
index 000000000..fed182a71
--- /dev/null
+++ b/config/initializers/locale.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'flavours', '*', 'names', '*.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('app', 'javascript', 'skins', '*', '*', 'names', '*.{rb,yml}').to_s]
+I18n.load_path += Dir[Rails.root.join('config', 'locales-glitch', '*.{rb,yml}').to_s]
diff --git a/config/locales-glitch/en.yml b/config/locales-glitch/en.yml
new file mode 100644
index 000000000..6268727a7
--- /dev/null
+++ b/config/locales-glitch/en.yml
@@ -0,0 +1,25 @@
+---
+en:
+  admin:
+    dashboard:
+      keybase: Keybase integration
+    settings:
+      enable_keybase:
+        desc_html: Allow your users to prove their identity via keybase
+        title: Enable keybase integration
+      outgoing_spoilers:
+        desc_html: When federating toots, add this content warning to toots that do not have one. It is useful if your server is specialized in content other servers might want to have under a Content Warning. Media will also be marked as sensitive.
+        title: Content warning for outgoing toots
+      hide_followers_count:
+        desc_html: Do not show followers count on user profiles
+        title: Hide followers count
+      show_reblogs_in_public_timelines:
+        desc_html: Show public boosts of public toots in local and public timelines.
+        title: Show boosts in public timelines
+      show_replies_in_public_timelines:
+        desc_html: In addition to public self-replies (threads), show public replies in local and public timelines.
+        title: Show replies in public timelines
+  generic:
+    use_this: Use this
+  settings:
+    flavours: Flavours
diff --git a/config/locales-glitch/es.yml b/config/locales-glitch/es.yml
new file mode 100644
index 000000000..7e8de1560
--- /dev/null
+++ b/config/locales-glitch/es.yml
@@ -0,0 +1,25 @@
+---
+es:
+  admin:
+    dashboard:
+      keybase: Integración con keybase
+    settings:
+      enable_keybase:
+        desc_html: Permite a tus usuarixs comprobar su identidad por medio de keybase
+        title: Habilitar la integración con keybase
+      outgoing_spoilers:
+        desc_html: Cuando los toots federen, agrega esta etiqueta de contenido a los toots que no tengan. Es útil si tu servidor se especializa en contenido que otros servidores desearían tener con una advertencia de contenido. Los medios también se marcarán como sensibles.
+        title: Advertencia de contenido para los toots salientes
+      hide_followers_count:
+        desc_html: No mostrar el conteo de seguidorxs en perfiles de usuarix
+        title: Ocultar conteo de seguidorxs
+      show_reblogs_in_public_timelines:
+        desc_html: Mostrar retoots públicos en las línea de tiempo local y pública.
+        title: Mostrar retoots en líneas de tiempo públicas
+      show_replies_in_public_timelines:
+        desc_html: Además de auto-respuestas públicas (hilos), mostrar respuestas públicas en las línea de tiempo local y pública.
+        title: Mostrar respuestas en líneas de tiempo públicas
+  generic:
+    use_this: Usar
+  settings:
+    flavours: Ediciones
\ No newline at end of file
diff --git a/config/locales-glitch/ja.yml b/config/locales-glitch/ja.yml
new file mode 100644
index 000000000..15fb6e566
--- /dev/null
+++ b/config/locales-glitch/ja.yml
@@ -0,0 +1,6 @@
+---
+ja:
+  generic:
+    use_this: これを使う
+  settings:
+    flavours: フレーバー
diff --git a/config/locales-glitch/pl.yml b/config/locales-glitch/pl.yml
new file mode 100644
index 000000000..3fcdedcf3
--- /dev/null
+++ b/config/locales-glitch/pl.yml
@@ -0,0 +1,6 @@
+---
+pl:
+  generic:
+    use_this: Użyj tego
+  settings:
+    flavours: Odmiany
diff --git a/config/locales-glitch/simple_form.en.yml b/config/locales-glitch/simple_form.en.yml
new file mode 100644
index 000000000..612943571
--- /dev/null
+++ b/config/locales-glitch/simple_form.en.yml
@@ -0,0 +1,20 @@
+---
+en:
+  simple_form:
+    hints:
+      defaults:
+        setting_default_content_type_html: When writing toots, assume they are written in raw HTML, unless specified otherwise
+        setting_default_content_type_markdown: When writing toots, assume they are using Markdown for rich text formatting, unless specified otherwise
+        setting_default_content_type_plain: When writing toots, assume they are plain text with no special formatting, unless specified otherwise (default Mastodon behavior)
+        setting_default_language: The language of your toots can be detected automatically, but it's not always accurate
+        setting_skin: Reskins the selected Mastodon flavour
+    labels:
+      defaults:
+        setting_default_content_type: Default format for toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Plain text
+        setting_favourite_modal: Show confirmation dialog before favouriting (applies to Glitch flavour only)
+        setting_hide_followers_count: Hide your followers count
+        setting_skin: Skin
+        setting_system_emoji_font: Use system's default font for emojis (applies to Glitch flavour only)
diff --git a/config/locales-glitch/simple_form.es.yml b/config/locales-glitch/simple_form.es.yml
new file mode 100644
index 000000000..977775be6
--- /dev/null
+++ b/config/locales-glitch/simple_form.es.yml
@@ -0,0 +1,20 @@
+---
+es:
+  simple_form:
+    hints:
+      defaults:
+        setting_default_content_type_html: Al escribir toots, asume que estás escritos en HTML, a menos que se especifique lo contrario
+        setting_default_content_type_markdown: Al escribir toots, asume que estás usando Markdown para dar formato de texto enriquecido, a menos que se especifique lo contrario
+        setting_default_content_type_plain: Al escribir toots, asume que estás usando texto sin formato, a menos que se especifique lo contrario (predeterminado de Mastodon)
+        setting_default_language: El idioma de tus toots se puede detectar automáticamente, pero no siempre es correcto
+        setting_skin: Cambia el diseño de la edición seleccionada de Mastodon
+    labels:
+      defaults:
+        setting_default_content_type: Formato predeterminado de tus toots
+        setting_default_content_type_html: HTML
+        setting_default_content_type_markdown: Markdown
+        setting_default_content_type_plain: Sin formato
+        setting_favourite_modal: Mostrar diálogo de confirmación antes de marcar como favorito (sólo aplica a la edición Glich)
+        setting_hide_followers_count: Ocultar tu conteo de seguidorxs
+        setting_skin: Diseño
+        setting_system_emoji_font: Usar la fuente predeterminada del sistema para emojis (sólo aplica a la edición Glitch)
diff --git a/config/locales-glitch/simple_form.ja.yml b/config/locales-glitch/simple_form.ja.yml
new file mode 100644
index 000000000..ba02c8091
--- /dev/null
+++ b/config/locales-glitch/simple_form.ja.yml
@@ -0,0 +1,6 @@
+---
+ja:
+  simple_form:
+    labels:
+      defaults:
+        setting_favourite_modal: お気に入りをする前に確認ダイアログを表示する
diff --git a/config/locales-glitch/simple_form.pl.yml b/config/locales-glitch/simple_form.pl.yml
new file mode 100644
index 000000000..264494c2d
--- /dev/null
+++ b/config/locales-glitch/simple_form.pl.yml
@@ -0,0 +1,10 @@
+---
+pl:
+  simple_form:
+    hints:
+      defaults:
+        setting_skin: Zmienia wygląd używanej odmiany Mastodona
+    labels:
+      defaults:
+        setting_favourite_modal: Pytaj o potwierdzenie przed dodaniem do ulubionych
+        setting_skin: Motyw
diff --git a/config/navigation.rb b/config/navigation.rb
index 3a82c7971..0bb3189c1 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -16,6 +16,12 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url
     end
 
+    n.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_url do |flavours|
+      Themes.instance.flavours.each do |flavour|
+        flavours.item flavour.to_sym, safe_join([fa_icon('star fw'), t("flavours.#{flavour}.name", default: flavour)]), settings_flavour_url(flavour)
+      end
+    end
+
     n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url, if: -> { current_user.functional? }
     n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? }
 
diff --git a/config/routes.rb b/config/routes.rb
index 780a52b0c..6814dc61e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -158,6 +158,8 @@ Rails.application.routes.draw do
       end
     end
 
+    resources :flavours, only: [:index, :show, :update], param: :flavour
+
     resource :delete, only: [:show, :destroy]
     resource :migration, only: [:show, :create]
 
@@ -341,6 +343,7 @@ Rails.application.routes.draw do
       end
 
       namespace :timelines do
+        resource :direct, only: :show, controller: :direct
         resource :home, only: :show, controller: :home
         resource :public, only: :show, controller: :public
         resources :tag, only: :show
@@ -423,9 +426,10 @@ Rails.application.routes.draw do
         end
       end
 
-      resources :notifications, only: [:index, :show] do
+      resources :notifications, only: [:index, :show, :destroy] do
         collection do
           post :clear
+          delete :destroy_multiple
         end
 
         member do
diff --git a/config/settings.yml b/config/settings.yml
index 9cf68a096..1d9488052 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -2,7 +2,7 @@
 # important settings can be changed from the admin interface.
 
 defaults: &defaults
-  site_title: Mastodon
+  site_title: 'Mastodon Glitch Edition'
   site_short_description: ''
   site_description: ''
   site_extended_description: ''
@@ -13,13 +13,14 @@ defaults: &defaults
   profile_directory: true
   closed_registrations_message: ''
   open_deletion: true
+  timeline_preview: false
   min_invite_role: 'admin'
-  timeline_preview: true
   show_staff_badge: true
   default_sensitive: false
   hide_network: false
   unfollow_modal: false
   boost_modal: false
+  favourite_modal: false
   delete_modal: true
   auto_play_gif: false
   display_media: 'default'
@@ -27,10 +28,14 @@ defaults: &defaults
   preview_sensitive_media: false
   reduce_motion: false
   disable_swiping: false
-  show_application: true
+  show_application: false
   system_font_ui: false
+  system_emoji_font: false
   noindex: false
-  theme: 'default'
+  hide_followers_count: false
+  enable_keybase: true
+  flavour: 'glitch'
+  skin: 'default'
   aggregate_reblogs: true
   advanced_layout: false
   use_blurhash: true
@@ -67,9 +72,13 @@ defaults: &defaults
   activity_api_enabled: true
   peers_api_enabled: true
   show_known_fediverse_at_about_page: true
+  show_reblogs_in_public_timelines: false
+  show_replies_in_public_timelines: false
+  default_content_type: 'text/plain'
   spam_check_enabled: true
   show_domain_blocks: 'disabled'
   show_domain_blocks_rationale: 'disabled'
+  outgoing_spoilers: ''
   require_invite_text: false
 
 development:
diff --git a/config/themes.yml b/config/themes.yml
deleted file mode 100644
index 9c21c9459..000000000
--- a/config/themes.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-default: styles/application.scss
-contrast: styles/contrast.scss
-mastodon-light: styles/mastodon-light.scss
diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js
index 25b6b7abd..f05c888d5 100644
--- a/config/webpack/configuration.js
+++ b/config/webpack/configuration.js
@@ -1,15 +1,61 @@
 // Common configuration for webpacker loaded from config/webpacker.yml
 
-const { resolve } = require('path');
+const { basename, dirname, extname, join, resolve } = require('path');
 const { env } = require('process');
 const { load } = require('js-yaml');
-const { readFileSync } = require('fs');
+const { lstatSync, readFileSync } = require('fs');
+const glob = require('glob');
 
 const configPath = resolve('config', 'webpacker.yml');
 const settings = load(readFileSync(configPath), 'utf8')[env.RAILS_ENV || env.NODE_ENV];
+const flavourFiles = glob.sync('app/javascript/flavours/*/theme.yml');
+const skinFiles = glob.sync('app/javascript/skins/*/*');
+const flavours = {};
 
-const themePath = resolve('config', 'themes.yml');
-const themes = load(readFileSync(themePath), 'utf8');
+const core = function () {
+  const coreFile = resolve('app', 'javascript', 'core', 'theme.yml');
+  const data = load(readFileSync(coreFile), 'utf8');
+  if (!data.pack_directory) {
+    data.pack_directory = dirname(coreFile);
+  }
+  return data.pack ? data : {};
+}();
+
+for (let i = 0; i < flavourFiles.length; i++) {
+  const flavourFile = flavourFiles[i];
+  const data = load(readFileSync(flavourFile), 'utf8');
+  data.name = basename(dirname(flavourFile));
+  data.skin = {};
+  if (!data.pack_directory) {
+    data.pack_directory = dirname(flavourFile);
+  }
+  if (data.locales) {
+    data.locales = join(dirname(flavourFile), data.locales);
+  }
+  if (data.pack && typeof data.pack === 'object') {
+    flavours[data.name] = data;
+  }
+}
+
+for (let i = 0; i < skinFiles.length; i++) {
+  const skinFile = skinFiles[i];
+  let skin = basename(skinFile);
+  const name = basename(dirname(skinFile));
+  if (!flavours[name]) {
+    continue;
+  }
+  const data = flavours[name].skin;
+  if (lstatSync(skinFile).isDirectory()) {
+    data[skin] = {};
+    const skinPacks = glob.sync(join(skinFile, '*.{css,scss}'));
+    for (let j = 0; j < skinPacks.length; j++) {
+      const pack = skinPacks[j];
+      data[skin][basename(pack, extname(pack))] = pack;
+    }
+  } else if ((skin = skin.match(/^(.*)\.s?css$/i))) {
+    data[skin[1]] = { common: skinFile };
+  }
+}
 
 const output = {
   path: resolve('public', settings.public_output_path),
@@ -18,7 +64,8 @@ const output = {
 
 module.exports = {
   settings,
-  themes,
+  core,
+  flavours,
   env: {
     NODE_ENV: env.NODE_ENV,
     PUBLIC_OUTPUT_PATH: settings.public_output_path,
diff --git a/config/webpack/generateLocalePacks.js b/config/webpack/generateLocalePacks.js
index b71cf2ade..09fba4a18 100644
--- a/config/webpack/generateLocalePacks.js
+++ b/config/webpack/generateLocalePacks.js
@@ -1,52 +1,66 @@
+// A message from upstream:
+// ========================
 // To avoid adding a lot of boilerplate, locale packs are
 // automatically generated here. These are written into the tmp/
 // directory and then used to generate locale_en.js, locale_fr.js, etc.
 
-const fs = require('fs');
-const path = require('path');
+// Glitch note:
+// ============
+// This code has been entirely rewritten to support glitch flavours.
+// However, the underlying process is exactly the same.
+
+const { existsSync, readdirSync, writeFileSync } = require('fs');
+const { join, resolve } = require('path');
 const rimraf = require('rimraf');
 const mkdirp = require('mkdirp');
+const { flavours } = require('./configuration.js');
+
+module.exports = Object.keys(flavours).reduce(function (map, entry) {
+  const flavour = flavours[entry];
+  if (!flavour.locales) {
+    return map;
+  }
+  const locales = readdirSync(flavour.locales).filter(
+    filename => /\.js(?:on)?$/.test(filename) && !/defaultMessages|whitelist|index/.test(filename)
+  );
+  const outPath = resolve('tmp', 'locales', entry);
 
-const localesJsonPath = path.join(__dirname, '../../app/javascript/mastodon/locales');
-const locales = fs.readdirSync(localesJsonPath).filter(filename => {
-  return /\.json$/.test(filename) &&
-    !/defaultMessages/.test(filename) &&
-    !/whitelist/.test(filename);
-}).map(filename => filename.replace(/\.json$/, ''));
-
-const outPath = path.join(__dirname, '../../tmp/packs');
-
-rimraf.sync(outPath);
-mkdirp.sync(outPath);
-
-const outPaths = [];
-
-locales.forEach(locale => {
-  const localePath = path.join(outPath, `locale_${locale}.js`);
-  const baseLocale = locale.split('-')[0]; // e.g. 'zh-TW' -> 'zh'
-  const localeDataPath = [
-    // first try react-intl
-    `../../node_modules/react-intl/locale-data/${baseLocale}.js`,
-    // then check locales/locale-data
-    `../../app/javascript/mastodon/locales/locale-data/${baseLocale}.js`,
-    // fall back to English (this is what react-intl does anyway)
-    '../../node_modules/react-intl/locale-data/en.js',
-  ].filter(filename => fs.existsSync(path.join(outPath, filename)))
-    .map(filename => filename.replace(/..\/..\/node_modules\//, ''))[0];
-
-  const localeContent = `//
-// locale_${locale}.js
+  rimraf.sync(outPath);
+  mkdirp.sync(outPath);
+
+  locales.forEach(function (locale) {
+    const localeName = locale.replace(/\.js(?:on)?$/, '');
+    const localePath = join(outPath, `${localeName}.js`);
+    const baseLocale = localeName.split('-')[0]; // e.g. 'zh-TW' -> 'zh'
+    const localeDataPath = [
+      // first try react-intl
+      `node_modules/react-intl/locale-data/${baseLocale}.js`,
+      // then check locales/locale-data
+      `app/javascript/locales/locale-data/${baseLocale}.js`,
+      // fall back to English (this is what react-intl does anyway)
+      'node_modules/react-intl/locale-data/en.js',
+    ].filter(
+      filename => existsSync(filename)
+    ).map(
+      filename => filename.replace(/(?:node_modules|app\/javascript)\//, '')
+    )[0];
+    const localeContent = `//
+// locales/${entry}/${localeName}.js
 // automatically generated by generateLocalePacks.js
 //
-import messages from '../../app/javascript/mastodon/locales/${locale}.json';
-import localeData from ${JSON.stringify(localeDataPath)};
-import { setLocale } from '../../app/javascript/mastodon/locales';
-setLocale({messages, localeData});
-`;
-  fs.writeFileSync(localePath, localeContent, 'utf8');
-  outPaths.push(localePath);
-});
 
-module.exports = outPaths;
+import messages from '../../../${flavour.locales}/${locale.replace(/\.js$/, '')}';
+import localeData from '${localeDataPath}';
+import { setLocale } from 'locales';
 
+setLocale({
+  localeData,
+  messages,
+});
+`;
+    writeFileSync(localePath, localeContent, 'utf8');
+    map[`locales/${entry}/${localeName}`] = localePath;
+  });
 
+  return map;
+}, {});
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index 2fc245c43..4d25748ee 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -12,6 +12,7 @@ module.exports = {
     {
       loader: 'babel-loader',
       options: {
+        sourceRoot: 'app/javascript',
         cacheDirectory: join(settings.cache_path, 'babel-loader'),
         cacheCompression: env.NODE_ENV === 'production',
         compact: env.NODE_ENV === 'production',
diff --git a/config/webpack/rules/css.js b/config/webpack/rules/css.js
index bc1f42c13..6ecfb3164 100644
--- a/config/webpack/rules/css.js
+++ b/config/webpack/rules/css.js
@@ -20,6 +20,9 @@ module.exports = {
     {
       loader: 'sass-loader',
       options: {
+        sassOptions: {
+          includePaths: ['app/javascript'],
+        },
         implementation: require('sass'),
         sourceMap: true,
       },
diff --git a/config/webpack/shared.js b/config/webpack/shared.js
index 05828aebe..ce08ac206 100644
--- a/config/webpack/shared.js
+++ b/config/webpack/shared.js
@@ -5,33 +5,56 @@ const { basename, dirname, join, relative, resolve } = require('path');
 const { sync } = require('glob');
 const MiniCssExtractPlugin = require('mini-css-extract-plugin');
 const AssetsManifestPlugin = require('webpack-assets-manifest');
-const extname = require('path-complete-extname');
-const { env, settings, themes, output } = require('./configuration');
+const { env, settings, core, flavours, output } = require('./configuration.js');
 const rules = require('./rules');
-const localePackPaths = require('./generateLocalePacks');
+const localePacks = require('./generateLocalePacks');
+
+function reducePacks (data, into = {}) {
+  if (!data.pack) {
+    return into;
+  }
+  Object.keys(data.pack).reduce((map, entry) => {
+    const pack = data.pack[entry];
+    if (!pack) {
+      return map;
+    }
+    const packFile = typeof pack === 'string' ? pack : pack.filename;
+    if (packFile) {
+      map[data.name ? `flavours/${data.name}/${entry}` : `core/${entry}`] = resolve(data.pack_directory, packFile);
+    }
+    return map;
+  }, into);
+  if (data.name) {
+    Object.keys(data.skin).reduce((map, entry) => {
+      const skin = data.skin[entry];
+      const skinName = entry;
+      if (!skin) {
+        return map;
+      }
+      Object.keys(skin).reduce((map, entry) => {
+        const packFile = skin[entry];
+        if (!packFile) {
+          return map;
+        }
+        map[`skins/${data.name}/${skinName}/${entry}`] = resolve(packFile);
+        return map;
+      }, into);
+      return map;
+    }, into);
+  }
+  return into;
+}
+
+const entries = Object.assign(
+  { locales: resolve('app', 'javascript', 'locales') },
+  localePacks,
+  reducePacks(core),
+  Object.keys(flavours).reduce((map, entry) => reducePacks(flavours[entry], map), {})
+);
 
-const extensionGlob = `**/*{${settings.extensions.join(',')}}*`;
-const entryPath = join(settings.source_path, settings.source_entry_path);
-const packPaths = sync(join(entryPath, extensionGlob));
 
 module.exports = {
-  entry: Object.assign(
-    packPaths.reduce((map, entry) => {
-      const localMap = map;
-      const namespace = relative(join(entryPath), dirname(entry));
-      localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
-      return localMap;
-    }, {}),
-    localePackPaths.reduce((map, entry) => {
-      const localMap = map;
-      localMap[basename(entry, extname(entry, extname(entry)))] = resolve(entry);
-      return localMap;
-    }, {}),
-    Object.keys(themes).reduce((themePaths, name) => {
-      themePaths[name] = resolve(join(settings.source_path, themes[name]));
-      return themePaths;
-    }, {}),
-  ),
+  entry: entries,
 
   output: {
     filename: 'js/[name]-[chunkhash].js',
@@ -43,7 +66,7 @@ module.exports = {
 
   optimization: {
     runtimeChunk: {
-      name: 'common',
+      name: 'locales',
     },
     splitChunks: {
       cacheGroups: {
@@ -51,7 +74,9 @@ module.exports = {
         vendors: false,
         common: {
           name: 'common',
-          chunks: 'all',
+          chunks (chunk) {
+            return !(chunk.name in entries);
+          },
           minChunks: 2,
           minSize: 0,
           test: /^(?!.*[\\\/]node_modules[\\\/]react-intl[\\\/]).+$/,
diff --git a/config/webpacker.yml b/config/webpacker.yml
index 4ad78a190..9accd6152 100644
--- a/config/webpacker.yml
+++ b/config/webpacker.yml
@@ -26,6 +26,7 @@ default: &default
     - .tiff
     - .ico
     - .svg
+    - .gif
     - .eot
     - .otf
     - .ttf