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

ewallah / moodle-repository_s3bucket / 13918331961

18 Mar 2025 08:10AM UTC coverage: 95.349% (+0.02%) from 95.327%
13918331961

push

github

rdebleu
cod_review

205 of 215 relevant lines covered (95.35%)

5.2 hits per line

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

95.33
/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

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

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

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

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

71
        foreach ($results->search('CommonPrefixes[].{Prefix: Prefix}') as $item) {
4✔
72
             $files[] = [
4✔
73
                  'title' => basename($item['Prefix']),
4✔
74
                  'children' => [],
4✔
75
                  'thumbnail' => $diricon,
4✔
76
                  'thumbnail_height' => 64,
4✔
77
                  'thumbnail_width' => 64,
4✔
78
                  'path' => $item['Prefix'], ];
4✔
79
        }
80

81
        $filesearch = 'Contents[?StorageClass != \'DEEP_ARCHIVE\'';
4✔
82
        $filesearch .= ' && StorageClass != \'GLACIER\'';
4✔
83
        $filesearch .= ' && starts_with(Key, \'' . $path . '\')]';
4✔
84
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
4✔
85
        foreach ($results->search($filesearch) as $item) {
4✔
86
            $pathinfo = pathinfo($item['Key']);
2✔
87
            if ($pathinfo['dirname'] == $epath || $pathinfo['dirname'] . '//' == $epath) {
2✔
88
                $files[] = [
2✔
89
                   'title' => $pathinfo['basename'],
2✔
90
                   'size' => $item['Size'],
2✔
91
                   'path' => $item['Key'],
2✔
92
                   'datemodified' => date_timestamp_get($item['LastModified']),
2✔
93
                   'thumbnail_height' => 64,
2✔
94
                   'thumbnail_width' => 64,
2✔
95
                   'source' => $item['Key'],
2✔
96
                   'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false), ];
2✔
97
            }
98
        }
99
        return [
4✔
100
           'list' => $files,
4✔
101
           'path' => $place,
4✔
102
           'manage' => false,
4✔
103
           'dynload' => true,
4✔
104
           'nologin' => true,
4✔
105
           'nosearch' => false, ];
4✔
106
    }
107

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

133
        $dirsearch = 'CommonPrefixes[?contains(Prefix, \'' . $q . '\')].{Prefix: Prefix}';
2✔
134
        foreach ($results->search($dirsearch) as $item) {
2✔
135
             $files[] = [
2✔
136
                  'title' => basename($item['Prefix']),
2✔
137
                  'children' => [],
2✔
138
                  'thumbnail' => $diricon,
2✔
139
                  'thumbnail_height' => 64,
2✔
140
                  'thumbnail_width' => 64,
2✔
141
                  'path' => $item['Prefix'], ];
2✔
142
        }
143

144
        $filesearch = 'Contents[?StorageClass != \'DEEP_ARCHIVE\'';
2✔
145
        $filesearch .= ' && StorageClass != \'GLACIER\'';
2✔
146
        $filesearch .= ' && contains(Key, \'' . $q . '\')]';
2✔
147
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
2✔
148
        foreach ($results->search($filesearch) as $item) {
2✔
149
            $pathinfo = pathinfo($item['Key']);
2✔
150
            $files[] = [
2✔
151
               'title' => $pathinfo['basename'],
2✔
152
               'size' => $item['Size'],
2✔
153
               'path' => $item['Key'],
2✔
154
               'datemodified' => date_timestamp_get($item['LastModified']),
2✔
155
               'thumbnail_height' => 64,
2✔
156
               'thumbnail_width' => 64,
2✔
157
               'source' => $item['Key'],
2✔
158
               'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false),
2✔
159
            ];
2✔
160
        }
161
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
2✔
162
    }
163

164
    /**
165
     * Repository method to serve the referenced file
166
     *
167
     * @param stored_file $storedfile the file that contains the reference
168
     * @param int $lifetime Number of seconds before the file should expire from caches (null means $CFG->filelifetime)
169
     * @param int $filter 0 (default)=no filtering, 1=all files, 2=html files only
170
     * @param bool $forcedownload If true (default true), forces download of file rather than view in browser/plugin
171
     * @param array|null $options additional options affecting the file serving
172
     */
