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,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