Files
meetletacbfree/classes/local/content.php
T

197 lines
7.1 KiB
PHP

<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Meeting content download helpers (Adobe Connect proxy).
*
* @package mod_meetletacbfree
* @copyright 2026 MeetLet <info@meetlet.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace mod_meetletacbfree\local;
use mod_meetletacbfree\api\service;
use stdClass;
defined('MOODLE_INTERNAL') || die();
/**
* Authenticated Moodle proxy for uploaded meeting content files.
*/
class content {
/**
* Opaque, HMAC-signed, current-user download URL.
*/
public static function download_url(int $userid, int $cmid, int $scoid): \moodle_url {
return new \moodle_url('/mod/meetletacbfree/download_content.php', [
'k' => self::make_download_token($userid, $cmid, $scoid),
]);
}
/**
* Unpack and verify a content download token.
*
* @return array{userid:int,cmid:int,cid:int}
*/
public static function unpack_download_token(string $token): array {
$token = trim($token);
if (!preg_match('/^[A-Za-z0-9_-]+$/', $token)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree');
}
$b64 = strtr($token, '-_', '+/');
$pad = strlen($b64) % 4;
if ($pad) {
$b64 .= str_repeat('=', 4 - $pad);
}
$raw = base64_decode($b64, true);
if ($raw === false || strlen($raw) < 44) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree');
}
$mac = substr($raw, 0, 32);
$payload = substr($raw, 32);
if (strlen($payload) < 32) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree');
}
$ids = unpack('Nuid/Ncmid/Ncid/Nexp', substr($payload, 0, 16));
$nonce = substr($payload, 16, 16);
$userid = (int)$ids['uid'];
$cmid = (int)$ids['cmid'];
$cid = (int)$ids['cid'];
$expires = (int)$ids['exp'];
$expected = hash_hmac(
'sha256',
'meetletacbfree-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
if (!hash_equals($expected, $mac)) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree');
}
if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) {
throw new \moodle_exception('errorbadcontenttoken', 'meetletacbfree');
}
return ['userid' => $userid, 'cmid' => $cmid, 'cid' => $cid];
}
/**
* Find a content item listed under a meeting folder.
*/
public static function find_in_meeting(service $service, int $meetingscoid, int $scoid): ?stdClass {
foreach ($service->list_meeting_content($meetingscoid) as $item) {
if ((int)($item->scoid ?? 0) === $scoid) {
return $item;
}
}
return null;
}
/**
* Adobe stores uploaded binaries under {url-path}/output/{filename}.
*/
public static function output_file_url(string $urlpath, string $filename): string {
$filename = basename(str_replace('\\', '/', $filename));
if ($filename === '' || strpos($filename, '/') !== false) {
throw new \moodle_exception('filenotfound', 'error');
}
$base = '/' . trim($urlpath, '/');
if ($base === '/') {
throw new \moodle_exception('filenotfound', 'error');
}
return config::public_url($base . '/output/' . rawurlencode($filename));
}
/**
* Stream a meeting content file to the browser via user SSO.
*/
public static function serve_download(instance $inst, int $scoid): void {
global $USER;
$service = new service();
$service->login_admin();
$item = self::find_in_meeting($service, $inst->require_meeting(), $scoid);
$service->logout();
if (!$item || empty($item->name) || empty($item->urlpath)) {
throw new \moodle_exception('filenotfound', 'error');
}
$adoberole = roles::resolve_adobe_role($inst->record, $inst->context, (int)$USER->id);
$permission = $adoberole ?: service::ROLE_PARTICIPANT;
$auth = sso::provision_and_login($USER, $scoid, $permission);
$fileurl = self::output_file_url((string)$item->urlpath, (string)$item->name);
$filename = clean_filename((string)$item->name);
if ($filename === '') {
$filename = 'content_' . $scoid;
}
$tmpdir = make_request_directory();
$tmp = $tmpdir . '/content_' . $scoid . '_' . $filename;
self::fetch_to_path($fileurl, $auth['cookie'], $tmp);
\core\session\manager::write_close();
send_temp_file($tmp, $filename);
}
/**
* Download remote content bytes using a BREEZESESSION cookie.
*/
protected static function fetch_to_path(string $url, string $session, string $dest): void {
$ch = curl_init($url);
$fp = fopen($dest, 'wb');
if ($fp === false) {
curl_close($ch);
throw new \moodle_exception('filenotfound', 'error');
}
curl_setopt_array($ch, [
CURLOPT_FILE => $fp,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 300,
CURLOPT_COOKIE => $session !== '' ? ('BREEZESESSION=' . $session) : '',
]);
$ok = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$size = (int)curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
curl_close($ch);
fclose($fp);
if (!$ok || $code < 200 || $code >= 400 || $size <= 0 || !is_file($dest) || filesize($dest) <= 0) {
@unlink($dest);
throw new \moodle_exception('filenotfound', 'error');
}
}
protected static function make_download_token(int $userid, int $cmid, int $scoid): string {
$expires = time() + DAYSECS * 7;
$nonce = random_bytes(16);
$payload = pack('N4', $userid, $cmid, $scoid, $expires) . $nonce;
$mac = hash_hmac(
'sha256',
'meetletacbfree-content-v1|' . $userid . ':' . $cmid . ':' . $scoid . ':' . $expires . '|' . $nonce,
self::download_secret(),
true
);
return rtrim(strtr(base64_encode($mac . $payload), '+/', '-_'), '=');
}
protected static function download_secret(): string {
return 'mod_meetletacbfree/content/' . get_site_identifier();
}
}