snapimage 0.0.1

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