cartodb-4.42/app/models/carto/visualization.rb

880 lines
23 KiB
Ruby
Raw Normal View History

2024-04-06 13:25:13 +08:00
require 'active_record'
require 'cartodb-common'
require_relative '../visualization/stats'
require_relative '../quota_checker'
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
TYPE_APP = 'app'.freeze
VALID_TYPES = [TYPE_CANONICAL, TYPE_DERIVED, TYPE_SLIDE, TYPE_REMOTE, TYPE_KUVIZ, TYPE_APP].freeze
MAP_TYPES = [TYPE_DERIVED, 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 }
validates :name, uniqueness: { scope: [:user_id, :type] }, if: -> { kuviz? || app? }
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 map?
kuviz? || derived?
end
def derived?
type == TYPE_DERIVED
end
def remote?
type == TYPE_REMOTE
end
def kuviz?
type == TYPE_KUVIZ
end
def app?
type == TYPE_APP
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
log_warning(message: "Couldn't add source analysis for layer", current_user: user, layer: layer.attributes)
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&.dependent_visualizations || []
end
def faster_dependent_visualizations(limit: nil)
@faster_dependent_visualizations ||= user_table&.faster_dependent_visualizations(limit: limit)
end
def dependent_visualizations_count
user_table&.dependent_visualizations_count.to_i
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
def subscription
if user_table
doss = Carto::DoSyncServiceFactory.get_for_user(user)
doss&.subscription_from_sync_table(user_table.name)
end
end
private
def remove_password
self.password_salt = nil
self.encrypted_password = nil
end
def perform_invalidations
invalidation_service.invalidate
rescue StandardError => 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.
log_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 StandardError => 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 StandardError => 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? && (map? || table?)
is_privacy_private? ? validate_change_to_private : validate_change_to_public
end
def validate_change_to_private
if (!user&.private_tables_enabled? && table?) || (!user&.private_maps_enabled? && map?)
errors.add(:privacy, 'cannot be set to private')
end
return unless !privacy_was || privacy_was != Carto::Visualization::PRIVACY_PRIVATE
if map? && CartoDB::QuotaChecker.new(user).will_be_over_private_map_quota?
errors.add(:privacy, 'over account private map quota')
end
end
def validate_change_to_public
return unless !privacy_was || privacy_was == Carto::Visualization::PRIVACY_PRIVATE
if map? && CartoDB::QuotaChecker.new(user).will_be_over_public_map_quota?
errors.add(:privacy, 'over account public map quota')
end
if table? && CartoDB::QuotaChecker.new(user).will_be_over_public_dataset_quota?
errors.add(:privacy, 'over account public dataset 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.get_config(:watcher, 'ttl') || 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