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
data/.autotest ADDED
@@ -0,0 +1,3 @@
1
+ Autotest.add_hook :initialize do |at|
2
+ %w{.git doc log tmp vendor}.each { |ex| at.add_exception(ex) }
3
+ end
data/.gitignore ADDED
@@ -0,0 +1,19 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ .rbenv-version
7
+ Gemfile.lock
8
+ InstalledFiles
9
+ _yardoc
10
+ coverage
11
+ doc/
12
+ lib/bundler/man
13
+ pkg
14
+ rdoc
15
+ spec/reports
16
+ test/tmp
17
+ test/version_tmp
18
+ tmp
19
+ /vendor/bundle
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in snapimage.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2012 Wesley Wong
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # Snapimage
2
+
3
+ TODO: Write a gem description
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'snapimage'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install snapimage
18
+
19
+ ## Usage
20
+
21
+ TODO: Write usage instructions here
22
+
23
+ ## Contributing
24
+
25
+ 1. Fork it
26
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
27
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
28
+ 4. Push to the branch (`git push origin my-new-feature`)
29
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ options = {
6
+ file: "snapimage_config.yml",
7
+ force: false
8
+ }
9
+
10
+ optparse = OptionParser.new do |opts|
11
+ opts.banner = "Usage: snapimage_generate_config local_root public_url [options]"
12
+
13
+ opts.separator ""
14
+ opts.separator "local_root\tPath to the directory where the images will be stored"
15
+ opts.separator "public_url\tURL at which the images will accessible from"
16
+
17
+ opts.separator ""
18
+ opts.separator "Options:"
19
+
20
+ opts.on("-f", "--file FILE", "File to generate (Default: snapimage_config.yml)") do |file|
21
+ options[:file] = file
22
+ end
23
+
24
+ opts.on("--force", "Overwrites the file if it exists") do
25
+ options[:force] = true
26
+ end
27
+
28
+ opts.on("-h", "--help", "Display the help screen") do
29
+ puts opts
30
+ exit
31
+ end
32
+ end
33
+ optparse.parse!
34
+
35
+ unless ARGV.length == 2
36
+ puts optparse.help
37
+ exit
38
+ end
39
+
40
+ local_root = ARGV[0]
41
+ public_url = ARGV[1]
42
+
43
+ if !options[:force] && File.exists?(options[:file])
44
+ puts "File '#{options[:file]}' already exists. Use --force if you want to overwrite."
45
+ puts "Config file not generated."
46
+ puts
47
+ puts optparse.help
48
+ exit
49
+ end
50
+
51
+ File.open(options[:file], "w") do |f|
52
+ f.write(<<-EOF
53
+ primary_storage_server: "local"
54
+ storage_servers:
55
+ -
56
+ name: "local"
57
+ type: "LOCAL"
58
+ local_root: "#{local_root}"
59
+ public_url: "#{public_url}"
60
+ EOF
61
+ )
62
+ end
63
+ puts "Config file generated at '#{options[:file]}'."
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+
5
+ options = {
6
+ port: 54321
7
+ }
8
+
9
+ optparse = OptionParser.new do |opts|
10
+ opts.banner = "Usage: snapimage_server config [options]"
11
+
12
+ opts.on("-p", "--port", "Set the port (Default: 54321") do |port|
13
+ options[:port] = port
14
+ end
15
+
16
+ opts.on("--path PATH", "Set the URL path of the SnapImage API Server (Default: /snapimage_api)") do |path|
17
+ options[:path] = path
18
+ end
19
+
20
+ opts.on("-h", "--help", "Display the help screen") do
21
+ puts opts
22
+ exit
23
+ end
24
+ end
25
+ optparse.parse!
26
+
27
+ unless ARGV.length == 1
28
+ puts optparse.help
29
+ exit
30
+ end
31
+ options[:config] = ARGV[0]
32
+
33
+ require "sinatra"
34
+ require "snapimage"
35
+
36
+ # Make sure the built-in web server runs.
37
+ set :run, true
38
+
39
+ # Set up Sinatra.
40
+ set :environment, :production
41
+ set :port, options[:port]
42
+
43
+ use SnapImage::Middleware, options
44
+
45
+ get "/crossdomain.xml" do
46
+ <<-XML
47
+ <?xml version="1.0"?>
48
+ <!DOCTYPE cross-domain-policy SYSTEM "http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">
49
+ <cross-domain-policy>
50
+
51
+ <allow-access-from domain="*" to-ports="#{options[:port]}" />
52
+
53
+ </cross-domain-policy>
54
+ XML
55
+ end
data/lib/snapimage.rb ADDED
@@ -0,0 +1,24 @@
1
+ require "rack"
2
+ require "json"
3
+ require "fileutils"
4
+ require "digest/sha1"
5
+ require "open-uri"
6
+ require "RMagick"
7
+ require "snapimage/version"
8
+ require "snapimage/exceptions"
9
+ require "snapimage/rack/request_file"
10
+ require "snapimage/rack/request"
11
+ require "snapimage/rack/response"
12
+ require "snapimage/image/image"
13
+ require "snapimage/image/image_name_utils"
14
+ require "snapimage/storage/storage_server"
15
+ require "snapimage/storage/storage_server.local"
16
+ require "snapimage/storage/storage"
17
+ require "snapimage/config"
18
+ require "snapimage/server_actions/server_actions.authorize"
19
+ require "snapimage/server_actions/server_actions.generate_image"
20
+ require "snapimage/server_actions/server_actions.sync_resource"
21
+ require "snapimage/server_actions/server_actions.delete_resource_images"
22
+ require "snapimage/server_actions/server_actions.list_resource_images"
23
+ require "snapimage/server"
24
+ require "snapimage/middleware"
@@ -0,0 +1,51 @@
1
+ module SnapImage
2
+ class Config
3
+ # Arguments:
4
+ # * config:: Filename of the YAML or JSOn file to load or a config Hash.
5
+ #
6
+ # NOTE: All keys are strings, not symbols.
7
+ def initialize(config)
8
+ @raw_config = config
9
+ end
10
+
11
+ def validate_config
12
+ raise SnapImage::InvalidConfig, 'Missing "primary_storage_server"' unless @config["primary_storage_server"]
13
+ raise SnapImage::InvalidConfig, 'Missing "storage_servers"' unless @config["storage_servers"]
14
+ raise SnapImage::InvalidConfig, '"storage_servers" must be an array' unless @config["storage_servers"].is_a? Array
15
+ end
16
+
17
+ def set_config_defaults
18
+ @config["max_width"] ||= 1024
19
+ @config["max_height"] ||= 2048
20
+ end
21
+
22
+ def get_config
23
+ return @config if @config
24
+ @config = @raw_config
25
+ if @raw_config.is_a? String
26
+ ext = File.extname(@raw_config)
27
+ case ext
28
+ when ".yml", ".yaml"
29
+ @config = YAML::load(File.open(@raw_config))
30
+ when ".json"
31
+ @config = JSON::parse(File.read(@raw_config))
32
+ else
33
+ raise SnapImage::UnknownFileType, "Unknown filetype. Expecting .yaml, .yml, or .json: #{@raw_config}"
34
+ end
35
+ end
36
+
37
+ raise SnapImage::UnknownConfigType, "Unknown config type. Expecting a filename or hash: #{@config}" unless @config.is_a? Hash
38
+ validate_config
39
+ set_config_defaults
40
+ @config
41
+ end
42
+
43
+ def [](key)
44
+ get_config[key]
45
+ end
46
+
47
+ def storage
48
+ @storage ||= SnapImage::Storage.new(get_config["storage_servers"], get_config["primary_storage_server"], get_config["max_width"], get_config["max_height"])
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ module SnapImage
2
+ # Configuration.
3
+ class InvalidConfig < StandardError; end
4
+ class InvalidStorageConfig < StandardError; end
5
+ class UnknownConfigType < StandardError; end
6
+ class MissingConfig < StandardError; end
7
+
8
+ # Authorization.
9
+ class AuthorizationRequired < StandardError; end
10
+ class AuthorizationFailed < StandardError; end
11
+
12
+ # Request.
13
+ class BadRequest < StandardError; end
14
+ class ActionNotImplemented < StandardError; end
15
+
16
+ # Files.
17
+ class UnknownFileType < StandardError; end
18
+ class FileDoesNotExist < StandardError; end
19
+
20
+ # Images.
21
+ class InvalidImageIdentifier < StandardError; end
22
+
23
+ # Resources.
24
+ class InvalidResourceIdentifier < StandardError; end
25
+ end
@@ -0,0 +1,96 @@
1
+ module SnapImage
2
+ class Image
3
+ def self.from_path(path, public_url)
4
+ image = SnapImage::Image.new(public_url)
5
+ image.set_image_from_path(path)
6
+ image
7
+ end
8
+
9
+ def self.from_blob(blob)
10
+ image = SnapImage::Image.new
11
+ image.set_image_from_blob(blob)
12
+ image
13
+ end
14
+
15
+ def self.from_image(img)
16
+ image = SnapImage::Image.new
17
+ image.set_image_from_image(img)
18
+ image
19
+ end
20
+
21
+ attr_accessor :public_url
22
+
23
+ # Arguments:
24
+ # * public_url:: Public URL associated with the image
25
+ def initialize(public_url = nil)
26
+ @public_url = public_url
27
+ end
28
+
29
+ # Arguments:
30
+ # * path:: Local path or URL
31
+ def set_image_from_path(path)
32
+ @image = Magick::ImageList.new(path)
33
+ end
34
+
35
+ # Arguments:
36
+ # * blob:: Image blob
37
+ def set_image_from_blob(blob)
38
+ @image = Magick::ImageList.new
39
+ @image.from_blob(blob)
40
+ end
41
+
42
+ # Arguments:
43
+ # * image:: RMagick Image
44
+ def set_image_from_image(image)
45
+ @image = image
46
+ end
47
+
48
+ def width
49
+ @image.columns
50
+ end
51
+
52
+ def height
53
+ @image.rows
54
+ end
55
+
56
+ def blob
57
+ @image.to_blob
58
+ end
59
+
60
+ # Crops the image with the given parameters and returns a SnapImage::Image
61
+ # object.
62
+ #
63
+ # Arguments:
64
+ # * x:: x coordinate of the top left corner
65
+ # * y:: y coordinate of the top left corner
66
+ # * width:: width of the crop rectangle
67
+ # * height:: height of the crop rectangle
68
+ def crop(x, y, width, height)
69
+ SnapImage::Image.from_image(@image.crop(x, y, width, height))
70
+ end
71
+
72
+ # Generates a new resized image and returns it as a SnapImage::Image object.
73
+ #
74
+ # Arguments:
75
+ # * width:: Width to resize to
76
+ # * height:: Height to resize to (optional)
77
+ # * maintain_aspect_ratio:: If true, the image will be resized to fit within the width/height specified while maintaining the aspect ratio. If false, the image is allowed to be stretched.
78
+ def resize(width, height = nil, maintain_aspect_ratio = true)
79
+ raise "Height must be specified when not maintaining aspect ratio." if !maintain_aspect_ratio && !height
80
+ # If no height is given, make sure it does not interfere with the
81
+ # resizing.
82
+ height ||= [width, @image.rows].max
83
+ if maintain_aspect_ratio
84
+ SnapImage::Image.from_image(@image.resize_to_fit(width, height))
85
+ else
86
+ SnapImage::Image.from_image(@image.resize(width, height))
87
+ end
88
+ end
89
+
90
+ # Generates a new sharpened image and returns it as a SnapImage::Image
91
+ # object.
92
+ def sharpen
93
+ SnapImage::Image.from_image(@image.sharpen)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,131 @@
1
+ module SnapImage
2
+ class ImageNameUtils
3
+ # Base Image Format:
4
+ # {basename}-{original dimensions}.{extname}
5
+ #
6
+ # Modified Image Format:
7
+ # {basename}-{original dimensions}-{crop}-{dimensions}-{sharpen}.{extname}
8
+ #
9
+ # Notes
10
+ # * crop:: {x}x{y}x{width}x{height}
11
+ # * dimensions:: {width}x{height}
12
+ # * sharpen:: 0 or 1
13
+ IMAGE_PATH_REGEXP = /^(.*\/|)(([a-z0-9]{8})-(\d+)x(\d+)(-(\d+)x(\d+)x(\d+)x(\d+)-(\d+)x(\d+)-([01])|).(png|jpg|gif))$/
14
+
15
+ class << self
16
+ # Returns true if the path is for a base image. False otherwise.
17
+ #
18
+ # The path points to a base image if the dimensions are the same as the
19
+ # original dimensions, there is no cropping, and there is no sharpening.
20
+ def base_image?(path)
21
+ get_image_name_parts(path)[:is_base]
22
+ end
23
+
24
+ # Returns the extension name of the path. Normalizes jpeg to jpg.
25
+ def get_image_type(path)
26
+ type = File.extname(path).sub(/^\./, "")
27
+ type = "jpg" if type == "jpeg"
28
+ type
29
+ end
30
+
31
+ # Returns true if the name is valid. False otherwise.
32
+ def valid?(path)
33
+ get_image_name_parts(path)
34
+ return true
35
+ rescue
36
+ return false
37
+ end
38
+
39
+ # Parses the following format:
40
+ # {basename}-{original dimensions}-{crop}-{dimensions}-{sharpen}.{extname}
41
+ # Notes about the format:
42
+ # * crop:: {x}x{y}x{width}x{height}
43
+ # * dimensions:: {width}x{height}
44
+ # * sharpen:: 0 or 1
45
+ #
46
+ # Returns the information in a hash.
47
+ def get_image_name_parts(path)
48
+ matches = path.match(SnapImage::ImageNameUtils::IMAGE_PATH_REGEXP)
49
+ raise SnapImage::InvalidImageIdentifier, "The image identifier is invalid: #{path}" unless matches
50
+ parts = {
51
+ is_base: matches[6].size == 0,
52
+ full: matches[0],
53
+ path: matches[1].sub(/\/$/, ""),
54
+ filename: matches[2],
55
+ basename: matches[3],
56
+ original_dimensions: [matches[4].to_i, matches[5].to_i],
57
+ extname: matches[14]
58
+ }
59
+ unless parts[:is_base]
60
+ parts.merge!({
61
+ crop: {
62
+ x: matches[7].to_i,
63
+ y: matches[8].to_i,
64
+ width: matches[9].to_i,
65
+ height: matches[10].to_i
66
+ },
67
+ dimensions: [matches[11].to_i, matches[12].to_i],
68
+ sharpen: !!matches[13]
69
+ })
70
+ end
71
+ parts
72
+ end
73
+
74
+ # Returns the base image name from the path.
75
+ def get_base_image_path(path)
76
+ parts = get_image_name_parts(path)
77
+ "#{parts[:path]}/#{parts[:basename]}-#{parts[:original_dimensions][0]}x#{parts[:original_dimensions][1]}.#{parts[:extname]}"
78
+ end
79
+
80
+ # Returns the name with the new width and height.
81
+ def get_resized_image_name(name, width, height)
82
+ parts = SnapImage::ImageNameUtils.get_image_name_parts(name)
83
+ if parts[:is_base]
84
+ resized_name = SnapImage::ImageNameUtils.generate_image_name(width, height, parts[:extname], basename: parts[:basename])
85
+ else
86
+ options = {
87
+ basename: parts[:basename],
88
+ crop: parts[:crop],
89
+ width: width,
90
+ height: height,
91
+ sharpen: parts[:sharpend]
92
+ }
93
+ resized_name = SnapImage::ImageNameUtils.generate_image_name(parts[:original_dimensions][0], parts[:original_dimensions][1], parts[:extname], options)
94
+ end
95
+ resized_name
96
+ end
97
+
98
+ # Generates a random alphanumeric string of 8 characters.
99
+ def generate_basename
100
+ (0...8).map { rand(36).to_s(36) }.join
101
+ end
102
+
103
+ # When no options besides :basename are given, generates a base image
104
+ # name in the format:
105
+ # {basename}-{original dimensions}.{extname}
106
+ #
107
+ # Otherwise, generates a modified image name in the format:
108
+ # {basename}-{original dimensions}-{crop}-{dimensions}-{sharpen}.{extname}
109
+ #
110
+ # Notes about the format:
111
+ # * crop:: {x}x{y}x{width}x{height}
112
+ # * dimensions:: {width}x{height}
113
+ # * sharpen:: 0 or 1
114
+ #
115
+ # Options:
116
+ # * basename:: Defaults to a randomly generated basename of 8 characters
117
+ # * crop:: Crop values {x: <int>, y: <int>, width: <int>, height: <int>}
118
+ # * width:: New width
119
+ # * height:: New height
120
+ # * sharpen:: True or false
121
+ def generate_image_name(original_width, original_height, extname, options = {})
122
+ basename = options.delete(:basename) || self.generate_basename
123
+ if options.empty?
124
+ "#{basename}-#{original_width}x#{original_height}.#{extname}"
125
+ else
126
+ "#{basename}-#{original_width}x#{original_height}-#{options[:crop][:x]}x#{options[:crop][:y]}x#{options[:crop][:width]}x#{options[:crop][:height]}-#{options[:width]}x#{options[:height]}-#{options[:sharpen] ? 1 : 0}.#{extname}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end