123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- 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;
|