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