First commit

This commit is contained in:
ebassi -
commit e3597abe8e
35 changed files with 2401 additions and 0 deletions

1
README.md Normal file
View file

@ -0,0 +1 @@
Link protection extension for phpBB. Work in progress

21
acp/link_replacement_info.php Executable file
View file

@ -0,0 +1,21 @@
<?php
namespace pedodev\linkprotection\acp;
class link_replacement_info
{
function module(): array
{
return array(
'filename' => '\pedodev\linkprotection\acp\link_replacement_module',
'title' => 'ACP_LINKPROTECTION_TITLE',
'modes' => array(
'settings' => array(
'title' => 'ACP_LINKPROTECTION_LINKREPLACEMENT',
'auth' => 'ext_pedodev/linkprotection && acl_a_board',
'cat' => ['ACP_LINKPROTECTION_TITLE']
),
),
);
}
}

245
acp/link_replacement_module.php Executable file
View file

@ -0,0 +1,245 @@
<?php
namespace pedodev\linkprotection\acp;
class link_replacement_module
{
private const CIPHER_WHITELIST = array('aes-128-cbc', 'aes-192-cbc', 'aes-256-cbc');
public string $u_action;
private $config, $request, $template, $user, $automatic_link_helper;
private array $automatic_links;
public function main(string $id, string $mode): void
{
global $phpbb_container;
$this->config = $phpbb_container->get('config');
$this->request = $phpbb_container->get('request');
$this->template = $phpbb_container->get('template');
$this->user = $phpbb_container->get('user');
$this->automatic_link_helper = $phpbb_container->get('pedodev.linkprotection.automatic_link_helper');
$this->tpl_name = 'acp_linkreplacement_body';
$this->page_title = $this->user->lang('ACP_LINKPROTECTION_TITLE');
$this->automatic_links = $this->automatic_link_helper->get_automatic_links();
add_form_key('pedodev/linkprotection');
if ($this->request->is_set_post('linkprotection_newkey'))
{
$this->update_key();
}
else if ($this->request->is_set_post('submit'))
{
$this->update_config();
}
$this->get_cipher_list();
$this->get_automatic_link_list();
$this->assign_template_vars();
}
private function check_form_key(): void
{
if (!check_form_key('pedodev/linkprotection'))
{
trigger_error('FORM_INVALID');
}
}
/*
* Called when the user clicks the 'Generate New Key' button
*/
private function update_key(): void
{
$this->check_form_key();
// openssl_cipher_key_length requires PHP >= 8.2
if (function_exists('openssl_cipher_key_length'))
{
$key_length = openssl_cipher_key_length($this->config['pedodev_linkprotection_cipher']);
}
else
{
$key_size = explode('-', $this->config['pedodev_linkprotection_cipher'])[1];
$key_length = $key_size / 8;
}
$new_key = base64_encode(random_bytes($key_length));
$this->config->set('pedodev_linkprotection_key', $new_key);
trigger_error($this->user->lang('ACP_LINKPROTECTION_KEY_UPDATED') . adm_back_link($this->u_action));
}
/*
* Called when the user clicks the 'Submit' button
*/
private function update_config(): void
{
$this->check_form_key();
$this->set_if_positive('pedodev_linkprotection_maxlinks', 'linkprotection_maxlinks');
$cipher = $this->request->variable('linkprotection_cipher', '');
if (!empty($cipher) && in_array($cipher, openssl_get_cipher_methods(), true))
{
$this->config->set('pedodev_linkprotection_cipher', $cipher);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $cipher, 'Cipher') . adm_back_link($this->u_action));
}
$this->config->set('pedodev_linkprotection_manualenabled', $this->request->variable('linkprotection_manualenabled', false));
$this->config->set('pedodev_linkprotection_automaticenabled', $this->request->variable('linkprotection_automaticenabled', false));
if ($this->request->is_set_post('linkprotection_strictmanual'))
{
$this->config->set('pedodev_linkprotection_strictmanual', $this->request->variable('linkprotection_strictmanual', false));
}
if ($this->request->is_set_post('linkprotection_manualtitle'))
{
$manual_title = preg_quote($this->request->variable('linkprotection_manualtitle', ''));
if (!empty($manual_title) && preg_match('#^[\w\. ]+$#', $manual_title))
{
$this->config->set('pedodev_linkprotection_manualtitle', $manual_title);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $manual_title, 'Default Title') . adm_back_link($this->u_action));
}
}
if ($this->request->is_set_post('linkprotection_manuallength'))
{
$this->set_if_positive('pedodev_linkprotection_manuallength', 'linkprotection_manuallength');
}
$manual_tags = preg_quote(rtrim(str_replace(' ', '', $this->request->variable('linkprotection_manualtags', '')), ','));
if (preg_match('#^([\w,]+)?$#', $manual_tags))
{
$this->config->set('pedodev_linkprotection_manualtags', $manual_tags);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $manual_tags, 'Additional Tags') . adm_back_link($this->u_action));
}
if ($this->request->is_set_post('linkprotection_automaticlength'))
{
$this->set_if_positive('pedodev_linkprotection_automaticlength', 'linkprotection_automaticlength');
}
$this->update_automatic_links();
trigger_error($this->user->lang('ACP_LINKPROTECTION_SETTING_SAVED') . adm_back_link($this->u_action));
}
/*
* Make sure the given variable is a positive number, otherwise output an error
*/
private function set_if_positive(string $conf, string $req): void
{
$value = $this->request->variable($req, 0);
if ($value > 0)
{
$this->config->set($conf, $value);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $value, $req) . adm_back_link($this->u_action));
}
}
private function update_automatic_links(): void
{
$counter = 0;
$update_automatic_links = false;
// Delete links
foreach ($this->automatic_links as $url => $title)
{
if ($this->request->is_set_post("linkprotection_automatic_delete_{$counter}"))
{
unset($this->automatic_links[$url]);
$update_automatic_links = true;
}
$counter++;
}
// Add link
if ($this->request->is_set_post('linkprotection_automatic_add_checkbox'))
{
$automatic_link_url = $this->request->variable('linkprotection_automatic_add_url', '');
$automatic_link_title = $this->request->variable('linkprotection_automatic_add_title', '');
if (!empty($automatic_link_url) && !empty($automatic_link_title))
{
$this->automatic_links[$automatic_link_url] = $automatic_link_title;
$update_automatic_links = true;
}
}
if ($update_automatic_links)
{
$this->automatic_link_helper->save_automatic_links($this->automatic_links);
}
}
private function get_cipher_list(): void
{
$cipher_list = array_intersect(
self::CIPHER_WHITELIST,
openssl_get_cipher_methods(),
);
foreach ($cipher_list as $cipher)
{
$this->template->assign_block_vars('cipher_types', [
'TYPE' => $cipher,
'NAME' => strtoupper($cipher),
]);
}
}
private function get_automatic_link_list(): void
{
$id = 0;
foreach ($this->automatic_links as $url => $title)
{
$this->template->assign_block_vars('automatic_links', [
'URL' => $url,
'TITLE' => $title,
'ID' => $id,
]);
$id++;
}
}
private function assign_template_vars(): void
{
$this->template->assign_vars(array(
'U_ACTION' => $this->u_action,
'LINKPROTECTION_MAXLINKS' => $this->config['pedodev_linkprotection_maxlinks'],
'LINKPROTECTION_KEY' => $this->config['pedodev_linkprotection_key'],
'LINKPROTECTION_MANUALENABLED' => $this->config['pedodev_linkprotection_manualenabled'],
'LINKPROTECTION_STRICTMANUAL' => $this->config['pedodev_linkprotection_strictmanual'],
'LINKPROTECTION_MANUALTITLE' => $this->config['pedodev_linkprotection_manualtitle'],
'LINKPROTECTION_MANUALLENGTH' => $this->config['pedodev_linkprotection_manuallength'],
'LINKPROTECTION_MANUALTAGS' => $this->config['pedodev_linkprotection_manualtags'],
'LINKPROTECTION_AUTOMATICENABLED' => $this->config['pedodev_linkprotection_automaticenabled'],
'LINKPROTECTION_AUTOMATICLENGTH' => $this->config['pedodev_linkprotection_automaticlength'],
'LINKPROTECTION_CIPHER' => $this->config['pedodev_linkprotection_cipher'],
));
}
}

21
acp/protected_page_info.php Executable file
View file

@ -0,0 +1,21 @@
<?php
namespace pedodev\linkprotection\acp;
class protected_page_info
{
function module(): array
{
return array(
'filename' => '\pedodev\linkprotection\acp\protected_page_module',
'title' => 'ACP_LINKPROTECTION_TITLE',
'modes' => array(
'settings' => array(
'title' => 'ACP_LINKPROTECTION_PROTECTED_PAGE',
'auth' => 'ext_pedodev/linkprotection && acl_a_board',
'cat' => ['ACP_LINKPROTECTION_TITLE']
),
),
);
}
}

190
acp/protected_page_module.php Executable file
View file

