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

ewallah / moodle-repository_s3bucket / 19540794347

20 Nov 2025 02:45PM UTC coverage: 83.26% (+0.1%) from 83.111%
19540794347

push

github

rdebleu
localstack

3 of 4 new or added lines in 1 file covered. (75.0%)

1 existing line in 1 file now uncovered.

189 of 227 relevant lines covered (83.26%)

11.45 hits per line

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

83.19
/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);
15✔
54
        $bucket = $this->get_option('bucket_name');
15✔
55
        $place = [['name' => $bucket, 'path' => $path]];
15✔
56
        $epath = ($path === '') ? '.' : $path . '/';
15✔
57
        $options = [
15✔
58
            'Bucket' => $bucket,
15✔
59
            'FetchOwner' => false,
15✔
60
            'Prefix' => $path,
15✔
61
            'MaxKeys' => 1000,
15✔
62
            'EncodingType' => 'url',
15✔
63
            'Delimiter' => '/', ];
15✔
64
        $results = [];
15✔
65
        $files = [];
15✔
66
        $s3 = $this->create_s3();
15✔
67
        try {
68
            $results = $s3->listObjectsV2($options);
10✔
69
        } catch (\Exception $e) {
10✔
70
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
10✔
71
        }
72

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

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

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

109
        return [
10✔
110
           'list' => $files,
10✔
111
           'path' => $place,
10✔
112
           'manage' => false,
10✔
113
           'dynload' => true,
10✔
114
           'nologin' => true,
10✔
115
           'nosearch' => false, ];
10✔
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);
5✔
128
        $bucket = $this->get_option('bucket_name');
5✔
129
        $options = [
5✔
130
            'Bucket' => $bucket,
5✔
131
            'FetchOwner' => false,
5✔
132
            'MaxKeys' => 1000,
5✔
133
            'EncodingType' => 'url',
5✔
134
            'Delimiter' => '/', ];
5✔
135
        $results = [];
5✔
136
        $files = [];
5✔
137
        $s3 = $this->create_s3();
5✔
138
        try {
139
            $results = $s3->listObjectsV2($options);
5✔
140
        } catch (\Exception $e) {
5✔
141
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
5✔
142
        }
143

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

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

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

180
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
5✔
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');
5✔
194
        $this->send_otherfile($storedfile->get_reference(), "+{$duration} minutes");
5✔
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 != '') {
5✔
205
            $s3 = $this->create_s3();
×
206
            $options = [
×
207
               'Bucket' => $this->get_option('bucket_name'),
×
208
               'Key' => $reference,
×
209
               'ResponseContentDisposition' => 'attachment', ];
×
210
            try {
211
                $result = $s3->getCommand('GetObject', $options);
×
212
                $req = $s3->createPresignedRequest($result, $lifetime);
×
213
            } catch (\Exception $e) {
×
214
                throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
×
215
            }
216
            $uri = $req->getUri()->__toString();
×
217
            $mimetype = get_mimetype_description(['filename' => $reference]);
×
218
            if (!PHPUNIT_TEST) {
×
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');
5✔
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;
5✔
239
        $path = pathinfo($url);
5✔
240
        $file = $path['basename'];
5✔
241
        $directory = $path['dirname'];
5✔
242
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
5✔
243
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
5✔
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) {
5✔
254
            throw new \repository_exception('cannotdownload', 'repository');
5✔
255
        }
256

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

261
        return $this->get_file_source_info($reference);
5✔
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);
5✔
275
        $s3 = $this->create_s3();
5✔
276
        $options = [
5✔
277
           'Bucket' => $this->get_option('bucket_name'),
5✔
278
           'Key' => $filepath,
5✔
279
           'SaveAs' => $path, ];
5✔
280
        try {
281
            $s3->getObject($options);
5✔
282
        } catch (\Exception $e) {
5✔
283
            throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
5✔
284
        }
285
        return ['path' => $path, 'url' => $filepath];
5✔
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 == '') {
5✔
296
            return get_string('unknownsource', 'repository');
5✔
297
        }
298
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
5✔
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'];
90✔
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;
5✔
318
        $choices = ['1' => 1, '2' => 2, '10' => 10, '15' => 15, '30' => 30, '60' => 60];
5✔
319
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices, $duration);
5✔
320
        $mform->setType('duration', PARAM_INT);
5✔
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'];
90✔
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);
20✔
341
        $strrequired = get_string('required');
20✔
342
        $textops = ['maxlength' => 255, 'size' => 50];
20✔
343
        $endpointselect = [];
20✔
344
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
20✔
345
        $endpoints = $all['partitions'][0]['regions'];
20✔
346
        foreach ($endpoints as $key => $value) {
20✔
347
            $endpointselect[$key] = $value['description'];
20✔
348
        }
349

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

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

361
        $mform->addRule('access_key', $strrequired, 'required', null, 'client');
20✔
362
        $mform->addRule('secret_key', $strrequired, 'required', null, 'client');
20✔
363
        $mform->addRule('bucket_name', $strrequired, 'required', null, 'client');
20✔
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'])) {
20✔
376
            $credentials = ['key' => $data['access_key'], 'secret' => $data['secret_key']];
20✔
377
            $arr = self::addproxy(['credentials' => $credentials, 'region' => $data['endpoint']]);
20✔
378
            try {
379
                $s3 = \Aws\S3\S3Client::factory($arr);
20✔
380
                // Check if the bucket exists.
381
                $s3->getCommand('HeadBucket', ['Bucket' => $data['bucket_name']]);
20✔
382
            } catch (\Aws\Exception\InvalidRegionException $regionexeption) {
5✔
383
                $errors[] = $regionexeption->get_message();
5✔
NEW
384
            } catch (\Exception) {
×
UNCOV
385
                $errors[] = get_string('errorwhilecommunicatingwith', 'repository');
×
386
            }
387
        }
388
        return $errors;
20✔
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;
5✔
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;
80✔
407
    }
408

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

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

428
        return $this->s3client;
25✔
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';
45✔
440
        $settings['signature_version'] = 'v4';
45✔
441

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

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

460
        return $settings;
45✔
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);
5✔
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