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

ewallah / moodle-repository_s3bucket / 21594565921

02 Feb 2026 02:44PM UTC coverage: 99.495% (-0.5%) from 100.0%
21594565921

push

github

rdebleu
tests

197 of 198 relevant lines covered (99.49%)

3.11 hits per line

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

99.49
/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
    #[\Override]
45
    public function get_listing($path = '.', $page = 1) {
46
        global $OUTPUT;
47
        $diricon = $OUTPUT->image_url(file_folder_icon())->out();
3✔
48
        $bucket = $this->get_option('bucket_name');
3✔
49
        $place = [['name' => $bucket, 'path' => $path]];
3✔
50
        $epath = ($path === '') ? '.' : $path . '/';
3✔
51
        $options = [
3✔
52
            'Bucket' => $bucket,
3✔
53
            'Prefix' => $path,
3✔
54
            'EncodingType' => 'url',
3✔
55
            'Delimiter' => '/', ];
3✔
56
        $results = [];
3✔
57
        $files = [];
3✔
58
        $s3 = $this->create_s3();
3✔
59
        try {
60
            $results = $s3->listObjectsV2($options);
2✔
61
            // @codeCoverageIgnoreStart
62
        } catch (\Exception $exception) {
63
            $this->throw_error($exception->getMessage());
64
            // @codeCoverageIgnoreEnd
65
        }
66

67
        $items = $results->search('CommonPrefixes[].{Prefix: Prefix}');
2✔
68
        if ($items) {
2✔
69
            foreach ($items as $item) {
1✔
70
                $files[] = [
1✔
71
                      'title' => basename($item['Prefix']),
1✔
72
                      'children' => [],
1✔
73
                      'thumbnail' => $diricon,
1✔
74
                      'thumbnail_height' => 64,
1✔
75
                      'thumbnail_width' => 64,
1✔
76
                      'path' => $item['Prefix'], ];
1✔
77
            }
78
        }
79
        $items = $results->search($this->filesearch(''));
2✔
80
        if ($items) {
2✔
81
            foreach ($items as $item) {
1✔
82
                $pathinfo = pathinfo($item['Key']);
1✔
83
                if ($pathinfo['dirname'] == $epath || $pathinfo['dirname'] . '//' === $epath) {
1✔
84
                    $files[] = [
1✔
85
                       'title' => $pathinfo['basename'],
1✔
86
                       'size' => $item['Size'],
1✔
87
                       'path' => $item['Key'],
1✔
88
                       'datemodified' => date_timestamp_get($item['LastModified']),
1✔
89
                       'thumbnail_height' => 64,
1✔
90
                       'thumbnail_width' => 64,
1✔
91
                       'source' => $item['Key'],
1✔
92
                       'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(), ];
1✔
93
                }
94
            }
95
        }
96

97
        return [
2✔
98
           'list' => $files,
2✔
99
           'path' => $place,
2✔
100
           'manage' => false,
2✔
101
           'dynload' => true,
2✔
102
           'nologin' => true,
2✔
103
           'nosearch' => false, ];
2✔
104
    }
105

106
    #[\Override]
107
    public function search($q, $page = 1) {
108
        global $OUTPUT;
109
        $diricon = $OUTPUT->image_url(file_folder_icon())->out();
2✔
110
        $bucket = $this->get_option('bucket_name');
2✔
111
        $options = [
2✔
112
            'Bucket' => $bucket,
2✔
113
            'EncodingType' => 'url',
2✔
114
            'Delimiter' => '/', ];
2✔
115
        $results = [];
2✔
116
        $files = [];
2✔
117
        $s3 = $this->create_s3();
2✔
118
        try {
119
            $results = $s3->listObjectsV2($options);
2✔
120
            // @codeCoverageIgnoreStart
121
        } catch (\Exception $exception) {
122
            $this->throw_error($exception->getMessage());
123
            // @codeCoverageIgnoreEnd
124
        }
125

126
        $dirsearch = sprintf("CommonPrefixes[?contains(Prefix, '%s')].{Prefix: Prefix}", $q);
2✔
127
        $items = $results->search($dirsearch);
2✔
128
        if ($items) {
2✔
129
            foreach ($items as $item) {
1✔
130
                $files[] = [
1✔
131
                    'title' => basename($item['Prefix']),
1✔
132
                    'children' => [],
1✔
133
                    'thumbnail' => $diricon,
1✔
134
                    'thumbnail_height' => 64,
1✔
135
                    'thumbnail_width' => 64,
1✔
136
                    'path' => $item['Prefix'],
1✔
137
                ];
1✔
138
            }
139
        }
140

141
        $items = $results->search($this->filesearch($q));
2✔
142
        if ($items) {
2✔
143
            foreach ($items as $item) {
2✔
144
                $pathinfo = pathinfo($item['Key']);
2✔
145
                $files[] = [
2✔
146
                   'title' => $pathinfo['basename'],
2✔
147
                   'size' => $item['Size'],
2✔
148
                   'path' => $item['Key'],
2✔
149
                   'datemodified' => date_timestamp_get($item['LastModified']),
2✔
150
                   'thumbnail_height' => 64,
2✔
151
                   'thumbnail_width' => 64,
2✔
152
                   'source' => $item['Key'],
2✔
153
                   'thumbnail' => $OUTPUT->image_url(file_extension_icon($pathinfo['basename']))->out(),
2✔
154
                ];
2✔
155
            }
156
        }
157

158
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
2✔
159
    }
160

161
    /**
162
     * Repository method to serve the out file
163
     *
164
     * @param string $search The text to search for
165
     * @return string The code we use to search for files
166
     */
167
    private function filesearch(string $search): string {
168
        $s = "Contents[";
4✔
169
        $s .= "?StorageClass != 'DEEP_ARCHIVE'";
4✔
170
        $s .= " && StorageClass != 'GLACIER' ";
4✔
171
        $s .= " && contains(Key, '" . $search . "')]";
4✔
172
        return $s . ".{Key: Key, Size: Size, LastModified: LastModified}";
4✔
173
    }
174

175
    #[\Override]
176
    public function send_file($storedfile, $lifetime = null, $filter = 0, $forcedownload = true, ?array $options = null): void {
177
        $duration = get_config('s3bucket', 'duration');
1✔
178
        $this->send_otherfile($storedfile->get_reference(), sprintf('+%s minutes', $duration));
1✔
179
    }
180

181
    /**
182
     * Repository method to serve the out file
183
     *
184
     * @param string $reference the filereference
185
     * @param string $lifetime Number of seconds before the file should expire from caches
186
     */
187
    public function send_otherfile($reference, $lifetime): void {
188
        if ($reference != '') {
2✔
189
            $s3 = $this->create_s3();
1✔
190
            $options = [
1✔
191
               'Bucket' => $this->get_option('bucket_name'),
1✔
192
               'Key' => $reference,
1✔
193
               'ResponseContentDisposition' => 'attachment', ];
1✔
194
            try {
195
                $result = $s3->getCommand('GetObject', $options);
1✔
196
                $req = $s3->createPresignedRequest($result, $lifetime);
1✔
197
                // @codeCoverageIgnoreStart
198
            } catch (\Exception $e) {
199
                $this->throw_error($e->getMessage());
200
                // @codeCoverageIgnoreEnd
201
            }
202

203
            $headers = [];
1✔
204
            $headers[] = 'Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0';
1✔
205
            $headers[] = 'Pragma: no-cache';
1✔
206
            $headers[] = 'Content-Type: ' . get_mimetype_description(['filename' => $reference]);
1✔
207
            $headers[] = sprintf('Content-Disposition: attachment; filename="%s"', $reference);
1✔
208
            $headers[] = 'Location: ' . $req->getUri()->__toString();
1✔
209

210
            foreach ($headers as $header) {
1✔
211
                if (PHPUNIT_TEST) {
1✔
212
                    echo $header;
1✔
213
                    // @codeCoverageIgnoreStart
214
                } else {
215
                    header($header);
216
                    // @codeCoverageIgnoreEnd
217
                }
218
            }
219
            if (PHPUNIT_TEST) {
1✔
220
                return;
1✔
221
                // @codeCoverageIgnoreStart
222
            } else {
223
                die;
224
                // @codeCoverageIgnoreEnd
225
            }
226
        }
227
        throw new \repository_exception('cannotdownload', 'repository');
1✔
228
    }
229

230
    /**
231
     * This method throws a repository exception.
232
     *
233
     * @param string $message Optional message
234
     */
235
    private function throw_error(string $message = ''): void {
236
        throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $message);
1✔
237
    }
