Merge tag 'v4.0.2'

This commit is contained in:
Mike Barnes 2022-12-17 22:55:12 +11:00
commit b0fa7842db
1563 changed files with 59605 additions and 38210 deletions

View file

@ -0,0 +1 @@
// Not needed

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View file

@ -0,0 +1 @@
use { color: #000 !important; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -11,6 +11,7 @@ module Mastodon
class RaceConditionError < Error; end
class RateLimitExceededError < Error; end
class SyntaxError < Error; end
class InvalidParameterError < Error; end
class UnexpectedResponseError < Error
attr_reader :response
@ -25,4 +26,13 @@ module Mastodon
end
end
end
class PrivateNetworkAddressError < HostValidationError
attr_reader :host
def initialize(host)
@host = host
super()
end
end
end

View file

@ -54,10 +54,10 @@ module Mastodon
option :email, required: true
option :confirmed, type: :boolean
option :role, default: 'user', enum: %w(user moderator admin)
option :role
option :reattach, type: :boolean
option :force, type: :boolean
desc 'create USERNAME', 'Create a new user'
desc 'create USERNAME', 'Create a new user account'
long_desc <<-LONG_DESC
Create a new user account with a given USERNAME and an
e-mail address provided with --email.
@ -65,8 +65,7 @@ module Mastodon
With the --confirmed option, the confirmation e-mail will
be skipped and the account will be active straight away.
With the --role option one of "user", "admin" or "moderator"
can be supplied. Defaults to "user"
With the --role option, the role can be supplied.
With the --reattach option, the new user will be reattached
to a given existing username of an old account. If the old
@ -75,9 +74,22 @@ module Mastodon
username to the new account anyway.
LONG_DESC
def create(username)
role_id = nil
if options[:role]
role = UserRole.find_by(name: options[:role])
if role.nil?
say('Cannot find user role with that name', :red)
exit(1)
end
role_id = role.id
end
account = Account.new(username: username)
password = SecureRandom.hex
user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true)
user = User.new(email: options[:email], password: password, agreement: true, approved: true, role_id: role_id, confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true)
if options[:reattach]
account = Account.find_local(username) || Account.new(username: username)
@ -106,14 +118,15 @@ module Mastodon
user.errors.to_h.each do |key, error|
say('Failure/Error: ', :red)
say(key)
say(' ' + error, :red)
say(" #{error}", :red)
end
exit(1)
end
end
option :role, enum: %w(user moderator admin)
option :role
option :remove_role, type: :boolean
option :email
option :confirm, type: :boolean
option :enable, type: :boolean
@ -121,12 +134,12 @@ module Mastodon
option :disable_2fa, type: :boolean
option :approve, type: :boolean
option :reset_password, type: :boolean
desc 'modify USERNAME', 'Modify a user'
desc 'modify USERNAME', 'Modify a user account'
long_desc <<-LONG_DESC
Modify a user account.
With the --role option, update the user's role to one of "user",
"moderator" or "admin".
With the --role option, update the user's role. To remove the user's
role, i.e. demote to normal user, use --remove-role.
With the --email option, update the user's e-mail address. With
the --confirm option, mark the user's e-mail as confirmed.
@ -152,8 +165,16 @@ module Mastodon
end
if options[:role]
user.admin = options[:role] == 'admin'
user.moderator = options[:role] == 'moderator'
role = UserRole.find_by(name: options[:role])
if role.nil?
say('Cannot find user role with that name', :red)
exit(1)
end
user.role_id = role.id
elsif options[:remove_role]
user.role_id = nil
end
password = SecureRandom.hex if options[:reset_password]
@ -172,7 +193,7 @@ module Mastodon
user.errors.to_h.each do |key, error|
say('Failure/Error: ', :red)
say(key)
say(' ' + error, :red)
say(" #{error}", :red)
end
exit(1)
@ -319,7 +340,7 @@ module Mastodon
unless skip_domains.empty?
say('The following domains were not available during the check:', :yellow)
skip_domains.each { |domain| say(' ' + domain) }
skip_domains.each { |domain| say(" #{domain}") }
end
end

