file upload api for courses, users, and groups

closes #7775

Allows specifying the folder to upload to as a slash-separated string,
as well.

test plan:

upload to both the current user, and an allowed course, verify the
workflow for s3 and local files. verify you can't upload to course you
don't have permissions to, or another user.

verify that you can specify a folder, and the folder will be created if
it doesn't exist.

Change-Id: Ib9082f047c1c93824fe65decf4789606d82450c6
Reviewed-on: https://gerrit.instructure.com/9603
Tested-by: Hudson <hudson@instructure.com>
Reviewed-by: Cody Cutrer <cody@instructure.com>
This commit is contained in:
Brian Palmer 2012-03-26 14:07:54 -06:00
parent 64230d2746
commit 4da8dc2abf
12 changed files with 379 additions and 95 deletions

View File

@ -1,5 +1,5 @@
#
# Copyright (C) 2011 Instructure, Inc.
# Copyright (C) 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
@ -24,7 +24,7 @@ require 'set'
class CoursesController < ApplicationController
before_filter :require_user, :only => [:index]
before_filter :require_pseudonym, :only => [:index]
before_filter :require_context, :only => [:roster, :locks, :switch_role]
before_filter :require_context, :only => [:roster, :locks, :switch_role, :create_file]
include Api::V1::Course
@ -152,6 +152,23 @@ class CoursesController < ApplicationController
end
end
# @API
#
# Upload a file to the course.
#
# This API endpoint is the first step in uploading a file to a course.
# See the {file:file_uploads.html File Upload Documentation} for details on
# the file upload workflow.
#
# Only those with the "Manage Files" permission on a course can upload files
# to the course. By default, this is Teachers, TAs and Designers.
def create_file
@attachment = Attachment.new(:context => @context)
if authorized_action(@attachment, @current_user, :create)
api_attachment_preflight(@context, request)
end
end
def backup
get_context
if authorized_action(@context, @current_user, :update)

View File

@ -474,7 +474,7 @@ class FilesController < ApplicationController
@attachment.uploaded_data = params[:file]
if @attachment.save
# for consistency with the s3 upload client flow, we redirect to the success url here to finish up
redirect_to api_v1_files_create_success_url(@attachment, :uuid => @attachment.uuid)
redirect_to api_v1_files_create_success_url(@attachment, :uuid => @attachment.uuid, :on_duplicate => params[:on_duplicate])
else
render(:nothing => true, :status => :bad_request)
end
@ -483,6 +483,8 @@ class FilesController < ApplicationController
def api_create_success
@attachment = Attachment.find_by_id_and_uuid(params[:id], params[:uuid])
return render(:nothing => true, :status => :bad_request) unless @attachment.try(:file_state) == 'deleted'
duplicate_handling = check_duplicate_handling_option(request)
return unless duplicate_handling
if Attachment.s3_storage?
return render(:nothing => true, :status => :bad_request) unless @attachment.state == :unattached
details = AWS::S3::S3Object.about(@attachment.full_filename, @attachment.bucket_name)
@ -491,6 +493,7 @@ class FilesController < ApplicationController
@attachment.file_state = 'available'
@attachment.save!
end
@attachment.handle_duplicates(duplicate_handling)
render :json => attachment_json(@attachment)
end

View File

@ -1,5 +1,5 @@
#
# Copyright (C) 2011 Instructure, Inc.
# Copyright (C) 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
@ -16,11 +16,16 @@
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# @API Groups
#
# API for accessing group information.
class GroupsController < ApplicationController
before_filter :get_context
before_filter :require_context, :only => [:create_category, :delete_category]
before_filter :get_group_as_context, :only => [:show]
include Api::V1::Attachment
def context_group_members
@group = @context
if authorized_action(@group, @current_user, :read_roster)
@ -287,6 +292,24 @@ class GroupsController < ApplicationController
render :json => json
end
# @API
#
# Upload a file to the group.
#
# This API endpoint is the first step in uploading a file to a group.
# See the {file:file_uploads.html File Upload Documentation} for details on
# the file upload workflow.
#
# Only those with the "Manage Files" permission on a group can upload files
# to the group. By default, this is anybody participating in the
# group, or any admin over the group.
def create_file
@attachment = Attachment.new(:context => @context)
if authorized_action(@attachment, @current_user, :create)
api_attachment_preflight(@context, request)
end
end
protected
def get_group_as_context

View File

