diff --git a/README.md b/README.md index 2d9e4e0c4..38cd9d9af 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,47 @@ cache: Of course, you can force a refresh at any time. +### Sharing (optional) + +You can generate sharing urls for queries. You can download up-to-date results for each query in CSV directly. + +This is useful for scripts or for automatic importing into spreadsheets. + +There are 2 steps necessary for setting up sharing: + +1. Configuring an API key +2. Make the sharing endpoint accessible in your routes + +First configure an API key in `blazer.yml`: + +```yml +sharing: + api_key: 'secret' +``` + +Alternatively you can set the `BLAZER_DOWNLOAD_API_KEY` ENV var which blazer uses by default. + +Now routes: we assume you have secured blazer so you will need to expose a new route outside of the mount. + +The default path for shares is `/blazer_share`. You can change this in `blazer.yml`: + +```yml +sharing: + path: /another_path +``` + +This config is only so that blazer can generate the correct url. + +Now add this route to your `routes.rb`: + +```ruby + get Blazer.sharing.route_path, to: Blazer.sharing.to_controller if Blazer.sharing.enabled? +``` + +Now restart your server and each query page will have a `share` button which will open up a modal that allows you to copy sharing urls. + +Each url has a unique token based on a hash of the query's id and the API key, so the token can't be reused for other queries. + ## Charts Blazer will automatically generate charts based on the types of the columns returned in your query. diff --git a/app/controllers/blazer/queries_controller.rb b/app/controllers/blazer/queries_controller.rb index ce0130d79..78fb458c3 100644 --- a/app/controllers/blazer/queries_controller.rb +++ b/app/controllers/blazer/queries_controller.rb @@ -84,6 +84,15 @@ def show def edit end + def share + return render_forbidden unless params[:token] && params[:query_id] + + @query = Query.find_by(id: params[:query_id]) if params[:query_id] + return render_forbidden unless @query.correct_token?(params[:token]) + + run + end + def run @query = Query.find_by(id: params[:query_id]) if params[:query_id] @@ -92,7 +101,8 @@ def run data_source ||= params[:data_source] @data_source = Blazer.data_sources[data_source] - @statement = Blazer::Statement.new(params[:statement], @data_source) + sql_statement = params[:statement] || @query.statement + @statement = Blazer::Statement.new(sql_statement, @data_source) # before process_vars @cohort_analysis = @statement.cohort_analysis? diff --git a/app/models/blazer/query.rb b/app/models/blazer/query.rb index b603f2bbb..5a13bcf57 100644 --- a/app/models/blazer/query.rb +++ b/app/models/blazer/query.rb @@ -1,5 +1,7 @@ module Blazer class Query < Record + has_secure_token :secret_token, length: 36 + belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class has_many :checks, dependent: :destroy has_many :dashboard_queries, dependent: :destroy @@ -15,6 +17,10 @@ def to_param [id, name].compact.join("-").gsub("'", "").parameterize end + def correct_token?(token) + ActiveSupport::SecurityUtils.secure_compare(secret_token, token) + end + def friendly_name name.to_s.sub(/\A[#\*]/, "").gsub(/\[.+\]/, "").strip end diff --git a/app/views/blazer/queries/_sharing_modal.html.erb b/app/views/blazer/queries/_sharing_modal.html.erb new file mode 100644 index 000000000..f47641a64 --- /dev/null +++ b/app/views/blazer/queries/_sharing_modal.html.erb @@ -0,0 +1,25 @@ + diff --git a/app/views/blazer/queries/show.html.erb b/app/views/blazer/queries/show.html.erb index d3cd8b82f..0c0161f86 100644 --- a/app/views/blazer/queries/show.html.erb +++ b/app/views/blazer/queries/show.html.erb @@ -3,17 +3,21 @@
-
+
<%= render partial: "blazer/nav" %>

<%= @query.name %>

