clouddrive 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +73 -0
- data/Rakefile +1 -0
- data/bin/clouddrive +151 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/clouddrive.gemspec +36 -0
- data/lib/clouddrive.rb +7 -0
- data/lib/clouddrive/account.rb +307 -0
- data/lib/clouddrive/node.rb +329 -0
- data/lib/clouddrive/version.rb +3 -0
- metadata +133 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 8d64073347f2f8b488312ea58fad514dba3c1cb6
|
4
|
+
data.tar.gz: 8395374122e477a41de9987861a7f1fc4b013329
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 8781961a4a644e9839084f17e6f234326ab00548864c3fb7dc67c824f49f069c6bc570fd82bc20350e15b2847e8add9f23b70f6095688a219bfa6cee48b04ba7
|
7
|
+
data.tar.gz: 3ea157a6089a9abf9310addd4d027343e00ab7bbf000fcc0e415301fadaf4705074386b6574fa90af4c824fe44741009a3f0f7ce4c1f5e57b49188e931a6f697
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# Contributor Code of Conduct
|
2
|
+
|
3
|
+
As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
|
4
|
+
|
5
|
+
We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, age, or religion.
|
6
|
+
|
7
|
+
Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
|
8
|
+
|
9
|
+
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
|
10
|
+
|
11
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
|
12
|
+
|
13
|
+
This Code of Conduct is adapted from the [Contributor Covenant](http:contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 TODO: Write your name
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
# CloudDrive SDK and CLI
|
2
|
+
|
3
|
+
This i a Ruby project built to interact with Amazon's CloudDrive API. It works as both an SDK and a CLI in the sense that I've built the code to easily be implemented in your own projects but it also includes an executable to run many common processes right from the command line.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'clouddrive'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install clouddrive
|
20
|
+
|
21
|
+
## CLI Usage
|
22
|
+
|
23
|
+
The CLI is used by running `clouddrive` with one of the following commands followed by any necessary arguments (use `help` argument before any of the following commands for more information).
|
24
|
+
|
25
|
+
```bash
|
26
|
+
init Initialize the CLI with your Amazon email and CloudDrive API credentials
|
27
|
+
sync Sync the local cache with Amazon CloudDrive
|
28
|
+
clearcache Clear the local cache
|
29
|
+
metadata Output JSON-formatted metadata related to the remote file give its remote path
|
30
|
+
```
|
31
|
+
|
32
|
+
## SDK Usage
|
33
|
+
|
34
|
+
### Account
|
35
|
+
|
36
|
+
#### Initialization
|
37
|
+
|
38
|
+
The CloudDrive SDK first needs have an authenticated `Account` object which can then be passed into the different classes for API calls.
|
39
|
+
|
40
|
+
The `Account` class is created by passing in the `email`, `client_id`, and `client_secret` into the constructor and calling the `authorize` method. This will handle authorizing and (if necessary) renewing authorization.
|
41
|
+
|
42
|
+
The initial `authorize` method call will return false with an `auth_url` key in its `data`. This URL can then be passed into a second `authorize` call which will parse out the `code` parameter and complete the initial OAuth process.
|
43
|
+
|
44
|
+
```ruby
|
45
|
+
account = CloudDrive::Account.new("me@example.com", "my-client-id", "clientsecret")
|
46
|
+
account.authorize
|
47
|
+
...
|
48
|
+
account.authorize(auth_url)
|
49
|
+
```
|
50
|
+
|
51
|
+
The `authorize` method call will still need to be called periodically to renew its authorization as the OAuth token expires every 60 minutes.
|
52
|
+
|
53
|
+
#### Local Cache
|
54
|
+
|
55
|
+
By default, the SDK stores all necessary information (OAuth token information, local account caches, etc) into `~/.clouddrive`. Each account (email), has its own cache file and local cache database of its remote filesystem. Once authenticated, the local cache is initially synced with the remote CloudDrive by calling the `sync` method.
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
account.sync
|
59
|
+
```
|
60
|
+
|
61
|
+
Every time `sync` is called, it will update the local cache with all changes since the last `sync` call. The local cache can be cleared out and reset by calling `clear_cache`.
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
account.clear_cache
|
65
|
+
```
|
66
|
+
|
67
|
+
## Contributing
|
68
|
+
|
69
|
+
1. Fork it ( https://github.com/[my-github-username]/clouddrive/fork )
|
70
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
71
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
72
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
73
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/clouddrive
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'thor'
|
2
|
+
require 'clouddrive'
|
3
|
+
require 'yaml'
|
4
|
+
require 'json'
|
5
|
+
|
6
|
+
module CloudDrive
|
7
|
+
class CLI < Thor
|
8
|
+
|
9
|
+
def initialize(*args)
|
10
|
+
super
|
11
|
+
@config_path = File.expand_path('~/.clouddrive') + "/"
|
12
|
+
end
|
13
|
+
|
14
|
+
desc "init", "Initialize app with Amazon e-mail and CloudDrive credentials"
|
15
|
+
option :email, :aliases => :e
|
16
|
+
option :client_id, :aliases => :i
|
17
|
+
option :client_secret, :aliases => :s
|
18
|
+
option :auth_url, :aliases => :u
|
19
|
+
def init
|
20
|
+
email = options[:email]
|
21
|
+
client_id = options[:client_id]
|
22
|
+
client_secret = options[:client_secret]
|
23
|
+
|
24
|
+
config = read_config
|
25
|
+
|
26
|
+
if email != nil
|
27
|
+
config[:email] = email
|
28
|
+
end
|
29
|
+
|
30
|
+
if client_id != nil
|
31
|
+
config[:client_id] = client_id
|
32
|
+
end
|
33
|
+
|
34
|
+
if client_secret != nil
|
35
|
+
config[:client_secret] = client_secret
|
36
|
+
end
|
37
|
+
|
38
|
+
if config[:email] == nil
|
39
|
+
puts "Email is required for authorization"
|
40
|
+
exit
|
41
|
+
end
|
42
|
+
|
43
|
+
if config[:client_id] == nil || config[:client_secret] == nil
|
44
|
+
puts "Amazon CloudDrive API credentials required"
|
45
|
+
exit
|
46
|
+
end
|
47
|
+
|
48
|
+
save_config(config)
|
49
|
+
|
50
|
+
account = CloudDrive::Account.new(config[:email], config[:client_id], config[:client_secret])
|
51
|
+
result = account.authorize
|
52
|
+
if result[:success] === false
|
53
|
+
if result[:data]["message"] == "Initial authorization required."
|
54
|
+
puts "Initiali authorization required. Navigate to the following URL and paste in the redirect URL here."
|
55
|
+
url = ask(result[:data]["auth_url"] + "\n")
|
56
|
+
result = account.authorize(url)
|
57
|
+
if result[:success]
|
58
|
+
puts "Successfully authenticated with Amazon CloudDrive"
|
59
|
+
else
|
60
|
+
puts "Failed to authenticate with Amazon CloudDrive: #{result[:data].to_json}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
else
|
64
|
+
puts "Already authenticated with Amazon CloudDrive"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
desc "sync", "Sync local cache with Amazon CloudDrive"
|
69
|
+
long_desc <<'LONGDESC'
|
70
|
+
Syncing the nodes from Amazon CloudDrive to a local database allows for quicker
|
71
|
+
API calls as well as the ability for some operations to be performed 'offline'.
|
72
|
+
Before running any commands that alter data remotely, the local cache should
|
73
|
+
always be synced up.
|
74
|
+
LONGDESC
|
75
|
+
def sync
|
76
|
+
setup
|
77
|
+
@account.authorize
|
78
|
+
@account.sync
|
79
|
+
end
|
80
|
+
|
81
|
+
desc "clearcache", "Clear local nodes cache"
|
82
|
+
def clearcache
|
83
|
+
setup
|
84
|
+
@account.authorize
|
85
|
+
@account.clear_cache
|
86
|
+
end
|
87
|
+
|
88
|
+
desc 'metadata REMOTE_PATH', 'Retrieve the node\'s metadata given its remote path'
|
89
|
+
def metadata(path)
|
90
|
+
setup
|
91
|
+
@account.authorize
|
92
|
+
api = CloudDrive::Node.new(@account)
|
93
|
+
|
94
|
+
if (node = api.find_by_path(path)) != nil
|
95
|
+
puts node.to_json
|
96
|
+
else
|
97
|
+
puts "File does not exist."
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
desc "mkdir REMOTE_PATH", "Create (recursively) a new remote directory"
|
102
|
+
def mkdir(path)
|
103
|
+
setup
|
104
|
+
@account.authorize
|
105
|
+
node = CloudDrive::Node.new(@account)
|
106
|
+
|
107
|
+
result = node.create_directory_path(path)
|
108
|
+
if result[:success]
|
109
|
+
puts "Successfully created new directory path: #{result[:data].to_json}"
|
110
|
+
else
|
111
|
+
puts "Failed to create new directory path: #{result[:data].to_json}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
private
|
116
|
+
|
117
|
+
def setup
|
118
|
+
config = read_config
|
119
|
+
@account = CloudDrive::Account.new(config[:email], config[:client_id], config[:client_secret])
|
120
|
+
end
|
121
|
+
|
122
|
+
def read_config
|
123
|
+
if File.exists?("#{@config_path}config.yaml")
|
124
|
+
return YAML.load_file("#{@config_path}config.yaml")
|
125
|
+
else
|
126
|
+
if !File.exists?(@config_path)
|
127
|
+
Dir.mkdir(@config_path)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
{
|
132
|
+
:email => nil,
|
133
|
+
:client_id => nil,
|
134
|
+
:client_secret => nil
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
def save_config config
|
139
|
+
if !File.exists?(@config_path)
|
140
|
+
Dir.mkdir(@config_path)
|
141
|
+
end
|
142
|
+
|
143
|
+
File.open("#{@config_path}config.yaml", 'w') do |file|
|
144
|
+
file.write(config.to_yaml)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
CloudDrive::CLI.start( ARGV )
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "clouddrive"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/clouddrive.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'clouddrive/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "clouddrive"
|
8
|
+
spec.version = CloudDrive::VERSION
|
9
|
+
spec.authors = ["Alex Phillips"]
|
10
|
+
spec.email = ["exonintrendo@gmail.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{CloudDrive for Ruby}
|
13
|
+
spec.description = %q{Ruby SDK and command line application for Amazon's CloudDrive}
|
14
|
+
spec.homepage = "https://github.com/exonintrendo/clouddrive"
|
15
|
+
spec.license = "MIT"
|
16
|
+
|
17
|
+
# Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
|
18
|
+
# delete this section to allow pushing this gem to any host.
|
19
|
+
# if spec.respond_to?(:metadata)
|
20
|
+
# spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
|
21
|
+
# else
|
22
|
+
# raise "RubyGems 2.0 or newer is required to protect against public gem pushes."
|
23
|
+
# end
|
24
|
+
|
25
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + Dir['lib/**/*.rb']
|
26
|
+
spec.bindir = "bin"
|
27
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
28
|
+
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
spec.add_development_dependency "bundler", "~> 1.9"
|
31
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
32
|
+
|
33
|
+
spec.add_runtime_dependency "rest-client", "~> 1.8"
|
34
|
+
spec.add_runtime_dependency "thor", "~> 0.19"
|
35
|
+
spec.add_runtime_dependency "sqlite3", "~>1.3"
|
36
|
+
end
|
data/lib/clouddrive.rb
ADDED
@@ -0,0 +1,307 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'cgi'
|
3
|
+
require 'rest-client'
|
4
|
+
require 'sqlite3'
|
5
|
+
|
6
|
+
module CloudDrive
|
7
|
+
|
8
|
+
class Account
|
9
|
+
|
10
|
+
attr_reader :access_token, :metadata_url, :content_url, :email, :token_store, :db
|
11
|
+
|
12
|
+
def initialize(email, client_id, client_secret)
|
13
|
+
@email = email
|
14
|
+
@cache_file = File.expand_path("~/.clouddrive/#{email}.cache")
|
15
|
+
@client_id = client_id
|
16
|
+
@client_secret = client_secret
|
17
|
+
|
18
|
+
@db = SQLite3::Database.new(File.expand_path("~/.clouddrive/#{@email}.db"))
|
19
|
+
if @db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='nodes';").empty?
|
20
|
+
@db.execute <<-SQL
|
21
|
+
CREATE TABLE nodes(
|
22
|
+
id VARCHAR PRIMARY KEY NOT NULL,
|
23
|
+
name VARCHAR NOT NULL,
|
24
|
+
kind VARCHAR NOT NULL,
|
25
|
+
md5 VARCHAR,
|
26
|
+
created DATETIME NOT NULL,
|
27
|
+
modified DATETIME NOT NULL,
|
28
|
+
raw_data TEXT NOT NULL
|
29
|
+
);
|
30
|
+
SQL
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def authorize(auth_url = nil)
|
35
|
+
retval = {
|
36
|
+
:success => true,
|
37
|
+
:data => {}
|
38
|
+
}
|
39
|
+
|
40
|
+
@token_store = {
|
41
|
+
"checkpoint" => nil,
|
42
|
+
"nodes" => {}
|
43
|
+
}
|
44
|
+
|
45
|
+
if File.exists?(@cache_file)
|
46
|
+
@token_store = JSON.parse(File.read(@cache_file))
|
47
|
+
end
|
48
|
+
|
49
|
+
if !@token_store.has_key?("access_token")
|
50
|
+
if auth_url.nil?
|
51
|
+
retval = {
|
52
|
+
:success => false,
|
53
|
+
:data => {
|
54
|
+
"message" => "Initial authorization required",
|
55
|
+
"auth_url" => "https://www.amazon.com/ap/oa?client_id=#{@client_id}&scope=clouddrive%3Aread%20clouddrive%3Awrite&response_type=code&redirect_uri=http://localhost"
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
return retval
|
60
|
+
else
|
61
|
+
data = request_authorization(auth_url)
|
62
|
+
end
|
63
|
+
|
64
|
+
if data[:success] === true
|
65
|
+
@token_store = data[:data]
|
66
|
+
else
|
67
|
+
return data
|
68
|
+
end
|
69
|
+
|
70
|
+
save_token_store
|
71
|
+
elsif (Time.new.to_i - @token_store["last_authorized"]) > 60
|
72
|
+
data = renew_authorization
|
73
|
+
if data[:success] === false
|
74
|
+
return data
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
@access_token = @token_store["access_token"]
|
79
|
+
|
80
|
+
if !@token_store.has_key?("metadataUrl") || !@token_store.has_key?("contentUrl")
|
81
|
+
result = get_endpoint
|
82
|
+
if result[:success] === true
|
83
|
+
@metadata_url = result[:data]["metadataUrl"]
|
84
|
+
@content_url = result[:data]["contentUrl"]
|
85
|
+
@token_store["contentUrl"] = @content_url
|
86
|
+
@token_store["metadataUrl"] = @metadata_url
|
87
|
+
|
88
|
+
save_token_store
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
@metadata_url = @token_store["metadataUrl"]
|
93
|
+
@content_url = @token_store["contentUrl"]
|
94
|
+
|
95
|
+
retval
|
96
|
+
end
|
97
|
+
|
98
|
+
def clear_cache
|
99
|
+
@token_store["nodes"] = {}
|
100
|
+
@token_store["checkpoint"] = nil
|
101
|
+
save_token_store
|
102
|
+
end
|
103
|
+
|
104
|
+
def get_endpoint
|
105
|
+
retval = {
|
106
|
+
:success => false,
|
107
|
+
:data => {}
|
108
|
+
}
|
109
|
+
RestClient.get("https://cdws.us-east-1.amazonaws.com/drive/v1/account/endpoint", {:Authorization => "Bearer #{@access_token}"}) do |response, request, result|
|
110
|
+
retval[:data] = JSON.parse(response.body)
|
111
|
+
if response.code === 200
|
112
|
+
retval[:success] = true
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
retval
|
117
|
+
end
|
118
|
+
|
119
|
+
def get_quota
|
120
|
+
retval = {
|
121
|
+
:success => false,
|
122
|
+
:data => {}
|
123
|
+
}
|
124
|
+
|
125
|
+
RestClient.get("#{@metadata_url}account/quota", {:Authorization => "Bearer #{@access_token}"}) do |response, request, result|
|
126
|
+
retval[:data] = JSON.parse(response.body)
|
127
|
+
if response.code === 200
|
128
|
+
retval[:success] = true
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
retval
|
133
|
+
end
|
134
|
+
|
135
|
+
def get_usage
|
136
|
+
retval = {
|
137
|
+
:success => false,
|
138
|
+
:data => {}
|
139
|
+
}
|
140
|
+
|
141
|
+
RestClient.get("#{@metadata_url}account/usage", {:Authorization => "Bearer #{@access_token}"}) do |response, request, result|
|
142
|
+
retval[:data] = JSON.parse(response.body)
|
143
|
+
if response.code === 200
|
144
|
+
retval[:success] = true
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
retval
|
149
|
+
end
|
150
|
+
|
151
|
+
def nodes
|
152
|
+
@token_store["nodes"]
|
153
|
+
end
|
154
|
+
|
155
|
+
def request_authorization(auth_url)
|
156
|
+
retval = {
|
157
|
+
:success => false,
|
158
|
+
:data => {}
|
159
|
+
}
|
160
|
+
|
161
|
+
params = CGI.parse(URI.parse(auth_url).query)
|
162
|
+
if !params.has_key?('code')
|
163
|
+
retval[:data]["message"] = "No authorization code exists in the callback URL: #{params}"
|
164
|
+
|
165
|
+
return retval
|
166
|
+
end
|
167
|
+
|
168
|
+
code = params["code"]
|
169
|
+
|
170
|
+
# Get token
|
171
|
+
#
|
172
|
+
# @TODO: why do I need to do this with code? (i.e., code[0])
|
173
|
+
body = {
|
174
|
+
'grant_type' => "authorization_code",
|
175
|
+
'code' => code[0],
|
176
|
+
'client_id' => @client_id,
|
177
|
+
'client_secret' => @client_secret,
|
178
|
+
'redirect_uri' => "http://localhost"
|
179
|
+
}
|
180
|
+
|
181
|
+
RestClient.post("https://api.amazon.com/auth/o2/token", body, :content_type => 'application/x-www-form-urlencoded') do |response, request, result|
|
182
|
+
retval[:data] = JSON.parse(response.body)
|
183
|
+
if response.code === 200
|
184
|
+
retval[:success] = true
|
185
|
+
retval[:data]["last_authorized"] = Time.new.to_i
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
retval
|
190
|
+
end
|
191
|
+
|
192
|
+
def renew_authorization
|
193
|
+
retval = {
|
194
|
+
:success => false,
|
195
|
+
:data => {}
|
196
|
+
}
|
197
|
+
|
198
|
+
body = {
|
199
|
+
'grant_type' => "refresh_token",
|
200
|
+
'refresh_token' => @token_store["refresh_token"],
|
201
|
+
'client_id' => @client_id,
|
202
|
+
'client_secret' => @client_secret,
|
203
|
+
'redirect_uri' => "http://localhost"
|
204
|
+
}
|
205
|
+
RestClient.post("https://api.amazon.com/auth/o2/token", body, :content_type => 'application/x-www-form-urlencoded') do |response, request, result|
|
206
|
+
retval[:data] = JSON.parse(response.body)
|
207
|
+
if response.code === 200
|
208
|
+
retval[:success] = true
|
209
|
+
|
210
|
+
@token_store["last_authorized"] = Time.new.to_i
|
211
|
+
@token_store["refresh_token"] = retval[:data]["refresh_token"]
|
212
|
+
@token_store["access_token"] = retval[:data]["access_token"]
|
213
|
+
|
214
|
+
@access_token = @token_store["access_token"]
|
215
|
+
@refresh_token = @token_store["refresh_token"]
|
216
|
+
|
217
|
+
save_token_store
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
retval
|
222
|
+
end
|
223
|
+
|
224
|
+
def save_token_store
|
225
|
+
File.open(@cache_file, 'w') do |file|
|
226
|
+
file.write(@token_store.to_json)
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
def sync
|
231
|
+
if !@token_store.has_key?("checkpoint")
|
232
|
+
@token_store["checkpoint"] = nil
|
233
|
+
end
|
234
|
+
|
235
|
+
body = {
|
236
|
+
:includePurged => "true"
|
237
|
+
}
|
238
|
+
|
239
|
+
loop do
|
240
|
+
if @token_store["checkpoint"] != nil
|
241
|
+
body[:checkpoint] = @token_store["checkpoint"]
|
242
|
+
end
|
243
|
+
|
244
|
+
loop = true
|
245
|
+
RestClient.post(@metadata_url + "changes", body.to_json, :Authorization => "Bearer #{@access_token}") do |response, request, result|
|
246
|
+
if response.code === 200
|
247
|
+
data = response.body.split("\n")
|
248
|
+
data.each do |xary|
|
249
|
+
xary = JSON.parse(xary)
|
250
|
+
if xary.has_key?("reset") && xary["reset"] == true
|
251
|
+
@db.execute("DELETE FROM nodes WHERE 1=1")
|
252
|
+
end
|
253
|
+
|
254
|
+
if xary.has_key?("end") && xary["end"] == true
|
255
|
+
loop = false
|
256
|
+
elsif xary.has_key?("nodes")
|
257
|
+
@token_store["checkpoint"] = xary["checkpoint"]
|
258
|
+
xary["nodes"].each do |node|
|
259
|
+
if node["status"] == "PURGED"
|
260
|
+
delete_node_by_id(node["id"])
|
261
|
+
else
|
262
|
+
save_node(node)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
break if loop === false
|
271
|
+
end
|
272
|
+
|
273
|
+
save_token_store
|
274
|
+
end
|
275
|
+
|
276
|
+
def delete_node_by_id(id)
|
277
|
+
begin
|
278
|
+
@db.execute("DELETE FROM nodes WHERE id = ?", id)
|
279
|
+
rescue SQLite3::Exception => e
|
280
|
+
puts "Exception deleting node with ID #{id}: #{e}"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
def save_node(node)
|
285
|
+
md5 = nil
|
286
|
+
if node["contentProperties"] != nil && node["contentProperties"]["md5"] != nil
|
287
|
+
md5 = node["contentProperties"]["md5"]
|
288
|
+
end
|
289
|
+
|
290
|
+
if node["name"] == nil && node["isRoot"] != nil && node["isRoot"] == true
|
291
|
+
node["name"] = "root"
|
292
|
+
end
|
293
|
+
|
294
|
+
begin
|
295
|
+
result = db.execute("INSERT OR REPLACE INTO nodes (id, name, kind, md5, created, modified, raw_data)
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?);", [node["id"], node["name"], node["kind"], md5, node["createdDate"], node["modifiedDate"], node.to_json])
|
297
|
+
rescue SQLite3::Exception => e
|
298
|
+
if node["name"] == nil
|
299
|
+
puts "Exception saving node: #{e}"
|
300
|
+
puts node.to_json
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
@@ -0,0 +1,329 @@
|
|
1
|
+
require 'rest-client'
|
2
|
+
require 'pathname'
|
3
|
+
require 'find'
|
4
|
+
require 'digest/md5'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
module CloudDrive
|
8
|
+
|
9
|
+
class Node
|
10
|
+
|
11
|
+
def initialize(account)
|
12
|
+
@account = account
|
13
|
+
end
|
14
|
+
|
15
|
+
def build_node_path(node)
|
16
|
+
path = []
|
17
|
+
loop do
|
18
|
+
path.push node["name"]
|
19
|
+
|
20
|
+
break if node.has_key?('isRoot') && node['isRoot'] == true
|
21
|
+
|
22
|
+
results = find_by_id(node["parents"][0])
|
23
|
+
if results[:success] == false
|
24
|
+
raise "No parent node found with ID #{node["parents"][0]}"
|
25
|
+
end
|
26
|
+
|
27
|
+
node = results[:data]
|
28
|
+
|
29
|
+
break if node.has_key?('isRoot') && node['isRoot'] === true
|
30
|
+
end
|
31
|
+
|
32
|
+
path = path.reverse
|
33
|
+
path.join('/')
|
34
|
+
end
|
35
|
+
|
36
|
+
def create_new_folder(name, parent_id = nil)
|
37
|
+
if parent_id == nil
|
38
|
+
parent_id = get_root['id']
|
39
|
+
end
|
40
|
+
|
41
|
+
body = {
|
42
|
+
:name => name,
|
43
|
+
:parents => [
|
44
|
+
parent_id
|
45
|
+
],
|
46
|
+
:kind => "FOLDER"
|
47
|
+
}
|
48
|
+
|
49
|
+
retval = {
|
50
|
+
:success => false,
|
51
|
+
:data => []
|
52
|
+
}
|
53
|
+
|
54
|
+
RestClient.post(
|
55
|
+
"#{@account.metadata_url}nodes",
|
56
|
+
body.to_json,
|
57
|
+
:Authorization => "Bearer #{@account.access_token}"
|
58
|
+
) do |response, request, result|
|
59
|
+
data = JSON.parse(response.body)
|
60
|
+
retval[:data] = data
|
61
|
+
if response.code === 201
|
62
|
+
retval[:success] = true
|
63
|
+
@account.save_node(data)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
retval
|
68
|
+
end
|
69
|
+
|
70
|
+
def create_directory_path(path)
|
71
|
+
retval = {
|
72
|
+
:success => true,
|
73
|
+
:data => {}
|
74
|
+
}
|
75
|
+
|
76
|
+
path = get_path_array(path)
|
77
|
+
previous_node = get_root
|
78
|
+
|
79
|
+
match = nil
|
80
|
+
path.each_with_index do |folder, index|
|
81
|
+
xary = path.slice(0, index + 1)
|
82
|
+
if (match = find_by_path(xary.join('/'))) === nil
|
83
|
+
result = create_new_folder(folder, previous_node["id"])
|
84
|
+
if result[:success] === false
|
85
|
+
return result
|
86
|
+
end
|
87
|
+
|
88
|
+
match = result[:data]
|
89
|
+
end
|
90
|
+
|
91
|
+
previous_node = match
|
92
|
+
end
|
93
|
+
|
94
|
+
if match == nil
|
95
|
+
retval[:data] = previous_node
|
96
|
+
else
|
97
|
+
retval[:data] = match
|
98
|
+
end
|
99
|
+
|
100
|
+
retval
|
101
|
+
end
|
102
|
+
|
103
|
+
# If given a local file, the MD5 will be compared as well
|
104
|
+
def exists?(remote_file, local_file = nil)
|
105
|
+
if (file = find_by_path(remote_file)) == nil
|
106
|
+
if local_file != nil
|
107
|
+
if (file = find_by_md5(Digest::MD5.file(local_file).to_s)) != nil
|
108
|
+
path = build_node_path(file)
|
109
|
+
return {
|
110
|
+
:success => true,
|
111
|
+
:data => {
|
112
|
+
"message" => "File with same MD5 exists at #{path}: #{file.to_json}"
|
113
|
+
}
|
114
|
+
}
|
115
|
+
end
|
116
|
+
end
|
117
|
+
return {
|
118
|
+
:success => false,
|
119
|
+
:data => {
|
120
|
+
"message" => "File #{remote_file} does not exist"
|
121
|
+
}
|
122
|
+
}
|
123
|
+
end
|
124
|
+
|
125
|
+
retval = {
|
126
|
+
:success => true,
|
127
|
+
:data => {
|
128
|
+
"message" => "File #{remote_file} exists"
|
129
|
+
}
|
130
|
+
}
|
131
|
+
|
132
|
+
if local_file != nil
|
133
|
+
if file["contentProperties"] != nil && file["contentProperties"]["md5"] != nil
|
134
|
+
if Digest::MD5.file(local_file).to_s != file["contentProperties"]["md5"]
|
135
|
+
retval[:data]["message"] = "File #{remote_file} exists butuum doesn't match"
|
136
|
+
else
|
137
|
+
retval[:data]["message"] = "File #{remote_file} exists and is identical"
|
138
|
+
end
|
139
|
+
else
|
140
|
+
retval[:data]["message"] = "File #{remote_file} exists, but no checksum is available"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
retval
|
145
|
+
end
|
146
|
+
|
147
|
+
def find_by_id(id)
|
148
|
+
retval = {
|
149
|
+
:success => false,
|
150
|
+
:data => {}
|
151
|
+
}
|
152
|
+
|
153
|
+
results = @account.db.execute("SELECT raw_data FROM nodes WHERE id = ?;", id)
|
154
|
+
if results.empty?
|
155
|
+
return retval
|
156
|
+
end
|
157
|
+
|
158
|
+
if results.count > 1
|
159
|
+
raise "Multiple nodes with same ID found: #{results[:data].to_json}"
|
160
|
+
end
|
161
|
+
|
162
|
+
{
|
163
|
+
:success => true,
|
164
|
+
:data => JSON.parse(results[0][0])
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
def find_by_md5(hash)
|
169
|
+
|
170
|
+
results = @account.db.execute("SELECT raw_data FROM nodes WHERE md5 = ?;", hash)
|
171
|
+
if results.empty?
|
172
|
+
return nil
|
173
|
+
end
|
174
|
+
|
175
|
+
if results.count > 1
|
176
|
+
raise "Multiple nodes with same MD5: #{results.to_json}"
|
177
|
+
end
|
178
|
+
|
179
|
+
JSON.parse(results[0][0])
|
180
|
+
end
|
181
|
+
|
182
|
+
def find_by_name(name)
|
183
|
+
retval = []
|
184
|
+
results = @account.db.execute("SELECT raw_data FROM nodes WHERE name = ?;", name)
|
185
|
+
if results.empty?
|
186
|
+
return retval
|
187
|
+
end
|
188
|
+
|
189
|
+
results.each do |result|
|
190
|
+
retval.push(JSON.parse(result[0]))
|
191
|
+
end
|
192
|
+
|
193
|
+
retval
|
194
|
+
end
|
195
|
+
|
196
|
+
def find_by_path(path)
|
197
|
+
path = path.gsub(/\A\//, '')
|
198
|
+
path = path.gsub(/\/$/, '')
|
199
|
+
path_info = Pathname.new(path)
|
200
|
+
|
201
|
+
found_nodes = find_by_name(path_info.basename.to_s)
|
202
|
+
if found_nodes.empty?
|
203
|
+
return nil
|
204
|
+
end
|
205
|
+
|
206
|
+
match = nil
|
207
|
+
found_nodes.each do |node|
|
208
|
+
if build_node_path(node) == path
|
209
|
+
match = node
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
match
|
214
|
+
end
|
215
|
+
|
216
|
+
def get_path_array(path)
|
217
|
+
return path if path.kind_of?(Array)
|
218
|
+
|
219
|
+
path = path.split('/')
|
220
|
+
path.reject! do |value|
|
221
|
+
value.empty?
|
222
|
+
end
|
223
|
+
|
224
|
+
path
|
225
|
+
end
|
226
|
+
|
227
|
+
def get_path_string(path)
|
228
|
+
path = path.join '/' if path.kind_of?(Array)
|
229
|
+
|
230
|
+
path.chomp
|
231
|
+
end
|
232
|
+
|
233
|
+
def get_root
|
234
|
+
results = find_by_name('root')
|
235
|
+
if results.empty?
|
236
|
+
raise "No node by the name of 'root' found in database"
|
237
|
+
end
|
238
|
+
|
239
|
+
results.each do |node|
|
240
|
+
if node.has_key?('isRoot') && node['isRoot'] === true
|
241
|
+
return node
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
nil
|
246
|
+
end
|
247
|
+
|
248
|
+
def upload_dir(src_path, dest_root, show_progress = false)
|
249
|
+
src_path = File.expand_path(src_path)
|
250
|
+
|
251
|
+
dest_root = get_path_array(dest_root)
|
252
|
+
dest_root.push(get_path_array(src_path).last)
|
253
|
+
dest_root = get_path_string(dest_root)
|
254
|
+
|
255
|
+
retval = []
|
256
|
+
Find.find(src_path) do |file|
|
257
|
+
# Skip root directory, no need to make it
|
258
|
+
next if file == src_path || File.directory?(file)
|
259
|
+
|
260
|
+
path_info = Pathname.new(file)
|
261
|
+
remote_dest = path_info.dirname.sub(src_path, dest_root).to_s
|
262
|
+
|
263
|
+
result = upload_file(file, remote_dest)
|
264
|
+
if show_progress == true
|
265
|
+
if result[:success] == true
|
266
|
+
puts "Successfully uploaded file #{file}: #{result[:data].to_json}"
|
267
|
+
else
|
268
|
+
puts "Failed to uploaded file #{file}: #{result[:data].to_json}"
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
retval.push(result)
|
273
|
+
|
274
|
+
# Since uploading a directory can take a while (depending on number/size of files)
|
275
|
+
# we will check if we need to renew our authorization after each file upload to
|
276
|
+
# make sure our authentication doesn't expire.
|
277
|
+
if (Time.new.to_i - @account.token_store["last_authorized"]) > 60
|
278
|
+
result = @account.renew_authorization
|
279
|
+
if result[:success] === false
|
280
|
+
raise "Failed to renew authorization: #{result[:data].to_json}"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
retval
|
286
|
+
end
|
287
|
+
|
288
|
+
def upload_file(src_path, dest_path)
|
289
|
+
retval = {
|
290
|
+
:success => false,
|
291
|
+
:data => []
|
292
|
+
}
|
293
|
+
|
294
|
+
path_info = Pathname.new(src_path)
|
295
|
+
dest_path = get_path_string(get_path_array(dest_path))
|
296
|
+
dest_folder = create_directory_path(dest_path)
|
297
|
+
|
298
|
+
result = exists?("#{dest_path}/#{path_info.basename}", src_path)
|
299
|
+
if result[:success] == true
|
300
|
+
retval[:data] = result[:data]
|
301
|
+
|
302
|
+
return retval
|
303
|
+
end
|
304
|
+
|
305
|
+
body = {
|
306
|
+
:metadata => {
|
307
|
+
:kind => 'FILE',
|
308
|
+
:name => path_info.basename,
|
309
|
+
:parents => [
|
310
|
+
dest_folder["id"]
|
311
|
+
]
|
312
|
+
}.to_json,
|
313
|
+
:content => File.new(File.expand_path(src_path), 'rb')
|
314
|
+
}
|
315
|
+
|
316
|
+
RestClient.post("#{@account.content_url}nodes", body, :Authorization => "Bearer #{@account.access_token}") do |response, request, result|
|
317
|
+
retval[:data] = JSON.parse(response.body)
|
318
|
+
if response.code === 201
|
319
|
+
retval[:success] = true
|
320
|
+
@account.save_node(retval[:data])
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
retval
|
325
|
+
end
|
326
|
+
|
327
|
+
end
|
328
|
+
|
329
|
+
end
|
metadata
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: clouddrive
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Alex Phillips
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-06-15 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.9'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.9'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rest-client
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.8'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.8'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: thor
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.19'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.19'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sqlite3
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.3'
|
76
|
+
type: :runtime
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.3'
|
83
|
+
description: Ruby SDK and command line application for Amazon's CloudDrive
|
84
|
+
email:
|
85
|
+
- exonintrendo@gmail.com
|
86
|
+
executables:
|
87
|
+
- clouddrive
|
88
|
+
- console
|
89
|
+
- setup
|
90
|
+
extensions: []
|
91
|
+
extra_rdoc_files: []
|
92
|
+
files:
|
93
|
+
- ".gitignore"
|
94
|
+
- ".rspec"
|
95
|
+
- ".travis.yml"
|
96
|
+
- CODE_OF_CONDUCT.md
|
97
|
+
- Gemfile
|
98
|
+
- LICENSE.txt
|
99
|
+
- README.md
|
100
|
+
- Rakefile
|
101
|
+
- bin/clouddrive
|
102
|
+
- bin/console
|
103
|
+
- bin/setup
|
104
|
+
- clouddrive.gemspec
|
105
|
+
- lib/clouddrive.rb
|
106
|
+
- lib/clouddrive/account.rb
|
107
|
+
- lib/clouddrive/node.rb
|
108
|
+
- lib/clouddrive/version.rb
|
109
|
+
homepage: https://github.com/exonintrendo/clouddrive
|
110
|
+
licenses:
|
111
|
+
- MIT
|
112
|
+
metadata: {}
|
113
|
+
post_install_message:
|
114
|
+
rdoc_options: []
|
115
|
+
require_paths:
|
116
|
+
- lib
|
117
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
118
|
+
requirements:
|
119
|
+
- - ">="
|
120
|
+
- !ruby/object:Gem::Version
|
121
|
+
version: '0'
|
122
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
123
|
+
requirements:
|
124
|
+
- - ">="
|
125
|
+
- !ruby/object:Gem::Version
|
126
|
+
version: '0'
|
127
|
+
requirements: []
|
128
|
+
rubyforge_project:
|
129
|
+
rubygems_version: 2.4.7
|
130
|
+
signing_key:
|
131
|
+
specification_version: 4
|
132
|
+
summary: CloudDrive for Ruby
|
133
|
+
test_files: []
|