mirror of
https://github.com/gfacciol/zotero_dataserver-docker.git
synced 2021-05-12 18:32:26 +03:00
513 lines
13 KiB
Perl
513 lines
13 KiB
Perl
package ZSS;
|
|
|
|
use strict;
|
|
use warnings;
|
|
|
|
use Plack::Request;
|
|
use Digest::HMAC_SHA1 qw(hmac_sha1);
|
|
use Digest::MD5 qw (md5_base64);
|
|
use MIME::Base64 qw(decode_base64 encode_base64);
|
|
use JSON::XS;
|
|
use Date::Parse;
|
|
use URI;
|
|
use URI::QueryParam;
|
|
use URI::Escape;
|
|
use Switch;
|
|
use Encode;
|
|
use Try::Tiny;
|
|
|
|
use ZSS::Store;
|
|
|
|
use Data::Dumper qw(Dumper);
|
|
$Data::Dumper::Sortkeys = 1;
|
|
|
|
sub new {
|
|
my ($class) = @_;
|
|
|
|
# TODO: read from config
|
|
my $self = {};
|
|
|
|
$self->{buckets}->{zotero}->{secretkey} = "yoursecretkey";
|
|
$self->{buckets}->{zotero}->{store} = ZSS::Store->new("/srv/zotero/storage/");
|
|
|
|
bless $self, $class;
|
|
}
|
|
|
|
sub respond {
|
|
my $code = shift;
|
|
my $msg = shift;
|
|
return [ $code, [ 'Content-Type' => 'text/plain', 'Content-Length' => length($msg)], [$msg] ];
|
|
}
|
|
|
|
sub xml2string {
|
|
my $xml = shift;
|
|
|
|
my $msg = '';
|
|
|
|
while (my $token = shift @{$xml}) {
|
|
my $data = shift @{$xml};
|
|
$msg .= '<'.$token.'>';
|
|
if (ref $data eq 'ARRAY') {
|
|
$msg .= xml2string($data);
|
|
} else {
|
|
$msg .= $data;
|
|
}
|
|
$msg .= '</'.$token.'>';
|
|
}
|
|
return $msg;
|
|
}
|
|
|
|
sub respondXML {
|
|
my $code = shift;
|
|
my $xml = shift;
|
|
|
|
return [ $code, [ 'Content-Type' => 'application/xml'], ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".xml2string($xml)] ];
|
|
}
|
|
|
|
sub check_policy {
|
|
my ($self, $cmp, $key, $val) = @_;
|
|
|
|
switch ($cmp) {
|
|
case 'eq' {
|
|
if ($key eq 'bucket') {
|
|
$key = $self->{request}->{bucket};
|
|
} else {
|
|
# TODO: Replace Plack::Request
|
|
$key = $self->{req}->parameters->get($key)
|
|
}
|
|
return 1 if $key eq $val;
|
|
};
|
|
case 'content-length-range' {
|
|
my $len = $self->{request}->{env}->{CONTENT_LENGTH};
|
|
# $self->log("Length: ".$len.", Limits: ".$key.", ".$val);
|
|
return 1 if (($len > $key) && ($len < $val));
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
sub log {
|
|
my ($self, $msg) = @_;
|
|
|
|
$self->{request}->{env}->{'psgix.logger'}->({ level => 'debug', message => $msg });
|
|
}
|
|
|
|
sub get_signature {
|
|
my $self = shift;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
|
|
my $secret = $self->{buckets}->{$request->{bucket}}->{secretkey};
|
|
|
|
my $query = {};
|
|
my $use_query = undef;
|
|
|
|
if ($env->{QUERY_STRING}) {
|
|
$query = $request->{uri}->query_form_hash();
|
|
}
|
|
|
|
if ($query->{Signature}) {
|
|
$use_query = 1;
|
|
}
|
|
|
|
# X-AMZ headers or query parameters
|
|
my $amzstring = '';
|
|
if ($use_query) {
|
|
for my $key (sort(grep(/^x-amz/, keys %$query))) {
|
|
my $value = $query->{$key};
|
|
$amzstring .= lc($key).":".$value."\n";
|
|
}
|
|
} else {
|
|
for my $key (sort(grep(/^HTTP_X_AMZ/, keys %$env))) {
|
|
next if ($key eq 'HTTP_X_AMZ_DATE');
|
|
my $value = $env->{$key};
|
|
$key =~ s/_/-/g;
|
|
$amzstring .= lc(substr($key,5)).":".$value."\n";
|
|
}
|
|
}
|
|
|
|
# Date Header or Expires query parameter
|
|
my $date;
|
|
if ($use_query) {
|
|
$date = $query->{Expires};
|
|
} else {
|
|
if ($env->{HTTP_X_AMZ_DATE}) {
|
|
$date = $env->{HTTP_X_AMZ_DATE};
|
|
} else {
|
|
$date = $env->{HTTP_DATE};
|
|
}
|
|
}
|
|
|
|
# changing response headers parameters
|
|
my $additional_params = '';
|
|
if ($query) {
|
|
my $sep = '?';
|
|
my @params = qw(response-cache-control response-content-disposition response-content-encoding response-content-language response-content-type response-expires);
|
|
for my $key (@params) {
|
|
if ($query->{$key}) {
|
|
$additional_params .= $sep.$key."=".$query->{$key};
|
|
$sep = '&' if ($sep eq '?');
|
|
}
|
|
}
|
|
}
|
|
|
|
my $stringtosign = $env->{REQUEST_METHOD}."\n".
|
|
($env->{HTTP_CONTENT_MD5} || '')."\n".
|
|
($env->{CONTENT_TYPE} || '')."\n".
|
|
($date || '')."\n".
|
|
$amzstring.
|
|
"/".$request->{bucket}."/".$request->{key_escaped}.$additional_params;
|
|
|
|
# $self->log("Stringtosign:".$stringtosign."End");
|
|
|
|
return encode_base64(hmac_sha1($stringtosign, $secret), '');
|
|
}
|
|
|
|
sub check_signature {
|
|
my $self = shift;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
|
|
my $received_signature;
|
|
|
|
if ($env->{QUERY_STRING} eq '') {
|
|
($received_signature) = ($env->{HTTP_AUTHORIZATION} || '') =~ m/^AWS .*:(.*)$/;
|
|
} else {
|
|
$received_signature = $request->{uri}->query_param('Signature') || '';
|
|
}
|
|
|
|
unless ($received_signature) {
|
|
return 0;
|
|
}
|
|
|
|
my $signature = $self->get_signature();
|
|
|
|
# $self->log("Check Signature: $received_signature == $signature");
|
|
|
|
return ($signature eq $received_signature);
|
|
}
|
|
|
|
sub handle_POST {
|
|
my ($self) = @_;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
|
|
my $req = $self->{req};
|
|
|
|
my $policy = $req->parameters->get('policy');
|
|
my $signature = $req->parameters->get('signature');
|
|
|
|
unless ($signature && $policy) {
|
|
return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']]);
|
|
}
|
|
|
|
unless ($signature eq encode_base64(hmac_sha1($policy, $self->{buckets}->{$request->{bucket}}->{secretkey}), '')) {
|
|
return respondXML(403, ['Error' => ['Code' => 'SignatureDoesNotMatch']]);
|
|
}
|
|
|
|
my $json;
|
|
try {
|
|
$json = JSON::XS->new->relaxed->decode(decode_base64($policy));
|
|
} catch {
|
|
return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']]);
|
|
};
|
|
|
|
return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument', 'Message' => 'No expiration time specified in policy document']]) unless (defined $json->{expiration});
|
|
|
|
my $expiration = Date::Parse::str2time($json->{expiration});
|
|
|
|
if ($self->{request}->{starttime} > $expiration) {
|
|
return respondXML(400, ['Error' => ['Code' => 'ExpiredToken']]);
|
|
}
|
|
# $self->log("Expires:".$expiration."; Starttime:".$self->{request}->{starttime});
|
|
|
|
foreach my $ref (@{$json->{conditions}}) {
|
|
if (ref $ref eq 'HASH') {
|
|
foreach my $key (keys %{$ref}) {
|
|
my $val = encode("utf8", $$ref{$key}); #TODO: better to decode parameter? Is unicode normalization required?
|
|
my $result = $self->check_policy('eq', $key, $val);
|
|
# $self->log($key."=".$val."(".$result.")");
|
|
unless ($result) {return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']])};
|
|
}
|
|
}
|
|
if (ref $ref eq 'ARRAY') {
|
|
my $key = $$ref[1];
|
|
$key =~ s/^\$//;
|
|
my $val = encode("utf8", $$ref[2]); #TODO: better to decode parameter? Is unicode normalization required?
|
|
my $result = $self->check_policy($$ref[0], $key, $val);
|
|
# $self->log($key." ".$$ref[0]." ".$val." (".$result.")");
|
|
unless ($result) {return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']])};
|
|
}
|
|
}
|
|
|
|
my $data = $req->parameters->get('file');
|
|
unless ($data) {
|
|
return respondXML(400, ['Error' => ['Code' => 'IncorrectNumberOfFilesInPostRequest']]);
|
|
}
|
|
|
|
my $md5 = md5_base64($data);
|
|
unless ($req->parameters->get('Content-MD5') eq $md5.'==') {
|
|
return respondXML(400, ['Error' => ['Code' => 'BadDigest']])
|
|
}
|
|
|
|
my $key = $req->parameters->get('key');
|
|
my $store = $self->{buckets}->{$request->{bucket}}->{store};
|
|
|
|
my $meta = {
|
|
'md5' => unpack('H*', decode_base64($md5)),
|
|
'acl' => $self->{req}->parameters->get('acl') || 'private'
|
|
};
|
|
|
|
$store->store_file($key, $req->parameters->get('file'), JSON::XS->new->utf8->encode($meta));
|
|
|
|
my $status = $req->parameters->get('success_action_status');
|
|
$status = '403' unless (($status eq '200') || ($status eq '201'));
|
|
|
|
# TODO: access_action_redirect
|
|
|
|
return respond($status, '');
|
|
}
|
|
|
|
sub handle_HEAD {
|
|
my ($self) = @_;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
my $key = $request->{key};
|
|
|
|
my $store = $self->{buckets}->{$request->{bucket}}->{store};
|
|
|
|
unless ($store->check_exists($key)) {
|
|
return respondXML(404, ['Error' => ['Code' => 'NoSuchKey']]);
|
|
}
|
|
|
|
my $meta;
|
|
try {
|
|
$meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
|
|
};
|
|
unless (ref($meta) eq 'HASH') {
|
|
$meta = {};
|
|
}
|
|
|
|
my $headers = ['Content-Length' => $store->get_size($key)];
|
|
if ($meta->{type}) {
|
|
push @$headers, 'Content-Type';
|
|
push @$headers, $meta->{type};
|
|
}
|
|
if ($meta->{md5}) {
|
|
push @$headers, 'ETag';
|
|
push @$headers, "\"".$meta->{md5}."\"";
|
|
}
|
|
|
|
return [200, $headers, []];
|
|
}
|
|
|
|
sub handle_GET {
|
|
my ($self) = @_;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
|
|
my $key = $request->{key};
|
|
|
|
my $store = $self->{buckets}->{$request->{bucket}}->{store};
|
|
|
|
unless($store->check_exists($key)){
|
|
return respondXML(404, ['Error' => ['Code' => 'NoSuchKey']]);
|
|
}
|
|
|
|
my $meta;
|
|
try {
|
|
$meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
|
|
};
|
|
unless (ref($meta) eq 'HASH') {
|
|
$meta = {};
|
|
}
|
|
|
|
my $headers = ['Content-Length' => $store->get_size($key)];
|
|
my $ct = $request->{uri}->query_param('response-content-type');
|
|
$ct = $meta->{type} unless ($ct);
|
|
if ($ct) {
|
|
push @$headers, 'Content-Type';
|
|
push @$headers, $ct;
|
|
}
|
|
if ($meta->{md5}) {
|
|
push @$headers, 'ETag';
|
|
push @$headers, "\"".$meta->{md5}."\"";
|
|
}
|
|
|
|
return [200, $headers, $store->retrieve_file($key)];
|
|
}
|
|
|
|
|
|
sub handle_PUT {
|
|
my $self = shift;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
my $store = $self->{buckets}->{$request->{bucket}}->{store};
|
|
|
|
my $key = $request->{key};
|
|
|
|
my $cl = $env->{CONTENT_LENGTH};
|
|
my $source = $env->{HTTP_X_AMZ_COPY_SOURCE};
|
|
|
|
if (($cl == 0) && ($source)) {
|
|
# Copy File
|
|
$source = uri_unescape($source);
|
|
(my $sourceBucket, my $sourceKey) = $source =~ m/^\/([^\?\/]*)\/?([^\?]*)/;
|
|
|
|
# $self->log("Source: ".$sourceBucket."/bla/".$sourceKey."\nDestinationKey: ".$key."\n");
|
|
|
|
my $res = $store->link_files($sourceKey, $key);
|
|
|
|
if ($res) {
|
|
|
|
my $meta;
|
|
try {
|
|
$meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
|
|
};
|
|
unless (ref($meta) eq 'HASH') {
|
|
$meta = {};
|
|
}
|
|
|
|
return respondXML(200, ['CopyObjectResult' => [ 'LastModified' => '2012', 'ETag' => $meta->{md5}]]);
|
|
} else {
|
|
return respondXML(500, ['Error' => ['Code' => 'InternalError']]);
|
|
}
|
|
} else {
|
|
# Normal PUT
|
|
my $input = $env->{'psgi.input'};
|
|
my $cl = $env->{CONTENT_LENGTH};
|
|
my $data;
|
|
|
|
if (($input->read($data, $cl)) != $cl) {
|
|
return respondXML(400, ['Error' => ['Code' => 'IncompleteBody']]);
|
|
}
|
|
|
|
my $md5 = md5_base64($data);
|
|
|
|
my $meta = {};
|
|
$meta->{type} = $env->{CONTENT_TYPE} if ($env->{CONTENT_TYPE});
|
|
$meta->{acl} = $env->{HTTP_X_AMZ_ACL} || 'private';
|
|
$meta->{md5} = unpack('H*', decode_base64($md5));
|
|
|
|
if ($env->{HTTP_CONTENT_MD5}) {
|
|
return respondXML(400, ['Error' => ['Code' => 'BadDigest']]) unless ($env->{HTTP_CONTENT_MD5} eq $md5.'==');
|
|
}
|
|
|
|
$store->store_file($key, $data, JSON::XS->new->utf8->encode($meta));
|
|
|
|
return respond(200, '');
|
|
}
|
|
}
|
|
|
|
sub handle_DELETE {
|
|
my $self = shift;
|
|
|
|
my $request = $self->{request};
|
|
my $env = $request->{env};
|
|
my $store = $self->{buckets}->{$request->{bucket}}->{store};
|
|
|
|
|
|
my $key = $request->{key};
|
|
|
|
unless ($store->check_exists($key)) {
|
|
return respondXML(404, ['Error' => ['Code' => 'NoSuchKey', 'Message' => 'The resource you requested does not exist', 'Resource' => $key]]);
|
|
}
|
|
|
|
if ($store->delete_file($key)) {
|
|
return [204, [], []];
|
|
} else {
|
|
return respondXML(500, ['Error' => ['Code' => 'InternalError']]);
|
|
}
|
|
|
|
}
|
|
|
|
sub request_uri {
|
|
my $env = shift;
|
|
|
|
my $uri = ($env->{'psgi.url_scheme'} || "http") .
|
|
"://" .
|
|
($env->{HTTP_HOST} || (($env->{SERVER_NAME} || "") . ":" . ($env->{SERVER_PORT} || 80))) .
|
|
($env->{SCRIPT_NAME} || "");
|
|
|
|
return URI->new($uri . $env->{REQUEST_URI})->canonical();
|
|
}
|
|
|
|
sub handle {
|
|
my ($self, $env) = @_;
|
|
|
|
my $request = {};
|
|
|
|
$request->{env} = $env;
|
|
$request->{starttime} = time();
|
|
|
|
$request->{uri} = request_uri($env);
|
|
|
|
# split in bucket and key (currently only path style buckets no host style)
|
|
($request->{bucket}, $request->{key_escaped}) = $env->{REQUEST_URI} =~ m/^\/([^\?\/]*)\/?([^\?]*)/;
|
|
$request->{key} = uri_unescape($request->{key_escaped}) || '';
|
|
|
|
return respond(200, "Nothing to see here") if ($request->{bucket} eq '');
|
|
|
|
if (not defined $self->{buckets}->{$request->{bucket}}) {
|
|
return respondXML(404,
|
|
['Error' =>
|
|
['Code' => 'NoSuchBucket',
|
|
'Message' => 'The specified bucket does not exist',
|
|
'BucketName' => $request->{bucket}]
|
|
]);
|
|
}
|
|
|
|
$self->{request} = $request;
|
|
|
|
# TODO: body parsing for POST. Parameter "file" should be saved as file instead of in memory
|
|
my $req = Plack::Request->new($env);
|
|
$self->{req} = $req;
|
|
|
|
my @methods = qw(POST GET HEAD PUT DELETE);
|
|
|
|
unless ($env->{REQUEST_METHOD} ~~ @methods) {
|
|
undef($self->{request});
|
|
|
|
return respondXML(405,
|
|
['Error' =>
|
|
['Code' => 'MethodNotAllowed',
|
|
'Message' => 'The specified method is not allowed']
|
|
]);
|
|
}
|
|
|
|
my $result;
|
|
if ($env->{REQUEST_METHOD} eq 'POST') {
|
|
$result = $self->handle_POST();
|
|
} else {
|
|
|
|
unless ($self->check_signature()) {
|
|
undef($self->{request});
|
|
return respondXML(403, ['Error' => ['Code' => 'SignatureDoesNotMatch']]);
|
|
}
|
|
|
|
my $method = 'handle_'.$env->{REQUEST_METHOD};
|
|
$result = $self->$method;
|
|
}
|
|
|
|
undef($self->{request});
|
|
|
|
return $result;
|
|
};
|
|
|
|
|
|
sub psgi_callback {
|
|
my $self = shift;
|
|
|
|
sub {
|
|
$self->handle( shift );
|
|
};
|
|
}
|
|
|
|
1;
|