├── spec ├── fixtures │ └── rockrule.jpeg ├── spec_helper.rb ├── s3 │ └── app_spec.rb └── shared_examples.rb ├── Rakefile ├── .gitignore ├── config.ru ├── lib ├── rack │ └── common_logger.rb └── remote_storage │ ├── s3.rb │ └── rest_provider.rb ├── run-dev.sh ├── .drone.yml ├── Dockerfile ├── Gemfile ├── config.yml.erb.example ├── liquor-cabinet.gemspec ├── LICENSE.txt ├── README.md ├── Gemfile.lock ├── migrate_storage_size_from_metadata.rb └── liquor-cabinet.rb /spec/fixtures/rockrule.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5apps/liquor-cabinet/HEAD/spec/fixtures/rockrule.jpeg -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.pattern = 'spec/**/*_spec.rb' 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yml 2 | config.yml.erb 3 | cs_credentials.json 4 | pids 5 | .bundle 6 | vendor/bundle 7 | log 8 | tmp 9 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | 4 | Bundler.require 5 | 6 | require './liquor-cabinet' 7 | run LiquorCabinet 8 | -------------------------------------------------------------------------------- /lib/rack/common_logger.rb: -------------------------------------------------------------------------------- 1 | # Disable Rack logger completely 2 | module Rack 3 | class CommonLogger 4 | def call(env) 5 | # do nothing 6 | @app.call(env) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /run-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RACK_ENV=development \ 4 | REDIS_HOST=localhost \ 5 | REDIS_PORT=6379 \ 6 | REDIS_DB=1 \ 7 | S3_ENDPOINT='http://localhost:9000' \ 8 | S3_ACCESS_KEY='dev-key' \ 9 | S3_SECRET_KEY='123456789' \ 10 | S3_BUCKET=remotestorage \ 11 | bundle exec rackup -p 4567 12 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | steps: 5 | - name: specs 6 | image: ruby 7 | environment: 8 | REDIS_HOST: redis 9 | commands: 10 | - cp config.yml.erb.example config.yml.erb 11 | - bundle install --jobs=3 --retry=3 12 | - bundle exec rake test 13 | 14 | services: 15 | - name: redis 16 | image: redis 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1.4 2 | 3 | WORKDIR /liquorcabinet 4 | ENV RACK_ENV=production 5 | 6 | COPY Gemfile Gemfile.lock /liquorcabinet/ 7 | RUN bundle install 8 | COPY . /liquorcabinet 9 | COPY ./config.yml.erb.example /liquorcabinet/config.yml.erb 10 | 11 | EXPOSE 4567 12 | 13 | CMD ["bundle", "exec", "rainbows", "--listen", "0.0.0.0:4567"] 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "sinatra", "~> 2.2.0" 4 | gem "sinatra-contrib", "~> 2.2.0" 5 | gem "activesupport", "~> 6.1.0" 6 | gem "redis", "~> 4.6.0" 7 | gem "rest-client", "~> 2.1.0" 8 | gem "aws-sigv4", "~> 1.0.0" 9 | gem "mime-types" 10 | gem "rainbows" 11 | 12 | group :test do 13 | gem 'rake' 14 | gem 'rack-test' 15 | gem 'm' 16 | gem 'minitest' 17 | gem 'minitest-stub_any_instance' 18 | gem 'webmock' 19 | end 20 | 21 | group :staging, :production do 22 | gem "sentry-raven", require: false 23 | end 24 | -------------------------------------------------------------------------------- /config.yml.erb.example: -------------------------------------------------------------------------------- 1 | development: &defaults 2 | maintenance: false 3 | redis: 4 | host: <%= ENV["REDIS_HOST"] || "localhost" %> 5 | port: <%= ENV["REDIS_PORT"] || 6379 %> 6 | db: <%= ENV["REDIS_DB"] || 1 %> 7 | s3: &s3_defaults 8 | endpoint: <%= ENV["S3_ENDPOINT"] || "http://127.0.0.1:9000" %> 9 | region: <%= ENV["S3_REGION"] || "us-east-1" %> 10 | access_key_id: <%= ENV["S3_ACCESS_KEY"] || "minioadmin" %> 11 | secret_key_id: <%= ENV["S3_SECRET_KEY"] || "minioadmin" %> 12 | bucket: <%= ENV["S3_BUCKET"] || "rs-development" %> 13 | test: 14 | <<: *defaults 15 | staging: 16 | <<: *defaults 17 | production: 18 | <<: *defaults 19 | -------------------------------------------------------------------------------- /liquor-cabinet.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib/', __FILE__) 3 | $:.unshift lib unless $:.include?(lib) 4 | 5 | require 'bundler/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "liquor-cabinet" 9 | s.version = "0.0.1" 10 | s.platform = Gem::Platform::RUBY 11 | s.authors = ["Sebastian Kippe"] 12 | s.email = ["sebastian@5apps.com"] 13 | s.homepage = "" 14 | s.summary = "" 15 | s.description = "" 16 | 17 | s.required_rubygems_version = ">= 1.3.6" 18 | 19 | s.add_dependency('sinatra') 20 | s.add_dependency('sinatra-contrib') 21 | s.add_dependency('riak-client') 22 | 23 | s.files = Dir.glob("{bin,lib}/**/*") + Dir['*.rb'] 24 | # s.executables = ['config.ru'] 25 | s.require_paths << '.' 26 | end 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Appcache Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RACK_ENV"] = "test" 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | Bundler.require 6 | 7 | require_relative '../liquor-cabinet' 8 | require 'minitest/autorun' 9 | require "minitest/stub_any_instance" 10 | require 'rack/test' 11 | require "redis" 12 | require "rest_client" 13 | require "ostruct" 14 | require 'webmock/minitest' 15 | 16 | WebMock.disable_net_connect! 17 | 18 | def app 19 | LiquorCabinet 20 | end 21 | 22 | app.set :environment, :test 23 | 24 | alias context describe 25 | 26 | if app.settings.respond_to? :redis 27 | def redis 28 | @redis ||= Redis.new(app.settings.redis.symbolize_keys) 29 | end 30 | 31 | def purge_redis 32 | redis.keys("rs*").each do |key| 33 | redis.del key 34 | end 35 | end 36 | end 37 | 38 | Minitest::Spec.class_eval do 39 | def self.shared_examples 40 | @shared_examples ||= {} 41 | end 42 | end 43 | 44 | module Minitest::Spec::SharedExamples 45 | def shared_examples_for(desc, &block) 46 | Minitest::Spec.shared_examples[desc] = block 47 | end 48 | 49 | def it_behaves_like(desc) 50 | self.instance_eval(&Minitest::Spec.shared_examples[desc]) 51 | end 52 | end 53 | 54 | Object.class_eval { include(Minitest::Spec::SharedExamples) } 55 | 56 | require_relative 'shared_examples' 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://drone.kosmos.org/api/badges/5apps/liquor-cabinet/status.svg)](https://drone.kosmos.org/5apps/liquor-cabinet) 2 | 3 | # Liquor Cabinet 4 | 5 | Liquor Cabinet is where Frank stores all his stuff. It's a 6 | [remoteStorage](https://remotestorage.io) HTTP API, based on Sinatra. The 7 | metadata and OAuth tokens are stored in Redis, and 8 | documents/files can be stored in anything that supports 9 | the S3 object storage API. 10 | 11 | Liquor Cabinet only implements the storage API part of the remoteStorage 12 | protocol, but does not include the Webfinger and OAuth parts. It is meant to be 13 | added to existing systems and user accounts, so you will have to add your own 14 | OAuth dialog for remoteStorage authorizations and persist the tokens in Redis. 15 | 16 | There is an [open-source accounts management 17 | app](https://gitea.kosmos.org/kosmos/akkounts/) by the Kosmos project, which 18 | comes with a built-in remoteStorage dashboard and is compatible with Liquor 19 | Cabinet. 20 | 21 | If you have any questions about this program, please [post to the RS 22 | forums](https://community.remotestorage.io/c/server-development), and we'll 23 | gladly answer them. 24 | 25 | ## System requirements 26 | 27 | * [Ruby](https://www.ruby-lang.org/en/) and [Bundler](https://bundler.io/) 28 | * [Redis](https://redis.io/) 29 | * S3-compatible object storage (e.g. [Garage](https://garagehq.deuxfleurs.fr/) 30 | or [MinIO](https://min.io/) for self-hosting) 31 | 32 | ## Setup 33 | 34 | 1. Check the `config.yml.erb.example` file. Either copy it to `config.yml.erb` 35 | and use the enviroment variables it contains, or create/deploy your own 36 | config YAML file with custom values. 37 | 2. Install dependencies: `bundle install` 38 | 39 | ## Development 40 | 41 | Running the test suite: 42 | 43 | bundle exec rake test 44 | 45 | Running the app: 46 | 47 | bundle exec rainbows 48 | 49 | ## Deployment 50 | 51 | _TODO document options_ 52 | 53 | ## Contributing 54 | 55 | We love pull requests. If you want to submit a patch: 56 | 57 | * Fork the project. 58 | * Make your feature addition or bug fix. 59 | * Write specs for it. This is important so nobody breaks it in a future version. 60 | * Push to your fork and send a pull request. 61 | -------------------------------------------------------------------------------- /lib/remote_storage/s3.rb: -------------------------------------------------------------------------------- 1 | require "remote_storage/rest_provider" 2 | require "digest" 3 | require "base64" 4 | require "cgi" 5 | require "openssl" 6 | 7 | module RemoteStorage 8 | class S3 9 | include RestProvider 10 | 11 | private 12 | 13 | def s3_signer 14 | signer ||= Aws::Sigv4::Signer.new( 15 | service: 's3', 16 | region: settings.s3["region"], 17 | access_key_id: settings.s3["access_key_id"].to_s, 18 | secret_access_key: settings.s3["secret_key_id"].to_s, 19 | uri_escape_path: false 20 | ) 21 | end 22 | 23 | # S3 already wraps the ETag with quotes 24 | def format_etag(etag) 25 | etag 26 | end 27 | 28 | def do_put_request(url, data, content_type) 29 | deal_with_unauthorized_requests do 30 | headers = { "Content-Type" => content_type } 31 | auth_headers = auth_headers_for("PUT", url, headers, data) 32 | 33 | # TODO check if put was successful, e.g. it's returning a 413 directly 34 | # if the back-end does, too (missing CORS headers in that case) 35 | res = RestClient.put(url, data, headers.merge(auth_headers)) 36 | 37 | return [ 38 | res.headers[:etag].delete('"'), 39 | timestamp_for(res.headers[:date]) # S3 does not return a Last-Modified response header on PUTs 40 | ] 41 | end 42 | end 43 | 44 | def do_get_request(url, &block) 45 | deal_with_unauthorized_requests do 46 | headers = {} 47 | headers["Range"] = server.env["HTTP_RANGE"] if server.env["HTTP_RANGE"] 48 | auth_headers = auth_headers_for("GET", url, headers) 49 | RestClient.get(url, headers.merge(auth_headers), &block) 50 | end 51 | end 52 | 53 | def do_head_request(url, &block) 54 | deal_with_unauthorized_requests do 55 | auth_headers = auth_headers_for("HEAD", url) 56 | RestClient.head(url, auth_headers, &block) 57 | end 58 | end 59 | 60 | def do_delete_request(url) 61 | deal_with_unauthorized_requests do 62 | auth_headers = auth_headers_for("DELETE", url) 63 | RestClient.delete(url, auth_headers) 64 | end 65 | end 66 | 67 | def try_to_delete(url) 68 | found = true 69 | 70 | begin 71 | do_head_request(url) 72 | rescue RestClient::ResourceNotFound 73 | found = false 74 | end 75 | 76 | do_delete_request(url) if found 77 | 78 | return found 79 | end 80 | 81 | def auth_headers_for(http_method, url, headers = {}, data = nil) 82 | signature = s3_signer.sign_request( 83 | http_method: http_method, url: url, headers: headers, body: data 84 | ) 85 | signature.headers 86 | end 87 | 88 | def base_url 89 | @base_url ||= settings.s3["endpoint"] 90 | end 91 | 92 | def container_url_for(user) 93 | "#{base_url}/#{settings.s3["bucket"]}/#{user}" 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (6.1.7.6) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 1.6, < 2) 7 | minitest (>= 5.1) 8 | tzinfo (~> 2.0) 9 | zeitwerk (~> 2.3) 10 | addressable (2.8.5) 11 | public_suffix (>= 2.0.2, < 6.0) 12 | aws-sigv4 (1.0.3) 13 | base64 (0.1.1) 14 | concurrent-ruby (1.2.2) 15 | crack (0.4.5) 16 | rexml 17 | domain_name (0.5.20190701) 18 | unf (>= 0.0.5, < 1.0.0) 19 | faraday (2.7.11) 20 | base64 21 | faraday-net_http (>= 2.0, < 3.1) 22 | ruby2_keywords (>= 0.0.4) 23 | faraday-net_http (3.0.2) 24 | hashdiff (1.0.1) 25 | http-accept (1.7.0) 26 | http-cookie (1.0.5) 27 | domain_name (~> 0.5) 28 | i18n (1.14.1) 29 | concurrent-ruby (~> 1.0) 30 | kgio (2.11.4) 31 | m (1.6.2) 32 | method_source (>= 0.6.7) 33 | rake (>= 0.9.2.2) 34 | method_source (1.0.0) 35 | mime-types (3.5.1) 36 | mime-types-data (~> 3.2015) 37 | mime-types-data (3.2023.1003) 38 | minitest (5.20.0) 39 | minitest-stub_any_instance (1.0.3) 40 | multi_json (1.15.0) 41 | mustermann (2.0.2) 42 | ruby2_keywords (~> 0.0.1) 43 | netrc (0.11.0) 44 | public_suffix (5.0.3) 45 | rack (2.2.8) 46 | rack-protection (2.2.4) 47 | rack 48 | rack-test (2.1.0) 49 | rack (>= 1.3) 50 | rainbows (5.2.1) 51 | kgio (~> 2.5) 52 | rack (>= 1.1, < 3.0) 53 | unicorn (~> 5.1) 54 | raindrops (0.20.1) 55 | rake (13.0.6) 56 | redis (4.6.0) 57 | rest-client (2.1.0) 58 | http-accept (>= 1.7.0, < 2.0) 59 | http-cookie (>= 1.0.2, < 2.0) 60 | mime-types (>= 1.16, < 4.0) 61 | netrc (~> 0.8) 62 | rexml (3.2.6) 63 | ruby2_keywords (0.0.5) 64 | sentry-raven (3.1.2) 65 | faraday (>= 1.0) 66 | sinatra (2.2.4) 67 | mustermann (~> 2.0) 68 | rack (~> 2.2) 69 | rack-protection (= 2.2.4) 70 | tilt (~> 2.0) 71 | sinatra-contrib (2.2.4) 72 | multi_json 73 | mustermann (~> 2.0) 74 | rack-protection (= 2.2.4) 75 | sinatra (= 2.2.4) 76 | tilt (~> 2.0) 77 | tilt (2.3.0) 78 | tzinfo (2.0.6) 79 | concurrent-ruby (~> 1.0) 80 | unf (0.1.4) 81 | unf_ext 82 | unf_ext (0.0.8.2) 83 | unicorn (5.8.0) 84 | kgio (~> 2.6) 85 | raindrops (~> 0.7) 86 | webmock (3.19.1) 87 | addressable (>= 2.8.0) 88 | crack (>= 0.3.2) 89 | hashdiff (>= 0.4.0, < 2.0.0) 90 | zeitwerk (2.6.12) 91 | 92 | PLATFORMS 93 | ruby 94 | 95 | DEPENDENCIES 96 | activesupport (~> 6.1.0) 97 | aws-sigv4 (~> 1.0.0) 98 | m 99 | mime-types 100 | minitest 101 | minitest-stub_any_instance 102 | rack-test 103 | rainbows 104 | rake 105 | redis (~> 4.6.0) 106 | rest-client (~> 2.1.0) 107 | sentry-raven 108 | sinatra (~> 2.2.0) 109 | sinatra-contrib (~> 2.2.0) 110 | webmock 111 | 112 | BUNDLED WITH 113 | 2.3.7 114 | -------------------------------------------------------------------------------- /spec/s3/app_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "../spec_helper" 2 | 3 | describe "S3 provider" do 4 | def container_url_for(user) 5 | "#{app.settings.s3["endpoint"]}/#{app.settings.s3["bucket"]}/#{user}" 6 | end 7 | 8 | def storage_class 9 | RemoteStorage::S3 10 | end 11 | 12 | before do 13 | stub_request(:put, "#{container_url_for("phil")}/food/aguacate"). 14 | to_return(status: 200, headers: { etag: '"0815etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 15 | # Write new content with an If-Match header (a new Etag is returned) 16 | stub_request(:put, "#{container_url_for("phil")}/food/aguacate"). 17 | with(body: "aye"). 18 | to_return(status: 200, headers: { etag: '"0915etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 19 | stub_request(:put, "#{container_url_for("phil")}/public/shares/example.jpg"). 20 | to_return(status: 200, headers: { etag: '"0817etag"', content_type: "image/jpeg", date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 21 | stub_request(:put, "#{container_url_for("phil")}/public/shares/example_partial.jpg"). 22 | to_return(status: 200, headers: { etag: '"0817etag"', content_type: "image/jpeg", date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 23 | stub_request(:head, "#{container_url_for("phil")}/food/aguacate"). 24 | to_return(status: 200, headers: { last_modified: "Fri, 04 Mar 2016 12:20:18 GMT" }) 25 | stub_request(:get, "#{container_url_for("phil")}/food/aguacate"). 26 | to_return(status: 200, body: "rootbody", headers: { etag: '"0817etag"', content_type: "text/plain; charset=utf-8" }) 27 | stub_request(:delete, "#{container_url_for("phil")}/food/aguacate"). 28 | to_return(status: 200, headers: { etag: '"0815etag"' }) 29 | stub_request(:get, "#{container_url_for("phil")}/public/shares/example.jpg"). 30 | to_return(status: 200, body: "", headers: { etag: '"0817etag"', content_type: "image/jpeg" }) 31 | stub_request(:get, "#{container_url_for("phil")}/public/shares/example_partial.jpg"). 32 | to_return(status: 206, body: "", headers: { etag: '"0817etag"', content_type: "image/jpeg", content_range: "bytes 0-16/128" }) 33 | 34 | # Write new content to check the metadata in Redis 35 | stub_request(:put, "#{container_url_for("phil")}/food/banano"). 36 | with(body: "si"). 37 | to_return(status: 200, headers: { etag: '"0815etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 38 | stub_request(:put, "#{container_url_for("phil")}/food/banano"). 39 | with(body: "oh, no"). 40 | to_return(status: 200, headers: { etag: '"0817etag"', date: "Fri, 04 Mar 2016 12:20:20 GMT" }) 41 | 42 | stub_request(:put, "#{container_url_for("phil")}/food/camaron"). 43 | to_return(status: 200, headers: { etag: '"0816etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 44 | stub_request(:head, "#{container_url_for("phil")}/food/camaron"). 45 | to_return(status: 200, headers: { last_modified: "Fri, 04 Mar 2016 12:20:18 GMT" }) 46 | stub_request(:delete, "#{container_url_for("phil")}/food/camaron"). 47 | to_return(status: 200, headers: { etag: '"0816etag"' }) 48 | 49 | stub_request(:put, "#{container_url_for("phil")}/food/desayunos/bolon"). 50 | to_return(status: 200, headers: { etag: '"0817etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 51 | stub_request(:head, "#{container_url_for("phil")}/food/desayunos/bolon"). 52 | to_return(status: 200, headers: { last_modified: "Fri, 04 Mar 2016 12:20:18 GMT" }) 53 | stub_request(:delete, "#{container_url_for("phil")}/food/desayunos/bolon"). 54 | to_return(status: 200, headers: { etag: '"0817etag"' }) 55 | 56 | # objects in root dir 57 | stub_request(:put, "#{container_url_for("phil")}/bamboo.txt"). 58 | to_return(status: 200, headers: { etag: '"0818etag"', date: "Fri, 04 Mar 2016 12:20:18 GMT" }) 59 | 60 | # 404 61 | stub_request(:head, "#{container_url_for("phil")}/food/steak"). 62 | to_return(status: 404) 63 | stub_request(:get, "#{container_url_for("phil")}/food/steak"). 64 | to_return(status: 404) 65 | end 66 | 67 | it_behaves_like 'a REST adapter' 68 | end 69 | -------------------------------------------------------------------------------- /migrate_storage_size_from_metadata.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "rubygems" 4 | require "bundler/setup" 5 | require "rest_client" 6 | require "redis" 7 | require "yaml" 8 | require "logger" 9 | require "active_support/core_ext/hash" 10 | 11 | class Migrator 12 | 13 | attr_accessor :username, :base_url, :environment, :settings, :logger 14 | 15 | def initialize(username) 16 | @username = username 17 | 18 | @environment = ENV["ENVIRONMENT"] || "staging" 19 | @settings = YAML.load(File.read('config.yml'))[@environment] 20 | 21 | @logger = Logger.new("log/migrate_storage_size_from_metadata.log") 22 | log_level = ENV["LOGLEVEL"] || "INFO" 23 | logger.level = Kernel.const_get "Logger::#{log_level}" 24 | logger.progname = username 25 | end 26 | 27 | def migrate 28 | logger.info "Starting migration for '#{username}'" 29 | set_container_migration_state("in_progress") 30 | begin 31 | write_storage_size_from_redis_metadata(username) 32 | rescue Exception => ex 33 | logger.error "Error setting storage size from metadata for '#{username}': #{ex}" 34 | set_container_migration_state("not_started") 35 | # write username to file for later reference 36 | File.open('log/failed_migration.log', 'a') { |f| f.puts username } 37 | exit 1 38 | end 39 | delete_container_migration_state 40 | logger.info "Finished migration for '#{username}'" 41 | end 42 | 43 | def redis 44 | @redis ||= Redis.new(@settings["redis"].symbolize_keys) 45 | end 46 | 47 | def write_storage_size_from_redis_metadata(user) 48 | lua_script = <<-EOF 49 | local user = ARGV[1] 50 | local total_size = 0 51 | local size_key = KEYS[1] 52 | 53 | local function get_size_from_items(parent, directory) 54 | local path 55 | if parent == "/" then 56 | path = directory 57 | else 58 | path = parent..directory 59 | end 60 | local items = redis.call("smembers", "rs:m:"..user..":"..path..":items") 61 | for index, name in pairs(items) do 62 | local redis_key = "rs:m:"..user..":" 63 | 64 | redis_key = redis_key..path..name 65 | 66 | -- if it's a directory, get the items inside of it 67 | if string.match(name, "/$") then 68 | get_size_from_items(path, name) 69 | -- if it's a file, get its size 70 | else 71 | local file_size = redis.call("hget", redis_key, "s") 72 | total_size = total_size + file_size 73 | end 74 | end 75 | end 76 | 77 | get_size_from_items("", "") -- Start from the root 78 | 79 | redis.call("set", size_key, total_size) 80 | EOF 81 | 82 | redis.eval(lua_script, ["rs:s:#{user}"], [user]) 83 | end 84 | 85 | def set_container_migration_state(type) 86 | redis.hset("rs:size_migration", username, type) 87 | end 88 | 89 | def delete_container_migration_state 90 | redis.hdel("rs:size_migration", username) 91 | end 92 | 93 | end 94 | 95 | class MigrationRunner 96 | attr_accessor :environment, :settings 97 | 98 | def initialize 99 | @environment = ENV["ENVIRONMENT"] || "staging" 100 | @settings = YAML.load(File.read('config.yml'))[@environment] 101 | end 102 | 103 | def migrate 104 | while username = pick_unmigrated_user 105 | migrator = Migrator.new username 106 | migrator.migrate 107 | end 108 | end 109 | 110 | def unmigrated_users 111 | redis.hgetall("rs:size_migration").select { |_, value| 112 | value == "not_started" 113 | }.keys 114 | end 115 | 116 | def pick_unmigrated_user 117 | unmigrated_users.sample # pick a random user from list 118 | end 119 | 120 | def redis 121 | @redis ||= Redis.new(@settings["redis"].symbolize_keys) 122 | end 123 | 124 | end 125 | 126 | username = ARGV[0] 127 | 128 | if username 129 | migrator = Migrator.new username 130 | migrator.migrate 131 | else 132 | runner = MigrationRunner.new 133 | runner.migrate 134 | end 135 | -------------------------------------------------------------------------------- /liquor-cabinet.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH << File.join(File.expand_path(File.dirname(__FILE__)), 'lib') 2 | 3 | require "json" 4 | require "sinatra/base" 5 | require 'sinatra/config_file' 6 | require "sinatra/reloader" 7 | require "remote_storage/s3" 8 | 9 | class LiquorCabinet < Sinatra::Base 10 | 11 | # 12 | # Configuration 13 | # 14 | 15 | configure do 16 | disable :protection, :logging 17 | enable :dump_errors 18 | 19 | register Sinatra::ConfigFile 20 | set :environments, %w{development test production staging} 21 | config_file 'config.yml.erb' 22 | end 23 | 24 | configure :development do 25 | register Sinatra::Reloader 26 | also_reload "lib/remote_storage/*.rb" 27 | set :logging, Logger::DEBUG 28 | end 29 | 30 | configure :production do 31 | # Disable logging 32 | require "rack/common_logger" 33 | end 34 | 35 | configure :production, :staging do 36 | if ENV['SENTRY_DSN'] 37 | require "raven/base" 38 | require "raven/integrations/rack" 39 | 40 | Raven.configure do |config| 41 | config.dsn = ENV['SENTRY_DSN'] 42 | config.tags = { environment: settings.environment.to_s } 43 | config.excluded_exceptions = ['Sinatra::NotFound'] 44 | end 45 | 46 | use Raven::Rack 47 | end 48 | end 49 | 50 | configure :staging do 51 | set :logging, Logger::DEBUG 52 | end 53 | 54 | # 55 | # Cabinet doors 56 | # 57 | 58 | before do 59 | halt 503 if settings.maintenance rescue false 60 | end 61 | 62 | ["/:user/*/:key", "/:user/:key", "/:user/*/", "/:user/"].each do |path| 63 | before path do 64 | headers 'Access-Control-Allow-Origin' => '*', 65 | 'Access-Control-Allow-Methods' => 'GET, PUT, DELETE', 66 | 'Access-Control-Allow-Headers' => 'Authorization, Content-Type, Origin, If-Match, If-None-Match, Range', 67 | 'Access-Control-Expose-Headers' => 'ETag, Content-Length, Content-Range, Content-Type', 68 | 'Accept-Ranges' => 'bytes' 69 | headers['Access-Control-Allow-Origin'] = env["HTTP_ORIGIN"] if env["HTTP_ORIGIN"] 70 | headers['Cache-Control'] = 'no-cache' 71 | 72 | @user, @key = params[:user], params[:key] 73 | @directory = params[:splat] && params[:splat].first || "" 74 | 75 | token = env["HTTP_AUTHORIZATION"] ? env["HTTP_AUTHORIZATION"].split(" ")[1] : "" 76 | 77 | no_key = @key.nil? || @key.empty? 78 | storage.authorize_request(@user, @directory, token, no_key) unless request.options? 79 | end 80 | 81 | options path do 82 | headers['Access-Control-Max-Age'] = '7200' 83 | halt 200 84 | end 85 | end 86 | 87 | ["/:user/*/:key", "/:user/:key"].each do |path| 88 | head path do 89 | storage.get_head(@user, @directory, @key) 90 | end 91 | 92 | get path do 93 | storage.get_data(@user, @directory, @key) 94 | end 95 | 96 | put path do 97 | data = request.body.read 98 | 99 | halt 422 unless env['CONTENT_TYPE'] 100 | 101 | if env['CONTENT_TYPE'] == "application/x-www-form-urlencoded" 102 | content_type = "text/plain; charset=utf-8" 103 | else 104 | content_type = env['CONTENT_TYPE'] 105 | end 106 | 107 | storage.put_data(@user, @directory, @key, data, content_type) 108 | end 109 | 110 | delete path do 111 | storage.delete_data(@user, @directory, @key) 112 | end 113 | end 114 | 115 | ["/:user/*/", "/:user/"].each do |path| 116 | head path do 117 | storage.get_head_directory_listing(@user, @directory) 118 | end 119 | 120 | get path do 121 | storage.get_directory_listing(@user, @directory) 122 | end 123 | end 124 | 125 | private 126 | 127 | def storage 128 | @storage ||= begin 129 | if settings.respond_to? :s3 130 | RemoteStorage::S3.new(settings, self) 131 | else 132 | puts <<-EOF 133 | You need to set one storage backend in your config.yml file. 134 | Riak and Swift are currently supported. See config.yml.example. 135 | EOF 136 | end 137 | end 138 | end 139 | 140 | end 141 | -------------------------------------------------------------------------------- /lib/remote_storage/rest_provider.rb: -------------------------------------------------------------------------------- 1 | require "rest_client" 2 | require "json" 3 | require "cgi" 4 | require "active_support/core_ext/time/conversions" 5 | require "active_support/core_ext/numeric/time" 6 | require "active_support/core_ext/hash" 7 | require "redis" 8 | require "digest/md5" 9 | 10 | module RemoteStorage 11 | module RestProvider 12 | 13 | attr_accessor :settings, :server 14 | 15 | def initialize(settings, server) 16 | @settings = settings 17 | @server = server 18 | end 19 | 20 | def authorize_request(user, directory, token, listing=false) 21 | request_method = server.env["REQUEST_METHOD"] 22 | 23 | if directory.split("/").first == "public" 24 | return true if ["GET", "HEAD"].include?(request_method) && !listing 25 | end 26 | 27 | server.halt 401, "Unauthorized" if token.nil? || token.empty? 28 | 29 | authorizations = redis.smembers("authorizations:#{user}:#{token}") 30 | permission = directory_permission(authorizations, directory) 31 | 32 | server.halt 401, "Unauthorized" unless permission 33 | if ["PUT", "DELETE"].include? request_method 34 | server.halt 401, "Unauthorized" unless permission == "rw" 35 | end 36 | end 37 | 38 | def get_head(user, directory, key) 39 | none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",") 40 | .map(&:strip) 41 | .map { |s| s.gsub(/^"?W\//, "") } 42 | metadata = redis.hgetall redis_metadata_object_key(user, directory, key) 43 | 44 | server.halt 404 if metadata.empty? 45 | 46 | # Set the response headers for a 304 or 200 response 47 | server.headers["ETag"] = %Q("#{metadata["e"]}") 48 | server.headers["Last-Modified"] = Time.at(metadata["m"].to_i / 1000).httpdate 49 | server.headers["Content-Type"] = metadata["t"] 50 | server.headers["Content-Length"] = metadata["s"] 51 | 52 | if none_match.include? %Q("#{metadata["e"]}") 53 | server.halt 304 54 | end 55 | end 56 | 57 | def get_data(user, directory, key) 58 | none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",") 59 | .map(&:strip) 60 | .map { |s| s.gsub(/^"?W\//, "") } 61 | metadata = redis.hgetall redis_metadata_object_key(user, directory, key) 62 | if none_match.include? %Q("#{metadata["e"]}") 63 | server.headers["ETag"] = %Q("#{metadata["e"]}") 64 | server.headers["Last-Modified"] = Time.at(metadata["m"].to_i / 1000).httpdate 65 | server.halt 304 66 | end 67 | 68 | url = url_for_key(user, directory, key) 69 | 70 | res = do_get_request(url) 71 | 72 | if res.headers[:content_range] 73 | # Partial content 74 | server.headers["Content-Range"] = res.headers[:content_range] 75 | server.status 206 76 | end 77 | set_response_headers(metadata) 78 | 79 | return res.body 80 | rescue RestClient::ResourceNotFound 81 | server.halt 404, "Not Found" 82 | end 83 | 84 | def get_head_directory_listing(user, directory) 85 | get_directory_listing(user, directory) 86 | 87 | "" # just return empty body, headers are set by get_directory_listing 88 | end 89 | 90 | def get_directory_listing(user, directory) 91 | etag = redis.hget "rs:m:#{user}:#{directory}/", "e" 92 | 93 | server.headers["Content-Type"] = "application/ld+json" 94 | 95 | none_match = (server.env["HTTP_IF_NONE_MATCH"] || "").split(",") 96 | .map(&:strip) 97 | .map { |s| s.gsub(/^"?W\//, "") } 98 | 99 | if etag 100 | server.halt 304 if none_match.include? %Q("#{etag}") 101 | 102 | items = get_directory_listing_from_redis_via_lua(user, directory) 103 | else 104 | etag = etag_for(user, directory) 105 | items = {} 106 | 107 | server.halt 304 if none_match.include? %Q("#{etag}") 108 | end 109 | 110 | server.headers["ETag"] = %Q("#{etag}") 111 | 112 | listing = { 113 | "@context" => "http://remotestorage.io/spec/folder-description", 114 | "items" => items 115 | } 116 | 117 | listing.to_json 118 | end 119 | 120 | def put_data(user, directory, key, data, content_type) 121 | # Do not try to perform the PUT request when the Content-Type does not 122 | # look like a MIME type 123 | server.halt 415 unless content_type.match(/^.+\/.+/i) 124 | server.halt 400 if server.env["HTTP_CONTENT_RANGE"] 125 | server.halt 409, "Conflict" if has_name_collision?(user, directory, key) 126 | 127 | existing_metadata = redis.hgetall redis_metadata_object_key(user, directory, key) 128 | url = url_for_key(user, directory, key) 129 | 130 | if required_match = server.env["HTTP_IF_MATCH"] 131 | required_match = required_match.gsub(/^"?W\//, "") 132 | unless required_match == %Q("#{existing_metadata["e"]}") 133 | 134 | # get actual metadata and compare in case redis metadata became out of sync 135 | begin 136 | head_res = do_head_request(url) 137 | # The file doesn't exist, return 412 138 | rescue RestClient::ResourceNotFound 139 | server.halt 412, "Precondition Failed" 140 | end 141 | 142 | if required_match == format_etag(head_res.headers[:etag]) 143 | # log previous size difference that was missed ealier because of redis failure 144 | log_size_difference(user, existing_metadata["s"], head_res.headers[:content_length]) 145 | else 146 | server.halt 412, "Precondition Failed" 147 | end 148 | end 149 | end 150 | if server.env["HTTP_IF_NONE_MATCH"] == "*" 151 | server.halt 412, "Precondition Failed" unless existing_metadata.empty? 152 | end 153 | 154 | etag, timestamp = do_put_request(url, data, content_type) 155 | 156 | metadata = { 157 | e: etag, 158 | s: data.size, 159 | t: content_type, 160 | m: timestamp 161 | } 162 | 163 | if update_metadata_object(user, directory, key, metadata) 164 | if metadata_changed?(existing_metadata, metadata) 165 | update_dir_objects(user, directory, timestamp, checksum_for(data)) 166 | log_size_difference(user, existing_metadata["s"], metadata[:s]) 167 | end 168 | 169 | server.headers["ETag"] = %Q("#{etag}") 170 | server.halt existing_metadata.empty? ? 201 : 200 171 | else 172 | server.halt 500 173 | end 174 | end 175 | 176 | def delete_data(user, directory, key) 177 | url = url_for_key(user, directory, key) 178 | 179 | existing_metadata = redis.hgetall "rs:m:#{user}:#{directory}/#{key}" 180 | 181 | if required_match = server.env["HTTP_IF_MATCH"] 182 | unless required_match.gsub(/^"?W\//, "") == %Q("#{existing_metadata["e"]}") 183 | server.halt 412, "Precondition Failed" 184 | end 185 | end 186 | 187 | found = try_to_delete(url) 188 | 189 | log_size_difference(user, existing_metadata["s"], 0) 190 | delete_metadata_objects(user, directory, key) 191 | delete_dir_objects(user, directory) 192 | 193 | if found 194 | server.headers["Etag"] = %Q("#{existing_metadata["e"]}") 195 | server.halt 200 196 | else 197 | server.halt 404, "Not Found" 198 | end 199 | end 200 | 201 | private 202 | 203 | # Implement this method in your class that includes this module. For example 204 | # %Q("#{etag}") if the ETag does not already have quotes around it 205 | def format_etag(etag) 206 | NotImplementedError 207 | end 208 | 209 | def base_url 210 | NotImplementedError 211 | end 212 | 213 | def container_url_for(user) 214 | NotImplementedError 215 | end 216 | 217 | def default_headers 218 | raise NotImplementedError 219 | end 220 | 221 | def set_response_headers(metadata) 222 | server.headers["ETag"] = %Q("#{metadata["e"]}") 223 | server.headers["Last-Modified"] = Time.at(metadata["m"].to_i / 1000).httpdate 224 | server.headers["Content-Type"] = metadata["t"] 225 | server.headers["Content-Length"] = metadata["s"] 226 | end 227 | 228 | def extract_category(directory) 229 | if directory.match(/^public\//) 230 | "public/#{directory.split('/')[1]}" 231 | else 232 | directory.split('/').first 233 | end 234 | end 235 | 236 | def directory_permission(authorizations, directory) 237 | authorizations = authorizations.map do |auth| 238 | auth.index(":") ? auth.split(":") : [auth, "rw"] 239 | end 240 | authorizations = Hash[*authorizations.flatten] 241 | 242 | permission = authorizations[""] 243 | 244 | authorizations.each do |key, value| 245 | if directory.match(/^(public\/)?#{key}(\/|$)/) 246 | if permission.nil? || permission == "r" 247 | permission = value 248 | end 249 | return permission if permission == "rw" 250 | end 251 | end 252 | 253 | permission 254 | end 255 | 256 | def has_name_collision?(user, directory, key) 257 | lua_script = <<-EOF 258 | local user = ARGV[1] 259 | local directory = ARGV[2] 260 | local key = ARGV[3] 261 | 262 | -- build table with parent directories from remaining arguments 263 | local parent_dir_count = #ARGV - 3 264 | local parent_directories = {} 265 | for i = 4, 4 + parent_dir_count do 266 | table.insert(parent_directories, ARGV[i]) 267 | end 268 | 269 | -- check for existing directory with the same name as the document 270 | local redis_key = "rs:m:"..user..":" 271 | if directory == "" then 272 | redis_key = redis_key..key.."/" 273 | else 274 | redis_key = redis_key..directory.."/"..key.."/" 275 | end 276 | if redis.call("hget", redis_key, "e") then 277 | return true 278 | end 279 | 280 | for index, dir in pairs(parent_directories) do 281 | if redis.call("hget", "rs:m:"..user..":"..dir.."/", "e") then 282 | -- the directory already exists, no need to do further checks 283 | return false 284 | else 285 | -- check for existing document with same name as directory 286 | if redis.call("hget", "rs:m:"..user..":"..dir, "e") then 287 | return true 288 | end 289 | end 290 | end 291 | 292 | return false 293 | EOF 294 | 295 | parent_directories = parent_directories_for(directory) 296 | 297 | redis.eval(lua_script, nil, [user, directory, key, *parent_directories]) 298 | end 299 | 300 | def metadata_changed?(old_metadata, new_metadata) 301 | # check metadata relevant to the directory listing 302 | return old_metadata["e"] != new_metadata[:e] || 303 | old_metadata["s"] != new_metadata[:s].to_s || 304 | old_metadata["m"] != new_metadata[:m] || 305 | old_metadata["t"] != new_metadata[:t] 306 | end 307 | 308 | def timestamp_for(date) 309 | return DateTime.parse(date).strftime("%Q").to_i 310 | end 311 | 312 | def log_size_difference(user, old_size, new_size) 313 | delta = new_size.to_i - old_size.to_i 314 | redis.incrby "rs:s:#{user}", delta 315 | end 316 | 317 | def checksum_for(data) 318 | Digest::MD5.hexdigest(data) 319 | end 320 | 321 | def parent_directories_for(directory) 322 | directories = directory.split("/") 323 | parent_directories = [] 324 | 325 | while directories.any? 326 | parent_directories << directories.join("/") 327 | directories.pop 328 | end 329 | 330 | parent_directories << "" # add empty string for the root directory 331 | 332 | parent_directories 333 | end 334 | 335 | def top_directory(directory) 336 | if directory.match(/\//) 337 | directory.split("/").last 338 | elsif directory != "" 339 | return directory 340 | end 341 | end 342 | 343 | def parent_directory_for(directory) 344 | if directory.match(/\//) 345 | return directory[0..directory.rindex("/")] 346 | elsif directory != "" 347 | return "/" 348 | end 349 | end 350 | 351 | def update_metadata_object(user, directory, key, metadata) 352 | redis_key = redis_metadata_object_key(user, directory, key) 353 | redis.hmset(redis_key, *metadata) 354 | redis.sadd "rs:m:#{user}:#{directory}/:items", key 355 | 356 | true 357 | end 358 | 359 | def update_dir_objects(user, directory, timestamp, checksum) 360 | parent_directories_for(directory).each do |dir| 361 | etag = etag_for(dir, timestamp, checksum) 362 | 363 | key = "rs:m:#{user}:#{dir}/" 364 | metadata = {e: etag, m: timestamp} 365 | redis.hmset(key, *metadata) 366 | redis.sadd "rs:m:#{user}:#{parent_directory_for(dir)}:items", "#{top_directory(dir)}/" 367 | end 368 | end 369 | 370 | def delete_metadata_objects(user, directory, key) 371 | redis.del redis_metadata_object_key(user, directory, key) 372 | redis.srem "rs:m:#{user}:#{directory}/:items", key 373 | end 374 | 375 | def delete_dir_objects(user, directory) 376 | timestamp = (Time.now.to_f * 1000).to_i 377 | 378 | parent_directories_for(directory).each do |dir| 379 | if dir_empty?(user, dir) 380 | redis.del "rs:m:#{user}:#{dir}/" 381 | redis.srem "rs:m:#{user}:#{parent_directory_for(dir)}:items", "#{top_directory(dir)}/" 382 | else 383 | etag = etag_for(dir, timestamp) 384 | 385 | metadata = {e: etag, m: timestamp} 386 | redis.hmset("rs:m:#{user}:#{dir}/", *metadata) 387 | end 388 | end 389 | end 390 | 391 | def dir_empty?(user, dir) 392 | redis.smembers("rs:m:#{user}:#{dir}/:items").empty? 393 | end 394 | 395 | def redis_metadata_object_key(user, directory, key) 396 | "rs:m:#{user}:#{[directory, key].delete_if(&:empty?).join("/")}" 397 | end 398 | 399 | def url_for_key(user, directory, key) 400 | File.join [container_url_for(user), escape(directory), escape(key)].compact 401 | end 402 | 403 | def do_put_request(url, data, content_type) 404 | deal_with_unauthorized_requests do 405 | res = RestClient.put(url, data, default_headers.merge({content_type: content_type})) 406 | 407 | return [ 408 | res.headers[:etag], 409 | timestamp_for(res.headers[:last_modified]) 410 | ] 411 | end 412 | end 413 | 414 | def do_get_request(url, &block) 415 | deal_with_unauthorized_requests do 416 | headers = { } 417 | headers["Range"] = server.env["HTTP_RANGE"] if server.env["HTTP_RANGE"] 418 | RestClient.get(url, default_headers.merge(headers), &block) 419 | end 420 | end 421 | 422 | def do_head_request(url, &block) 423 | deal_with_unauthorized_requests do 424 | RestClient.head(url, default_headers, &block) 425 | end 426 | end 427 | 428 | def do_delete_request(url) 429 | deal_with_unauthorized_requests do 430 | RestClient.delete(url, default_headers) 431 | end 432 | end 433 | 434 | def escape(str) 435 | # We want spaces to turn into %20 and slashes to stay slashes 436 | CGI::escape(str).gsub('+', '%20').gsub('%2F', '/') 437 | end 438 | 439 | def redis 440 | @redis ||= Redis.new(settings.redis.symbolize_keys) 441 | end 442 | 443 | def etag_for(*args) 444 | Digest::MD5.hexdigest args.join(":") 445 | end 446 | 447 | def deal_with_unauthorized_requests(&block) 448 | begin 449 | block.call 450 | rescue RestClient::Unauthorized => ex 451 | Raven.capture_exception(ex) 452 | server.halt 500 453 | end 454 | end 455 | 456 | def try_to_delete(url) 457 | found = true 458 | 459 | begin 460 | do_delete_request(url) 461 | rescue RestClient::ResourceNotFound 462 | found = false 463 | end 464 | 465 | found 466 | end 467 | 468 | def get_directory_listing_from_redis_via_lua(user, directory) 469 | lua_script = <<-EOF 470 | local user = ARGV[1] 471 | local directory = ARGV[2] 472 | local items = redis.call("smembers", "rs:m:"..user..":"..directory.."/:items") 473 | local listing = {} 474 | 475 | for index, name in pairs(items) do 476 | local redis_key = "rs:m:"..user..":" 477 | if directory == "" then 478 | redis_key = redis_key..name 479 | else 480 | redis_key = redis_key..directory.."/"..name 481 | end 482 | 483 | local metadata_values = redis.call("hgetall", redis_key) 484 | local metadata = {} 485 | 486 | -- redis returns hashes as a single list of alternating keys and values 487 | -- this collates it into a table 488 | for idx = 1, #metadata_values, 2 do 489 | metadata[metadata_values[idx]] = metadata_values[idx + 1] 490 | end 491 | 492 | listing[name] = {["ETag"] = metadata["e"]} 493 | if string.sub(name, -1) ~= "/" then 494 | listing[name]["Content-Type"] = metadata["t"] 495 | listing[name]["Content-Length"] = tonumber(metadata["s"]) 496 | listing[name]["Last-Modified"] = tonumber(metadata["m"]) 497 | end 498 | end 499 | 500 | return cjson.encode(listing) 501 | EOF 502 | 503 | items = JSON.parse(redis.eval(lua_script, nil, [user, directory])) 504 | 505 | items.reject{|k, _| k.end_with? "/"}.each do |_, v| 506 | v["Last-Modified"] = Time.at(v["Last-Modified"]/1000).httpdate 507 | end 508 | 509 | items 510 | end 511 | 512 | end 513 | end 514 | -------------------------------------------------------------------------------- /spec/shared_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'a REST adapter' do 2 | include Rack::Test::Methods 3 | 4 | def container_url_for(user) 5 | raise NotImplementedError 6 | end 7 | 8 | def storage_class 9 | raise NotImplementedError 10 | end 11 | 12 | it "returns 404 on non-existing routes" do 13 | get "/virginmargarita" 14 | _(last_response.status).must_equal 404 15 | end 16 | 17 | describe "OPTIONS requests" do 18 | 19 | it "returns CORS headers" do 20 | options "/phil/food/aguacate" 21 | 22 | _(last_response.headers["Access-Control-Allow-Origin"]).wont_be_nil 23 | _(last_response.headers["Access-Control-Allow-Methods"]).must_equal "GET, PUT, DELETE" 24 | _(last_response.headers["Access-Control-Max-Age"].to_i).must_be :> , 10 25 | end 26 | 27 | end 28 | 29 | describe "PUT requests" do 30 | 31 | before do 32 | purge_redis 33 | end 34 | 35 | context "authorized" do 36 | before do 37 | redis.sadd "authorizations:phil:amarillo", [":rw"] 38 | header "Authorization", "Bearer amarillo" 39 | end 40 | 41 | it "creates the metadata object in redis" do 42 | put "/phil/food/aguacate", "si" 43 | 44 | metadata = redis.hgetall "rs:m:phil:food/aguacate" 45 | _(metadata["s"]).must_equal "2" 46 | _(metadata["t"]).must_equal "text/plain; charset=utf-8" 47 | _(metadata["e"]).must_equal "0815etag" 48 | _(metadata["m"].length).must_equal 13 49 | end 50 | 51 | it "updates the metadata object in redis when it changes" do 52 | put "/phil/food/banano", "si" 53 | put "/phil/food/banano", "oh, no" 54 | 55 | metadata = redis.hgetall "rs:m:phil:food/banano" 56 | _(metadata["s"]).must_equal "6" 57 | _(metadata["t"]).must_equal "text/plain; charset=utf-8" 58 | _(metadata["e"]).must_equal "0817etag" 59 | _(metadata["m"]).must_equal "1457094020000" 60 | end 61 | 62 | it "creates the directory objects metadata in redis" do 63 | put "/phil/food/aguacate", "si" 64 | put "/phil/food/camaron", "yummi" 65 | 66 | metadata = redis.hgetall "rs:m:phil:/" 67 | _(metadata["e"]).must_equal "fe2976909daaf074660981ab563fe65d" 68 | _(metadata["m"].length).must_equal 13 69 | 70 | metadata = redis.hgetall "rs:m:phil:food/" 71 | _(metadata["e"]).must_equal "926f98ff820f2f9764fd3c60a22865ad" 72 | _(metadata["m"].length).must_equal 13 73 | 74 | food_items = redis.smembers "rs:m:phil:food/:items" 75 | food_items.each do |food_item| 76 | _(["camaron", "aguacate"]).must_include food_item 77 | end 78 | 79 | root_items = redis.smembers "rs:m:phil:/:items" 80 | _(root_items).must_equal ["food/"] 81 | end 82 | 83 | context "response code" do 84 | it "is 201 for newly created objects" do 85 | put "/phil/food/aguacate", "ci" 86 | 87 | _(last_response.status).must_equal 201 88 | end 89 | 90 | it "is 200 for updated objects" do 91 | put "/phil/food/aguacate", "deliciosa" 92 | put "/phil/food/aguacate", "muy deliciosa" 93 | 94 | _(last_response.status).must_equal 200 95 | end 96 | end 97 | 98 | context "logging usage size" do 99 | it "logs the complete size when creating new objects" do 100 | put "/phil/food/aguacate", "1234567890" 101 | 102 | size_log = redis.get "rs:s:phil" 103 | _(size_log).must_equal "10" 104 | end 105 | 106 | it "logs the size difference when updating existing objects" do 107 | put "/phil/food/camaron", "1234567890" 108 | put "/phil/food/aguacate", "1234567890" 109 | put "/phil/food/aguacate", "123" 110 | 111 | size_log = redis.get "rs:s:phil" 112 | _(size_log).must_equal "13" 113 | end 114 | end 115 | 116 | describe "objects in root dir" do 117 | before do 118 | put "/phil/bamboo.txt", "shir kan" 119 | end 120 | 121 | it "are listed in the directory listing with all metadata" do 122 | get "phil/" 123 | 124 | _(last_response.status).must_equal 200 125 | _(last_response.content_type).must_equal "application/ld+json" 126 | 127 | content = JSON.parse(last_response.body) 128 | _(content["items"]["bamboo.txt"]).wont_be_nil 129 | _(content["items"]["bamboo.txt"]["ETag"]).must_equal "0818etag" 130 | _(content["items"]["bamboo.txt"]["Content-Type"]).must_equal "text/plain; charset=utf-8" 131 | _(content["items"]["bamboo.txt"]["Content-Length"]).must_equal 8 132 | _(content["items"]["bamboo.txt"]["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 133 | end 134 | end 135 | 136 | describe "name collision checks" do 137 | it "is successful when there is no name collision" do 138 | put "/phil/food/aguacate", "si" 139 | 140 | _(last_response.status).must_equal 201 141 | 142 | metadata = redis.hgetall "rs:m:phil:food/aguacate" 143 | _(metadata["s"]).must_equal "2" 144 | end 145 | 146 | it "conflicts when there is a directory with same name as document" do 147 | put "/phil/food/aguacate", "si" 148 | put "/phil/food", "wontwork" 149 | 150 | _(last_response.status).must_equal 409 151 | _(last_response.body).must_equal "Conflict" 152 | 153 | metadata = redis.hgetall "rs:m:phil:food" 154 | _(metadata).must_be_empty 155 | end 156 | 157 | it "conflicts when there is a document with same name as directory" do 158 | put "/phil/food/aguacate", "si" 159 | put "/phil/food/aguacate/empanado", "wontwork" 160 | 161 | _(last_response.status).must_equal 409 162 | 163 | metadata = redis.hgetall "rs:m:phil:food/aguacate/empanado" 164 | _(metadata).must_be_empty 165 | end 166 | 167 | it "returns 400 when a Content-Range header is sent" do 168 | header "Content-Range", "bytes 0-3/3" 169 | 170 | put "/phil/food/aguacate", "si" 171 | 172 | _(last_response.status).must_equal 400 173 | end 174 | end 175 | 176 | describe "If-Match header" do 177 | before do 178 | put "/phil/food/aguacate", "si" 179 | end 180 | 181 | it "allows the request if the header matches the current ETag" do 182 | header "If-Match", "\"0815etag\"" 183 | 184 | put "/phil/food/aguacate", "aye" 185 | 186 | _(last_response.status).must_equal 200 187 | _(last_response.headers["Etag"]).must_equal "\"0915etag\"" 188 | end 189 | 190 | it "allows the request if the header contains a weak ETAG matching the current ETag" do 191 | header "If-Match", "W/\"0815etag\"" 192 | 193 | put "/phil/food/aguacate", "aye" 194 | 195 | _(last_response.status).must_equal 200 196 | _(last_response.headers["Etag"]).must_equal "\"0915etag\"" 197 | end 198 | 199 | it "allows the request if the header contains a weak ETAG with leading quote matching the current ETag" do 200 | header "If-Match", "\"W/\"0815etag\"" 201 | 202 | put "/phil/food/aguacate", "aye" 203 | 204 | _(last_response.status).must_equal 200 205 | _(last_response.headers["Etag"]).must_equal "\"0915etag\"" 206 | end 207 | 208 | it "fails the request if the header does not match the current ETag" do 209 | header "If-Match", "someotheretag" 210 | 211 | put "/phil/food/aguacate", "aye" 212 | 213 | _(last_response.status).must_equal 412 214 | _(last_response.body).must_equal "Precondition Failed" 215 | end 216 | 217 | it "allows the request if redis metadata became out of sync" do 218 | header "If-Match", "\"0815etag\"" 219 | 220 | put "/phil/food/aguacate", "aye" 221 | 222 | _(last_response.status).must_equal 200 223 | end 224 | end 225 | 226 | describe "If-None-Match header set to '*'" do 227 | it "succeeds when the document doesn't exist yet" do 228 | header "If-None-Match", "*" 229 | 230 | put "/phil/food/aguacate", "si" 231 | 232 | _(last_response.status).must_equal 201 233 | end 234 | 235 | it "fails the request if the document already exists" do 236 | put "/phil/food/aguacate", "si" 237 | 238 | header "If-None-Match", "*" 239 | put "/phil/food/aguacate", "si" 240 | 241 | _(last_response.status).must_equal 412 242 | _(last_response.body).must_equal "Precondition Failed" 243 | end 244 | end 245 | 246 | describe "Content-Type" do 247 | it "must be in the type/subtype format" do 248 | header "Content-Type", "text" 249 | 250 | put "/phil/food/invalid_content_type", "invalid" 251 | 252 | _(last_response.status).must_equal 415 253 | end 254 | end 255 | end 256 | 257 | end 258 | 259 | describe "DELETE requests" do 260 | 261 | before do 262 | purge_redis 263 | end 264 | 265 | context "not authorized" do 266 | describe "with no token" do 267 | it "says it's not authorized" do 268 | delete "/phil/food/aguacate" 269 | 270 | _(last_response.status).must_equal 401 271 | _(last_response.body).must_equal "Unauthorized" 272 | end 273 | end 274 | 275 | describe "with empty token" do 276 | it "says it's not authorized" do 277 | header "Authorization", "Bearer " 278 | delete "/phil/food/aguacate" 279 | 280 | _(last_response.status).must_equal 401 281 | _(last_response.body).must_equal "Unauthorized" 282 | end 283 | end 284 | 285 | describe "with wrong token" do 286 | it "says it's not authorized" do 287 | header "Authorization", "Bearer wrongtoken" 288 | delete "/phil/food/aguacate" 289 | 290 | _(last_response.status).must_equal 401 291 | _(last_response.body).must_equal "Unauthorized" 292 | end 293 | end 294 | 295 | end 296 | 297 | context "authorized" do 298 | before do 299 | redis.sadd "authorizations:phil:amarillo", [":rw"] 300 | header "Authorization", "Bearer amarillo" 301 | 302 | put "/phil/food/aguacate", "si" 303 | put "/phil/food/camaron", "yummi" 304 | put "/phil/food/desayunos/bolon", "wow" 305 | end 306 | 307 | it "decreases the size log by size of deleted object" do 308 | delete "/phil/food/aguacate" 309 | 310 | size_log = redis.get "rs:s:phil" 311 | _(size_log).must_equal "8" 312 | end 313 | 314 | it "deletes the metadata object in redis" do 315 | delete "/phil/food/aguacate" 316 | 317 | metadata = redis.hgetall "rs:m:phil:food/aguacate" 318 | _(metadata).must_be_empty 319 | end 320 | 321 | it "deletes the directory objects metadata in redis" do 322 | old_metadata = redis.hgetall "rs:m:phil:food/" 323 | 324 | storage_class.stub_any_instance :etag_for, "newetag" do 325 | delete "/phil/food/aguacate" 326 | end 327 | 328 | metadata = redis.hgetall "rs:m:phil:food/" 329 | _(metadata["e"]).must_equal "newetag" 330 | _(metadata["m"].length).must_equal 13 331 | _(metadata["m"]).wont_equal old_metadata["m"] 332 | 333 | food_items = redis.smembers "rs:m:phil:food/:items" 334 | _(food_items.sort).must_equal ["camaron", "desayunos/"] 335 | 336 | root_items = redis.smembers "rs:m:phil:/:items" 337 | _(root_items).must_equal ["food/"] 338 | end 339 | 340 | it "deletes the parent directory objects metadata when deleting all items" do 341 | delete "/phil/food/aguacate" 342 | delete "/phil/food/camaron" 343 | delete "/phil/food/desayunos/bolon" 344 | 345 | _(redis.smembers("rs:m:phil:food/desayunos:items")).must_be_empty 346 | _(redis.hgetall("rs:m:phil:food/desayunos/")).must_be_empty 347 | 348 | _(redis.smembers("rs:m:phil:food/:items")).must_be_empty 349 | _(redis.hgetall("rs:m:phil:food/")).must_be_empty 350 | 351 | _(redis.smembers("rs:m:phil:/:items")).must_be_empty 352 | end 353 | 354 | it "responds with the ETag of the deleted item in the header" do 355 | delete "/phil/food/aguacate" 356 | 357 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 358 | end 359 | 360 | context "when item doesn't exist" do 361 | before do 362 | purge_redis 363 | 364 | delete "/phil/food/steak" 365 | end 366 | 367 | it "returns a 404" do 368 | _(last_response.status).must_equal 404 369 | _(last_response.body).must_equal "Not Found" 370 | end 371 | 372 | it "deletes any metadata that might still exist" do 373 | delete "/phil/food/steak" 374 | 375 | metadata = redis.hgetall "rs:m:phil:food/steak" 376 | _(metadata).must_be_empty 377 | 378 | _(redis.smembers("rs:m:phil:food/:items")).must_be_empty 379 | _(redis.hgetall("rs:m:phil:food/")).must_be_empty 380 | 381 | _(redis.smembers("rs:m:phil:/:items")).must_be_empty 382 | end 383 | end 384 | 385 | describe "If-Match header" do 386 | it "succeeds when the header matches the current ETag" do 387 | header "If-Match", "\"0815etag\"" 388 | 389 | delete "/phil/food/aguacate" 390 | 391 | _(last_response.status).must_equal 200 392 | end 393 | 394 | it "succeeds when the header contains a weak ETAG matching the current ETag" do 395 | header "If-Match", "W/\"0815etag\"" 396 | 397 | delete "/phil/food/aguacate" 398 | 399 | _(last_response.status).must_equal 200 400 | end 401 | 402 | it "fails the request if it does not match the current ETag" do 403 | header "If-Match", "someotheretag" 404 | 405 | delete "/phil/food/aguacate" 406 | 407 | _(last_response.status).must_equal 412 408 | _(last_response.body).must_equal "Precondition Failed" 409 | end 410 | end 411 | end 412 | end 413 | 414 | describe "GET requests" do 415 | 416 | before do 417 | purge_redis 418 | end 419 | 420 | context "requests to public resources" do 421 | before do 422 | redis.sadd "authorizations:phil:amarillo", [":rw"] 423 | header "Authorization", "Bearer amarillo" 424 | end 425 | 426 | describe "normal request" do 427 | before do 428 | header "Content-Type", "image/jpeg" 429 | 430 | put "/phil/public/shares/example.jpg", "" 431 | end 432 | 433 | it "returns the required response headers" do 434 | get "/phil/public/shares/example.jpg" 435 | 436 | _(last_response.status).must_equal 200 437 | _(last_response.headers["Content-Type"]).must_equal "image/jpeg" 438 | end 439 | end 440 | 441 | describe "partial request" do 442 | before do 443 | header "Content-Type", "image/jpeg" 444 | 445 | put "/phil/public/shares/example_partial.jpg", <<-EOF 446 | JFIFddDuckyA␍⎺␉␊␍ 447 | #%'%#//33//@@@@@@@@@@@@@@@&&0##0+.'''.+550055@@?@@@@@@@@@@@>"!1AQaq"2B 448 | EOF 449 | end 450 | 451 | it "returns the required response headers" do 452 | header 'Range', 'bytes=0-16' 453 | get "/phil/public/shares/example_partial.jpg" 454 | 455 | _(last_response.status).must_equal 206 456 | _(last_response.headers["Content-Type"]).must_equal "image/jpeg" 457 | end 458 | end 459 | end 460 | 461 | context "not authorized" do 462 | 463 | describe "without token" do 464 | it "says it's not authorized" do 465 | get "/phil/food/" 466 | 467 | _(last_response.status).must_equal 401 468 | _(last_response.body).must_equal "Unauthorized" 469 | end 470 | end 471 | 472 | describe "with wrong token" do 473 | it "says it's not authorized" do 474 | header "Authorization", "Bearer wrongtoken" 475 | get "/phil/food/" 476 | 477 | _(last_response.status).must_equal 401 478 | _(last_response.body).must_equal "Unauthorized" 479 | end 480 | end 481 | 482 | end 483 | 484 | context "authorized" do 485 | 486 | before do 487 | redis.sadd "authorizations:phil:amarillo", [":rw"] 488 | header "Authorization", "Bearer amarillo" 489 | 490 | put "/phil/food/aguacate", "si" 491 | put "/phil/food/camaron", "yummi" 492 | put "/phil/food/desayunos/bolon", "wow" 493 | end 494 | 495 | describe "documents" do 496 | 497 | it "returns the required response headers" do 498 | get "/phil/food/aguacate" 499 | 500 | _(last_response.status).must_equal 200 501 | # ETag is coming from the Redis metadata, not the storage server (which has "0817etag") 502 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 503 | _(last_response.headers["Cache-Control"]).must_equal "no-cache" 504 | _(last_response.headers["Content-Type"]).must_equal "text/plain; charset=utf-8" 505 | end 506 | 507 | it "returns a 404 when data doesn't exist" do 508 | get "/phil/food/steak" 509 | 510 | _(last_response.status).must_equal 404 511 | _(last_response.body).must_equal "Not Found" 512 | end 513 | 514 | it "responds with 304 when IF_NONE_MATCH header contains the ETag" do 515 | header "If-None-Match", "\"0815etag\"" 516 | 517 | get "/phil/food/aguacate" 518 | 519 | _(last_response.status).must_equal 304 520 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 521 | _(last_response.headers["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 522 | end 523 | 524 | it "responds with 304 when IF_NONE_MATCH header contains weak ETAG matching the current ETag" do 525 | header "If-None-Match", "W/\"0815etag\"" 526 | 527 | get "/phil/food/aguacate" 528 | 529 | _(last_response.status).must_equal 304 530 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 531 | _(last_response.headers["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 532 | end 533 | 534 | end 535 | 536 | describe "directory listings" do 537 | 538 | it "returns the correct ETag header" do 539 | get "/phil/food/" 540 | 541 | _(last_response.status).must_equal 200 542 | _(last_response.headers["ETag"]).must_equal "\"f9f85fbf5aa1fa378fd79ac8aa0a457d\"" 543 | end 544 | 545 | it "returns a Cache-Control header with value 'no-cache'" do 546 | get "/phil/food/" 547 | 548 | _(last_response.status).must_equal 200 549 | _(last_response.headers["Cache-Control"]).must_equal "no-cache" 550 | end 551 | 552 | it "responds with 304 when IF_NONE_MATCH header contains the ETag" do 553 | header "If-None-Match", "\"f9f85fbf5aa1fa378fd79ac8aa0a457d\"" 554 | get "/phil/food/" 555 | 556 | _(last_response.status).must_equal 304 557 | end 558 | 559 | it "responds with 304 when IF_NONE_MATCH header contains weak ETAG matching the ETag" do 560 | header "If-None-Match", "W/\"f9f85fbf5aa1fa378fd79ac8aa0a457d\"" 561 | get "/phil/food/" 562 | 563 | _(last_response.status).must_equal 304 564 | end 565 | 566 | it "contains all items in the directory" do 567 | get "/phil/food/" 568 | 569 | _(last_response.status).must_equal 200 570 | _(last_response.content_type).must_equal "application/ld+json" 571 | 572 | content = JSON.parse(last_response.body) 573 | _(content["@context"]).must_equal "http://remotestorage.io/spec/folder-description" 574 | _(content["items"]["aguacate"]).wont_be_nil 575 | _(content["items"]["aguacate"]["Content-Type"]).must_equal "text/plain; charset=utf-8" 576 | _(content["items"]["aguacate"]["Content-Length"]).must_equal 2 577 | _(content["items"]["aguacate"]["ETag"]).must_equal "0815etag" 578 | _(content["items"]["camaron"]).wont_be_nil 579 | _(content["items"]["camaron"]["Content-Type"]).must_equal "text/plain; charset=utf-8" 580 | _(content["items"]["camaron"]["Content-Length"]).must_equal 5 581 | _(content["items"]["camaron"]["ETag"]).must_equal "0816etag" 582 | _(content["items"]["desayunos/"]).wont_be_nil 583 | _(content["items"]["desayunos/"]["ETag"]).must_equal "dd36e3cfe52b5f33421150b289a7d48d" 584 | end 585 | 586 | it "contains all items in the root directory" do 587 | get "phil/" 588 | 589 | _(last_response.status).must_equal 200 590 | _(last_response.content_type).must_equal "application/ld+json" 591 | 592 | content = JSON.parse(last_response.body) 593 | _(content["items"]["food/"]).wont_be_nil 594 | _(content["items"]["food/"]["ETag"]).must_equal "f9f85fbf5aa1fa378fd79ac8aa0a457d" 595 | end 596 | 597 | it "responds with an empty directory liting when directory doesn't exist" do 598 | get "phil/some-non-existing-dir/" 599 | 600 | _(last_response.status).must_equal 200 601 | _(last_response.content_type).must_equal "application/ld+json" 602 | 603 | content = JSON.parse(last_response.body) 604 | _(content["items"]).must_equal({}) 605 | end 606 | 607 | end 608 | end 609 | 610 | end 611 | 612 | describe "HEAD requests" do 613 | 614 | before do 615 | purge_redis 616 | end 617 | 618 | context "not authorized" do 619 | 620 | describe "without token" do 621 | it "says it's not authorized" do 622 | head "/phil/food/camarones" 623 | 624 | _(last_response.status).must_equal 401 625 | _(last_response.body).must_be_empty 626 | end 627 | end 628 | 629 | describe "with wrong token" do 630 | it "says it's not authorized" do 631 | header "Authorization", "Bearer wrongtoken" 632 | head "/phil/food/camarones" 633 | 634 | _(last_response.status).must_equal 401 635 | _(last_response.body).must_be_empty 636 | end 637 | end 638 | 639 | end 640 | 641 | context "authorized" do 642 | 643 | before do 644 | redis.sadd "authorizations:phil:amarillo", [":rw"] 645 | header "Authorization", "Bearer amarillo" 646 | 647 | put "/phil/food/aguacate", "si" 648 | put "/phil/food/camaron", "yummi" 649 | put "/phil/food/desayunos/bolon", "wow" 650 | end 651 | 652 | describe "directory listings" do 653 | it "returns the correct header information" do 654 | get "/phil/food/" 655 | 656 | _(last_response.status).must_equal 200 657 | _(last_response.content_type).must_equal "application/ld+json" 658 | _(last_response.headers["ETag"]).must_equal "\"f9f85fbf5aa1fa378fd79ac8aa0a457d\"" 659 | end 660 | end 661 | 662 | describe "documents" do 663 | context "when the document doesn't exist" do 664 | it "returns a 404" do 665 | head "/phil/food/steak" 666 | 667 | _(last_response.status).must_equal 404 668 | _(last_response.body).must_be_empty 669 | end 670 | end 671 | 672 | context "when the document exists" do 673 | it "returns the required response headers" do 674 | head "/phil/food/aguacate" 675 | 676 | _(last_response.status).must_equal 200 677 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 678 | _(last_response.headers["Cache-Control"]).must_equal "no-cache" 679 | _(last_response.headers["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 680 | _(last_response.headers["Content-Type"]).must_equal "text/plain; charset=utf-8" 681 | _(last_response.headers["Content-Length"]).must_equal "2" 682 | end 683 | 684 | it "responds with 304 when IF_NONE_MATCH header contains the ETag" do 685 | header "If-None-Match", "\"0815etag\"" 686 | 687 | head "/phil/food/aguacate" 688 | 689 | _(last_response.status).must_equal 304 690 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 691 | _(last_response.headers["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 692 | end 693 | 694 | it "responds with 304 when IF_NONE_MATCH header contains weak ETAG matching the current ETag" do 695 | header "If-None-Match", "W/\"0815etag\"" 696 | 697 | head "/phil/food/aguacate" 698 | 699 | _(last_response.status).must_equal 304 700 | _(last_response.headers["ETag"]).must_equal "\"0815etag\"" 701 | _(last_response.headers["Last-Modified"]).must_equal "Fri, 04 Mar 2016 12:20:18 GMT" 702 | end 703 | end 704 | end 705 | end 706 | end 707 | end 708 | --------------------------------------------------------------------------------