browse-everything 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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