From 33b4eb55f9c6a4ca42de7453bfde46e7a865ed0b Mon Sep 17 00:00:00 2001 From: Cameron Matheson Date: Wed, 7 May 2014 18:05:05 -0600 Subject: [PATCH] canvadocs This commit adds support for document previews from a Box View compatible API. closes CNVS-12416 Test plan: * NOTE: check everywhere for previews (speedgrader, files list, individual files page, etc?) * Enable Scribd and Crocodoc * attachments should be previewable in scribd or crocodoc (submission attachments should go to Crocodoc) * Enable Canvadocs * Files that previously previewed in scribd should now go to Canvadocs. Nothing should ever go to scribd. * New submission attachments should continue to use Crocodoc (for supported file types) * Other attachments should preview in Canvadocs Change-Id: I173e4fabc0ae677cdddd4bd065777a90360c1f34 Reviewed-on: https://gerrit.instructure.com/34535 Reviewed-by: Simon Williams Tested-by: Jenkins Product-Review: Matt Fairbourn QA-Review: Amber Taniuchi --- .../canvadoc_sessions_controller.rb | 43 +++++ app/controllers/folders_controller.rb | 6 +- app/helpers/attachment_helper.rb | 7 + app/models/assignment.rb | 2 +- app/models/attachment.rb | 57 +++++- app/models/canvadoc.rb | 69 +++++++ app/models/crocodoc_document.rb | 13 +- app/models/submission.rb | 9 +- .../plugins/_canvadocs_settings.html.erb | 20 ++ config/routes.rb | 1 + .../20140423003242_create_canvadocs_table.rb | 20 ++ lib/canvadocs.rb | 180 ++++++++++++++++++ lib/canvas/plugins/default_plugins.rb | 13 +- public/javascripts/full_files.js | 6 +- public/javascripts/jquery.doc_previews.js | 16 +- public/javascripts/speed_grader.js | 6 + .../canvadoc_sessions_controller_spec.rb | 75 ++++++++ spec/factories/attachment_factory.rb | 2 + spec/models/attachment_spec.rb | 71 ++++++- spec/models/canvadoc_spec.rb | 80 ++++++++ 20 files changed, 676 insertions(+), 20 deletions(-) create mode 100644 app/controllers/canvadoc_sessions_controller.rb create mode 100644 app/models/canvadoc.rb create mode 100644 app/views/plugins/_canvadocs_settings.html.erb create mode 100644 db/migrate/20140423003242_create_canvadocs_table.rb create mode 100644 lib/canvadocs.rb create mode 100644 spec/controllers/canvadoc_sessions_controller_spec.rb create mode 100644 spec/models/canvadoc_spec.rb diff --git a/app/controllers/canvadoc_sessions_controller.rb b/app/controllers/canvadoc_sessions_controller.rb new file mode 100644 index 00000000000..f1253a7becb --- /dev/null +++ b/app/controllers/canvadoc_sessions_controller.rb @@ -0,0 +1,43 @@ +# +# Copyright (C) 2014 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +class CanvadocSessionsController < ApplicationController + before_filter :require_user + + def show + unless Canvas::Security.verify_hmac_sha1(params[:hmac], params[:blob]) + render :text => 'unauthorized', :status => :unauthorized + return + end + + blob = JSON.parse(params[:blob]) + attachment = Attachment.find(blob["attachment_id"]) + + unless @current_user.global_id == blob["user_id"] + render :text => 'unauthorized', :status => :unauthorized + return + end + + if attachment.canvadocable? + attachment.submit_to_canvadocs unless attachment.canvadoc_available? + redirect_to attachment.canvadoc.session_url + else + render :text => "Not found", :status => :not_found + end + end +end diff --git a/app/controllers/folders_controller.rb b/app/controllers/folders_controller.rb index 6d176d95551..b3a0e89d593 100644 --- a/app/controllers/folders_controller.rb +++ b/app/controllers/folders_controller.rb @@ -221,7 +221,11 @@ class FoldersController < ApplicationController res = { :actual_folder => @folder.as_json(folders_options), :sub_folders => sub_folders_scope.by_position.map { |f| f.as_json(folders_options) }, - :files => files.map { |f| f.as_json(files_options)} + :files => files.map { |f| + f.as_json(files_options).tap { |json| + json['attachment']['canvadoc_session_url'] = f.canvadoc_url(@current_user) + } + } } format.json { render :json => res } end diff --git a/app/helpers/attachment_helper.rb b/app/helpers/attachment_helper.rb index 59c95296377..16d81cf5af0 100644 --- a/app/helpers/attachment_helper.rb +++ b/app/helpers/attachment_helper.rb @@ -27,6 +27,13 @@ module AttachmentHelper rescue => e ErrorReport.log_exception('crocodoc', e) end + elsif attachment.canvadocable? + blob = { + user_id: @current_user.global_id, + attachment_id: attachment.global_id, + }.to_json + hmac = Canvas::Security.hmac_sha1(blob) + attrs[:canvadoc_session_url] = canvadoc_session_path(blob: blob, hmac: hmac) elsif attachment.scribdable? && scribd_doc = attachment.scribd_doc begin attrs[:scribd_doc_id] = scribd_doc.doc_id diff --git a/app/models/assignment.rb b/app/models/assignment.rb index 586999233ee..e2aad313749 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1229,7 +1229,7 @@ class Assignment < ActiveRecord::Base a.as_json( :only => attachment_fields, :methods => [:view_inline_ping_url, :scribd_render_url] - ) + ).tap { |json| json[:attachment][:canvadoc_url] = a.canvadoc_url(user) } end end end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index a6418b97e12..6b3d0cdee96 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -67,6 +67,7 @@ class Attachment < ActiveRecord::Base has_one :thumbnail, :foreign_key => "parent_id", :conditions => {:thumbnail => "thumb"} has_many :thumbnails, :foreign_key => "parent_id" has_one :crocodoc_document + has_one :canvadoc before_save :infer_display_name before_save :default_values @@ -1284,12 +1285,22 @@ class Attachment < ActiveRecord::Base CrocodocDocument::MIME_TYPES.include?(content_type) end + def canvadocable? + Canvadocs.enabled? && Canvadoc::MIME_TYPES.include?(content_type) + end + def self.submit_to_scribd(ids) Attachment.find_all_by_id(ids).compact.each do |attachment| attachment.submit_to_scribd! rescue nil end end + def self.submit_to_canvadocs(ids) + Attachment.find_each(ids).each do |a| + a.submit_to_canvadocs + end + end + def self.skip_3rd_party_submits(skip=true) @skip_3rd_party_submits = skip end @@ -1309,11 +1320,12 @@ class Attachment < ActiveRecord::Base end def needs_scribd_doc? - if self.scribd_attempts >= MAX_SCRIBD_ATTEMPTS + if Canvadocs.enabled? + return false + elsif self.scribd_attempts >= MAX_SCRIBD_ATTEMPTS self.mark_errored false - end - if self.scribd_doc? + elsif self.scribd_doc? Scribd::API.instance.user = scribd_user begin status = self.scribd_doc.conversion_status @@ -1386,6 +1398,31 @@ class Attachment < ActiveRecord::Base end end + def submit_to_canvadocs(attempt = 1, opts = {}) + # ... or crocodoc (this will go away soon) + return if Attachment.skip_3rd_party_submits? + + if opts[:wants_annotation] && crocodocable? + submit_to_crocodoc(attempt) + elsif canvadocable? + doc = canvadoc || create_canvadoc + doc.upload + update_attribute(:workflow_state, 'processing') + end + rescue => e + update_attribute(:workflow_state, 'errored') + ErrorReport.log_exception(:canvadocs, e, :attachment_id => id) + + if attempt <= Setting.get('max_canvadocs_attempts', '5').to_i + send_later_enqueue_args :submit_to_canvadocs, { + :n_strand => 'canvadocs_retries', + :run_at => (5 * attempt).minutes.from_now, + :max_attempts => 1, + :priority => Delayed::LOW_PRIORITY, + }, attempt + 1, opts + end + end + def submit_to_crocodoc(attempt = 1) if crocodocable? && !Attachment.skip_3rd_party_submits? crocodoc = crocodoc_document || create_crocodoc_document @@ -1696,6 +1733,10 @@ class Attachment < ActiveRecord::Base crocodoc_document.try(:available?) end + def canvadoc_available? + canvadoc.try(:available?) + end + def view_inline_ping_url "/#{context_url_prefix}/files/#{self.id}/inline_view" end @@ -1717,6 +1758,16 @@ class Attachment < ActiveRecord::Base end end + def canvadoc_url(user) + return unless canvadocable? + blob = { + user_id: user.global_id, + attachment_id: id, + }.to_json + hmac = Canvas::Security.hmac_sha1(blob) + "/canvadoc_session?blob=#{URI.encode blob}&hmac=#{URI.encode hmac}" + end + def check_rerender_scribd_doc if scribd_doc_missing? attachment = root_attachment || self diff --git a/app/models/canvadoc.rb b/app/models/canvadoc.rb new file mode 100644 index 00000000000..30d50fca89e --- /dev/null +++ b/app/models/canvadoc.rb @@ -0,0 +1,69 @@ +# +# Copyright (C) 2014 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# + +class Canvadoc < ActiveRecord::Base + attr_accessible :document_id, :process_state + + belongs_to :attachment + + MIME_TYPES = %w( + application/excel + application/msword + application/pdf + application/vnd.ms-excel + application/vnd.ms-powerpoint + application/vnd.openxmlformats-officedocument.presentationml.presentation + application/vnd.openxmlformats-officedocument.wordprocessingml.document + ) + + def upload + return if document_id.present? + + url = attachment.authenticated_s3_url(:expires => 1.day) + + response = Canvas.timeout_protection("canvadocs") { + canvadocs_api.upload(url) + } + + if response && response['id'] + update_attributes document_id: response['id'], process_state: response['status'] + elsif response.nil? + raise "no response received (request timed out?)" + else + raise response.inspect + end + end + + def session_url + Canvas.timeout_protection("canvadocs") do + session = canvadocs_api.session(document_id) + canvadocs_api.view(session["id"]) + end + end + + def available? + !!(document_id && process_state != 'error' && Canvadocs.enabled?) + end + + def canvadocs_api + raise "Canvadocs isn't enabled" unless Canvadocs.enabled? + Canvadocs::API.new(token: Canvadocs.config['api_key'], + base_url: Canvadocs.config['base_url']) + end + private :canvadocs_api +end diff --git a/app/models/crocodoc_document.rb b/app/models/crocodoc_document.rb index 77f4a2925bc..7caeb2125e3 100644 --- a/app/models/crocodoc_document.rb +++ b/app/models/crocodoc_document.rb @@ -156,9 +156,16 @@ class CrocodocDocument < ActiveRecord::Base if error_uuids.present? error_docs = CrocodocDocument.where(:uuid => error_uuids) - attachment_ids = error_docs.map(&:attachment_id) - Attachment.send_later_enqueue_args :submit_to_scribd, - {:n_strand => 'scribd', :max_attempts => 1}, + attachment_ids = error_docs.pluck(:attachment_id) + if Canvadocs.enabled? + method = :submit_to_canvadocs + strand = "canvadocs" + else + method = :submit_to_scribd + strand = "scribd" + end + Attachment.send_later_enqueue_args method, + {:n_strand => strand, :max_attempts => 1}, attachment_ids end end diff --git a/app/models/submission.rb b/app/models/submission.rb index edf3c1b892c..aa6a4ecc1d0 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -113,7 +113,7 @@ class Submission < ActiveRecord::Base after_save :touch_user after_save :update_assignment after_save :update_attachment_associations - after_save :submit_attachments_to_crocodoc + after_save :submit_attachments_to_canvadocs after_save :queue_websnap after_save :update_final_score after_save :submit_to_turnitin_later @@ -472,14 +472,15 @@ class Submission < ActiveRecord::Base end private :attachment_fake_belongs_to_group - def submit_attachments_to_crocodoc + def submit_attachments_to_canvadocs if attachment_ids_changed? attachments = attachment_associations.map(&:attachment) attachments.each do |a| - a.send_later_enqueue_args :submit_to_crocodoc, - :n_strand => 'crocodoc', + a.send_later_enqueue_args :submit_to_canvadocs, { + :n_strand => 'canvadocs', :max_attempts => 1, :priority => Delayed::LOW_PRIORITY + }, 1, wants_annotation: true end end end diff --git a/app/views/plugins/_canvadocs_settings.html.erb b/app/views/plugins/_canvadocs_settings.html.erb new file mode 100644 index 00000000000..74a37f0afea --- /dev/null +++ b/app/views/plugins/_canvadocs_settings.html.erb @@ -0,0 +1,20 @@ +<% settings[:base_url] = "https://view-api.box.com/1" if settings[:base_url].blank? %> +<%= fields_for :settings, OpenObject.new(settings) do |f| %> + + + + + + + + + + + + +
+

