[
  {
    "path": ".gitignore",
    "content": "vhost.yml\n"
  },
  {
    "path": ".rspec",
    "content": "--color\n--require spec_helper\n--format documentation\n\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: ruby\nbundler_args: --retry=3 --jobs=8 --no-deployment\ncache: bundler\nsudo: false\nrvm:\n  - 2.2.3\nmatrix:\n  fast_finish: true\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\n1. Fork it ( http://github.com/choonkeat/attache/fork )\n2. Create your feature branch (`git checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Add some feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create new Pull Request\n"
  },
  {
    "path": "Gemfile",
    "content": "source 'https://rubygems.org'\n\nruby \"2.2.3\"\n\ngemspec\n"
  },
  {
    "path": "Guardfile",
    "content": "guard :rspec, cmd: \"bundle exec rspec\" do\n  watch(%r{^spec/.+_spec\\.rb$})\n  watch(%r{^lib/(.+)\\.rb$})     { |m| \"spec/lib/#{m[1]}_spec.rb\" }\nend\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015 Chew Choon Keat\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n"
  },
  {
    "path": "Procfile",
    "content": "web: bundle exec puma -C config/puma.rb\nworker: bundle exec sidekiq -e production -q attache_vhost_jobs -r ./lib/attache.rb\n"
  },
  {
    "path": "README.md",
    "content": "# attache\n\n[![Gem Version](https://badge.fury.io/rb/attache.svg)](https://badge.fury.io/rb/attache)\n[![Build Status](https://travis-ci.org/choonkeat/attache.svg?branch=master)](https://travis-ci.org/choonkeat/attache)\n\n## But why?\n\nIf you're interested in the \"why\", checkout [my slides](http://www.slideshare.net/choonkeat/file-upload-2015) and [the blog post](http://blog.choonkeat.com/weblog/2015/10/file-uploads-2015.html).\n\nYour app can easily support\n- dynamic resize of images (no predefined styles in your app)\n- all file types since attache let apps [display non-image files as icons through `<img src...>`](https://github.com/choonkeat/attache/pull/28)\n- resumeable upload over unreliable (mobile) networks [using TUS protocol](https://github.com/choonkeat/attache/pull/10)\n\n## Run an instance\n\n#### Heroku\n\nYou can run your own instance on your own Heroku server\n\n[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)\n\n#### Docker\n\n```\ndocker run -it -p 9292:5000 --rm attache/attache\n```\n\nAlso, see [Deploying Attache on Digital Ocean using Docker](https://github.com/choonkeat/attache/wiki/Deploying-Attache-on-Digital-Ocean-using-Docker)\n\n#### RubyGem\n\nYou can install the gem and then execute `attache` command\n\n```\ngem install attache\nattache start -c web=1 -p 9292\n```\n\nNOTE: some config files will be written into your current directory\n\n```\n.\n├── Procfile\n├── config\n│   ├── puma.rb\n│   └── vhost.yml\n└── config.ru\n```\n\n#### Bundler\n\nYou can also use bundler to manage the gem; add this into your `Gemfile`\n\n```\ngem 'attache'\n```\n\nthen execute\n\n```\nbundle install\nbundle exec attache start -c web=1 -p 9292\n```\n\nNOTE: some config files will be written into your current directory (see RubyGems above)\n\n#### Source code\n\nYou can checkout the source code and run it like a regular [a Procfile-based app](https://ddollar.github.io/foreman/):\n\n```\ngit clone https://github.com/choonkeat/attache.git\ncd attache\nbundle install\nforeman start -c web=1 -p 9292\n```\n\nSee [foreman](https://github.com/ddollar/foreman) for more details.\n\n## Configuration\n\n`LOCAL_DIR` is where your local disk cache will be. By default, attache will use a system assigned temporary directory which may not be the same everytime you run attache.\n\n`CACHE_SIZE_BYTES` determines how much disk space will be used for the local disk cache. If the size of cache exceeds, least recently used files will be evicted after `CACHE_EVICTION_INTERVAL_SECONDS` duration.\n\n#### Asynchronous delete\n\nBy default `attache` will delete files from cloud storage using the lightweight, async processing library [sucker_punch](https://github.com/brandonhilkert/sucker_punch). This requires no additional setup (read: 1x free dyno).\n\nHowever if you prefer a more durable queue for reliable uploads, configuring `REDIS_PROVIDER` or `REDIS_URL` will switch `attache` to use a `redis` queue instead, via `sidekiq`. [Read Sidekiq's documentation](https://github.com/mperham/sidekiq/wiki/Using-Redis#using-an-env-variable) for details on these variables.\n\nIf for some reason you'd want the cloud storage delete to be synchronous, set `INLINE_JOB=1` instead.\n\n#### Virtual Host Cloud Storage\n\n`attache` uses a different config (and backup files into a different cloud service) depending on the request hostname that it was accessed by.\n\nThis means a single attache server can be the workhorse for different apps. Refer to `config/vhost.example.yml` file for configuration details.\n\nAt boot time, `attache` server will first look at `VHOST` environment variable. If that is missing, it will load the content of `config/vhost.yml`. If neither exist, the `attache` server run in development mode; uploaded files are only stored locally and may be evicted to free up disk space.\n\nIf you do not want to write down sensitive information like aws access key and secrets into a `config/vhost.yml` file, you can convert the entire content into `json` format and assign it to the `VHOST` environment variable instead.\n\n```\n# bash\nexport VHOST=$(bundle exec rake attache:vhost)\n\n# heroku\nheroku config:set VHOST=$(bundle exec rake attache:vhost)\n```\n\n#### Virtual Host Authorization\n\nBy default `attache` will accept uploads and delete requests from any client. Set `SECRET_KEY` to ensure attache only receives upload (and delete commands) from your own app.\n\nTo most app developers *using* attache in your rails app through a library like [attache-rails gem](https://github.com/choonkeat/attache-rails), how this work may not matter. But if you are developing attache itself or writing a client library for attache, then read on.\n\n#### Virtual Host Authorization (Developer)\n\nWhen `SECRET_KEY` is set, `attache` will require a valid `hmac` parameter in the upload request. Upload and Delete requests will be refused with `HTTP 401` error unless the `hmac` is correct.\n\nThe additional parameters required for authorized request are:\n\n* `uuid` is a uuid string\n* `expiration` is a unix timestamp of a future time. the significance is, if the timestamp has passed, the upload will be regarded as invalid\n* `hmac` is the `HMAC-SHA1` of the `SECRET_KEY` and the concatenated value of `uuid` and `expiration`\n\ni.e.\n\n``` ruby\nhmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), SECRET_KEY, uuid + expiration)\n```\n\n## APIs\n\nThe attache server is a reference implementation of these interfaces. If you write your own server, [compatibility can be verified by running a test suite](https://github.com/choonkeat/attache_api#testing-against-an-attache-compatible-server).\n\n#### Upload\n\nUsers will upload files directly into the `attache` server from their browser, bypassing the main app.\n\n> ```\n> PUT /upload?file=image123.jpg\n> ```\n> file content is the http request body\n\nThe main app front end will receive a unique `path` for each uploaded file - the only information to store in the main app database.\n\n> ```\n> {\"path\":\"pre/fix/image123.jpg\",\"content_type\":\"image/jpeg\",\"geometry\":\"1920x1080\"}\n> ```\n> json response from attache after upload.\n\n##### Upload by url\n\n> ```\n> GET /upload_url?url=https://example.com/logo.png\n> ```\n\nAttache will download the file from `url` supplied and uploads it through the regular `/upload` handler. So be expecting the same json response after upload. works with `GET`, `POST`, `PUT`.\n\nData URIs (aka base64 encoded file binaries) can also be uploaded to the same `/upload_url` endpoint through the same `url` parameter.\n\n#### Download\n\nWhenever the main app wants to display the uploaded file, constrained to a particular size, it will use a helper method provided by the `attache` lib. e.g. `embed_attache(path)` which will generate the necessary, barebones markup.\n\n> ```\n> <img src=\"https://example.com/view/pre/fix/100x100/image123.jpg\" />\n> ```\n> use [the imagemagick resize syntax](http://www.imagemagick.org/Usage/resize/) to specify the desired output.\n>\n> make sure to `escape` the geometry string.\n> e.g. for a hard crop of `50x50#`, the url should be `50x50%23`\n>\n> ```\n> <img src=\"https://example.com/view/pre/fix/50x50%23/image123.jpg\" />\n> ```\n> requesting for a geometry of `original` will return the uploaded file. this works well for non-image file uploads.\n> requesting for a geometry of `remote` will skip the local cache and serve from cloud storage.\n\n* Attache keeps the uploaded file in the local harddisk (a temp directory)\n* Attache will also upload the file into cloud storage if `FOG_CONFIG` is set\n* If the local file does not exist for some reason (e.g. cleared cache), it will download from cloud storage and store it locally\n* When a specific size is requested, it will generate the resized file based on the local file and serve it in the http response\n* If cloud storage is defined, local disk cache will store up to a maximum of `CACHE_SIZE_BYTES` bytes. By default `CACHE_SIZE_BYTES` will 80% of available diskspace\n\n#### Delete\n\n> ```\n> DELETE /delete\n> paths=image1.jpg%0Aprefix2%2Fimage2.jpg%0Aimage3.jpg\n> ```\n\nRemoving 1 or more files from the local cache and remote storage can be done via a http `POST` or `DELETE` request to `/delete`, with a `paths` parameter in the request body.\n\nThe `paths` value should be delimited by the newline character, aka `\\n`. In the example above, 3 files will be requested for deletion: `image1.jpg`, `prefix2/image2.jpg`, and `image3.jpg`.\n\n#### Backup\n\n> ```\n> POST /backup\n> paths=image1.jpg%0Aprefix2%2Fimage2.jpg%0Aimage3.jpg\n> ```\n\nThis feature might be known as `promote` in other file upload solutions. `attache` allows client app to `backup` uploaded images to another bucket for longer term storage.\n\nCopying 1 or more files from the default remote storage to the backup remote storage (backup) can be done via a http `POST` request to `/backup`, with a `paths` parameter in the request body.\n\nThe `paths` value should be delimited by the newline character, aka `\\n`. In the example above, 3 files will be requested for backup: `image1.jpg`, `prefix2/image2.jpg`, and `image3.jpg`.\n\nIf backup remote storage is not configured, this API call will be a noop. If configured, the backup storage must be accessible by the same credentials as default cloud storage as the system. Please refer to the `BACKUP_CONFIG` configuration illustrated in `config/vhost.example.yml` file in this repository.\n\nBy default, `backup` operation is performed synchronously. Set `BACKUP_ASYNC` environment variable to make it follow the same synchronicity as `delete`\n\nThe main reason to configure a backup storage is to make the default cloud storage auto expire files; mitigating [abuse](https://github.com/choonkeat/attache/issues/13). You should consult the documentation of your cloud storage provider on how to setup auto expiry, e.g. [here](https://aws.amazon.com/blogs/aws/amazon-s3-object-expiration/) or [here](https://cloud.google.com/storage/docs/lifecycle)\n\n## License\n\nMIT\n"
  },
  {
    "path": "Rakefile",
    "content": "if ENV['RACK_ENV'] == 'production'\n  # Heroku\n  # https://gist.github.com/Geesu/d0b58488cfae51f361c6\n  namespace :assets do\n    task 'precompile' do\n      puts \"Not applicable\"\n    end\n  end\nelse\n  require \"bundler/gem_tasks\"\n  require 'rspec/core/rake_task'\n  require 'attache/tasks'\n\n  RSpec::Core::RakeTask.new(:spec)\n  task :default => :spec\nend\n"
  },
  {
    "path": "app.json",
    "content": "{\n  \"name\": \"attache server\",\n  \"description\": \"Image server\",\n  \"repository\": \"https://github.com/choonkeat/attache\",\n  \"keywords\": [\"ruby\", \"rack\", \"image\", \"resize\", \"direct\", \"upload\"],\n  \"env\": {\n    \"REMOTE_DIR\": \"attache\"\n  }\n}\n"
  },
  {
    "path": "attache.gemspec",
    "content": "$:.push File.expand_path(\"../lib\", __FILE__)\n\n# Maintain your gem's version:\nrequire \"attache/version\"\n\n# Describe your gem and declare its dependencies:\nGem::Specification.new do |s|\n  s.name        = \"attache\"\n  s.version     = Attache::VERSION\n  s.authors     = [\"choonkeat\"]\n  s.email       = [\"choonkeat@gmail.com\"]\n  s.homepage    = \"https://github.com/choonkeat/attache\"\n  s.summary     = \"Image server for everybody\"\n  s.description = \"Standalone rack app to manage files onbehalf of your app\"\n  s.license     = \"MIT\"\n\n  s.files       = Dir[\"{app,lib}/**/*\", \"MIT-LICENSE\", \"Rakefile\", \"README.md\", 'exe/**/*',\n                      \"config/vhost.example.yml\", \"config/puma.rb\", \"config.ru\", 'public/**/*']\n  s.bindir      = 'exe'\n  s.executables = ['attache']\n\n  s.add_runtime_dependency 'rack', '~> 1.6'\n  s.add_runtime_dependency 'activesupport'\n  s.add_runtime_dependency 'paperclip', '~> 4.3'\n  s.add_runtime_dependency 'puma', '~> 2.14'\n  s.add_runtime_dependency 'net-ssh'\n  s.add_runtime_dependency 'fog', '~> 1.34'\n  s.add_runtime_dependency 'excon', '~> 0.45'\n  s.add_runtime_dependency 'sys-filesystem', '~> 0'\n  s.add_runtime_dependency 'disk_store', '~> 0'\n  s.add_runtime_dependency 'celluloid', '< 0.17' # 0.17 has compatibility issues with disk_store\n  s.add_runtime_dependency 'foreman', '~> 0'\n  s.add_runtime_dependency 'connection_pool', '~> 2.2'\n  s.add_runtime_dependency 'sidekiq', '~> 3.4'\n  s.add_runtime_dependency 'sucker_punch', '~> 1.5' # single-process Ruby asynchronous processing library\n\n  s.add_development_dependency 'rspec', '~> 3.2'\n  s.add_development_dependency 'shoulda', '~> 3.5'\n  s.add_development_dependency 'guard-rspec', '~> 4.6'\nend\n"
  },
  {
    "path": "config/puma.rb",
    "content": "workers Integer(ENV['PUMA_WORKERS'] || 1)\nthreads Integer(ENV['MIN_THREADS']  || 1), Integer(ENV['MAX_THREADS'] || 16)\n\npreload_app!\n\nrackup      DefaultRackup\nport        ENV['PORT']     || 3000\nenvironment ENV['RACK_ENV'] || 'development'\n"
  },
  {
    "path": "config/vhost.example.yml",
    "content": "# This is an example file. You can copy this file as `vhost.yml` and edit\n# the content with the correct values.\n\n# This section will only take effect if a request is made to `google.lvh.me:9292`\n\"google.lvh.me:9292\":\n  \"SECRET_KEY\": CHANGEME                         # this is the shared secret between your app and this attache server\n  \"REMOTE_DIR\": CHANGEME                         # this is the root directory to use in the `bucket`; omit to use root\n  \"GEOMETRY_WHITELIST\":                          # this limits the type of `geometry` we resize to; optional\n    - \"100x100\"\n    - \"1024>\"\n  \"FOG_CONFIG\":                                  #\n    \"provider\": Google                           # refer to `fog.io/storage` documentation\n    \"google_storage_access_key_id\": CHANGEME     #\n    \"google_storage_secret_access_key\": CHANGEME #\n    \"bucket\": CHANGEME                           # This `bucket` key is not standard Fog config. BUT attache server needs it\n\n# This section will only take effect if a request is made to `aws.example.com`\n\"aws.example.com\":\n  \"SECRET_KEY\": CHANGEME\n  \"FOG_CONFIG\":\n    \"provider\": AWS\n    \"aws_access_key_id\": CHANGEME\n    \"aws_secret_access_key\": CHANGEME\n    \"bucket\": CHANGEME\n    \"region\": us-west-1\n  \"BACKUP_CONFIG\":\n    \"bucket\": CHANGEME_BAK\n    # only supports 1 key: `bucket`\n\n# This section will only take effect if a request is made to `localhost:9292`\n\"localhost:9292\":\n\n\n# This section will apply if a request did not match anything else\n\"0.0.0.0\":\n"
  },
  {
    "path": "config.ru",
    "content": "require 'attache'\n\nuse Attache::Delete\nuse Attache::UploadUrl\nuse Attache::Upload\nuse Attache::Download\nuse Attache::Tus::Upload\nuse Attache::Backup\nuse Rack::Static, urls: [\"/\"], root: Attache.publicdir, index: \"index.html\"\n\nrun proc {|env| [200, {}, []] }\n"
  },
  {
    "path": "docker/Dockerfile",
    "content": "FROM ruby:2.2\n\nRUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y imagemagick ghostscript\nRUN curl -sSL https://raw.githubusercontent.com/choonkeat/attache/master/docker/bundler_geminstaller_install_with_timeout.rb | ruby\n\nRUN useradd -d /app -m app && \\\n    chown -R app /usr/local/bundle\nUSER app\nRUN mkdir -p /app/src\nWORKDIR /app/src\n\nRUN curl -sSL http://johnvansickle.com/ffmpeg/releases/ffmpeg-release-32bit-static.tar.xz | tar -xJv\nENV PATH \"$PATH:/app/src/ffmpeg-2.8.3-32bit-static\"\n\nRUN echo 'source \"https://rubygems.org\"' > Gemfile && \\\n    echo 'gem \"attache\", \">= 2.3.0\"'     >> Gemfile && bundle && \\\n    gem install --no-ri --no-rdoc attache --version '>= 2.3.0'\n\nEXPOSE 5000\nCMD [\"attache\", \"start\", \"-c\", \"web=1\"]\n"
  },
  {
    "path": "docker/bundler_geminstaller_install_with_timeout.rb",
    "content": "# Usage:\n#   ruby bundler_geminstaller_install_with_timeout.rb\n\ntarget = `which bundle`.chomp\n*old_lines, last_line = IO.read(target).split(/[\\r\\n]+/)\nif (old_lines.grep(/install_with_timeout/)).empty?\n  new_line = DATA.read.strip\n  combined = (old_lines + [new_line, last_line]).join($/)\n  open(target, \"w\") {|f| f.write(combined) }\n  puts \"installed.\"\nelse\n  puts \"already installed.\"\nend\n\n__END__\n\nrequire \"timeout\"\n\nrequire \"rubygems/installer\"\nGem::Installer.class_eval do\n  def install_with_timeout\n    puts \"Gem install_with_timeout...\"\n    Timeout.timeout(Integer(ENV.fetch(\"GEM_INSTALL_TIMEOUT\", 60))) {\n      install_without_timeout\n    }\n  rescue Timeout::Error\n    @tries = @tries.to_i + 1\n    raise unless @tries < 5\n    STDERR.puts \"Gem timed out #{$!} (#{@tries})...\"\n    retry\n  end\n\n  alias :install_without_timeout :install\n  alias :install :install_with_timeout\nend\n\nrequire \"bundler/installer/gem_installer\"\nBundler::GemInstaller.class_eval do\n  def install_with_timeout\n    puts \"Bundler install_with_timeout...\"\n    Timeout.timeout(Integer(ENV.fetch(\"GEM_INSTALL_TIMEOUT\", 60))) {\n      install_without_timeout\n    }\n  rescue Timeout::Error\n    @tries = @tries.to_i + 1\n    raise unless @tries < 5\n    STDERR.puts \"Bundler timed out #{$!} (#{@tries})...\"\n    retry\n  end\n\n  alias :install_without_timeout :install\n  alias :install :install_with_timeout\nend\n"
  },
  {
    "path": "exe/attache",
    "content": "#!/bin/env ruby\n\nrequire 'fileutils'\n\n# attache config\nif ENV['VHOST']\n  puts \"Using VHOST env\"\nelsif File.exists?(\"config/vhost.yml\")\n  puts \"Using config/vhost.yml\"\nelse\n  FileUtils.mkdir_p 'config'\n  FileUtils.copy File.expand_path(\"../config/vhost.example.yml\", File.dirname(__FILE__)), 'config/vhost.yml'\n  puts \"Initialized config/vhost.yml\"\nend\n\n# puma config\nif File.exists?(\"config/puma.rb\")\n  puts \"Using config/puma.rb\"\nelse\n  FileUtils.mkdir_p 'config'\n  FileUtils.copy File.expand_path(\"../config/puma.rb\", File.dirname(__FILE__)), 'config/puma.rb'\n  puts \"Initialized config/puma.rb\"\nend\n\n# procfile\nif File.exists?(\"Procfile\")\n  puts \"Using Procfile\"\nelse\n  open(\"Procfile\", \"w\") do |f|\n    f.write <<-EOM.gsub(/^\\s+/, '')\n      web: bundle exec puma -C config/puma.rb\n      worker: bundle exec sidekiq -e production -q attache_vhost_jobs -r #{File.expand_path(\"../lib/attache.rb\", File.dirname(__FILE__))}\n    EOM\n  end\n  puts \"Initialized Procfile\"\nend\n\n# rakefile\nif File.exists?(\"Rakefile\")\n  puts \"Using Rakefile\"\nelse\n  open(\"Rakefile\", \"w\") do |f|\n    f.write <<-EOM.gsub(/^\\s+/, '')\n      require 'attache/tasks'\n    EOM\n  end\n  puts \"Initialized Rakefile\"\nend\n\n# rack config\nif File.exists?(\"config.ru\")\n  puts \"Using config.ru\"\nelse\n  FileUtils.copy File.expand_path(\"../config.ru\", File.dirname(__FILE__)), 'config.ru'\n  puts \"Initialized config.ru\"\nend\n\ncase ARGV.first\nwhen 'start'\n  require \"foreman/cli\"\n  Foreman::CLI.start\nelse\n  puts \"\"\n  puts \"Setup complete: run `foreman start` to begin\"\n  puts \"\"\nend\n"
  },
  {
    "path": "lib/attache/backup.rb",
    "content": "class Attache::Backup < Attache::Base\n  def initialize(app)\n    @app = app\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when '/backup'\n      request  = Rack::Request.new(env)\n      params   = request.params\n      return config.unauthorized unless config.authorized?(params)\n\n      if config.storage && config.bucket\n        sync_method = (ENV['BACKUP_ASYNC'] ? :async : :send)\n        threads = []\n        params['paths'].to_s.split(\"\\n\").each do |relpath|\n          threads << Thread.new do\n            Attache.logger.info \"BACKUP remote #{relpath}\"\n            config.send(sync_method, :backup_file, relpath: relpath)\n          end\n        end\n        threads.each(&:join)\n      end\n      [200, config.headers_with_cors, []]\n    else\n      @app.call(env)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache/base.rb",
    "content": "class Attache::Base\n  def call(env)\n    if vhost = vhost_for(request_hostname(env))\n      dup._call(env, vhost)\n    else\n      @app.call(env)\n    end\n  rescue Timeout::Error\n    Attache.logger.error $@\n    Attache.logger.error $!\n    Attache.logger.error \"ERROR 503 #{env['PATH_INFO']} REFERER #{env['HTTP_REFERER'].inspect}\"\n    [503, { 'X-Exception' => $!.to_s }, []]\n  rescue Exception\n    Attache.logger.error $@\n    Attache.logger.error $!\n    Attache.logger.error \"ERROR 500 #{env['PATH_INFO']} REFERER #{env['HTTP_REFERER'].inspect}\"\n    [500, { 'X-Exception' => $!.to_s }, []]\n  end\n\n  def vhost_for(host)\n    Attache::VHost.new(Attache.vhost[host] || Attache.vhost['0.0.0.0'])\n  end\n\n  def request_hostname(env)\n    env['HTTP_X_FORWARDED_HOST'] || env['HTTP_HOST'] || \"unknown.host\"\n  end\n\n  def content_type_of(fullpath)\n    Paperclip::ContentTypeDetector.new(fullpath).detect\n  rescue Paperclip::Errors::NotIdentifiedByImageMagickError\n    # best effort only\n  end\n\n  def geometry_of(fullpath)\n    Paperclip::Geometry.from_file(fullpath).tap(&:auto_orient).to_s\n  rescue Paperclip::Errors::NotIdentifiedByImageMagickError\n    # best effort only\n  end\n\n  def filesize_of(fullpath)\n    File.stat(fullpath).size\n  end\n\n  def params_of(env)\n    env['QUERY_STRING'].to_s.split('&').inject({}) do |sum, pair|\n      k, v = pair.split('=').collect {|s| CGI.unescape(s) }\n      sum.merge(k => v)\n    end\n  end\n\n  def path_of(cachekey)\n    Attache.cache.send(:key_file_path, cachekey)\n  end\n\n  def rack_response_body_for(file)\n    Attache::FileResponseBody.new(file)\n  end\n\n  def generate_relpath(basename)\n    File.join(*SecureRandom.hex.scan(/\\w\\w/), basename)\n  end\n\n  def json_of(relpath, cachekey, vhost)\n    filepath = path_of(cachekey)\n    json = {\n      path:         relpath,\n      content_type: content_type_of(filepath),\n      geometry:     geometry_of(filepath),\n      bytes:        filesize_of(filepath),\n    }\n    if vhost && vhost.secret_key\n      content = json.sort.collect {|k,v| \"#{k}=#{v}\" }.join('&')\n      json['signature'] = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), vhost.secret_key, content)\n    end\n    json.to_json\n  end\n\nend\n"
  },
  {
    "path": "lib/attache/delete.rb",
    "content": "class Attache::Delete < Attache::Base\n  def initialize(app)\n    @app = app\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when '/delete'\n      request  = Rack::Request.new(env)\n      params   = request.params\n      return config.unauthorized unless config.authorized?(params)\n\n      threads = []\n      params['paths'].to_s.split(\"\\n\").each do |relpath|\n        if Attache.cache\n          threads << Thread.new do\n            Attache.logger.info \"DELETING local #{relpath}\"\n            cachekey = File.join(request_hostname(env), relpath)\n            Attache.cache.delete(cachekey)\n          end\n        end\n        if config.storage && config.bucket\n          threads << Thread.new do\n            Attache.logger.info \"DELETING remote #{relpath}\"\n            config.async(:storage_destroy, relpath: relpath)\n          end\n        end\n        if config.backup\n          threads << Thread.new do\n            Attache.logger.info \"DELETING backup #{relpath}\"\n            config.backup.async(:storage_destroy, relpath: relpath)\n          end\n        end\n      end\n      threads.each(&:join)\n      [200, config.headers_with_cors, []]\n    else\n      @app.call(env)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache/download.rb",
    "content": "require 'connection_pool'\n\nclass Attache::Download < Attache::Base\n  RESIZE_JOB_POOL = ConnectionPool.new(JSON.parse(ENV.fetch('RESIZE_POOL') { '{ \"size\": 2, \"timeout\": 60 }' }).symbolize_keys) { Attache::ResizeJob.new }\n\n  def initialize(app)\n    @app = app\n    @mutexes = {}\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when %r{\\A/view/}\n      vhosts = {}\n      vhosts[ENV.fetch('REMOTE_GEOMETRY') { 'remote' }] = config.storage && config.bucket && config\n      vhosts[ENV.fetch('BACKUP_GEOMETRY') { 'backup' }] = config.backup\n\n      parse_path_info(env['PATH_INFO']['/view/'.length..-1]) do |dirname, geometry, basename, relpath|\n        unless config.try(:geometry_whitelist).blank? || config.geometry_whitelist.include?(geometry)\n          return [415, config.download_headers, [\"#{geometry} is not supported\"]]\n        end\n\n        if vhost = vhosts[geometry]\n          headers = vhost.download_headers.merge({\n                      'Location' => vhost.storage_url(relpath: relpath),\n                      'Cache-Control' => 'private, no-cache',\n                    })\n          return [302, headers, []]\n        end\n\n        thumbnail = case geometry\n          when 'original', *vhosts.keys\n            get_original_file(relpath, vhosts, env)\n          else\n            get_thumbnail_file(geometry, basename, relpath, vhosts, env)\n          end\n\n        return [404, config.download_headers, []] if thumbnail.try(:size).to_i == 0\n\n        headers = {\n          'Content-Type' => content_type_of(thumbnail.path),\n        }.merge(config.download_headers)\n\n        [200, headers, rack_response_body_for(thumbnail)]\n      end\n    else\n      @app.call(env)\n    end\n  end\n\n  private\n\n    def parse_path_info(geometrypath)\n      parts = geometrypath.split('/')\n      basename = CGI.unescape parts.pop\n      geometry = CGI.unescape parts.pop\n      dirname  = parts.join('/')\n      relpath  = File.join(dirname, basename)\n      yield dirname, geometry, basename, relpath\n    end\n\n    def synchronize(key, &block)\n      mutex = @mutexes[key] ||= Mutex.new\n      mutex.synchronize(&block)\n    ensure\n      @mutexes.delete(key)\n    end\n\n    def get_thumbnail_file(geometry, basename, relpath, vhosts, env)\n      cachekey = File.join(request_hostname(env), relpath, geometry)\n      synchronize(cachekey) do\n        tempfile = nil\n        Attache.cache.fetch(cachekey) do\n          Attache.logger.info \"[POOL] new job\"\n          tempfile = RESIZE_JOB_POOL.with do |job|\n            job.perform(geometry, basename, relpath, vhosts, env) do\n              # opens up possibility that job implementation\n              # does not require we download original file prior\n              get_original_file(relpath, vhosts, env)\n            end\n          end\n        end.tap { File.unlink(tempfile.path) if tempfile.try(:path) }\n      end\n    end\n\n    def get_original_file(relpath, vhosts, env)\n      cachekey = File.join(request_hostname(env), relpath)\n      synchronize(cachekey) do\n        Attache.cache.fetch(cachekey) do\n          name_with_vhost_pairs = vhosts.inject({}) { |sum,(k,v)| (v ? sum.merge(k => v) : sum) }\n          get_first_result_present_async(name_with_vhost_pairs.collect {|name, vhost|\n            lambda { Thread.handle_interrupt(BasicObject => :on_blocking) {\n              begin\n                Attache.logger.info \"[POOL] looking for #{name} #{relpath}...\"\n                vhost.storage_get(relpath: relpath).tap do |v|\n                  Attache.logger.info \"[POOL] found #{name} #{relpath} = #{v.inspect}\"\n                end\n              rescue Exception\n                Attache.logger.error $!\n                Attache.logger.error $@\n                Attache.logger.info \"[POOL] not found #{name} #{relpath}\"\n                nil\n              end\n            } }\n          })\n        end\n      end\n    rescue Exception # Errno::ECONNREFUSED, OpenURI::HTTPError, Excon::Errors, Fog::Errors::Error\n      Attache.logger.error \"ERROR REFERER #{env['HTTP_REFERER'].inspect}\"\n      nil\n    end\n\n    # Ref https://gist.github.com/sferik/39831f34eb87686b639c#gistcomment-1652888\n    # a bit more complicated because we *want* to ignore falsey result\n    def get_first_result_present_async(lambdas)\n      return if lambdas.empty? # queue.pop will never happen\n      queue = Queue.new\n      threads = lambdas.shuffle.collect { |code| Thread.new { queue << [Thread.current, code.call] } }\n      until (item = queue.pop).last do\n        thread, _ = item\n        thread.join # we could be popping `queue` before thread exited\n        break unless threads.any?(&:alive?) || queue.size > 0\n      end\n      threads.each(&:kill)\n      _, result = item\n      result\n    end\nend\n"
  },
  {
    "path": "lib/attache/file_response_body.rb",
    "content": "class Attache::FileResponseBody\n  def initialize(file, range_start = nil, range_end = nil)\n    @file        = file\n    @range_start = range_start || 0\n    @range_end   = range_end || File.size(@file.path)\n  end\n\n  # adapted from rack/file.rb\n  def each\n    @file.seek(@range_start)\n    remaining_len = @range_end\n    while remaining_len > 0\n      part = @file.read([8192, remaining_len].min)\n      break unless part\n      remaining_len -= part.length\n\n      yield part\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache/job.rb",
    "content": "class Attache::Job\n  RETRY_DURATION = ENV.fetch('CACHE_EVICTION_INTERVAL_SECONDS') { 60 }.to_i / 3\n\n  def perform(method, env, args)\n    config = Attache::VHost.new(env)\n    config.send(method, args.symbolize_keys)\n  rescue Exception\n    Attache.logger.error $@\n    Attache.logger.error $!\n    Attache.logger.error [method, args].inspect\n    self.class.perform_in(RETRY_DURATION, method, env, args)\n  end\n\n  # Background processing setup\n\n  if defined?(::SuckerPunch::Job)\n    include ::SuckerPunch::Job\n    def later(sec, *args)\n      after(sec) { perform(*args) }\n    end\n    def self.perform_async(*args)\n      self.new.async.perform(*args)\n    end\n    def self.perform_in(duration, *args)\n      self.new.async.later(duration, *args)\n    end\n  else\n    include Sidekiq::Worker\n    sidekiq_options :queue => :attache_vhost_jobs\n    sidekiq_retry_in {|count| RETRY_DURATION} # uncaught exception, retry after RETRY_DURATION\n  end\nend\n"
  },
  {
    "path": "lib/attache/resize_job.rb",
    "content": "require 'digest/sha1'\nrequire 'stringio'\n\nclass Attache::ResizeJob\n  def perform(target_geometry_string, basename, relpath, vhosts, env, t = Time.now)\n    closed_file = yield\n    return StringIO.new if closed_file.try(:size).to_i == 0\n\n    extension = basename.split(/\\W+/).last\n    Attache.logger.info \"[POOL] start\"\n    return make_nonimage_preview(closed_file, basename) if ['pdf', 'txt'].include?(extension.to_s.downcase)\n\n    thumbnail = thumbnail_for(closed_file: closed_file, target_geometry_string: target_geometry_string, extension: extension)\n    thumbnail.instance_variable_set('@basename', make_safe_filename(thumbnail.instance_variable_get('@basename')))\n    thumbnail.make\n  rescue Paperclip::Errors::NotIdentifiedByImageMagickError\n    make_nonimage_preview(closed_file, basename)\n  ensure\n    Attache.logger.info \"[POOL] done in #{Time.now - t}s\"\n  end\n\n  private\n\n    BOLD_FONT_FILE = ENV.fetch('FONT_FILE', File.join(Attache.publicdir, \"vendor/roboto/Roboto-Medium.ttf\"))\n    THIN_FONT_FILE = ENV.fetch('FONT_FILE', File.join(Attache.publicdir, \"vendor/roboto/Roboto-Light.ttf\"))\n    BORDER_SIZE = ENV.fetch('BORDER_SIZE', \"3\")\n    FG_COLOR = ENV.fetch('FG_COLOR', \"#ffffff\")\n    BG_COLOR = ENV.fetch('BG_COLOR', \"#dddddd\")\n    EXT_COLOR = ENV.fetch('EXT_COLOR', \"#333333\")\n    TXT_SIZE = ENV.fetch('TXT_SIZE', \"12\")\n    PREVIEW_SIZE = ENV.fetch('PREVIEW_SIZE', '96x')\n\n    def make_nonimage_preview(closed_file, basename)\n      t = Time.now\n      Attache.logger.info \"[POOL] start nonimage preview\"\n      output_file = Tempfile.new([\"preview\", \".png\"]).tap(&:close)\n      cmd = case basename\n      when /\\.pdf$/i\n        \"convert -size #{PREVIEW_SIZE.inspect} #{closed_file.path.inspect}[0] -thumbnail #{PREVIEW_SIZE.inspect} -font #{BOLD_FONT_FILE.inspect}\"\n      else\n        \"convert -size #{PREVIEW_SIZE.inspect} \\\\( -gravity center -font #{BOLD_FONT_FILE.inspect} -fill #{EXT_COLOR.inspect} label:'#{make_safe_filename(basename).split(/\\W+/).last}' \\\\)\"\n      end + \" -bordercolor #{FG_COLOR.inspect} -border #{BORDER_SIZE} -background #{BG_COLOR.inspect} -gravity center -font #{THIN_FONT_FILE.inspect} -pointsize 12 -set caption #{basename.inspect} -polaroid 0 #{output_file.path.inspect}\"\n      Attache.logger.info cmd\n      system cmd\n      File.new(output_file.path)\n    ensure\n      Attache.logger.info \"[POOL] done nonimage preview in #{Time.now - t}s\"\n    end\n\n    def make_safe_filename(str)\n      str.to_s.gsub(/[^\\w\\.]/, '_')\n    end\n\n    def thumbnail_for(closed_file:, target_geometry_string:, extension:, max: 2048)\n      convert_options = '-interlace Plane' if %w(jpg jpeg).include?(extension.to_s.downcase)\n      thumbnail = Paperclip::Thumbnail.new(closed_file, geometry: target_geometry_string, format: extension, convert_options: convert_options)\n      current_geometry = current_geometry_for(thumbnail)\n      target_geometry = Paperclip::GeometryParser.new(target_geometry_string).make\n      if target_geometry.larger <= max && current_geometry.larger > max\n        # optimization:\n        #  when users upload \"super big files\", we can speed things up\n        #  by working from a \"reasonably large 2048x2048 thumbnail\" (<2 seconds)\n        #  instead of operating on the original (>10 seconds)\n        #  we store this reusably in Attache.cache to persist reboot, but not uploaded to cloud\n        working_geometry = \"#{max}x#{max}>\"\n        working_file = Attache.cache.fetch(Digest::SHA1.hexdigest(working_geometry + closed_file.path)) do\n          Attache.logger.info \"[POOL] generate working_file\"\n          Paperclip::Thumbnail.new(closed_file, geometry: working_geometry, format: extension).make\n        end\n        Attache.logger.info \"[POOL] use working_file #{working_file.path}\"\n        thumbnail = Paperclip::Thumbnail.new(working_file.tap(&:close), geometry: target_geometry_string, format: extension, convert_options: convert_options)\n      end\n      thumbnail\n    end\n\n    # allow stub in spec\n    def current_geometry_for(thumbnail)\n      thumbnail.current_geometry.tap(&:auto_orient)\n    end\n\nend\n"
  },
  {
    "path": "lib/attache/tasks.rb",
    "content": "require \"rake\"\n\nnamespace :attache do\n\n  desc \"Convert content of FILE to a JSON string; default FILE=config/vhost.yml\"\n  task :vhost do\n    require 'yaml'\n    require 'json'\n\n    file = ENV.fetch(\"FILE\") { \"config/vhost.yml\" }\n    puts YAML.load(IO.read(file)).to_json\n  end\n\nend\n"
  },
  {
    "path": "lib/attache/tus/upload.rb",
    "content": "class Attache::Tus::Upload < Attache::Base\n  def initialize(app)\n    @app = app\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when '/tus/files'\n      tus = ::Attache::Tus.new(env, config)\n      params = params_of(env) # avoid unnecessary `invalid byte sequence in UTF-8` on `request.params`\n      return config.unauthorized unless config.authorized?(params)\n\n      case env['REQUEST_METHOD']\n      when 'POST'\n        if positive_number?(tus.upload_length)\n          relpath = generate_relpath(Attache::Upload.sanitize(tus.upload_metadata['filename'] || params['file']))\n          cachekey = File.join(request_hostname(env), relpath)\n\n          bytes_wrote = Attache.cache.write(cachekey, StringIO.new)\n          uri = URI.parse(Rack::Request.new(env).url)\n          uri.query = (uri.query ? \"#{uri.query}&\" : '') + \"relpath=#{CGI.escape relpath}\"\n          [201, tus.headers_with_cors('Location' => uri.to_s), []]\n        else\n          [400, tus.headers_with_cors('X-Exception' => \"Bad upload length\"), []]\n        end\n\n      when 'PATCH'\n        relpath = params['relpath']\n        cachekey = File.join(request_hostname(env), relpath)\n        http_offset = tus.upload_offset\n        if positive_number?(env['CONTENT_LENGTH']) &&\n           positive_number?(http_offset) &&\n           (env['CONTENT_TYPE'] == 'application/offset+octet-stream') &&\n           tus.resumable_version.to_s == '1.0.0' &&\n           current_offset(cachekey, relpath, config) >= http_offset.to_i\n\n          append_to(cachekey, http_offset, env['rack.input'])\n          config.storage_create(relpath: relpath, cachekey: cachekey) if config.storage && config.bucket\n\n          [200,\n            tus.headers_with_cors({'Content-Type' => 'text/json'}, offset: current_offset(cachekey, relpath, config)),\n            [json_of(relpath, cachekey, config)],\n          ]\n        else\n          [400, tus.headers_with_cors('X-Exception' => 'Bad headers'), []]\n        end\n\n      when 'OPTIONS'\n        [201, tus.headers_with_cors, []]\n\n      when 'HEAD'\n        relpath = params['relpath']\n        cachekey = File.join(request_hostname(env), relpath)\n        [200,\n          tus.headers_with_cors({'Content-Type' => 'text/json'}, offset: current_offset(cachekey, relpath, config)),\n          [json_of(relpath, cachekey, config)],\n        ]\n\n      when 'GET'\n        relpath = params['relpath']\n        uri = URI.parse(Rack::Request.new(env).url)\n        uri.query = nil\n        uri.path = File.join('/view', File.dirname(relpath), 'original', CGI.escape(File.basename(relpath)))\n        [302, tus.headers_with_cors('Location' => uri.to_s), []]\n      end\n    else\n      @app.call(env)\n    end\n  end\n\n  private\n\n    def current_offset(cachekey, relpath, config)\n      file = Attache.cache.fetch(cachekey) do\n        config.storage_get(relpath: relpath) if config.storage && config.bucket\n      end\n      file.size\n    rescue\n      Attache.cache.write(cachekey, StringIO.new)\n    ensure\n      file.tap(&:close)\n    end\n\n    def append_to(cachekey, offset, io)\n      f = File.open(path_of(cachekey), 'r+b')\n      f.sync = true\n      f.seek(offset.to_i)\n      f.write(io.read)\n    ensure\n      f.close\n    end\n\n    def positive_number?(value)\n      (value.to_s == \"0\" || value.to_i > 0)\n    end\n\nend\n"
  },
  {
    "path": "lib/attache/tus.rb",
    "content": "class Attache::Tus\n  LENGTH_KEYS   = %w[Upload-Length   Entity-Length]\n  OFFSET_KEYS   = %w[Upload-Offset   Offset]\n  METADATA_KEYS = %w[Upload-Metadata Metadata]\n\n  attr_accessor :env, :config\n\n  def initialize(env, config)\n    @env = env\n    @config = config\n  end\n\n  def header_value(keys)\n    value = nil\n    keys.find {|k| value = env[\"HTTP_#{k.gsub('-', '_').upcase}\"]}\n    value\n  end\n\n  def upload_length\n    header_value LENGTH_KEYS\n  end\n\n  def upload_offset\n    header_value OFFSET_KEYS\n  end\n\n  def upload_metadata\n    value = header_value METADATA_KEYS\n    Hash[*value.split(/[, ]/)].inject({}) do |h, (k, v)|\n      h.merge(k => Base64.decode64(v))\n    end\n  end\n\n  def resumable_version\n    header_value [\"Tus-Resumable\"]\n  end\n\n  def headers_with_cors(headers = {}, offset: nil)\n    tus_headers = {\n      \"Access-Control-Allow-Methods\" => \"PATCH\",\n      \"Access-Control-Allow-Headers\" => \"Tus-Resumable, #{LENGTH_KEYS.join(', ')}, #{METADATA_KEYS.join(', ')}, #{OFFSET_KEYS.join(', ')}\",\n      \"Access-Control-Expose-Headers\" => \"Location, #{OFFSET_KEYS.join(', ')}\",\n    }\n    OFFSET_KEYS.each do |k|\n      tus_headers[k] = offset\n    end if offset\n\n    # append\n    tus_headers.inject(config.headers_with_cors.merge(headers)) do |sum, (k, v)|\n      sum.merge(k => [*sum[k], v].join(', '))\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache/upload.rb",
    "content": "class Attache::Upload < Attache::Base\n  def initialize(app)\n    @app = app\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when '/upload'\n      case env['REQUEST_METHOD']\n      when 'POST', 'PUT', 'PATCH'\n        request  = Rack::Request.new(env)\n        params   = request.GET # stay away from parsing body\n        return config.unauthorized unless config.authorized?(params)\n\n        relpath = generate_relpath(Attache::Upload.sanitize params['file'])\n        cachekey = File.join(request_hostname(env), relpath)\n\n        bytes_wrote = Attache.cache.write(cachekey, request.body)\n        if bytes_wrote == 0\n          return [500, config.headers_with_cors.merge('X-Exception' => 'Local file failed'), []]\n        else\n          Attache.logger.info \"[Upload] received #{bytes_wrote} #{cachekey}\"\n        end\n\n        config.storage_create(relpath: relpath, cachekey: cachekey) if config.storage && config.bucket\n\n        [200, config.headers_with_cors.merge('Content-Type' => 'text/json'), [json_of(relpath, cachekey, config)]]\n      when 'OPTIONS'\n        [200, config.headers_with_cors, []]\n      else\n        [400, config.headers_with_cors, []]\n      end\n    else\n      @app.call(env)\n    end\n  end\n\n  def self.sanitize(filename)\n    filename.to_s.gsub(/\\%/, '_')\n  end\nend\n"
  },
  {
    "path": "lib/attache/upload_url.rb",
    "content": "class Attache::UploadUrl < Attache::Base\n  def initialize(app)\n    @app = app\n  end\n\n  def _call(env, config)\n    case env['PATH_INFO']\n    when '/upload_url'\n\n      # always pretend to be `POST /upload`\n      env['PATH_INFO'] = '/upload'\n      env['REQUEST_METHOD'] = 'POST'\n\n      request  = Rack::Request.new(env)\n      params   = request.params\n      return config.unauthorized unless config.authorized?(params)\n\n      if params['url']\n        file, filename, content_type = download_file(params['url'])\n        filename = \"index\" if filename == '/'\n\n        env['CONTENT_TYPE'] = content_type || content_type_of(file.path)\n        env['rack.request.query_hash'] = (env['rack.request.query_hash'] || {}).merge('file' => filename)\n        env['rack.input'] = file\n      end\n    end\n    @app.call(env)\n  end\n\n  MAX_DEPTH = 30\n  def download_file(url, depth = 0)\n    raise Net::HTTPError, \"Too many redirects\" if depth > MAX_DEPTH\n    Attache.logger.info \"Upload GET #{url}\"\n\n    if url.match /\\Adata:([^;,]+|)(;base64|),/\n      # data:[<mediatype>][;base64],<data>\n      # http://tools.ietf.org/html/rfc2397\n\n      data = URI.decode(url[url.index(',')+1..-1])\n      data = Base64.decode64(data) if $2 == ';base64'\n      content_type = ($1 == '' ? \"text/plain\" : $1)\n      filename = \"data.#{content_type.gsub(/\\W+/, '.')}\"\n      return [StringIO.new(data), filename, content_type]\n    end\n\n    uri = uri.kind_of?(URI::Generic) ? url : URI.parse(url)\n    http = Net::HTTP.new(uri.host, uri.port)\n    http.use_ssl = true if uri.scheme == 'https'\n    req = Net::HTTP::Get.new(uri.request_uri)\n    req.initialize_http_header({\"User-Agent\" => ENV['USER_AGENT']}) if ENV['USER_AGENT']\n    req.basic_auth(uri.user, uri.password) if uri.user || uri.password\n    res = http.request(req)\n    case res.code\n    when /\\A30[1,2]\\z/\n      download_file URI.join(url, res['Location']).to_s, depth + 1\n\n    when /\\A2\\d\\d\\z/\n      f = Tempfile.new([\"upload_url\", File.extname(uri.path)])\n      f.write(res.body)\n      f.close\n      [f.tap(&:open), File.basename(uri.path)]\n\n    else\n      raise Net::HTTPError, \"Failed #{res.code}\"\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache/version.rb",
    "content": "module Attache\n  VERSION = \"3.0.0\"\nend\n"
  },
  {
    "path": "lib/attache/vhost.rb",
    "content": "class Attache::VHost\n  attr_accessor :remotedir,\n                :secret_key,\n                :backup,\n                :bucket,\n                :storage,\n                :download_headers,\n                :headers_with_cors,\n                :geometry_whitelist,\n                :env\n\n  def initialize(hash)\n    self.env = hash || {}\n    self.remotedir  = env['REMOTE_DIR'] # nil means no fixed top level remote directory, and that's fine.\n    self.secret_key = env['SECRET_KEY'] # nil means no auth check; anyone can upload a file\n    self.geometry_whitelist = env['GEOMETRY_WHITELIST'] # nil means everything is acceptable\n\n    if env['FOG_CONFIG']\n      self.bucket       = env['FOG_CONFIG'].fetch('bucket')\n      self.storage      = Fog::Storage.new(env['FOG_CONFIG'].except('bucket').symbolize_keys)\n\n      if env['BACKUP_CONFIG']\n        backup_fog = env['FOG_CONFIG'].merge(env['BACKUP_CONFIG'])\n        self.backup = Attache::VHost.new(env.except('BACKUP_CONFIG').merge('FOG_CONFIG' => backup_fog))\n      end\n    end\n    self.download_headers = {\n      \"Cache-Control\" => \"public, max-age=31536000\"\n    }.merge(env['DOWNLOAD_HEADERS'] || {})\n    self.headers_with_cors = {\n      'Access-Control-Allow-Origin' => '*',\n      'Access-Control-Allow-Methods' => 'POST, PUT',\n      'Access-Control-Allow-Headers' => 'Content-Type',\n    }.merge(env['UPLOAD_HEADERS'] || {})\n  end\n\n  def hmac_for(content)\n    OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret_key, content)\n  end\n\n  def hmac_valid?(params)\n    params['uuid'] &&\n    params['hmac']  &&\n    params['expiration'] &&\n    Time.at(params['expiration'].to_i) > Time.now &&\n    Rack::Utils.secure_compare(params['hmac'], hmac_for(\"#{params['uuid']}#{params['expiration']}\"))\n  end\n\n  def storage_url(args)\n    object = remote_api.new({\n      key: File.join(*remotedir, args[:relpath]),\n    })\n    result = if object.respond_to?(:url)\n      object.url(Time.now + 600)\n    else\n      object.public_url\n    end\n  ensure\n    Attache.logger.info \"storage_url: #{result}\"\n  end\n\n  def storage_get(args)\n    open storage_url(args)\n  end\n\n  def storage_create(args)\n    Attache.logger.info \"[JOB] uploading #{args[:cachekey].inspect}\"\n    body = begin\n      Attache.cache.read(args[:cachekey])\n    rescue Errno::ENOENT\n      :no_entry # upload file no longer exist; likely deleted immediately after upload\n    end\n    unless body == :no_entry\n      remote_api.create({\n        key: File.join(*remotedir, args[:relpath]),\n        body: body,\n      })\n      Attache.logger.info \"[JOB] uploaded #{args[:cachekey]}\"\n    end\n  end\n\n  def storage_destroy(args)\n    Attache.logger.info \"[JOB] deleting #{args[:relpath]}\"\n    remote_api.new({\n      key: File.join(*remotedir, args[:relpath]),\n    }).destroy\n    Attache.logger.info \"[JOB] deleted #{args[:relpath]}\"\n  end\n\n  def remote_api\n    storage.directories.new(key: bucket).files\n  end\n\n  def async(method, args)\n    ::Attache::Job.perform_async(method, env, args)\n  end\n\n  def authorized?(params)\n    secret_key.blank? || hmac_valid?(params)\n  end\n\n  def unauthorized\n    [401, headers_with_cors.merge('X-Exception' => 'Authorization failed'), []]\n  end\n\n  def backup_file(args)\n    if backup\n      key = File.join(*remotedir, args[:relpath])\n      storage.copy_object(bucket, key, backup.bucket, key)\n    end\n  end\nend\n"
  },
  {
    "path": "lib/attache.rb",
    "content": "require 'active_support/all'\nrequire 'sys/filesystem'\nrequire 'securerandom'\nrequire 'disk_store'\nrequire 'fileutils'\nrequire 'paperclip'\nrequire 'net/http'\nrequire 'tempfile'\nrequire 'sidekiq'\nrequire 'tmpdir'\nrequire 'logger'\nrequire 'base64'\nrequire 'rack'\nrequire 'json'\nrequire 'uri'\nrequire 'cgi'\nrequire 'fog'\n\nif ENV['REDIS_PROVIDER'] || ENV['REDIS_URL']\n  # default sidekiq\nelsif ENV['INLINE_JOB']\n  require 'sidekiq/testing/inline'\nelse\n  require 'sucker_punch'\nend\n\nmodule Attache\n  class << self\n    attr_accessor :localdir,\n                  :vhost,\n                  :cache,\n                  :logger,\n                  :publicdir\n  end\nend\n\nAttache.logger     = Logger.new(STDOUT)\nAttache.localdir   = File.expand_path(ENV.fetch('LOCAL_DIR') { Dir.tmpdir })\nAttache.vhost      = JSON.parse(ENV.fetch('VHOST') { YAML.load(IO.read('config/vhost.yml')).to_json rescue '{}' })\nAttache.cache      = DiskStore.new(Attache.localdir, {\n  cache_size: ENV.fetch('CACHE_SIZE_BYTES') {\n    stat = Sys::Filesystem.stat(\"/\")\n    available = stat.block_size * stat.blocks_available\n    (available * 0.8).floor # use 80% free disk by default\n  }.to_i,\n  reaper_interval:   ENV.fetch('CACHE_EVICTION_INTERVAL_SECONDS') { 60 }.to_i,\n  eviction_strategy: (Attache.vhost.empty? ? nil : :LRU), # lru eviction only when there is remote storage\n})\nAttache.publicdir = ENV.fetch(\"PUBLIC_DIR\") { File.expand_path(\"../public\", File.dirname(__FILE__)) }\n\nrequire 'attache/job'\nrequire 'attache/resize_job'\nrequire 'attache/base'\nrequire 'attache/vhost'\nrequire 'attache/upload_url'\nrequire 'attache/upload'\nrequire 'attache/delete'\nrequire 'attache/backup'\nrequire 'attache/download'\nrequire 'attache/file_response_body'\n\nrequire 'attache/tus'\nrequire 'attache/tus/upload'\n"
  },
  {
    "path": "public/index.html",
    "content": "It works!\n"
  },
  {
    "path": "public/vendor/roboto/Apache License.txt",
    "content": "Font data copyright Google 2012\n\n                                Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "spec/fixtures/sample.txt",
    "content": "data:not a data uri\n"
  },
  {
    "path": "spec/lib/attache/backup_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Backup do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:middleware) { Attache::Backup.new(app) }\n  let(:params) { {} }\n  let(:filename) { \"hello#{rand}.gif\" }\n  let(:reldirname) { \"path#{rand}\" }\n  let(:file) { StringIO.new(IO.binread(\"spec/fixtures/transparent.gif\"), 'rb') }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n    allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(nil)\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, env = middleware.call Rack::MockRequest.env_for('http://example.com', {})\n    expect(code).to eq 200\n  end\n\n  context \"backup file\" do\n    let(:params) { Hash(paths: ['image1.jpg', filename].join(\"\\n\")) }\n\n    subject { proc { middleware.call Rack::MockRequest.env_for('http://example.com/backup?' + params.collect {|k,v| \"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}\"}.join('&'), method: 'DELETE', \"HTTP_HOST\" => \"example.com\") } }\n\n    it 'should respond with json' do\n    end\n\n    it 'should not touch local file' do\n      expect(Attache).not_to receive(:cache)\n      code, headers, body = subject.call\n      expect(code).to eq(200)\n    end\n\n    context 'storage configured' do\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(double(:storage))\n        allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(double(:bucket))\n      end\n\n      it 'should backup file' do\n        expect_any_instance_of(Attache::VHost).to receive(:backup_file).exactly(2).times\n        subject.call\n      end\n    end\n\n    context 'storage NOT configured' do\n      it 'should backup file' do\n        expect_any_instance_of(Attache::VHost).not_to receive(:backup_file)\n        subject.call\n      end\n    end\n\n    context 'with secret_key' do\n      let(:secret_key) { \"topsecret#{rand}\" }\n\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(secret_key)\n      end\n\n      it 'should respond with error' do\n        code, headers, body = subject.call\n        expect(code).to eq(401)\n        expect(headers['X-Exception']).to eq('Authorization failed')\n      end\n\n      context 'invalid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, \"wrong#{secret_key}\", \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with error' do\n          code, headers, body = subject.call\n          expect(code).to eq(401)\n          expect(headers['X-Exception']).to eq('Authorization failed')\n        end\n      end\n\n      context 'valid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, secret_key, \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with success' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n        end\n\n        context 'expired' do\n          let(:expiration) { (Time.now - 1).to_i } # the past\n\n          it 'should respond with error' do\n            code, headers, body = subject.call\n            expect(code).to eq(401)\n            expect(headers['X-Exception']).to eq('Authorization failed')\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/delete_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Delete do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:middleware) { Attache::Delete.new(app) }\n  let(:params) { {} }\n  let(:filename) { \"hello#{rand}.gif\" }\n  let(:reldirname) { \"path#{rand}\" }\n  let(:file) { StringIO.new(IO.binread(\"spec/fixtures/transparent.gif\"), 'rb') }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n    allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(nil)\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, env = middleware.call Rack::MockRequest.env_for('http://example.com', {})\n    expect(code).to eq 200\n  end\n\n  context \"deleting\" do\n    let(:params) { Hash(paths: ['image1.jpg', filename].join(\"\\n\")) }\n\n    subject { proc { middleware.call Rack::MockRequest.env_for('http://example.com/delete?' + params.collect {|k,v| \"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}\"}.join('&'), method: 'DELETE', \"HTTP_HOST\" => \"example.com\") } }\n\n    it 'should respond with json' do\n    end\n\n    it 'should delete file locally' do\n      expect(Attache.cache).to receive(:delete) do |path|\n        expect(path).to start_with('example.com')\n      end.exactly(2).times\n      code, headers, body = subject.call\n      expect(code).to eq(200)\n    end\n\n    context 'delete fail locally' do\n      before do\n        expect(Attache.cache).to receive(:delete) do\n          raise Exception.new\n        end.at_least(1).times\n      end\n\n      it 'should respond with error' do\n        code, headers, body = subject.call\n        expect(code).to eq(500)\n      end\n    end\n\n    context 'storage configured' do\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(double(:storage))\n        allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(double(:bucket))\n      end\n\n      it 'should delete file remotely' do\n        expect_any_instance_of(Attache::VHost).to receive(:async) do |instance, method, path|\n          expect(method).to eq(:storage_destroy)\n        end.exactly(2).times\n        subject.call\n      end\n    end\n\n    context 'storage NOT configured' do\n      it 'should NOT delete file remotely' do\n        expect_any_instance_of(Attache::VHost).not_to receive(:async)\n        subject.call\n      end\n    end\n\n    context 'backup configured' do\n      let(:backup) { double(:backup) }\n\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:backup).and_return(backup)\n      end\n\n      it 'should delete file in backup' do\n        expect(backup).to receive(:async) do |method, path|\n          expect(method).to eq(:storage_destroy)\n        end.exactly(2).times\n        subject.call\n      end\n    end\n\n    context 'backup NOT configured' do\n      it 'should NOT delete file in backup' do\n        expect_any_instance_of(Attache::VHost).not_to receive(:async)\n        subject.call\n      end\n    end\n\n    context 'with secret_key' do\n      let(:secret_key) { \"topsecret#{rand}\" }\n\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(secret_key)\n      end\n\n      it 'should respond with error' do\n        code, headers, body = subject.call\n        expect(code).to eq(401)\n        expect(headers['X-Exception']).to eq('Authorization failed')\n      end\n\n      context 'invalid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, \"wrong#{secret_key}\", \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with error' do\n          code, headers, body = subject.call\n          expect(code).to eq(401)\n          expect(headers['X-Exception']).to eq('Authorization failed')\n        end\n      end\n\n      context 'valid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, secret_key, \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with success' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n        end\n\n        context 'expired' do\n          let(:expiration) { (Time.now - 1).to_i } # the past\n\n          it 'should respond with error' do\n            code, headers, body = subject.call\n            expect(code).to eq(401)\n            expect(headers['X-Exception']).to eq('Authorization failed')\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/download_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Download do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:middleware) { Attache::Download.new(app) }\n  let(:params) { {} }\n  let(:filename) { \"hello#{rand}.gif\" }\n  let(:reldirname) { \"path#{rand}\" }\n  let(:geometry) { CGI.escape('2x2#') }\n  let(:file) { StringIO.new(IO.binread(\"spec/fixtures/transparent.gif\"), 'rb') }\n  let(:remote_url) { \"http://example.com/image.jpg\" }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, env = middleware.call Rack::MockRequest.env_for('http://example.com', \"HTTP_HOST\" => \"example.com\")\n    expect(code).to eq 200\n  end\n\n  context 'downloading' do\n    subject { proc { middleware.call Rack::MockRequest.env_for(\"http://example.com/view/#{reldirname}/#{geometry}/#{filename}\", \"HTTP_HOST\" => \"example.com\") } }\n\n    context 'not in local cache' do\n      before do\n        Attache.cache.delete(\"example.com/#{reldirname}/#{filename}\")\n      end\n\n      context 'no cloud storage configured' do\n        before do\n          allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(nil)\n          allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(nil)\n        end\n\n        it 'should respond not found' do\n          code, headers, body = subject.call\n          expect(code).to eq(404)\n        end\n\n        it 'should continue to respond not found' do\n          code, headers, body = subject.call\n          expect(code).to eq(404)\n          code, headers, body = subject.call\n          expect(code).to eq(404)\n        end\n      end\n\n      context 'with cloud storage configured' do\n        before do\n          allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(double(:storage, directories: Struct.new(:key, :files)))\n          allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(double(:bucket))\n        end\n\n        it 'should respond not found' do\n          code, headers, body = subject.call\n          expect(code).to eq(404)\n        end\n\n        context 'with backup configured' do\n          it 'should respond not found' do\n            allow_any_instance_of(Attache::VHost).to receive(:backup).and_return(double(:backup, storage_get: nil))\n            code, headers, body = subject.call\n            expect(code).to eq(404)\n          end\n\n          it 'should respond found if in backup' do\n            allow_any_instance_of(Attache::VHost).to receive(:backup).and_return(double(:backup, storage_get: file))\n            code, headers, body = subject.call\n            expect(code).to eq(200)\n          end\n        end\n\n        context 'available remotely' do\n          before do\n            allow_any_instance_of(Attache::VHost).to receive(:storage_get).and_return(file)\n            allow_any_instance_of(Attache::VHost).to receive(:storage_url).and_return(remote_url)\n          end\n\n          it 'should proceed normally' do\n            code, headers, body = subject.call\n            expect(code).to eq(200)\n          end\n\n          context 'geometry is \"remote\"' do\n\n            let(:geometry) { CGI.escape('remote') }\n\n            it 'should send remote file' do\n              expect(Attache.cache).not_to receive(:fetch)\n              expect_any_instance_of(Attache::VHost).to receive(:storage_url)\n              code, headers, body = subject.call\n              response_content = ''\n              body.each {|p| response_content += p }\n              expect(response_content).to eq('')\n              expect(code).to eq(302)\n              expect(headers['Location']).to eq(remote_url)\n              expect(headers['Cache-Control']).to eq(\"private, no-cache\")\n            end\n          end\n        end\n      end\n    end\n\n    context 'in local cache' do\n      before do\n        Attache.cache.write(\"example.com/#{reldirname}/#{filename}\", file)\n      end\n\n      context 'geometry is \"original\"' do\n        let(:geometry) { CGI.escape('original') }\n\n        it 'should send original file' do\n          expect_any_instance_of(middleware.class).not_to receive(:get_thumbnail_file)\n          code, headers, body = subject.call\n          response_content = ''\n          body.each {|p| response_content += p }\n          original_content = file.tap(&:rewind).read\n          expect(response_content).to eq(original_content)\n        end\n      end\n\n      context 'geometry_whitelist is present' do\n        let(:geometry_whitelist) { ['100x100'] }\n\n        before do\n          allow(middleware).to receive(:vhost_for).and_return(double(:vhost,\n            geometry_whitelist: geometry_whitelist,\n            storage: nil,\n            backup: nil,\n            download_headers: {}))\n        end\n\n        context 'geometry is whitelisted' do\n          let(:geometry) { geometry_whitelist.sample }\n\n          it 'should be allowed' do\n            code, headers, body = subject.call\n            expect(code).to eq(200)\n          end\n        end\n\n        context 'geometry is NOT whitelisted' do\n          let(:geometry) { '999x999' }\n\n          it 'should NOT be allowed' do\n            code, headers, body = subject.call\n            expect(code).to eq(415)\n            expect(body).to eq([\"#{geometry} is not supported\"])\n          end\n        end\n      end\n\n      context 'rendering' do\n        context 'non image' do\n          let(:file) { StringIO.new(IO.binread(\"spec/fixtures/sample.txt\"), 'rb') }\n          let(:filename) { \"hello#{rand}.txt\" }\n\n          it 'should output as png' do\n            expect_any_instance_of(Attache::ResizeJob).to receive(:make_nonimage_preview).exactly(1).times.and_call_original\n            code, headers, body = subject.call\n            expect(code).to eq(200)\n            expect(headers['Content-Type']).to eq(\"image/png\")\n          end\n        end\n\n        context 'image' do\n          it 'should output as gif' do\n            expect_any_instance_of(Attache::ResizeJob).not_to receive(:make_nonimage_preview)\n            code, headers, body = subject.call\n            expect(code).to eq(200)\n            expect(headers['Content-Type']).to eq(\"image/gif\")\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/resize_job_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::ResizeJob do\n  describe '#thumbnail_for' do\n    let(:max) { 2048 }\n    let(:current) { [1, 1] }\n    let(:current_w) { current.shift }\n    let(:current_h) { current.shift }\n    let(:job) { Attache::ResizeJob.new }\n    let(:original_path) { \"spec/fixtures/transparent.gif\" }\n\n    before {\n      allow(job).to receive(:current_geometry_for).and_return(Paperclip::Geometry.new(current_w, current_h))\n      Attache.cache.delete(Digest::SHA1.hexdigest(\"#{max}x#{max}>\" + original_path))\n    }\n\n    subject {\n      job.send(:thumbnail_for, closed_file: File.new(original_path),\n                               target_geometry_string: target,\n                               extension: \"gif\").file.path\n    }\n\n    context 'target > max' do\n      let(:target) { [\"#{max+1}x1>\", \"1x#{max+1}>\"].sample }\n\n      it {\n        expect_any_instance_of(Paperclip::Thumbnail).not_to receive(:make)\n        is_expected.to eq(original_path)\n      }\n    end\n\n    context 'target <= max' do\n      let(:target) { [\"#{max}x1>\", \"1x#{max}>\"].sample }\n\n      context 'current > max' do\n        let(:current) { [max+1, 1].shuffle }\n\n        it {\n          expect_any_instance_of(Paperclip::Thumbnail).to receive(:make) do |instance|\n            expect(instance.target_geometry.to_s).to eq(\"#{max}x#{max}>\")\n            File.new(original_path)\n          end\n          is_expected.not_to eq(original_path)\n        }\n      end\n\n      context 'current <= max' do\n        let(:current) { [max-1, 1].shuffle }\n\n        it {\n          expect_any_instance_of(Paperclip::Thumbnail).not_to receive(:make)\n          is_expected.to eq(original_path)\n        }\n      end\n    end\n\n    context 'convert_options for jpg, jpeg' do\n      let(:jpg_path) { \"spec/fixtures/landscape.jpg\" }\n      let(:thumbnail) {\n        job.send(:thumbnail_for, closed_file: File.new(jpg_path),\n                               target_geometry_string: '1x1>',\n                               extension: %w(jpg jpeg).sample)\n      }\n\n      it { expect(thumbnail.convert_options).to eq(%w(-interlace Plane)) }\n    end\n\n    context 'convert_options for other file extensions' do\n      let(:thumbnail) {\n        job.send(:thumbnail_for, closed_file: File.new(original_path),\n                               target_geometry_string: '1x1>',\n                               extension: %w(png gif tiff bmp).sample)\n      }\n\n      it { expect(thumbnail.convert_options).to be_nil }\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/tus/upload_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Tus::Upload do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:middleware) { Attache::Tus::Upload.new(app) }\n  let(:params) { Hash(file: filename) }\n  let(:filename) { \"Exãmple %#{rand} %20.gif\" }\n  let(:file) { StringIO.new(IO.binread(\"spec/fixtures/landscape.jpg\"), 'rb') }\n  let(:filesize) { File.size \"spec/fixtures/landscape.jpg\" }\n  let(:hostname) { \"example.com\" }\n  let(:create_path) { '/tus/files?' + params.collect {|k,v| \"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}\"}.join('&') }\n  let(:resume_path) { @location }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n    allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(nil)\n    allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(nil)\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, headers, body = middleware.call Rack::MockRequest.env_for('http://' + hostname, \"HTTP_HOST\" => hostname)\n    expect(code).to eq 200\n  end\n\n\n  def make_request_to(request_uri, headers)\n    middleware.call Rack::MockRequest.env_for('http://' + hostname + request_uri, Hash(\"HTTP_HOST\" => hostname, 'HTTP_UPLOAD_METADATA' => \"key #{Base64.encode64('value')},filename #{Base64.encode64(filename)}\").merge(headers))\n  end\n\n  context \"tus creation\" do\n    it \"must reject missing HTTP_ENTITY_LENGTH\" do\n      code, headers, body = make_request_to(create_path, method: 'POST', input: file)\n      expect(code).to eq(400)\n    end\n\n    it \"must reject invalid HTTP_ENTITY_LENGTH\" do\n      code, headers, body = make_request_to(create_path, method: 'POST', input: file, 'HTTP_ENTITY_LENGTH' => [-1, 'abc'].sample)\n      expect(code).to eq(400)\n    end\n\n    it \"must respond successfully with HTTP 201 + Location header\" do\n      code, headers, body = make_request_to(create_path, method: 'POST', input: file, 'HTTP_ENTITY_LENGTH' => filesize)\n      expect(code).to eq(201)\n      expect(headers['Location']).to be_present\n    end\n  end\n\n  context \"with uploaded file\" do\n    let(:relpath)  { CGI.unescape @location.match(/relpath=([^&]+)/)[1] }\n    let(:cachekey) { File.join(hostname, relpath) }\n    let(:current_offset) { 3 + rand(10) }\n\n    before do\n      code, headers, body = make_request_to(create_path, method: 'POST', input: file, 'HTTP_ENTITY_LENGTH' => filesize)\n      expect(code).to eq(201)\n      @location = URI.parse(headers['Location']).request_uri\n      open(middleware.path_of(cachekey), \"a\") {|f| f.write('a' * current_offset) }\n    end\n\n    context \"tus patch\" do\n      it \"must reject invalid HTTP_OFFSET\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file)\n        expect(code).to eq(400)\n      end\n\n      it \"must reject invalid HTTP_CONTENT_LENGTH\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => 0)\n        expect(code).to eq(400)\n      end\n\n      it \"must reject invalid Content-Type: application/offset+octet-stream\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => 0, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => [\"application/octet-stream\", nil].sample)\n        expect(code).to eq(400)\n      end\n\n      it \"must reject invalid Tus-Resumable version\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => 0, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => \"application/offset+octet-stream\", \"HTTP_TUS_RESUMABLE\" => [\"0.9.9\", \"2.0.0\"].sample)\n        expect(code).to eq(400)\n      end\n\n      it \"must respond successfully with HTTP 200\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => 0, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => \"application/offset+octet-stream\", \"HTTP_TUS_RESUMABLE\" => \"1.0.0\")\n        expect(headers['X-Exception']).to be_nil\n        expect(code).to eq(200)\n      end\n\n      it \"must accept `offset` smaller or equal to current offset\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => current_offset - [0, 1].sample, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => \"application/offset+octet-stream\", \"HTTP_TUS_RESUMABLE\" => \"1.0.0\")\n        expect(headers['X-Exception']).to be_nil\n        expect(code).to eq(200)\n      end\n\n      it \"must reject `offset` larger than current offset\" do\n        code, headers, body = make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => current_offset + 1, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => \"application/offset+octet-stream\", \"HTTP_TUS_RESUMABLE\" => \"1.0.0\")\n        expect(code).to eq(400)\n      end\n\n      it \"new current offset must be Offset + Content-Length\" do\n        expect {\n          make_request_to(resume_path, method: 'PATCH', input: file, \"HTTP_OFFSET\" => current_offset, \"HTTP_CONTENT_LENGTH\" => filesize, \"CONTENT_TYPE\" => \"application/offset+octet-stream\", \"HTTP_TUS_RESUMABLE\" => \"1.0.0\")\n        }.to change {\n          middleware.send(:current_offset, cachekey, relpath, config = {})\n        }.by(filesize)\n      end\n    end\n\n    context \"tus head\" do\n      it \"must respond successfully with HTTP 200 + Offset header\" do\n        code, headers, body = make_request_to(resume_path, method: 'HEAD')\n        expect(code).to eq(200)\n        expect(headers).to eq({\n          \"Access-Control-Allow-Origin\"   => \"*\",\n          \"Access-Control-Allow-Methods\"  => \"POST, PUT, PATCH\",\n          \"Access-Control-Allow-Headers\"  => \"Content-Type, Tus-Resumable, Upload-Length, Entity-Length, Upload-Metadata, Metadata, Upload-Offset, Offset\",\n          \"Content-Type\"                  => \"text/json\",\n          \"Access-Control-Expose-Headers\" => \"Location, Upload-Offset, Offset\",\n          \"Offset\"                        => current_offset.to_s,\n          \"Upload-Offset\"                 => current_offset.to_s,\n        })\n      end\n    end\n\n    context \"tus get\" do\n      it \"must redirec to attache download url for original geometry\" do\n        code, headers, body = make_request_to(resume_path, method: 'GET')\n        expect(code).to eq(302)\n        expect(File.basename headers['Location']).to eq(CGI.escape(Attache::Upload.sanitize filename))\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/tus_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Tus do\n  let(:env) { @env }\n  let(:config) { @config }\n  let(:tus) { Attache::Tus.new(env, config) }\n\n  it \"should return Entity-Length for upload_length\" do\n    @env = { 'HTTP_ENTITY_LENGTH' => rand }\n    expect(tus.upload_length).to eq(@env['HTTP_ENTITY_LENGTH'])\n  end\n\n  it \"should prefer Upload-Length for upload_length\" do\n    @env = { 'HTTP_ENTITY_LENGTH' => rand, 'HTTP_UPLOAD_LENGTH' => rand }\n    expect(tus.upload_length).to eq(@env['HTTP_UPLOAD_LENGTH'])\n  end\n\n  it \"should return Offset for upload_offset\" do\n    @env = { 'HTTP_OFFSET' => rand }\n    expect(tus.upload_offset).to eq(@env['HTTP_OFFSET'])\n  end\n\n  it \"should prefer Upload-Offset for upload_offset\" do\n    @env = { 'HTTP_OFFSET' => rand, 'HTTP_UPLOAD_OFFSET' => rand }\n    expect(tus.upload_offset).to eq(@env['HTTP_UPLOAD_OFFSET'])\n  end\n\n  it \"should parse upload_metadata\" do\n    @env = { \"HTTP_UPLOAD_METADATA\" => \"key dmFsdWU=,randkey0.87393016369783 cmFuZHZhbHVlMC44MzYxNjcyOTk3OTQyMTU2\" }\n    expect(tus.upload_metadata).to eq({\n      \"key\" => \"value\",\n      \"randkey0.87393016369783\" => \"randvalue0.8361672997942156\",\n    })\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/upload_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::Upload do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:middleware) { Attache::Upload.new(app) }\n  let(:params) { {} }\n  let(:filename) { \"Exãmple %#{rand} %20.gif\" }\n  let(:file) { StringIO.new(IO.binread(\"spec/fixtures/landscape.jpg\"), 'rb') }\n  let(:request_input) { file }\n  let(:base64_data) { \"data:image/gif;base64,\" + Base64.encode64(file.read) }\n  let(:hostname) { \"example.com\" }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n    allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(nil)\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, headers, body = middleware.call Rack::MockRequest.env_for('http://' + hostname, \"HTTP_HOST\" => hostname)\n    expect(code).to eq 200\n  end\n\n  context \"uploading\" do\n    let(:params) { Hash(file: filename) }\n\n    subject { proc { middleware.call Rack::MockRequest.env_for('http://' + hostname + '/upload?' + params.collect {|k,v| \"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}\"}.join('&'), method: 'PUT', input: request_input, \"HTTP_HOST\" => hostname) } }\n\n    it 'should respond successfully with json' do\n      code, headers, body = subject.call\n      expect(code).to eq(200)\n      expect(headers['Content-Type']).to eq('text/json')\n      JSON.parse(body.join('')).tap do |json|\n        expect(json).to be_has_key('path')\n        expect(json['geometry']).to eq('4x3')\n        expect(json['bytes']).to eq(425)\n        expect(json['signature']).to eq(nil)\n      end\n    end\n\n    it 'should wrote to cache with Attache::Upload.sanitize(params[:file]) as filename' do\n      code, headers, body = subject.call\n      json = JSON.parse(body.join(''))\n      relpath = json['path']\n      expect(relpath).to end_with(Attache::Upload.sanitize params[:file])\n      expect(Attache.cache.read(hostname + '/' + relpath).tap(&:close)).to be_kind_of(File)\n    end\n\n    # does not support base64/data uri here\n    # see upload_url.rb\n    context 'base64' do\n      context 'base64-encoded image' do\n        let!(:request_input) { StringIO.new(\"data:image/gif;base64,\" + Base64.encode64(file.read)) }\n\n        it 'should respond identically as when uploading binary' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['geometry']).to eq(nil)\n            expect(json['content_type']).to eq('text/plain')\n          end\n        end\n      end\n\n      # various Data URI permutations\n      # https://developer.mozilla.org/en-US/docs/Web/HTTP/data_URIs\n      context 'simple text/plain data' do\n        let!(:request_input) { StringIO.new \"data:,Hello%2C%20World!\" }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/plain')\n            expect(json['bytes']).to eq(23)\n          end\n        end\n      end\n\n      context \"base64-encoded version of the above\" do\n        let!(:request_input) { StringIO.new \"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D\" }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/plain')\n            expect(json['bytes']).to eq(47)\n          end\n        end\n      end\n\n      context \"An HTML document with <html><body><h1>Hello, World!</h1></body></html>\" do\n        let!(:request_input) { StringIO.new \"data:text/html,%3Chtml%3E%3Cbody%3E%3Ch1%3EHello,%20World!%3C/h1%3E%3C/body%3E%3C/html%3E\" }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/plain')\n            expect(json['bytes']).to eq(89)\n          end\n        end\n      end\n\n      context \"An HTML document that executes a JavaScript alert\" do\n        let!(:request_input) { StringIO.new \"data:text/html,<script>alert('hi');</script>\" }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/html')\n            expect(json['bytes']).to eq(44)\n          end\n        end\n      end\n    end\n\n    context 'plain text with data: prefix' do\n      let!(:file) { StringIO.new(IO.binread(\"spec/fixtures/sample.txt\"), 'rb') }\n\n      it 'should not be mangled by Base64 decoding' do\n        code, headers, body = subject.call\n        expect(code).to eq(200)\n        expect(headers['Content-Type']).to eq('text/json')\n        JSON.parse(body.join('')).tap do |json|\n          expect(json).to be_has_key('path')\n          expect(json['content_type']).to eq('text/plain')\n          expect(json['bytes']).to eq(20)\n        end\n      end\n    end\n\n    context 'save fail locally' do\n      before do\n        allow(Attache.cache).to receive(:write).and_return(0)\n      end\n\n      it 'should respond with error' do\n        code, headers, body = subject.call\n        expect(code).to eq(500)\n        expect(headers['X-Exception']).to eq('Local file failed')\n      end\n    end\n\n    context 'storage not configured' do\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(nil)\n        allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(nil)\n      end\n\n      it 'should NOT save file remotely' do\n        expect_any_instance_of(Attache::VHost).not_to receive(:storage_create)\n        subject.call\n      end\n    end\n\n    context 'storage configured' do\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(double(:storage))\n        allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(double(:bucket))\n      end\n\n      it 'should save file remotely' do\n        expect_any_instance_of(Attache::VHost).to receive(:storage_create).and_return(anything)\n        subject.call\n      end\n    end\n\n    context 'with secret_key' do\n      let(:secret_key) { \"topsecret#{rand}\" }\n\n      before do\n        allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(secret_key)\n      end\n\n      it 'should respond with error' do\n        code, headers, body = subject.call\n        expect(code).to eq(401)\n        expect(headers['X-Exception']).to eq('Authorization failed')\n      end\n\n      context 'invalid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, \"wrong#{secret_key}\", \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with error' do\n          code, headers, body = subject.call\n          expect(code).to eq(401)\n          expect(headers['X-Exception']).to eq('Authorization failed')\n        end\n      end\n\n      context 'valid auth' do\n        let(:expiration) { (Time.now + 10).to_i }\n        let(:uuid) { \"hi#{rand}\" }\n        let(:digest) { OpenSSL::Digest.new('sha1') }\n        let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, secret_key, \"#{uuid}#{expiration}\")) }\n\n        it 'should respond with success' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n        end\n\n        it 'should respond successfully with json with signature' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            json_without_signature = json.reject {|k,v| k == 'signature' }\n            generated_signature = OpenSSL::HMAC.hexdigest(digest, secret_key, json_without_signature.sort.collect {|k,v| \"#{k}=#{v}\" }.join('&'))\n            expect(json['signature']).to eq(generated_signature)\n          end\n        end\n\n        context 'expired' do\n          let(:expiration) { (Time.now - 1).to_i } # the past\n\n          it 'should respond with error' do\n            code, headers, body = subject.call\n            expect(code).to eq(401)\n            expect(headers['X-Exception']).to eq('Authorization failed')\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/upload_url_spec.rb",
    "content": "require 'spec_helper'\n\ndescribe Attache::UploadUrl do\n  let(:app) { ->(env) { [200, env, \"app\"] } }\n  let(:uploader) { Attache::Upload.new(app) }\n  let(:middleware) { Attache::UploadUrl.new(uploader) }\n  let(:hostname) { \"example.com\" }\n\n  before do\n    allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety\n    allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(nil)\n  end\n\n  after do\n    FileUtils.rm_rf(Attache.localdir)\n  end\n\n  it \"should passthrough irrelevant request\" do\n    code, headers, body = middleware.call Rack::MockRequest.env_for('http://' + hostname, \"HTTP_HOST\" => hostname)\n    expect(code).to eq 200\n  end\n\n  context 'upload as url' do\n    subject { proc { middleware.call Rack::MockRequest.env_for('http://' + hostname + '/upload_url?' + params.collect {|k,v| \"#{CGI.escape k.to_s}=#{CGI.escape v.to_s}\"}.join('&'), method: 'PUT', \"HTTP_HOST\" => hostname) } }\n\n    context 'to image' do\n      let(:params) { Hash(url: \"https://raw.githubusercontent.com/choonkeat/attache/master/spec/fixtures/landscape.jpg\") }\n\n      it 'should respond successfully with json' do\n        code, headers, body = subject.call\n        expect(code).to eq(200)\n        expect(headers['Content-Type']).to eq('text/json')\n        JSON.parse(body.join('')).tap do |json|\n          expect(json).to be_has_key('path')\n          expect(json['geometry']).to eq('4x3')\n        end\n      end\n    end\n\n    context 'follow redirect; works with non image too' do\n      let(:params) { Hash(url: \"http://google.com\") }\n\n      it 'should respond successfully with json' do\n        code, headers, body = subject.call\n        expect(code).to eq(200)\n        expect(headers['Content-Type']).to eq('text/json')\n        JSON.parse(body.join('')).tap do |json|\n          expect(json).to be_has_key('path')\n          expect(json['path']).not_to end_with('/')\n          expect(json['content_type']).to eq('text/html')\n        end\n      end\n    end\n\n    context 'data uri' do\n      context 'base64-encoded image' do\n        let(:file) { StringIO.new(IO.binread(\"spec/fixtures/landscape.jpg\"), 'rb') }\n        let(:params) { Hash(url: \"data:image/gif;base64,\" + Base64.encode64(file.read)) }\n\n        it 'should respond identically as when uploading binary' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['geometry']).to eq('4x3')\n            expect(json['bytes']).to eq(425)\n          end\n        end\n      end\n\n      # various Data URI permutations\n      # https://developer.mozilla.org/en-US/docs/Web/HTTP/data_URIs\n      context 'simple text/plain data' do\n        let(:params) { Hash(url: \"data:,Hello%2C%20World!\") }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/plain')\n            expect(json['bytes']).to eq(13)\n          end\n        end\n      end\n\n      context \"base64-encoded version of the above\" do\n        let(:params) { Hash(url: \"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D\") }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/plain')\n            expect(json['bytes']).to eq(13)\n          end\n        end\n      end\n\n      context \"An HTML document with <html><body><h1>Hello, World!</h1></body></html>\" do\n        let(:params) { Hash(url: \"data:text/html,%3Chtml%3E%3Cbody%3E%3Ch1%3EHello,%20World!%3C/h1%3E%3C/body%3E%3C/html%3E\") }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/html')\n            expect(json['bytes']).to eq(48)\n          end\n        end\n      end\n\n      context \"An HTML document that executes a JavaScript alert\" do\n        let(:params) { Hash(url: \"data:text/html,<script>alert('hi');</script>\") }\n\n        it 'should decode' do\n          code, headers, body = subject.call\n          expect(code).to eq(200)\n          expect(headers['Content-Type']).to eq('text/json')\n          JSON.parse(body.join('')).tap do |json|\n            expect(json).to be_has_key('path')\n            expect(json['content_type']).to eq('text/html')\n            expect(json['bytes']).to eq(29)\n          end\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/lib/attache/vhost_spec.rb",
    "content": "require 'spec_helper'\nrequire 'fog/storage/local/models/file'\nrequire 'fog/aws/models/storage/file'\n\ndescribe Attache::VHost do\n  let(:config) { { 'REMOTE_DIR' => remotedir } }\n  let(:config_with_backup) { YAML.load_file('config/vhost.example.yml').fetch(\"aws.example.com\").merge('REMOTE_DIR' => remotedir) }\n  let(:vhost) { Attache::VHost.new(config) }\n  let(:remote_api) { double(:remote_api) }\n  let(:file_io) { StringIO.new(\"\") }\n  let(:relpath) { 'relpath' }\n  let(:cachekey) { 'hostname/relpath' }\n  let(:remotedir) { 'remote_directory' }\n\n  before do\n    allow(vhost).to receive(:remote_api).and_return(remote_api)\n  end\n\n  describe '#storage_url' do\n    let(:url) { 'http://example.com/a/b/c' }\n\n    before do\n      allow(remote_api).to receive(:new).and_return(files)\n      allow_any_instance_of(Fog::Storage::Local::File).to receive(:public_url).and_return(url)\n      allow_any_instance_of(Fog::Storage::AWS::File).to receive(:url).and_return(url)\n    end\n\n    context 'fog local storage' do\n      let(:files) { return Fog::Storage::Local::File.new }\n\n      it 'should return' do\n        expect(vhost.storage_url(relpath: relpath)).to eq(url)\n      end\n    end\n\n    context 'fog s3 storage' do\n      let(:files) { return Fog::Storage::AWS::File.new }\n\n      it 'should return' do\n        expect(vhost.storage_url(relpath: relpath)).to eq(url)\n      end\n    end\n  end\n\n  describe '#storage_create' do\n    it 'should read with cachekey, write with remotedir prefix' do\n      expect(Attache.cache).to receive(:read).with(cachekey).and_return(file_io)\n      expect(remote_api).to receive(:create).with(key: \"#{remotedir}/#{relpath}\", body: file_io)\n      vhost.storage_create(relpath: relpath, cachekey: cachekey)\n    end\n\n    it 'should raise on other errors' do\n      allow(Attache.cache).to receive(:read) { raise Exception.new }\n      expect(remote_api).not_to receive(:create)\n\n      expect { vhost.storage_create(relpath: relpath, cachekey: cachekey) }.to raise_error(Exception)\n    end\n  end\n\n  describe '#storage' do\n    it { expect(vhost.storage).to be_nil }\n\n    context 'configured' do\n      let(:config) { config_with_backup }\n\n      it { expect(vhost.storage).to be_kind_of(Fog::Storage::AWS::Real) }\n      it { expect(vhost.storage.region).to eq('us-west-1') }\n    end\n  end\n\n  describe '#bucket' do\n    it { expect(vhost.bucket).to be_nil }\n\n    context 'configured' do\n      let(:config) { config_with_backup }\n\n      it { expect(vhost.bucket).to eq(\"CHANGEME\") }\n    end\n  end\n\n  describe '#backup' do\n    it { expect(vhost.backup).to be_nil }\n\n    describe '#backup_file' do\n      it 'should not do anything' do\n        allow_message_expectations_on_nil\n        expect(vhost.storage).not_to receive(:copy_object)\n        vhost.backup_file(relpath: relpath)\n      end\n    end\n\n    context 'configured' do\n      let(:config) { config_with_backup }\n\n      it { expect(vhost.backup).to be_kind_of(Attache::VHost) }\n      it { expect(vhost.backup.storage).to be_kind_of(Fog::Storage::AWS::Real) }\n      it { expect(vhost.backup.storage.region).to eq('us-west-1') }\n      it { expect(vhost.backup.bucket).to eq(\"CHANGEME_BAK\") }\n\n      describe '#backup_file' do\n        it 'should not do anything' do\n          expect(vhost.storage).to receive(:copy_object).with(\n            vhost.bucket, \"#{remotedir}/#{relpath}\",\n            vhost.backup.bucket, \"#{remotedir}/#{relpath}\"\n          )\n          vhost.backup_file(relpath: relpath)\n        end\n      end\n    end\n  end\nend\n"
  },
  {
    "path": "spec/spec_helper.rb",
    "content": "ENV['VHOST'] = '{\"0.0.0.0\":{}}'\n\nrequire 'attache.rb'\nrequire 'sucker_punch/testing/inline'\n\n# This file was generated by the `rspec --init` command. Conventionally, all\n# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.\n# The generated `.rspec` file contains `--require spec_helper` which will cause\n# this file to always be loaded, without a need to explicitly require it in any\n# files.\n#\n# Given that it is always loaded, you are encouraged to keep this file as\n# light-weight as possible. Requiring heavyweight dependencies from this file\n# will add to the boot time of your test suite on EVERY test run, even for an\n# individual file that may not need all of that loaded. Instead, consider making\n# a separate helper file that requires the additional dependencies and performs\n# the additional setup, and require it from the spec files that actually need\n# it.\n#\n# The `.rspec` file also contains a few flags that are not defaults but that\n# users commonly want.\n#\n# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration\nRSpec.configure do |config|\n  # rspec-expectations config goes here. You can use an alternate\n  # assertion/expectation library such as wrong or the stdlib/minitest\n  # assertions if you prefer.\n  config.expect_with :rspec do |expectations|\n    # This option will default to `true` in RSpec 4. It makes the `description`\n    # and `failure_message` of custom matchers include text for helper methods\n    # defined using `chain`, e.g.:\n    #     be_bigger_than(2).and_smaller_than(4).description\n    #     # => \"be bigger than 2 and smaller than 4\"\n    # ...rather than:\n    #     # => \"be bigger than 2\"\n    expectations.include_chain_clauses_in_custom_matcher_descriptions = true\n  end\n\n  # rspec-mocks config goes here. You can use an alternate test double\n  # library (such as bogus or mocha) by changing the `mock_with` option here.\n  config.mock_with :rspec do |mocks|\n    # Prevents you from mocking or stubbing a method that does not exist on\n    # a real object. This is generally recommended, and will default to\n    # `true` in RSpec 4.\n    mocks.verify_partial_doubles = true\n  end\n\n# The settings below are suggested to provide a good initial experience\n# with RSpec, but feel free to customize to your heart's content.\n  # These two settings work together to allow you to limit a spec run\n  # to individual examples or groups you care about by tagging them with\n  # `:focus` metadata. When nothing is tagged with `:focus`, all examples\n  # get run.\n  config.filter_run :focus\n  config.run_all_when_everything_filtered = true\n\n  # Run specs in random order to surface order dependencies. If you find an\n  # order dependency and want to debug it, you can fix the order by providing\n  # the seed, which is printed after each run.\n  #     --seed 1234\n  config.order = :random\n\n=begin\n  # Limits the available syntax to the non-monkey patched syntax that is\n  # recommended. For more details, see:\n  #   - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax\n  #   - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/\n  #   - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching\n  config.disable_monkey_patching!\n\n  # This setting enables warnings. It's recommended, but in some cases may\n  # be too noisy due to issues in dependencies.\n  config.warnings = true\n\n  # Many RSpec users commonly either run the entire suite or an individual\n  # file, and it's useful to allow more verbose output when running an\n  # individual spec file.\n  if config.files_to_run.one?\n    # Use the documentation formatter for detailed output,\n    # unless a formatter has already been configured\n    # (e.g. via a command-line flag).\n    config.default_formatter = 'doc'\n  end\n\n  # Print the 10 slowest examples and example groups at the\n  # end of the spec run, to help surface which specs are running\n  # particularly slow.\n  config.profile_examples = 10\n\n  # Seed global randomization in this process using the `--seed` CLI option.\n  # Setting this allows you to use `--seed` to deterministically reproduce\n  # test failures related to randomization by passing the same `--seed` value\n  # as the one that triggered the failure.\n  Kernel.srand config.seed\n=end\nend\n\nAttache.logger = Logger.new(\"/dev/null\")\nPaperclip.options[:log] = false\nSidekiq::Logging.logger = nil\nSuckerPunch.logger = nil\n"
  }
]