Skip to content

Commit

Permalink
Add /backup endpoint
Browse files Browse the repository at this point in the history
- receives array of path in the same manner as /delete
- will call "backup_file" on each relpath
- if backup is configured, fog.copy_object will be called to copy the file from bucket to backup bucket

Fixes #13
  • Loading branch information
choonkeat committed Dec 14, 2015
1 parent ad70501 commit 3a8bae4
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 0 deletions.
15 changes: 15 additions & 0 deletions README.md
Expand Up @@ -173,6 +173,21 @@ Removing 1 or more files from the local cache and remote storage can be done via

The `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`.

#### Backup

> ```
> POST /backup
> paths=image1.jpg%0Aprefix2%2Fimage2.jpg%0Aimage3.jpg
> ```
Copying 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.

The `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`.

If 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.

The 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)

## License

MIT
1 change: 1 addition & 0 deletions config.ru
Expand Up @@ -4,6 +4,7 @@ use Attache::Delete
use Attache::Upload
use Attache::Download
use Attache::Tus::Upload
use Attache::Backup
use Rack::Static, urls: ["/"], root: Attache.publicdir, index: "index.html"

run proc {|env| [200, {}, []] }
1 change: 1 addition & 0 deletions lib/attache.rb
Expand Up @@ -52,6 +52,7 @@ class << self
require 'attache/vhost'
require 'attache/upload'
require 'attache/delete'
require 'attache/backup'
require 'attache/download'
require 'attache/file_response_body'

Expand Down
30 changes: 30 additions & 0 deletions lib/attache/backup.rb
@@ -0,0 +1,30 @@
class Attache::Backup < Attache::Base
def initialize(app)
@app = app
end

def _call(env, config)
case env['PATH_INFO']
when '/backup'
request = Rack::Request.new(env)
params = request.params
return config.unauthorized unless config.authorized?(params)

params['paths'].to_s.split("\n").each do |relpath|
Attache.logger.info "CONFIRM local #{relpath}"
cachekey = File.join(request_hostname(env), relpath)
if config.storage && config.bucket
Attache.logger.info "CONFIRM remote #{relpath}"
config.async(:backup_file, relpath: relpath)
end
end
[200, config.headers_with_cors, []]
else
@app.call(env)
end
rescue Exception
Attache.logger.error $@
Attache.logger.error $!
[500, { 'X-Exception' => $!.to_s }, []]
end
end
109 changes: 109 additions & 0 deletions spec/lib/attache/backup_spec.rb
@@ -0,0 +1,109 @@
require 'spec_helper'

describe Attache::Backup do
let(:app) { ->(env) { [200, env, "app"] } }
let(:middleware) { Attache::Backup.new(app) }
let(:params) { {} }
let(:filename) { "hello#{rand}.gif" }
let(:reldirname) { "path#{rand}" }
let(:file) { StringIO.new(IO.binread("spec/fixtures/transparent.gif"), 'rb') }

before do
allow(Attache).to receive(:logger).and_return(Logger.new('/dev/null'))
allow(Attache).to receive(:localdir).and_return(Dir.tmpdir) # forced, for safety
end

after do
FileUtils.rm_rf(Attache.localdir)
end

it "should passthrough irrelevant request" do
code, env = middleware.call Rack::MockRequest.env_for('http://example.com', {})
expect(code).to eq 200
end

context "backup file" do
let(:params) { Hash(paths: ['image1.jpg', filename].join("\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") } }

it 'should respond with json' do
end

it 'should not touch local file' do
expect(Attache).not_to receive(:cache)
code, headers, body = subject.call
expect(code).to eq(200)
end

context 'storage configured' do
before do
allow_any_instance_of(Attache::VHost).to receive(:storage).and_return(double(:storage))
allow_any_instance_of(Attache::VHost).to receive(:bucket).and_return(double(:bucket))
end

it 'should backup file' do
expect_any_instance_of(Attache::VHost).to receive(:async) do |instance, method, path|
expect(method).to eq(:backup_file)
end.exactly(2).times
subject.call
end
end

context 'storage NOT configured' do
it 'should backup file' do
expect_any_instance_of(Attache::VHost).not_to receive(:async)
subject.call
end
end

context 'with secret_key' do
let(:secret_key) { "topsecret#{rand}" }

before do
allow_any_instance_of(Attache::VHost).to receive(:secret_key).and_return(secret_key)
end

it 'should respond with error' do
code, headers, body = subject.call
expect(code).to eq(401)
expect(headers['X-Exception']).to eq('Authorization failed')
end

context 'invalid auth' do
let(:expiration) { (Time.now + 10).to_i }
let(:uuid) { "hi#{rand}" }
let(:digest) { OpenSSL::Digest.new('sha1') }
let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, "wrong#{secret_key}", "#{uuid}#{expiration}")) }

it 'should respond with error' do
code, headers, body = subject.call
expect(code).to eq(401)
expect(headers['X-Exception']).to eq('Authorization failed')
end
end

context 'valid auth' do
let(:expiration) { (Time.now + 10).to_i }
let(:uuid) { "hi#{rand}" }
let(:digest) { OpenSSL::Digest.new('sha1') }
let(:params) { Hash(file: filename, expiration: expiration, uuid: uuid, hmac: OpenSSL::HMAC.hexdigest(digest, secret_key, "#{uuid}#{expiration}")) }

it 'should respond with success' do
code, headers, body = subject.call
expect(code).to eq(200)
end

context 'expired' do
let(:expiration) { (Time.now - 1).to_i } # the past

it 'should respond with error' do
code, headers, body = subject.call
expect(code).to eq(401)
expect(headers['X-Exception']).to eq('Authorization failed')
end
end
end
end
end
end

0 comments on commit 3a8bae4

Please sign in to comment.