mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-07-28 19:14:59 +00:00
[Imapsync] fix custom params
This commit is contained in:
+27
-15
@@ -1520,7 +1520,9 @@ paths:
|
||||
timeout1: "600"
|
||||
timeout2: "600"
|
||||
exclude: "(?i)spam|(?i)junk"
|
||||
custom_params: '[{"o":"dry","v":""}]'
|
||||
custom_params:
|
||||
- {o: dry, v: ""}
|
||||
- {o: folder, v: INBOX}
|
||||
delete2duplicates: "1"
|
||||
delete1: "1"
|
||||
delete2: "0"
|
||||
@@ -1570,13 +1572,18 @@ paths:
|
||||
type: string
|
||||
custom_params:
|
||||
description: >-
|
||||
JSON array of allowlisted imapsync options as
|
||||
option/value pairs, e.g.
|
||||
`[{"o":"folder","v":"INBOX"},{"o":"dry","v":""}]`. Only
|
||||
option names from `GET /get/syncjob_options` are accepted;
|
||||
values may contain any character and are passed as a single
|
||||
argument.
|
||||
type: string
|
||||
Allowlisted imapsync options as option/value pairs. `o` is the
|
||||
option name (must be one from `GET /get/syncjob_options`); `v`
|
||||
is the value (empty string for pure flags). Values may contain
|
||||
any character and are passed to imapsync as a single argument.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
o:
|
||||
type: string
|
||||
v:
|
||||
type: string
|
||||
delete2duplicates:
|
||||
description: delete duplicates on destination (--delete2duplicates)
|
||||
type: boolean
|
||||
@@ -4070,13 +4077,18 @@ paths:
|
||||
type: boolean
|
||||
custom_params:
|
||||
description: >-
|
||||
JSON array of allowlisted imapsync options as
|
||||
option/value pairs, e.g.
|
||||
`[{"o":"folder","v":"INBOX"},{"o":"dry","v":""}]`. Only
|
||||
option names from `GET /get/syncjob_options` are
|
||||
accepted; values may contain any character and are
|
||||
passed as a single argument.
|
||||
type: string
|
||||
Allowlisted imapsync options as option/value pairs. `o` is
|
||||
the option name (must be one from `GET /get/syncjob_options`);
|
||||
`v` is the value (empty string for flags). Values are passed to imapsync as a single
|
||||
argument.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
o:
|
||||
type: string
|
||||
v:
|
||||
type: string
|
||||
delete1:
|
||||
description: Delete from source when completed
|
||||
type: boolean
|
||||
|
||||
@@ -361,15 +361,15 @@ function imapsync_get_settings() {
|
||||
}
|
||||
|
||||
function imapsync_normalize_custom_params($input) {
|
||||
$pairs = json_decode((string)$input, true);
|
||||
if (!is_array($pairs)) return '[]';
|
||||
$allow = $GLOBALS['IMAPSYNC_OPTIONS']['whitelist'];
|
||||
if (!is_array($input)) return '[]';
|
||||
$pairs = $input;
|
||||
$allow = $GLOBALS['IMAPSYNC_OPTIONS'];
|
||||
$out = array();
|
||||
foreach ($pairs as $p) {
|
||||
if (!is_array($p)) continue;
|
||||
$o = ltrim(strtolower(trim((string)($p['o'] ?? ''))), '-');
|
||||
if ($o === '') continue;
|
||||
if (!in_array($o, $allow, true)) return false; // disallowed option -> reject the whole set
|
||||
if (!array_key_exists($o, $allow)) return false; // disallowed option -> reject the whole set
|
||||
$out[] = array('o' => $o, 'v' => str_replace("\0", '', (string)($p['v'] ?? '')));
|
||||
}
|
||||
return json_encode($out);
|
||||
@@ -484,7 +484,7 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
|
||||
$subfolder2 = $_data['subfolder2'];
|
||||
$user1 = $_data['user1'];
|
||||
$mins_interval = $_data['mins_interval'];
|
||||
$custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
|
||||
$custom_params = $_data['custom_params'] ?? '';
|
||||
|
||||
// Resolve and authorize the chosen sync source. Visibility is enforced by syncjob('get', 'source').
|
||||
$source = syncjob('get', 'source', array('id' => $source_id));
|
||||
@@ -843,7 +843,8 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
|
||||
$subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2'];
|
||||
$mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval'];
|
||||
$exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude'];
|
||||
$custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params'];
|
||||
$custom_params_provided = isset($_data['custom_params']);
|
||||
$custom_params = $custom_params_provided ? $_data['custom_params'] : $is_now['custom_params'];
|
||||
$maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage'];
|
||||
$maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond'];
|
||||
$timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1'];
|
||||
@@ -873,8 +874,9 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
|
||||
$password1 = '';
|
||||
}
|
||||
|
||||
// Structured custom params: validate option names against the allowlist, store as JSON pairs
|
||||
$custom_params = imapsync_normalize_custom_params($custom_params);
|
||||
if ($custom_params_provided) {
|
||||
$custom_params = imapsync_normalize_custom_params($custom_params);
|
||||
}
|
||||
if ($custom_params === false) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
|
||||
@@ -1535,7 +1535,7 @@ function init_db_schema()
|
||||
|
||||
// Syncjobs: migrate custom_params from raw imapsync string to structured JSON pairs.
|
||||
// Old values could not contain spaces (they were rejected), so a whitespace split is safe.
|
||||
$allow = $GLOBALS['IMAPSYNC_OPTIONS']['whitelist'];
|
||||
$allow = $GLOBALS['IMAPSYNC_OPTIONS'];
|
||||
$cp_upd = $pdo->prepare("UPDATE `imapsync` SET `custom_params` = :cp WHERE `id` = :id");
|
||||
foreach ($pdo->query("SELECT `id`, `custom_params` FROM `imapsync`")->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||
$cp = trim((string)$r['custom_params']);
|
||||
@@ -1548,7 +1548,7 @@ function init_db_schema()
|
||||
$opt = ltrim($tokens[$i], '-'); $val = '';
|
||||
if (strpos($opt, '=') !== false) { list($opt, $val) = explode('=', $opt, 2); }
|
||||
elseif ($i + 1 < count($tokens) && strpos($tokens[$i + 1], '--') !== 0) { $val = $tokens[++$i]; }
|
||||
if (!in_array(strtolower($opt), $allow)) continue; // drop options no longer allowed
|
||||
if (!array_key_exists(strtolower($opt), $allow)) continue; // drop options no longer allowed
|
||||
$pairs[] = array('o' => strtolower($opt), 'v' => $val);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-91
@@ -281,96 +281,89 @@ $RSPAMD_MAPS = array(
|
||||
);
|
||||
|
||||
|
||||
// Allowed imapsync options for a sync job's custom_params, mapped to whether they take a value:
|
||||
// true = option takes a value
|
||||
// false = pure flag, no value
|
||||
// Anything not listed here is rejected.
|
||||
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd'
|
||||
$IMAPSYNC_OPTIONS = array(
|
||||
'whitelist' => array(
|
||||
'abort',
|
||||
'authmd51',
|
||||
'authuser1',
|
||||
'debug',
|
||||
'debugcontent',
|
||||
'debugcrossduplicates',
|
||||
'debugflags',
|
||||
'debugfolders',
|
||||
'debugimap',
|
||||
'debugimap1',
|
||||
'debugmemory',
|
||||
'debugssl',
|
||||
'delete1emptyfolders',
|
||||
'delete2folders',
|
||||
'disarmreadreceipts',
|
||||
'domain1',
|
||||
'domino1',
|
||||
'errorsmax',
|
||||
'exchange1',
|
||||
'exitwhenover',
|
||||
'expunge1',
|
||||
'f1f2',
|
||||
'filterbuggyflags',
|
||||
'folder',
|
||||
'folderfirst',
|
||||
'folderlast',
|
||||
'folderrec',
|
||||
'gmail1',
|
||||
'idatefromheader',
|
||||
'include',
|
||||
'inet4',
|
||||
'inet6',
|
||||
'justconnect',
|
||||
'justfolders',
|
||||
'justfoldersizes',
|
||||
'justlogin',
|
||||
'keepalive1',
|
||||
'log',
|
||||
'maxbytesafter',
|
||||
'maxlinelength',
|
||||
'maxmessagespersecond',
|
||||
'maxsize',
|
||||
'maxsleep',
|
||||
'minage',
|
||||
'minsize',
|
||||
'noabletosearch',
|
||||
'noabletosearch1',
|
||||
'noexpunge1',
|
||||
'nofoldersizesatend',
|
||||
'noid',
|
||||
'nolog',
|
||||
'nomixfolders',
|
||||
'noresyncflags',
|
||||
'nossl1',
|
||||
'nosyncacls',
|
||||
'notls1',
|
||||
'nousecache',
|
||||
'office1',
|
||||
'prefix1',
|
||||
'resyncflags',
|
||||
'resynclabels',
|
||||
'search',
|
||||
'search1',
|
||||
'sep1',
|
||||
'skipemptyfolders',
|
||||
'subfolder1',
|
||||
'subscribe',
|
||||
'subscribed',
|
||||
'syncacls',
|
||||
'syncduplicates',
|
||||
'syncinternaldates',
|
||||
'synclabels',
|
||||
'tests',
|
||||
'testslive',
|
||||
'testslive6',
|
||||
'truncmess',
|
||||
'usecache',
|
||||
'useheader',
|
||||
'useuid'
|
||||
),
|
||||
'blacklist' => array(
|
||||
'skipmess',
|
||||
'delete2foldersonly',
|
||||
'delete2foldersbutnot',
|
||||
'regexflag',
|
||||
'regexmess',
|
||||
'pipemess',
|
||||
'regextrans2',
|
||||
'maxlinelengthcmd'
|
||||
)
|
||||
'abort' => false,
|
||||
'authmd51' => false,
|
||||
'authuser1' => true,
|
||||
'debug' => false,
|
||||
'debugcontent' => false,
|
||||
'debugcrossduplicates' => false,
|
||||
'debugflags' => false,
|
||||
'debugfolders' => false,
|
||||
'debugimap' => false,
|
||||
'debugimap1' => false,
|
||||
'debugmemory' => false,
|
||||
'debugssl' => true,
|
||||
'delete1emptyfolders' => false,
|
||||
'delete2folders' => false,
|
||||
'disarmreadreceipts' => false,
|
||||
'domain1' => true,
|
||||
'domino1' => false,
|
||||
'errorsmax' => true,
|
||||
'exchange1' => false,
|
||||
'exitwhenover' => true,
|
||||
'expunge1' => false,
|
||||
'f1f2' => true,
|
||||
'filterbuggyflags' => false,
|
||||
'folder' => true,
|
||||
'folderfirst' => true,
|
||||
'folderlast' => true,
|
||||
'folderrec' => true,
|
||||
'gmail1' => false,
|
||||
'idatefromheader' => false,
|
||||
'include' => true,
|
||||
'inet4' => false,
|
||||
'inet6' => false,
|
||||
'justconnect' => false,
|
||||
'justfolders' => false,
|
||||
'justfoldersizes' => false,
|
||||
'justlogin' => false,
|
||||
'keepalive1' => false,
|
||||
'log' => false,
|
||||
'maxbytesafter' => true,
|
||||
'maxlinelength' => true,
|
||||
'maxmessagespersecond' => true,
|
||||
'maxsize' => true,
|
||||
'maxsleep' => true,
|
||||
'minage' => true,
|
||||
'minsize' => true,
|
||||
'noabletosearch' => false,
|
||||
'noabletosearch1' => false,
|
||||
'noexpunge1' => false,
|
||||
'nofoldersizesatend' => false,
|
||||
'noid' => false,
|
||||
'nolog' => false,
|
||||
'nomixfolders' => false,
|
||||
'noresyncflags' => false,
|
||||
'nossl1' => false,
|
||||
'nosyncacls' => false,
|
||||
'notls1' => false,
|
||||
'nousecache' => false,
|
||||
'office1' => false,
|
||||
'prefix1' => true,
|
||||
'resyncflags' => false,
|
||||
'resynclabels' => false,
|
||||
'search' => true,
|
||||
'search1' => true,
|
||||
'sep1' => true,
|
||||
'skipemptyfolders' => false,
|
||||
'subfolder1' => true,
|
||||
'subscribe' => false,
|
||||
'subscribed' => false,
|
||||
'syncacls' => false,
|
||||
'syncduplicates' => false,
|
||||
'syncinternaldates' => false,
|
||||
'synclabels' => false,
|
||||
'tests' => false,
|
||||
'testslive' => false,
|
||||
'testslive6' => false,
|
||||
'truncmess' => true,
|
||||
'usecache' => false,
|
||||
'useheader' => true,
|
||||
'useuid' => false,
|
||||
);
|
||||
|
||||
@@ -520,44 +520,62 @@ function imapsyncSyncSourceOauthToggle($form) {
|
||||
$form.find('.imapsync-source-redirect-uri').val(window.location.origin + '/syncjob-oauth');
|
||||
}
|
||||
|
||||
// Allowlisted imapsync option names for the custom-params selects. Loaded once and cached.
|
||||
// Allowlisted imapsync options as a map { name: takesValue }. Loaded once and cached.
|
||||
var imapsyncCpOptions = null;
|
||||
function imapsyncCpEnsureOptions(cb) {
|
||||
if (imapsyncCpOptions !== null) { cb(); return; }
|
||||
$.get("/api/v1/get/syncjob_options", function(opts) {
|
||||
imapsyncCpOptions = Array.isArray(opts) ? opts : [];
|
||||
imapsyncCpOptions = (opts && typeof opts === 'object') ? opts : {};
|
||||
cb();
|
||||
});
|
||||
}
|
||||
|
||||
// Append one row to an editor; option is a bootstrap-select (not free-text), value is arbitrary.
|
||||
// A imapsync flag option takes no value
|
||||
function imapsyncCpTakesValue(name) {
|
||||
return !(imapsyncCpOptions && imapsyncCpOptions[name] === false);
|
||||
}
|
||||
|
||||
// Enable/disable/require a row's value field based on the selected option.
|
||||
function imapsyncCpApplyValueState($row) {
|
||||
var name = $row.find('.imapsync-cp-opt').val() || '';
|
||||
var isFlag = (name !== '') && !imapsyncCpTakesValue(name);
|
||||
var needsValue = (name !== '') && !isFlag;
|
||||
var $val = $row.find('.imapsync-cp-val');
|
||||
$val.prop('disabled', isFlag);
|
||||
if (isFlag) $val.val('');
|
||||
$val.prop('required', needsValue);
|
||||
$val.attr('placeholder', isFlag
|
||||
? ((lang.syncjobs && lang.syncjobs.custom_param_no_value) || 'no value')
|
||||
: ((lang.syncjobs && lang.syncjobs.custom_param_value) || 'value'));
|
||||
}
|
||||
|
||||
// Append one row to an editor; option is a native <select> (not free-text), value is arbitrary.
|
||||
function imapsyncCpAddRow($editor, o, v) {
|
||||
var $row = $(
|
||||
'<div class="input-group mb-2 imapsync-cp-row">' +
|
||||
'<select class="form-control imapsync-cp-opt" data-width="35%"></select>' +
|
||||
'<select class="form-control imapsync-cp-opt" style="flex:0 0 35%"></select>' +
|
||||
'<input type="text" class="form-control imapsync-cp-val">' +
|
||||
'<button type="button" class="btn btn-secondary imapsync-cp-remove"><i class="bi bi-trash"></i></button>' +
|
||||
'</div>'
|
||||
);
|
||||
var $sel = $row.find('.imapsync-cp-opt')
|
||||
.attr('title', (lang.syncjobs && lang.syncjobs.custom_param_option) || 'option');
|
||||
(imapsyncCpOptions || []).forEach(function(name) {
|
||||
var $sel = $row.find('.imapsync-cp-opt');
|
||||
$sel.append($('<option></option>').attr('value', '').text('— ' + ((lang.syncjobs && lang.syncjobs.custom_param_option) || 'option') + ' —'));
|
||||
Object.keys(imapsyncCpOptions || {}).forEach(function(name) {
|
||||
$sel.append($('<option></option>').attr('value', name).text(name));
|
||||
});
|
||||
// keep a stored value that is no longer allowlisted visible instead of silently dropping it
|
||||
if (o && (imapsyncCpOptions || []).indexOf(o) === -1) {
|
||||
if (o && !(imapsyncCpOptions && (o in imapsyncCpOptions))) {
|
||||
$sel.append($('<option></option>').attr('value', o).text(o));
|
||||
}
|
||||
$sel.val(o || '');
|
||||
$row.find('.imapsync-cp-val').attr('placeholder', (lang.syncjobs && lang.syncjobs.custom_param_value) || 'value').val(v || '');
|
||||
$row.find('.imapsync-cp-val').val(v || '');
|
||||
$editor.find('.imapsync-cp-rows').append($row);
|
||||
$sel.selectpicker();
|
||||
imapsyncCpApplyValueState($row);
|
||||
}
|
||||
|
||||
// Build the rows of an editor from its hidden JSON value.
|
||||
function imapsyncCpBuild($editor) {
|
||||
var $hidden = $editor.siblings('.imapsync-cp-value');
|
||||
$editor.find('.imapsync-cp-opt').selectpicker('destroy');
|
||||
$editor.find('.imapsync-cp-rows').empty();
|
||||
var pairs = [];
|
||||
try { pairs = JSON.parse($hidden.val() || '[]'); } catch (e) { pairs = []; }
|
||||
@@ -581,6 +599,7 @@ function imapsyncCpSerialize($editor) {
|
||||
$(document).ready(function() {
|
||||
// Custom-params editor: keep the hidden JSON in sync with the rows
|
||||
$(document).on('change', '.imapsync-cp-opt', function() {
|
||||
imapsyncCpApplyValueState($(this).closest('.imapsync-cp-row'));
|
||||
imapsyncCpSerialize($(this).closest('.imapsync-cp-editor'));
|
||||
});
|
||||
$(document).on('input', '.imapsync-cp-val', function() {
|
||||
@@ -592,7 +611,6 @@ $(document).ready(function() {
|
||||
});
|
||||
$(document).on('click', '.imapsync-cp-remove', function() {
|
||||
var $editor = $(this).closest('.imapsync-cp-editor');
|
||||
$(this).closest('.imapsync-cp-row').find('.imapsync-cp-opt').selectpicker('destroy');
|
||||
$(this).closest('.imapsync-cp-row').remove();
|
||||
imapsyncCpSerialize($editor);
|
||||
});
|
||||
@@ -609,11 +627,12 @@ $(document).ready(function() {
|
||||
var $form = $sel.closest('form');
|
||||
var isOauth = ($opt.data('auth-type') === 'XOAUTH2');
|
||||
var isAuthCode = isOauth && ($opt.data('oauth-flow') === 'authorization_code');
|
||||
var isEdit = ($form.data('id') === 'editsyncjob');
|
||||
$form.find('.password1-row').toggle(!isOauth);
|
||||
$form.find('input[name="password1"]').prop('required', !isOauth);
|
||||
$form.find('input[name="password1"]').prop('required', !isOauth && !isEdit);
|
||||
$form.find('.imapsync-oauth-connect-row').toggle(isAuthCode);
|
||||
$form.find('.imapsync-user1-row').toggle(!isAuthCode);
|
||||
$form.find('input[name="user1"]').prop('required', !isAuthCode);
|
||||
$form.find('input[name="user1"]').prop('required', !isAuthCode && !isEdit);
|
||||
});
|
||||
|
||||
// Source editor: OAuth block + authorization_code fields
|
||||
|
||||
@@ -1050,8 +1050,7 @@ if (isset($_GET['query'])) {
|
||||
process_get_return($data);
|
||||
break;
|
||||
case "syncjob_options":
|
||||
// allowlisted imapsync option names, for the custom-params datalist in the UI
|
||||
process_get_return($GLOBALS['IMAPSYNC_OPTIONS']['whitelist']);
|
||||
process_get_return($GLOBALS['IMAPSYNC_OPTIONS']);
|
||||
break;
|
||||
case "syncjob_source":
|
||||
switch ($object) {
|
||||
|
||||
@@ -1297,7 +1297,8 @@
|
||||
"custom_params": "Eigene Parameter",
|
||||
"custom_params_hint": "Pro Zeile eine imapsync-Option. Es werden nur erlaubte Optionen akzeptiert; der Wert darf beliebige Zeichen enthalten (Leerzeichen, Regex, …) und wird als ein einzelnes Argument übergeben.",
|
||||
"custom_param_option": "Option",
|
||||
"custom_param_value": "Wert (optional)",
|
||||
"custom_param_value": "Wert",
|
||||
"custom_param_no_value": "kein Wert nötig",
|
||||
"custom_param_add": "Parameter hinzufügen",
|
||||
"order_id": "Position",
|
||||
"order_id_hint": "Kleinere Position läuft zuerst; Ändern sortiert die globale Warteschlange um.",
|
||||
|
||||
@@ -1304,7 +1304,8 @@
|
||||
"custom_params": "Custom parameters",
|
||||
"custom_params_hint": "Add one imapsync option per row. Only allowlisted options are accepted; the value may contain any character (spaces, regex, …) and is passed as a single argument.",
|
||||
"custom_param_option": "Option",
|
||||
"custom_param_value": "Value (optional)",
|
||||
"custom_param_value": "Value",
|
||||
"custom_param_no_value": "no value needed",
|
||||
"custom_param_add": "Add parameter",
|
||||
"order_id": "Position",
|
||||
"order_id_hint": "Lower position runs first; setting it re-sorts the global queue.",
|
||||
|
||||
@@ -96,7 +96,6 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="{{ result.custom_params }}">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
|
||||
@@ -1034,7 +1034,6 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
|
||||
Reference in New Issue
Block a user