browse-everything 0.15.1 → 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +61 -9
  3. data/.rubocop_todo.yml +2 -15
  4. data/.travis.yml +19 -19
  5. data/CONTRIBUTING.md +6 -6
  6. data/Gemfile +12 -8
  7. data/README.md +30 -0
  8. data/Rakefile +2 -1
  9. data/app/assets/javascripts/browse_everything/behavior.js.coffee +5 -0
  10. data/app/controllers/browse_everything_controller.rb +75 -23
  11. data/app/helpers/browse_everything_helper.rb +2 -8
  12. data/app/helpers/font_awesome_version_helper.rb +9 -8
  13. data/app/services/browse_everything_session.rb +10 -0
  14. data/app/services/browse_everything_session/provider_session.rb +42 -0
  15. data/app/services/browser_factory.rb +25 -0
  16. data/app/views/browse_everything/_files.html.erb +56 -6
  17. data/browse-everything.gemspec +29 -25
  18. data/config/routes.rb +7 -2
  19. data/lib/browse-everything.rb +2 -0
  20. data/lib/browse_everything.rb +45 -12
  21. data/lib/browse_everything/auth/google/credentials.rb +28 -0
  22. data/lib/browse_everything/auth/google/request_parameters.rb +61 -0
  23. data/lib/browse_everything/browser.rb +11 -4
  24. data/lib/browse_everything/driver/authentication_factory.rb +22 -0
  25. data/lib/browse_everything/driver/base.rb +72 -19
  26. data/lib/browse_everything/driver/box.rb +46 -17
  27. data/lib/browse_everything/driver/dropbox.rb +36 -10
  28. data/lib/browse_everything/driver/file_system.rb +14 -26
  29. data/lib/browse_everything/driver/google_drive.rb +187 -54
  30. data/lib/browse_everything/driver/s3.rb +81 -75
  31. data/lib/browse_everything/engine.rb +3 -2
  32. data/lib/browse_everything/file_entry.rb +3 -1
  33. data/lib/browse_everything/retriever.rb +103 -31
  34. data/lib/browse_everything/version.rb +3 -1
  35. data/lib/generators/browse_everything/assets_generator.rb +3 -2
  36. data/lib/generators/browse_everything/config_generator.rb +11 -9
  37. data/lib/generators/browse_everything/install_generator.rb +3 -2
  38. data/lib/generators/browse_everything/templates/browse_everything_providers.yml.example +12 -11
  39. data/spec/controllers/browse_everything_controller_spec.rb +80 -0
  40. data/spec/features/select_files_spec.rb +13 -13
  41. data/spec/features/test_compiling_stylesheets_spec.rb +2 -0
  42. data/spec/fixtures/vcr_cassettes/google_drive.yml +331 -0
  43. data/spec/fixtures/vcr_cassettes/retriever.yml +93 -0
  44. data/spec/helper/browse_everything_controller_helper_spec.rb +21 -7
  45. data/spec/javascripts/jasmine_spec.rb +2 -0
  46. data/spec/javascripts/support/jasmine_helper.rb +1 -0
  47. data/spec/lib/browse_everything/auth/google/credentials_spec.rb +41 -0
  48. data/spec/{unit → lib/browse_everything}/browse_everything_helper_spec.rb +2 -0
  49. data/spec/lib/browse_everything/browser_spec.rb +109 -0
  50. data/spec/{unit → lib/browse_everything/driver}/base_spec.rb +5 -4
  51. data/spec/{unit → lib/browse_everything/driver}/box_spec.rb +20 -5
  52. data/spec/{unit → lib/browse_everything/driver}/dropbox_spec.rb +15 -18
  53. data/spec/{unit → lib/browse_everything/driver}/file_system_spec.rb +32 -26
  54. data/spec/lib/browse_everything/driver/google_drive_spec.rb +171 -0
  55. data/spec/{unit → lib/browse_everything/driver}/s3_spec.rb +38 -21
  56. data/spec/lib/browse_everything/driver_spec.rb +38 -0
  57. data/spec/{unit → lib/browse_everything}/file_entry_spec.rb +4 -1
  58. data/spec/lib/browse_everything/retriever_spec.rb +200 -0
  59. data/spec/lib/browse_everything_spec.rb +67 -0
  60. data/spec/services/browse_everything_session/provider_session_spec.rb +50 -0
  61. data/spec/services/browser_factory_spec.rb +40 -0
  62. data/spec/spec_helper.rb +39 -18
  63. data/spec/support/app/controllers/file_handler_controller.rb +4 -4
  64. data/spec/support/app/views/file_handler/main.html.erb +1 -1
  65. data/spec/support/capybara.rb +17 -0
  66. data/spec/support/rake.rb +3 -1
  67. data/spec/support/wait_for_ajax.rb +14 -0
  68. data/spec/test_app_templates/Gemfile.extra +1 -0
  69. data/spec/test_app_templates/lib/generators/test_app_generator.rb +10 -4
  70. data/spec/views/browse_everything/{_file.html.erb_spec.rb → _files.html.erb_spec.rb} +24 -18
  71. data/tasks/ci.rake +2 -0
  72. metadata +159 -107
  73. data/app/views/browse_everything/_file.html.erb +0 -52
  74. data/app/views/browse_everything/resolve.html.erb +0 -1
  75. data/spec/unit/browser_spec.rb +0 -76
  76. data/spec/unit/retriever_spec.rb +0 -109
@@ -1,44 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'google/apis/drive_v3'
4
+ require 'googleauth'
5
+ require 'googleauth/stores/file_token_store'
6
+ require_relative 'authentication_factory'
7
+
1
8
  module BrowseEverything
2
9
  module Driver
3
10
  class GoogleDrive < Base
4
- require 'google/apis/drive_v3'
5
- require 'signet'
11
+ class << self
12
+ attr_accessor :authentication_klass
13
+
14
+ def default_authentication_klass
15
+ Google::Auth::UserAuthorizer
16
+ end
17
+ end
18
+
19
+ attr_reader :credentials
20
+
21
+ # Constructor
22
+ # @param config_values [Hash] configuration for the driver
23
+ def initialize(config_values)
24
+ self.class.authentication_klass ||= self.class.default_authentication_klass
25
+ super(config_values)
26
+ end
27
+
28
+ # The token here must be set using a Hash
29
+ # @param value [String, Hash] the new access token
30
+ def token=(value)
31
+ # This is invoked within BrowseEverythingController using a Hash
32
+ value = value.fetch('access_token') if value.is_a? Hash
33
+
34
+ # Restore the credentials if the access token string itself has been cached
35
+ restore_credentials(value) if @credentials.nil?
36
+
37
+ super(value)
38
+ end
6
39
 
7
40
  def icon
8
41
  'google-plus-sign'
9
42
  end
10
43
 
44
+ # Validates the configuration for the Google Drive provider
11
45
  def validate_config
12
- unless config[:client_id]
13
- raise BrowseEverything::InitializationError, 'GoogleDrive driver requires a :client_id argument'
14
- end
15
- unless config[:client_secret]
16
- raise BrowseEverything::InitializationError, 'GoogleDrive driver requires a :client_secret argument'
17
- end
18
- end
19
-
20
- def contents(path = '')
21
- return to_enum(:contents, path) unless block_given?
22
- default_params = {
23
- order_by: 'folder,modifiedTime desc,name',
24
- fields: 'nextPageToken,files(name,id,mimeType,size,modifiedTime,parents,web_content_link)'
25
- # page_size: 100
26
- }
27
- page_token = nil
28
- begin
29
- default_params[:q] = "'#{path}' in parents" unless path.blank?
30
- default_params[:page_token] = page_token unless page_token.blank?
31
- response = drive.list_files(default_params)
32
- page_token = response.next_page_token
33
- response.files.select do |file|
34
- path.blank? ? (file.parents.blank? || file.parents.any? { |p| p == 'root' }) : true
35
- end.each do |file|
36
- d = details(file, path)
37
- yield d if d
38
- end
39
- end while !page_token.blank?
46
+ raise InitializationError, 'GoogleDrive driver requires a :client_id argument' unless config[:client_id]
47
+ raise InitializationError, 'GoogleDrive driver requires a :client_secret argument' unless config[:client_secret]
40
48
  end
41
49
 
