cartodb/app/controllers/carto/api/visualizations_controller.rb
2020-06-15 10:58:47 +08:00

509 lines
22 KiB
Ruby

require_relative 'visualization_presenter'
require_dependency 'carto/api/vizjson_presenter'
require_relative '../../../models/visualization/stats'
require_relative 'paged_searcher'
require_relative '../controller_helper'
require_dependency 'carto/uuidhelper'
require_dependency 'static_maps_url_helper'
require_relative 'vizjson3_presenter'
require_dependency 'visualization/name_generator'
require_dependency 'visualization/table_blender'
require_dependency 'carto/visualization_migrator'
require_dependency 'carto/google_maps_api'
require_dependency 'carto/ghost_tables_manager'
module Carto
module Api
class VisualizationsController < ::Api::ApplicationController
include VisualizationSearcher
include PagedSearcher
include Carto::UUIDHelper
include Carto::ControllerHelper
include VisualizationsControllerHelper
include Carto::VisualizationMigrator
ssl_required :index, :show, :create, :update, :destroy, :google_maps_static_image
ssl_allowed :vizjson2, :vizjson3, :list_watching, :static_map,
:notify_watching, :list_watching, :add_like, :remove_like
# TODO: compare with older, there seems to be more optional authentication endpoints
skip_before_filter :api_authorization_required, only: [:show, :index, :vizjson2, :vizjson3, :add_like,
:remove_like, :notify_watching, :list_watching,
:static_map, :show]
# :update and :destroy are correctly handled by permission check on the model
before_filter :ensure_user_can_create, only: [:create]
before_filter :optional_api_authorization, only: [:show, :index, :vizjson2, :vizjson3, :add_like,
:remove_like, :notify_watching, :list_watching, :static_map]
before_filter :id_and_schema_from_params
before_filter :load_visualization, only: [:add_like, :remove_like, :show,
:list_watching, :notify_watching, :static_map, :vizjson2, :vizjson3,
:update, :destroy, :google_maps_static_image]
before_filter :ensure_username_matches_visualization_owner, only: [:show, :static_map, :vizjson2, :vizjson3,
:list_watching, :notify_watching, :update,
:destroy, :google_maps_static_image]
before_filter :ensure_visualization_owned, only: [:destroy, :google_maps_static_image]
before_filter :ensure_visualization_is_likeable, only: [:add_like, :remove_like]
before_filter :link_ghost_tables, only: [:index]
before_filter :load_common_data, only: [:index]
rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
rescue_from Carto::UUIDParameterFormatError, with: :rescue_from_carto_error
rescue_from Carto::ProtectedVisualizationLoadError, with: :rescue_from_protected_visualization_load_error
VALID_ORDER_PARAMS = %i(name updated_at size mapviews favorited estimated_row_count privacy
dependent_visualizations).freeze
def show
presenter = VisualizationPresenter.new(
@visualization, current_viewer, self,
related_canonical_visualizations: params[:fetch_related_canonical_visualizations] == 'true',
show_user: params[:fetch_user] == 'true',
show_user_basemaps: params[:show_user_basemaps] == 'true',
show_liked: params[:show_liked] == 'true',
show_permission: params[:show_permission] == 'true',
show_stats: params[:show_stats] == 'true',
show_auth_tokens: params[:show_auth_tokens] == 'true',
password: params[:password],
with_dependent_visualizations: params[:with_dependent_visualizations].to_i || 0
)
render_jsonp(::JSON.dump(presenter.to_poro))
rescue => e
CartoDB::Logger.error(exception: e)
head(404)
end
def index
offdatabase_orders = Carto::VisualizationQueryOrderer::SUPPORTED_OFFDATABASE_ORDERS.map(&:to_sym)
valid_order_combinations = VALID_ORDER_PARAMS - offdatabase_orders
opts = { valid_order_combinations: valid_order_combinations }
page, per_page, order, order_direction = page_per_page_order_params(VALID_ORDER_PARAMS, opts)
_, total_types = get_types_parameters
vqb = query_builder_with_filter_from_hash(params)
presenter_cache = Carto::Api::PresenterCache.new
presenter_options = presenter_options_from_hash(params).merge(related: false)
visualizations = vqb.with_order(order, order_direction)
.build_paged(page, per_page).map do |v|
VisualizationPresenter.new(v, current_viewer, self, presenter_options)
.with_presenter_cache(presenter_cache).to_poro
end
response = {
visualizations: visualizations,
total_entries: vqb.build.size
}
if current_user && (params[:load_totals].to_s != 'false')
response.merge!(calculate_totals(total_types))
end
render_jsonp(response)
rescue CartoDB::BoundingBoxError => e
render_jsonp({ error: e.message }, 400)
rescue Carto::ParamInvalidError, Carto::ParamCombinationInvalidError => e
render_jsonp({ error: e.message }, e.status)
rescue StandardError => e
CartoDB::Logger.error(exception: e)
render_jsonp({ error: e.message }, 500)
end
def add_like
@visualization.add_like_from(current_viewer)
render_jsonp(
id: @visualization.id,
liked: @visualization.liked_by?(current_viewer)
)
rescue Carto::Visualization::UnauthorizedLikeError
render_jsonp({ text: "You don't have enough permissions to favorite this visualization" }, 403)
rescue Carto::Visualization::AlreadyLikedError
render_jsonp({ text: "You've already favorited this visualization" }, 400)
end
def remove_like
@visualization.remove_like_from(current_viewer)
render_jsonp(id: @visualization.id, liked: @visualization.liked_by?(current_viewer))
rescue Carto::Visualization::UnauthorizedLikeError
render_jsonp({ text: "You don't have enough permissions to favorite this visualization" }, 403)
end
def notify_watching
return(head 403) unless @visualization.has_read_permission?(current_viewer)
watcher = Carto::Visualization::Watcher.new(current_user, @visualization)
watcher.notify
render_jsonp(watcher.list)
end
def list_watching
return(head 403) unless @visualization.has_read_permission?(current_viewer)
render_jsonp(Carto::Visualization::Watcher.new(current_user, @visualization).list)
end
def vizjson2
@visualization.mark_as_vizjson2 unless carto_referer?
render_vizjson(generate_vizjson2)
end
def vizjson3
render_vizjson(generate_vizjson3(@visualization))
end
def static_map
# Abusing here of .to_i fallback to 0 if not a proper integer
map_width = params.fetch('width',nil).to_i
map_height = params.fetch('height', nil).to_i
# @see https://github.com/CartoDB/Windshaft-cartodb/blob/b59e0a00a04f822154c6d69acccabaf5c2fdf628/docs/Map-API.md#limits
if map_width < 2 || map_height < 2 || map_width > 8192 || map_height > 8192
return(head 400)
end
response.headers['X-Cache-Channel'] = "#{@visualization.varnish_key}:vizjson"
response.headers['Surrogate-Key'] = "#{CartoDB::SURROGATE_NAMESPACE_VIZJSON} #{@visualization.surrogate_key}"
response.headers['Cache-Control'] = "max-age=86400,must-revalidate, public"
redirect_to Carto::StaticMapsURLHelper.new.url_for_static_map(request, @visualization, map_width, map_height)
end
def create
vis_data = payload
vis_data.delete(:permission)
vis_data.delete(:permission_id)
param_tables = vis_data.delete(:tables)
current_user_id = current_user.id
origin = 'blank'
source_id = vis_data.delete(:source_visualization_id)
valid_attributes = vis_data.slice(*VALID_CREATE_ATTRIBUTES)
vis = if source_id
user = Carto::User.find(current_user_id)
source = Carto::Visualization.where(id: source_id).first
return head(403) unless source && source.is_viewable_by_user?(user) && !source.kind_raster?
if source.derived?
origin = 'copy'
duplicate_derived_visualization(source_id, user)
else
create_visualization_from_tables([source.user_table], valid_attributes)
end
elsif param_tables
subdomain = CartoDB.extract_subdomain(request)
viewed_user = Carto::User.where(username: subdomain).first
tables = param_tables.map do |table_name|
Carto::Helpers::TableLocator.new.get_by_id_or_name(table_name, viewed_user) if viewed_user
end
create_visualization_from_tables(tables.flatten, valid_attributes)
else
Carto::Visualization.new(valid_attributes.merge(name: name_candidate, user_id: current_user_id))
end
vis.ensure_valid_privacy
vis.save!
current_viewer_id = current_viewer.id
properties = {
user_id: current_viewer_id,
origin: origin,
visualization_id: vis.id
}
if vis.derived?
Carto::Tracking::Events::CreatedMap.new(current_viewer_id, properties).report
else
Carto::Tracking::Events::CreatedDataset.new(current_viewer_id, properties).report
end
render_jsonp(Carto::Api::VisualizationPresenter.new(vis, current_viewer, self).to_poro)
rescue => e
CartoDB::Logger.error(message: "Error creating visualization", visualization_id: vis.try(:id), exception: e)
raise e if e.is_a?(Carto::UnauthorizedError)
render_jsonp({ errors: vis.try(:errors).try(:full_messages) }, 400)
end
def update
vis = @visualization
return head(403) unless payload[:id] == vis.id
return head(403) unless vis.has_permission?(current_user, Carto::Permission::ACCESS_READWRITE)
vis_data = payload
vis_data.delete(:permission) || vis_data.delete('permission')
vis_data.delete(:permission_id) || vis_data.delete('permission_id')
vis.transition_options = params[:transition_options] if params[:transition_options]
# when a table gets renamed, its canonical visualization is renamed, so we must revert renaming if that failed
# This is far from perfect, but works without messing with table-vis sync and their two backends
valid_attributes = vis_data.slice(*VALID_UPDATE_ATTRIBUTES)
if vis.table?
old_vis_name = vis.name
vis.attributes = valid_attributes
new_vis_name = vis.name
old_table_name = vis.table.name
vis.save!
if new_vis_name != old_vis_name && vis.table.name == old_table_name
vis.name = old_vis_name
vis.save!
end
else
old_version = vis.version
vis.attributes = valid_attributes
vis.save!
if version_needs_migration?(old_version, vis.version)
migrate_visualization_to_v3(vis)
end
end
render_jsonp(Carto::Api::VisualizationPresenter.new(vis, current_viewer, self).to_poro)
rescue => e
CartoDB::Logger.error(message: "Error updating visualization", visualization_id: vis.id, exception: e)
error_code = vis.errors.include?(:privacy) ? 403 : 400
render_jsonp({ errors: vis.errors.full_messages.empty? ? ['Error updating'] : vis.errors.full_messages },
error_code)
end
def destroy
return head(403) unless @visualization.has_permission?(current_viewer, Carto::Permission::ACCESS_READWRITE)
current_viewer_id = current_viewer.id
properties = { user_id: current_viewer_id, visualization_id: @visualization.id }
# Tracking. Can this be moved to the model?
if @visualization.derived?
Carto::Tracking::Events::DeletedMap.new(current_viewer_id, properties).report
else
Carto::Tracking::Events::DeletedDataset.new(current_viewer_id, properties).report
end
if @visualization.table
@visualization.table.fully_dependent_visualizations.each do |dependent_vis|
properties = { user_id: current_viewer_id, visualization_id: dependent_vis.id }
if dependent_vis.derived?
Carto::Tracking::Events::DeletedMap.new(current_viewer_id, properties).report
else
Carto::Tracking::Events::DeletedDataset.new(current_viewer_id, properties).report
end
end
end
@visualization.destroy
head 204
rescue => exception
CartoDB::Logger.error(message: 'Error deleting visualization', exception: exception,
visualization: @visualization)
render_jsonp({ errors: [exception.message] }, 400)
end
def google_maps_static_image
gmaps_api = Carto::GoogleMapsApi.new
base_layer_options = @visualization.base_layers.first.options
base_url = gmaps_api.build_static_image_url(
center: params[:center],
map_type: base_layer_options[:baseType],
size: params[:size],
zoom: params[:zoom],
style: JSON.parse(base_layer_options[:style], symbolize_names: true)
)
render(json: { url: gmaps_api.sign_url(@visualization.user, base_url) })
rescue => e
CartoDB::Logger.error(message: 'Error generating Google API URL', exception: e)
render(json: { errors: 'Error generating static image URL' }, status: 400)
end
private
# excluded:
# :id, :map_id, :type, :created_at, :external_source, :url, :version, :table, :user_id
# :synchronization, :uses_builder_features, :auth_tokens, :transition_options, :prev_id, :next_id, :parent_id
# :active_child, :permission
VALID_UPDATE_ATTRIBUTES = [:name, :display_name, :active_layer_id, :tags, :description, :privacy, :updated_at,
:locked, :source, :title, :license, :attributions, :kind, :password, :version].freeze
# TODO: This lets more things through than it should. This is due to tests using this endpoint to create
# test visualizations.
VALID_CREATE_ATTRIBUTES = (VALID_UPDATE_ATTRIBUTES + [:type, :map_id] - [:version]).freeze
def generate_vizjson2
Carto::Api::VizJSONPresenter.new(@visualization, $tables_metadata).to_vizjson(https_request: is_https?)
end
def render_vizjson(vizjson)
set_vizjson_response_headers_for(@visualization)
render_jsonp(vizjson)
rescue => exception
CartoDB.notify_exception(exception)
raise exception
end
def load_visualization
@visualization = load_visualization_from_id_or_name(params[:id])
if @visualization.nil?
raise Carto::LoadError.new('Visualization does not exist', 404)
end
if !@visualization.is_accessible_with_password?(current_viewer, params[:password])
if @visualization.password_protected?
raise Carto::ProtectedVisualizationLoadError.new(@visualization)
else
raise Carto::LoadError.new('Visualization not viewable', 403)
end
end
end
def ensure_username_matches_visualization_owner
unless request_username_matches_visualization_owner
raise Carto::LoadError.new('Visualization of that user does not exist', 404)
end
end
def ensure_visualization_owned
raise Carto::LoadError.new('Visualization not editable', 403) unless @visualization.is_owner?(current_viewer)
end
def ensure_visualization_is_likeable
return(head 403) unless current_viewer && @visualization.is_viewable_by_user?(current_viewer)
end
def ensure_user_can_create
return (head 403) unless current_viewer && !current_viewer.viewer
end
# This avoids crossing usernames and visualizations.
# Remember that the url of a visualization shared with a user contains that user's username instead of owner's
def request_username_matches_visualization_owner
# Support both for username at `/u/username` and subdomain, prioritizing first
username = [CartoDB.username_from_request(request), CartoDB.subdomain_from_request(request)].compact.first
# URL must always contain username, either at subdomain or at path.
# Domainless url documentation: http://cartodb.readthedocs.org/en/latest/configuration.html#domainless-urls
return false unless username.present?
# Either user is owner or is current and has permission
# R permission check is based on current_viewer because current_user assumes you're viewing your subdomain
username == @visualization.user.username ||
(current_user && username == current_user.username && @visualization.has_read_permission?(current_viewer))
end
def id_and_schema_from_params
if params.fetch('id', nil) =~ /\./
@id, @schema = params.fetch('id').split('.').reverse
else
@id, @schema = [params.fetch('id', nil), nil]
end
end
def set_vizjson_response_headers_for(visualization)
# We don't cache non-public vis
if @visualization.is_publically_accesible?
response.headers['X-Cache-Channel'] = "#{@visualization.varnish_key}:vizjson"
response.headers['Surrogate-Key'] = "#{CartoDB::SURROGATE_NAMESPACE_VIZJSON} #{visualization.surrogate_key}"
response.headers['Cache-Control'] = 'no-cache,max-age=86400,must-revalidate, public'
end
end
def carto_referer?
referer_host = URI.parse(request.referer).host
referer_host && (referer_host.ends_with?('carto.com') || referer_host.ends_with?('cartodb.com'))
rescue URI::InvalidURIError
false
end
def payload
request.body.rewind
::JSON.parse(request.body.read.to_s || String.new, symbolize_names: true)
end
def duplicate_derived_visualization(source, user)
export_service = Carto::VisualizationsExportService2.new
visualization_hash = export_service.export_visualization_json_hash(source, user)
visualization_copy = export_service.build_visualization_from_hash_export(visualization_hash)
visualization_copy.name = name_candidate
visualization_copy.version = user.new_visualizations_version
Carto::VisualizationsExportPersistenceService.new.save_import(user, visualization_copy)
visualization_copy
end
def name_candidate
CartoDB::Visualization::NameGenerator.new(current_user).name(params[:name])
end
def create_visualization_from_tables(tables, vis_data)
blender = CartoDB::Visualization::TableBlender.new(Carto::User.find(current_user.id), tables)
map = blender.blend
Carto::Visualization.new(vis_data.merge(name: name_candidate,
map_id: map.id,
type: 'derived',
privacy: blender.blended_privacy,
user_id: current_user.id,
overlays: Carto::OverlayFactory.build_default_overlays(current_user)))
end
def link_ghost_tables
return unless current_user && current_user.has_feature_flag?('ghost_tables')
# This call will trigger ghost tables synchronously if there's risk of displaying a stale table
# or asynchronously otherwise.
Carto::GhostTablesManager.new(current_user.id).link_ghost_tables
end
def load_common_data
return true unless current_user.present?
begin
visualizations_api_url = CartoDB::Visualization::CommonDataService.build_url(self)
::Resque.enqueue(::Resque::UserDBJobs::CommonData::LoadCommonData, current_user.id, visualizations_api_url) if current_user.should_load_common_data?
rescue Exception => e
# We don't block the load of the dashboard because we aren't able to load common dat
CartoDB.notify_exception(e, {user:current_user})
return true
end
end
def calculate_totals(total_types)
# Prefetching at counts removes duplicates
{
total_user_entries: VisualizationQueryBuilder.new
.with_types(total_types)
.with_user_id(current_user.id)
.build.size,
total_locked: VisualizationQueryBuilder.new
.with_types(total_types)
.with_user_id(current_user.id)
.with_locked(true)
.build.size,
total_likes: VisualizationQueryBuilder.new
.with_types(total_types)
.with_liked_by_user_id(current_user.id)
.with_locked(false)
.build.size,
total_shared: VisualizationQueryBuilder.new
.with_types(total_types)
.with_shared_with_user_id(current_user.id)
.with_user_id_not(current_user.id)
.with_locked(false)
.with_prefetch_table
.build.size
}
end
end
end
end