-
Notifications
You must be signed in to change notification settings - Fork 9
/
aS3StreamWrapper.class.php
1302 lines (1207 loc) · 37.4 KB
/
aS3StreamWrapper.class.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* TODO:
*
* optional buffering on disk rather than in RAM
* Use of ../ within a URL should be allowed (and automatically resolved) for ease of coding
*
* A stream wrapper for Amazon S3 based on Amazon's offical PHP API.
* Amazon S3 files can be accessed at s3://bucketname/path/to/object. Unlike other
* wrappers, this wrapper supports opendir/readdir/closedir for any subdirectory level
* (to the limit of S3 key length). Although S3 does not have a concept exactly matching
* subdirectories, it can do prefix matches and return common prefixes, which is just as good.
* Just keep in mind that you don't really have to create or remove directories. mkdir and rmdir
* return success and do nothing (however, rmdir fails if the "directory" is not empty in order to
* better emulate what regular filesystems do since some code may rely on this behavior).
* Any subdirectories returned by readdir() have a trailing / attached to allow both your code
* and the stat() function to distinguish them from files without an expensive network call.
*
* THERE IS A 5GB LIMIT ON EACH FILE, AND YOUR PHP MEMORY LIMIT WILL PROBABLY STOP YOU LONG
* BEFORE YOU GET THERE, since currently everything is read and written as a complete file and
* buffered in its entirety in memory. I'm looking into changing this, but one step at a time.
*
* Usage (with a public ACL so people can see your files via the web):
*
* $wrapper = new aS3StreamWrapper();
* $wrapper->register(array('key' => 'xyz', 'secretKey' => 'abc', 'region' => AmazonS3::REGION_US_E1, 'acl' => AmazonS3::ACL_PUBLIC));
* Now fopen("s3://mybucket/path/to/myobject.txt", "r") and friends work.
*
* You can get buffering of the first 8K of every file and the stat() results in a local cache by passing a
* cache option, which must be an object supporting get($key) and set($key, $value, $lifetime_in_seconds)
* (such as any implementation of sfCache). This cache must be consistently consulted by all
* servers of course or it will not work properly
*
* Built for Apostrophe apostrophenow.com
*
* @class aS3StreamWrapper
* @license BSD
* @author tom@punkave.com Tom Boutell of P'unk Avenue
*/
require dirname(__FILE__) . '/../vendor/aws-sdk/sdk.class.php';
require dirname(__FILE__) . '/aS3StreamWrapperMimeTypes.class.php';
class aS3StreamWrapper
{
/**
* Create an object of this class with new and then call register() on it.
*
* Internal dev note: supposedly the constructor of a stream wrapper does not get called
* except on stream_open, so keep that in mind
*/
public function __construct()
{
}
/**
* Options specified in the register() call. These can be overridden
* for individual streams using a stream context
*/
static protected $protocolOptions;
/**
* Stream context, set by fopen and friends if stream_create_context was used.
* We can call stream_context_get_options on this
*/
public $context;
/**
* Final set of options arrived at by merging the above
*/
protected $options;
/**
* Protocol name. This is usually s3 but you can register more than one, and
* we use this to distinguish sets of options. Usually $this->init() sets this
* from the $path but the rename() operation, which takes two paths, sets it
* on its own
*/
protected $protocol;
/**
* Returns options set at register() time, unless overridden by
* options set with stream_context_create() for this particular stream.
* Looks for options in the 's3' key of the array passed to stream_context_create()
*/
protected function getOption($o, $default = null)
{
if (!isset($this->options))
{
$this->options = array();
if (isset(self::$protocolOptions[$this->protocol]))
{
$this->options = self::$protocolOptions[$this->protocol];
}
if ($this->context)
{
$streamOptions = stream_context_get_options($this->context);
if (isset($streamOptions['s3']))
{
$this->options = array_merge($this->options, $streamOptions['s3']);
}
}
}
if (isset($this->options[$o]))
{
return $this->options[$o];
}
return $default;
}
protected function getRegion()
{
return $this->getOption('region', AmazonS3::REGION_US_E1);
}
/**
* Register the stream wrapper. Options passed here are available to
* the stream wrapper methods at any time via getOption. Note that you can
* register two different s3 "protocols" with different credentials (the default
* protocol name is s3).
*
* The following options are required, either here or in stream_create_context:
* 'key', 'secretKey'
*
* The optional 'region' option specifies what Amazon S3 region to create
* new buckets in, if you choose to create buckets with mkdir() calls.
* It defaults to AmazonS3::REGION_US_E1. See services/s3.class.php in the SDK
*
* The optional 'protocol' option changes the name of the protocol from s3 to
* something else. You can register multiple protocols
*
*/
public function register(array $options = array())
{
// Every protocol gets its own set of options
$protocol = isset($options['protocol']) ? $options['protocol'] : 's3';
self::$protocolOptions[$protocol] = $options;
stream_wrapper_register($protocol, get_class($this));
}
/**
* Directory listing data doled out by readdir
*/
protected $dirInfo = false;
/**
* Offset into directory data
*/
protected $dirOffset = 0;
/**
* Array with protocol, bucket and path keys once init() is called successfully
*/
protected $info = false;
/**
* Amazon S3 service objects. Usually just one exists but if you make
* requests with custom credentials via a stream context or multiple
* protocol registrations more than one can be created
*/
static protected $services = array();
/**
* Bust up the path of interest into its component parts.
* The "site" name must be a bucket name. The path (key) will
* always be at least / for consistency
*/
protected function init($path)
{
$info = $this->parse($path);
if (!$info)
{
return false;
}
$this->info = $info;
$this->protocol = $info['protocol'];
return true;
}
protected function parse($path)
{
$info = array();
$parsed = parse_url($path);
if (!$parsed)
{
return false;
}
$info['protocol'] = $parsed['scheme'];
$info['bucket'] = $parsed['host'];
// No leading / in S3 (otherwise our public S3 URLs are strange)
if (isset($parsed['path']))
{
$info['path'] = substr($parsed['path'], 1);
// Lame: substr() returns false, not the empty string, if you
// attempt to take an empty substring starting right after the end
if ($info['path'] === false)
{
$info['path'] = '';
}
}
else
{
$info['path'] = '';
}
// Consecutive slashes make no difference in the filesystems we're emulating here
$info['path'] = preg_replace('/\/+/', '/', $info['path']);
return $info;
}
/**
* Allow separate S3 objects for separate credentials but don't
* make redundant S3 objects
*/
protected function getService()
{
$id = $this->getOption('key', '') . ':' . $this->getOption('secretKey', '') . ':' . $this->getOption('token', '');
if (!isset(self::$services[$id]))
{
self::$services[$id] = new AmazonS3($this->getOption('key'), $this->getOption('secretKey'), $this->getOption('token'));
}
return self::$services[$id];
}
protected $dirResults = null;
protected $dirPosition = 0;
/**
* Implements opendir(). Pulls a list of "files" and "directories" at
* $path from S3 and preps them to be returned one by one by readdir().
* Note that directories are suffixed with a / to distinguish them
*/
public function dir_opendir ($path, $optionsDummy)
{
if (!$this->init($path))
{
return false;
}
$this->dirResults = $this->getDirectoryListing($this->info);
if ($this->dirResults === false)
{
$this->dirResults = null;
return false;
}
$this->dirPosition = 0;
return true;
}
/**
* Set up options array for a call to list_objects. If delimited
* is true, return "subdirectories" plus "files" at this level,
* rather than all objects
*/
protected function getOptionsForDirectory($options = array())
{
$s3Options = array();
// Usually the path is the single path this operation cares about, but not always
$path = isset($options['path']) ? $options['path'] : $this->info['path'];
// Append a / unless we are listing items at the root
if (strlen($path) && (!preg_match('/\/$/', $path)))
{
$path .= '/';
}
$s3Options['prefix'] = $path;
if (isset($options['delimited']) && (!$options['delimited']))
{
// No delimiter wanted (for instance, we want a simple "are there any files darn it" test on just one XML query)
}
else
{
// Normal case: return everything in the same "subdirectory" as a subdirectory
$s3Options['delimiter'] = '/';
}
return $s3Options;
}
protected function getDirectoryListing($info = null, $options = array())
{
if ($info === null)
{
$info = $this->info;
}
$options = $this->getOptionsForDirectory(array_merge($options, array('path' => $info['path'])));
$results = array();
// Markers can be fetched more than once according to the spec, don't return them twice,
// but don't blindly assume we scan skip the first result either in case they surprise us
$have = array();
do
{
$list = $this->getService()->list_objects($info['bucket'], $options);
if (!$list->isOK())
{
return false;
}
// Subdirectories
$keys = $list->body->query('descendant-or-self::Prefix');
if ($keys)
{
foreach ($keys as $key)
{
$key = (string) $key;
if (strlen($key) <= strlen($options['prefix']))
{
// S3 tells us about the directory itself as a prefix, which is not interesting
continue;
}
// results of readdir() do not include the path, just the basename
$key = substr($key, strlen($options['prefix']));
if (!isset($have[$key]))
{
// Make sure there is no XML object funny business returned
// Leave the delimiter attached, it allows us to identify
// directories without more network calls
$results[] = $key;
$have[$key] = true;
}
}
}
// Files
$keys = $list->body->query('descendant-or-self::Key');
if ($keys)
{
foreach ($keys as $key)
{
$key = (string) $key;
// results of readdir() do not include the path, just the basename
if (strlen($key) <= strlen($options['prefix']))
{
// If something is both a file and a directory - possible in s3 where directories
// are virtual - it could show up in its own listing. This tends to result in
// nasty infinite loops in recursive delete functions etc. Defend against this by
// not returning it
continue;
}
$key = substr($key, strlen($options['prefix']));
if (!isset($have[$key]))
{
// Make sure there is no XML object funny business returned
$results[] = (string) $key;
$have[$key] = true;
}
}
}
// Pick up where we left off
$options = array_merge($options, array('marker' => end($results)));
} while (((string) $list->body->IsTruncated) === 'true');
return $results;
}
/**
* Implements readdir(), reading the name of the next file or subdirectory
* in the directory or returning false if there are no more or opendir() was
* never called. Subdirectories returned are suffixed with '/' to distinguish them
* from files without repeated API calls
*/
public function dir_readdir()
{
if (isset($this->dirResults))
{
if ($this->dirPosition < count($this->dirResults))
{
return $this->dirResults[$this->dirPosition++];
}
}
return false;
}
/**
* Implements closedir(), closing the directory listing
*/
public function dir_closedir()
{
$this->dirResults = null;
$this->dirPosition = 0;
return true;
}
/**
* Rewind to start of directory listing so we can start calling
* readdir again from the top
*/
public function dir_rewinddir()
{
if (isset($this->dirResults))
{
$this->dirPosition = 0;
return true;
}
return false;
}
/**
* Implements mkdir for the s3 protocol
* Make a directory. If $path is s3://bucketname or s3://bucketname/
* with no subdirectory name, we attempt to create that bucket and
* return failure if it already exists or it otherwise cannot be made.
* Buckets are created in the region specified by the
* region option when register() is called, defaulting to
* AmazonS3::REGION_US_E1 (see services/s3.class.php in the SDK).
*
* If there is a subdirectory name, we always return success since
* you don't really have to create common prefixes with S3, they
* just work. Note that in this case we assume the bucket already exists for
* performance reasons (if it isn't you'll find out soon enough when
* you try to manipulate files or read directory contents).
*/
public function mkdir($path, $mode, $options)
{
if (!$this->init($path))
{
return false;
}
$path = $this->info['path'];
if ($path === '')
{
return $this->getService()->create_bucket($this->info['bucket'], $this->getRegion())->isOK();
}
// Subdirectory creation always succeeds because subdirectories are implemented
// using the prefix/delimiter mechanism, which doesn't require creating anything first
return true;
}
/**
* Implements rmdir for the s3 protocol
* Remove a directory. If the URL is s3://bucketname/ or just s3://bucketname we
* attempt to remove the entire bucket, returning failure if it is not empty or
* otherwise not a valid bucket to delete. If the URL has a subdirectory in it,
* we just return success as long as the subdirectory is not empty, because this is what
* other file systems do, and some code may use it as a test. S3 doesn't really
* need us to physically delete a "directory" since it does not have directory
* objects, just a prefix/delimiter mechanism for queries. But let's emulate
* the semantics as closely as possible
*/
public function rmdir($path, $options)
{
if (!$this->init($path))
{
return false;
}
$path = $this->info['path'];
if ($path === '')
{
// On success this returns a CFResponse, on failure it returns false.
// Convert the CFResponse to plain old true
return !!$this->getService()->delete_bucket($this->info['bucket']);
}
if ($this->hasDirectoryContents())
{
return false;
}
return true;
}
protected function hasDirectoryContents()
{
$list = $this->getService()->list_objects($this->info['bucket'], array_merge($this->getOptionsForDirectory(array('delimited' => false)), array('max-keys' => 1)));
$keys = $list->body->query('descendant-or-self::Key');
return !!count($keys);
}
/**
* Implement unlink() for the s3 protocol. Removes files only, not folders or buckets
* (see rmdir()).
*/
public function unlink($path)
{
if (!$this->init($path))
{
return false;
}
$this->deleteCache();
return $this->getService()->delete_object($this->info['bucket'], $this->info['path'])->isOK();
}
/**
* Implement rename() for the s3 protocol
* Rename a file or directory. WARNING: S3 does NOT have a native rename feature,
* so this method must COPY EVERYTHING INVOLVED. If you rename a bucket, the
* ENTIRE BUCKET MUST BE COPIED. If you copy a subdirectory, everything in that
* subdirectory must be copied, etc. That equals a lot of S3 traffic.
*
* For safety, this method does not delete the old material at $from until the copy operation has
* completely succeeded.
*
* If, after the copy has completely succeeded, there are errors during the deletion
* of the source or its contents, this method returns false but the new copy remains
* in place along with whatever portions of the old copy could not be removed. Otherwise
* you could be left with no way to recover a portion of your data.
*
* THERE IS A 5GB LIMIT ON THE SIZE OF INDIVIDUAL OBJECTS INVOLVED IN A rename() OPERATION.
* This is a limitation of the copy_object API in Amazon S3.
*/
public function rename($from, $to)
{
$fromInfo = $this->parse($from);
if (!$fromInfo)
{
return false;
}
$this->protocol = $fromInfo['protocol'];
$toInfo = $this->parse($to);
if (!$toInfo)
{
return false;
}
if ($fromInfo['protocol'] !== $toInfo['protocol'])
{
// You cannot "rename" across protocols
return false;
}
$service = $this->getService();
// See if this is a simple copy of an object. If $from is an object rather than a bucket or
// subdirectory then this operation will succeed. Don't try this if either from or to is
// the root of a bucket
if (strlen($fromInfo['path']) && strlen($toInfo['path']))
{
if ($service->copy_object(array('bucket' => $fromInfo['bucket'], 'filename' => $fromInfo['path']),
array('bucket' => $toInfo['bucket'], 'filename' => $toInfo['path']), array('acl' => $this->getOption('acl')))->isOK())
{
// Make sure we reset the mime type based on the new file extension.
// Otherwise added extensions like .tmp tend to mean everything winds up
// application/octet-stream even after it is renamed to remove .tmp
if (!$service->change_content_type($toInfo['bucket'], $toInfo['path'], $this->getMimeType($toInfo['path']))->isOK())
{
$service->delete_object($toInfo['bucket'], $toInfo['path']);
return false;
}
// That worked so delete the original
$this->deleteCache($fromInfo);
if ($service->delete_object($fromInfo['bucket'], $fromInfo['path'])->isOK())
{
return true;
}
// The delete failed, but the copy succeeded. No way to be that specific in our error message
return false;
}
}
$createdBucket = true;
// If $to is the root of a bucket, create the bucket
if ($toInfo['path'] === '')
{
if (!$service->create_bucket($toInfo['bucket'], $this->getRegion())->isOK())
{
return false;
}
}
// Get a full list of objects at $from
$objects = $this->getDirectoryListing($fromInfo, array('delimited' => false));
if ($objects === false)
{
if ($createdBucket)
{
$service->delete_bucket($toInfo['bucket']);
}
return false;
}
$fromPaths = array();
$toPaths = array();
foreach ($objects as $object)
{
if (strlen($fromInfo['path']))
{
$fromPaths[] = $fromInfo['path'] . '/' . $object;
}
else
{
$fromPaths[] = $object;
}
if (strlen($toInfo['path']))
{
$toPaths[] = $toInfo['path'] . '/' . $object;
}
else
{
$toPaths[] = $object;
}
}
// and copy them all to $to
for ($i = 0; ($i < count($objects)); $i++)
{
// Make sure we reset the mime type based on the new file extension.
// Otherwise added extensions like .tmp tend to mean everything winds up
// application/octet-stream even after it is renamed to remove .tmp
if ((!$service->copy_object(array('bucket' => $fromInfo['bucket'], 'filename' => $fromPaths[$i]), array('bucket' => $toInfo['bucket'], 'filename' => $toPaths[$i]), array('acl' => $this->getOption('acl'), 'contentType' => $this->getMimeType($toInfo['path'])))->isOK()) || (!$service->change_content_type($toInfo['bucket'], $toInfo['path'], $this->getMimeType($toInfo['path']))))
{
for ($j = 0; ($j <= $i); $j++)
{
$service->delete_object($toInfo['bucket'], $toPaths[$j]);
}
if ($createdBucket)
{
$service->delete_bucket($toInfo['bucket']);
}
return false;
}
}
// BEGIN DELETION UNDER THE ORIGINAL NAME
// Once we get started with the deletions of the old copy it is better not to delete the
// new copy if something goes wrong, because then we have no copies at all.
for ($i = 0; ($i < count($objects)); $i++)
{
$this->deleteCache(array_merge($fromInfo, array('path' => $fromPaths[$i])));
if (!$service->delete_object($fromInfo['bucket'], $fromPaths[$i])->isOK())
{
return false;
}
}
// If $from is the root of a bucket delete the old bucket
if ($fromInfo['path'] === '')
{
if (!$service->delete_bucket($fromInfo['bucket']))
{
return false;
}
}
return true;
}
/**
* s3 does not have a select() operation, so we can't cast to a resource
*/
public function stream_cast ($cast_as)
{
return false;
}
/**
* Data to be written to or read from a stream. Alas S3's limited semantics pretty much
* require we read or write the entire object at a time (even if it's massive) which leads
* to practical limitations due to memory usage. Possibly we can use multipart upload later
* to ameliorate this in the case of writing big new objects
* https://forums.aws.amazon.com/thread.jspa?threadID=10752&start=25&tstart=0
*/
protected $data = null;
/**
* Offset into the data of the seek pointer at this time
*/
protected $dataOffset = 0;
/**
* True if the data was modified in any way and therefore we must write on close
*/
protected $dirty = false;
/**
* Whether we are expecting read operations
*/
protected $read = false;
/**
* Whether we are expecting write operations
*/
protected $write = false;
/**
* When a cache is configured, we cache the first 8K block of each file whenever
* possible to avoid unnecessary slow S3 calls for things like getimagesize()
* or exif_read_info() etc. etc. stream_open sets $this->start to that initial
* block of 8K bytes (or less, if the file is smaller than 8K bytes) as retrieved
* from the cache
*/
protected $start = null;
/**
* When a cache is configured, we also cache the results of stat() for quick
* access
*/
protected $stat = null;
/**
* After the first block is read from $this->start there must be a hint to the
* next stream_read call to call $this->fullRead() and move the pointer to the
* 8K boundary. This is that hint
*/
protected $afterStart = false;
/**
* If a stream_seek is attempted to somewhere other than byte 0, we need to
* give up on the start cache - that is, if they actually read from that point
* in the stream. However we don't want to give up right away in case the caller
* is just implementing the fseek(SEEK_END) ... ftell()... fseek(SEEK_SET)
* pattern to measure the length and then come back to the top because they are
* afraid to use stat() (I'm looking at you, exif_read_file)
*/
protected $startSeeking = false;
/**
* Opens a stream, as in fopen() or file_get_contents()
*/
public function stream_open ($path, $mode, $options, &$opened_path)
{
if (!$this->init($path))
{
return false;
}
$end = false;
$create = false;
$modes = array_flip(str_split($mode));
if (isset($modes['r']))
{
$this->read = true;
$this->write = false;
}
elseif (isset($modes['a']))
{
$this->read = true;
$this->write = true;
$end = true;
$create = true;
}
elseif (isset($modes['w']))
{
// Read nothing in, get ready to write to the buffer
$this->read = false;
$this->write = true;
$create = true;
}
elseif (isset($modes['x']))
{
$this->read = false;
$this->write = true;
$create = true;
$response = $this->getService()->get_object_headers($this->info['bucket'], $this->info['path']);
if ($response->isOK())
{
// x does not allow opening an existing file
return false;
}
}
elseif (isset($modes['c']))
{
$this->read = false;
$this->write = true;
$create = true;
}
else
{
// Unsupported mode
return false;
}
if (isset($modes['+']))
{
$this->read = true;
$this->write = true;
}
$this->data = '';
$this->dataOffset = 0;
$this->dirty = false;
if ($this->read && (!$this->write) && (!$end))
{
// Read-only operations support an optional cache of the first 8K block so that
// repeated operations like getimagesize() can succeed quickly. Note that
// PHP fread()s in 8K blocks
$cacheInfo = $this->getCacheInfo();
if ($cacheInfo)
{
$this->stat = $cacheInfo['stat'];
$this->start = $cacheInfo['start'];
return true;
}
}
if ($this->read || isset($modes['c']))
{
$result = $this->fullRead();
if (!$result)
{
if ($end)
{
// It's OK if an append operation starts a new file
// Mark it dirty so we know the creation of the file is needed even if
// nothing gets written to it
$this->dirty = true;
return true;
}
else
{
// Otherwise failure to find an existing object here is an error
return false;
}
}
else
{
if ($end)
{
$this->dataOffset = strlen($this->data);
}
}
}
else
{
// If we are not reading, and creating missing files is
// implied by the mode, then make sure we mark the file dirty
// so that we upload it even if 0 bytes are written
if ($create)
{
$this->dirty = true;
}
}
return true;
}
/**
* Fetch and unserialize cache contents for the specified file or the
* file indicated by $this->info
*/
protected function getCacheInfo($info = null)
{
if (is_null($info))
{
$info = $this->info;
}
$cache = $this->getCache();
if ($cache)
{
$info = $cache->get($this->getCacheKey($info));
if (!is_null($info))
{
$info = unserialize($info);
return $info;
}
}
return null;
}
/**
* cache key for a given protocol/bucket/path
*/
protected function getCacheKey($info = null)
{
if ($info === null)
{
$info = $this->info;
}
return $info['protocol'] . ':' . $info['bucket'] . ':' . $info['path'];
}
protected function fullRead()
{
$result = $this->getService()->get_object($this->info['bucket'], $this->info['path']);
if (!$result->isOK())
{
return false;
}
$this->data = (string) $result->body;
/**
* Theoretically redundant, but if S3 files are created by a non-cache-aware tool this lets us
* gradually roll that information into the cache
*/
$this->updateCache();
return true;
}
protected function updateCache()
{
// Cache the first 8K and the stat() results for future calls, if desired
$cache = $this->getCache();
if ($cache)
{
$cache->set($this->getCacheKey(), serialize(array('start' => substr($this->data, 0, 8192), 'stat' => $this->getStatInfo(false, strlen($this->data), time()))), 365 * 86400);
}
}
protected function deleteCache($info = null)
{
$cache = $this->getCache();
if ($cache)
{
$cache->remove($this->getCacheKey($info));
}
}
protected function getCache()
{
if ($this->getOption('cache'))
{
return $this->getOption('cache');
}
return null;
}
/**
* Close a stream opened with stream_open. Implements fclose() and is also closed by
* file_put_contents and the like
*/
public function stream_close()
{
if (is_null($this->data))
{
// No stream open
return false;
}
$result = $this->stream_flush();
// If this distresses you should call fflush separately first and make sure it works.
// That's necessary with any filesystem in principle although we rarely bother
// to check with the regular filesystem (and then we get busted by "disk full")
$this->data = null;
return $result;
}
/**
* Flush any unstored data in the buffer to S3. Implements fflush() and is used by stream_close
*/
public function stream_flush()
{
if ($this->write)
{
if ($this->dirty)
{
$response = $this->getService()->create_object($this->info['bucket'], $this->info['path'], array('body' => $this->data, 'acl' => $this->getOption('acl'), 'contentType' => $this->getMimeType($this->info['path'])));
if (!$response->isOK())
{
// PHP calls stream_flush when closing a stream (before calling stream_close, FYI),
// but it doesn't pay any attention to the return value of stream_flush:
// PHP bug https://bugs.php.net/bug.php?id=60110
// Call trigger_error so the programmer is not completely in the dark.
// This is similar to what the native file functionality does on I/O errors
trigger_error("Unable to write to bucket " . $this->info['bucket'] . ", path " . $this->info['path'], E_USER_WARNING);
return false;
}
$this->updateCache();
$this->dirty = false;
}
}
return true;
}
/**
* Returns true if we are at the end of a stream.
*
*/
public function stream_eof()
{
return (strlen($this->data) === $this->dataOffset);
}
/**
* You can't lock an S3 "file"
*/
public function stream_lock($operation)
{
return false;
}
/**
* You can't unlock an S3 "file"
*/
public function stream_unlock($operation)
{
return false;
}
/**
* Someday: stream_metadata. Doesn't exist in 5.3
*/
/**
* Read specified # of bytes. Implements fread() among other things
*/
public function stream_read($bytes)
{
if (!$this->read)
{
// Not supposed to be reading
return false;
}
// If we have a cache of the first 8K block and that's what we've been asked for, cough it up
if (!is_null($this->start))
{
if (($bytes === 8192) && (!$this->startSeeking))