Skip to content

Commit

Permalink
Issue 212 (#289)
Browse files Browse the repository at this point in the history
* Issue #212 - Implement S3 signatures for S3 direct upload functionality (#273)

* S3 class lets you generate a new signature for direct upload
  * S3 class lets you generate signed URL for access to a S3 object
  * S3Signature#show endpoint provides a signature used for direct upload
  * ResourcePathBuilder is a helper class for build object paths used for upload
    file to S3

#212

* Issue 212 resource form (#287)

* Issue #212 - Implement S3 signatures for S3 direct upload functionality
  * S3 class lets you generate a new signature for direct upload
  * S3 class lets you generate signed URL for access to a S3 object
  * S3Signature#show endpoint provides a signature used for direct upload
  * ResourcePathBuilder is a helper class for build object paths used for upload
    file to S3

#212

* Issue #212 - Implement S3 signatures for S3 direct upload functionality (#273)

* S3 class lets you generate a new signature for direct upload
  * S3 class lets you generate signed URL for access to a S3 object
  * S3Signature#show endpoint provides a signature used for direct upload
  * ResourcePathBuilder is a helper class for build object paths used for upload
    file to S3

#212

* Issue #212 - Implemented resource form with file upload. file content processing done.
  * A user can upload a PDF file as part of a resource
  * The user can add metadata on edit, but cannot change the url or file
  * When the user uploads PDF a background job extract the content and beings indexed
  * S3 can fetch files.
  * New queue created in Sidekiq: file.

#212
  • Loading branch information
nsanta authored Sep 30, 2017
1 parent 4ab4be7 commit 37e35dd
Show file tree
Hide file tree
Showing 32 changed files with 742 additions and 28 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ PORT=3000
AWS_ACCESS_KEY_ID='abc123'
AWS_SECRET_ACCESS_KEY='xyz789'
AWS_REGION='us-east-1'
AWS_UPLOADS_BUCKET='dev-bucket'

HYPOTHESIS_API_KEY= 'def456'
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ gem 'bootstrap-daterangepicker-rails'
gem 'bootstrap-tagsinput-rails'
gem 'selectize-rails'
gem 'scout_apm'
gem 'pdf-reader'

group :development do
gem 'bummr'
Expand Down
12 changes: 12 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
GEM
remote: https://rubygems.org/
specs:
Ascii85 (1.0.2)
actioncable (5.0.0.1)
actionpack (= 5.0.0.1)
nio4r (~> 1.2)
Expand Down Expand Up @@ -42,6 +43,7 @@ GEM
activerecord (>= 4.0)
addressable (2.5.0)
public_suffix (~> 2.0, >= 2.0.2)
afm (0.2.2)
archive-zip (0.8.0)
io-like (~> 0.3.0)
arel (7.1.4)
Expand Down Expand Up @@ -183,6 +185,7 @@ GEM
guard (~> 2.8)
guard-compat (~> 1.0)
multi_json (~> 1.8)
hashery (2.1.2)
hashie (3.4.6)
high_voltage (3.0.0)
http_parser.rb (0.6.0)
Expand Down Expand Up @@ -248,6 +251,12 @@ GEM
request_store (~> 1.1)
parser (2.3.1.4)
ast (~> 2.2)
pdf-reader (2.0.0)
Ascii85 (~> 1.0.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (0.19.0)
powerpack (0.1.1)
pry (0.10.4)
Expand Down Expand Up @@ -345,6 +354,7 @@ GEM
rubocop-rspec (1.8.0)
rubocop (>= 0.42.0)
ruby-progressbar (1.8.1)
ruby-rc4 (0.1.5)
sass (3.4.22)
sass-rails (5.0.6)
railties (>= 4.0.0, < 6)
Expand Down Expand Up @@ -386,6 +396,7 @@ GEM
thor (0.19.1)
thread_safe (0.3.5)
tilt (2.0.5)
ttfunk (1.5.1)
tzinfo (1.2.2)
thread_safe (~> 0.1)
uglifier (3.0.3)
Expand Down Expand Up @@ -442,6 +453,7 @@ DEPENDENCIES
mini_magick
momentjs-rails
paper_trail
pdf-reader
pg
pry-byebug
puma (~> 3.0)
Expand Down
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
//= require form

//= require components
//= require_tree ./resources

//= require filters
//= require expand
Expand Down
110 changes: 110 additions & 0 deletions app/assets/javascripts/components/file_picker/direct_upload_input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
var DirectUploadInput = React.createClass({
propTypes: {
name: React.PropTypes.string,
id: React.PropTypes.string,
onUploadSuccess: React.PropTypes.func
},

getInitialState: function() {
return {
s3Url: [location.protocol, "//", window.greencommons.S3.bucket, ".s3.amazonaws.com/"].join(''),
errorMesssage: null,
uploadMesssage: null
};
},

buildFormData: function(signature, file){
var formData = new FormData();
formData.append('acl', signature.acl);
formData.append('AWSAccessKeyId', window.greencommons.S3.accessKeyId);
formData.append('success_action_status', 200);
formData.append('key', signature.key);
formData.append('policy', signature.policy);
formData.append('signature', signature.signature);
formData.append('Content-Type', signature.mime_type);
formData.append('file', file);
return formData;
},

requestSignature: function(file, filename){
var url = new URL("/s3_signature", location.origin);
var _this = this;
url.searchParams.append('filename', filename);
return fetch(url).then(function(response){
if (response.ok) {
return response.json();
} else {
_this.setState({
errorMesssage: 'The file was not uploaded correctly. Please try again',
uploadMesssage: null
});
throw new Error(response.statusText);
}
});
},

uploadToS3: function(signature, file){
var formData = this.buildFormData(signature, file);
var _this = this;
var headers = new Headers();
headers.append('Access-Control-Allow-Origin', '*');
return fetch(
this.state.s3Url,
{method: 'POST', body: formData, mode: 'cors', headers: headers}
).then(function(response){
if (response.ok) {
return response;
} else {
_this.setState({
errorMesssage: 'The file was not uploaded correctly. Please try again',
uploadMesssage: null
});
throw new Error(response.statusText);
}
}).then(function(response){
_this.props.onUploadSuccess(file, _this.state.s3Url + signature.key);
});
},

handleChange: function(event) {
var _this = this;
var file = event.target.files[0];
this.setState({errorMesssage: null, uploadMesssage: 'Uploading...'});
_this.requestSignature(file, file.name)
.then(function(json){
return _this.uploadToS3(json.s3_signature, file);
}).then(function(){ _this.setState({uploadMesssage: null}) });
},

displayErrorMessage: function(){
if (this.state.errorMesssage){
return (<div className="alert-warning">{this.state.errorMesssage}</div>)
}
},

displayUploadingMessage: function(){
if (this.state.uploadMesssage){
return (<div className="alert-notice">{this.state.uploadMesssage}</div>)
}
},

render: function() {
return (
<div>
<FileInput
id={this.props.id}
name={this.props.name}
accept={this.props.accept}
placeholder={this.props.placeholder}
onChange={this.handleChange}
className="form-control"
/>
<div>
{this.displayUploadingMessage()}
{this.displayErrorMessage()}
</div>
</div>
);
}

});
75 changes: 75 additions & 0 deletions app/assets/javascripts/components/file_picker/file_input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
var FileInput = React.createClass({
propTypes: {
name: React.PropTypes.string,
id: React.PropTypes.string
},

randomString: function(){
return Math.random().toString(36);
},

getInitialState: function() {
return {
value: '',
styles: {
parent: {
position: 'relative'
},
file: {
position: 'absolute',
top: 0,
left: 0,
opacity: 0,
width: '100%',
zIndex: 1
},
text: {
zIndex: -1
}
}
};
},

handleChange: function(e) {
this.setState({
value: e.target.value.split(/(\\|\/)/g).pop()
});
if (this.props.onChange) this.props.onChange(e);
},

reset: function(){
this.setState({value: '', fileInputKey: this.randomString()})
},

render: function() {
return React.DOM.div({
style: this.state.styles.parent
},

// Actual file input
React.DOM.input({
type: 'file',
id: this.props.id,
name: this.props.name,
className: this.props.className,
onChange: this.handleChange,
disabled: this.props.disabled,
accept: this.props.accept,
style: this.state.styles.file,
key: (this.state.theInputKey || '')
}),

// Emulated file input
React.DOM.input({
type: 'text',
tabIndex: -1,
name: '_filename',
value: this.state.value,
className: this.props.className,
onChange: function() {},
placeholder: this.props.placeholder,
disabled: this.props.disabled,
style: this.state.styles.text
}));
}
});
41 changes: 41 additions & 0 deletions app/assets/javascripts/components/file_picker/pdf_file_input.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
var PdfFileInput = React.createClass({
propTypes: {
name: React.PropTypes.string,
id: React.PropTypes.string,
onUploadSuccess: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.func,
])
},

getInitialState: function() {
return {};
},

_wrapOnUploadSuccess: function(file, fileUrl){
if(typeof this.props.onUploadSuccess === 'string'){
window[this.props.onUploadSuccess](file, fileUrl)
}else{
this.props.onUploadSuccess(file, fileUrl)
}
},


handleOnUploadSuccess: function(file, fileUrl) {
this._wrapOnUploadSuccess(file, fileUrl);
},


render: function() {
return (
<DirectUploadInput
id={this.props.id}
name={this.props.name}
accept=".pdf"
placeholder="Select a PDF file."
onUploadSuccess={this.handleOnUploadSuccess}
/>
);
}

});
4 changes: 4 additions & 0 deletions app/assets/javascripts/config.js.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@ window.greencommons = {
hypothesis: {
apiUrl: 'https://hypothes.is/api',
key: '<%= ENV.fetch('HYPOTHESIS_API_KEY')%>'
},
S3: {
accessKeyId: '<%= ENV.fetch('AWS_ACCESS_KEY_ID')%>',
bucket: '<%= ENV.fetch('AWS_UPLOADS_BUCKET') %>'
}
};
4 changes: 4 additions & 0 deletions app/assets/javascripts/resources/form.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
window.processResourceFile = function(file, fileUrl){
$('input#resource_url').val(fileUrl);
$('input#resource_content_download_link').val(fileUrl);
}
41 changes: 32 additions & 9 deletions app/controllers/resources_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,34 @@ def show
end

def new
@resource = Resource.new
@resource = Resource.new(resource_type: :url)
authorize @resource
load_resource_types
end

def edit
load_resource_types
end

def create
@resource = Resource.new(resource_params)
@resource = Resource.new(resource_params_for_create)
@resource.assign_attributes(user: current_user, user_input: true)
authorize @resource

@resource.resource_type = :url
@resource.user = current_user

if @resource.save
redirect_to @resource, notice: 'Resource was successfully created.'
redirect_to edit_resource_path(@resource), notice: resource_created_message
else
load_resource_types
render :new
end
end

def update
if @resource.update(resource_params)
@resource.assign_attributes(user_input: true)
if @resource.update(resource_params_for_update)
redirect_to @resource, notice: 'Resource was successfully updated.'
else
load_resource_types
render :edit
end
end
Expand All @@ -54,7 +57,27 @@ def set_resource
authorize @resource
end

def resource_params
params.require(:resource).permit(:title, :url)
def resource_params_for_create
params.require(:resource).
permit(:title, :url, :resource_type, :short_content, :privacy, :content_download_link)
end

def resource_params_for_update
params.require(:resource).
permit(:title, :resource_type, :short_content, :privacy, :creators,
:publisher, :date, :publisher, :rights, :pages, :isbn, :tag_list)
end

def load_resource_types
@resource_types = (Resource::RESOURCE_TYPES.keys - %w(audio video image url)).sort
end

def resource_created_message
'Your resource has been created! Now,'\
" add some metadata, or go straight to the #{link_to_resource}."
end

def link_to_resource
"<a href=\"#{resource_path(@resource)}\">resource page</a>"
end
end
Loading

0 comments on commit 37e35dd

Please sign in to comment.