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,78 @@
1
+ module SnapImage
2
+ module ServerActions
3
+ class SyncResource
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
+ if request_valid?
16
+ @response.set_success(
17
+ message: "Image Sync Successful",
18
+ deleted_image_urls: sync
19
+ )
20
+ else
21
+ @response.set_bad_request
22
+ end
23
+ @response
24
+ end
25
+
26
+ private
27
+
28
+ # Returns true if the request is valid. False otherwise.
29
+ def request_valid?
30
+ !!(content_valid? && @request.json["sync_date_time"])
31
+ end
32
+
33
+ # Returns true if "content" in the request is valid. False otherwise.
34
+ def content_valid?
35
+ content = @request.json["content"]
36
+ !!(content && content.is_a?(Hash) && !content.empty?)
37
+ end
38
+
39
+ # Concatenates all the content and returns it.
40
+ def get_content
41
+ @request.json["content"].values.inject("") { |result, element| result + element }
42
+ end
43
+
44
+ def urls_to_keep
45
+ content = get_content
46
+ keep = {}
47
+ @storage.url_regexps.each do |regexp|
48
+ # We use #scan instead of #match because #match returns only the
49
+ # first match. #scan will return all matches that don't overlap.
50
+ # However, #scan does not behave like #match. If the regexp contains
51
+ # groupings, #scan returns only the matched groups. Otherwise, it
52
+ # returns the entire match.
53
+ # To normalize, we take the given regexp and always wrap it in a
54
+ # group to ensure that the regexp always has a group and we always
55
+ # get back the entire match.
56
+ content.scan(Regexp.new("(#{regexp.source})", regexp.options)).each do |match|
57
+ keep[match[0]] = true
58
+ # If the image is modified, make sure to keep the base image too.
59
+ if !SnapImage::ImageNameUtils.base_image?(match[0])
60
+ keep[SnapImage::ImageNameUtils.get_base_image_path(match[0])] = true
61
+ end
62
+ end
63
+ end
64
+ keep.keys
65
+ end
66
+
67
+ def sync
68
+ urls_to_delete = @storage.get_resource_urls(
69
+ @request.json["resource_identifier"],
70
+ # DateTime only deals with years and days. We need to convert to a
71
+ # Time first to handle seconds, then convert back to a DateTime.
72
+ (DateTime.parse(@request.json["sync_date_time"]).to_time - 3).to_datetime
73
+ ) - urls_to_keep
74
+ urls_to_delete.each { |url| @storage.delete(url) }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,120 @@
1
+ module SnapImage
2
+ class Storage
3
+ TYPES = %w{LOCAL}
4
+ FILE_TYPES = %w{png jpg gif}
5
+
6
+
7
+ def initialize(server_configs, primary_server_name, max_width, max_height)
8
+ @server_configs = server_configs
9
+ # TODO: Remove this once we figure out how to handle multiple servers.
10
+ raise SnapImage::InvalidConfig, "Only one storage server can be specified at the moment" unless @server_configs.size == 1
11
+ @primary_server_name = primary_server_name
12
+ @max_width = max_width
13
+ @max_height = max_height
14
+ end
15
+
16
+ # Returns an array of all the url regexps that are handled by the storage.
17
+ def url_regexps
18
+ @url_regexps ||= servers.values.map { |s| s.url_regexp }
19
+ end
20
+
21
+ # Returns true if the url is local to the storage. False otherwise.
22
+ def local?(url)
23
+ !!get_server_by_url(url)
24
+ end
25
+
26
+ # Adds the image to the storage and returns the public url to the file.
27
+ # Arguments:
28
+ # * file:: SnapImage::RequestFile
29
+ # * resource_id:: Resource identifier
30
+ def add_upload(file, resource_id)
31
+ raise UnknownFileType, "Unknown file type for upload: #{file.type}" unless FILE_TYPES.include?(file.type)
32
+ primary_server.store_file(file.file, file.type, resource_id)
33
+ end
34
+
35
+ # Adds the image to the storage from the url.
36
+ # If the image is from a storage server, nothing happens.
37
+ # If the image is from another place, the image is downloaded and added to
38
+ # the primary storage server.
39
+ # The image is returned.
40
+ # Arguments:
41
+ # * url:: A full url to the image
42
+ # * resource_id:: Resource identifier
43
+ def add_url(url, resource_id)
44
+ # If the url is local, then the image should already be in the storage.
45
+ return get(url) if get_server_by_url(url)
46
+ type = ImageNameUtils.get_image_type(url)
47
+ raise UnknownFileType, "Unknown file type for upload: #{type}" unless FILE_TYPES.include?(type)
48
+ primary_server.store_url(url, type, resource_id)
49
+ end
50
+
51
+ # Adds the image to the storage.
52
+ # Arguments:
53
+ # * image:: SnapImage::Image object
54
+ # * name:: Name of the image
55
+ # * resource_id:: Resource identifier
56
+ def add_image(image, name, resource_id)
57
+ primary_server.store_image(image, name, resource_id)
58
+ end
59
+
60
+ # Returns a SnapImage::Image using the url.
61
+ def get(url)
62
+ get_server_by_url(url).get(url)
63
+ end
64
+
65
+ # Returns all the image urls for the given resource in the storage.
66
+ # Arguments:
67
+ # * resource_id:: Filter by resource identifier
68
+ # * timestamp:: Only images that were updated before the DateTime
69
+ def get_resource_urls(resource_id, timestamp = nil)
70
+ servers.values.inject([]) { |urls, server| urls + server.get_resource_urls(resource_id, timestamp) }
71
+ end
72
+
73
+ # Deletes the given url from the storage.
74
+ # Arguments:
75
+ # * url:: URL to delete
76
+ def delete(url)
77
+ get_server_by_url(url).delete(url)
78
+ end
79
+
80
+ # Deletes all the image related to the resource.
81
+ # Arugments:
82
+ # * resource_id:: Resource identifier
83
+ def delete_resource_images(resource_id)
84
+ deleted_urls = []
85
+ servers.each do |name, server|
86
+ deleted_urls += server.delete_resource_images(resource_id)
87
+ end
88
+ deleted_urls
89
+ end
90
+
91
+ private
92
+
93
+ def servers
94
+ return @servers if @servers
95
+ @servers = {}
96
+ @server_configs.each do |config|
97
+ type = config["type"]
98
+ @servers[config["name"]] = get_server_class(type).new(config.merge("max_width" => @max_width, "max_height" => @max_height))
99
+ end
100
+ @servers
101
+ end
102
+
103
+ def primary_server
104
+ servers[@primary_server_name]
105
+ end
106
+
107
+ def get_server_class(type)
108
+ raise SnapImage::InvalidStorageConfig, "Storage server type not supported: #{type}" unless TYPES.include?(type)
109
+ klassname = type.downcase.split("_").map { |t| t.capitalize }.join("")
110
+ SnapImage::StorageServer.const_get(klassname)
111
+ end
112
+
113
+ def get_server_by_url(url)
114
+ servers.each do |name, server|
115
+ return server if server.local?(url)
116
+ end
117
+ return nil
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,120 @@
1
+ module SnapImage
2
+ module StorageServer
3
+ class Local < SnapImage::StorageServer::Base
4
+ def validate_config
5
+ super
6
+ raise InvalidStorageConfig, 'Missing "local_root"' unless @config["local_root"]
7
+ end
8
+
9
+ def store_file(file, type, resource_id)
10
+ image = SnapImage::Image.from_blob(file.read)
11
+ name = SnapImage::ImageNameUtils.generate_image_name(image.width, image.height, type)
12
+ store(image, name, resource_id)
13
+ end
14
+
15
+ def store_url(url, type, resource_id)
16
+ image = SnapImage::Image.from_blob(URI.parse(url).read)
17
+ name = SnapImage::ImageNameUtils.generate_image_name(image.width, image.height, type)
18
+ store(image, name, resource_id)
19
+ end
20
+
21
+ def store_image(image, name, resource_id)
22
+ store(image, name, resource_id)
23
+ end
24
+
25
+ def get(url)
26
+ path = public_url_to_local_path(url)
27
+ raise SnapImage::FileDoesNotExist, "Missing file: #{path}" unless File.exists?(path)
28
+ SnapImage::Image.from_path(path, url)
29
+ end
30
+
31
+ def get_resource_urls(resource_id, timestamp = nil)
32
+ urls = []
33
+ get_resource_filenames(resource_id).each do |filename|
34
+ urls.push(local_path_to_public_url(filename)) if file_modified_before_timestamp?(filename, timestamp)
35
+ end
36
+ urls
37
+ end
38
+
39
+ def delete(url)
40
+ path = public_url_to_local_path(url)
41
+ raise SnapImage::FileDoesNotExist, "Missing file: #{path}" unless File.exists?(path)
42
+ File.delete(path)
43
+ end
44
+
45
+ def delete_resource_images(resource_id)
46
+ deleted_urls = get_resource_urls(resource_id)
47
+ FileUtils.rm_rf(File.join(root, resource_id)) if deleted_urls.size > 0
48
+ deleted_urls
49
+ end
50
+
51
+ private
52
+
53
+ # Stores the image and returns a SnapImage::Image object.
54
+ # If the image does not fit within the server max width/height, the image
55
+ # is resized and the modified image is returned.
56
+ # Arguments:
57
+ # * image:: SnapImage::Image to store
58
+ # * name:: Suggested name to use
59
+ # * resource_id:: Resource identifier
60
+ def store(image, name, resource_id)
61
+ result = resize_to_fit(image, name)
62
+ image = result[:image]
63
+ name = result[:name]
64
+
65
+ # Generate the filename and public url.
66
+ local_path = File.join(resource_id, name)
67
+ image.public_url = "#{File.join(@config["public_url"], local_path)}"
68
+
69
+ # Store the file.
70
+ path = File.join(root, local_path)
71
+ # Ensure the directory exists accounting for the resource_id.
72
+ dir = File.dirname(path)
73
+ FileUtils.mkdir_p(dir)
74
+ # Write the file to the storage.
75
+ File.open(path, "wb") { |f| f.write(image.blob) }
76
+
77
+ # Return the image.
78
+ image
79
+ end
80
+
81
+ def root
82
+ unless @root_exists
83
+ FileUtils.mkdir_p(@config["local_root"]) unless File.directory?(@config["local_root"])
84
+ @root_exists = true
85
+ end
86
+ @config["local_root"]
87
+ end
88
+
89
+ def get_local_path_parts(path)
90
+ match = path.match(/#{root}\/(.+)\/([^\/]+\.(png|jpg|gif))/)
91
+ return match && {
92
+ resource_id: match[1],
93
+ filename: match[2]
94
+ }
95
+ end
96
+
97
+ def local_path_to_public_url(path)
98
+ parts = get_local_path_parts(path)
99
+ "#{File.join(@config["public_url"], parts[:resource_id], parts[:filename])}"
100
+ end
101
+
102
+ def public_url_to_local_path(url)
103
+ parts = get_url_parts(url)
104
+ path = File.join(root, parts[:path])
105
+ end
106
+
107
+ def get_resource_filenames(resource_id)
108
+ Dir.glob(File.join(root, resource_id, "/**/*.{png,jpg,gif}"))
109
+ end
110
+
111
+ # Returns true if no timestamp is given or the file was modified before
112
+ # the timestamp.
113
+ def file_modified_before_timestamp?(filename, timestamp = nil)
114
+ # File.mtime returns a Time object. Convert it to a DateTime because
115
+ # timestamp is a DateTime.
116
+ !timestamp || File.mtime(filename).to_datetime < timestamp
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,110 @@
1
+ module SnapImage
2
+ module StorageServer
3
+ class Base
4
+ # Config:
5
+ # * name:: Name of the storage.
6
+ # * public_url:: URL to acces the storage.
7
+ def initialize(config)
8
+ @config = config
9
+ validate_config
10
+ end
11
+
12
+ def name
13
+ @config["name"]
14
+ end
15
+
16
+ # Returns a regular expression for matching urls handled by this storage
17
+ # server.
18
+ def url_regexp
19
+ @url_regexp ||= /#{@config["public_url"]}\/.+?\.(png|gif|jpg)/
20
+ end
21
+
22
+ def local?(url)
23
+ !!url.match(url_regexp)
24
+ end
25
+
26
+ # Stores the file in the storage and returns a SnapImage::Image object.
27
+ # Arguments:
28
+ # * file:: File object representing the file to store.
29
+ # * type:: File type
30
+ # * resource_id:: Resource identifier.
31
+ def store_file(file, type, resource_id)
32
+ raise "#store_file needs to be overridden."
33
+ end
34
+
35
+ # Downloads the file and adds it to the storage and returns a
36
+ # SnapImage::Image object.
37
+ # Arguments:
38
+ # * url:: Url to get the image
39
+ # * type:: File type
40
+ # * resource_id:: Resource identifier.
41
+ def store_url(url, type, resource_id)
42
+ raise "#store_url needs to be overridden."
43
+ end
44
+
45
+ # Adds the image to the storage. Overwrites existing file.
46
+ # Arguments:
47
+ # * image:: SnapImage::Image object
48
+ # * name:: Name of the image
49
+ # * resource_id:: Resource identifier
50
+ def store_image(image, name, resource_id)
51
+ raise "#store_image needs to be overridden."
52
+ end
53
+
54
+ # Returns the SnapImage:Image object from the url.
55
+ def get(url)
56
+ raise "#get needs to be overriden."
57
+ end
58
+
59
+ # Returns all the image urls for the given resource in the storage.
60
+ # Arguments:
61
+ # * resource_id:: Filter by resource identifier
62
+ # * timestamp:: Only images that were updated before the DateTime
63
+ def get_resource_urls(resource_id, timestamp = nil)
64
+ raise "#get_all_urls needs to be overriden."
65
+ end
66
+
67
+ # Deletes the given url.
68
+ def delete(url)
69
+ raise "#delete needs to be overridden."
70
+ end
71
+
72
+ # Deletes the given resource images.
73
+ def delete_resource_images(resource_id)
74
+ raise "#delete_resource needs to be overridden."
75
+ end
76
+
77
+ private
78
+
79
+ # Validates the config. Subclasses should add to this.
80
+ def validate_config
81
+ raise InvalidStorageConfig, 'Missing "name"' unless @config["name"]
82
+ raise InvalidStorageConfig, 'Missing "public_url"' unless @config["public_url"]
83
+ raise InvalidStorageConfig, 'Missing "max_width"' unless @config["max_width"]
84
+ raise InvalidStorageConfig, 'Missing "max_height"' unless @config["max_height"]
85
+ end
86
+
87
+ def get_url_parts(url)
88
+ match = url.match(/^(([a-z]+):|)(#{@config["public_url"]})\/(.+)$/)
89
+ return match && {
90
+ protocol: match[2],
91
+ public_url: match[3],
92
+ path: match[4]
93
+ }
94
+ end
95
+
96
+ # Resizes the image if it doesn't fit on the server. Updates the name if
97
+ # needed.
98
+ # Returns { image: resized_image, name: resized_name }.
99
+ def resize_to_fit(image, name)
100
+ # Resize the image if it's larger than the max width/height.
101
+ if image.width > @config["max_width"] || image.height > @config["max_height"]
102
+ resized_image = image.resize([image.width, @config["max_width"]].min, [image.height, @config["max_height"]].min)
103
+ # Generate a new name.
104
+ resized_name = SnapImage::ImageNameUtils.get_resized_image_name(name, resized_image.width, resized_image.height)
105
+ end
106
+ { image: resized_image || image, name: resized_name || name }
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,3 @@
1
+ module SnapImage
2
+ VERSION = "0.0.1"
3
+ end
data/snapimage.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/snapimage/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Wesley Wong"]
6
+ gem.email = ["wesley@snapeditor.com"]
7
+ gem.description = %q{Rack Middleware for handling the SnapImage API}
8
+ gem.summary = %q{SnapImage API Rack Middleware}
9
+ gem.homepage = "http://SnapEditor.com"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "snapimage"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = SnapImage::VERSION
17
+
18
+ gem.add_dependency("rack")
19
+ gem.add_dependency("rmagick")
20
+ gem.add_dependency("sinatra")
21
+ gem.add_dependency("thin")
22
+ gem.add_dependency("rake")
23
+
24
+ gem.add_development_dependency("rspec")
25
+ gem.add_development_dependency("autotest")
26
+ gem.add_development_dependency("rack-test")
27
+ end