50
+ # Retrieve the file details
51
+ # @param file [Google::Apis::DriveV3::File] the Google Drive File
52
+ # @param path [String] path for the resource details (unused)
53
+ # @return [BrowseEverything::FileEntry] file entry for the resource node
42
54
  def details(file, _path = '')
43
55
  mime_folder = file.mime_type == 'application/vnd.google-apps.folder'
44
56
  BrowseEverything::FileEntry.new(
@@ -46,60 +58,181 @@ module BrowseEverything
46
58
  "#{key}:#{file.id}",
47
59
  file.name,
48
60
  file.size.to_i,
49
- file.modified_time || DateTime.new,
61
+ file.modified_time || Time.new,
50
62
  mime_folder,
51
63
  mime_folder ? 'directory' : file.mime_type
52
64
  )
53
65
  end
54
66
 
67
+ # Lists the files given a Google Drive context
68
+ # @param drive [Google::Apis::DriveV3::DriveService] the Google Drive context
69
+ # @param request_params [RequestParameters] the object containing the parameters for the Google Drive API request
70
+ # @param path [String] the path (default to the root)
71
+ # @return [Array<BrowseEverything::FileEntry>] file entries for the path
72
+ def list_files(drive, request_params, path: '')
73
+ drive.list_files(request_params.to_h) do |file_list, error|
74
+ # Raise an exception if there was an error Google API's
75
+ if error.present?
76
+ # In order to properly trigger reauthentication, the token must be cleared
77
+ # Additionally, the error is not automatically raised from the Google Client
78
+ @token = nil
79
+ raise error
80
+ end
81
+
82
+ @entries += file_list.files.map do |gdrive_file|
83
+ details(gdrive_file, path)
84
+ end
85
+
86
+ request_params.page_token = file_list.next_page_token
87
+ end
88
+
89
+ @entries += list_files(drive, request_params, path: path) if request_params.page_token.present?
90
+ end
91
+
92
+ # Retrieve the files for any given resource on Google Drive
93
+ # @param path [String] the root or Folder path for which to list contents
94
+ # @return [Array<BrowseEverything::FileEntry>] file entries for the path
95
+ def contents(path = '')
96
+ @entries = []
97
+ drive_service.batch do |drive|
98
+ request_params = Auth::Google::RequestParameters.new
99
+ request_params.q += " and '#{path}' in parents " if path.present?
100
+ list_files(drive, request_params, path: path)
101
+ end
102
+
103
+ @sorter.call(@entries)
104
+ end
105
+
106
+ # Retrieve a link for a resource
107
+ # @param id [String] identifier for the resource
108
+ # @return [Array<String, Hash>] authorized link to the resource
55
109
  def link_for(id)
56
- file = drive.get_file(id)
57
- auth_header = { 'Authorization' => "Bearer #{auth_client.access_token}" }
110
+ file = drive_service.get_file(id, fields: 'id, name, size')
111
+ auth_header = { 'Authorization' => "Bearer #{credentials.access_token}" }
58
112
  extras = {
59
113
  auth_header: auth_header,
60
114
  expires: 1.hour.from_now,
61
115
  file_name: file.name,
62
116
  file_size: file.size.to_i
63
117
  }
64
- [file.web_content_link, extras]
118
+ [download_url(id), extras]
65
119
  end
66
120
 
121
+ # Provides a URL for authorizing against Google Drive
122
+ # @return [String] the URL
67
123
  def auth_link
68
- auth_client.authorization_uri
124
+ Addressable::URI.parse(authorizer.get_authorization_url)
69
125
  end
70
126
 
127
+ # Whether or not the current provider is authorized
128
+ # @return [true,false]
71
129
  def authorized?
72
- token.present?
130
+ @token.present?
131
+ end
132
+
133
+ # Client ID for authorizing against the Google API's
134
+ # @return [Google::Auth::ClientId]
135
+ def client_id
136
+ @client_id ||= Google::Auth::ClientId.from_hash(client_secrets)
137
+ end
138
+
139
+ # Token store file used for authorizing against the Google API's
140
+ # (This is fundamentally used to temporarily cache access tokens)
141
+ # @return [Google::Auth::Stores::FileTokenStore]
142
+ def token_store
143
+ Google::Auth::Stores::FileTokenStore.new(file: file_token_store_path)
144
+ end
145
+
146
+ def session
147
+ AuthenticationFactory.new(
148
+ self.class.authentication_klass,
149
+ client_id,
150
+ scope,
151
+ token_store,
152
+ callback
153
+ )
73
154
  end
74
155
 
156
+ delegate :authenticate, to: :session
157
+
158
+ # Authorization Object for Google API
159
+ # @return [Google::Auth::UserAuthorizer]
160
+ def authorizer
161
+ @authorizer ||= authenticate
162
+ end
163
+
164
+ # Request to authorize the provider
165
+ # This is *the* method which, passing an HTTP request, redeems an authorization code for an access token
166
+ # @return [String] a new access token
167
+ def authorize!
168
+ @credentials = authorizer.get_credentials_from_code(user_id: user_id, code: code)
169
+ @token = @credentials.access_token
170
+ @code = nil # The authorization code can only be redeemed for an access token once
171
+ @token
172
+ end
173
+
174
+ # This is the method accessed by the BrowseEverythingController for authorizing using an authorization code
175
+ # @param params [Hash] HTTP response passed to the OAuth callback
176
+ # @param _data [Object,nil] an unused parameter
177
+ # @return [String] a new access token
75
178
  def connect(params, _data)
76
- auth_client.code = params[:code]
77
- self.token = auth_client.fetch_access_token!
179
+ @code = params[:code]
180
+ authorize!
78
181
  end
79
182
 
80
- def drive
81
- @drive ||= Google::Apis::DriveV3::DriveService.new.tap do |s|
82
- s.authorization = authorization
183
+ # Construct a new object for interfacing with the Google Drive API
184
+ # @return [Google::Apis::DriveV3::DriveService]
185
+ def drive_service
186
+ Google::Apis::DriveV3::DriveService.new.tap do |s|
187
+ s.authorization = credentials
83
188
  end
84
189
  end
85
190
 
86
191
  private
87
192
 
88
- def authorization
89
- return @auth_client unless @auth_client.nil?
90
- return nil unless token.present?
91
- auth_client.update_token!(token)
92
- self.token = auth_client.fetch_access_token! if auth_client.expired?
93
- auth_client
193
+ def client_secrets
194
+ {
195
+ Google::Auth::ClientId::WEB_APP => {
196
+ Google::Auth::ClientId::CLIENT_ID => config[:client_id],
197
+ Google::Auth::ClientId::CLIENT_SECRET => config[:client_secret]
198
+ }
199
+ }
200
+ end
201
+
202
+ # This is required for using the googleauth Gem
203
+ # @see http://www.rubydoc.info/gems/googleauth/Google/Auth/Stores/FileTokenStore FileTokenStore for googleauth
204
+ # @return [Tempfile] temporary file within which to cache credentials
205
+ def file_token_store_path
206
+ Tempfile.new('gdrive.yaml')
207
+ end
208
+
209
+ def scope
210
+ Google::Apis::DriveV3::AUTH_DRIVE
211
+ end
212
+
213
+ # Provides the user ID for caching access tokens
214
+ # (This is a hack which attempts to anonymize the access tokens)
215
+ # @return [String] the ID for the user
216
+ def user_id
217
+ 'current_user'
218
+ end
219
+
220
+ # Please see https://developers.google.com/drive/v3/web/manage-downloads
221
+ # @param id [String] the ID for the Google Drive File
222
+ # @return [String] the URL for the file download
223
+ def download_url(id)
224
+ "https://www.googleapis.com/drive/v3/files/#{id}?alt=media"
94
225
  end
95
226
 
96
- def auth_client
97
- @auth_client ||= Signet::OAuth2::Client.new token_credential_uri: 'https://www.googleapis.com/oauth2/v3/token',
98
- authorization_uri: 'https://accounts.google.com/o/oauth2/auth',
99
- scope: 'https://www.googleapis.com/auth/drive',
100
- client_id: config[:client_id],
101
- client_secret: config[:client_secret],
102
- redirect_uri: callback
227
+ # Restore the credentials for the Google API
228
+ # @param access_token [String] the access token redeemed using an authorization code
229
+ # @return Credentials credentials restored from a cached access token
230
+ def restore_credentials(access_token)
231
+ client = Auth::Google::Credentials.new
232
+ client.client_id = client_id.id
233
+ client.client_secret = client_id.secret
234
+ client.update_token!('access_token' => access_token)
235
+ @credentials = client
103
236
  end