@ -0,0 +1,190 @@
<?php
namespace pedodev\linkprotection\acp;
class protected_page_module
{
public string $u_action;
private $db, $config, $request, $template, $user, $group_helper, $captcha_factory;
private array $group_links;
private array $captcha_list;
function main(string $id, string $mode): void
{
global $phpbb_container;
$this->db = $phpbb_container->get('dbal.conn');
$this->config = $phpbb_container->get('config');
$this->request = $phpbb_container->get('request');
$this->template = $phpbb_container->get('template');
$this->user = $phpbb_container->get('user');
$this->group_helper = $phpbb_container->get('group_helper');
$this->captcha_factory = $phpbb_container->get('captcha.factory');
$this->tpl_name = 'acp_protectedlinkpage_body';
$this->page_title = $this->user->lang('ACP_LINKPROTECTION_TITLE');
$group_links = json_decode($this->config['pedodev_linkprotection_grouplinks'], $associative = true);
$this->group_links = $group_links ? $group_links : array();
$captcha_types = $this->captcha_factory->get_captcha_types();
$this->captcha_list = $captcha_types['available'];
add_form_key('pedodev/linkprotection');
if ($this->request->is_set_post('submit'))
{
$this->update_config();
}
$this->get_captcha_list();
$this->get_group_list();
$this->assign_template_vars();
}
private function check_form_key(): void
{
if (!check_form_key('pedodev/linkprotection'))
{
trigger_error('FORM_INVALID');
}
}
/*
* Called when a user clicks the 'Submit' button
*/
private function update_config(): void
{
$this->check_form_key();
$protected_prefix = $this->request->variable('linkprotection_protectedprefix', '');
if (!empty($protected_prefix) && preg_match('#^[\w]+$#', $protected_prefix))
{
$this->config->set('pedodev_linkprotection_protectedprefix', $protected_prefix);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $protected_prefix, 'Protected Page Prefix') . adm_back_link($this->u_action));
}
$captcha = $this->request->variable('linkprotection_captcha', '');
if (!empty($captcha) && isset($this->captcha_list[$captcha]))
{
$this->config->set('pedodev_linkprotection_captcha', $captcha);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $captcha, 'Captcha') . adm_back_link($this->u_action));
}
$this->config->set('pedodev_linkprotection_showsubmit', $this->request->variable('linkprotection_showsubmit', false));
$this->set_if_positive('pedodev_linkprotection_solvetime', 'linkprotection_solvetime');
$this->config->set('pedodev_linkprotection_multiplelinks', $this->request->variable('linkprotection_multiplelinks', false));
if ($this->request->is_set_post('linkprotection_solveduration'))
{
$this->set_if_positive('pedodev_linkprotection_solveduration', 'linkprotection_solveduration');
}
$this->update_groups();
trigger_error($this->user->lang('ACP_LINKPROTECTION_SETTING_SAVED') . adm_back_link($this->u_action));
}
/*
* Make sure the given variable is a positive number, otherwise output an error
*/
private function set_if_positive(string $conf, string $req): void
{
$value = $this->request->variable($req, 0);
if ($value > 0)
{
$this->config->set($conf, $value);
}
else
{
trigger_error($this->user->lang('ACP_lINKPROTECTION_VALUE_ERROR', $value, $req) . adm_back_link($this->u_action));
}
}
private function update_groups(): void
{
$sql = 'SELECT g.group_id, g.group_name
FROM ' . GROUPS_TABLE . ' g
ORDER BY g.group_type ASC, g.group_name';
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
$id = (int)$row['group_id'];
$req = 'linkprotection_grouplinks_' . $id;
if ($this->request->is_set_post($req))
{
$num_links = $this->request->variable($req, 0);
if ($num_links > 0)
{
$this->group_links[$id] = $num_links;
}
}
}
$this->db->sql_freeresult($result);
$group_links_encoded = json_encode($this->group_links);
$this->config->set('pedodev_linkprotection_grouplinks', $group_links_encoded);
}
private function get_captcha_list(): void
{
foreach ($this->captcha_list as $type => $name)
{
$this->template->assign_block_vars('captcha_types', [
'TYPE' => $type,
'NAME' => $name,
]);
}
}
private function get_group_list(): void
{
$sql = 'SELECT g.group_id, g.group_name
FROM ' . GROUPS_TABLE . ' g
ORDER BY g.group_type ASC, g.group_name';
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
$id = (int)$row['group_id'];
$name = $this->group_helper->get_name($row['group_name']);
$this->template->assign_block_vars('group_links', [
'ID' => $id,
'NAME' => $name,
'VALUE' => isset($this->group_links[$id]) ? $this->group_links[$id] : 1,
]);
}
$this->db->sql_freeresult($result);
}
private function assign_template_vars(): void
{
$captcha = $this->captcha_factory->get_instance($this->config['pedodev_linkprotection_captcha']);
$this->template->assign_vars(array(
'U_ACTION' => $this->u_action,
'LINKPROTECTION_SHOWSUBMIT' => $this->config['pedodev_linkprotection_showsubmit'],
'LINKPROTECTION_SOLVETIME' => $this->config['pedodev_linkprotection_solvetime'],
'LINKPROTECTION_MULTIPLELINKS' => $this->config['pedodev_linkprotection_multiplelinks'],
'LINKPROTECTION_SOLVEDURATION' => $this->config['pedodev_linkprotection_solveduration'],
'LINKPROTECTION_CAPTCHA' => $this->config['pedodev_linkprotection_captcha'],
'LINKPROTECTION_PROTECTEDPREFIX' => $this->config['pedodev_linkprotection_protectedprefix'],
'LINKPROTECTION_CAPTCHA_PREVIEW' => $captcha->get_demo_template($id = 'captcha'),
));
}
}

View file

