• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ewallah / moodle-repository_s3bucket / 19545160635

20 Nov 2025 05:01PM UTC coverage: 85.903% (+2.6%) from 83.26%
19545160635

push

github

rdebleu
localstack

195 of 227 relevant lines covered (85.9%)

2.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.84
/lib.php
1
<?php
2
// This file is part of Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16

17
/**
18
 * This plugin is used to access s3bucket files
19
 *
20
 * @package    repository_s3bucket
21
 * @copyright  eWallah (www.eWallah.net) (based on work by Dongsheng Cai)
22
 * @author     Renaat Debleu <info@eWallah.net>
23
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 */
25
use core\exception\moodle_exception;
26

27
defined('MOODLE_INTERNAL') || die;
28

29
global $CFG;
30
require_once($CFG->dirroot . '/repository/lib.php');
×
31

32
/**
33
 * This is a repository class used to browse a Amazon S3 bucket.
34
 *
35
 * @package    repository_s3bucket
36
 * @copyright  eWallah (www.eWallah.net) (based on work by Dongsheng Cai)
37
 * @author     Renaat Debleu <info@eWallah.net>
38
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39
 */
40
class repository_s3bucket extends repository {
41
    /** @var s3client s3 client object */
42
    private $s3client;
43

44
    /**
45
     * Get S3 file list
46
     *
47
     * @param string $path this parameter can a folder name, or a identification of folder
48
     * @param string $page the page number of file list
49
     * @return array the list of files, including some meta infomation
50
     */
51
    public function get_listing($path = '.', $page = 1) {
52
        global $OUTPUT;
53
        $diricon = $OUTPUT->image_url(file_folder_icon())->out(false);
3✔
54
        $bucket = $this->get_option('bucket_name');
3✔
55
        $place = [['name' => $bucket, 'path' => $path]];
3✔
56
        $epath = ($path === '') ? '.' : $path . '/';
3✔
57
        $options = [
3✔
58
            'Bucket' => $bucket,
3✔
59
            'FetchOwner' => false,
3✔
60
            'Prefix' => $path,
3✔
61
            'MaxKeys' => 1000,
3✔
62
            'EncodingType' => 'url',
3✔
63
            'Delimiter' => '/', ];
3✔
64
        $results = [];
3✔
65
        $files = [];
3✔
66
        $s3 = $this->create_s3();
3✔
67
        try {
68
            $results = $s3->listObjectsV2($options);
2✔
69
        } catch (\Exception $e) {
×
70
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
×
71
        }
72

73
        $items = $results->search('CommonPrefixes[].{Prefix: Prefix}');
2✔
74
        if ($items) {
2✔
75
            foreach ($items as $item) {
2✔
76
                $files[] = [
2✔
77
                      'title' => basename((string) $item['Prefix']),
2✔
78
                      'children' => [],
2✔
79
                      'thumbnail' => $diricon,
2✔
80
                      'thumbnail_height' => 64,
2✔
81
                      'thumbnail_width' => 64,
2✔
82
                      'path' => $item['Prefix'], ];
2✔
83
            }
84
        }
85

86
        $filesearch = "Contents[?StorageClass != 'DEEP_ARCHIVE'";
2✔
87
        $filesearch .= " && StorageClass != 'GLACIER'";
2✔
88
        $filesearch .= " && starts_with(Key, '{$path}')]";
2✔
89
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
2✔
90

91
        $items = $results->search($filesearch);
2✔
92
        if ($items) {
2✔
93
            foreach ($items as $item) {
1✔
94
                $pathinfo = pathinfo((string)$item['Key']);
1✔
95
                if ($pathinfo['dirname'] == $epath || $pathinfo['dirname'] . '//' === $epath) {
1✔
96
                    $files[] = [
1✔
97
                       'title' => $pathinfo['basename'],
1✔
98
                       'size' => $item['Size'],
1✔
99
                       'path' => $item['Key'],
1✔
100
                       'datemodified' => date_timestamp_get($item['LastModified']),
1✔
101
                       'thumbnail_height' => 64,
1✔
102
                       'thumbnail_width' => 64,
1✔
103
                       'source' => $item['Key'],
1✔
104
                       'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false), ];
1✔
105
                }
106
            }
107
        }
108

109
        return [
2✔
110
           'list' => $files,
2✔
111
           'path' => $place,
2✔
112
           'manage' => false,
2✔
113
           'dynload' => true,
2✔
114
           'nologin' => true,
2✔
115
           'nosearch' => false, ];
2✔
116
    }
117

118
    /**
119
     * Search through all the files.
120
     *
121
     * @param  String  $q    The query string.
122
     * @param  integer $page The page number.
123
     * @return array of results.
124
     */
125
    public function search($q, $page = 1) {
126
        global $OUTPUT;
127
        $diricon = $OUTPUT->image_url(file_folder_icon())->out(false);
1✔
128
        $bucket = $this->get_option('bucket_name');
1✔
129
        $options = [
1✔
130
            'Bucket' => $bucket,
1✔
131
            'FetchOwner' => false,
1✔
132
            'MaxKeys' => 1000,
1✔
133
            'EncodingType' => 'url',
1✔
134
            'Delimiter' => '/', ];
1✔
135
        $results = [];
1✔
136
        $files = [];
1✔
137
        $s3 = $this->create_s3();
1✔
138
        try {
139
            $results = $s3->listObjectsV2($options);
1✔
140
        } catch (\Exception $e) {
1✔
141
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
1✔
142
        }
143

144
        $dirsearch = "CommonPrefixes[?contains(Prefix, '{$q}')].{Prefix: Prefix}";
1✔
145
        $items = $results->search($dirsearch);
1✔
146
        if ($items) {
1✔
147
            foreach ($items as $item) {
1✔
148
                $files[] = [
1✔
149
                    'title' => basename((string)$item['Prefix']),
1✔
150
                    'children' => [],
1✔
151
                    'thumbnail' => $diricon,
1✔
152
                    'thumbnail_height' => 64,
1✔
153
                    'thumbnail_width' => 64,
1✔
154
                    'path' => $item['Prefix'], ];
1✔
155
            }
156
        }
157

158
        $filesearch = "Contents[?StorageClass != 'DEEP_ARCHIVE'";
1✔
159
        $filesearch .= " && StorageClass != 'GLACIER'";
1✔
160
        $filesearch .= " && contains(Key, '{$q}')]";
1✔
161
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
1✔
162

163
        $items = $results->search($filesearch);
1✔
164
        if ($items) {
1✔
165
            foreach ($results->search($filesearch) as $item) {
1✔
166
                $pathinfo = pathinfo((string)$item['Key']);
1✔
167
                $files[] = [
1✔
168
                   'title' => $pathinfo['basename'],
1✔
169
                   'size' => $item['Size'],
1✔
170
                   'path' => $item['Key'],
1✔
171
                   'datemodified' => date_timestamp_get($item['LastModified']),
1✔
172
                   'thumbnail_height' => 64,
1✔
173
                   'thumbnail_width' => 64,
1✔
174
                   'source' => $item['Key'],
1✔
175
                   'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false),
1✔
176
                ];
1✔
177
            }
178
        }
179

180
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
1✔
181
    }
182

183
    /**
184
     * Repository method to serve the referenced file
185
     *
186
     * @param stored_file $storedfile the file that contains the reference
187
     * @param int $lifetime Number of seconds before the file should expire from caches (null means $CFG->filelifetime)
188
     * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
189
     * @param bool $forcedownload If true (default true), forces download of file rather than view in browser/plugin
190
     * @param array|null $options additional options affecting the file serving
191
     */
192
    public function send_file($storedfile, $lifetime = null, $filter = 0, $forcedownload = true, ?array $options = null) {
193
        $duration = get_config('s3bucket', 'duration');
1✔
194
        $this->send_otherfile($storedfile->get_reference(), "+{$duration} minutes");
1✔
195
    }
196

197
    /**
198
     * Repository method to serve the out file
199
     *
200
     * @param string $reference the filereference
201
     * @param string $lifetime Number of seconds before the file should expire from caches
202
     */
203
    public function send_otherfile($reference, $lifetime) {
204
        if ($reference != '') {
2✔
205
            $s3 = $this->create_s3();
1✔
206
            $options = [
1✔
207
               'Bucket' => $this->get_option('bucket_name'),
1✔
208
               'Key' => $reference,
1✔
209
               'ResponseContentDisposition' => 'attachment', ];
1✔
210
            try {
211
                $result = $s3->getCommand('GetObject', $options);
1✔
212
                $req = $s3->createPresignedRequest($result, $lifetime);
1✔
213
            } catch (\Exception $e) {
×
214
                throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
×
215
            }
216
            $uri = $req->getUri()->__toString();
1✔
217
            $mimetype = get_mimetype_description(['filename' => $reference]);
1✔
218
            if (!PHPUNIT_TEST) {
1✔
219
                header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
×
220
                header('Pragma: no-cache');
×
221
                header("Content-Type: {$mimetype}");
×
222
                header("Content-Disposition: attachment; filename=\"{$reference}\"");
×
223
                header("Location: {$uri}");
×
224
                die;
×
225
            }
226
        }
227

228
        throw new \repository_exception('cannotdownload', 'repository');
2✔
229
    }
230

231
    /**
232
     * This method creates a download link from the repository.
233
     *
234
     * @param string $url relative path to the chosen file
235
     * @return string the generated download link.
236
     */
237
    public function get_link($url) {
238
        $cid = $this->context->id;
1✔
239
        $path = pathinfo($url);
1✔
240
        $file = $path['basename'];
1✔
241
        $directory = $path['dirname'];
1✔
242
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
1✔
243
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
1✔
244
    }
245

246
    /**
247
     * Get human readable file info from a the reference.
248
     *
249
     * @param string $reference
250
     * @param int $filestatus 0 - ok, 666 - source missing
251
     */
252
    public function get_reference_details($reference, $filestatus = 0) {
253
        if ($this->disabled) {
1✔
254
            throw new \repository_exception('cannotdownload', 'repository');
1✔
255
        }
256

257
        if ($filestatus == 666) {
1✔
258
            $reference = '';
1✔
259
        }
260

261
        return $this->get_file_source_info($reference);
1✔
262
    }
263

264
    /**
265
     * Download S3 files to moodle
266
     *
267
     * @param string $filepath
268
     * @param string $file The file path in moodle
269
     * @return array with elements:
270
     *   path: internal location of the file
271
     *   url: URL to the source (from parameters)
272
     */
273
    public function get_file($filepath, $file = '') {
274
        $path = $this->prepare_file($file);
2✔
275
        $s3 = $this->create_s3();
2✔
276
        $options = [
2✔
277
           'Bucket' => $this->get_option('bucket_name'),
2✔
278
           'Key' => $filepath,
2✔
279
           'SaveAs' => $path, ];
2✔
280
        try {
281
            $s3->getObject($options);
2✔
282
        } catch (\Exception $e) {
1✔
283
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
1✔
284
        }
285
        return ['path' => $path, 'url' => $filepath];
2✔
286
    }
287

288
    /**
289
     * Return the source information
290
     *
291
     * @param stdClass $filepath
292
     * @return string
293
     */
294
    public function get_file_source_info($filepath) {
295
        if (empty($filepath) || $filepath == '') {
1✔
296
            return get_string('unknownsource', 'repository');
1✔
297
        }
298
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
1✔
299
    }
300

301
    /**
302
     * Return names of the general options.
303
     *
304
     * @return array
305
     */
306
    public static function get_type_option_names() {
307
        return ['duration'];
18✔
308
    }
309

310
    /**
311
     * Edit/Create Admin Settings Moodle form
312
     *
313
     * @param moodleform $mform Moodle form (passed by reference)
314
     * @param string $classname repository class name
315
     */
316
    public static function type_config_form($mform, $classname = 'repository') {
317
        $duration = get_config('s3bucket', 'duration') ?? 2;
1✔
318
        $choices = ['1' => 1, '2' => 2, '10' => 10, '15' => 15, '30' => 30, '60' => 60];
1✔
319
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices, $duration);
1✔
320
        $mform->setType('duration', PARAM_INT);
1✔
321
    }
322

323
    /**
324
     * Return names of the instance options.
325
     * By default: no instance option name
326
     *
327
     * @return array
328
     */
329
    public static function get_instance_option_names() {
330
        return ['access_key', 'secret_key', 'endpoint', 'bucket_name'];
18✔
331
    }
332

333
    /**
334
     * Edit/Create Instance Settings Moodle form
335
     *
336
     * @param moodleform $mform Moodle form (passed by reference)
337
     */
338
    public static function instance_config_form($mform) {
339
        global $CFG;
340
        parent::instance_config_form($mform);
4✔
341
        $strrequired = get_string('required');
4✔
342
        $textops = ['maxlength' => 255, 'size' => 50];
4✔
343
        $endpointselect = [];
4✔
344
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
4✔
345
        $endpoints = $all['partitions'][0]['regions'];
4✔
346
        foreach ($endpoints as $key => $value) {
4✔
347
            $endpointselect[$key] = $value['description'];
4✔
348
        }
349

350
        $mform->addElement('passwordunmask', 'access_key', get_string('access_key', 'repository_s3'), $textops);
4✔
351
        $mform->setType('access_key', PARAM_RAW_TRIMMED);
4✔
352
        $mform->addElement('passwordunmask', 'secret_key', get_string('secret_key', 'repository_s3'), $textops);
4✔
353
        $mform->setType('secret_key', PARAM_RAW_TRIMMED);
4✔
354
        $mform->addElement('text', 'bucket_name', get_string('bucketname', 'repository_s3bucket'), $textops);
4✔
355
        $mform->setType('bucket_name', PARAM_RAW_TRIMMED);
4✔
356

357
        $boptions = ['placeholder' => 'us-east-1', 'tags' => true];
4✔
358
        $mform->addElement('autocomplete', 'endpoint', get_string('endpoint', 'repository_s3'), $endpointselect, $boptions);
4✔
359
        $mform->setDefault('endpoint', 'us-east-1');
4✔
360

361
        $mform->addRule('access_key', $strrequired, 'required', null, 'client');
4✔
362
        $mform->addRule('secret_key', $strrequired, 'required', null, 'client');
4✔
363
        $mform->addRule('bucket_name', $strrequired, 'required', null, 'client');
4✔
364
    }
365

366
    /**
367
     * Validate repository plugin instance form
368
     *
369
     * @param moodleform $mform moodle form
370
     * @param array $data form data
371
     * @param array $errors errors
372
     * @return array errors
373
     */
374
    public static function instance_form_validation($mform, $data, $errors) {
375
        if (isset($data['access_key']) && isset($data['secret_key']) && isset($data['bucket_name'])) {
4✔
376
            $credentials = ['key' => $data['access_key'], 'secret' => $data['secret_key']];
4✔
377
            $arr = self::addproxy(['credentials' => $credentials, 'region' => $data['endpoint']]);
4✔
378
            try {
379
                $s3 = \Aws\S3\S3Client::factory($arr);
4✔
380
                // Check if the bucket exists.
381
                $s3->getCommand('HeadBucket', ['Bucket' => $data['bucket_name']]);
4✔
382
            } catch (\Aws\Exception\InvalidRegionException $regionexeption) {
×
383
                $errors[] = get_string('errorwhilecommunicatingwith', 'repository');
×
384
            } catch (\Exception) {
×
385
                $errors[] = get_string('errorwhilecommunicatingwith', 'repository');
×
386
            }
387
        }
388
        return $errors;
4✔
389
    }
390

391
    /**
392
     * Which return type should be selected by default.
393
     *
394
     * @return int
395
     */
396
    public function default_returntype() {
397
        return FILE_REFERENCE;
1✔
398
    }
399

400
    /**
401
     * S3 plugins does support return links of files
402
     *
403
     * @return int
404
     */
405
    public function supported_returntypes() {
406
        return FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL;
16✔
407
    }
408

409
    /**
410
     * Get S3
411
     *
412
     * @return s3
413
     */
414
    private function create_s3() {
415
        if ($this->s3client == null) {
6✔
416
            $accesskey = $this->get_option('access_key');
6✔
417
            if (empty($accesskey)) {
6✔
418
                throw new moodle_exception('needaccesskey', 'repository_s3');
1✔
419
            }
420

421
            $arr = self::addproxy([
5✔
422
                'credentials' => ['key' => $accesskey, 'secret' => $this->get_option('secret_key')],
5✔
423
                'use_path_style_endpoint' => true,
5✔
424
                'region' => $this->get_option('endpoint'), ]);
5✔
425
            $this->s3client = \Aws\S3\S3Client::factory($arr);
5✔
426
        }
427

428
        return $this->s3client;
5✔
429
    }
430

431
    /**
432
     * Add proxy
433
     *
434
     * @param array $settings Settings
435
     * @return array Array of settings
436
     */
437
    private static function addproxy($settings) {
438
        global $CFG;
439
        $settings['version'] = 'latest';
9✔
440
        $settings['signature_version'] = 'v4';
9✔
441

442
        if (!empty($CFG->proxyhost) && !empty($CFG->proxytype) && $CFG->proxytype != 'SOCKS5') {
9✔
443
            $host = (empty($CFG->proxyport)) ? $CFG->proxyhost : $CFG->proxyhost . ':' . $CFG->proxyport;
4✔
444
            $type = (empty($CFG->proxytype)) ? 'http://' : $CFG->proxytype;
4✔
445
            $cond = (!empty($CFG->proxyuser) && !empty($CFG->proxypassword));
4✔
446
            $user = $cond ? $CFG->proxyuser . '.' . $CFG->proxypassword . '@' : '';
4✔
447
            $settings['request.options'] = ['proxy' => "{$type}{$user}{$host}"];
4✔
448
        }
449

450
        if (defined('BEHAT_SITE_RUNNING') || get_config('core', 's3mock')) {
9✔
451
            $mock = new \Aws\MockHandler();
9✔
452
            $day = new DateTime();
9✔
453
            $result = new \Aws\Result([
9✔
454
                'CommonPrefixes' => [['Prefix' => '2020_dir']],
9✔
455
                'Contents' => [['Key' => '2020_f.jpg', 'Size' => 15, 'StorageClass' => 'STANDARD', 'LastModified' => $day]], ]);
9✔
456
            $mock->append($result, $result);
9✔
457
            $settings['handler'] = $mock;
9✔
458
        }
459

460
        return $settings;
9✔
461
    }
462

463
    /**
464
     * Is this repository accessing private data?
465
     *
466
     * This function should return false to give access to private repository data.
467
     * @return boolean True when the repository accesses private external data.
468
     */
469
    public function contains_private_data() {
470
        return ($this->context->contextlevel === CONTEXT_USER);
1✔
471
    }
472
}
473