-
+
<%= link_to "Edit", edit_query_path(@query, params: variable_params(@query)), class: "btn btn-default", disabled: !@query.editable?(blazer_user) %> <%= link_to "Fork", new_query_path(params: {variables: variable_params(@query), fork_query_id: @query.id, data_source: @query.data_source, name: @query.name}), class: "btn btn-info" %> <% if !@error && @success %> + <% if Blazer.sharing.enabled? %> + Share + <% end %> + <%= button_to "Download", run_queries_path(format: "csv"), params: @run_data, class: "btn btn-primary" %> <% end %>
@@ -72,3 +76,7 @@ } <% end %> + +<% if Blazer.sharing.enabled? %> + <%= render(partial: 'sharing_modal') %> +<% end %> diff --git a/config/routes.rb b/config/routes.rb index 5df190d91..18afa62ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,6 +6,7 @@ get :tables, on: :collection get :schema, on: :collection get :docs, on: :collection + get Blazer.sharing.route_path, to: 'queries#share', as: :share end resources :checks, except: [:show] do diff --git a/lib/blazer.rb b/lib/blazer.rb index 5ddae3270..65665574b 100644 --- a/lib/blazer.rb +++ b/lib/blazer.rb @@ -14,6 +14,7 @@ require_relative "blazer/result" require_relative "blazer/result_cache" require_relative "blazer/run_statement" +require_relative "blazer/sharing" require_relative "blazer/statement" # adapters @@ -135,6 +136,13 @@ def self.data_sources end end + def self.sharing + @sharing ||= begin + sharing_settings = settings["sharing"] || {} + Blazer::Sharing.new(**sharing_settings.symbolize_keys) + end + end + def self.run_checks(schedule: nil) checks = Blazer::Check.includes(:query) checks = checks.where(schedule: schedule) if schedule diff --git a/lib/blazer/sharing.rb b/lib/blazer/sharing.rb new file mode 100644 index 000000000..f4f62885e --- /dev/null +++ b/lib/blazer/sharing.rb @@ -0,0 +1,33 @@ +module Blazer + class Sharing + attr_accessor :path, :enabled + + def initialize(enabled: false, path: '/blazer_share') + @path = path.sub(/\/$/, '') # Strip trailing / + @enabled = enabled + end + + def route_path + @route_path ||= "#{path}/:token/:query_id" + end + + def to_controller + 'blazer/queries#share' + end + + def enabled? + enabled + end + + def share_path(query_id, format: nil) + query = Query.find(query_id) + "#{path}/#{query.secret_token}/#{query_id}#{".#{format}" if format}" + end + + def url_for(query_id, current_url, format: 'csv') + url = URI.parse(current_url) + url.path = share_path(query_id, format: format) + url.to_s + end + end +end diff --git a/lib/generators/blazer/templates/install.rb.tt b/lib/generators/blazer/templates/install.rb.tt index bf1999303..22220aea0 100644 --- a/lib/generators/blazer/templates/install.rb.tt +++ b/lib/generators/blazer/templates/install.rb.tt @@ -5,6 +5,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version t.string :name t.text :description t.text :statement + t.text :secret_token t.string :data_source t.string :status t.timestamps null: false diff --git a/test/internal/config/blazer.yml b/test/internal/config/blazer.yml index 11ab6b398..721d90c1c 100644 --- a/test/internal/config/blazer.yml +++ b/test/internal/config/blazer.yml @@ -81,3 +81,7 @@ uploads: url: postgres://localhost/blazer_test schema: uploads data_source: main + +sharing: + path: /blazer_share + enabled: true diff --git a/test/internal/config/routes.rb b/test/internal/config/routes.rb index fc4c62b3e..d0932527b 100644 --- a/test/internal/config/routes.rb +++ b/test/internal/config/routes.rb @@ -1,3 +1,5 @@ Rails.application.routes.draw do mount Blazer::Engine, at: "/" + + get Blazer.sharing.route_path, to: Blazer.sharing.to_controller, as: :share_query if Blazer.sharing.enabled? end diff --git a/test/internal/db/schema.rb b/test/internal/db/schema.rb index b8355bfe8..9dd8f43c3 100644 --- a/test/internal/db/schema.rb +++ b/test/internal/db/schema.rb @@ -4,6 +4,7 @@ t.string :name t.text :description t.text :statement + t.text :secret_token t.string :data_source t.string :status t.timestamps null: false diff --git a/test/queries_test.rb b/test/queries_test.rb index 3981e1761..dc93111e6 100644 --- a/test/queries_test.rb +++ b/test/queries_test.rb @@ -82,6 +82,22 @@ def test_variables_time_range assert_match "daterangepicker", response.body end + def test_correct_token + query = create_query(statement: "SELECT 1") + get share_query_path(query.id, token: query.secret_token, format: 'csv') + + assert_response :success + assert_equal "text/csv; charset=utf-8", response.content_type + end + + def test_incorrect_token + query = create_query(statement: "SELECT 1") + get share_query_path(query.id, token: "x") + + assert_response :forbidden + assert_match "Access denied", response.body + end + def test_variable_defaults query = create_query(statement: "SELECT {default_var}") get blazer.query_path(query) @@ -138,6 +154,15 @@ def test_csv_query_variables assert_equal "text/csv; charset=utf-8", response.headers["Content-Type"] end + def test_share + query = create_query + assert query.secret_token + + get blazer.query_share_path(query_id: query.id, token: query.secret_token, format: 'csv') + + assert_response :success + end + def test_url run_query "SELECT 'http://localhost:3000/'" assert_match %{http://localhost:3000/}, response.body