[Imapsync] Use dropdown for custom params

This commit is contained in:
FreddleSpl0it
2026-07-17 20:45:46 +02:00
parent 496c3a51ac
commit 069b89629e
12 changed files with 237 additions and 125 deletions
+13 -18
View File
@@ -5,6 +5,7 @@ use LockFile::Simple qw(lock trylock unlock);
use Proc::ProcessTable;
use Data::Dumper qw(Dumper);
use IPC::Run 'run';
use JSON::PP;
use File::Temp;
use Try::Tiny;
use POSIX ();
@@ -12,23 +13,6 @@ use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT);
sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s };
sub qqw($) {
my @params = ();
my @values = split(/(?=--)/, $_[0]);
foreach my $val (@values) {
my @tmpparam = split(/ /, $val, 2);
foreach my $tmpval (@tmpparam) {
if ($tmpval ne '') {
push @params, $tmpval;
}
}
}
foreach my $val (@params) {
$val=trim($val);
}
return @params;
}
# Move a finished job to the back of the queue, keeping order_id contiguous (1..N).
# Called only from the parent after reaping a child, so all rotations are serialized.
sub rotate_to_back {
@@ -214,7 +198,18 @@ foreach my $row (@rows) {
print $passfile2 trim($master_pass) . "\n";
my @custom_params_a = qqw($custom_params);
# Structured custom params: JSON array of {o,v}. Build one argv token per pair as "--opt=value"
# (value glued to its option) so a value can never become a separate option; passed via
# IPC::Run list form (execvp, no shell). Only allowlisted option names are stored (validated on write).
my @custom_params_a;
my $cp_pairs = eval { decode_json(defined($custom_params) && $custom_params ne '' ? $custom_params : '[]') };
if (ref($cp_pairs) eq 'ARRAY') {
for my $p (@$cp_pairs) {
next unless ref($p) eq 'HASH' && defined $p->{o} && $p->{o} ne '';
(my $ov = defined($p->{v}) ? $p->{v} : '') =~ s/\x00//g;
push @custom_params_a, ($ov eq '' ? "--$p->{o}" : "--$p->{o}=$ov");
}
}
my $custom_params_ref = \@custom_params_a;
# Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit.
+47 -3
View File
@@ -1520,7 +1520,7 @@ paths:
timeout1: "600"
timeout2: "600"
exclude: "(?i)spam|(?i)junk"
custom_params: "--dry"
custom_params: '[{"o":"dry","v":""}]'
delete2duplicates: "1"
delete1: "1"
delete2: "0"
@@ -1569,7 +1569,13 @@ paths:
description: exclude objects (regex)
type: string
custom_params:
description: custom parameters
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
delete2duplicates:
description: delete duplicates on destination (--delete2duplicates)
@@ -4063,7 +4069,13 @@ paths:
etc.)
type: boolean
custom_params:
description: Custom parameters passed to imapsync command
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
delete1:
description: Delete from source when completed
@@ -6216,6 +6228,38 @@ paths:
flags — they are never returned over the JSON API.
operationId: Get sync job sources
summary: Get sync job sources
/api/v1/get/syncjob_options:
get:
parameters:
- description: e.g. api-key-string
example: api-key-string
in: header
name: X-API-Key
required: false
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- dry
- folder
- delete2folders
- maxsize
description: OK
headers: {}
tags:
- Sync jobs
description: >-
Returns the allowlist of imapsync option names accepted in a sync job's
`custom_params`. Used by the UI to populate the option autocompletion.
operationId: Get sync job options
summary: Get sync job options
"/api/v1/get/tls-policy-map/{id}":
get:
parameters:
+33 -60
View File
@@ -360,6 +360,21 @@ function imapsync_get_settings() {
return $settings;
}
function imapsync_normalize_custom_params($input) {
$pairs = json_decode((string)$input, true);
if (!is_array($pairs)) return '[]';
$allow = $GLOBALS['IMAPSYNC_OPTIONS']['whitelist'];
$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
$out[] = array('o' => $o, 'v' => str_replace("\0", '', (string)($p['v'] ?? '')));
}
return json_encode($out);
}
function imapsync_set_setting($name, $value) {
// Admin-only writer for a global syncjob setting.
global $pdo;
@@ -508,36 +523,15 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
return false;
}
// validate custom params
foreach (explode('-', $custom_params) as $param){
if(empty($param)) continue;
// extract option
if (str_contains($param, '=')) $param = explode('=', $param)[0];
else $param = rtrim($param, ' ');
// remove first char if first char is -
if ($param[0] == '-') $param = ltrim($param, $param[0]);
if (str_contains($param, ' ')) {
// bad char
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'bad character SPACE'
);
return false;
}
// check if param is whitelisted
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
// bad option
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'bad option '. $param
);
return false;
}
// Structured custom params: validate option names against the allowlist, store as JSON pairs
$custom_params = imapsync_normalize_custom_params($custom_params);
if ($custom_params === false) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'imapsync_custom_param_invalid'
);
return false;
}
if (empty($subfolder2)) {
$subfolder2 = "";
@@ -879,36 +873,15 @@ function syncjob($_action, $_type, $_data = null, $_extra = null) {
$password1 = '';
}
// validate custom params
foreach (explode('-', $custom_params) as $param){
if(empty($param)) continue;
// extract option
if (str_contains($param, '=')) $param = explode('=', $param)[0];
else $param = rtrim($param, ' ');
// remove first char if first char is -
if ($param[0] == '-') $param = ltrim($param, $param[0]);
if (str_contains($param, ' ')) {
// bad char
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'bad character SPACE'
);
return false;
}
// check if param is whitelisted
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
// bad option
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'bad option '. $param
);
return false;
}
// Structured custom params: validate option names against the allowlist, store as JSON pairs
$custom_params = imapsync_normalize_custom_params($custom_params);
if ($custom_params === false) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'imapsync_custom_param_invalid'
);
continue;
}
if (empty($subfolder2)) {
$subfolder2 = "";
+24 -2
View File
@@ -4,7 +4,7 @@ function init_db_schema()
try {
global $pdo;
$db_version = "16072026_1000";
$db_version = "17072026_1000";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -886,7 +886,7 @@ function init_db_schema()
"delete2" => "TINYINT(1) NOT NULL DEFAULT '0'",
"automap" => "TINYINT(1) NOT NULL DEFAULT '0'",
"skipcrossduplicates" => "TINYINT(1) NOT NULL DEFAULT '0'",
"custom_params" => "VARCHAR(512) NOT NULL DEFAULT ''",
"custom_params" => "TEXT",
"timeout1" => "SMALLINT NOT NULL DEFAULT '600'",
"timeout2" => "SMALLINT NOT NULL DEFAULT '600'",
"subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'",
@@ -1533,6 +1533,28 @@ function init_db_schema()
$pdo->query("UPDATE `imapsync` SET `order_id` = (@r := @r + 1) ORDER BY `id`");
}
// 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'];
$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']);
if ($cp !== '' && $cp[0] === '[') continue; // already migrated (JSON)
$pairs = array();
if ($cp !== '') {
$tokens = preg_split('/\s+/', $cp, -1, PREG_SPLIT_NO_EMPTY);
for ($i = 0; $i < count($tokens); $i++) {
if (strpos($tokens[$i], '--') !== 0) continue;
$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
$pairs[] = array('o' => strtolower($opt), 'v' => $val);
}
}
$cp_upd->execute(array(':cp' => json_encode($pairs), ':id' => $r['id']));
}
// Inject admin if not exists
$stmt = $pdo->query("SELECT NULL FROM `admin`");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
-32
View File
@@ -285,10 +285,7 @@ $IMAPSYNC_OPTIONS = array(
'whitelist' => array(
'abort',
'authmd51',
'authmd52',
'authmech2',
'authuser1',
'authuser2',
'debug',
'debugcontent',
'debugcrossduplicates',
@@ -296,20 +293,15 @@ $IMAPSYNC_OPTIONS = array(
'debugfolders',
'debugimap',
'debugimap1',
'debugimap2',
'debugmemory',
'debugssl',
'delete1emptyfolders',
'delete2folders',
'disarmreadreceipts',
'domain1',
'domain2',
'domino1',
'domino2',
'dry',
'errorsmax',
'exchange1',
'exchange2',
'exitwhenover',
'expunge1',
'f1f2',
@@ -319,7 +311,6 @@ $IMAPSYNC_OPTIONS = array(
'folderlast',
'folderrec',
'gmail1',
'gmail2',
'idatefromheader',
'include',
'inet4',
@@ -329,10 +320,7 @@ $IMAPSYNC_OPTIONS = array(
'justfoldersizes',
'justlogin',
'keepalive1',
'keepalive2',
'log',
'logdir',
'logfile',
'maxbytesafter',
'maxlinelength',
'maxmessagespersecond',
@@ -342,43 +330,24 @@ $IMAPSYNC_OPTIONS = array(
'minsize',
'noabletosearch',
'noabletosearch1',
'noabletosearch2',
'noexpunge1',
'noexpunge2',
'nofoldersizesatend',
'noid',
'nolog',
'nomixfolders',
'noresyncflags',
'nossl1',
'nossl2',
'nosyncacls',
'notls1',
'notls2',
'nouidexpunge2',
'nousecache',
'oauthaccesstoken2',
'oauthdirect2',
'office1',
'office2',
'pidfile',
'pidfilelocking',
'prefix1',
'prefix2',
'proxyauth1',
'proxyauth2',
'resyncflags',
'resynclabels',
'search',
'search1',
'search2',
'sep1',
'sep2',
'showpasswords',
'skipemptyfolders',
'ssl2',
'sslargs1',
'sslargs2',
'subfolder1',
'subscribe',
'subscribed',
@@ -389,7 +358,6 @@ $IMAPSYNC_OPTIONS = array(
'tests',
'testslive',
'testslive6',
'tls2',
'truncmess',
'usecache',
'useheader',
+85
View File
@@ -480,7 +480,88 @@ 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.
var imapsyncCpOptions = null;
function imapsyncCpEnsureOptions(cb) {
if (imapsyncCpOptions !== null) { cb(); return; }
$.get("/api/v1/get/syncjob_options", function(opts) {
imapsyncCpOptions = Array.isArray(opts) ? opts : [];
cb();
});
}
// Append one row to an editor; option is a bootstrap-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>' +
'<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) {
$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) {
$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 || '');
$editor.find('.imapsync-cp-rows').append($row);
$sel.selectpicker();
}
// 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 = []; }
if (!Array.isArray(pairs)) pairs = [];
pairs.forEach(function(p) {
if (p && typeof p === 'object') imapsyncCpAddRow($editor, p.o, p.v);
});
}
// Re-serialize an editor's rows back into its hidden JSON value (option name required).
function imapsyncCpSerialize($editor) {
var out = [];
$editor.find('.imapsync-cp-row').each(function() {
var o = $.trim($(this).find('.imapsync-cp-opt').val() || '').replace(/^-+/, '');
if (o === '') return;
out.push({ o: o, v: $(this).find('.imapsync-cp-val').val() || '' });
});
$editor.siblings('.imapsync-cp-value').val(JSON.stringify(out));
}
$(document).ready(function() {
// Custom-params editor: keep the hidden JSON in sync with the rows
$(document).on('change', '.imapsync-cp-opt', function() {
imapsyncCpSerialize($(this).closest('.imapsync-cp-editor'));
});
$(document).on('input', '.imapsync-cp-val', function() {
imapsyncCpSerialize($(this).closest('.imapsync-cp-editor'));
});
$(document).on('click', '.imapsync-cp-add', function() {
var $editor = $(this).closest('.imapsync-cp-editor');
imapsyncCpEnsureOptions(function() { imapsyncCpAddRow($editor); });
});
$(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);
});
// Edit page (syncjob.twig): build rows from the stored value on load
imapsyncCpEnsureOptions(function() {
$('.imapsync-cp-editor').each(function() { imapsyncCpBuild($(this)); });
});
// Syncjob form: toggle password row / OAuth "connect" button by the selected source's flow
$(document).on('change', 'select.imapsync-source-select', function() {
var $sel = $(this);
@@ -543,5 +624,9 @@ $(document).ready(function() {
// Add-syncjob modal opens: (re)load the source dropdown from the API
$(document).on('shown.bs.modal', '#addSyncJobModalAdmin, #addSyncJobModal', function() {
populateImapsyncSourceSelects();
var $modal = $(this);
imapsyncCpEnsureOptions(function() {
$modal.find('.imapsync-cp-editor').each(function() { imapsyncCpBuild($(this)); });
});
});
});
+4
View File
@@ -1049,6 +1049,10 @@ 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']);
break;
case "syncjob_source":
switch ($object) {
case "all":
+5 -1
View File
@@ -560,6 +560,7 @@
"imapsync_source_port_invalid": "Ungültiger Port",
"imapsync_source_enc_invalid": "Ungültige Verschlüsselungsmethode",
"imapsync_source_auth_invalid": "Ungültige Authentifizierungsmethode",
"imapsync_custom_param_invalid": "Eigene Parameter enthalten eine nicht erlaubte imapsync-Option",
"imapsync_source_oauth_required": "OAuth2 erfordert Token-Endpoint, Client-ID, Client-Secret und Scope",
"imapsync_source_oauth_endpoint_invalid": "Ungültige OAuth2-Endpoint-URL",
"imapsync_source_oauth_extra_invalid": "OAuth2-Extra-Parameter müssen gültiges JSON sein",
@@ -1290,7 +1291,10 @@
"timeout2": "Timeout für Verbindung zum lokalen Host",
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
"custom_params": "Eigene Parameter",
"custom_params_hint": "Richtig: --param=xy, falsch: --param xy",
"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_add": "Parameter hinzufügen",
"order_id": "Position",
"order_id_hint": "Kleinere Position läuft zuerst; Ändern sortiert die globale Warteschlange um.",
"set_position": "Position setzen"
+5 -1
View File
@@ -560,6 +560,7 @@
"imapsync_source_port_invalid": "Invalid port",
"imapsync_source_enc_invalid": "Invalid encryption method",
"imapsync_source_auth_invalid": "Invalid authentication method",
"imapsync_custom_param_invalid": "Custom parameters contain a disallowed imapsync option",
"imapsync_source_oauth_required": "OAuth2 requires token endpoint, client ID, client secret and scope",
"imapsync_source_oauth_endpoint_invalid": "Invalid OAuth2 endpoint URL",
"imapsync_source_oauth_extra_invalid": "OAuth2 extra parameters must be valid JSON",
@@ -1297,7 +1298,10 @@
"timeout2": "Timeout for connection to local host",
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
"custom_params": "Custom parameters",
"custom_params_hint": "Right: --param=xy, wrong: --param xy",
"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_add": "Add parameter",
"order_id": "Position",
"order_id_hint": "Lower position runs first; setting it re-sorts the global queue.",
"set_position": "Set position"
+7 -3
View File
@@ -90,10 +90,14 @@
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="custom_params">{{ lang.syncjobs.custom_params }}</label>
<label class="control-label col-sm-2">{{ lang.syncjobs.custom_params }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="custom_params" id="custom_params" value="{{ result.custom_params }}" placeholder="--some-param=xy --other-param=yx">
<small class="text-muted">{{ lang.syncjobs.custom_params_hint }}</small>
<div class="imapsync-cp-editor">
<div class="imapsync-cp-rows"></div>
<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">
+7 -3
View File
@@ -1028,10 +1028,14 @@
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end" for="custom_params">{{ lang.syncjobs.custom_params }}</label>
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.custom_params }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="custom_params" placeholder="--some-param=xy --other-param=yx">
<small class="text-muted">{{ lang.syncjobs.custom_params_hint }}</small>
<div class="imapsync-cp-editor">
<div class="imapsync-cp-rows"></div>
<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">
+7 -2
View File
@@ -112,9 +112,14 @@
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="custom_params">{{ lang.syncjobs.custom_params }}</label>
<label class="control-label col-sm-2">{{ lang.syncjobs.custom_params }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="custom_params" placeholder="--delete2folders --otheroption">
<div class="imapsync-cp-editor">
<div class="imapsync-cp-rows"></div>
<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">