@ -0,0 +1,127 @@
{% INCLUDE 'overall_header.html' %}
<h1>{{ lang('SETTINGS') }}</h1>
<form id="acp_board" method="post" action="{{ U_ACTION }}">
<fieldset>
<legend>{{ lang('ACP_LINKPROTECTION_GENERAL') }}</legend>
<dl>
<dt><label for="linkprotection_maxlinks">{{ lang('ACP_LINKPROTECTION_MAXLINKS') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MAXLINKS_EXPLANATION') }}</span></dt>
<dd><input type="number" class="number" id="linkprotection_maxlinks" name="linkprotection_maxlinks" value="{{ LINKPROTECTION_MAXLINKS }}" min="1"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_key">{{ lang('ACP_LINKPROTECTION_KEY') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_KEY_EXPLANATION') }}<br><span style="color: crimson">{{ lang('ACP_LINKPROTECTION_WARNING') }}</span></span></dt>
<dd><input type="text" class="text" id="linkprotection_key" name="linkprotection_key" value="{{ LINKPROTECTION_KEY }}" readonly/>&nbsp;
<input class="button1" type="submit" id="linkprotection_newkey" name="linkprotection_newkey" value="{{ lang('ACP_LINKPROTECTION_NEWKEY') }}" /></dd>
</dl>
<dl>
<dt><label for="linkprotection_cipher">{{ lang('ACP_LINKPROTECTION_CIPHER') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_CIPHER_EXPLANATION') }}<br><span style="color: crimson">{{ lang('ACP_LINKPROTECTION_WARNING') }}</span></span></dt>
<dd><select name="linkprotection_cipher" id="linkprotection_cipher">
{% for CIPHER in loops.cipher_types %}
<option value={{ CIPHER.TYPE }} {% if CIPHER.TYPE === LINKPROTECTION_CIPHER %}selected="selected"{% endif %}>{{ CIPHER.NAME }}</option>
{% endfor %}
</select></dd>
</dl>
</fieldset>
<fieldset>
<legend>{{ lang('ACP_LINKPROTECTION_MANUAL') }}</legend>
<dl>
<dt><label for="linkprotection_manualenabled">{{ lang('ACP_LINKPROTECTION_MANUALENABLED') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MANUALENABLED_EXPLANATION') }}</span></dt>
<label><input type="radio" class="radio" name="linkprotection_manualenabled" value="1" id="linkprotection_manualenabled" {% if LINKPROTECTION_MANUALENABLED %}checked="checked"{% endif %} /> {{ lang('YES') }}</label>
<label><input type="radio" class="radio" name="linkprotection_manualenabled" value="0" {% if not LINKPROTECTION_MANUALENABLED %}checked="checked"{% endif %} /> {{ lang('NO') }}</label>
</dl>
{% if LINKPROTECTION_MANUALENABLED %}
<dl>
<dt><label for="linkprotection_strictmanual">{{ lang('ACP_LINKPROTECTION_STRICTMANUAL') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_STRICTMANUAL_EXPLANATION') }}</span></dt>
<label><input type="radio" class="radio" name="linkprotection_strictmanual" value="1" id="linkprotection_strictmanual" {% if LINKPROTECTION_STRICTMANUAL %}checked="checked"{% endif %} /> {{ lang('YES') }}</label>
<label><input type="radio" class="radio" name="linkprotection_strictmanual" value="0" {% if not LINKPROTECTION_STRICTMANUAL %}checked="checked"{% endif %} /> {{ lang('NO') }}</label>
</dl>
<dl>
<dt><label for="linkprotection_manualtitle">{{ lang('ACP_LINKPROTECTION_MANUALTITLE') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MANUALTITLE_EXPLANATION') }}</span></dt>
<dd><input type="text" class="text" id="linkprotection_manualtitle" name="linkprotection_manualtitle" value="{{ LINKPROTECTION_MANUALTITLE }}"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_manuallength">{{ lang('ACP_LINKPROTECTION_MANUALLENGTH') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MANUALLENGTH_EXPLANATION') }}</span></dt>
<dd><input type="number" class="number" id="linkprotection_manuallength" name="linkprotection_manuallength" value="{{ LINKPROTECTION_MANUALLENGTH }}" min="1"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_manualtags">{{ lang('ACP_LINKPROTECTION_MANUALTAGS') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MANUALTAGS_EXPLANATION') }}</span></dt>
<dd><input type="text" class="text" id="linkprotection_manualtags" name="linkprotection_manualtags" value="{{ LINKPROTECTION_MANUALTAGS }}"/></dd>
</dl>
{% endif %}
</fieldset>
<fieldset>
<legend>{{ lang('ACP_LINKPROTECTION_AUTOMATIC') }}</legend>
<dl>
<dt><label for="linkprotection_automaticenabled">{{ lang('ACP_LINKPROTECTION_AUTOMATICENABLED') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_AUTOMATICENABLED_EXPLANATION') }}</span></dt>
<label><input type="radio" class="radio" name="linkprotection_automaticenabled" value="1" id="linkprotection_automaticenabled" {% if LINKPROTECTION_AUTOMATICENABLED %}checked="checked"{% endif %} /> {{ lang('YES') }}</label>
<label><input type="radio" class="radio" name="linkprotection_automaticenabled" value="0" {% if not LINKPROTECTION_AUTOMATICENABLED %}checked="checked"{% endif %} /> {{ lang('NO') }}</label>
</dl>
{% if LINKPROTECTION_AUTOMATICENABLED %}
<dl>
<dt><label for="linkprotection_automaticlength">{{ lang('ACP_LINKPROTECTION_AUTOMATICLENGTH') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_AUTOMATICLENGTH_EXPLANATION') }}</span></dt>
<dd><input type="number" class="number" id="linkprotection_automaticlength" name="linkprotection_automaticlength" value="{{ LINKPROTECTION_AUTOMATICLENGTH }}" min="1"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_automaticlinks">{{ lang('ACP_LINKPROTECTION_AUTOMATICLINKS') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_AUTOMATICLINKS_EXPLANATION') }}</span></dt>
<dd><table class="zebra-table" style="width:75%">
<thead>
<tr>
<th width="45%">URL Match</th>
<th width="45%">Protected Link Title</th>
<th width="10%">Delete</th>
</tr>
</thead>
<tbody>
{% if loops.automatic_links|length %}
{% for ITEM in loops.automatic_links %}
<tr>
<td>{{ ITEM.URL }}</td>
<td>{{ ITEM.TITLE }}</td>
<td><input type='checkbox' class='checkbox' name='linkprotection_automatic_delete_{{ ITEM.ID }}' value='1'></td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
<table style="width:75%">
<tr>
<th width="45%">URL Match</th>
<th width="45%">Protected Link Title</th>
<th width="45%">Add</th>
</tr>
<tr>
<td><input type="text" class="text" name="linkprotection_automatic_add_url" value=""></td>
<td><input type="text" class="text" name="linkprotection_automatic_add_title" value=""></td>
<td><input type="checkbox" class="checkbox" name="linkprotection_automatic_add_checkbox" value="1"></td>
</tr>
</table></dd>
</dl>
{% endif %}
</fieldset>
<fieldset>
<p class="submit-buttons">
<input class="button1" type="submit" id="submit" name="submit" value="{{ lang('SUBMIT') }}" />&nbsp;
<input class="button2" type="reset" id="reset" name="reset" value="{{ lang('RESET') }}" />
</p>
{{ S_FORM_TOKEN }}
</fieldset>
</form>
{% INCLUDE 'overall_footer.html' %}

View file

@ -0,0 +1,84 @@
{% INCLUDE 'overall_header.html' %}
<h1>{{ lang('SETTINGS') }}</h1>
<form id="acp_board" method="post" action="{{ U_ACTION }}">
<fieldset>
<dl>
<dt><label for="linkprotection_protectedprefix">{{ lang('ACP_LINKPROTECTION_PROTECTEDPREFIX') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_PROTECTEDPREFIX_EXPLANATION') }}</span></dt>
<dd><input type="text" class="text" id="linkprotection_protectedprefix" name="linkprotection_protectedprefix" value="{{ LINKPROTECTION_PROTECTEDPREFIX }}"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_captcha">{{ lang('ACP_LINKPROTECTION_CAPTCHA') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_CAPTCHA_EXPLANATION') }}</span></dt>
<dd><select name="linkprotection_captcha" id="linkprotection_captcha">
{% for CAPTCHA in loops.captcha_types %}
<option value={{ CAPTCHA.TYPE }} {% if CAPTCHA.TYPE === LINKPROTECTION_CAPTCHA %}selected="selected"{% endif %}>{{ CAPTCHA.NAME }}</option>
{% endfor %}
</select></dd>
</dl>
<dl>
<dt><label for="linkprotection_showsubmit">{{ lang('ACP_LINKPROTECTION_SHOWSUBMIT') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_SHOWSUBMIT_EXPLANATION') }}</span></dt>
<label><input type="radio" class="radio" name="linkprotection_showsubmit" value="1" id="linkprotection_showsubmit" {% if LINKPROTECTION_SHOWSUBMIT %}checked="checked"{% endif %} /> {{ lang('YES') }}</label>
<label><input type="radio" class="radio" name="linkprotection_showsubmit" value="0" {% if not LINKPROTECTION_SHOWSUBMIT %}checked="checked"{% endif %} /> {{ lang('NO') }}</label>
</dl>
<dl>
<dt><label for="linkprotection_solvetime">{{ lang('ACP_LINKPROTECTION_SOLVETIME') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_SOLVETIME_EXPLANATION') }}</span></dt>
<dd><input type="number" class="number" id="linkprotection_solvetime" name="linkprotection_solvetime" value="{{ LINKPROTECTION_SOLVETIME }}" min="1"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_multiplelinks">{{ lang('ACP_LINKPROTECTION_MULTIPLELINKS') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_MULTIPLELINKS_EXPLANATION') }}</span></dt>
<label><input type="radio" class="radio" name="linkprotection_multiplelinks" value="1" id="linkprotection_multiplelinks" {% if LINKPROTECTION_MULTIPLELINKS %}checked="checked"{% endif %} /> {{ lang('YES') }}</label>
<label><input type="radio" class="radio" name="linkprotection_multiplelinks" value="0" {% if not LINKPROTECTION_MULTIPLELINKS %}checked="checked"{% endif %} /> {{ lang('NO') }}</label>
</dl>
{% if LINKPROTECTION_MULTIPLELINKS %}
<dl>
<dt><label for="linkprotection_solveduration">{{ lang('ACP_LINKPROTECTION_SOLVEDURATION') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_SOLVEDURATION_EXPLANATION') }}</span></dt>
<dd><input type="number" class="number" id="linkprotection_solveduration" name="linkprotection_solveduration" value="{{ LINKPROTECTION_SOLVEDURATION }}" min="1"/></dd>
</dl>
<dl>
<dt><label for="linkprotection_grouplinks">{{ lang('ACP_LINKPROTECTION_GROUPLINKS') ~ lang('COLON') }}</label><br /><span>{{ lang('ACP_LINKPROTECTION_GROUPLINKS_EXPLANATION') }}</span></dt>
<dd><table class="zebra-table" style="width:75%">
<thead>
<tr>
<th width="80%">Group Name</th>
<th width="20%">Number of Links</th>
</tr>
</thead>
<tbody>
{% for GROUP in loops.group_links %}
<tr>
<td>{{ GROUP.NAME }}</td>
<td><input type="number" class="number" id="linkprotection_grouplinks_{{ GROUP.ID }}" name="linkprotection_grouplinks_{{ GROUP.ID }}" value="{{ GROUP.VALUE }}" min="1"/></td>
</tr>
{% endfor %}
</tbody>
</table></dd>
</dl>
{% endif %}
</fieldset>
<fieldset>
<legend>{{ lang('ACP_LINKPROTECTION_DEMO') }}</legend>
{% INCLUDE LINKPROTECTION_CAPTCHA_PREVIEW %}
</fieldset>
<fieldset>
<p class="submit-buttons">
<input class="button1" type="submit" id="submit" name="submit" value="{{ lang('SUBMIT') }}" />&nbsp;
<input class="button2" type="reset" id="reset" name="reset" value="{{ lang('RESET') }}" />
</p>
{{ S_FORM_TOKEN }}
</fieldset>
</form>
{% INCLUDE 'overall_footer.html' %}

22
composer.json Executable file
View file

@ -0,0 +1,22 @@
{
"name": "pedodev/linkprotection",
"type": "phpbb-extension",
"description": "Link Protection system for phpBB 3.3. Allows users to protect links through the use of a captcha",
"version": "1.0.0",
"license": "None",
"authors": [
{
"name": "PedoDeveloper"
}
],
"require": {
"php": ">=8.0",
"composer/installers": "~1.0"
},
"extra": {
"display-name": "Link Protection",
"soft-require": {
"phpbb/phpbb": ">=3.3"
}
}
}

12
config/routing.yml Executable file
View file

@ -0,0 +1,12 @@
pedodev_linkprotection_protected_page_route:
path: /{protected_prefix}/link/{link}
defaults: { _controller: pedodev.linkprotection.protected_page_controller:handle, link: '' }
requirements:
protected_prefix: '\w+'
link: '[\w\-]+\.{0,2}'
pedodev_linkprotection_protected_captcha_route:
path: /{protected_prefix}/captcha
defaults: { _controller: pedodev.linkprotection.protected_page_captcha_controller:handle, }
requirements:
protected_prefix: '\w+'

88
config/services.yml Executable file
View file

@ -0,0 +1,88 @@
services:
pedodev.linkprotection.crypto:
class: pedodev\linkprotection\core\crypto
arguments:
- '@config'
pedodev.linkprotection.parser:
class: pedodev\linkprotection\core\parser
arguments:
- '@config'
pedodev.linkprotection.automatic_link_helper:
class: pedodev\linkprotection\core\automatic_link_helper
arguments:
- '@config'
pedodev.linkprotection.link_replacer:
class: pedodev\linkprotection\core\link_replacer
arguments:
- '@config'
- '@auth'
- '@user'
- '@language'
- '@pedodev.linkprotection.parser'
- '@pedodev.linkprotection.crypto'
- '@pedodev.linkprotection.automatic_link_helper'
pedodev.linkprotection.cache_helper:
class: pedodev\linkprotection\core\cache_helper
arguments:
- '@cache.driver'
- '@user'
- '@config'
pedodev.linkprotection.make_post_listener:
class: pedodev\linkprotection\event\make_post_listener
arguments:
- '@pedodev.linkprotection.link_replacer'
- '@language'
tags:
- { name: event.listener }
pedodev.linkprotection.edit_post_listener:
class: pedodev\linkprotection\event\edit_post_listener
arguments:
- '@pedodev.linkprotection.link_replacer'
tags:
- { name: event.listener }
pedodev.linkprotection.preview_post_listener:
class: pedodev\linkprotection\event\preview_post_listener
arguments:
- '@pedodev.linkprotection.link_replacer'
tags:
- { name: event.listener }
pedodev.linkprotection.bbcode_listener:
class: pedodev\linkprotection\event\bbcode_listener
tags:
- { name: event.listener }
arguments:
- '@controller.helper'
- '@config'
pedodev.linkprotection.permissions_listener:
class: pedodev\linkprotection\event\permissions
tags:
- { name: event.listener }
pedodev.linkprotection.protected_page_controller:
class: pedodev\linkprotection\controller\protected_page
arguments:
- '@config'
- '@controller.helper'
- '@template'
- '@user'
- '@captcha.factory'
- '@request'
- '@pedodev.linkprotection.crypto'
- '@pedodev.linkprotection.cache_helper'
- '@auth'
- '@language'
pedodev.linkprotection.protected_page_captcha_controller:
class: pedodev\linkprotection\controller\protected_page_captcha
arguments:
- '@config'
- '@captcha.factory'

272
controller/protected_page.php Executable file
View file

@ -0,0 +1,272 @@
<?php
/*
* Our protected link page
* Displays the captcha. When the captcha is solved correctly, displays the link
* Also uses caching to let a user view multiple links after solving a captcha, if enabled
*/
namespace pedodev\linkprotection\controller;
use phpbb\config\config;
use phpbb\controller\helper;
use phpbb\template\template;
use phpbb\user;
use phpbb\captcha\factory;
use phpbb\request\request_interface;
use pedodev\linkprotection\core\crypto;
use pedodev\linkprotection\core\cache_helper;
use phpbb\auth\auth;
use phpbb\language\language;
class protected_page
{
private object $captcha;
private array $errors;
private bool $bypass;
private array $group_links;
public function __construct(
private config $config,
private helper $helper,
private template $template,
private user $user,
private factory $captcha_factory,
private request_interface $request,
private crypto $crypto,
private cache_helper $cache_helper,
private auth $auth,
private language $language,
)
{
$this->errors = array();
$this->bypass = false;
$group_links = json_decode($this->config['pedodev_linkprotection_grouplinks'], $associative = true);
$this->group_links = $group_links ? $group_links : array();
}
/*
* Our main function. Called when the page is opened
*/
public function handle(string $link, string $protected_prefix): object
{
$this->language->add_lang('protected_page', 'pedodev/linkprotection');
if ($protected_prefix !== $this->config['pedodev_linkprotection_protectedprefix'])
{
throw new \phpbb\exception\http_exception(403, 'LINKPROTECTION_ROUTE_ERROR');
}
else if ($link === 'preview')
{
$this->display_preview();
}
else if (!$this->auth->acl_get('u_pedodev_linkprotection_canviewlinks'))
{
$this->errors[] = $this->language->lang('LINKPROTECTION_PERMISSION_ERROR');
}
// check whether the user has bypass permissions or a cached link 'allowance'
else if ($this->can_view_link())
{
$this->cache_helper->decrement_remaining_links();
$this->display_link($link);
}
// User is trying to solve the captcha
else if ($this->request->is_set_post('submit'))
{
$this->load_captcha();
if ($this->validate_form())
{
$this->captcha->reset();
$this->cache_helper->save_object($this->get_num_links());
$this->display_link($link);
}
else
{
$this->display_captcha();
}
}
else {
$this->load_captcha();
$this->captcha->reset();
$this->display_captcha();
}
$this->display_errors();
return $this->helper->render('protected_page.html', $link);
}
private function load_captcha(): void
{
$this->captcha = $this->captcha_factory->get_instance($this->config['pedodev_linkprotection_captcha']);
// CONFIRM_LOGIN is a captcha style defined in includes/constants.php
$this->captcha->init(CONFIRM_LOGIN);
}
/*
* Checks whether the user can bypass or has a cached link allowance
*/
private function can_view_link(): bool
{
if ($this->auth->acl_get('m_pedodev_linkprotection_canbypasscaptcha'))
{
$this->bypass = true;
return true;
}
$this->cache_helper->read_object();
return ($this->cache_helper->get_remaining_links() > 0);
}
private function validate_form(): bool
{
if (!check_form_key('pedodev_linkprotection'))
{
trigger_error('FORM_INVALID');
}
return ($this->validate_time() && $this->validate_captcha());
}
/*
* Make sure captcha is not expired
*/
private function validate_time(): bool
{
$current_time = time();
$creation_time = (int)$this->request->variable('creation_time', 0, request_interface::POST);
$solve_time = $this->config['pedodev_linkprotection_solvetime'];
if ($current_time < $creation_time || $current_time > ($creation_time + $solve_time))
{
$this->captcha->reset();
$this->errors[] = $this->language->lang('LINKPROTECTION_CAPTCHA_EXPIRED');
return false;
}
return true;
}
/*
* Make sure captcha was solved correctly
*/
private function validate_captcha(): bool
{
$error = $this->captcha->validate();
if (empty($error))
{
return true;
}
$this->errors[] = $error;
return false;
}
/*
* Get the number of links a user can view after solving a captcha
*/
private function get_num_links(): int
{
$group_id = $this->user->data['group_id'];
$links = (isset($this->group_links[$group_id]) ? $this->group_links[$group_id] : 1);
// Substract 1 because they have already viewed 1 link on this page
return ($links - 1);
}
private function display_link(string $link): void
{
// Make sure the link is valid encoded base64. Technically unnecessary because it is already checked by the routing system
if (!preg_match('#^[\w\-]+\.{0,2}$#', $link))
{
$this->error[] = $this->language->lang('LINKPROTECTION_LINK_ERROR');
return;
}
$link_data = $this->crypto->decrypt_link($link);
// Make sure link decrypted successfully
if (!$link_data)
{
$this->errors[] = $this->language->lang('LINKPROTECTION_DECRYPTION_ERROR');
return;
}
$unhidden_link = $link_data['link'];
// If link is a valid URL, we output a link to that URL. Otherwise the link is to '#' (a link that does nothing)
$link_source = (filter_var($unhidden_link, FILTER_VALIDATE_URL) ? $unhidden_link : '#');
$info_message = '';
$remaining_links = $this->cache_helper->get_remaining_links();
if ($this->bypass)
{
$info_message = $this->language->lang('LINKPROTECTION_BYPASS');
}
else if ($remaining_links > 0)
{
$remaining_time = $this->cache_helper->get_remaining_time();
$info_message = $this->language->lang('LINKPROTECTION_REMAINING_LINKS', $remaining_links, $remaining_time);
}
else if ($remaining_links == 0)
{
$info_message = $this->language->lang('LINKPROTECTION_NO_LINKS_REMAINING');
}
$this->template->assign_vars([
'LINKPROTECTION_SHOW_LINK' => 1,
'LINKPROTECTION_UNHIDDEN_LINK' => $unhidden_link,
'LINKPROTECTION_LINK_SOURCE' => $link_source,
'LINKPROTECTION_INFO_MESSAGE' => $info_message,
'LINKPROTECTION_SOLVED_MESSAGE' => $this->language->lang('LINKPROTECTION_CAPTCHA_SOLVED'),
]);
}
private function display_captcha(): void
{
$this->template->assign_var('LINKPROTECTION_SOLVE_CAPTCHA_MESSAGE', $this->user->lang('LINKPROTECTION_SOLVE_CAPTCHA'));
$hidden_fields = $this->captcha->get_hidden_fields();
$captcha_template = $this->captcha->get_template();
$route = $this->helper->route('pedodev_linkprotection_protected_captcha_route', array('protected_prefix' => $this->config['pedodev_linkprotection_protectedprefix']), $append_sid = false);
$captcha_link = "{$route}?confirm_id={$hidden_fields['confirm_id']}";
$captcha_src = '<img src="' . $captcha_link . '" />';
add_form_key('pedodev_linkprotection');
$this->template->assign_vars(array(
'LINKPROTECTION_SHOW_CAPTCHA' => 1,
'LINKPROTECTION_SHOWSUBMIT' => (bool)$this->config['pedodev_linkprotection_showsubmit'],
'LINKPROTECTION_CAPTCHA' => $captcha_template,
'CONFIRM_IMAGE_LINK' => $captcha_link,
'CONFIRM_IMAGE' => $captcha_src,
'CONFIRM_IMG' => $captcha_src,
));
}
private function display_preview(): void
{
$this->template->assign_var('LINKPROTECTION_PREVIEW', 1);
}
private function display_errors(): void
{
foreach ($this->errors as $error)
{
$this->template->assign_block_vars('errors', [
'TEXT' => $error,
]);
}
}
}

View file

@ -0,0 +1,35 @@
<?php
/*
* This controller simply outputs the captcha image on the protected page
*/
namespace pedodev\linkprotection\controller;
use phpbb\config\config;
use phpbb\captcha\factory;
class protected_page_captcha
{
public function __construct(
private config $config,
private factory $captcha_factory
) {}
public function handle(string $protected_prefix): object
{
if ($protected_prefix !== $this->config['pedodev_linkprotection_protectedprefix'])
{
throw new \phpbb\exception\http_exception(403, 'INVALID ROUTE');
}
$captcha = $this->captcha_factory->get_instance($this->config['pedodev_linkprotection_captcha']);
// CONFIRM_LOGIN is a captcha style defined in includes/constants.php
$captcha->init(CONFIRM_LOGIN);
$captcha->execute();
garbage_collection();
exit_handler();
}
}

View file

@ -0,0 +1,86 @@
<?php
namespace pedodev\linkprotection\core;
use phpbb\config\config;
class automatic_link_helper
{
private string $filepath;
private array $automatic_links = array();
public function __construct(config $config)
{
$this->filepath = __DIR__ . "/../automatic_links_{$config['pedodev_linkprotection_fileprefix']}.json";
if ($config['pedodev_linkprotection_automaticenabled'])
{
$this->load_automatic_links();
}
}
public function load_automatic_links(): void
{
if (!file_exists($this->filepath))
{
return;
}
$automatic_links_json = file_get_contents($this->filepath);
var_dump($automatic_links_json);
if ($automatic_links_json && ctype_print($automatic_links_json))
{
$this->automatic_links = json_decode($automatic_links_json, $associative = true);
}
}
public function save_automatic_links(array $automatic_links): void
{
if (!touch($this->filepath) || !chmod($this->filepath, 0600))
{
trigger_error("Unable to touch automatic link JSON file. Please check your file and ownership permissions", E_USER_ERROR);
}
$automatic_links_json = json_encode($automatic_links);
if (!$automatic_links_json || !ctype_print($automatic_links_json))
{
trigger_error("Unable to encode automatic link list to JSON format", E_USER_ERROR);
}
if (file_put_contents($this->filepath, $automatic_links_json) === false)
{
trigger_error("Unable to write automatic link list to file", E_USER_ERROR);
}
if (!chmod($this->filepath, 0400))
{
trigger_error("Unable to set automatic link JSON file permissions", E_USER_WARNING);
}
$this->automatic_links = $automatic_links;
}
public function get_automatic_links(): array
{
return $this->automatic_links;
}
/*
* Checks whether the given link contains a substring match in any of the automatic links configured in the admin CP
* Returns the title of the automatic link if a match is found, otherwise returns null
*/
public function find_substring_match(string $link): ?string
{
foreach ($this->automatic_links as $url => $title)
{
if (str_contains($link, $url))
{
return $title;
}
}
return null;
}
}

98
core/cache_helper.php Executable file
View file

@ -0,0 +1,98 @@
<?php
/*
* The cache helper is used by the protected link page when the 'multiple links' option is enabled.
* It handles reading and writing to the cache the data object which stores the number of remaining links and the remaining time a user has after solving a captcha
* data['links'] is the number of remaining links, while data['time'] is a UNIX timestamp containing the time at which the duration of the object expires
*/
namespace pedodev\linkprotection\core;
use phpbb\cache\driver\driver_interface;
use phpbb\user;
use phpbb\config\config;
class cache_helper
{
private string $name;
private array $data;
public function __construct(
private driver_interface $cache,
private user $user,
private config $config,
) {}
private function write_object(int $time): void
{
$this->cache->put(
$this->name,
$this->data,
$time,
);
}
/*
* Read from the cache and store it in $this->data
*/
public function read_object(): void
{
if (!$this->config['pedodev_linkprotection_multiplelinks'])
{
return;
}
$this->name = 'linkprotection_' . $this->user->data['user_id'];
$this->data = array();
if ($this->cache->_exists($this->name))
{
$this->data = $this->cache->get($this->name);
}
}
/*
* Called when a user solves a captcha. Stores the number of links (depends on their group) and the duration in the cache
*/
public function save_object(int $num_links): void
{
if (!$this->config['pedodev_linkprotection_multiplelinks'] || $num_links <= 0)
{
return;
}
$save_duration = (int)$this->config['pedodev_linkprotection_solveduration'];
$this->name = 'linkprotection_' . $this->user->data['user_id'];
$this->data = array(
'links' => $num_links,
'time' => time() + $save_duration,
);
$this->write_object($save_duration);
}
/*
* Called when a user visits a link without solving a captcha. Decreases their link 'allowance' by 1.
*/
public function decrement_remaining_links(): void
{
if (empty($this->data) || !isset($this->data['links']))
{
return;
}
$this->data['links'] -= 1;
$this->write_object($this->get_remaining_time());
}
public function get_remaining_links(): int
{
return ((isset($this->data) && isset($this->data['links'])) ? $this->data['links'] : -1);
}
public function get_remaining_time(): int
{
return ((isset($this->data) && isset($this->data['time'])) ? ($this->data['time'] - time()) : -1);
}
}

132
core/crypto.php Executable file
View file

@ -0,0 +1,132 @@
<?php
/*
* This is our crypto class which handles encryption and decryption of links.
* It is used by both the link replacement and protected link page.
* Encryption uses an explicit initialization vector.
* The user ID and post ID are encoded into a binary string and prepended to the plaintext so that they can be retrieved later upon decryption.
*/
namespace pedodev\linkprotection\core;
use phpbb\config\config;
class crypto
{
private const INT_32_SIZE = 4;
private string $key;
private string $cipher;
private int $iv_length;
private string $binary_vars;
public function __construct(config $config)
{
$this->key = base64_decode($config['pedodev_linkprotection_key']);
$this->cipher = $config['pedodev_linkprotection_cipher'];
$this->iv_length = openssl_cipher_iv_length($this->cipher);
var_dump($this->iv_length);
}
/*
* Packs two unsigned 32-bit integers into an 8-byte binary string
*/
public function set_vars(int $user_id, int $post_id): void
{
$this->binary_vars = pack('L2', $user_id, $post_id);
}
/*
* The inverse of the set_vars function.
* Unpacks the 8-byte binary string into an array of two integers
*/
private function get_binary_vars(string $plaintext): array|bool
{
$binary_string = substr($plaintext, $this->iv_length);
return unpack('Luser_id/Lpost_id', $binary_string);
}
/*
* Encodes base64 data so it can be used in URLs, replacing + / = with - _ . respectively
*/
private function base64_url_encode(string $input): string
{
return strtr($input, '+/=', '-_.');
}
/*
* The inverse of the base64_url_encode function
*/
private function base64_url_decode(string $input): string
{
return strtr($input, '-_.', '+/=');
}
/*
* Pad the plaintext with the explicit IV and the 8-byte binary string
*/
private function pad_link(string $link): string
{
$explicit_iv = openssl_random_pseudo_bytes($this->iv_length);
return $explicit_iv . $this->binary_vars . $link;
}
/*
* Get the decrypted link without the explicit IV or binary string
*/
private function get_link(string $plaintext): string
{
$link_offset = $this->iv_length + 2 * self::INT_32_SIZE;
return substr($plaintext, $link_offset);
}
/*
* Encrypt a given link and returns it in URL encoded base64
* Returns null on failure
*/
public function encrypt_link(string $link): ?string
{
$iv = openssl_random_pseudo_bytes($this->iv_length);
$padded_link = $this->pad_link($link);
$ciphertext = openssl_encrypt($padded_link, $this->cipher, $this->key, $options = 0, $iv);
return ($ciphertext ? $this->base64_url_encode($ciphertext) : null);
}
/*
* Decrypt data and returns an array containing the original link, the user ID and the post ID
* Returns null on failure
*/
public function decrypt_link(string $encoded_ciphertext): ?array
{
$ciphertext = $this->base64_url_decode($encoded_ciphertext);
$iv = openssl_random_pseudo_bytes($this->iv_length);
$plaintext = openssl_decrypt($ciphertext, $this->cipher, $this->key, $options = 0, $iv);
if (!$plaintext)
{
return null;
}
$binary_vars = $this->get_binary_vars($plaintext);
if (!$binary_vars)
{
return null;
}
$link = $this->get_link($plaintext);
if (!ctype_print($link))
{
return null;
}
return array(
'user_id' => $binary_vars['user_id'],
'post_id' => $binary_vars['post_id'],
'link' => $link,
);
}
}

213
core/link_replacer.php Executable file
View file

@ -0,0 +1,213 @@
<?php
/*
* This class is used to replace links in a post whenever it is edited, previewed or created.
* It handles manual and automatic link replacement
*/
namespace pedodev\linkprotection\core;
use phpbb\config\config;
use pedodev\linkprotection\core\parser;
use pedodev\linkprotection\core\crypto;
use pedodev\linkprotection\core\automatic_link_helper;
use phpbb\auth\auth;
use phpbb\user;
use phpbb\language\language;
class link_replacer
{
private bool $preview;
private int $link_counter;
public string $error;
public function __construct(
private config $config,
private auth $auth,
private user $user,
private language $language,
private parser $parser,
private crypto $crypto,
private automatic_link_helper $automatic_link_helper,
)
{
$this->preview = false;
$this->link_counter = 0;
$this->error = '';
}
/*
* Must be called once before encrypting links
* Sets the user ID and post ID variables in the crypto object
*/
public function set_crypto_vars(int $user_id, int $post_id): void
{
$this->crypto->set_vars($user_id, $post_id);
}
/*
* Called when a post is created or previewed.
* If previewing, replace links with a link to the preview page
* If not previewing, encrypt and replace links
*/
public function find_protect_links(string $message, bool $preview): string
{
$this->link_counter = $this->parser->count_encrypted_links($message);
$this->preview = $preview;
// Limit is set to 1 above the limit so our error will trigger if they try to protect too many links
$limit = (int)$this->config['pedodev_linkprotection_maxlinks'] + 1;
if ($this->config['pedodev_linkprotection_manualenabled'])
{
$callback = [$this, 'replace_manual_link'];
$message = $this->parser->find_replace_manual_links($message, $callback, $limit);
}
if ($this->config['pedodev_linkprotection_automaticenabled'])
{
$callback = [$this, 'replace_automatic_link'];
$message = $this->parser->find_replace_automatic_links($message, $callback, $limit);
}
return $message;
}
/*
* Called by manual and automatic link replacement
* Replaces an individual link
*/
public function replace_link(array $link_data): string
{
if (!ctype_print($link_data['link']))
{
return $link_data[0];
}
if ($this->link_limit())
{
return $link_data[0];
}
$link_data['title'] = empty($link_data['title']) ? $this->config['pedodev_linkprotection_manualtitle'] : $link_data['title'];
$link_data['encrypted'] = $this->preview ? 'preview' : $this->crypto->encrypt_link($link_data['link']);
if (!isset($link_data['encrypted']))
{
return $link_data[0];
}
return $this->parser->get_encrypted_tag($link_data);
}
/*
* Called when a post is edited
* Replaces all encrypted links with the original, unprotected links
*/
public function find_restore_links(string $message): string
{
$callback = [$this, 'restore_link'];
$limit = (int)$this->config['pedodev_linkprotection_maxlinks'];
return $this->parser->find_restore_encrypted_links($message, $callback, $limit);
}
/*
* Our callback function for find_restore_links. Decrypts and restores an individual link
*/
public function restore_link(array $data): string
{
if ($this->link_limit())
{
return $data[0];
}
$link_data = $this->crypto->decrypt_link($data['encrypted']);
if (!$link_data)
{
return $data[0];
}
$link_data['title'] = $data['title'];
$can_view_original = ($link_data['user_id'] == $this->user->data['user_id']) || $this->auth->acl_get('m_pedodev_linkprotection_vieworiginallinks');
return $can_view_original ? $this->parser->get_original_text($link_data) : $data[0];
}
/*
* Called whenever a link is protected
* Increments the link counter by 1
* If the link limit is reached, outputs an error and returns true
* Otherwise returns false
*/
private function link_limit(): bool
{
$this->link_counter++;
if ($this->link_counter > $this->config['pedodev_linkprotection_maxlinks'])
{
$this->error = $this->language->lang('LINKPROTECTION_TOO_MANY_LINKS', $this->config['pedodev_linkprotection_maxlinks']);
return true;
}
return false;
}
/*
* Callback function for manual link replacement
*/
public function replace_manual_link(array $link_data): string
{
if (strlen($link_data['link']) > (int)$this->config['pedodev_linkprotection_manuallength'])
{
return $link_data[0];
}
if ($this->config['pedodev_linkprotection_strictmanual'] && !filter_var($link_data['link'], FILTER_VALIDATE_URL))
{
return $link_data[0];
}
return $this->replace_link($link_data);
}
/*
* Callback function for automatic link replacement
*/
public function replace_automatic_link(array $link_data): string
{
$link = $link_data[0];
$title = $this->automatic_link_helper->find_substring_match($link);
if (empty($title) || strlen($link) > (int)$this->config['pedodev_linkprotection_automaticlength'])
{
return $link;
}
$link_data = array_merge($link_data, array(
'link' => $link,
'title' => $title,
));
return $this->replace_link($link_data);
}
/*
* Checks whether user has permission to replace links.
* If no permission, checks whether a user is trying to manually protect links
* If trying to protect links without permission, outputs an error message
*/
public function check_permission(string $message = ''): bool
{
$has_permission = $this->auth->acl_get('u_pedodev_linkprotection_canprotectlinks');
if (!$has_permission && $this->parser->find_manual_links($message))
{
$this->error = $this->language->lang('LINKPROTECTION_CANNOT_PROTECT_LINKS');
}
return $has_permission;
}
}

123
core/parser.php Executable file
View file

@ -0,0 +1,123 @@
<?php
/*
* Our parser object is used by the link_replacer object to handle all parsing and validation
* This class contains all of our regex and preg functions
*/
namespace pedodev\linkprotection\core;
use phpbb\config\config;
class parser
{
// Default manual protection tag. Also used when editing a post to restore the original tags
private const DEFAULT_OPENING_TAG = 'protect';
// Matches base64 data encoded in URL format
private const ENCODED_DATA_REGEX = '[\w\-]+\.{0,2}';
// Matches all printable ASCII characters
private const ASCII_REGEX = '[ -~]+';
// Controls the title that can be given to protected links
private const TITLE_REGEX = '[\w\'\. ]{1,100}';
// Matches any URL
private const URL_REGEX = '((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?«»“”‘’]))';
// Matches the [encrypted] BBCode tag in normal text. Used to count the number of already-protected links in a post to prevent bugs
private const ENCRYPTED_BBCODE_REGEX = '#\[encrypted=' . self::ENCODED_DATA_REGEX . ']' . self::TITLE_REGEX . '\[/encrypted\]#';
// Matches an [encrypted] BBCode tag in XML format. Used to restore original links when editing a post
private const ENCRYPTED_XML_REGEX = '#<ENCRYPTED encrypted="(?<encrypted>' . self::ENCODED_DATA_REGEX . ')"><s>\[encrypted=(?P=encrypted)\]</s>(?<title>' . self::TITLE_REGEX . ')<e>\[/encrypted\]</e></ENCRYPTED>#';
private string $manual_protection_regex;
private string $automatic_protection_regex;
private array $automatic_links;
public function __construct(config $config)
{
if ($config['pedodev_linkprotection_manualenabled'])
{
$this->setup_manual_protection_regex($config);
}
if ($config['pedodev_linkprotection_automaticenabled'])
{
$this->setup_automatic_protection_regex($config);
$this->automatic_links = json_decode($config['pedodev_linkprotection_automaticlinks'], $associative = true);
}
}
private function setup_manual_protection_regex(config $config): void
{
$opening_tag = self::DEFAULT_OPENING_TAG;
// If additional tags are enabled in the configuration, add them to the opening_tag regex, replacing ',' with '|'
if (!empty($config['pedodev_linkprotection_manualtags']))
{
$opening_tag .= '|' . str_replace(',', '|', $config['pedodev_linkprotection_manualtags']);
}
// We attach the lazy modifier '?' to avoid issues with protecting multiple links on one line
$manual_link_regex = self::ASCII_REGEX . '?';
$this->manual_protection_regex = "#\[(?<opening_tag>{$opening_tag})(=(?<title>" . self::TITLE_REGEX . "))?\](?<link>{$manual_link_regex})\[/(?P=opening_tag)\]#";
}
private function setup_automatic_protection_regex(config $config): void
{
// Negative lookbehind stops us from accidentally parsing links when we aren't supposed to, for example when they're part of a [url] tag.
$negative_lookbehind = '(?<!url=|\]|/)';
$this->automatic_protection_regex = "#{$negative_lookbehind}" . self::URL_REGEX . '#i';
}
/*
* When protecting a link, this is the text we replace it with
*/
public function get_encrypted_tag(array $link_data): string
{
return "[encrypted={$link_data['encrypted']}]{$link_data['title']}[/encrypted]";
}
/*
* When restoring a link while editing a post, we use this to get the original text
*/
public function get_original_text(array $link_data): string
{
return '[' . self::DEFAULT_OPENING_TAG . '=' . $link_data['title'] . ']' . $link_data['link'] . '[/' . self::DEFAULT_OPENING_TAG . ']';
}
public function find_replace_automatic_links(string $message, callable $callback, int $limit): string
{
return preg_replace_callback($this->automatic_protection_regex, $callback, $message, $limit);
}
public function find_replace_manual_links(string $message, callable $callback, int $limit): string
{
return preg_replace_callback($this->manual_protection_regex, $callback, $message, $limit);
}
/*
* Returns true if the user is trying to manually protect links, otherwise returns false
*/
public function find_manual_links(string $message): bool
{
return preg_match($this->manual_protection_regex, $message);
}
public function find_restore_encrypted_links(string $message, callable $callback, int $limit): string
{
return preg_replace_callback(self::ENCRYPTED_XML_REGEX, $callback, $message, $limit);
}
/*
* Count the number of protected links in a post. We use this to stop a bug where the user can exceed the link protection limit
*/
public function count_encrypted_links(string $message): int
{
return preg_match_all(self::ENCRYPTED_BBCODE_REGEX, $message);
}
}

47
event/bbcode_listener.php Executable file
View file

@ -0,0 +1,47 @@
<?php
/*
* This event listener is used to create the [encrypted] BBCode tag which is used to display protected links
*/
namespace pedodev\linkprotection\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use phpbb\config\config;
use phpbb\controller\helper;
class bbcode_listener implements EventSubscriberInterface
{
public function __construct(
private helper $controller_helper,
private config $config,
) {}
static public function getSubscribedEvents(): array
{
return [
'core.text_formatter_s9e_configure_after' => 'configure_encrypted_bbcode',
];
}
public function configure_encrypted_bbcode(object $event): void
{
$configurator = $event['configurator'];
// Unset any existing BBCode that might already exist
unset($configurator->BBCodes['encrypted']);
unset($configurator->tags['encrypted']);
// Get a route to the protected page with {TEXT1} as the link
$route = $this->controller_helper->route('pedodev_linkprotection_protected_page_route',
array('protected_prefix' => $this->config['pedodev_linkprotection_protectedprefix']),
$append_sid = false)
. '/{TEXT1}';
// Setup the [encrypted] BBCode
$configurator->BBCodes->addCustom(
'[encrypted={TEXT1}]{TEXT2}[/encrypted]',
'<a class="protected_link" target="_blank" href="' . $route . '">{TEXT2}</a>'
);
}
}

41
event/edit_post_listener.php Executable file
View file

@ -0,0 +1,41 @@
<?php
/*
* This event listener is used to restore the original unprotected links whenever a user edits his own post.
*/
namespace pedodev\linkprotection\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use pedodev\linkprotection\core\link_replacer;
class edit_post_listener implements EventSubscriberInterface
{
public function __construct(
private link_replacer $link_replacer
) {}
static public function getSubscribedEvents(): array
{
return [
'core.posting_modify_post_data' => 'restore_links_edit_post',
];
}
public function restore_links_edit_post(object $event): void
{
if ($event['mode'] != 'edit')
{
return;
}
if (!$this->link_replacer->check_permission())
{
return;
}
$post_data = $event['post_data'];
$post_data['post_text'] = $this->link_replacer->find_restore_links($post_data['post_text']);
$event['post_data'] = $post_data;
}
}

57
event/make_post_listener.php Executable file
View file

@ -0,0 +1,57 @@
<?php
/*
* This event listener is used to find and protect links when a post is submitted
*/
namespace pedodev\linkprotection\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use pedodev\linkprotection\core\link_replacer;
use phpbb\language\language;
class make_post_listener implements EventSubscriberInterface
{
public function __construct(
private link_replacer $link_replacer,
private language $language,
) {}
static public function getSubscribedEvents(): array
{
return [
'core.posting_modify_message_text' => 'replace_links_new_post',
];
}
public function replace_links_new_post(object $event): void
{
// Loads the language file we need for outputting error messages
$this->language->add_lang('link_replacement', 'pedodev/linkprotection');
if ($event['preview'])
{
return;
}
$message_parser = $event['message_parser'];
if ($this->link_replacer->check_permission($message_parser->message))
{
$user_id = (int)$event['post_data']['poster_id'];
$post_id = (int)$event['post_id'];
$this->link_replacer->set_crypto_vars($user_id, $post_id);
$message_parser->message = $this->link_replacer->find_protect_links($message_parser->message, $preview = false);
$event['message_parser'] = $message_parser;
}
if (!empty($this->link_replacer->error))
{
$error = $event['error'];
$error[] = $this->link_replacer->error;
$event['error'] = $error;
}
}
}

27
event/permissions.php Executable file
View file

@ -0,0 +1,27 @@
<?php
/*
* Adds permissions to the Permissions page in the Admin CP
*/
namespace pedodev\linkprotection\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class permissions implements EventSubscriberInterface
{
static public function getSubscribedEvents(): array
{
return [
'core.permissions' => 'load_permissions',
];
}
public function load_permissions(object $event): void
{
$event->update_subarray('permissions', 'u_pedodev_linkprotection_canprotectlinks', ['lang' => 'ACL_U_PEDODEV_LINKPROTECTION_CANPROTECTLINKS', 'cat' => 'post']);
$event->update_subarray('permissions', 'u_pedodev_linkprotection_canviewlinks', ['lang' => 'ACL_U_PEDODEV_LINKPROTECTION_CANVIEWLINKS', 'cat' => 'misc']);
$event->update_subarray('permissions', 'm_pedodev_linkprotection_canbypasscaptcha', ['lang' => 'ACL_M_PEDODEV_LINKPROTECTION_CANBYPASSCAPTCHA', 'cat' => 'misc']);
$event->update_subarray('permissions', 'm_pedodev_linkprotection_vieworiginallinks', ['lang' => 'ACL_M_PEDODEV_LINKPROTECTION_VIEWORIGINALLINKS', 'cat' => 'post_actions']);
}
}

67
event/preview_post_listener.php Executable file
View file

@ -0,0 +1,67 @@
<?php
/*
* This event listener is used to replace links with a link to the preview page when previewing a post
* First we store the original post as $this->post
* Then we find and replace links so they appear in the preview
* After the preview is generated, we restore the original post so it appears in the posting box
*/
namespace pedodev\linkprotection\event;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use pedodev\linkprotection\core\link_replacer;
class preview_post_listener implements EventSubscriberInterface
{
// Stores the original unmodified post
private string $post;
public function __construct(
private link_replacer $link_replacer,
) {}
static public function getSubscribedEvents(): array
{
return [
'core.posting_modify_message_text' => 'replace_links_preview',
'core.posting_modify_template_vars' => 'restore_original_post',
];
}
public function replace_links_preview(object $event): void
{
if (!$event['preview'])
{
return;
}
$message_parser = $event['message_parser'];
$this->post = $message_parser->message;
if ($this->link_replacer->check_permission($message_parser->message))
{
$message_parser->message = $this->link_replacer->find_protect_links($message_parser->message, $preview = true);
$event['message_parser'] = $message_parser;
}
if (!empty($this->link_replacer->error))
{
$error = $event['error'];
$error[] = $this->link_replacer->error;
$event['error'] = $error;
}
}
public function restore_original_post(object $event): void
{
if (!$event['preview'])
{
return;
}
$page_data = $event['page_data'];
$page_data['MESSAGE'] = $this->post;
$event['page_data'] = $page_data;
}
}

17
ext.php Executable file
View file

@ -0,0 +1,17 @@
<?php
namespace pedodev\linkprotection;
class ext extends \phpbb\extension\base
{
public function is_enableable(): bool
{
global $config;
return extension_loaded('openssl')
&& in_array('aes-128-cbc', openssl_get_cipher_methods(), true)
&& version_compare(phpversion(), '8.0.0', '>=')
&& phpbb_version_compare($config['version'], '3.3.0', '>=')
&& parent::is_enableable();
}
}

View file

@ -0,0 +1,70 @@
<?php
if (!defined('IN_PHPBB'))
{
exit;
}
if (empty($lang) || !is_array($lang))
{
$lang = [];
}
$lang = array_merge($lang, array(
'ACP_LINKPROTECTION_TITLE' => 'Link Protection',
'ACP_LINKPROTECTION_LINKREPLACEMENT' => 'Link Replacement',
'ACP_LINKPROTECTION_PROTECTED_PAGE' => 'Protected Link Page',
'ACP_LINKPROTECTION_SETTING_SAVED' => 'Settings have been saved successfully!',
'ACP_LINKPROTECTION_KEY_UPDATED' => 'A new encryption key was successfully generated!',
'ACP_lINKPROTECTION_VALUE_ERROR' => 'Invalid value \'%s\' for option \'%s\'. Settings have not been updated',
'ACP_LINKPROTECTION_GENERAL' => 'General Settings',
'ACP_LINKPROTECTION_MANUAL' => 'Manual Link Replacement',
'ACP_LINKPROTECTION_AUTOMATIC' => 'Automatic Link Replacement',
'ACP_LINKPROTECTION_MAXLINKS' => 'Maximum Protected Links Per Post',
'ACP_LINKPROTECTION_MAXLINKS_EXPLANATION' => 'The maximum number of links that can be protected in a single post. It should be set to a reasonable number to avoid placing excessive load on the server',
'ACP_LINKPROTECTION_KEY' => 'Encryption Key',
'ACP_LINKPROTECTION_KEY_EXPLANATION' => 'The key used to encrypt and decrypt links. Do not share the key with anyone. You should store a backup copy of this key somewhere safe',
'ACP_LINKPROTECTION_NEWKEY' => 'Generate New Key',
'ACP_LINKPROTECTION_CIPHER' => 'Encryption Cipher',
'ACP_LINKPROTECTION_CIPHER_EXPLANATION' => 'The cipher used to encrypt links. Increasing the key length will make the encryption more secure but place additional load on the server. After changing the cipher, you must generate a new key',
'ACP_LINKPROTECTION_WARNING' => 'WARNING: Once the system is live, this setting should never be changed',
'ACP_LINKPROTECTION_MANUALENABLED' => 'Enabled',
'ACP_LINKPROTECTION_MANUALENABLED_EXPLANATION' => 'When manual link protection is enabled, users can protect links using the [protect=title]url[/protect] or [protect]url[/protect] tags',
'ACP_LINKPROTECTION_STRICTMANUAL' => 'Strict Manual Protection',
'ACP_LINKPROTECTION_STRICTMANUAL_EXPLANATION' => 'When strict manual link protection is enabled, only valid URLs can be protected using the [protect] tag. If this is not enabled, any text can be protected',
'ACP_LINKPROTECTION_MANUALTITLE' => 'Default Title',
'ACP_LINKPROTECTION_MANUALTITLE_EXPLANATION' => 'The default title for manually protected links. This will be used when the user does not specify a title for the protected link',
'ACP_LINKPROTECTION_MANUALLENGTH' => 'Maximum Length',
'ACP_LINKPROTECTION_MANUALLENGTH_EXPLANATION' => 'The maximum length of any text or URL that can be protected with manual link protection. This should be set to a reasonable value to avoid placing excessive load on the server',
'ACP_LINKPROTECTION_MANUALTAGS' => 'Additional Tags',
'ACP_LINKPROTECTION_MANUALTAGS_EXPLANATION' => 'Here you can specify additional BBCode tags that can be used to manually protect links. Note that the [protect] tag will always be available. Additional tags should be separated by commas, for example dl,download will add support for [dl] and [download] tags. Only alphanumeric characters are accepted',
'ACP_LINKPROTECTION_AUTOMATICENABLED' => 'Enabled',
'ACP_LINKPROTECTION_AUTOMATICENABLED_EXPLANATION' => 'When automatic link protection is enabled, any valid links matching the URLs specified below will be automatically protected inside posts',
'ACP_LINKPROTECTION_AUTOMATICLENGTH' => 'Maximum Length',
'ACP_LINKPROTECTION_AUTOMATICLENGTH_EXPLANATION' => 'The maximum length of any URL that can be automatically protected. URLs exceeding this length will not be protected. It should be set to a reasonable value to avoid placing excessive load on the server',
'ACP_LINKPROTECTION_AUTOMATICLINKS' => 'Site List',
'ACP_LINKPROTECTION_AUTOMATICLINKS_EXPLANATION' => 'Here you can add new links to the automatic protection system. The URL match is the text your link should contain in order to be automatically protected. The title is what the protected links will appear as inside a post. To delete a URL, select the Delete checkbox next to the link. To add a new URL, enter the details and select the Add checkbox',
'ACP_LINKPROTECTION_PROTECTEDPREFIX' => 'Protected Page Prefix',
'ACP_LINKPROTECTION_PROTECTEDPREFIX_EXPLANATION' => 'The URL prefix of the protected link page. Can be changed to prevent conflict with other plugins',
'ACP_LINKPROTECTION_CAPTCHA' => 'Captcha',
'ACP_LINKPROTECTION_CAPTCHA_EXPLANATION' => 'Select the captcha that will be used on the protected link page',
'ACP_LINKPROTECTION_SHOWSUBMIT' => 'Captcha Submit Button',
'ACP_LINKPROTECTION_SHOWSUBMIT_EXPLANATION' => 'Whether to add a submit button to the protected link page captcha. Some captchas styles such as one-click captchas do not require a submit button',
'ACP_LINKPROTECTION_SOLVETIME' => 'Captcha Solve Time',
'ACP_LINKPROTECTION_SOLVETIME_EXPLANATION' => 'The amount of time in seconds that a user has to solve the captcha once the page loads. After this time the captcha will expire',
'ACP_LINKPROTECTION_MULTIPLELINKS' => 'Multiple Links Per Captcha',
'ACP_LINKPROTECTION_MULTIPLELINKS_EXPLANATION' => 'If this option is enabled, the user will be able to visit multiple links within a short time period after solving a single captcha. The number of links and the duration can be configured below',
'ACP_LINKPROTECTION_SOLVEDURATION' => 'Captcha Solve Duration',
'ACP_LINKPROTECTION_SOLVEDURATION_EXPLANATION' => 'If the multiple links option is enabled, this controls the time period during which the user will be able to view multiple links',
'ACP_LINKPROTECTION_GROUPLINKS' => 'Links Per Group',
'ACP_LINKPROTECTION_GROUPLINKS_EXPLANATION' => 'If the multiple links option is enabled, this controls how many protected links the user will be able to view before having to solve another captcha. The number of links can be set per group. If the user is a member of multiple groups, the highest value of all groups will be used',
'ACP_LINKPROTECTION_DEMO' => 'Captcha Preview',
'CAPTCHA_PREVIEW_EXPLAIN' => 'The plugin as it would look like using the current selection',
));

View file

@ -0,0 +1,16 @@
<?php
if (!defined('IN_PHPBB'))
{
exit;
}
if (empty($lang) || !is_array($lang))
{
$lang = [];
}
$lang = array_merge($lang, array(
'LINKPROTECTION_TOO_MANY_LINKS' => 'You are trying to protect too many links. Maximum per post: %d',
'LINKPROTECTION_CANNOT_PROTECT_LINKS' => 'You are not allowed to protect links',
));

18
language/en/permissions_acp.php Executable file
View file

@ -0,0 +1,18 @@
<?php
if (!defined('IN_PHPBB'))
{
exit;
}
if (empty($lang) || !is_array($lang))
{
$lang = [];
}
$lang = array_merge($lang, array(
'ACL_U_PEDODEV_LINKPROTECTION_CANPROTECTLINKS' => 'Can protect links',
'ACL_U_PEDODEV_LINKPROTECTION_CANVIEWLINKS' => 'Can view protected links',
'ACL_M_PEDODEV_LINKPROTECTION_CANBYPASSCAPTCHA' => 'Can bypass protected link captcha',
'ACL_M_PEDODEV_LINKPROTECTION_VIEWORIGINALLINKS' => 'Can view unprotected links while editing a post',
));

24
language/en/protected_page.php Executable file
View file

@ -0,0 +1,24 @@
<?php
if (!defined('IN_PHPBB'))
{
exit;
}
if (empty($lang) || !is_array($lang))
{
$lang = [];
}
$lang = array_merge($lang, array(
'LINKPROTECTION_SOLVE_CAPTCHA' => 'Solve the captcha to view the protected link',
'LINKPROTECTION_CAPTCHA_SOLVED' => 'Solved! Here is your unprotected link',
'LINKPROTECTION_BYPASS' => 'You have permission to bypass the captcha',
'LINKPROTECTION_REMAINING_LINKS' => 'You can visit an additional %d links within the next %d seconds',
'LINKPROTECTION_NO_LINKS_REMAINING' => 'You have no links remaining',
'LINKPROTECTION_CAPTCHA_EXPIRED' => 'Captcha expired',
'LINKPROTECTION_ROUTE_ERROR' => 'Invalid route',
'LINKPROTECTION_PERMISSION_ERROR' => 'You do not have permission to view protected links',
'LINKPROTECTION_LINK_ERROR' => 'Invalid link',
'LINKPROTECTION_DECRYPTION_ERROR' => 'Unable to decrypt link',
));

108
migrations/release_1_0_0.php Executable file
View file

@ -0,0 +1,108 @@
<?php
namespace pedodev\linkprotection\migrations;
class release_1_0_0 extends \phpbb\db\migration\migration
{
public function effectively_installed()
{
return isset($this->config['pedodev_linkprotection']);
}
static public function depends_on()
{
return ['\phpbb\db\migration\data\v330\v330'];
}
public function update_data()
{
// openssl_cipher_key_length requires PHP >= 8.2
if (function_exists('openssl_cipher_key_length'))
{
$key_length = openssl_cipher_key_length('aes-128-cbc');
}
else
{
$key_length = 16;
}
$random_key = base64_encode(random_bytes($key_length));
return [
// A simple config variable so we know the migration is already completed
['config.add', ['pedodev_linkprotection', 1]],
/* Add the ACP modules */
['module.add', [
'acp',
'ACP_CAT_DOT_MODS',
'ACP_LINKPROTECTION_TITLE'
]],
['module.add', [
'acp',
'ACP_LINKPROTECTION_TITLE',
[
'module_basename' => '\pedodev\linkprotection\acp\link_replacement_module',
'modes' => ['settings'],
],
]],
['module.add', [
'acp',
'ACP_LINKPROTECTION_TITLE',
[
'module_basename' => '\pedodev\linkprotection\acp\protected_page_module',
'modes' => ['settings'],
],
]],
/* Add our configuration variables (with sane defaults) */
// Link replacement
['config.add', ['pedodev_linkprotection_maxlinks', 100]],
['config.add', ['pedodev_linkprotection_key', $random_key]],
['config.add', ['pedodev_linkprotection_cipher', 'aes-128-cbc']],
['config.add', ['pedodev_linkprotection_manualenabled', 1]],
['config.add', ['pedodev_linkprotection_manualtitle', 'Protected']],
['config.add', ['pedodev_linkprotection_strictmanual', 0]],
['config.add', ['pedodev_linkprotection_manuallength', 255]],
['config.add', ['pedodev_linkprotection_manualtags', 'dl,download']],
['config.add', ['pedodev_linkprotection_automaticenabled', 1]],
['config.add', ['pedodev_linkprotection_automaticlength', 255]],
['config.add', ['pedodev_linkprotection_automaticlinks', '{"1fichier.com":"1fichier"}']],
// Protected link page
['config.add', ['pedodev_linkprotection_protectedprefix', 'protected']],
['config.add', ['pedodev_linkprotection_captcha', 'core.captcha.plugins.nogd']],
['config.add', ['pedodev_linkprotection_solvetime', 60]],
['config.add', ['pedodev_linkprotection_showsubmit', 1]],
['config.add', ['pedodev_linkprotection_multiplelinks', 1]],
['config.add', ['pedodev_linkprotection_solveduration', 120]],
['config.add', ['pedodev_linkprotection_grouplinks', '']],
/* Configure our permissions and setup sane defaults for roles*/
['permission.add', ['u_pedodev_linkprotection_canprotectlinks']],
['permission.permission_set', ['ROLE_USER_LIMITED', 'u_pedodev_linkprotection_canprotectlinks']],
['permission.permission_set', ['ROLE_USER_STANDARD', 'u_pedodev_linkprotection_canprotectlinks']],
['permission.permission_set', ['ROLE_USER_FULL', 'u_pedodev_linkprotection_canprotectlinks']],
['permission.add', ['u_pedodev_linkprotection_canviewlinks']],
['permission.permission_set', ['ROLE_USER_LIMITED', 'u_pedodev_linkprotection_canviewlinks']],
['permission.permission_set', ['ROLE_USER_STANDARD', 'u_pedodev_linkprotection_canviewlinks']],
['permission.permission_set', ['ROLE_USER_FULL', 'u_pedodev_linkprotection_canviewlinks']],
['permission.add', ['m_pedodev_linkprotection_canbypasscaptcha']],
['permission.permission_set', ['ROLE_MOD_STANDARD', 'm_pedodev_linkprotection_canbypasscaptcha']],
['permission.permission_set', ['ROLE_MOD_FULL', 'm_pedodev_linkprotection_canbypasscaptcha']],
['permission.add', ['m_pedodev_linkprotection_vieworiginallinks']],
['permission.permission_set', ['ROLE_MOD_STANDARD', 'm_pedodev_linkprotection_vieworiginallinks']],
['permission.permission_set', ['ROLE_MOD_FULL', 'm_pedodev_linkprotection_vieworiginallinks']],
];
}
}

40
migrations/release_1_1_0.php Executable file
View file

@ -0,0 +1,40 @@
<?php
namespace pedodev\linkprotection\migrations;
class release_1_1_0 extends \phpbb\db\migration\migration
{
public function effectively_installed()
{
return isset($this->config['pedodev_linkprotection_fileprefix']);
}
static public function depends_on()
{
return ['\phpbb\db\migration\data\v330\v330'];
}
public function update_data()
{
$file_prefix = bin2hex(random_bytes(8));
return [
// The prefix we will add to our automatic links JSON file
['config.add', ['pedodev_linkprotection_fileprefix', $file_prefix]],
];
}
public function revert_data()
{
$filepath = __DIR__ . "/../automatic_links_{$this->config['pedodev_linkprotection_fileprefix']}.json";
if (file_exists($filepath))
{
unlink($filepath);
}
return [];
}
}

View file

@ -0,0 +1 @@
{% INCLUDECSS '@pedodev_linkprotection/pedodev_linkprotection_main.css' %}

View file

@ -0,0 +1,27 @@
{% include 'overall_header.html' %}
<center>
{% if loops.errors|length %}
<dl>
{% for ERROR in loops.errors %}
<dd class="error">{{ ERROR.TEXT }}</dd>
{% endfor %}
</dl>
{% endif %}
{% if LINKPROTECTION_PREVIEW %}
{% INCLUDE 'protected_page_preview.html' %}
{% endif %}
{% if LINKPROTECTION_SHOW_LINK %}
{% INCLUDE 'protected_page_link.html' %}
{% endif %}
{% if LINKPROTECTION_SHOW_CAPTCHA %}
{% INCLUDE 'protected_page_captcha.html' %}
{% endif %}
</center>
{% include 'overall_footer.html' %}

View file

@ -0,0 +1,31 @@
<form id="protectedlink" method="post">
<div class="forabg">
<div class="inner">
<ul class="topiclist">
<li class="header">
<dl class="row-item">
<dt>{{ LINKPROTECTION_SOLVE_CAPTCHA_MESSAGE }}</dt>
</dl>
</li>
</ul>
<ul class="forums">
<br>
{% include LINKPROTECTION_CAPTCHA %}
<br>
{% if LINKPROTECTION_SHOWSUBMIT %}
<p class="submit-buttons">
<input class="button1" type="submit" id="submit" name="submit" value="{{ lang('SUBMIT') }}" />&nbsp;
</p>
<br>
{% endif %}
</ul>
</div>
</div>
{{ S_FORM_TOKEN }}
</form>

View file

@ -0,0 +1,31 @@
<div class="forabg">
<div class="inner">
<ul class="topiclist">
<li class="header">
<dl class="row-item">
<dt>{{ LINKPROTECTION_SOLVED_MESSAGE ~ lang('COLON') }}</dt>
</dl>
</li>
</ul>
<ul class="topiclist forums">
<li class="row">
<dl class="row-item">
<dt>
<div>
<br>
<a href="{{ LINKPROTECTION_LINK_SOURCE }}" class="forumtitle">{{ LINKPROTECTION_UNHIDDEN_LINK }}</a>
<br>
{% if LINKPROTECTION_INFO_MESSAGE %}
{{ LINKPROTECTION_INFO_MESSAGE }}
<br>
{% endif %}
<br>
</div>
</dt>
</dl>
</li>
</ul>
</div>
</div>

View file

@ -0,0 +1 @@
<h3>Protected links are unavailable while previewing a post</h3>

View file

@ -0,0 +1,8 @@
.protected_link {
padding: 2px 4px 2px 4px;
background-color: rgba(0, 0, 0, 0.1);
}
.protected_link:hover {
background-color: rgba(0, 0, 0, 0.3);
}