social-avatar-proxy 1.2.0 → 2.0.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.
- data/.gitignore +2 -1
- data/.travis.yml +2 -0
- data/README.md +39 -6
- data/lib/social_avatar_proxy/app.rb +17 -111
- data/lib/social_avatar_proxy/avatar.rb +16 -5
- data/lib/social_avatar_proxy/avatar_file.rb +27 -0
- data/lib/social_avatar_proxy/config.rb +6 -27
- data/lib/social_avatar_proxy/configuration/cache.rb +57 -0
- data/lib/social_avatar_proxy/configuration/caches.rb +15 -0
- data/lib/social_avatar_proxy/configuration/file_cache.rb +46 -0
- data/lib/social_avatar_proxy/configuration/http_cache.rb +55 -0
- data/lib/social_avatar_proxy/configuration/memcache.rb +97 -0
- data/lib/social_avatar_proxy/configuration.rb +112 -0
- data/lib/social_avatar_proxy/response.rb +90 -0
- data/lib/social_avatar_proxy/version.rb +1 -1
- data/lib/social_avatar_proxy.rb +1 -0
- data/social-avatar-proxy.gemspec +1 -0
- data/spec/social_avatar_proxy/app_spec.rb +106 -30
- data/spec/social_avatar_proxy/avatar_spec.rb +0 -14
- data/spec/social_avatar_proxy/config_spec.rb +31 -28
- metadata +33 -3
data/.gitignore
CHANGED
data/.travis.yml
CHANGED
data/README.md
CHANGED
@@ -54,22 +54,55 @@ image_tag(facebook_avatar_path(12345))
|
|
54
54
|
You might wish to configure a default image which will be rendered if an avatar could not be found, or if the remote service has an error (e.g. times out or has a redirect loop), this is simple with an initializer:
|
55
55
|
|
56
56
|
```ruby
|
57
|
-
require "social_avatar_proxy/config"
|
58
|
-
|
59
57
|
# choose our image
|
60
|
-
image_path = Rails.root.join(
|
58
|
+
image_path = Rails.root.join(*%W(app assets images default_avatar.jpg))
|
61
59
|
# set the config
|
62
|
-
SocialAvatarProxy
|
60
|
+
SocialAvatarProxy.configure do
|
61
|
+
default_image(image_path)
|
62
|
+
end
|
63
|
+
```
|
64
|
+
|
65
|
+
HTTP caching can be configured as below:
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
SocialAvatarProxy.configure do
|
69
|
+
http_cache do
|
70
|
+
expires 10.minutes
|
71
|
+
cache_control({
|
72
|
+
max_age: 5.days,
|
73
|
+
max_stale: 1.day,
|
74
|
+
public: true
|
75
|
+
})
|
76
|
+
end
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
Files can be cached in either or both of Memcached and the file system, here is the configuration options:
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
SocialAvatarProxy.configure do
|
84
|
+
file_cache do
|
85
|
+
directory "/path/to/folder"
|
86
|
+
end
|
87
|
+
|
88
|
+
memcache do
|
89
|
+
enable # only required if you don't set either of the following options
|
90
|
+
host "192.168.1.2:11211" # defaults to "localhost:11211"
|
91
|
+
namespace "something:" # defaults to "sap/"
|
92
|
+
end
|
93
|
+
end
|
63
94
|
```
|
64
95
|
|
65
96
|
### Rack
|
66
97
|
|
67
98
|
The Rack app is available at: SocialAvatarProxy::App.
|
68
99
|
|
69
|
-
Both the class and an instance respond to `call` so either can be mounted.
|
100
|
+
Both the class and an instance respond to `call` so either can be mounted.
|
70
101
|
|
71
102
|
```ruby
|
72
|
-
use SocialAvatarProxy::App
|
103
|
+
use SocialAvatarProxy::App
|
104
|
+
# or
|
105
|
+
use SocialAvatarProxy::App.new
|
73
106
|
```
|
74
107
|
|
75
108
|
## Contributing
|
@@ -2,33 +2,23 @@ require "social_avatar_proxy/config"
|
|
2
2
|
require "social_avatar_proxy/facebook_avatar"
|
3
3
|
require "social_avatar_proxy/twitter_avatar"
|
4
4
|
require "social_avatar_proxy/routes"
|
5
|
+
require "social_avatar_proxy/response"
|
5
6
|
require "social_avatar_proxy/timeout_error"
|
6
7
|
require "social_avatar_proxy/too_many_redirects_error"
|
7
8
|
require "rack"
|
8
9
|
|
9
10
|
module SocialAvatarProxy
|
10
11
|
class App
|
11
|
-
def self.call(env
|
12
|
-
new
|
12
|
+
def self.call(env)
|
13
|
+
new.call(env)
|
13
14
|
end
|
14
15
|
|
15
|
-
def self.routes
|
16
|
-
new
|
16
|
+
def self.routes
|
17
|
+
new.routes
|
17
18
|
end
|
18
19
|
|
19
20
|
attr_reader :options, :request
|
20
21
|
|
21
|
-
def initialize(options = {})
|
22
|
-
@options = {
|
23
|
-
expires: 86400, # 1 day from now
|
24
|
-
cache_control: {
|
25
|
-
max_age: 86400, # store for 1 day, after that re-request
|
26
|
-
max_stale: 86400, # allow cache to be a day stale
|
27
|
-
public: true # allow proxy caching
|
28
|
-
}
|
29
|
-
}.merge(options)
|
30
|
-
end
|
31
|
-
|
32
22
|
def call(env)
|
33
23
|
@request = Rack::Request.new(env)
|
34
24
|
begin
|
@@ -41,7 +31,7 @@ module SocialAvatarProxy
|
|
41
31
|
end
|
42
32
|
|
43
33
|
def path_prefix
|
44
|
-
(
|
34
|
+
((request && request.env["SCRIPT_NAME"]) || "").gsub(/\/$/, "")
|
45
35
|
end
|
46
36
|
|
47
37
|
def response
|
@@ -49,60 +39,11 @@ module SocialAvatarProxy
|
|
49
39
|
unless request.path =~ /^#{path_prefix}\/(facebook|twitter)\/([\w\-\.]+)(\.(jpe?g|png|gif|bmp))?$/i
|
50
40
|
return not_found
|
51
41
|
end
|
52
|
-
#
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
Rack::Response.new do |response|
|
58
|
-
# set the response data
|
59
|
-
response.write(avatar.body)
|
60
|
-
# set the response headers
|
61
|
-
response = set_avatar_headers(response, avatar)
|
62
|
-
response = set_caching_headers(response)
|
63
|
-
# return the response
|
64
|
-
response
|
65
|
-
end
|
66
|
-
# if the avatar doesn't exist
|
67
|
-
else
|
68
|
-
not_found
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
|
-
def not_found
|
73
|
-
Rack::Response.new([], 404) do |response|
|
74
|
-
# if we have a custom default image
|
75
|
-
if Config.default_image
|
76
|
-
render_default_image(response)
|
77
|
-
# without a default image
|
78
|
-
else
|
79
|
-
response.write "Not Found"
|
80
|
-
end
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
def timeout
|
85
|
-
Rack::Response.new([], 504) do |response|
|
86
|
-
# if we have a custom default image
|
87
|
-
if Config.default_image
|
88
|
-
render_default_image(response)
|
89
|
-
# without a default image
|
90
|
-
else
|
91
|
-
response.write "Remote server timeout"
|
92
|
-
end
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
def bad_gateway
|
97
|
-
Rack::Response.new([], 502) do |response|
|
98
|
-
# if we have a custom default image
|
99
|
-
if Config.default_image
|
100
|
-
render_default_image(response)
|
101
|
-
# without a default image
|
102
|
-
else
|
103
|
-
response.write "Bad response from remote server"
|
104
|
-
end
|
105
|
-
end
|
42
|
+
# create our response
|
43
|
+
SocialAvatarProxy::Response.build({
|
44
|
+
service: $1,
|
45
|
+
identifier: $2
|
46
|
+
})
|
106
47
|
end
|
107
48
|
|
108
49
|
def routes
|
@@ -110,51 +51,16 @@ module SocialAvatarProxy
|
|
110
51
|
end
|
111
52
|
|
112
53
|
private
|
113
|
-
def
|
114
|
-
|
115
|
-
response.write(Config.default_image_data)
|
116
|
-
# set the content type
|
117
|
-
response["Content-Type"] = Config.default_image_content_type
|
118
|
-
# set expiry
|
119
|
-
response = set_caching_headers(response)
|
120
|
-
# return the response
|
121
|
-
response
|
122
|
-
end
|
123
|
-
|
124
|
-
def set_avatar_headers(response, avatar)
|
125
|
-
# set the last modified header
|
126
|
-
response["Last-Modified"] = avatar.last_modified
|
127
|
-
# set the content type header
|
128
|
-
response["Content-Type"] = avatar.content_type
|
129
|
-
# return the response
|
130
|
-
response
|
54
|
+
def not_found
|
55
|
+
Rack::Response.new("Not Found", 404)
|
131
56
|
end
|
132
57
|
|
133
|
-
def
|
134
|
-
|
135
|
-
if options[:expires]
|
136
|
-
response["Expires"] = (Time.now + options[:expires]).httpdate
|
137
|
-
end
|
138
|
-
# if we want to set cache control settings
|
139
|
-
if cc = options[:cache_control]
|
140
|
-
directives = []
|
141
|
-
directives << "no-cache" if cc[:no_cache]
|
142
|
-
directives << "max-stale=#{cc[:max_stale]}" if cc[:max_stale]
|
143
|
-
directives << "max-age=#{cc[:max_age]}" if cc[:max_age]
|
144
|
-
directives << (cc[:public] ? "public" : "private")
|
145
|
-
response["Cache-Control"] = directives.join(", ")
|
146
|
-
end
|
147
|
-
# return the response
|
148
|
-
response
|
58
|
+
def timeout
|
59
|
+
Rack::Response.new("Gateway Timeout", 504)
|
149
60
|
end
|
150
61
|
|
151
|
-
def
|
152
|
-
|
153
|
-
service = service.gsub(/[\_\-]/, " ").gsub(/\b([a-z])/) do |match|
|
154
|
-
match.upcase
|
155
|
-
end
|
156
|
-
# pass it onto the
|
157
|
-
SocialAvatarProxy.const_get("#{service}Avatar").new(id)
|
62
|
+
def bad_gateway
|
63
|
+
Rack::Response.new("Bad Gateway: Too many redirects", 502)
|
158
64
|
end
|
159
65
|
end
|
160
66
|
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require "social_avatar_proxy/remote_file_resolver"
|
2
|
+
require "social_avatar_proxy/avatar_file"
|
2
3
|
|
3
4
|
module SocialAvatarProxy
|
4
5
|
class Avatar
|
@@ -39,16 +40,26 @@ module SocialAvatarProxy
|
|
39
40
|
end
|
40
41
|
end
|
41
42
|
|
42
|
-
# returns whether the avatar has changed
|
43
|
-
def modified_since?(timestamp)
|
44
|
-
last_modified > timestamp
|
45
|
-
end
|
46
|
-
|
47
43
|
# in the base Avatar class, we'll use the identifier as the URL
|
48
44
|
def remote_url
|
49
45
|
identifier
|
50
46
|
end
|
51
47
|
|
48
|
+
# return a temporary file object
|
49
|
+
def file
|
50
|
+
AvatarFile.new(File.basename(remote_url)).tap do |file|
|
51
|
+
begin
|
52
|
+
file.write(body)
|
53
|
+
file.content_type(content_type)
|
54
|
+
file.mtime(last_modified)
|
55
|
+
file.rewind
|
56
|
+
rescue
|
57
|
+
file.close
|
58
|
+
raise
|
59
|
+
end
|
60
|
+
end if exist?
|
61
|
+
end
|
62
|
+
|
52
63
|
private
|
53
64
|
# request the remote file
|
54
65
|
def response
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require "tempfile"
|
2
|
+
require "fileutils"
|
3
|
+
require "delegate"
|
4
|
+
|
5
|
+
module SocialAvatarProxy
|
6
|
+
class AvatarFile < SimpleDelegator
|
7
|
+
attr_reader :file
|
8
|
+
|
9
|
+
def initialize(path)
|
10
|
+
if File.exist?(path)
|
11
|
+
@file = File.new(path)
|
12
|
+
mtime(file.mtime)
|
13
|
+
else
|
14
|
+
@file = Tempfile.new(path.gsub("/", "-"))
|
15
|
+
end
|
16
|
+
super(file)
|
17
|
+
end
|
18
|
+
|
19
|
+
def mtime(value = nil)
|
20
|
+
value ? @mtime = value.to_i : Time.at(@mtime)
|
21
|
+
end
|
22
|
+
|
23
|
+
def content_type(value = nil)
|
24
|
+
value ? @content_type = value : @content_type
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -1,30 +1,9 @@
|
|
1
|
+
require "social_avatar_proxy/configuration"
|
2
|
+
|
1
3
|
module SocialAvatarProxy
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
def default_image_file
|
7
|
-
default_image && File.new(default_image)
|
8
|
-
end
|
9
|
-
|
10
|
-
def default_image_data
|
11
|
-
default_image_file.read
|
12
|
-
end
|
13
|
-
|
14
|
-
def default_image_content_type
|
15
|
-
case File.extname(default_image).downcase
|
16
|
-
when /^\.jpe?g$/
|
17
|
-
"image/jpeg"
|
18
|
-
when /^\.png$/
|
19
|
-
"image/png"
|
20
|
-
when /^\.gif$/
|
21
|
-
"image/gif"
|
22
|
-
when /^\.svg$/
|
23
|
-
"image/svg+xml"
|
24
|
-
else
|
25
|
-
"application/octet-stream"
|
26
|
-
end
|
27
|
-
end
|
28
|
-
end
|
4
|
+
Config = Configuration.new
|
5
|
+
|
6
|
+
def self.configure(&block)
|
7
|
+
Config.configure(&block)
|
29
8
|
end
|
30
9
|
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module SocialAvatarProxy
|
2
|
+
class Configuration
|
3
|
+
class Cache
|
4
|
+
def read(options = {})
|
5
|
+
false
|
6
|
+
end
|
7
|
+
|
8
|
+
def write(file, options = {})
|
9
|
+
file
|
10
|
+
end
|
11
|
+
|
12
|
+
def fetch(options = {}, &block)
|
13
|
+
# if the cache is enabled attempt read
|
14
|
+
file = attempt_read(options)
|
15
|
+
# if read fails yield the block
|
16
|
+
# if the cache is enabled attempt write
|
17
|
+
# return the block result
|
18
|
+
unless file
|
19
|
+
file = block.yield
|
20
|
+
attempt_write(file, options) if file
|
21
|
+
end
|
22
|
+
# return the file
|
23
|
+
file
|
24
|
+
end
|
25
|
+
|
26
|
+
def configure(&block)
|
27
|
+
enable
|
28
|
+
instance_eval(&block)
|
29
|
+
end
|
30
|
+
|
31
|
+
def disabled?
|
32
|
+
!enabled?
|
33
|
+
end
|
34
|
+
|
35
|
+
def enabled?
|
36
|
+
!!@enabled
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
def disable
|
41
|
+
@enabled = false
|
42
|
+
end
|
43
|
+
|
44
|
+
def enable
|
45
|
+
@enabled = true
|
46
|
+
end
|
47
|
+
|
48
|
+
def attempt_read(options = {})
|
49
|
+
enabled? && read(options)
|
50
|
+
end
|
51
|
+
|
52
|
+
def attempt_write(file, options = {})
|
53
|
+
enabled? && write(file, options)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require "social_avatar_proxy/avatar_file"
|
2
|
+
require "social_avatar_proxy/configuration/cache"
|
3
|
+
require "digest/md5"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module SocialAvatarProxy
|
7
|
+
class Configuration
|
8
|
+
class FileCache < Cache
|
9
|
+
def read(options = {})
|
10
|
+
path = generate_key(options)
|
11
|
+
# ensure the file exists and it's readable
|
12
|
+
unless File.exist?(path) && File.readable?(path)
|
13
|
+
return false
|
14
|
+
end
|
15
|
+
# return the file
|
16
|
+
AvatarFile.new(path).tap do |file|
|
17
|
+
file.mtime(File.mtime(path))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def write(file, options = {})
|
22
|
+
# generate the file location
|
23
|
+
path = generate_key(options)
|
24
|
+
# create the folder
|
25
|
+
FileUtils.mkdir_p File.dirname(path)
|
26
|
+
# write the file
|
27
|
+
File.open(path, "wb") do |f|
|
28
|
+
f.write(file.read)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
def generate_key(options = {})
|
34
|
+
# generate a hash for the filename
|
35
|
+
hash = "#{options[:service]}-#{options[:identifier]}"
|
36
|
+
name = "sap-#{Digest::MD5.hexdigest(hash)}"
|
37
|
+
File.join(@directory, name)
|
38
|
+
end
|
39
|
+
|
40
|
+
# set the directory
|
41
|
+
def directory(value)
|
42
|
+
@directory = value
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module SocialAvatarProxy
|
2
|
+
class Configuration
|
3
|
+
class HttpCache
|
4
|
+
def configure(&block)
|
5
|
+
@enabled = true
|
6
|
+
instance_eval(&block)
|
7
|
+
end
|
8
|
+
|
9
|
+
def apply_caching_headers(response)
|
10
|
+
# if we want to expire in a set time, calculate the header
|
11
|
+
if expires
|
12
|
+
response["Expires"] = (Time.now + expires.to_i).httpdate
|
13
|
+
end
|
14
|
+
# if we want to set cache control settings
|
15
|
+
if cc = cache_control
|
16
|
+
directives = []
|
17
|
+
directives << "no-cache" if cc[:no_cache]
|
18
|
+
directives << "max-stale=#{cc[:max_stale]}" if cc[:max_stale]
|
19
|
+
directives << "max-age=#{cc[:max_age]}" if cc[:max_age]
|
20
|
+
directives << (cc[:public] ? "public" : "private")
|
21
|
+
response["Cache-Control"] = directives.join(", ")
|
22
|
+
end
|
23
|
+
# return the response
|
24
|
+
response
|
25
|
+
end
|
26
|
+
|
27
|
+
def disabled?
|
28
|
+
!enabled?
|
29
|
+
end
|
30
|
+
|
31
|
+
def enabled?
|
32
|
+
!!@enabled
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
def cache_control(value = nil)
|
37
|
+
if value
|
38
|
+
@cache_control = value
|
39
|
+
end
|
40
|
+
@cache_control
|
41
|
+
end
|
42
|
+
|
43
|
+
def expires(value = nil)
|
44
|
+
if value
|
45
|
+
@expires = value
|
46
|
+
end
|
47
|
+
@expires
|
48
|
+
end
|
49
|
+
|
50
|
+
def disable
|
51
|
+
@enabled = false
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require "social_avatar_proxy/avatar_file"
|
2
|
+
require "social_avatar_proxy/configuration/cache"
|
3
|
+
require "dalli"
|
4
|
+
require "digest/md5"
|
5
|
+
|
6
|
+
module SocialAvatarProxy
|
7
|
+
class Configuration
|
8
|
+
class Memcache < Cache
|
9
|
+
def read(options = {})
|
10
|
+
# generate the key
|
11
|
+
file_key = generate_key(options)
|
12
|
+
# if the key exists, serve the data
|
13
|
+
if data = connection.get(file_key)
|
14
|
+
content_type = connection.get("#{file_key}-content-type") || "image/jpeg"
|
15
|
+
mtime = connection.get("#{file_key}-mtime") || Time.now
|
16
|
+
# serve the avatar file
|
17
|
+
AvatarFile.new(file_key).tap do |file|
|
18
|
+
begin
|
19
|
+
file.write(data)
|
20
|
+
file.content_type(content_type)
|
21
|
+
file.mtime(mtime)
|
22
|
+
file.rewind
|
23
|
+
rescue
|
24
|
+
file.close
|
25
|
+
file.unlink
|
26
|
+
raise
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def write(file, options = {})
|
33
|
+
# generate the file location
|
34
|
+
file_key = generate_key(options)
|
35
|
+
# write the file
|
36
|
+
connection.set(file_key, file.read)
|
37
|
+
# if the file has a content type
|
38
|
+
if file.respond_to?(:content_type) && file.content_type
|
39
|
+
# write the content type
|
40
|
+
connection.set("#{file_key}-content-type", file.content_type)
|
41
|
+
end
|
42
|
+
# if the file has a modified time
|
43
|
+
if file.respond_to?(:mtime) && file.mtime
|
44
|
+
# write the content type
|
45
|
+
connection.set("#{file_key}-mtime", file.mtime.to_i)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def initialize
|
50
|
+
key do |options|
|
51
|
+
pre_hash = "#{options[:service]}-#{options[:identifier]}"
|
52
|
+
"#{@namespace || "sap/"}#{Digest::MD5.hexdigest(pre_hash)}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
# set which Memcached server to use
|
58
|
+
# default: "localhost:11211"
|
59
|
+
def host(value)
|
60
|
+
@host = value
|
61
|
+
end
|
62
|
+
|
63
|
+
# set the key namespace
|
64
|
+
# default: "sap/"
|
65
|
+
def namespace(value)
|
66
|
+
@namespace = value
|
67
|
+
end
|
68
|
+
|
69
|
+
# set the key generation block
|
70
|
+
# should accept a hash of options
|
71
|
+
def key(&block)
|
72
|
+
@key = block
|
73
|
+
end
|
74
|
+
|
75
|
+
# generate a key given the arguments
|
76
|
+
def generate_key(*args)
|
77
|
+
@key.yield(*args)
|
78
|
+
end
|
79
|
+
|
80
|
+
# return a connection to Memcache
|
81
|
+
# defaults to a Dalli::Client object
|
82
|
+
def connection(value = nil)
|
83
|
+
if value
|
84
|
+
unless value.respond_to?(:get) && value.respond_to?(:set)
|
85
|
+
raise ArgumentError, %Q(
|
86
|
+
#{value.inspect}
|
87
|
+
must respond to #set and #get
|
88
|
+
when using as a Memcache connection
|
89
|
+
)
|
90
|
+
end
|
91
|
+
@connection = value
|
92
|
+
end
|
93
|
+
@connection ||= Dalli::Client.new(@host || "localhost:11211")
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require "social_avatar_proxy/configuration/file_cache"
|
2
|
+
require "social_avatar_proxy/configuration/http_cache"
|
3
|
+
require "social_avatar_proxy/configuration/memcache"
|
4
|
+
require "social_avatar_proxy/configuration/caches"
|
5
|
+
require "social_avatar_proxy/avatar_file"
|
6
|
+
|
7
|
+
module SocialAvatarProxy
|
8
|
+
class Configuration
|
9
|
+
def initialize
|
10
|
+
@memcache = Memcache.new
|
11
|
+
@file_cache = FileCache.new
|
12
|
+
@http_cache = HttpCache.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# updates the configuration
|
16
|
+
def configure(&block)
|
17
|
+
instance_eval(&block)
|
18
|
+
end
|
19
|
+
|
20
|
+
# configures or retrieves the file cache instance
|
21
|
+
def file_cache(&block)
|
22
|
+
if block_given?
|
23
|
+
@file_cache.configure(&block)
|
24
|
+
else
|
25
|
+
@file_cache
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# configures or retrieves the http cache instance
|
30
|
+
def http_cache(&block)
|
31
|
+
if block_given?
|
32
|
+
@http_cache.configure(&block)
|
33
|
+
else
|
34
|
+
@http_cache
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# configures or retrieves the memcache cache instance
|
39
|
+
def memcache(&block)
|
40
|
+
if block_given?
|
41
|
+
@memcache.configure(&block)
|
42
|
+
else
|
43
|
+
@memcache
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# serves the file via a header
|
48
|
+
def x_send_file
|
49
|
+
serve_via(:x_send_file)
|
50
|
+
end
|
51
|
+
|
52
|
+
# serves the file via an x_accel_redirect
|
53
|
+
def x_accel_redirect
|
54
|
+
serve_via(:x_accel_redirect)
|
55
|
+
end
|
56
|
+
|
57
|
+
# returns how the app should serve the file
|
58
|
+
# defaults to streaming the file data
|
59
|
+
def delivery_method
|
60
|
+
@serve_via ||= :stream
|
61
|
+
end
|
62
|
+
|
63
|
+
# returns a Caches object containing the configured caches
|
64
|
+
def caches
|
65
|
+
Caches.new.tap do |set|
|
66
|
+
set.push(@memcache) if @memcache.enabled?
|
67
|
+
set.push(@file_cache) if @file_cache.enabled?
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def default_image(value = nil)
|
72
|
+
if value
|
73
|
+
unless File.exist?(value) && File.readable?(value)
|
74
|
+
raise ArgumentError, "#{value} does not exist, or is unreadable"
|
75
|
+
end
|
76
|
+
@default_image = value
|
77
|
+
end
|
78
|
+
@default_image && AvatarFile.new(@default_image).tap do |file|
|
79
|
+
file.content_type(default_image_content_type)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def default_image_content_type(value = nil)
|
84
|
+
@default_image_content_type = value if value
|
85
|
+
[
|
86
|
+
@default_image_content_type,
|
87
|
+
auto_detect_default_image_content_type,
|
88
|
+
"application/octet-stream"
|
89
|
+
].compact.first
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
def serve_via(value)
|
94
|
+
@serve_via = value
|
95
|
+
end
|
96
|
+
|
97
|
+
def auto_detect_default_image_content_type
|
98
|
+
case File.extname(@default_image)
|
99
|
+
when ".png"
|
100
|
+
"image/png"
|
101
|
+
when ".gif"
|
102
|
+
"image/gif"
|
103
|
+
when ".svg"
|
104
|
+
"application/svg+xml"
|
105
|
+
when /\.jpe?g/
|
106
|
+
"image/jpeg"
|
107
|
+
else
|
108
|
+
"application/octet-stream"
|
109
|
+
end if @default_image
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
require "social_avatar_proxy/config"
|
2
|
+
require "rack"
|
3
|
+
|
4
|
+
module SocialAvatarProxy
|
5
|
+
class Response < Rack::Response
|
6
|
+
def self.build(options = {})
|
7
|
+
# build the response
|
8
|
+
response = ResponseBuilder.new(options).response
|
9
|
+
# apply the HTTP caching to the response
|
10
|
+
SocialAvatarProxy::Config.http_cache.apply_caching_headers(response)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class ResponseBuilder
|
15
|
+
attr_reader :service, :identifier
|
16
|
+
|
17
|
+
def initialize(options)
|
18
|
+
@service = options.fetch(:service)
|
19
|
+
@identifier = options.fetch(:identifier)
|
20
|
+
end
|
21
|
+
|
22
|
+
def response
|
23
|
+
return @response if defined?(@response)
|
24
|
+
if file
|
25
|
+
@response = Response.new do |response|
|
26
|
+
write_file(response)
|
27
|
+
set_file_headers(response)
|
28
|
+
end
|
29
|
+
else
|
30
|
+
@response = Rack::Response.new("Not Found", 404)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def set_file_headers(response)
|
35
|
+
# set the modified time
|
36
|
+
if file.respond_to?(:mtime) && file.mtime
|
37
|
+
response["Last-Modified"] = file.mtime.httpdate
|
38
|
+
end
|
39
|
+
# set the content type
|
40
|
+
if file.respond_to?(:content_type) && file.content_type
|
41
|
+
response["Content-Type"] = file.content_type
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def write_file(response)
|
46
|
+
# configure the file delivery
|
47
|
+
case Config.delivery_method
|
48
|
+
when :x_accel_redirect
|
49
|
+
# set the NGINX header
|
50
|
+
response["X-Accel-Redirect"] = file.path
|
51
|
+
when :x_send_file
|
52
|
+
# set the Apache header
|
53
|
+
response["X-Sendfile"] = file.path
|
54
|
+
else
|
55
|
+
begin
|
56
|
+
# read the file contents
|
57
|
+
data = file.read
|
58
|
+
# write it to the response
|
59
|
+
response.write(data)
|
60
|
+
ensure
|
61
|
+
# close the file
|
62
|
+
file.close
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def file
|
68
|
+
@file ||= SocialAvatarProxy::Config.caches.fetch({
|
69
|
+
service: service,
|
70
|
+
identifier: identifier
|
71
|
+
}) do
|
72
|
+
remote_avatar_file || default_image
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
def default_image
|
78
|
+
SocialAvatarProxy::Config.default_image
|
79
|
+
end
|
80
|
+
|
81
|
+
def remote_avatar_file
|
82
|
+
# titleize the service name
|
83
|
+
service_name = service.gsub(/[\_\-]/, " ").gsub(/\b([a-z])/) do |match|
|
84
|
+
match.upcase
|
85
|
+
end
|
86
|
+
# pass it onto the service, and retrieve the file
|
87
|
+
SocialAvatarProxy.const_get("#{service_name}Avatar").new(identifier).file
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
data/lib/social_avatar_proxy.rb
CHANGED
@@ -4,6 +4,7 @@ require "social_avatar_proxy/app"
|
|
4
4
|
require "social_avatar_proxy/avatar"
|
5
5
|
require "social_avatar_proxy/facebook_avatar"
|
6
6
|
require "social_avatar_proxy/twitter_avatar"
|
7
|
+
require "social_avatar_proxy/config"
|
7
8
|
|
8
9
|
if defined?(Rails) && defined?(Rails::Engine)
|
9
10
|
require "social_avatar_proxy/engine"
|
data/social-avatar-proxy.gemspec
CHANGED
@@ -6,9 +6,7 @@ describe SocialAvatarProxy::App do
|
|
6
6
|
Rack::MockRequest.new(app)
|
7
7
|
end
|
8
8
|
|
9
|
-
let(:app) { SocialAvatarProxy::App.new
|
10
|
-
|
11
|
-
let(:options) { Hash.new }
|
9
|
+
let(:app) { SocialAvatarProxy::App.new }
|
12
10
|
|
13
11
|
let(:successful_response) do
|
14
12
|
response = Net::HTTPSuccess.new("1.1", 200, "Success")
|
@@ -23,7 +21,13 @@ describe SocialAvatarProxy::App do
|
|
23
21
|
|
24
22
|
let(:response) { nil }
|
25
23
|
|
24
|
+
let(:image_path) do
|
25
|
+
File.join(File.dirname(__FILE__), "../fixtures/image.jpg")
|
26
|
+
end
|
27
|
+
|
26
28
|
before(:each) do
|
29
|
+
SocialAvatarProxy.send(:remove_const, :Config)
|
30
|
+
SocialAvatarProxy.const_set(:Config, SocialAvatarProxy::Configuration.new)
|
27
31
|
klass = SocialAvatarProxy::Avatar
|
28
32
|
klass.any_instance.stub(:response).and_return(response)
|
29
33
|
end
|
@@ -41,9 +45,8 @@ describe SocialAvatarProxy::App do
|
|
41
45
|
subject { SocialAvatarProxy::App }
|
42
46
|
|
43
47
|
it "should return a object providing the path helpers" do
|
44
|
-
|
45
|
-
expect(subject.routes
|
46
|
-
expect(subject.routes(options).twitter_avatar_path(2202971)).to match(/^\/.*$/)
|
48
|
+
expect(subject.routes.facebook_avatar_path("ryandtownsend")).to match(/^\/.*$/)
|
49
|
+
expect(subject.routes.twitter_avatar_path(2202971)).to match(/^\/.*$/)
|
47
50
|
end
|
48
51
|
end
|
49
52
|
|
@@ -62,17 +65,6 @@ describe SocialAvatarProxy::App do
|
|
62
65
|
it "should return a 404 response" do
|
63
66
|
expect(subject.get(path).status).to eq(404)
|
64
67
|
end
|
65
|
-
|
66
|
-
context "with a custom image" do
|
67
|
-
before(:each) do
|
68
|
-
image_path = File.join(File.dirname(__FILE__), "../fixtures/image.jpg")
|
69
|
-
SocialAvatarProxy::Config.default_image = image_path
|
70
|
-
end
|
71
|
-
|
72
|
-
it "should render the image" do
|
73
|
-
expect(subject.get(path)["Content-Type"]).to eq("image/jpeg")
|
74
|
-
end
|
75
|
-
end
|
76
68
|
end
|
77
69
|
|
78
70
|
context "given a valid path" do
|
@@ -104,17 +96,76 @@ describe SocialAvatarProxy::App do
|
|
104
96
|
expect(subject.get(path).status).to eq(200)
|
105
97
|
end
|
106
98
|
|
107
|
-
|
108
|
-
|
99
|
+
context "with memcache in place" do
|
100
|
+
before(:each) do
|
101
|
+
SocialAvatarProxy.configure do
|
102
|
+
memcache do
|
103
|
+
enable
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
it "should attempt to get the image from the cache" do
|
109
|
+
expect(subject.get(path).status).to eq(200)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
context "with file cache in place" do
|
114
|
+
before(:each) do
|
115
|
+
SocialAvatarProxy.configure do
|
116
|
+
file_cache do
|
117
|
+
directory File.join(File.dirname(__FILE__), "../../.tmp")
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
it "should attempt to get the image from the cache" do
|
123
|
+
expect(subject.get(path).status).to eq(200)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
context "with memcache and the file cache in place" do
|
128
|
+
before(:each) do
|
129
|
+
SocialAvatarProxy.configure do
|
130
|
+
memcache do
|
131
|
+
enable
|
132
|
+
end
|
133
|
+
file_cache do
|
134
|
+
directory File.join(File.dirname(__FILE__), "../../.tmp")
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
it "should attempt to get the image from the cache" do
|
140
|
+
expect(subject.get(path).status).to eq(200)
|
141
|
+
end
|
109
142
|
end
|
110
143
|
|
111
|
-
|
112
|
-
|
144
|
+
context "with Cache-Control set with Max-Age and Max-Stale as 1 day" do
|
145
|
+
before(:each) do
|
146
|
+
SocialAvatarProxy.configure do
|
147
|
+
http_cache do
|
148
|
+
cache_control({
|
149
|
+
max_stale: 86400,
|
150
|
+
max_age: 86400,
|
151
|
+
public: true
|
152
|
+
})
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
it "should return a Cache-Control header" do
|
158
|
+
expect(subject.get(path).headers["Cache-Control"]).to eq("max-stale=86400, max-age=86400, public")
|
159
|
+
end
|
113
160
|
end
|
114
161
|
|
115
162
|
context "given Cache-Control is turned off" do
|
116
|
-
|
117
|
-
|
163
|
+
before(:each) do
|
164
|
+
SocialAvatarProxy.configure do
|
165
|
+
http_cache do
|
166
|
+
disable
|
167
|
+
end
|
168
|
+
end
|
118
169
|
end
|
119
170
|
|
120
171
|
it "should not return a Cache-Control header" do
|
@@ -123,8 +174,12 @@ describe SocialAvatarProxy::App do
|
|
123
174
|
end
|
124
175
|
|
125
176
|
context "given Cache-Control is set to private" do
|
126
|
-
|
127
|
-
|
177
|
+
before(:each) do
|
178
|
+
SocialAvatarProxy.configure do
|
179
|
+
http_cache do
|
180
|
+
cache_control({ public: false })
|
181
|
+
end
|
182
|
+
end
|
128
183
|
end
|
129
184
|
|
130
185
|
it "should return a Cache-Control header marked for private use" do
|
@@ -133,8 +188,12 @@ describe SocialAvatarProxy::App do
|
|
133
188
|
end
|
134
189
|
|
135
190
|
context "given Expires is turned off" do
|
136
|
-
|
137
|
-
|
191
|
+
before(:each) do
|
192
|
+
SocialAvatarProxy.configure do
|
193
|
+
http_cache do
|
194
|
+
cache_control({ expires: false })
|
195
|
+
end
|
196
|
+
end
|
138
197
|
end
|
139
198
|
|
140
199
|
it "should not return a Cache-Control header" do
|
@@ -143,8 +202,12 @@ describe SocialAvatarProxy::App do
|
|
143
202
|
end
|
144
203
|
|
145
204
|
context "given Expires is set to two days" do
|
146
|
-
|
147
|
-
|
205
|
+
before(:each) do
|
206
|
+
SocialAvatarProxy.configure do
|
207
|
+
http_cache do
|
208
|
+
expires 86400 * 2
|
209
|
+
end
|
210
|
+
end
|
148
211
|
end
|
149
212
|
|
150
213
|
it "should not return a Cache-Control header" do
|
@@ -155,11 +218,24 @@ describe SocialAvatarProxy::App do
|
|
155
218
|
|
156
219
|
context "that fails to find an avatar" do
|
157
220
|
let(:response) { not_found_response }
|
221
|
+
let(:path) { "/facebook/someRandomUserThatDoesntExist" }
|
158
222
|
|
159
223
|
it "should return a 404 response" do
|
160
|
-
path = "/facebook/someRandomUserThatDoesntExist"
|
161
224
|
expect(subject.get(path).status).to eq(404)
|
162
225
|
end
|
226
|
+
|
227
|
+
context "with a custom image" do
|
228
|
+
before(:each) do
|
229
|
+
ip = image_path
|
230
|
+
SocialAvatarProxy.configure do
|
231
|
+
default_image(ip)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
it "should render the image" do
|
236
|
+
expect(subject.get(path)["Content-Type"]).to eq("image/jpeg")
|
237
|
+
end
|
238
|
+
end
|
163
239
|
end
|
164
240
|
end
|
165
241
|
end
|
@@ -72,20 +72,6 @@ describe SocialAvatarProxy::Avatar do
|
|
72
72
|
expect(subject.last_modified.to_i).to eq(timestamp.to_i)
|
73
73
|
end
|
74
74
|
end
|
75
|
-
|
76
|
-
describe "#modified_since?" do
|
77
|
-
context "given a date older than the timestamp" do
|
78
|
-
it "should return true" do
|
79
|
-
expect(subject.modified_since?(timestamp - 86400)).to be_true
|
80
|
-
end
|
81
|
-
end
|
82
|
-
|
83
|
-
context "given a date newer than the timestamp" do
|
84
|
-
it "should return true" do
|
85
|
-
expect(subject.modified_since?(Time.now)).to be_false
|
86
|
-
end
|
87
|
-
end
|
88
|
-
end
|
89
75
|
end
|
90
76
|
|
91
77
|
context "with no last-modified header" do
|
@@ -3,8 +3,13 @@ require "spec_helper"
|
|
3
3
|
describe SocialAvatarProxy::Config do
|
4
4
|
subject { SocialAvatarProxy::Config }
|
5
5
|
|
6
|
-
|
7
|
-
|
6
|
+
around(:each) do
|
7
|
+
SocialAvatarProxy.send(:remove_const, :Config)
|
8
|
+
SocialAvatarProxy.const_set(:Config, SocialAvatarProxy::Configuration.new)
|
9
|
+
end
|
10
|
+
|
11
|
+
let(:image_path) do
|
12
|
+
File.join(File.dirname(__FILE__), "../fixtures/image.jpg")
|
8
13
|
end
|
9
14
|
|
10
15
|
describe "::default_image" do
|
@@ -13,45 +18,43 @@ describe SocialAvatarProxy::Config do
|
|
13
18
|
end
|
14
19
|
|
15
20
|
it "should store a path" do
|
16
|
-
subject.
|
17
|
-
|
21
|
+
subject.configure do
|
22
|
+
default_image(image_path)
|
23
|
+
end
|
24
|
+
expect(subject.default_image.path).to eq(image_path)
|
18
25
|
end
|
19
26
|
end
|
20
27
|
|
21
28
|
describe "::default_image_content_type" do
|
22
|
-
|
23
|
-
|
24
|
-
subject.default_image = "/test.JPG"
|
25
|
-
expect(subject.default_image_content_type).to eq "image/jpeg"
|
26
|
-
end
|
29
|
+
it "should default to nil" do
|
30
|
+
expect(subject.default_image_content_type).to be_nil
|
27
31
|
end
|
28
32
|
|
29
|
-
context "with
|
30
|
-
it "should return
|
31
|
-
subject.
|
32
|
-
|
33
|
+
context "with an overridden content type" do
|
34
|
+
it "should return the value set" do
|
35
|
+
subject.configure do
|
36
|
+
default_image(image_path)
|
37
|
+
default_image_content_type("something/banana")
|
38
|
+
end
|
39
|
+
expect(subject.default_image_content_type).to eq("something/banana")
|
33
40
|
end
|
34
41
|
end
|
35
|
-
|
36
|
-
context "with a
|
37
|
-
|
38
|
-
subject.
|
39
|
-
|
42
|
+
|
43
|
+
context "with a default_image set" do
|
44
|
+
before(:each) do
|
45
|
+
subject.configure do
|
46
|
+
default_image(image_path)
|
47
|
+
end
|
40
48
|
end
|
41
|
-
end
|
42
49
|
|
43
|
-
|
44
|
-
|
45
|
-
subject.default_image = "/test.svg"
|
46
|
-
expect(subject.default_image_content_type).to eq "image/svg+xml"
|
50
|
+
it "should return the calculated content type" do
|
51
|
+
expect(subject.default_image_content_type).to eq("image/jpeg")
|
47
52
|
end
|
48
|
-
end
|
49
53
|
|
50
|
-
|
51
|
-
|
52
|
-
subject.default_image = "/test"
|
53
|
-
expect(subject.default_image_content_type).to eq "application/octet-stream"
|
54
|
+
it "should set the content type on the image" do
|
55
|
+
expect(subject.default_image.content_type).to eq("image/jpeg")
|
54
56
|
end
|
55
57
|
end
|
56
58
|
end
|
59
|
+
|
57
60
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: social-avatar-proxy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2012-
|
12
|
+
date: 2012-11-06 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rack
|
@@ -27,6 +27,22 @@ dependencies:
|
|
27
27
|
- - ! '>='
|
28
28
|
- !ruby/object:Gem::Version
|
29
29
|
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: dalli
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
30
46
|
- !ruby/object:Gem::Dependency
|
31
47
|
name: combustion
|
32
48
|
requirement: !ruby/object:Gem::Requirement
|
@@ -126,11 +142,19 @@ files:
|
|
126
142
|
- lib/social_avatar_proxy.rb
|
127
143
|
- lib/social_avatar_proxy/app.rb
|
128
144
|
- lib/social_avatar_proxy/avatar.rb
|
145
|
+
- lib/social_avatar_proxy/avatar_file.rb
|
129
146
|
- lib/social_avatar_proxy/config.rb
|
147
|
+
- lib/social_avatar_proxy/configuration.rb
|
148
|
+
- lib/social_avatar_proxy/configuration/cache.rb
|
149
|
+
- lib/social_avatar_proxy/configuration/caches.rb
|
150
|
+
- lib/social_avatar_proxy/configuration/file_cache.rb
|
151
|
+
- lib/social_avatar_proxy/configuration/http_cache.rb
|
152
|
+
- lib/social_avatar_proxy/configuration/memcache.rb
|
130
153
|
- lib/social_avatar_proxy/engine.rb
|
131
154
|
- lib/social_avatar_proxy/facebook_avatar.rb
|
132
155
|
- lib/social_avatar_proxy/path_helpers.rb
|
133
156
|
- lib/social_avatar_proxy/remote_file_resolver.rb
|
157
|
+
- lib/social_avatar_proxy/response.rb
|
134
158
|
- lib/social_avatar_proxy/routes.rb
|
135
159
|
- lib/social_avatar_proxy/timeout_error.rb
|
136
160
|
- lib/social_avatar_proxy/too_many_redirects_error.rb
|
@@ -163,15 +187,21 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
163
187
|
- - ! '>='
|
164
188
|
- !ruby/object:Gem::Version
|
165
189
|
version: '0'
|
190
|
+
segments:
|
191
|
+
- 0
|
192
|
+
hash: -284777560749383164
|
166
193
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
167
194
|
none: false
|
168
195
|
requirements:
|
169
196
|
- - ! '>='
|
170
197
|
- !ruby/object:Gem::Version
|
171
198
|
version: '0'
|
199
|
+
segments:
|
200
|
+
- 0
|
201
|
+
hash: -284777560749383164
|
172
202
|
requirements: []
|
173
203
|
rubyforge_project:
|
174
|
-
rubygems_version: 1.8.
|
204
|
+
rubygems_version: 1.8.24
|
175
205
|
signing_key:
|
176
206
|
specification_version: 3
|
177
207
|
summary: This gem acts as a proxy for avatars on Twitter & Facebook.
|