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

ewallah / moodle-repository_s3bucket / 21485657462

29 Jan 2026 04:12PM UTC coverage: 16.667% (-66.6%) from 83.26%
21485657462

push

github

rdebleu
Version update

31 of 186 relevant lines covered (16.67%)

1.56 hits per line

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

16.22
/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
// @codeCoverageIgnoreStart
28
defined('MOODLE_INTERNAL') || die;
29

30
global $CFG;
31
require_once($CFG->dirroot . '/repository/lib.php');
32
// @codeCoverageIgnoreEnd
33

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

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

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

99
        return [
×
100
           'list' => $files,
×
101
           'path' => $place,
×
102
           'manage' => false,
×
103
           'dynload' => true,
×
104
           'nologin' => true,
×
105
           'nosearch' => false, ];
×
106
    }
107

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

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

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

160
        return ['list' => $files, 'dynload' => true, 'pages' => 0, 'page' => $page];
×
161
    }
162

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

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

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

205
            $uri = $req->getUri()->__toString();
×
206
            if (!PHPUNIT_TEST) {
×
207
                // @codeCoverageIgnoreStart
208
                header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
209
                header('Pragma: no-cache');
210
                header('Content-Type: ' . get_mimetype_description(['filename' => $reference]));
211
                header(sprintf('Content-Disposition: attachment; filename="%s"', $reference));
212
                header('Location: ' . $uri);
213
                die;
214
                // @codeCoverageIgnoreEnd
215
            }
216
        }
217

218
        throw new \repository_exception('cannotdownload', 'repository');
×
219
    }
220

221
    /**
222
     * This method throws a repository exception.
223
     *
224
     * @param string $message Optional message
225
     */
226
    private function throw_error(string $message = ''): void {
227
        throw new moodle_exception('errorwhilecommunicatingwith', 'repository', '', $this->get_name(), $message);
5✔
228
    }
229

230
    #[\Override]
231
    public function get_link($url) {
232
        $cid = $this->context->id;
×
233
        $path = pathinfo($url);
×
234
        $file = $path['basename'];
×
235
        $directory = $path['dirname'];
×
236
        $directory = $directory == '.' ? '/' : '/' . $directory . '/';
×
237

238
        return \moodle_url::make_pluginfile_url($cid, 'repository_s3bucket', 's3', $this->id, $directory, $file)->out();
×
239
    }
240

241
    #[\Override]
242
    public function get_reference_details($reference, $filestatus = 0) {
243
        if ($this->disabled) {
×
244
            throw new \repository_exception('cannotdownload', 'repository');
×
245
        }
246

247
        if ($filestatus == 666) {
×
248
            $reference = '';
×
249
        }
250

251
        return $this->get_file_source_info($reference);
×
252
    }
253

254
    #[\Override]
255
    public function get_file($filepath, $file = '') {
256
        $path = $this->prepare_file($file);
×
257
        $s3 = $this->create_s3();
×
258
        $options = [
×
259
           'Bucket' => $this->get_option('bucket_name'),
×
260
           'Key' => $filepath,
×
261
           'SaveAs' => $path, ];
×
262
        try {
263
            $s3->getObject($options);
×
264
        } catch (\Exception $exception) {
×
265
            // @codeCoverageIgnoreStart
266
            $this->throw_error($exception->getMessage());
267
            // @codeCoverageIgnoreEnd
268
        }
269

270
        return ['path' => $path, 'url' => $filepath];
×
271
    }
272

273
    #[\Override]
274
    public function get_file_source_info($filepath) {
275
        if (empty($filepath)) {
×
276
            return get_string('unknownsource', 'repository');
×
277
        }
278

279
        return $this->get_short_filename('s3://' . $this->get_option('bucket_name') . '/' . $filepath, 50);
×
280
    }
281

282
    #[\Override]
283
    public static function get_type_option_names() {
284
        return ['duration'];
30✔
285
    }
286

287
    #[\Override]
288
    public static function type_config_form($mform, $classname = 'repository'): void {
289
        $duration = get_config('s3bucket', 'duration') ?? '2';
×
290
        $duration = intval($duration);
×
291

292
        $choices = [1 => 1, 2 => 2, 3 => 10, 4 => 15, 5 => 30, 6 => 60];
×
293
        $mform->addElement('select', 'duration', get_string('duration', $classname), $choices);
×
294
        $mform->setType('duration', PARAM_INT);
×
295
        $mform->setDefault('duration', $duration);
×
296
    }
297

298
    #[\Override]
299
    public static function get_instance_option_names() {
300
        return ['access_key', 'secret_key', 'endpoint', 'bucket_name'];
30✔
301
    }
302

303
    #[\Override]
304
    public static function instance_config_form($mform): void {
305
        global $CFG;
306
        parent::instance_config_form($mform);
×
307
        $strrequired = get_string('required');
×
308
        $textops = ['maxlength' => 255, 'size' => 50];
×
309
        $endpointselect = [];
×
310
        $all = require($CFG->libdir . '/aws-sdk/src/data/endpoints.json.php');
×
311
        $endpoints = $all['partitions'][0]['regions'];
×
312
        foreach ($endpoints as $key => $value) {
×
313
            $endpointselect[$key] = $value['description'];
×
314
        }
315

316
        $mform->addElement('passwordunmask', 'access_key', get_string('access_key', 'repository_s3'), $textops);
×
317
        $mform->setType('access_key', PARAM_RAW_TRIMMED);
×
318
        $mform->addElement('passwordunmask', 'secret_key', get_string('secret_key', 'repository_s3'), $textops);
×
319
        $mform->setType('secret_key', PARAM_RAW_TRIMMED);
×
320
        $mform->addElement('text', 'bucket_name', get_string('bucketname', 'repository_s3bucket'), $textops);
×
321
        $mform->setType('bucket_name', PARAM_RAW_TRIMMED);
×
322

323
        $boptions = ['placeholder' => 'us-east-1', 'tags' => true];
×
324
        $mform->addElement('autocomplete', 'endpoint', get_string('endpoint', 'repository_s3'), $endpointselect, $boptions);
×
325
        $mform->setDefault('endpoint', 'us-east-1');
×
326

327
        $mform->addRule('access_key', $strrequired, 'required', null, 'client');
×
328
        $mform->addRule('secret_key', $strrequired, 'required', null, 'client');
×
329
        $mform->addRule('bucket_name', $strrequired, 'required', null, 'client');
×
330
    }
331

332
    #[\Override]
333
    public static function instance_form_validation($mform, $data, $errors) {
334
        if (!isset($data['access_key'])) {
×
335
            $errors[] = get_string('missingparam', 'error', get_string('access_key', 'repository_s3'));
×
336
        }
337

338
        if (!isset($data['secret_key'])) {
×
339
            $errors[] = get_string('missingparam', 'error', get_string('secret_key', 'repository_s3'));
×
340
        }
341

342
        if (!isset($data['bucket_name'])) {
×
343
            $errors[] = get_string('missingparam', 'error', get_string('bucketname', 'repository_s3bucket'));
×
344
        }
345

346
        if (!isset($data['endpoint'])) {
×
347
            $errors[] = get_string('missingparam', 'error', get_string('endpoint', 'repository_s3'));
×
348
        }
349

350
        // TODO: Check if the bucket exists.
351
        return $errors;
×
352
    }
353

354
    #[\Override]
355
    public function default_returntype() {
356
        return FILE_REFERENCE;
×
357
    }
358

359
    #[\Override]
360
    public function supported_returntypes() {
361
        return FILE_INTERNAL | FILE_REFERENCE | FILE_EXTERNAL;
20✔
362
    }
363

364
    /**
365
     * Get S3
366
     *
367
     * @return s3
368
     */
369
    private function create_s3() {
370
        if ($this->s3client == null) {
10✔
371
            $accesskey = $this->get_option('access_key');
10✔
372
            if (empty($accesskey)) {
10✔
373
                throw new moodle_exception('needaccesskey', 'repository_s3');
×
374
            }
375

376
            $arr = $this->addproxy([
10✔
377
                'credentials' => ['key' => $accesskey, 'secret' => $this->get_option('secret_key')],
10✔
378
                'use_path_style_endpoint' => true,
10✔
379
                'region' => $this->get_option('endpoint'), ]);
10✔
380
            $this->s3client = \Aws\S3\S3Client::factory($arr);
10✔
381
        }
382

383
        return $this->s3client;
10✔
384
    }
385

386
    /**
387
     * Add proxy
388
     *
389
     * @param array $settings Settings
390
     * @return array Array of settings
391
     */
392
    private function addproxy(array $settings): array {
393
        $settings['version'] = 'latest';
10✔
394
        $settings['signature_version'] = 'v4';
10✔
395

396
        $region = $settings['region'] ?? 'us-east-1';
10✔
397
        if (str_starts_with(strtolower($region), 'http')) {
10✔
398
            $settings['endpoint'] = $region;
5✔
399
            $settings['region'] = 'us-east-1';
5✔
400
        }
401

402
        return $settings;
10✔
403
    }
404

405
    #[\Override]
406
    public function contains_private_data() {
407
        return ($this->context->contextlevel === CONTEXT_USER);
×
408
    }
409

410
    /**
411
     * Do we have localstack available?
412
     *
413
     * @return bool True if no localstack installed.
414
     */
415
    public static function no_localstack(): bool {
416
        $curl = new \curl();
×
417
        $curl->head('http://localhost:4566/testbucket/testfile.jpg');
×
418
        $info = $curl->get_info();
×
419
        $installed = !empty($info['http_code']) && $info['http_code'] == 200;
×
420
        return !$installed;
×
421
    }
422
}
423

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

451
    if ($handled) {
452
        $duration = get_config('s3bucket', 'duration');
453
        $itemid = array_shift($args);
454
        $reference = implode('/', $args);
455
        $repo = repository::get_repository_by_id($itemid, $context);
456
        $repo->send_otherfile($reference, sprintf('+%s minutes', $duration));
457
    }
458

459
    return false;
460
}
461

462
// @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