238

239
    #[\Override]
240
    public function get_link($url) {
241
        $cid = $this->context->id;
2✔
242
        $path = pathinfo($url);
2✔
243
        $file = $path['basename'];
2✔
244
        $directory = $path['dirname'];
2✔
245
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
2✔
246

247
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
2✔
248
    }
249

250
    #[\Override]
251
    public function get_reference_details($reference, $filestatus = 0) {
252
        if ($this->disabled) {
2✔
253
            throw new \repository_exception('cannotdownload', 'repository');
1✔
254
        }
255

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

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

263
    #[\Override]
264
    public function get_file($filepath, $file = '') {
265
        $path = $this->prepare_file($file);
2✔
266
        $s3 = $this->create_s3();
2✔
267
        $options = [
2✔
268
           'Bucket' => $this->get_option('bucket_name'),
2✔
269
           'Key' => $filepath,
2✔
270
           'SaveAs' => $path, ];
2✔
271
        try {
272
            $s3->getObject($options);
2✔
273
        } catch (\Exception $exception) {
1✔
274
            // @codeCoverageIgnoreStart
275
            $this->throw_error($exception->getMessage());
276
            // @codeCoverageIgnoreEnd
277
        }
278

279
        return ['path' => $path, 'url' => $filepath];
2✔
280
    }
281

282
    #[\Override]
283
    public function get_file_source_info($filepath) {
284
        if (empty($filepath)) {
2✔
285
            return get_string('unknownsource', 'repository');
1✔
286
        }
287

288
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
2✔
289
    }
290

291
    #[\Override]
292
    public static function get_type_option_names() {
293
        return ['duration'];
21✔
294
    }
295

296
    #[\Override]
297
    public static function type_config_form($mform, $classname = 'repository'): void {
298
        $duration = get_config('s3bucket', 'duration') ?? '2';
1✔
299
        $duration = intval($duration);
1✔
300

301
        $choices = [1 => 1, 2 => 2, 3 => 10, 4 => 15, 5 => 30, 6 => 60];
1✔
302
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices);
1✔
303
        $mform->setType('duration', PARAM_INT);
1✔
304
        $mform->setDefault('duration', $duration);
1✔
305
    }