173
    public function send_file($storedfile, $lifetime = null, $filter = 0, $forcedownload = true, ?array $options = null) {
174
        $duration = get_config('s3bucket', 'duration');
2✔
175
        $this->send_otherfile($storedfile->get_reference(), "+$duration minutes");
2✔
176
    }
177

178
    /**
179
     * Repository method to serve the out file
180
     *
181
     * @param string $reference the filereference
182
     * @param string $lifetime Number of seconds before the file should expire from caches
183
     */
184
    public function send_otherfile($reference, $lifetime) {
185
        if ($reference != '') {
4✔
186
            $s3 = $this->create_s3();
2✔
187
            $options = [
2✔
188
               'Bucket' => $this->get_option('bucket_name'),
2✔
189
               'Key' => $reference,
2✔
190
               'ResponseContentDisposition' => 'attachment', ];
2✔
191
            try {
192
                $result = $s3->getCommand('GetObject', $options);
2✔
193
                $req = $s3->createPresignedRequest($result, $lifetime);
2✔
194
            } catch (\Exception $e) {
×
195
                throw new \moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
×
196
            }
197
            $uri = $req->getUri()->__toString();
2✔
198
            $mimetype = get_mimetype_description(['filename' => $reference]);
2✔
199
            header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
2✔
200
            header('Pragma: no-cache');
×
201
            header("Content-Type: $mimetype\n");
×
202
            header("Content-Disposition: attachment; filename=\"$reference\"");
×
203
            header("Location: $uri");
×
204
            die;
×
205
        }
206
        throw new \repository_exception('cannotdownload', 'repository');
2✔
207
    }
208

209
    /**
210
     * This method creates a download link from the repository.
211
     *
212
     * @param string $url relative path to the chosen file
213
     * @return string the generated download link.
214
     */
215
    public function get_link($url) {
216
        $cid = $this->context->id;
2✔
217
        $path = pathinfo($url);
2✔
218
        $file = $path['basename'];
2✔
219
        $directory = $path['dirname'];
2✔
220
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
2✔
221
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
2✔
222
    }
223

224
    /**
225
     * Get human readable file info from a the reference.
226
     *
227
     * @param string $reference
228
     * @param int $filestatus 0 - ok, 666 - source missing
229
     */
230
    public function get_reference_details($reference, $filestatus = 0) {
231
        if ($this->disabled) {
2✔
232
            throw new \repository_exception('cannotdownload', 'repository');
2✔
233
        }
234
        if ($filestatus == 666) {
2✔
235
            $reference = '';
2✔
236
        }
237
        return $this->get_file_source_info($reference);
2✔
238
    }
239

240
    /**
241
     * Download S3 files to moodle
242
     *
243
     * @param string $filepath
244
     * @param string $file The file path in moodle
245
     * @return array with elements:
246
     *   path: internal location of the file
247
     *   url: URL to the source (from parameters)
248
     */
249
    public function get_file($filepath, $file = '') {
250
        $path = $this->prepare_file($file);
2✔
251
        $s3 = $this->create_s3();
2✔
252
        $options = [
2✔
253
           'Bucket' => $this->get_option('bucket_name'),
2✔
254
           'Key' => $filepath,
2✔
255
           'SaveAs' => $path, ];
2✔
256
        try {
257
            $s3->getObject($options);
2✔
258
        } catch (\Exception $e) {
2✔
259
            throw new \moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
2✔
260
        }
261
        return ['path' => $path, 'url' => $filepath];
2✔
262
    }
263

264
    /**
265
     * Return the source information
266
     *
267
     * @param stdClass $filepath
268
     * @return string
269
     */
270
    public function get_file_source_info($filepath) {
271
        if (empty($filepath) || $filepath == '') {
2✔
272
            return get_string('unknownsource', 'repository');
2✔
273
        }
274
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
2✔
275
    }
276

277
    /**
278
     * Return names of the general options.
279
     *
280
     * @return array
281
     */
282
    public static function get_type_option_names() {
283
        return ['duration'];
36✔
284
    }
285

286
    /**
287
     * Edit/Create Admin Settings Moodle form
288
     *
289
     * @param moodleform $mform Moodle form (passed by reference)
290
     * @param string $classname repository class name
291
     */
292
    public static function type_config_form($mform, $classname = 'repository') {
293
        $duration = get_config('s3bucket', 'duration') ?? 2;
2✔
294
        $choices = ['1' => 1, '2' => 2, '10' => 10, '15' => 15, '30' => 30, '60' => 60];
2✔
295
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices, $duration);
2✔
296
        $mform->setType('duration', PARAM_INT);
2✔
297
    }
298

299
    /**
300
     * Return names of the instance options.
301
     * By default: no instance option name
302
     *
303
     * @return array
304
     */
305
    public static function get_instance_option_names() {
306
        return ['access_key', 'secret_key', 'endpoint', 'bucket_name'];
36✔
307
    }
308

309
    /**
310
     * Edit/Create Instance Settings Moodle form
311
     *
312
     * @param moodleform $mform Moodle form (passed by reference)
313
     */
314
    public static function instance_config_form($mform) {
315
        global $CFG;
316
        parent::instance_config_form($mform);
8✔
317
        $strrequired = get_string('required');
8✔
318
        $textops = ['maxlength' => 255, 'size' => 50];
8✔
319
        $endpointselect = [];
8✔
320
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
8✔
321
        $endpoints = $all['partitions'][0]['regions'];
8✔
322
        foreach ($endpoints as $key => $value) {
8✔
323
            $endpointselect[$key] = $value['description'];
8✔
324
        }
325

326
        $mform->addElement('passwordunmask', 'access_key', get_string('access_key', 'repository_s3'), $textops);
8✔
327
        $mform->setType('access_key', PARAM_RAW_TRIMMED);
8✔
328
        $mform->addElement('passwordunmask', 'secret_key', get_string('secret_key', 'repository_s3'), $textops);
8✔
329
        $mform->setType('secret_key', PARAM_RAW_TRIMMED);
8✔
330
        $mform->addElement('text', 'bucket_name', get_string('bucketname', 'repository_s3bucket'), $textops);
8✔
331
        $mform->setType('bucket_name', PARAM_RAW_TRIMMED);
8✔
332

333
        $boptions = ['placeholder' => 'us-east-1'];
8✔
334
        $mform->addElement('autocomplete', 'endpoint', get_string('endpoint', 'repository_s3'), $endpointselect, $boptions);
8✔
335
        // $mform->addElement('select', 'endpoint', get_string('endpoint', 'repository_s3'), $endpointselect);
336
        $mform->setDefault('endpoint', 'us-east-1');
8✔
337

338
        $mform->addRule('access_key', $strrequired, 'required', null, 'client');
8✔
339
        $mform->addRule('secret_key', $strrequired, 'required', null, 'client');
8✔
340
        $mform->addRule('bucket_name', $strrequired, 'required', null, 'client');
8✔
341
    }
342

343
    /**
344
     * Validate repository plugin instance form
345
     *
346
     * @param moodleform $mform moodle form
347
     * @param array $data form data
348
     * @param array $errors errors
349
     * @return array errors
350
     */
351
    public static function instance_form_validation($mform, $data, $errors) {
352
        if (isset($data['access_key']) && isset($data['secret_key']) && isset($data['bucket_name'])) {
8✔
353
            $credentials = ['key' => $data['access_key'], 'secret' => $data['secret_key']];
8✔
354
            $arr = self::addproxy(['credentials' => $credentials, 'region' => $data['endpoint']]);
8✔
355
            $s3 = \Aws\S3\S3Client::factory($arr);
8✔
356
            try {
357
                // Check if the bucket exists.
358
                $s3->getCommand('HeadBucket', ['Bucket' => $data['bucket_name']]);
8✔
359
            } catch (\Exception $e) {
×
360
                $errors[] = get_string('errorwhilecommunicatingwith', 'repository');
×
361
            }
362
        }
363
        return $errors;
8✔
364
    }
365

366
    /**
367
     * Which return type should be selected by default.
368
     *
369
     * @return int
370
     */
371
    public function default_returntype() {
372
        return FILE_REFERENCE;
2✔
373
    }
374

375
    /**
376
     * S3 plugins does support return links of files
377
     *
378
     * @return int
379
     */
380
    public function supported_returntypes() {
381
        return FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL;
32✔
382
    }
383

384
    /**
385
     * Get S3
386
     *
387
     * @return s3
388
     */
389
    private function create_s3() {
390
        if ($this->s3client == null) {
14✔
391
            $accesskey = $this->get_option('access_key');
14✔
392
            if (empty($accesskey)) {
14✔
393
                throw new \moodle_exception('needaccesskey', 'repository_s3');
4✔
394
            }
395
            $arr = self::addproxy([
12✔
396
                'credentials' => ['key' => $accesskey, 'secret' => $this->get_option('secret_key')],
12✔
397
                'use_path_style_endpoint' => true,
12✔
398
                'region' => $this->get_option('endpoint'), ]);
12✔
399
            $this->s3client = \Aws\S3\S3Client::factory($arr);
12✔
400
        }
401
        return $this->s3client;
12✔
402
    }
403

404
    /**
405
     * Add proxy
406
     *
407
     * @param array $settings
408
     * @return array
409
     */
410
    private static function addproxy($settings) {
411
        global $CFG;
412
        $settings['version'] = 'latest';
20✔
413
        $settings['signature_version'] = 'v4';
20✔
414
        if (!empty($CFG->proxyhost) && !empty($CFG->proxytype) && $CFG->proxytype != 'SOCKS5') {
20✔
415
            $host = (empty($CFG->proxyport)) ? $CFG->proxyhost : $CFG->proxyhost . ':' . $CFG->proxyport;
10✔
416
            $type = (empty($CFG->proxytype)) ? 'http://' : $CFG->proxytype;
10✔
417
            $cond = (!empty($CFG->proxyuser) && !empty($CFG->proxypassword));
10✔
418
            $user = $cond ? $CFG->proxyuser . '.' . $CFG->proxypassword . '@' : '';
10✔
419
            $settings['request.options'] = ['proxy' => "$type$user$host"];
10✔
420
        }
421
        if (defined('BEHAT_SITE_RUNNING') || get_config('core', 's3mock')) {
20✔
422
            $mock = new \Aws\MockHandler();
20✔
423
            $day = new DateTime();
20✔
424
            $result = new \Aws\Result([
20✔
425
                'CommonPrefixes' => [['Prefix' => '2020_dir']],
20✔
426
                'Contents' => [['Key' => '2020_f.jpg', 'Size' => 15, 'StorageClass' => 'STANDARD', 'LastModified' => $day]], ]);
20✔
427
            $mock->append($result, $result);
20✔
428
            $settings['handler'] = $mock;
20✔
429
        }
430
        return $settings;
20✔
431
    }
432

433
    /**
434
     * Is this repository accessing private data?
435
     *
436
     * This function should return false to give access to private repository data.
437
     * @return boolean True when the repository accesses private external data.
438
     */
439
    public function contains_private_data() {
440
        return ($this->context->contextlevel === CONTEXT_USER);
2✔
441
    }
442
}
443

444

445
/**
446
 * Serve the files from the repository_s3bucket file areas
447
 *
448
 * @param stdClass $course the course object
449
 * @param stdClass $cm the course module object
450
 * @param context $context the context
451
 * @param string $filearea the name of the file area
452
 * @param array $args extra arguments (itemid, path)
453
 * @param bool $forcedownload whether or not force download
454
 * @param array $options additional options affecting the file serving
455
 * @return bool false if the file not found, just send the file otherwise and do not return
456
 */
457
function repository_s3bucket_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []) {
458
    $handled = false;
2✔
459
    if ($filearea == 's3') {
2✔
460
        if ($context->contextlevel === CONTEXT_SYSTEM) {
2✔
461
            $handled = has_capability('moodle/course:view', $context);
2✔
462
        } else if ($context->contextlevel === CONTEXT_COURSE) {
2✔
463
            $handled = $course && has_capability('moodle/course:view', $context);
2✔
464
        } else if ($cm) {
2✔
465
            if (has_capability('mod/' . $cm->modname . ':view', $context)) {
2✔
466
                $modinfo = get_fast_modinfo($course);
2✔
467
                $cmi = $modinfo->cms[$cm->id];
2✔
468
                $handled = ($cmi->uservisible && $cmi->is_visible_on_course_page());
2✔
469
            }
470
        }
471
    }
472
    if ($handled) {
2✔
473
        $duration = get_config('s3bucket', 'duration');
2✔
474
        $itemid = array_shift($args);
2✔
475
        $reference = implode('/', $args);
2✔
476
        $repo = repository::get_repository_by_id($itemid, $context);
2✔
477
        $repo->send_otherfile($reference, "+$duration minutes");
2✔
478
    }
479
    return false;
2✔
480
}
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