Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/tus #10

Merged
merged 5 commits into from Oct 21, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions boot.rb
Expand Up @@ -6,6 +6,7 @@
require 'sidekiq'
require 'tmpdir'
require 'logger'
require 'base64'
require 'rack'
require 'json'
require 'uri'
Expand All @@ -29,3 +30,6 @@
require './lib/attache/delete.rb'
require './lib/attache/download.rb'
require './lib/attache/file_response_body.rb'

require './lib/attache/tus.rb'
require './lib/attache/tus/upload.rb'
1 change: 1 addition & 0 deletions config.ru
Expand Up @@ -3,6 +3,7 @@ require './boot.rb'
use Attache::Delete
use Attache::Upload
use Attache::Download
use Attache::Tus::Upload
use Rack::Static, urls: ["/"], root: "public", index: "index.html"

run proc {|env| [200, {}, []] }
30 changes: 30 additions & 0 deletions lib/attache/base.rb
Expand Up @@ -30,4 +30,34 @@ def geometry_of(fullpath)
def filesize_of(fullpath)
File.stat(fullpath).size
end

def params_of(env)
env['QUERY_STRING'].to_s.split('&').inject({}) do |sum, pair|
k, v = pair.split('=').collect {|s| CGI.unescape(s) }
sum.merge(k => v)
end
end

def path_of(cachekey)
Attache.cache.send(:key_file_path, cachekey)
end

def rack_response_body_for(file)
Attache::FileResponseBody.new(file)
end

def generate_relpath(basename)
File.join(*SecureRandom.hex.scan(/\w\w/), basename)
end

def json_of(relpath, cachekey)
filepath = path_of(cachekey)
{
path: relpath,
content_type: content_type_of(filepath),
geometry: geometry_of(filepath),
bytes: filesize_of(filepath),
}.to_json
end

end
7 changes: 1 addition & 6 deletions lib/attache/delete.rb
Expand Up @@ -8,12 +8,7 @@ def _call(env, config)
when '/delete'
request = Rack::Request.new(env)
params = request.params

if config.secret_key
unless config.hmac_valid?(params)
return [401, config.headers_with_cors.merge('X-Exception' => 'Authorization failed'), []]
end
end
return config.unauthorized unless config.authorized?(params)

params['paths'].to_s.split("\n").each do |relpath|
Attache.logger.info "DELETING local #{relpath}"
Expand Down
4 changes: 0 additions & 4 deletions lib/attache/download.rb
Expand Up @@ -83,8 +83,4 @@ def make_thumbnail_for(file, geometry, extension)
end
end

def rack_response_body_for(file)
Attache::FileResponseBody.new(file)
end

end
53 changes: 53 additions & 0 deletions lib/attache/tus.rb
@@ -0,0 +1,53 @@
class Attache::Tus
LENGTH_KEYS = %w[Upload-Length Entity-Length]
OFFSET_KEYS = %w[Upload-Offset Offset]
METADATA_KEYS = %w[Upload-Metadata Metadata]

attr_accessor :env, :config

def initialize(env, config)
@env = env
@config = config
end

def header_value(keys)
value = nil
keys.find {|k| value = env["HTTP_#{k.gsub('-', '_').upcase}"]}
value
end

def upload_length
header_value LENGTH_KEYS
end

def upload_offset
header_value OFFSET_KEYS
end

def upload_metadata
value = header_value METADATA_KEYS
Hash[*value.split(/[, ]/)].inject({}) do |h, (k, v)|
h.merge(k => Base64.decode64(v))
end
end

def resumable_version
header_value ["Tus-Resumable"]
end

def headers_with_cors(headers = {}, offset: nil)
tus_headers = {
"Access-Control-Allow-Methods" => "PATCH",
"Access-Control-Allow-Headers" => "Tus-Resumable, #{LENGTH_KEYS.join(', ')}, #{METADATA_KEYS.join(', ')}, #{OFFSET_KEYS.join(', ')}",
"Access-Control-Expose-Headers" => "Location, #{OFFSET_KEYS.join(', ')}",
}
OFFSET_KEYS.each do |k|
tus_headers[k] = offset
end if offset

# append
tus_headers.inject(config.headers_with_cors.merge(headers)) do |sum, (k, v)|
sum.merge(k => [*sum[k], v].join(', '))
end
end
end
102 changes: 102 additions & 0 deletions lib/attache/tus/upload.rb
@@ -0,0 +1,102 @@
class Attache::Tus::Upload < Attache::Base
def initialize(app)
@app = app
end

def _call(env, config)
case env['PATH_INFO']
when '/tus/files'
tus = ::Attache::Tus.new(env, config)
params = params_of(env) # avoid unnecessary `invalid byte sequence in UTF-8` on `request.params`
return config.unauthorized unless config.authorized?(params)

case env['REQUEST_METHOD']
when 'POST'
if positive_number?(tus.upload_length)
relpath = generate_relpath(Attache::Upload.sanitize(tus.upload_metadata['filename'] || params['file']))
cachekey = File.join(request_hostname(env), relpath)

