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', ' | ' .
+ '' .
+ ' ' .
+ 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 $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 .= '
';
}
+ 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'] .= '
';
};
+ 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 '
';
};
+ 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 .= '
';
};*/