View file

@ -18,17 +18,15 @@ module Mastodon
When suspending a local user, a hash of a "canonical" version of their e-mail
address is stored to prevent them from signing up again.
This command can be used to find whether a known email address is blocked,
and if so, which account it was attached to.
This command can be used to find whether a known email address is blocked.
LONG_DESC
def find(email)
accts = CanonicalEmailBlock.find_blocks(email).map(&:reference_account).map(&:acct).to_a
accts = CanonicalEmailBlock.matching_email(email)
if accts.empty?
say("#{email} is not blocked", :yellow)
say("#{email} is not blocked", :green)
else
accts.each do |acct|
say(acct, :white)
end
say("#{email} is blocked", :red)
end
end
@ -40,24 +38,13 @@ module Mastodon
This command allows removing a canonical email block.
LONG_DESC
def remove(email)
blocks = CanonicalEmailBlock.find_blocks(email)
blocks = CanonicalEmailBlock.matching_email(email)
if blocks.empty?
say("#{email} is not blocked", :yellow)
say("#{email} is not blocked", :green)
else
blocks.destroy_all
say("Removed canonical email block for #{email}", :green)
end
end
private
def color(processed, failed)
if !processed.zero? && failed.zero?
:green
elsif failed.zero?
:yellow
else
:red
say("Unblocked #{email}", :green)
end
end
end

View file

@ -14,7 +14,7 @@ module Mastodon
end
MIN_SUPPORTED_VERSION = 2019_10_01_213028 # rubocop:disable Style/NumericLiterals
MAX_SUPPORTED_VERSION = 2022_03_16_233212 # rubocop:disable Style/NumericLiterals
MAX_SUPPORTED_VERSION = 2022_11_04_133904 # rubocop:disable Style/NumericLiterals
# Stubs to enjoy ActiveRecord queries while not depending on a particular
# version of the code/database
@ -45,6 +45,7 @@ module Mastodon
class FollowRecommendationSuppression < ApplicationRecord; end
class CanonicalEmailBlock < ApplicationRecord; end
class Appeal < ApplicationRecord; end
class Webhook < ApplicationRecord; end
class PreviewCard < ApplicationRecord
self.inheritance_column = false
@ -182,6 +183,7 @@ module Mastodon
deduplicate_accounts!
deduplicate_tags!
deduplicate_webauthn_credentials!
deduplicate_webhooks!
Scenic.database.refresh_materialized_view('instances', concurrently: true, cascade: false) if ActiveRecord::Migrator.current_version >= 2020_12_06_004238
Rails.cache.clear
@ -497,6 +499,7 @@ module Mastodon
def deduplicate_tags!
remove_index_if_exists!(:tags, 'index_tags_on_name_lower')
remove_index_if_exists!(:tags, 'index_tags_on_name_lower_btree')
@prompt.say 'Deduplicating tags…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
@ -509,11 +512,10 @@ module Mastodon
end
@prompt.say 'Restoring tags indexes…'
ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
if ActiveRecord::Base.connection.indexes(:tags).any? { |i| i.name == 'index_tags_on_name_lower_btree' }
@prompt.say 'Reindexing textual indexes on tags…'
ActiveRecord::Base.connection.execute('REINDEX INDEX index_tags_on_name_lower_btree;')
if ActiveRecord::Migrator.current_version < 20210421121431
ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
else
ActiveRecord::Base.connection.execute 'CREATE UNIQUE INDEX CONCURRENTLY index_tags_on_name_lower_btree ON tags (lower(name) text_pattern_ops)'
end
end
@ -531,6 +533,20 @@ module Mastodon
ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
end
def deduplicate_webhooks!
return unless ActiveRecord::Base.connection.table_exists?(:webhooks)
remove_index_if_exists!(:webhooks, 'index_webhooks_on_url')
@prompt.say 'Deduplicating webhooks…'
ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webhooks GROUP BY url HAVING count(*) > 1").each do |row|
Webhooks.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
end
@prompt.say 'Restoring webhooks indexes…'
ActiveRecord::Base.connection.add_index :webhooks, ['url'], name: 'index_webhooks_on_url', unique: true
end
def deduplicate_local_accounts!(accounts)
accounts = accounts.sort_by(&:id).reverse

