snapimage 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. data/.autotest +3 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +2 -0
  4. data/Gemfile +4 -0
  5. data/LICENSE +22 -0
  6. data/README.md +29 -0
  7. data/Rakefile +2 -0
  8. data/bin/snapimage_generate_config +63 -0
  9. data/bin/snapimage_server +55 -0
  10. data/lib/snapimage.rb +24 -0
  11. data/lib/snapimage/config.rb +51 -0
  12. data/lib/snapimage/exceptions.rb +25 -0
  13. data/lib/snapimage/image/image.rb +96 -0
  14. data/lib/snapimage/image/image_name_utils.rb +131 -0
  15. data/lib/snapimage/middleware.rb +27 -0
  16. data/lib/snapimage/rack/request.rb +19 -0
  17. data/lib/snapimage/rack/request_file.rb +26 -0
  18. data/lib/snapimage/rack/response.rb +51 -0
  19. data/lib/snapimage/server.rb +50 -0
  20. data/lib/snapimage/server_actions/server_actions.authorize.rb +69 -0
  21. data/lib/snapimage/server_actions/server_actions.delete_resource_images.rb +23 -0
  22. data/lib/snapimage/server_actions/server_actions.generate_image.rb +167 -0
  23. data/lib/snapimage/server_actions/server_actions.list_resource_images.rb +23 -0
  24. data/lib/snapimage/server_actions/server_actions.sync_resource.rb +78 -0
  25. data/lib/snapimage/storage/storage.rb +120 -0
  26. data/lib/snapimage/storage/storage_server.local.rb +120 -0
  27. data/lib/snapimage/storage/storage_server.rb +110 -0
  28. data/lib/snapimage/version.rb +3 -0
  29. data/snapimage.gemspec +27 -0
  30. data/spec/acceptance/delete_resource_images_spec.rb +166 -0
  31. data/spec/acceptance/list_resource_images_spec.rb +158 -0
  32. data/spec/acceptance/modify_spec.rb +165 -0
  33. data/spec/acceptance/sync_spec.rb +260 -0
  34. data/spec/acceptance/upload_spec.rb +235 -0
  35. data/spec/snapimage/config_spec.rb +56 -0
  36. data/spec/snapimage/image/image_name_utils_spec.rb +127 -0
  37. data/spec/snapimage/image/image_spec.rb +71 -0
  38. data/spec/snapimage/middleware_spec.rb +27 -0
  39. data/spec/snapimage/rack/request_file_spec.rb +15 -0
  40. data/spec/snapimage/rack/request_spec.rb +52 -0
  41. data/spec/snapimage/rack/response_spec.rb +33 -0
  42. data/spec/snapimage/server_actions/server_actions.authorize_spec.rb +67 -0
  43. data/spec/snapimage/server_actions/server_actions.generate_image_spec.rb +146 -0
  44. data/spec/snapimage/server_actions/server_actions.sync_resource_spec.rb +91 -0
  45. data/spec/snapimage/server_spec.rb +55 -0
  46. data/spec/snapimage/storage/assets/local/resource_1/12345678-1x1-0x0x1x1-1x1-1.gif +0 -0
  47. data/spec/snapimage/storage/assets/local/resource_1/12345678-1x1-0x0x1x1-300x200-0.jpg +0 -0
  48. data/spec/snapimage/storage/assets/local/resource_1/12345678-1x1.png +0 -0
  49. data/spec/snapimage/storage/assets/local/resource_2/12345678-1x1-0x0x1x1-1x1-1.gif +0 -0
  50. data/spec/snapimage/storage/assets/local/resource_2/12345678-1x1-0x0x1x1-300x200-0.jpg +0 -0
  51. data/spec/snapimage/storage/assets/local/resource_2/12345678-1x1.png +0 -0
  52. data/spec/snapimage/storage/storage_server.local_spec.rb +150 -0
  53. data/spec/snapimage/storage/storage_server_spec.rb +97 -0
  54. data/spec/snapimage/storage/storage_spec.rb +49 -0
  55. data/spec/spec_helper.rb +18 -0
  56. data/spec/support/assets/config.json +8 -0
  57. data/spec/support/assets/config.yml +9 -0
  58. data/spec/support/assets/stub-1x1.png +0 -0
  59. data/spec/support/assets/stub-2048x100.png +0 -0
  60. data/spec/support/assets/stub-300x200.png +0 -0
  61. metadata +272 -0
