Initial commit

This commit is contained in:
zhongjin
2020-06-15 10:58:47 +08:00
commit 4f1dfe7564
8590 changed files with 1516878 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
class AccessToken < OauthToken
def before_create
self.authorized_at = Time.now
self.created_at = Time.now
super
end
def before_save
self.updated_at = Time.now
super
end
def after_create
store_api_credentials
end
def after_destroy
$api_credentials.del metadata_key
super
end
def store_api_credentials
$api_credentials.hset metadata_key, "consumer_key", client_application.key
$api_credentials.hset metadata_key, "consumer_secret", client_application.secret
$api_credentials.hset metadata_key, "access_token_token", token
$api_credentials.hset metadata_key, "access_token_secret", secret
$api_credentials.hset metadata_key, "user_id", user_id
$api_credentials.hset metadata_key, "time", authorized_at
end
private
def metadata_key
"rails:oauth_access_tokens:#{token}"
end
end
+184
View File
@@ -0,0 +1,184 @@
require 'open-uri'
require_relative '../../lib/cartodb/image_metadata.rb'
require_relative '../helpers/file_upload'
require_dependency 'carto/configuration'
class Asset < Sequel::Model
include Carto::Configuration
many_to_one :user
KIND_ORG_AVATAR = 'orgavatar'
VALID_EXTENSIONS = %w{ .jpeg .jpg .gif .png .svg }
attr_accessor :asset_file, :url
def before_create
store
super
end
def after_destroy
super
remove unless self.public_url.blank?
end
def validate
super
errors.add(:user_id, "can't be blank") if user_id.blank?
download_file if url.present?
validate_file if asset_file.present?
end
def download_file
dir = Dir.mktmpdir
stdout, stderr, status = Open3.capture3('wget', '-nv', '-P', dir, '-E', url)
self.asset_file = Dir[File.join(dir, '*')][0]
errors.add(:url, "is invalid") unless status.exitstatus == 0
end
def max_size
Cartodb::config[:assets]["max_file_size"]
end
def validate_file
extension = asset_file_extension
unless VALID_EXTENSIONS.include?(extension)
errors.add(:file, "has invalid format")
return
end
@file = open_file(asset_file)
unless @file && File.readable?(@file.path)
errors.add(:file, "is invalid")
return
end
max_size_in_mb = (max_size.to_f / (1024 * 1024).to_f).round(2)
if @file.size > max_size
errors.add(:file, "is too big, #{max_size_in_mb}MB max")
return
end
metadata = CartoDB::ImageMetadata.new(@file.path, extension: extension)
errors.add(:file, "is too big, 1024x1024 max") if metadata.width > 1024 || metadata.height > 1024
# If metadata reports no size, 99% sure not valid, so out
errors.add(:file, "doesn't appear to be an image") if metadata.width == 0 || metadata.height == 0
rescue => e
errors.add(:file, "error while uploading: #{e.message}")
end
def asset_file_extension
filename = asset_file.respond_to?(:original_filename) ? asset_file.original_filename : asset_file
extension = File.extname(filename).downcase
# Filename might include a postfix hash -- Rack::Test::UploadedFile adds it
extension.gsub!(/\d+-\w+-\w+\z/, '') if Rails.env.test?
extension if VALID_EXTENSIONS.include?(extension)
end
##
# Tries to open the specified file object or full path
#
def open_file(handle)
(handle.respond_to?(:path) ? handle : File.open(handle.to_s))
rescue Errno::ENOENT
nil
end
def store
return unless @file
filename = (@file.respond_to?(:original_filename) ? @file.original_filename : File.basename(@file))
filename = "#{Time.now.strftime("%Y%m%d%H%M%S")}#{filename}"
remote_url = (use_s3? ? save_to_s3(filename) : save_local(filename))
self.set(public_url: remote_url)
self.this.update(public_url: remote_url)
end
def save_to_s3(filename)
obj = s3_bucket.object("#{target_asset_path}#{filename}")
obj.upload_file(@file.path, acl: 'public-read', content_type: MIME::Types.type_for(filename).first.to_s)
obj.public_url.to_s
end
def local_dir
@local_dir ||= Pathname.new(public_uploaded_assets_path).join(target_asset_path)
end
def local_filename(filename)
local_dir.join(filename)
end
def save_local(filename)
FileUtils.mkdir_p local_dir
FileUtils.cp @file.path, local_filename(filename)
mode = chmod_mode
FileUtils.chmod(mode, local_filename(filename)) if mode
File.join('/', ASSET_SUBFOLDER, target_asset_path, ERB::Util.url_encode(filename))
end
def use_s3?
Cartodb.get_config(:assets, "s3_bucket_name") && Cartodb.get_config(:aws, "s3")
end
def remove
unless use_s3?
local_url = CGI.unescape(public_url.gsub(/(http:)?\/\/#{CartoDB.account_host}/, ''))
begin
FileUtils.rm((public_uploaded_assets_path + local_url).gsub('/uploads/uploads/', '/uploads/'))
rescue => e
CartoDB::Logger.error(message: "Error removing asset", asset: self, exception: e)
end
return
end
basename = File.basename(public_url)
obj = s3_bucket.object("#{target_asset_path}#{basename}")
obj.delete
end
def target_asset_path
"#{Rails.env}/#{self.user.username}/assets/"
end
def s3_bucket
s3_config = Cartodb.config[:aws]["s3"].symbolize_keys
Aws.config = s3_config
s3 = Aws::S3::Resource.new
@s3_bucket ||= s3.bucket(Cartodb.config[:assets]["s3_bucket_name"])
end
ASSET_SUBFOLDER = 'uploads'.freeze
def absolute_public_url
uri = URI.parse(public_url)
(uri.absolute? ? uri : URI.join(base_domain, uri)).to_s
rescue URI::InvalidURIError
public_url
end
private
def base_domain
CartoDB.base_domain_from_name(user ? user.subdomain : organization.name)
end
def chmod_mode
# Example in case asset kind should change mode
# kind == KIND_ORG_AVATAR ? 0644 : nil
0644
end
def asset_protocol
# Avatars without protocol to allow the browser pick http/https.
# Other assets with http, because, for example, tiler needs access to landmark images.
kind == KIND_ORG_AVATAR ? '' : 'http:'
end
end
+52
View File
@@ -0,0 +1,52 @@
class AutomaticGeocoding < Sequel::Model
MAX_RETRIES = 5
many_to_one :table, class: :UserTable
one_to_many :geocodings, order: :created_at
def self.active
AutomaticGeocoding.where("state IN ?", ['created', 'idle']).all.select do |ag|
last_modified = ag.table.data_last_modified || 1.year.ago
last_modified > ag.ran_at
end
end # active
def before_create
super
self.created_at ||= Time.now
self.ran_at ||= Time.now
self.state ||= 'created'
end # before_create
def before_save
super
self.updated_at = Time.now
end # before_save
def validate
super
validates_presence :table_id
end # validate
def enqueue
self.update(state: 'on_queue')
Resque.enqueue(Resque::AutomaticGeocoderJobs, job_id: id)
end # enqueue
def run
self.update(state: 'running')
options = {
user_id: table.owner.id,
table_id: table.id,
formatter: geocodings.first.formatter,
automatic_geocoding_id: self.id
}
Geocoding.create(options).run!
self.update(state: 'idle', ran_at: Time.now, retried_times: 0)
rescue => e
self.update(state: 'failed') and raise(e) if retried_times.to_i >= MAX_RETRIES
self.update(retried_times: retried_times.to_i + 1, state: 'idle')
end # run
end # AutomaticGeocoding
+31
View File
@@ -0,0 +1,31 @@
module Carto
class AccountType < ActiveRecord::Base
belongs_to :rate_limit, dependent: :destroy
def soft_geocoding_limit?(user)
!!user[:soft_geocoding_limit]
end
def soft_here_isolines_limit?(user)
!!user[:soft_here_isolines_limit]
end
def soft_obs_snapshot_limit?(user)
!!user[:soft_obs_snapshot_limit]
end
def soft_obs_general_limit?(user)
!!user[:soft_obs_general_limit]
end
def soft_mapzen_routing_limit?(user)
!!user[:soft_mapzen_routing_limit]
end
def mailchimp?(_user)
# Mailchimp is currently not supported
false
end
end
end
+114
View File
@@ -0,0 +1,114 @@
require 'json'
require_relative './carto_json_serializer'
require_dependency 'carto/table_utils'
class Carto::Analysis < ActiveRecord::Base
extend Carto::TableUtils
serialize :analysis_definition, ::Carto::CartoJsonSymbolizerSerializer
validates :analysis_definition, carto_json_symbolizer: true
validate :validate_user_not_viewer
before_destroy :validate_user_not_viewer
belongs_to :visualization, class_name: Carto::Visualization
belongs_to :user, class_name: Carto::User
after_save :update_map_dataset_dependencies, :notify_map_change
after_destroy :update_map_dataset_dependencies, :notify_map_change
def self.find_by_natural_id(visualization_id, natural_id)
analysis = find_by_sql(
[
"select id from analyses where visualization_id = :visualization_id and analysis_definition ->> 'id' = :natural_id",
{ visualization_id: visualization_id, natural_id: natural_id }
]
).first
# Load all data
analysis.reload if analysis
analysis
end
def self.source_analysis_for_layer(layer, index)
map = layer.map
visualization = map.visualization
user = visualization.user
visualization_id = visualization.id
user_id = user.id
layer_options = layer.options
username = layer_options[:user_name] || layer.user.username
table_name = layer_options[:table_name]
qualified_table_name = if layer.user.organization_user?
safe_schema_and_table_quoting(username, table_name)
else
table_name
end
analysis_definition = {
id: 'abcdefghijklmnopqrstuvwxyz'[index] + '0',
type: 'source',
params: { query: layer.default_query(user) },
options: { table_name: qualified_table_name }
}
new(visualization_id: visualization_id, user_id: user_id, analysis_definition: analysis_definition)
end
def update_table_name(old_name, new_name)
analysis_node.fix_analysis_node_queries(user.username, user, old_name => new_name)
save
end
def analysis_definition_for_api
filter_valid_properties(analysis_node)
end
def natural_id
pj = analysis_definition
return nil unless pj
pj[:id]
end
def map
return nil unless visualization
visualization.map
end
def analysis_node
Carto::AnalysisNode.new(analysis_definition)
end
private
# Analysis definition contains attributes not needed by Analysis API (see #7128).
# This methods extract the needed ones.
VALID_ANALYSIS_PROPERTIES = [:id, :type, :params].freeze
def filter_valid_properties(node)
valid = node.definition.select { |property, _| VALID_ANALYSIS_PROPERTIES.include?(property) }
node.children_and_location.each do |location, child|
child_in_hash = location.reduce(valid, :[])
child_in_hash.replace(filter_valid_properties(child))
end
valid
end
def update_map_dataset_dependencies
map.update_dataset_dependencies
end
def notify_map_change
map.notify_map_change if map
end
def validate_user_not_viewer
if user.viewer
errors.add(:user, "Viewer users can't edit analyses")
return false
end
end
end
+100
View File
@@ -0,0 +1,100 @@
require_dependency 'carto/query_rewriter'
class Carto::AnalysisNode
include Carto::QueryRewriter
def initialize(definition)
@definition = definition
end
attr_reader :definition
def self.find_by_natural_id(visualization_id, natural_id)
analyses = Carto::Analysis.where(visualization_id: visualization_id).all
analyses.lazy.map { |analysis| analysis.analysis_node.find_by_id(natural_id) }.find(&:present?)
end
def id
definition[:id]
end
def type
definition[:type]
end
def params
definition[:params]
end
def non_child_params
param_locations = children_and_location.keys.select { |cal| cal.first == :params }.map(&:second)
params && params.reject { |key| param_locations.include?(key) }
end
def options
definition[:options] ||= Hash.new
end
def children
children_and_location.values
end
def children_and_location
@children ||= get_children(@definition)
end
def find_by_id(node_id)
return self if node_id == id
children.lazy.map { |child| child.find_by_id(node_id) }.find { |child| child }
end
def source?
type == 'source'
end
def table_source?(table_name)
# Maybe check params[:query]
source? && options && options[:table_name] == table_name
end
def source_descendants
return [self] if source?
children.map(&:source_descendants).flatten
end
def descendants
[self] + children.map(&:descendants).flatten
end
def fix_analysis_node_queries(old_username, new_user, renamed_tables)
if options && options.key?(:table_name)
old_table_name = options[:table_name]
old_username, old_table_name = old_table_name.split('.') if old_table_name.include?('.')
options[:table_name] = renamed_tables.fetch(old_table_name, old_table_name)
end
if params && old_username
query = params[:query]
params[:query] = rewrite_query(query, old_username, new_user, renamed_tables) if query.present?
end
children.each { |child| child.fix_analysis_node_queries(old_username, new_user, renamed_tables) }
end
private
MANDATORY_KEYS_FOR_ANALYSIS_NODE = [:id, :type, :params].freeze
def get_children(definition, path = [])
children = definition.map do |k, v|
if v.is_a?(Hash)
this_path = path + [k]
if (MANDATORY_KEYS_FOR_ANALYSIS_NODE - v.keys).empty?
{ this_path => Carto::AnalysisNode.new(v) }
else
get_children(v, this_path)
end
end
end
children.flatten.compact.reduce({}, :merge)
end
end
+647
View File
@@ -0,0 +1,647 @@
require 'securerandom'
require_dependency 'carto/errors'
require_dependency 'carto/helpers/auth_token_generator'
require_dependency 'carto/oauth_provider/scopes/scopes'
require_dependency 'carto/api_key_permissions'
class ApiKeyGrantsValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return record.errors[attribute] = ['grants has to be an array'] unless value&.is_a?(Array)
record.errors[attribute] << 'only one apis section is allowed' unless value.count { |v| v[:type] == 'apis' } == 1
max_one_sections = ['database', 'dataservices', 'user']
max_one_sections.each do |section|
if value.count { |v| v[:type] == section } > 1
record.errors[attribute] << "only one #{section} section is allowed"
end
end
end
end
module Carto
class ApiKey < ActiveRecord::Base
include Carto::AuthTokenGenerator
TYPE_REGULAR = 'regular'.freeze
TYPE_MASTER = 'master'.freeze
TYPE_DEFAULT_PUBLIC = 'default'.freeze
TYPE_OAUTH = 'oauth'.freeze
VALID_TYPES = [TYPE_REGULAR, TYPE_MASTER, TYPE_DEFAULT_PUBLIC, TYPE_OAUTH].freeze
NAME_MASTER = 'Master'.freeze
NAME_DEFAULT_PUBLIC = 'Default public'.freeze
API_SQL = 'sql'.freeze
API_MAPS = 'maps'.freeze
API_DO = 'do'.freeze
GRANTS_ALL_APIS = { type: "apis", apis: [API_SQL, API_MAPS] }.freeze
GRANTS_ALL_DATA_SERVICES = {
type: 'dataservices',
services: ['geocoding', 'routing', 'isolines', 'observatory']
}.freeze
GRANTS_ALL_USER_DATA = {
type: 'user',
data: ['profile']
}.freeze
MASTER_API_KEY_GRANTS = [GRANTS_ALL_APIS, GRANTS_ALL_DATA_SERVICES, GRANTS_ALL_USER_DATA].freeze
TOKEN_DEFAULT_PUBLIC = 'default_public'.freeze
TYPE_WEIGHTED_ORDER = "CASE WHEN type = '#{TYPE_MASTER}' THEN 3 " \
"WHEN type = '#{TYPE_DEFAULT_PUBLIC}' THEN 2 " \
"WHEN type = '#{TYPE_REGULAR}' THEN 1 " \
"ELSE 0 END DESC".freeze
CDB_CONF_KEY_PREFIX = 'api_keys_'.freeze
self.inheritance_column = :_type
belongs_to :user
has_one :oauth_access_token, inverse_of: :api_key
before_create :create_token, if: ->(k) { k.needs_setup? && !k.token }
before_create :create_db_config, if: ->(k) { k.needs_setup? && !(k.db_role && k.db_password) }
serialize :grants, Carto::CartoJsonSymbolizerSerializer
validates :grants, carto_json_symbolizer: true, api_key_grants: true, json_schema: true
validates :type, inclusion: { in: VALID_TYPES }
validates :type, uniqueness: { scope: :user_id }, unless: :needs_setup?
validates :name, presence: true, uniqueness: { scope: :user_id }
validate :valid_name_for_type
validate :check_permissions, unless: :skip_role_setup
validate :valid_master_key, if: :master?
validate :valid_default_public_key, if: :default_public?
after_create :setup_db_role, if: ->(k) { k.needs_setup? && !k.skip_role_setup }
after_save { remove_from_redis(redis_key(token_was)) if token_changed? }
after_save { invalidate_cache if token_changed? }
after_save :add_to_redis, if: :valid_user?
after_save :save_cdb_conf_info, unless: :skip_cdb_conf_info?
after_destroy :reassign_owner, :drop_db_role, if: ->(k) { k.needs_setup? && !k.skip_role_setup }
after_destroy :remove_from_redis
after_destroy :invalidate_cache
after_destroy :remove_cdb_conf_info, unless: :skip_cdb_conf_info?
scope :master, -> { where(type: TYPE_MASTER) }
scope :default_public, -> { where(type: TYPE_DEFAULT_PUBLIC) }
scope :regular, -> { where(type: TYPE_REGULAR) }
scope :user_visible, -> { where(type: [TYPE_MASTER, TYPE_DEFAULT_PUBLIC, TYPE_REGULAR]) }
scope :by_type, ->(types) { types.blank? ? user_visible : where(type: types) }
scope :order_weighted_by_type, -> { order(TYPE_WEIGHTED_ORDER) }
attr_accessor :skip_role_setup, :ownership_role_name
attr_writer :skip_cdb_conf_info
private_class_method :new, :create, :create!
def self.create_master_key!(user: Carto::User.find(scope_attributes['user_id']))
create!(
user: user,
type: TYPE_MASTER,
name: NAME_MASTER,
token: user.api_key,
grants: MASTER_API_KEY_GRANTS,
db_role: user.database_username,
db_password: user.database_password
)
end
def self.create_default_public_key!(user: Carto::User.find(scope_attributes['user_id']))
create!(
user: user,
type: TYPE_DEFAULT_PUBLIC,
name: NAME_DEFAULT_PUBLIC,
token: TOKEN_DEFAULT_PUBLIC,
grants: [GRANTS_ALL_APIS],
db_role: user.database_public_username,
db_password: CartoDB::PUBLIC_DB_USER_PASSWORD
)
end
def self.create_regular_key!(user: Carto::User.find(scope_attributes['user_id']), name:, grants:)
over_regular_quota = CartoDB::QuotaChecker.new(user).will_be_over_regular_api_key_quota?
raise CartoDB::QuotaExceeded.new('You have reached the limit of API keys for your plan') if over_regular_quota
create!(
user: user,
type: TYPE_REGULAR,
name: name,
grants: grants
)
end
def self.create_oauth_key!(user: Carto::User.find(scope_attributes['user_id']), name:, grants:, ownership_role_name:)
create!(
user: user,
type: TYPE_OAUTH,
name: name,
grants: grants,
ownership_role_name: ownership_role_name
)
end
def self.new_from_hash(api_key_hash)
new(
id: api_key_hash[:id],
created_at: api_key_hash[:created_at],
db_password: api_key_hash[:db_password],
db_role: api_key_hash[:db_role],
name: api_key_hash[:name],
token: api_key_hash[:token],
type: api_key_hash[:type],
updated_at: api_key_hash[:updated_at],
grants: api_key_hash[:grants],
user_id: api_key_hash[:user_id],
skip_role_setup: true,
skip_cdb_conf_info: true
)
end
def revoke_permissions(table, revoked_permissions)
Carto::TableAndFriends.apply(db_connection, table.database_schema, table.name) do |s, t, qualified_name|
query = %{
REVOKE #{revoked_permissions.join(', ')}
ON TABLE #{qualified_name}
FROM \"#{db_role}\"
}
db_run(query)
sequences_for_table(s, t).each do |seq|
db_run("REVOKE ALL ON SEQUENCE #{seq} FROM \"#{db_role}\"")
end
end
end
def granted_apis
@granted_apis ||= process_granted_apis
end
def table_permissions
@table_permissions_cache ||= process_table_permissions
@table_permissions_cache.values
end
def schema_permissions
@schema_permissions_cache ||= process_schema_permissions
@schema_permissions_cache.values
end
def dataset_metadata_permissions
@dataset_metadata_permissions ||= process_dataset_metadata_permissions
end
def data_observatory_permissions?
granted_apis&.include?(API_DO)
end
def table_permissions_from_db
query = %{
WITH permissions AS (
SELECT
table_schema,
table_name,
string_agg(DISTINCT lower(privilege_type),',') privilege_types
FROM
information_schema.table_privileges tp
WHERE
tp.grantee = '#{db_role}'
GROUP BY
table_schema,
table_name
),
ownership AS (
SELECT
n.nspname as table_schema,
relname as table_name
FROM pg_class
JOIN pg_catalog.pg_namespace n ON n.oid = pg_class.relnamespace
WHERE pg_catalog.pg_get_userbyid(relowner) = '#{db_role}'
)
SELECT
p.table_name,
p.table_schema,
p.privilege_types,
CASE WHEN o.table_name IS NULL THEN false
ELSE true
END AS owner
FROM permissions p
LEFT JOIN ownership o ON (p.table_name = o.table_name AND p.table_schema = o.table_schema)
}
db_run(query).map do |line|
TablePermissions.new(schema: line['table_schema'],
name: line['table_name'],
owner: line['owner'] == 't' ? true : false,
permissions: line['privilege_types'].split(','))
end
end
def schema_permissions_from_db
user.db_service.all_schemas_granted_hashed(db_role).map do |schema, permissions|
SchemaPermissions.new(name: schema, permissions: permissions)
end
end
def data_services
@data_services ||= process_data_services
end
def user_data
@user_data ||= process_user_data_grants
end
def regenerate_token!
if master?
# Send all master key updates through the user model, avoid circular updates
::User[user.id].regenerate_api_key
reload
else
create_token
save!
end
end
def master?
type == TYPE_MASTER
end
def default_public?
type == TYPE_DEFAULT_PUBLIC
end
def regular?
type == TYPE_REGULAR
end
def oauth?
type == TYPE_OAUTH
end
def needs_setup?
regular? || oauth?
end
def data_services?
data_services.present?
end
def valid_name_for_type
if !master? && name == NAME_MASTER || !default_public? && name == NAME_DEFAULT_PUBLIC
errors.add(:name, "api_key name cannot be #{NAME_MASTER} nor #{NAME_DEFAULT_PUBLIC}")
end
end
def can_be_deleted?
regular?
end
def role_creation_queries
["CREATE ROLE \"#{db_role}\" NOSUPERUSER NOCREATEDB LOGIN ENCRYPTED PASSWORD '#{db_password}'"]
end
def role_permission_queries
queries = [
"GRANT \"#{user.service.database_public_username}\" TO \"#{db_role}\"",
"ALTER ROLE \"#{db_role}\" SET search_path TO #{user.db_service.build_search_path}",
]
# This is GRANTED to the organizational role for organization users, and the PUBLIC users for non-orgs
# We do not want to grant the organization role to the Api Keys, since that also opens access to the analysis
# catalog and tablemetadata. To be more consistent, we should probably GRANT this to the organization public
# user instead, but that has the downside of leaking quotas to the public.
# This works for now, but if you are adding new permissions, please reconsider this decision.
if user.organization_user?
queries << "GRANT ALL ON FUNCTION \"#{user.database_schema}\"._CDB_UserQuotaInBytes() TO \"#{db_role}\""
end
if regular?
queries << "GRANT \"#{db_role}\" TO \"#{user.database_username}\""
end
queries
end
def set_enabled_for_engine
# We enable/disable API keys for engine usage by adding/removing them from Redis
valid_user? ? add_to_redis : remove_from_redis
end
def save_cdb_conf_info
db_run("SELECT cartodb.cdb_conf_setconf('#{CDB_CONF_KEY_PREFIX}#{db_role}', '#{cdb_conf_info.to_json}');")
end
def remove_cdb_conf_info
db_run("SELECT cartodb.CDB_Conf_RemoveConf('#{CDB_CONF_KEY_PREFIX}#{db_role}');")
end
def cdb_conf_info
{
username: user.username,
permissions: data_services || [],
ownership_role_name: effective_ownership_role_name || ''
}
end
def skip_cdb_conf_info
@skip_cdb_conf_info || false
end
def skip_cdb_conf_info?
skip_cdb_conf_info.present?
end
def effective_ownership_role_name
return if schema_permissions.all? { |s| s.permissions.empty? }
ownership_role_name || oauth_access_token.try(:ownership_role_name)
end
def grant_ownership_role_privileges
db_run("GRANT \"#{effective_ownership_role_name}\" TO \"#{db_role}\"") if effective_ownership_role_name.present?
end
private
PASSWORD_LENGTH = 40
REDIS_KEY_PREFIX = 'api_keys:'.freeze
def raise_unprocessable_entity_error(error)
raise Carto::UnprocesableEntityError.new(/PG::Error: ERROR: (.+)/ =~ error.message && $1 || 'Unexpected error')
end
def invalidate_cache
return unless user
user.invalidate_varnish_cache
end
def create_token
begin
self.token = generate_auth_token
end while self.class.exists?(user_id: user_id, token: token)
end
def add_to_redis
redis_client.hmset(redis_key, redis_hash_as_array)
end
def process_granted_apis
apis = grants.find { |v| v[:type] == 'apis' }[:apis]
raise UnprocesableEntityError.new('apis array is needed for type "apis"') unless apis
apis
end
def process_table_permissions
table_permissions = {}
databases = grants.find { |v| v[:type] == 'database' }
return table_permissions unless databases.try(:[], :tables).present?
databases[:tables].each do |table|
table_id = "#{table[:schema]}.#{table[:name]}"
table_permissions[table_id] ||= Carto::TablePermissions.new(schema: table[:schema], name: table[:name])
table_permissions[table_id].merge!(table[:permissions])
end
table_permissions
end
def process_schema_permissions
schema_permissions = {}
databases = grants.find { |v| v[:type] == 'database' }
return schema_permissions unless databases.try(:[], :schemas).present?
databases[:schemas].each do |schema|
schema_id = schema[:name]
schema_permissions[schema_id] ||= Carto::SchemaPermissions.new(name: schema[:name])
schema_permissions[schema_id].merge!(schema[:permissions])
end
schema_permissions
end
def process_data_services
data_services_grants = grants.find { |v| v[:type] == 'dataservices' }
return nil unless data_services_grants.present?
data_services_grants[:services]
end
def process_user_data_grants
user_data_grants = grants.find { |v| v[:type] == 'user' }
return nil unless user_data_grants.present?
user_data_grants[:data]
end
def process_dataset_metadata_permissions
dataset_metadata_grants = grants.find { |v| v[:type] == 'database' }
dataset_metadata_grants.try(:[], :table_metadata)
end
def check_permissions
# Only checks if no previous errors in JSON definition
check_table_permissions
check_schema_permissions
end
def check_table_permissions
if errors[:grants].empty? && invalid_tables_permissions.any?
errors.add(:grants, 'can only grant table permissions you have')
end
end
def check_schema_permissions
if errors[:grants].empty? && invalid_schemas_permissions.any?
errors.add(:grants, 'can only grant schema permissions you have')
end
end
def invalid_tables_permissions
databases = grants.find { |v| v[:type] == 'database' }
return [] unless databases.try(:[], :tables).present?
allowed = user.db_service.all_tables_granted_hashed
invalid = []
databases[:tables].each do |table|
if allowed[table[:schema]].nil? ||
allowed[table[:schema]][table[:name]].nil? ||
(table[:permissions] - allowed[table[:schema]][table[:name]]).any?
invalid << table
end
end
invalid
end
def invalid_schemas_permissions
databases = grants.find { |v| v[:type] == 'database' }
return [] unless databases.try(:[], :schemas).present?
allowed = user.db_service.all_schemas_granted_hashed
invalid = []
databases[:schemas].each do |schema|
invalid_schema = (schema[:permissions] - allowed[schema[:name]].to_a).any?
invalid << schema if invalid_schema
end
invalid
end
def create_db_config
begin
self.db_role = Carto::DB::Sanitize.sanitize_identifier("carto_role_#{SecureRandom.hex}")
end while self.class.exists?(user_id: user_id, db_role: db_role)
self.db_password = SecureRandom.hex(PASSWORD_LENGTH / 2) unless db_password
end
def setup_db_role
create_role
setup_table_permissions
setup_schema_permissions
grant_ownership_role_privileges
end
def setup_table_permissions
setup_permissions(table_permissions) do |tp|
Carto::TableAndFriends.apply(db_connection, tp.schema, tp.name) do |schema, table_name, qualified_name|
db_run("GRANT #{tp.permissions.join(', ')} ON TABLE #{qualified_name} TO \"#{db_role}\"")
sequences_for_table(schema, table_name).each do |seq|
db_run("GRANT USAGE, SELECT ON SEQUENCE #{seq} TO \"#{db_role}\"")
end
end
end
schemas_from_granted_tables.each { |s| grant_usage_privileges_for_schema(s) }
end
def setup_schema_permissions
setup_permissions(schema_permissions) do |sp|
db_run("GRANT #{sp.permissions.join(', ')} ON SCHEMA \"#{sp.name}\" TO \"#{db_role}\"")
end
end
def setup_permissions(permissions)
non_existent = []
errors = []
permissions.each do |api_key_permission|
next if api_key_permission.permissions.empty?
begin
# here we catch exceptions to show a proper error to the user request
# this is because we allow OAuth requests to include a `datasets` or `schemas` scope with
# tables or schema that may or may not exist
yield api_key_permission
rescue Carto::UnprocesableEntityError => e
raise e unless e.message =~ /does not exist/
non_existent << api_key_permission.name
errors << e.message
end
end
raise Carto::RelationDoesNotExistError.new(errors, non_existent) unless non_existent.empty?
end
def create_role
(role_creation_queries + role_permission_queries).each { |q| db_run(q) }
end
def drop_db_role
db_run("DROP OWNED BY \"#{db_role}\"")
db_run("DROP ROLE IF EXISTS \"#{db_role}\"")
end
def reassign_owner
db_run("REASSIGN OWNED BY \"#{db_role}\" TO \"#{user.database_username}\";")
end
def schemas_from_granted_tables
# assume table friends don't introduce new schemas
table_permissions.map(&:schema).uniq
end
def redis_key(token = self.token)
"#{REDIS_KEY_PREFIX}#{user.username}:#{token}"
end
def remove_from_redis(key = redis_key)
redis_client.del(key)
end
def sequences_for_table(schema, table)
db_run(%{
SELECT
n.nspname, c.relname
FROM
pg_depend d
JOIN pg_class c ON d.objid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE
d.refobjsubid > 0 AND
d.classid = 'pg_class'::regclass AND
c.relkind = 'S'::"char" AND
d.refobjid = (
QUOTE_IDENT(#{db_connection.quote(schema)}) ||
'.' ||
QUOTE_IDENT(#{db_connection.quote(table)})
)::regclass;
}).map { |r| "\"#{r['nspname']}\".\"#{r['relname']}\"" }
end
def db_run(query, connection = db_connection)
connection.execute(query)
rescue ActiveRecord::StatementInvalid => e
CartoDB::Logger.warning(message: 'Error running SQL command', exception: e)
return if e.message =~ /OWNED BY/ # role might not exist becuase it has been already dropped
raise_unprocessable_entity_error(e)
end
def user_db_run(query)
db_run(query, user_db_connection)
end
def db_connection
@db_connection ||= user.in_database(as: :superuser)
end
def user_db_connection
@user_db_connection ||= user.in_database
end
def redis_hash_as_array
hash = ['user', user.username, 'type', type, 'database_role', db_role, 'database_password', db_password]
granted_apis.each { |api| hash += ["grants_#{api}", true] }
hash
end
def redis_client
$users_metadata
end
def grant_usage_privileges_for_schema(schema)
db_run("GRANT USAGE ON SCHEMA \"#{schema}\" TO \"#{db_role}\"")
db_run("GRANT ALL ON FUNCTION \"#{schema}\"._CDB_UserQuotaInBytes() TO \"#{db_role}\"")
end
def valid_master_key
errors.add(:name, "must be #{NAME_MASTER} for master keys") unless name == NAME_MASTER
unless grants == MASTER_API_KEY_GRANTS
errors.add(:grants, "must grant all apis")
end
errors.add(:token, "must match user model for master keys") unless token == user.api_key
end
def valid_default_public_key
errors.add(:name, "must be #{NAME_DEFAULT_PUBLIC} for default public keys") unless name == NAME_DEFAULT_PUBLIC
errors.add(:grants, "must grant all apis") unless grants == [GRANTS_ALL_APIS]
errors.add(:token, "must be #{TOKEN_DEFAULT_PUBLIC} for default public keys") unless token == TOKEN_DEFAULT_PUBLIC
end
def valid_user?
# This is not avalidation per-se, since we don't want to remove api keys when a user is disabled
!(user.locked? || regular? && !user.engine_enabled?)
end
end
end
+45
View File
@@ -0,0 +1,45 @@
module Carto
class ApiKeyPermissions
attr_reader :name, :permissions
def initialize(name:, permissions: [])
@name = name
@permissions = permissions
end
def merge!(permissions)
down_permissions = permissions.map(&:downcase)
@permissions += down_permissions.reject { |p| @permissions.include?(p) }
end
def write?
!(@permissions & write_permissions).empty?
end
def write_permissions; end
end
class TablePermissions < ApiKeyPermissions
WRITE_PERMISSIONS = ['insert', 'update', 'delete', 'truncate'].freeze
attr_reader :schema, :owner
def initialize(schema:, name:, owner: false, permissions: [])
super(name: name, permissions: permissions)
@schema = schema
@owner = owner
end
def write_permissions
WRITE_PERMISSIONS
end
end
class SchemaPermissions < ApiKeyPermissions
WRITE_PERMISSIONS = ['create'].freeze
def write_permissions
WRITE_PERMISSIONS
end
end
end
+77
View File
@@ -0,0 +1,77 @@
require_dependency 'carto/assets/image_assets_service'
require_dependency 'carto/assets/organization_image_assets_service'
require_dependency 'carto/assets/kuviz_assets_service'
module Carto
class Asset < ActiveRecord::Base
belongs_to :user, class_name: Carto::User
belongs_to :organization, class_name: Carto::Organization
belongs_to :visualization, class_name: Carto::Visualization
serialize :storage_info, CartoJsonSymbolizerSerializer
validates :storage_info, carto_json_symbolizer: true
validates :user, presence: true, unless: -> { organization.present? || visualization.present? }
validates :organization, presence: true, unless: -> { user.present? || visualization.present? }
validates :visualization, presence: true, unless: -> { user.present? || organization.present? }
validates :storage_info, presence: true, if: -> { organization.present? || visualization.present? }
validates :public_url, presence: true
validate :validate_storage_info, if: :storage_info
before_destroy :remove_asset_from_storage, if: :storage_info
def self.for_organization(organization:, resource:)
storage_info, url = OrganizationImageAssetsService.instance.upload(organization, resource)
new(organization: organization,
public_url: url,
storage_info: storage_info,
kind: 'organization_asset')
end
def self.for_visualization(visualization:, resource:)
storage_info, url = KuvizAssetsService.instance.upload(visualization, resource)
new(visualization: visualization,
public_url: url,
storage_info: storage_info,
kind: 'kuviz_asset')
end
def update_visualization_resource(resource)
storage_info, url = KuvizAssetsService.instance.upload(visualization, resource)
self.public_url = url
self.storage_info = storage_info
save
end
def absolute_public_url
uri = URI.parse(public_url)
(uri.absolute? ? uri : URI.join(base_domain, uri)).to_s
rescue URI::InvalidURIError
public_url
end
private
def validate_storage_info
schema = Definition.instance.load_from_file('lib/formats/asset/storage_info.json')
indifferent_storage_info = storage_info.with_indifferent_access
errs = JSON::Validator::fully_validate(schema, indifferent_storage_info)
errors.add(:storage_info, errs.join(', ')) if errs.any?
end
def remove_asset_from_storage
Carto::AssetsService.new.remove(storage_info)
end
def base_domain
if visualization.present?
return CartoDB.base_domain_from_name(visualization.user.subdomain)
end
CartoDB.base_domain_from_name(user ? user.subdomain : organization.name)
end
end
end
+9
View File
@@ -0,0 +1,9 @@
require 'active_record'
module Carto
class AutomaticGeocoding < ActiveRecord::Base
has_many :geocodings, -> { order(:created_at) }
belongs_to :table, class_name: Carto::UserTable, inverse_of: :automatic_geocodings, foreign_key: :id
belongs_to :user, class_name: Carto::User, inverse_of: :automatic_geocodings
end
end
+66
View File
@@ -0,0 +1,66 @@
module Carto
# Rails 3 stores nil strings with JSON serialization as 'null', so we need to replace it with a valid value.
# See https://github.com/rails/rails/issues/13144
class CartoJsonSerializer
def self.dump(hash)
hash.try(&:to_json)
end
def self.load(value)
value.try(&:with_indifferent_access)
end
end
# CartoJsonSerializer is closer to Rails Json serializer, using with_indifferent_access
# for load. The problem with that is that although you can _access_ first
# level keys both with symbols and keys, other common operations, such as
# key comparison, fail, and it only applies to the first level.
class CartoJsonSymbolizerSerializer < CartoJsonSerializer
def self.load(value)
value && symbolize(value)
end
def self.symbolize(value)
if value.is_a?(Hash)
value.deep_symbolize_keys
elsif value.is_a?(Array)
value.map { |v| symbolize(v) }
else
value
end
end
private_class_method :symbolize
end
end
class CartoJsonSymbolizerValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
if value && !(value.is_a?(Hash) || value.is_a?(Array))
record.errors[attribute] << 'wrongly formatted (not a Hash or invalid JSON)'
end
end
end
class JsonSchemaValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return unless value
value_for_json = prepare_for_json(value)
schema = Carto::Definition.instance.load_from_file("lib/formats/#{record.class.to_s.underscore}/#{attribute}.json")
errors = JSON::Validator::fully_validate(schema, value_for_json, strict: true)
record.errors[attribute] << errors.join(', ') if errors.any?
end
private
def prepare_for_json(value)
if value.is_a? Hash
value.with_indifferent_access
elsif value.respond_to? :map
value.map { |v| prepare_for_json(v) }
else
value
end
end
end
+11
View File
@@ -0,0 +1,11 @@
require 'active_record'
module Carto
class ClientApplication < ActiveRecord::Base
belongs_to :user, class_name: Carto::User
has_many :oauth_tokens, class_name: Carto::OauthToken
has_many :access_tokens, -> { where(type: ['AccessToken']) }, class_name: Carto::OauthToken
end
end
@@ -0,0 +1,66 @@
require 'carto/connector'
class Carto::ConnectorConfiguration < ActiveRecord::Base
belongs_to :connector_provider, class_name: Carto::ConnectorProvider
belongs_to :user, class_name: Carto::User, inverse_of: :connector_configurations
belongs_to :organization, class_name: Carto::Organization, inverse_of: :connector_configurations
# A ConnectorConfiguration can belong to either user or an organization, but not both;
# it may also be a default configuration (no user or organization).
# Only one configuration may exist for any valid combination of user, organization and provider.
# So we have 3 kinds of configuration records:
# * default configuration for provider: user.nil? && organization.nil? FIXME: remove this? (only app_config defaults)
# * organization configuration: user.nil? && organization.present?
# * user configuration: organization.nil? && user.present?
validates :connector_provider_id, presence: true
validates :user_id, uniqueness: { scope: [:connector_provider_id, :organization_id] }, if: 'organization_id.nil?'
validates :organization_id, uniqueness: { scope: [:connector_provider_id, :user_id] }, if: 'user_id.nil?'
validate :not_user_and_organization_simultaneously
def not_user_and_organization_simultaneously
if user_id.present? && organization_id.present?
errors.add(:user_id, "can't assign to an organization simultaneously with a user")
end
end
# columns:
# enabled boolean
# max_rows integer
def self.default(provider)
# Look for default provider configration
config = where(user_id: nil, organization_id: nil, connector_provider_id: provider.id).first
if !config
# Create in memory record using app_config defaults
config = new(
connector_provider: provider,
enabled: Cartodb.get_config(:connectors, provider.name, 'enabled') || false,
max_rows: Cartodb.get_config(:connectors, provider.name, 'max_rows')
)
end
config
end
def self.for_organization(organization, provider)
if provider
config = where(organization_id: organization.id, connector_provider_id: provider.id).first
if config.blank?
config = default(provider)
end
config
end
end
def self.for_user(user, provider)
if provider
config = where(user_id: user.id, connector_provider_id: provider.id).first
if config.blank? && user.organization_id.present?
config = for_organization(user.organization, provider)
end
if config.blank?
config = default(provider)
end
config
end
end
end
+8
View File
@@ -0,0 +1,8 @@
require 'carto/connector'
class Carto::ConnectorProvider < ActiveRecord::Base
validates :name, presence: true
validates :name, uniqueness: true
has_many :connector_configurations
end
@@ -0,0 +1,12 @@
module Carto
class DashboardSearchResult
attr_accessor :tags, :visualizations, :total_count
def initialize(tags: [], visualizations: [], total_count: 0)
@tags = tags
@visualizations = visualizations
@total_count = total_count
end
end
end
+42
View File
@@ -0,0 +1,42 @@
require 'active_record'
module Carto
class DataImport < ActiveRecord::Base
include Carto::DataImportConstants
# INFO: hack to workaround `ActiveRecord::DangerousAttributeError: logger is defined by ActiveRecord`
class << self
def instance_method_already_implemented?(method_name)
return true if method_name == 'logger'
super
end
end
STATE_ENQUEUED = 'enqueued' # Default state for imports whose files are not yet at "import source"
STATE_PENDING = 'pending' # Default state for files already at "import source" (e.g. S3 bucket)
STATE_UNPACKING = 'unpacking'
STATE_IMPORTING = 'importing'
STATE_COMPLETE = 'complete'
STATE_UPLOADING = 'uploading'
STATE_FAILURE = 'failure'
STATE_STUCK = 'stuck'
belongs_to :user, class_name: Carto::User
belongs_to :log, class_name: Carto::Log, foreign_key: :logger
has_many :external_data_imports, inverse_of: :data_import, class_name: Carto::ExternalDataImport
has_many :user_tables, class_name: Carto::UserTable
validate :validate_collision_strategy
def is_raster?
::JSON.parse(self.stats).select{ |item| item['type'] == '.tif' }.length > 0
end
def final_state?
[STATE_COMPLETE, STATE_FAILURE, STATE_STUCK].include?(state)
end
private
end
end
+14
View File
@@ -0,0 +1,14 @@
module Carto
module DataImportConstants
COLLISION_STRATEGY_SKIP = 'skip'.freeze
COLLISION_STRATEGY_OVERWRITE = 'overwrite'.freeze
VALID_COLLISION_STRATEGIES = [COLLISION_STRATEGY_SKIP, COLLISION_STRATEGY_OVERWRITE, nil].freeze
def validate_collision_strategy
unless VALID_COLLISION_STRATEGIES.include?(collision_strategy)
errors.add(:collision_strategy, "Invalid collision strategy")
end
end
end
end
+15
View File
@@ -0,0 +1,15 @@
require 'active_record'
module Carto
class ExternalDataImport < ActiveRecord::Base
belongs_to :data_import, class_name: Carto::DataImport
belongs_to :external_source, class_name: Carto::ExternalSource
belongs_to :synchronization, class_name: Carto::Synchronization, dependent: :destroy
def self.by_user_id(user_id)
user_data_imports = Carto::DataImport.where(user_id: user_id)
Carto::ExternalDataImport.where(data_import_id: user_data_imports.select(:id)).all
end
end
end
+34
View File
@@ -0,0 +1,34 @@
require 'active_record'
class Carto::ExternalSource < ActiveRecord::Base
# Seconds
REFRESH_INTERVAL = 30*24*60*60
has_many :external_data_imports, class_name: Carto::ExternalDataImport, dependent: :destroy
belongs_to :visualization, inverse_of: :external_source
validates :visualization, :import_url, :rows_counted, :size, presence: true
def update_data(import_url, geometry_types, rows_counted, size, username = nil)
self.import_url = import_url
# This check avoids a "false change" if the data is actually equal (AR bug on arrays)
self.geometry_types = geometry_types unless equal_pg_array?(self.geometry_types, geometry_types)
self.rows_counted = rows_counted
self.size = size
self.username = username
self
end
def importable_by?(user)
user.present? && visualization.user_id == user.id
end
private
# True if array (`['ST_MultiPolygon']`) contains the same elements than string, in PG notation (`{ST_MultiPolygon}`)
def equal_pg_array?(array, string)
array = [] if array.nil?
string = '{}' if string.nil?
"{#{array.join(',')}}" == string
end
end
+13
View File
@@ -0,0 +1,13 @@
module Carto
class FeatureFlag < ActiveRecord::Base
validates :name, presence: true
has_many :feature_flags_user, dependent: :destroy
def self.find_by_user(user)
FeatureFlag.where(restricted: false) + user.feature_flags.select(&:restricted)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
module Carto
class FeatureFlagsUser < ActiveRecord::Base
belongs_to :feature_flag, foreign_key: :feature_flag_id
belongs_to :user, foreign_key: :user_id
end
end
+50
View File
@@ -0,0 +1,50 @@
require 'active_record'
require_relative '../../../services/table-geocoder/lib/exceptions'
module Carto
class Geocoding < ActiveRecord::Base
PUBLIC_ATTRIBUTES = [:id, :table_id, :table_name, :state, :kind, :country_code, :region_code, :formatter,
:geocoder_type, :geometry_type, :error, :processed_rows, :cache_hits, :processable_rows,
:real_rows, :price, :used_credits, :remaining_quota, :country_column, :region_column,
:data_import_id, :error_code]
def self.processable_rows(table_service, force_all_rows=false)
dataset = table_service.owner.in_database.select.from(table_service.sequel_qualified_table_name)
if !force_all_rows && dataset.columns.include?(:cartodb_georef_status)
dataset = dataset.exclude(cartodb_georef_status: true)
end
dataset.count
end
belongs_to :user
belongs_to :automatic_geocoding
def public_values
Hash[PUBLIC_ATTRIBUTES.map{ |k| [k, (self.send(k) rescue self[k].to_s)] }]
end
def error
additional_info = Carto::GeocoderErrors.additional_info(error_code)
if additional_info
{ title: additional_info.title, description: additional_info.what_about }
else
{ title: 'Geocoding error', description: '' }
end
end
def price
return 0 unless used_credits.to_i > 0
(user.geocoding_block_price * used_credits) / Carto::User::GEOCODING_BLOCK_SIZE.to_f
end
def remaining_quota
user.remaining_geocoding_quota
end
# TODO: Properly migrate log to AR and remove this
def log
CartoDB::Log[log_id]
end
end
end
+15
View File
@@ -0,0 +1,15 @@
module Carto
class Grantable
attr_reader :id, :name, :type, :avatar_url
def initialize(attributes)
h = attributes.symbolize_keys
@id = h[:id]
@name = h[:name]
@type = h[:type]
@avatar_url = h[:avatar_url]
end
end
end
+203
View File
@@ -0,0 +1,203 @@
require 'active_record'
require_dependency 'cartodb/errors'
require_dependency 'carto/helpers/auth_token_generator'
require_relative 'paged_model'
module Carto
# Groups are created by the editor because of extension requests, so
# standard Rails operations (creation, destruction, etc) doesn't trigger
# extension management functions. In order to keep extension and database
# in sync, there're several methods that do trigger it:
# - create_group_with_extension
# - rename_group_with_extension
# - destroy_group_with_extension
# - add_users_with_extension
# - remove_users_with_extension
class Group < ActiveRecord::Base
include PagedModel
include AuthTokenGenerator
belongs_to :organization, class_name: Carto::Organization
has_many :users_group, dependent: :destroy, class_name: Carto::UsersGroup
has_many :users, through: :users_group
private_class_method :new
validates :name, :database_role, :organization, presence: true
# In order to avoid locks between CDB_Organization_Remove_Organization_Access_Permission and CDB_Group_DropGroup
# the "shared with" deletion must be performed outside the transaction, on deletion.
after_commit :destroy_shared_with
# Constructor for groups already existing in the database
def self.new_instance(database_name, name, database_role, display_name = name)
organization = Organization.find_by_database_name(database_name)
raise "Organization not found for database #{database_name}" unless organization
raise CartoDB::ModelAlreadyExistsError if Group.find_by_organization_id_and_name_and_database_role(organization.id, name, database_role)
new(name: name, database_role: database_role, display_name: display_name, organization: organization)
end
# Creation of brand-new group with the extension
def self.create_group_with_extension(organization, display_name)
name = valid_group_name(display_name)
organization.owner.in_database do |conn|
create_group_extension_query(conn, name)
end
# Extension triggers a request to the editor databases endpoint which actually creates the group
group = Carto::Group.find_by_organization_id_and_name(organization.id, name)
raise "Group was not created by the extension. Is it installed and configured?" if group.nil?
group.display_name = display_name
group.save
group
end
# Constructor for groups metadata, ignores database roles. Should be generally avoided.
def self.new_instance_without_validation(name:, database_role:, display_name: name, auth_token: nil)
new(
name: name,
display_name: display_name,
database_role: database_role,
auth_token: auth_token
)
end
def rename_group_with_extension(new_display_name)
raise CartoDB::ModelAlreadyExistsError if Group.find_by_organization_id_and_display_name(organization.id, new_display_name)
new_name = Carto::Group.valid_group_name(new_display_name)
organization.owner.in_database do |conn|
Carto::Group.rename_group_extension_query(conn, name, new_name)
end
reload
self.display_name = new_display_name
save
end
# INFO: public because it's called by Organization.
def destroy_group_with_extension
# INFO: currently only a superuser can destroy a group. See CartoDB/cartodb-postgresql#114
organization.owner.in_database(as: :superuser) do |conn|
Carto::Group.destroy_group_extension_query(conn, name)
end
end
def add_users_with_extension(users)
organization.owner.in_database do |conn|
Carto::Group.add_users_group_extension_query(conn, name, users.collect(&:username))
end
reload
end
def remove_users_with_extension(users)
organization.owner.in_database do |conn|
Carto::Group.remove_users_group_extension_query(conn, name, users.collect(&:username))
end
reload
end
def grant_db_permission(table, access)
table.owner.in_database do |conn|
case access
when CartoDB::Permission::ACCESS_NONE
Carto::Group.revoke_all(conn, name, table.database_schema, table.name)
when CartoDB::Permission::ACCESS_READONLY
Carto::Group.grant_read(conn, name, table.database_schema, table.name)
when CartoDB::Permission::ACCESS_READWRITE
Carto::Group.grant_write(conn, name, table.database_schema, table.name)
else
raise "Unknown access: #{access}"
end
end
end
def rename(new_name, new_database_role)
self.name = new_name
self.database_role = new_database_role
end
def add_user(username)
user = Carto::User.find_by_username(username)
raise "User #{username} not found" unless user
raise CartoDB::ModelAlreadyExistsError unless users_group.where(user_id: user.id, group_id: id).first.nil?
user_group = Carto::UsersGroup.new(user: user, group: self)
users_group << user_group
user_group
end
def remove_user(username)
user = Carto::User.find_by_username(username)
raise "User #{username} not found" unless user
if users.include?(user)
users.destroy(user)
user
end
end
def database_name
organization.database_name
end
private
def destroy_shared_with
if transaction_include_any_action?([:destroy])
Carto::SharedEntity.where(recipient_id: id).each do |se|
viz = Carto::Visualization.find(se.entity_id)
permission = viz.permission
permission.remove_group_permission(self)
permission.save
end
end
end
# TODO: PG Format("%I", strvar); ?
def self.valid_group_name(display_name)
name = display_name.squish
name = "g_#{name}" unless name[/^[a-zA-Z_]{1}/]
name.gsub(/[^a-zA-Z0-9_ ]/, '_').gsub(/_{2,}/, '_')
end
def self.create_group_extension_query(conn, name)
conn.execute(%{ select cartodb.CDB_Group_CreateGroup('#{name}') })
end
def self.rename_group_extension_query(conn, name, new_name)
conn.execute(%{ select cartodb.CDB_Group_RenameGroup('#{name}', '#{new_name}') })
end
def self.destroy_group_extension_query(conn, name)
conn.execute(%{ select cartodb.CDB_Group_DropGroup('#{name}') })
end
def self.add_users_group_extension_query(conn, name, usernames)
conn.execute(%{ select cartodb.CDB_Group_AddUsers('#{name}', ARRAY['#{usernames.join("','")}']) })
end
def self.remove_users_group_extension_query(conn, name, usernames)
conn.execute(%{ select cartodb.CDB_Group_RemoveUsers('#{name}', ARRAY['#{usernames.join("','")}']) })
end
def self.revoke_all(conn, group_name, table_database_schema, table_name)
conn.execute(%{ select cartodb._CDB_Group_Table_RevokeAll('#{group_name}', '#{table_database_schema}', '#{table_name}', false) })
end
def self.grant_read(conn, group_name, table_database_schema, table_name)
conn.execute(%{ select cartodb._CDB_Group_Table_GrantRead('#{group_name}', '#{table_database_schema}', '#{table_name}', false) })
end
def self.grant_write(conn, group_name, table_database_schema, table_name)
conn.execute(%{ select cartodb._CDB_Group_Table_GrantReadWrite('#{group_name}', '#{table_database_schema}', '#{table_name}', false) })
end
end
end
@@ -0,0 +1,19 @@
module Carto
module AuthTokenGenerator
def get_auth_token
auth_token || generate_and_save_auth_token
end
private
def generate_and_save_auth_token
self.auth_token = generate_auth_token
save
auth_token
end
def generate_auth_token
SecureRandom.urlsafe_base64(nil, false)
end
end
end
@@ -0,0 +1,30 @@
module Carto
module BatchQueriesStatementTimeout
def batch_queries_statement_timeout
if !defined?(@batch_queries_statement_timeout)
timeout_str = $users_metadata.HMGET(batch_limits_key, 'timeout').first
@batch_queries_statement_timeout = timeout_str.nil? ? nil : timeout_str.to_i
end
@batch_queries_statement_timeout
end
def batch_queries_statement_timeout=(timeout_ms)
unless !timeout_ms.present? || timeout_ms.to_i > 0
raise 'batch_queries_statement_timeout must be nil or an integer greater than 0'
end
if !timeout_ms.present?
# NOTE: nil is cast to the empty string in redis (""), which can be cast to 0 in
# the SQL API. In order to prevent undesired behavior, just delete this key.
@batch_queries_statement_timeout = nil
$users_metadata.HDEL batch_limits_key, 'timeout'
else
@batch_queries_statement_timeout = timeout_ms.to_i
$users_metadata.HMSET batch_limits_key, 'timeout', @batch_queries_statement_timeout
end
end
def batch_limits_key
"limits:batch:#{username}"
end
end
end
+27
View File
@@ -0,0 +1,27 @@
require 'active_support/time'
module Carto
module BillingCycle
def last_billing_cycle
day = period_end_date.day
date = day > Date.today.day ? (Date.today - 1.month) : Date.today
begin
Date.new(date.year, date.month, day)
rescue ArgumentError
day = day - 1
retry
end
end
def next_billing_cycle
day = period_end_date.day
date = day > Date.today.day ? Date.today : Date.today + 1.month
begin
Date.new(date.year, date.month, day)
rescue ArgumentError
day = day - 1
retry
end
end
end
end
@@ -0,0 +1,8 @@
module Carto
module HasConnectorConfiguration
def connector_configuration(provider_name)
provider = ConnectorProvider.find_by_name(provider_name)
ConnectorConfiguration.for_user(self, provider)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
class UrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors[attribute] << (options[:message] || "must be a valid URL") unless url_valid?(value)
end
def url_valid?(url)
url = URI.parse(url) rescue false
url.kind_of?(URI::HTTP) || url.kind_of?(URI::HTTPS)
end
end
+98
View File
@@ -0,0 +1,98 @@
require 'active_record'
require 'cartodb-common'
require_dependency 'cartodb/errors'
require_dependency 'carto/user_authenticator'
module Carto
class Invitation < ActiveRecord::Base
# Because of an activerecord-postgresql-array bug that makes array
# insertions unusable we can't set _users_emails mandatory on construction,
# so we create a creator enforcing desired behaviour.
# This will be fixed when we upgrade Ruby and Rails
# validates :users_emails, :welcome_text, presence: true
validates :inviter_user, :organization, :welcome_text, presence: true
validates :users_emails, email: true
validate :users_emails_not_taken
belongs_to :inviter_user, class_name: Carto::User
belongs_to :organization
private_class_method :new
def self.create_new(inviter_user, users_emails, welcome_text, viewer)
raise CartoDB::InvalidUser.new("Only admins can create invitations") unless inviter_user.organization_admin?
# ActiveRecord validation for all values
invitation = new(inviter_user: inviter_user,
organization: inviter_user.organization,
users_emails: users_emails,
welcome_text: welcome_text,
viewer: viewer)
return invitation unless invitation.valid?
# Two-step creation workarounding array bug
invitation = new(inviter_user: inviter_user,
organization: inviter_user.organization,
welcome_text: welcome_text,
viewer: viewer)
invitation.seed = Carto::Common::EncryptionService.make_token
if invitation.save
invitation.reload
invitation.users_emails = users_emails
invitation.used_emails = []
invitation.save
::Resque.enqueue(::Resque::OrganizationJobs::Mail::Invitation, invitation.id)
end
invitation
end
def self.query_with_valid_email(email)
Carto::Invitation.where('? = ANY(users_emails)', email)
end
def self.query_with_unused_email(email)
query_with_valid_email(email).where('? != ALL(used_emails)', email)
end
def token(email)
Carto::Common::EncryptionService.encrypt(sha_class: Digest::SHA256, password: email, salt: seed)
end
def use(email, token)
# reload and used_emails assignment is needed because otherwise
# activerecord-postgresql-array won't update the invitations
reload
if users_emails.include?(email) && verify_token(token, email)
if used_emails.include?(email)
raise AlreadyUsedInvitationError.new("#{email} has already used the invitation")
else
self.used_emails = used_emails.push(email)
save
true
end
else
false
end
end
private
def verify_token(token, email)
Carto::Common::EncryptionService.verify(password: email, secure_password: token, salt: seed)
end
def users_emails_not_taken
return unless users_emails
users_emails.each do |email|
errors[:users_emails] << "Existing user for #{email}" if Carto::User.find_by_email(email)
end
end
end
end
class AlreadyUsedInvitationError < StandardError; end
+372
View File
@@ -0,0 +1,372 @@
require 'active_record'
require 'active_record/connection_adapters/postgresql/oid/json'
require_relative './carto_json_serializer'
require_dependency 'carto/table_utils'
require_dependency 'carto/query_rewriter'
module Carto
module LayerTableDependencies
private
def affected_tables
return [] unless maps.first.present? && options.present?
node_id = options.symbolize_keys[:source]
if node_id.present?
visualization_id = map.visualization.id
node = AnalysisNode.find_by_natural_id(visualization_id, node_id)
return [] unless node
dependencies = node.source_descendants.map do |source_node|
tables_by_query = tables_from_query(source_node.params[:query])
table_name = source_node.options[:table_name]
tables_by_name = table_name ? tables_from_names([table_name], user) : []
tables_by_query.present? ? tables_by_query : tables_by_name
end
dependencies.flatten.compact.uniq
else
tables_by_query = tables_from_query_option
dependencies = tables_by_query.present? ? tables_by_query : tables_from_table_name_option
dependencies.compact.uniq
end
end
def tables_from_query_option
tables_from_query(query)
end
def tables_from_query(query)
query.present? ? tables_from_names(affected_table_names(query), user) : []
rescue => e
# INFO: this covers changes that CartoDB can't track, so we must handle it gracefully.
# For example, if layer SQL contains wrong SQL (uses a table that doesn't exist, or uses an invalid operator).
# This warning level is checked in tests to ensure that embed view does not need user DB connection,
# so we need to keep it (or change the tests accordingly)
CartoDB::Logger.warning(message: 'Could not retrieve tables from query', exception: e, user: user, layer: self)
[]
end
def tables_from_table_name_option
return [] if options.empty?
sym_options = options.symbolize_keys
user_name = sym_options[:user_name]
table_name = sym_options[:table_name]
schema_prefix = user_name.present? && table_name.present? && !table_name.include?('.') ? %{"#{user_name}".} : ''
tables_from_names(["#{schema_prefix}#{table_name}"], user)
end
end
class Layer < ActiveRecord::Base
include Carto::TableUtils
include LayerTableDependencies
include Carto::QueryRewriter
attribute :options, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json.new
attribute :infowindow, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json.new
attribute :tooltip, ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json.new
serialize :options, CartoJsonSerializer
serialize :infowindow, CartoJsonSerializer
serialize :tooltip, CartoJsonSerializer
has_many :layers_maps, dependent: :destroy
has_many :maps, through: :layers_maps, after_add: :after_added_to_map
has_many :layers_user, dependent: :destroy
has_many :users, through: :layers_user, after_add: :set_default_order
has_many :layers_user_tables, dependent: :destroy
has_many :user_tables, through: :layers_user_tables, class_name: Carto::UserTable
has_many :widgets, -> { order(:order) }, class_name: Carto::Widget
has_many :legends, -> { order(:created_at) }, class_name: Carto::Legend, dependent: :destroy
has_many :layer_node_styles
before_destroy :ensure_not_viewer
before_save :lock_user_tables
after_save :invalidate_maps, :update_layer_node_style
after_save :register_table_dependencies, if: :data_layer?
ALLOWED_KINDS = %w{carto tiled background gmapsbase torque wms}.freeze
validates :kind, inclusion: { in: ALLOWED_KINDS }
validate :validate_not_viewer
TEMPLATES_MAP = {
'table/views/infowindow_light' => 'infowindow_light',
'table/views/infowindow_dark' => 'infowindow_dark',
'table/views/infowindow_light_header_blue' => 'infowindow_light_header_blue',
'table/views/infowindow_light_header_yellow' => 'infowindow_light_header_yellow',
'table/views/infowindow_light_header_orange' => 'infowindow_light_header_orange',
'table/views/infowindow_light_header_green' => 'infowindow_light_header_green',
'table/views/infowindow_header_with_image' => 'infowindow_header_with_image'
}.freeze
def set_default_order(parent)
# Reload maps upon adding this layer to a map (AR doesn't do this automatically)
maps.reload if persisted?
return unless order.nil?
max_order = parent.layers.map(&:order).compact.max || -1
self.order = max_order + 1
save if persisted?
end
# Sequel model compatibility (for TableBlender)
# TODO: Remove this after `::UserTable` deletion, and inline into TableBlender
def add_map(map)
map.layers << self
end
def user_tables_readable_by(user)
user_tables.select { |ut| ut.readable_by?(user) }
end
def data_readable_by?(user)
user_tables.all? { |ut| ut.readable_by?(user) }
end
def legend
@legend ||= options['legend']
end
def qualified_table_name(schema_owner_user = nil)
table_name = options['table_name']
if table_name.present? && table_name.include?('.')
table_name
else
schema_prefix = schema_owner_user.nil? ? '' : "#{schema_owner_user.sql_safe_database_schema}."
"#{schema_prefix}#{safe_table_name_quoting(options['table_name'])}"
end
end
# INFO: for vizjson v3 this is not used, see VizJSON3LayerPresenter#to_vizjson_v3
def infowindow_template_path
if infowindow.present? && infowindow['template_name'].present?
template_name = TEMPLATES_MAP.fetch(infowindow['template_name'], infowindow['template_name'])
Rails.root.join("lib/assets/javascripts/cartodb/table/views/infowindow/templates/#{template_name}.jst.mustache")
end
end
# INFO: for vizjson v3 this is not used, see VizJSON3LayerPresenter#to_vizjson_v3
def tooltip_template_path
if tooltip.present? && tooltip['template_name'].present?
template_name = TEMPLATES_MAP.fetch(tooltip['template_name'], tooltip['template_name'])
Rails.root.join("lib/assets/javascripts/cartodb/table/views/tooltip/templates/#{template_name}.jst.mustache")
end
end
def basemap?
gmapsbase? || tiled?
end
def base_layer?
tiled? || background? || gmapsbase? || wms?
end
def torque?
kind == 'torque'
end
def data_layer?
!base_layer?
end
def user_layer?
tiled? || background? || gmapsbase? || wms?
end
def named_map_layer?
tiled? || background? || gmapsbase? || wms? || carto?
end
def carto?
kind == 'carto'
end
def tiled?
kind == 'tiled'
end
def background?
kind == 'background'
end
def gmapsbase?
kind == 'gmapsbase'
end
def wms?
kind == 'wms'
end
def supports_labels_layer?
basemap? && options["labels"] && options["labels"]["urlTemplate"]
end
def map
maps[0]
end
def visualization
map.visualization if map
end
def user
@user ||= map.nil? ? nil : map.user
end
def default_query(user = nil)
sym_options = options.symbolize_keys
query = sym_options[:query]
if query.present?
query
else
user_username = user.nil? ? nil : user.username
user_name = sym_options[:user_name] || user_username
table_name = sym_options[:table_name]
qualify = (user && user.organization_user?) || user_username != user_name
if table_name.present? && !table_name.include?('.') && user_name.present? && qualify
"SELECT * FROM #{safe_table_name_quoting(user_name)}.#{safe_table_name_quoting(table_name)}"
else
"SELECT * FROM #{qualified_table_name}"
end
end
end
def register_table_dependencies
if data_layer?
if persisted?
user_tables.reload
maps.reload
end
self.user_tables = affected_tables
end
end
def fix_layer_user_information(old_username, new_user, renamed_tables)
new_username = new_user.username
if options.key?(:user_name)
old_username = options[:user_name] || old_username
options[:user_name] = new_username
end
if options.key?(:table_name)
old_table_name = options[:table_name]
options[:table_name] = renamed_tables.fetch(old_table_name, old_table_name)
end
# query_history is not modified as a safety measure for cases where this naive replacement doesn't work
query = options[:query]
options[:query] = rewrite_query(query, old_username, new_user, renamed_tables) if query.present?
end
def force_notify_change
map.force_notify_map_change if map
end
def custom?
CUSTOM_CATEGORIES.include?(category)
end
def category
options && options['category']
end
def rename_table(current_table_name, new_table_name)
return self unless data_layer?
target_keys = %w{table_name tile_style query}
targets = options.select { |key, _| target_keys.include?(key) }
renamed = targets.map do |key, value|
[key, rename_in(value, current_table_name, new_table_name)]
end
self.options = options.merge(renamed.to_h)
self
end
def uses_private_tables?
user_tables.any?(&:private?)
end
def after_added_to_map(map)
# This avoids unnecessary operations for in-memory logic. Example: Mapcap recreation. See #12473.
return unless map.persisted?
set_default_order(map)
register_table_dependencies
end
def depends_on?(user_table)
layers_user_tables.map(&:user_table_id).include?(user_table.id)
end
def source_id
options.symbolize_keys[:source]
end
def qualify_for_organization(owner_username)
options['query'] = qualify_query(query, options['table_name'], owner_username) if query
end
private
# The table dependencies will only be updated after the layer. However, when deleting them, they need to be deleted
# before the model. This can cause deadlocks with simultaneous request to update and delete the model.
# This request a explicit lock to PostgreSQL so the tables are always accessed in the same order. #11443
def lock_user_tables
user_tables.lock.all if persisted?
end
def rename_in(target, anchor, substitution)
return if target.blank?
regex = /(\A|\W+)(#{anchor})(\W+|\z)/
target.gsub(regex) { |match| match.gsub(anchor, substitution) }
end
CUSTOM_CATEGORIES = %w{Custom NASA TileJSON Mapbox WMS}.freeze
def tables_from_names(table_names, user)
::Table.get_all_user_tables_by_names(table_names, user)
end
def affected_table_names(query)
return [] unless query.present?
query_tables = user.in_database.execute("SELECT unnest(CDB_QueryTablesText(#{user.in_database.quote(query)}))")
query_tables.column_values(0).uniq
end
def query
options.symbolize_keys[:query]
end
def invalidate_maps
maps.each(&:notify_map_change)
end
def ensure_not_viewer
raise CartoDB::InvalidMember.new(user: "Viewer users can't destroy layers") if user && user.viewer
end
def validate_not_viewer
errors.add(:maps, "Viewer users can't edit layers") if maps.any? { |m| m.user && m.user.viewer }
end
def update_layer_node_style
style = current_layer_node_style
if style
style.update_from_layer(self)
style.save
end
end
def current_layer_node_style
return nil unless source_id
layer_node_styles.where(source_id: source_id).first || LayerNodeStyle.new(layer: self, source_id: source_id)
end
end
end
+23
View File
@@ -0,0 +1,23 @@
require_dependency 'carto/carto_json_serializer'
module Carto
class LayerNodeStyle < ActiveRecord::Base
belongs_to :layer
serialize :options, CartoJsonSymbolizerSerializer
serialize :infowindow, CartoJsonSymbolizerSerializer
serialize :tooltip, CartoJsonSymbolizerSerializer
OPTIONS_TO_COPY = [:sql_wrap, :style_properties, :tile_style].freeze
def self.from_visualization_and_source(visualization, source_id)
Carto::LayerNodeStyle.where(layer_id: visualization.data_layers.map(&:id), source_id: source_id)
end
def update_from_layer(layer)
self.infowindow = layer.infowindow || {}
self.tooltip = layer.tooltip || {}
self.options = layer.options.symbolize_keys.slice(*OPTIONS_TO_COPY)
end
end
end
+37
View File
@@ -0,0 +1,37 @@
require 'active_record'
require_dependency 'carto/visualization_backup_service'
module Carto
class LayersMap < ActiveRecord::Base
include Carto::VisualizationBackupService
belongs_to :layer, class_name: Carto::Layer
belongs_to :map, class_name: Carto::Map
before_destroy :backup_visualization
after_destroy :invalidate_map, :destroy_layer
private
def invalidate_map
map.notify_map_change if map
end
def destroy_layer
# Destroy the actual layer when removing it from the map (as layers cannot be in multiple maps)
# This is a workaround for a bug in Rails 4 (https://github.com/rails/rails/issues/13609)
# A better way to do this is with `dependent: :destroy` on the association
return if @layer_destroyed || layer.destroyed?
@layer_destroyed = true
layer.destroy
end
def backup_visualization
return if !map.visualization || map.visualization.destroyed? || @layer_destroyed || layer.destroyed?
create_visualization_backup(
visualization: map.visualization,
category: Carto::VisualizationBackup::CATEGORY_LAYER
)
end
end
end
+6
View File
@@ -0,0 +1,6 @@
module Carto
class LayersUser < ActiveRecord::Base
belongs_to :layer
belongs_to :user
end
end
+10
View File
@@ -0,0 +1,10 @@
require 'active_record'
module Carto
class LayersUserTable < ActiveRecord::Base
belongs_to :layer, class_name: Carto::Layer
belongs_to :user_table, class_name: Carto::UserTable
end
end
+205
View File
@@ -0,0 +1,205 @@
# See http://www.rubydoc.info/gems/net-ldap/0.11
require 'net/ldap'
class Carto::Ldap::Configuration < ActiveRecord::Base
# Not encrypted
ENCRYPTION_NONE = nil
# Encrypted from start
ENCRYPTION_SIMPLE_TLS = 'simple_tls'
# Upgrade to encrypted once connected
ENCRYPTION_START_TLS = 'start_tls'
ENCRYPTION_SSL_VERSION_DEFAULT = nil
ENCRYPTION_SSL_VERSION_TLSV1_1 = 'TLSv1_1'
DOMAIN_BASES_SEPARATOR = '||'
self.table_name = 'ldap_configurations'
belongs_to :organization, class_name: Carto::Organization
# @param Uuid id (Self-generated)
# @param Uuid organization_id
# @param String host LDAP host or ip address
# @param Int port LDAP port e.g. 389, 636 (LDAPS)
# @param String encryption (Optional) Encryption type to use. Empty means standard/simple Auth
# @param String ca_file UNUSED FOR NOW - (Optional) Certificate file path for start_tls encryption.
# Example: "/etc/cafile.pem"
# @param String ssl_version For start_tls_encryption. Example: "TLSv1_1"
# @param String connection_user Full CN for "search connections" to LDAP: `CN=admin, DC=cartodb, DC=COM`
# @param String connection_password Password for "search connections" to LDAP
# @param String user_id_field Which LDAP entry field represents the user id. e.g. `sAMAccountName`, `uid`
# @param String username_field Which LDAP entry field represents the username that will be mapped to cartodb.
# For now, same as user_id_field
# @param String email_field Which LDAP entry field represents the email
# @param String domain_bases List of DCs conforming the path.
# Serialized, e.g. "['a','b']", due to Rails 3 or PG gem issue handling `PG text[]` fields
# @param String additional_search_filter Additional filter to add (with &) to the search query if present
# @param String user_object_class Name of the attribute where the sers are maped in LDAP
# @param String group_object_class Name of the attribute where the groups are maped in LDAP
# @param DateTime created_at (Self-generated)
# @param DateTime updated_at (Self-generated)
attr_readonly :user_id_field
validates :organization, :host, :port, :connection_user, :connection_password, :user_id_field, :username_field,
:email_field, :user_object_class, :group_object_class, presence: true
validates :ca_file, length: { minimum: 0, allow_nil: true }
validates :encryption, inclusion: { in: [ENCRYPTION_SIMPLE_TLS, ENCRYPTION_START_TLS], allow_nil: true }
validates :ssl_version, inclusion: { in: [ENCRYPTION_SSL_VERSION_TLSV1_1], allow_nil: true }
validate :domain_bases_not_empty
def domain_bases_list
domain_bases.split(DOMAIN_BASES_SEPARATOR) if domain_bases
end
def domain_bases_list=(list)
self.domain_bases = list.join(DOMAIN_BASES_SEPARATOR)
end
# Returns matching Carto::Ldap::Entry or false if credentials are wrong
# @param String username. No full CN, just the username, e.g. 'administrator1'
# @param String password
def authenticate(username, password)
return false if username.blank? || password.blank?
ldap_connection = Net::LDAP.new(connect_timeout: CONNECTION_TIMEOUT)
ldap_connection.host = self.host
ldap_connection.port = self.port
configure_encryption(ldap_connection)
ldap_connection.auth self.connection_user, self.connection_password
valid_ldap_entry = nil
domain_bases_list.find do |domain|
valid_ldap_entry = ldap_connection.bind_as(
base: domain,
filter: search_filter(username),
password: password
)
end
@last_authentication_result = ldap_connection.get_operation_result
return false unless valid_ldap_entry
Carto::Ldap::Entry.new(valid_ldap_entry.first, self)
rescue Net::LDAP::Error => e
CartoDB::Logger.error(exception: e, message: 'Error authenticating against LDAP', username: username)
end
# INFO: Resets connection if already made
def test_connection
result = ldap_connection(true).bind
if result
{ success: true, connection: result }
else
{ success: false, error: last_operation_result.to_hash }
end
rescue => exception
{ success: false, error: { message: exception.message } }
end
def users(objectClass = self.user_object_class)
search_in_domain_bases(Net::LDAP::Filter.eq('objectClass', objectClass))
end
def groups(objectClass = self.group_object_class)
search_in_domain_bases(Net::LDAP::Filter.eq('objectClass', objectClass))
end
def last_authentication_result
@last_authentication_result.nil? ? nil : Carto::Ldap::OperationResult.new(
@last_authentication_result.code, @last_authentication_result.error_message,
@last_authentication_result.matched_dn, @last_authentication_result.message)
end
def last_operation_result
ldap_result = ldap_connection.get_operation_result
Carto::Ldap::OperationResult.new(ldap_result.code, ldap_result.error_message, ldap_result.matched_dn,
ldap_result.message)
end
private
CONNECTION_TIMEOUT = 8
def search_filter(username)
user_id_filter = "(#{user_id_field}=#{username})"
if additional_search_filter.present?
"(&#{user_id_filter}#{additional_search_filter})"
else
user_id_filter
end
end
def domain_bases_not_empty
errors.add(:domain_bases, "No domain bases set") unless domain_bases.present?
errors.add(:domain_bases_list, "Domain bases list empty") unless domain_bases_list.present?
end
def search_in_domain_bases(filter)
domain_bases_list.map { |domain|
search(domain, filter)
}.flatten.compact
end
# @param String base DC to search at
# @Param Net::LDAP::Filter filter (Optional)
def search(base, filter = nil)
if filter
ldap_connection.search(base: base, filter: filter)
else
ldap_connection.search(base: base)
end
end
# Performs connection always with the search connection user
def ldap_connection(reset = false)
@conn = nil if reset
@conn ||= connect
end
# Connect, by default with the search connection user
# @param String user full CN, like `CN=test_user, CN=developers, DC=cartodb, DC=COM`
# @param String password Connection password
# @throws InvalidConfigurationEncryptionError
def connect(user = self.connection_user, password = self.connection_password)
ldap = Net::LDAP.new(connect_timeout: CONNECTION_TIMEOUT)
ldap.host = self.host
ldap.port = self.port
configure_encryption(ldap)
# implicity this does basic/simple auth if no encryption added above
ldap.auth(user, password)
ldap
end
def configure_encryption(ldap)
tls_options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS
case self.encryption
when ENCRYPTION_NONE
return
when ENCRYPTION_START_TLS
tls_options.merge!(:ca_file => self.ca_file) if self.ca_file
when ENCRYPTION_SIMPLE_TLS
# No special value needed
else
raise InvalidConfigurationEncryptionError.new(self.encryption)
end
tls_options.merge!(:verify_mode => OpenSSL::SSL::VERIFY_NONE)
# Default value is "SSLv23" at the gem
tls_options.merge!(:ssl_version => self.ssl_version) if self.ssl_version
ldap.encryption(method: self.encryption.to_sym, tls_options: tls_options)
end
end
class InvalidConfigurationEncryptionError < StandardError
def initialize(incorrect_encryption_value)
super("Invalid encryption value supplied: #{incorrect_encryption_value}. Valid values: [nil, '', '']")
end
end
+38
View File
@@ -0,0 +1,38 @@
# See http://www.rubydoc.info/gems/net-ldap/0.11
require 'net/ldap'
class Carto::Ldap::Entry
def initialize(ldap_entry, ldap_configuration)
@entry = ldap_entry
@configuration = ldap_configuration
end
def user_id
extract_field(@configuration.user_id_field)
end
def cartodb_user_id
Carto::Ldap::Manager.sanitize_for_cartodb(user_id)
end
def username
extract_field(@configuration.username_field)
end
def email
extract_field(@configuration.email_field)
end
def configuration
@configuration
end
private
def extract_field(field)
value = @entry[field]
value.nil? ? nil : value.first
end
end
+64
View File
@@ -0,0 +1,64 @@
# See http://www.rubydoc.info/gems/net-ldap/0.11
require 'net/ldap'
module Carto
module Ldap
class Manager
# @throws LDAPUserNotPresentAtCartoDBError
def authenticate(username, password)
@last_authentication_result = nil
user = nil
ldap_entry = nil
Carto::Ldap::Configuration.all.each { |ldap|
ldap_entry ||= ldap.authenticate(username, password)
@last_authentication_result = ldap.last_authentication_result
}
if ldap_entry
user = ::User.where({
username: ldap_entry.cartodb_user_id,
organization_id: ldap_entry.configuration.organization_id
}).first
if user.nil?
raise LDAPUserNotPresentAtCartoDBError.new(ldap_entry.cartodb_user_id,
ldap_entry.configuration.organization_id, username, ldap_entry.email)
end
end
user
end
def configuration_present?
Carto::Ldap::Configuration.first != nil
end
def last_authentication_result
@last_authentication_result
end
def self.sanitize_for_cartodb(ldap_value)
ldap_value.to_s.downcase.gsub(/[^a-z0-9\-]/,'')
end
end
class LDAPUserNotPresentAtCartoDBError < StandardError
def initialize(cartodb_username, cartodb_organization_id, ldap_username, ldap_email='')
@ldap_username = ldap_username
@ldap_email = ldap_email
@cartodb_username = cartodb_username
@organization_id = cartodb_organization_id
super("'#{ldap_username}' not found at CARTO (username:'#{cartodb_username}', organization id:'#{cartodb_organization_id}')")
end
attr_reader :ldap_email, :ldap_username, :cartodb_username, :organization_id
end
end
end
+37
View File
@@ -0,0 +1,37 @@
# See http://www.rubydoc.info/gems/net-ldap/0.11
require 'net/ldap'
module Carto
module Ldap
class OperationResult
CODE_UNKNOWN = -1
def initialize(code = CODE_UNKNOWN, error_message = "", matched_dn = "", message = "")
@code = code ||
@error_message = error_message
@matched_dn = matched_dn
@message = message
end
attr_reader :code, :message, :error_message, :matched_dn
def to_hash
{
code: code,
message: message,
error_message: error_message,
matched_dn: matched_dn
}
end
def success?
@code == 0
end
def failure?
!success?
end
end
end
end
+99
View File
@@ -0,0 +1,99 @@
require_relative './carto_json_serializer'
require_relative '../../controllers/carto/api/legend_presenter'
require_dependency 'carto/legend_definition_validator'
module Carto
class Legend < ActiveRecord::Base
self.inheritance_column = :_type
belongs_to :layer, class_name: Carto::Layer
VALID_LEGEND_TYPES = %w(category bubble choropleth custom custom_choropleth torque).freeze
LEGEND_TYPES_PER_ATTRIBUTE = {
color: %w(category choropleth custom custom_choropleth torque),
size: %w(bubble)
}.freeze
serialize :definition, ::Carto::CartoJsonSymbolizerSerializer
serialize :conf, ::Carto::CartoJsonSymbolizerSerializer
validates :definition, carto_json_symbolizer: true
serialize :conf, ::Carto::CartoJsonSymbolizerSerializer
validates :type, :layer, presence: true
validates :type, inclusion: { in: VALID_LEGEND_TYPES }, allow_nil: true
validate :on_data_layer,
:under_max_legends_per_layer,
:under_max_legends_per_layer_and_type,
:validate_definition_schema,
:validate_conf_schema
before_validation :ensure_definition, :ensure_conf
after_save :force_notify_layer_change
after_destroy :force_notify_layer_change
private
def ensure_definition
self.definition ||= Hash.new
end
def ensure_conf
self.conf ||= Hash.new
end
def on_data_layer
if layer && !layer.data_layer?
errors.add(:layer, "'#{layer.kind}' layers can't have legends")
end
end
MAX_LEGENDS_PER_LAYER = 2
def under_max_legends_per_layer
if layer
other_legends = layer.legends.select { |legend| legend.id != id }
unless other_legends.count < MAX_LEGENDS_PER_LAYER
errors.add(:layer, 'Maximum number of legends per layer reached')
end
end
end
def under_max_legends_per_layer_and_type
return unless type
LEGEND_TYPES_PER_ATTRIBUTE.each do |attribute, legend_types|
next unless legend_types.include?(type)
other_legends_present = layer.legends.any? { |legend| legend.id != id && legend_types.include?(legend.type) }
errors.add(:layer, "Only one #{attribute} legend per layer allowed") if other_legends_present
end
end
def validate_definition_schema
validator = Carto::LegendDefinitionValidator.new(type, definition)
definition_errors = validator.errors
if definition_errors.any?
errors.add(:definition, definition_errors.join(', '))
end
end
def validate_conf_schema
schema = Carto::Definition.instance
.load_from_file('lib/formats/legends/conf.json')
parsed_conf = conf.try(:is_a?, Hash) ? conf.with_indifferent_access : conf
conf_errors = JSON::Validator.fully_validate(schema, parsed_conf)
if conf_errors.any?
errors.add(:conf, conf_errors.join(', '))
end
end
def force_notify_layer_change
layer.force_notify_change
end
end
end
+68
View File
@@ -0,0 +1,68 @@
module Carto
class License
MIT_LICENSE = :mit
GPLV2_LICENSE = :gplv2
GPLV3_LICENSE = :gplv3
APACHE_LICENSE = :apache
PDDL_LICENSE = :pddl
CC_0 = :cc_0
CC_BY_LICENSE = :cc_by
CC_BY_NC_LICENSE = :cc_by_nc
CC_BY_SA_LICENSE = :cc_by_sa
CC_BY_ND_LICENSE = :cc_by_nd
CC_BY_NC_SA_LICENSE = :cc_by_nc_sa
CC_BY_NC_ND_LICENSE = :cc_by_nc_nd
PUBLIC_DOMAIN_LICENSE = :public_domain
OPEN_GOVERNMENT_LICENSE = :open_government
LICENSE_VALUES = {
MIT_LICENSE => { id: MIT_LICENSE, name: 'MIT' },
APACHE_LICENSE => { id: APACHE_LICENSE, name: 'Apache license' },
GPLV3_LICENSE => { id: GPLV3_LICENSE, name: 'GPLv3 license' },
GPLV2_LICENSE => { id: GPLV2_LICENSE, name: 'GPLv2 license' },
PDDL_LICENSE => { id: PDDL_LICENSE, name: 'Public Domain Dedication and License' },
CC_0 => { id: CC_0, name: 'CC0: CC Public domain' },
CC_BY_LICENSE => { id: CC_BY_LICENSE, name: 'CC BY: Attribution alone' },
CC_BY_NC_LICENSE => { id: CC_BY_NC_LICENSE, name: 'CC BY-NC: Attribution + Noncommercial' },
CC_BY_SA_LICENSE => { id: CC_BY_SA_LICENSE, name: 'CC BY-SA: Attribution + ShareAlike' },
CC_BY_ND_LICENSE => { id: CC_BY_ND_LICENSE, name: 'CC BY-ND: Attribution + NoDerivatives' },
CC_BY_NC_SA_LICENSE => { id: CC_BY_NC_SA_LICENSE, name: 'CC BY-NC-SA: Attribution + Noncommercial + ShareAlike' },
CC_BY_NC_ND_LICENSE => { id: CC_BY_NC_ND_LICENSE, name: 'CC BY-NC-ND: Attribution + Noncommercial + NoDerivatives' },
PUBLIC_DOMAIN_LICENSE => { id: PUBLIC_DOMAIN_LICENSE, name: 'Public domain' },
OPEN_GOVERNMENT_LICENSE => { id: OPEN_GOVERNMENT_LICENSE, name: 'Open Government License' }
}
attr_reader :id, :name
private_class_method :new
def self.find(license_id)
if !license_id.nil? && !license_id.empty? && LICENSE_VALUES.has_key?(license_id)
data = LICENSE_VALUES[license_id]
license = new(data[:id], data[:name])
license
else
return nil
end
end
def self.all
values = []
LICENSE_VALUES.each do |k, v|
values << find(k)
end
values
end
private
attr_writer :id, :name
def initialize(id, name)
self.id = id
self.name = name
end
end
end
+15
View File
@@ -0,0 +1,15 @@
module Carto
class LicensePresenter
def initialize(license)
@license = license
end
def to_poro
{
id: @license.id,
name: @license.name
}
end
end
end
+5
View File
@@ -0,0 +1,5 @@
require 'active_record'
class Carto::Like < ActiveRecord::Base
# INFO: old code expects `actor` to be the uuid, not the entity
end
+72
View File
@@ -0,0 +1,72 @@
class Carto::Log < ActiveRecord::Base
has_one :data_import, class_name: Carto::DataImport, foreign_key: :logger
MAX_ENTRY_LENGTH = 256
ENTRY_POSTFIX = "\n"
ENTRY_FORMAT = "%s: %s#{ENTRY_POSTFIX}"
TYPE_USER_CREATION = 'user_creation'.freeze
# INFO: disable ActiveRecord inheritance column
self.inheritance_column = :_type
def self.new_user_creation
log = Carto::Log.new
log.entries = 'New user creation'
log.type = TYPE_USER_CREATION
log
end
def append(line, truncate = true, timestamp = Time.now.utc)
line.slice!(MAX_ENTRY_LENGTH..-1) if truncate
entry = ENTRY_FORMAT % [ timestamp, line.slice(0..MAX_ENTRY_LENGTH) ]
self.entries = "#{self.entries}#{entry}"
self.save
end
def append_exception(line, exception:, truncate: true, timestamp: Time.now.utc)
append("#{line}: #{exception_to_string(exception)}", truncate, timestamp)
end
def store
self.save
end
def logger
@logger ||= LogWriter.new(self)
end
private
def exception_to_string(error)
error.inspect + "\n" + error.backtrace.join("\n") + "\n"
end
# A logger implementation that logs to this Carto::Log
class LogWriter < ::Logger
SAVE_BLOCK = 10 # Save the changes to DB every X lines
def initialize(log)
@log = log
@stored_entries = 0
end
def add(_severity, _progname = nil, message = nil)
if message.present?
@log.entries += message.to_s + "\n"
@stored_entries += 1
if @stored_entries >= SAVE_BLOCK
@stored_entries = 0
@log.save
end
end
end
def close
@log.save
end
end
end
+230
View File
@@ -0,0 +1,230 @@
require 'active_record'
require_relative './carto_json_serializer'
require_dependency 'common/map_common'
require_dependency 'carto/bounding_box_utils'
class Carto::Map < ActiveRecord::Base
include Carto::MapBoundaries
has_many :layers_maps, dependent: :destroy
has_many :layers, -> { order(:order) }, class_name: 'Carto::Layer',
through: :layers_maps,
after_add: Proc.new { |map, layer| layer.after_added_to_map(map) }
has_many :base_layers, -> { order(:order) }, class_name: 'Carto::Layer',
through: :layers_maps,
source: :layer
# autosave must be explicitly disabled due to https://github.com/rails/rails/issues/9336
has_one :user_table, class_name: Carto::UserTable, inverse_of: :map, dependent: :destroy, autosave: false
belongs_to :user
# Autosave disabled because this caused the `inverse_of` to break (visualization.map != self) until `reload`
# Fixed in Rails 5 https://github.com/rails/rails/pull/23197
has_one :visualization, class_name: Carto::Visualization, inverse_of: :map, autosave: false
# bounding_box_sw, bounding_box_new and center should probably be JSON serialized fields
# However, many callers expect to get an string (and do the JSON deserialization themselves), mainly in presenters
# So for now, we are just treating them as strings (see the .to_s in the constant below), but this could be improved
DEFAULT_OPTIONS = {
zoom: 3,
bounding_box_sw: [Carto::BoundingBoxUtils::DEFAULT_BOUNDS[:miny],
Carto::BoundingBoxUtils::DEFAULT_BOUNDS[:minx]].to_s,
bounding_box_ne: [Carto::BoundingBoxUtils::DEFAULT_BOUNDS[:maxy],
Carto::BoundingBoxUtils::DEFAULT_BOUNDS[:maxx]].to_s,
provider: 'leaflet',
center: [30, 0].to_s
}.freeze
serialize :options, ::Carto::CartoJsonSerializer
validates :options, carto_json_symbolizer: true
validate :validate_options
after_initialize :ensure_options
after_save :notify_map_change
after_save :save_table # Manual save, since autosave is disabled
def data_layers
layers.select(&:data_layer?)
end
def carto_layers
layers.select(&:carto?)
end
def user_layers
layers.select(&:user_layer?)
end
def torque_layers
layers.select(&:torque?)
end
def other_layers
layers.reject(&:carto?)
.reject(&:tiled?)
.reject(&:background?)
.reject(&:gmapsbase?)
.reject(&:wms?)
end
def named_map_layers
layers.select(&:named_map_layer?)
end
def viz_updated_at
latest_mapcap = visualization.latest_mapcap
latest_mapcap ? latest_mapcap.created_at : get_the_last_time_tiles_have_changed_to_render_it_in_vizjsons
end
def self.provider_for_baselayer_kind(kind)
kind == 'tiled' ? 'leaflet' : 'googlemaps'
end
# (lat,lon) points on all map data
def center_data
(center.nil? || center == '') ? DEFAULT_OPTIONS[:center] : center.gsub(/\[|\]|\s*/, '').split(',')
end
def view_bounds_data
if view_bounds_sw.nil? || view_bounds_sw == ''
bbox_sw = DEFAULT_OPTIONS[:bounding_box_sw]
else
bbox_sw = view_bounds_sw.gsub(/\[|\]|\s*/, '').split(',').map(&:to_f)
end
if view_bounds_ne.nil? || view_bounds_ne == ''
bbox_ne = DEFAULT_OPTIONS[:bounding_box_ne]
else
bbox_ne = view_bounds_ne.gsub(/\[|\]|\s*/, '').split(',').map(&:to_f)
end
{
# LowerCorner longitude, in decimal degrees
west: bbox_sw[1],
# LowerCorner latitude, in decimal degrees
south: bbox_sw[0],
# UpperCorner longitude, in decimal degrees
east: bbox_ne[1],
# UpperCorner latitude, in decimal degrees
north: bbox_ne[0]
}
end
def writable_by_user?(user)
visualization.writable_by?(user)
end
def contains_layer?(layer)
return false unless layer
layers_maps.map(&:layer_id).include?(layer.id)
end
def notify_map_change
visualization.try(:invalidate_after_commit)
end
alias :force_notify_map_change :notify_map_change
def update_dataset_dependencies
data_layers.each(&:register_table_dependencies)
end
def dashboard_menu=(value)
options[:dashboard_menu] = value
end
def dashboard_menu
options[:dashboard_menu]
end
def layer_selector=(value)
options[:layer_selector] = value
end
def layer_selector
options[:layer_selector]
end
def admits_layer?(layer)
return admits_more_torque_layers? if layer.torque?
return admits_more_data_layers? if layer.data_layer?
return admits_more_base_layers?(layer) if layer.base_layer?
end
def can_add_layer?(user, layer)
return true if layer.base_layer?
return false if user.max_layers && user.max_layers <= data_layers.count
visualization.writable_by?(user)
end
def process_privacy_in(layer)
if layer.uses_private_tables? && visualization.can_be_private?
visualization.privacy = Carto::Visualization::PRIVACY_PRIVATE
visualization.save
end
end
private
def save_table
if user_table && !user_table.persisted?
user_table.map = self
user_table.save!
end
end
def admits_more_data_layers?
!visualization.canonical? || data_layers.empty?
end
def admits_more_torque_layers?
!visualization.canonical? || torque_layers.empty?
end
def admits_more_base_layers?(layer)
# no basemap layer, always allow
return true if user_layers.empty?
# have basemap? then allow only if comes on top (for labels)
layer.order >= layers.last.order
end
def ensure_options
self.zoom ||= DEFAULT_OPTIONS[:zoom]
self.bounding_box_sw ||= DEFAULT_OPTIONS[:bounding_box_sw]
self.bounding_box_ne ||= DEFAULT_OPTIONS[:bounding_box_ne]
self.center ||= DEFAULT_OPTIONS[:center]
self.provider ||= DEFAULT_OPTIONS[:provider]
self.options ||= {}
options[:dashboard_menu] = true if options[:dashboard_menu].nil?
options[:layer_selector] = false if options[:layer_selector].nil?
options[:legends] = legends if options[:legends].nil?
options[:scrollwheel] = scrollwheel if options[:scrollwheel].nil?
options
end
def validate_options
location = "#{Rails.root}/lib/formats/map/options.json"
schema = Carto::Definition.instance.load_from_file(location)
options_wia = options.with_indifferent_access
json_errors = JSON::Validator.fully_validate(schema, options_wia)
errors.add(:options, json_errors.join(', ')) if json_errors.any?
end
def get_the_last_time_tiles_have_changed_to_render_it_in_vizjsons
from_table = user_table.service.data_last_modified if user_table
[from_table, data_layers.map(&:updated_at)].flatten.compact.max
end
def table_name
user_table.try(:name)
end
end
+75
View File
@@ -0,0 +1,75 @@
require_dependency 'carto/visualizations_export_service_2'
require_relative './carto_json_serializer'
require_dependency 'carto/named_maps/api'
module Carto
class Mapcap < ActiveRecord::Base
include Carto::VisualizationsExportService2Importer
include Carto::VisualizationsExportService2Exporter
belongs_to :visualization, class_name: Carto::Visualization, foreign_key: 'visualization_id'
serialize :ids_json, ::Carto::CartoJsonSymbolizerSerializer
serialize :export_json, ::Carto::CartoJsonSymbolizerSerializer
after_save :notify_map_change, :update_named_map
after_destroy :notify_map_change
before_validation :lazy_export_json, :lazy_ids_json
validates :ids_json, carto_json_symbolizer: true
validates :export_json, carto_json_symbolizer: true
def regenerate_visualization
regenerated_visualization = build_visualization_from_hash_export(lazy_export_json)
regenerated_visualization.user = regenerated_visualization.map.user = visualization.user
regenerated_visualization.permission = visualization.permission
regenerated_visualization.populate_ids(lazy_ids_json)
set_tree_as_readonly!(regenerated_visualization)
regenerated_visualization
end
def self.latest_for_visualization(visualization_id)
where(visualization_id: visualization_id).order('created_at DESC')
end
private
def lazy_export_json
self.export_json ||= export_visualization_json_hash(visualization_id, visualization.user, with_mapcaps: false)
end
def lazy_ids_json
self.ids_json ||= visualization.ids_json
end
def notify_map_change
visualization.map.force_notify_map_change
end
def update_named_map
Carto::NamedMaps::Api.new(regenerate_visualization).upsert
end
def set_tree_as_readonly!(entity)
set_entity_as_readonly(entity)
Carto::Visualization.reflections.each_key do |dep_name|
dependency = entity.public_send(dep_name)
if dependency.is_a? ActiveRecord::Associations::CollectionProxy
dependency.each { |e| set_entity_as_readonly(e) }
else
set_entity_as_readonly(dependency)
end
end
end
def set_entity_as_readonly(entity)
return unless entity.public_methods.include?(:readonly!)
return if entity.readonly?
entity.readonly!
end
end
end
+30
View File
@@ -0,0 +1,30 @@
class Carto::MobileApp
include ActiveModel::Validations
include ActiveModel::Conversion
extend ActiveModel::Naming
APP_PLATFORMS = %w(android ios xamarin-android xamarin-ios windows-phone).freeze
APP_TYPES = %w(open private).freeze
validates :name, presence: true
validates :icon_url, presence: true
validates :platform, inclusion: { in: APP_PLATFORMS }
validates :app_id, presence: true
validates :app_type, inclusion: { in: APP_TYPES }
attr_accessor :id, :name, :description, :icon_url, :platform, :app_id, :app_type, :license_key, :monthly_users
def initialize(attributes = {})
attributes.each do |name, value|
send("#{name}=", value)
end
end
def persisted?
id.present?
end
def data(current_user, fetch_mobile_platforms: false, fetch_app_types: false)
Carto::Api::MobileAppPresenter.new(self, current_user, fetch_mobile_platforms, fetch_app_types).data
end
end
+50
View File
@@ -0,0 +1,50 @@
require_dependency 'carto/notifications_markdown_renderer'
module Carto
class Notification < ActiveRecord::Base
MAX_BODY_LENGTH = 140
# Update CartoGearsAPI::Notifications::Notification when adding constants here
ICON_ALERT = 'alert'.freeze
ICON_SUCCESS = 'success'.freeze
ICONS = [ICON_SUCCESS, ICON_ALERT].freeze
RECIPIENT_ALL = 'all'.freeze
RECIPIENTS = [RECIPIENT_ALL, 'builders'.freeze, 'viewers'.freeze].freeze
belongs_to :organization, inverse_of: :notifications
has_many :received_notifications, inverse_of: :notification
validates :icon, presence: true, inclusion: { in: ICONS }
validates :recipients, inclusion: { in: [nil] + RECIPIENTS }
validates :recipients, presence: true, if: :organization
validates :body, presence: true
validate :valid_markdown
after_create :send_to_organization_members
private
def send_to_organization_members
return unless organization
# This avoids resending an already sent notification. Example: importing notifications
return unless received_notifications.empty?
users = if recipients == 'builders'
organization.builder_users
elsif recipients == 'viewers'
organization.viewer_users
else
organization.users
end
users.each { |u| received_notifications.create!(user: u, received_at: created_at) }
end
def valid_markdown
return unless body.present?
text = Redcarpet::Markdown.new(NotificationsMarkdownRenderer).render(body).strip
errors.add(:body, "cannot be longer than #{MAX_BODY_LENGTH} characters") if text.length > MAX_BODY_LENGTH
rescue RuntimeError => e
errors.add(:body, e.message)
end
end
end
+57
View File
@@ -0,0 +1,57 @@
require_dependency 'carto/oauth_provider/errors'
require_dependency 'carto/oauth_provider/scopes/scopes'
module Carto
class OauthAccessToken < ActiveRecord::Base
include OauthProvider::Scopes
ACCESS_TOKEN_EXPIRATION_TIME = 1.hour
belongs_to :oauth_app_user, inverse_of: :oauth_access_tokens
belongs_to :api_key, inverse_of: :oauth_access_token, dependent: :destroy
validates :oauth_app_user, presence: true
validates :scopes, scopes: true
before_create :create_api_key, unless: :skip_api_key_creation
after_create :rename_api_key, unless: :skip_api_key_creation
scope :expired, -> { where('created_at < ?', Time.now - ACCESS_TOKEN_EXPIRATION_TIME) }
attr_accessor :skip_api_key_creation
def expires_in
created_at + ACCESS_TOKEN_EXPIRATION_TIME - Time.now
end
def user
oauth_app_user.user
end
def ownership_role_name
oauth_app_user.ownership_role_name
end
private
def create_api_key
grants = [{ type: 'apis', apis: [] }]
scopes.each do |s|
scope = OauthProvider::Scopes.build(s)
scope.add_to_api_key_grants(grants, user)
end
self.api_key = oauth_app_user.user.api_keys.create_oauth_key!(
name: "oauth_authorization #{SecureRandom.uuid}",
grants: grants,
ownership_role_name: ownership_role_name
)
end
def rename_api_key
# Rename after creation so we have the ID
api_key.update!(name: "oauth_authorization #{id}")
end
end
end
+116
View File
@@ -0,0 +1,116 @@
require_dependency 'carto/helpers/url_validator'
module Carto
class OauthApp < ActiveRecord::Base
# Multiple of 3 for pretty base64
CLIENT_ID_RANDOM_BYTES = 9
CLIENT_SECRET_RANDOM_BYTES = 18
belongs_to :user, inverse_of: :oauth_apps
has_many :oauth_app_users, inverse_of: :oauth_app, dependent: :destroy
has_many :oauth_app_organizations, inverse_of: :oauth_app, dependent: :destroy
validates :user, presence: true, if: -> { sync_with_central? || !central_enabled? }
validates :name, presence: true
validates :website_url, presence: true, url: true
validates :client_id, presence: true
validates :client_secret, presence: true
validates :redirect_uris, presence: true
validates :icon_url, url: true, if: -> { icon_url.present? }
validates :oauth_app_organizations, absence: true, unless: :restricted?
validate :validate_uris
before_validation :ensure_keys_generated
after_create :create_central, if: :sync_with_central?
after_update :update_central, if: :sync_with_central?
after_destroy :delete_central, if: :sync_with_central?
before_destroy :collect_users, unless: :avoid_send_notification, prepend: true
after_destroy :send_app_removal_notification, unless: :avoid_send_notification
ALLOWED_SYNC_ATTRIBUTES = %i[id name client_id client_secret redirect_uris
icon_url restricted description website_url].freeze
attr_accessor :avoid_sync_central, :avoid_send_notification
# this should be exactly the same method as in Central
# mostly used for testing the Superadmin API
def api_attributes
attributes.symbolize_keys.slice(*ALLOWED_SYNC_ATTRIBUTES).merge(user_id: user.id)
end
def regenerate_client_secret!
self.client_secret = SecureRandom.urlsafe_base64(CLIENT_SECRET_RANDOM_BYTES)
save!
end
private
def collect_users
@user_ids = oauth_app_users.collect { |u| u.user.id }
end
def send_app_removal_notification
return if @user_ids.empty?
notification = Carto::Notification.create!(body: notification_body, icon: Notification::ICON_ALERT)
::Resque.enqueue(::Resque::UserJobs::Notifications::Send, @user_ids, notification.id)
rescue StandardError => e
CartoDB::Logger.warning(
message: "Couldn't notify users about oauth_app '#{name}' deletion",
notification_id: notification&.id,
exception: e)
raise e
end
def notification_body
"The app #{name} has signed off. You may find more information in their [website](#{website_url})."
end
def ensure_keys_generated
self.client_id ||= SecureRandom.urlsafe_base64(CLIENT_ID_RANDOM_BYTES)
self.client_secret ||= SecureRandom.urlsafe_base64(CLIENT_SECRET_RANDOM_BYTES)
end
def validate_uris
redirect_uris && redirect_uris.each { |uri| validate_uri(uri) }
end
def validate_uri(redirect_uri)
uri = URI.parse(redirect_uri)
errors.add(:redirect_uris, "must be absolute") unless uri.absolute?
errors.add(:redirect_uris, "must be https") unless uri.scheme == 'https'
errors.add(:redirect_uris, "must not contain a fragment") unless uri.fragment.nil?
rescue URI::InvalidURIError
errors.add(:redirect_uris, "must be valid")
end
def create_central
cartodb_central_client.create_oauth_app(user.username, sync_attributes)
end
def update_central
cartodb_central_client.update_oauth_app(user.username, id, sync_attributes)
end
def delete_central
cartodb_central_client.delete_oauth_app(user.username, id)
end
def cartodb_central_client
@cartodb_central_client ||= Cartodb::Central.new
end
def sync_with_central?
central_enabled? && !avoid_sync_central
end
def central_enabled?
Cartodb::Central.sync_data_with_cartodb_central?
end
def sync_attributes(attrs = attributes)
attrs.symbolize_keys.slice(*ALLOWED_SYNC_ATTRIBUTES)
end
end
end
@@ -0,0 +1,14 @@
module Carto
class OauthAppOrganization < ActiveRecord::Base
belongs_to :organization, inverse_of: :oauth_app_organizations
belongs_to :oauth_app, inverse_of: :oauth_app_organizations
validates :organization, presence: true, uniqueness: { scope: :oauth_app }
validates :oauth_app, presence: true
validates :seats, presence: true, numericality: { only_integer: true, greater_than: 0 }
def open_seats?
oauth_app.oauth_app_users.joins(:user).where(users: { organization_id: organization.id }).count < seats
end
end
end
+253
View File
@@ -0,0 +1,253 @@
require_dependency 'carto/oauth_provider/scopes/scopes'
require_dependency 'carto/oauth_provider/errors'
module Carto
class OauthAppUser < ActiveRecord::Base
include OauthProvider::Scopes
belongs_to :user, inverse_of: :oauth_app_users
belongs_to :oauth_app, inverse_of: :oauth_app_users
belongs_to :api_key, inverse_of: :oauth_app_user
has_many :oauth_authorization_codes, inverse_of: :oauth_app_user, dependent: :destroy
has_many :oauth_access_tokens, inverse_of: :oauth_app_user, dependent: :destroy
has_many :oauth_refresh_tokens, inverse_of: :oauth_app_user, dependent: :destroy
validates :user, presence: true, uniqueness: { scope: :oauth_app }
validates :oauth_app, presence: true
validates :scopes, scopes: true
validate :validate_user_authorizable, on: :create
after_create :create_roles, :enable_schema_triggers, :ensure_role_grants, unless: :skip_role_setup
before_update :grant_dataset_role_privileges
after_destroy :reassign_owners, :drop_roles, :disable_schema_triggers, unless: :skip_role_setup
attr_accessor :skip_role_setup
def authorized?(requested_scopes)
OauthProvider::Scopes.subtract_scopes(requested_scopes, all_scopes, user.database_schema).empty?
end
def upgrade!(requested_scopes)
update!(scopes: all_scopes | requested_scopes)
end
def all_scopes
no_dataset_scopes + dataset_scopes
end
def revoke_permissions(table, revoked_permissions)
db_connection = user.in_database(as: :superuser)
Carto::TableAndFriends.apply(db_connection, table.database_schema, table.name) do |_s, _t, qualified_name|
query = %{
REVOKE #{revoked_permissions.join(', ')}
ON TABLE #{qualified_name}
FROM \"#{dataset_role_name}\"
}
db_connection.execute(query)
sequences_for_table(qualified_name).each do |seq|
db_connection.execute("REVOKE ALL ON SEQUENCE #{seq} FROM \"#{dataset_role_name}\"")
end
end
end
def create_roles
create_dataset_role
create_ownership_role
end
def create_dataset_role
db_run(create_dataset_role_query)
end
def create_ownership_role
db_run(create_ownership_role_query)
end
def create_dataset_role_query
"CREATE ROLE \"#{dataset_role_name}\" CREATEROLE"
end
def create_ownership_role_query
"CREATE ROLE \"#{ownership_role_name}\""
end
def ensure_role_grants
grant_dataset_role_privileges
grant_ownership_role_privileges
end
def grant_ownership_role_privileges
db_run("GRANT \"#{ownership_role_name}\" TO \"#{user.database_username}\"")
end
def grant_dataset_role_privileges
validate_scopes
invalid_scopes = []
DatasetsScope.valid_scopes(scopes).each do |scope|
dataset_scope = DatasetsScope.new(scope)
schema = dataset_scope.schema || user.database_schema
schema_table = Carto::TableAndFriends.qualified_table_name(schema, dataset_scope.table)
table_query = %{
GRANT #{dataset_scope.permission.join(',')}
ON TABLE #{schema_table}
TO \"#{dataset_role_name}\" WITH GRANT OPTION;
}
schema_query = %{
GRANT USAGE ON SCHEMA \"#{schema}\"
TO \"#{dataset_role_name}\";
}
begin
user.in_database(as: :superuser).execute(table_query)
user.in_database(as: :superuser).execute(schema_query)
sequences_for_table(schema_table).each do |seq|
seq_query = "GRANT USAGE, SELECT ON SEQUENCE #{seq} TO \"#{dataset_role_name}\""
user.in_database(as: :superuser).execute(seq_query)
end
rescue ActiveRecord::StatementInvalid => e
invalid_scopes << scope
CartoDB::Logger.error(message: 'Error granting permissions to dataset role', exception: e)
end
end
raise OauthProvider::Errors::InvalidScope.new(invalid_scopes) if invalid_scopes.any?
end
def ownership_role_name
"carto_oauth_app_o_#{id}"
end
def exists_ownership_role?
user.db_service.exists_role?(ownership_role_name)
end
private
def validate_user_authorizable
return unless oauth_app.try(:restricted?)
organization = user.organization
return errors.add(:user, 'is not part of an organization') unless user.organization
org_authorization = oauth_app.oauth_app_organizations.where(organization: organization).first
unless org_authorization
return errors.add(:user, 'is part of an organization which is not allowed access to this application')
end
errors.add(:user, 'does not have an available seat to use this application') unless org_authorization.open_seats?
end
def no_dataset_scopes
DatasetsScope.non_dataset_scopes(scopes)
end
def dataset_scopes
begin
results = user.db_service.all_tables_granted(dataset_role_name)
rescue ActiveRecord::StatementInvalid => e
CartoDB::Logger.error(message: 'Error getting scopes', exception: e)
raise OauthProvider::Errors::ServerError.new
end
scopes = []
results.each do |row|
permission = DatasetsScope.permission_from_db_to_scope(row['permission'])
unless permission.nil?
schema = row['schema'] != user.database_schema ? "#{row['schema']}." : ''
scopes << "datasets:#{permission}:#{schema}#{row['t']}"
end
end
scopes
end
def reassign_owners
roles = [dataset_role_name, ownership_role_name]
queries = roles.map do |role|
"REASSIGN OWNED BY \"#{role}\" TO \"#{user.database_username}\";"
end
db_run(queries.join, error_title: 'Error reassigning owners')
end
def drop_roles
roles = [dataset_role_name, ownership_role_name]
queries = roles.map do |role|
%{
DROP OWNED BY "#{role}";
DROP ROLE IF EXISTS "#{role}";
}
end
db_run(queries.join, error_title: 'Error dropping roles')
end
def enable_schema_triggers
return if user.organization_user? && oauth_users_in_organization > 1
user.db_service.create_oauth_reassign_ownership_event_trigger
rescue StandardError => e
CartoDB::Logger.error(
message: "Error enabling schema trigger",
exception: e,
user: self
)
end
def disable_schema_triggers
return if user.organization_user? && oauth_users_in_organization >= 1
user.db_service.drop_oauth_reassign_ownership_event_trigger
rescue StandardError => e
CartoDB::Logger.error(
message: "Error disabling schema trigger",
exception: e,
user: self
)
end
def validate_scopes
invalid_scopes = OauthProvider::Scopes.invalid_scopes_and_tables(scopes, user)
raise OauthProvider::Errors::InvalidScope.new(invalid_scopes) if invalid_scopes.present?
end
def sequences_for_table(schema_table)
query = %{
SELECT
n.nspname, quote_ident(c.relname) as relname
FROM
pg_depend d
JOIN pg_class c ON d.objid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE
d.refobjsubid > 0 AND
d.classid = 'pg_class'::regclass AND
c.relkind = 'S'::"char" AND
d.refobjid = ('#{schema_table}')::regclass
}
user.in_database.execute(query).map { |r| "\"#{r['nspname']}\".#{r['relname']}" }
end
def dataset_role_name
"carto_oauth_app_#{id}"
end
def db_run(query, connection = db_connection, error_title: 'Error running SQL command')
connection.execute(query)
rescue ActiveRecord::StatementInvalid => e
CartoDB::Logger.error(message: error_title, exception: e)
return if e.message =~ /OWNED BY/ # role might not exist becuase it has been already dropped
raise OauthProvider::Errors::ServerError.new("#{error_title}: #{e.message}")
end
def db_connection
@db_connection ||= user.in_database(as: :superuser)
end
def oauth_users_in_organization
return 0 unless user.organization_user?
Carto::OauthAppUser.joins(:user).where('organization_id = ?', user.organization_id).count
end
end
end
@@ -0,0 +1,49 @@
require_dependency 'carto/oauth_provider/errors'
require_dependency 'carto/oauth_provider/scopes/scopes'
module Carto
class OauthAuthorizationCode < ActiveRecord::Base
include OauthProvider::Scopes
# Multiple of 3 for pretty base64
CODE_RANDOM_BYTES = 12
CODE_EXPIRATION_TIME = 1.minute
belongs_to :oauth_app_user, inverse_of: :oauth_authorization_codes
validates :oauth_app_user, presence: true
validates :code, presence: true
validates :scopes, scopes: true
before_validation :ensure_code_generated
scope :expired, -> { where('created_at < ?', Time.now - CODE_EXPIRATION_TIME) }
def exchange!
raise OauthProvider::Errors::InvalidGrant.new if expired?
ActiveRecord::Base.transaction do
destroy!
access_token = oauth_app_user.oauth_access_tokens.create!(scopes: scopes)
if scopes.include?(SCOPE_OFFLINE)
refresh_token = oauth_app_user.oauth_refresh_tokens.create!(scopes: scopes)
end
[access_token, refresh_token]
end
end
def oauth_app
oauth_app_user.oauth_app
end
private
def ensure_code_generated
self.code ||= SecureRandom.urlsafe_base64(CODE_RANDOM_BYTES)
end
def expired?
created_at < Time.now - CODE_EXPIRATION_TIME
end
end
end
+68
View File
@@ -0,0 +1,68 @@
require_dependency 'carto/oauth_provider/errors'
require_dependency 'carto/oauth_provider/scopes/scopes'
module Carto
class OauthRefreshToken < ActiveRecord::Base
include OauthProvider::Scopes
# Multiple of 3 for pretty base64
TOKEN_RANDOM_BYTES = 15
REFRESH_TOKEN_EXPIRATION_TIME = 6.months
MAX_TOKENS_PER_OAUTH_APP_USER = 50
belongs_to :oauth_app_user, inverse_of: :oauth_refresh_tokens
validates :oauth_app_user, presence: true
validates :scopes, scopes: true
validate :check_offline_scope
before_create :remove_oldest_ones, unless: :skip_token_regeneration
before_create :regenerate_token, unless: :skip_token_regeneration
scope :expired, -> { where('updated_at < ?', Time.now - REFRESH_TOKEN_EXPIRATION_TIME) }
attr_accessor :skip_token_regeneration
def exchange!(requested_scopes: scopes)
raise OauthProvider::Errors::InvalidGrant.new if expired?
invalid_scopes = Carto::OauthProvider::Scopes.subtract_scopes(requested_scopes, scopes, oauth_app_user.user.database_schema)
raise OauthProvider::Errors::InvalidScope.new(invalid_scopes) if invalid_scopes.any?
ActiveRecord::Base.transaction do
regenerate_token
save!
[oauth_app_user.oauth_access_tokens.create!(scopes: requested_scopes), self]
end
end
def oauth_app
oauth_app_user.oauth_app
end
private
def remove_oldest_ones
oldest_tokens = OauthRefreshToken.where(oauth_app_user: oauth_app_user)
.order(updated_at: :desc)
.offset(MAX_TOKENS_PER_OAUTH_APP_USER - 1)
unless oldest_tokens.count.zero?
CartoDB::Logger.debug(message: "Removing #{oldest_tokens.count} refresh tokens",
oauth_app_user: oauth_app_user)
oldest_tokens.find_each(&:destroy!)
end
end
def expired?
updated_at < Time.now - REFRESH_TOKEN_EXPIRATION_TIME
end
def check_offline_scope
errors.add(:scopes, "must contain `#{SCOPE_OFFLINE}`") unless scopes.include?(SCOPE_OFFLINE)
end
def regenerate_token
self.token = SecureRandom.urlsafe_base64(TOKEN_RANDOM_BYTES)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
require 'active_record'
module Carto
class OauthToken < ActiveRecord::Base
belongs_to :user, class_name: Carto::User
belongs_to :client_application, class_name: Carto::ClientApplication
end
end
+176
View File
@@ -0,0 +1,176 @@
require 'active_record'
require_relative '../../helpers/data_services_metrics_helper'
require_dependency 'carto/helpers/auth_token_generator'
require_dependency 'carto/carto_json_serializer'
require_dependency 'common/organization_common'
module Carto
class Organization < ActiveRecord::Base
include DataServicesMetricsHelper
include AuthTokenGenerator
include Carto::OrganizationSoftLimits
serialize :auth_saml_configuration, CartoJsonSymbolizerSerializer
before_validation :ensure_auth_saml_configuration
validates :auth_saml_configuration, carto_json_symbolizer: true
has_many :users, -> { order(:username) }, inverse_of: :organization
belongs_to :owner, class_name: Carto::User, inverse_of: :owned_organization
has_many :groups, -> { order(:display_name) }, inverse_of: :organization
has_many :assets, class_name: Carto::Asset, dependent: :destroy, inverse_of: :organization
has_many :notifications, -> { order('created_at DESC') }, dependent: :destroy
has_many :connector_configurations, inverse_of: :organization, dependent: :destroy
has_many :oauth_app_organizations, inverse_of: :oauth_app, dependent: :destroy
before_destroy :destroy_groups_with_extension
def self.find_by_database_name(database_name)
Carto::Organization.
joins('INNER JOIN users ON organizations.owner_id = users.id').
where('users.database_name = ?', database_name).first
end
def get_geocoding_calls(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
get_organization_geocoding_data(self, date_from, date_to)
end
def period_end_date
owner && owner.period_end_date
end
def get_here_isolines_calls(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
get_organization_here_isolines_data(self, date_from, date_to)
end
def get_mapzen_routing_calls(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
get_organization_mapzen_routing_data(self, date_from, date_to)
end
def get_obs_snapshot_calls(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
get_organization_obs_snapshot_data(self, date_from, date_to)
end
def get_obs_general_calls(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
get_organization_obs_general_data(self, date_from, date_to)
end
def twitter_imports_count(options = {})
require_organization_owner_presence!
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : owner.last_billing_cycle)
Carto::SearchTweet.twitter_imports_count(users.joins(:search_tweets), date_from, date_to)
end
def owner?(user)
owner_id == user.id
end
def remaining_geocoding_quota(options = {})
remaining = geocoding_quota.to_i - get_geocoding_calls(options)
(remaining > 0 ? remaining : 0)
end
def remaining_here_isolines_quota(options = {})
remaining = here_isolines_quota.to_i - get_here_isolines_calls(options)
(remaining > 0 ? remaining : 0)
end
def remaining_mapzen_routing_quota(options = {})
remaining = mapzen_routing_quota.to_i - get_mapzen_routing_calls(options)
(remaining > 0 ? remaining : 0)
end
def remaining_obs_snapshot_quota(options = {})
remaining = obs_snapshot_quota.to_i - get_obs_snapshot_calls(options)
(remaining > 0 ? remaining : 0)
end
def remaining_obs_general_quota(options = {})
remaining = obs_general_quota.to_i - get_obs_general_calls(options)
(remaining > 0 ? remaining : 0)
end
def signup_page_enabled
whitelisted_email_domains.present? && auth_enabled?
end
def auth_enabled?
auth_username_password_enabled || auth_google_enabled || auth_github_enabled || auth_saml_enabled?
end
def database_name
owner ? owner.database_name : nil
end
def create_group(display_name)
Carto::Group.create_group_with_extension(self, display_name)
end
def name_to_display
display_name.nil? ? name : display_name
end
def assigned_quota
self.users.sum(:quota_in_bytes).to_i
end
def unassigned_quota
self.quota_in_bytes - assigned_quota
end
def require_organization_owner_presence!
if owner.nil?
raise ::Organization::OrganizationWithoutOwner.new(self)
end
end
def auth_saml_enabled?
auth_saml_configuration.present?
end
def builder_users
users.reject(&:viewer)
end
def viewer_users
users.select(&:viewer)
end
def admin?(user)
user.belongs_to_organization?(self) && user.organization_admin?
end
def non_owner_users
users.reject { |u| owner && u.id == owner.id }
end
private
def ensure_auth_saml_configuration
self.auth_saml_configuration ||= Hash.new
end
def destroy_groups_with_extension
return unless groups
groups.map { |g| g.destroy_group_with_extension }
reload
end
end
end
+75
View File
@@ -0,0 +1,75 @@
require 'active_record'
module Carto
class Overlay < ActiveRecord::Base
# INFO: disable ActiveRecord inheritance column
self.inheritance_column = :_type
belongs_to :visualization
serialize :options, JSON
validates :type, presence: true
validate :unique_overlay_not_duplicated
validate :validate_user_not_viewer
before_destroy :validate_user_not_viewer
after_save :invalidate_cache
after_destroy :invalidate_cache
# There can be at most one of this types per visualization
UNIQUE_TYPES = [
'header', 'search', 'layer_selector', 'share', 'zoom', 'logo', 'loader', 'fullscreen'
].freeze
BUILDER_COMPATIBLE_TYPES = ['search', 'layer_selector', 'share', 'fullscreen', 'loader', 'logo', 'zoom'].freeze
scope :builder_incompatible, -> { where("type NOT IN ('#{BUILDER_COMPATIBLE_TYPES.join("','")}')") }
def hide
options['display'] = false
self
end
def show
options['display'] = true
self
end
def hidden?
!options['display']
end
private
def unique_overlay_not_duplicated
# This check probably belongs in visualization. See #6919
if UNIQUE_TYPES.include?(type)
other_overlay = Carto::Overlay.where(visualization_id: visualization_id, type: type)
other_overlay = other_overlay.where('id != ?', id) unless new_record?
unless other_overlay.first.nil?
errors.add(:base, "Unique overlay of type #{type} already exists")
end
end
end
def validate_user_not_viewer
# TODO: `visualization` check is needed because the creation of default overlays for visualization
# is yet done from the old models (Member). Member assigns the id (because it can't assign itself, as
# it's not a `Carto::Visualization`), but since that happens in a transaction managed by Sequel
# we can't get the `Carto::Visualization` here. When `Member` is gone the `visualization` check can
# be removed, as an `Overlay` must have a `Visualization`.
if visualization && visualization.user.viewer
errors.add(:visualization, "Viewer users can't edit overlays")
return false
end
end
def invalidate_cache
# Using `send` to avoid making it public. A future refactor should make this call unnecessary
# Visualization might not exist during creation, as the overlays are created before the visualization
visualization.try(:invalidate_after_commit)
end
end
end
+15
View File
@@ -0,0 +1,15 @@
module Carto
module PagedModel
def paged(page = 1, per_page = 20, order = nil)
self.paged_association(self, page, per_page, order)
end
def self.paged_association(association, page = 1, per_page = 20, order = nil)
paged = association.offset((page - 1) * per_page).limit(per_page)
paged = paged.order(order) unless order.nil?
paged
end
end
end
+530
View File
@@ -0,0 +1,530 @@
require 'active_record'
require_dependency 'cartodb/errors'
class Carto::Permission < ActiveRecord::Base
DEFAULT_ACL_VALUE = [].freeze
ACCESS_READONLY = 'r'.freeze
ACCESS_READWRITE = 'rw'.freeze
ACCESS_NONE = 'n'.freeze
SORTED_ACCESSES = [ACCESS_READWRITE, ACCESS_READONLY, ACCESS_NONE].freeze
TYPE_USER = 'user'.freeze
TYPE_ORGANIZATION = 'org'.freeze
TYPE_GROUP = 'group'.freeze
belongs_to :owner, -> { select(Carto::User::DEFAULT_SELECT) }, class_name: Carto::User
has_one :visualization, inverse_of: :permission, class_name: Carto::Visualization, foreign_key: :permission_id
validate :not_w_permission_to_viewers
after_update :update_changes
after_destroy :update_changes_deletion
def acl
JSON.parse(access_control_list, symbolize_names: true)
end
def user_has_read_permission?(user)
owner?(user) || permitted?(user, ACCESS_READONLY)
end
def user_has_write_permission?(user)
owner?(user) || permitted?(user, ACCESS_READWRITE)
end
# Explicitly remove columns from AR schema
DELETED_COLUMNS = ['entity_id', 'entity_type'].freeze
def self.columns
super.reject { |c| DELETED_COLUMNS.include?(c.name) }
end
def entities_with_read_permission
entities = acl.map do |entry|
entity_id = entry[:id]
case entry[:type]
when TYPE_USER
Carto::User.where(id: entity_id).first
when TYPE_ORGANIZATION
Carto::Organization.where(id: entity_id).first
when TYPE_GROUP
Carto::Group.where(id: entity_id).first
end
end
entities.compact.uniq
end
def entity_type
ENTITY_TYPE_VISUALIZATION
end
def entity_id
visualization.try(:id)
end
def is_owner?(subject)
owner_id == subject.id
end
# Format:
# [
# {
# type: string,
# entity:
# {
# id: uuid,
# username: string,
# avatar_url: string, (optional)
# },
# access: string
# }
# ]
#
# type is from TYPE_xxxxx constants
# access is from ACCESS_xxxxx constants
#
# @param value Array
# @throws CartoDB::PermissionError
def acl=(value)
incoming_acl = value.nil? ? DEFAULT_ACL_VALUE : value
raise CartoDB::PermissionError.new('ACL is not an array') unless incoming_acl.is_a? Array
incoming_acl.map do |item|
unless item.is_a?(Hash) && acl_has_required_fields?(item) && acl_has_valid_access?(item)
raise CartoDB::PermissionError.new('Wrong ACL entry format')
end
end
acl_items = incoming_acl.map do |item|
{
type: item[:type],
id: item[:entity][:id],
access: item[:access]
}
end
cleaned_acl = acl_items.select { |i| i[:id] } # Cleaning, see #5668
if @old_acl.nil?
@old_acl = acl
end
self.access_control_list = ::JSON.dump(cleaned_acl)
end
def owner=(value)
self.owner_id = value.id
self.owner_username = value.username
end
def permission_for_user(user)
return ACCESS_READWRITE if owner?(user)
accesses = acl_entries_for_user(user).map do |entry|
entry[:access]
end
higher_access(accesses)
end
def permitted?(user, permission_type)
SORTED_ACCESSES.index(permission_for_user(user)) <= SORTED_ACCESSES.index(permission_type)
end
def entity
visualization
end
def notify_permissions_change(permissions_changes)
permissions_changes.each do |c, v|
# At the moment we just check users permissions
if c == TYPE_USER
v.each do |affected_id, perm|
# Perm is an array. For the moment just one type of permission can
# be applied to a type of object. But with an array this is open
# to more than one permission change at a time
perm.each do |p|
if visualization.derived?
if p['action'] == 'grant'
# At this moment just inform as read grant
if p['type'].include?('r')
::Resque.enqueue(::Resque::UserJobs::Mail::ShareVisualization, entity.id, affected_id)
end
elsif p['action'] == 'revoke'
if p['type'].include?('r')
::Resque.enqueue(::Resque::UserJobs::Mail::UnshareVisualization,
entity.name, owner_username, affected_id)
end
end
elsif visualization.canonical?
if p['action'] == 'grant'
# At this moment just inform as read grant
if p['type'].include?('r')
::Resque.enqueue(::Resque::UserJobs::Mail::ShareTable, entity.id, affected_id)
end
elsif p['action'] == 'revoke'
if p['type'].include?('r')
::Resque.enqueue(::Resque::UserJobs::Mail::UnshareTable, entity.name, owner_username, affected_id)
end
end
end
end
end
end
end
rescue => e
CartoDB::Logger.error(message: "Problem sending notification mail", exception: e)
end
def set_user_permission(subject, access)
set_subject_permission(subject.id, access, TYPE_USER)
end
def set_group_permission(group, access)
# You only want to switch it off when request is a permission request coming from database
@update_db_group_permission = false
granted_access = granted_access_for_group(group)
if granted_access == access
raise ModelAlreadyExistsError.new("Group #{group.name} already has #{access} access")
elsif granted_access == ACCESS_NONE
set_subject_permission(group.id, access, TYPE_GROUP)
else
# Remove group entry from acl in order to add new (or none if ACCESS_NONE)
new_acl = inputable_acl.reject { |entry| entry[:entity][:id] == group.id }
unless access == ACCESS_NONE
acl_entry = {
type: TYPE_GROUP,
entity: {
id: group.id,
name: group.name
},
access: access
}
new_acl << acl_entry
end
self.acl = new_acl
end
end
def remove_group_permission(group)
# You only want to switch it off when request is a permission request coming from database
@update_db_group_permission = false
set_group_permission(group, ACCESS_NONE)
end
def remove_user_permission(user)
granted_access = granted_access_for_user(user)
if granted_access != ACCESS_NONE
self.acl = inputable_acl.select { |entry| entry[:entity][:id] != user.id }
end
end
# acl write method expects entries to have entity, although they're not
# stored.
# TODO: fix this, since this is coupled to representation.
def inputable_acl
acl.map do |entry|
{
type: entry[:type],
entity: {
id: entry[:id],
avatar_url: '',
username: '',
name: ''
},
access: entry[:access]
}
end
end
private
def real_entity_type
entity.type
end
def not_w_permission_to_viewers
viewer_writers = users_with_permissions(ACCESS_READWRITE).select(&:viewer)
unless viewer_writers.empty?
errors.add(:access_control_list, "grants write to viewers: #{viewer_writers.map(&:username).join(',')}")
end
end
def users_with_permissions(access)
user_ids = relevant_user_acl_entries(acl).select { |e| access == e[:access] }.map { |e| e[:id] }
user_ids.empty? ? [] : Carto::User.where(id: user_ids).all
end
ENTITY_TYPE_VISUALIZATION = 'vis'.freeze
ALLOWED_ENTITY_KEYS = [:id, :username, :name, :avatar_url].freeze
def owner?(user)
owner_id == user.id
end
def higher_access(accesses)
return ACCESS_NONE if accesses.empty?
index = SORTED_ACCESSES.index do |access|
accesses.include?(access)
end
SORTED_ACCESSES[index]
end
def acl_entries_for_user(user)
acl.select do |entry|
(
acl_entry_is_for_user_id?(entry, user.id) ||
acl_entry_is_for_organization_id(entry, user.organization_id) ||
(!user.groups.nil? && acl_entry_is_for_a_user_group(entry, user.groups.map(&:id)))
)
end
end
def acl_entry_is_for_user_id?(entry, user_id)
entry[:type] == TYPE_USER && entry[:id] == user_id
end
def acl_entry_is_for_organization_id(entry, organization_id)
entry[:type] == TYPE_ORGANIZATION && entry[:id] == organization_id
end
def acl_entry_is_for_a_user_group(entry, group_ids)
entry[:type] == TYPE_GROUP && group_ids.include?(entry[:id])
end
def acl_has_required_fields?(acl_item)
acl_item[:entity].present? && acl_item[:type].present? && acl_item[:access].present? &&
acl_has_valid_entity_field?(acl_item)
end
def acl_has_valid_entity_field?(acl_item)
acl_item[:entity].keys - ALLOWED_ENTITY_KEYS == []
end
def acl_has_valid_access?(acl_item)
valid_access = [ACCESS_READONLY, ACCESS_NONE]
if entity.table?
valid_access << ACCESS_READWRITE
end
valid_access.include?(acl_item[:access])
end
def update_changes
if !@old_acl.nil?
notify_permissions_change(CartoDB::Permission.compare_new_acl(@old_acl, acl))
Carto::DbPermissionService.shared_entities_revokes(@old_acl, acl, entity.table) if entity.table?
end
update_shared_entities
# Notify change, caches should be invalidated
entity.table.update_cdb_tablemetadata if entity && entity.table
end
def update_changes_deletion
# Hack. I need to set the new acl as empty so all the old acls are
# considered revokes
# We need to pass the current acl as old_acl and the new_acl as something
# empty to recreate a revoke by deletion
notify_permissions_change(CartoDB::Permission.compare_new_acl(acl, []))
end
def update_shared_entities
e = entity
# First clean previous sharings
destroy_shared_entities
revoke_previous_permissions(e)
# Create user entities for the new ACL
users = relevant_user_acl_entries(acl)
# Avoid entries without recipient id. See #5668.
users.select { |u| u[:id] }.each do |user|
shared_entity = Carto::SharedEntity.create(
recipient_id: user[:id],
recipient_type: Carto::SharedEntity::RECIPIENT_TYPE_USER,
entity_id: entity.id,
entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION
)
if e.table?
grant_db_permission(e, user[:access], shared_entity)
end
end
org = relevant_org_acl_entry(acl)
if org
shared_entity = Carto::SharedEntity.create(
recipient_id: org[:id],
recipient_type: Carto::SharedEntity::RECIPIENT_TYPE_ORGANIZATION,
entity_id: entity.id,
entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION
)
if e.table?
grant_db_permission(e, org[:access], shared_entity)
end
end
groups = relevant_groups_acl_entries(acl)
groups.each do |group|
Carto::SharedEntity.create(
recipient_id: group[:id],
recipient_type: Carto::SharedEntity::RECIPIENT_TYPE_GROUP,
entity_id: entity.id,
entity_type: Carto::SharedEntity::ENTITY_TYPE_VISUALIZATION
)
# You only want to switch it off when request is a permission request coming from database
if e.table? && @update_db_group_permission != false
Carto::Group.find(group[:id]).grant_db_permission(e.table, group[:access])
end
end
e.invalidate_after_commit
end
def destroy_shared_entities
Carto::SharedEntity.where(entity_id: entity.id).each(&:destroy)
end
def revoke_previous_permissions(entity)
users = relevant_user_acl_entries(@old_acl.nil? ? [] : @old_acl)
org = relevant_org_acl_entry(@old_acl.nil? ? [] : @old_acl)
groups = relevant_groups_acl_entries(@old_acl.nil? ? [] : @old_acl)
case entity.class.name
when Carto::Visualization.to_s
if entity.table
if org
entity.table.remove_organization_access
end
users.each do |user|
# Cleaning, see #5668
u = Carto::User.find(user[:id])
entity.table.remove_access(u) if u
end
# update_db_group_permission check is needed to avoid updating db requests
if @update_db_group_permission != false
groups.each do |group|
Carto::Group.find(group[:id]).grant_db_permission(entity.table, ACCESS_NONE)
end
end
check_related_visualizations(entity.table)
end
else
raise CartoDB::PermissionError.new("Unsupported entity type trying to grant permission: #{entity.class.name}")
end
end
def relevant_user_acl_entries(acl_list)
relevant_acl_entries(acl_list, TYPE_USER)
end
def relevant_org_acl_entry(acl_list)
relevant_acl_entries(acl_list, TYPE_ORGANIZATION).first
end
def relevant_groups_acl_entries(acl_list)
relevant_acl_entries(acl_list, TYPE_GROUP)
end
def relevant_acl_entries(acl_list, type)
acl_list.select { |entry| entry[:type] == type && entry[:access] != ACCESS_NONE }.map do |entry|
{ id: entry[:id], access: entry[:access] }
end
end
def check_related_visualizations(table)
fully_dependent_visualizations = table.fully_dependent_visualizations.to_a
partially_dependent_visualizations = table.partially_dependent_visualizations.to_a
table_visualization = table.table_visualization
partially_dependent_visualizations.each do |visualization|
# check permissions, if the owner does not have permissions
# to see the table the layers using this table are removed
perm = visualization.permission
unless table_visualization.has_permission?(perm.owner, ACCESS_READONLY)
visualization.unlink_from(table)
end
end
fully_dependent_visualizations.each do |visualization|
# check permissions, if the owner does not have permissions
# to see the table the visualization is removed
perm = visualization.permission
unless table_visualization.has_permission?(perm.owner, ACCESS_READONLY)
visualization.delete
end
end
end
def grant_db_permission(entity, access, shared_entity)
if shared_entity.recipient_type == Carto::SharedEntity::RECIPIENT_TYPE_ORGANIZATION
permission_strategy = CartoDB::OrganizationPermission.new
else
u = Carto::User.find(shared_entity[:recipient_id])
permission_strategy = CartoDB::UserPermission.new(u)
end
case entity.class.name
when Carto::Visualization.to_s
# assert database permissions for non canonical tables are assigned
# its canonical vis
if not entity.table
raise CartoDB::PermissionError.new('Trying to change permissions to a table without ownership')
end
table = entity.table
# check ownership
if not owner_id == entity.permission.owner_id
raise CartoDB::PermissionError.new('Trying to change permissions to a table without ownership')
end
# give permission
if access == ACCESS_READONLY
permission_strategy.add_read_permission(table)
elsif access == ACCESS_READWRITE
permission_strategy.add_read_write_permission(table)
end
else
raise CartoDB::PermissionError.new('Unsupported entity type trying to grant permission')
end
end
def set_subject_permission(subject_id, access, type)
new_acl = inputable_acl
new_acl << {
type: type,
entity: {
id: subject_id,
avatar_url: '',
username: '',
name: ''
},
access: access
}
self.acl = new_acl
end
def granted_access_for_user(user)
granted_access_for_entry_type(TYPE_USER, user)
end
def granted_access_for_group(group)
granted_access_for_entry_type(TYPE_GROUP, group)
end
def granted_access_for_entry_type(type, entity)
permission = nil
acl.map do |entry|
if entry[:type] == type && entry[:id] == entity.id
permission = entry[:access]
end
end
permission = ACCESS_NONE if permission.nil?
permission
end
end
+87
View File
@@ -0,0 +1,87 @@
module Carto
class RateLimit < ActiveRecord::Base
RATE_LIMIT_ATTRIBUTES = [:maps_anonymous,
:maps_static,
:maps_static_named,
:maps_dataview,
:maps_dataview_search,
:maps_analysis,
:maps_tile,
:maps_attributes,
:maps_named_list,
:maps_named_create,
:maps_named_get,
:maps_named,
:maps_named_update,
:maps_named_delete,
:maps_named_tiles,
:maps_analysis_catalog,
:sql_query,
:sql_query_format,
:sql_job_create,
:sql_job_get,
:sql_job_delete,
:sql_copy_from,
:sql_copy_to].freeze
RATE_LIMIT_ATTRIBUTES.each { |attr| serialize attr, RateLimitValues }
RATE_LIMIT_ATTRIBUTES.each { |attr| validates attr, presence: true }
# FIXME remove this after syncing rate_limits
RATE_LIMIT_DEFAULTS = {
sql_copy_from: [1, 1, 60],
sql_copy_to: [1, 1, 60]
}.freeze
###
def self.from_api_attributes(attributes)
rate_limit = RateLimit.new
rate_limit.rate_limit_attributes(attributes).each { |k, v| rate_limit[k] = RateLimitValues.new(v) }
rate_limit
end
def to_redis
result = {}
RATE_LIMIT_ATTRIBUTES.each do |key|
result[key.to_s.sub('_', ':')] = self[key].to_redis_array
end
result
end
def save_to_redis(user)
raise ActiveRecord::RecordInvalid.new(self) unless valid?
to_redis.each do |key, value|
$limits_metadata.DEL "limits:rate:store:#{user.username}:#{key}"
$limits_metadata.RPUSH "limits:rate:store:#{user.username}:#{key}", value
end
end
def api_attributes
RATE_LIMIT_ATTRIBUTES.map { |attr| [attr.to_sym, self[attr].to_array] }.to_h
end
def destroy_completely(user)
destroy
delete_from_redis(user)
end
def delete_from_redis(user)
to_redis.each_key do |key|
$limits_metadata.DEL "limits:rate:store:#{user.username}:#{key}"
end
end
def rate_limit_attributes(attrs = attributes)
attrs.with_indifferent_access.slice(*Carto::RateLimit::RATE_LIMIT_ATTRIBUTES)
.reverse_merge!(RATE_LIMIT_DEFAULTS) # FIXME remove this after syncing rate_limits
end
def ==(rate_limit)
super(rate_limit) ||
rate_limit_attributes.lazy.zip(rate_limit.rate_limit_attributes).all? do |x, y|
x == y
end
end
end
end
+81
View File
@@ -0,0 +1,81 @@
module Carto
class RateLimitValues
extend Forwardable
delegate [:<<,
:[],
:[]=,
:all,
:clear,
:each,
:empty?,
:flat_map,
:first,
:length,
:pop,
:present?,
:push,
:second] => :@rate_limits
VALUES_PER_RATE_LIMIT = 3
def initialize(values)
@rate_limits = []
values = convert_from_db_array(values)
values.each_slice(VALUES_PER_RATE_LIMIT) do |slice|
push(RateLimitValue.new(slice))
end
end
def to_array
flat_map(&:to_array)
end
def ==(other)
other.class == self.class && to_array == other.try(:to_array)
end
def to_redis_array
RateLimitValues.dump(self)
end
def self.dump(rate_limit_values)
return [] if rate_limit_values.nil?
rate_limit_values.flat_map(&:to_array)
end
def self.load(values)
RateLimitValues.new(values)
end
private
def convert_from_db_array(values)
return [] if values.nil? || values.empty?
return values.delete('{}').split(',') if values.is_a? String
values
end
end
class RateLimitValue
attr_accessor :max_burst, :count_per_period, :period
def initialize(values)
@values = values
self.max_burst, self.count_per_period, self.period = values.map(&:to_i) if valid?
end
def valid?
values_per_rate_limit = Carto::RateLimitValues::VALUES_PER_RATE_LIMIT
if !@values || @values.length < values_per_rate_limit && @values.length % values_per_rate_limit != 0
raise 'Error: Number of rate limits needs to be multiple of three'
end
true
end
def to_array
[max_burst, count_per_period, period]
end
end
end
+11
View File
@@ -0,0 +1,11 @@
module Carto
class ReceivedNotification < ActiveRecord::Base
# autosave must be explicitly disabled due to https://github.com/rails/rails/issues/9336
# but we probably should not autosave from a ternary table anyway
belongs_to :user, inverse_of: :received_notifications, autosave: false
belongs_to :notification, inverse_of: :received_notifications, autosave: false
scope :unread, -> { where(read_at: nil).order('received_at DESC') }
delegate :icon, :body, to: :notification
end
end
+28
View File
@@ -0,0 +1,28 @@
require 'active_record'
module Carto
class SearchTweet < ActiveRecord::Base
belongs_to :user, inverse_of: :search_tweets, class_name: 'Carto::User'
belongs_to :user_table, class_name: 'Carto::UserTable'
belongs_to :data_import, class_name: 'Carto::DataImport'
def self.twitter_imports_count(query, date_from, date_to)
query
.where('search_tweets.state' => ::SearchTweet::STATE_COMPLETE)
.where('search_tweets.created_at >= ? AND search_tweets.created_at <= ?', date_from, date_to + 1.days)
.sum("search_tweets.retrieved_items").to_i
end
def self.twitter_imports_count_by_date(query, date_from, date_to)
query
.where('search_tweets.state' => ::SearchTweet::STATE_COMPLETE)
.where('search_tweets.created_at >= ? AND search_tweets.created_at <= ?', date_from, date_to + 1.days)
.group("date_trunc('day', search_tweets.created_at)")
.select("date_trunc('day', search_tweets.created_at) as date, SUM(search_tweets.retrieved_items) as count")
.all
.map { |t| { t.date.to_date => t.count.to_i } }
.reduce({}, &:merge)
end
end
end
+32
View File
@@ -0,0 +1,32 @@
require 'active_record'
require_relative '../shared_entity'
module Carto
class SharedEntity < ActiveRecord::Base
ENTITY_TYPE_VISUALIZATION = 'vis'.freeze
RECIPIENT_TYPE_USER = 'user'.freeze
RECIPIENT_TYPE_ORGANIZATION = 'org'.freeze
RECIPIENT_TYPE_GROUP = 'group'.freeze
scope :shared_visualizations, -> { where(entity_type: ENTITY_TYPE_VISUALIZATION) }
belongs_to :visualization, foreign_key: :entity_id, inverse_of: :shared_entities
validates :recipient_id, :recipient_type, :entity_id, :entity_type, presence: true
validates :recipient_id, uniqueness: { scope: :entity_id, message: 'is already taken' }
validate :supported_type
attr_accessible :entity_id, :recipient_id, :entity_type, :recipient_type
def self.columns
super.reject { |c| c.name == "id" }
end
private
def supported_type
errors.add(:entity_type, 'unsupported type') unless self.entity_type == ENTITY_TYPE_VISUALIZATION
end
end
end
+15
View File
@@ -0,0 +1,15 @@
require 'json'
require_relative './carto_json_serializer'
module Carto
class Snapshot < ActiveRecord::Base
belongs_to :visualization, class_name: Carto::Visualization
belongs_to :user, class_name: Carto::User
default_scope { order('created_at DESC') }
serialize :state, ::Carto::CartoJsonSymbolizerSerializer
validates :state, carto_json_symbolizer: true
validates :visualization, :user, presence: true
end
end
+21
View File
@@ -0,0 +1,21 @@
require 'json'
require_relative './carto_json_serializer'
class Carto::State < ActiveRecord::Base
belongs_to :visualization, class_name: Carto::Visualization
serialize :json, ::Carto::CartoJsonSymbolizerSerializer
validates :json, carto_json_symbolizer: true
after_initialize :ensure_json
def self.columns
super.reject { |c| c.name == 'user_id' }
end
private
def ensure_json
self.json ||= Hash.new
end
end
+39
View File
@@ -0,0 +1,39 @@
require 'active_record'
class Carto::Synchronization < ActiveRecord::Base
belongs_to :user
belongs_to :visualization, class_name: Carto::Visualization
belongs_to :log, class_name: Carto::Log
has_many :external_data_imports
STATE_CREATED = 'created'
# Already at resque, waiting for slot
STATE_QUEUED = 'queued'
# Actually syncing
STATE_SYNCING = 'syncing'
STATE_SUCCESS = 'success'
STATE_FAILURE = 'failure'
def authorize?(user)
user.id == user_id && (!!user.sync_tables_enabled || from_external_source?)
end
def to_json(*args)
attributes.merge({from_external_source: from_external_source?}).to_json(*args)
end
def to_hash
attributes.merge({from_external_source: from_external_source?}).to_hash
end
def success?
state == STATE_SUCCESS
end
def from_external_source?
::ExternalDataImport.where(synchronization_id: self.id).first != nil
end
end
+18
View File
@@ -0,0 +1,18 @@
require 'active_record'
module Carto
class SynchronizationOauth < ActiveRecord::Base
belongs_to :user, class_name: 'Carto::User', inverse_of: :synchronization_oauths
# TODO: partial duplication with DataImportsService (this one hasn't redis_storage)
def get_service_datasource
datasource = CartoDB::Datasources::DatasourcesFactory.get_datasource(service, user, {
http_timeout: ::DataImport.http_timeout_for(user)
})
datasource.token = token unless datasource.nil?
datasource
end
end
end
+10
View File
@@ -0,0 +1,10 @@
require 'active_record'
module Carto
class Tag < ActiveRecord::Base
belongs_to :user, class_name: Carto::User
belongs_to :user_table, class_name: Carto::UserTable, foreign_key: :table_id, primary_key: :table_id
end
end
+75
View File
@@ -0,0 +1,75 @@
require 'active_record'
module Carto
class Template < ActiveRecord::Base
# INFO: On delete org will wipe out all templates
belongs_to :organization
# INFO: On delete of source visualization will wipe out all templates
belongs_to :visualization, primary_key: :source_visualization_id
validates :organization_id, presence: true
validates :source_visualization_id, presence: true
validates :title, presence: true
validate :source_visualization_referential_integrity
validate :required_tables_should_be_qualified
validate :required_tables_referential_integrity
# INFO: Needed as rails 3 + activerecord-postgres-array gem is still buggy
def required_tables
self.required_tables_list.split(',')
end
# INFO: Needed as rails 3 + activerecord-postgres-array gem is still buggy
def required_tables=(list=[])
self.required_tables_list = list.join(',')
end
def relates_to_table?(table)
required_tables.include?(table.qualified_table_name.gsub('"',''))
end
private
def source_visualization_referential_integrity
return if errors.keys.include?(:source_visualization_id)
visualization = Carto::Visualization.where(id: self.source_visualization_id).first
errors.add(:source_visualization_id, "Source visualization not found") if visualization.nil?
if visualization.user.organization_id != self.organization_id
errors.add(:source_visualization_id, "Source visualization not found")
end
end
def required_tables_should_be_qualified
wrong_table_names = required_tables.select { |table_name|
!::Table.is_qualified_name_valid?(table_name)
}
errors.add(:required_tables, "Invalid names: #{wrong_table_names.join(', ')}") if wrong_table_names.length > 0
end
def required_tables_referential_integrity
# If already have invalid names, don't run this validation
return if errors.keys.include?(:required_tables)
wrong_tables = required_tables.select { |qualified_name|
schema, table_name = qualified_name.split('.')
begin
user = Carto::User.where({ database_schema: schema, organization_id: self.organization_id}).first
Carto::VisualizationQueryBuilder.new
.with_name(table_name)
.with_user_id(user.id)
.build
.count == 0
rescue
true
end
}
errors.add(:required_tables, "Invalid tables: #{wrong_tables.join(', ')}") if wrong_tables.length > 0
end
end
end
+819
View File
@@ -0,0 +1,819 @@
require 'active_record'
require 'cartodb-common'
require_relative 'user_service'
require_relative 'user_db_service'
require_relative 'synchronization_oauth'
require_relative '../../helpers/data_services_metrics_helper'
require_dependency 'carto/helpers/auth_token_generator'
require_dependency 'carto/helpers/has_connector_configuration'
require_dependency 'carto/helpers/batch_queries_statement_timeout'
require_dependency 'carto/helpers/billing_cycle'
# TODO: This probably has to be moved as the service of the proper User Model
class Carto::User < ActiveRecord::Base
extend Forwardable
include DataServicesMetricsHelper
include Carto::AuthTokenGenerator
include Carto::HasConnectorConfiguration
include Carto::BatchQueriesStatementTimeout
include Carto::BillingCycle
GEOCODING_BLOCK_SIZE = 1000
HERE_ISOLINES_BLOCK_SIZE = 1000
OBS_SNAPSHOT_BLOCK_SIZE = 1000
OBS_GENERAL_BLOCK_SIZE = 1000
MAPZEN_ROUTING_BLOCK_SIZE = 1000
STATE_ACTIVE = 'active'.freeze
STATE_LOCKED = 'locked'.freeze
# Make sure the following date is after Jan 29, 2015,
# which is the date where a message to accept the Terms and
# conditions and the Privacy policy was included in the Signup page.
# See https://github.com/CartoDB/cartodb-central/commit/3627da19f071c8fdd1604ddc03fb21ab8a6dff9f
FULLSTORY_ENABLED_MIN_DATE = Date.new(2017, 1, 1)
FULLSTORY_SUPPORTED_PLANS = ['FREE', 'PERSONAL30', 'Individual'].freeze
MAGELLAN_TRIAL_DAYS = 15
PERSONAL30_TRIAL_DAYS = 30
INDIVIDUAL_TRIAL_DAYS = 14
TRIAL_PLANS = ['personal30', 'individual'].freeze
# INFO: select filter is done for security and performance reasons. Add new columns if needed.
DEFAULT_SELECT = "users.email, users.username, users.admin, users.organization_id, users.id, users.avatar_url," \
"users.api_key, users.database_schema, users.database_name, users.name, users.location," \
"users.disqus_shortname, users.account_type, users.twitter_username, users.google_maps_key, " \
"users.viewer, users.quota_in_bytes, users.database_host, users.crypted_password, " \
"users.builder_enabled, users.private_tables_enabled, users.private_maps_enabled, " \
"users.org_admin, users.last_name, users.google_maps_private_key, users.website, " \
"users.description, users.available_for_hire, users.frontend_version, users.asset_host, "\
"users.no_map_logo, users.industry, users.company, users.phone, users.job_role, "\
"users.public_map_quota, users.maintenance_mode, users.company_employees, users.use_case".freeze
has_many :tables, class_name: Carto::UserTable, inverse_of: :user
has_many :visualizations, inverse_of: :user
has_many :maps, inverse_of: :user
has_many :layers_user
has_many :layers, through: :layers_user, after_add: Proc.new { |user, layer| layer.set_default_order(user) }
belongs_to :organization, inverse_of: :users
belongs_to :rate_limit
has_one :owned_organization, class_name: Carto::Organization, inverse_of: :owner, foreign_key: :owner_id
has_one :static_notifications, class_name: Carto::UserNotification, inverse_of: :user
has_many :feature_flags_user, dependent: :destroy, foreign_key: :user_id, inverse_of: :user
has_many :feature_flags, through: :feature_flags_user
has_many :assets, inverse_of: :user
has_many :data_imports, inverse_of: :user
has_many :geocodings, inverse_of: :user
has_many :synchronization_oauths, class_name: Carto::SynchronizationOauth, inverse_of: :user, dependent: :destroy
has_many :search_tweets, inverse_of: :user
has_many :synchronizations, inverse_of: :user
has_many :tags, inverse_of: :user
has_many :permissions, inverse_of: :owner, foreign_key: :owner_id
has_many :connector_configurations, inverse_of: :user, dependent: :destroy
has_many :client_applications, class_name: Carto::ClientApplication
has_many :oauth_tokens, class_name: Carto::OauthToken
has_many :users_group, dependent: :destroy, class_name: Carto::UsersGroup
has_many :groups, through: :users_group
has_many :received_notifications, inverse_of: :user
has_many :api_keys, inverse_of: :user
has_many :user_multifactor_auths, inverse_of: :user, class_name: Carto::UserMultifactorAuth
has_many :oauth_apps, inverse_of: :user, dependent: :destroy
has_many :oauth_app_users, inverse_of: :user, dependent: :destroy
has_many :granted_oauth_apps, through: :oauth_app_users, class_name: Carto::OauthApp, source: 'oauth_app'
delegate [
:database_username, :database_password, :in_database,
:db_size_in_bytes, :get_api_calls, :table_count, :public_visualization_count, :all_visualization_count,
:visualization_count, :owned_visualization_count, :twitter_imports_count,
:link_privacy_visualization_count, :password_privacy_visualization_count, :public_privacy_visualization_count
] => :service
attr_reader :password
# TODO: From sequel, can be removed once finished
alias_method :maps_dataset, :maps
alias_method :layers_dataset, :layers
alias_method :assets_dataset, :assets
alias_method :data_imports_dataset, :data_imports
alias_method :geocodings_dataset, :geocodings
before_create :set_database_host
before_create :generate_api_key
after_save { reset_password_rate_limit if crypted_password_changed? }
after_destroy { rate_limit.destroy_completely(self) if rate_limit }
after_destroy :invalidate_varnish_cache
LOGIN_NOT_RATE_LIMITED = -1
MULTIFACTOR_AUTHENTICATION_ENABLED = 'enabled'.freeze
MULTIFACTOR_AUTHENTICATION_DISABLED = 'disabled'.freeze
MULTIFACTOR_AUTHENTICATION_NEEDS_SETUP = 'setup'.freeze
include ::VarnishCacheHandler
# Auto creates notifications on first access
def static_notifications_with_creation
static_notifications_without_creation || build_static_notifications(user: self, notifications: {})
end
alias_method_chain :static_notifications, :creation
def name_or_username
name.present? || last_name.present? ? [name, last_name].select(&:present?).join(' ') : username
end
def password_validator
if organization.try(:strong_passwords_enabled)
Carto::PasswordValidator.new(Carto::StrongPasswordStrategy.new)
else
Carto::PasswordValidator.new(Carto::StandardPasswordStrategy.new)
end
end
def password=(value)
return if !value.nil? && password_validator.validate(value, value, self).any?
@password = value
self.crypted_password = Carto::Common::EncryptionService.encrypt(password: value,
secret: Cartodb.config[:password_secret])
end
def reset_password_rate_limit
$users_metadata.DEL rate_limit_password_key if password_rate_limit_configured?
end
def rate_limit_password_key
"limits:password:#{username}"
end
def password_login_attempt
return LOGIN_NOT_RATE_LIMITED unless password_rate_limit_configured?
rate_limit = $users_metadata.call('CL.THROTTLE', rate_limit_password_key, @max_burst, @count, @period)
# it returns the number of seconds until the user should retry
# -1 means the action was allowed
# see https://github.com/brandur/redis-cell#response
rate_limit[3]
end
def password_confirmation=(password_confirmation)
# TODO: Implement
end
def default_avatar
"cartodb.s3.amazonaws.com/static/public_dashboard_default_avatar.png"
end
def feature_flag_names
@feature_flag_names ||= (feature_flags_user.map do |ff|
ff.feature_flag.name
end +
FeatureFlag.where(restricted: false).map(&:name)).uniq.sort
end
# TODO: Revisit methods below to delegate to the service, many look like not proper of the model itself
def service
@service ||= Carto::UserService.new(self)
end
def db_service
@db_service ||= Carto::UserDBService.new(self)
end
# +--------+---------+------+
# valid_privacy logic | Public | Private | Link |
# +-------------------------+--------+---------+------+
# | private_tables_enabled | T | T | T |
# | !private_tables_enabled | T | F | F |
# +-------------------------+--------+---------+------+
#
def valid_privacy?(privacy)
private_tables_enabled || privacy == Carto::UserTable::PRIVACY_PUBLIC
end
def default_dataset_privacy
Carto::UserTable::PRIVACY_VALUES_TO_TEXTS[default_table_privacy]
end
def default_table_privacy
private_tables_enabled ? Carto::UserTable::PRIVACY_PRIVATE : Carto::UserTable::PRIVACY_PUBLIC
end
# @return String public user url, which is also the base url for a given user
def public_url(subdomain_override = nil, protocol_override = nil)
base_subdomain = subdomain_override.nil? ? subdomain : subdomain_override
CartoDB.base_url(base_subdomain, CartoDB.organization_username(self), protocol_override)
end
def subdomain
if CartoDB.subdomainless_urls?
username
else
organization.nil? ? username : organization.name
end
end
def feature_flags_list
@feature_flag_names ||= (feature_flags_user
.map { |ff| ff.feature_flag.name } + FeatureFlag.where(restricted: false)
.map(&:name)).uniq.sort
end
def has_feature_flag?(feature_flag_name)
feature_flags_list.present? && feature_flags_list.include?(feature_flag_name)
end
def has_organization?
!organization_id.nil?
end
def avatar
avatar_url.nil? ? "//#{default_avatar}" : avatar_url
end
def remove_logo?
has_organization? ? organization.no_map_logo? : no_map_logo?
end
def sql_safe_database_schema
database_schema.include?('-') ? "\"#{database_schema}\"" : database_schema
end
def database_public_username
database_schema == CartoDB::DEFAULT_DB_SCHEMA ? CartoDB::PUBLIC_DB_USER : "cartodb_publicuser_#{id}"
end
# returns google maps api key. If the user is in an organization and
# that organization has api key it's used
def google_maps_api_key
organization.try(:google_maps_key).blank? ? google_maps_key : organization.google_maps_key
end
def twitter_datasource_enabled
(read_attribute(:twitter_datasource_enabled) || organization.try(&:twitter_datasource_enabled)) && twitter_configured?
end
def twitter_configured?
# DatasourcesFactory.config_for takes configuration from organization if user is an organization user
CartoDB::Datasources::DatasourcesFactory.customized_config?(Search::Twitter::DATASOURCE_NAME, self)
end
# TODO: this is the correct name for what's stored in the model, refactor changing that name
alias_method :google_maps_query_string, :google_maps_api_key
# Returns the google maps private key. If the user is in an organization and
# that organization has a private key, the org's private key is returned.
def google_maps_private_key
if organization.try(:google_maps_private_key).blank?
read_attribute(:google_maps_private_key)
else
organization.google_maps_private_key
end
end
def google_maps_geocoder_enabled?
google_maps_private_key.present? && google_maps_client_id.present?
end
def google_maps_client_id
Rack::Utils.parse_nested_query(google_maps_query_string)['client'] if google_maps_query_string
end
# returns a list of basemaps enabled for the user
def basemaps
(Cartodb.config[:basemaps] || []).select { |group| group != 'GMaps' || google_maps_enabled? }
end
def google_maps_enabled?
google_maps_query_string.present?
end
# return the default basemap based on the default setting. If default attribute is not set, first basemaps is returned
# it only takes into account basemaps enabled for that user
def default_basemap
default = if google_maps_enabled? && basemaps['GMaps'].present?
['GMaps', basemaps['GMaps']]
else
basemaps.find { |_, group_basemaps| group_basemaps.find { |_, attr| attr['default'] } }
end
default ||= basemaps.first
# return only the attributes
default[1].first[1]
end
def remaining_geocoding_quota(options = {})
remaining = if organization.present?
organization.remaining_geocoding_quota(options)
else
geocoding_quota - get_geocoding_calls(options)
end
(remaining > 0 ? remaining : 0)
end
def remaining_here_isolines_quota(options = {})
remaining = if organization.present?
organization.remaining_here_isolines_quota(options)
else
here_isolines_quota - get_here_isolines_calls(options)
end
(remaining > 0 ? remaining : 0)
end
def remaining_obs_snapshot_quota(options = {})
remaining = if organization.present?
organization.remaining_obs_snapshot_quota(options)
else
obs_snapshot_quota - get_obs_snapshot_calls(options)
end
(remaining > 0 ? remaining : 0)
end
def remaining_obs_general_quota(options = {})
remaining = if organization.present?
organization.remaining_obs_general_quota(options)
else
obs_general_quota - get_obs_general_calls(options)
end
(remaining > 0 ? remaining : 0)
end
def remaining_mapzen_routing_quota(options = {})
remaining = if organization.present?
organization.remaining_mapzen_routing_quota(options)
else
mapzen_routing_quota.to_i - get_mapzen_routing_calls(options)
end
(remaining > 0 ? remaining : 0)
end
def oauth_for_service(service)
synchronization_oauths.where(service: service).first
end
# INFO: don't use, use CartoDB::OAuths#add instead
def add_oauth(service, token)
# INFO: this should be the right way, but there's a problem with pgbouncer:
# ActiveRecord::StatementInvalid: PG::Error: ERROR: prepared statement "a1" does not exist
# synchronization_oauths.create(
# service: service,
# token: token
# )
# INFO: even this fails eventually, th the same error. See https://github.com/CartoDB/cartodb/issues/4003
synchronization_oauth = Carto::SynchronizationOauth.new(
user_id: id,
service: service,
token: token
)
synchronization_oauth.save
synchronization_oauths.append(synchronization_oauth)
synchronization_oauth
end
def get_geocoding_calls(options = {})
date_from, date_to, orgwise = ds_metrics_parameters_from_options(options)
get_user_geocoding_data(self, date_from, date_to, orgwise)
end
def get_here_isolines_calls(options = {})
date_from, date_to, orgwise = ds_metrics_parameters_from_options(options)
get_user_here_isolines_data(self, date_from, date_to, orgwise)
end
def get_obs_snapshot_calls(options = {})
date_from, date_to, orgwise = ds_metrics_parameters_from_options(options)
get_user_obs_snapshot_data(self, date_from, date_to, orgwise)
end
def get_obs_general_calls(options = {})
date_from, date_to, orgwise = ds_metrics_parameters_from_options(options)
get_user_obs_general_data(self, date_from, date_to, orgwise)
end
def get_mapzen_routing_calls(options = {})
date_from, date_to, orgwise = ds_metrics_parameters_from_options(options)
get_user_mapzen_routing_data(self, date_from, date_to, orgwise)
end
# TODO: Remove unused param `use_total`
def remaining_quota(_use_total = false, db_size = service.db_size_in_bytes)
return nil unless db_size
quota_in_bytes - db_size
end
# can be nil table quotas
def remaining_table_quota
if table_quota.present?
remaining = table_quota - service.table_count
remaining < 0 ? 0 : remaining
end
end
def organization_user?
organization.present?
end
def belongs_to_organization?(organization)
organization_user? && !organization.nil? && organization_id == organization.id
end
def soft_geocoding_limit?
Carto::AccountType.new.soft_geocoding_limit?(self)
end
alias_method :soft_geocoding_limit, :soft_geocoding_limit?
def hard_geocoding_limit?
!soft_geocoding_limit?
end
alias_method :hard_geocoding_limit, :hard_geocoding_limit?
def soft_here_isolines_limit?
Carto::AccountType.new.soft_here_isolines_limit?(self)
end
alias_method :soft_here_isolines_limit, :soft_here_isolines_limit?
def hard_here_isolines_limit?
!soft_here_isolines_limit?
end
alias_method :hard_here_isolines_limit, :hard_here_isolines_limit?
def soft_obs_snapshot_limit?
Carto::AccountType.new.soft_obs_snapshot_limit?(self)
end
alias_method :soft_obs_snapshot_limit, :soft_obs_snapshot_limit?
def hard_obs_snapshot_limit?
!soft_obs_snapshot_limit?
end
alias_method :hard_obs_snapshot_limit, :hard_obs_snapshot_limit?
def soft_obs_general_limit?
Carto::AccountType.new.soft_obs_general_limit?(self)
end
alias_method :soft_obs_general_limit, :soft_obs_general_limit?
def hard_obs_general_limit?
!soft_obs_general_limit?
end
alias_method :hard_obs_general_limit, :hard_obs_general_limit?
def soft_twitter_datasource_limit?
soft_twitter_datasource_limit == true
end
def hard_twitter_datasource_limit?
!soft_twitter_datasource_limit?
end
alias_method :hard_twitter_datasource_limit, :hard_twitter_datasource_limit?
def soft_mapzen_routing_limit?
Carto::AccountType.new.soft_mapzen_routing_limit?(self)
end
alias_method :soft_mapzen_routing_limit, :soft_mapzen_routing_limit?
def hard_mapzen_routing_limit?
!soft_mapzen_routing_limit?
end
alias_method :hard_mapzen_routing_limit, :hard_mapzen_routing_limit?
def trial_ends_at
if account_type.to_s.casecmp('magellan').zero? && upgraded_at && upgraded_at + 15.days > Date.today
upgraded_at + MAGELLAN_TRIAL_DAYS.days
elsif account_type.to_s.casecmp('personal30').zero?
created_at + PERSONAL30_TRIAL_DAYS.days
elsif account_type.to_s.casecmp('individual').zero?
created_at + INDIVIDUAL_TRIAL_DAYS.days
end
end
def remaining_days_deletion
return nil unless state == STATE_LOCKED
begin
deletion_date = Cartodb::Central.new.get_user(username).fetch('scheduled_deletion_date', nil)
return nil unless deletion_date
(deletion_date.to_date - Date.today).to_i
rescue StandardError => e
CartoDB::Logger.warning(exception: e, message: 'Something went wrong calculating the number of remaining days for account deletion')
return nil
end
end
def viewable_by?(viewer)
id == viewer.id || organization.try(:admin?, viewer)
end
def editable_by?(user)
id == user.id || user.belongs_to_organization?(organization) && (user.organization_owner? || !organization_admin?)
end
# Some operations, such as user deletion, won't ask for password confirmation if password is not set (because of Google sign in, for example)
def needs_password_confirmation?
(!oauth_signin? || !last_password_change_date.nil?) &&
!created_with_http_authentication? &&
!organization.try(:auth_saml_enabled?)
end
def validate_old_password(old_password)
return true unless needs_password_confirmation?
Carto::Common::EncryptionService.verify(password: old_password, secure_password: crypted_password,
secret: Cartodb.config[:password_secret])
end
def valid_password_confirmation(password)
valid = validate_old_password(password)
errors.add(:password, 'Confirmation password sent does not match your current password') unless valid
valid
end
def valid_password?(key, value, confirmation_value)
password_validator.validate(value, confirmation_value, self).each { |e| errors.add(key, e) }
validate_password_not_in_use(nil, value, key)
errors[key].empty?
end
def validate_password_not_in_use(old_password = nil, new_password = nil, key = :new_password)
if password_in_use?(old_password, new_password)
errors.add(key, 'New password cannot be the same as old password')
end
errors[key].empty?
end
def password_in_use?(old_password = nil, new_password = nil)
return false if new_record?
return old_password == new_password if old_password
Carto::Common::EncryptionService.verify(password: new_password, secure_password: crypted_password_was,
secret: Cartodb.config[:password_secret])
end
alias_method :should_display_old_password?, :needs_password_confirmation?
alias_method :password_set?, :needs_password_confirmation?
def oauth_signin?
google_sign_in || github_user_id.present?
end
def created_with_http_authentication?
Carto::UserCreation.http_authentication.find_by_user_id(id).present?
end
def organization_owner?
organization && organization.owner_id == id
end
def organization_admin?
organization_user? && (organization_owner? || org_admin)
end
def mobile_sdk_enabled?
mobile_max_open_users > 0 || mobile_max_private_users > 0
end
def get_auth_tokens
tokens = [get_auth_token]
if has_organization?
tokens << organization.get_auth_token
tokens += groups.map(&:get_auth_token)
end
tokens
end
def get_auth_token
# Circumvent DEFAULT_SELECT, didn't add auth_token there for sercurity (presenters, etc)
auth_token = Carto::User.select(:auth_token).find(id).auth_token
auth_token || generate_and_save_auth_token
end
def notifications_for_category(category)
static_notifications.notifications[category] || {}
end
def builder_enabled?
if has_organization? && builder_enabled.nil?
organization.builder_enabled
else
!!builder_enabled
end
end
def engine_enabled?
if has_organization? && engine_enabled.nil?
organization.engine_enabled
else
!!engine_enabled
end
end
def new_visualizations_version
builder_enabled? ? 3 : 2
end
def can_change_email?
(!google_sign_in || last_password_change_date.present?) && !Carto::Ldap::Manager.new.configuration_present?
end
def can_change_password?
!Carto::Ldap::Manager.new.configuration_present?
end
def view_dashboard
update_column(:dashboard_viewed_at, Time.now)
end
# Special url that goes to Central if active (for old dashboard only)
def account_url(request_protocol)
request_protocol + CartoDB.account_host + CartoDB.account_path + '/' + username if CartoDB.account_host
end
# Special url that goes to Central if active
def plan_url(request_protocol)
account_url(request_protocol) + '/plan'
end
def relevant_frontend_version
frontend_version || CartoDB::Application.frontend_version
end
def cant_be_deleted_reason
if organization_owner?
"You can't delete your account because you are admin of an organization"
elsif Carto::UserCreation.http_authentication.where(user_id: id).first.present?
"You can't delete your account because you are using HTTP Header Authentication"
end
end
# Gets the list of OAuth accounts the user has (currently only used for synchronization)
# @return CartoDB::OAuths
def oauths
@oauths ||= CartoDB::OAuths.new(self)
end
def get_oauth_services
datasources = CartoDB::Datasources::DatasourcesFactory.get_all_oauth_datasources
array = []
datasources.each do |serv|
obj ||= Hash.new
title = ::User::OAUTH_SERVICE_TITLES.fetch(serv, serv)
revoke_url = ::User::OAUTH_SERVICE_REVOKE_URLS.fetch(serv, nil)
enabled = case serv
when 'gdrive'
Cartodb.config[:oauth][serv]['client_id'].present?
when 'box'
Cartodb.config[:oauth][serv]['client_id'].present?
when 'dropbox'
Cartodb.config[:oauth]['dropbox']['app_key'].present?
when 'mailchimp'
Cartodb.config[:oauth]['mailchimp']['app_key'].present? && has_feature_flag?('mailchimp_import')
when 'instagram'
Cartodb.config[:oauth]['instagram']['app_key'].present? && has_feature_flag?('instagram_import')
else
true
end
if enabled
oauth = oauths.select(serv)
obj['name'] = serv
obj['title'] = title
obj['revoke_url'] = revoke_url
obj['connected'] = !oauth.nil? ? true : false
array.push(obj)
end
end
array
end
def account_url(request_protocol)
if CartoDB.account_host
request_protocol + CartoDB.account_host + CartoDB.account_path + '/' + username
end
end
# Special url that goes to Central if active
def plan_url(request_protocol)
account_url(request_protocol) + '/plan'
end
def update_payment_url(request_protocol)
account_url(request_protocol) + '/update_payment'
end
def active?
state == STATE_ACTIVE
end
def locked?
state == STATE_LOCKED
end
def fullstory_enabled?
FULLSTORY_SUPPORTED_PLANS.include?(account_type) && created_at > FULLSTORY_ENABLED_MIN_DATE
end
def password_expired?
return false unless password_expiration_in_d && password_set?
password_date + password_expiration_in_d.days.to_i < Time.now
end
def password_expiration_in_d
organization_user? ? organization.password_expiration_in_d : Cartodb.get_config(:passwords, 'expiration_in_d')
end
def password_date
last_password_change_date || created_at
end
def send_password_reset!
generate_token(:password_reset_token)
self.password_reset_sent_at = Time.zone.now
save!
Resque.enqueue(::Resque::UserJobs::Mail::PasswordReset, id)
end
def multifactor_authentication_configured?
user_multifactor_auths.any?
end
def active_multifactor_authentication
user_multifactor_auths.order(created_at: :desc).first
end
def multifactor_authentication_status
if user_multifactor_auths.setup.any?
MULTIFACTOR_AUTHENTICATION_NEEDS_SETUP
elsif user_multifactor_auths.enabled.any?
MULTIFACTOR_AUTHENTICATION_ENABLED
else
MULTIFACTOR_AUTHENTICATION_DISABLED
end
end
def remaining_trial_days
return 0 unless trial_ends_at
((trial_ends_at - Time.now) / 1.day).round
end
def trial_user?
TRIAL_PLANS.include?(account_type.to_s.downcase)
end
def get_database_roles
api_key_roles = api_keys.reject { |k| k.db_role =~ /^publicuser/ }.map(&:db_role)
oauth_app_owner_roles = api_keys.reject { |k| k.effective_ownership_role_name == nil }.map(&:effective_ownership_role_name)
(api_key_roles + oauth_app_owner_roles).uniq
end
private
def password_rate_limit_configured?
@max_burst ||= Cartodb.get_config(:passwords, 'rate_limit', 'max_burst')
@count ||= Cartodb.get_config(:passwords, 'rate_limit', 'count')
@period ||= Cartodb.get_config(:passwords, 'rate_limit', 'period')
[@max_burst, @count, @period].all?(&:present?)
end
def set_database_host
self.database_host ||= ::SequelRails.configuration.environment_for(Rails.env)['host']
end
def generate_api_key
self.api_key ||= make_token
end
def generate_token(column)
begin
self[column] = SecureRandom.urlsafe_base64
end while Carto::User.exists?(column => self[column])
end
def ds_metrics_parameters_from_options(options)
date_from = (options[:from] ? options[:from].to_date : last_billing_cycle)
date_to = (options[:to] ? options[:to].to_date : Date.today)
orgwise = options.fetch(:orgwise, true)
[date_from, date_to, orgwise]
end
def make_token
Carto::Common::EncryptionService.make_token
end
end
+350
View File
@@ -0,0 +1,350 @@
require 'cartodb-common'
require_dependency 'carto/user_authenticator'
class Carto::UserCreation < ActiveRecord::Base
# Synced with CartoGearsApi::Events::UserCreationEvent
CREATED_VIA_SAML = 'saml'.freeze
CREATED_VIA_LDAP = 'ldap'.freeze
CREATED_VIA_ORG_SIGNUP = 'org_signup'.freeze
CREATED_VIA_API = 'api'.freeze
CREATED_VIA_HTTP_AUTENTICATION = 'http_authentication'.freeze
VALID_CREATED_VIA = [
CREATED_VIA_LDAP, CREATED_VIA_SAML, CREATED_VIA_ORG_SIGNUP,
CREATED_VIA_API, CREATED_VIA_HTTP_AUTENTICATION
].freeze
IN_PROGRESS_STATES = [:initial, :enqueuing, :creating_user, :validating_user, :saving_user, :promoting_user, :load_common_data, :creating_user_in_central]
FINAL_STATES = [:success, :failure]
scope :http_authentication, -> { where(created_via: CREATED_VIA_HTTP_AUTENTICATION) }
scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }
belongs_to :log, class_name: Carto::Log
belongs_to :user, class_name: Carto::User
after_create :use_invitation
def self.new_user_signup(user, created_via = CREATED_VIA_ORG_SIGNUP)
# Normal validation breaks state_machine method generation
raise 'User needs username' unless user.username
raise 'User needs email' unless user.email
raise "Not valid #{created_via}: #{VALID_CREATED_VIA.join(', ')}" unless VALID_CREATED_VIA.include?(created_via)
user_creation = Carto::UserCreation.new
user_creation.username = user.username
user_creation.email = user.email
user_creation.crypted_password = user.crypted_password
user_creation.organization_id = user.organization.nil? ? nil : user.organization.id
user_creation.quota_in_bytes = user.quota_in_bytes
user_creation.soft_geocoding_limit = user.soft_geocoding_limit
user_creation.soft_here_isolines_limit = user.soft_here_isolines_limit
user_creation.soft_obs_snapshot_limit = user.soft_obs_snapshot_limit
user_creation.soft_obs_general_limit = user.soft_obs_general_limit
user_creation.soft_twitter_datasource_limit = user.soft_twitter_datasource_limit.nil? ? false : user.soft_twitter_datasource_limit
user_creation.soft_mapzen_routing_limit = user.soft_mapzen_routing_limit
user_creation.google_sign_in = user.google_sign_in
user_creation.github_user_id = user.github_user_id
user_creation.log = Carto::Log.new_user_creation
user_creation.created_via = created_via
user_creation.viewer = user.viewer || false
user_creation.org_admin = user.org_admin || false
user_creation.last_password_change_date = user.last_password_change_date
user_creation
end
state_machine :state, :initial => :enqueuing do
before_transition any => any, :do => :log_transition_begin
after_transition any => any, :do => :log_transition_end
after_failure do
self.fail_user_creation
end
after_transition any => :creating_user, :do => :initialize_user
after_transition any => :validating_user, :do => :validate_user
after_transition any => :saving_user, :do => :save_user
after_transition any => :promoting_user, :do => :promote_user
after_transition any => :load_common_data, :do => :load_common_data
after_transition any => :creating_user_in_central, :do => :create_in_central
before_transition any => :success, :do => :close_creation
before_transition any => :failure, :do => :clean_user
event :next_creation_step do
transition :enqueuing => :creating_user,
:creating_user => :validating_user,
:validating_user => :saving_user,
:saving_user => :promoting_user
# This looks more complex than it actually is. The flow is always:
# promoting_user -> creating_user_in_central -> load_common_data -> success
# creating_user_in_central is skipped if central is not configured
# load_common_data is skipped for viewers
transition promoting_user: :creating_user_in_central, if: :sync_data_with_cartodb_central?
transition promoting_user: :load_common_data, unless: :viewer?
transition promoting_user: :success
transition creating_user_in_central: :load_common_data, unless: :viewer?
transition creating_user_in_central: :success
transition load_common_data: :success
end
event :fail_user_creation do
transition any => :failure
end
state all - [:success, :failure] do
def finished?
false
end
end
state :success, :failure do
def finished?
true
end
end
end
def set_owner_promotion(promote_to_organization_owner)
@promote_to_organization_owner = promote_to_organization_owner
end
def set_common_data_url(common_data_url)
@common_data_url = common_data_url
end
# TODO: Shorcut, search for a better solution to detect requirement
def requires_validation_email?
google_sign_in != true &&
!github_user_id &&
!has_valid_invitation? &&
!Carto::Ldap::Manager.new.configuration_present? &&
!created_via_api? &&
!created_via_http_authentication? &&
!created_via_saml?
end
def autologin?
state == 'success' && created_at > Time.now - 1.minute && enabled? && cartodb_user.dashboard_viewed_at.nil?
end
def subdomain
cartodb_user.subdomain
end
def with_invitation_token(invitation_token)
self.invitation_token = invitation_token
self
end
def created_via_api?
created_via == CREATED_VIA_API
end
def created_via_http_authentication?
created_via == CREATED_VIA_HTTP_AUTENTICATION
end
def created_via_saml?
created_via == CREATED_VIA_SAML
end
def has_valid_invitation?
return false unless invitation_token
!valid_invitation.nil?
end
def pertinent_invitation
@pertinent_invitation ||= select_valid_invitation_token(Carto::Invitation.query_with_unused_email(email).all)
end
private
def enabled?
cartodb_user.enable_account_token.nil? && cartodb_user.enabled
end
def valid_invitation
select_valid_invitation_token(Carto::Invitation.query_with_valid_email(email).all)
end
# Returns the first matching token invitation, and raises error if none is found
# but a token is set, since it might be fake
def select_valid_invitation_token(invitations)
return nil if invitations.empty?
invitation = invitations.select do |i|
i.token(email) == invitation_token &&
organization_id == i.organization_id
end.first
raise "Fake token sent for email #{email}, #{invitation_token}" if invitation_token && invitation.nil?
invitation
end
def cartodb_user
@cartodb_user ||= ::User.where(id: user_id).first
end
def log_transition_begin
log_transition('Beginning')
end
def log_transition_end
log_transition('End')
end
def log_transition(prefix)
self.log.append("#{prefix}: State: #{self.state}")
end
def initialize_user
@cartodb_user = ::User.new
@cartodb_user.username = username
@cartodb_user.email = email
@cartodb_user.crypted_password = crypted_password
@cartodb_user.google_sign_in = google_sign_in
@cartodb_user.github_user_id = github_user_id
@cartodb_user.invitation_token = invitation_token
@cartodb_user.enable_account_token = Carto::Common::EncryptionService.make_token if requires_validation_email?
unless organization_id.nil? || @promote_to_organization_owner
organization = ::Organization.where(id: organization_id).first
raise "Trying to copy organization settings from one without owner" if organization.owner.nil?
@cartodb_user.organization = organization
@cartodb_user.organization.owner.copy_account_features(@cartodb_user)
end
@cartodb_user.quota_in_bytes = quota_in_bytes unless quota_in_bytes.nil?
@cartodb_user.soft_geocoding_limit = soft_geocoding_limit unless soft_geocoding_limit.nil?
@cartodb_user.soft_here_isolines_limit = soft_here_isolines_limit unless soft_here_isolines_limit.nil?
@cartodb_user.soft_obs_snapshot_limit = soft_obs_snapshot_limit unless soft_obs_snapshot_limit.nil?
@cartodb_user.soft_obs_general_limit = soft_obs_general_limit unless soft_obs_general_limit.nil?
@cartodb_user.soft_twitter_datasource_limit = soft_twitter_datasource_limit unless soft_twitter_datasource_limit.nil?
@cartodb_user.soft_mapzen_routing_limit = soft_mapzen_routing_limit unless soft_mapzen_routing_limit.nil?
@cartodb_user.viewer = viewer if viewer
@cartodb_user.org_admin = org_admin if org_admin
@cartodb_user.last_password_change_date = last_password_change_date unless last_password_change_date.nil?
if pertinent_invitation
@cartodb_user.viewer = pertinent_invitation.viewer
end
@cartodb_user
rescue => e
handle_failure(e, mark_as_failure = true)
nil
end
# Central validation
def validate_user
@cartodb_user.validate_credentials_not_taken_in_central
raise "Credentials already used" unless @cartodb_user.errors.empty?
rescue => e
handle_failure(e, mark_as_failure = true)
end
def save_user
# INFO: until here we haven't user_id, so in-memory @cartodb_user is needed.
# After this, self.cartodb_user is used, which can be either @cartodb_user or loaded from database.
# This enables resuming.
@cartodb_user.save(raise_on_failure: true)
self.user_id = @cartodb_user.id
self.save
rescue => e
handle_failure(e, mark_as_failure = true)
end
def use_invitation
return unless invitation_token
invitation = pertinent_invitation
return unless invitation
invitation.use(email, invitation_token)
end
def promote_user
return unless @promote_to_organization_owner
organization = ::Organization.where(id: self.organization_id).first
raise "Trying to set organization owner when there's already one" unless organization.owner.nil?
user_organization = CartoDB::UserOrganization.new(organization_id, @cartodb_user.id)
user_organization.promote_user_to_admin
@cartodb_user.reload
rescue => e
handle_failure(e, mark_as_failure = true)
end
def load_common_data
@cartodb_user.load_common_data(@common_data_url) unless @common_data_url.nil?
rescue => e
handle_failure(e, mark_as_failure = false)
end
def create_in_central
cartodb_user.create_in_central
rescue => e
handle_failure(e, mark_as_failure = true)
end
def close_creation
clean_password
cartodb_user.notify_new_organization_user unless has_valid_invitation?
cartodb_user.organization.notify_if_disk_quota_limit_reached if cartodb_user.organization && !cartodb_user.viewer?
cartodb_user.organization.notify_if_seat_limit_reached if cartodb_user.organization && !cartodb_user.viewer?
CartoGearsApi::Events::EventManager.instance.notify(
CartoGearsApi::Events::UserCreationEvent.new(created_via, cartodb_user)
)
rescue => e
handle_failure(e, mark_as_failure = false)
end
def handle_failure(e, mark_as_failure)
CartoDB.notify_exception(e, { user_creation: self, mark_as_failure: mark_as_failure })
self.log.append("Error on state #{self.state}, mark_as_failure: #{mark_as_failure}. Error: #{e.message}")
self.log.append(e.backtrace.join("\n"))
process_failure if mark_as_failure
end
def process_failure
self.state = 'failure'
self.save
self.fail_user_creation
end
def clean_user
return unless cartodb_user && !cartodb_user.id.nil?
begin
cartodb_user.destroy
rescue => e
CartoDB.notify_exception(e, action: 'safe user destruction', user: cartodb_user)
begin
cartodb_user.delete
rescue => ee
CartoDB.notify_exception(ee, action: 'safe user deletion', user: cartodb_user)
end
end
end
def clean_password
self.crypted_password = ''
self.save
end
# INFO: state_machine needs guard methods to be instance methods
def sync_data_with_cartodb_central?
Cartodb::Central.sync_data_with_cartodb_central?
end
end
+175
View File
@@ -0,0 +1,175 @@
module Carto
class UserDBService
# Also default schema for new users
SCHEMA_PUBLIC = 'public'.freeze
SCHEMA_CARTODB = 'cartodb'.freeze
SCHEMA_IMPORTER = 'cdb_importer'.freeze
SCHEMA_CDB_DATASERVICES_API = 'cdb_dataservices_client'.freeze
def initialize(user)
@user = user
end
def build_search_path(user_schema = nil, quote_user_schema = true)
user_schema ||= @user.database_schema
UserDBService.build_search_path(user_schema, quote_user_schema)
end
# Centralized method to provide the (ordered) search_path
def self.build_search_path(user_schema, quote_user_schema = true)
# TODO Add SCHEMA_CDB_GEOCODER when we open the geocoder API to all the people
quote_char = quote_user_schema ? "\"" : ""
"#{quote_char}#{user_schema}#{quote_char}, #{SCHEMA_CARTODB}, #{SCHEMA_CDB_DATASERVICES_API}, #{SCHEMA_PUBLIC}"
end
def public_user_roles
@user.organization_user? ? [CartoDB::PUBLIC_DB_USER, @user.service.database_public_username] : [CartoDB::PUBLIC_DB_USER]
end
# Execute a query in the user database
# @param [String] query, using $1, $2 ... for placeholders
# @param ... values for the placeholders
# @return [Array<Hash<String, Value>>] Something ({ActiveRecord::Result} in this case) that behaves like an Array
# of Hashes that map column name (as string) to value
# @raise [PG::Error] if the query fails
def execute_in_user_database(query, *binds)
@user.in_database.exec_query(query, 'ExecuteUserDb', binds.map { |v| [nil, v] })
rescue ActiveRecord::StatementInvalid => exception
raise exception.cause
end
def tables_effective(schema = 'public')
query = "select table_name::text from information_schema.tables where table_schema = '#{schema}'"
execute_in_user_database(query).map { |i| i['table_name'] }
end
def all_user_roles
roles = [@user.database_username]
if @user.organization_user?
roles << organization_member_group_role_member_name
roles += @user.groups.map(&:database_role)
end
roles
end
def all_tables_granted(role = nil)
roles_str = role ? "'#{role}'" : all_user_roles.map { |r| "'#{r}'" }.join(',')
query = %{
SELECT
s.nspname as schema,
c.relname as t,
string_agg(lower(acl.privilege_type), ',') as permission
FROM
pg_class c
JOIN pg_namespace s ON c.relnamespace = s.oid
JOIN LATERAL aclexplode(COALESCE(c.relacl, acldefault('r'::"char", c.relowner))) acl ON TRUE
JOIN pg_roles r ON acl.grantee = r.oid
WHERE
r.rolname IN (#{roles_str}) AND
s.nspname NOT IN ('cartodb', 'cdb', 'cdb_importer')
GROUP BY schema, t;
}
@user.in_database(as: :superuser) do |database|
database.execute(query)
end
end
def all_tables_granted_hashed(role = nil)
results = all_tables_granted(role)
privileges_hashed = {}
if !results.nil?
results.each do |row|
privileges_hashed[row['schema']] = {} if privileges_hashed[row['schema']].nil?
privileges_hashed[row['schema']][row['t']] = row['permission'].split(',')
end
end
privileges_hashed
end
def all_schemas_granted(role)
roles_str = role ? role : all_user_roles.join(',')
permissions = 'create,usage'
query = %{
WITH
roles AS (
SELECT unnest('{#{roles_str}}'::text[]) AS rname
),
permissions AS (
SELECT 'SCHEMA' AS ptype, unnest('{#{permissions}}'::text[]) AS pname
),
schemas AS (
SELECT schema_name AS sname
FROM information_schema.schemata
WHERE schema_name !~ '^pg_'
AND schema_name NOT IN ('cartodb', 'cdb', 'cdb_importer')
),
schemas_roles_permissions AS (
SELECT
permissions.ptype,
schemas.sname AS obj_name,
roles.rname,
permissions.pname,
has_schema_privilege(roles.rname, schemas.sname, permissions.pname) AS has_permission
FROM
schemas
CROSS JOIN roles
CROSS JOIN permissions
WHERE
permissions.ptype = 'SCHEMA'
),
schemas_and_grants AS (
SELECT obj_name AS object_name,
COALESCE(string_agg(DISTINCT CASE WHEN has_permission THEN pname END, ','), '') AS granted_permissions
FROM schemas_roles_permissions
GROUP BY 1
ORDER BY 1
)
SELECT
object_name, granted_permissions
FROM
schemas_and_grants
WHERE
granted_permissions is not null and granted_permissions <> '';
}
@user.in_database(as: :superuser) do |database|
database.execute(query)
end
end
def all_schemas_granted_hashed(role = nil)
results = all_schemas_granted(role)
return {} if results.nil?
results.map { |row| [row['object_name'], row['granted_permissions'].split(',')] }.to_h
end
def exists_role?(rolname)
query = %{
SELECT 1
FROM pg_catalog.pg_roles
WHERE rolname = '#{rolname}'
}
result = @user.in_database(as: :superuser).execute(query)
result.count > 0
end
def organization_member_group_role_member_name
query = "SELECT cartodb.CDB_Organization_Member_Group_Role_Member_Name() as org_member_role;"
execute_in_user_database(query).first['org_member_role']
end
def create_oauth_reassign_ownership_event_trigger
@user.in_database(as: :superuser).execute('SELECT CDB_EnableOAuthReassignTablesTrigger()')
end
def drop_oauth_reassign_ownership_event_trigger
@user.in_database(as: :superuser).execute('SELECT CDB_DisableOAuthReassignTablesTrigger()')
end
end
end
+132
View File
@@ -0,0 +1,132 @@
require 'active_record'
require 'fileutils'
require_relative '../../../services/user-mover/export_user'
require_dependency 'file_upload'
require_dependency 'resque/user_migration_jobs'
require_dependency 'carto/ghost_tables_manager'
module Carto
class UserMigrationExport < ::ActiveRecord::Base
belongs_to :organization, class_name: Carto::Organization
belongs_to :user, class_name: Carto::User
belongs_to :log, class_name: Carto::Log
STATE_PENDING = 'pending'.freeze
STATE_EXPORTING = 'exporting'.freeze
STATE_UPLOADING = 'uploading'.freeze
STATE_COMPLETE = 'complete'.freeze
STATE_FAILURE = 'failure'.freeze
VALID_STATES = [STATE_PENDING, STATE_EXPORTING, STATE_UPLOADING, STATE_COMPLETE, STATE_FAILURE].freeze
after_initialize :set_defaults
validates :state, inclusion: { in: VALID_STATES }
validate :user_or_organization_present
validate :validate_export_data
def run_export
check_valid_user(user) if user && export_metadata
check_valid_organization(organization) if organization && export_metadata
log.append("=== Exporting #{organization ? 'user' : 'org'} data ===")
update_attributes(state: STATE_EXPORTING)
package = if backup
UserMigrationPackage.for_backup("backup_#{user.username}_#{Time.now.iso8601}", log)
else
UserMigrationPackage.for_export(id, log)
end
export_job = CartoDB::DataMover::ExportJob.new(export_job_arguments(package.data_dir)) if export_data?
run_metadata_export(package.meta_dir) if export_metadata?
log.append("=== Uploading ===")
update_attributes(
state: STATE_UPLOADING,
json_file: export_data? ? "#{id}/#{export_job.json_file}" : 'no_data_exported'
)
uploaded_path = package.upload
state = uploaded_path.present? ? STATE_COMPLETE : STATE_FAILURE
log.append("=== Finishing. State: #{state}. File: #{uploaded_path} ===")
update_attributes(state: state, exported_file: uploaded_path)
true
rescue => e
log.append_exception('Exporting', exception: e)
CartoDB::Logger.error(exception: e, message: 'Error exporting user data', job: inspect)
update_attributes(state: STATE_FAILURE)
false
ensure
package.try(:cleanup)
end
def enqueue
Resque.enqueue(Resque::UserMigrationJobs::Export, export_id: id)
end
private
def check_valid_user(user)
Carto::GhostTablesManager.new(user.id).link_ghost_tables_synchronously
end
def check_valid_organization(organization)
organization.users.each do |user|
check_valid_user(user)
end
end
def run_metadata_export(meta_dir)
if organization
Carto::OrganizationMetadataExportService.new.export_to_directory(organization, meta_dir)
elsif user
Carto::UserMetadataExportService.new.export_to_directory(user, meta_dir)
else
raise 'Unrecognized export type for exporting metadata'
end
end
def export_job_arguments(data_dir)
args = if user.present?
{
id: user.username,
schema_mode: false
}
else
{
organization_name: organization.name,
schema_mode: true,
split_user_schemas: false
}
end
args.merge(
path: data_dir,
job_uuid: id,
export_job_logger: log.logger,
logger: log.logger,
metadata: false,
data: export_data?
)
end
def user_or_organization_present
unless (user.present? && organization.blank?) || (organization.present? && user.blank?)
errors.add(:user, 'exactly one user or organization required')
end
end
def validate_export_data
errors.add(:export_metadata, 'needs to be true if export_data is set to false') if !export_data? && !export_metadata?
end
def set_defaults
self.log = Carto::Log.create(type: 'user_migration_export') unless log
self.state = STATE_PENDING unless state
save
end
end
end
+228
View File
@@ -0,0 +1,228 @@
require 'active_record'
require 'fileutils'
require_relative '../../../services/user-mover/import_user'
require_dependency 'resque/user_migration_jobs'
require_dependency 'carto/user_metadata_export_service'
require_dependency 'carto/organization_metadata_export_service'
module Carto
class UserMigrationImport < ::ActiveRecord::Base
belongs_to :organization, class_name: Carto::Organization
belongs_to :user, class_name: Carto::User
belongs_to :log, class_name: Carto::Log
STATE_PENDING = 'pending'.freeze
STATE_DOWNLOADING = 'downloading'.freeze
STATE_IMPORTING = 'importing'.freeze
STATE_COMPLETE = 'complete'.freeze
STATE_FAILURE = 'failure'.freeze
VALID_STATES = [STATE_PENDING, STATE_DOWNLOADING, STATE_IMPORTING, STATE_COMPLETE, STATE_FAILURE].freeze
after_initialize :set_defaults
validates :state, inclusion: { in: VALID_STATES }
validates :database_host, presence: true
validates :exported_file, presence: true
validates :json_file, presence: true
validate :valid_org_import
validate :valid_dry_settings
validate :validate_import_data
def run_import
raise errors.full_messages.join(', ') unless valid?
log.append('=== Downloading ===')
update_attributes(state: STATE_DOWNLOADING)
package = UserMigrationPackage.for_import(id, log)
package.download(exported_file)
log.append('=== Importing ===')
update_attributes(state: STATE_IMPORTING)
service = (org_import? ? Carto::OrganizationMetadataExportService : Carto::UserMetadataExportService).new
import(service, package)
log.append('=== Complete ===')
update_attributes(state: STATE_COMPLETE)
rescue StandardError => e
puts "ERROR: #{e}"
log.append_exception('Importing', exception: e)
CartoDB::Logger.error(exception: e, message: 'Error importing user data', job: inspect)
update_attributes(state: STATE_FAILURE)
false
ensure
package.try(:cleanup)
end
def enqueue
Resque.enqueue(Resque::UserMigrationJobs::Import, import_id: id)
end
private
def valid_org_import
if org_import?
errors.add(:user_id, "user_id can't be present") if user_id.present?
else
errors.add(:organization_id, "organization_id can't be present") if organization_id.present?
end
end
def valid_dry_settings
errors.add(:dry, 'dry cannot be true while import_metadata is true') if import_metadata && dry
end
def validate_import_data
errors.add(:import_metadata, 'needs to be true if export_data is set to false') if !import_data? && !import_metadata?
end
def import(service, package)
if import_data?
import_job = CartoDB::DataMover::ImportJob.new(import_job_arguments(package.data_dir))
raise "DB already exists at DB host" if import_job.db_exists?
end
imported = do_import_metadata(package, service) if import_metadata?
begin
do_import_data(import_job) if import_data?
rescue => e
log.append('=== Error importing data. Rollback! ===')
rollback_import_data(package)
service.rollback_import_from_directory(package.meta_dir) if import_metadata?
raise e
end
import_visualizations(imported, package, service) if import_metadata?
reconfigure_dataservices if import_metadata?
reconfigure_aggregation_tables if import_metadata?
end
def do_import_metadata(package, service)
log.append('=== Importing metadata ===')
begin
imported = service.import_from_directory(package.meta_dir)
rescue UserAlreadyExists, OrganizationAlreadyExists => e
log.append('Organization already exists. Skipping!')
raise e
rescue => e
log.append('=== Error importing metadata. Rollback! ===')
service.rollback_import_from_directory(package.meta_dir)
raise e
end
org_import? ? self.organization = imported : self.user = imported
update_database_host
save!
imported
end
def do_import_data(import_job)
log.append('=== Importing data ===')
begin
import_job.run!
ensure
import_job.terminate_connections
end
end
def reconfigure_dataservices
if org_import?
::Organization[organization.id].owner.db_service.install_and_configure_geocoder_api_extension
else
::User[user.id].db_service.install_and_configure_geocoder_api_extension
end
end
def reconfigure_aggregation_tables
u = org_import? ? ::Organization[organization.id].owner : ::User[user.id]
begin
u.db_service.connect_to_aggregation_tables
rescue => e
CartoDB::Logger.error(message: "Error trying to refresh aggregation tables for user",
exception: e,
user: u,
organization: (org_import? ? organization : nil),
user_migration_import_id: id)
end
end
def import_visualizations(imported, package, service)
log.append('=== Importing visualizations and search tweets ===')
begin
ActiveRecord::Base.transaction do
service.import_metadata_from_directory(imported, package.meta_dir)
end
rescue => e
log.append('=== Error importing visualizations and search tweets. Rollback! ===')
rollback_import_data(package)
service.rollback_import_from_directory(package.meta_dir)
raise e
end
end
def rollback_import_data(package)
org_import? ? self.organization = nil : self.user = nil
save!
return unless import_data?
import_job = CartoDB::DataMover::ImportJob.new(
import_job_arguments(package.data_dir).merge(rollback: true,
mode: :rollback,
drop_database: true,
drop_roles: true)
)
import_job.run!
import_job.terminate_connections
rescue => e
log.append('There was an error while rolling back import data:' + e.to_s)
end
def update_database_host
users.each do |user|
user.database_host = database_host
user.save!
# This is because Sequel models are being cached along request. This forces reload.
# It's being used in visualizations_export_persistence_service.rb#save_import
::User[user.id].reload
end
end
def users
org_import? ? organization.users : [user]
end
def import_only_data?
# If the destination user/org already exists, import only data (same cloud, different DBs)
org_import? ? organization.present? : user.present?
end
def import_job_arguments(data_dir)
export_file = json_file.split('/').last
{
job_uuid: id,
file: "#{data_dir}/#{export_file}",
data: true,
metadata: false,
host: database_host,
rollback: false,
# This is used to import a non-org user into an organization. It is untested and unsupported.
# Disabling it unconditionally until we need it makes sense.
# into_org_name: org_import? || organization.nil? ? nil : organization.name,
into_org_name: nil,
mode: :import,
logger: log.logger,
import_job_logger: log.logger,
update_metadata: !dry
}
end
def set_defaults
self.log = Carto::Log.create(type: 'user_migration_import') unless log
self.state = STATE_PENDING unless state
save
end
end
end
+130
View File
@@ -0,0 +1,130 @@
module Carto
class UserMigrationPackage
private_class_method :new
def self.for_export(id, log)
base_dir = Cartodb.get_config(:user_migrator, 'user_exports_folder')
instance = new(base_dir, id, log, Cartodb.get_config(:user_migrator))
FileUtils.mkdir_p(instance.data_dir)
FileUtils.mkdir_p(instance.meta_dir)
instance
end
def self.for_import(id, log)
base_dir = Cartodb.get_config(:user_migrator, 'user_imports_folder')
instance = new(base_dir, id, log, Cartodb.get_config(:user_migrator))
FileUtils.mkdir_p(instance.work_dir)
instance
end
def self.for_backup(id, log)
base_dir = Cartodb.get_config(:user_backup, 'user_exports_folder')
config = Cartodb.get_config(:user_backup)
instance = new(base_dir, id, log, config.merge(upload_path: 'backups'))
FileUtils.mkdir_p(instance.data_dir)
FileUtils.mkdir_p(instance.meta_dir)
instance
end
attr_reader :data_dir, :meta_dir, :work_dir
def initialize(base_dir, id, log, config)
@id = id
@log = log
@config = config
@base_dir = base_dir
@work_dir = "#{base_dir}/#{id}"
@data_dir = "#{work_dir}/data"
@meta_dir = "#{work_dir}/meta"
end
def upload
upload_package(compress)
end
def download(exported_file)
uncompress(download_package(exported_file))
end
def cleanup
FileUtils.remove_dir(work_dir, true)
end
private
attr_reader :id, :log, :base_dir
def compress
filename = "user_export_#{id}.zip"
log.append('=== Compressing export ===')
`cd #{work_dir}/ && zip -0 -r \"../#{filename}\" . && cd -`
FileUtils.remove_dir(work_dir)
"#{base_dir}/#{filename}"
end
def upload_package(filepath)
log.append('=== Uploading user data package ===')
file = CartoDB::FileUploadFile.new(filepath)
s3_config = @config['s3'] || {}
results = file_upload_helper.upload_file_to_storage(
file_param: file,
s3_config: s3_config,
allow_spaces: true,
force_s3_upload: true,
random_token: @config[:upload_path]
)
export_path = if results[:file_path].present?
log.append("Ad-hoc export download: #{results[:file_path]}")
results[:file_path]
else
log.append("By file_upload_helper: #{results[:file_uri]}")
results[:file_uri]
end
log.append('=== Deleting tmp file ===')
FileUtils.rm(filepath)
export_path
end
def download_package(exported_file)
destination = "#{work_dir}/export.zip"
log.append("=== Downloading #{exported_file} to #{destination} ===")
if exported_file.starts_with?('http')
http_client.get_file(exported_file, destination)
else
FileUtils.cp(exported_file, destination)
end
destination
end
def uncompress(filename)
log.append("=== Unzipping #{filename} ===")
`cd #{work_dir}; unzip -u #{filename}; cd -`
log.append('=== Deleting zip package ===')
FileUtils.rm(filename)
end
def http_client
Carto::Http::Client.get('user_imports')
end
def file_upload_helper
CartoDB::FileUpload.new(@config[:uploads_path])
end
end
end
+91
View File
@@ -0,0 +1,91 @@
require 'rotp'
require 'rqrcode'
module Carto
class UserMultifactorAuth < ActiveRecord::Base
TYPE_TOTP = 'totp'.freeze
VALID_TYPES = [TYPE_TOTP].freeze
DRIFT = 60.seconds
INTERVAL = 30.seconds
ISSUER = 'CARTO'.freeze
QR_CODE_SIZE = 400
belongs_to :user, inverse_of: :user_multifactor_auths, foreign_key: :user_id
validates :type, inclusion: { in: VALID_TYPES }
validates_uniqueness_of :type, scope: :user_id
before_create :create_shared_secret
after_save :sync_central, unless: :skip_central_sync
after_destroy :sync_central, unless: :skip_central_sync
self.inheritance_column = :_type
scope :enabled, -> { where(enabled: true) }
scope :setup, -> { where(enabled: false) }
attr_accessor :skip_central_sync
def self.new_from_hash(uma_hash, skip_central_sync = true)
new(
created_at: uma_hash[:created_at],
updated_at: uma_hash[:updated_at],
last_login: uma_hash[:last_login],
type: uma_hash[:type],
shared_secret: uma_hash[:shared_secret],
user_id: uma_hash[:user_id],
enabled: uma_hash[:enabled],
skip_central_sync: skip_central_sync
)
end
def verify!(code)
timestamp = verify(code)
raise Carto::UnauthorizedError.new('The code is not valid') unless timestamp
update!(enabled: true, last_login: timestamp)
end
def needs_setup?
!enabled
end
def provisioning_uri
totp.provisioning_uri(user.username)
end
def qr_code
qrcode = RQRCode::QRCode.new(totp.provisioning_uri(user.username))
qrcode.as_png(size: QR_CODE_SIZE).to_data_url
end
def to_h
attributes.symbolize_keys
end
private
def sync_central
# due to AR/Sequel transactions the user might not exist in the database yet
# this happens when cascade saving a new user with user_multifactor_auths (i.e. in user migrations)
::User[user.id].update_in_central
end
def last_login_in_seconds
last_login.strftime('%s').to_i if last_login
end
def totp
@totp ||= ROTP::TOTP.new(shared_secret, issuer: ISSUER, interval: INTERVAL)
end
def verify(code)
timestamp = totp.verify_with_drift_and_prior(code.to_s, DRIFT, last_login_in_seconds)
Time.at(timestamp) if timestamp
end
def create_shared_secret
self.shared_secret = ROTP::Base32.random_base32 unless shared_secret.present?
end
end
end
+21
View File
@@ -0,0 +1,21 @@
require_relative './carto_json_serializer'
module Carto
class UserNotification < ActiveRecord::Base
belongs_to :user
serialize :notifications, ::Carto::CartoJsonSymbolizerSerializer
validates :user, presence: true
validate :only_valid_categories
VALID_CATEGORIES = [:builder, :dashboard].freeze
private
def only_valid_categories
notifications.keys.none? do |category|
errors.add(:notifications, "Invalid category: #{category}") unless VALID_CATEGORIES.include?(category)
end
end
end
end
+187
View File
@@ -0,0 +1,187 @@
require 'active_record'
require 'cartodb-common'
require_dependency 'carto/db/connection'
module Carto
class UserService
def initialize(user_model)
@user = user_model
end
# TODO: Review usage to move to UserPresenter if not used anywhere else
# Only returns owned tables (not shared ones)
def table_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.new
.with_user_id(@user.id)
.with_type(Carto::Visualization::TYPE_CANONICAL)
.build
.count
end
def owned_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.new
.with_user_id(@user.id)
.with_type(Carto::Visualization::TYPE_DERIVED)
.build
.count
end
def visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.new
.with_owned_by_or_shared_with_user_id(@user.id)
.build
.count
end
def public_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.user_public_visualizations(@user)
.build
.count
end
def public_privacy_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.user_public_privacy_visualizations(@user)
.build
.count
end
def link_privacy_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.user_link_privacy_visualizations(@user)
.build
.count
end
def password_privacy_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.user_password_privacy_visualizations(@user)
.build
.count
end
def all_visualization_count
return 0 unless @user.id
Carto::VisualizationQueryBuilder.user_all_visualizations(@user)
.build
.count
end
def twitter_imports_count(options={})
date_to = (options[:to] ? options[:to].to_date : Date.today)
date_from = (options[:from] ? options[:from].to_date : @user.last_billing_cycle)
Carto::SearchTweet.twitter_imports_count(@user.search_tweets, date_from, date_to)
end
# Returns an array representing the last 30 days, populated with api_calls
# from three different sources
def get_api_calls(options = {})
return CartoDB::Stats::APICalls.new.get_api_calls_without_dates(@user.username, {old_api_calls: false})
end
# This method is innaccurate and understates point based tables (the /2 is to account for the_geom_webmercator)
# TODO: Without a full table scan, ignoring the_geom_webmercator, we cannot accuratly asses table size
# Needs to go on a background job.
def db_size_in_bytes
return 0 if @user.new_record?
attempts = 0
begin
# Hack to support users without the new MU functiones loaded
# TODO: Check this works as expected
user_data_size_function = cartodb_extension_version_pre_mu? ?
"CDB_UserDataSize()" :
"CDB_UserDataSize('#{@user.database_schema}')"
in_database(as: :superuser) do |user_database|
user_database.transaction do
user_database.execute(%{SET LOCAL lock_timeout = '1s'})
user_database.execute(%{SELECT cartodb.#{user_data_size_function}}).first['cdb_userdatasize'].to_i
end
end
rescue => e
attempts += 1
begin
in_database(as: :superuser).execute("ANALYZE")
rescue => ee
CartoDB.report_exception(ee, "Failed to get user db size, retrying...", user: @user)
raise ee
end
retry unless attempts > 1
CartoDB.notify_exception(e, user: @user)
# INFO: we need to return something to avoid 'disabled' return value
nil
end
end
def database_username
if Rails.env.production?
"cartodb_user_#{@user.id}"
elsif Rails.env.staging?
"cartodb_staging_user_#{@user.id}"
else
"#{Rails.env}_cartodb_user_#{@user.id}"
end
end
def cartodb_extension_version_pre_mu?
current_version = cartodb_extension_semver(cartodb_extension_version)
if current_version.size == 3
major, minor, _ = current_version
major == 0 and minor < 3
else
raise 'Current cartodb extension version does not match standard x.y.z format'
end
end
def database_public_username
@user.database_schema != CartoDB::DEFAULT_DB_SCHEMA ? "cartodb_publicuser_#{@user.id}" : CartoDB::PUBLIC_DB_USER
end
def organization_member_group_role_member_name
@user.in_database.execute(
"SELECT cartodb.CDB_Organization_Member_Group_Role_Member_Name() as org_member_role;"
).first['org_member_role']
end
# Returns a tree elements array with [major, minor, patch] as in http://semver.org/
def cartodb_extension_semver(extension_version)
extension_version.split('.').take(3).map(&:to_i)
end
def cartodb_extension_version
@cartodb_extension_version ||= in_database(:as => :superuser).execute('select cartodb.cdb_version() as v')
.first['v']
end
def database_password
Carto::Common::EncryptionService.hex_digest(@user.crypted_password) + database_username
end
def in_database(options = {})
options[:username] = @user.database_username
options[:password] = @user.database_password
options[:user_schema] = @user.database_schema
Carto::Db::Connection.connect(@user.database_host, @user.database_name, options) do |_, connection|
if block_given?
yield(connection)
else
connection
end
end
end
end
end
+296
View File
@@ -0,0 +1,296 @@
require 'active_record'
require_dependency 'carto/db/sanitize'
# Integer type for PostgreSQL oid columns, with proper range
class Carto::OidType < ActiveRecord::Type::Integer
def max_value
(1 << 32) - 1
end
def min_value
0
end
end
module Carto
class UserTable < ActiveRecord::Base
PRIVACY_PRIVATE = 0
PRIVACY_PUBLIC = 1
PRIVACY_LINK = 2
PRIVACY_VALUES_TO_TEXTS = {
PRIVACY_PRIVATE => 'private',
PRIVACY_PUBLIC => 'public',
PRIVACY_LINK => 'link'
}.freeze
# Column table_id is of type oid and the type casting provided by ActiveRecord is not
# good for it since it only supports signed values
attribute 'table_id', Carto::OidType.new
# AR sets privacy = 0 (private) by default, taken from the DB. We want it to be `nil`
# so the `before_validation` hook sets an appropriate privacy based on the table owner
attribute 'privacy', Type::Integer.new, default: nil
# The ::Table service depends on the constructor not being able to set all parameters, only these are allowed
# This is done so things like name changes are forced to go through ::Table.name= to ensure renaming behaviour
attr_accessible :privacy, :tags, :description
belongs_to :user
belongs_to :map, inverse_of: :user_table
belongs_to :data_import
has_many :automatic_geocodings, inverse_of: :table, class_name: Carto::AutomaticGeocoding,
foreign_key: :table_id, dependent: :destroy
# Disabled to avoid conflicting with the `tags` field. This relation is updated by ::Table.manage_tags.
# TODO: We can remove both the `user_tables.tags` field and the `tags` table in favour of the canonical viz tags.
# has_many :tags, foreign_key: :table_id
has_many :layers_user_table
has_many :layers, through: :layers_user_table
before_validation :set_default_table_privacy
validates :user, presence: true
validate :validate_user_not_viewer
validates :name, uniqueness: { scope: :user_id }
validates :name, exclusion: Carto::DB::Sanitize::RESERVED_TABLE_NAMES
validates :privacy, inclusion: [PRIVACY_PRIVATE, PRIVACY_PUBLIC, PRIVACY_LINK].freeze
validate :validate_privacy_changes
before_create :service_before_create
after_create :create_canonical_visualization, unless: :map
after_create :service_after_create
after_save :service_after_save
# The `destroyed?` check is needed to avoid the hook running twice when deleting a table from the ::Table service
# as it is triggered directly, and a second time from canonical visualization destruction hooks.
# TODO: This can be simplified after deleting the old UserTable model
before_destroy :ensure_not_viewer
before_destroy :cache_dependent_visualizations, unless: :destroyed?
after_destroy :destroy_dependent_visualizations
after_destroy :service_after_destroy
def geometry_types
@geometry_types ||= service.geometry_types
end
# Estimated size
def size
row_count_and_size[:size]
end
def table_size
service.table_size
end
# Estimated row_count. Preferred: `estimated_row_count`
def row_count
row_count_and_size[:row_count]
end
# Estimated row count and size. Preferred `estimated_row_count` for row count.
def row_count_and_size
@row_count_and_size ||= service.row_count_and_size
end
def service
@service ||= ::Table.new(user_table: self)
end
def set_service(table)
@service = table
end
def visualization
map.visualization if map
end
def synchronization
visualization.synchronization if visualization
end
def fully_dependent_visualizations
affected_visualizations.select { |v| v.fully_dependent_on?(self) }
end
def accessible_dependent_derived_maps
affected_visualizations.select { |v| (v.has_read_permission?(user) && v.derived?) ? v : nil }
end
def partially_dependent_visualizations
affected_visualizations.select { |v| v.partially_dependent_on?(self) }
end
def dependent_visualizations
affected_visualizations.select { |v| v.dependent_on?(self) }
end
def affected_visualizations
layers.map(&:visualization).uniq.compact
end
def name_for_user(other_user)
is_owner?(other_user) ? name : fully_qualified_name
end
def private?
privacy == PRIVACY_PRIVATE
end
def public?
privacy == PRIVACY_PUBLIC
end
def public_with_link_only?
privacy == PRIVACY_LINK
end
def privacy_text
visualization_privacy.upcase
end
def visualization_privacy
PRIVACY_VALUES_TO_TEXTS[privacy]
end
def readable_by?(user)
!private? || is_owner?(user) || visualization_readable_by?(user)
end
def raster?
service.is_raster?
end
def geometry_type
service.the_geom_type || 'geometry'
end
def estimated_row_count
service.estimated_row_count
end
def actual_row_count
service.actual_row_count
end
def sync_table_id
self.table_id = service.get_table_id
end
def permission
visualization.permission if visualization
end
def external_source_visualization
data_import.try(:external_data_imports).try(:first).try(:external_source).try(:visualization)
end
def table_visualization
map.visualization if map
end
def update_cdb_tablemetadata
service.update_cdb_tablemetadata
end
def save_changes
# TODO: Compatibility with Sequel model, can be removed afterwards. Used in ::Table.set_the_geom_column!
save if changed?
end
def tags=(value)
return unless value
super(value.split(',').map(&:strip).reject(&:blank?).uniq.join(','))
end
# TODO: Compatibility with Sequel model, can be removed afterwards.
def set_tag_array(tag_array)
self.tags = tag_array.join(',')
end
# TODO: This is related to an incompatibility between visualizations models, `get_related_tables`, See #11705
def privacy_text_for_vizjson
privacy == PRIVACY_LINK ? 'PUBLIC' : privacy_text
end
def automatic_geocoding
automatic_geocodings.first
end
def is_owner?(user)
return false unless user
user_id == user.id
end
private
def default_privacy_value
user.try(:default_table_privacy)
end
def set_default_table_privacy
self.privacy ||= default_privacy_value
end
def fully_qualified_name
"\"#{user.database_schema}\".#{name}"
end
def visualization_readable_by?(user)
user && permission && permission.user_has_read_permission?(user)
end
def validate_user_not_viewer
errors.add(:user, "Viewer users can't create tables") if user.try(:viewer)
end
def validate_privacy_changes
if !user.try(:private_tables_enabled) && !public? && (new_record? || privacy_changed?)
errors.add(:privacy, 'unauthorized to create private tables')
end
end
def create_canonical_visualization
visualization = Carto::VisualizationFactory.create_canonical_visualization(self)
update_attribute(:map, visualization.map)
visualization.map.set_default_boundaries!
end
def cache_dependent_visualizations
@fully_dependent_visualizations_cache = fully_dependent_visualizations
@partially_dependent_visualizations_cache = partially_dependent_visualizations
end
def destroy_dependent_visualizations
table_visualization.try(:delete_from_table)
@fully_dependent_visualizations_cache.each(&:destroy)
@partially_dependent_visualizations_cache.each do |visualization|
visualization.unlink_from(self)
end
end
def ensure_not_viewer
# Loading ::User is a workaround for User deletion: viewer attribute change is not visible at AR transaction
raise "Viewer users can't destroy tables" if user && user.viewer && ::User[user_id].viewer
end
def service_before_create
service.before_create
end
def service_after_create
service.after_create
end
def service_after_save
service.after_save
end
def service_after_destroy
service.after_destroy
end
end
end
+10
View File
@@ -0,0 +1,10 @@
require 'active_record'
module Carto
class UsersGroup < ActiveRecord::Base
# autosave must be explicitly disabled due to https://github.com/rails/rails/issues/9336
# but we probably should not autosave from a ternary table anyway
belongs_to :user, class_name: Carto::User, autosave: false
belongs_to :group, class_name: Carto::Group, autosave: false
end
end
+834
View File
@@ -0,0 +1,834 @@
require 'active_record'
require 'cartodb-common'
require_relative '../visualization/stats'
require_dependency 'carto/named_maps/api'
require_dependency 'carto/helpers/auth_token_generator'
require_dependency 'carto/uuidhelper'
require_dependency 'carto/visualization_invalidation_service'
require_dependency 'carto/visualization_backup_service'
module Carto::VisualizationDependencies
def fully_dependent_on?(user_table)
derived? && layers_dependent_on(user_table).count == data_layers.count
end
def partially_dependent_on?(user_table)
derived? && layers_dependent_on(user_table).count.between?(1, data_layers.count - 1)
end
def dependent_on?(user_table)
derived? && layers_dependent_on(user_table).any?
end
private
def layers_dependent_on(user_table)
data_layers.select { |l| l.depends_on?(user_table) }
end
end
class Carto::Visualization < ActiveRecord::Base
include CacheHelper
include Carto::UUIDHelper
include Carto::AuthTokenGenerator
include Carto::VisualizationDependencies
include Carto::VisualizationBackupService
TYPE_CANONICAL = 'table'.freeze
TYPE_DERIVED = 'derived'.freeze
TYPE_SLIDE = 'slide'.freeze
TYPE_REMOTE = 'remote'.freeze
TYPE_KUVIZ = 'kuviz'.freeze
VALID_TYPES = [TYPE_CANONICAL, TYPE_DERIVED, TYPE_SLIDE, TYPE_REMOTE, TYPE_KUVIZ].freeze
KIND_GEOM = 'geom'.freeze
KIND_RASTER = 'raster'.freeze
PRIVACY_PUBLIC = 'public'.freeze
PRIVACY_PRIVATE = 'private'.freeze
PRIVACY_LINK = 'link'.freeze
PRIVACY_PROTECTED = 'password'.freeze
PRIVACIES = [PRIVACY_LINK, PRIVACY_PROTECTED, PRIVACY_PUBLIC, PRIVACY_PRIVATE].freeze
VERSION_BUILDER = 3
V2_VISUALIZATIONS_REDIS_KEY = 'vizjson2_visualizations'.freeze
scope :remotes, -> { where(type: TYPE_REMOTE) }
# INFO: disable ActiveRecord inheritance column
self.inheritance_column = :_type
belongs_to :user, -> { select(Carto::User::DEFAULT_SELECT) }, inverse_of: :visualizations
belongs_to :full_user, -> { readonly(true) }, class_name: Carto::User, inverse_of: :visualizations,
primary_key: :id, foreign_key: :user_id
belongs_to :permission, inverse_of: :visualization, dependent: :destroy
has_many :likes, foreign_key: :subject
has_many :shared_entities, foreign_key: :entity_id, inverse_of: :visualization, dependent: :destroy
has_one :external_source, class_name: Carto::ExternalSource, dependent: :destroy, inverse_of: :visualization
has_many :unordered_children, class_name: Carto::Visualization, foreign_key: :parent_id
has_many :overlays, -> { order(:order) }, dependent: :destroy, inverse_of: :visualization
belongs_to :active_layer, class_name: Carto::Layer
belongs_to :map, class_name: Carto::Map, inverse_of: :visualization, dependent: :destroy
has_one :asset, class_name: Carto::Asset, inverse_of: :visualization, dependent: :destroy
has_many :related_templates, class_name: Carto::Template, foreign_key: :source_visualization_id
has_one :synchronization, class_name: Carto::Synchronization, dependent: :destroy
has_many :external_sources, class_name: Carto::ExternalSource
has_many :analyses, class_name: Carto::Analysis
has_many :mapcaps, -> { order('created_at DESC') }, class_name: Carto::Mapcap, dependent: :destroy
has_one :state, class_name: Carto::State, autosave: true
has_many :snapshots, class_name: Carto::Snapshot, dependent: :destroy
validates :name, :privacy, :type, :user_id, :version, presence: true
validates :privacy, inclusion: { in: PRIVACIES }
validates :type, inclusion: { in: VALID_TYPES }
validate :validate_password_presence
validate :validate_privacy_changes
validate :validate_user_not_viewer, on: :create
before_validation :set_default_version, :set_register_table_only
before_create :set_random_id, :set_default_permission
before_save :remove_password_if_unprotected
after_save :propagate_attribution_change
after_save :propagate_privacy_and_name_to, if: :table
before_destroy :backup_visualization
after_commit :perform_invalidations
attr_accessor :register_table_only
def set_register_table_only
self.register_table_only = false
# This is a callback, returning `true` avoids halting because of assignment `false` return value
true
end
def set_default_version
self.version ||= user.try(:new_visualizations_version)
end
DELETED_COLUMNS = ['state_id', 'url_options'].freeze
def self.columns
super.reject { |c| DELETED_COLUMNS.include?(c.name) }
end
def size
# Only canonical visualizations (Datasets) have a related table and then count against disk quota,
# but we want to not break and even allow ordering by size multiple types
if user_table
user_table.size
elsif remote? && external_source
external_source.size
else
0
end
end
def tags
tags = super
tags == nil ? [] : tags
end
def tags=(tags)
tags.reject!(&:blank?) if tags
super(tags)
end
def user_table
map.user_table if map
end
def table
@table ||= user_table.try(:service)
end
def layers_with_data_readable_by(user)
return [] unless map
map.layers.select { |l| l.data_readable_by?(user) }
end
def related_tables
@related_tables ||= get_related_tables
end
def related_tables_readable_by(user)
layers_with_data_readable_by(user).map { |l| l.user_tables_readable_by(user) }.flatten.uniq
end
def related_canonical_visualizations
@related_canonical_visualizations ||= get_related_canonical_visualizations
end
def stats
@stats ||= CartoDB::Visualization::Stats.new(self).to_poro
end
def transition_options
@transition_options ||= (slide_transition_options.nil? ? {} : JSON.parse(slide_transition_options).symbolize_keys)
end
def transition_options=(value)
self.slide_transition_options = ::JSON.dump(value.nil? ? DEFAULT_OPTIONS_VALUE : value)
end
def children
ordered = []
children_vis = self.unordered_children
if children_vis.count > 0
ordered << children_vis.select { |vis| vis.prev_id.nil? }.first
while !ordered.last.next_id.nil?
target = ordered.last.next_id
unless target.nil?
ordered << children_vis.select { |vis| vis.id == target }.first
end
end
end
ordered
end
# TODO: refactor next methods, all have similar naming but some receive user and some others user_id
def liked_by?(user)
likes_by_user(user).any?
end
def likes_by_user(user)
likes.where(actor: user.id)
end
def add_like_from(user)
unless has_read_permission?(user)
raise UnauthorizedLikeError
end
likes.create!(actor: user.id)
self
rescue ActiveRecord::RecordNotUnique
raise AlreadyLikedError
end
def remove_like_from(user)
unless has_read_permission?(user)
raise UnauthorizedLikeError
end
item = likes.where(actor: user.id)
item.first.destroy unless item.first.nil?
self
end
def send_like_email(current_viewer, vis_preview_image)
if self.type == Carto::Visualization::TYPE_CANONICAL
::Resque.enqueue(::Resque::UserJobs::Mail::TableLiked, self.id, current_viewer.id, vis_preview_image)
elsif self.type == Carto::Visualization::TYPE_DERIVED
::Resque.enqueue(::Resque::UserJobs::Mail::MapLiked, self.id, current_viewer.id, vis_preview_image)
end
end
def is_viewable_by_user?(user)
is_publically_accesible? || has_read_permission?(user)
end
def is_accesible_by_user?(user)
is_viewable_by_user?(user) || password_protected?
end
def is_accessible_with_password?(user, password)
is_viewable_by_user?(user) || password_valid?(password)
end
def is_publically_accesible?
(public? || public_with_link?) && published?
end
def writable_by?(user)
(user_id == user.id && !user.viewer?) || has_write_permission?(user)
end
def varnish_key
"#{user.database_name}:#{sorted_related_table_names},#{id}"
end
def surrogate_key
get_surrogate_key(CartoDB::SURROGATE_NAMESPACE_VISUALIZATION, id)
end
def qualified_name(viewer_user = nil)
if viewer_user.nil? || owner?(viewer_user)
name
else
"#{user.sql_safe_database_schema}.#{name}"
end
end
# Despite storing always a named map, no need to retrieve it for "public" visualizations
def retrieve_named_map?
password_protected? || has_private_tables?
end
def has_password?
!password_salt.nil? && !encrypted_password.nil?
end
def type_slide?
type == TYPE_SLIDE
end
def kind_raster?
kind == KIND_RASTER
end
def canonical?
type == TYPE_CANONICAL
end
# TODO: remove. Kept for backwards compatibility with ::Permission model
def table?
type == TYPE_CANONICAL
end
def derived?
type == TYPE_DERIVED
end
def remote?
type == TYPE_REMOTE
end
def kuviz?
type == TYPE_KUVIZ
end
def layers
map ? map.layers : []
end
def data_layers
map ? map.data_layers : []
end
def carto_layers
map ? map.carto_layers : []
end
def user_layers
map ? map.user_layers : []
end
def torque_layers
map ? map.torque_layers : []
end
def other_layers
map ? map.other_layers : []
end
def base_layers
map ? map.base_layers : []
end
def named_map_layers
map ? map.named_map_layers : []
end
def password_valid?(password)
password_protected? &&
Carto::Common::EncryptionService.verify(password: password, secure_password: encrypted_password,
salt: password_salt, secret: Cartodb.config[:password_secret])
end
def organization?
privacy == PRIVACY_PRIVATE && !permission.acl.empty?
end
def password_protected?
privacy == PRIVACY_PROTECTED
end
def private?
is_privacy_private? && !organization?
end
def is_privacy_private?
privacy == PRIVACY_PRIVATE
end
def public?
privacy == PRIVACY_PUBLIC
end
def public_with_link?
self.privacy == PRIVACY_LINK
end
def editable?
!(kind_raster? || type_slide?)
end
def get_auth_tokens
[get_auth_token]
end
def mapviews
@mapviews ||= CartoDB::Visualization::Stats.mapviews(stats)
end
def total_mapviews
@total_mapviews ||= CartoDB::Visualization::Stats.new(self, nil).total_mapviews
end
def geometry_types
@geometry_types ||= user_table.geometry_types if user_table
end
def table_service
user_table.try(:service)
end
def has_read_permission?(user)
user && (owner?(user) || (permission && permission.user_has_read_permission?(user)))
end
alias :can_view_private_info? :has_read_permission?
def estimated_row_count
user_table.try(:row_count)
end
def actual_row_count
user_table.try(:actual_row_count)
end
def license_info
if !license.nil?
Carto::License.find(license.to_sym)
end
end
def can_be_cached?
!is_privacy_private?
end
def widgets
# Preload widgets for all layers
ActiveRecord::Associations::Preloader.new.preload(layers, :widgets)
layers.map(&:widgets).flatten
end
def analysis_widgets
widgets.select { |w| w.source_id.present? }
end
def attributions_from_derived_visualizations
related_canonical_visualizations.map(&:attributions).reject(&:blank?)
end
def delete_from_table
destroy if persisted?
end
def allowed_auth_tokens
entities = [user] + permission.entities_with_read_permission
entities.map(&:get_auth_token)
end
# - v2 (Editor): not private
# - v3 (Builder): not derived or not private, mapcapped
# This Ruby code should match the SQL code at Carto::VisualizationQueryBuilder#build section for @only_published.
def published?
!is_privacy_private? && (!builder? || !derived? || mapcapped?)
end
def builder?
version == VERSION_BUILDER
end
MAX_MAPCAPS_PER_VISUALIZATION = 1
def create_mapcap!
unless mapcaps.count < MAX_MAPCAPS_PER_VISUALIZATION
mapcaps.last.destroy
end
auto_generate_indices_for_all_layers
mapcaps.create!
end
def mapcapped?
latest_mapcap.present?
end
def latest_mapcap
mapcaps.first
end
def uses_builder_features?
builder? || analyses.any? || widgets.any? || mapcapped?
end
def add_source_analyses
return unless analyses.empty?
data_layers.each_with_index do |layer, index|
analysis = Carto::Analysis.source_analysis_for_layer(layer, index)
if analysis.save
layer.options[:source] = analysis.natural_id
layer.options[:letter] = analysis.natural_id.first
layer.save
else
CartoDB::Logger.warning(message: 'Couldn\'t add source analysis for layer', user: user, layer: layer)
end
end
end
def ids_json
layers_for_hash = layers.map do |layer|
{ layer_id: layer.id, widgets: layer.widgets.map(&:id) }
end
{
visualization_id: id,
map_id: map.id,
layers: layers_for_hash
}
end
def populate_ids(ids_json)
self.id = ids_json[:visualization_id]
map.id = ids_json[:map_id]
map.layers.each_with_index do |layer, index|
stored_layer_ids = ids_json[:layers][index]
stored_layer_id = stored_layer_ids[:layer_id]
layer.id = stored_layer_id
layer.maps = [map]
layer.widgets.each_with_index do |widget, widget_index|
widget.id = stored_layer_ids[:widgets][widget_index]
widget.layer_id = stored_layer_id
end
end
end
def for_presentation
mapcapped? ? latest_mapcap.regenerate_visualization : self
end
# TODO: we should make visualization privacy/security methods aware of mapcaps and make those
# deal with all the different the cases internally.
# See https://github.com/CartoDB/cartodb/pull/9678
def non_mapcapped
persisted? ? self : Carto::Visualization.find(id)
end
def mark_as_vizjson2
$tables_metadata.SADD(V2_VISUALIZATIONS_REDIS_KEY, id)
end
def uses_vizjson2?
$tables_metadata.SISMEMBER(V2_VISUALIZATIONS_REDIS_KEY, id) > 0
end
def open_in_editor?
!builder? && uses_vizjson2?
end
def can_be_automatically_migrated_to_v3?
overlays.builder_incompatible.none?
end
def state
super ? super : build_state
end
def can_be_private?
derived? ? user.try(:private_maps_enabled) : user.try(:private_tables_enabled)
end
# TODO: Backward compatibility with Sequel
def store_using_table(_table_privacy_changed = false)
store
end
def store
save!
self
end
def is_owner?(user)
user && user.id == user_id
end
def unlink_from(user_table)
layers_dependent_on(user_table).each do |layer|
Carto::Analysis.find_by_natural_id(id, layer.source_id).try(:destroy) if layer.source_id
layer.destroy
end
end
def invalidate_after_commit
# This marks this visualization as affected by this transaction, so AR will call its `after_commit` hook, which
# performs the actual invalidations. This takes this operation outside of the DB transaction to avoid long locks
if self.class.connection.open_transactions.zero?
raise 'invalidate_after_commit should be called within a transaction'
end
add_to_transaction
true
end
# TODO: Privacy manager compatibility, can be removed after removing ::UserTable
alias :invalidate_cache :invalidate_after_commit
def has_permission?(user, permission_type)
return false if user.viewer && permission_type == Carto::Permission::ACCESS_READWRITE
return is_owner?(user) if permission_id.nil?
is_owner?(user) || permission.permitted?(user, permission_type)
end
def ensure_valid_privacy
self.privacy = default_privacy if privacy.nil?
self.privacy = PRIVACY_PUBLIC unless can_be_private?
end
def default_privacy
can_be_private? ? PRIVACY_LINK : PRIVACY_PUBLIC
end
def privacy=(privacy)
super(privacy.try(:downcase))
end
def password=(value)
if value.present?
self.password_salt = ""
self.encrypted_password = Carto::Common::EncryptionService.encrypt(password: value,
secret: Cartodb.config[:password_secret])
end
end
def synced?
synchronization.present?
end
def dependent_visualizations
user_table.try(:dependent_visualizations) || []
end
def backup_visualization(category = Carto::VisualizationBackup::CATEGORY_VISUALIZATION)
return true if remote?
if map && !destroyed?
create_visualization_backup(visualization: self, category: category)
end
end
private
def remove_password
self.password_salt = nil
self.encrypted_password = nil
end
def perform_invalidations
invalidation_service.invalidate
rescue => e
# This is called at an after_commit. If there's any error, we won't notice
# but the after_commit chain stops.
# This was discovered during #12844, because "Updates changes even if named maps communication fails" test
# begun failing because Overlay#invalidate_cache invokes this method directly.
# We chose to log and continue to keep coherence on calls to this outside the callback.
CartoDB::Logger.error(message: "Error on visualization invalidation", exception: e, visualization_id: id)
end
def propagate_privacy_and_name_to
raise "Empty table sent to propagate_privacy_and_name_to()" unless table
propagate_privacy if privacy_changed? && canonical?
propagate_name if name_was != name # name_changed? returns false positives in changes like a->A->a (sanitization)
end
def propagate_privacy
table.reload
if privacy && table.privacy_text.casecmp(privacy) != 0 # privacy is different, case insensitive
CartoDB::TablePrivacyManager.new(table).set_from_visualization(self).update_cdb_tablemetadata
end
end
def propagate_name
# TODO: Move this to ::Table?
return if table.changing_name?
table.register_table_only = register_table_only
table.name = name
if table.name != name
# Sanitization. For example, spaces -> _
update_column(:name, table.name)
end
table.update(name: name)
if name_changed?
support_tables.rename(name_was, name, true, name_was)
end
self
rescue => exception
if name_changed? && !(exception.to_s =~ /relation.*does not exist/)
revert_name_change(name_was)
end
raise CartoDB::InvalidMember.new(exception.to_s)
end
def revert_name_change(previous_name)
self.name = previous_name
store
rescue => exception
raise CartoDB::InvalidMember.new(exception.to_s)
end
def propagate_attribution_change
table.propagate_attribution_change(attributions) if table && attributions_changed?
end
def support_tables
@support_tables ||= CartoDB::Visualization::SupportTables.new(
user.in_database, parent_id: id, parent_kind: kind, public_user_roles: user.db_service.public_user_roles
)
end
def auto_generate_indices_for_all_layers
user_tables = data_layers.map(&:user_tables).flatten.uniq
user_tables.each do |ut|
::Resque.enqueue(::Resque::UserDBJobs::UserDBMaintenance::AutoIndexTable, ut.id)
end
end
def set_random_id
# This should be done with a DB default
self.id ||= random_uuid
end
def set_default_permission
self.permission ||= Carto::Permission.create(owner: user, owner_username: user.username)
end
def has_private_tables?
!related_tables.index { |table| table.private? }.nil?
end
def sorted_related_table_names
mapped_table_names = related_tables.map { |table| "#{user.database_schema}.#{table.name}" }
mapped_table_names.sort { |i, j| i <=> j }.join(',')
end
def get_related_tables
return [] unless map
map.data_layers.flat_map(&:user_tables).uniq
end
def get_related_canonical_visualizations
get_related_visualizations_by_types([TYPE_CANONICAL])
end
def get_related_visualizations_by_types(types)
Carto::Visualization.where(map_id: related_tables.map(&:map_id), type: types).all
end
def has_write_permission?(user)
user && !user.viewer? && (owner?(user) || (permission && permission.user_has_write_permission?(user)))
end
def owner?(user)
user_id == user.id
end
def validate_password_presence
errors.add(:password, 'required for protected visualization') if password_protected? && !has_password?
end
def remove_password_if_unprotected
remove_password unless password_protected?
end
def validate_privacy_changes
return unless privacy_changed? && derived?
if is_privacy_private? && !user.try(:private_maps_enabled?)
errors.add(:privacy, 'cannot be set to private')
elsif (privacy_was == Carto::Visualization::PRIVACY_PRIVATE ||
(!privacy_was && privacy != Carto::Visualization::PRIVACY_PRIVATE)) &&
CartoDB::QuotaChecker.new(user).will_be_over_public_map_quota?
errors.add(:privacy, 'over account public map quota')
end
end
def validate_user_not_viewer
if user.viewer
errors.add(:user, 'cannot be viewer')
end
end
def invalidation_service
@invalidation_service ||= Carto::VisualizationInvalidationService.new(self)
end
class Watcher
# watcher:_orgid_:_vis_id_:_user_id_
KEY_FORMAT = "watcher:%s".freeze
# @params user Carto::User
# @params visualization Carto::Visualization
# @throws Carto::Visualization::WatcherError
def initialize(user, visualization, notification_ttl = nil)
raise WatcherError.new('User must belong to an organization') if user.organization.nil?
@user = user
@visualization = visualization
default_ttl = Cartodb.config[:watcher].present? ? Cartodb.config[:watcher].try("fetch", 'ttl', 60) : 60
@notification_ttl = notification_ttl.nil? ? default_ttl : notification_ttl
end
# Notifies that is editing the visualization
# NOTE: Expiration is handled internally by redis
def notify
key = KEY_FORMAT % @visualization.id
$tables_metadata.multi do
$tables_metadata.hset(key, @user.username, current_timestamp + @notification_ttl)
$tables_metadata.expire(key, @notification_ttl)
end
end
# Returns a list of usernames currently editing the visualization
def list
key = KEY_FORMAT % @visualization.id
users_expiry = $tables_metadata.hgetall(key)
now = current_timestamp
users_expiry.select { |_, expiry| expiry.to_i > now }.keys
end
private
def current_timestamp
Time.now.getutc.to_i
end
end
class WatcherError < CartoDB::BaseCartoDBError; end
class AlreadyLikedError < StandardError; end
class UnauthorizedLikeError < StandardError; end
end
+21
View File
@@ -0,0 +1,21 @@
require 'active_record'
module Carto
class VisualizationBackup < ActiveRecord::Base
# @param Uuid id
# @param Uuid user_id
# @param Uuid visualization_id
# @param DateTime created_at (CURRENT_TIMESTAMP)
# @param String category
# @param JSON export
attr_accessible :id, :user_id, :visualization_id, :category, :export
validates :user_id, :visualization_id, :category, :export, presence: true
serialize :export, CartoJsonSymbolizerSerializer
validates :export, carto_json_symbolizer: true
CATEGORY_VISUALIZATION = 'visualization'.freeze
CATEGORY_LAYER = 'layer'.freeze
end
end
+114
View File
@@ -0,0 +1,114 @@
require 'uri'
require 'fileutils'
require 'active_record'
require_relative '../../services/carto/visualizations_export_service_2'
require_relative '../../../services/sql-api/sql_api'
require_relative './visualization'
require_dependency 'carto/visualization_exporter'
module Carto
class VisualizationExport < ::ActiveRecord::Base
include VisualizationExporter
include Carto::VisualizationsExportService2Validator
belongs_to :visualization, class_name: Carto::Visualization
belongs_to :user, class_name: Carto::User
belongs_to :log, class_name: Carto::Log
validate :visualization_exportable_by_user?, if: :new_record?
validate :user_tables_ids_valid?, if: :new_record?
validate :valid_visualization_type?, if: :new_record?
STATE_PENDING = 'pending'.freeze
STATE_EXPORTING = 'exporting'.freeze
STATE_UPLOADING = 'uploading'.freeze
STATE_COMPLETE = 'complete'.freeze
STATE_FAILURE = 'failure'.freeze
def exporter_folder
ensure_folder(exporter_config['exporter_temporal_folder'] || DEFAULT_EXPORTER_TMP_FOLDER)
end
def run_export!(file_upload_helper: default_file_upload_helper, download_path: nil)
logger = Carto::Log.new(type: 'visualization_export')
logger.append('Exporting')
update_attributes(state: STATE_EXPORTING, log: logger)
filepath = export(visualization, user, user_tables_ids: user_tables_ids)
logger.append('Uploading')
update_attributes(state: STATE_UPLOADING, file: filepath)
file = CartoDB::FileUploadFile.new(filepath)
s3_config = Cartodb.config[:exporter]['s3'].deep_dup || {}
plain_name = header_encode(file.original_filename.force_encoding('iso-8859-1'))
utf_name = header_encode(file.original_filename)
s3_config['content-disposition'] = %{attachment;filename="#{plain_name}";filename*=utf-8''#{utf_name}}
results = file_upload_helper.upload_file_to_storage(
file_param: file,
s3_config: s3_config,
allow_spaces: true,
force_s3_upload: true
)
if results[:file_uri].present?
logger.append("By file_upload_helper: #{results[:file_uri]}, #{filepath}, (ignored: #{results[:file_path]})")
export_url = results[:file_uri]
export_file = filepath
else
logger.append("Ad-hoc export download: #{results[:file_path]} (ignored: #{filepath})")
export_url = download_path
export_file = results[:file_path]
end
logger.append('Deleting tmp file')
FileUtils.rm(filepath)
state = export_url.present? && export_file.present? ? STATE_COMPLETE : STATE_FAILURE
logger.append("Finishing. State: #{state}. File: #{export_file}. URL: #{url}")
update_attributes(state: state, file: export_file, url: export_url)
true
rescue => e
logger.append_exception('Exporting', exception: e)
CartoDB::Logger.error(
exception: e,
message: "Visualization export error",
user: user,
visualization_id: visualization.id,
visualization_export_id: id)
update_attributes(state: STATE_FAILURE)
false
end
private
def header_encode(name)
URI.encode(name, /[^#{URI::PATTERN::UNRESERVED}]/)
end
def default_file_upload_helper
CartoDB::FileUpload.new(Cartodb.get_config(:exporter, "uploads_path"))
end
def visualization_exportable_by_user?
errors.add(:user, 'Must be able to view the visualization') unless visualization.is_viewable_by_user?(user)
end
def user_tables_ids_valid?
return unless user_tables_ids.present?
related_tables_ids = visualization.related_tables_readable_by(user).map(&:id)
not_valid = user_tables_ids.split(',').select { |user_table_id| !related_tables_ids.include?(user_table_id) }
not_valid.each do |user_table_id|
errors.add(:user_tables_ids, "User table #{user_table_id} is not related to visualization #{visualization.id}")
end
end
def valid_visualization_type?
errors.add(:visualization, 'Only derived visualizations can be exported') unless visualization.derived?
end
end
end
+101
View File
@@ -0,0 +1,101 @@
require_relative './carto_json_serializer'
class Carto::Widget < ActiveRecord::Base
# INFO: disable ActiveRecord inheritance column
self.inheritance_column = :_type
serialize :options, ::Carto::CartoJsonSymbolizerSerializer
validates :options, carto_json_symbolizer: true
serialize :style, ::Carto::CartoJsonSymbolizerSerializer
validates :style, carto_json_symbolizer: true
belongs_to :layer, class_name: Carto::Layer
before_validation :set_style_if_nil
validates :layer, :order, :type, :options, :source_id, presence: true
validates :source_id, presence: true
validate :validate_user_not_viewer
validate :valid_source_id
before_destroy :validate_user_not_viewer
after_save :notify_maps_change, :auto_generate_indices
after_destroy :notify_maps_change, :auto_generate_indices
def self.from_visualization_id(visualization_id)
visualization = Carto::Visualization.where(id: visualization_id).first
visualization.nil? ? [] : visualization.widgets.flatten
end
def self.visualization_analysis_widgets(visualization_id)
visualization = Carto::Visualization.where(id: visualization_id).first
visualization.nil? ? [] : visualization.analysis_widgets
end
def self.layer_widgets(layer_id)
Carto::Widget.where(layer_id: layer_id).where(source_id: nil).all
end
def belongs_to_map?(map_id)
!layer.nil? && !layer.maps.nil? && layer.maps.map(&:id).include?(map_id)
end
def writable_by_user?(user)
return false unless layer
return false unless layer.maps
layer.maps { |l| l.writable_by_user?(user) }.select { |writable| !writable }.empty?
end
def visualization
layer.visualization
end
def analysis_node
return nil unless source_id
analysis_nodes = visualization.analyses.map(&:analysis_node)
analysis_nodes.lazy.map { |node| node.find_by_id(source_id) }.find { |node| node }
end
def column
options[:column]
end
private
def set_style_if_nil
self.style ||= {}
end
def notify_maps_change
layer.maps.each do |m|
map = Map.where(id: m.id).first
map.notify_map_change if map
end
end
def auto_generate_indices
layer.user_tables.each do |ut|
::Resque.enqueue(::Resque::UserDBJobs::UserDBMaintenance::AutoIndexTable, ut.id)
end
end
def validate_user_not_viewer
if user && user.viewer
errors.add(:layer, "Viewer users can't edit widgets")
false
end
end
def valid_source_id
unless source_id.is_a?(String) && source_id =~ /^\w*$/
errors.add(:source_id, "Source id must be a string")
end
end
def user
@user ||= layer.nil? ? nil : layer.user
end
end
+73
View File
@@ -0,0 +1,73 @@
require 'oauth'
class DomainPatcherRequestProxy < OAuth::RequestProxy::RackRequest
def uri
super.sub('carto.com', 'cartodb.com')
end
end
class ClientApplication < Sequel::Model
extend CartoDB::ConfigUtils
one_to_many :tokens, :class_name => :OauthToken
one_to_many :access_tokens
one_to_many :oauth_tokens
plugin :association_dependencies
add_association_dependencies :oauth_tokens => :destroy
attr_accessor :token_callback_url
def self.find_token(token_key)
return nil if token_key.nil?
token = ::RequestToken.first(:token => token_key) || ::AccessToken.first(:token => token_key)
token && token.authorized? ? token : nil
end
def self.find_by_key(key)
first(:key => key)
end
def user
::User[user_id]
end
def user=(value)
set(:user_id => value.id)
end
def self.verify_request(request, options = {}, &block)
value = OAuth::Signature.build(request, options, &block).verify
if !value && !cartodb_com_hosted?
# Validation failed, try to see if it has been signed for cartodb.com
cartodb_request = DomainPatcherRequestProxy.new(request, options)
value = OAuth::Signature.build(cartodb_request, options, &block).verify
end
value
rescue OAuth::Signature::UnknownSignatureMethod
false
end
def oauth_server
@oauth_server ||= OAuth::Server.new("http://your.site")
end
def credentials
@oauth_client ||= OAuth::Consumer.new(key, secret)
end
# If your application requires passing in extra parameters handle it here
def create_request_token(params={})
RequestToken.create :client_application => self, :callback_url=>self.token_callback_url
end
def before_create
self.key = OAuth::Helper.generate_key(40)[0,40]
self.secret = OAuth::Helper.generate_key(40)[0,40]
self.created_at = Time.now
end
def before_save
self.updated_at = Time.now
end
end
+62
View File
@@ -0,0 +1,62 @@
require_dependency 'carto/bounding_box_service'
module Carto::MapBoundaries
MAXIMUM_ZOOM = 18
def set_default_boundaries!
bounds = get_map_bounds
bounds ? set_viewport_from_bounds(bounds) : set_default_viewport
save
rescue => exception
CartoDB::Logger.error(exception: exception, message: 'Error setting default bounds')
end
def recalculate_bounds!
set_boundaries(get_map_bounds || Carto::BoundingBoxUtils::DEFAULT_BOUNDS)
save
end
private
def set_viewport_from_bounds(bounds)
set_boundaries(bounds)
recenter_using_bounds(bounds)
recalculate_zoom(bounds)
end
def set_default_viewport
set_boundaries(Carto::BoundingBoxUtils::DEFAULT_BOUNDS)
self.center = Carto::Map::DEFAULT_OPTIONS[:center]
self.zoom = Carto::Map::DEFAULT_OPTIONS[:zoom]
end
def set_boundaries(bounds)
# switch to (lat,lon) for the frontend
self.view_bounds_ne = "[#{bounds[:maxy]}, #{bounds[:maxx]}]"
self.view_bounds_sw = "[#{bounds[:miny]}, #{bounds[:minx]}]"
end
def recenter_using_bounds(bounds)
x = (bounds[:maxx] + bounds[:minx]) / 2
y = (bounds[:maxy] + bounds[:miny]) / 2
self.center = "[#{y},#{x}]"
end
def recalculate_zoom(bounds)
latitude_size = bounds[:maxy] - bounds[:miny]
longitude_size = bounds[:maxx] - bounds[:minx]
# Don't touch zoom if the table is empty or has no bounds
return if longitude_size.zero? && latitude_size.zero?
zoom = -1 * ((Math.log([longitude_size, latitude_size].max) / Math.log(2)) - (Math.log(360) / Math.log(2)))
zoom = [[zoom.round, 1].max, MAXIMUM_ZOOM].min
self.zoom = zoom
end
def get_map_bounds
# (lon,lat) as comes out from postgis
Carto::BoundingBoxService.new(user, table_name).table_bounds
end
end
+25
View File
@@ -0,0 +1,25 @@
module Carto::OrganizationSoftLimits
def soft_geocoding_limit?
owner.try(:soft_geocoding_limit)
end
def soft_twitter_datasource_limit?
owner.try(:soft_twitter_datasource_limit)
end
def soft_here_isolines_limit?
owner.try(:soft_here_isolines_limit)
end
def soft_obs_snapshot_limit?
owner.try(:soft_obs_snapshot_limit)
end
def soft_obs_general_limit?
owner.try(:soft_obs_general_limit)
end
def soft_mapzen_routing_limit?
owner.try(:soft_mapzen_routing_limit)
end
end
+112
View File
@@ -0,0 +1,112 @@
require_relative '../../lib/carto/http/client'
require_relative '../../services/sql-api/sql_api'
require_relative '../helpers/common_data_redis_cache'
require_relative '../../lib/carto_api/json_client'
class CommonData
def initialize(visualizations_api_url)
@datasets = nil
@http_client = Carto::Http::Client.get('common_data', log_requests: true)
@visualizations_api_url = visualizations_api_url
end
def datasets
return @datasets unless @datasets.nil?
@datasets = []
if is_enabled?
is_https_request = (@visualizations_api_url =~ /^https:\/\//)
cached_data = redis_cache.get(is_https_request)
if cached_data.nil?
client = CartoAPI::JsonClient.new(http_client_tag: 'common_data')
begin
response = client.get_visualizations_v1_from_url(@visualizations_api_url)
if response.code == 200
@datasets = get_datasets(response.response_body)
redis_cache.set(is_https_request, response.headers, response.response_body)
else
CartoDB::Logger.warning(message: "Error retrieving common data datasets", response: response.to_s)
end
rescue StandardError => e
CartoDB::Logger.error(exception: e)
end
else
@datasets = get_datasets(cached_data[:body])
end
CartoDB::Logger.error(message: 'common-data empty', url: @visualizations_api_url) if @datasets.empty?
end
@datasets
end
def is_enabled?
!config('username').nil?
end
private
def get_datasets(json)
begin
rows = JSON.parse(json).fetch('visualizations', [])
rescue StandardError => e
CartoDB::Logger.error(exception: e)
rows = []
end
datasets = []
rows.each do |row|
# Common-data's meta tables starts with meta_ and we want to avoid them
next if row["name"] =~ /meta_/
datasets << get_common_data_from_visualization(row)
end
datasets
end
def get_common_data_from_visualization(row)
{
"name" => row["name"],
"display_name" => row["display_name"].nil? ? row["name"] : row["display_name"],
"tabname" => row["name"],
"attributions" => row["attributions"],
"description" => row["description"],
"source" => row["source"],
"license" => row["license"],
"tags" => row["tags"],
"geometry_types" => %Q[{#{row["table"]["geometry_types"].join(',')}}],
"rows" => row["table"]["row_count"],
"size" => row["table"]["size"],
"url" => export_url(row["name"]),
"created_at" => row["created_at"],
"updated_at" => row["updated_at"]
}
end
def export_url(table_name)
query = %Q[select * from "#{table_name}"]
sql_api_url(query, table_name, config('format', 'gpkg'))
end
def sql_api_url(query, filename, format)
common_data_base_url = config('base_url')
CartoDB::SQLApi.new({
base_url: common_data_base_url,
protocol: 'https',
username: config('username'),
}).url(query, format, filename)
end
def config(key, default=nil)
if Cartodb.config[:common_data].present?
Cartodb.config[:common_data][key].present? ? Cartodb.config[:common_data][key] : default
else
default
end
end
def redis_cache
@redis_cache ||= CommonDataRedisCache.new
end
end
+25
View File
@@ -0,0 +1,25 @@
class CommonDataSingleton
include Singleton
def initialize
@common_data = nil
@last_usage = Time.now
end
def datasets(visualizations_api_url)
now = Time.now
if @common_data.nil? || (now - @last_usage > (cache_ttl * 60))
@common_data = CommonData.new(visualizations_api_url)
@last_usage = now
end
@common_data.datasets
end
def cache_ttl
ttl = 0
if Cartodb.config[:common_data].present?
ttl = Cartodb.config[:common_data]['cache_ttl'] || ttl
end
ttl
end
end
@@ -0,0 +1,208 @@
module Concerns
module CartodbCentralSynchronizable
# This validation can't be added to the model because if a user creation begins at Central we can't know if user is the same or existing
def validate_credentials_not_taken_in_central
return true unless self.is_a?(::User)
return true unless Cartodb::Central.sync_data_with_cartodb_central?
central_client = Cartodb::Central.new
errors.add(:username, "Username taken") if central_client.get_user(self.username)['username'] == self.username
errors.add(:email, "Email taken") if central_client.get_user(self.email)['email'] == self.email
errors.empty?
end
def create_in_central
return true unless sync_data_with_cartodb_central?
if self.is_a?(::User)
if organization.present?
cartodb_central_client.create_organization_user(organization.name, allowed_attributes_to_central(:create))
else
CartoDB.notify_debug("User creations at box without organization are not notified to Central", user: self)
end
elsif self.is_a?(Organization)
raise "Can't create organizations in editor"
end
return true
end
def update_in_central
return true unless sync_data_with_cartodb_central?
if self.is_a?(::User)
if organization.present?
cartodb_central_client.update_organization_user(organization.name, username, allowed_attributes_to_central(:update))
else
cartodb_central_client.update_user(username, allowed_attributes_to_central(:update))
end
elsif self.is_a?(Organization)
cartodb_central_client.update_organization(name, allowed_attributes_to_central(:update))
end
return true
end
def delete_in_central
return true unless sync_data_with_cartodb_central?
if self.is_a?(::User)
if organization.nil?
cartodb_central_client.delete_user(self.username)
else
if self.organization.owner && self.organization.owner != self
cartodb_central_client.delete_organization_user(organization.name, username)
else
raise "Can't destroy the organization owner"
end
end
elsif is_a?(Organization)
# See Organization#destroy_cascade
raise "Delete organizations is not allowed" if Carto::Configuration.saas?
cartodb_central_client.delete_organization(name)
end
return true
end
def allowed_attributes_from_central(action)
if is_a?(Organization)
case action
when :create
%i(name seats viewer_seats quota_in_bytes display_name description website
discus_shortname twitter_username geocoding_quota map_view_quota
geocoding_block_price map_view_block_price
twitter_datasource_enabled twitter_datasource_block_size
twitter_datasource_block_price twitter_datasource_quota
google_maps_key google_maps_private_key auth_username_password_enabled
auth_google_enabled here_isolines_quota here_isolines_block_price
obs_snapshot_quota obs_snapshot_block_price obs_general_quota
obs_general_block_price salesforce_datasource_enabled geocoder_provider
isolines_provider routing_provider engine_enabled builder_enabled
mapzen_routing_quota mapzen_routing_block_price no_map_logo auth_github_enabled
password_expiration_in_d)
when :update
%i(seats viewer_seats quota_in_bytes display_name description website
discus_shortname twitter_username geocoding_quota map_view_quota
geocoding_block_price map_view_block_price
twitter_datasource_enabled twitter_datasource_block_size
twitter_datasource_block_price twitter_datasource_quota
google_maps_key google_maps_private_key auth_username_password_enabled
auth_google_enabled here_isolines_quota here_isolines_block_price
obs_snapshot_quota obs_snapshot_block_price obs_general_quota
obs_general_block_price salesforce_datasource_enabled geocoder_provider
isolines_provider routing_provider engine_enabled builder_enabled
mapzen_routing_quota mapzen_routing_block_price no_map_logo auth_github_enabled
password_expiration_in_d)
end
elsif is_a?(::User)
%i(account_type admin org_admin crypted_password database_host
database_timeout description disqus_shortname available_for_hire email
geocoding_block_price geocoding_quota map_view_block_price
map_view_quota max_layers
max_import_file_size max_import_table_row_count max_concurrent_import_count
name last_name notification organization_id
period_end_date private_tables_enabled quota_in_bytes
sync_tables_enabled table_quota public_map_quota regular_api_key_quota twitter_username upgraded_at
user_timeout username website soft_geocoding_limit
batch_queries_statement_timeout
twitter_datasource_enabled twitter_datasource_block_size
twitter_datasource_block_price twitter_datasource_quota
soft_twitter_datasource_limit
google_sign_in last_password_change_date github_user_id
google_maps_key google_maps_private_key
private_maps_enabled here_isolines_quota here_isolines_block_price soft_here_isolines_limit
obs_snapshot_quota obs_snapshot_block_price soft_obs_snapshot_limit
obs_general_quota obs_general_block_price soft_obs_general_limit
mobile_xamarin mobile_custom_watermark mobile_offline_maps
mobile_gis_extension mobile_max_open_users mobile_max_private_users
salesforce_datasource_enabled viewer geocoder_provider
isolines_provider routing_provider engine_enabled builder_enabled
mapzen_routing_quota mapzen_routing_block_price soft_mapzen_routing_limit no_map_logo
user_render_timeout database_render_timeout state industry company phone job_role
password_reset_token password_reset_sent_at maintenance_mode company_employees use_case)
end
end
def allowed_attributes_to_central(action)
if is_a?(Organization)
case action
when :create
raise "Can't create organizations from editor"
when :update
allowed_attributes = %i(seats viewer_seats display_name description website discus_shortname twitter_username
auth_username_password_enabled auth_google_enabled password_expiration_in_d)
values.slice(*allowed_attributes)
end
elsif is_a?(::User)
allowed_attributes = %i(
account_type admin org_admin crypted_password database_host database_timeout description disqus_shortname
available_for_hire email geocoding_block_price geocoding_quota map_view_block_price map_view_quota max_layers
max_import_file_size max_import_table_row_count max_concurrent_import_count name last_name notification
organization_id period_end_date private_tables_enabled quota_in_bytes sync_tables_enabled table_quota
public_map_quota regular_api_key_quota twitter_username upgraded_at user_timeout username website
soft_geocoding_limit twitter_datasource_enabled soft_twitter_datasource_limit google_sign_in
last_password_change_date github_user_id google_maps_key google_maps_private_key here_isolines_quota
here_isolines_block_price soft_here_isolines_limit obs_snapshot_quota obs_snapshot_block_price
soft_obs_snapshot_limit obs_general_quota obs_general_block_price soft_obs_general_limit viewer
geocoder_provider isolines_provider routing_provider builder_enabled engine_enabled mapzen_routing_quota
mapzen_routing_block_price soft_mapzen_routing_limit industry company phone job_role password_reset_token
password_reset_sent_at company_employees use_case
)
attrs = values.slice(*allowed_attributes)
attrs[:multifactor_authentication_status] = multifactor_authentication_status
case action
when :create
attrs[:remote_user_id] = id
attrs.delete(:organization_id)
return attrs
when :update
attrs[:batch_queries_statement_timeout] = batch_queries_statement_timeout
attrs
end
end
end
def set_fields_from_central(params, action)
return self unless params.present? && action.present?
self.set(params.slice(*allowed_attributes_from_central(action)))
if self.is_a?(::User) && params.has_key?(:password)
self.password = self.password_confirmation = params[:password]
end
self
end
def set_relationships_from_central(params)
if params.present? && params.has_key?(:feature_flags)
update_feature_flags(params[:feature_flags])
end
end
def sync_data_with_cartodb_central?
Cartodb::Central.sync_data_with_cartodb_central?
end
def cartodb_central_client
@cartodb_central_client ||= Cartodb::Central.new
end
def update_feature_flags(feature_flag_ids)
feature_flag_ids = feature_flag_ids.compact.reject(&:empty?)
current_feature_flag_ids = self.feature_flags_user.map { | ffu | ffu.feature_flag_id }
to_add = feature_flag_ids - current_feature_flag_ids
to_remove = current_feature_flag_ids - feature_flag_ids
removed_feature_flags_user = self.feature_flags_user.select { | ffu | to_remove.include?(ffu.feature_flag_id) }
removed_feature_flags_user.map do | rffu |
rffu.destroy
end
to_add.map { | ff_id |
ffu = FeatureFlagsUser.new
ffu.user_id = self.id
ffu.feature_flag_id = ff_id
ffu.save
}.each { |ffu|
self.feature_flags_user << ffu
}
end
end
end
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
require_relative './data_import'
require_relative './visualization/external_source'
class ExternalDataImport < Sequel::Model
many_to_one :data_import
many_to_one :external_source, class: CartoDB::Visualization::ExternalSource
def initialize(data_import_id, external_source_id, synchronization_id = nil)
super({
external_source_id: external_source_id,
data_import_id: data_import_id,
synchronization_id: synchronization_id
})
end
def self.by_user_id(user_id)
user_data_imports = DataImport.where(user_id: user_id)
ExternalDataImport.where(data_import_id: user_data_imports.select(:id)).all
end
def synchronization
CartoDB::Synchronization::Member.new(id: self.synchronization_id).fetch
rescue KeyError
nil
end
end
+16
View File
@@ -0,0 +1,16 @@
# Feature flags are synchronized through API, so there's no need for validation
class FeatureFlag < Sequel::Model
include CartoDB::MiniSequel
one_to_many :feature_flags_user
plugin :association_dependencies
add_association_dependencies :feature_flags_user => :destroy
# @param name String
# @param restricted boolean
def self.allowed?(name)
!!where(name: name, restricted: false).first
end
end
+8
View File
@@ -0,0 +1,8 @@
class FeatureFlagsUser < Sequel::Model
include CartoDB::MiniSequel
many_to_one :feature_flag
many_to_one :user
end
+396
View File
@@ -0,0 +1,396 @@
require 'socket'
require_relative '../../services/table-geocoder/lib/table_geocoder_factory'
require_relative '../../services/table-geocoder/lib/exceptions'
require_relative '../../services/table-geocoder/lib/mail_geocoder'
require_relative '../../services/geocoder/lib/geocoder_config'
require_relative '../../lib/cartodb/metrics'
require_relative 'log'
require_relative '../../lib/cartodb/stats/geocoding'
require_dependency 'carto/configuration'
class Geocoding < Sequel::Model
include Carto::Configuration
ALLOWED_KINDS = %w(admin0 admin1 namedplace postalcode high-resolution ipaddress)
PUBLIC_ATTRIBUTES = [:id, :table_id, :table_name, :state, :kind, :country_code, :region_code, :formatter,
:geocoder_type, :geometry_type, :error, :processed_rows, :cache_hits, :processable_rows,
:real_rows, :price, :used_credits, :remaining_quota, :country_column, :region_column,
:data_import_id, :error_code]
# Characters in the following Unicode categories: Letter, Mark, Number and Connector_Punctuation,
# plus spaces and single quotes
SANITIZED_FORMATTER_REGEXP = /\A[[[:word:]]\s\,\']*\z/
MIN_GEOCODING_TIME_TO_NOTIFY = 3 * 60 #seconds
many_to_one :user
many_to_one :user_table, :key => :table_id
many_to_one :automatic_geocoding
many_to_one :data_import
attr_reader :table_geocoder
attr_reader :started_at, :finished_at
attr_reader :log
def self.get_not_aggregated_user_geocoding_calls(db, user_id, date_from, date_to)
geocoding_calls_sql = "SELECT date(created_at), sum(processed_rows) as processed_rows, " \
"sum(cache_hits) as cache_hits FROM geocodings WHERE kind = 'high-resolution' " \
"AND user_id = ? AND geocodings.created_at >= ? and geocodings.created_at <= ?" \
"GROUP BY date(created_at) ORDER BY date(created_at) DESC"
db.fetch(geocoding_calls_sql, user_id, date_from, date_to + 1.days).all
end
def public_values
Hash[PUBLIC_ATTRIBUTES.map{ |k| [k, (self.send(k) rescue self[k].to_s)] }]
end
def validate
super
validates_presence :formatter
# INFO: since we want to register geocodings during importing we can't register the table
# validates_presence :table_id
validates_includes ALLOWED_KINDS, :kind
end
def before_save
super
self.updated_at = Time.now
# cancel if state == 'cancelled'
end
def geocoding_logger
@@geocoding_logger ||= CartoDB.unformatted_logger(log_file_path('geocodings.log'))
end
def error
additional_info = Carto::GeocoderErrors.additional_info(error_code)
if additional_info
{ title: additional_info.title, description: additional_info.what_about }
else
{ title: 'Geocoding error', description: '' }
end
end
# The table geocoder is meant to be instantiated just once.
# Memoize the table geocoder or nil if it couldn't be instantiated
def table_geocoder
if !defined?(@table_geocoder)
begin
@table_geocoder = Carto::TableGeocoderFactory.get(user,
self,
CartoDB::GeocoderConfig.instance.get,
table_service,
original_formatter: formatter,
formatter: sanitize_formatter,
remote_id: remote_id,
countries: country_code,
regions: region_code,
geometry_type: geometry_type,
kind: kind,
max_rows: max_geocodable_rows,
country_column: country_column,
region_column: region_column,
log: log)
rescue => e
@table_geocoder = nil
raise e
end
else
@table_geocoder
end
end
# INFO: table_geocoder method is very coupled to table model, and we want to use this model during imports, without table yet.
# this method allows to inject the dependency to the geocoder
# TODO: refactor geocoder instantiation out of this class
def force_geocoder geocoder
@table_geocoder = geocoder
end
def cancel
log.append_and_store "Cancelling job because of user request"
table_geocoder.cancel
rescue => e
log.append_and_store "Error trying to cancel a job because of user request: #{e.inspect}"
count ||= 1
retry unless (count = count.next) > 5
CartoDB::notify_exception(e, user: user)
end
# INFO: this method shall always be called from a queue processor
def run!
@started_at = Time.now
log.append_and_store "Running geocoding job on server #{Socket.gethostname} with PID: #{Process.pid}"
if self.force_all_rows == true
table_geocoder.reset_cartodb_georef_status
else
table_geocoder.mark_rows_to_geocode
end
store_geocoder_info(Process.pid)
processable_rows = self.class.processable_rows(table_service)
if processable_rows == 0
self.update(state: 'finished', real_rows: 0, used_credits: 0, processed_rows: 0, cache_hits: 0)
self.report
return
end
rows_geocoded_before = table_service.owner.in_database.select.from(table_service.sequel_qualified_table_name).where(cartodb_georef_status: true).count rescue 0
self.run_geocoding!(processable_rows, rows_geocoded_before)
rescue => e
# state == nil probably means it has failed even before run_geocoding begun
handle_geocoding_failure(e, rows_geocoded_before || 0) if state == nil
log.append_and_store "Unexpected exception: #{e}"
log.append_and_store e.backtrace
CartoDB.notify_exception(e)
raise e
ensure
log.store # Make sure the log is stored in DB
user.db_service.reset_pooled_connections
end
def run_geocoding!(processable_rows, rows_geocoded_before = 0)
log.append_and_store "run_geocoding!()"
self.update state: 'started', processable_rows: processable_rows
@started_at ||= Time.now
# INFO: this is where the real stuff is done
table_geocoder.run
# Sync state to avoid overwrites for state and log
refresh
return false if cancelled?
self.update(table_geocoder.update_geocoding_status)
# TODO better exception handling here
raise 'Geocoding failed' if state == 'failed'
raise 'Geocoding timed out' if state == 'timeout'
handle_geocoding_success(rows_geocoded_before)
rescue => e
handle_geocoding_failure(e, rows_geocoded_before)
end
def report(error = nil)
payload = metrics_payload(error)
CartoDB::Metrics.new.report(:geocoding, payload)
payload.delete_if {|k,v| %w{distinct_id email table_id}.include?(k.to_s)}
geocoding_logger.info(payload.to_json)
end
def self.processable_rows(table_service, force_all_rows=false)
dataset = table_service.owner.in_database.select.from(table_service.sequel_qualified_table_name)
if !force_all_rows && dataset.columns.include?(:cartodb_georef_status)
dataset = dataset.exclude(cartodb_georef_status: true)
end
dataset.count
end # self.processable_rows
def calculate_used_credits
return 0 unless kind == 'high-resolution' && geocoder_type == 'heremaps'
total_rows = processed_rows.to_i + cache_hits.to_i
geocoding_quota = user.organization.present? ? user.organization.geocoding_quota.to_i : user.geocoding_quota
used_geocoding_calls = user.organization_user? ? user.organization.get_geocoding_calls : user.get_geocoding_calls
# ::User#get_geocoding_calls includes this geocoding run, so we discount it
remaining_quota = geocoding_quota + total_rows - used_geocoding_calls
remaining_quota = (remaining_quota > 0 ? remaining_quota : 0)
used_credits = total_rows - remaining_quota
(used_credits > 0 ? used_credits : 0)
end # calculate_used_credits
def price
return 0 unless used_credits.to_i > 0
(user.geocoding_block_price * used_credits) / ::User::GEOCODING_BLOCK_SIZE.to_f
end # price
def cost
return 0 unless kind == 'high-resolution'
processed_rows.to_i * CartoDB::GeocoderConfig.instance.get['cost_per_hit_in_cents'] rescue 0
end
def remaining_quota
user.remaining_geocoding_quota
end # remaining_quota
def sanitize_formatter
translated_formatter = translate_formatter
if translated_formatter =~ SANITIZED_FORMATTER_REGEXP
translated_formatter
else
CartoDB::Logger.warning(message: %{Incorrect formatter string received: "#{formatter}"}, user: user)
''
end
end
# {field}, SPAIN => field, ', SPAIN'
def translate_formatter
translated = formatter.to_s.squish
translated.prepend("'") unless /^\{/.match(formatter)
translated << "'" unless /\}$/.match(formatter)
translated.gsub(/^\{/, '')
.gsub(/\}$/, '')
.gsub(/\}/, ", '")
.gsub(/\{/, "', ")
end # translate_formatter
def max_geocodable_rows
# This is an arbitrary number, previously set to 1M
return 50000 if user.soft_geocoding_limit?
user.remaining_geocoding_quota
end
def successful_rows
real_rows.to_i
end
def failed_rows
processable_rows.to_i - real_rows.to_i
end
def metrics_payload(exception = nil)
payload = {
created_at: created_at,
distinct_id: user.username,
username: user.username,
email: user.email,
id: id,
table_id: table_id,
kind: kind,
country_code: country_code,
formatter: formatter,
geocoder_type: geocoder_type,
geometry_type: geometry_type,
processed_rows: processed_rows,
cache_hits: cache_hits,
processable_rows: processable_rows,
real_rows: real_rows,
successful_rows: successful_rows,
failed_rows: failed_rows,
price: price,
cost: cost,
used_credits: used_credits,
remaining_quota: remaining_quota,
data_import_id: data_import_id,
batched: self.batched
}
if state == 'finished' && exception.nil?
payload.merge!(
success: true
)
elsif exception.present?
payload.merge!(
success: false,
error: exception.message,
backtrace: exception.backtrace
)
else
payload.merge!(
success: false,
error: error
)
end
payload.merge!(queue_time: started_at - created_at) if started_at && created_at
payload.merge!(processing_time: finished_at - started_at) if started_at && finished_at
payload
end
def log
@log ||= instantiate_log
end
def cancelled?
state == 'cancelled'
end
def timeout?
state == 'timeout'
end
private
def table_service
@table_service ||= Table.new(user_table: user_table) if user_table.present?
end
def instantiate_log
if self.log_id
@log = CartoDB::Log.where(id: log_id).first
else
@log = CartoDB::Log.new({
type: CartoDB::Log::TYPE_GEOCODING,
user_id: user.id
})
@log.store
self.log_id = @log.id
self.save
end
@log
end
def handle_geocoding_success(rows_geocoded_before)
self.update(cache_hits: table_geocoder.cache.hits) if table_geocoder.respond_to?(:cache)
stats_aggregator = CartoDB::Stats::Geocoding.instance
stats_aggregator.gauge("requests", "+#{self.processed_rows}") rescue nil
stats_aggregator.gauge("cache_hits", "+#{self.cache_hits}") rescue nil
@finished_at = Time.now
self.batched = table_geocoder.used_batch_request?
self.geocoder_type = table_geocoder.name
geocoded_rows = total_geocoded_rows(rows_geocoded_before)
self.update(state: 'finished', real_rows: geocoded_rows, used_credits: calculate_used_credits)
send_report_mail(state, self.table_name, nil, self.processable_rows, geocoded_rows)
log.append_and_store "Geocoding finished"
# In the import table_service could be nil
if !table_service.nil?
# To store the bbox in visualizations
table_service.update_bounding_box
end
self.report
end
def handle_geocoding_failure(raised_exception, rows_geocoded_before)
@finished_at = Time.now
self.batched = table_geocoder.nil? ? false : table_geocoder.used_batch_request?
self.geocoder_type = table_geocoder.nil? ? '' : table_geocoder.name
if raised_exception.is_a? Carto::GeocoderErrors::GeocoderBaseError
self.error_code = raised_exception.class.additional_info.error_code
end
self.update(state: 'failed', processed_rows: 0, cache_hits: 0)
CartoDB::notify_exception(raised_exception, user: user)
geocoded_rows = total_geocoded_rows(rows_geocoded_before)
send_report_mail(state, self.table_name, error_code, self.processable_rows, geocoded_rows)
log.append_and_store "Unexpected exception: #{raised_exception}"
log.append_and_store raised_exception.backtrace
self.report(raised_exception)
end
def total_geocoded_rows(rows_geocoded_before)
rows_geocoded_after = table_service.owner.in_database.select.from(table_service.sequel_qualified_table_name).where('cartodb_georef_status is true and the_geom is not null').count rescue 0
rows_geocoded_after - rows_geocoded_before
end
# Used in the run! method
def send_report_mail(state, table_name, error_code=nil, processable_rows, number_geocoded_rows)
geocoding_time = @finished_at - @started_at
CartoDB::Geocoder::MailNotifier.new(user.id, state, table_name, error_code, processable_rows, number_geocoded_rows, geocoding_time).notify_if_needed
end
def store_geocoder_info(pid)
# we use the object instead of the method because
# we don't want to build the object until it has
# all the needed data that is created in the run method
# of the geocoder object. If not is going to fail because
# the object is memoized.
self.pid = pid
if defined?(@table_geocoder)
self.geocoder_type = table_geocoder.name
end
self.save
end
end
+8
View File
@@ -0,0 +1,8 @@
require_dependency 'carto/helpers/auth_token_generator'
class Group < Sequel::Model
many_to_one :organization
include Carto::AuthTokenGenerator
end
+256
View File
@@ -0,0 +1,256 @@
require_relative 'layer/presenter'
require_relative 'table/user_table'
require_relative '../../lib/cartodb/stats/editor_apis'
require_dependency 'carto/table_utils'
require_dependency 'carto/query_rewriter'
require_relative 'carto/layer'
class Layer < Sequel::Model
include Carto::TableUtils
include Carto::LayerTableDependencies
include Carto::QueryRewriter
plugin :serialization, :json, :options, :infowindow, :tooltip
ALLOWED_KINDS = %W{ carto tiled background gmapsbase torque wms }
BASE_LAYER_KINDS = %w(tiled background gmapsbase wms)
DATA_LAYER_KINDS = ALLOWED_KINDS - BASE_LAYER_KINDS
PUBLIC_ATTRIBUTES = %W{ options kind infowindow tooltip id order }
TEMPLATES_MAP = {
'table/views/infowindow_light' => 'infowindow_light',
'table/views/infowindow_dark' => 'infowindow_dark',
'table/views/infowindow_light_header_blue' => 'infowindow_light_header_blue',
'table/views/infowindow_light_header_yellow' => 'infowindow_light_header_yellow',
'table/views/infowindow_light_header_orange' => 'infowindow_light_header_orange',
'table/views/infowindow_light_header_green' => 'infowindow_light_header_green',
'table/views/infowindow_header_with_image' => 'infowindow_header_with_image'
}
# Sets default order to the maximum order of the sibling layers + 1
def set_default_order(parent)
max_order = parent.layers_dataset.select(:order).map(&:order).compact.max
order = (max_order == nil ? 0 : max_order + 1)
self.update(:order => order) if self.order.blank?
end
one_to_many :layer_node_styles
many_to_many :maps, after_add: proc { |layer, parent| layer.after_added_to_map(parent) }
many_to_many :users, after_add: proc { |layer, parent| layer.set_default_order(parent) }
many_to_many :user_tables,
join_table: :layers_user_tables,
left_key: :layer_id, right_key: :user_table_id,
reciprocal: :layers, class: ::UserTable
plugin :association_dependencies, :maps => :nullify, :users => :nullify, :user_tables => :nullify
def public_values
Hash[ PUBLIC_ATTRIBUTES.map { |attribute| [attribute, send(attribute)] } ]
end
def validate
super
errors.add(:kind, "not accepted") unless ALLOWED_KINDS.include?(kind)
errors.add(:maps, "Viewer users can't edit layers") if maps.find { |m| m.user && m.user.viewer }
if ((Cartodb.config[:enforce_non_empty_layer_css] rescue true))
style = options.include?('tile_style') ? options['tile_style'] : nil
if style.nil? || style.strip.empty?
errors.add(:options, 'Tile style is empty')
stats_aggregator = CartoDB::Stats::EditorAPIs.instance
stats_aggregator.increment("errors.layer.empty-css")
stats_aggregator.increment("errors.total")
end
end
end
def before_save
super
self.updated_at = Time.now
end
def to_json(*args)
public_values.merge(
infowindow: self.values[:infowindow].nil? ? {} : JSON.parse(self.values[:infowindow]),
tooltip: JSON.parse(self.values[:tooltip]),
options: self.values[:options].nil? ? {} : JSON.parse(self.values[:options])
).to_json(*args)
end
def after_save
super
maps.each(&:notify_map_change)
if data_layer?
register_table_dependencies
update_layer_node_style
end
end
def before_destroy
raise CartoDB::InvalidMember.new(user: "Viewer users can't destroy layers") if user && user.viewer
maps.each(&:notify_map_change)
super
end
def key
"rails:layer_styles:#{self.id}"
end
def infowindow_template_path
if self.infowindow.present? && self.infowindow['template_name'].present?
template_name = TEMPLATES_MAP.fetch(self.infowindow['template_name'], self.infowindow['template_name'])
Rails.root.join("lib/assets/javascripts/cartodb/table/views/infowindow/templates/#{template_name}.jst.mustache")
else
nil
end
end
def tooltip_template_path
if self.tooltip.present? && self.tooltip['template_name'].present?
template_name = TEMPLATES_MAP.fetch(self.tooltip['template_name'], self.tooltip['template_name'])
Rails.root.join("lib/assets/javascripts/cartodb/table/views/tooltip/templates/#{template_name}.jst.mustache")
else
nil
end
end
def copy(override_attributes={})
attributes = public_values.select { |k, v| k != 'id' }.merge(override_attributes)
::Layer.new(attributes)
end
alias dup copy
def data_layer?
!base_layer?
end
def torque_layer?
kind == 'torque'
end
def base_layer?
BASE_LAYER_KINDS.include?(kind)
end
def basemap?
["gmapsbase", "tiled"].include?(kind)
end
def supports_labels_layer?
basemap? && options["labels"] && options["labels"]["urlTemplate"]
end
def register_table_dependencies(db=SequelRails.connection)
db.transaction do
delete_table_dependencies
insert_table_dependencies
end
end
def rename_table(current_table_name, new_table_name)
return self unless data_layer? or torque_layer?
target_keys = %w{ table_name tile_style query }
options = JSON.parse(self[:options])
targets = options.select { |key, value| target_keys.include?(key) }
renamed = targets.map do |key, value|
[key, rename_in(value, current_table_name, new_table_name)]
end
self.options = options.merge(Hash[renamed])
self
end
def uses_private_tables?
user_tables.select(&:private?).any?
end
def legend
options['legend']
end
def get_presenter(options, configuration)
CartoDB::LayerModule::Presenter.new(self, options, configuration)
end
def set_option(key, value)
return unless data_layer?
self.options[key] = value
end
def qualified_table_name(viewer_user)
"#{viewer_user.sql_safe_database_schema}.#{safe_table_name_quoting(options['table_name'])}"
end
def user
map.user if map
end
def qualify_for_organization(owner_username)
options['query'] = qualify_query(query, options['table_name'], owner_username) if query
end
def after_added_to_map(map)
set_default_order(map)
register_table_dependencies
end
def source_id
options && options.symbolize_keys[:source]
end
def depends_on?(table)
user_tables.map(&:id).include?(table.id)
end
private
def rename_in(target, anchor, substitution)
return if target.nil? || target.empty?
regex = /(\A|\W+)(#{anchor})(\W+|\z)/
target.gsub(regex) { |match| match.gsub(anchor, substitution) }
end
def delete_table_dependencies
# remove_* and remove_all_* do not delete the object from the database
# only disassociate the associated object from the receiver
user_tables.map { |table| remove_user_table(table) }
end
def insert_table_dependencies
affected_tables.map { |table| add_user_table(table) }
end
def tables_from_names(table_names, user)
::Table.get_all_by_names(table_names, user)
end
def affected_table_names(query)
query_tables = user.in_database["SELECT unnest(CDB_QueryTablesText(?))", query]
query_tables.map(:unnest)
end
def map
maps.first
end
def query
options.symbolize_keys[:query]
end
def update_layer_node_style
style = current_layer_node_style
if style
style.update_from_layer(self)
style.save
end
end
def current_layer_node_style
return nil unless source_id
LayerNodeStyle.find(layer_id: id, source_id: source_id) || LayerNodeStyle.new(layer: self, source_id: source_id)
end
end
+302
View File
@@ -0,0 +1,302 @@
require 'json'
require 'ejs'
require_dependency 'carto/table_utils'
module CartoDB
# Natural naming would be "Layer", but it collides with Layer class
module LayerModule
class Presenter
include Carto::TableUtils
EMPTY_CSS = '#dummy{}'
TORQUE_ATTRS = %w(
table_name
user_name
property
blendmode
resolution
countby
torque-duration
torque-steps
torque-blend-mode
query
source
tile_style
named_map
visible
)
INFOWINDOW_KEYS = %w(
fields template_name template alternative_names width maxHeight
)
def initialize(layer, options={}, configuration={}, decoration_data={})
@layer = layer
@options = options
@configuration = configuration
@decoration_data = decoration_data
end
def to_vizjson_v2
if base?(layer)
with_kind_as_type(layer.public_values).symbolize_keys
elsif torque?(layer)
as_torque
else
{
id: layer.id,
type: 'CartoDB',
infowindow: infowindow_data_v2,
tooltip: tooltip_data_v2,
legend: layer.legend,
order: layer.order,
visible: layer.public_values['options']['visible'],
options: options_data_v2
}
end
end
# Old layers_controller directly does layer.to_json, but to be uniform with new controller,
# always call through the presenter at least in tests
def to_json(*args)
layer.to_json(*args)
end
def to_vizjson_v1
return layer.public_values.symbolize_keys if base?(layer)
{
id: layer.id,
kind: 'CartoDB',
infowindow: infowindow_data_v1,
order: layer.order,
options: options_data_v1
}
end
def to_poro
# .merge left for backwards compatibility
poro = layer.public_values
return poro unless poro['options']
# INFO changed to support new presenter's way of sending owner
if @options[:user] && !poro['options']['user_name'].present?
user_name = @options[:user].username
schema_name = @options[:user].sql_safe_database_schema
elsif poro['options']['user_name'].present?
user_name = poro['options']['user_name']
schema_name = poro['options']['user_name']
end
if options[:viewer_user] && user_name && poro['options']['table_name']
# if the table_name already have a schema don't add another one
# this case happens when you share a layer already shared with you
if user_name != options[:viewer_user].username && !poro['options']['table_name'].include?('.')
poro['options']['table_name'] = safe_schema_and_table_quoting(schema_name, poro['options']['table_name'])
end
end
poro
end
private
attr_reader :layer, :options, :configuration
# Decorates the layer presentation with data if needed. nils on the decoration act as removing the field
def decorate_with_data(source_hash, decoration_data)
decoration_data.each { |key, value|
source_hash[key] = value
source_hash.delete_if { |k, v|
v.nil?
}
}
source_hash
end
def base?(layer)
['tiled', 'background', 'gmapsbase', 'wms'].include? layer.kind
end
def torque?(layer)
layer.kind == 'torque'
end
def with_kind_as_type(attributes)
decorate_with_data(attributes.merge(type: attributes.delete('kind')), @decoration_data)
end
def as_torque
api_templates_type = options.fetch(:https_request, false) ? 'private' : 'public'
layer_options = decorate_with_data(
# Make torque always have a SQL query too (as vizjson v2)
layer.options.merge({ 'query' => wrap(sql_from(layer.options), layer.options) }),
@decoration_data
)
{
id: layer.id,
type: 'torque',
order: layer.order,
legend: layer.legend,
options: {
stat_tag: options.fetch(:visualization_id),
maps_api_template: fix_torque_maps_api_template(ApplicationHelper.maps_api_template(api_templates_type)),
sql_api_template: ApplicationHelper.sql_api_template(api_templates_type),
# tiler_* is kept for backwards compatibility
tiler_protocol: (configuration[:tiler]["public"]["protocol"] rescue nil),
tiler_domain: (configuration[:tiler]["public"]["domain"] rescue nil),
tiler_port: (configuration[:tiler]["public"]["port"] rescue nil),
# sql_api_* is kept for backwards compatibility
sql_api_protocol: (configuration[:sql_api]["public"]["protocol"] rescue nil),
sql_api_domain: (configuration[:sql_api]["public"]["domain"] rescue nil),
sql_api_endpoint: (configuration[:sql_api]["public"]["endpoint"] rescue nil),
sql_api_port: (configuration[:sql_api]["public"]["port"] rescue nil),
layer_name: name_for(layer),
}.merge(
layer_options.select { |k| TORQUE_ATTRS.include? k })
}
end
def infowindow_data_v1
with_template(layer.infowindow, layer.infowindow_template_path)
rescue => e
CartoDB::Logger.error(exception: e)
throw e
end
def infowindow_data_v2
whitelisted_infowindow(with_template(layer.infowindow, layer.infowindow_template_path))
rescue => e
CartoDB::Logger.error(exception: e)
throw e
end
def tooltip_data_v2
whitelisted_infowindow(with_template(layer.tooltip, layer.tooltip_template_path))
rescue => e
CartoDB::Logger.error(exception: e)
throw e
end
def with_template(infowindow, path)
# Careful with this logic:
# - nil means absolutely no infowindow (e.g. a torque)
# - path = nil or template filled: either pre-filled or custom infowindow, nothing to do here
# - template and path not nil but template not filled: stay and fill
return nil if infowindow.nil?
template = infowindow['template']
return infowindow if (!template.nil? && !template.empty?) || path.nil?
infowindow.merge!(template: File.read(path))
infowindow
end
def options_data_v2
if options[:full]
decorate_with_data(layer.options, @decoration_data)
else
layer_options = layer.options
data = {
layer_name: name_for(layer),
cartocss: css_from(layer.options),
cartocss_version: layer.options.fetch('style_version'),
interactivity: layer.options.fetch('interactivity')
}
source = layer.options['source']
if options[:for_named_map] && source
data[:source] = { id: source }
else
data[:sql] = wrap(sql_from(layer.options), layer.options)
end
if layer_options['style_properties'] && (layer_options['style_properties']['autogenerated'] == false || layer_options['style_properties']['autogenerated'].nil?)
sql_wrap = layer_options['sql_wrap'] || layer_options['query_wrapper']
data['sql_wrap'] = sql_wrap if sql_wrap.present?
end
data = decorate_with_data(data, @decoration_data)
viewer = options[:viewer_user]
if viewer
unless data['user_name'] == viewer.username
data['table_name'] = "\"#{data['user_name']}\".#{data['table_name']}"
end
end
data
end
end
def name_for(layer)
layer_alias = layer.options.fetch('table_name_alias', nil)
table_name = layer.options.fetch('table_name')
return table_name unless layer_alias && !layer_alias.empty?
layer_alias
end
def options_data_v1
return layer.options if options[:full]
layer.options.select { |key, value| public_options.include?(key.to_s) }
end
def sql_from(options)
query = options.fetch('query', '')
return default_query_for(options) if query.nil? || query.empty?
query
end
def css_from(options)
style = options.include?('tile_style') ? options['tile_style'] : nil
(style.nil? || style.strip.empty?) ? EMPTY_CSS : options.fetch('tile_style')
end
def wrap(query, options)
wrapper = options.fetch('query_wrapper', nil)
return query if wrapper.nil? || wrapper.empty?
EJS.evaluate(wrapper, sql: query)
end
def default_query_for(layer_options)
if options[:viewer_user]
unless layer_options['user_name'] == options[:viewer_user].username
name = if layer_options['user_name'] && layer_options['user_name'].include?('-')
"\"#{layer_options['user_name']}\""
else
layer_options['user_name']
end
return "select * from #{name}.#{safe_table_name_quoting(layer_options['table_name'])}"
end
end
"select * from #{safe_table_name_quoting(layer_options.fetch('table_name'))}"
end
def public_options
return configuration if configuration.empty?
configuration.fetch(:layer_opts).fetch("public_opts")
end
def whitelisted_infowindow(infowindow)
infowindow.nil? ? nil : infowindow.select { |key, value|
INFOWINDOW_KEYS.include?(key) || INFOWINDOW_KEYS.include?(key.to_s)
}
end
def fix_torque_maps_api_template(maps_api_template)
if visualization_owner_is_table_owner?
maps_api_template
else
# This fixes #9017 avoding Torque request to table owner named map instead of visualization owner.
# See https://github.com/CartoDB/torque/blob/8a14fe546ac411829b5f52bb575526ad5ecb79f8/lib/torque/provider/windshaft.js#L361
maps_api_template.gsub('{user}', layer.user.username)
end
end
def visualization_owner_is_table_owner?
layer.options.nil? || layer.options['user_name'].nil? || layer.user.nil? ||
layer.options['user_name'] == layer.user.username
end
end
end
end
+56
View File
@@ -0,0 +1,56 @@
require_relative '../layer/presenter'
module CartoDB
module LayerGroup
class Presenter
LAYER_GROUP_VERSION = '1.0.1'
DEFAULT_TILER_FILTER = 'mapnik'
def initialize(layers, options, configuration)
@layers = layers
@options = options
@configuration = configuration
end #initialize
def to_poro
return nil if cartodb_layers.empty?
api_templates_type = options.fetch(:https_request, false) ? 'private' : 'public'
{
type: 'layergroup',
options: {
user_name: options.fetch(:user_name),
maps_api_template: ApplicationHelper.maps_api_template(api_templates_type),
sql_api_template: ApplicationHelper.sql_api_template(api_templates_type),
# tiler_* and sql_api_* are kept for backwards compatibility
tiler_protocol: (configuration[:tiler]["public"]["protocol"] rescue nil),
tiler_domain: (configuration[:tiler]["public"]["domain"] rescue nil),
tiler_port: (configuration[:tiler]["public"]["port"] rescue nil),
sql_api_protocol: (configuration[:sql_api]["public"]["protocol"] rescue nil),
sql_api_domain: (configuration[:sql_api]["public"]["domain"] rescue nil),
sql_api_endpoint: (configuration[:sql_api]["public"]["endpoint"] rescue nil),
sql_api_port: (configuration[:sql_api]["public"]["port"] rescue nil),
filter: @configuration[:tiler].fetch('filter', DEFAULT_TILER_FILTER),
layer_definition: {
stat_tag: options.fetch(:visualization_id),
version: LAYER_GROUP_VERSION,
layers: cartodb_layers
},
attribution: options.fetch(:attributions).join(', ')
}
}
end
private
attr_reader :layers, :configuration, :options
def cartodb_layers
@cartodb_layers ||= layers.map do |layer|
CartoDB::LayerModule::Presenter.new(layer, options, configuration).to_vizjson_v2
end
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More