104
237
  end
105
238
  end
@@ -1,11 +1,22 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'aws-sdk-s3'
4
+ require_relative 'authentication_factory'
2
5
 
3
6
  module BrowseEverything
4
7
  module Driver
5
8
  class S3 < Base
6
- DEFAULTS = { response_type: :signed_url }.freeze
7
- RESPONSE_TYPES = [:signed_url, :public_url, :s3_uri].freeze
8
- CONFIG_KEYS = [:bucket].freeze
9
+ DEFAULTS = { response_type: :signed_url, expires_in: 14400 }.freeze
10
+ RESPONSE_TYPES = %i[signed_url public_url s3_uri].freeze
11
+ CONFIG_KEYS = %i[bucket].freeze
12
+
13
+ class << self
14
+ attr_accessor :authentication_klass
15
+
16
+ def default_authentication_klass
17
+ Aws::S3::Client
18
+ end
19
+ end
9
20
 
10
21
  attr_reader :entries
11
22
 
@@ -14,8 +25,9 @@ module BrowseEverything
14
25
  warn '[DEPRECATION] Amazon S3 driver: `:signed_url` is deprecated. Please use `:response_type` instead.'
15
26
  config[:response_type] = :public_url
16
27
  end
17
- config = DEFAULTS.merge(config)
18
- super
28
+ merged_config = DEFAULTS.merge(config)
29
+ self.class.authentication_klass ||= self.class.default_authentication_klass
30
+ super(merged_config, *args)
19
31
  end
20
32
 
21
33
  def icon
@@ -23,85 +35,36 @@ module BrowseEverything
23
35
  end
24
36
 
25
37
  def validate_config
26
- if config.values_at(:app_key, :app_secret).compact.length == 1
27
- raise BrowseEverything::InitializationError, 'Amazon S3 driver: If either :app_key or :app_secret is provided, both must be.'
28
- end
29
- unless RESPONSE_TYPES.include?(config[:response_type].to_sym)
30
- raise BrowseEverything::InitializationError, "Amazon S3 driver: Valid response types: #{RESPONSE_TYPES.join(',')}"
31
- end
38
+ raise InitializationError, 'Amazon S3 driver: If either :app_key or :app_secret is provided, both must be.' if config.values_at(:app_key, :app_secret).compact.length == 1
39
+ raise InitializationError, "Amazon S3 driver: Valid response types: #{RESPONSE_TYPES.join(',')}" unless RESPONSE_TYPES.include?(config[:response_type].to_sym)
32
40
  return if CONFIG_KEYS.all? { |key| config[key].present? }
33
- raise BrowseEverything::InitializationError, "Amazon S3 driver requires #{CONFIG_KEYS.join(',')}"
41
+ raise InitializationError, "Amazon S3 driver requires #{CONFIG_KEYS.join(',')}"
34
42
  end
35
43
 
44
+ # Retrieve the entries from the S3 Bucket
36
45
  # @return [Array<BrowseEverything::FileEntry>]
37
- # Appends / to the path before querying S3
38
46
  def contents(path = '')
39
47
  path = File.join(path, '') unless path.empty?
40
48
  init_entries(path)
41
49
  generate_listing(path)
42
- sort_entries
43
- end
44
-
45
- def generate_listing(path)
46
- listing = client.list_objects(bucket: config[:bucket], delimiter: '/', prefix: full_path(path))
47
- add_directories(listing)
48
- add_files(listing, path)
49
- end
50
-
51
- def add_directories(listing)
52
- listing.common_prefixes.each do |prefix|
53
- entries << entry_for(from_base(prefix.prefix), 0, Time.current, true)
54
- end
50
+ @sorter.call(@entries)
55
51
  end
56
52
 
