7 Commits

Author SHA1 Message Date
Gitea Actions
1d1f7b0cfb Update manifest version to 2.4.2 [▶️] 2026-01-21 23:25:47 +00:00
2bf70a8ada Update README.md
All checks were successful
Build, Push, Publish / Build & Release (push) Successful in 25s
2026-01-21 23:25:23 +00:00
Gitea Actions
ae48bafbeb Update manifest version to 2.4.1 [▶️] 2026-01-11 23:48:24 +00:00
c14a54c802 Update plugin.php
All checks were successful
Build, Push, Publish / Build & Release (push) Successful in 22s
2026-01-11 23:48:14 +00:00
Gitea Actions
1bde8f701c Update manifest version to 2.4.0 [▶️] 2026-01-11 23:46:11 +00:00
474209dd2b Upload files to "/"
All checks were successful
Build, Push, Publish / Build & Release (push) Successful in 24s
2026-01-11 23:45:56 +00:00
Gitea Actions
961f35d0d1 Sync README from template [▶️] 2026-01-10 04:01:52 +00:00
4 changed files with 367 additions and 22 deletions

View File

@@ -1,19 +1,6 @@
# ICC Meta Redirect plugin for YOURLS # ICC Meta Redirect plugin for YOURLS
This YOURLS plugin make it possible to change logo, title, page footer, add custom CSS, and customize favicon lines into your YOURLS installation. This YOURLS plugin make it possible to change logo, title, page footer, add custom CSS, and customize favicon lines into your YOURLS installation.
<!-- buttons -->
[![Stars](https://img.shields.io/github/stars/ivancarlosti/yourlsiccwebmastersettings?label=⭐%20Stars&color=gold&style=flat)](https://github.com/ivancarlosti/yourlsiccwebmastersettings/stargazers)
[![Watchers](https://img.shields.io/github/watchers/ivancarlosti/yourlsiccwebmastersettings?label=Watchers&style=flat&color=red)](https://github.com/sponsors/ivancarlosti)
[![Forks](https://img.shields.io/github/forks/ivancarlosti/yourlsiccwebmastersettings?label=Forks&style=flat&color=ff69b4)](https://github.com/sponsors/ivancarlosti)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/ivancarlosti/yourlsiccwebmastersettings?label=Activity)](https://github.com/ivancarlosti/yourlsiccwebmastersettings/pulse)
[![GitHub Issues](https://img.shields.io/github/issues/ivancarlosti/yourlsiccwebmastersettings?label=Issues&color=orange)](https://github.com/ivancarlosti/yourlsiccwebmastersettings/issues)
[![License](https://img.shields.io/github/license/ivancarlosti/yourlsiccwebmastersettings?label=License)](LICENSE)
[![GitHub last commit](https://img.shields.io/github/last-commit/ivancarlosti/yourlsiccwebmastersettings?label=Last%20Commit)](https://github.com/ivancarlosti/yourlsiccwebmastersettings/commits)
[![Security](https://img.shields.io/badge/Security-View%20Here-purple)](https://github.com/ivancarlosti/yourlsiccwebmastersettings/security)
[![Code of Conduct](https://img.shields.io/badge/Code%20of%20Conduct-2.1-4baaaa)](https://github.com/ivancarlosti/yourlsiccwebmastersettings?tab=coc-ov-file)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/ivancarlosti?label=GitHub%20Sponsors&color=ffc0cb)][sponsor]
<!-- endbuttons -->
## Inspiration ## Inspiration
* Project inspired by [YOURLS-GWallChangeLogo](https://github.com/gioxx/YOURLS-GWallChangeLogo), [YOURLS-GWallChangeTitle](https://github.com/gioxx/YOURLS-GWallChangeTitle),. * Project inspired by [YOURLS-GWallChangeLogo](https://github.com/gioxx/YOURLS-GWallChangeLogo), [YOURLS-GWallChangeTitle](https://github.com/gioxx/YOURLS-GWallChangeTitle),.
@@ -57,12 +44,10 @@ input {
* For personal support and queries, please submit a new issue to have it addressed. * For personal support and queries, please submit a new issue to have it addressed.
* For commercial related questions, please [**contact me**][ivancarlos] for consulting costs. * For commercial related questions, please [**contact me**][ivancarlos] for consulting costs.
## 🩷 Project support | 🩷 Project support |
| If you found this project helpful, consider |
| :---: | | :---: |
[**buying me a coffee**][buymeacoffee] or [**supporting me on Patreon**][patreon] If you found this project helpful, consider [**buying me a coffee**][buymeacoffee]
|Thanks for your support, it is much appreciated!| |Thanks for your support, it is much appreciated!|
[ivancarlos]: https://ivancarlos.me [ivancarlos]: https://ivancarlos.me
[buymeacoffee]: https://www.buymeacoffee.com/ivancarlos [buymeacoffee]: https://www.buymeacoffee.com/ivancarlos
[patreon]: https://patreon.com/ivancarlos

243
authenticator.php Normal file
View File

@@ -0,0 +1,243 @@
<?php
class PHPGangsta_GoogleAuthenticator
{
protected $_codeLength = 6;
/**
* Create new secret.
* 16 characters, randomly chosen from the allowed base32 characters.
*
* @param int $secretLength
*
* @return string
*/
public function createSecret($secretLength = 16)
{
$validChars = $this->_getBase32LookupTable();
// Valid secret lengths are 80 to 640 bits
if ($secretLength < 16 || $secretLength > 128) {
throw new Exception('Bad secret length');
}
$secret = '';
$rnd = false;
if (function_exists('random_bytes')) {
$rnd = random_bytes($secretLength);
} elseif (function_exists('mcrypt_create_iv')) {
$rnd = mcrypt_create_iv($secretLength, MCRYPT_DEV_URANDOM);
} elseif (function_exists('openssl_random_pseudo_bytes')) {
$rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong);
if (!$cryptoStrong) {
$rnd = false;
}
}
if ($rnd !== false) {
for ($i = 0; $i < $secretLength; ++$i) {
$secret .= $validChars[ord($rnd[$i]) & 31];
}
} else {
throw new Exception('No source of secure random');
}
return $secret;
}
/**
* Calculate the code, with given secret and point in time.
*
* @param string $secret
* @param int|null $timeSlice
*
* @return string
*/
public function getCode($secret, $timeSlice = null)
{
if ($timeSlice === null) {
$timeSlice = floor(time() / 30);
}
$secretkey = $this->_base32Decode($secret);
// Pack time into binary string
$time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice);
// Hash it with users secret key
$hm = hash_hmac('SHA1', $time, $secretkey, true);
// Use last nipple of result as index/offset
$offset = ord(substr($hm, -1)) & 0x0F;
// grab 4 bytes of the result
$hashpart = substr($hm, $offset, 4);
// Unpak binary value
$value = unpack('N', $hashpart);
$value = $value[1];
// Only 32 bits
$value = $value & 0x7FFFFFFF;
$modulo = pow(10, $this->_codeLength);
return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT);
}
/**
* Get QR-Code URL for image, from google charts.
*
* @param string $name
* @param string $secret
* @param string $title
* @param array $params
*
* @return string
*/
public function getQRCodeGoogleUrl($name, $secret, $title = null, $params = array())
{
$width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200;
$height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200;
$level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M';
$urlencoded = urlencode('otpauth://totp/'.$name.'?secret='.$secret.'');
if (isset($title)) {
$urlencoded .= urlencode('&issuer='.urlencode($title));
}
return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size=${width}x${height}&ecc=$level";
}
/**
* Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now.
*
* @param string $secret
* @param string $code
* @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after)
* @param int|null $currentTimeSlice time slice if we want use other that time()
*
* @return bool
*/
public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null)
{
if ($currentTimeSlice === null) {
$currentTimeSlice = floor(time() / 30);
}
if (strlen($code) != 6) {
return false;
}
for ($i = -$discrepancy; $i <= $discrepancy; ++$i) {
$calculatedCode = $this->getCode($secret, $currentTimeSlice + $i);
if ($this->timingSafeEquals($calculatedCode, $code)) {
return true;
}
}
return false;
}
/**
* Set the code length, should be >=6.
*
* @param int $length
*
* @return PHPGangsta_GoogleAuthenticator
*/
public function setCodeLength($length)
{
$this->_codeLength = $length;
return $this;
}
/**
* Helper class to decode base32.
*
* @param $secret
*
* @return bool|string
*/
protected function _base32Decode($secret)
{
if (empty($secret)) {
return '';
}
$base32chars = $this->_getBase32LookupTable();
$base32charsFlipped = array_flip($base32chars);
$paddingCharCount = substr_count($secret, $base32chars[32]);
$allowedValues = array(6, 4, 3, 1, 0);
if (!in_array($paddingCharCount, $allowedValues)) {
return false;
}
for ($i = 0; $i < 4; ++$i) {
if ($paddingCharCount == $allowedValues[$i] &&
substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) {
return false;
}
}
$secret = str_replace('=', '', $secret);
$secret = str_split($secret);
$binaryString = '';
for ($i = 0; $i < count($secret); $i = $i + 8) {
$x = '';
if (!in_array($secret[$i], $base32chars)) {
return false;
}
for ($j = 0; $j < 8; ++$j) {
$x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT);
}
$eightBits = str_split($x, 8);
for ($z = 0; $z < count($eightBits); ++$z) {
$binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : '';
}
}
return $binaryString;
}
/**
* Get array with all 32 characters for decoding from/encoding to base32.
*
* @return array
*/
protected function _getBase32LookupTable()
{
return array(
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7
'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15
'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23
'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31
'=', // padding char
);
}
/**
* A timing safe equals comparison
* more info here: http://blog.ircmaxell.com/2014/11/its-all-about-time.html.
*
* @param string $safeString The internal (safe) value to be checked
* @param string $userString The user submitted (unsafe) value
*
* @return bool True if the two strings are identical
*/
private function timingSafeEquals($safeString, $userString)
{
if (function_exists('hash_equals')) {
return hash_equals($safeString, $userString);
}
$safeLen = strlen($safeString);
$userLen = strlen($userString);
if ($userLen != $safeLen) {
return false;
}
$result = 0;
for ($i = 0; $i < $userLen; ++$i) {
$result |= (ord($safeString[$i]) ^ ord($userString[$i]));
}
// They are only identical strings if $result is exactly 0...
return $result === 0;
}
}

View File

@@ -1,4 +1,4 @@
{ {
"version": "2.3.1", "version": "2.4.2",
"author": "Ivan Carlos" "author": "Ivan Carlos"
} }

View File

@@ -2,8 +2,8 @@
/* /*
Plugin Name: ICC Webmaster Settings Plugin Name: ICC Webmaster Settings
Plugin URI: https://git.icc.gg/ivancarlos/yourlsiccwebmastersettings Plugin URI: https://git.icc.gg/ivancarlos/yourlsiccwebmastersettings
Description: Customize Logo, Title, Footer, CSS & Favicons. Add reCAPTCHA v3, HTTP Redirect, 301/302 Redirects, Dash/Underscore, Force Lowercase & Remove Share features Description: Customize logo, title, footer, CSS, favicons, add 2FA & reCAPTCHA, HTTP, 301/302 redirects, allow dash/underscore, force lowercase, remove share buttons.
Version: 2.3 Version: 3.0
Author: Ivan Carlos Author: Ivan Carlos
Author URI: https://ivancarlos.com.br/ Author URI: https://ivancarlos.com.br/
*/ */
@@ -15,6 +15,9 @@ if (!defined('YOURLS_ABSPATH'))
// Default redirect delay in seconds (used when option unset) // Default redirect delay in seconds (used when option unset)
define('ICC_MRDR_DEFAULT_DELAY', 1); define('ICC_MRDR_DEFAULT_DELAY', 1);
// Load 2FA library
require_once 'authenticator.php';
// Register unified config page // Register unified config page
yourls_add_action('plugins_loaded', 'icc_config_add_page'); yourls_add_action('plugins_loaded', 'icc_config_add_page');
function icc_config_add_page() function icc_config_add_page()
@@ -96,6 +99,67 @@ function icc_config_do_page()
$icc_force_lowercase_enabled = yourls_get_option('icc_force_lowercase_enabled'); $icc_force_lowercase_enabled = yourls_get_option('icc_force_lowercase_enabled');
$force_lowercase_checked = $icc_force_lowercase_enabled ? 'checked' : ''; $force_lowercase_checked = $icc_force_lowercase_enabled ? 'checked' : '';
// 2FA options
$icc_2fa_tokens = json_decode(yourls_get_option('icc_2fa_tokens', '{}'), true);
$user_2fa = isset($icc_2fa_tokens[YOURLS_USER]) ? $icc_2fa_tokens[YOURLS_USER] : ['active' => false, 'secret' => '', 'type' => ''];
$is_2fa_active = $user_2fa['active'];
// Handle 2FA Actions
$twofa_message = '';
if (isset($_POST['icc_2fa_activate'])) {
$ga = new PHPGangsta_GoogleAuthenticator();
$secret = $ga->createSecret();
$icc_2fa_tokens[YOURLS_USER] = [
'active' => false,
'secret' => $secret,
'type' => 'otp'
];
yourls_update_option('icc_2fa_tokens', json_encode($icc_2fa_tokens));
$user_2fa = $icc_2fa_tokens[YOURLS_USER];
} elseif (isset($_POST['icc_2fa_verify'])) {
$token = isset($_POST['icc_2fa_token']) ? trim($_POST['icc_2fa_token']) : '';
$ga = new PHPGangsta_GoogleAuthenticator();
if ($ga->verifyCode($user_2fa['secret'], $token, 2)) {
$icc_2fa_tokens[YOURLS_USER]['active'] = true;
yourls_update_option('icc_2fa_tokens', json_encode($icc_2fa_tokens));
$is_2fa_active = true;
$twofa_message = '<p style="color:green;">2FA Activated successfully!</p>';
} else {
$twofa_message = '<p style="color:red;">Invalid token. Please try again.</p>';
}
} elseif (isset($_POST['icc_2fa_deactivate'])) {
$icc_2fa_tokens[YOURLS_USER]['active'] = false;
$icc_2fa_tokens[YOURLS_USER]['secret'] = '';
yourls_update_option('icc_2fa_tokens', json_encode($icc_2fa_tokens));
$is_2fa_active = false;
$twofa_message = '<p style="color:blue;">2FA Deactivated.</p>';
}
$twofa_html = '';
if ($is_2fa_active) {
$twofa_html = '<p>2FA is currently <strong>enabled</strong>.</p>
<form method="post">
<input type="submit" name="icc_2fa_deactivate" value="Deactivate 2FA" class="button" />
</form>';
} else {
if (isset($_POST['icc_2fa_activate']) || (isset($_POST['icc_2fa_verify']) && !$is_2fa_active)) {
$ga = new PHPGangsta_GoogleAuthenticator();
$qrCodeUrl = $ga->getQRCodeGoogleUrl('YOURLS (' . YOURLS_USER . ')', $user_2fa['secret']);
$twofa_html = '<p>1. Scan this QR code with your Authenticator app (Google Authenticator, Authy, etc.):</p>
<p><img src="' . $qrCodeUrl . '" style="border:1px solid #ccc;" /></p>
<p>2. Enter the 6-digit code from the app to verify:</p>
<form method="post">
<input type="text" name="icc_2fa_token" size="6" maxlength="6" autocomplete="off" />
<input type="submit" name="icc_2fa_verify" value="Verify and Activate" class="button" />
</form>';
} else {
$twofa_html = '<p>2FA is currently <strong>disabled</strong>.</p>
<form method="post">
<input type="submit" name="icc_2fa_activate" value="Setup 2FA" class="button" />
</form>';
}
}
echo <<<HTML echo <<<HTML
<h2>Webmaster Settings</h2> <h2>Webmaster Settings</h2>
<form method="post"> <form method="post">
@@ -176,10 +240,14 @@ function icc_config_do_page()
<p><input type="submit" name="icc_submit" value="Update values" /></p> <p><input type="submit" name="icc_submit" value="Update values" /></p>
</form> </form>
<hr style="margin-top: 40px" />
<h3>2FA (Two-Factor Authentication)</h3>
{$twofa_message}
{$twofa_html}
<hr style="margin-top: 40px" /> <hr style="margin-top: 40px" />
<p><strong><a href="https://ivancarlos.me/" target="_blank">Ivan Carlos</a></strong> &raquo; <p><strong><a href="https://ivancarlos.me/" target="_blank">Ivan Carlos</a></strong> &raquo;
<a href="https://buymeacoffee.com/ivancarlos" target="_blank">Buy Me a Coffee</a> &raquo; <a href="https://buymeacoffee.com/ivancarlos" target="_blank">Buy Me a Coffee</a></p>
<a href="https://patreon.com/ivancarlos" target="_blank">Patreon</a></p>
HTML; HTML;
} }
@@ -515,3 +583,52 @@ function icc_shunt_share_box($shunt)
{ {
return true; return true;
} }
// 2FA Support Logic
// Add 2FA input to the login form
yourls_add_action('login_form_bottom', 'icc_2fa_add_input');
function icc_2fa_add_input()
{
echo '<p>
<label for="icc_2fa_otp">' . yourls__('2FA Token') . '</label><br />
<input type="text" id="icc_2fa_otp" name="icc_2fa_otp" placeholder="' . yourls__('Leave empty if not enabled') . '" size="30" class="text" autocomplete="off" />
</p>';
}
// Attach 2FA validate function
yourls_add_filter('is_valid_user', 'icc_2fa_validate');
function icc_2fa_validate($is_valid)
{
// If user failed to properly authenticate, return
if (!$is_valid) {
return false;
}
// If cookies are set, we are already logged in OR if this is an API request, skip 2fa
if (isset($_COOKIE[yourls_cookie_name()]) || yourls_is_API()) {
return $is_valid;
}
$icc_2fa_tokens = json_decode(yourls_get_option('icc_2fa_tokens', '{}'), true);
if (!isset($icc_2fa_tokens[YOURLS_USER]) || !$icc_2fa_tokens[YOURLS_USER]['active']) {
// User has not enabled 2fa
return $is_valid;
}
// User has enabled 2FA
if ($icc_2fa_tokens[YOURLS_USER]['type'] == 'otp') {
$token = isset($_REQUEST['icc_2fa_otp']) ? trim($_REQUEST['icc_2fa_otp']) : '';
if (empty($token)) {
return false;
}
$ga = new PHPGangsta_GoogleAuthenticator();
if ($ga->verifyCode($icc_2fa_tokens[YOURLS_USER]['secret'], $token, 2)) {
return true;
}
}
return false;
}