@@ -0,0 +1,27 @@
1
+ module SnapImage
2
+ # SnapImage API Rack Middleware to handle all SnapImage API calls.
3
+ class Middleware
4
+ # Arguments:
5
+ # * app:: Rack application
6
+ # * path:: The URL path to access the SnapImage API (defaults to "/snapimage_api")
7
+ # * config:: Filename of the YAML or JSON config file or a config Hash
8
+ def initialize(app, options = {})
9
+ @app = app
10
+ @path = options[:path] || "/snapimage_api"
11
+ # TODO: If no config is given, set defaults.
12
+ # For example, if it's a Rails app, set the filename to
13
+ # config/snapimage.yml.
14
+ raise SnapImage::MissingConfig, "Missing config." if options[:config].nil?
15
+ @config = SnapImage::Config.new(options[:config])
16
+ end
17
+
18
+ def call(env)
19
+ request = SnapImage::Request.new(env)
20
+ if request.path_info == @path
21
+ SnapImage::Server.new(request, @config).call
22
+ else
23
+ @app.call(env)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ module SnapImage
2
+ class Request < Rack::Request
3
+ def bad_request?
4
+ !(self.post? && self.POST["json"] && self.json["action"] && self.json["resource_identifier"])
5
+ end
6
+
7
+ # NOTE: Call bad_request? first to make sure there is json to parse.
8
+ def json
9
+ @json ||= JSON.parse(self.POST["json"])
10
+ end
11
+
12
+ # Returns a SnapImage::RequestFile which encapsulates the file that Rack
13
+ # provides. Returns nil if there is no file.
14
+ def file
15
+ return nil unless self.POST["file"]
16
+ @file ||= SnapImage::RequestFile.new(self.POST["file"])
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ module SnapImage
2
+ class RequestFile
3
+ # The file comes through Rack's request's POST like this:
4
+ # {
5
+ # :filename=>"jpeg.jpeg",
6
+ # :type=>"image/jpeg",
7
+ # :name=>"file",
8
+ # :tempfile=>#<File:/tmp/RackMultipart20120628-19317-1w4ouxp>,
9
+ # :head=>"Content-Disposition: form-data; name=\"file\"; filename=\"jpeg.jpeg\"\r\nContent-Type: image/jpeg\r\n"}
10
+ # }
11
+ def initialize(file)
12
+ @file = file
13
+ end
14
+
15
+ def file
16
+ @file[:tempfile]
17
+ end
18
+
19
+ def type
20
+ return @type if @type
21
+ @type = File.extname(@file[:filename])[1..-1]
22
+ @type = "jpg" if type == "jpeg"
23
+ @type
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ module SnapImage
2
+ class Response < Rack::Response
3
+ attr_accessor :content_type, :template, :json
4
+
5
+ def initialize(options = {})
6
+ @content_type = options[:content_type] || "text/json"
7
+ @template = options[:template] || "{{json}}"
8
+ @json = {}
9
+ super
10
+ end
11
+
12
+ def set_success(info = {})
13
+ info[:message] ||= "Success"
14
+ @json = { status_code: 200 }.merge(info)
15
+ end
16
+
17
+ def set_bad_request
18
+ @json = { status_code: 400, message: "Bad Request" }
19
+ end
20
+
21
+ def set_authorization_required
22
+ @json = { status_code: 401, message: "Authorization Required" }
23
+ end
24
+
25
+ def set_authorization_failed
26
+ @json = { status_code: 402, message: "Authorization Failed" }
27
+ end
28
+
29
+ def set_invalid_image_identifier
30
+ @json = { status_code: 403, message: "Invalid Image Identifier" }
31
+ end
32
+
33
+ def set_invalid_resource_identifier
34
+ @json = { status_code: 404, message: "Invalid Resource Identifier" }
35
+ end
36
+
37
+ def set_internal_server_error
38
+ @json = { status_code: 500, message: "Internal Server Error" }
39
+ end
40
+
41
+ def set_not_implemented
42
+ @json = { status_code: 501, message: "Not Implemented" }
43
+ end
44
+
45
+ def finish
46
+ self.body = [@template.gsub(/{{json}}/, @json.to_json)]
47
+ self["Content-Type"] = @content_type
48
+ super
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ module SnapImage
2
+ class Server
3
+ ACTIONS = ["generate_image", "sync_resource", "delete_resource_images", "list_resource_images"]
4
+ RESOURCE_ID_REGEXP = /^[a-z0-9_-]+(\/[a-z0-9_-]+)*$/
5
+
6
+ # Arguments:
7
+ # * request:: Rack::Request
8
+ def initialize(request, config)
9
+ @request = request
10
+ @config = config
11
+ @storage = @config.storage
12
+ end
13
+
14
+ # Handles the request and returns a Rack::Response.
15
+ def call
16
+ @response = SnapImage::Response.new
17
+ begin
18
+ raise SnapImage::BadRequest if @request.bad_request?
19
+ raise SnapImage::InvalidResourceIdentifier unless !!@request.json["resource_identifier"].match(SnapImage::Server::RESOURCE_ID_REGEXP)
20
+ @response.content_type = @request.json["response_content_type"] if @request.json["response_content_type"]
21
+ @response.template = @request.json["response_template"] if @request.json["response_template"]
22
+ action = @request.json["action"]
23
+ raise SnapImage::ActionNotImplemented unless ACTIONS.include?(action)
24
+ @response = get_action_class(action).new(@config, @request, @response).call
25
+ rescue SnapImage::BadRequest
26
+ @response.set_bad_request
27
+ rescue SnapImage::ActionNotImplemented
28
+ @response.set_not_implemented
29
+ rescue SnapImage::AuthorizationRequired
30
+ @response.set_authorization_required
31
+ rescue SnapImage::AuthorizationFailed
32
+ @response.set_authorization_failed
33
+ rescue SnapImage::InvalidImageIdentifier
34
+ @response.set_invalid_image_identifier
35
+ rescue SnapImage::InvalidResourceIdentifier
36
+ @response.set_invalid_resource_identifier
37
+ #rescue
38
+ #@response.set_internal_server_error
39
+ end
40
+ @response.finish
41
+ end
42
+
43
+ private
44
+
45
+ def get_action_class(action)
46
+ klassname = action.split("_").map { |t| t.capitalize }.join("")
47
+ SnapImage::ServerActions.const_get(klassname)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,69 @@
1
+ module SnapImage
2
+ module ServerActions
3
+ module Authorize
4
+ def get_token(role)
5
+ @request.json["#{role}_security_token"]
6
+ end
7
+
8
+ # Arguments:
9
+ # * role:: Can be either :client or :server
10
+ def token_available?(role)
11
+ !!get_token(role)
12
+ end
13
+
14
+ # A string is generated using
15
+ # * role:: "client" or "server"
16
+ # * date:: Date in the format "YYYY-MM-DD"
17
+ # * salt:: A shared salt
18
+ # * resource_id:: The resource's identifier
19
+ # and concatenated as "role:date:salt:resource_id".
20
+ #
21
+ # A SHA1 digest is generated off of the string to create a token.
22
+ #
23
+ # 3 tokens are generated: [yesterday, today, tomorrow].
24
+ def generate_tokens(role)
25
+ salt = @config["security_salt"]
26
+ now = Time.now
27
+ yesterday = (now - 24*60*60).strftime("%Y-%m-%d")
28
+ today = now.strftime("%Y-%m-%d")
29
+ tomorrow = (now + 24*60*60).strftime("%Y-%m-%d")
30
+ resource_id = @request.json["resource_identifier"]
31
+ [
32
+ Digest::SHA1.hexdigest("#{role}:#{yesterday}:#{salt}:#{resource_id}"),
33
+ Digest::SHA1.hexdigest("#{role}:#{today}:#{salt}:#{resource_id}"),
34
+ Digest::SHA1.hexdigest("#{role}:#{tomorrow}:#{salt}:#{resource_id}")
35
+ ]
36
+ end
37
+
38
+ # If "security_salt" is set in the config, authorization is performed.
39
+ #
40
+ # A string is generated using
41
+ # * role:: "client" or "server"
42
+ # * date:: Date in the format "YYYY-MM-DD"
43
+ # * salt:: A shared salt
44
+ # * resource_id:: The resource's identifier
45
+ # and concatenated as "role:date:salt:resource_id".
46
+ #
47
+ # A SHA1 digest is generated off of the string to create a token. A token
48
+ # for yesterday, today, and tomorrow are generated and used to compare
49
+ # with the security token.
50
+ #
51
+ # When authorization fails, an error is raised. Authorization can fail in
52
+ # 2 ways.
53
+ # * AuthorizationRequired:: The role's security token is missing
54
+ # * AuthorizationFailed:: The security token did not match the generated token
55
+ #
56
+ # If authorization is successful, true is returned.
57
+ #
58
+ # Arguments:
59
+ # * role:: Can be either :client or :server
60
+ def authorize(role)
61
+ if @config["security_salt"]
62
+ raise SnapImage::AuthorizationRequired unless token_available?(role)
63
+ raise SnapImage::AuthorizationFailed unless generate_tokens(role).include?(get_token(role))
64
+ end
65
+ return true
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,23 @@
1
+ module SnapImage
2
+ module ServerActions
3
+ class DeleteResourceImages
4
+ include SnapImage::ServerActions::Authorize
5
+
6
+ def initialize(config, request, response)
7
+ @config = config
8
+ @storage = config.storage
9
+ @request = request
10
+ @response = response
11
+ end
12
+
13
+ def call
14
+ authorize(:server)
15
+ @response.set_success(
16
+ message: "Delete Resource Images Successful",
17
+ deleted_image_urls: @storage.delete_resource_images(@request.json["resource_identifier"])
18
+ )
19
+ @response
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,167 @@
1
+ module SnapImage
2
+ module ServerActions
3
+ class GenerateImage
4
+ include SnapImage::ServerActions::Authorize
5
+
6
+ def initialize(config, request, response)
7
+ @config = config
8
+ @storage = config.storage
9
+ @request = request
10
+ @response = response
11
+ end
12
+
13
+ def call
14
+ authorize(:client)
15
+ if request_valid?
16
+ image = get_image_for_modification
17
+ parts = SnapImage::ImageNameUtils.get_image_name_parts(image.public_url)
18
+ result = modify_image(image)
19
+ modified_image = result[:image]
20
+ modified_image_name = result[:name]
21
+ stored_image = @storage.add_image(modified_image, modified_image_name, @request.json["resource_identifier"])
22
+ @response.set_success(
23
+ message: "Get Modified Image Successful",
24
+ image_url: stored_image.public_url,
25
+ image_width: stored_image.width,
26
+ image_height: stored_image.height
27
+ )
28
+ else
29
+ @response.set_bad_request
30
+ end
31
+ @response
32
+ end
33
+
34
+ private
35
+
36
+ # Returns true if the request is valid. False otherwise.
37
+ def request_valid?
38
+ source_image_defined?
39
+ end
40
+
41
+ # Returns true if either "file" or JSON "url" is defined.
42
+ def source_image_defined?
43
+ !!(@request.file || @request.json["url"])
44
+ end
45
+
46
+ # Returns true if the image is being uploaded. False otherwise.
47
+ def upload?
48
+ @request.file || !@storage.local?(@request.json["url"])
49
+ end
50
+
51
+ # Returns true if cropping is required. False otherwise.
52
+ def crop?
53
+ @request.json["crop_x"] || @request.json["crop_y"] || @request.json["crop_width"] || @request.json["crop_height"]
54
+ end
55
+
56
+ # Returns true if all the cropping values are defined. False otherwise.
57
+ def crop_valid?
58
+ @request.json["crop_x"] && @request.json["crop_y"] && @request.json["crop_width"] && @request.json["crop_height"]
59
+ end
60
+
61
+ # Returns true if resizing is required. False otherwise.
62
+ def resize?
63
+ @request.json["width"] || @request.json["height"]
64
+ end
65
+
66
+ # Returns true if the image is too large and needs to be resized to fit.
67
+ # False otherwise.
68
+ def resize_to_fit?(image)
69
+ image.width > get_max_width || image.height > get_max_height
70
+ end
71
+
72
+ # Returns true if sharpening is required. False otherwise
73
+ def sharpen?
74
+ @request.json["sharpen"]
75
+ end
76
+
77
+ # Gets the max width. Takes the lesser of the JSON "max_width" or the
78
+ # server max width.
79
+ def get_max_width
80
+ server_max_width = @config["max_width"]
81
+ [(@request.json["max_width"] && @request.json["max_width"].to_i || server_max_width), server_max_width].min
82
+ end
83
+
84
+ # Gets the max height. Takes the lesser of the JSON "max_height" or the
85
+ # server max height.
86
+ def get_max_height
87
+ server_max_height = @config["max_height"]
88
+ [(@request.json["max_height"] && @request.json["max_height"].to_i || server_max_height), server_max_height].min
89
+ end
90
+
91
+ def get_image_for_modification
92
+ if upload?
93
+ # Add the image to the storage.
94
+ if @request.file
95
+ image = @storage.add_upload(@request.file, @request.json["resource_identifier"])
96
+ else
97
+ image = @storage.add_url(@request.json["url"], @request.json["resource_identifier"])
98
+ end
99
+ else
100
+ # Get the base image.
101
+ raise SnapImage::InvalidImageIdentifier unless SnapImage::ImageNameUtils.valid?(@request.json["url"])
102
+ image = @storage.get(SnapImage::ImageNameUtils.get_base_image_path(@request.json["url"]))
103
+ end
104
+ image
105
+ end
106
+
107
+ # Arguments:
108
+ # * image:: SnapImage::Image object that represents the base image
109
+ def modify_image(image)
110
+ parts = SnapImage::ImageNameUtils.get_image_name_parts(image.public_url)
111
+
112
+ # Crop.
113
+ cropped = crop?
114
+ crop = nil
115
+ if cropped
116
+ raise SnapImage::BadRequest, "Missing crop values." unless crop_valid?
117
+ crop = {
118
+ x: @request.json["crop_x"],
119
+ y: @request.json["crop_y"],
120
+ width: @request.json["crop_width"],
121
+ height: @request.json["crop_height"]
122
+ }
123
+ image = image.crop(crop[:x], crop[:y], crop[:width], crop[:height])
124
+ end
125
+
126
+ # Resize.
127
+ resized = resize?
128
+ if resized
129
+ width = @request.json["width"] && @request.json["width"].to_i
130
+ height = @request.json["height"] && @request.json["height"].to_i
131
+ if width && height
132
+ # When both width and height are specified, resize without
133
+ # maintaining the aspect ratio.
134
+ image = image.resize(width, height, false)
135
+ else
136
+ # When only one of width/height is specified, set the other to the
137
+ # max and maintain the aspect ratio.
138
+ image = image.resize(width || @config["max_width"], height || @config["max_height"])
139
+ end
140
+ end
141
+
142
+ # Resize to fit.
143
+ resized_to_fit = resize_to_fit?(image)
144
+ image = image.resize(get_max_width, get_max_height) if resized_to_fit
145
+
146
+ # Sharpen.
147
+ sharpened = sharpen?
148
+ image = image.sharpen if sharpened
149
+
150
+ # Get the dimensions at the end.
151
+ if cropped || resized || resized_to_fit || sharpened
152
+ modifications = {
153
+ crop: crop || { x: 0, y: 0, width: parts[:original_dimensions][0], height: parts[:original_dimensions][1] },
154
+ width: image.width,
155
+ height: image.height,
156
+ sharpen: sharpened
157
+ }
158
+ name = SnapImage::ImageNameUtils.generate_image_name(parts[:original_dimensions][0], parts[:original_dimensions][1], parts[:extname], {basename: parts[:basename]}.merge(modifications))
159
+ else
160
+ name = parts[:filename]
161
+ end
162
+
163
+ { image: image, name: name }
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,23 @@
1
+ module SnapImage
2
+ module ServerActions
3
+ class ListResourceImages
4
+ include SnapImage::ServerActions::Authorize
5
+
6
+ def initialize(config, request, response)
7
+ @config = config
8
+ @storage = config.storage
9
+ @request = request
10
+ @response = response
11
+ end
12
+
13
+ def call
14
+ authorize(:server)
15
+ @response.set_success(
16
+ message: "List Resource Images Successful",
17
+ image_urls: @storage.get_resource_urls(@request.json["resource_identifier"])
18
+ )
19
+ @response
20
+ end
21
+ end
22
+ end
23
+ end