. /** * Meeting content download helpers (Adobe Connect proxy). * * @package mod_meetletacbmarketplace * @copyright 2026 MeetLet * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace mod_meetletacbmarketplace\local; use mod_meetletacbmarketplace\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/meetletacbmarketplace/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', 'meetletacbmarketplace'); } $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', 'meetletacbmarketplace'); } $mac = substr($raw, 0, 32); $payload = substr($raw, 32); if (strlen($payload) < 32) { throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace'); } $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', 'meetletacbmarketplace-content-v1|' . $userid . ':' . $cmid . ':' . $cid . ':' . $expires . '|' . $nonce, self::download_secret(), true ); if (!hash_equals($expected, $mac)) { throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace'); } if ($expires < time() || $userid <= 0 || $cmid <= 0 || $cid <= 0) { throw new \moodle_exception('errorbadcontenttoken', 'meetletacbmarketplace'); } 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', 'meetletacbmarketplace-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_meetletacbmarketplace/content/' . get_site_identifier(); } }