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

ewallah / moodle-repository_s3bucket / 14579423532

21 Apr 2025 06:56PM UTC coverage: 82.326% (-13.0%) from 95.349%
14579423532

push

github

rdebleu
version_update

177 of 215 relevant lines covered (82.33%)

11.73 hits per line

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

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

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

81
        $filesearch = 'Contents[?StorageClass != \'DEEP_ARCHIVE\'';
10✔
82
        $filesearch .= ' && StorageClass != \'GLACIER\'';
10✔
83
        $filesearch .= ' && starts_with(Key, \'' . $path . '\')]';
10✔
84
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
10✔
85
        foreach ($results->search($filesearch) as $item) {
10✔
86
            $pathinfo = pathinfo($item['Key']);
5✔
87
            if ($pathinfo['dirname'] == $epath || $pathinfo['dirname'] . '//' == $epath) {
5✔
88
                $files[] = [
5✔
89
                   'title' => $pathinfo['basename'],
5✔
90
                   'size' => $item['Size'],
5✔
91
                   'path' => $item['Key'],
5✔
92
                   'datemodified' => date_timestamp_get($item['LastModified']),
5✔
93
                   'thumbnail_height' => 64,
5✔
94
                   'thumbnail_width' => 64,
5✔
95
                   'source' => $item['Key'],
5✔
96
                   'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false), ];
5✔
97
            }
98
        }
99
        return [
10✔
100
           'list' => $files,
10✔
101
           'path' => $place,
10✔
102
           'manage' => false,
10✔
103
           'dynload' => true,
10✔
104
           'nologin' => true,
10✔
105
           'nosearch' => false, ];
10✔
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);
5✔
118
        $bucket = $this->get_option('bucket_name');
5✔
119
        $options = [
5✔
120
            'Bucket' => $bucket,
5✔
121
            'FetchOwner' => false,
5✔
122
            'MaxKeys' => 1000,
5✔
123
            'EncodingType' => 'url',
5✔
124
            'Delimiter' => '/', ];
5✔
125
        $results = $files = [];
5✔
126
        $s3 = $this->create_s3();
5✔
127
        try {
128
            $results = $s3->listObjectsV2($options);
5✔
129
        } catch (\Exception $e) {
5✔
130
            throw new \moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
5✔
131
        }
132

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

144
        $filesearch = 'Contents[?StorageClass != \'DEEP_ARCHIVE\'';
5✔
145
        $filesearch .= ' && StorageClass != \'GLACIER\'';
5✔
146
        $filesearch .= ' && contains(Key, \'' . $q . '\')]';
5✔
147
        $filesearch .= '.{Key: Key, Size: Size, LastModified: LastModified}';
5✔
148
        foreach ($results->search($filesearch) as $item) {
5✔
149
            $pathinfo = pathinfo($item['Key']);
5✔
150
            $files[] = [
5✔
151
               'title' => $pathinfo['basename'],
5✔
152
               'size' => $item['Size'],
5✔
153
               'path' => $item['Key'],
5✔
154
               'datemodified' => date_timestamp_get($item['LastModified']),
5✔
155
               'thumbnail_height' => 64,
5✔
156
               'thumbnail_width' => 64,
5✔
157
               'source' => $item['Key'],
5✔
158
               'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(false),
5✔
159
            ];
5✔
160
        }
161
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
5✔
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');
5✔
175
        $this->send_otherfile($storedfile->get_reference(), "+$duration minutes");
5✔
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 != '') {
5✔
186
            $s3 = $this->create_s3();
×
187
            $options = [
×
188
               'Bucket' => $this->get_option('bucket_name'),
×
189
               'Key' => $reference,
×
190
               'ResponseContentDisposition' => 'attachment', ];
×
191
            try {
192
                $result = $s3->getCommand('GetObject', $options);
×
193
                $req = $s3->createPresignedRequest($result, $lifetime);
×
194
            } catch (\Exception $e) {
×
195
                throw new \moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
×
196
            }
197
            $uri = $req->getUri()->__toString();
×
198
            $mimetype = get_mimetype_description(['filename' => $reference]);
×
199
            header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
×
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');
5✔
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;
5✔
217
        $path = pathinfo($url);
5✔
218
        $file = $path['basename'];
5✔
219
        $directory = $path['dirname'];
5✔
220
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
5✔
221
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
5✔
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) {
5✔
232
            throw new \repository_exception('cannotdownload', 'repository');
5✔
233
        }
234
        if ($filestatus == 666) {
5✔
235
            $reference = '';
5✔
236
        }
237
        return $this->get_file_source_info($reference);
5✔
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);
5✔
251
        $s3 = $this->create_s3();
5✔
252
        $options = [
5✔
253
           'Bucket' => $this->get_option('bucket_name'),
5✔
254
           'Key' => $filepath,
5✔
255
           'SaveAs' => $path, ];
5✔
256
        try {
257
            $s3->getObject($options);
5✔
258
        } catch (\Exception $e) {
5✔
259
            throw new \moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $e->getMessage());
5✔
260
        }
261
        return ['path' => $path, 'url' => $filepath];
5✔
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 == '') {
5✔
272
            return get_string('unknownsource', 'repository');
5✔
273
        }
274
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
5✔
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'];
94✔
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;
5✔
294
        $choices = ['1' => 1, '2' => 2, '10' => 10, '15' => 15, '30' => 30, '60' => 60];
5✔
295
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices, $duration);
5✔
296
        $mform->setType('duration', PARAM_INT);
5✔
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'];
94✔
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);
20✔
317
        $strrequired = get_string('required');
20✔
318
        $textops = ['maxlength' => 255, 'size' => 50];
20✔
319
        $endpointselect = [];
20✔
320
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
20✔
321
        $endpoints = $all['partitions'][0]['regions'];
20✔
322
        foreach ($endpoints as $key => $value) {
20✔
323
            $endpointselect[$key] = $value['description'];
20✔
324
        }
325

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

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

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

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

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

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

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

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

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

443

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