@ -412,6 +412,25 @@ class UsersController < ApplicationController
render :json => { :hidden => true }
end
# @API
#
# Upload a file to the user's personal files section.
#
# This API endpoint is the first step in uploading a file to a user's files.
# See the {file:file_uploads.html File Upload Documentation} for details on
# the file upload workflow.
#
# Note that typically users will only be able to upload files to their
# own files section. Passing a user_id of +self+ is an easy shortcut
# to specify the current user.
def create_file
@user = api_find(User, params[:user_id])
@attachment = Attachment.new(:context => @user)
if authorized_action(@attachment, @current_user, :create)
api_attachment_preflight(@current_user, request)
end
end
def close_notification
@current_user.close_announcement(AccountNotification.find(params[:id]))
render :json => @current_user.to_json

View File

@ -649,6 +649,7 @@ ActionController::Routing::Routes.draw do |map|
courses.delete 'courses/:id', :action => :destroy
courses.post 'courses/:course_id/course_copy', :controller => :content_imports, :action => :copy_course_content
courses.get 'courses/:course_id/course_copy/:id', :controller => :content_imports, :action => :copy_course_status, :path_name => :course_copy_status
courses.post 'courses/:course_id/files', :action => :create_file
end
api.with_options(:controller => :enrollments_api) do |enrollments|
@ -735,6 +736,7 @@ ActionController::Routing::Routes.draw do |map|
users.get 'accounts/:account_id/users', :action => :index, :path_name => 'account_users'
users.put 'users/:id', :action => :update
users.post 'users/:user_id/files', :action => :create_file
end
api.with_options(:controller => :pseudonyms) do |pseudonyms|
@ -792,14 +794,18 @@ ActionController::Routing::Routes.draw do |map|
events.post 'calendar_events/:id/reservations/:participant_id', :action => :reserve, :path_name => 'calendar_event_reserve'
end
api.with_options(:controller => :appointment_groups) do |groups|
groups.get 'appointment_groups', :action => :index, :path_name => 'appointment_groups'
groups.post 'appointment_groups', :action => :create
groups.get 'appointment_groups/:id', :action => :show, :path_name => 'appointment_group'
groups.put 'appointment_groups/:id', :action => :update
groups.delete 'appointment_groups/:id', :action => :destroy
groups.get 'appointment_groups/:id/users', :action => :users, :path_name => 'appointment_group_users'
groups.get 'appointment_groups/:id/groups', :action => :groups, :path_name => 'appointment_group_groups'
api.with_options(:controller => :appointment_groups) do |appt_groups|
appt_groups.get 'appointment_groups', :action => :index, :path_name => 'appointment_groups'
appt_groups.post 'appointment_groups', :action => :create
appt_groups.get 'appointment_groups/:id', :action => :show, :path_name => 'appointment_group'
appt_groups.put 'appointment_groups/:id', :action => :update
appt_groups.delete 'appointment_groups/:id', :action => :destroy
appt_groups.get 'appointment_groups/:id/users', :action => :users, :path_name => 'appointment_group_users'
appt_groups.get 'appointment_groups/:id/groups', :action => :groups, :path_name => 'appointment_group_groups'
end
api.with_options(:controller => :groups) do |groups|
groups.post 'groups/:group_id/files', :action => :create_file
end
api.post 'files/:id/create_success', :controller => :files, :action => :api_create_success, :path_name => 'files_create_success'

View File

