diff --git a/rebuild_roundcube_config.php b/rebuild_roundcube_config.php new file mode 100644 index 0000000..9c20ec3 --- /dev/null +++ b/rebuild_roundcube_config.php @@ -0,0 +1,367 @@ + [--apply] + * + * Without --apply (dry run, default): + * writes config.inc.php.new and config.inc.php.removed-params..txt + * next to the existing files, does NOT touch config.inc.php itself. + * + * With --apply: + * additionally backs up the existing config.inc.php to + * config.inc.php.bak. and replaces it with the regenerated file. + */ + +// --------------------------------------------------------------------------- +// Parameters that must always end up in the regenerated config.inc.php, even +// if their value equals the default (they are still kept explicit/visible +// per site policy). Add further keys here as needed. Note: this only +// suppresses the "equals default" removal - a parameter that no longer +// exists in Roundcube at all is still dropped, see UNDOCUMENTED_CORE_PARAMS +// below for parameters that need to be recognized as still valid. +// --------------------------------------------------------------------------- +const FORCE_KEEP_PARAMS = [ + 'imap_host', + 'imap_auth_type', + 'smtp_host', + 'smtp_port', + 'smtp_user', + 'smtp_pass', +]; + +// --------------------------------------------------------------------------- +// Parameters that exist in Roundcube 1.7.1 core (verified against program/ +// source via `->config->get('key', ...)` calls) but are NOT documented in +// the shipped config/defaults.inc.php. Keys found here are kept (not treated +// as "removed"), using this one-line note as their comment since +// defaults.inc.php has none. Verified 2026-07-03 against roundcubemail-1.7.1. +// --------------------------------------------------------------------------- +const UNDOCUMENTED_CORE_PARAMS = [ + 'additional_logo_types' => 'Additional file extensions accepted as skin logo/favicon images.', + 'dark_mode_support' => 'Tell the skin/UI that dark mode is supported (exposed to the client as env var).', + 'devel_mode' => 'Enable developer/debug mode (verbose errors, disables some internal caching).', + 'editor_css_location' => 'Path to a custom CSS file loaded into the HTML message editor (TinyMCE).', + 'embed_css_location' => 'Path to a custom CSS file loaded for embedded/quoted HTML messages.', + 'enable_caching' => 'Enable IMAP/message caching in the database.', + 'lock_special_folders' => "Prevent users from unsubscribing/renaming special folders (Inbox, Sent, ...).", + 'media_browser_css_location' => 'Path to a custom CSS file for the TinyMCE media browser.', + 'namespace_fixed' => 'Treat the configured IMAP namespace as fixed instead of auto-detecting it.', + 'pagesize' => 'Legacy fallback for addressbook_pagesize (see mail_pagesize/addressbook_pagesize).', + 'performance_stats' => 'Log performance/timing statistics (like devel_mode).', + 'shared_cache_ttl' => 'TTL for the shared memcache/redis object cache.', + 'spellcheck_atd_key' => 'API key for the After-the-Deadline spell checking service.', + 'storage_driver' => "Storage backend driver; currently only 'imap' is implemented by core.", + 'tnef_decode' => 'Enable/disable decoding of winmail.dat (TNEF) attachments.', +]; + +function fail(string $msg): void +{ + fwrite(STDERR, "ERROR: $msg\n"); + exit(1); +} + +function read_lines(string $file): array +{ + $lines = file($file); + if ($lines === false) { + fail("Cannot read $file"); + } + return $lines; +} + +/** + * Parse a Roundcube config source file into an ordered list of parameter + * blocks: key => ['comment' => , + * 'value_src' => ] + * + * Returns [orderedKeys (first-seen order), paramsByKey (last occurrence wins, + * matching PHP's own "last assignment wins" semantics for duplicate keys)] + */ +function parse_config_source(string $file): array +{ + $lines = read_lines($file); + $n = count($lines); + + $order = []; + $params = []; + $commentBuf = []; + $i = 0; + + while ($i < $n) { + $line = $lines[$i]; + + // $config = []; / $config = array(); is the file's own initializer, + // not a parameter - discard any accumulated header/license comments. + if (preg_match('/^\$config\s*=\s*(\[\]|array\(\))\s*;/', trim($line))) { + $commentBuf = []; + $i++; + continue; + } + + if (preg_match("/^\\\$config\\['([a-zA-Z0-9_]+)'\\]\\s*=/", $line, $m)) { + $key = $m[1]; + $stmtLines = [$line]; + $depth = substr_count($line, '(') - substr_count($line, ')') + + substr_count($line, '[') - substr_count($line, ']'); + $j = $i; + while (!(preg_match('/;\s*$/', rtrim($line)) && $depth <= 0)) { + $j++; + if ($j >= $n) { + fail("Unterminated statement for \$config['$key'] in $file at line " . ($i + 1)); + } + $line = $lines[$j]; + $stmtLines[] = $line; + $depth += substr_count($line, '(') - substr_count($line, ')') + + substr_count($line, '[') - substr_count($line, ']'); + } + $raw = implode('', $stmtLines); + if (!preg_match("/^\\\$config\\['" . preg_quote($key, '/') . "'\\]\\s*=\\s*(.*);\\s*$/s", $raw, $vm)) { + fail("Could not extract value for \$config['$key'] in $file"); + } + + $comment = rtrim(implode('', $commentBuf)); + $params[$key] = [ + 'comment' => $comment, + 'value_src' => rtrim($vm[1]), + ]; + if (!in_array($key, $order, true)) { + $order[] = $key; + } + $commentBuf = []; + $i = $j + 1; + continue; + } + + $commentBuf[] = $line; + $i++; + } + + return [$order, $params]; +} + +/** + * Evaluate a raw PHP expression (right-hand side of a $config[...] = ...; + * assignment) in isolation. These expressions in defaults.inc.php / + * config.inc.php are self-contained literals (strings, bools, numbers, + * arrays) with no external references, so isolated eval() is safe here. + */ +function eval_value_src(string $key, string $src, string $originFile) +{ + try { + return eval("return $src;"); + } catch (\Throwable $e) { + fail("Failed to evaluate \$config['$key'] from $originFile: " . $e->getMessage()); + } +} + +function values_equal($a, $b): bool +{ + return var_export($a, true) === var_export($b, true); +} + +function format_value(string $key, $val): string +{ + // Prefer var_export for the (rare) fallback path where we don't have + // the original source text available. + $s = var_export($val, true); + return $s; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +$configDir = isset($argv[1]) ? rtrim($argv[1], '/') : null; +$apply = in_array('--apply', $argv, true); + +if (!$configDir || !is_dir($configDir)) { + fail("Usage: php rebuild_roundcube_config.php [--apply]"); +} + +$defaultsFile = "$configDir/defaults.inc.php"; +$localFile = "$configDir/config.inc.php"; + +if (!is_file($defaultsFile)) { + fail("Not found: $defaultsFile"); +} +if (!is_file($localFile)) { + fail("Not found: $localFile"); +} + +// defaults.inc.php references RCUBE_INSTALL_PATH (e.g. for log_dir/temp_dir +// defaults). Define it exactly as Roundcube's own bootstrap does: the +// installation root (parent of config/) with a trailing slash. +if (!defined('RCUBE_INSTALL_PATH')) { + define('RCUBE_INSTALL_PATH', dirname($configDir) . '/'); +} + +[$defaultsOrder, $defaultsParams] = parse_config_source($defaultsFile); +[$localOrder, $localParams] = parse_config_source($localFile); + +// Detect duplicate keys in the local file, and report which value wins +// (informational - matches actual PHP "last wins" behaviour). +$rawLocalLines = read_lines($localFile); +$assignCounts = []; +foreach ($rawLocalLines as $line) { + if (preg_match("/^\\\$config\\['([a-zA-Z0-9_]+)'\\]\\s*=/", $line, $m)) { + $assignCounts[$m[1]] = ($assignCounts[$m[1]] ?? 0) + 1; + } +} +$duplicates = array_filter($assignCounts, fn($c) => $c > 1); + +$keep = []; // key => value_src (raw source text to emit) +$removed = []; // key => reason string + +foreach ($localOrder as $key) { + $localSrc = $localParams[$key]['value_src']; + $localVal = eval_value_src($key, $localSrc, $localFile); + $forceKeep = in_array($key, FORCE_KEEP_PARAMS, true); + + if (array_key_exists($key, $defaultsParams)) { + $defaultVal = eval_value_src($key, $defaultsParams[$key]['value_src'], $defaultsFile); + if (!$forceKeep && values_equal($localVal, $defaultVal)) { + $removed[$key] = [ + 'reason' => 'entspricht dem Default-Wert', + 'value' => format_value($key, $localVal), + ]; + continue; + } + $keep[$key] = $localSrc; + } elseif (array_key_exists($key, UNDOCUMENTED_CORE_PARAMS)) { + // Known to still be a valid core parameter, just undocumented. + $keep[$key] = $localSrc; + } else { + // Parameter no longer exists at all (neither in defaults.inc.php nor + // in UNDOCUMENTED_CORE_PARAMS) - FORCE_KEEP_PARAMS cannot resurrect + // it, there is nothing left to keep it in sync with. + $removed[$key] = [ + 'reason' => 'Parameter existiert in dieser Roundcube-Version nicht (mehr) / ist unbekannt', + 'value' => format_value($key, $localVal), + ]; + } +} + +// Force-keep parameters that were not set in config.inc.php at all: fall +// back to the default value so the parameter still shows up explicitly. +// Only applies to parameters that still exist in defaults.inc.php. +foreach (FORCE_KEEP_PARAMS as $key) { + if (array_key_exists($key, $keep) || array_key_exists($key, $localParams)) { + continue; + } + if (array_key_exists($key, $defaultsParams)) { + $keep[$key] = $defaultsParams[$key]['value_src']; + } +} + +// --------------------------------------------------------------------------- +// Build new config.inc.php content +// --------------------------------------------------------------------------- + +$timestamp = date('Y-m-d-Hi'); +$totalKept = count($keep); + +$out = []; +$out[] = ' $src) { + $out[] = '// ' . (UNDOCUMENTED_CORE_PARAMS[$key] ?? ''); + $out[] = "\$config['$key'] = " . $src . ';'; + $out[] = ''; + } +} + +$newContent = implode("\n", $out) . "\n"; + +// --------------------------------------------------------------------------- +// Build removed-params report +// --------------------------------------------------------------------------- + +$report = []; +$report[] = "# Parameter removed from config.inc.php on regeneration ($timestamp)"; +$report[] = "# Format: key = last effective value // reason"; +$report[] = ""; +foreach ($removed as $key => $info) { + $report[] = "$key = {$info['value']} // {$info['reason']}"; +} +$reportContent = implode("\n", $report) . "\n"; + +// --------------------------------------------------------------------------- +// Write output / apply +// --------------------------------------------------------------------------- + +$newFile = "$localFile.new"; +$reportFile = "$configDir/config.inc.php.removed-params.$timestamp.txt"; + +file_put_contents($newFile, $newContent); +file_put_contents($reportFile, $reportContent); + +echo "Wrote: $newFile\n"; +echo "Wrote: $reportFile\n"; +echo "\n"; +echo "Kept parameters : $totalKept\n"; +echo "Removed parameters: " . count($removed) . "\n"; + +if (!empty($duplicates)) { + echo "\nWARNING: the following keys were assigned more than once in config.inc.php\n"; + echo "(PHP uses the LAST assignment - already reflected above, listed here for review):\n"; + foreach ($duplicates as $key => $count) { + echo " - $key ($count assignments)\n"; + } +} + +if ($apply) { + $backupFile = "$localFile.bak.$timestamp"; + if (!copy($localFile, $backupFile)) { + fail("Could not create backup $backupFile"); + } + if (!rename($newFile, $localFile)) { + fail("Could not move $newFile to $localFile"); + } + echo "\nApplied: backed up original to $backupFile and replaced $localFile\n"; +} else { + echo "\nDry run only. Review $newFile and $reportFile, then re-run with --apply.\n"; +}