57
- def add_files(listing, path)
58
- listing.contents.each do |entry|
59
- key = from_base(entry.key)
60
- unless strip(key) == strip(path)
61
- entries << entry_for(key, entry.size, entry.last_modified, false)
62
- end
63
- end
64
- end
65
-
66
- def sort_entries
67
- entries.sort do |a, b|
68
- if b.container?
69
- a.container? ? a.name.downcase <=> b.name.downcase : 1
70
- else
71
- a.container? ? -1 : a.name.downcase <=> b.name.downcase
72
- end
73
- end
74
- end
75
-
76
- def init_entries(path)
77
- @entries = if path.empty?
78
- []
79
- else
80
- [BrowseEverything::FileEntry.new(Pathname(path).join('..').to_s, '', '..',
81
- 0, Time.current, true)]
82
- end
83
- end
53
+ def link_for(path)
54
+ obj = bucket.object(full_path(path))
84
55
 
85
- def entry_for(name, size, date, dir)
86
- BrowseEverything::FileEntry.new(name, [key, name].join(':'), File.basename(name), size, date, dir)
87
- end
56
+ extras = {
57
+ file_name: File.basename(path),
58
+ expires: (config[:expires_in] if config[:response_type] == :signed_url)
59
+ }.compact
88
60
 
89
- def details(path)
90
- entry = client.head_object(full_path(path))
91
- BrowseEverything::FileEntry.new(
92
- entry.key, [key, entry.key].join(':'),
93
- File.basename(entry.key), entry.size,
94
- entry.last_modified, false
95
- )
96
- end
61
+ url = case config[:response_type].to_sym
62
+ when :signed_url then obj.presigned_url(:get, expires_in: config[:expires_in])
63
+ when :public_url then obj.public_url
64
+ when :s3_uri then "s3://#{obj.bucket_name}/#{obj.key}"
65
+ end
97
66
 
98
- def link_for(path)
99
- obj = bucket.object(full_path(path))
100
- case config[:response_type].to_sym
101
- when :signed_url then obj.presigned_url(:get, expires_in: 14400)
102
- when :public_url then obj.public_url
103
- when :s3_uri then "s3://#{obj.bucket_name}/#{obj.key}"
104
- end
67
+ [url, extras]
105
68
  end
106
69
 
107
70
  def authorized?
@@ -112,10 +75,6 @@ module BrowseEverything
112
75
  @bucket ||= Aws::S3::Bucket.new(config[:bucket], client: client)
113
76
  end
114
77
 
115
- def client
116
- @client ||= Aws::S3::Client.new(aws_config)
117
- end
118
-
119
78
  private
120
79
 
121
80
  def strip(path)
@@ -136,6 +95,53 @@ module BrowseEverything
136
95
  result[:region] = config[:region] if config.key?(:region)
137
96
  result
138
97
  end
98
+
99
+ def session
100
+ AuthenticationFactory.new(
101
+ self.class.authentication_klass,
102
+ aws_config
103
+ )
104
+ end
105
+
106
+ def authenticate
107
+ session.authenticate
108
+ end
109
+
110
+ def client
111
+ @client ||= authenticate
112
+ end
113
+
114
+ def init_entries(path)
115
+ @entries = if path.empty?
116
+ []
117
+ else
118
+ [BrowseEverything::FileEntry.new(Pathname(path).join('..').to_s, '', '..',
119
+ 0, Time.current, true)]
120
+ end
121
+ end
122
+
123
+ def entry_for(name, size, date, dir)
124
+ BrowseEverything::FileEntry.new(name, [key, name].join(':'), File.basename(name), size, date, dir)
125
+ end
126
+
127
+ def add_directories(listing)
128
+ listing.common_prefixes.each do |prefix|
129
+ @entries << entry_for(from_base(prefix.prefix), 0, Time.current, true)
130
+ end
131
+ end
132
+
133
+ def add_files(listing, path)
134
+ listing.contents.each do |entry|
135
+ key = from_base(entry.key)
136
+ @entries << entry_for(key, entry.size, entry.last_modified, false) unless strip(key) == strip(path)
137
+ end
138
+ end
139
+
140
+ def generate_listing(path)
141
+ listing = client.list_objects(bucket: config[:bucket], delimiter: '/', prefix: full_path(path))
142
+ add_directories(listing)
143
+ add_files(listing, path)
144
+ end
139
145
  end
140
146
  end
141
147
  end