@ -22,14 +22,17 @@ Arguments:
<dt>name</dt> <dd>The filename of the file. Any UTF-8 name is allowed. Path components such as `/` and `\` will be treated as part of the filename, not a path to a sub-folder.</dd>
<dt>size</dt> <dd>The size of the file, in bytes.</dd>
<dt>content_type</dt> <dd>The content type of the file. If not given, it will be guessed based on the file extension.</dd>
<dt>folder</dt> <dd>The path of the folder to store the file in. The path separator is the forward slash `/`, never a back slash. The folder will be created if it does not already exist. This parameter only applies to file uploads in a context that has folders, such as a user, a course, or a group. If not given, a default folder will be used.</dd>
<dt>on_duplicate</dt> <dd>How to handle duplicate filenames. If `overwrite`, then this file upload will overwrite any other file in the folder with the same name. If `rename`, then this file will be renamed if another file in the folder exists with the given name. If no parameter is given, the default is `overwrite`. This doesn't apply to file uploads in a context that doesn't have folders.</dd>
</dl>
Example Request:
curl 'https://<canvas>/api/v1/users/self/files' \
-F 'name=profile_pic.jpg' \
-F 'size=302185' \
-F 'content_type=image/jpeg' \
curl 'https://<canvas>/api/v1/users/self/files' \
-F 'name=profile_pic.jpg' \
-F 'size=302185' \
-F 'content_type=image/jpeg' \
-F 'folder=my_files/section1' \
-H "Authorization: Bearer <token>"
Example Response:

View File

@ -41,10 +41,26 @@ module Api::V1::Attachment
@attachment.file_state = 'deleted'
@attachment.workflow_state = 'unattached'
@attachment.content_type = request.params[:content_type].presence || Attachment.mimetype(@attachment.filename)
if opts.key?(:folder)
@attachment.folder = folder
elsif context.respond_to?(:folders) && request.params[:folder].is_a?(String)
@attachment.folder = Folder.assert_path(request.params[:folder], context)
end
duplicate_handling = check_duplicate_handling_option(request)
duplicate_handling = nil if duplicate_handling == 'overwrite'
@attachment.save!
render :json => @attachment.ajax_upload_params(@current_pseudonym,
api_v1_files_create_url,
api_v1_files_create_success_url(@attachment, :uuid => @attachment.uuid),
api_v1_files_create_url(:on_duplicate => duplicate_handling),
api_v1_files_create_success_url(@attachment, :uuid => @attachment.uuid, :on_duplicate => duplicate_handling),
:ssl => request.ssl?).slice(:upload_url, :upload_params)
end
def check_duplicate_handling_option(request)
duplicate_handling = request.params[:on_duplicate].presence || 'overwrite'
unless %w(rename overwrite).include?(duplicate_handling)
render(:json => { :message => 'invalid on_duplicate option' }, :status => :bad_request)
return nil
end
duplicate_handling
end
end

View File

@ -0,0 +1,182 @@
#
# Copyright (C) 2012 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 <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/api_spec_helper')
shared_examples_for "file uploads api" do
it "should upload (local files)" do
filename = "my_essay.doc"
content = "this is a test doc"
local_storage!
# step 1, preflight
json = preflight({ :name => filename })
json['upload_url'].should == "http://www.example.com/files_api"
# step 2, upload
tmpfile = Tempfile.new(["test", File.extname(filename)])
tmpfile.write(content)
tmpfile.rewind
post_params = json["upload_params"].merge({"file" => tmpfile})
send_multipart(json["upload_url"], post_params)
attachment = Attachment.last(:order => :id)
attachment.should be_deleted
response.should redirect_to("http://www.example.com/api/v1/files/#{attachment.id}/create_success?uuid=#{attachment.uuid}")
# step 3, confirmation
post response['Location'], {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment.reload
json = json_parse(response.body)
json.should == {
'id' => attachment.id,
'url' => file_download_url(attachment, :verifier => attachment.uuid, :download => '1', :download_frd => '1'),
'content-type' => attachment.content_type,
'display_name' => attachment.display_name,
'filename' => attachment.filename,
'size' => tmpfile.size,
}
attachment.file_state.should == 'available'
attachment.content_type.should == "application/msword"
attachment.open.read.should == content
attachment.display_name.should == filename
attachment
end
it "should upload (s3 files)" do
filename = "my_essay.doc"
content = "this is a test doc"
s3_storage!
# step 1, preflight
json = preflight({ :name => filename })
json['upload_url'].should == "http://no-bucket.s3.amazonaws.com/"
attachment = Attachment.last(:order => :id)
redir = json['upload_params']['success_action_redirect']
redir.should == "http://www.example.com/api/v1/files/#{attachment.id}/create_success?uuid=#{attachment.uuid}"
attachment.should be_deleted
# step 2, upload
# we skip the actual call and stub this out, since we can't hit s3 during specs
AWS::S3::S3Object.expects(:about).with(attachment.full_filename, attachment.bucket_name).returns({
'content-type' => 'application/msword',
'content-length' => 1234,
})
# step 3, confirmation
post redir, {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment.reload
json = json_parse(response.body)
json.should == {
'id' => attachment.id,
'url' => file_download_url(attachment, :verifier => attachment.uuid, :download => '1', :download_frd => '1'),
'content-type' => attachment.content_type,
'display_name' => attachment.display_name,
'filename' => attachment.filename,
'size' => 1234,
}
attachment.file_state.should == 'available'
attachment.content_type.should == "application/msword"
attachment.display_name.should == filename
attachment
end
end
shared_examples_for "file uploads api with folders" do
it_should_behave_like "file uploads api"
it "should allow specifying a folder" do
preflight({ :name => "with_path.txt", :folder => "files/a/b/c/mypath" })
attachment = Attachment.last(:order => :id)
attachment.folder.should == Folder.assert_path("/files/a/b/c/mypath", context)
end
it "should upload to an existing folder" do
@folder = Folder.assert_path("/files/a/b/c/mypath", context)
@folder.should be_present
@folder.should be_visible
preflight({ :name => "my_essay.doc", :folder => "files/a/b/c/mypath" })
attachment = Attachment.last(:order => :id)
attachment.folder.should == @folder
end
it "should overwrite duplicate files by default" do
local_storage!
@folder = Folder.assert_path("test", context)
a1 = Attachment.create!(:folder => @folder, :context => context, :filename => "test.txt", :uploaded_data => StringIO.new("first"))
json = preflight({ :name => "test.txt", :folder => "test" })
tmpfile = Tempfile.new(["test", ".txt"])
tmpfile.write("second")
tmpfile.rewind
post_params = json["upload_params"].merge({"file" => tmpfile})
send_multipart(json["upload_url"], post_params)
post response['Location'], {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment = Attachment.last(:order => :id)
a1.reload.should be_deleted
attachment.reload.should be_available
end
it "should allow renaming instead of overwriting duplicate files (local storage)" do
local_storage!
@folder = Folder.assert_path("test", context)
a1 = Attachment.create!(:folder => @folder, :context => context, :filename => "test.txt", :uploaded_data => StringIO.new("first"))
json = preflight({ :name => "test.txt", :folder => "test", :on_duplicate => 'rename' })
tmpfile = Tempfile.new(["test", ".txt"])
tmpfile.write("second")
tmpfile.rewind
post_params = json["upload_params"].merge({"file" => tmpfile})
send_multipart(json["upload_url"], post_params)
post response['Location'], {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment = Attachment.last(:order => :id)
a1.reload.should be_available
attachment.reload.should be_available
attachment.display_name.should == "test-1.txt"
end
it "should allow renaming instead of overwriting duplicate files (s3 storage)" do
s3_storage!
@folder = Folder.assert_path("test", context)
a1 = Attachment.create!(:folder => @folder, :context => context, :filename => "test.txt", :uploaded_data => StringIO.new("first"))
json = preflight({ :name => "test.txt", :folder => "test", :on_duplicate => 'rename' })
redir = json['upload_params']['success_action_redirect']
attachment = Attachment.last(:order => :id)
AWS::S3::S3Object.expects(:about).with(attachment.full_filename, attachment.bucket_name).returns({
'content-type' => 'application/msword',
'content-length' => 1234,
})
post redir, {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
a1.reload.should be_available
attachment.reload.should be_available
attachment.display_name.should == "test-1.txt"
end
it "should reject other duplicate file handling params" do
proc { preflight({ :name => "test.txt", :folder => "test", :on_duplicate => 'killall' }) }.should raise_error
end
end

View File

@ -1,5 +1,5 @@
#
# Copyright (C) 2011 Instructure, Inc.
# Copyright (C) 2012 Instructure, Inc.
#
# This file is part of Canvas.
#
@ -17,6 +17,7 @@
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../file_uploads_spec_helper')
class TestCourseApi
include Api::V1::Course
@ -499,6 +500,28 @@ describe CoursesController, :type => :integration do
{ :controller => 'courses', :action => 'show', :id => @course1.to_param, :format => 'json' })
json['id'].should == @course1.id
end
context "course files" do
it_should_behave_like "file uploads api with folders"
def preflight(preflight_params)
@user = @teacher
api_call(:post, "/api/v1/courses/#{@course.id}/files",
{ :controller => "courses", :action => "create_file", :format => "json", :course_id => @course.to_param, },
preflight_params)
end
def context
@course
end
it "should require the correct permission to upload" do
@user = student_in_course(:course => @course).user
api_call(:post, "/api/v1/courses/#{@course.id}/files",
{ :controller => "courses", :action => "create_file", :format => "json", :course_id => @course.to_param, },
{ :name => 'failboat.txt' }, {}, :expected_status => 401)
end
end
end
def each_copy_option
@ -676,5 +699,4 @@ describe ContentImportsController, :type => :integration do
check_counts(1, option)
end
end
end

View File

@ -0,0 +1,41 @@
#
# Copyright (C) 2012 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 <http://www.gnu.org/licenses/>.
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../file_uploads_spec_helper')
describe "Groups API", :type => :integration do
context "group files" do
it_should_behave_like "file uploads api with folders"
before do
group_model
@group.add_user(user_with_pseudonym)
end
def preflight(preflight_params)
api_call(:post, "/api/v1/groups/#{@group.id}/files",
{ :controller => "groups", :action => "create_file", :format => "json", :group_id => @group.to_param, },
preflight_params)
end
def context
@group
end
end
end

View File

@ -17,6 +17,7 @@
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../file_uploads_spec_helper')
describe 'Submissions API', :type => :integration do
@ -1583,83 +1584,12 @@ describe 'Submissions API', :type => :integration do
@user = @student1
end
it "should allow uploading files to the student's own submission (local files)" do
local_storage!
it_should_behave_like "file uploads api"
# step 1, preflight
json = api_call(:post, "/api/v1/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student1.id}/files",
def preflight(preflight_params)
api_call(:post, "/api/v1/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student1.id}/files",
{ :controller => "submissions_api", :action => "create_file", :format => "json", :course_id => @course.to_param, :assignment_id => @assignment.to_param, :user_id => @student1.to_param },
{ :name => "my_essay.doc" })
json['upload_url'].should == "http://www.example.com/files_api"
# step 2, upload
tmpfile = Tempfile.new(["test", ".doc"])
tmpfile.write("this is a test doc")
tmpfile.rewind
post_params = json["upload_params"].merge({"file" => tmpfile})
send_multipart(json["upload_url"], post_params)
attachment = Attachment.last(:order => :id)
response.should redirect_to("http://www.example.com/api/v1/files/#{attachment.id}/create_success?uuid=#{attachment.uuid}")
# step 3, confirmation
post response['Location'], {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment.reload
json = json_parse(response.body)
json.should == {
'id' => attachment.id,
'url' => file_download_url(attachment, :verifier => attachment.uuid, :download => '1', :download_frd => '1'),
'content-type' => attachment.content_type,
'display_name' => attachment.display_name,
'filename' => attachment.filename,
'size' => tmpfile.size,
}
attachment.context.should == @student1
attachment.file_state.should == 'available'
attachment.content_type.should == "application/msword"
attachment.open.read.should == "this is a test doc"
attachment.display_name.should == "my_essay.doc"
end
it "should allow uploading files to the student's own submission (s3 files)" do
s3_storage!
# step 1, preflight
json = api_call(:post, "/api/v1/courses/#{@course.id}/assignments/#{@assignment.id}/submissions/#{@student1.id}/files",
{ :controller => "submissions_api", :action => "create_file", :format => "json", :course_id => @course.to_param, :assignment_id => @assignment.to_param, :user_id => @student1.to_param },
{ :name => "my_essay.doc" })
json['upload_url'].should == "http://no-bucket.s3.amazonaws.com/"
attachment = Attachment.last(:order => :id)
redir = json['upload_params']['success_action_redirect']
redir.should == "http://www.example.com/api/v1/files/#{attachment.id}/create_success?uuid=#{attachment.uuid}"
# step 2, upload
# we skip the actual call and stub this out, since we can't hit s3 during specs
AWS::S3::S3Object.expects(:about).with(attachment.full_filename, attachment.bucket_name).returns({
'content-type' => 'application/msword',
'content-length' => 1234,
})
# step 3, confirmation
post redir, {}, { 'Authorization' => "Bearer #{@user.access_tokens.first.token}" }
response.should be_success
attachment.reload
json = json_parse(response.body)
json.should == {
'id' => attachment.id,
'url' => file_download_url(attachment, :verifier => attachment.uuid, :download => '1', :download_frd => '1'),
'content-type' => attachment.content_type,
'display_name' => attachment.display_name,
'filename' => attachment.filename,
'size' => 1234,
}
attachment.context.should == @student1
attachment.file_state.should == 'available'
attachment.content_type.should == "application/msword"
attachment.display_name.should == "my_essay.doc"
preflight_params)
end
it "should reject uploading files to other students' submissions" do

View File

@ -17,6 +17,7 @@
#
require File.expand_path(File.dirname(__FILE__) + '/../api_spec_helper')
require File.expand_path(File.dirname(__FILE__) + '/../file_uploads_spec_helper')
class TestUserApi
include Api::V1::User
@ -473,4 +474,25 @@ describe "Users API", :type => :integration do
end
end
end
context "user files" do
it_should_behave_like "file uploads api with folders"
def preflight(preflight_params)
api_call(:post, "/api/v1/users/self/files",
{ :controller => "users", :action => "create_file", :format => "json", :user_id => 'self', },
preflight_params)
end
def context
@user
end
it "should not allow uploading to other users" do
user2 = User.create!
api_call(:post, "/api/v1/users/#{user2.id}/files",
{ :controller => "users", :action => "create_file", :format => "json", :user_id => user2.to_param, },
{ :name => "my_essay.doc" }, {}, :expected_status => 401)
end
end
end