bytes_wrote = Attache.cache.write(cachekey, StringIO.new)
uri = URI.parse(Rack::Request.new(env).url)
uri.query = (uri.query ? "#{uri.query}&" : '') + "relpath=#{CGI.escape relpath}"
[201, tus.headers_with_cors('Location' => uri.to_s), []]
else
[400, tus.headers_with_cors('X-Exception' => "Bad upload length"), []]
end

when 'PATCH'
relpath = params['relpath']
cachekey = File.join(request_hostname(env), relpath)
http_offset = tus.upload_offset
if positive_number?(env['CONTENT_LENGTH']) &&
positive_number?(http_offset) &&
(env['CONTENT_TYPE'] == 'application/offset+octet-stream') &&
tus.resumable_version.to_s == '1.0.0' &&
current_offset(cachekey, relpath, config) >= http_offset.to_i

append_to(cachekey, http_offset, env['rack.input'])
config.storage_create(relpath: relpath, cachekey: cachekey) if config.storage && config.bucket

[200,
tus.headers_with_cors({'Content-Type' => 'text/json'}, offset: current_offset(cachekey, relpath, config)),
[json_of(relpath, cachekey)],
]
else
[400, tus.headers_with_cors('X-Exception' => 'Bad headers'), []]
end

when 'OPTIONS'
[201, tus.headers_with_cors, []]

when 'HEAD'
relpath = params['relpath']
cachekey = File.join(request_hostname(env), relpath)
[200,
tus.headers_with_cors({'Content-Type' => 'text/json'}, offset: current_offset(cachekey, relpath, config)),
[json_of(relpath, cachekey)],
]

when 'GET'
relpath = params['relpath']
uri = URI.parse(Rack::Request.new(env).url)
uri.query = nil
uri.path = File.join('/view', File.dirname(relpath), 'original', CGI.escape(File.basename(relpath)))
[302, tus.headers_with_cors('Location' => uri.to_s), []]
end
else
@app.call(env)
end
rescue Exception
Attache.logger.error $@
Attache.logger.error $!
Attache.logger.error "ERROR REFERER #{env['HTTP_REFERER'].inspect}"
[500, { 'X-Exception' => $!.to_s }, []]
end

private

def current_offset(cachekey, relpath, config)
file = Attache.cache.fetch(cachekey) do
config.storage_get(relpath: relpath) if config.storage && config.bucket
end
file.size
rescue
Attache.cache.write(cachekey, StringIO.new)
ensure
file.tap(&:close)
end

def append_to(cachekey, offset, io)
f = File.open(path_of(cachekey), 'r+b')
f.sync = true
f.seek(offset.to_i)
f.write(io.read)
ensure
f.close
end

def positive_number?(value)
(value.to_s == "0" || value.to_i > 0)
end

end
26 changes: 4 additions & 22 deletions lib/attache/upload.rb
Expand Up @@ -6,16 +6,11 @@ def initialize(app)
def _call(env, config)
case env['PATH_INFO']
when '/upload'
request = Rack::Request.new(env)
params = request.params

case env['REQUEST_METHOD']
when 'POST', 'PUT', 'PATCH'
if config.secret_key
unless config.hmac_valid?(params)
return [401, config.headers_with_cors.merge('X-Exception' => 'Authorization failed'), []]
end
end
request = Rack::Request.new(env)
params = request.params
return config.unauthorized unless config.authorized?(params)

relpath = generate_relpath(Attache::Upload.sanitize params['file'])
cachekey = File.join(request_hostname(env), relpath)
Expand All @@ -27,14 +22,7 @@ def _call(env, config)

config.storage_create(relpath: relpath, cachekey: cachekey) if config.storage && config.bucket

file = Attache.cache.read(cachekey)
file.close unless file.closed?
[200, config.headers_with_cors.merge('Content-Type' => 'text/json'), [{
path: relpath,
content_type: content_type_of(file.path),
geometry: geometry_of(file.path),
bytes: filesize_of(file.path),
}.to_json]]
[200, config.headers_with_cors.merge('Content-Type' => 'text/json'), [json_of(relpath, cachekey)]]
when 'OPTIONS'
[200, config.headers_with_cors, []]
else
Expand All @@ -53,10 +41,4 @@ def _call(env, config)
def self.sanitize(filename)
filename.to_s.gsub(/\%/, '_')
end

private

def generate_relpath(basename)
File.join(*SecureRandom.hex.scan(/\w\w/), basename)
end
end
8 changes: 8 additions & 0 deletions lib/attache/vhost.rb
Expand Up @@ -78,4 +78,12 @@ def remote_api
def async(method, args)
::Attache::Job.perform_async(method, env, args)
end

def authorized?(params)
secret_key.blank? || hmac_valid?(params)
end

def unauthorized
[401, headers_with_cors.merge('X-Exception' => 'Authorization failed'), []]
end
end