View file

@ -187,6 +187,7 @@ module Mastodon
option :account, type: :string
option :domain, type: :string
option :status, type: :numeric
option :days, type: :numeric
option :concurrency, type: :numeric, default: 5, aliases: [:c]
option :verbose, type: :boolean, default: false, aliases: [:v]
option :dry_run, type: :boolean, default: false
@ -204,6 +205,8 @@ module Mastodon
Use the --domain option to download attachments from a specific domain.
Use the --days option to limit attachments created within days.
By default, attachments that are believed to be already downloaded will
not be re-downloaded. To force re-download of every URL, use --force.
DESC
@ -224,10 +227,16 @@ module Mastodon
scope = MediaAttachment.where(account_id: account.id)
elsif options[:domain]
scope = MediaAttachment.joins(:account).merge(Account.by_domain_and_subdomains(options[:domain]))
elsif options[:days].present?
scope = MediaAttachment.remote
else
exit(1)
end
if options[:days].present?
scope = scope.where('media_attachments.id > ?', Mastodon::Snowflake.id_at(options[:days].days.ago, with_random: false))
end
processed, aggregate = parallelize_with_progress(scope) do |media_attachment|
next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?)
next if DomainBlock.reject_media?(media_attachment.account.domain)

View file

@ -30,7 +30,7 @@ module Mastodon
changed since the last run. Index upgrades erase index data.
Even if creating or upgrading indices is not necessary, data from the
database will be imported into the indices, unless overriden with --no-import.
database will be imported into the indices, unless overridden with --no-import.
LONG_DESC
def deploy
if options[:concurrency] < 1

View file

@ -5,15 +5,15 @@ module Mastodon
module_function
def major
3
4
end
def minor
5
0
end
def patch
5
2
end
def flags

View file

@ -81,6 +81,9 @@ module Paperclip
# to respond or don't respond at all and as such minimize the
# impact of object storage outages on application throughput
def save
# Don't go through Stoplight if we don't have anything object-storage-oriented to do
return super if @queued_for_delete.empty? && @queued_for_write.empty? && !dirty?
Stoplight('object-storage') { super }.with_threshold(STOPLIGHT_THRESHOLD).with_cool_off_time(STOPLIGHT_COOLDOWN).with_error_handler do |error, handle|
if error.is_a?(Seahorse::Client::NetworkingError)
handle.call(error)

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module SimpleNavigation
module ItemExtensions
def url
if @url.nil? && @sub_navigation
@sub_navigation.items.first.url
else
@url
end
end
end
end
SimpleNavigation::Item.prepend(SimpleNavigation::ItemExtensions)

77
lib/tasks/branding.rake Normal file
View file

@ -0,0 +1,77 @@
namespace :branding do
desc 'Generate necessary graphic assets for branding from source SVG files'
task generate: :environment do
Rake::Task['branding:generate_app_icons'].invoke
Rake::Task['branding:generate_app_badge'].invoke
Rake::Task['branding:generate_github_assets'].invoke
Rake::Task['branding:generate_mailer_assets'].invoke
end
desc 'Generate PNG icons and logos for e-mail templates'
task generate_mailer_assets: :environment do
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-h :size --keep-aspect-ratio :input -o :output')
output_dest = Rails.root.join('app', 'javascript', 'images', 'mailer')
# Displayed size is 64px, at 3x it's 192px
Dir[Rails.root.join('app', 'javascript', 'images', 'icons', '*.svg')].each do |path|
rsvg_convert.run(input: path, size: 192, output: output_dest.join("#{File.basename(path, '.svg')}.png"))
end
# Displayed size is 34px, at 3x it's 102px
rsvg_convert.run(input: Rails.root.join('app', 'javascript', 'images', 'logo-symbol-wordmark.svg'), size: 102, output: output_dest.join('wordmark.png'))
# Displayed size is 24px, at 3x it's 72px
rsvg_convert.run(input: Rails.root.join('app', 'javascript', 'images', 'logo-symbol-icon.svg'), size: 72, output: output_dest.join('logo.png'))
end
desc 'Generate light/dark logotypes for GitHub'
task generate_github_assets: :environment do
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '--stylesheet :stylesheet -h :size --keep-aspect-ratio :input -o :output')
output_dest = Rails.root.join('lib', 'assets')
rsvg_convert.run(stylesheet: Rails.root.join('lib', 'assets', 'wordmark.dark.css'), input: Rails.root.join('app', 'javascript', 'images', 'logo-symbol-wordmark.svg'), size: 102, output: output_dest.join('wordmark.dark.png'))
rsvg_convert.run(stylesheet: Rails.root.join('lib', 'assets', 'wordmark.light.css'), input: Rails.root.join('app', 'javascript', 'images', 'logo-symbol-wordmark.svg'), size: 102, output: output_dest.join('wordmark.light.png'))
end
desc 'Generate favicons and app icons from SVG source files'
task generate_app_icons: :environment do
favicon_source = Rails.root.join('app', 'javascript', 'images', 'logo.svg')
app_icon_source = Rails.root.join('app', 'javascript', 'images', 'app-icon.svg')
output_dest = Rails.root.join('app', 'javascript', 'icons')
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '-w :size -h :size --keep-aspect-ratio :input -o :output')
convert = Terrapin::CommandLine.new('convert', ':input :output')
favicon_sizes = [16, 32, 48]
apple_icon_sizes = [57, 60, 72, 76, 114, 120, 144, 152, 167, 180, 1024]
android_icon_sizes = [36, 48, 72, 96, 144, 192, 256, 384, 512]
favicons = []
favicon_sizes.each do |size|
output_path = output_dest.join("favicon-#{size}x#{size}.png")
favicons << output_path
rsvg_convert.run(size: size, input: favicon_source, output: output_path)
end
convert.run(input: favicons, output: Rails.root.join('public', 'favicon.ico'))
apple_icon_sizes.each do |size|
rsvg_convert.run(size: size, input: app_icon_source, output: output_dest.join("apple-touch-icon-#{size}x#{size}.png"))
end
android_icon_sizes.each do |size|
rsvg_convert.run(size: size, input: app_icon_source, output: output_dest.join("android-chrome-#{size}x#{size}.png"))
end
end
desc 'Generate badge icon from SVG source files'
task generate_app_badge: :environment do
rsvg_convert = Terrapin::CommandLine.new('rsvg-convert', '--stylesheet :stylesheet -w :size -h :size --keep-aspect-ratio :input -o :output')
badge_source = Rails.root.join('app', 'javascript', 'images', 'logo-symbol-icon.svg')
output_dest = Rails.root.join('public')
stylesheet = Rails.root.join('lib', 'assets', 'wordmark.light.css')
rsvg_convert.run(stylesheet: stylesheet, input: badge_source, size: 192, output: output_dest.join('badge.png'))
end
end

View file

@ -45,7 +45,7 @@ end
namespace :emojis do
desc 'Generate a unicode to filename mapping'
task :generate do
source = 'http://www.unicode.org/Public/emoji/13.1/emoji-test.txt'
source = 'http://www.unicode.org/Public/emoji/14.0/emoji-test.txt'
codes = []
dest = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json')
@ -69,7 +69,7 @@ namespace :emojis do
end
end
existence_maps = grouped_codes.map { |c| c.index_with { |cc| File.exist?(Rails.root.join('public', 'emoji', codepoints_to_filename(cc) + '.svg')) } }
existence_maps = grouped_codes.map { |c| c.index_with { |cc| File.exist?(Rails.root.join('public', 'emoji', "#{codepoints_to_filename(cc)}.svg")) } }
map = {}
existence_maps.each do |group|

View file

@ -11,7 +11,7 @@ namespace :mastodon do
# When the application code gets loaded, it runs `lib/mastodon/redis_configuration.rb`.
# This happens before application environment configuration and sets REDIS_URL etc.
# These variables are then used even when REDIS_HOST etc. are changed, so clear them
# out so they don't interfer with our new configuration.
# out so they don't interfere with our new configuration.
ENV.delete('REDIS_URL')
ENV.delete('CACHE_REDIS_URL')
ENV.delete('SIDEKIQ_REDIS_URL')
@ -142,7 +142,40 @@ namespace :mastodon do
prompt.say "\n"
if prompt.yes?('Do you want to store uploaded files on the cloud?', default: false)
case prompt.select('Provider', ['Amazon S3', 'Wasabi', 'Minio', 'Google Cloud Storage'])
case prompt.select('Provider', ['DigitalOcean Spaces', 'Amazon S3', 'Wasabi', 'Minio', 'Google Cloud Storage'])
when 'DigitalOcean Spaces'
env['S3_ENABLED'] = 'true'
env['S3_PROTOCOL'] = 'https'
env['S3_BUCKET'] = prompt.ask('Space name:') do |q|
q.required true
q.default "files.#{env['LOCAL_DOMAIN']}"
q.modify :strip
end
env['S3_REGION'] = prompt.ask('Space region:') do |q|
q.required true
q.default 'nyc3'
q.modify :strip
end
env['S3_HOSTNAME'] = prompt.ask('Space endpoint:') do |q|
q.required true
q.default 'nyc3.digitaloceanspaces.com'
q.modify :strip
end
env['S3_ENDPOINT'] = "https://#{env['S3_HOSTNAME']}"
env['AWS_ACCESS_KEY_ID'] = prompt.ask('Space access key:') do |q|
q.required true
q.modify :strip
end
env['AWS_SECRET_ACCESS_KEY'] = prompt.ask('Space secret key:') do |q|
q.required true
q.modify :strip
end
when 'Amazon S3'
env['S3_ENABLED'] = 'true'
env['S3_PROTOCOL'] = 'https'
@ -271,6 +304,7 @@ namespace :mastodon do
env['SMTP_PORT'] = 25
env['SMTP_AUTH_METHOD'] = 'none'
env['SMTP_OPENSSL_VERIFY_MODE'] = 'none'
env['SMTP_ENABLE_STARTTLS'] = 'auto'
else
env['SMTP_SERVER'] = prompt.ask('SMTP server:') do |q|
q.required true
@ -299,6 +333,8 @@ namespace :mastodon do
end
env['SMTP_OPENSSL_VERIFY_MODE'] = prompt.select('SMTP OpenSSL verify mode:', %w(none peer client_once fail_if_no_peer_cert))
env['SMTP_ENABLE_STARTTLS'] = prompt.select('Enable STARTTLS:', %w(auto always never))
end
env['SMTP_FROM_ADDRESS'] = prompt.ask('E-mail address to send e-mails "from":') do |q|
@ -312,6 +348,20 @@ namespace :mastodon do
send_to = prompt.ask('Send test e-mail to:', required: true)
begin
enable_starttls = nil
enable_starttls_auto = nil
case env['SMTP_ENABLE_STARTTLS']
when 'always'
enable_starttls = true
when 'never'
enable_starttls = false
when 'auto'
enable_starttls_auto = true
else
enable_starttls_auto = env['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
end
ActionMailer::Base.smtp_settings = {
port: env['SMTP_PORT'],
address: env['SMTP_SERVER'],
@ -320,7 +370,8 @@ namespace :mastodon do
domain: env['LOCAL_DOMAIN'],
authentication: env['SMTP_AUTH_METHOD'] == 'none' ? nil : env['SMTP_AUTH_METHOD'] || :plain,
openssl_verify_mode: env['SMTP_OPENSSL_VERIFY_MODE'],
enable_starttls_auto: true,
enable_starttls: enable_starttls,
enable_starttls_auto: enable_starttls_auto,
}
ActionMailer::Base.default_options = {
@ -433,9 +484,12 @@ namespace :mastodon do
password = SecureRandom.hex(16)
user = User.new(admin: true, email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true)
owner_role = UserRole.find_by(name: 'Owner')
user = User.new(email: email, password: password, confirmed_at: Time.now.utc, account_attributes: { username: username }, bypass_invite_request_check: true, role: owner_role)
user.save(validate: false)
Setting.site_contact_username = username
prompt.ok "You can login with the password: #{password}"
prompt.warn 'You can change your password once you login.'
end

View file

@ -38,10 +38,61 @@ namespace :tests do
puts 'Instance actor does not have a private key'
exit(1)
end
unless Account.find_by(username: 'user', domain: nil).custom_filters.map { |filter| filter.keywords.pluck(:keyword) } == [['test'], ['take']]
puts 'CustomFilterKeyword records not created as expected'
exit(1)
end
end
desc 'Populate the database with test data for 2.4.3'
task populate_v2_4_3: :environment do # rubocop:disable Naming/VariableNumber
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO "custom_filters"
(id, account_id, phrase, context, whole_word, irreversible, created_at, updated_at)
VALUES
(1, 2, 'test', '{ "home", "public" }', true, true, now(), now()),
(2, 2, 'take', '{ "home" }', false, false, now(), now());
-- Orphaned admin action logs
INSERT INTO "admin_action_logs"
(account_id, action, target_type, target_id, created_at, updated_at)
VALUES
(1, 'destroy', 'Account', 1312, now(), now()),
(1, 'destroy', 'User', 1312, now(), now()),
(1, 'destroy', 'Report', 1312, now(), now()),
(1, 'destroy', 'DomainBlock', 1312, now(), now()),
(1, 'destroy', 'EmailDomainBlock', 1312, now(), now()),
(1, 'destroy', 'Status', 1312, now(), now()),
(1, 'destroy', 'CustomEmoji', 1312, now(), now());
-- Admin action logs with linked objects
INSERT INTO "domain_blocks"
(id, domain, created_at, updated_at)
VALUES
(1, 'example.org', now(), now());
INSERT INTO "email_domain_blocks"
(id, domain, created_at, updated_at)
VALUES
(1, 'example.org', now(), now());
INSERT INTO "admin_action_logs"
(account_id, action, target_type, target_id, created_at, updated_at)
VALUES
(1, 'destroy', 'Account', 1, now(), now()),
(1, 'destroy', 'User', 1, now(), now()),
(1, 'destroy', 'DomainBlock', 1312, now(), now()),
(1, 'destroy', 'EmailDomainBlock', 1312, now(), now()),
(1, 'destroy', 'Status', 1, now(), now()),
(1, 'destroy', 'CustomEmoji', 3, now(), now());
SQL
end
desc 'Populate the database with test data for 2.4.0'
task populate_v2_4: :environment do
task populate_v2_4: :environment do # rubocop:disable Naming/VariableNumber
ActiveRecord::Base.connection.execute(<<~SQL)
INSERT INTO "settings"
(id, thing_type, thing_id, var, value, created_at, updated_at)
@ -191,18 +242,18 @@ namespace :tests do
-- custom emoji
INSERT INTO "custom_emojis"
(shortcode, created_at, updated_at)
(id, shortcode, created_at, updated_at)
VALUES
('test', now(), now()),
('Test', now(), now()),
('blobcat', now(), now());
(1, 'test', now(), now()),
(2, 'Test', now(), now()),
(3, 'blobcat', now(), now());
INSERT INTO "custom_emojis"
(shortcode, domain, uri, created_at, updated_at)
(id, shortcode, domain, uri, created_at, updated_at)
VALUES
('blobcat', 'remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
('blobcat', 'Remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
('Blobcat', 'remote.org', 'https://remote.org/emoji/Blobcat', now(), now());
(4, 'blobcat', 'remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
(5, 'blobcat', 'Remote.org', 'https://remote.org/emoji/blobcat', now(), now()),
(6, 'Blobcat', 'remote.org', 'https://remote.org/emoji/Blobcat', now(), now());
-- favourites