<%= t :description, "This plugin integrates with Canvadocs (or any + Box View compatible API) to provide an HTML5 document previewer." %>

+
<%= f.blabel :api_key, :en => "API Key" %><%= f.text_field :api_key %>
<%= f.blabel :base_url, :en => "Base URL" %><%= f.text_field :base_url %>
+<% end %> + diff --git a/config/routes.rb b/config/routes.rb index fc5802befa2..8d4ec2f98a0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -406,6 +406,7 @@ routes.draw do match '/submissions/:submission_id/attachments/:attachment_id/crocodoc_sessions' => 'crocodoc_sessions#create', :via => :post match '/attachments/:attachment_id/crocodoc_sessions' => 'crocodoc_sessions#create', :via => :post + match '/canvadoc_session' => 'canvadoc_sessions#show', :via => :get, :as => :canvadoc_session resources :page_views, :only => [:update] match 'media_objects' => 'context#create_media_object', :as => :create_media_object, :via => :post diff --git a/db/migrate/20140423003242_create_canvadocs_table.rb b/db/migrate/20140423003242_create_canvadocs_table.rb new file mode 100644 index 00000000000..5bc2c5e6a6f --- /dev/null +++ b/db/migrate/20140423003242_create_canvadocs_table.rb @@ -0,0 +1,20 @@ +class CreateCanvadocsTable < ActiveRecord::Migration + tag :predeploy + + def self.up + create_table :canvadocs do |t| + t.string :document_id + t.string :process_state + t.integer :attachment_id, limit: 8, null: false + t.timestamps + end + add_index :canvadocs, :document_id, :unique => true + add_index :canvadocs, :attachment_id + add_index :canvadocs, :process_state + add_foreign_key :canvadocs, :attachments + end + + def self.down + drop_table :canvadocs + end +end diff --git a/lib/canvadocs.rb b/lib/canvadocs.rb new file mode 100644 index 00000000000..e6b898021b8 --- /dev/null +++ b/lib/canvadocs.rb @@ -0,0 +1,180 @@ +require 'cgi' +require 'net/http' +require 'net/https' +require 'json' + +module Canvadocs + # Public: A small ruby client that wraps the Box View api. + # + # Examples + # + # Canvadocs::API.new(:token => ) + class API + attr_accessor :token, :http, :url + + # Public: The base part of the url that is the same for all api requests. + BASE_URL = "https://view-api.box.com/1" + + # Public: Initialize a Canvadocs api object + # + # opts - A hash of options with which to initialize the object + # :token - The api token to use to authenticate requests. Required. + # + # Examples + # crocodoc = Canvadocs::API.new(:token => ) + # # => > + def initialize(opts) + self.token = opts[:token] + + # setup the http object for ssl + @url = URI.parse(opts[:base_url] || BASE_URL) + @http = Net::HTTP.new(@url.host, @url.port) + @http.use_ssl = true + end + + # -- Documents -- + + # Public: Create a document with the file at the given url. + # + # obj - a url string + # + # Examples + # + # upload("http://www.example.com/test.doc") + # # => { "id": 1234, "status": "queued" } + # + # Returns a hash containing the document's id and status + def upload(obj) + params = if obj.is_a?(File) + { :file => obj } + raise Canvadocs::Error, "TODO: support raw files" + else + { :url => obj.to_s } + end + + raw_body = api_call(:post, "documents", params) + JSON.parse(raw_body) + end + + # Public: Delete a document. + # + # id - a single document id to delete + # + def delete(id) + api_call(:delete, "documents/#{id}") + end + + # -- Sessions -- + + # Public: Create a session, which is a unique id with which you can view + # the document. Sessions expire 60 minutes after they are generated. + # + # id - The id of the document for the session + # + # Examples + # + # session(1234) + # # => { "id": "CFAmd3Qjm_2ehBI7HyndnXKsDrQXJ7jHCuzcRv" } + # + # Returns a hash containing the session id + def session(document_id, opts={}) + raw_body = api_call(:post, "sessions", + opts.merge(:document_id => document_id)) + JSON.parse(raw_body) + end + + # Public: Get the url for the viewer for a session. + # + # session_id - The id of the session (see #session) + # + # Examples + # view("CFAmd3Qjm_2ehBI7HyndnXKsDrQXJ7jHCuzcRv_V4FAgbSmaBkF") + # # => https://view-api.box.com/1/sessions/#{session_id}/view?theme=dark" + # + # Returns a url string for viewing the session + def view(session_id) + "#{@url.to_s}/sessions/#{session_id}/view?theme=dark" + end + + + # -- API Glue -- + + # Internal: Setup the api call, format the parameters, send the request, + # parse the response and return it. + # + # method - The http verb to use, currently :get or :post + # endpoint - The api endpoint to hit. this is the part after + # +base_url+. please do not include a beginning slash. + # params - Parameters to send with the api call + # + # Examples + # + # api_call(:post, + # "documents", + # { url: "http://www.example.com/test.doc" }) + # # => { "id": 1234 } + # + # Returns the json parsed response body of the call + def api_call(method, endpoint, params={}) + # dispatch to the right method, with the full path (/api/v2 + endpoint) + request = self.send("format_#{method}", "#{@url.path}/#{endpoint}", params) + request["Authorization"] = "Token #{token}" + response = @http.request(request) + + unless response.code =~ /\A20./ + raise Canvadocs::Error, "HTTP Error #{response.code}: #{response.body}" + end + response.body + end + + + # Internal: Format and create a Net::HTTP get request, with query + # parameters. + # + # path - the path to get + # params - the params to add as query params to the path + # + # Examples + # + # format_get("/api/v2/document/status", + # { :token => , :uuids => }) + # # => > for + # # "/api/v2/document/status?token=&uuids=" + # + # Returns a Net::HTTP::Get object for the path with query params + def format_get(path, params) + query = params.map { |k,v| "#{k}=#{CGI::escape(v.to_s)}" }.join("&") + Net::HTTP::Get.new("#{path}?#{query}") + end + + # Internal: Format and create a Net::HTTP post request, with form + # parameters. + # + # path - the path to get + # params - the params to add as form params to the path + # + # Examples + # + # format_post("/api/v2/document/upload", + # { :token => , :url => }) + # # => > + # + # Returns a Net::HTTP::Post object for the path with json-formatted params + def format_post(path, params) + Net::HTTP::Post.new(path).tap { |req| + req["Content-Type"] = "application/json" + req.body = params.to_json + } + end + end + + class Error < StandardError; end + + def self.config + PluginSetting.settings_for_plugin(:canvadocs) + end + + def self.enabled? + !!config + end +end diff --git a/lib/canvas/plugins/default_plugins.rb b/lib/canvas/plugins/default_plugins.rb index a8e5223aaca..5f7f0e912f3 100644 --- a/lib/canvas/plugins/default_plugins.rb +++ b/lib/canvas/plugins/default_plugins.rb @@ -227,7 +227,7 @@ Canvas::Plugin.register('assignment_freezer', nil, { Canvas::Plugin.register('crocodoc', :previews, { :name => lambda { t :name, 'Crocodoc' }, - :description => lambda { t :description, 'Enabled Crocodoc as a document preview option' }, + :description => lambda { t :description, 'Enable Crocodoc as a document preview option' }, :website => 'https://crocodoc.com/', :author => 'Instructure', :author_website => 'http://www.instructure.com', @@ -235,6 +235,17 @@ Canvas::Plugin.register('crocodoc', :previews, { :settings_partial => 'plugins/crocodoc_settings', :settings => nil }) + +Canvas::Plugin.register('canvadocs', :previews, { + :name => lambda { t :name, 'Canvadocs' }, + :description => lambda { t :description, 'Enable Canvadocs (compatible with Box View) as a document preview option' }, + :author => 'Instructure', + :author_website => 'http://www.instructure.com', + :version => '1.0.0', + :settings_partial => 'plugins/canvadocs_settings', + :settings => nil +}) + Canvas::Plugin.register('account_reports', nil, { :name => lambda{ t :name, 'Account Reports' }, :description => lambda{ t :description, 'Select account reports' }, diff --git a/public/javascripts/full_files.js b/public/javascripts/full_files.js index 74cccfa9642..eef153c449e 100644 --- a/public/javascripts/full_files.js +++ b/public/javascripts/full_files.js @@ -1586,6 +1586,7 @@ define([ attachment_id: data.id, height: '100%', crocodoc_session_url: data.crocodocSession, + canvadoc_session_url: data.canvadoc_session_url, scribd_doc_id: data.scribd_doc && data.scribd_doc.attributes && data.scribd_doc.attributes.doc_id, scribd_access_key: data.scribd_doc && data.scribd_doc.attributes && data.scribd_doc.attributes.access_key, attachment_view_inline_ping_url: files.viewInlinePingUrl(data.context_string, data.id), @@ -1593,7 +1594,10 @@ define([ attachment_preview_processing: data.workflow_state == 'pending_upload' || data.workflow_state == 'processing' }); }; - if (data.permissions && data.permissions.download && $.isPreviewable(data.content_type)) { + if (data.canvadoc_session_url) { + showPreview(); + } + else if (data.permissions && data.permissions.download && $.isPreviewable(data.content_type)) { if (data['crocodoc_available?'] && !data.crocodocSession) { $preview.disableWhileLoading( $.ajaxJSON( diff --git a/public/javascripts/jquery.doc_previews.js b/public/javascripts/jquery.doc_previews.js index b9c45f51bcf..e09d935a286 100644 --- a/public/javascripts/jquery.doc_previews.js +++ b/public/javascripts/jquery.doc_previews.js @@ -91,7 +91,7 @@ define([ // if I have a url to ping back to the app that I viewed this file inline, ping it. if (opts.attachment_view_inline_ping_url) { $.ajaxJSON(opts.attachment_view_inline_ping_url, 'POST', {}, function() { }, function() { }); - $.trackEvent('Doc Previews', serviceUsed, JSON.stringify(opts, ['attachment_id', 'submission_id', 'mimetype', 'crocodoc_session_url', 'scribd_doc_id'])); + $.trackEvent('Doc Previews', serviceUsed, JSON.stringify(opts, ['attachment_id', 'submission_id', 'mimetype', 'crocodoc_session_url', 'canvadoc_session_url', 'scribd_doc_id'])); } } @@ -108,6 +108,20 @@ define([ opts.ready(); }); } + else if (opts.canvadoc_session_url) { + var iframe = $('