306

307
    #[\Override]
308
    public static function get_instance_option_names() {
309
        return ['access_key', 'secret_key', 'endpoint', 'bucket_name'];
21✔
310
    }
311

312
    #[\Override]
313
    public static function instance_config_form($mform): void {
314
        global $CFG;
315
        parent::instance_config_form($mform);
4✔
316
        $strrequired = get_string('required');
4✔
317
        $textops = ['maxlength' => 255, 'size' => 50];
4✔
318
        $endpointselect = [];
4✔
319
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
4✔
320
        $endpoints = $all['partitions'][0]['regions'];
4✔
321
        foreach ($endpoints as $key => $value) {
4✔
322
            $endpointselect[$key] = $value['description'];
4✔
323
        }
324

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

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

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

341
    #[\Override]
342
    public static function instance_form_validation($mform, $data, $errors) {
343
        if (!isset($data['access_key'])) {
4✔
344
            $errors[] = get_string('missingparam', 'error', get_string('access_key', 'repository_s3'));
1✔
345
        }
346

347
        if (!isset($data['secret_key'])) {
4✔
348
            $errors[] = get_string('missingparam', 'error', get_string('secret_key', 'repository_s3'));
2✔
349
        }
350

351
        if (!isset($data['bucket_name'])) {
4✔
352
            $errors[] = get_string('missingparam', 'error', get_string('bucketname', 'repository_s3bucket'));
2✔
353
        }
354

355
        if (!isset($data['endpoint'])) {
4✔
356
            $errors[] = get_string('missingparam', 'error', get_string('endpoint', 'repository_s3'));
1✔
357
        }
358

359
        // TODO: Check if the bucket exists.
360
        return $errors;
4✔
361
    }
362

363
    #[\Override]
364
    public function default_returntype() {
365
        return FILE_REFERENCE;
1✔
366
    }
367

368
    #[\Override]
369
    public function supported_returntypes() {
370
        return FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL;
19✔
371
    }
372

373
    /**
374
     * Get S3
375
     *
376
     * @return s3
377
     */
378
    private function create_s3() {
379
        if ($this->s3client == null) {
7✔
380
            $accesskey = $this->get_option('access_key');
7✔
381
            if (empty($accesskey)) {
7✔
382
                throw new moodle_exception('needaccesskey', 'repository_s3');
1✔
383
            }
384

385
            $arr = $this->addproxy([
6✔
386
                'credentials' => ['key' => $accesskey, 'secret' => $this->get_option('secret_key')],
6✔
387
                'use_path_style_endpoint' => true,
6✔
388
                'region' => $this->get_option('endpoint'), ]);
6✔
389
            $this->s3client = \Aws\S3\S3Client::factory($arr);
6✔
390
        }
391

392
        return $this->s3client;
6✔
393
    }
394

395
    /**
396
     * Add proxy
397
     *
398
     * @param array $settings Settings
399
     * @return array Array of settings
400
     */
401
    private function addproxy(array $settings): array {
402
        $settings['version'] = 'latest';
6✔
403
        $settings['signature_version'] = 'v4';
6✔
404

405
        $region = $settings['region'] ?? 'us-east-1';
6✔
406
        if (str_starts_with(strtolower($region), 'http')) {
6✔
407
            $settings['endpoint'] = $region;
5✔
408
            $settings['region'] = 'us-east-1';
5✔
409
        }
410

411
        return $settings;
6✔
412
    }
413

414
    #[\Override]
415
    public function contains_private_data() {
416
        return ($this->context->contextlevel === CONTEXT_USER);
1✔
417
    }
418

419
    /**
420
     * Do we have localstack available?
421
     *
422
     * @param string $url Url to be checked.
423
     * @return bool True if no localstack installed.
424
     */
425
    public static function no_localstack(string $url = 'http://localhost:4566/testbucket/testfile.jpg'): bool {
426
        $ch = curl_init($url);
16✔
427
        curl_setopt($ch, CURLOPT_NOBODY, true);
16✔
428
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
16✔
429
        curl_exec($ch);
16✔
430
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
16✔
431
        curl_close($ch);
16✔
432
        return $code != 200;
16✔
433
    }
434
}
435