474

475
/**
476
 * Serve the files from the repository_s3bucket file areas
477
 *
478
 * @param stdClass $course the course object
479
 * @param stdClass $cm the course module object
480
 * @param context $context the context
481
 * @param string $filearea the name of the file area
482
 * @param array $args extra arguments (itemid, path)
483
 * @param bool $forcedownload whether or not force download
484
 * @param array $options additional options affecting the file serving
485
 * @return bool false if the file not found, just send the file otherwise and do not return
486
 */
487
function repository_s3bucket_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) {
488
    $handled = false;
×
489
    if ($filearea == 's3') {
×
490
        if ($context->contextlevel === CONTEXT_SYSTEM) {
×
491
            $handled = has_capability('moodle/course:view', $context);
×
492
        } else if ($context->contextlevel === CONTEXT_COURSE) {
×
493
            $handled = $course && has_capability('moodle/course:view', $context);
×
494
        } else if ($cm && has_capability('mod/' . $cm->modname . ':view', $context)) {
×
495
            $modinfo = get_fast_modinfo($course);
×
496
            $cmi = $modinfo->cms[$cm->id];
×
497
            $handled = ($cmi->uservisible && $cmi->is_visible_on_course_page());
×
498
        }
499
    }
500

501
    if ($handled) {
×
502
        $duration = get_config('s3bucket', 'duration');
×
503
        $itemid = array_shift($args);
×
504
        $reference = implode('/', $args);
×
505
        $repo = repository::get_repository_by_id($itemid, $context);
×
506
        $repo->send_otherfile($reference, "+{$duration} minutes");
×
507
    }
508

509
    return false;
×
510
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc