mirror of
https://github.com/moodle/moodle.git
synced 2025-08-03 16:13:28 +02:00
MDL-58018 core: finish dev to support open sessions without a lock
This commit is contained in:
parent
82da35fd51
commit
4400ed3e1c
12 changed files with 159 additions and 54 deletions
|
@ -628,6 +628,13 @@ $CFG->admin = 'admin';
|
||||||
//
|
//
|
||||||
// $CFG->debugsessionlock = 5;
|
// $CFG->debugsessionlock = 5;
|
||||||
//
|
//
|
||||||
|
// There are times when a session lock is not required during a request. For a page/service to opt-in whether or not a
|
||||||
|
// session lock is required this setting must first be set to 'true'.
|
||||||
|
// This is an experimental issue. The session store can not be in the session, please
|
||||||
|
// see https://docs.moodle.org/en/Session_handling#Read_only_sessions.
|
||||||
|
//
|
||||||
|
// $CFG->enable_read_only_sessions = true;
|
||||||
|
//
|
||||||
// Uninstall plugins from CLI only. This stops admins from uninstalling plugins from the graphical admin
|
// Uninstall plugins from CLI only. This stops admins from uninstalling plugins from the graphical admin
|
||||||
// user interface, and forces plugins to be uninstalled from the Command Line tool only, found at
|
// user interface, and forces plugins to be uninstalled from the Command Line tool only, found at
|
||||||
// admin/cli/plugin_uninstall.php.
|
// admin/cli/plugin_uninstall.php.
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define('AJAX_SCRIPT', true);
|
define('AJAX_SCRIPT', true);
|
||||||
define('REQUIRE_SESSION_LOCK', false);
|
define('READ_ONLY_SESSION', true);
|
||||||
|
|
||||||
/** Include config */
|
/** Include config */
|
||||||
require_once(__DIR__ . '/../../config.php');
|
require_once(__DIR__ . '/../../config.php');
|
||||||
|
|
|
@ -28,8 +28,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
define('AJAX_SCRIPT', true);
|
define('AJAX_SCRIPT', true);
|
||||||
// Services can declare 'requiresessionlock' in their config located in db/services.php, if not present will default to true.
|
// Services can declare 'readonlysession' in their config located in db/services.php, if not present will default to false.
|
||||||
define('REQUIRE_SESSION_LOCK', false);
|
define('READ_ONLY_SESSION', true);
|
||||||
|
|
||||||
if (!empty($_GET['nosessionupdate'])) {
|
if (!empty($_GET['nosessionupdate'])) {
|
||||||
define('NO_SESSION_UPDATE', true);
|
define('NO_SESSION_UPDATE', true);
|
||||||
|
|
|
@ -34,8 +34,8 @@ defined('MOODLE_INTERNAL') || die();
|
||||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||||
*/
|
*/
|
||||||
abstract class handler {
|
abstract class handler {
|
||||||
/** @var boolean $haslock does the session need and/or have a lock? */
|
/** @var boolean $requireswritelock does the session need and/or have a lock? */
|
||||||
protected $haslock = false;
|
protected $requireswritelock = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the session.
|
* Start the session.
|
||||||
|
@ -50,9 +50,9 @@ abstract class handler {
|
||||||
* with a write lock, then we will abort the session instead if able.
|
* with a write lock, then we will abort the session instead if able.
|
||||||
*/
|
*/
|
||||||
public function write_close() {
|
public function write_close() {
|
||||||
if ($this->has_writelock()) {
|
if ($this->requires_write_lock()) {
|
||||||
session_write_close();
|
session_write_close();
|
||||||
$this->haslock = false;
|
$this->requireswritelock = false;
|
||||||
} else {
|
} else {
|
||||||
$this->abort();
|
$this->abort();
|
||||||
}
|
}
|
||||||
|
@ -65,7 +65,7 @@ abstract class handler {
|
||||||
*/
|
*/
|
||||||
public function abort() {
|
public function abort() {
|
||||||
session_abort();
|
session_abort();
|
||||||
$this->haslock = false;
|
$this->requireswritelock = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,10 +73,10 @@ abstract class handler {
|
||||||
* opened should be writable or not. This is intentionally captured even if your
|
* opened should be writable or not. This is intentionally captured even if your
|
||||||
* handler doesn't support non-locking sessions, so that behavior (upon session close)
|
* handler doesn't support non-locking sessions, so that behavior (upon session close)
|
||||||
* matches closely between handlers.
|
* matches closely between handlers.
|
||||||
* @param bool $needslock true if needs to be open for writing
|
* @param bool $requireswritelock true if needs to be open for writing
|
||||||
*/
|
*/
|
||||||
public function set_needslock($needslock) {
|
public function set_requires_write_lock($requireswritelock) {
|
||||||
$this->haslock = $needslock;
|
$this->requireswritelock = $requireswritelock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -84,8 +84,8 @@ abstract class handler {
|
||||||
* start() if you support read-only sessions.
|
* start() if you support read-only sessions.
|
||||||
* @return bool true if session is intended to have a write lock.
|
* @return bool true if session is intended to have a write lock.
|
||||||
*/
|
*/
|
||||||
public function has_writelock() {
|
public function requires_write_lock() {
|
||||||
return $this->haslock;
|
return $this->requireswritelock;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -57,6 +57,9 @@ class manager {
|
||||||
/** @var string $logintokenkey Key used to get and store request protection for login form. */
|
/** @var string $logintokenkey Key used to get and store request protection for login form. */
|
||||||
protected static $logintokenkey = 'core_auth_login';
|
protected static $logintokenkey = 'core_auth_login';
|
||||||
|
|
||||||
|
/** @var array Stores the the SESSION before a request is performed, used to check incorrect read-only modes */
|
||||||
|
private static $priorsession = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the current session is not writeable, abort it, and re-open it
|
* If the current session is not writeable, abort it, and re-open it
|
||||||
* requesting (and blocking) until a write lock is acquired.
|
* requesting (and blocking) until a write lock is acquired.
|
||||||
|
@ -68,13 +71,9 @@ class manager {
|
||||||
* as practical across environments.
|
* as practical across environments.
|
||||||
*/
|
*/
|
||||||
public static function restart_with_write_lock() {
|
public static function restart_with_write_lock() {
|
||||||
if (self::$sessionactive) {
|
if (self::$sessionactive && !self::$handler->requires_write_lock()) {
|
||||||
if (!self::$handler->has_writelock()) {
|
@self::$handler->abort();
|
||||||
@self::$handler->abort();
|
self::$sessionactive = false;
|
||||||
self::$sessionactive = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!self::$sessionactive) {
|
|
||||||
self::start_session(true);
|
self::start_session(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -104,49 +103,34 @@ class manager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defined('REQUIRE_SESSION_LOCK') && defined('ENABLE_READ_ONLY_SESSIONS') && ENABLE_READ_ONLY_SESSIONS) {
|
if (defined('READ_ONLY_SESSION') && !empty($CFG->enable_read_only_sessions)) {
|
||||||
$needslock = REQUIRE_SESSION_LOCK;
|
$requireslock = !READ_ONLY_SESSION;
|
||||||
} else {
|
} else {
|
||||||
$needslock = true; // For backwards compatibility, we default to assuming that a lock is needed.
|
$requireslock = true; // For backwards compatibility, we default to assuming that a lock is needed.
|
||||||
}
|
}
|
||||||
self::start_session($needslock);
|
self::start_session($requireslock);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles starting a session.
|
* Handles starting a session.
|
||||||
*
|
*
|
||||||
* @param bool $needslock If this is false then no write lock will be acquired,
|
* @param bool $requireslock If this is false then no write lock will be acquired,
|
||||||
* and the session will be read-only.
|
* and the session will be read-only.
|
||||||
*/
|
*/
|
||||||
private static function start_session(bool $needslock) {
|
private static function start_session(bool $requireslock) {
|
||||||
global $PERF;
|
global $PERF;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
self::$handler->init();
|
self::$handler->init();
|
||||||
self::$handler->set_needslock($needslock);
|
self::$handler->set_requires_write_lock($requireslock);
|
||||||
self::prepare_cookies();
|
self::prepare_cookies();
|
||||||
$isnewsession = empty($_COOKIE[session_name()]);
|
$isnewsession = empty($_COOKIE[session_name()]);
|
||||||
|
|
||||||
if (defined('DEBUG_SESSION_TIMING_MIN_TIME')) {
|
|
||||||
$starttime = microtime(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!self::$handler->start()) {
|
if (!self::$handler->start()) {
|
||||||
// Could not successfully start/recover session.
|
// Could not successfully start/recover session.
|
||||||
throw new \core\session\exception(get_string('servererror'));
|
throw new \core\session\exception(get_string('servererror'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEBUG_SESSION_TIMING_MIN_TIME if defined is a float, and we'll show a message for
|
|
||||||
// session aquisition that exceeds this amount in seconds.
|
|
||||||
if (defined('DEBUG_SESSION_TIMING_MIN_TIME')) {
|
|
||||||
$duration = microtime(true) - $starttime;
|
|
||||||
if ($duration > DEBUG_SESSION_TIMING_MIN_TIME) {
|
|
||||||
/* don't log the raw session ID, since the session ID itself is a secret */
|
|
||||||
error_log("slow_session_id:".hash('sha256', session_id())."|duration:".
|
|
||||||
round($duration, 3)."s|session_url:".$_SERVER['PHP_SELF']."|write_lock:".($needslock ? '1' : '0'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grab the time when session lock starts.
|
// Grab the time when session lock starts.
|
||||||
$PERF->sessionlock['gained'] = microtime(true);
|
$PERF->sessionlock['gained'] = microtime(true);
|
||||||
$PERF->sessionlock['wait'] = $PERF->sessionlock['gained'] - $PERF->sessionlock['start'];
|
$PERF->sessionlock['wait'] = $PERF->sessionlock['gained'] - $PERF->sessionlock['start'];
|
||||||
|
@ -154,6 +138,10 @@ class manager {
|
||||||
self::$sessionactive = true; // Set here, so the session can be cleared if the security check fails.
|
self::$sessionactive = true; // Set here, so the session can be cleared if the security check fails.
|
||||||
self::check_security();
|
self::check_security();
|
||||||
|
|
||||||
|
if (!$requireslock) {
|
||||||
|
self::$priorsession = (array) $_SESSION['SESSION'];
|
||||||
|
}
|
||||||
|
|
||||||
// Link global $USER and $SESSION,
|
// Link global $USER and $SESSION,
|
||||||
// this is tricky because PHP does not allow references to references
|
// this is tricky because PHP does not allow references to references
|
||||||
// and global keyword uses internally once reference to the $GLOBALS array.
|
// and global keyword uses internally once reference to the $GLOBALS array.
|
||||||
|
@ -708,6 +696,28 @@ class manager {
|
||||||
$PERF->sessionlock['url'] = me();
|
$PERF->sessionlock['url'] = me();
|
||||||
self::update_recent_session_locks($PERF->sessionlock);
|
self::update_recent_session_locks($PERF->sessionlock);
|
||||||
self::sessionlock_debugging();
|
self::sessionlock_debugging();
|
||||||
|
|
||||||
|
if (!self::$handler->requires_write_lock()) {
|
||||||
|
// Compare the array of the earlier session data with the array now, if
|
||||||
|
// there is a difference then a lock is required.
|
||||||
|
$arraydiff = self::array_session_diff(
|
||||||
|
self::$priorsession,
|
||||||
|
(array) $_SESSION['SESSION']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($arraydiff) {
|
||||||
|
if (isset($arraydiff['cachestore_session'])) {
|
||||||
|
throw new \moodle_exception('The session store can not be in the session when '
|
||||||
|
. 'enable_read_only_sessions is enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log('This session was started as a read-only session but writes have been detected.');
|
||||||
|
error_log('The following SESSION params were either added, or were updated.');
|
||||||
|
foreach ($arraydiff as $key => $value) {
|
||||||
|
error_log('SESSION key: ' . $key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// More control over whether session data
|
// More control over whether session data
|
||||||
|
@ -717,9 +727,13 @@ class manager {
|
||||||
// indication session start was clean.
|
// indication session start was clean.
|
||||||
self::$handler->write_close();
|
self::$handler->write_close();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, if possibile lock exists want
|
// Otherwise, if possible lock exists want
|
||||||
// to clear it, but do not write session.
|
// to clear it, but do not write session.
|
||||||
@self::$handler->abort();
|
// If the $handler has not been set then
|
||||||
|
// there is no session to abort.
|
||||||
|
if (isset(self::$handler)) {
|
||||||
|
@self::$handler->abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self::$sessionactive = false;
|
self::$sessionactive = false;
|
||||||
|
@ -1363,4 +1377,27 @@ class manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two arrays outputs the difference.
|
||||||
|
*
|
||||||
|
* Note this does not use array_diff_assoc due to
|
||||||
|
* the use of stdClasses in Moodle sessions.
|
||||||
|
*
|
||||||
|
* @param array $array1
|
||||||
|
* @param array $array2
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private static function array_session_diff(array $array1, array $array2) : array {
|
||||||
|
$difference = [];
|
||||||
|
foreach ($array1 as $key => $value) {
|
||||||
|
if (!isset($array2[$key])) {
|
||||||
|
$difference[$key] = $value;
|
||||||
|
} else if ($array2[$key] !== $value) {
|
||||||
|
$difference[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $difference;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,7 +101,7 @@ class memcached extends handler {
|
||||||
* @return bool success
|
* @return bool success
|
||||||
*/
|
*/
|
||||||
public function start() {
|
public function start() {
|
||||||
ini_set('memcached.sess_locking', $this->has_writelock() ? '1' : '0');
|
ini_set('memcached.sess_locking', $this->requires_write_lock() ? '1' : '0');
|
||||||
|
|
||||||
// NOTE: memcached before 2.2.0 expires session locks automatically after max_execution_time,
|
// NOTE: memcached before 2.2.0 expires session locks automatically after max_execution_time,
|
||||||
// this leads to major difference compared to other session drivers that timeout
|
// this leads to major difference compared to other session drivers that timeout
|
||||||
|
|
|
@ -748,7 +748,7 @@ $functions = array(
|
||||||
'type' => 'read',
|
'type' => 'read',
|
||||||
'loginrequired' => false,
|
'loginrequired' => false,
|
||||||
'ajax' => true,
|
'ajax' => true,
|
||||||
'requiresessionlock' => true, // Fetching removes from stack.
|
'readonlysession' => false, // Fetching removes from stack.
|
||||||
),
|
),
|
||||||
'core_session_touch' => array(
|
'core_session_touch' => array(
|
||||||
'classname' => 'core\session\external',
|
'classname' => 'core\session\external',
|
||||||
|
@ -1375,7 +1375,7 @@ $functions = array(
|
||||||
'type' => 'read',
|
'type' => 'read',
|
||||||
'ajax' => true,
|
'ajax' => true,
|
||||||
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
|
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
|
||||||
'requiresessionlock' => false, // We don't modify the session.
|
'readonlysession' => true, // We don't modify the session.
|
||||||
),
|
),
|
||||||
'core_message_mark_all_notifications_as_read' => array(
|
'core_message_mark_all_notifications_as_read' => array(
|
||||||
'classname' => 'core_message_external',
|
'classname' => 'core_message_external',
|
||||||
|
|
|
@ -161,10 +161,10 @@ class external_api {
|
||||||
} else {
|
} else {
|
||||||
$function->loginrequired = true;
|
$function->loginrequired = true;
|
||||||
}
|
}
|
||||||
if (isset($functions[$function->name]['requiresessionlock'])) {
|
if (isset($functions[$function->name]['readonlysession'])) {
|
||||||
$function->requiresessionlock = $functions[$function->name]['requiresessionlock'];
|
$function->readonlysession = $functions[$function->name]['readonlysession'];
|
||||||
} else {
|
} else {
|
||||||
$function->requiresessionlock = true;
|
$function->readonlysession = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -190,7 +190,8 @@ class external_api {
|
||||||
$externalfunctioninfo = static::external_function_info($function);
|
$externalfunctioninfo = static::external_function_info($function);
|
||||||
|
|
||||||
// Eventually this should shift into the various handlers and not be handled via config.
|
// Eventually this should shift into the various handlers and not be handled via config.
|
||||||
if ($externalfunctioninfo->requiresessionlock) {
|
$readonlysession = $externalfunctioninfo->readonlysession ?? false;
|
||||||
|
if (!$readonlysession || empty($CFG->enable_read_only_sessions)) {
|
||||||
\core\session\manager::restart_with_write_lock();
|
\core\session\manager::restart_with_write_lock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -854,4 +854,61 @@ class core_session_manager_testcase extends advanced_testcase {
|
||||||
$this->assertCount(1, $SESSION->recentsessionlocks);
|
$this->assertCount(1, $SESSION->recentsessionlocks);
|
||||||
$this->assertEquals('/good.php?id=4', $SESSION->recentsessionlocks[0]['url']);
|
$this->assertEquals('/good.php?id=4', $SESSION->recentsessionlocks[0]['url']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_array_session_diff_same_array() {
|
||||||
|
$a = [];
|
||||||
|
$a['c'] = new stdClass();
|
||||||
|
$a['c']->o = new stdClass();
|
||||||
|
$a['c']->o->o = new stdClass();
|
||||||
|
$a['c']->o->o->l = 'cool';
|
||||||
|
|
||||||
|
$class = new ReflectionClass('\core\session\manager');
|
||||||
|
$method = $class->getMethod('array_session_diff');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs(null, [$a, $a]);
|
||||||
|
|
||||||
|
$this->assertEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_array_session_diff_first_array_larger() {
|
||||||
|
$a = [];
|
||||||
|
$a['stdClass'] = new stdClass();
|
||||||
|
$a['stdClass']->attribute = 'This is an attribute';
|
||||||
|
$a['array'] = ['array', 'contents'];
|
||||||
|
|
||||||
|
$b = [];
|
||||||
|
$b['array'] = ['array', 'contents'];
|
||||||
|
|
||||||
|
$class = new ReflectionClass('\core\session\manager');
|
||||||
|
$method = $class->getMethod('array_session_diff');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs(null, [$a, $b]);
|
||||||
|
|
||||||
|
$expected = [];
|
||||||
|
$expected['stdClass'] = new stdClass();
|
||||||
|
$expected['stdClass']->attribute = 'This is an attribute';
|
||||||
|
$this->assertEquals($expected, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_array_session_diff_second_array_larger() {
|
||||||
|
$a = [];
|
||||||
|
$a['array'] = ['array', 'contents'];
|
||||||
|
|
||||||
|
$b = [];
|
||||||
|
$b['stdClass'] = new stdClass();
|
||||||
|
$b['stdClass']->attribute = 'This is an attribute';
|
||||||
|
$b['array'] = ['array', 'contents'];
|
||||||
|
|
||||||
|
$class = new ReflectionClass('\core\session\manager');
|
||||||
|
$method = $class->getMethod('array_session_diff');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invokeArgs(null, [$a, $b]);
|
||||||
|
|
||||||
|
// It's empty because the first array contains all the contents of the second.
|
||||||
|
$expected = [];
|
||||||
|
$this->assertEquals($expected, $result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,10 @@ information provided here is intended especially for developers.
|
||||||
* H5P libraries have been moved from /lib/h5p to h5p/h5plib as an h5plib plugintype.
|
* H5P libraries have been moved from /lib/h5p to h5p/h5plib as an h5plib plugintype.
|
||||||
* mdn-polyfills has been renamed to polyfills. The reason there is no polyfill from the MDN is
|
* mdn-polyfills has been renamed to polyfills. The reason there is no polyfill from the MDN is
|
||||||
because there is no example polyfills on the MDN for this functionality.
|
because there is no example polyfills on the MDN for this functionality.
|
||||||
|
* AJAX pages can be called without requiring a session lock if they set READ_ONLY_SESSION to true, eg.
|
||||||
|
define('READ_ONLY_SESSION', true); Note - this also requires $CFG->enable_read_only_sessions to be set to true.
|
||||||
|
* External functions can be called without requiring a session lock if they define 'readonlysession' => true in
|
||||||
|
db/services.php. Note - this also requires $CFG->enable_read_only_sessions to be set to true.
|
||||||
|
|
||||||
=== 3.8 ===
|
=== 3.8 ===
|
||||||
* Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
|
* Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
|
||||||
|
|
|
@ -41,6 +41,6 @@ $functions = array(
|
||||||
'type' => 'read',
|
'type' => 'read',
|
||||||
'ajax' => true,
|
'ajax' => true,
|
||||||
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
|
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
|
||||||
'requiresessionlock' => false,
|
'readonlysession' => true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,7 +28,6 @@
|
||||||
if (!defined('NO_DEBUG_DISPLAY')) {
|
if (!defined('NO_DEBUG_DISPLAY')) {
|
||||||
define('NO_DEBUG_DISPLAY', true);
|
define('NO_DEBUG_DISPLAY', true);
|
||||||
}
|
}
|
||||||
define('REQUIRE_SESSION_LOCK', false); // Plugins will need to acquire a lock if they need one.
|
|
||||||
|
|
||||||
require_once('config.php');
|
require_once('config.php');
|
||||||
require_once('lib/filelib.php');
|
require_once('lib/filelib.php');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue