cartodb/app/services/carto/user_metadata_export_service.rb
2020-06-15 10:58:47 +08:00

619 lines
22 KiB
Ruby

require 'json'
require 'carto/export/layer_exporter'
require 'carto/export/data_import_exporter'
require_dependency 'carto/export/connector_configuration_exporter'
# Not migrated
# snapshots -> difficult to do between clouds, not in use yet
# tags -> regenerated from tables
# visualization_export -> only purpose would be logging
# Version History
# 1.0.0: export user metadata
# 1.0.1: export search tweets
# 1.0.2: export user notifications
# 1.0.3: export rate limits
# 1.0.4: company and phone in users table
# 1.0.5: synchronization_oauths and connector configurations
# 1.0.6: client_applications & friends and sql_copy rate_limits
# 1.0.7: export password_reset_token and password_reset_sent_at user fields
# 1.0.8: user_multifactor_auths
# 1.0.9: oauth_apps, oauth_app_users and friends
# 1.0.10: public_map_quota
# 1.0.11: regular_api_key_quota
# 1.0.12: maintenance_mode
# 1.0.13: company_employees, use_case
module Carto
module UserMetadataExportServiceConfiguration
CURRENT_VERSION = '1.0.13'.freeze
EXPORTED_USER_ATTRIBUTES = %i(
email crypted_password database_name username admin enabled invite_token invite_token_date
map_enabled quota_in_bytes table_quota public_map_quota regular_api_key_quota account_type private_tables_enabled
period_end_date map_view_quota max_layers database_timeout user_timeout upgraded_at map_view_block_price
geocoding_quota dashboard_viewed_at sync_tables_enabled database_host geocoding_block_price api_key
notification organization_id created_at updated_at disqus_shortname id twitter_username website
description name avatar_url database_schema soft_geocoding_limit auth_token
twitter_datasource_enabled twitter_datasource_block_price twitter_datasource_block_size
twitter_datasource_quota soft_twitter_datasource_limit available_for_hire private_maps_enabled
google_sign_in last_password_change_date max_import_file_size max_import_table_row_count
max_concurrent_import_count last_common_data_update_date google_maps_key google_maps_private_key
enable_account_token location here_isolines_quota here_isolines_block_price soft_here_isolines_limit
obs_snapshot_quota obs_snapshot_block_price soft_obs_snapshot_limit mobile_xamarin
mobile_custom_watermark mobile_offline_maps mobile_gis_extension mobile_max_open_users
mobile_max_private_users obs_general_quota obs_general_block_price soft_obs_general_limit viewer
salesforce_datasource_enabled builder_enabled geocoder_provider isolines_provider routing_provider
github_user_id engine_enabled mapzen_routing_quota mapzen_routing_block_price soft_mapzen_routing_limit
no_map_logo org_admin last_name user_render_timeout database_render_timeout frontend_version
asset_host state company phone industry job_role password_reset_token password_reset_sent_at maintenance_mode
company_employees use_case
).freeze
BLANK_UUID = '00000000-0000-0000-0000-000000000000'.freeze
def compatible_version?(version)
version.to_i == CURRENT_VERSION.split('.')[0].to_i
end
end
module UserMetadataExportServiceImporter
include UserMetadataExportServiceConfiguration
include LayerImporter
include DataImportImporter
include ConnectorConfigurationImporter
def build_user_from_json_export(exported_json_string)
build_user_from_hash_export(parse_json(exported_json_string))
end
def build_user_from_hash_export(exported_hash)
raise 'Wrong export version' unless compatible_version?(exported_hash[:version])
build_user_from_hash(exported_hash[:user])
end
def build_search_tweets_from_json_export(exported_json_string)
build_search_tweets_from_hash_export(parse_json(exported_json_string))
end
def build_search_tweets_from_hash_export(exported_hash)
exported_hash[:user].fetch(:search_tweets, []).map { |st| build_search_tweet_from_hash(st) }
end
def save_imported_user(user)
user.save!
::User[user.id].after_save
client_application = user.client_applications.first
if client_application
client_application.access_tokens.each do |t|
# AR does not know about this, so it needs to be fixed
t.update_column(:type, 'AccessToken')
AccessToken[t.id].after_save
end
end
end
def save_imported_search_tweet(search_tweet, user)
if search_tweet.data_import
persisted_import = Carto::DataImport.where(id: search_tweet.data_import.id).first
search_tweet.data_import = persisted_import if persisted_import
search_tweet.table_id = search_tweet.data_import.table_id
else
# Some search tweets can be exported without data import if the FK point to a non-existent data import.
# However, this field is NOT NULL, so we cannot leave it empty.
# We could skip the import of the tweet, but instead, we keep it with an invalid ID (like in the source), so
# we can still correctly compute quota usage.
search_tweet.data_import_id = BLANK_UUID
end
search_tweet.user = user
search_tweet.save!
end
private
def parse_json(exported_json_string)
JSON.parse(exported_json_string, symbolize_names: true)
end
def build_user_from_hash(exported_user)
user = User.new(exported_user.slice(*EXPORTED_USER_ATTRIBUTES - [:id]))
user.feature_flags_user = exported_user[:feature_flags].map { |ff_name| build_feature_flag_from_name(ff_name) }
.compact
user.assets = exported_user[:assets].map { |asset| build_asset_from_hash(asset.symbolize_keys) }
user.layers = build_layers_from_hash(exported_user[:layers])
user.rate_limit = build_rate_limit_from_hash(exported_user[:rate_limit])
api_keys = exported_user[:api_keys] || []
user.api_keys += api_keys.map { |api_key| Carto::ApiKey.new_from_hash(api_key) }
user_multifactor_auths = exported_user[:user_multifactor_auths] || []
user.user_multifactor_auths += user_multifactor_auths.map { |uma| Carto::UserMultifactorAuth.new_from_hash(uma) }
if exported_user[:notifications]
user.static_notifications = Carto::UserNotification.create(notifications: exported_user[:notifications])
end
user.synchronization_oauths = build_synchronization_oauths_from_hash(exported_user[:synchronization_oauths])
user.connector_configurations = build_connector_configurations_from_hash(exported_user[:connector_configurations])
user.client_applications = build_client_applications_from_hash(exported_user[:client_application])
user.oauth_app_users = build_oauth_app_users_from_hash(exported_user[:oauth_app_users])
# Must be the last one to avoid attribute assignments to try to run SQL
user.id = exported_user[:id]
user
end
def build_feature_flag_from_name(ff_name)
ff = FeatureFlag.where(name: ff_name).first
if ff
Carto::FeatureFlagsUser.new(feature_flag_id: ff.id)
else
CartoDB::Logger.warning(message: 'Feature flag not found in user import', feature_flag: ff_name)
nil
end
end
def build_asset_from_hash(exported_asset)
Asset.new(
public_url: exported_asset[:public_url],
kind: exported_asset[:kind],
storage_info: exported_asset[:storage_info]
)
end
def build_search_tweet_from_hash(exported_search_tweet)
SearchTweet.new(
data_import: build_data_import_from_hash(exported_search_tweet[:data_import]),
service_item_id: exported_search_tweet[:service_item_id],
retrieved_items: exported_search_tweet[:retrieved_items],
state: exported_search_tweet[:state],
created_at: exported_search_tweet[:created_at],
updated_at: exported_search_tweet[:updated_at]
)
end
def build_rate_limit_from_hash(exported_hash)
return unless exported_hash
rate_limit = Carto::RateLimit.from_api_attributes(exported_hash[:limits])
rate_limit.id = exported_hash[:id]
rate_limit
end
def build_synchronization_oauths_from_hash(exported_array)
return [] unless exported_array.present?
exported_array.map { |so| build_synchronization_oauth_from_hash(so) }
end
def build_synchronization_oauth_from_hash(exported_hash)
SynchronizationOauth.new(
service: exported_hash[:service],
token: exported_hash[:token],
created_at: exported_hash[:created_at],
updated_at: exported_hash[:updated_at]
)
end
def build_oauth_token_fom_hash(exported_oauth_token)
Carto::OauthToken.new(
token: exported_oauth_token[:token],
secret: exported_oauth_token[:secret],
callback_url: exported_oauth_token[:callback_url],
verifier: exported_oauth_token[:verifier],
scope: exported_oauth_token[:scope],
authorized_at: exported_oauth_token[:authorized_at],
invalidated_at: exported_oauth_token[:invalidated_at],
valid_to: exported_oauth_token[:valid_to],
created_at: exported_oauth_token[:created_at],
updated_at: exported_oauth_token[:updated_at]
)
end
def build_client_applications_from_hash(client_app_hash)
return [] unless client_app_hash
client_application = Carto::ClientApplication.new(
name: client_app_hash[:name],
url: client_app_hash[:url],
support_url: client_app_hash[:support_url],
callback_url: client_app_hash[:callback_url],
key: client_app_hash[:key],
secret: client_app_hash[:secret],
created_at: client_app_hash[:created_at],
updated_at: client_app_hash[:updated_at],
oauth_tokens: client_app_hash[:oauth_tokens].map { |t| build_oauth_token_fom_hash(t) },
access_tokens: client_app_hash[:access_tokens].map { |t| build_oauth_token_fom_hash(t) }
)
[client_application]
end
def build_oauth_app_users_from_hash(oauth_app_users)
return [] unless oauth_app_users
oauth_app_users.map { |oau| build_oauth_app_user_from_hash(oau) }
end
def build_oauth_app_user_from_hash(oau_hash)
oau = Carto::OauthAppUser.new(
id: oau_hash[:id],
oauth_app_id: oau_hash[:oauth_app_id],
scopes: oau_hash[:scopes],
created_at: oau_hash[:created_at],
updated_at: oau_hash[:updated_at],
skip_role_setup: true
)
if oau_hash[:oauth_authorization_codes]
oau.oauth_authorization_codes = oau_hash[:oauth_authorization_codes].map do |oac_hash|
build_oauth_authorization_code_from_hash(oac_hash)
end
end
if oau_hash[:oauth_access_tokens]
oau.oauth_access_tokens = oau_hash[:oauth_access_tokens].map do |oat_hash|
build_oauth_access_token_from_hash(oat_hash)
end
end
if oau_hash[:oauth_refresh_tokens]
oau.oauth_refresh_tokens = oau_hash[:oauth_refresh_tokens].map do |ort_hash|
build_oauth_refresh_token_from_hash(ort_hash)
end
end
oau
end
def build_oauth_authorization_code_from_hash(oac_hash)
Carto::OauthAuthorizationCode.new(
scopes: oac_hash[:scopes],
code: oac_hash[:code],
redirect_uri: oac_hash[:redirect_uri],
created_at: oac_hash[:created_at]
)
end
def build_oauth_access_token_from_hash(oat_hash)
Carto::OauthAccessToken.new(
api_key_id: oat_hash[:api_key_id],
scopes: oat_hash[:scopes],
created_at: oat_hash[:created_at],
skip_api_key_creation: true
)
end
def build_oauth_refresh_token_from_hash(ort_hash)
Carto::OauthRefreshToken.new(
token: ort_hash[:token],
scopes: ort_hash[:scopes],
created_at: ort_hash[:created_at],
updated_at: ort_hash[:updated_at],
skip_token_regeneration: true
)
end
end
module UserMetadataExportServiceExporter
include UserMetadataExportServiceConfiguration
include LayerExporter
include DataImportExporter
include ConnectorConfigurationExporter
def export_user_json_string(user)
export_user_json_hash(user).to_json
end
def export_user_json_hash(user)
{
version: CURRENT_VERSION,
user: export(user)
}
end
private
def export(user)
user_hash = EXPORTED_USER_ATTRIBUTES.map { |att| [att, user.attributes[att.to_s]] }.to_h
user_hash[:feature_flags] = user.feature_flags_user.map(&:feature_flag).map(&:name)
user_hash[:assets] = user.assets.map { |a| export_asset(a) }
user_hash[:layers] = user.layers.map { |l| export_layer(l) }
user_hash[:search_tweets] = user.search_tweets.map { |st| export_search_tweet(st) }
user_hash[:api_keys] = user.api_keys.map { |api_key| export_api_key(api_key) }
user_hash[:user_multifactor_auths] = user.user_multifactor_auths.map { |uma| export_user_multifactor_auth(uma) }
user_hash[:rate_limit] = export_rate_limit(user.rate_limit)
user_hash[:notifications] = user.static_notifications.notifications
user_hash[:synchronization_oauths] = user.synchronization_oauths.map { |so| export_synchronization_oauth(so) }
user_hash[:connector_configurations] = user.connector_configurations.map do |cc|
export_connector_configuration(cc)
end
# Use Sequel models to export. Single table inheritance causes AR to try and create Sequel models -> fail.
user_hash[:client_application] = export_client_application(::User[user.id].client_application)
user_hash[:oauth_app_users] = user.oauth_app_users.map { |oau| export_oauth_app_user(oau) }
user_hash
end
def export_client_application(app)
return nil unless app
a_t_tokens = app.access_tokens.map(&:token)
{
name: app.name,
url: app.url,
support_url: app.support_url,
callback_url: app.callback_url,
key: app.key,
secret: app.secret,
created_at: app.created_at,
updated_at: app.updated_at,
oauth_tokens: app.oauth_tokens.reject { |t| a_t_tokens.include?(t.token) }.map { |ot| export_oauth_token(ot) },
access_tokens: app.access_tokens.map { |ot| export_oauth_token(ot) }
}
end
def export_oauth_token(oauth_token)
{
token: oauth_token.token,
secret: oauth_token.secret,
callback_url: oauth_token.callback_url,
verifier: oauth_token.verifier,
scope: oauth_token.scope,
authorized_at: oauth_token.authorized_at,
invalidated_at: oauth_token.invalidated_at,
valid_to: oauth_token.valid_to,
created_at: oauth_token.created_at,
updated_at: oauth_token.updated_at
}
end
def export_asset(asset)
{
public_url: asset.public_url,
kind: asset.kind,
storage_info: asset.storage_info
}
end
def export_search_tweet(search_tweet)
{
data_import: export_data_import(search_tweet.data_import),
service_item_id: search_tweet.service_item_id,
retrieved_items: search_tweet.retrieved_items,
state: search_tweet.state,
created_at: search_tweet.created_at,
updated_at: search_tweet.updated_at
}
end
def export_api_key(api_key)
{
id: api_key.id,
created_at: api_key.created_at,
db_password: api_key.db_password,
db_role: api_key.db_role,
name: api_key.name,
token: api_key.token,
type: api_key.type,
updated_at: api_key.updated_at,
grants: api_key.grants,
user_id: api_key.user_id
}
end
def export_user_multifactor_auth(user_multifactor_auth)
exported_keys = %i(user_id type shared_secret enabled last_login created_at updated_at)
user_multifactor_auth.to_h.slice(*exported_keys)
end
def export_rate_limit(rate_limit)
return unless rate_limit
{
id: rate_limit.id,
limits: rate_limit.api_attributes
}
end
def export_synchronization_oauth(sync_oauth)
{
service: sync_oauth.service,
token: sync_oauth.token,
created_at: sync_oauth.created_at,
updated_at: sync_oauth.updated_at
}
end
def export_oauth_app_user(oau)
oauth_authorization_codes = oau.oauth_authorization_codes.map { |oac| export_oauth_authorization_code(oac) }
oauth_access_tokens = oau.oauth_access_tokens.map { |oat| export_oauth_access_token(oat) }
oauth_refresh_tokens = oau.oauth_refresh_tokens.map { |ort| export_oauth_refresh_token(ort) }
{
id: oau.id,
oauth_app_id: oau.oauth_app_id,
scopes: oau.scopes,
created_at: oau.created_at,
updated_at: oau.updated_at,
oauth_authorization_codes: oauth_authorization_codes,
oauth_access_tokens: oauth_access_tokens,
oauth_refresh_tokens: oauth_refresh_tokens
}
end
def export_oauth_authorization_code(oac)
{
scopes: oac.scopes,
code: oac.code,
redirect_uri: oac.redirect_uri,
created_at: oac.created_at
}
end
def export_oauth_access_token(oat)
{
api_key_id: oat.api_key_id,
scopes: oat.scopes,
created_at: oat.created_at
}
end
def export_oauth_refresh_token(ort)
{
token: ort.token,
scopes: ort.scopes,
created_at: ort.created_at,
updated_at: ort.updated_at
}
end
end
class UserAlreadyExists < RuntimeError; end
# Both String and Hash versions are provided because `deep_symbolize_keys` won't symbolize through arrays
# and having separated methods make handling and testing much easier.
class UserMetadataExportService
include UserMetadataExportServiceImporter
include UserMetadataExportServiceExporter
def export_to_directory(user, path)
root_dir = Pathname.new(path)
root_dir.mkpath
user_json = export_user_json_string(user)
root_dir.join("user_#{user.id}.json").open('w') { |file| file.write(user_json) }
redis_json = Carto::RedisExportService.new.export_user_json_string(user)
root_dir.join("redis_user_#{user.id}.json").open('w') { |file| file.write(redis_json) }
export_user_visualizations_to_directory(user, Carto::Visualization::TYPE_REMOTE, path)
export_user_visualizations_to_directory(user, Carto::Visualization::TYPE_CANONICAL, path)
export_user_visualizations_to_directory(user, Carto::Visualization::TYPE_DERIVED, path)
end
def import_from_directory(path)
user = user_from_file(path)
raise UserAlreadyExists.new if ::Carto::User.exists?(id: user.id)
save_imported_user(user)
Carto::RedisExportService.new.restore_redis_from_json_export(redis_user_file(path))
user
end
def rollback_import_from_directory(path)
user = user_from_file(path)
return unless user
user = ::User[user.id]
return unless user
carto_user = Carto::User.find(user.id)
carto_user.assets.each(&:delete)
carto_user.destroy
user.before_destroy(skip_table_drop: true)
Carto::RedisExportService.new.remove_redis_from_json_export(redis_user_file(path))
rescue ActiveRecord::RecordNotFound
# User was not created so not found and no redis removal needed
end
def import_user_visualizations_from_directory(user, type, meta_path)
with_non_viewer_user(user) do
Dir["#{meta_path}/#{type}_*#{Carto::VisualizationExporter::EXPORT_EXTENSION}"].each do |fname|
imported_vis = Carto::VisualizationsExportService2.new.build_visualization_from_json_export(File.read(fname))
Carto::VisualizationsExportPersistenceService.new.save_import(user, imported_vis, full_restore: true)
if Carto::VisualizationsExportService2.new.marked_as_vizjson2_from_json_export?(File.read(fname))
imported_vis.mark_as_vizjson2
end
end
end
end
def import_metadata_from_directory(user, meta_path)
import_user_visualizations_from_directory(user, Carto::Visualization::TYPE_REMOTE, meta_path)
import_user_visualizations_from_directory(user, Carto::Visualization::TYPE_CANONICAL, meta_path)
import_user_visualizations_from_directory(user, Carto::Visualization::TYPE_DERIVED, meta_path)
import_search_tweets_from_directory(user, meta_path)
end
def import_search_tweets_from_directory(user, meta_path)
user_file = user_file_dir(meta_path)
search_tweets = build_search_tweets_from_json_export(File.read(user_file))
search_tweets.each { |st| save_imported_search_tweet(st, user) }
end
private
def user_from_file(path)
build_user_from_json_export(File.read(user_file_dir(path)))
end
def user_file_dir(path)
Dir["#{path}/user_*.json"].first
end
def redis_user_file(path)
File.read(Dir["#{path}/redis_user_*.json"].first)
end
def export_user_visualizations_to_directory(user, type, path)
root_dir = Pathname.new(path)
user.visualizations.where(type: type).each do |visualization|
next if visualization.canonical? && should_skip_canonical_viz_export(visualization)
next if !visualization.remote? && visualization.map.nil?
visualization_export = Carto::VisualizationsExportService2.new.export_visualization_json_string(
visualization.id, user, with_password: true
)
filename = "#{visualization.type}_#{visualization.id}#{Carto::VisualizationExporter::EXPORT_EXTENSION}"
root_dir.join(filename).open('w') { |file| file.write(visualization_export) }
end
end
def should_skip_canonical_viz_export(viz)
return true if viz.table.nil?
viz.user.visualizations.where(type: viz.type,
name: viz.name).all.sort_by(&:updated_at).last.id != viz.id
end
def with_non_viewer_user(user)
was_viewer = user.viewer
if user.viewer
user.update_attributes(viewer: false)
::User[user.id].reload
end
yield
ensure
if was_viewer
user.update_attributes(viewer: true)
::User[user.id].reload
end
end
end
end