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
@@ -0,0 +1,155 @@
require_dependency 'carto/uuidhelper'
require_relative '../builder/builder_users_module'
module Carto
module Api
class AnalysesController < ::Api::ApplicationController
include Carto::ControllerHelper
include Carto::UUIDHelper
include Carto::Builder::BuilderUsersModule
ssl_required :show, :create, :update, :destroy
before_filter :builder_users_only
before_filter :load_visualization
before_filter :check_user_can_add_analysis, only: [:show, :create, :update, :destroy]
before_filter :load_analysis, only: [:show, :update, :destroy]
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def show
render_jsonp(AnalysisPresenter.new(@analysis).to_poro)
end
def create
natural_id = analysis_definition_from_request['id']
analysis = Carto::Analysis.find_by_natural_id(@visualization.id, natural_id)
if analysis
analysis.analysis_definition = analysis_definition_from_request
else
analysis = Carto::Analysis.new(
visualization_id: @visualization.id,
user_id: current_user.id,
analysis_definition: analysis_definition_from_request
)
end
analysis.save!
render_jsonp(AnalysisPresenter.new(analysis).to_poro, 201)
end
def update
new_definition = analysis_definition_from_request
new_root_node = Carto::AnalysisNode.new(new_definition.deep_symbolize_keys)
modified_node_ids = find_modified_nodes(@analysis.analysis_node, new_root_node)
affected_node_ids = find_affected_nodes(modified_node_ids)
@analysis.analysis_definition = new_definition
@analysis.save!
purge_layer_node_style_history(affected_node_ids)
render_jsonp(AnalysisPresenter.new(@analysis).to_poro, 200)
end
def destroy
@analysis.destroy
render_jsonp(AnalysisPresenter.new(@analysis).to_poro, 200)
end
private
def purge_layer_node_style_history(node_ids)
Carto::LayerNodeStyle.from_visualization_and_source(@visualization, node_ids).delete_all
end
def find_affected_nodes(modified_node_ids)
all_visualization_nodes = @visualization.analyses.map(&:analysis_node).map(&:descendants).flatten
all_visualization_nodes.select { |node|
node.descendants.any? { |descendant| modified_node_ids.include?(descendant.id) }
}.map(&:id)
end
def find_modified_nodes(old_root, new_root)
old_nodes = old_root.descendants
new_nodes = new_root.descendants
old_ids = old_nodes.map(&:id)
new_ids = new_nodes.map(&:id)
kept_ids = old_ids & new_ids
kept_ids.select do |node_id|
old_node = old_nodes.find { |n| n.id == node_id }
new_node = new_nodes.find { |n| n.id == node_id }
old_node.non_child_params != new_node.non_child_params
end
end
def analysis_definition_from_request
analysis_json = json_post(request.raw_post)
if analysis_json.nil? || analysis_json.empty?
raise Carto::UnprocesableEntityError.new("Empty analysis")
end
analysis_definition = analysis_json['analysis_definition']
if analysis_definition.nil? || analysis_definition.empty? || analysis_definition.class == String
raise Carto::UnprocesableEntityError.new("Invalid analysis definition")
end
analysis_definition
end
def json_post(raw_post = request.raw_post)
@json_post ||= (raw_post.present? ? JSON.parse(raw_post) : nil)
rescue => e
# Malformed JSON is not our business
CartoDB.notify_warning_exception(e)
raise UnprocesableEntityError.new("Malformed JSON: #{raw_post}")
end
def load_visualization
visualization_id = params[:visualization_id]
if payload_visualization_id.present? && payload_visualization_id != visualization_id
raise UnprocesableEntityError.new("url vis (#{visualization_id}) != payload (#{payload_visualization_id})")
end
@visualization = Carto::Visualization.where(id: visualization_id).first if visualization_id
raise LoadError.new("Visualization not found: #{visualization_id}") unless @visualization
end
def payload_visualization_id
json_post.present? ? json_post['visualization_id'] : nil
end
def payload_analysis_id
json_post.present? ? json_post['id'] : nil
end
def check_user_can_add_analysis
if @visualization.user_id != current_user.id
raise Carto::UnauthorizedError.new("#{current_user.id} doesn't own visualization #{@visualization.id}")
end
end
def load_analysis
if payload_analysis_id.present? && payload_analysis_id != params[:id]
raise UnprocesableEntityError.new("url analysis (#{params[:id]}) != payload (#{payload_analysis_id})")
end
unless params[:id].nil?
@analysis = Carto::Analysis.where(id: params[:id]).first if is_uuid?(params[:id])
if @analysis.nil?
@analysis = Carto::Analysis.find_by_natural_id(@visualization.id, params[:id])
end
end
raise Carto::LoadError.new("Analysis not found: #{params[:id]}") unless @analysis
end
end
end
end
@@ -0,0 +1,34 @@
module Carto
module Api
class AnalysisPresenter
def initialize(analysis)
@analysis = analysis
end
def to_poro
return {} unless @analysis
@analysis.analysis_node.descendants.each do |node|
style_history = Carto::LayerNodeStyle.from_visualization_and_source(@analysis.visualization, node.id)
node.options[:style_history] = style_history.map { |lns|
[
lns.layer_id,
{
tooltip: lns.tooltip,
infowindow: lns.infowindow,
options: lns.options
}
]
}.to_h
end
{
id: @analysis.id,
analysis_definition: @analysis.analysis_definition
}
end
end
end
end
@@ -0,0 +1,81 @@
require 'json'
module Carto
module Api
class ApiKeyPresenter
def initialize(api_key)
@api_key = api_key
end
def to_poro
return {} unless @api_key
{
name: @api_key.name,
user: { username: @api_key.user.username },
type: @api_key.type,
token: @api_key.token,
grants: get_grants,
created_at: @api_key.created_at.to_s,
updated_at: @api_key.updated_at.to_s
}
end
private
def get_grants
grants = [
{
type: 'apis',
apis: @api_key.granted_apis
}
]
type_database = {
type: 'database',
tables: table_permissions_for_api_key,
schemas: schema_permissions_for_api_key
}
if @api_key.dataset_metadata_permissions
type_database['table_metadata'] = []
end
grants << type_database
if @api_key.data_services?
grants << {
type: 'dataservices',
services: @api_key.data_services
}
end
grants
end
def table_permissions_for_api_key
return [] if @api_key.master? || @api_key.default_public?
@api_key.table_permissions_from_db.map do |p|
{
schema: p.schema,
name: p.name,
owner: p.owner,
permissions: p.permissions
}
end
end
def schema_permissions_for_api_key
return [] if @api_key.master? || @api_key.default_public?
@api_key.schema_permissions_from_db.map do |p|
{
name: p.name,
permissions: p.permissions
}
end
end
end
end
end
@@ -0,0 +1,97 @@
require_relative 'paged_searcher'
class Carto::Api::ApiKeysController < ::Api::ApplicationController
include Carto::ControllerHelper
include Carto::UUIDHelper
include Carto::Api::PagedSearcher
include Carto::Api::AuthApiAuthentication
ssl_required :create, :destroy, :regenerate_token, :show, :index
before_filter :any_api_authorization_required, only: [:index, :show]
skip_filter :api_authorization_required, only: [:index, :show]
before_filter :engine_required
before_filter :load_api_key, only: [:destroy, :regenerate_token, :show]
rescue_from Carto::ParamInvalidError, with: :rescue_from_carto_error
rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
rescue_from Carto::CartoError, with: :rescue_from_carto_error
VALID_ORDER_PARAMS = [:type, :name, :updated_at].freeze
VALID_TYPE_PARAMS = [Carto::ApiKey::TYPE_MASTER,
Carto::ApiKey::TYPE_DEFAULT_PUBLIC,
Carto::ApiKey::TYPE_REGULAR].freeze
def create
carto_viewer = Carto::User.find(current_viewer.id)
api_key = carto_viewer.api_keys.create_regular_key!(name: params[:name], grants: params[:grants])
render_jsonp(Carto::Api::ApiKeyPresenter.new(api_key).to_poro, 201)
rescue ActiveRecord::RecordInvalid => e
raise Carto::UnprocesableEntityError.new(e.message)
rescue CartoDB::QuotaExceeded => e
raise Carto::CartoError.new(e.message, 403)
end
def destroy
raise Carto::UnauthorizedError.new unless @viewed_api_key.can_be_deleted?
@viewed_api_key.destroy
head :no_content
end
def regenerate_token
@viewed_api_key.regenerate_token!
render_jsonp(Carto::Api::ApiKeyPresenter.new(@viewed_api_key).to_poro, 200)
end
def index
page, per_page, order, _order_direction = page_per_page_order_params(VALID_ORDER_PARAMS)
api_keys = Carto::User.find(current_user.id).api_keys.by_type(type_param).order_weighted_by_type
api_keys = request_api_key.master? ? api_keys : api_keys.where(id: request_api_key.id)
filtered_api_keys = Carto::PagedModel.paged_association(api_keys, page, per_page, order)
result = filtered_api_keys.map { |api_key| json_for_api_key(api_key) }
render_jsonp(
paged_result(
result: result,
total_count: api_keys.count,
page: page,
per_page: per_page,
params: params
) { |params| api_keys_url(params) },
200
)
end
def show
render_jsonp(Carto::Api::ApiKeyPresenter.new(@viewed_api_key).to_poro, 200)
end
private
def load_api_key
name = params[:id]
@viewed_api_key = Carto::ApiKey.where(user_id: current_viewer.id, name: name).user_visible.first
if !@viewed_api_key || !request_api_key.master? && @viewed_api_key != request_api_key
raise Carto::LoadError.new("API key not found: #{name}")
end
end
def json_for_api_key(api_key)
Carto::Api::ApiKeyPresenter.new(api_key).to_poro.merge(
_links: {
self: api_key_url(id: CGI::escape(api_key.name))
}
)
end
def type_param
types = (params[:type] || '').split(',').map(&:strip)
raise Carto::ParamInvalidError.new(:type, VALID_TYPE_PARAMS) unless (types - VALID_TYPE_PARAMS).empty?
types
end
end
@@ -0,0 +1,14 @@
class Carto::Api::AssetPresenter
def initialize(asset)
@asset = asset
end
def to_hash
{
id: @asset.id,
public_url: @asset.absolute_public_url,
user_id: @asset.user_id,
kind: @asset.kind
}
end
end
@@ -0,0 +1,11 @@
class Carto::Api::AssetsController < ::Api::ApplicationController
ssl_required :index
def index
assets = current_viewer.assets.map do |asset|
Carto::Api::AssetPresenter.new(asset).to_hash
end
render json: { total_entries: assets.size, assets: assets }
end
end
@@ -0,0 +1,67 @@
require_relative '../../../models/carto/permission'
module Carto
module Api
class ColumnsController < ::Api::ApplicationController
ssl_required :index, :show, :create, :update, :destroy
before_filter :load_user_table, only: [:index, :show, :create, :update, :destroy]
before_filter :read_privileges?, only: [:index, :show]
before_filter :write_privileges?, only: [:create, :update, :destroy]
def index
render_jsonp(@user_table.service.schema(cartodb_types: true))
end
def show
resp = @user_table.service.schema(cartodb_types: true).select { |e| e[0] == params[:id].to_sym }.first.last
render_jsonp(type: resp)
rescue => e
CartoDB::Logger.error(message: 'Error loading column', exception: e,
column_id: params[:id], user_table: @user_table)
render_jsonp({ errors: "Column #{params[:id]} doesn't exist" }, 404)
end
def create
render_jsonp(@user_table.service.add_column!(params.slice(:type, :name)))
rescue => e
errors = e.is_a?(CartoDB::InvalidType) ? [e.db_message] : [translate_error(e.message.split("\n").first)]
render_jsonp({ errors: errors }, 400)
end
def update
render_jsonp(@user_table.service.modify_column!(name: params[:id],
type: params[:type],
new_name: params[:new_name]))
rescue => e
errors = e.is_a?(CartoDB::InvalidType) ? [e.db_message] : [translate_error(e.message.split("\n").first)]
render_jsonp({ errors: errors }, 400)
end
def destroy
@user_table.service.drop_column!(name: params[:id])
head :no_content
rescue => e
errors = e.is_a?(CartoDB::InvalidType) ? [e.db_message] : [translate_error(e.message.split("\n").first)]
render_jsonp({ errors: errors }, 400)
end
protected
def load_user_table
@user_table = Carto::Helpers::TableLocator.new.get_by_id_or_name(params[:table_id], current_user)
raise RecordNotFound unless @user_table
end
def read_privileges?
head(401) unless current_user && @user_table.visualization.is_viewable_by_user?(current_user)
end
def write_privileges?
head(401) unless current_user && @user_table.visualization.writable_by?(current_user)
end
end
end
end
@@ -0,0 +1,75 @@
module Carto
module Api
class ConnectorsController < ::Api::ApplicationController
MAX_LISTED_TABLES = 500
ssl_required :index, :show, :tables, :connect
before_filter :check_availability
def index
render_jsonp(Carto::Connector.providers(user: current_user))
end
def show
provider_id = params[:provider_id]
begin
information = Carto::Connector.information(provider_id)
render_jsonp(information)
rescue Carto::Connector::InvalidParametersError
render_jsonp({ errors: "Provider #{provider_id} unknown" }, 422)
end
end
def connect
provider_id = params[:provider_id]
parameters = build_connection_parameters(provider_id, params)
begin
connector = Carto::Connector.new(parameters, user: current_user, logger: nil)
render_jsonp({"connected": connector.check_connection})
rescue Carto::Connector::InvalidParametersError => e
render_jsonp({ errors: e.message }, 422)
rescue
render_jsonp({ errors: "Error connecting to provider #{provider_id}, check connection parameters" }, 400)
end
end
def tables
provider_id = params[:provider_id]
parameters = build_connection_parameters(provider_id, params)
if Carto::Connector.list_tables?(provider_id)
begin
connector = Carto::Connector.new(parameters, user: current_user, logger: nil)
render_jsonp(connector.list_tables(MAX_LISTED_TABLES))
rescue Carto::Connector::InvalidParametersError => e
render_jsonp({ errors: e.message }, 422)
rescue
render_jsonp({ errors: "Error connecting to provider #{provider_id}, check connection parameters" }, 400)
end
else
render_jsonp({ errors: "Provider #{provider_id} doesn't support list tables" }, 422)
end
end
private
def build_connection_parameters(provider_id, request_params)
parameters = {}
parameters[:provider] = request_params[:provider_id]
parameters[:connection] = {}
provider_information = Carto::Connector.information(provider_id)
provider_information[:parameters]["connection"].each do |key, _value|
if request_params[key.to_sym].present?
parameters[:connection][key.to_sym] = request_params[key.to_sym]
end
end
parameters
end
def check_availability
head 404 unless Connector.available?(current_user)
end
end
end
end
@@ -0,0 +1,191 @@
require 'uri'
require 'json'
module Carto
module Api
class DataImportPresenter
HTTP_RESPONSE_CODE_MESSAGES = {
"300" => "This usually means the file has been moved! Click on the link to get its new location.",
"301" => "This usually means the file has been moved! Click on the link to get its new location.",
"302" => "This usually means the file has been temporally moved! Click on the link to get its new location.",
"303" => "This usually means the file has been moved! Click on the link to get its new location.",
"307" => "This usually means the file has been temporally moved! Click on the link to get its new location.",
"400" => "This usually means CARTO did not perform the request properly.",
"401" => "This usually means CARTO is not authorized to retrieve this file. If you have authorization, " \
"download the file manually and upload it from your computer.",
"402" => "This usually means CARTO is not authorized to retrieve this file. If you have authorization, " \
"download the file manually and upload it from your computer.",
"403" => "This usually means CARTO is not authorized to retrieve this file. If you have authorization, " \
"download the file manually and upload it from your computer. Tip: Should you be using 'https' " \
"instead of 'http' in your URL?",
"404" => "This usually means no file was found at the specified URL.",
"405" => "This usually means CARTO could not negotiate the download with the file's provider. The " \
"provider is probably using a non standard method to serve this file.",
"407" => "This usually means CARTO is not authorized to retrieve this file as proxy authentication " \
"is required. If you can use the apropiate proxy, download the file manually and upload " \
"it from your computer.",
"408" => "This usually means a timeout request was produced. You may want to try again.",
"409" => "This usually means a confilict in the request was produced. You might want to try again.",
"410" => "This usually means the file specified is now longer available at this location.",
"411" => "This usually means CARTO did not perform the request properly. 'Content Length' header is missing.",
"412" => "This usually means CARTO could not negotiate the download with the file's provider. The " \
"provider is probably using a non standard method to serve this file.",
"413" => "This usually means the file provider is denying the download because the file is too large.",
"417" => "This usually means CARTO could not negotiate the download with the file's provider. The " \
"provider is probably using a non standard method to serve this file.",
"500" => "This usually means the file provider responded with an internal server error. They might " \
"be overloaded or having some down time. Try again later!",
"501" => "This usually means CARTO could not negotiate the download with the file's provider. The " \
"provider is probably using a non standard method to serve this file.",
"502" => "This usually means the file provider responded with a bad gateway error.",
"503" => "This usually means the file provider responded with an internal server error. They might " \
"be overloaded or having some down time. Try again later!",
"505" => "This usually means the file provider doesn't seem to support the HTTP version used in the " \
"transaction. The provider is probably using a non standard method to serve this file.",
"511" => "This usually means CARTO is not authorized to retrieve this file. If you have " \
"authorization, download the file manually and upload it from your computer." }
def initialize(data_import)
@data_import = data_import
end
def api_public_values
public_values.reject { |key|
NON_API_VISIBLE_ATTRIBUTES.include?(key)
}
end
private
PUBLIC_ATTRIBUTES = [
'id',
'user_id',
'table_id',
'data_type',
'table_name',
'state',
'error_code',
'queue_id',
'tables_created_count',
'synchronization_id',
'service_name',
'service_item_id',
'type_guessing',
'quoted_fields_guessing',
'content_guessing',
'server',
'host',
'upload_host',
'resque_ppid',
'create_visualization',
'visualization_id',
# String field containing a json, format:
# {
# twitter_credits: Integer
# }
# No automatic conversion coded
'user_defined_limits'
]
NON_API_VISIBLE_ATTRIBUTES = [
'service_item_id',
'service_name',
'server',
'host',
'upload_host',
'resque_ppid',
]
def public_values
values = Hash[PUBLIC_ATTRIBUTES.map{ |attribute| [attribute, @data_import.send(attribute)] }]
values.merge!('get_error_text' => get_error_text)
values.merge!('display_name' => display_name)
values.merge!('queue_id' => @data_import.id)
values.merge!(success: @data_import.success) if @data_import.final_state?
values.merge!(original_url: @data_import.original_url)
values.merge!(data_type: @data_import.data_type)
values.merge!(warnings: get_warnings)
values.merge!(is_raster: @data_import.is_raster?)
if !@data_import.http_response_code.nil? && !@data_import.http_response_code.start_with?('2')
values.merge!(http_response_code: @data_import.http_response_code)
values.merge!(http_response_code_message: get_http_response_code_message(@data_import.http_response_code))
end
values
end
def get_error_text
if @data_import.error_code.nil?
nil
else
@data_import.error_code.blank? ? CartoDB::IMPORTER_ERROR_CODES[99999] : CartoDB::IMPORTER_ERROR_CODES[@data_import.error_code]
end
end
def display_name
if @data_import.service_name == 'twitter_search'
extract_twitter_display_name(@data_import)
else
url = [@data_import.data_source, @data_import.service_item_id].compact.select { |s| s != '' }.first
display_name = url.nil? ? @data_import.id : extract_filename(url)
display_name || @data_import.id
end
rescue => e
CartoDB.notify_debug(
'Error extracting display name',
data_import_id: @data_import.id,
service_item_id: @data_import.service_item_id,
data_source: @data_import.data_source,
exception: e.inspect
)
@data_import.id
end
def extract_filename(url)
URI.decode(File.basename(URI.parse(URI.encode(url.strip)).path))
end
def extract_twitter_display_name(data_import)
"Tweets about '#{JSON.parse(data_import.service_item_id)['categories'].map { |c| c['terms'] }.join(', ')}'"
rescue => e
CartoDB.notify_debug('Error extracting Twitter import display name', { data_import_id: data_import.id, service_item_id: data_import.service_item_id, data_source: data_import.data_source })
"Twitter search #{data_import.id}"
end
def get_http_response_code_message(http_response_code)
return nil if http_response_code.nil?
message = HTTP_RESPONSE_CODE_MESSAGES[http_response_code]
if message.nil?
message = case http_response_code
when /^1/ then "This usually means more actions are required to download this file."
when /^2/ then "This usually means everything went fine while fetching the file."
when /^3/ then "This usually means an unknown redirection error has occured."
when /^4/ then "This usually means an unknown client error has occured."
when /^5/ then "This usually means an unknown server error has occured."
else "This is an unkown type of HTTP status code."
end
end
message
end
# All warnings should be parsed into a single Hash here
def get_warnings
warnings = {}
if !@data_import.rejected_layers.nil?
warnings.merge!(rejected_layers: @data_import.rejected_layers.split(','))
warnings.merge!(user_max_layers: @data_import.user.max_layers)
end
warnings.merge!(JSON.parse(@data_import.runner_warnings)) if !@data_import.runner_warnings.nil?
warnings.empty? ? nil : warnings
end
end
end
end
@@ -0,0 +1,197 @@
require_dependency 'cartodb/errors'
module Carto
module Api
# Group metadata registration. Exclusively for usage from PostgreSQL Extension, not from the Editor.
# It only registers metadata, actual group roles management must be done by the extension.
# Named "DatabaseGroups" because it receives _databases_, not organizations.
class DatabaseGroupsController < ::ApplicationController
respond_to :json
ssl_required :create, :update, :destroy, :add_users, :remove_users, :update_permission, :destroy_permission
# TODO: Make this controller inherit from ::Api::ApplicationController and remove skip_before_filter bellow
skip_before_filter :verify_authenticity_token
# Allow HTTPS on local/test as the calls from the groups API are done sending a https X-Forwarded-Proto,
# like simulating they come from https:
# @see https://github.com/CartoDB/cartodb-postgresql/blob/bce61c1e4359653134134097d269edae581e5660/scripts-available/CDB_Groups_API.sql#L170
# Without it, SSL is forbidden and a 302 to url "without HTTP" was returned so the API didn't work on testing
def ssl_allowed?
Rails.env.development? || Rails.env.test?
end
before_filter :authenticate_extension
before_filter :load_parameters
before_filter :load_mandatory_group, :only => [:destroy, :add_users, :remove_users, :update_permission, :destroy_permission]
before_filter :load_user_from_username, :only => [:load_table, :update_permission, :destroy_permission]
before_filter :load_users_from_username, :only => [:add_users, :remove_users]
before_filter :load_table, :only => [:update_permission, :destroy_permission]
def create
group = Group.new_instance(@database_name, @name, @database_role)
if group.save
render json: group.to_json
else
render json: { errors: "Error saving group: #{group.errors}" }, status: 400
end
rescue CartoDB::ModelAlreadyExistsError => e
CartoDB.notify_debug('Group already exists', { params: params })
render json: { errors: "A group with that data already exists" }, status: 409
rescue => e
CartoDB.notify_exception(e, { params: params , group: (group ? group : 'not created') })
render json: { errors: e.message }, status: 500
end
def update
new_name = params['name']
group = get_group_from_loaded_parameters
if group
group.rename(new_name, @database_role)
if group.save
render json: @group.to_json
else
raise "Error saving group: #{@group.errors}"
end
else
renamed_group = get_group(@database_name, new_name)
if renamed_group && renamed_group.database_role == @database_role
CartoDB.notify_debug('Group already renamed', { params: params })
render json: { errors: "That group has already been renamed" }, status: 409
else
raise "Group not found and no matching rename found"
end
end
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
def destroy
@group.destroy
render json: {}, status: 204
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
def add_users
added_usernames = []
@usernames.map { |username|
begin
added_usernames << @group.add_user(username).user.username
rescue CartoDB::ModelAlreadyExistsError => e
# This will provoke 409 response later
end
}
if added_usernames.length == @usernames.length
render json: { users: added_usernames }, status: 200
else
render json: { errors: "Some users were already in the group: #{@usernames - added_usernames }", users: added_usernames }, status: 409
end
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
def remove_users
removed_usernames = []
@usernames.map { |username|
removed_user = @group.remove_user(username)
removed_usernames << removed_user.username if !removed_user.nil?
}
if removed_usernames.length == @usernames.length
render json: { users: removed_usernames }, status: 200
else
render json: { errors: "Some users (#{@usernames - removed_usernames}) were not in the group", users: removed_usernames }, status: 404
end
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
def update_permission
permission = CartoDB::Permission[@table.permission.id]
permission.set_group_permission(@group, @access)
permission.save
render json: {}, status: 200
rescue CartoDB::ModelAlreadyExistsError => e
CartoDB.notify_debug('Permission already granted', { params: params })
render json: { errors: "That permission is already granted" }, status: 409
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
def destroy_permission
permission = CartoDB::Permission[@table.permission.id]
permission.remove_group_permission(@group)
permission.save
render json: {}, status: 200
rescue CartoDB::ModelAlreadyExistsError => e
CartoDB.notify_debug('Permission already revoked', { params: params })
render json: { errors: "That permission is already revoked" }, status: 404
rescue => e
CartoDB.notify_exception(e, { params: params , group: (@group ? @group : 'not loaded') })
render json: { errors: e.message }, status: 500
end
private
def authenticate_extension
raise "missing org_metadata_api configuration" unless Cartodb.config[:org_metadata_api]
authenticate_or_request_with_http_basic do |username, password|
username == Cartodb.config[:org_metadata_api]["username"] && password == Cartodb.config[:org_metadata_api]["password"]
end
end
def load_parameters
@database_name = params[:database_name]
@name = [params[:old_name], params[:name]].compact.first
@database_role = params[:database_role]
@username = params[:username]
@usernames = @username.present? ? [ @username ] : params[:users]
@table_name = params[:table_name]
case params['access']
when nil
when 'r'
@access = CartoDB::Permission::ACCESS_READONLY
when 'w'
@access = CartoDB::Permission::ACCESS_READWRITE
else raise "Unknown access #{params['access']}"
end
end
def get_group_from_loaded_parameters
get_group(@database_name, @name)
end
def get_group(database_name, name)
Group.where(organization_id: Organization.find_by_database_name(database_name).id, name: name).first
end
def load_mandatory_group
@group = get_group_from_loaded_parameters
render json: { errors: "Group with database_name #{@database_name} and name #{@name} not found" }, status: 404 unless @group
end
def load_user_from_username
@user = Carto::User.where(username: @username).first
end
def load_users_from_username
@users = @usernames.map { |username| Carto::User.where(username: username).first }
end
def load_table
@table = Carto::Visualization.where(user_id: @user.id, type: 'table', name: @table_name).first
render json: { errors: "Table #{@username}.#{@table_name} not found" }, status: 404 unless @table
end
end
end
end
@@ -0,0 +1,22 @@
module Carto
module Api
class ExternalSourcePresenter
def initialize(external_source)
@external_source = external_source
end
def to_poro
return {} if @external_source.nil?
{
size: @external_source.size,
row_count: @external_source.rows_counted,
geometry_types: @external_source.geometry_types
}
end
end
end
end
@@ -0,0 +1,105 @@
module Carto
module Api
class GeocodingsController < ::Api::ApplicationController
GEOCODING_SQLAPI_CALLS_TIMEOUT = 45
ssl_required :index, :show, :available_geometries, :estimation_for
def index
# data_import_id is set for content guessing geocodes
# TODO: agree on a more flexible API with params
geocodings = Carto::Geocoding.where(
"user_id = ? AND (state NOT IN (?)) AND (data_import_id IS NULL)",
current_user.id, ['failed', 'finished', 'cancelled']
).all
render json: { geocodings: geocodings }, root: false
end
def show
geocoding = Carto::Geocoding.where(user_id: current_user.id, id: params[:id]).first
raise RecordNotFound unless geocoding
render json: geocoding.public_values
end
def available_geometries
case params[:kind]
when 'admin1'
return render(json: ['polygon'])
when 'namedplace'
return render(json: ['point'])
when 'postalcode'
return available_geometries_for_postalcode
else
return head(400)
end
end
def estimation_for
table = get_table(params[:table_name])
render_jsonp( { description: "table #{params[:table_name]} doesn't exist" }, 500) and return unless table
force_all_rows = (params[:force_all_rows].to_s == 'true')
total_rows = Carto::Geocoding.processable_rows(table, force_all_rows)
remaining_quota = uri_user.remaining_geocoding_quota
remaining_quota = (remaining_quota > 0 ? remaining_quota : 0)
used_credits = total_rows - remaining_quota
used_credits = (used_credits > 0 ? used_credits : 0)
render json: {
rows: total_rows,
estimation: (uri_user.geocoding_block_price.to_i * used_credits) / Carto::User::GEOCODING_BLOCK_SIZE.to_f
}
rescue => e
CartoDB.notify_exception(e, params: params)
render_jsonp( { description: e.message }, 500)
end
private
# TODO: this should be moved upwards in the controller hierarchy, and make it a replacement for current_user
def uri_user
@uri_user ||= Carto::User.where(id: current_user.id).first
end
def available_geometries_for_postalcode
return head(400) unless params[:free_text] || (params[:column_name] && params[:table_name])
input = params[:free_text].present? ? clean_free_text_input([params[:free_text]]) : select_distinct_from_table_and_column(params[:table_name], params[:column_name])
return head(400) if input.nil? && params[:table_name].present?
render(json: []) and return if input.nil? || input.empty?
list = input.map { |v| "'#{v.to_s.gsub("'", "''")}'" }.join(",")
internal_geocoder_api_config = CartoDB::GeocoderConfig.instance.get['internal']
.symbolize_keys
.merge(timeout: GEOCODING_SQLAPI_CALLS_TIMEOUT)
services = CartoDB::SQLApi.new(internal_geocoder_api_config)
.fetch("SELECT (admin0_available_services(Array[#{list}])).*")
geometries = []
points = services.select { |s| s['postal_code_points'] }.size
polygons = services.select { |s| s['postal_code_polygons'] }.size
geometries.append 'point' if points > 0 && points >= polygons
geometries.append 'polygon' if polygons > 0 && polygons >= points
render(json: geometries)
rescue => e
CartoDB.notify_exception(e, params: params)
end
def clean_free_text_input(input)
input.map{ |v| v.to_s.squish.downcase.gsub(/[^\p{Alnum}]/, '') }.reject(&:blank?)
end
def select_distinct_from_table_and_column(table_name, column_name)
table = get_table(table_name)
return nil if table.nil?
input = table.sequel.distinct.select_map(params[:column_name].to_sym)
end
def get_table(table_name)
Carto::Helpers::TableLocator.new.get_by_id_or_name(table_name, uri_user).try(&:service)
end
end
end
end
@@ -0,0 +1,36 @@
module Carto
module Api
class GrantablePresenter
def initialize(grantable)
@grantable = grantable
end
def to_poro
{
id: @grantable.id,
type: @grantable.type,
name: @grantable.name,
avatar_url: @grantable.avatar_url,
model: model_presenter.to_poro
}
end
private
def model_presenter
case @grantable.type
when 'user'
Carto::Api::UserPresenter.new(Carto::User.find(@grantable.id), fetch_groups: true)
when 'group'
Carto::Api::GroupPresenter.new(Carto::Group.find(@grantable.id), fetch_users: true)
else
raise "Unknown grantable type #{@grantable.type}"
end
end
end
end
end
@@ -0,0 +1,50 @@
require_dependency 'cartodb/errors'
module Carto
module Api
class GrantablesController < ::Api::ApplicationController
include PagedSearcher
include Carto::ControllerHelper
respond_to :json
ssl_required :index
before_filter :load_organization
VALID_ORDER_PARAMS = [:id, :name, :type, :avatar_url, :organization_id, :updated_at].freeze
def index
page, per_page, order, _order_direction = page_per_page_order_params(VALID_ORDER_PARAMS)
query = params[:q]
grantable_query = Carto::GrantableQueryBuilder.new(@organization).with_filter(query)
grantables = grantable_query.run(page, per_page, order)
total_entries = grantable_query.count
render_jsonp({
grantables: grantables.map { |g| Carto::Api::GrantablePresenter.new(g).to_poro },
total_entries: total_entries
}, 200)
rescue Carto::ParamInvalidError => e
render json: { errors: e.message }, status: e.status
rescue StandardError => e
CartoDB.notify_exception(e, { params: params })
render json: { errors: e.message }, status: 500
end
private
def load_organization
@organization = Carto::Organization.where(id: params['organization_id']).first
render json: { errors: "Organization #{params['organization_id']} not found" }, status: 404 unless @organization
render json: { errors: "You don't belong to organization #{params['organization_id']}" }, status: 400 unless current_user.organization_id == @organization.id
end
end
end
end
@@ -0,0 +1,67 @@
require_relative 'user_presenter'
module Carto
module Api
class GroupPresenter
FULL_FETCH_OPTIONS = {
fetch_shared_tables_count: true,
fetch_shared_maps_count: true,
fetch_users: true
}
def self.full(group)
Carto::Api::GroupPresenter.new(group, FULL_FETCH_OPTIONS)
end
# Available fetching options:
# - fetch_shared_tables_count
# - fetch_shared_maps_count
# - fetch_users
def initialize(group, fetching_options = {})
@group = group
@fetching_options = fetching_options
end
def to_poro
poro = {
id: @group.id,
organization_id: @group.organization_id,
name: @group.name,
display_name: @group.display_name
}
if @fetching_options[:fetch_shared_tables_count] == true
poro.merge!({ shared_tables_count: shared_tables_count })
end
if @fetching_options[:fetch_shared_maps_count] == true
poro.merge!({ shared_maps_count: shared_maps_count })
end
if @fetching_options[:fetch_users] == true
poro.merge!({ users: users })
end
poro
end
private
def shared_tables_count
shared_visualizations_query.where(:visualizations => { type: Carto::Visualization::TYPE_CANONICAL }).count
end
def shared_maps_count
shared_visualizations_query.where(:visualizations => { type: Carto::Visualization::TYPE_DERIVED }).count
end
def shared_visualizations_query
Carto::SharedEntity.where(recipient_id: @group.id).joins(:visualization)
end
def users
@group.users.map { |u| Carto::Api::UserPresenter.new(u, { fetch_groups: false } ).to_poro }
end
end
end
end
@@ -0,0 +1,178 @@
require_relative 'paged_searcher'
require_dependency 'cartodb/errors'
module Carto
module Api
class GroupsController < ::Api::ApplicationController
include PagedSearcher
include Carto::ControllerHelper
ssl_required :index, :show, :create, :update, :destroy, :add_users, :remove_users
before_filter :load_fetching_options, only: [:show, :index]
before_filter :load_organization
before_filter :load_user
before_filter :validate_organization_or_user_loaded
before_filter :load_group, only: [:show, :update, :destroy, :add_users, :remove_users]
before_filter :org_admin_only, only: [:create, :update, :destroy, :add_users, :remove_users]
before_filter :org_users_only, only: [:show, :index]
before_filter :load_organization_users, only: [:add_users, :remove_users]
before_filter :valid_password_confirmation, only: [:destroy, :add_users, :remove_users]
rescue_from Carto::ParamInvalidError, with: :rescue_from_carto_error
rescue_from Carto::PasswordConfirmationError, with: :rescue_from_password_confirmation_error
VALID_ORDER_PARAMS = [:id, :name, :display_name, :organization_id, :updated_at].freeze
def index
page, per_page, order, _order_direction = page_per_page_order_params(VALID_ORDER_PARAMS)
groups = @user ? @user.groups : @organization.groups
groups = groups.where('name ilike ?', "%#{params[:q]}%") if params[:q]
total_entries = groups.count
groups = Carto::PagedModel.paged_association(groups, page, per_page, order)
render_jsonp({
groups: groups.map { |g| Carto::Api::GroupPresenter.new(g, @fetching_options).to_poro },
total_entries: total_entries
}, 200)
end
def show
render_jsonp(Carto::Api::GroupPresenter.new(@group, @fetching_options).to_poro, 200)
end
def create
group = @organization.create_group(params['display_name'])
render_jsonp(Carto::Api::GroupPresenter.full(group).to_poro, 200)
rescue CartoDB::ModelAlreadyExistsError => e
CartoDB::Logger.debug(message: 'Group already exists', exception: e, params: params)
render json: { errors: ["A group with that name already exists"] }, status: 409
rescue ActiveRecord::StatementInvalid => e
handle_statement_invalid_error(e, group)
rescue => e
CartoDB::Logger.error(exception: e, params: params, group: group || 'no group', organization: @organization)
render json: { errors: [e.message] }, status: 500
end
def update
@group.rename_group_with_extension(params['display_name'])
render_jsonp(Carto::Api::GroupPresenter.full(@group).to_poro, 200)
rescue CartoDB::ModelAlreadyExistsError => e
CartoDB::Logger.debug(message: 'Group display name already exists', params: params)
render json: { errors: ["A group with that name already exists"] }, status: 409
rescue ActiveRecord::StatementInvalid => e
handle_statement_invalid_error(e, @group)
rescue => e
CartoDB::Logger.error(exception: e, params: params, group: @group)
render json: { errors: [e.message] }, status: 500
end
def destroy
@group.destroy_group_with_extension
render json: {}, status: 204
rescue ActiveRecord::StatementInvalid => e
handle_statement_invalid_error(e, @group)
rescue => e
CartoDB::Logger.error(exception: e, params: params, group: @group)
render json: { errors: [e.message] }, status: 500
end
def add_users
@group.add_users_with_extension(@organization_users)
render json: {}, status: 200
rescue ActiveRecord::StatementInvalid => e
handle_statement_invalid_error(e, @group)
rescue => e
CartoDB::Logger.error(exception: e, user: @user, params: params, group: @group)
render json: { errors: [e.message] }, status: 500
end
def remove_users
@group.remove_users_with_extension(@organization_users)
render json: {}, status: 200
rescue ActiveRecord::StatementInvalid => e
handle_statement_invalid_error(e, @group)
rescue => e
CartoDB::Logger.error(exception: e, user: @user, params: params, group: @group)
render json: { errors: [e.message] }, status: 500
end
private
def load_fetching_options
@fetching_options = {
fetch_shared_tables_count: params[:fetch_shared_tables_count] == 'true',
fetch_shared_maps_count: params[:fetch_shared_maps_count] == 'true',
fetch_users: params[:fetch_users] == 'true'
}
end
def load_organization
return unless params['organization_id'].present?
@organization = Carto::Organization.where(id: params['organization_id']).first
render json: { errors: ["Org. #{params['organization_id']} not found"] }, status: 404 unless @organization
end
def load_user
return unless params['user_id'].present?
@user = Carto::User.where(id: params['user_id']).first
render json: { errors: ["User #{params['user_id']} not found"] }, status: 404 unless @user
if @organization.nil?
@organization = @user.organization
elsif @user.organization_id != @organization.id
render json: { errors: ["You can't get other organization users"] }, status: 501
end
unless @user.id == current_user.id || current_user.organization_admin?
render json: { errors: ["You can't get other users groups"] }, status: 501
end
end
def validate_organization_or_user_loaded
render json: { errors: ["You must set user_id or organization_id"] }, status: 404 unless @organization || @user
end
def org_users_only
unless @organization.id == current_user.organization_id
render json: { errors: ["Not organization user"] }, status: 400
end
end
def org_admin_only
unless @organization.admin?(current_user)
render json: { errors: ["Not org. admin"] }, status: 400
end
end
def load_group
@group = @organization.groups.where(id: params['group_id']).first
render json: { errors: ["Group #{params['group_id']} not found"] }, status: 404 unless @group
end
def load_organization_users
ids = params['users'].present? ? params['users'] : [ params['user_id'] ]
@organization_users = ids.map { |id| @organization.users.where(id: id).first }
render json: { errors: ["Users #{ids} not found"] }, status: 404 if @organization_users.empty?
end
def handle_statement_invalid_error(e, group)
err_regexp = /ERROR: (.*)\n/
if e.message =~ err_regexp
render json: { errors: [err_regexp.match(e.message)[1]] }, status: 422
else
CartoDB::Logger.error(exception: e, params: params, group: group || 'no group', organization: @organization)
render json: { errors: [e.message] }, status: 500
end
end
def rescue_from_password_confirmation_error(error)
render_jsonp({ message: "Error modifying groups", errors: [error.message] }, 403)
end
end
end
end
@@ -0,0 +1,26 @@
module Carto
module Api
class ImageProxyController < ::Api::ApplicationController
ssl_required :show
def show
# image url
url = params[:url]
render :status => 404 if url.blank?
# fetch the image
http_client = Carto::Http::Client.get('image_proxy', log_requests: true)
response = http_client.get(url, followlocation: true, timeout: 3)
if response.code == 200
send_data response.response_body, type: response.headers['Content-Type'], disposition: 'inline'
else
render :status => 404
end
end
end
end
end
@@ -0,0 +1,118 @@
module Carto
module Api
class ImportsController < ::Api::ApplicationController
ssl_required :index, :show
ssl_allowed :service_token_valid?, :list_files_for_service, :get_service_auth_url, :validate_service_oauth_code,
:service_oauth_callback
def index
imports = DataImportsService.new.process_recent_user_imports(current_user)
render json: { imports: imports.map(&:id), success: true }
end
def show
import = DataImportsService.new.process_by_id(params[:id])
render_404 and return if import.nil?
data = Carto::Api::DataImportPresenter.new(import).api_public_values
if import.state == Carto::DataImport::STATE_COMPLETE
data[:any_table_raster] = import.is_raster?
decorate_twitter_import_data!(data, import)
decorate_default_visualization_data!(data, import)
end
render json: data
end
def service_token_valid?
valid = DataImportsService.new.validate_synchronization_oauth(uri_user, params[:id])
render_jsonp({ oauth_valid: valid, success: true })
rescue CartoDB::Datasources::TokenExpiredOrInvalidError => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: e.message }, 401)
rescue => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: e.message }, 400)
end
def list_files_for_service
filter = params[:filter].present? ? params[:filter] : []
results = DataImportsService.new.get_service_files(uri_user, params[:id], filter)
render_jsonp({ files: results, success: true })
rescue CartoDB::Datasources::TokenExpiredOrInvalidError => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: e.message }, 401)
rescue => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: { imports: e.message } }, 400)
end
def get_service_auth_url
auth_url = DataImportsService.new.get_service_auth_url(uri_user, params[:id])
render_jsonp({ url: auth_url, success: true})
rescue CartoDB::Datasources::TokenExpiredOrInvalidError => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: e.message }, 401)
rescue => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: { imports: e.message } }, 400)
end
def validate_service_oauth_code
success = DataImportsService.new.validate_service_oauth_code(uri_user, params[:id], params[:code])
render_jsonp({ success: success })
rescue CartoDB::Datasources::TokenExpiredOrInvalidError => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: e.message }, 401)
rescue => e
CartoDB.notify_exception(e, { user: uri_user, params: params })
render_jsonp({ errors: { imports: e.message } }, 400)
end
def service_oauth_callback
DataImportsService.new.validate_callback(uri_user, params[:id], params)
request.format = 'html'
respond_to do |format|
format.all { render text: '<script>window.close();</script>', content_type: 'text/html' }
end
rescue CartoDB::Datasources::TokenExpiredOrInvalidError => e
CartoDB::Logger.warning(message: "Expired oauth token", exception: e, user: uri_user, params: params)
render text: 'Expired token. Try reconnecting<script>setTimeout(function(){window.close()}, 1000);</script>',
content_type: 'text/html', status: 401
rescue => e
CartoDB::Logger.warning(message: "Error in oauth callback", exception: e, user: uri_user, params: params)
render text: 'Connection failed<script>setTimeout(function(){window.close()}, 1000);</script>',
content_type: 'text/html', status: 400
end
private
# TODO: this should be moved upwards in the controller hierarchy, and make it a replacement for current_user
def uri_user
@uri_user ||= Carto::User.where(id: current_user.id).first
end
def decorate_twitter_import_data!(data, data_import)
return if data_import.service_name != CartoDB::Datasources::Search::Twitter::DATASOURCE_NAME
audit_entry = ::SearchTweet.where(data_import_id: data_import.id).first
data[:tweets_georeferenced] = audit_entry.retrieved_items
data[:tweets_cost] = audit_entry.price
data[:tweets_overquota] = audit_entry.user.remaining_twitter_quota == 0
end
def decorate_default_visualization_data!(data, data_import)
derived_vis_id = nil
if data_import.create_visualization && !data_import.visualization_id.nil?
derived_vis_id = data_import.visualization_id
end
data[:derived_visualization_id] = derived_vis_id
end
end
end
end
@@ -0,0 +1,97 @@
module Carto
module Api
module InfowindowMigrator
MUSTACHE_ROOT_PATH = 'lib/assets/javascripts/builder/mustache-templates'.freeze
def migrate_builder_infowindow(templated_element, mustache_dir: 'infowindows')
return nil if templated_element.nil?
template = templated_element['template']
return templated_element if template.present?
templated_sym = templated_element.deep_symbolize_keys
old_template_name = templated_sym[:template_name]
return templated_element if old_template_name == 'none'
fields = templated_element['fields']
unless fields.present?
templated_element['template_name'] = 'none'
return templated_element
end
if MIGRATED_TEMPLATES.include?(old_template_name)
new_template_name = 'infowindow_color'
fixed_color = extract_color_from_old_template(old_template_name)
template_content_path = "#{MUSTACHE_ROOT_PATH}/#{mustache_dir}/infowindow_color.jst.mustache"
templated_element[:template] = get_template(
new_template_name,
templated_sym[:template],
template_content_path).gsub('#35AAE5', fixed_color)
templated_element[:headerColor] = {
color: {
opacity: 1,
fixed: fixed_color
}
}
else
new_template_name = parse_old_template_name(old_template_name)
templated_element[:template] = get_template(
old_template_name,
templated_sym[:template],
"#{MUSTACHE_ROOT_PATH}/#{mustache_dir}/#{get_template_name(old_template_name)}.jst.mustache")
end
templated_element[:template_name] = new_template_name
templated_element
end
private
INFOWINDOW_COLOR_TEMPLATE = 'infowindow_color'.freeze
MIGRATED_TEMPLATES = %w{ infowindow_light_header_blue infowindow_light_header_yellow
infowindow_light_header_orange infowindow_light_header_green }.freeze
COLOR_MAP = {
'blue' => '#35AAE5',
'green' => '#7FC97F',
'orange' => '#E68165',
'yellow' => '#E5C13D'
}.freeze
def extract_color_from_old_template(old_template_name)
COLOR_MAP[old_template_name.split('_').last]
end
def parse_old_template_name(old_template_name)
Pathname.new(old_template_name).basename.to_s
rescue => exception
CartoDB::Logger.error(message: "#{self.class}: Error parsing template",
exception: exception,
parsing: old_template_name)
old_template_name
end
def get_template_name(name)
Carto::Layer::TEMPLATES_MAP.fetch(name, name)
end
def get_template(template_name, fallback_template, template_path)
if template_name.present?
path = Rails.root.join(template_path)
File.read(path)
else
fallback_template
end
end
end
end
end
@@ -0,0 +1,22 @@
module Carto
module Api
class InvitationPresenter
def initialize(invitation)
@invitation = invitation
end
def to_poro
{
id: @invitation.id,
users_emails: @invitation.users_emails,
welcome_text: @invitation.welcome_text,
viewer: @invitation.viewer,
created_at: @invitation.created_at,
updated_at: @invitation.updated_at
}
end
end
end
end
@@ -0,0 +1,40 @@
module Carto
module Api
class InvitationsController < ::Api::ApplicationController
ssl_required :create
before_filter :load_organization
def create
@organization.update_attribute(:auth_username_password_enabled, true) if params[:enable_organization_signup] === true
invitation = Carto::Invitation.create_new(
Carto::User.find(current_user.id),
params[:users_emails],
params[:welcome_text],
params[:viewer]
)
if invitation.valid?
render_jsonp(Carto::Api::InvitationPresenter.new(invitation).to_poro)
else
render json: { errors: invitation.errors }, status: 400
end
rescue => e
CartoDB.notify_exception(e, params: params , invitation: (invitation ? invitation : 'not created'))
render json: { errors: e.message }, status: 500
end
private
def load_organization
@organization = Carto::Organization.where(id: params[:organization_id]).first
render_404 && return unless @organization
unless @organization.admin?(current_user)
render_jsonp({ errors: { organization: 'not admin' } }, 401) && return
end
end
end
end
end
@@ -0,0 +1,839 @@
require_dependency 'carto/api/infowindow_migrator'
require_dependency 'carto/table_utils'
module Carto
module Api
class LayerPresenter
include InfowindowMigrator
include Carto::TableUtils
PUBLIC_VALUES = %W{ options kind infowindow tooltip id order }
# CSS is not stored by default, only when sent by frontend,
# so this is returned whenever a layer that needs CSS but has none is requestesd
EMPTY_CSS = '#dummy{}'
TORQUE_ATTRS = %w(
table_name
user_name
property
blendmode
resolution
countby
torque-duration
torque-steps
torque-blend-mode
query
tile_style
named_map
visible
)
INFOWINDOW_KEYS = %w(
fields template_name template alternative_names width maxHeight
)
# Options:
# - viewer_user
# - user: owner user
# - with_style_properties: request style_properties generation if needed or not. Default: false.
def initialize(layer, options = {}, configuration = {}, decoration_data = {})
@layer = layer
@options = options
@configuration = configuration
@decoration_data = decoration_data
@viewer_user = options.fetch(:viewer_user, nil)
@owner_user = options.fetch(:user, nil)
@with_style_properties = options.fetch(:with_style_properties, false)
end
def to_poro(migrate_builder_infowindows: false)
poro = base_poro(@layer)
if migrate_builder_infowindows
if poro['infowindow'].present?
poro['infowindow'] = migrate_builder_infowindow(poro['infowindow'])
end
if poro['tooltip'].present?
poro['tooltip'] = migrate_builder_infowindow(poro['tooltip'], mustache_dir: 'tooltips')
end
end
poro
end
def to_json
public_values(@layer).to_json
end
def to_embed_poro
{
id: @layer.id,
options: @layer.options.select { |k| ['tile_style', 'style_version'].include?(k) }
}
end
def to_vizjson_v2
if base?(@layer)
with_kind_as_type(base_poro(@layer)).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: public_values(@layer).symbolize_keys[:options]['visible'],
options: options_data_v2
}
end
end
def to_vizjson_v1
return base_poro(@layer).symbolize_keys if base?(@layer)
{
id: @layer.id,
kind: 'CartoDB',
infowindow: infowindow_data_v1,
order: @layer.order,
options: options_data_v1
}
end
private
def viewer_is_owner?
return (@owner_user.id == @viewer_user.id) if (@owner_user && @viewer_user)
# This can be removed if 'user_name' support is dropped
layer_opts = @layer.options.nil? ? Hash.new : @layer.options
if @viewer_user && layer_opts['user_name'] && layer_opts['table_name']
@viewer_user.username == layer_opts['user_name']
else
true
end
end
# INFO: Assumes table_name needs to always be qualified, don't call if doesn't
def qualify_table_name
layer_opts = @layer.options.nil? ? Hash.new : @layer.options
# 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
return layer_opts['table_name'] if layer_opts['table_name'].include?('.')
if @owner_user && @viewer_user
@layer.qualified_table_name(@owner_user)
else
# TODO: Legacy support: Remove 'user_name' and use always :viewer_user and :user
user_name = layer_opts['user_name']
if user_name.include?('-')
"\"#{layer_opts['user_name']}\".#{safe_table_name_quoting(layer_opts['table_name'])}"
else
"#{layer_opts['user_name']}.#{safe_table_name_quoting(layer_opts['table_name'])}"
end
end
end
def base_poro(layer)
# .merge left for backwards compatibility
public_values(layer).merge('options' => layer_options)
end
def public_values(layer)
Hash[ PUBLIC_VALUES.map { |attribute| [attribute, layer.send(attribute)] } ]
end
# 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_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[:template] = File.read(path)
infowindow
end
def layer_options
layer_opts = @layer.options.nil? ? Hash.new : @layer.options
if layer_opts['table_name'] && !viewer_is_owner?
layer_opts['table_name'] = qualify_table_name
end
if @with_style_properties &&
(layer_opts['style_properties'].nil? || layer_opts['style_properties']['autogenerated'] == true)
StylePropertiesGenerator.new(@layer).migrate(layer_opts)
end
layer_opts
end
def options_data_v1
return @layer.options if @options[:full]
@layer.options.select { |key, value| public_options.include?(key.to_s) }
end
def options_data_v2
if @options[:full]
decorate_with_data(@layer.options, @decoration_data)
else
sql = sql_from(@layer.options)
data = {
sql: wrap(sql, @layer.options),
layer_name: name_for(@layer),
cartocss: css_from(@layer.options),
cartocss_version: @layer.options.fetch('style_version'), # Mandatory
interactivity: @layer.options.fetch('interactivity') # Mandatory
}
data = decorate_with_data(data, @decoration_data)
if @viewer_user
if @layer.options['table_name'] && !viewer_is_owner?
data['table_name'] = qualify_table_name
end
end
data
end
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: 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 name_for(layer)
layer_alias = layer.options.fetch('table_name_alias', nil)
table_name = layer.options['table_name']
return table_name unless layer_alias && !layer_alias.empty?
layer_alias
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 : 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 viewer_is_owner?
"select * from #{safe_table_name_quoting(layer_options['table_name'])}"
else
"select * from #{qualify_table_name}"
end
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
end
# Used to migrate `wizard_properties` to `style_properties`
class StylePropertiesGenerator
def initialize(layer)
@layer = layer
@wizard_properties = @layer.options['wizard_properties']
@source_type = @wizard_properties.present? ? @wizard_properties['type'] : nil
end
def migrate(options)
return nil unless @wizard_properties.present?
wpp = @wizard_properties['properties']
type = if @source_type == 'density'
wpp['geometry_type'] == 'Rectangles' ? 'squares' : 'hexabins'
elsif @source_type == 'torque_heat'
wpp['heat-animated'] ? 'animation' : 'heatmap'
else
STYLE_PROPERTIES_TYPE[@source_type]
end
return nil unless type
options['cartocss_custom'] = options['tile_style_custom'] || false
options['cartocss_history'] = options['tile_style_history'] || []
options['style_properties'] = {
'autogenerated' => true,
'type' => type,
'properties' => wizard_properties_properties_to_style_properties_properties(wpp, type)
}
merge_into_if_present(options['style_properties']['properties'], 'aggregation', generate_aggregation(wpp))
if SOURCE_TYPES_WITH_SQL_WRAP.include?(@source_type)
options['sql_wrap'] = options['query_wrapper']
end
if @source_type == 'cluster'
options['cartocss_custom'] = true
end
if type == 'animation' && @layer.widgets.where(type: 'time-series').none?
create_time_series_widget(wpp)
end
end
private
STYLE_PROPERTIES_TYPE = {
'polygon' => 'simple',
'bubble' => 'simple',
'choropleth' => 'simple',
'category' => 'simple',
'torque' => 'animation',
'torque_cat' => 'animation',
'cluster' => 'simple'
}.freeze
SOURCE_TYPES_WITH_SQL_WRAP = ['cluster', 'density', 'torque_cat'].freeze
def set_if_present(hash, key, value)
# Dirty check because `false` is a valid `value`
hash[key] = value if value.present?
hash
end
def merge_into_if_present(hash, key, hash_value)
hash[key] = hash_value.deep_merge!(hash[key] || {}) if hash_value.present?
hash
end
def apply_direct_mapping(hash, original_hash, mapping)
mapping.each do |source, target|
set_if_present(hash, target, original_hash[source])
end
hash
end
def apply_default_opacity(hash)
if hash['fixed'] && !hash['opacity']
hash['opacity'] = 1
end
hash
end
PROPERTIES_DIRECT_MAPPING = {
"marker-comp-op" => "blending",
"torque-blend-mode" => "blending",
"line-comp-op" => "blending"
}.freeze
BLENDING_ALIAS = {
'source-over' => 'src-over'
}.freeze
ANIMATED_TYPES = ['animation', 'heatmap'].freeze
def wizard_properties_properties_to_style_properties_properties(wizard_properties_properties, type)
spp = {}
wpp = wizard_properties_properties
return spp unless wpp
apply_direct_mapping(spp, wpp, PROPERTIES_DIRECT_MAPPING)
if spp['blending'].blank?
spp['blending'] = 'none'
else
spp['blending'] = BLENDING_ALIAS.fetch(spp['blending'], spp['blending'])
end
merge_into_if_present(spp, 'stroke', generate_stroke(wpp))
merge_into_if_present(spp, drawing_property(wpp), generate_drawing_properties(wpp))
merge_into_if_present(spp, 'labels', generate_labels(wpp))
if ANIMATED_TYPES.include?(type)
merge_into_if_present(spp, 'animated', generate_animated(wpp))
end
set_animated_style(spp) if type == 'animation'
set_property(spp, wpp)
spp
end
def drawing_property(wpp)
wpp['geometry_type'] == 'line' ? 'stroke' : 'fill'
end
def set_property(spp, wpp)
return unless wpp['property']
drawing_property = drawing_property(wpp)
destination = case @source_type
when 'bubble'
spp[drawing_property]['size']
when 'choropleth', 'category'
spp[drawing_property]['color']
when 'torque', 'torque_heat', 'torque_cat'
spp['animated']
when 'density'
spp['aggregation']['value']
else
# Ignore some malformed wizards that have a property set even when the type does not support it
return
end
destination['attribute'] = wpp['property']
end
def set_animated_style(spp)
spp['style'] = @source_type == 'torque_heat' ? 'heatmap' : 'simple'
end
def generate_drawing_properties(wpp)
fill = {}
merge_into_if_present(fill, @source_type == 'bubble' ? 'size' : 'color', generate_dimension_properties(wpp))
case @source_type
when 'polygon', 'torque', 'torque_cat'
fill['size'] = { 'fixed' => wpp['marker-width'] }.merge(fill['size'] || {})
when 'choropleth', 'category'
fill['size'] = { 'fixed' => 10 }.merge(fill['size'] || {})
when 'torque_heat'
fill['size'] = { 'fixed' => 35 }.merge(fill['size'] || {})
end
merge_into_if_present(fill, 'color', generate_color(wpp))
fill
end
STROKE_FROM_POINT_MAPPING = {
'size' => {
"marker-line-width" => 'fixed'
},
'color' => {
"marker-line-color" => 'fixed',
"marker-line-opacity" => 'opacity'
}
}.freeze
STROKE_FROM_NON_POINT_MAPPING = {
'size' => {
"line-width" => 'fixed'
},
'color' => {
"line-color" => 'fixed',
"line-opacity" => 'opacity'
}
}.freeze
def generate_stroke(wpp)
stroke_mapping = if wpp['geometry_type'] == 'point' && @source_type != 'density'
STROKE_FROM_POINT_MAPPING
else
STROKE_FROM_NON_POINT_MAPPING
end
stroke = {}
merge_into_if_present(stroke, 'size', apply_direct_mapping({}, wpp, stroke_mapping['size']))
merge_into_if_present(stroke, 'color', apply_direct_mapping({}, wpp, stroke_mapping['color']))
stroke
end
TORQUE_HEAT_COLOR_DEFAULTS = {
'attribute' => 'points_agg',
'range' => ['blue', 'cyan', 'lightgreen', 'yellow', 'orange', 'red'],
'bins' => 6
}.freeze
def generate_color(wpp)
color = {}
%w(polygon marker).each do |prefix|
set_if_present(color, 'fixed', wpp["#{prefix}-fill"])
unless color['opacity']
set_if_present(color, 'opacity', wpp["#{prefix}-opacity"])
end
apply_default_opacity(color)
end
if wpp['categories'].present?
color['range'] = wpp['categories'].map { |c| c['color'] }
color['domain'] = wpp['categories'].map { |c| c['title'] }
end
color_attribute = if wpp['property_cat']
wpp['property_cat']
elsif @source_type == 'density'
'agg_value'
end
color['attribute'] = color_attribute if color_attribute
color.merge!(TORQUE_HEAT_COLOR_DEFAULTS) if @source_type == 'torque_heat'
color
end
SIZE_DIRECT_MAPPING = {
'qfunction' => 'quantification'
}.freeze
QUANTIFICATION_MAPPING = {
'Jenks' => 'jenks',
'Equal Interval' => 'equal',
'Heads/Tails' => 'headtails',
'Quantile' => 'quantiles'
}.freeze
COLOR_RANGE_SOURCE_TYPES = ['choropleth', 'density'].freeze
def generate_dimension_properties(wpp)
size = {}
radius_min = wpp['radius_min']
radius_max = wpp['radius_max']
if radius_min && radius_max
size['range'] = [radius_min, radius_max]
end
apply_direct_mapping(size, wpp, SIZE_DIRECT_MAPPING)
quantification = size['quantification']
size['quantification'] = QUANTIFICATION_MAPPING.fetch(quantification, quantification) if quantification.present?
if %w{ bubble category }.include?(@source_type)
size['bins'] = 10
end
if COLOR_RANGE_SOURCE_TYPES.include?(@source_type)
# Commented because it might not be definitive
# size['range'] = colorbrewer_ramp_array_from_color_ramp(wpp['color_ramp'])
size['range'] = wpp['color_ramp']
size['bins'] = extract_bins_from_method(wpp['method']).to_i
end
size
end
ANIMATED_DIRECT_MAPPING = {
'torque-cumulative' => 'overlap',
'torque-duration' => 'duration',
'torque-frame-count' => 'steps',
'torque-resolution' => 'resolution',
'torque-trails' => 'trails'
}.freeze
DEFAULT_ANIMATED = {
'attribute' => nil,
'overlap' => false,
'duration' => 30,
'steps' => 256,
'resolution' => 2,
'trails' => 2
}.freeze
def generate_animated(wpp)
animated = {}
apply_direct_mapping(animated, wpp, ANIMATED_DIRECT_MAPPING)
DEFAULT_ANIMATED.merge(animated)
end
AGGREGATION_SOURCE_TYPES = %w{ density torque_heat }.freeze
def generate_aggregation(wpp)
return {} unless AGGREGATION_SOURCE_TYPES.include?(@source_type)
size = case @source_type
when 'density'
wpp['polygon-size'] || 100
when 'torque_heat'
wpp['torque-resolution']
else
raise "Unsupported source type for aggregation: #{@source_type}"
end
{
"size" => size,
"value" => {
"operator" => 'COUNT',
"attribute" => ''
}
}
end
# Taken from `lib/assets/javascripts/cartodb/models/color_ramps.js`
COLOR_ARRAYS_FROM_RAMPS = {
'pink' => "['#E7E1EF', '#C994C7', '#DD1C77']",
'red' => "['#FFEDA0', '#FEB24C', '#F03B20']",
'black' => "['#F0F0F0', '#BDBDBD', '#636363']",
'green' => "['#E5F5F9', '#99D8C9', '#2CA25F']",
'blue' => "['#EDF8B1', '#7FCDBB', '#2C7FB8']",
'inverted_pink' => "['#DD1C77','#C994C7','#E7E1EF']",
'inverted_red' => "['#F03B20','#FEB24C','#FFEDA0']",
'inverted_black' => "['#636363','#BDBDBD','#F0F0F0']",
'inverted_green' => "['#2CA25F','#99D8C9','#E5F5F9']",
'inverted_blue' => "['#2C7FB8','#7FCDBB','#EDF8B1']",
'spectrum1' => "['#1a9850', '#fff2cc', '#d73027']",
'spectrum2' => "['#0080ff', '#fff2cc', '#ff4d4d']",
'blue_states' => "['#ECF0F6', '#6182B5', '#43618F']",
'purple_states' => "['#F1E6F1', '#B379B3', '#8A4E8A']",
'red_states' => "['#F2D2D3', '#D4686C', '#C1373C']",
'inverted_blue_states' => "['#43618F', '#6182B5', '#ECF0F6']",
'inverted_purple_states' => "['#8A4E8A', '#B379B3', '#F1E6F1']",
'inverted_red_states' => "['#C1373C', '#D4686C', '#F2D2D3']"
}.freeze
def colorbrewer_ramp_array_from_color_ramp(ramp)
return [] unless ramp
COLOR_ARRAYS_FROM_RAMPS[ramp]
end
DEFAULT_BINS = 6
def extract_bins_from_method(method)
return DEFAULT_BINS unless method
number_match = method.match(/(\d*) Buckets/i)
number_match && number_match[1] ? number_match[1] : DEFAULT_BINS
end
TEXT_DIRECT_MAPPING = {
'text-name' => 'attribute',
'text-face-name' => 'font',
'text-dy' => 'offset',
'text-allow-overlap' => 'overlap',
'text-placement-type' => 'placement'
}.freeze
DEFAULT_LABELS = {
'enabled' => false,
'attribute' => nil,
'font' => 'DejaVu Sans Book',
'fill' => {
'size' => {
'fixed' => 10
},
'color' => {
'fixed' => '#000',
'opacity' => 1
}
},
'halo' => {
'size' => {
'fixed' => 1
},
'color' => {
'fixed' => '#111',
'opacity' => 1
}
},
'offset' => -10,
'overlap' => true,
'placement' => 'point'
}.freeze
def generate_labels(wpp)
labels = {}
apply_direct_mapping(labels, wpp, TEXT_DIRECT_MAPPING)
labels['attribute'] = nil if labels['attribute'].to_s.downcase == 'none'
merge_into_if_present(labels, 'fill', generate_labels_fill(wpp))
merge_into_if_present(labels, 'halo', generate_labels_halo(wpp))
labels['enabled'] = labels['attribute'].present?
DEFAULT_LABELS.merge(labels)
end
def generate_labels_fill(wpp)
labels_fill = {}
merge_into_if_present(labels_fill, 'size', generate_labels_fill_size(wpp))
merge_into_if_present(labels_fill, 'color', generate_labels_fill_color(wpp))
labels_fill
end
TEXT_SIZE_DIRECT_MAPPING = {
'text-size' => 'fixed'
}.freeze
TEXT_COLOR_DIRECT_MAPPING = {
'text-fill' => 'fixed',
'text-opacity' => 'opacity'
}.freeze
def generate_labels_fill_size(wpp)
size = {}
apply_direct_mapping(size, wpp, TEXT_SIZE_DIRECT_MAPPING)
size
end
def generate_labels_fill_color(wpp)
color = {}
apply_direct_mapping(color, wpp, TEXT_COLOR_DIRECT_MAPPING)
apply_default_opacity(color)
color
end
def generate_labels_halo(wpp)
labels_halo = {}
merge_into_if_present(labels_halo, 'size', generate_labels_halo_size(wpp))
merge_into_if_present(labels_halo, 'color', generate_labels_halo_color(wpp))
labels_halo
end
HALO_SIZE_DIRECT_MAPPING = {
'text-halo-radius' => 'fixed'
}.freeze
HALO_COLOR_DIRECT_MAPPING = {
'text-halo-fill' => 'fixed',
'text-halo-opacity' => 'opacity'
}.freeze
def generate_labels_halo_size(wpp)
size = {}
apply_direct_mapping(size, wpp, HALO_SIZE_DIRECT_MAPPING)
size
end
def generate_labels_halo_color(wpp)
color = {}
apply_direct_mapping(color, wpp, HALO_COLOR_DIRECT_MAPPING)
apply_default_opacity(color)
color
end
def create_time_series_widget(wpp)
if wpp['property'] && @layer.options[:source]
@layer.widgets.create(
type: 'time-series',
order: 0,
title: 'time_date__t',
options: {
column: wpp['property'],
bins: 256,
sync_on_data_change: true,
sync_on_bbox_change: true
},
source_id: @layer.options[:source]
)
end
end
end
end
end
@@ -0,0 +1,73 @@
module Carto
module Api
class LayerVizJSONAdapter
extend Forwardable
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'
}
delegate [:options, :kind, :id, :order, :legend, :user] => :layer
attr_reader :layer
def initialize(layer)
@layer = layer
end
def public_values
{
'options' => options,
# TODO: kind should be renamed to type
# rename once a new layer presenter is written. See CartoDB::LayerModule::Presenter#with_kind_as_type
# TODO: use symbols instead of strings
'kind' => kind,
'infowindow' => infowindow,
'tooltip' => tooltip,
'id' => id,
'order' => order
}
end
def get_presenter(options, configuration)
Carto::Api::LayerPresenter.new(self, options, configuration)
end
def infowindow
@layer.infowindow
end
def tooltip
@layer.tooltip
end
def infowindow_template_path
if infowindow.present? && infowindow['template_name'].present?
template_name = TEMPLATES_MAP.fetch(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 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")
else
nil
end
end
end
end
end
@@ -0,0 +1,283 @@
require_dependency 'carto/uuidhelper'
module Carto
module Api
class LayersController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :show, :layers_by_map, :custom_layers_by_user, :map_index, :user_index, :map_show, :user_show,
:map_create, :user_create, :map_update, :user_update, :map_destroy, :user_destroy
before_filter :ensure_current_user, only: [:user_index, :user_show, :user_create, :user_update, :user_destroy]
before_filter :load_user_layer, only: [:user_show, :user_destroy]
before_filter :load_user_layers, only: [:user_update]
before_filter :load_map, only: [:map_index, :map_show, :map_create, :map_update, :map_destroy]
before_filter :ensure_writable_map, only: [:map_create, :map_update, :map_destroy]
before_filter :load_map_layer, only: [:map_show, :map_destroy]
before_filter :load_map_layers, only: [:map_update]
rescue_from LoadError,
UnprocesableEntityError,
UnauthorizedError, with: :rescue_from_carto_error
def map_index
index(@map.layers)
end
def user_index
index(@user.layers, owner: @user)
end
def map_show
show(@map.user)
end
def user_show
show(current_user)
end
def map_create
layer = Carto::Layer.new(layer_attributes(params))
validate_for_map(layer)
save_layer(layer) do
@map.layers << layer
@map.process_privacy_in(layer)
from_layer = Carto::Layer.where(id: params[:from_layer_id]).first if params[:from_layer_id]
from_letter = params[:from_letter]
update_layer_node_styles(layer, from_layer, from_letter)
end
end
def user_create
layer = Carto::Layer.new(layer_attributes(params))
save_layer(layer) { @user.layers << layer }
end
def map_update
update
end
def user_update
update
end
def map_destroy
destroy
end
def user_destroy
destroy
end
private
def validate_for_map(layer)
unless @map.can_add_layer?(current_user, layer)
raise UnprocesableEntityError.new('Cannot add more layers to this visualization')
end
unless @map.admits_layer?(layer)
raise UnprocesableEntityError.new('Cannot add more layers of this type')
end
table_name = layer.options['table_name']
user_name = layer.options['user_name']
if user_name.present?
table_name = user_name + '.' + table_name
end
if layer.data_layer?
table_visualization = Helpers::TableLocator.new.get_by_id_or_name(
table_name,
current_user
).visualization
unless table_visualization.has_read_permission?(current_user)
raise UnauthorizedError.new('You do not have permission in the layer you are trying to add')
end
end
end
def index(layers, owner: nil)
presented_layers = layers.map do |layer|
Carto::Api::LayerPresenter.new(layer, viewer_user: current_user, user: owner || owner_user(layer)).to_poro
end
render_jsonp layers: presented_layers, total_entries: presented_layers.size
end
def show(owner)
render_jsonp Carto::Api::LayerPresenter.new(@layer, viewer_user: current_user, user: owner).to_json
end
# Takes a block, executed after saving
def save_layer(layer)
if layer.save
yield
render_jsonp Carto::Api::LayerPresenter.new(layer, viewer_user: current_user).to_poro
else
CartoDB::Logger.error(message: 'Error creating layer', errors: layer.errors.full_messages)
raise UnprocesableEntityError.new(layer.errors.full_messages)
end
end
def update
layers = @layers.map do |layer|
layer_params = params[:layers].present? ? params[:layers].find { |p| p['id'] == layer.id } : params
# don't allow to override table_name and user_name
new_layer_options = layer_params[:options]
['table_name', 'user_name'].each do |key|
if layer.options.include?(key)
new_layer_options[key] = layer.options[key]
else
new_layer_options.delete(key)
end
end
unless layer.update_attributes(layer_attributes(layer_params))
raise UnprocesableEntityError.new(layer.errors.full_messages)
end
layer
end
if layers.count > 1
render_jsonp(layers: layers.map { |l| Carto::Api::LayerPresenter.new(l, viewer_user: current_user).to_poro })
else
render_jsonp Carto::Api::LayerPresenter.new(layers[0], viewer_user: current_user).to_poro
end
rescue RuntimeError => e
CartoDB::Logger.error(message: 'Error updating layer', exception: e)
render_jsonp({ description: e.message }, 400)
end
def destroy
@layer.destroy
head :no_content
end
def layer_attributes(param)
param.slice(:options, :kind, :infowindow, :tooltip, :order).permit!
end
def ensure_current_user
user_id = uuid_parameter(:user_id)
raise UnauthorizedError unless current_user.id == user_id
@user = Carto::User.find(user_id)
end
def load_user_layer
load_user_layers
raise LoadError.new('Layer not found') unless @layers.length == 1
@layer = @layers.first
end
def load_user_layers
@layers = layers_ids.map { |id| @user.layers.find(id) }
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Layer not found')
end
def load_map
map_id = uuid_parameter(:map_id)
# User must be owner or have permissions for the map's visualization
@map = Carto::Map.find(map_id)
vis = @map.visualization
raise LoadError.new('Map not found') unless vis.try(:is_viewable_by_user?, current_user)
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Map not found')
end
def ensure_writable_map
raise UnauthorizedError unless @map.visualization.writable_by?(current_user)
end
def load_map_layer
load_map_layers
raise LoadError.new('Layer not found') unless @layers.length == 1
@layer = @layers.first
end
def load_map_layers
@layers = layers_ids.map { |id| @map.layers.find(id) }
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Layer not found')
end
def layers_ids
if params[:id]
[params[:id]]
elsif params[:layers]
params[:layers].map { |l| l['id'] }
else
raise LoadError.new('Layer not found')
end
end
def owner_user(layer)
if current_user.nil? || @map.user.id != current_user.id
# This keeps backwards compatibility with map user assignment. See #8974
@map.user
elsif layer.options && layer.options['user_name'].present?
::User.where(username: layer.options['user_name']).first
else
layer.user
end
end
def update_layer_node_styles(to_layer, from_layer, from_letter)
to_letter = to_layer.options['letter']
to_source = to_layer.options['source']
if from_layer.present? && from_letter.present? && to_letter.present? && to_source.present?
move_layer_node_styles(from_layer, from_letter, to_layer, to_letter, to_source)
update_source_layer_styles(from_layer, from_letter, to_letter, to_source)
end
rescue => e
CartoDB::Logger.error(
message: 'Error updating layer node styles',
exception: e,
from_layer: from_layer,
from_letter: from_letter,
to_layer: to_layer
)
end
def move_layer_node_styles(from_layer, from_letter, to_layer, to_letter, to_source)
source_node_number = to_source[1..-1].to_i
nodes_to_move = from_layer.layer_node_styles.select do |lns|
lns.source_id.starts_with?(from_letter) && lns.source_id[1..-1].to_i < source_node_number
end
nodes_to_move.each do |lns|
# Move LayerNodeStyles from the old layer if given.
lns.source_id = lns.source_id.gsub(from_letter, to_letter)
to_layer.layer_node_styles << lns
end
end
def update_source_layer_styles(from_layer, from_letter, to_letter, to_source)
if from_letter != to_letter
# Dragging middle node: rename the moved node
node_id_to_fix = to_source.gsub(to_letter, from_letter)
style_node = ::LayerNodeStyle.where(layer_id: from_layer.id, source_id: node_id_to_fix).first
if style_node
style_node.source_id = to_source
style_node.save
end
else
# Dragging head node: remove unneeded old styles in the old layer
from_layer.reload
from_layer.layer_node_styles.select { |lns|
lns.source_id.starts_with?(from_letter) && lns.source_id != to_source
}.each(&:destroy)
end
end
end
end
end
@@ -0,0 +1,24 @@
module Carto
module Api
class LegendPresenter
def initialize(legend)
@legend = legend
end
def to_hash
{
conf: @legend.conf,
created_at: @legend.created_at,
definition: @legend.definition,
id: @legend.id,
layer_id: @legend.layer_id,
post_html: @legend.post_html,
pre_html: @legend.pre_html,
title: @legend.title,
type: @legend.type,
updated_at: @legend.updated_at
}
end
end
end
end
@@ -0,0 +1,89 @@
module Carto
module Api
class LegendsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :index, :show, :create, :update, :destroy
before_filter :load_layer,
:owners_only
before_filter :load_legend, only: [:show, :update, :destroy]
rescue_from Carto::LoadError,
Carto::UnauthorizedError,
Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def index
legend_presentations = @layer.legends.map do |legend|
LegendPresenter.new(legend).to_hash
end
render_jsonp(legend_presentations, :ok)
end
def show
legend_presentation = LegendPresenter.new(@legend).to_hash
render_jsonp(legend_presentation, :ok)
end
def create
legend_params_with_layer_id = legend_params.merge(layer_id: @layer.id)
legend = Legend.create(legend_params_with_layer_id)
if legend.valid?
legend_presentation = LegendPresenter.new(legend).to_hash
render_jsonp(legend_presentation, :created)
else
error = legend.errors.full_messages.join(', ')
raise Carto::UnprocesableEntityError.new(error)
end
end
def update
@legend.update_attributes!(legend_params)
legend_presentation = LegendPresenter.new(@legend).to_hash
render_jsonp(legend_presentation, :ok)
rescue ActiveRecord::RecordInvalid
error = @legend.errors.full_messages.join(', ')
raise Carto::UnprocesableEntityError.new(error)
end
def destroy
@legend.destroy
render_jsonp({}, :no_content)
end
private
def load_layer
@layer = Carto::Layer.find(params[:layer_id])
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new('Layer not found')
end
def owners_only
visualization = Carto::Visualization.find(params[:visualization_id])
unless visualization.layers.include?(@layer) &&
visualization.writable_by?(current_viewer)
raise Carto::UnauthorizedError.new
end
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new('Visualization not found')
end
def load_legend
@legend = @layer.legends.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new('Legend not found')
end
def legend_params
params.slice(:title, :pre_html, :post_html, :type, :definition, :conf).permit!
end
end
end
end
@@ -0,0 +1,26 @@
module Carto
module Api
class MapPresenter
def initialize(map)
@map = map
end
def to_hash
{
bounding_box_ne: @map.bounding_box_ne,
bounding_box_sw: @map.bounding_box_sw,
center: @map.center,
options: @map.options,
id: @map.id,
provider: @map.provider,
user_id: @map.user_id,
view_bounds_ne: @map.view_bounds_ne,
view_bounds_sw: @map.view_bounds_sw,
zoom: @map.zoom,
legends: @map.legends,
scrollwheel: @map.scrollwheel
}
end
end
end
end
@@ -0,0 +1,20 @@
module Carto
module Api
class MapcapPresenter
def initialize(mapcap)
@mapcap = mapcap
end
def to_poro
return {} unless @mapcap
{
id: @mapcap.id,
related_ids: @mapcap.ids_json,
created_at: @mapcap.created_at
}
end
end
end
end
@@ -0,0 +1,80 @@
require_relative '../builder/builder_users_module'
require_dependency 'carto/tracking/events'
module Carto
module Api
class MapcapsController < ::Api::ApplicationController
include Carto::ControllerHelper
include Carto::Builder::BuilderUsersModule
ssl_required :show, :create, :destroy, :index
before_filter :builder_users_only,
:load_visualization,
:owners_only
before_filter :load_mapcap, only: [:show, :destroy]
after_filter :track_published_map, only: :create
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::LoadError,
Carto::UnauthorizedError,
Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def index
render_jsonp(@visualization.mapcaps.map { |mapcap| Carto::Api::MapcapPresenter.new(mapcap).to_poro })
end
def create
mapcap = @visualization.create_mapcap!
render_jsonp(Carto::Api::MapcapPresenter.new(mapcap).to_poro, :created)
rescue ActiveRecord::RecordInvalid => exception
message = exception.record.errors.full_messages.join(', ')
raise Carto::UnprocesableEntityError.new(message)
end
def show
render_jsonp(Carto::Api::MapcapPresenter.new(@mapcap).to_poro)
end
def destroy
@mapcap.destroy
render_jsonp({}, :no_content)
end
private
def load_visualization
visualization_id = uuid_parameter(:visualization_id)
@visualization = Carto::Visualization.find(visualization_id)
raise ActiveRecord::RecordNotFound if @visualization.canonical?
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new("Visualization not found: #{visualization_id}")
end
def owners_only
raise Carto::UnauthorizedError.new unless @visualization.writable_by?(current_user)
end
def load_mapcap
mapcap_id = uuid_parameter(:id)
@mapcap = Carto::Mapcap.find(mapcap_id)
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new("Mapcap not found: #{mapcap_id}")
end
def track_published_map
current_viewer_id = current_viewer.id
Carto::Tracking::Events::PublishedMap.new(current_viewer_id,
user_id: current_viewer_id,
visualization_id: @visualization.id).report
end
end
end
end
@@ -0,0 +1,67 @@
module Carto
module Api
class MapsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :show, :update
before_filter :load_map, :owners_only
rescue_from Carto::LoadError,
Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def show
render_jsonp(map_presentation)
end
def update
@map.update_attributes!(update_params)
render_jsonp(map_presentation)
rescue ActiveRecord::RecordInvalid
validation_errors = @map.errors.full_messages.join(', ')
raise Carto::UnprocesableEntityError.new(validation_errors)
end
private
def load_map
@map = Carto::Map.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise Carto::LoadError.new('Map not found')
end
def owners_only
unless @map.writable_by_user?(current_viewer)
raise Carto::LoadError.new('Map not found')
end
end
STRING_PARAMS = [:bounding_box_sw, :bounding_box_ne, :center, :view_bounds_sw, :view_bounds_ne].freeze
def update_params
update_params = params.slice(:bounding_box_ne,
:bounding_box_sw,
:center,
:options,
:provider,
:view_bounds_ne,
:view_bounds_sw,
:zoom,
:legends,
:scrollwheel).permit!
STRING_PARAMS.each do |param|
update_params[param] = update_params[param].to_s
end
# Remove empty values, keeping `false`s (`present?` can't be used because of this)
update_params.reject { |_k, v| v.nil? || v == '' }
end
def map_presentation
Carto::Api::MapPresenter.new(@map).to_hash
end
end
end
end
@@ -0,0 +1,38 @@
module Carto
module Api
class MetricsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :create
skip_before_filter :api_authorization_required
before_filter :load_event, only: :create
rescue_from Carto::LoadError,
Carto::UnauthorizedError,
Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def create
@event.report!
render json: Hash.new, status: :created
end
private
def load_event
event_name = params[:name]
raise Carto::UnprocesableEntityError.new('name not provided') unless event_name
modulized_name = "Carto::Tracking::Events::#{event_name.parameterize('_').camelize}"
@event = Carto::Tracking::Events::SegmentEvent.build(params[:name], current_viewer.try(:id), params[:properties])
@event ||= modulized_name.constantize.new(current_viewer.try(:id), params[:properties])
rescue NameError
raise Carto::LoadError.new("Event not found: #{event_name}")
end
end
end
end
@@ -0,0 +1,99 @@
module Carto
module Api
class MobileAppPresenter
def initialize(mobile_app, current_user, fetch_mobile_platforms, fetch_app_types)
@mobile_app = mobile_app
@current_user = current_user
@fetch_mobile_platforms = fetch_mobile_platforms
@fetch_app_types = fetch_app_types
end
def data
return {} if @mobile_app.nil?
data = {
id: @mobile_app.id,
name: @mobile_app.name,
description: @mobile_app.description,
icon_url: @mobile_app.icon_url,
platform: @mobile_app.platform,
app_type: @mobile_app.app_type,
app_id: @mobile_app.app_id,
license_key: @mobile_app.license_key,
monthly_users: @mobile_app.monthly_users
}
if @fetch_mobile_platforms == true
data[:mobile_platforms] = {
"android": {
text: "Android",
available: platform_available?('android', true),
selected: platform_selected?('android'),
legend: "Use package from AndroidManifest.xml. E.g: com.example.mycartoapp."
},
"ios": {
text: "iOS",
available: platform_available?('ios', true),
selected: platform_selected?('ios'),
legend: "Use Bundle identifier. You can find it in the project properties. E.g: com.example.mycartoapp."
},
"xamarin-android": {
text: "Xamarin Android",
available: platform_available?('xamarin-android', @current_user.mobile_xamarin),
selected: platform_selected?('xamarin-android'),
legend: "Use package from AndroidManifest.xml. E.g: com.example.mycartoapp."
},
"xamarin-ios": {
text: "Xamarin iOS",
available: platform_available?('xamarin-ios', @current_user.mobile_xamarin),
selected: platform_selected?('xamarin-ios'),
legend: "Use Bundle identifier. You can find it in the project properties. E.g: com.example.mycartoapp."
},
"windows-phone": {
text: "Windows Phone",
available: platform_available?('windows-phone', @current_user.mobile_xamarin),
selected: platform_selected?('windows-phone'),
legend: "Use the Package name from Package.appmanifest. E.g: c882d38a-5c09-4994-87f0-89875cdee539."
}
}
end
if @fetch_app_types == true
data[:app_types] = {
"open": {
text: "Limits based on your CARTO plan. <a href='https://carto.com/developers/fundamentals/limits/'
target='_blank'>Learn more</a>.",
available: app_type_available?('open', @current_user.open_apps_enabled?),
selected: app_type_selected?('open')
},
"private": {
text: "Only for enterprise. <a href='https://carto.com/pricing/' target='_blank'>Learn more</a>.",
available: app_type_available?('private', @current_user.private_apps_enabled?),
selected: app_type_selected?('private')
}
}
end
data
end
private
def platform_available?(platform, current_platform_enabled)
(!@mobile_app.persisted? && current_platform_enabled) || platform_selected?(platform)
end
def platform_selected?(platform)
@mobile_app.platform == platform
end
def app_type_available?(app_type, current_apps_enabled)
(!@mobile_app.persisted? && current_apps_enabled) || app_type_selected?(app_type) || (@mobile_app.persisted? && current_apps_enabled)
end
def app_type_selected?(app_type)
@mobile_app.app_type == app_type
end
end
end
end
@@ -0,0 +1,28 @@
module Carto
module Api
class MultifactorAuthPresenter
def initialize(multifactor_auth)
@multifactor_auth = multifactor_auth
end
def to_poro
return {} unless @multifactor_auth
{
id: @multifactor_auth.id,
user: @multifactor_auth.user.username,
type: @multifactor_auth.type,
enabled: @multifactor_auth.enabled,
created_at: @multifactor_auth.created_at.to_s,
updated_at: @multifactor_auth.updated_at.to_s
}
end
def to_poro_with_qrcode
to_poro.merge(
qrcode: @multifactor_auth.qr_code
)
end
end
end
end
@@ -0,0 +1,51 @@
module Carto
module Api
class MultifactorAuthenticationController < ::Api::ApplicationController
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
include OrganizationUsersHelper
ssl_required
before_action :load_organization
before_action :admins_only
before_action :load_user
before_action :create_service
before_action :ensure_edit_permissions
setup_default_rescues
def show
render_jsonp({ mfa_required: @service.exists?(type: base_params[:type]) }, 200)
end
def create
if @service.exists?(type: base_params[:type])
raise Carto::UnprocesableEntityError.new('Multi-factor authentication already exists')
end
@service.update(enabled: true, type: base_params[:type])
render_jsonp({ success: true }, 201)
end
def destroy
unless @service.exists?(type: base_params[:type])
raise Carto::UnprocesableEntityError.new('Multi-factor authentication does not exist')
end
@service.update(enabled: false, type: base_params[:type])
render_jsonp({ success: true }, 204)
end
private
def base_params
@base_params ||= params.permit([:type])
end
def create_service
@service = Carto::UserMultifactorAuthUpdateService.new(user_id: @user.id)
end
end
end
end
@@ -0,0 +1,60 @@
class Carto::Api::MultifactorAuthsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required
before_action :load_user
before_action :load_multifactor_auth, only: [:show, :verify_code, :destroy]
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
def create
multifactor_auth = @carto_viewer.user_multifactor_auths.create!(create_params)
render json: Carto::Api::MultifactorAuthPresenter.new(multifactor_auth).to_poro_with_qrcode, status: 201
rescue ActionController::ParameterMissing => e
raise Carto::UnprocesableEntityError.new(e.message)
rescue ActiveRecord::RecordInvalid => e
raise Carto::UnprocesableEntityError.new(e.message)
end
def verify_code
@multifactor_auth.verify!(params[:code])
render json: Carto::Api::MultifactorAuthPresenter.new(@multifactor_auth).to_poro, status: 200
rescue ActiveRecord::RecordInvalid => e
raise Carto::UnprocesableEntityError.new(e.message)
end
def destroy
@multifactor_auth.destroy
render json: Carto::Api::MultifactorAuthPresenter.new(@multifactor_auth).to_poro, status: 204
end
def show
render json: Carto::Api::MultifactorAuthPresenter.new(@multifactor_auth).to_poro, status: 200
end
def index
auths = @carto_viewer.user_multifactor_auths.map do |auth|
Carto::Api::MultifactorAuthPresenter.new(auth).to_poro
end
render json: auths, status: 200
end
private
def load_user
@carto_viewer = Carto::User.find(current_viewer.id)
end
def load_multifactor_auth
@multifactor_auth = @carto_viewer.user_multifactor_auths.find(params[:id])
end
def create_params
{ type: params.require(:type) }
end
end
@@ -0,0 +1,25 @@
class Carto::Api::NotificationPresenter
extend Forwardable
include Carto::HtmlSafe
delegate [:id, :icon, :recipients, :body, :created_at] => :@notification
def initialize(notification)
@notification = notification
end
def html_body
markdown_html_safe(@notification.body)
end
def to_hash
{
id: @notification.id,
organization_id: @notification.organization_id,
icon: @notification.icon,
recipients: @notification.recipients,
body: @notification.body,
created_at: @notification.created_at
}
end
end
@@ -0,0 +1,170 @@
require 'uri'
module Carto
module Api
class OembedController < ::Api::ApplicationController
include VisualizationSearcher
ssl_allowed :show
skip_before_filter :api_authorization_required
# Returns oembed data as required
def show
url = params[:url]
width = params[:maxwidth] || '100%'
height = params[:maxheight] || '520px'
format = request.query_parameters[:format]
force_https = true if params[:allow_http].nil?
raise ActionController::RoutingError.new('Incorrect width') if (width =~ /\A[0-9]+(%|px)?\z/).nil?
raise ActionController::RoutingError.new('Incorrect height') if (height =~ /\A[0-9]+(%|px)?\z/).nil?
uri = URI.parse(url)
uuid = extract_uuid_from_string(uri.path)
raise ActionController::RoutingError.new('UUID not found in URL') if uuid.nil?
vis = Carto::Visualization.where(id: @id).first
if !vis.nil?
name = vis.name
end
fields = url_fields_from_fragments(url, force_https)
# build the url using full schema because any visuaization should work with any user
if fields[:organization_name].nil?
url = CartoDB.base_url(fields[:username])
else
url = CartoDB.base_url(fields[:organization_name], fields[:username])
end
url += CartoDB.path(self, 'public_visualizations_embed_map', id: uuid, user_domain: nil)
# force the schema
if fields[:protocol] == 'https' && !url.include?('https')
url = url.sub('http', 'https')
end
html = "<iframe width='#{width}' height='#{height}' frameborder='0' src='#{url}' allowfullscreen webkitallowfullscreen mozallowfullscreen oallowfullscreen msallowfullscreen></iframe>"
response_data = {
:type => 'rich',
:version => '1.0',
:width => width,
:height => height,
:title => name,
:html => html,
:author_name => fields[:username],
:author_url => fields[:user_profile_url],
:provider_name => 'CartoDB',
:provider_url => "#{fields[:protocol]}://carto.com/"
}
if format == 'xml'
render xml: response_data.to_xml(root: 'oembed')
else
render json: response_data.to_json, :callback => params['callback']
end
end
private
def url_fields_from_fragments(url, force_https)
domain = CartoDB.session_domain
# @see http://ruby-doc.org/stdlib-1.9.3/libdoc/uri/rdoc/URI.html#method-c-split
url_fragments = URI.split(url)
protocol = force_https ? "https" : URI.parse(url).scheme
data = nil
unless CartoDB.subdomainless_urls?
begin
data = from_url(url_fragments, protocol, domain)
rescue UrlFRagmentsError
# URL is subdomainless so do nothing
end
end
# Either subdomains disallowed or url doesn't uses them
if data.nil?
data = from_domainless_url(url_fragments, protocol)
end
raise UrlFRagmentsError.new("Couldn't extract URL fields") if data.nil?
{
organization_name: data[:organization_name],
username: data[:username],
user_profile_url: data[:user_profile_url],
protocol: protocol
}
end
# testuser.carto.com || testorg.carto.com/u/user
def from_url(url_fragments, protocol, domain)
# To ease testing don't request eactly all URI.split params
raise UrlFRagmentsError.new("Invalid url_fragments parameter") unless url_fragments.length > 5
subdomain = url_fragments[2].sub(domain, '.').split('.')[0]
raise UrlFRagmentsError.new("Subdomain not found at url") if subdomain.nil?
# org-based
if url_fragments[5][0..2] == "/u/"
organization_name = subdomain
username = username_from_url_fragments(url_fragments)
else
organization_name = nil
username = subdomain
end
{
username: username,
organization_name: organization_name,
user_profile_url: CartoDB.base_url(subdomain, organization_name.nil? ? nil : username, protocol)
}
end
# https://carto.com/u/testuser/...
def from_domainless_url(url_fragments, protocol)
# To ease testing don't request eactly all URI.split params
raise UrlFRagmentsError.new("Invalid url_fragments parameter") unless url_fragments.length > 5
# url_fragments[5]: Path
raise UrlFRagmentsError.new("URL needs username specified in the Path") if url_fragments[5][0..2] != "/u/"
# url_fragments[3]: Host
port_fragment =
url_fragments[3].nil? || url_fragments[3] == '' || url_fragments[3].to_i == 80 ? '' : ":#{url_fragments[3]}"
username = username_from_url_fragments(url_fragments)
{
username: username,
organization_name: nil,
# url_fragments[2]: Host
user_profile_url: "#{protocol}://#{url_fragments[2]}#{port_fragment}/u/#{username}"
}
end
def username_from_url_fragments(url_fragments)
path_fragments = url_fragments[5].split('/')
raise UrlFRagmentsError.new("Username not found at url") if path_fragments.length < 3 || path_fragments[2].length == 0
path_fragments[2]
end
def extract_uuid_from_string(str)
# TODO: move this method to app/helpers/carto/uuidhelper.rb. Not used yet because this changed was pushed before
# UUIDTools::UUID_REGEXP cannot be reused because of /^ $/
matches = /([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{2})([0-9a-f]{2})-([0-9a-f]{12})/.match(str)
if matches
matches[0]
else
nil
end
end
end
class UrlFRagmentsError < StandardError
end
end
end
@@ -0,0 +1,78 @@
module Carto
module Api
class OrganizationAssetsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :index, :show, :create, :destroy
before_filter :load_organization,
:organization_members_only
before_filter :organization_owners_only, only: [:create, :destroy]
before_filter :load_asset, only: [:show, :destroy]
before_filter :load_resource, only: :create
rescue_from LoadError,
UnprocesableEntityError,
UnauthorizedError, with: :rescue_from_carto_error
def index
presentation = @organization.assets.map do |asset|
AssetPresenter.new(asset).to_hash
end
render json: presentation
end
def show
render json: AssetPresenter.new(@asset).to_hash
end
def create
asset = Asset.for_organization(organization: @organization,
resource: @resource)
asset.save!
render json: AssetPresenter.new(asset), status: :created
rescue ActiveRecord::RecordInvalid => exception
raise UnprocesableEntityError.with_full_messages(exception)
end
def destroy
@asset.destroy
head :no_content
end
private
def load_organization
@organization = Organization.find(params[:organization_id])
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Organization not found')
end
def organization_members_only
unless current_viewer.belongs_to_organization?(@organization)
raise UnauthorizedError.new
end
end
def organization_owners_only
raise UnauthorizedError.new unless @organization.owner?(current_viewer)
end
def load_asset
@asset = Asset.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Asset not found')
end
def load_resource
@resource = params[:resource]
unless @resource.present?
raise UnprocesableEntityError.new('Missing resource for asset')
end
end
end
end
end
@@ -0,0 +1,45 @@
module Carto
module Api
class OrganizationNotificationsController < ::Api::ApplicationController
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
ssl_required :create, :destroy
before_filter :owners_only, :load_organization
before_filter :load_notification, only: [:destroy]
setup_default_rescues
respond_to :json
def create
notification_parameters = params.require(:notification).permit(:icon, :body, :recipients)
notification = @organization.notifications.create!(notification_parameters)
render_jsonp(NotificationPresenter.new(notification).to_hash, :created)
end
def destroy
@notification.destroy
head :no_content
end
private
def load_organization
@organization = Carto::Organization.find(current_user.organization_id)
unless [@organization.name, @organization.id].include?(params[:organization_id])
raise Carto::LoadError.new('Cannot find organization')
end
end
def owners_only
raise Carto::UnauthorizedError.new('Only organization owners') unless current_user.organization_owner?
end
def load_notification
@notification = @organization.notifications.find(params[:id])
end
end
end
end
@@ -0,0 +1,62 @@
require_relative 'group_presenter'
module Carto
module Api
class OrganizationPresenter
def initialize(organization)
@organization = organization
end
def to_poro
return {} if @organization.nil?
filtered_user ||= @organization.owner
{
created_at: @organization.created_at,
description: @organization.description,
discus_shortname: @organization.discus_shortname,
display_name: @organization.display_name,
id: @organization.id,
name: @organization.name,
owner: {
id: @organization.owner ? @organization.owner.id : nil,
username: @organization.owner ? @organization.owner.username : nil,
avatar_url: @organization.owner ? @organization.owner.avatar_url : nil,
email: @organization.owner ? @organization.owner.email : nil,
groups: @organization.owner && @organization.owner.groups ? @organization.owner.groups.map { |g| Carto::Api::GroupPresenter.new(g).to_poro } : []
},
admins: @organization.users.where(org_admin: true).map { |u| { id: u.id } },
quota_in_bytes: @organization.quota_in_bytes,
unassigned_quota: @organization.unassigned_quota,
geocoding_quota: @organization.geocoding_quota,
here_isolines_quota: @organization.here_isolines_quota,
here_isolines_block_price: @organization.here_isolines_block_price,
obs_snapshot_quota: @organization.obs_snapshot_quota,
obs_snapshot_block_price: @organization.obs_snapshot_block_price,
obs_general_quota: @organization.obs_general_quota,
obs_general_block_price: @organization.obs_general_block_price,
mapzen_routing_quota: @organization.mapzen_routing_quota,
mapzen_routing_block_price: @organization.mapzen_routing_block_price,
geocoder_provider: @organization.geocoder_provider,
isolines_provider: @organization.isolines_provider,
routing_provider: @organization.routing_provider,
map_view_quota: @organization.map_view_quota,
twitter_datasource_quota: @organization.twitter_datasource_quota,
map_view_block_price: @organization.map_view_block_price,
geocoding_block_price: @organization.geocoding_block_price,
seats: @organization.seats,
viewer_seats: @organization.viewer_seats,
twitter_username: @organization.twitter_username,
location: @organization.location,
updated_at: @organization.updated_at,
website: @organization.website,
admin_email: @organization.admin_email,
avatar_url: @organization.avatar_url,
user_count: @organization.users.count,
password_expiration_in_d: @organization.password_expiration_in_d
}
end
end
end
end
@@ -0,0 +1,15 @@
module Carto
module Api
class OrganizationPublicPresenter
def initialize(organization)
@organization = organization
end
def to_hash
{
name: @organization.name
}
end
end
end
end
@@ -0,0 +1,176 @@
require_relative './user_presenter'
module Carto
module Api
class OrganizationUsersController < ::Api::ApplicationController
include OrganizationUsersHelper
ssl_required :index, :show, :create, :update, :destroy
before_filter :load_organization
before_filter :admins_only
before_filter :load_user, only: [:show, :update, :destroy]
before_filter :ensure_edit_permissions, only: [:show, :update, :destroy]
def index
presentations = @organization.users.map do |user|
Carto::Api::UserPresenter.new(user, current_viewer: current_viewer).to_eumapi_poro
end
render_jsonp presentations, 200
end
def show
presentation = Carto::Api::UserPresenter.new(@user, current_viewer: current_viewer)
.to_eumapi_poro
render_jsonp presentation, 200
end
def create
account_creator = CartoDB::UserAccountCreator.new(Carto::UserCreation::CREATED_VIA_API)
.with_organization(@organization)
account_creator.with_username(create_params[:username]) if create_params[:username].present?
account_creator.with_email(create_params[:email]) if create_params[:email].present?
account_creator.with_password(create_params[:password]) if create_params[:password].present?
account_creator.with_quota_in_bytes(create_params[:quota_in_bytes]) if create_params[:quota_in_bytes].present?
param_viewer = create_params[:viewer]
account_creator.with_viewer(param_viewer) if param_viewer
param_org_admin = create_params[:org_admin]
account_creator.with_org_admin(param_org_admin) if param_org_admin
if create_params[:soft_geocoding_limit].present?
account_creator.with_soft_geocoding_limit(create_params[:soft_geocoding_limit])
end
if create_params[:soft_here_isolines_limit].present?
account_creator.with_soft_here_isolines_limit(create_params[:soft_here_isolines_limit])
end
if create_params[:soft_obs_snapshot_limit].present?
account_creator.with_soft_obs_snapshot_limit(create_params[:soft_obs_snapshot_limit])
end
if create_params[:soft_obs_general_limit].present?
account_creator.with_soft_obs_general_limit(create_params[:soft_obs_general_limit])
end
if create_params[:soft_twitter_datasource_limit].present?
account_creator.with_soft_twitter_datasource_limit(create_params[:soft_twitter_datasource_limit])
end
if create_params[:soft_mapzen_routing_limit].present?
account_creator.with_soft_mapzen_routing_limit(create_params[:soft_mapzen_routing_limit])
end
if create_params[:force_password_change] == true
account_creator.with_force_password_change
end
unless account_creator.valid_creation?(current_viewer)
render_jsonp(account_creator.validation_errors.full_messages, 410)
return
end
account_creator.enqueue_creation(self)
presentation = Carto::Api::UserPresenter.new(account_creator.user,
current_viewer: current_viewer)
.to_eumapi_poro
render_jsonp presentation, 200
rescue => e
CartoDB.notify_exception(e, user: account_creator.user.inspect)
render_jsonp('An error has ocurred. Please contact support', 500)
end
def update
render_jsonp('No update params provided', 410) && return if update_params.empty?
params_to_update = update_params
# ::User validation requires confirmation
password = params_to_update[:password]
params_to_update[:password_confirmation] = password
# NOTE: Verify soft limits BEFORE updating the user
model_validation_ok = soft_limits_validation(@user, params_to_update)
model_validation_ok &&= @user.valid_password?(:password, password, password) if password.present?
@user.set_fields(params_to_update, params_to_update.keys)
model_validation_ok &&= @user.valid_update?(current_user)
unless model_validation_ok
render_jsonp(@user.errors.full_messages, 410)
return
end
@user.update_in_central
@user.save
presentation = Carto::Api::UserPresenter.new(@user, current_viewer: current_viewer)
.to_eumapi_poro
render_jsonp presentation, 200
rescue CartoDB::CentralCommunicationFailure => e
CartoDB.notify_exception(e)
render_jsonp 'Central comunication failure', 500
end
def destroy
if @organization.owner_id == @user.id
render_jsonp("Can't delete org owner", 401) and return
end
force_destroy = params[:force].present?
if !force_destroy && @user.has_shared_entities?
error_message = "Can't delete @user. 'Has shared entities"
render_jsonp(error_message, 410 ) and return
end
@user.set_force_destroy if force_destroy
@user.destroy
@user.delete_in_central
render_jsonp 'User deleted', 200
rescue CartoDB::CentralCommunicationFailure => e
CartoDB::Logger.error(exception: e, message: 'Central error deleting user from EUMAPI', user: @user)
render_jsonp "User couldn't be deleted", 500
rescue => e
render_jsonp "User couldn't be deleted: #{e.message}", 500
end
private
COMMON_MUTABLE_ATTRIBUTES = [
:email,
:password,
:quota_in_bytes,
:soft_geocoding_limit,
:soft_here_isolines_limit,
:soft_obs_general_limit,
:soft_obs_snapshot_limit,
:soft_twitter_datasource_limit,
:soft_mapzen_routing_limit,
:viewer,
:org_admin
].freeze
# TODO: Use native strong params when in Rails 4+
def create_params
@create_params ||=
permit(COMMON_MUTABLE_ATTRIBUTES + [:username, :force_password_change])
end
# TODO: Use native strong params when in Rails 4+
def update_params
@update_params ||= permit(COMMON_MUTABLE_ATTRIBUTES)
end
end
end
end
@@ -0,0 +1,35 @@
require_relative './user_presenter'
module Carto
module Api
class OrganizationsController < ::Api::ApplicationController
include OrganizationsHelper
include PagedSearcher
include Carto::ControllerHelper
ssl_required :users
before_filter :load_organization, :load_group
rescue_from Carto::ParamInvalidError, with: :rescue_from_carto_error
VALID_ORDER_PARAMS = [:username, :updated_at].freeze
def users
page, per_page, order, = page_per_page_order_params(VALID_ORDER_PARAMS, default_per_page: 50,
default_order: :username)
query = params[:q]
users_query = [@group, @organization].compact.first.users
users_query = users_query.where('(username like ? or email like ?)', "%#{query}%", "#{query}") if query
total_user_entries = users_query.count
users_query = users_query.offset(( page - 1 ) * per_page ).limit(per_page).order(order)
users = users_query.all
render_jsonp({ users: users.map { |u|
Carto::Api::UserPresenter.new(u, current_viewer: current_user, fetch_db_size: false).to_poro
}, total_user_entries: total_user_entries, total_entries: users.count })
end
end
end
end
@@ -0,0 +1,30 @@
module Carto
module Api
class OverlayPresenter
PUBLIC_VALUES = [:id, :order, :type, :template, :options, :visualization_id].freeze
VIZJSON_VALUES = [:type, :order, :options, :template].freeze
def initialize(overlay)
@overlay = overlay
end
def to_poro
values(@overlay, PUBLIC_VALUES)
end
def to_vizjson_poro
values(@overlay, VIZJSON_VALUES)
end
alias_method :to_vizjson, :to_vizjson_poro
protected
def values(overlay, attribute_list)
Hash[attribute_list.map { |attribute| [attribute, overlay.send(attribute)] }]
end
end
end
end
@@ -0,0 +1,104 @@
require_dependency 'carto/controller_helper'
module Carto
module Api
class OverlaysController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :index, :show, :create, :update, :destroy
before_filter :logged_users_only
before_filter :load_visualization
before_filter :check_current_user_has_permissions_on_vis
before_filter :load_overlay, only: [:show, :update, :destroy]
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::CartoError, with: :rescue_from_carto_error
def index
collection = @visualization.overlays.map do |overlay|
Carto::Api::OverlayPresenter.new(overlay).to_poro
end
render_jsonp(collection)
end
def show
render_jsonp(Carto::Api::OverlayPresenter.new(@overlay).to_poro)
end
def create
@stats_aggregator.timing('overlays.create') do
begin
overlay = Carto::Overlay.new(type: params[:type],
options: params[:options],
template: params[:template],
order: params[:order],
visualization_id: @visualization.id)
saved = @stats_aggregator.timing('save') do
overlay.save
end
if saved
render_jsonp(Carto::Api::OverlayPresenter.new(overlay).to_poro)
else
render_jsonp({ errors: overlay.errors }, :unprocessable_entity)
end
end
end
end
def update
@stats_aggregator.timing('overlays.update') do
begin
@overlay.type = params[:type] if params[:type]
@overlay.options = params[:options] if params[:options]
@overlay.template = params[:template] if params[:template]
@overlay.order = params[:order] if params[:order]
saved = @stats_aggregator.timing('save') do
@overlay.save
end
if saved
render_jsonp(Carto::Api::OverlayPresenter.new(@overlay).to_poro)
else
render_jsonp({ errors: @overlay.errors }, :unprocessable_entity)
end
end
end
end
def destroy
@stats_aggregator.timing('overlays.destroy') do
@stats_aggregator.timing('delete') do
@overlay.destroy
end
head :no_content
end
end
protected
def logged_users_only
raise Carto::UnauthorizedError.new if current_user.nil?
end
def load_visualization
visualization_id = uuid_parameter('visualization_id')
@visualization = Carto::Visualization.where(id: visualization_id).first
raise Carto::LoadError.new("Visualization not found: #{visualization_id}") unless @visualization
end
def check_current_user_has_permissions_on_vis
unless @visualization.writable_by?(current_user)
raise Carto::UnauthorizedError.new("#{current_user.id} doesn't own visualization #{@visualization.id}")
end
end
def load_overlay
overlay_id = uuid_parameter('id')
@overlay = @visualization.overlays.where(id: overlay_id).first
raise Carto::LoadError.new("Overlay not found: #{overlay_id}") unless @overlay
end
end
end
end
@@ -0,0 +1,68 @@
module Carto
module Api
module PagedSearcher
VALID_ORDER_DIRECTIONS = [:asc, :desc].freeze
def page_per_page_params(default_per_page: 20)
page = (params[:page].presence || 1).to_i
unless page > 0
raise Carto::ParamInvalidError.new('page')
end
per_page = (params[:per_page].presence || default_per_page).to_i
unless per_page > 0
raise Carto::ParamInvalidError.new('per_page')
end
[page, per_page]
end
def page_per_page_order_params(valid_order_values, default_per_page: 20, default_order: 'updated_at',
default_order_direction: 'desc', valid_order_combinations: [])
page, per_page = page_per_page_params(default_per_page: default_per_page)
order = extract_param(name: :order, default_value: default_order, valid_values: valid_order_values,
valid_combinations: valid_order_combinations)
order_direction = extract_param(name: :order_direction, default_value: default_order_direction,
valid_values: VALID_ORDER_DIRECTIONS,
valid_combinations: VALID_ORDER_DIRECTIONS)
[page, per_page, order, order_direction]
end
def paged_result(result:, total_count:, page:, per_page:, params:)
last_page = (total_count / per_page.to_f).ceil
link_params = params.merge(per_page: per_page)
metadata = {
total: total_count,
count: result.count,
result: result,
_links: {
first: { href: yield(link_params.merge(page: 1)) },
last: { href: yield(link_params.merge(page: last_page)) }
}
}
metadata[:_links][:prev] = { href: yield(link_params.merge(page: page - 1)) } if page > 1
metadata[:_links][:next] = { href: yield(link_params.merge(page: page + 1)) } if last_page > page
metadata
end
def extract_param(name:, default_value:, valid_values:, valid_combinations:)
param = (params[name].presence || default_value).to_sym
param_values = param.to_s.split(',').map(&:to_sym)
single_parameter = valid_combinations.empty? || param_values.size == 1
if single_parameter && valid_values.exclude?(param)
raise Carto::ParamInvalidError.new(name, valid_values)
end
if !single_parameter && (param_values - valid_combinations).present?
raise Carto::ParamCombinationInvalidError.new(name, valid_combinations)
end
param
end
end
end
end
@@ -0,0 +1,107 @@
require_relative 'user_presenter'
module Carto
module Api
class PermissionPresenter
def initialize(permission, current_viewer: nil, fetch_user_groups: false)
@permission = permission
@presenter_cache = Carto::Api::PresenterCache.new
@current_viewer = current_viewer
@fetch_user_groups = fetch_user_groups
end
def with_presenter_cache(presenter_cache)
@presenter_cache = presenter_cache
self
end
def to_poro
return to_public_poro unless current_viewer && @permission.user_has_read_permission?(current_viewer)
owner = @presenter_cache.get_poro(@permission.owner) do
Carto::Api::UserPresenter.new(@permission.owner,
fetch_groups: fetch_user_groups,
current_viewer: current_viewer,
fetch_profile: false)
end
{
id: @permission.id,
owner: owner,
entity: {
id: @permission.visualization.id,
type: 'vis'
},
acl: @permission.acl.map { |entry|
entity = entity_decoration(entry)
if entity.blank?
nil
else
{
type: entry[:type],
entity: entity,
access: entry[:access]
}
end
}.reject(&:nil?),
created_at: @permission.created_at,
updated_at: @permission.updated_at
}
end
def to_public_poro
owner = @presenter_cache.get_poro(@permission.owner) do
Carto::Api::UserPresenter.new(@permission.owner,
fetch_groups: fetch_user_groups, current_viewer: current_viewer)
end
{
id: @permission.id,
owner: owner
}
end
def entity_decoration(entry)
if entry[:type] == Carto::Permission::TYPE_USER
user_decoration(entry[:id])
elsif entry[:type] == Carto::Permission::TYPE_ORGANIZATION
organization_decoration(entry[:id])
elsif entry[:type] == Carto::Permission::TYPE_GROUP
group_decoration(entry[:id])
else
raise "Unknown entity type: #{entry[:type]}"
end
end
def user_decoration(user_id)
user = ::User.where(id: user_id).first
return {} if user.nil?
Carto::Api::UserPresenter.new(user, fetch_groups: fetch_user_groups).to_public_poro
end
def organization_decoration(org_id)
org = Carto::Organization.where(id: org_id).first
return {} if org.nil?
{
id: org.id,
name: org.name,
avatar_url: org.avatar_url
}
end
def group_decoration(group_id)
group = Carto::Group.where(id: group_id).first
return {} if group.nil?
{
id: group.id,
name: group.name
}
end
private
attr_reader :current_viewer, :fetch_user_groups
end
end
end
@@ -0,0 +1,28 @@
class Carto::Api::PermissionsController < ::Api::ApplicationController
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
ssl_required :update
def update
permission = Carto::Permission.where(id: params[:id]).first
return head(404) if permission.nil?
return head(401) unless permission.is_owner?(current_user)
begin
acl = params[:acl]
acl ||= []
permission.acl = acl.map(&:deep_symbolize_keys)
rescue CartoDB::PermissionError => e
CartoDB::Logger.error(exception: e)
return head(400)
end
permission.save!
render json: Carto::Api::PermissionPresenter.new(permission,
current_viewer: current_viewer, fetch_user_groups: true).to_poro
end
end
@@ -0,0 +1,45 @@
module Carto
module Api
class PresenterCache
def initialize
@cache = Hash.new
end
# Caches to_poro of the presenter that is passed in a block, based on model class and id. Example:
# cache.get_poro(user) { Carto::Api::UserPresenter.new(user, { fetch_groups: false } ) }
def get_poro(model)
raise "no model given" unless model
model_class = model.class
model_id = model.id
if model_id.nil?
presenter = yield
raise "no presenter given" if presenter.nil?
return presenter.to_poro
end
class_cache = get_class_cache(model_class)
unless class_cache[model_id]
presenter = yield
raise "no presenter given" if presenter.nil?
class_cache[model_id] = presenter.to_poro
end
class_cache[model_id]
end
private
def get_class_cache(model_class)
unless @cache[model_class]
@cache[model_class] = Hash.new
end
@cache[model_class]
end
end
end
end
@@ -0,0 +1,42 @@
class Carto::Api::Public::ApplicationController < ::Api::ApplicationController
include Carto::Api::AuthApiAuthentication
# Override all CORS settings
skip_before_action :cors_preflight_check
skip_after_action :allow_cross_domain_access
before_action :allow_full_cross_domain_access
# Only allow API Key authorization
skip_before_action :http_header_authentication, :api_authorization_required
prepend_before_action :only_api_key_authorization
# Disable authorization check for OPTIONS
skip_before_action :only_api_key_authorization, only: [:options]
WARDEN_SCOPE = :$public_api_scope
def allow_full_cross_domain_access
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = '*'
response.headers['Access-Control-Allow-Headers'] = 'Authorization'
end
def options
head :ok
end
# Override current_user and current_viewer so they only return the user authenticated via API Key (ignore session)
def current_user
warden.user(WARDEN_SCOPE)
end
def current_viewer
current_user
end
private
def only_api_key_authorization
authenticate!(:any_auth_api, :api_authentication, scope: WARDEN_SCOPE)
end
end
@@ -0,0 +1,144 @@
require_relative '../visualization_searcher'
require_relative '../paged_searcher'
class Carto::Api::Public::CustomVisualizationsController < Carto::Api::Public::ApplicationController
include Carto::Api::VisualizationSearcher
include Carto::Api::PagedSearcher
CONTENT_LENGTH_LIMIT_IN_BYTES = 100000
VALID_ORDER_PARAMS = %i(name updated_at privacy).freeze
ALLOWED_PRIVACY_MODES = [
Carto::Visualization::PRIVACY_PUBLIC,
Carto::Visualization::PRIVACY_PROTECTED
].freeze
ssl_required
before_action :validate_mandatory_creation_params, only: [:create]
before_action :validate_input_parameters, only: [:create, :update]
before_action :get_kuviz, only: [:update, :delete]
before_action :get_user, only: [:create, :update, :delete]
before_action :check_for_permission, only: [:update, :delete]
def index
opts = { valid_order_combinations: VALID_ORDER_PARAMS }
page, per_page, order, order_direction = page_per_page_order_params(VALID_ORDER_PARAMS, opts)
params[:type] = Carto::Visualization::TYPE_KUVIZ
vqb = query_builder_with_filter_from_hash(params)
visualizations = vqb.with_order(order, order_direction)
.build_paged(page, per_page).map do |v|
Carto::Api::Public::KuvizPresenter.new(self, v.user, v).to_hash
end
response = {
visualizations: visualizations,
total_entries: vqb.build.size
}
render_jsonp(response)
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 create
kuviz = create_visualization_metadata(@logged_user)
asset = Carto::Asset.for_visualization(visualization: kuviz,
resource: StringIO.new(Base64.decode64(params[:data])))
asset.save
render_jsonp(Carto::Api::Public::KuvizPresenter.new(self, @logged_user, kuviz).to_hash, 200)
rescue StandardError => e
CartoDB::Logger.error(message: 'Error creating kuviz', params: params, exception: e)
render_jsonp({ error: 'cant create the kuviz' }, 500)
end
def update
@kuviz.update_attributes!(params.permit(:name, :privacy, :password))
if params[:data].present?
@kuviz.asset.update_visualization_resource(StringIO.new(Base64.decode64(params[:data])))
# In case we only update the asset we need to invalidate the visualization
@kuviz.save
end
render_jsonp(Carto::Api::Public::KuvizPresenter.new(self, @logged_user, @kuviz).to_hash, 200)
end
def delete
@kuviz.destroy
head 204
rescue StandardError => exception
CartoDB::Logger.error(message: 'Error deleting kuviz', exception: exception,
visualization: @kuviz)
render_jsonp({ errors: [exception.message] }, 400)
end
private
def create_visualization_metadata(user)
kuviz = Carto::Visualization.new
kuviz.name = params[:name]
kuviz.privacy = params[:password].present? ? Carto::Visualization::PRIVACY_PROTECTED : Carto::Visualization::PRIVACY_PUBLIC
kuviz.password = params[:password]
kuviz.type = Carto::Visualization::TYPE_KUVIZ
kuviz.user = user
kuviz.save
kuviz
end
def get_user
@logged_user = current_viewer.present? ? Carto::User.find(current_viewer.id) : nil
end
def check_for_permission
head(403) unless @kuviz.has_permission?(@logged_user, Carto::Permission::ACCESS_READWRITE)
end
def validate_input_parameters
if request.content_length > CONTENT_LENGTH_LIMIT_IN_BYTES
return render_jsonp({ error: "visualization over the size limit (#{CONTENT_LENGTH_LIMIT_IN_BYTES})" }, 400)
end
if params[:privacy].present?
unless ALLOWED_PRIVACY_MODES.include?(params[:privacy])
return render_jsonp({ error: "privacy mode not allowed. Allowed ones are #{ALLOWED_PRIVACY_MODES}" }, 400)
end
if params[:privacy] == Carto::Visualization::PRIVACY_PROTECTED && !params[:password].present?
return render_jsonp({ error: 'Changing privacy to protected should come along with the password param' }, 400)
end
end
if params[:data].present?
begin
decoded_data = Base64.strict_decode64(params[:data])
return render_jsonp({ error: 'data parameter must be HTML' }, 400) unless html_param?(decoded_data)
rescue ArgumentError
return render_jsonp({ error: 'data parameter must be encoded in base64' }, 400)
end
end
end
def validate_mandatory_creation_params
if !params[:data].present?
render_jsonp({ error: 'missing data parameter' }, 400)
elsif !params[:name].present?
render_jsonp({ error: 'missing name parameter' }, 400)
end
end
def html_param?(data)
# FIXME this is a very naive implementantion. I'm trying to use
# Nokogiri to validate the HTML but it doesn't works as I want
# so
data.match(/\<html.*\>/).present?
end
def get_kuviz
@kuviz = Carto::Visualization.find(params[:id])
if @kuviz.nil?
raise Carto::LoadError.new('Kuviz doesn\'t exist', 404)
end
end
end
@@ -0,0 +1,158 @@
module Carto
module Api
module Public
class DataObservatoryController < Carto::Api::Public::ApplicationController
include Carto::ControllerHelper
include Carto::Api::PagedSearcher
extend Carto::DefaultRescueFroms
ssl_required
before_action :load_user
before_action :load_filters, only: [:subscriptions]
before_action :load_id, only: [:subscription_info, :subscribe, :unsubscribe]
before_action :load_type, only: [:subscription_info, :subscribe]
before_action :check_api_key_permissions
before_action :check_licensing_enabled, only: [:subscription_info, :subscribe, :unsubscribe]
setup_default_rescues
respond_to :json
BIGQUERY_KEY = 'bq'.freeze
VALID_TYPES = %w(dataset geography).freeze
DATASET_REGEX = /[\w\-]+\.[\w\-]+\.[\w\-]+/.freeze
VALID_ORDER_PARAMS = %i(id table dataset project type).freeze
METADATA_FIELDS = %i(id estimated_delivery_days subscription_list_price tos tos_link licenses licenses_link
rights type).freeze
TABLES_BY_TYPE = { 'dataset' => 'datasets', 'geography' => 'geographies' }.freeze
def token
response = Cartodb::Central.new.get_do_token(@user.username)
render(json: response)
end
def subscriptions
available_subscriptions = bq_subscriptions.select { |dataset| Time.parse(dataset['expires_at']) > Time.now }
response = present_subscriptions(available_subscriptions)
render(json: { subscriptions: response })
end
def subscription_info
response = present_metadata(subscription_metadata)
render(json: response)
end
def subscribe
metadata = subscription_metadata
response = present_metadata(metadata)
return render(json: response) if metadata[:estimated_delivery_days].positive?
license_info = {
dataset_id: metadata[:id],
available_in: metadata[:available_in],
price: metadata[:subscription_list_price],
expires_at: Time.now.round + 1.year
}
Carto::DoLicensingService.new(@user.username).subscribe([license_info])
render(json: response)
end
def unsubscribe
Carto::DoLicensingService.new(@user.username).unsubscribe(@id)
head :no_content
end
private
def load_user
@user = Carto::User.find(current_viewer.id)
end
def load_filters
_, _, @order, @direction = page_per_page_order_params(
VALID_ORDER_PARAMS, default_order: 'id', default_order_direction: 'asc'
)
load_type(required: false)
end
def load_id
@id = params[:id]
raise ParamInvalidError.new(:id) unless @id =~ DATASET_REGEX
end
def load_type(required: true)
@type = params[:type]
return if @type.nil? && !required
raise ParamInvalidError.new(:type, VALID_TYPES.join(', ')) unless VALID_TYPES.include?(@type)
end
def check_api_key_permissions
api_key = Carto::ApiKey.find_by_token(params["api_key"])
raise UnauthorizedError unless api_key&.master? || api_key&.data_observatory_permissions?
end
def check_licensing_enabled
raise UnauthorizedError.new('DO licensing not enabled') unless @user.has_feature_flag?('do-licensing')
end
def rescue_from_central_error(exception)
render_jsonp({ errors: exception.errors }, 500)
end
def bq_subscriptions
redis_key = "do:#{@user.username}:datasets"
redis_value = $users_metadata.hget(redis_key, BIGQUERY_KEY) || '[]'
JSON.parse(redis_value)
end
def present_subscriptions(subscriptions)
enriched_subscriptions = subscriptions.map do |subscription|
qualified_id = subscription['dataset_id']
project, dataset, table = qualified_id.split('.')
# FIXME: better save the type in Redis or look for it in the metadata tables
type = table.starts_with?('geography') ? 'geography' : 'dataset'
{ project: project, dataset: dataset, table: table, id: qualified_id, type: type }
end
enriched_subscriptions.select! { |subscription| subscription[:type] == @type } if @type
ordered_subscriptions = enriched_subscriptions.sort_by { |subscription| subscription[@order] }
@direction == :asc ? ordered_subscriptions : ordered_subscriptions.reverse
end
def present_metadata(metadata)
metadata[:estimated_delivery_days] = metadata[:estimated_delivery_days].to_f
metadata[:subscription_list_price] = metadata[:subscription_list_price].to_f
metadata.slice(*METADATA_FIELDS)
end
def subscription_metadata
metadata_user = ::User.where(username: 'do-metadata').first
raise Carto::LoadError.new('No Data Observatory metadata found') unless metadata_user
query = "SELECT *, '#{@type}' as type FROM #{TABLES_BY_TYPE[@type]} WHERE id = '#{@id}'"
result = metadata_user.in_database[query].first
validate_metadata(result)
end
def validate_metadata(result)
raise Carto::LoadError.new("No metadata found for #{@id}") unless result
valid_data = result[:available_in].present? && result[:estimated_delivery_days].present? &&
result[:subscription_list_price].present?
unless valid_data
CartoDB::Logger.info(message: 'Incomplete DO metadata', id: @id)
raise Carto::LoadError.new("Incomplete metadata found for #{@id}")
end
result
end
end
end
end
end
@@ -0,0 +1,126 @@
require_relative '../paged_searcher'
require_dependency 'carto/oauth_provider/errors'
module Carto
module Api
module Public
class DatasetsController < Carto::Api::Public::ApplicationController
include Carto::Api::PagedSearcher
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
ssl_required
before_action :load_user
before_action :load_params
before_action :check_permissions
before_action :engine_required
setup_default_rescues
rescue_from Carto::OauthProvider::Errors::ServerError, with: :rescue_oauth_errors
VALID_ORDER_PARAMS = %i(name type).freeze
VALID_TYPE_PARAMS = %w(table view matview).freeze
def index
tables = @user.in_database[select_tables_query].all
result = enrich_tables(tables)
total = @user.in_database[count_tables_query].first[:count]
render_paged(result, total)
end
private
def load_user
@user = ::User.where(id: current_viewer.id).first
end
def load_params
@page, @per_page, @order, @direction = page_per_page_order_params(
VALID_ORDER_PARAMS, default_order: 'name', default_order_direction: 'asc'
)
@offset = (@page - 1) * @per_page
@types = load_type
end
def load_type
types = params[:type]&.split(',').to_a
raise Carto::ParamInvalidError.new(:type, VALID_TYPE_PARAMS) if (types - VALID_TYPE_PARAMS).any?
types.empty? ? VALID_TYPE_PARAMS : types
end
def check_permissions
api_key = Carto::ApiKey.find_by_token(params["api_key"])
raise UnauthorizedError unless api_key.master? || api_key.dataset_metadata_permissions
end
def enrich_tables(tables)
table_names = tables.map { |table| table[:name] }
visualizations = table_visualizations(table_names)
tables.map do |table|
visualizations.find { |visualization| visualization[:name] == table[:name] } || table
end
end
def table_visualizations(names)
visualizations = Carto::VisualizationQueryBuilder.new
.with_user_id(@user.id)
.with_name(names)
.with_type(Carto::Visualization::TYPE_CANONICAL)
.build.all
visualizations.map do |visualization|
{
name: visualization.name,
cartodbfied: true,
type: 'table',
privacy: visualization.privacy,
updated_at: visualization.updated_at
}
end
end
def select_tables_query
%{
SELECT * FROM (#{tables_and_views_query}) AS tables_and_views
ORDER BY #{@order} #{@direction}
LIMIT #{@per_page}
OFFSET #{@offset}
}.squish
end
def count_tables_query
"SELECT COUNT(*) FROM (#{tables_and_views_query}) AS tables_and_views"
end
def tables_and_views_query
@types.map { |type|
%{
SELECT #{type}name AS name, '#{type}' AS type, false AS cartodbfied, NULL AS privacy, NULL AS updated_at
FROM pg_#{type}s
WHERE schemaname = '#{@user.database_schema}'
AND #{type}owner <> 'postgres'
}
}.join(' UNION ').squish
end
def render_paged(result, total)
enriched_response = paged_result(
result: result,
total_count: total,
page: @page,
per_page: @per_page,
params: params.except('controller', 'action')
) { |params| api_v4_datasets_url(params) }
render_jsonp(enriched_response, 200)
end
def rescue_oauth_errors(exception)
render json: { errors: exception.parameters[:error_description] }, status: 500
end
end
end
end
end
@@ -0,0 +1,27 @@
module Carto
module Api
module Public
class KuvizPresenter
def initialize(context, user, kuviz)
@context = context
@user = user
@kuviz = kuviz
end
def to_hash
{
id: @kuviz.id,
name: @kuviz.name,
privacy: @kuviz.privacy,
created_at: @kuviz.created_at,
updated_at: @kuviz.updated_at,
url: CartoDB.url(@context, 'kuviz_show',
params: { id: @kuviz.id },
user: @user)
}
end
end
end
end
end
@@ -0,0 +1,37 @@
module Carto
module Api
module Public
class OauthAppPresenter
PRIVATE_ATTRIBUTES = %i(
id user_id name created_at updated_at client_id client_secret
redirect_uris icon_url restricted description website_url
).freeze
PUBLIC_ATTRIBUTES = %i(id name created_at updated_at description website_url icon_url).freeze
def initialize(oauth_app, user: nil)
@oauth_app = oauth_app
@user = user
end
def to_hash(private_data: false)
private_data ? to_private_hash : to_public_hash
end
private
def to_private_hash
@oauth_app.slice(*PRIVATE_ATTRIBUTES).merge(username: @oauth_app.user.username)
end
def to_public_hash
oauth_app_user = @oauth_app.oauth_app_users.where(user: @user).first
scopes = Carto::OauthProvider::Scopes.scopes_by_category(oauth_app_user&.all_scopes)
@oauth_app.slice(*PUBLIC_ATTRIBUTES).merge(scopes: scopes)
end
end
end
end
end
@@ -0,0 +1,143 @@
require_relative '../paged_searcher'
require_dependency 'carto/oauth_provider/errors'
module Carto
module Api
module Public
class OauthAppsController < Carto::Api::Public::ApplicationController
include Carto::Api::PagedSearcher
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
ssl_required
before_action :load_user
before_action :load_owned_app, only: [:show, :update, :regenerate_secret, :destroy]
before_action :load_granted_app, only: :revoke
before_action :load_index_params, only: [:index, :index_granted]
before_action :engine_required
setup_default_rescues
rescue_from Carto::OauthProvider::Errors::ServerError, with: :rescue_oauth_errors
VALID_ORDER_PARAMS = [:name, :updated_at, :restricted, :user_id].freeze
def index
oauth_apps = user_or_organization_apps
render_paged(oauth_apps, private_data: true) { |params| api_v4_oauth_apps_url(params) }
end
def index_granted
oauth_apps = @user.granted_oauth_apps
render_paged(oauth_apps) { |params| api_v4_oauth_apps_index_granted_url(params) }
end
def show
render_jsonp(OauthAppPresenter.new(@oauth_app).to_hash(private_data: true), 200)
end
def create
create_params = permitted_params.merge(user: @user)
@oauth_app = OauthApp.create!(create_params)
track_event('CreatedOauthApp')
render_jsonp(OauthAppPresenter.new(@oauth_app).to_hash(private_data: true), 201)
end
def update
@oauth_app.update_attributes!(permitted_params)
render_jsonp(OauthAppPresenter.new(@oauth_app).to_hash(private_data: true), 200)
end
def regenerate_secret
@oauth_app.regenerate_client_secret!
render_jsonp(OauthAppPresenter.new(@oauth_app.reload).to_hash(private_data: true), 200)
end
def destroy
@oauth_app.destroy!
track_event('DeletedOauthApp')
head :no_content
end
def revoke
oauth_app_user = @oauth_app.oauth_app_users.where(user_id: @user.id).first
oauth_app_user.destroy!
track_event('DeletedOauthAppUser')
head :no_content
end
private
def load_user
@user = Carto::User.find(current_viewer.id)
end
def load_owned_app
@oauth_app = Carto::OauthApp.find(params[:id])
raise ActiveRecord::RecordNotFound.new unless owned?
end
def load_granted_app
@oauth_app = Carto::OauthApp.find(params[:id])
raise ActiveRecord::RecordNotFound.new unless granted?
end
def owned?
return true if @oauth_app.user_id == @user.id
@user.organization_admin? && @user.organization == @oauth_app.user.organization
end
def granted?
@user.granted_oauth_apps.include?(@oauth_app)
end
def load_index_params
@page, @per_page, @order = page_per_page_order_params(VALID_ORDER_PARAMS)
end
def user_or_organization_apps
return @user.oauth_apps unless @user.organization_admin?
org_users = @user.organization.users
Carto::OauthApp.where(user: org_users)
end
def permitted_params
params.permit(:name, :icon_url, :description, :website_url, redirect_uris: [])
end
def render_paged(oauth_apps, private_data: false)
filtered_oauth_apps = Carto::PagedModel.paged_association(oauth_apps, @page, @per_page, @order)
result = filtered_oauth_apps.map do |oauth_app|
OauthAppPresenter.new(oauth_app, user: @user).to_hash(private_data: private_data)
end
enriched_response = paged_result(
result: result,
total_count: oauth_apps.size,
page: @page,
per_page: @per_page,
params: params.except('controller', 'action')
) { |params| yield(params) }
render_jsonp(enriched_response, 200)
end
def rescue_oauth_errors(exception)
render json: { errors: exception.parameters[:error_description] }, status: 500
end
def track_event(event_name)
properties = {
user_id: @user.id,
app_id: @oauth_app.id,
app_name: @oauth_app.name
}
event_class = "Carto::Tracking::Events::#{event_name}".constantize
event_class.new(@user.id, properties).report
end
end
end
end
end
@@ -0,0 +1,19 @@
module Carto
module Api
module Public
class OrganizationPublicProfilePresenter
def initialize(organization)
@organization = organization
end
def to_hash
{
owner: {
username: @organization.owner.username
}
}
end
end
end
end
end
@@ -0,0 +1,29 @@
module Carto
module Api
module Public
class UserPublicPresenter
include SqlApiHelper
include MapsApiHelper
def initialize(user)
@user = user
end
def to_hash
base_rails_url = CartoDB.base_url(@user.username)
{
username: @user.username,
organization: @user.has_organization? ? OrganizationPublicPresenter.new(@user.organization).to_hash : nil,
api_endpoints: {
sql: sql_api_url(@user.username),
maps: maps_api_url(@user.username),
import: base_rails_url,
auth: base_rails_url
}
}
end
end
end
end
end
@@ -0,0 +1,22 @@
module Carto
module Api
module Public
class UserPublicProfilePresenter < UserPublicPresenter
def initialize(user)
@user = user
end
def to_hash
org_hash = OrganizationPublicProfilePresenter.new(@user.organization).to_hash if @user.has_organization?
super.deep_merge(
organization: org_hash,
avatar_url: @user.avatar_url,
first_name: @user.name,
last_name: @user.last_name
)
end
end
end
end
end
@@ -0,0 +1,19 @@
module Carto
module Api
module Public
class UsersController < Carto::Api::Public::ApplicationController
ssl_required
def me_public
presentation = if request_api_key.user_data.try(:include?, 'profile')
UserPublicProfilePresenter.new(request_api_key.user).to_hash
else
UserPublicPresenter.new(request_api_key.user).to_hash
end
render(json: presentation)
end
end
end
end
end
@@ -0,0 +1,17 @@
class Carto::Api::ReceivedNotificationPresenter
include Carto::HtmlSafe
def initialize(received_notification)
@received_notification = received_notification
end
def to_hash
{
id: @received_notification.id,
icon: @received_notification.icon,
html_body: markdown_html_safe(@received_notification.body),
received_at: @received_notification.received_at,
read_at: @received_notification.read_at
}
end
end
@@ -0,0 +1,41 @@
module Carto
module Api
class ReceivedNotificationsController < ::Api::ApplicationController
include Carto::ControllerHelper
extend Carto::DefaultRescueFroms
ssl_required :update
before_filter :load_user
before_filter :load_notification, only: [:update]
setup_default_rescues
def update
changed_notification = params[:notification]
if changed_notification
read_at = changed_notification[:read_at]
@received_notification.read_at = DateTime.parse(read_at) if read_at
@received_notification.save!
end
render_jsonp ReceivedNotificationPresenter.new(@received_notification).to_hash
rescue ArgumentError => e
render_jsonp({ errors: { read_at: 'invalid date format' } }, 422)
CartoDB::Logger.warning(exception: e)
end
private
def load_user
@user = Carto::User.find(current_user.id)
raise Carto::UnauthorizedError.new('Can only access own notifications') unless @user.id == params[:user_id]
end
def load_notification
@received_notification = @user.received_notifications.find(params[:id])
end
end
end
end
@@ -0,0 +1,89 @@
require_relative '../../../models/carto/permission'
module Carto
module Api
class RecordsController < ::Api::ApplicationController
ssl_required :show, :create, :update, :destroy
REJECT_PARAMS = %w{ format controller action row_id requestId column_id
api_key table_id oauth_token oauth_token_secret api_key user_domain }.freeze
before_filter :set_start_time
before_filter :load_user_table, only: [:show, :create, :update, :destroy]
before_filter :read_privileges?, only: [:show]
before_filter :write_privileges?, only: [:create, :update, :destroy]
# This endpoint is not used by the editor but by users. Do not remove
def show
render_jsonp(@user_table.service.record(params[:id]))
rescue => e
CartoDB::Logger.error(message: 'Error loading record', exception: e,
record_id: params[:id], user_table: @user_table)
render_jsonp({ errors: ["Record #{params[:id]} not found"] }, 404)
end
def create
primary_key = @user_table.service.insert_row!(filtered_row)
render_jsonp(@user_table.service.record(primary_key))
rescue => e
render_jsonp({ errors: [e.message] }, 400)
end
def update
if params[:cartodb_id].present?
begin
resp = @user_table.service.update_row!(params[:cartodb_id], filtered_row)
if resp > 0
render_jsonp(@user_table.service.record(params[:cartodb_id]))
else
render_jsonp({ errors: ["row identified with #{params[:cartodb_id]} not found"] }, 404)
end
rescue => e
CartoDB::Logger.warning(message: 'Error updating record', exception: e)
render_jsonp({ errors: [translate_error(e.message.split("\n").first)] }, 400)
end
else
render_jsonp({ errors: ["cartodb_id can't be blank"] }, 404)
end
end
def destroy
id = (params[:cartodb_id] =~ /\A\d+\z/ ? params[:cartodb_id] : params[:cartodb_id].to_s.split(','))
schema_name = current_user.database_schema
if current_user.id != @user_table.service.owner.id
schema_name = @user_table.service.owner.database_schema
end
current_user.in_database
.select
.from(Sequel.qualify(schema_name.to_sym, @user_table.service.name.to_sym))
.where(cartodb_id: id)
.delete
head :no_content
rescue
render_jsonp({ errors: ["row identified with #{params[:cartodb_id]} not found"] }, 404)
end
protected
def filtered_row
params.reject { |k, _| REJECT_PARAMS.include?(k) }.symbolize_keys
end
def load_user_table
@user_table = Carto::Helpers::TableLocator.new.get_by_id_or_name(params[:table_id], current_user)
raise RecordNotFound unless @user_table
end
def read_privileges?
head(401) unless current_user && @user_table.visualization.is_viewable_by_user?(current_user)
end
def write_privileges?
head(401) unless current_user && @user_table.visualization.writable_by?(current_user)
end
end
end
end
@@ -0,0 +1,41 @@
require_dependency 'carto/controller_helper'
class Carto::Api::SearchPreviewController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required
before_filter :login_required
before_filter :load_parameters
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::ParamCombinationInvalidError, with: :rescue_from_carto_error
DEFAULT_LIMIT = 4
DEFAULT_TYPES = "derived,table,remote,tag".freeze
VALID_TYPES = Carto::Visualization::VALID_TYPES + ["tag"]
def index
searcher_params = { user: current_viewer, pattern: @pattern, types: @types, limit: @limit }
searcher = Carto::DashboardPreviewSearcher.new(searcher_params)
result = searcher.search
presenter_params = { dashboard_search_result: result, limit: @limit, current_viewer: current_viewer, context: self }
presentation = Carto::Api::SearchPreviewPresenter.new(presenter_params).to_poro
render json: presentation
end
private
def load_parameters
@limit = params.fetch(:limit, DEFAULT_LIMIT).to_i
@pattern = params[:q]
@types = params.fetch(:types, DEFAULT_TYPES).split(',')
if (@types - VALID_TYPES).present?
raise Carto::ParamCombinationInvalidError.new(:types, VALID_TYPES)
end
end
end
@@ -0,0 +1,31 @@
class Carto::Api::SearchPreviewPresenter
def initialize(dashboard_search_result:, limit:, current_viewer:, context:)
@dashboard_search_result = dashboard_search_result
@limit = limit
@current_viewer = current_viewer
@context = context
end
def to_poro
result = (poro_tags + poro_visualizations).first(@limit)
{
result: result,
total_count: @dashboard_search_result.total_count
}
end
private
def poro_tags
@dashboard_search_result.tags.map do |tag|
Carto::Api::TagPresenter.new(tag, @current_viewer, @context).to_search_preview_poro
end
end
def poro_visualizations
@dashboard_search_result.visualizations.map do |visualization|
Carto::Api::VisualizationPresenter.new(visualization, @current_viewer, @context).to_search_preview_poro
end
end
end
@@ -0,0 +1,19 @@
module Carto
module Api
class SnapshotPresenter
def initialize(snapshot)
@snapshot = snapshot
end
def to_hash
{
id: @snapshot.id,
created_at: @snapshot.created_at,
updated_at: @snapshot.updated_at,
state: @snapshot.state,
user: Carto::Api::UserPresenter.new(@snapshot.user).to_public_poro
}
end
end
end
end
@@ -0,0 +1,84 @@
module Carto
module Api
class SnapshotsController < ::Api::ApplicationController
include ControllerHelper
ssl_required :index, :show, :create, :update, :destroy
before_filter :load_visualization,
:check_visualization_viewable
before_filter :load_snapshot,
:owners_only, only: [:show, :update, :destroy]
rescue_from LoadError,
UnauthorizedError,
UnprocesableEntityError, with: :rescue_from_carto_error
def index
snapshots = @visualization.snapshots.where(user_id: current_viewer.id)
presentation = snapshots.map do |snapshot|
SnapshotPresenter.new(snapshot).to_hash
end
render json: presentation
end
def show
render json: SnapshotPresenter.new(@snapshot).to_hash
end
def create
snapshot = Snapshot.create!(user_id: current_viewer.id,
visualization_id: @visualization.id,
state: params[:state])
render json: SnapshotPresenter.new(snapshot).to_hash, status: :created
rescue ActiveRecord::RecordInvalid => exception
message = exception.record.errors.full_messages.join(', ')
raise UnprocesableEntityError.new(message)
end
def update
@snapshot.update_attributes!(state: params[:state])
render json: SnapshotPresenter.new(@snapshot).to_hash
rescue ActiveRecord::RecordInvalid => exception
message = exception.record.errors.full_messages.join(', ')
raise UnprocesableEntityError.new(message)
end
def destroy
@snapshot.destroy
render json: Hash.new, status: :no_content
end
private
def load_visualization
@visualization = Visualization.find(params[:visualization_id])
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Visualization not found')
end
def check_visualization_viewable
unless @visualization.is_viewable_by_user?(current_viewer)
raise UnauthorizedError.new
end
end
def load_snapshot
@snapshot = Snapshot.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Snapshot not found')
end
def owners_only
unless @snapshot.user_id == current_viewer.id
raise UnauthorizedError.new
end
end
end
end
end
@@ -0,0 +1,19 @@
module Carto
module Api
class StatePresenter
def initialize(state)
@state = state
end
def to_hash
{
id: @state.id,
visualization_id: @state.visualization_id,
created_at: @state.created_at,
updated_at: @state.updated_at,
json: @state.json
}
end
end
end
end
@@ -0,0 +1,40 @@
module Carto
module Api
class StatesController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :update
before_filter :load_visualization,
:check_writer, only: :update
rescue_from LoadError,
UnauthorizedError,
UnprocesableEntityError, with: :rescue_from_carto_error
def update
@visualization.state.json = params[:json]
@visualization.state.save!
render json: StatePresenter.new(@visualization.state).to_hash
rescue ActiveRecord::RecordInvalid
message = @visualization.errors.full_messages.join(', ')
raise UnprocesableEntityError.new(message)
end
private
def load_visualization
@visualization = Visualization.find(params[:visualization_id])
rescue ActiveRecord::RecordNotFound
raise LoadError.new('Visualization not found')
end
def check_writer
unless @visualization.writable_by?(current_viewer)
raise UnauthorizedError.new
end
end
end
end
end
@@ -0,0 +1,31 @@
require_dependency 'carto/uuidhelper'
module Carto
module Api
class StaticNotificationsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :update
before_filter :load_static_notifications, only: [:update]
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::LoadError, with: :rescue_from_carto_error
def update
category = params[:category].to_sym
@notifications.notifications[category] = params[:notifications]
if @notifications.save
render_jsonp({ notifications: @notifications.notifications[category] }, 200)
else
render_jsonp({ errors: @notifications.errors.to_h }, 422)
end
end
private
def load_static_notifications
@notifications = Carto::User.find(current_user.id).static_notifications
end
end
end
end
@@ -0,0 +1,43 @@
module Carto
module Api
class SynchronizationPresenter
def initialize(synchronization)
@synchronization = synchronization
end
def to_poro
return nil if @synchronization.nil?
{
checksum: @synchronization.checksum,
created_at: @synchronization.created_at,
error_code: @synchronization.error_code,
error_message: @synchronization.error_message,
id: @synchronization.id,
interval: @synchronization.interval,
modified_at: @synchronization.modified_at,
name: @synchronization.name,
ran_at: @synchronization.ran_at,
retried_times: @synchronization.retried_times,
run_at: @synchronization.run_at,
service_item_id: @synchronization.service_item_id,
service_name: @synchronization.service_name,
state: @synchronization.state,
updated_at: @synchronization.updated_at,
url: @synchronization.url,
user_id: @synchronization.user_id,
content_guessing: @synchronization.content_guessing,
etag: @synchronization.etag,
log_id: @synchronization.log_id,
quoted_fields_guessing: @synchronization.quoted_fields_guessing,
type_guessing: @synchronization.type_guessing,
from_external_source: @synchronization.from_external_source?,
visualization_id: @synchronization.visualization_id
}
end
end
end
end
@@ -0,0 +1,48 @@
module Carto
module Api
class SynchronizationsController < ::Api::ApplicationController
ssl_required :show, :index, :syncing?
before_filter :load_synchronization, only: [:show, :syncing?]
def show
render_jsonp(@synchronization)
rescue => exception
CartoDB.notify_exception(exception)
head(404)
end
def index
synchronizations = Carto::Synchronization.where(user_id: current_user.id)
representation = synchronizations.map(&:to_hash)
response = {
synchronizations: representation,
total_entries: synchronizations.count
}
render_jsonp(response)
rescue => exception
CartoDB.notify_exception(exception)
head(404)
end
def syncing?
render_jsonp( { state: @synchronization.state } )
rescue => exception
CartoDB.notify_exception(exception)
head(404)
end
private
def load_synchronization
@synchronization = Carto::Synchronization::where(id: params[:id]).first
head(404) and return unless @synchronization
head(401) and return unless @synchronization.authorize?(current_user)
end
end
end
end
@@ -0,0 +1,45 @@
module Carto
module Api
class TablePresenter
def initialize(table, current_viewer, context)
@table = table
@current_viewer = current_viewer
@context = context
end
def to_poro
{
id: @table.id,
name: qualified_table_name,
privacy: @table.privacy_text,
schema: @table.schema,
updated_at: @table.updated_at,
rows_counted: @table.rows_estimated,
table_size: @table.table_size,
map_id: @table.map_id,
description: @table.description,
geometry_types: @table.geometry_types,
table_visualization: VisualizationPresenter.new(@table.table_visualization, @current_viewer, @context).to_poro,
dependent_visualizations: @table.fully_dependent_visualizations.map do |v|
VisualizationPresenter.new(v, @current_viewer, @context).to_summarized_poro
end,
non_dependent_visualizations: @table.partially_dependent_visualizations.map do |v|
VisualizationPresenter.new(v, @current_viewer, @context).to_summarized_poro
end,
synchronization: SynchronizationPresenter.new(@table.synchronization).to_poro
}
end
private
def qualified_table_name
owner = @table.owner
if @current_viewer && owner && owner.id != @current_viewer.id
"#{owner.sql_safe_database_schema}.#{@table.name}"
else
@table.name
end
end
end
end
end
@@ -0,0 +1,100 @@
require_relative '../../../models/carto/permission'
require_relative '../../../models/carto/user_table'
module Carto
module Api
class TablesController < ::Api::ApplicationController
ssl_required :show, :create, :update
before_filter :set_start_time
before_filter :load_user_table, only: [:show, :update]
before_filter :read_privileges?, only: [:show]
before_filter :write_privileges?, only: [:update]
def show
table = @user_table.service
render_jsonp(TablePresenter.new(table, current_user, self).to_poro.merge(schema: table.schema(reload: true)))
end
# Very basic controller method to simply make blank tables
# All other table creation things are controlled via the imports_controller#create
def create
table = ::Table.new
table.user_id = current_user.id
tables = Carto::Db::UserSchema.new(current_user).table_names
table.name = Carto::ValidTableNameProposer.new.propose_valid_table_name(params[:name], taken_names: tables)
table.description = params[:description] if params[:description]
table.the_geom_type = params[:the_geom_type] if params[:the_geom_type]
table.force_schema = params[:schema] if params[:schema]
table.tags = params[:tags] if params[:tags]
table.import_from_query = params[:from_query] if params[:from_query]
if table.valid? && table.save
render_jsonp(TablePresenter.new(table, current_user, self).to_poro, 200, location: "/tables/#{table.id}")
table_visualization = table.table_visualization
if table_visualization
current_viewer_id = current_viewer.id
Carto::Tracking::Events::CreatedDataset.new(current_viewer_id,
visualization_id: table_visualization.id,
user_id: current_viewer_id,
origin: 'blank').report
end
else
CartoDB::Logger.error(message: 'Error on tables#create', errors: table.errors.full_messages)
render_jsonp({ description: table.errors.full_messages, stack: table.errors.full_messages }, 400)
end
rescue CartoDB::QuotaExceeded
current_viewer_id = current_viewer.id
Carto::Tracking::Events::ExceededQuota.new(current_viewer_id,
user_id: current_viewer_id).report
render_jsonp({ errors: ['You have reached your table quota'] }, 400)
end
def update
# TODO This endpoint is only used to geocode from editor, passing `latitude_column` and `longitude_column`
# TODO It also supports attributes assignement, but this is not called from our frontend
table = @user_table.service
warnings = []
# TODO: this is bad, passing all params blindly to the table object
@user_table.assign_attributes(params.symbolize_keys.reject { |k, _| k == :name })
if params.keys.include?('latitude_column') && params.keys.include?('longitude_column')
latitude_column = params[:latitude_column] == 'nil' ? nil : params[:latitude_column].try(:to_sym)
longitude_column = params[:longitude_column] == 'nil' ? nil : params[:longitude_column].try(:to_sym)
table.georeference_from!(latitude_column: latitude_column, longitude_column: longitude_column)
table.update_bounding_box
render_jsonp(TablePresenter.new(table, current_user, self).to_poro.merge(warnings: warnings))
return
end
if @user_table.save
render_jsonp(TablePresenter.new(table, current_user, self).to_poro.merge(warnings: warnings))
else
render_jsonp({ errors: table.errors.full_messages }, 400)
end
rescue => e
CartoDB::Logger.error(message: 'Error updating table', exception: e)
render_jsonp({ errors: [translate_error(e.message.split("\n").first)] }, 400)
end
private
def load_user_table
@user_table = Carto::Helpers::TableLocator.new.get_by_id_or_name(params[:id], current_user)
raise RecordNotFound unless @user_table
end
def read_privileges?
head(403) unless current_user && @user_table.visualization.is_viewable_by_user?(current_user)
end
def write_privileges?
head(401) unless current_user && @user_table.visualization.writable_by?(current_user)
end
end
end
end
@@ -0,0 +1,17 @@
class Carto::Api::TagPresenter
def initialize(tag_name, current_viewer, context)
@tag_name = tag_name
@current_viewer = current_viewer
@context = context
end
def to_search_preview_poro
{
type: "tag",
name: @tag_name,
url: CartoDB.url(@context, "tag_search", user: @current_viewer, params: { q: @tag_name })
}
end
end
@@ -0,0 +1,61 @@
require_dependency 'carto/controller_helper'
module Carto
module Api
class TagsController < ::Api::ApplicationController
include Carto::ControllerHelper
include PagedSearcher
ssl_required
before_filter :load_parameters
rescue_from StandardError, with: :rescue_from_standard_error
rescue_from Carto::ParamCombinationInvalidError, with: :rescue_from_carto_error
DEFAULT_TAGS_PER_PAGE = 6
def index
query_builder = tag_query_builder
result = query_builder.build_paged(@page, @per_page)
total_count = query_builder.total_count
render json: format_response(result, total_count)
end
private
def tag_query_builder
query_builder = Carto::TagQueryBuilder.new
.with_types(@types)
.with_partial_match(@pattern)
return query_builder.with_owned_by_or_shared_with_user_id(current_viewer.id) if @include_shared
query_builder.with_owned_by_user_id(current_viewer.id)
end
def load_parameters
@page, @per_page = page_per_page_params(default_per_page: DEFAULT_TAGS_PER_PAGE)
@pattern = params[:q]
@include_shared = params[:include_shared] == 'true'
@types = params.fetch(:types, "").split(',')
if (@types - Carto::Visualization::VALID_TYPES).present?
raise Carto::ParamCombinationInvalidError.new(:types, Carto::Visualization::VALID_TYPES)
end
end
def format_response(result, total_count)
paged_result(
result: result,
total_count: total_count,
page: @page,
per_page: @per_page,
params: params
) { |params| api_v3_users_tags_url(params) }
end
end
end
end
@@ -0,0 +1,31 @@
module Carto
module Api
class TemplatePresenter
def initialize(template)
@template = template
end
# When used inside a list, parent will be a " items: [ ... ] "
def public_values
{
id: @template.id,
title: @template.title,
description: @template.description,
source_visualization: {
id: @template.source_visualization_id
},
min_supported_version: @template.min_supported_version,
max_supported_version: @template.max_supported_version,
code: @template.code,
organization: {
id: @template.organization_id
},
required_tables: @template.required_tables,
created_at: @template.created_at
}
end
end
end
end
@@ -0,0 +1,146 @@
module Carto
module Api
class TemplatesController < ::Api::ApplicationController
ssl_required :index, :show, :create, :update, :destroy, :related_templates_by_visualization,
:related_templates_by_table
before_filter :load_template, only: [ :show, :update, :destroy ]
before_filter :check_feature_flag
def index
templates = Carto::Template.where(organization_id: current_user.organization_id)
.order(:created_at)
.reverse_order
.all
render_jsonp({ items: templates.map { |template| Carto::Api::TemplatePresenter.new(template).public_values } })
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
def show
render_jsonp(Carto::Api::TemplatePresenter.new(@template).public_values)
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
def create
@stats_aggregator.timing('templates.create') do
begin
@template = Carto::Template.new({
source_visualization_id: params['source_visualization_id'],
title: params['title'],
description: params.fetch('description', ''),
min_supported_version: params['min_supported_version'],
max_supported_version: params['max_supported_version'],
code: params.fetch('code', ''),
organization_id: current_user.organization_id,
required_tables: params.fetch('required_tables', [])
})
result = @stats_aggregator.timing('save') do
@template.save
end
render_jsonp({ :errors => ["#{@template.errors.messages.values.join(',')}"] }, 400) and return unless result
render_jsonp(Carto::Api::TemplatePresenter.new(@template).public_values)
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
end
end
def update
@stats_aggregator.timing('templates.update') do
begin
@template.title = params['title']
@template.description = params.fetch('description', '')
@template.min_supported_version = params['min_supported_version']
@template.max_supported_version = params['max_supported_version']
@template.code = params.fetch('code', '')
@template.required_tables = params.fetch('required_tables', [])
result = @stats_aggregator.timing('save') do
@template.save
end
render_jsonp({ :errors => ["#{@template.errors.messages.values.join(',')}"] }, 400) and return unless result
@template.reload
render_jsonp(Carto::Api::TemplatePresenter.new(@template).public_values)
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
end
end
def destroy
@stats_aggregator.timing('templates.destroy') do
begin
@stats_aggregator.timing('delete') do
@template.delete
end
head :ok
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
end
end
def related_templates_by_visualization
return head(400) if params[:id].nil?
vis = Carto::Visualization.where(id: params[:id]).first
return head(400) if vis.nil?
templates = vis.related_templates
render_jsonp({ items: templates.map { |template| Carto::Api::TemplatePresenter.new(template).public_values } })
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
def related_templates_by_table
user_table = Carto::Helpers::TableLocator.new.get_by_id_or_name(params.fetch('id'), current_user)
templates = user_table.service.related_templates
render_jsonp(items: templates.map { |template| Carto::Api::TemplatePresenter.new(template).public_values })
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
private
def load_template
@template = Carto::Template.where(id: params[:id]).first
return render json: { errors: ["Template #{params[:id]} not found"] }, status: 404 if @template.nil?
rescue => e
CartoDB.notify_exception(e)
render json: { error: [e.message] }, status: 400
end
def check_feature_flag
unless current_user.has_feature_flag?('templated_workflows')
render json: { error: "Endpoint disabled for this user" }, status: 403
end
end
end
end
end
@@ -0,0 +1,25 @@
module Carto
module Api
class UserCreationPresenter
def initialize(user_creation)
@user_creation = user_creation
end
def to_poro
{
id: @user_creation.id,
username: @user_creation.username,
email: @user_creation.email,
organization_id: @user_creation.organization_id,
google_sign_in: @user_creation.google_sign_in,
requires_validation_email: @user_creation.requires_validation_email?,
state: @user_creation.state,
created_at: @user_creation.created_at,
updated_at: @user_creation.updated_at
}
end
end
end
end
@@ -0,0 +1,31 @@
require_relative 'user_creation_presenter'
module Carto
module Api
class UserCreationsController < ::Api::ApplicationController
skip_before_filter :api_authorization_required
skip_before_filter :http_header_authentication, only: [:show]
ssl_required :show
before_filter :load_user_creation, only: :show
def show
if @user_creation.autologin?
params[:username] = @user_creation.username
authenticate!(:user_creation, scope: @user_creation.username)
end
render_jsonp(UserCreationPresenter.new(@user_creation).to_poro)
end
private
def load_user_creation
@user_creation = Carto::UserCreation.where(id: params[:id]).first
render_404 and return false unless @user_creation
end
end
end
end
+307
View File
@@ -0,0 +1,307 @@
require_relative 'group_presenter'
module Carto
module Api
class UserPresenter
include AccountTypeHelper
def initialize(user,
fetch_groups: false,
current_viewer: nil,
fetch_db_size: true,
fetch_basemaps: false,
fetch_profile: true)
@user = user
@fetch_groups = fetch_groups
@current_viewer = current_viewer
@fetch_db_size = fetch_db_size
@fetch_basemaps = fetch_basemaps
@fetch_profile = fetch_profile
end
def to_poro
return {} if @user.nil?
return to_public_poro unless current_viewer && @user.viewable_by?(current_viewer)
poro = {
id: @user.id,
name: @user.name,
last_name: @user.last_name,
username: @user.username,
email: @user.email,
avatar_url: @user.avatar_url,
website: @user.website,
description: @user.description,
location: @user.location,
twitter_username: @user.twitter_username,
disqus_shortname: @user.disqus_shortname,
available_for_hire: @user.available_for_hire,
base_url: @user.public_url,
google_maps_query_string: @user.google_maps_query_string,
quota_in_bytes: @user.quota_in_bytes,
table_count: @user.table_count,
viewer: @user.viewer?,
org_admin: @user.organization_admin?,
public_visualization_count: @user.public_visualization_count,
all_visualization_count: @user.all_visualization_count,
org_user: @user.organization_id.present?,
remove_logo: @user.remove_logo?
}
if fetch_groups
poro[:groups] = @user.groups ? @user.groups.map { |g| Carto::Api::GroupPresenter.new(g).to_poro } : []
end
poro[:basemaps] = @user.basemaps if fetch_basemaps
poro[:db_size_in_bytes] = @user.db_size_in_bytes if fetch_db_size
if fetch_profile
poro[:industry] = @user.industry
poro[:company_employees] = @user.company_employees
poro[:use_case] = @user.use_case
poro[:company] = @user.company
poro[:phone] = @user.phone
poro[:job_role] = @user.job_role
end
poro
end
def to_eumapi_poro
presentation = to_poro
presentation.delete(:id)
presentation[:soft_geocoding_limit] = @user.soft_geocoding_limit
presentation[:api_key] = @user.api_key
presentation
end
def to_public_poro
return {} if @user.nil?
poro = {
id: @user.id,
username: @user.username,
name: @user.name,
last_name: @user.last_name,
avatar_url: @user.avatar_url,
base_url: @user.public_url,
google_maps_query_string: @user.google_maps_query_string,
disqus_shortname: @user.disqus_shortname,
viewer: @user.viewer?,
org_admin: @user.organization_admin?,
org_user: @user.organization_id.present?,
remove_logo: @user.remove_logo?
}
if fetch_groups
poro[:groups] = @user.groups ? @user.groups.map { |g| Carto::Api::GroupPresenter.new(g).to_poro } : []
end
poro
end
def data(options = {})
return {} if @user.nil?
calls = @user.get_api_calls(from: @user.last_billing_cycle, to: Date.today)
calls.fill(0, calls.size..29)
db_size_in_bytes = @user.db_size_in_bytes
data = {
id: @user.id,
email: @user.email,
name: @user.name,
last_name: @user.last_name,
created_at: @user.created_at,
username: @user.username,
state: @user.state,
account_type: @user.account_type,
account_type_display_name: plan_name(@user.account_type),
table_quota: @user.table_quota,
public_map_quota: @user.public_map_quota,
regular_api_key_quota: @user.regular_api_key_quota,
table_count: @user.table_count,
viewer: @user.viewer?,
industry: @user.industry,
company_employees: @user.company_employees,
use_case: @user.use_case,
company: @user.company,
phone: @user.phone,
job_role: @user.job_role,
org_admin: @user.organization_admin?,
public_visualization_count: @user.public_visualization_count,
public_privacy_map_count: @user.public_privacy_visualization_count,
link_privacy_map_count: @user.link_privacy_visualization_count,
password_privacy_map_count: @user.password_privacy_visualization_count,
owned_visualization_count: @user.owned_visualization_count,
all_visualization_count: @user.all_visualization_count,
visualization_count: @user.visualization_count,
failed_import_count: failed_import_count,
success_import_count: success_import_count,
import_count: import_count,
last_visualization_created_at: last_visualization_created_at,
quota_in_bytes: @user.quota_in_bytes,
db_size_in_bytes: db_size_in_bytes,
db_size_in_megabytes: db_size_in_bytes.present? ? (db_size_in_bytes / (1024.0 * 1024.0)).round(2) : nil,
remaining_table_quota: @user.remaining_table_quota,
remaining_byte_quota: @user.remaining_quota(false, db_size_in_bytes).to_f,
api_calls: calls,
api_calls_quota: @user.organization_user? ? @user.organization.map_view_quota : @user.map_view_quota,
api_calls_block_price: @user.organization_user? ? @user.organization.map_view_block_price : @user.map_view_block_price,
geocoding: {
quota: @user.organization_user? ? @user.organization.geocoding_quota : @user.geocoding_quota,
block_price: @user.organization_user? ? @user.organization.geocoding_block_price : @user.geocoding_block_price,
monthly_use: @user.organization_user? ? @user.organization.get_geocoding_calls : @user.get_geocoding_calls,
hard_limit: @user.hard_geocoding_limit?
},
here_isolines: {
quota: @user.organization_user? ? @user.organization.here_isolines_quota : @user.here_isolines_quota,
block_price: @user.organization_user? ? @user.organization.here_isolines_block_price : @user.here_isolines_block_price,
monthly_use: @user.organization_user? ? @user.organization.get_here_isolines_calls : @user.get_here_isolines_calls,
hard_limit: @user.hard_here_isolines_limit?
},
mapzen_routing: {
quota: @user.organization_user? ? @user.organization.mapzen_routing_quota : @user.mapzen_routing_quota,
block_price: @user.organization_user? ? @user.organization.mapzen_routing_block_price : @user.mapzen_routing_block_price,
monthly_use: @user.organization_user? ? @user.organization.get_mapzen_routing_calls : @user.get_mapzen_routing_calls,
hard_limit: @user.hard_mapzen_routing_limit?
},
geocoder_provider: @user.geocoder_provider,
isolines_provider: @user.isolines_provider,
routing_provider: @user.routing_provider,
obs_snapshot: {
quota: @user.organization_user? ? @user.organization.obs_snapshot_quota : @user.obs_snapshot_quota,
block_price: @user.organization_user? ? @user.organization.obs_snapshot_block_price : @user.obs_snapshot_block_price,
monthly_use: @user.organization_user? ? @user.organization.get_obs_snapshot_calls : @user.get_obs_snapshot_calls,
hard_limit: @user.hard_obs_snapshot_limit?
},
obs_general: {
quota: @user.organization_user? ? @user.organization.obs_general_quota : @user.obs_general_quota,
block_price: @user.organization_user? ? @user.organization.obs_general_block_price : @user.obs_general_block_price,
monthly_use: @user.organization_user? ? @user.organization.get_obs_general_calls : @user.get_obs_general_calls,
hard_limit: @user.hard_obs_general_limit?
},
twitter: {
enabled: @user.twitter_datasource_enabled,
quota: @user.organization_user? ? @user.organization.twitter_datasource_quota : @user.twitter_datasource_quota,
block_price: @user.organization_user? ? @user.organization.twitter_datasource_block_price : @user.twitter_datasource_block_price,
block_size: @user.organization_user? ? @user.organization.twitter_datasource_block_size : @user.twitter_datasource_block_size,
monthly_use: @user.organization_user? ? @user.organization.twitter_imports_count : @user.twitter_imports_count,
hard_limit: @user.hard_twitter_datasource_limit,
customized_config: CartoDB::Datasources::DatasourcesFactory.customized_config?(CartoDB::Datasources::Search::Twitter::DATASOURCE_NAME, @user)
},
salesforce: {
enabled: @user.organization_user? ? @user.organization.salesforce_datasource_enabled : @user.salesforce_datasource_enabled
},
mailchimp: {
enabled: Carto::AccountType.new.mailchimp?(@user)
},
billing_period: @user.last_billing_cycle,
next_billing_period: @user.next_billing_cycle,
api_key: @user.api_key,
layers: @user.layers.map { |layer|
Carto::Api::LayerPresenter.new(layer).to_poro
},
trial_ends_at: @user.trial_ends_at,
upgraded_at: @user.upgraded_at,
show_trial_reminder: @user.trial_ends_at.present?,
show_upgraded_message: (@user.account_type.downcase != 'free' && @user.upgraded_at && @user.upgraded_at + 15.days > Date.today ? true : false),
actions: {
private_tables: @user.private_tables_enabled,
private_maps: @user.private_maps_enabled?,
remove_logo: @user.remove_logo?,
sync_tables: @user.sync_tables_enabled,
google_maps_geocoder_enabled: @user.google_maps_geocoder_enabled?,
google_maps_enabled: @user.google_maps_enabled?,
engine_enabled: @user.engine_enabled?,
builder_enabled: @user.builder_enabled?,
mobile_sdk_enabled: @user.mobile_sdk_enabled?
},
limits: {
concurrent_syncs: CartoDB::PlatformLimits::Importer::UserConcurrentSyncsAmount::MAX_SYNCS_PER_USER,
concurrent_imports: @user.max_concurrent_import_count,
import_file_size: @user.max_import_file_size,
import_table_rows: @user.max_import_table_row_count,
max_layers: @user.max_layers
},
notification: @user.notification,
avatar_url: @user.avatar,
feature_flags: @user.feature_flag_names,
base_url: @user.public_url,
needs_password_confirmation: @user.needs_password_confirmation?,
description: @user.description,
website: @user.website,
twitter_username: @user.twitter_username,
disqus_shortname: @user.disqus_shortname,
available_for_hire: @user.available_for_hire,
location: @user.location,
mfa_configured: @user.multifactor_authentication_configured?
}
if @user.google_maps_geocoder_enabled? && (!@user.organization.present? || @user.organization_owner?)
data[:google_maps_private_key] = @user.google_maps_private_key
end
if @user.organization.present?
data[:organization] = Carto::Api::OrganizationPresenter.new(@user.organization).to_poro
data[:organization][:available_quota_for_user] = @user.organization.unassigned_quota + @user.quota_in_bytes
end
if !@user.groups.nil?
data[:groups] = @user.groups.map { |g| Carto::Api::GroupPresenter.new(g).to_poro }
end
if @user.mobile_sdk_enabled?
data[:mobile_apps] = {
mobile_xamarin: @user.mobile_xamarin,
mobile_custom_watermark: @user.mobile_custom_watermark,
mobile_offline_maps: @user.mobile_offline_maps,
mobile_gis_extension: @user.mobile_gis_extension,
mobile_max_open_users: @user.mobile_max_open_users,
mobile_max_private_users: @user.mobile_max_private_users
}
end
if options[:extended]
# TODO: This fields are pending migration
data.merge({
:real_table_count => @user.real_tables.size,
:last_active_time => @user.get_last_active_time
})
else
data
end
end
private
attr_reader :current_viewer, :current_user, :fetch_groups, :fetch_db_size, :fetch_basemaps, :fetch_profile
def failed_import_count
Carto::DataImport.where(user_id: @user.id, state: 'failure').count
end
def success_import_count
Carto::DataImport.where(user_id: @user.id, state: 'complete').count
end
def import_count
DataImport.where(user_id: @user.id).count
end
def last_visualization_created_at
row_data = Carto::VisualizationQueryBuilder.new
.with_user_id(@user.id)
.with_order(:created_at, :desc)
.build_paged(1, 1)
.pluck(:created_at)
row_data.nil? ? nil : row_data[0]
end
end
end
end
@@ -0,0 +1,79 @@
module Carto
module Api
class UserTablePresenter
# options:
# - accessible_dependent_derived_maps
PRIVACY_PRIVATE = 0
PRIVACY_PUBLIC = 1
PRIVACY_LINK = 2
PRIVACY_VALUES_TO_TEXTS = {
PRIVACY_PRIVATE => 'private',
PRIVACY_PUBLIC => 'public',
PRIVACY_LINK => 'link'
}
def initialize(user_table, current_viewer, show_size_and_row_count: true)
@user_table = user_table
@current_viewer = current_viewer
@presenter_cache = Carto::Api::PresenterCache.new
@show_size_and_row_count = show_size_and_row_count
end
def with_presenter_cache(presenter_cache)
@presenter_cache = presenter_cache
self
end
def to_poro(accessible_dependent_derived_maps: false, context: nil)
return {} if @user_table.nil?
permission = @user_table.permission
permission_presentation = Carto::Api::PermissionPresenter.new(
permission, current_viewer: @current_viewer
).with_presenter_cache(@presenter_cache).to_poro if permission
poro = {
id: @user_table.id,
name: @user_table.name_for_user(@current_viewer),
permission: permission_presentation,
geometry_types: @user_table.geometry_types,
privacy: privacy_text(@user_table.privacy).upcase,
updated_at: @user_table.updated_at
}
if @user_table.is_owner?(@current_viewer)
poro[:synchronization] = Carto::Api::SynchronizationPresenter.new(@user_table.synchronization).to_poro
end
if show_size_and_row_count
row_count_and_size = @user_table.row_count_and_size
poro[:size] = row_count_and_size[:size]
poro[:row_count] = row_count_and_size[:row_count]
end
if accessible_dependent_derived_maps && context
poro[:accessible_dependent_derived_maps] = derived_maps_to_presenter(context)
end
poro
end
def privacy_text(privacy)
#TODO: This came from UserTable
privacy == PRIVACY_LINK ? 'PUBLIC' : PRIVACY_VALUES_TO_TEXTS[privacy]
end
private
attr_reader :show_size_and_row_count
def derived_maps_to_presenter(context)
visualizations = @user_table.accessible_dependent_derived_maps
visualizations.map { |v| Carto::Api::VisualizationPresenter.new(v, @current_viewer, context).to_poro }
end
end
end
end
@@ -0,0 +1,248 @@
require_relative '../../helpers/avatar_helper'
require_dependency 'carto/controller_helper'
module Carto
module Api
class UsersController < ::Api::ApplicationController
include OrganizationUsersHelper
include AppAssetsHelper
include MapsApiHelper
include SqlApiHelper
include CartoDB::ConfigUtils
include FrontendConfigHelper
include AccountTypeHelper
include AvatarHelper
include Carto::ControllerHelper
UPDATE_ME_FIELDS = %i(
name last_name website description location twitter_username disqus_shortname available_for_hire company
industry phone job_role company_employees use_case
).freeze
PASSWORD_DOES_NOT_MATCH_MESSAGE = 'Password does not match'.freeze
ssl_required
before_action :optional_api_authorization, only: [:me]
before_action :any_api_authorization_required, only: [:me_public]
before_action :recalculate_user_db_size, only: [:me]
skip_before_action :api_authorization_required, only: [:me, :me_public, :get_authenticated_users]
skip_before_action :check_user_state, only: [:me, :delete_me]
rescue_from StandardError, with: :rescue_from_standard_error
def show
render json: Carto::Api::UserPresenter.new(uri_user).data
end
def me
carto_viewer = current_viewer.present? ? Carto::User.find(current_viewer.id) : nil
cant_be_deleted_reason = carto_viewer.try(:cant_be_deleted_reason)
can_be_deleted = carto_viewer.present? ? cant_be_deleted_reason.nil? : nil
viewer_organization_notifications = carto_viewer ? organization_notifications(carto_viewer) : []
viewer_unfiltered_notifications = carto_viewer ? unfiltered_organization_notifications(carto_viewer) : []
render json: {
user_data: carto_viewer.present? ? Carto::Api::UserPresenter.new(carto_viewer).data : nil,
default_fallback_basemap: carto_viewer.try(:default_basemap),
config: frontend_config_hash(current_viewer),
dashboard_notifications: carto_viewer.try(:notifications_for_category, :dashboard),
organization_notifications: viewer_organization_notifications,
unfiltered_organization_notifications: viewer_unfiltered_notifications,
is_just_logged_in: carto_viewer.present? ? !!flash['logged'] : nil,
is_first_time_viewing_dashboard: !carto_viewer.try(:dashboard_viewed_at),
can_change_email: carto_viewer.try(:can_change_email?),
auth_username_password_enabled: carto_viewer.try(:organization).try(:auth_username_password_enabled),
can_change_password: carto_viewer.try(:can_change_password?),
plan_name: carto_viewer.present? ? plan_name(carto_viewer.account_type) : nil,
plan_url: carto_viewer.try(:plan_url, request.protocol),
can_be_deleted: can_be_deleted,
cant_be_deleted_reason: cant_be_deleted_reason,
services: carto_viewer.try(:get_oauth_services),
user_frontend_version: carto_viewer.try(:relevant_frontend_version) || CartoDB::Application.frontend_version,
asset_host: carto_viewer.try(:asset_host),
google_sign_in: carto_viewer.try(:google_sign_in),
mfa_required: multifactor_authentication_required?
}
end
def update_me
user = current_viewer
attributes = params[:user]
if attributes.present?
unless user.valid_password_confirmation(attributes[:password_confirmation])
raise Carto::PasswordConfirmationError.new
end
update_user_attributes(user, attributes)
raise Sequel::ValidationFailed.new('Validation failed') unless user.errors.try(:empty?) && user.valid?
ActiveRecord::Base.transaction do
update_user_multifactor_authentication(user, attributes[:mfa])
user.update_in_central
user.save(raise_on_failure: true)
end
end
render_jsonp(Carto::Api::UserPresenter.new(user, current_viewer: current_viewer).to_poro)
rescue CartoDB::CentralCommunicationFailure => e
CartoDB::Logger.error(exception: e, user: user, params: params)
render_jsonp({ errors: "There was a problem while updating your data. Please, try again." }, 422)
rescue Sequel::ValidationFailed, ActiveRecord::RecordInvalid
render_jsonp({ message: "Error updating your account details", errors: user.errors }, 400)
rescue Carto::PasswordConfirmationError
render_jsonp({ message: "Error updating your account details", errors: user.errors }, 403)
end
def delete_me
user = current_viewer
deletion_password_confirmation = params[:deletion_password_confirmation]
if user.needs_password_confirmation? && !user.validate_old_password(deletion_password_confirmation)
render_jsonp({ message: "Error deleting user: #{PASSWORD_DOES_NOT_MATCH_MESSAGE}" }, 400) and return
end
user.destroy_account
render_jsonp({ logout_url: logout_url }, 200)
rescue CartoDB::CentralCommunicationFailure => e
CartoDB::Logger.error(exception: e, message: 'Central error deleting user at CartoDB', user: @user)
render_jsonp({ errors: "Error deleting user: #{e.user_message}" }, 422)
rescue => e
CartoDB.notify_exception(e, user: user.inspect)
render_jsonp({ message: "Error deleting user: #{e.message}", errors: user.errors }, 400)
end
def get_authenticated_users
referer = request.env["HTTP_ORIGIN"].blank? ? request.env["HTTP_REFERER"] : %[#{request.env['HTTP_X_FORWARDED_PROTO']}://#{request.env["HTTP_HOST"]}]
referer_match = /https?:\/\/([\w\-\.]+)(:[\d]+)?(\/((u|user)\/([\w\-\.]+)))?/.match(referer)
if referer_match.nil?
render json: { error: "Referer #{referer} does not match" }, status: 400 and return
end
if session_user.nil?
render json: {
urls: [],
username: nil,
avatar_url: nil
} and return
end
subdomain = referer_match[1].gsub(CartoDB.session_domain, '').downcase
# referer_match[6] is the username
referer_organization_username = referer_match[6]
render_auth_users_data(session_user, referer, subdomain, referer_organization_username)
end
private
def unfiltered_organization_notifications(carto_viewer)
carto_viewer.received_notifications.order('received_at DESC').limit(10).map do |n|
Carto::Api::ReceivedNotificationPresenter.new(n).to_hash
end
end
def organization_notifications(carto_viewer)
carto_viewer.received_notifications.unread.map { |n| Carto::Api::ReceivedNotificationPresenter.new(n).to_hash }
end
def render_auth_users_data(user, referrer, subdomain, referrer_organization_username=nil)
organization_name = nil
# It doesn't have a organization username component. We assume it's not a organization referer
if referrer_organization_username.nil?
# The user is authenticated but seeing another user dashboard
if user.username != subdomain
organization_name = CartoDB::UserOrganization.user_belongs_to_organization?(user.username)
end
else
referrer_organization_username = referrer_organization_username.downcase
# The user is seeing its own organization dashboard
if user.username == referrer_organization_username
organization_name = subdomain
# The user is seeing a organization dashboard, but not its one
else
# Authenticated with a user of the organization
if user.organization && user.organization.name == subdomain
organization_name = subdomain
# The user is authenticated with a user not belonging to the requested organization dashboard
# Let's get the first user in the session
else
organization_name = CartoDB::UserOrganization.user_belongs_to_organization?(user.username)
end
end
end
render json: {
urls: ["#{CartoDB.base_url(user.username, organization_name)}#{CartoDB.path(self, 'dashboard_bis')}"],
username: user.username,
name: user.name,
last_name: user.last_name,
avatar_url: user.avatar_url,
email: user.email,
organization: Carto::Api::OrganizationPresenter.new(user.organization).to_poro,
base_url: user.public_url
}
end
# TODO: this should be moved upwards in the controller hierarchy, and make it a replacement for current_user
# URI present-user if has valid session, or nil
def uri_user
@uri_user ||= (current_user.nil? ? nil : Carto::User.where(id: current_user.id).first)
end
# TODO: this should be moved upwards in the controller hierarchy, and make it a replacement for current_viewer
# 1st user that has valid session, if coincides with URI then same as uri_user
def session_user
@session_user ||= (current_viewer.nil? ? nil : Carto::User.where(id: current_viewer.id).first)
end
def update_user_attributes(user, attributes)
update_password_if_needed(user, attributes)
if user.can_change_email? && attributes[:email].present?
user.set_fields(attributes, [:email])
end
if attributes[:avatar_url].present? && valid_avatar_file?(attributes[:avatar_url])
user.set_fields(attributes, [:avatar_url])
end
fields_to_be_updated = UPDATE_ME_FIELDS.select { |field| attributes.has_key?(field) }
user.set_fields(attributes, fields_to_be_updated) if fields_to_be_updated.present?
end
def update_password_if_needed(user, attributes)
if password_change?(user, attributes)
user.change_password(
attributes[:password_confirmation],
attributes[:new_password],
attributes[:confirm_password]
)
update_session_security_token(user)
end
end
def password_change?(user, attributes)
(attributes[:new_password].present? || attributes[:confirm_password].present?) && user.can_change_password?
end
def recalculate_user_db_size
current_user && Carto::UserDbSizeCache.new.update_if_old(current_user)
end
def update_user_multifactor_authentication(user, mfa_enabled)
return if mfa_enabled.nil?
service = Carto::UserMultifactorAuthUpdateService.new(user_id: user.id)
service.update(enabled: mfa_enabled)
warden.session(user.username)[:multifactor_authentication_performed] = false unless mfa_enabled
end
end
end
end
@@ -0,0 +1,25 @@
module Carto
module Api
class VisualizationExportPresenter
def initialize(visualization_export)
@visualization_export = visualization_export
end
def to_poro
return {} unless @visualization_export
{
id: @visualization_export.id,
visualization_id: @visualization_export.visualization_id,
user_id: @visualization_export.user_id,
state: @visualization_export.state,
url: @visualization_export.url,
created_at: @visualization_export.created_at,
updated_at: @visualization_export.updated_at
}
end
end
end
end
@@ -0,0 +1,85 @@
require_dependency 'carto/tracking/events'
module Carto
module Api
class VisualizationExportsController < ::Api::ApplicationController
include Carto::ControllerHelper
include VisualizationsControllerHelper
ssl_required :create, :show, :download
skip_before_filter :api_authorization_required, only: [:create, :show, :download]
before_filter :optional_api_authorization, only: [:create, :show, :download]
before_filter :load_visualization, only: :create
before_filter :load_visualization_export, only: [:show, :download]
rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def create
user = current_viewer ? Carto::User.find(current_viewer.id) : nil
visualization_export = Carto::VisualizationExport.new(
visualization: @visualization,
user: user,
user_tables_ids: params[:user_tables_ids])
unless visualization_export.save
if visualization_export.errors[:user].present?
raise Carto::UnauthorizedError.new("Errors: #{visualization_export.errors.full_messages}")
else
CartoDB::Logger.warning(
message: 'Validation error creating visualization export',
user: user,
visualization: @visualization.id,
user_tables_ids: params[:user_tables_ids],
visualization_export: visualization_export.inspect,
error_messages: visualization_export.errors.full_messages
)
raise Carto::UnprocesableEntityError.new("Errors: #{visualization_export.errors.full_messages}")
end
end
# In order to generate paths you need to be in a controller, so path is sent to Resque
download_path_params = { visualization_export_id: visualization_export.id }
download_path = CartoDB.path(self, 'visualization_export_download', download_path_params)
Resque.enqueue(Resque::ExporterJobs, job_id: visualization_export.id, download_path: download_path)
if current_viewer
current_viewer_id = current_viewer.id
Carto::Tracking::Events::ExportedMap.new(current_viewer_id,
user_id: current_viewer_id,
visualization_id: @visualization.id).report
end
render_jsonp(VisualizationExportPresenter.new(visualization_export).to_poro, 201)
end
def show
render_jsonp(VisualizationExportPresenter.new(@visualization_export).to_poro, 200)
end
def download
send_file @visualization_export.file, type: "application/zip"
end
private
def load_visualization
visualization_id = params[:visualization_id]
@visualization = Carto::Visualization.where(id: visualization_id).first
raise Carto::LoadError.new("Visualization not found: #{visualization_id}") unless @visualization
end
def load_visualization_export
id = params[:id].present? ? uuid_parameter(:id) : uuid_parameter(:visualization_export_id)
@visualization_export = Carto::VisualizationExport.where(id: id).first
raise Carto::LoadError.new("Visualization export not found: #{id}") unless @visualization_export
export_user_id = @visualization_export.user_id
unless export_user_id.nil? || (current_viewer && export_user_id == current_viewer.id)
raise Carto::UnauthorizedError.new
end
end
end
end
end
@@ -0,0 +1,280 @@
require_relative 'external_source_presenter'
require_relative 'permission_presenter'
require_relative 'user_table_presenter'
module Carto
module Api
class VisualizationPresenter
def initialize(visualization, current_viewer, context,
related: true, related_canonical_visualizations: false, show_user: false,
show_stats: true, show_table: true, show_liked: true,
show_permission: true, show_synchronization: true, show_uses_builder_features: true,
show_table_size_and_row_count: true, show_auth_tokens: true, show_user_basemaps: false,
password: nil, with_dependent_visualizations: 0)
@visualization = visualization
@current_viewer = current_viewer
@context = context
@related = related
@load_related_canonical_visualizations = related_canonical_visualizations
@show_user = show_user
@show_stats = show_stats
@show_liked = show_liked
@show_table = show_table
@show_permission = show_permission
@show_synchronization = show_synchronization
@show_uses_builder_features = show_uses_builder_features
@show_table_size_and_row_count = show_table_size_and_row_count
@show_auth_tokens = show_auth_tokens
@show_user_basemaps = show_user_basemaps
@password = password
@with_dependent_visualizations = with_dependent_visualizations
@presenter_cache = Carto::Api::PresenterCache.new
end
def with_presenter_cache(presenter_cache)
@presenter_cache = presenter_cache
self
end
def to_poro
return_private_poro = @visualization.can_view_private_info?(@current_viewer)
poro = return_private_poro ? to_private_poro : to_public_poro
poro[:user] = user if show_user
if load_related_canonical_visualizations
poro[:related_canonical_visualizations] = related_canonicals
# The count doesn't take into account privacy concerns
poro[:related_canonical_visualizations_count] = @visualization.related_canonical_visualizations.count
end
if with_dependent_visualizations > 0
dependencies = @visualization.dependent_visualizations
poro[:dependent_visualizations_count] = dependencies.count
limited_dependencies = most_recent_dependencies(dependencies, with_dependent_visualizations)
poro[:dependent_visualizations] = limited_dependencies.map do |dependent_visualization|
VisualizationPresenter.new(dependent_visualization, @current_viewer, @context).to_summarized_poro
end
end
poro[:liked] = @current_viewer ? @visualization.liked_by?(@current_viewer) : false if show_liked
poro[:permission] = permission if show_permission
poro[:stats] = show_stats ? @visualization.stats : {}
if show_auth_tokens
poro[:auth_tokens] = auth_tokens
elsif return_private_poro || @visualization.is_accessible_with_password?(@current_viewer, @password)
poro[:auth_tokens] = auth_tokens
end
poro[:table] = user_table_presentation if show_table
poro
end
def to_private_poro
poro = {
id: @visualization.id,
name: @visualization.name,
display_name: @visualization.display_name,
map_id: @visualization.map_id,
active_layer_id: @visualization.active_layer_id,
type: @visualization.type,
tags: @visualization.tags,
description: @visualization.description,
privacy: @visualization.privacy.upcase,
created_at: @visualization.created_at,
updated_at: @visualization.updated_at,
locked: @visualization.locked,
source: @visualization.source,
title: @visualization.title,
license: @visualization.license,
attributions: @visualization.attributions,
kind: @visualization.kind,
external_source: Carto::Api::ExternalSourcePresenter.new(@visualization.external_source).to_poro,
url: url,
version: @visualization.version || 2,
# TODO: The following are Odyssey fields and could be removed
# They are kept here for now for compatibility with the old presenter and JS code
# `children` is hardcoded to avoid a performance impact (an extra query)
prev_id: @visualization.prev_id,
next_id: @visualization.next_id,
parent_id: @visualization.parent_id,
transition_options: @visualization.transition_options,
active_child: nil,
children: []
}
poro[:related_tables] = related_tables if related
poro[:synchronization] = synchronization if show_synchronization
poro[:uses_builder_features] = @visualization.uses_builder_features? if show_uses_builder_features
poro
end
def to_public_poro
{
id: @visualization.id,
name: @visualization.name,
display_name: @visualization.display_name,
attributions: @visualization.attributions,
source: @visualization.source,
license: @visualization.license,
type: @visualization.type,
tags: @visualization.tags,
description: @visualization.description,
created_at: @visualization.created_at,
updated_at: @visualization.updated_at,
title: @visualization.title,
kind: @visualization.kind,
privacy: @visualization.privacy.upcase,
}
end
# For dependent visualizations
def to_summarized_poro
{
id: @visualization.id,
name: @visualization.name,
updated_at: @visualization.updated_at,
permission: permission.slice(:id, :owner),
auth_tokens: auth_tokens
}
end
def to_search_preview_poro
{
type: @visualization.type,
name: @visualization.name,
url: url
}
end
# Ideally this should go at a lower level, as relates to url generation, but at least centralize logic here
# INFO: For now, no support for non-org users, as intended use is for sharing urls
def privacy_aware_map_url(additional_params = {}, action = 'public_visualizations_show_map')
organization = @visualization.user.organization
return unless organization
# When a visualization is private, checks of permissions need not only the Id but also the vis owner database schema
# Logic on public_map route will handle permissions so here we only "namespace the id" when proceeds
if @visualization.is_privacy_private?
# Final url will be like ORG.carto.com/u/VIEWER/viz/OWNER_SCHEMA.VIS_ID/public_map
base_url_username = @current_viewer.username
vis_id_schema = @visualization.user.database_schema
else
# Final url will be like ORG.carto.com/u/VIEWER/viz/VIS_ID/public_map
base_url_username = @visualization.user.username
vis_id_schema = nil
end
# this builds only the fragment /viz/xxxxx/public_map
path = CartoDB.path(@context, action, additional_params.merge(id: qualified_visualization_id(vis_id_schema)))
"#{CartoDB.base_url(organization.name, base_url_username)}#{path}"
end
def qualified_visualization_id(schema = nil)
schema.nil? ? @visualization.id : "#{schema}.#{@visualization.id}"
end
private
attr_reader :related, :load_related_canonical_visualizations, :show_user,
:show_stats, :show_table, :show_liked,
:show_permission, :show_synchronization, :show_uses_builder_features,
:show_table_size_and_row_count, :show_auth_tokens,
:show_user_basemaps, :with_dependent_visualizations
def user_table_presentation
Carto::Api::UserTablePresenter.new(@visualization.user_table, @current_viewer,
show_size_and_row_count: show_table_size_and_row_count)
.with_presenter_cache(@presenter_cache).to_poro
end
def synchronization
Carto::Api::SynchronizationPresenter.new(@visualization.synchronization).to_poro
end
def permission
unless @visualization.permission.nil?
Carto::Api::PermissionPresenter.new(@visualization.permission, current_viewer: @current_viewer)
.with_presenter_cache(@presenter_cache).to_poro
end
end
def auth_tokens
if @visualization.password_protected? && (@visualization.user.id == @current_viewer.try(:id) || @visualization.password_valid?(@password))
@visualization.get_auth_tokens
elsif @visualization.is_privacy_private?
@current_viewer.get_auth_tokens
else
[]
end
end
def related_tables
related = if @visualization.user_table
@visualization.related_tables.select { |table| table.id != @visualization.user_table.id }
else
@visualization.related_tables
end
related.map do |table|
Carto::Api::UserTablePresenter.new(table, @current_viewer).with_presenter_cache(@presenter_cache).to_poro
end
end
def related_canonicals
@visualization
.related_canonical_visualizations
.select { |v| v.is_viewable_by_user?(@current_viewer) }
.map { |v| self.class.new(v, @current_viewer, @context).to_poro }
end
def children_poro(visualization)
{
id: visualization.id,
prev_id: visualization.prev_id,
type: visualization.type,
next_id: visualization.next_id,
transition_options: visualization.transition_options,
map_id: visualization.map_id
}
end
def url
if @visualization.canonical?
dataset_name = @visualization.qualified_name(@current_viewer).tr('"', '')
CartoDB.url(@context, 'public_tables_show_bis',
params: { id: dataset_name },
user: @current_viewer)
elsif @visualization.kuviz?
CartoDB.url(@context, 'kuviz_show',
params: { id: @visualization.id },
user: @current_viewer)
else
CartoDB.url(@context, 'public_visualizations_show_map',
params: { id: @visualization.id },
user: @current_viewer)
end
end
def user
Carto::Api::UserPresenter.new(@visualization.user,
current_viewer: @current_viewer,
fetch_db_size: false,
fetch_basemaps: show_user_basemaps,
fetch_profile: false).to_poro
end
def most_recent_dependencies(dependencies, limit)
sorted_dependencies = dependencies.sort_by(&:updated_at).reverse
sorted_dependencies.first(limit)
end
end
end
end
@@ -0,0 +1,153 @@
require_dependency 'carto/bounding_box_utils'
module Carto
module Api
module VisualizationSearcher
FILTER_SHARED_YES = 'yes'
FILTER_SHARED_NO = 'no'
FILTER_SHARED_ONLY = 'only'
# Creates a visualization query builder ready
# to search based on params hash (can be request params).
# It doesn't apply ordering or paging, just filtering.
def query_builder_with_filter_from_hash(params)
types, total_types = get_types_parameters
validate_parameters(types, params)
pattern = params[:q]
only_liked = params[:only_liked] == 'true'
only_shared = params[:only_shared] == 'true'
exclude_shared = params[:exclude_shared] == 'true'
exclude_raster = params[:exclude_raster] == 'true'
locked = params[:locked]
shared = compose_shared(params[:shared], only_shared, exclude_shared)
tags = params.fetch(:tags, '').split(',')
tags = nil if tags.empty?
bbox_parameter = params.fetch(:bbox,nil)
privacy = params.fetch(:privacy,nil)
only_with_display_name = params[:only_with_display_name] == 'true'
with_dependent_visualizations = params[:with_dependent_visualizations].to_i
only_published = params[:only_published] == 'true'
vqb = VisualizationQueryBuilder.new
.with_prefetch_user
.with_prefetch_table
.with_prefetch_permission
.with_prefetch_synchronization
.with_prefetch_external_source
.with_types(types)
.with_tags(tags)
if !bbox_parameter.blank?
vqb.with_bounding_box(Carto::BoundingBoxUtils.parse_bbox_parameters(bbox_parameter))
end
# FIXME Patch to exclude legacy visualization from data-library #5097
if only_with_display_name
vqb.with_display_name
end
vqb.with_published if only_published
if current_user
vqb.with_current_user_id(current_user.id)
vqb.with_liked_by_user_id(current_user.id) if only_liked
case shared
when FILTER_SHARED_YES
vqb.with_owned_by_or_shared_with_user_id(current_user.id)
when FILTER_SHARED_NO
vqb.with_user_id(current_user.id) if !only_liked
when FILTER_SHARED_ONLY
vqb.with_shared_with_user_id(current_user.id)
.with_user_id_not(current_user.id)
end
vqb.without_raster if exclude_raster
if locked == 'true'
vqb.with_locked(true)
elsif locked == 'false'
vqb.with_locked(false)
end
if types.include? Carto::Visualization::TYPE_REMOTE
vqb.without_synced_external_sources
vqb.without_imported_remote_visualizations
end
vqb.with_privacy(privacy) unless privacy.nil?
vqb.with_prefetch_dependent_visualizations if with_dependent_visualizations > 0
else
# TODO: ok, this looks like business logic, refactor
subdomain = CartoDB.extract_subdomain(request)
vqb.with_user_id(Carto::User.where(username: subdomain).first.id)
.with_privacy(Carto::Visualization::PRIVACY_PUBLIC)
end
if pattern.present?
vqb.with_partial_match(pattern)
end
vqb
end
def presenter_options_from_hash(params)
options = {}
options[:show_stats] = false if params[:show_stats].to_s == 'false'
options[:show_table] = false if params[:show_table].to_s == 'false'
options[:show_permission] = false if params[:show_permission].to_s == 'false'
options[:show_uses_builder_features] = false if params[:show_uses_builder_features].to_s == 'false'
options[:show_synchronization] = false if params[:show_synchronization].to_s == 'false'
options[:show_table_size_and_row_count] = false if params[:show_table_size_and_row_count].to_s == 'false'
options[:with_dependent_visualizations] = params[:with_dependent_visualizations].to_i
options
end
private
def get_types_parameters
# INFO: this fits types and type into types, so only types is used for search.
# types defaults to type if empty.
# types defaults to derived if type is also empty.
# total_types are the types used for total counts.
types = params.fetch(:types, "").split(',')
type = params[:type].present? ? params[:type] : (types.empty? ? nil : types[0])
# TODO: add this assumption to a test or remove it (this is coupled to the UI)
total_types = [(type == Carto::Visualization::TYPE_REMOTE ? Carto::Visualization::TYPE_CANONICAL : type)].compact
types = [type].compact if types.empty?
types = [Carto::Visualization::TYPE_DERIVED] if types.empty?
return types, total_types
end
def compose_shared(shared, only_shared, exclude_shared)
valid_shared = shared if [FILTER_SHARED_ONLY, FILTER_SHARED_NO, FILTER_SHARED_YES].include?(shared)
return valid_shared if valid_shared
if only_shared
FILTER_SHARED_ONLY
elsif exclude_shared
FILTER_SHARED_NO
elsif exclude_shared == false
FILTER_SHARED_YES
else
# INFO: exclude_shared == nil && !only_shared
nil
end
end
def validate_parameters(types, parameters)
if (!params.fetch(:bbox, nil).nil? && (types.length > 1 || !types.include?(Carto::Visualization::TYPE_CANONICAL)))
raise CartoDB::BoundingBoxError.new('Filter by bbox is only supported for type table')
end
end
end
end
end
@@ -0,0 +1,69 @@
require_relative '../../../helpers/carto/html_safe'
require_relative 'layer_vizjson_adapter'
module Carto
module Api
class VisualizationVizJSONAdapter
extend Forwardable
include Carto::HtmlSafe
delegate [:id, :map, :qualified_name, :description, :retrieve_named_map?, :password_protected?, :overlays,
:prev_id, :next_id, :transition_options, :has_password?, :parent_id, :get_auth_tokens, :user,
:related_canonical_visualizations
] => :visualization
attr_reader :visualization
def initialize(visualization, redis_cache = nil)
@visualization = visualization
@layer_cache = {}
# INFO: needed for children
@redis_cache = redis_cache
end
def layers(kind)
@layer_cache[kind] ||= get_layers(kind)
end
def attributions_from_derived_visualizations
@visualization.related_canonical_visualizations.map(&:attributions).reject {|attribution| attribution.blank?}
end
def children
@visualization.children.map { |v|
Carto::Api::VisualizationVizJSONAdapter.new(v, @redis_cache)
}
end
def parent
@visualization.parent ? Carto::Api::VisualizationVizJSONAdapter.new(@visualization.parent, @redis_cache) : nil
end
def to_vizjson
Carto::Api::VizJSONPresenter.new(self, @redis_cache).to_vizjson
end
private
def get_layers(kind)
choose_layers(kind).map { |layer|
Carto::Api::LayerVizJSONAdapter.new(layer)
}
end
def choose_layers(kind)
case kind
when :base
map.user_layers
when :cartodb
map.carto_layers
when :others
map.other_layers
else
raise "Unknown: #{kind}"
end
end
end
end
end
@@ -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
@@ -0,0 +1,445 @@
require_dependency 'carto/api/layer_vizjson_adapter'
require_dependency 'carto/api/infowindow_migrator'
require_dependency 'cartodb/redis_vizjson_cache'
require_dependency 'carto/named_maps/template'
require_dependency 'carto/legend_migrator'
require_dependency 'carto/html_safe'
module Carto
module Api
module ApiTemplates
API_TEMPLATE_PUBLIC = 'public'.freeze
API_TEMPLATE_PRIVATE = 'private'.freeze
def api_templates_type(options)
options.fetch(:https_request, false) ? API_TEMPLATE_PRIVATE : API_TEMPLATE_PUBLIC
end
end
module DisplayVizjsonMode
def display_named_map?(visualization, forced_privacy_version)
forced_privacy_version != :force_anonymous && (visualization.retrieve_named_map? || forced_privacy_version == :force_named)
end
end
class VizJSON3Presenter
include ApiTemplates
include DisplayVizjsonMode
include Carto::HtmlSafe
def initialize(visualization,
redis_vizjson_cache = CartoDB::Visualization::RedisVizjsonCache.new($tables_metadata, 3))
@visualization = visualization
@redis_vizjson_cache = redis_vizjson_cache
end
def to_vizjson(https_request: false)
generate_vizjson(https_request: https_request, forced_privacy_version: nil)
end
def to_named_map_vizjson(https_request: false)
generate_vizjson(https_request: https_request, forced_privacy_version: :force_named)
end
def to_anonymous_map_vizjson(https_request: false)
generate_vizjson(https_request: https_request, forced_privacy_version: :force_anonymous)
end
private
def generate_vizjson(https_request:, forced_privacy_version:)
https_request ||= false
version = case forced_privacy_version
when :force_named
'3n'
when :force_anonymous
'3a'
else
3
end
vizjson = if @redis_vizjson_cache
@redis_vizjson_cache.cached(@visualization.id, https_request, version) do
calculate_vizjson(https_request: https_request, forced_privacy_version: forced_privacy_version)
end
else
calculate_vizjson(https_request: https_request, forced_privacy_version: forced_privacy_version)
end
vizjson
end
VIZJSON_VERSION = '3.0.0'.freeze
def calculate_vizjson(https_request: false, forced_privacy_version: nil)
options = { https_request: https_request }
user = @visualization.user
map = @visualization.map
vizjson = {
bounds: bounds_from(map),
center: map.center,
datasource: datasource_vizjson(options, forced_privacy_version),
description: markdown_html_safe(@visualization.description),
options: map_options(@visualization),
id: @visualization.id,
layers: layers_vizjson(forced_privacy_version),
map_provider: map.provider,
overlays: overlays_vizjson(@visualization),
title: @visualization.qualified_name(user),
updated_at: @visualization.updated_at,
user: user_info_vizjson(user),
version: VIZJSON_VERSION,
widgets: widgets_vizjson,
zoom: map.zoom
}
visualization_analyses = @visualization.analyses
vizjson[:analyses] = if display_named_map?(@visualization, forced_privacy_version)
visualization_analyses.map { |a| named_map_analysis_json(a) }
else
visualization_analyses.map(&:analysis_definition)
end
vizjson
end
def bounds_from(map)
return nil unless map.view_bounds_sw && map.view_bounds_ne
::JSON.parse("[#{map.view_bounds_sw}, #{map.view_bounds_ne}]")
rescue => e
CartoDB::Logger.debug(
message: "Error parsing map bounds: #{map.id}, #{map.view_bounds_sw}, #{map.view_bounds_ne}", exception: e)
nil
end
def layers_vizjson(forced_privacy_version)
ordered_layers = (
[basemap_layer] +
@visualization.carto_layers +
@visualization.other_layers +
non_basemap_base_layers).compact
if display_named_map?(@visualization, forced_privacy_version)
named_map_vizjson_for_layers(ordered_layers)
else
anonymous_vizjson_for_layers(ordered_layers)
end
end
def named_map_vizjson_for_layers(layers)
layers.map do |layer|
VizJSON3NamedMapLayerPresenter.new(layer, configuration).to_vizjson
end
end
def anonymous_vizjson_for_layers(layers)
layers.map do |layer|
VizJSON3LayerPresenter.new(layer, configuration).to_vizjson
end
end
def basemap_layer
@visualization.user_layers.first
end
def non_basemap_base_layers
base_layers = @visualization.user_layers
base_layers.empty? ? [] : base_layers.slice(1, base_layers.length)
end
def named_map_analysis_json(analysis)
analysis_definition_without_sources(analysis.analysis_definition)
end
def analysis_definition_without_sources(analysis_definition)
if analysis_definition[:type] == 'source'
analysis_definition.delete(:params)
elsif analysis_definition[:params] && analysis_definition[:params][:source]
analysis_definition_without_sources(analysis_definition[:params][:source])
end
analysis_definition
end
def configuration
Cartodb.config
end
def datasource_vizjson(options, forced_privacy_version)
ds = {
user_name: @visualization.user.username,
maps_api_template: ApplicationHelper.maps_api_template(api_templates_type(options)),
stat_tag: @visualization.id
}
if display_named_map?(@visualization, forced_privacy_version)
ds[:template_name] = Carto::NamedMaps::Template.new(@visualization).name
end
ds
end
def user_info_vizjson(user)
{
fullname: user.name_or_username,
avatar_url: user.avatar_url,
profile_url: user.public_url
}
end
def widgets_vizjson
@visualization.widgets.map do |widget|
Carto::Api::WidgetPresenter.new(widget).to_vizjson
end
end
def map_options(visualization)
map = visualization.map
migration_options = { layer_selector: true } if visualization.overlays.any? { |o| o.type == 'layer_selector' }
map.options.merge(migration_options || {})
end
def overlays_vizjson(visualization)
visualization.overlays.
reject { |o| o.type == 'layer_selector' }.
map { |o| Carto::Api::OverlayPresenter.new(o).to_vizjson }
end
end
class VizJSON3NamedMapLayerPresenter
include ApiTemplates
def initialize(layer, configuration)
@configuration = configuration
@layer = layer
end
# Prepare a PORO (Hash object) for easy JSONification
# @see https://github.com/CartoDB/carto.js/blob/privacy-maps/doc/vizjson_format.md
def to_vizjson
layer_vizjson = VizJSON3LayerPresenter.new(@layer, @configuration).to_vizjson
if @layer.user_layer?
layer_vizjson
elsif layer_vizjson[:type] == 'torque'
data_for_torque_layer(layer_vizjson)
else
data_for_carto_layer(layer_vizjson)
end
end
private
def data_for_torque_layer(layer_vizjson)
layer_vizjson.delete(:sql)
layer_vizjson
end
def data_for_carto_layer(layer_vizjson)
layer_options = layer_vizjson[:options]
data = {
id: layer_vizjson[:id],
type: layer_vizjson[:type],
visible: layer_vizjson[:visible],
options: {
layer_name: layer_options[:layer_name],
attribution: layer_options[:attribution],
cartocss: layer_options[:cartocss]
}
}
if layer_options && layer_options[:source]
data[:options][:source] = layer_options[:source]
end
infowindow = layer_vizjson[:infowindow]
if infowindow && infowindow['fields'] && !infowindow['fields'].empty?
data[:infowindow] = infowindow
end
tooltip = layer_vizjson[:tooltip]
if tooltip && tooltip['fields'] && !tooltip['fields'].empty?
data[:tooltip] = tooltip
end
legend = layer_vizjson[:legend]
if legend && legend['type'] != 'none'
data[:legend] = legend
end
data[:legends] = layer_vizjson[:legends] || []
data
end
end
class VizJSON3LayerPresenter
include ApiTemplates
include InfowindowMigrator
EMPTY_CSS = '#dummy{}'.freeze
TORQUE_ATTRS = %i(
table_name
user_name
property
blendmode
resolution
countby
torque-duration
torque-steps
torque-blend-mode
named_map
visible
attribution
).freeze
INFOWINDOW_AND_TOOLTIP_KEYS = %w(
fields template_name template template_type alternative_names width maxHeight headerColor
).freeze
def initialize(layer, configuration = {})
@layer = layer
@configuration = configuration
end
def to_vizjson
vizjson = { id: @layer.id }
if @layer.base_layer?
vizjson.merge(as_base)
else
vizjson.merge(as_data).merge(@layer.torque? ? as_torque : as_carto)
end
end
private
def attribution
@layer.user_tables.map(&:visualization).map(&:attributions).join(', ')
end
def as_data
old_legend_not_migrated = @layer.legend && !@layer.legend[:migrated]
if @layer.persisted? && old_legend_not_migrated
@layer.legends.any? ? mark_old_legend_migrated : migrate_old_legend
end
legends_presentation = @layer.legends.map do |legend|
Carto::Api::LegendPresenter.new(legend).to_hash
end
{ legends: legends_presentation }
end
def migrate_old_legend
Carto::LegendMigrator.new(@layer.id, @layer.legend).build.save
mark_old_legend_migrated
@layer.legends.reload
end
def mark_old_legend_migrated
@layer.options[:legend][:migrated] = true
@layer.save
end
def as_torque
layer_options = @layer.options.deep_symbolize_keys
torque_options = layer_options.select { |k| TORQUE_ATTRS.include? k }
torque_options[:attribution] = attribution
torque_options[:layer_name] = layer_name
torque = {
type: 'torque',
options: torque_options
}
torque[:cartocss] = layer_options[:tile_style]
torque[:cartocss_version] = layer_options[:style_version]
torque[:sql] = sql_from(@layer)
if @layer.options['source'].present?
torque[:source] = @layer.options['source']
end
sql_wrap = @layer.options['sql_wrap'] || @layer.options['query_wrapper']
torque[:sql_wrap] = sql_wrap if sql_wrap
torque
end
def as_carto
{
type: 'CartoDB',
infowindow: whitelisted_attrs(migrate_builder_infowindow(@layer.infowindow, mustache_dir: 'infowindows')),
tooltip: whitelisted_attrs(migrate_builder_infowindow(@layer.tooltip, mustache_dir: 'tooltips')),
visible: @layer.options['visible'],
options: options_data
}
end
def as_base
{
type: @layer.kind,
options: @layer.options
}
end
def options_data
data = {
layer_name: layer_name,
cartocss: css_from(@layer.options),
cartocss_version: @layer.options.fetch('style_version'),
attribution: attribution
}
source = @layer.options['source']
if source
data[:source] = source
data.delete(:sql)
else
data[:sql] = sql_from(@layer)
end
sql_wrap = @layer.options['sql_wrap'] || @layer.options['query_wrapper']
data[:sql_wrap] = sql_wrap if sql_wrap
data
end
def layer_name
layer_alias = @layer.options.fetch('table_name_alias', nil)
table_name = @layer.options.fetch('table_name')
# .present? is not valid, as we want to allow whitespaced layer names.
layer_alias.nil? || layer_alias.empty? ? table_name : layer_alias
end
def sql_from(layer)
query = layer.options.fetch('query', '')
query.present? ? query : layer.default_query(layer.visualization.user)
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 public_options
return @configuration if @configuration.empty?
@configuration.fetch(:layer_opts).fetch("public_opts")
end
def whitelisted_attrs(infowindow_or_tooltip)
if infowindow_or_tooltip
infowindow_or_tooltip.select do |key, _|
INFOWINDOW_AND_TOOLTIP_KEYS.include?(key) || INFOWINDOW_AND_TOOLTIP_KEYS.include?(key.to_s)
end
end
end
end
end
end
@@ -0,0 +1,40 @@
require_dependency 'visualization/vizjson'
require_dependency 'carto/api/visualization_vizjson_adapter'
require_dependency 'cartodb/redis_vizjson_cache'
module Carto
module Api
class VizJSONPresenter
def initialize(visualization, redis_cache)
@visualization = visualization
@redis_cache = redis_cache
@redis_vizjson_cache = CartoDB::Visualization::RedisVizjsonCache.new(redis_cache)
end
def to_vizjson(options={})
@redis_vizjson_cache.cached(@visualization.id, options.fetch(:https_request, false)) do
calculate_vizjson(options)
end
end
private
def calculate_vizjson(options={})
vizjson_options = {
full: false,
user_name: user.username,
user_api_key: user.api_key,
user: user,
viewer_user: user
}.merge(options)
CartoDB::Visualization::VizJSON.new(Carto::Api::VisualizationVizJSONAdapter.new(@visualization, @redis_cache), vizjson_options, Cartodb.config).to_poro
end
def user
@user ||= @visualization.user
end
end
end
end
@@ -0,0 +1,31 @@
module Carto
module Api
class WidgetPresenter
def initialize(widget)
@widget = widget
end
def to_poro
return {} unless @widget
poro = {
id: @widget.id,
type: @widget.type,
title: @widget.title,
order: @widget.order,
layer_id: @widget.layer_id,
options: @widget.options,
style: @widget.style
}
poro[:source] = { id: @widget.source_id } if @widget.source_id.present?
poro
end
alias_method :to_vizjson, :to_poro
end
end
end
@@ -0,0 +1,148 @@
module Carto
module Api
class WidgetsController < ::Api::ApplicationController
include Carto::ControllerHelper
ssl_required :show, :create, :update, :destroy, :update_many
before_filter :load_map
before_filter :load_layer, :load_widget_id, except: [:update_many]
before_filter :load_widget, only: [:show, :update, :destroy]
rescue_from Carto::LoadError, with: :rescue_from_carto_error
rescue_from Carto::UnauthorizedError, with: :rescue_from_carto_error
rescue_from Carto::UnprocesableEntityError, with: :rescue_from_carto_error
def show
render_jsonp(WidgetPresenter.new(@widget).to_poro)
end
def create
order_param = params[:order]
order = if order_param.present?
order_param
else
@map.visualization.widgets.count
end
widget = Carto::Widget.new(
layer_id: @layer_id,
order: order,
type: params[:type],
title: params[:title],
options: params[:options],
style: params[:style],
source_id: source_id_from_params)
widget.save!
Carto::Tracking::Events::CreatedWidget.new(current_viewer.id,
user_id: current_viewer.id,
visualization_id: @layer.visualization.id,
widget_id: widget.id).report
render_jsonp(WidgetPresenter.new(widget).to_poro, 201)
rescue ActiveRecord::RecordInvalid
render json: { errors: widget.errors }, status: 422
rescue => e
CartoDB::Logger.error(exception: e, message: "Error creating widget", widget: (widget ? widget : 'not created'))
render json: { errors: e.message }, status: 500
end
def update
update_widget!(@widget, params)
render_jsonp(WidgetPresenter.new(@widget).to_poro)
rescue => e
CartoDB::Logger.error(exception: e, message: "Error updating widget", widget: @widget)
render json: { errors: e.message }, status: 500
end
def update_many
entries = params[:_json].map do |json_widget|
{ widget: widget_with_validations(json_widget[:id]), json: json_widget }
end
result = ActiveRecord::Base.transaction do
entries.map { |entry| update_widget!(entry[:widget], entry[:json]) }
end
render_jsonp(result.map { |widget| WidgetPresenter.new(widget).to_poro })
end
def destroy
@widget.destroy
render_jsonp(WidgetPresenter.new(@widget).to_poro)
rescue => e
CartoDB::Logger.error(exception: e, message: "Error destroying widget", widget: @widget)
render json: { errors: e.message }, status: 500
end
private
def update_widget!(widget, json_params)
update_params = json_params.permit(:order, :type, :title)
update_params[:source_id] = source_id_from_params(json_params) if source_id_from_params(json_params)
widget.update_attributes(update_params)
widget.options = json_params[:options]
widget.style = json_params[:style]
widget.save!
widget
end
def load_map
@map_id = params[:map_id]
@map = Carto::Map.where(id: @map_id).first
raise LoadError.new("Map not found: #{@map_id}") unless @map
raise Carto::UnauthorizedError.new("Not authorized for map #{@map.id}") unless @map.writable_by_user?(current_user)
end
def load_layer
@layer_id = params[:map_layer_id]
payload_layer_id = params['layer_id']
if [payload_layer_id, @layer_id].compact.uniq.length >= 2
raise UnprocesableEntityError.new("URL layer id (#{@layer_id}) and payload layer id (#{payload_layer_id}) don't match")
end
@layer = Carto::Layer.where(id: @layer_id).first
raise LoadError.new("Layer not found: #{@layer_id}") unless @layer
raise UnprocesableEntityError.new("Layer #{@layer_id} doesn't belong to map #{@map_id}") unless @map.contains_layer?(@layer)
end
def load_widget_id
@widget_id = params[:id]
if [@widget_id, params[:id]].compact.uniq.length >= 2
raise UnprocesableEntityError.new("URL id (#{@widget_id}) and payload id (#{params[:id]}) don't match")
end
end
def source_id_from_params(parameters = params)
parameters[:source] ? parameters[:source][:id] : nil
end
def load_widget
@widget = widget_with_validations(@widget_id, @layer_id)
end
def widget_with_validations(widget_id, layer_id = nil)
widget = Carto::Widget.find(widget_id)
raise Carto::LoadError.new("Widget not found: #{@widget_id}") unless widget
if layer_id && widget.layer_id != layer_id
raise Carto::LoadError.new("Widget not found: #{@widget_id}")
end
unless widget.belongs_to_map?(@map_id)
raise Carto::LoadError.new("Widget not found: #{@widget_id} for that map (#{@map_id})")
end
unless widget.writable_by_user?(current_user)
raise Carto::UnauthorizedError.new("Not authorized for widget #{@widget_id}")
end
widget
end
end
end
end