diff --git a/category.php b/category.php index 2496aea5..b62e0b8f 100644 --- a/category.php +++ b/category.php @@ -163,7 +163,7 @@ public function definition() { $id = optional_param('id', 0, PARAM_INT); $category = $DB->get_record_sql(' - SELECT c.id, c.name, c.pid, c.internshare, c.shareall, c.iconmerge + SELECT c.id, c.name, c.pid, c.internshare, c.shareall, c.iconmerge, c.externaccess, c.hash FROM {block_exaportcate} c WHERE c.userid = ? AND id = ? ', array($USER->id, $id)); @@ -172,6 +172,8 @@ public function definition() { $category->shareall = 0; $category->id = 0; $category->iconmerge = 0; + $category->externaccess = 0; + $category->hash = ''; }; // Don't forget the underscore! @@ -260,6 +262,27 @@ public function definition() { $mform->addElement('html', ''); $mform->addElement('html', '' . '
grouplist
'); + + // External access (read-only link for non-logged-in users) — inside sharing submenu. + if (block_exaport_externaccess_enabled() + && has_capability('block/exaport:shareextern', context_system::instance())) { + $mform->addElement('html', '
'); + $mform->addElement('html', ''); + $mform->addElement('html', 'externaccess ? ' checked="checked"' : '') . '/>'); + $mform->addElement('html', '' . get_string('externalaccess', 'block_exaport') . ''); + + // Show the external link if already enabled and category exists. + if ($category->id > 0 && $category->externaccess && $category->hash) { + $url = block_exaport_get_external_category_url($category, $USER->id); + $mform->addElement('html', '' . + '
' . s($url) . '
' . + '
' . + get_string('externaccess_category_readonly', 'block_exaport') . + '
'); + } + } + $mform->addElement('html', ''); $mform->addElement('html', ''); }; @@ -292,6 +315,24 @@ public function validation($data, $files) { $newentry->internshare = 0; } + // Handle external access. + if (block_exaport_externaccess_enabled() + && has_capability('block/exaport:shareextern', context_system::instance())) { + $newentry->externaccess = optional_param('externaccess', 0, PARAM_INT) ? 1 : 0; + } else { + $newentry->externaccess = 0; + } + + // Generate hash for category if not yet set. + if ($newentry->externaccess) { + $existingcat = $newentry->id ? $DB->get_record('block_exaportcate', ['id' => $newentry->id], 'hash') : null; + if (!$existingcat || empty($existingcat->hash)) { + do { + $newentry->hash = md5(random_bytes(16)); + } while ($DB->record_exists("block_exaportcate", array("hash" => $newentry->hash))); + } + } + if ($newentry->id) { // keep creatorid as is.. not "updatedby" but "CREATORid" so keep it $DB->update_record("block_exaportcate", $newentry); @@ -430,7 +471,7 @@ public function validation($data, $files) { $category = null; if ($id = optional_param('id', 0, PARAM_INT)) { $category = $DB->get_record_sql(' - SELECT c.id, c.name, c.pid, c.internshare, c.shareall, c.iconmerge + SELECT c.id, c.name, c.pid, c.internshare, c.shareall, c.iconmerge, c.externaccess, c.hash FROM {block_exaportcate} c WHERE c.userid = ? AND id = ? ', array($USER->id, $id)); diff --git a/db/install.xml b/db/install.xml index 52239524..290296b5 100644 --- a/db/install.xml +++ b/db/install.xml @@ -48,6 +48,8 @@ + + @@ -62,6 +64,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index 98003321..1ee81761 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -1326,5 +1326,31 @@ function block_exaport_wrong_personal_information_upgrade_2012120301($matches) { upgrade_block_savepoint(true, 2026022404, 'exaport'); } + if ($oldversion < 2026032501) { + // Add externaccess field to block_exaportcate table. + $table = new xmldb_table('block_exaportcate'); + $field = new xmldb_field('externaccess', XMLDB_TYPE_INTEGER, '3', null, null, null, '0', 'creatorid'); + + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Add hash field to block_exaportcate table. + $field = new xmldb_field('hash', XMLDB_TYPE_CHAR, '32', null, null, null, null, 'externaccess'); + + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Add index on hash field. + $index = new xmldb_index('hash', XMLDB_INDEX_NOTUNIQUE, array('hash')); + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Exaport savepoint reached. + upgrade_block_savepoint(true, 2026032501, 'exaport'); + } + return $result; } diff --git a/lang/en/block_exaport.php b/lang/en/block_exaport.php index eba03941..94b9d148 100644 --- a/lang/en/block_exaport.php +++ b/lang/en/block_exaport.php @@ -977,3 +977,10 @@ $string['no_views_to_distribute'] = 'No view template defined to distribute'; $string['views_created'] = 'Views created: {$a}'; $string['views_skipped'] = 'Views skipped (already exist): {$a}'; + +// Shared category external access. +$string['externaccess_category_readonly'] = 'This link allows read-only access. Visitors can only view the category contents - no editing, uploading, or deleting is possible.'; +$string['shared_category_readonly'] = 'You are viewing a shared category. This is a read-only view.'; +$string['shared_category_notfound'] = 'Shared category not found or access denied.'; +$string['shared_category'] = 'Shared Category'; + diff --git a/lib/sharelib.php b/lib/sharelib.php index cd56dcea..d70dbcc7 100644 --- a/lib/sharelib.php +++ b/lib/sharelib.php @@ -30,6 +30,67 @@ function block_exaport_get_external_view_url(stdClass $view, $userid = -1) { return $CFG->wwwroot . '/blocks/exaport/shared_view.php?access=hash/' . $userid . '-' . $view->hash; } + /** + * Generate external URL for a shared category. + * + * @param stdClass $category Category object with hash field + * @param int $userid Owner user ID (-1 for current user) + * @return string The external URL + */ + function block_exaport_get_external_category_url(stdClass $category, $userid = -1) { + global $CFG, $USER; + if ($userid == -1) { + $userid = $USER->id; + } + return $CFG->wwwroot . '/blocks/exaport/shared_category.php?access=hash/' . $userid . '-' . $category->hash; + } + + /** + * Get a category from an external access string (hash-based). + * Returns the category if access is valid, null otherwise. + * This is strictly read-only access. + * + * @param string $access Access string in format "hash/-" + * @return stdClass|null Category object with access info, or null + */ + function block_exaport_get_category_from_access($access) { + global $DB; + + $accesspath = explode('/', $access); + if (count($accesspath) != 2) { + return null; + } + + if ($accesspath[0] !== 'hash') { + return null; + } + + $hash = $accesspath[1]; + $hash = explode('-', $hash); + + if (count($hash) != 2) { + return null; + } + + $userid = clean_param($hash[0], PARAM_INT); + $hash = clean_param($hash[1], PARAM_ALPHANUM); + + if (empty($userid) || empty($hash)) { + return null; + } + + // Look up the category by userid, hash, and externaccess flag. + $conditions = array("userid" => $userid, "hash" => $hash, "externaccess" => 1); + if (!$category = $DB->get_record("block_exaportcate", $conditions)) { + return null; + } + + $category->access = new stdClass(); + $category->access->request = 'extern'; + + return $category; + } + function block_exaport_get_user_from_access($access, $epopaccess = false) { global $DB; @@ -362,6 +423,42 @@ function block_exaport_get_item($itemid, $access, $epopaccess = false, $pdfacces $item->allowComments = true; $item->showComments = true; } + } else if (preg_match('!^category/(.+)$!', $access, $matches)) { + // External category access mode (read-only). + if (!$category = block_exaport_get_category_from_access($matches[1])) { + return; + } + + // Verify the item belongs to the category owner. + $conditions = array("id" => $itemid, "userid" => $category->userid); + if (!$item = $DB->get_record("block_exaportitem", $conditions)) { + return; + } + + // Verify the item is in this category or a subcategory of it. + $incategory = false; + $checkcat = $item->categoryid; + $maxdepth = 50; + while ($checkcat && $maxdepth-- > 0) { + if ($checkcat == $category->id) { + $incategory = true; + break; + } + $parentcat = $DB->get_field('block_exaportcate', 'pid', ['id' => $checkcat, 'userid' => $category->userid]); + if (!$parentcat) { + break; + } + $checkcat = $parentcat; + } + if (!$incategory) { + return; + } + + $item->access = $category->access; + $item->access->page = 'category'; + // Strictly read-only: no comments at all. + $item->allowComments = false; + $item->showComments = false; } else { return; } diff --git a/shared_category.php b/shared_category.php new file mode 100644 index 00000000..5e764db6 --- /dev/null +++ b/shared_category.php @@ -0,0 +1,220 @@ +. +// (c) 2016 GTN - Global Training Network GmbH . + +/** + * Read-only external view of a shared category. + * + * This page allows external (non-logged-in) users to view the contents of a + * shared category via a hash-based URL. Strictly read-only: no editing, + * uploading, deleting, or commenting is possible. + */ + +require_once(__DIR__ . '/inc.php'); +require_once(__DIR__ . '/lib/externlib.php'); +require_once(__DIR__ . '/blockmediafunc.php'); + +$access = optional_param('access', '', PARAM_TEXT); +$subcategoryid = optional_param('subcategoryid', 0, PARAM_INT); + +/** Maximum category nesting depth to prevent infinite loops. */ +define('BLOCK_EXAPORT_MAX_CATEGORY_DEPTH', 50); + +$context = context_system::instance(); +$PAGE->set_context($context); + +// Allow access without being logged in (external access via hash). +require_login(0, true); + +$url = '/blocks/exaport/shared_category.php'; +$PAGE->set_url($url, ['access' => $access]); + +// Validate the access hash and get the root shared category. +$rootcategory = block_exaport_get_category_from_access($access); +if (!$rootcategory) { + throw new moodle_exception('shared_category_notfound', 'block_exaport'); +} + +// Get the owner user. +$owner = $DB->get_record('user', ['id' => $rootcategory->userid]); +if (!$owner || $owner->deleted) { + throw new moodle_exception('shared_category_notfound', 'block_exaport'); +} + +// Determine which category to display: root or a subcategory. +if ($subcategoryid > 0) { + // Verify the subcategory belongs to the same owner and is a descendant of the root category. + $currentcategory = $DB->get_record('block_exaportcate', [ + 'id' => $subcategoryid, + 'userid' => $rootcategory->userid, + ]); + if (!$currentcategory) { + throw new moodle_exception('shared_category_notfound', 'block_exaport'); + } + + // Walk up the parent chain to verify this subcategory is actually under the root category. + $verified = false; + $checkcat = $currentcategory; + $maxdepth = BLOCK_EXAPORT_MAX_CATEGORY_DEPTH; // Prevent infinite loops. + while ($checkcat && $maxdepth-- > 0) { + if ($checkcat->id == $rootcategory->id) { + $verified = true; + break; + } + if (empty($checkcat->pid)) { + break; + } + $checkcat = $DB->get_record('block_exaportcate', [ + 'id' => $checkcat->pid, + 'userid' => $rootcategory->userid, + ]); + } + if (!$verified) { + throw new moodle_exception('shared_category_notfound', 'block_exaport'); + } +} else { + $currentcategory = $rootcategory; +} + +// Page setup. +$PAGE->set_title(get_string('shared_category', 'block_exaport') . ': ' . format_string($currentcategory->name)); +$PAGE->set_heading(get_string('shared_category', 'block_exaport')); + +block_exaport_init_js_css(); + +$PAGE->requires->js(new moodle_url($CFG->wwwroot . '/blocks/exaport/javascript/vedeo-js/video.js'), true); +$PAGE->requires->css('/blocks/exaport/javascript/vedeo-js/video-js.css'); + +echo $OUTPUT->header(); +echo $OUTPUT->heading(format_string($currentcategory->name)); + +// Show owner info. +echo '
'; +echo $OUTPUT->user_picture($owner, ['link' => false, 'size' => 35]); +echo ' ' . fullname($owner) . ''; +echo '
'; + +// Read-only notice. +echo '
'; +echo get_string('shared_category_readonly', 'block_exaport'); +echo '
'; + +// Breadcrumb navigation. +echo '
'; +$breadcrumbs = []; +if ($currentcategory->id != $rootcategory->id) { + // Build breadcrumb from root to current. + $crumbchain = []; + $cat = $currentcategory; + $maxdepth = BLOCK_EXAPORT_MAX_CATEGORY_DEPTH; + while ($cat && $maxdepth-- > 0) { + array_unshift($crumbchain, $cat); + if ($cat->id == $rootcategory->id) { + break; + } + if (empty($cat->pid)) { + break; + } + $cat = $DB->get_record('block_exaportcate', [ + 'id' => $cat->pid, + 'userid' => $rootcategory->userid, + ]); + } + foreach ($crumbchain as $i => $crumb) { + if ($i == count($crumbchain) - 1) { + // Current (last) item - no link. + $breadcrumbs[] = '' . format_string($crumb->name) . ''; + } else { + $crumburl = new moodle_url('/blocks/exaport/shared_category.php', [ + 'access' => $access, + 'subcategoryid' => ($crumb->id == $rootcategory->id) ? 0 : $crumb->id, + ]); + $breadcrumbs[] = '' . format_string($crumb->name) . ''; + } + } + echo implode(' » ', $breadcrumbs); +} else { + echo '' . format_string($currentcategory->name) . ''; +} +echo '
'; + +// Get subcategories. +$subcategories = $DB->get_records('block_exaportcate', [ + 'pid' => $currentcategory->id, + 'userid' => $rootcategory->userid, +], 'name ASC'); + +// The access string for items within this category, used for file serving via portfoliofile.php. +$categoryaccess = 'category/' . $access; + +// Get items in this category. +$items = $DB->get_records_sql(" + SELECT i.* + FROM {block_exaportitem} i + WHERE i.categoryid = ? + AND i.userid = ? + ORDER BY i.name ASC +", [$currentcategory->id, $rootcategory->userid]); + +// Display subcategories. +if ($subcategories) { + echo '

' . get_string('category', 'block_exaport') . '

'; + echo '
'; + foreach ($subcategories as $subcat) { + $subcaturl = new moodle_url('/blocks/exaport/shared_category.php', [ + 'access' => $access, + 'subcategoryid' => $subcat->id, + ]); + echo ''; + } + echo '
'; +} + +// Display items with rich content (read-only, no edit/delete/comment). +if ($items) { + echo '

' . get_string('artefacts', 'block_exaport') . '

'; + + foreach ($items as $item) { + $item->intro = process_media_url($item->intro, 320, 240); + echo '
'; + block_exaport_print_extern_item($item, $categoryaccess); + echo '
'; + } +} + +if (empty($subcategories) && empty($items)) { + echo '

' . get_string('nobookmarksall', 'block_exaport') . '

'; +} + +// Back link if we're in a subcategory. +if ($currentcategory->id != $rootcategory->id) { + // Go up to parent. + $parentid = ($currentcategory->pid == $rootcategory->id) ? 0 : $currentcategory->pid; + $backurl = new moodle_url('/blocks/exaport/shared_category.php', [ + 'access' => $access, + 'subcategoryid' => $parentid, + ]); + echo '
'; + echo '« ' . get_string('back') . ''; + echo '
'; +} + +echo $OUTPUT->footer(); diff --git a/version.php b/version.php index c24f29b1..e9a13a51 100644 --- a/version.php +++ b/version.php @@ -19,6 +19,6 @@ $plugin->component = 'block_exaport'; $plugin->release = '5.1'; -$plugin->version = 2026032500; +$plugin->version = 2026032501; $plugin->requires = 2021051700; // moodle 3.11 $plugin->maturity = MATURITY_STABLE; diff --git a/view_items.php b/view_items.php index 5d025715..cae2c295 100644 --- a/view_items.php +++ b/view_items.php @@ -468,6 +468,9 @@ function block_exaport_print_category_select($categoriesbyparent, $currentcatego $currentcategoryPathItemButtons .= block_exaport_fontawesome_icon('handshake', 'regular', 1); // $currentcategoryPathItemButtons .= ' file'; } + if (!empty($currentcategory->externaccess)) { + $currentcategoryPathItemButtons .= block_exaport_fontawesome_icon('globe', 'solid', 1); + } $currentcategoryPathItemButtons .= ' ' . block_exaport_fontawesome_icon('pen-to-square', 'regular', 1) @@ -563,6 +566,9 @@ function block_exaport_print_category_select($categoriesbyparent, $currentcatego $table->data[$itemind]['icons'] .= block_exaport_fontawesome_icon('handshake', 'regular', 1); // $table->data[$itemind]['icons'] .= 'file'; }; + if (!empty($category->externaccess)) { + $table->data[$itemind]['icons'] .= block_exaport_fontawesome_icon('globe', 'solid', 1); + } if (@$category->structure_share) { $table->data[$itemind]['icons'] .= ' '; } @@ -855,6 +861,9 @@ function block_exaport_category_template_tile($category, $courseid, $type, $curr $categoryContent .= block_exaport_fontawesome_icon('handshake', 'regular', 1); // echo 'file'; }; + if (!empty($category->externaccess)) { + $categoryContent .= block_exaport_fontawesome_icon('globe', 'solid', 1); + } if (@$category->structure_share) { $categoryContent .= ' '; }; @@ -1064,6 +1073,9 @@ function block_exaport_category_template_bootstrap_card($category, $courseid, $t (isset($category->shareall) && $category->shareall == 1))) { $categoryContent .= block_exaport_fontawesome_icon('handshake', 'regular', 1); }; + if (!empty($category->externaccess)) { + $categoryContent .= block_exaport_fontawesome_icon('globe', 'solid', 1); + } /*if (@$category->structure_share) { $categoryContent .= ' '; };*/