Initial commit
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
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
|
||||
Reference in New Issue
Block a user