ZSS.pm 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. package ZSS;
  2. use strict;
  3. use warnings;
  4. use Plack::Request;
  5. use Digest::HMAC_SHA1 qw(hmac_sha1);
  6. use Digest::MD5 qw (md5_base64);
  7. use MIME::Base64 qw(decode_base64 encode_base64);
  8. use JSON::XS;
  9. use Date::Parse;
  10. use URI;
  11. use URI::QueryParam;
  12. use URI::Escape;
  13. use Switch;
  14. use Encode;
  15. use Try::Tiny;
  16. use ZSS::Store;
  17. use Data::Dumper qw(Dumper);
  18. $Data::Dumper::Sortkeys = 1;
  19. sub new {
  20. my ($class) = @_;
  21. # TODO: read from config
  22. my $self = {};
  23. $self->{buckets}->{zotero}->{secretkey} = "yoursecretkey";
  24. $self->{buckets}->{zotero}->{store} = ZSS::Store->new("/srv/zotero/storage/");
  25. bless $self, $class;
  26. }
  27. sub respond {
  28. my $code = shift;
  29. my $msg = shift;
  30. return [ $code, [ 'Content-Type' => 'text/plain', 'Content-Length' => length($msg)], [$msg] ];
  31. }
  32. sub xml2string {
  33. my $xml = shift;
  34. my $msg = '';
  35. while (my $token = shift @{$xml}) {
  36. my $data = shift @{$xml};
  37. $msg .= '<'.$token.'>';
  38. if (ref $data eq 'ARRAY') {
  39. $msg .= xml2string($data);
  40. } else {
  41. $msg .= $data;
  42. }
  43. $msg .= '</'.$token.'>';
  44. }
  45. return $msg;
  46. }
  47. sub respondXML {
  48. my $code = shift;
  49. my $xml = shift;
  50. return [ $code, [ 'Content-Type' => 'application/xml'], ["<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".xml2string($xml)] ];
  51. }
  52. sub check_policy {
  53. my ($self, $cmp, $key, $val) = @_;
  54. switch ($cmp) {
  55. case 'eq' {
  56. if ($key eq 'bucket') {
  57. $key = $self->{request}->{bucket};
  58. } else {
  59. # TODO: Replace Plack::Request
  60. $key = $self->{req}->parameters->get($key)
  61. }
  62. return 1 if $key eq $val;
  63. };
  64. case 'content-length-range' {
  65. my $len = $self->{request}->{env}->{CONTENT_LENGTH};
  66. # $self->log("Length: ".$len.", Limits: ".$key.", ".$val);
  67. return 1 if (($len > $key) && ($len < $val));
  68. }
  69. }
  70. return 0;
  71. }
  72. sub log {
  73. my ($self, $msg) = @_;
  74. $self->{request}->{env}->{'psgix.logger'}->({ level => 'debug', message => $msg });
  75. }
  76. sub get_signature {
  77. my $self = shift;
  78. my $request = $self->{request};
  79. my $env = $request->{env};
  80. my $secret = $self->{buckets}->{$request->{bucket}}->{secretkey};
  81. my $query = {};
  82. my $use_query = undef;
  83. if ($env->{QUERY_STRING}) {
  84. $query = $request->{uri}->query_form_hash();
  85. }
  86. if ($query->{Signature}) {
  87. $use_query = 1;
  88. }
  89. # X-AMZ headers or query parameters
  90. my $amzstring = '';
  91. if ($use_query) {
  92. for my $key (sort(grep(/^x-amz/, keys %$query))) {
  93. my $value = $query->{$key};
  94. $amzstring .= lc($key).":".$value."\n";
  95. }
  96. } else {
  97. for my $key (sort(grep(/^HTTP_X_AMZ/, keys %$env))) {
  98. next if ($key eq 'HTTP_X_AMZ_DATE');
  99. my $value = $env->{$key};
  100. $key =~ s/_/-/g;
  101. $amzstring .= lc(substr($key,5)).":".$value."\n";
  102. }
  103. }
  104. # Date Header or Expires query parameter
  105. my $date;
  106. if ($use_query) {
  107. $date = $query->{Expires};
  108. } else {
  109. if ($env->{HTTP_X_AMZ_DATE}) {
  110. $date = $env->{HTTP_X_AMZ_DATE};
  111. } else {
  112. $date = $env->{HTTP_DATE};
  113. }
  114. }
  115. # changing response headers parameters
  116. my $additional_params = '';
  117. if ($query) {
  118. my $sep = '?';
  119. my @params = qw(response-cache-control response-content-disposition response-content-encoding response-content-language response-content-type response-expires);
  120. for my $key (@params) {
  121. if ($query->{$key}) {
  122. $additional_params .= $sep.$key."=".$query->{$key};
  123. $sep = '&' if ($sep eq '?');
  124. }
  125. }
  126. }
  127. my $stringtosign = $env->{REQUEST_METHOD}."\n".
  128. ($env->{HTTP_CONTENT_MD5} || '')."\n".
  129. ($env->{CONTENT_TYPE} || '')."\n".
  130. ($date || '')."\n".
  131. $amzstring.
  132. "/".$request->{bucket}."/".$request->{key_escaped}.$additional_params;
  133. # $self->log("Stringtosign:".$stringtosign."End");
  134. return encode_base64(hmac_sha1($stringtosign, $secret), '');
  135. }
  136. sub check_signature {
  137. my $self = shift;
  138. my $request = $self->{request};
  139. my $env = $request->{env};
  140. my $received_signature;
  141. if ($env->{QUERY_STRING} eq '') {
  142. ($received_signature) = ($env->{HTTP_AUTHORIZATION} || '') =~ m/^AWS .*:(.*)$/;
  143. } else {
  144. $received_signature = $request->{uri}->query_param('Signature') || '';
  145. }
  146. unless ($received_signature) {
  147. return 0;
  148. }
  149. my $signature = $self->get_signature();
  150. # $self->log("Check Signature: $received_signature == $signature");
  151. return ($signature eq $received_signature);
  152. }
  153. sub handle_POST {
  154. my ($self) = @_;
  155. my $request = $self->{request};
  156. my $env = $request->{env};
  157. my $req = $self->{req};
  158. my $policy = $req->parameters->get('policy');
  159. my $signature = $req->parameters->get('signature');
  160. unless ($signature && $policy) {
  161. return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']]);
  162. }
  163. unless ($signature eq encode_base64(hmac_sha1($policy, $self->{buckets}->{$request->{bucket}}->{secretkey}), '')) {
  164. return respondXML(403, ['Error' => ['Code' => 'SignatureDoesNotMatch']]);
  165. }
  166. my $json;
  167. try {
  168. $json = JSON::XS->new->relaxed->decode(decode_base64($policy));
  169. } catch {
  170. return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']]);
  171. };
  172. return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument', 'Message' => 'No expiration time specified in policy document']]) unless (defined $json->{expiration});
  173. my $expiration = Date::Parse::str2time($json->{expiration});
  174. if ($self->{request}->{starttime} > $expiration) {
  175. return respondXML(400, ['Error' => ['Code' => 'ExpiredToken']]);
  176. }
  177. # $self->log("Expires:".$expiration."; Starttime:".$self->{request}->{starttime});
  178. foreach my $ref (@{$json->{conditions}}) {
  179. if (ref $ref eq 'HASH') {
  180. foreach my $key (keys %{$ref}) {
  181. my $val = encode("utf8", $$ref{$key}); #TODO: better to decode parameter? Is unicode normalization required?
  182. my $result = $self->check_policy('eq', $key, $val);
  183. # $self->log($key."=".$val."(".$result.")");
  184. unless ($result) {return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']])};
  185. }
  186. }
  187. if (ref $ref eq 'ARRAY') {
  188. my $key = $$ref[1];
  189. $key =~ s/^\$//;
  190. my $val = encode("utf8", $$ref[2]); #TODO: better to decode parameter? Is unicode normalization required?
  191. my $result = $self->check_policy($$ref[0], $key, $val);
  192. # $self->log($key." ".$$ref[0]." ".$val." (".$result.")");
  193. unless ($result) {return respondXML(400, ['Error' => ['Code' => 'InvalidPolicyDocument']])};
  194. }
  195. }
  196. my $data = $req->parameters->get('file');
  197. unless ($data) {
  198. return respondXML(400, ['Error' => ['Code' => 'IncorrectNumberOfFilesInPostRequest']]);
  199. }
  200. my $md5 = md5_base64($data);
  201. unless ($req->parameters->get('Content-MD5') eq $md5.'==') {
  202. return respondXML(400, ['Error' => ['Code' => 'BadDigest']])
  203. }
  204. my $key = $req->parameters->get('key');
  205. my $store = $self->{buckets}->{$request->{bucket}}->{store};
  206. my $meta = {
  207. 'md5' => unpack('H*', decode_base64($md5)),
  208. 'acl' => $self->{req}->parameters->get('acl') || 'private'
  209. };
  210. $store->store_file($key, $req->parameters->get('file'), JSON::XS->new->utf8->encode($meta));
  211. my $status = $req->parameters->get('success_action_status');
  212. $status = '403' unless (($status eq '200') || ($status eq '201'));
  213. # TODO: access_action_redirect
  214. return respond($status, '');
  215. }
  216. sub handle_HEAD {
  217. my ($self) = @_;
  218. my $request = $self->{request};
  219. my $env = $request->{env};
  220. my $key = $request->{key};
  221. my $store = $self->{buckets}->{$request->{bucket}}->{store};
  222. unless ($store->check_exists($key)) {
  223. return respondXML(404, ['Error' => ['Code' => 'NoSuchKey']]);
  224. }
  225. my $meta;
  226. try {
  227. $meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
  228. };
  229. unless (ref($meta) eq 'HASH') {
  230. $meta = {};
  231. }
  232. my $headers = ['Content-Length' => $store->get_size($key)];
  233. if ($meta->{type}) {
  234. push @$headers, 'Content-Type';
  235. push @$headers, $meta->{type};
  236. }
  237. if ($meta->{md5}) {
  238. push @$headers, 'ETag';
  239. push @$headers, "\"".$meta->{md5}."\"";
  240. }
  241. return [200, $headers, []];
  242. }
  243. sub handle_GET {
  244. my ($self) = @_;
  245. my $request = $self->{request};
  246. my $env = $request->{env};
  247. my $key = $request->{key};
  248. my $store = $self->{buckets}->{$request->{bucket}}->{store};
  249. unless($store->check_exists($key)){
  250. return respondXML(404, ['Error' => ['Code' => 'NoSuchKey']]);
  251. }
  252. my $meta;
  253. try {
  254. $meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
  255. };
  256. unless (ref($meta) eq 'HASH') {
  257. $meta = {};
  258. }
  259. my $headers = ['Content-Length' => $store->get_size($key)];
  260. my $ct = $request->{uri}->query_param('response-content-type');
  261. $ct = $meta->{type} unless ($ct);
  262. if ($ct) {
  263. push @$headers, 'Content-Type';
  264. push @$headers, $ct;
  265. }
  266. if ($meta->{md5}) {
  267. push @$headers, 'ETag';
  268. push @$headers, "\"".$meta->{md5}."\"";
  269. }
  270. return [200, $headers, $store->retrieve_file($key)];
  271. }
  272. sub handle_PUT {
  273. my $self = shift;
  274. my $request = $self->{request};
  275. my $env = $request->{env};
  276. my $store = $self->{buckets}->{$request->{bucket}}->{store};
  277. my $key = $request->{key};
  278. my $cl = $env->{CONTENT_LENGTH};
  279. my $source = $env->{HTTP_X_AMZ_COPY_SOURCE};
  280. if (($cl == 0) && ($source)) {
  281. # Copy File
  282. $source = uri_unescape($source);
  283. (my $sourceBucket, my $sourceKey) = $source =~ m/^\/([^\?\/]*)\/?([^\?]*)/;
  284. # $self->log("Source: ".$sourceBucket."/bla/".$sourceKey."\nDestinationKey: ".$key."\n");
  285. my $res = $store->link_files($sourceKey, $key);
  286. if ($res) {
  287. my $meta;
  288. try {
  289. $meta = JSON::XS->new->utf8->decode($store->retrieve_filemeta($key));
  290. };
  291. unless (ref($meta) eq 'HASH') {
  292. $meta = {};
  293. }
  294. return respondXML(200, ['CopyObjectResult' => [ 'LastModified' => '2012', 'ETag' => $meta->{md5}]]);
  295. } else {
  296. return respondXML(500, ['Error' => ['Code' => 'InternalError']]);
  297. }
  298. } else {
  299. # Normal PUT
  300. my $input = $env->{'psgi.input'};
  301. my $cl = $env->{CONTENT_LENGTH};
  302. my $data;
  303. if (($input->read($data, $cl)) != $cl) {
  304. return respondXML(400, ['Error' => ['Code' => 'IncompleteBody']]);
  305. }
  306. my $md5 = md5_base64($data);
  307. my $meta = {};
  308. $meta->{type} = $env->{CONTENT_TYPE} if ($env->{CONTENT_TYPE});
  309. $meta->{acl} = $env->{HTTP_X_AMZ_ACL} || 'private';
  310. $meta->{md5} = unpack('H*', decode_base64($md5));
  311. if ($env->{HTTP_CONTENT_MD5}) {
  312. return respondXML(400, ['Error' => ['Code' => 'BadDigest']]) unless ($env->{HTTP_CONTENT_MD5} eq $md5.'==');
  313. }
  314. $store->store_file($key, $data, JSON::XS->new->utf8->encode($meta));
  315. return respond(200, '');
  316. }
  317. }
  318. sub handle_DELETE {
  319. my $self = shift;
  320. my $request = $self->{request};
  321. my $env = $request->{env};
  322. my $store = $self->{buckets}->{$request->{bucket}}->{store};
  323. my $key = $request->{key};
  324. unless ($store->check_exists($key)) {
  325. return respondXML(404, ['Error' => ['Code' => 'NoSuchKey', 'Message' => 'The resource you requested does not exist', 'Resource' => $key]]);
  326. }
  327. if ($store->delete_file($key)) {
  328. return [204, [], []];
  329. } else {
  330. return respondXML(500, ['Error' => ['Code' => 'InternalError']]);
  331. }
  332. }
  333. sub request_uri {
  334. my $env = shift;
  335. my $uri = ($env->{'psgi.url_scheme'} || "http") .
  336. "://" .
  337. ($env->{HTTP_HOST} || (($env->{SERVER_NAME} || "") . ":" . ($env->{SERVER_PORT} || 80))) .
  338. ($env->{SCRIPT_NAME} || "");
  339. return URI->new($uri . $env->{REQUEST_URI})->canonical();
  340. }
  341. sub handle {
  342. my ($self, $env) = @_;
  343. my $request = {};
  344. $request->{env} = $env;
  345. $request->{starttime} = time();
  346. $request->{uri} = request_uri($env);
  347. # split in bucket and key (currently only path style buckets no host style)
  348. ($request->{bucket}, $request->{key_escaped}) = $env->{REQUEST_URI} =~ m/^\/([^\?\/]*)\/?([^\?]*)/;
  349. $request->{key} = uri_unescape($request->{key_escaped}) || '';
  350. return respond(200, "Nothing to see here") if ($request->{bucket} eq '');
  351. if (not defined $self->{buckets}->{$request->{bucket}}) {
  352. return respondXML(404,
  353. ['Error' =>
  354. ['Code' => 'NoSuchBucket',
  355. 'Message' => 'The specified bucket does not exist',
  356. 'BucketName' => $request->{bucket}]
  357. ]);
  358. }
  359. $self->{request} = $request;
  360. # TODO: body parsing for POST. Parameter "file" should be saved as file instead of in memory
  361. my $req = Plack::Request->new($env);
  362. $self->{req} = $req;
  363. my @methods = qw(POST GET HEAD PUT DELETE);
  364. unless ($env->{REQUEST_METHOD} ~~ @methods) {
  365. undef($self->{request});
  366. return respondXML(405,
  367. ['Error' =>
  368. ['Code' => 'MethodNotAllowed',
  369. 'Message' => 'The specified method is not allowed']
  370. ]);
  371. }
  372. my $result;
  373. if ($env->{REQUEST_METHOD} eq 'POST') {
  374. $result = $self->handle_POST();
  375. } else {
  376. unless ($self->check_signature()) {
  377. undef($self->{request});
  378. return respondXML(403, ['Error' => ['Code' => 'SignatureDoesNotMatch']]);
  379. }
  380. my $method = 'handle_'.$env->{REQUEST_METHOD};
  381. $result = $self->$method;
  382. }
  383. undef($self->{request});
  384. return $result;
  385. };
  386. sub psgi_callback {
  387. my $self = shift;
  388. sub {
  389. $self->handle( shift );
  390. };
  391. }
  392. 1;