436
// @codeCoverageIgnoreStart
437
/**
438
 * Serve the files from the repository_s3bucket file areas
439
 *
440
 * @param stdClass $course the course object
441
 * @param stdClass $cm the course module object
442
 * @param context $context the context
443
 * @param string $filearea the name of the file area
444
 * @param array $args extra arguments (itemid, path)
445
 * @param bool $forcedownload whether or not force download
446
 * @param array $options additional options affecting the file serving
447
 * @return bool false if the file not found, just send the file otherwise and do not return
448
 */
449
function repository_s3bucket_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, array $options = []): bool {
450
    $handled = false;
451
    if ($filearea == 's3') {
452
        if ($context->contextlevel === CONTEXT_SYSTEM) {
453
            $handled = has_capability('moodle/course:view', $context);
454
        } else if ($context->contextlevel === CONTEXT_COURSE) {
455
            $handled = $course && has_capability('moodle/course:view', $context);
456
        } else if ($cm && has_capability('mod/' . $cm->modname . ':view', $context)) {
457
            $modinfo = get_fast_modinfo($course);
458
            $cmi = $modinfo->cms[$cm->id];
459
            $handled = ($cmi->uservisible && $cmi->is_visible_on_course_page());
460
        }
461
    }
462

463
    if ($handled) {
464
        $duration = get_config('s3bucket', 'duration');
465
        $itemid = array_shift($args);
466
        $reference = implode('/', $args);
467
        $repo = repository::get_repository_by_id($itemid, $context);
468
        $repo->send_otherfile($reference, sprintf('+%s minutes', $duration));
469
    }
470

471
    return false;
472
}
473

474
// @codeCoverageIgnoreEnd
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

© 2026 Coveralls, Inc