mirror of
https://github.com/VladPolskiy/dokuwiki.git
synced 2025-08-16 16:24:12 +00:00
Merge pull request #2432 from dokuwiki/tokenauth
Implement Token Authentication
This commit is contained in:
82
_test/tests/inc/JWTTest.php
Normal file
82
_test/tests/inc/JWTTest.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\test;
|
||||
|
||||
use dokuwiki\JWT;
|
||||
|
||||
class JWTTest extends \DokuWikiTest
|
||||
{
|
||||
|
||||
|
||||
public function testCreation()
|
||||
{
|
||||
// no token file yet
|
||||
$file = JWT::getStorageFile('test');
|
||||
$this->assertFileNotExists($file);
|
||||
|
||||
// initialize a new token
|
||||
$jwt = JWT::fromUser('test');
|
||||
$this->assertFileExists($file);
|
||||
$this->assertEquals('test', $jwt->getUser());
|
||||
$token = $jwt->getToken();
|
||||
$issued = $jwt->getIssued();
|
||||
|
||||
// validate the token
|
||||
$jwt = JWT::validate($token);
|
||||
$this->assertEquals('test', $jwt->getUser());
|
||||
$this->assertEquals($issued, $jwt->getIssued());
|
||||
|
||||
|
||||
// next access should get the same token
|
||||
$jwt = JWT::fromUser('test');
|
||||
$this->assertEquals($token, $jwt->getToken());
|
||||
$this->assertEquals($issued, $jwt->getIssued());
|
||||
|
||||
// saving should create a new token
|
||||
sleep(1); // make sure we have a new timestamp
|
||||
$jwt->save();
|
||||
$this->assertNotEquals($token, $jwt->getToken());
|
||||
$this->assertNotEquals($issued, $jwt->getIssued());
|
||||
}
|
||||
|
||||
public function testValidationFail()
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Invalid JWT signature');
|
||||
JWT::validate('invalid');
|
||||
}
|
||||
|
||||
public function testLoadFail()
|
||||
{
|
||||
$jwt = JWT::fromUser('test');
|
||||
$token = $jwt->getToken();
|
||||
$file = JWT::getStorageFile('test');
|
||||
unlink($file);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('JWT not found, maybe it expired?');
|
||||
JWT::validate($token);
|
||||
}
|
||||
|
||||
public function testLoadExpireFail()
|
||||
{
|
||||
$jwt = JWT::fromUser('test');
|
||||
$token = $jwt->getToken();
|
||||
sleep(1); // make sure we have a new timestamp
|
||||
$jwt->save();
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('JWT invalid, maybe it expired?');
|
||||
JWT::validate($token);
|
||||
}
|
||||
|
||||
public function testLogin()
|
||||
{
|
||||
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . JWT::fromUser('testuser')->getToken();
|
||||
|
||||
$this->assertArrayNotHasKey('REMOTE_USER', $_SERVER);
|
||||
auth_tokenlogin();
|
||||
$this->assertEquals('testuser', $_SERVER['REMOTE_USER']);
|
||||
unset($_SERVER['HTTP_AUTHORIZATION']);
|
||||
}
|
||||
}
|
31
inc/Action/Authtoken.php
Normal file
31
inc/Action/Authtoken.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\Action;
|
||||
|
||||
use dokuwiki\Action\Exception\ActionAbort;
|
||||
use dokuwiki\Action\Exception\ActionException;
|
||||
use dokuwiki\JWT;
|
||||
|
||||
class Authtoken extends AbstractUserAction {
|
||||
|
||||
/** @inheritdoc */
|
||||
public function minimumPermission() {
|
||||
return AUTH_NONE;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function checkPreconditions() {
|
||||
parent::checkPreconditions();
|
||||
|
||||
if(!checkSecurityToken()) throw new ActionException('profile');
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
public function preProcess() {
|
||||
global $INPUT;
|
||||
parent::preProcess();
|
||||
$token = JWT::fromUser($INPUT->server->str('REMOTE_USER'));
|
||||
$token->save();
|
||||
throw new ActionAbort('profile');
|
||||
}
|
||||
}
|
179
inc/JWT.php
Normal file
179
inc/JWT.php
Normal file
@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki;
|
||||
|
||||
/**
|
||||
* Minimal JWT implementation
|
||||
*/
|
||||
class JWT
|
||||
{
|
||||
|
||||
protected $user;
|
||||
protected $issued;
|
||||
protected $secret;
|
||||
|
||||
/**
|
||||
* Create a new JWT object
|
||||
*
|
||||
* Use validate() or create() to create a new instance
|
||||
*
|
||||
* @param string $user
|
||||
* @param int $issued
|
||||
*/
|
||||
protected function __construct($user, $issued)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->issued = $issued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cookiesalt as secret
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function getSecret()
|
||||
{
|
||||
return auth_cookiesalt(false, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from a token
|
||||
*
|
||||
* @param $token
|
||||
* @return self
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function validate($token)
|
||||
{
|
||||
[$header, $payload, $signature] = sexplode('.', $token, 3, '');
|
||||
$signature = base64_decode($signature);
|
||||
|
||||
if (!hash_equals($signature, hash_hmac('sha256', "$header.$payload", self::getSecret(), true))) {
|
||||
throw new \Exception('Invalid JWT signature');
|
||||
}
|
||||
|
||||
$header = json_decode(base64_decode($header), true);
|
||||
$payload = json_decode(base64_decode($payload), true);
|
||||
|
||||
if (!$header || !$payload || !$signature) {
|
||||
throw new \Exception('Invalid JWT');
|
||||
}
|
||||
|
||||
if ($header['alg'] !== 'HS256') {
|
||||
throw new \Exception('Unsupported JWT algorithm');
|
||||
}
|
||||
if ($header['typ'] !== 'JWT') {
|
||||
throw new \Exception('Unsupported JWT type');
|
||||
}
|
||||
if ($payload['iss'] !== 'dokuwiki') {
|
||||
throw new \Exception('Unsupported JWT issuer');
|
||||
}
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
throw new \Exception('JWT expired');
|
||||
}
|
||||
|
||||
$user = $payload['sub'];
|
||||
$file = self::getStorageFile($user);
|
||||
if (!file_exists($file)) {
|
||||
throw new \Exception('JWT not found, maybe it expired?');
|
||||
}
|
||||
|
||||
if(file_get_contents($file) !== $token) {
|
||||
throw new \Exception('JWT invalid, maybe it expired?');
|
||||
}
|
||||
|
||||
return new self($user, $payload['iat']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from a user
|
||||
*
|
||||
* Loads an existing token if available
|
||||
*
|
||||
* @param $user
|
||||
* @return self
|
||||
*/
|
||||
public static function fromUser($user)
|
||||
{
|
||||
$file = self::getStorageFile($user);
|
||||
|
||||
if (file_exists($file)) {
|
||||
try {
|
||||
return self::validate(io_readFile($file));
|
||||
} catch (\Exception $ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
$token = new self($user, time());
|
||||
$token->save();
|
||||
return $token;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the JWT token for this instance
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getToken()
|
||||
{
|
||||
$header = [
|
||||
'alg' => 'HS256',
|
||||
'typ' => 'JWT',
|
||||
];
|
||||
$header = base64_encode(json_encode($header));
|
||||
$payload = [
|
||||
'iss' => 'dokuwiki',
|
||||
'sub' => $this->user,
|
||||
'iat' => $this->issued,
|
||||
];
|
||||
$payload = base64_encode(json_encode($payload));
|
||||
$signature = hash_hmac('sha256', "$header.$payload", self::getSecret(), true);
|
||||
$signature = base64_encode($signature);
|
||||
return "$header.$payload.$signature";
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the token for the user
|
||||
*
|
||||
* Resets the issued timestamp
|
||||
*/
|
||||
public function save()
|
||||
{
|
||||
$this->issued = time();
|
||||
io_saveFile(self::getStorageFile($this->user), $this->getToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user of this token
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getUser()
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the issued timestamp of this token
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getIssued()
|
||||
{
|
||||
return $this->issued;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage file for this token
|
||||
*
|
||||
* Tokens are stored to be able to invalidate them
|
||||
*
|
||||
* @param string $user The user the token is for
|
||||
* @return string
|
||||
*/
|
||||
public static function getStorageFile($user)
|
||||
{
|
||||
return getCacheName($user, '.token');
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ namespace dokuwiki\Ui;
|
||||
|
||||
use dokuwiki\Extension\AuthPlugin;
|
||||
use dokuwiki\Form\Form;
|
||||
use dokuwiki\JWT;
|
||||
|
||||
/**
|
||||
* DokuWiki User Profile Interface
|
||||
@ -21,21 +22,61 @@ class UserProfile extends Ui
|
||||
*/
|
||||
public function show()
|
||||
{
|
||||
global $lang;
|
||||
global $conf;
|
||||
global $INPUT;
|
||||
global $INFO;
|
||||
/** @var AuthPlugin $auth */
|
||||
global $auth;
|
||||
global $INFO;
|
||||
global $INPUT;
|
||||
|
||||
$userinfo = [
|
||||
'user' => $_SERVER['REMOTE_USER'],
|
||||
'name' => $INPUT->post->str('fullname', $INFO['userinfo']['name'], true),
|
||||
'mail' => $INPUT->post->str('email', $INFO['userinfo']['mail'], true),
|
||||
|
||||
];
|
||||
|
||||
// print intro
|
||||
echo p_locale_xhtml('updateprofile');
|
||||
echo '<div class="centeralign">';
|
||||
|
||||
$fullname = $INPUT->post->str('fullname', $INFO['userinfo']['name'], true);
|
||||
$email = $INPUT->post->str('email', $INFO['userinfo']['mail'], true);
|
||||
echo $this->updateProfileForm($userinfo)->toHTML('UpdateProfile');
|
||||
echo $this->tokenForm($userinfo['user'])->toHTML();
|
||||
if ($auth->canDo('delUser') && actionOK('profile_delete')) {
|
||||
$this->deleteProfileForm()->toHTML('ProfileDelete');
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the password confirmation field to the form if configured
|
||||
*
|
||||
* @param Form $form
|
||||
* @return void
|
||||
*/
|
||||
protected function addPasswordConfirmation(Form $form)
|
||||
{
|
||||
global $lang;
|
||||
global $conf;
|
||||
|
||||
if (!$conf['profileconfirm']) return;
|
||||
$form->addHTML("<br>\n");
|
||||
$attr = ['size' => '50', 'required' => 'required'];
|
||||
$input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)
|
||||
->addClass('edit');
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the profile form
|
||||
*
|
||||
* @return Form
|
||||
*/
|
||||
protected function updateProfileForm($userinfo)
|
||||
{
|
||||
global $lang;
|
||||
/** @var AuthPlugin $auth */
|
||||
global $auth;
|
||||
|
||||
// create the updateprofile form
|
||||
$form = new Form(['id' => 'dw__register']);
|
||||
$form->addTagOpen('div')->addClass('no');
|
||||
$form->addFieldsetOpen($lang['profile']);
|
||||
@ -43,22 +84,28 @@ class UserProfile extends Ui
|
||||
$form->setHiddenField('save', '1');
|
||||
|
||||
$attr = ['size' => '50', 'disabled' => 'disabled'];
|
||||
$input = $form->addTextInput('login', $lang['user'])->attrs($attr)->addClass('edit')
|
||||
->val($INPUT->server->str('REMOTE_USER'));
|
||||
$input = $form->addTextInput('login', $lang['user'])
|
||||
->attrs($attr)
|
||||
->addClass('edit')
|
||||
->val($userinfo['user']);
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
|
||||
$attr = ['size' => '50'];
|
||||
if (!$auth->canDo('modName')) $attr['disabled'] = 'disabled';
|
||||
$input = $form->addTextInput('fullname', $lang['fullname'])->attrs($attr)->addClass('edit')
|
||||
->val($fullname);
|
||||
$input = $form->addTextInput('fullname', $lang['fullname'])
|
||||
->attrs($attr)
|
||||
->addClass('edit')
|
||||
->val($userinfo['name']);
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
|
||||
$attr = ['type' => 'email', 'size' => '50'];
|
||||
if (!$auth->canDo('modMail')) $attr['disabled'] = 'disabled';
|
||||
$input = $form->addTextInput('email', $lang['email'])->attrs($attr)->addClass('edit')
|
||||
->val($email);
|
||||
$input = $form->addTextInput('email', $lang['email'])
|
||||
->attrs($attr)
|
||||
->addClass('edit')
|
||||
->val($userinfo['mail']);
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
|
||||
@ -73,13 +120,7 @@ class UserProfile extends Ui
|
||||
$form->addHTML("<br>\n");
|
||||
}
|
||||
|
||||
if ($conf['profileconfirm']) {
|
||||
$form->addHTML("<br>\n");
|
||||
$attr = ['size' => '50', 'required' => 'required'];
|
||||
$input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)->addClass('edit');
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
}
|
||||
$this->addPasswordConfirmation($form);
|
||||
|
||||
$form->addButton('', $lang['btn_save'])->attr('type', 'submit');
|
||||
$form->addButton('', $lang['btn_reset'])->attr('type', 'reset');
|
||||
@ -87,38 +128,58 @@ class UserProfile extends Ui
|
||||
$form->addFieldsetClose();
|
||||
$form->addTagClose('div');
|
||||
|
||||
echo $form->toHTML('UpdateProfile');
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the profile delete form
|
||||
*
|
||||
* @return Form
|
||||
*/
|
||||
protected function deleteProfileForm()
|
||||
{
|
||||
global $lang;
|
||||
|
||||
if ($auth->canDo('delUser') && actionOK('profile_delete')) {
|
||||
// create the profiledelete form
|
||||
$form = new Form(['id' => 'dw__profiledelete']);
|
||||
$form->addTagOpen('div')->addClass('no');
|
||||
$form->addFieldsetOpen($lang['profdeleteuser']);
|
||||
$form->setHiddenField('do', 'profile_delete');
|
||||
$form->setHiddenField('delete', '1');
|
||||
$form = new Form(['id' => 'dw__profiledelete']);
|
||||
$form->addTagOpen('div')->addClass('no');
|
||||
$form->addFieldsetOpen($lang['profdeleteuser']);
|
||||
$form->setHiddenField('do', 'profile_delete');
|
||||
$form->setHiddenField('delete', '1');
|
||||
|
||||
$form->addCheckbox('confirm_delete', $lang['profconfdelete'])
|
||||
->attrs(['required' => 'required'])
|
||||
->id('dw__confirmdelete')
|
||||
->val('1');
|
||||
$form->addCheckbox('confirm_delete', $lang['profconfdelete'])
|
||||
->attrs(['required' => 'required'])
|
||||
->id('dw__confirmdelete')
|
||||
->val('1');
|
||||
|
||||
if ($conf['profileconfirm']) {
|
||||
$form->addHTML("<br>\n");
|
||||
$attr = ['size' => '50', 'required' => 'required'];
|
||||
$input = $form->addPasswordInput('oldpass', $lang['oldpass'])->attrs($attr)
|
||||
->addClass('edit');
|
||||
$input->getLabel()->attr('class', 'block');
|
||||
$form->addHTML("<br>\n");
|
||||
}
|
||||
$this->addPasswordConfirmation($form);
|
||||
|
||||
$form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit');
|
||||
$form->addFieldsetClose();
|
||||
$form->addTagClose('div');
|
||||
$form->addButton('', $lang['btn_deleteuser'])->attr('type', 'submit');
|
||||
$form->addFieldsetClose();
|
||||
$form->addTagClose('div');
|
||||
return $form;
|
||||
}
|
||||
|
||||
echo $form->toHTML('ProfileDelete');
|
||||
}
|
||||
/**
|
||||
* Get the authentication token form
|
||||
*
|
||||
* @param string $user
|
||||
* @return Form
|
||||
*/
|
||||
protected function tokenForm($user)
|
||||
{
|
||||
global $lang;
|
||||
|
||||
echo '</div>';
|
||||
$token = JWT::fromUser($user);
|
||||
|
||||
$form = new Form(['id' => 'dw__profiletoken', 'action' => wl(), 'method' => 'POST']);
|
||||
$form->setHiddenField('do', 'authtoken');
|
||||
$form->setHiddenField('id', 'ID');
|
||||
$form->addFieldsetOpen($lang['proftokenlegend']);
|
||||
$form->addHTML('<p>' . $lang['proftokeninfo'] . '</p>');
|
||||
$form->addHTML('<p><code style="display: block; word-break: break-word">' . $token->getToken() . '</code></p>');
|
||||
$form->addButton('regen', $lang['proftokengenerate']);
|
||||
$form->addFieldsetClose();
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
||||
|
78
inc/auth.php
78
inc/auth.php
@ -91,21 +91,24 @@ function auth_setup()
|
||||
$INPUT->set('p', stripctl($INPUT->str('p')));
|
||||
}
|
||||
|
||||
$ok = null;
|
||||
if ($auth instanceof AuthPlugin && $auth->canDo('external')) {
|
||||
$ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r'));
|
||||
}
|
||||
if(!auth_tokenlogin()) {
|
||||
$ok = null;
|
||||
|
||||
if ($ok === null) {
|
||||
// external trust mechanism not in place, or returns no result,
|
||||
// then attempt auth_login
|
||||
$evdata = [
|
||||
'user' => $INPUT->str('u'),
|
||||
'password' => $INPUT->str('p'),
|
||||
'sticky' => $INPUT->bool('r'),
|
||||
'silent' => $INPUT->bool('http_credentials')
|
||||
];
|
||||
Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper');
|
||||
if ($auth instanceof AuthPlugin && $auth->canDo('external')) {
|
||||
$ok = $auth->trustExternal($INPUT->str('u'), $INPUT->str('p'), $INPUT->bool('r'));
|
||||
}
|
||||
|
||||
if ($ok === null) {
|
||||
// external trust mechanism not in place, or returns no result,
|
||||
// then attempt auth_login
|
||||
$evdata = [
|
||||
'user' => $INPUT->str('u'),
|
||||
'password' => $INPUT->str('p'),
|
||||
'sticky' => $INPUT->bool('r'),
|
||||
'silent' => $INPUT->bool('http_credentials')
|
||||
];
|
||||
Event::createAndTrigger('AUTH_LOGIN_CHECK', $evdata, 'auth_login_wrapper');
|
||||
}
|
||||
}
|
||||
|
||||
//load ACL into a global array XXX
|
||||
@ -165,6 +168,53 @@ function auth_loadACL()
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try a token login
|
||||
*
|
||||
* @return bool true if token login succeeded
|
||||
*/
|
||||
function auth_tokenlogin() {
|
||||
global $USERINFO;
|
||||
global $INPUT;
|
||||
/** @var DokuWiki_Auth_Plugin $auth */
|
||||
global $auth;
|
||||
if(!$auth) return false;
|
||||
|
||||
// see if header has token
|
||||
$header = '';
|
||||
if(function_exists('apache_request_headers')) {
|
||||
// Authorization headers are not in $_SERVER for mod_php
|
||||
$headers = array_change_key_case(apache_request_headers());
|
||||
if(isset($headers['authorization'])) $header = $headers['authorization'];
|
||||
} else {
|
||||
$header = $INPUT->server->str('HTTP_AUTHORIZATION');
|
||||
}
|
||||
if(!$header) return false;
|
||||
list($type, $token) = sexplode(' ', $header, 2);
|
||||
if($type !== 'Bearer') return false;
|
||||
|
||||
// check token
|
||||
try {
|
||||
$authtoken = \dokuwiki\JWT::validate($token);
|
||||
} catch (Exception $e) {
|
||||
msg(hsc($e->getMessage()), -1);
|
||||
return false;
|
||||
}
|
||||
|
||||
// fetch user info from backend
|
||||
$user = $authtoken->getUser();
|
||||
$USERINFO = $auth->getUserData($user);
|
||||
if(!$USERINFO) return false;
|
||||
|
||||
// the code is correct, set up user
|
||||
$INPUT->server->set('REMOTE_USER', $user);
|
||||
$_SESSION[DOKU_COOKIE]['auth']['user'] = $user;
|
||||
$_SESSION[DOKU_COOKIE]['auth']['pass'] = 'nope';
|
||||
$_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event hook callback for AUTH_LOGIN_CHECK
|
||||
*
|
||||
|
@ -108,6 +108,11 @@ $lang['profconfdelete'] = 'I wish to remove my account from this wiki. <b
|
||||
$lang['profconfdeletemissing'] = 'Confirmation check box not ticked';
|
||||
$lang['proffail'] = 'User profile was not updated.';
|
||||
|
||||
|
||||
$lang['proftokenlegend'] = 'Authentication Token';
|
||||
$lang['proftokengenerate'] = 'Reset Token';
|
||||
$lang['proftokeninfo'] = 'The Authentication Token can be used to let 3rd party applications to log in and act on your behalf. Resetting the token will invalidate the old one and log out all applications that used the previous token.';
|
||||
|
||||
$lang['pwdforget'] = 'Forgotten your password? Get a new one';
|
||||
$lang['resendna'] = 'This wiki does not support password resending.';
|
||||
$lang['resendpwd'] = 'Set new password for';
|
||||
@ -397,4 +402,4 @@ $lang['email_signature_text'] = 'This mail was generated by DokuWiki at
|
||||
|
||||
$lang['log_file_too_large'] = 'Log file too large. Previous lines skipped!';
|
||||
$lang['log_file_failed_to_open'] = 'Failed to open log file.';
|
||||
$lang['log_file_failed_to_read'] = 'An error occurred while reading the log.';
|
||||
$lang['log_file_failed_to_read'] = 'An error occurred while reading the log.';
|
||||
|
Reference in New Issue
Block a user