From 9e15c3b96c5031b1e47ec8b873760fade7b7b298 Mon Sep 17 00:00:00 2001 From: Egor Bugaev Date: Mon, 6 Jul 2026 19:52:05 +0300 Subject: [PATCH] Add rename-user SQL template; make batch transaction handling robust against non-Postgres backends - New 'rename-user' template (templates / template rename-user ): best-effort uid rename across core tables (oc_users, oc_preferences, oc_group_user, oc_group_admin, oc_ldap_user_mapping, oc_share, oc_mounts, oc_storages). Explicitly NOT a supported Nextcloud operation - the generated script carries an in-line warning (as leading SQL comments) that app-specific tables (Talk, Calendar, Contacts, Mail, 2FA/WebAuthn...) are not covered and that the data directory must be renamed on disk manually, followed by occ files:scan --all. Documented the same caveat in both READMEs. - Hardened the round-2 transaction wrapping: beginTransaction()/commit()/ rollBack() are now wrapped in try/catch. PostgreSQL (our backend) has fully transactional DDL so this wasn't actually broken here, but Nextcloud also supports MySQL/MariaDB via the same IDBConnection, where DDL implicitly commits - on that backend a DDL statement in the batch would make a later commit()/rollBack() throw 'no active transaction' and previously that exception was unhandled (HTTP 500 instead of a clean JSON response). Now: if rollBack() itself fails after an error, we report rollbackFailed+warning instead of falsely claiming rolledBack, since earlier statements in that batch may already be permanently applied. If commit() fails with nothing to commit (already auto-committed), that's logged only, since the effects are already durably persisted. --- README.md | 9 +++++ README.ru.md | 10 +++++ js/index.js | 38 ++++++++++++++++++ lib/Controller/DbController.php | 68 +++++++++++++++++++++++++++++---- 4 files changed, 118 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f78b33..9dbbcb7 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,15 @@ you are about to run, ideally against a non-critical row/user first. - The application is not a real interactive terminal and does not support long running tasks. So if your instance is pretty big, commands like `occ files:scan` will time out and fail. - Do not use `occ maintenance:mode --on`, obvious... +- The `rename-user` SQL template is **best-effort only**: renaming a Nextcloud + username is not an officially supported operation. It updates the core + tables it knows about (`oc_users`, `oc_preferences`, `oc_group_user`, + `oc_group_admin`, `oc_ldap_user_mapping`, `oc_share`, `oc_mounts`, + `oc_storages`), but does **not** touch app-specific tables (Talk, Calendar, + Contacts, Mail, two-factor/WebAuthn, etc.). You must also manually rename + the user's data directory on disk (`data/ -> data/`) with the web + server stopped or in maintenance mode, then run `occ files:scan --all`. + Back up the database first and test on a non-critical account. ## Deploying updates diff --git a/README.ru.md b/README.ru.md index 496765b..06095cb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -79,6 +79,16 @@ occ-команд. Поэтому на крупных инсталляциях команды вроде `occ files:scan` могут завершаться по таймауту с ошибкой. - Не используйте `occ maintenance:mode --on`, это очевидно... +- Шаблон `rename-user` — это **best-effort**, а не официально поддерживаемая + Nextcloud операция. Он обновляет только известные таблицы ядра + (`oc_users`, `oc_preferences`, `oc_group_user`, `oc_group_admin`, + `oc_ldap_user_mapping`, `oc_share`, `oc_mounts`, `oc_storages`), но **не** + трогает таблицы сторонних приложений (Talk, Calendar, Contacts, Mail, + двухфакторная аутентификация/WebAuthn и т.д.). Дополнительно нужно вручную + переименовать каталог данных пользователя на диске (`data/<старый> -> + data/<новый>`) при остановленном веб-сервере или в режиме обслуживания, а + затем выполнить `occ files:scan --all`. Сначала сделайте бэкап базы и + протестируйте на некритичном аккаунте. ## Обновление кода на сервере diff --git a/js/index.js b/js/index.js index c792ebd..23035db 100644 --- a/js/index.js +++ b/js/index.js @@ -103,6 +103,42 @@ "SELECT * FROM oc_ldap_user_mapping WHERE owncloud_name = '" + v + "'" ].join(';\n') + ';'; } + }, + 'rename-user': { + args: ['old_uid', 'new_uid'], + description: 'ВНИМАНИЕ: не официальная операция Nextcloud. Переименовывает uid только в основных таблицах ядра — покрывает не всё, требует ручных доп. шагов (см. предупреждение в самом скрипте)', + build: function (oldUid, newUid) { + var o = escapeSqlString(oldUid); + var n = escapeSqlString(newUid); + // Предупреждение — только однострочные "--"-комментарии без ";" внутри, + // поэтому splitStatements() (парный на бэкенде и здесь) не разобьёт их + // как отдельные запросы, а stripLeadingComments() на бэкенде уберёт + // этот блок перед определением типа самого первого запроса (SELECT). + var warning = + "-- WARNING rename is NOT an officially supported Nextcloud operation\n" + + "-- This only updates core tables below - it does NOT cover app-specific\n" + + "-- tables (Talk, Calendar, Contacts, Mail, two-factor, WebAuthn, etc.)\n" + + "-- After running this you must ALSO, with the web server stopped or in\n" + + "-- maintenance mode, rename the data directory on disk (data/" + o + " -> data/" + n + ")\n" + + "-- and then run: occ files:scan --all\n" + + "-- Back up the database first and test on a non-critical account\n"; + var statements = [ + "SELECT uid FROM oc_users WHERE uid = '" + o + "'", + "SELECT uid FROM oc_users WHERE uid = '" + n + "'", + "UPDATE oc_users SET uid = '" + n + "' WHERE uid = '" + o + "'", + "UPDATE oc_preferences SET userid = '" + n + "' WHERE userid = '" + o + "'", + "UPDATE oc_group_user SET uid = '" + n + "' WHERE uid = '" + o + "'", + "UPDATE oc_group_admin SET uid = '" + n + "' WHERE uid = '" + o + "'", + "UPDATE oc_ldap_user_mapping SET owncloud_name = '" + n + "' WHERE owncloud_name = '" + o + "'", + "UPDATE oc_share SET uid_owner = '" + n + "' WHERE uid_owner = '" + o + "'", + "UPDATE oc_share SET uid_initiator = '" + n + "' WHERE uid_initiator = '" + o + "'", + "UPDATE oc_share SET share_with = '" + n + "' WHERE share_with = '" + o + "' AND share_type = 0", + "UPDATE oc_mounts SET user_id = '" + n + "' WHERE user_id = '" + o + "'", + "UPDATE oc_storages SET id = 'home::" + n + "' WHERE id = 'home::" + o + "'", + "SELECT uid FROM oc_users WHERE uid = '" + n + "'" + ]; + return warning + statements.join(';\n') + ';'; + } } }; @@ -218,6 +254,8 @@ }); if (response.rolledBack) { term.echo('[[;#ff5555;]Batch failed partway through — all statements in this batch were rolled back.]'); + } else if (response.rollbackFailed) { + term.echo('[[;#ff0000;]' + $.terminal.escape_formatting(response.warning || 'Batch failed and the rollback itself failed — earlier statements may have been permanently applied. Check manually.') + ']'); } } diff --git a/lib/Controller/DbController.php b/lib/Controller/DbController.php index 0f24bfa..b2d7afd 100644 --- a/lib/Controller/DbController.php +++ b/lib/Controller/DbController.php @@ -259,10 +259,30 @@ class DbController extends Controller // выполненные в этой же пачке изменения откатываются, а не // остаются частично применёнными. SET (без LOCAL) не транзакционен // в PostgreSQL, поэтому откат не затрагивает current_setting(). + // + // Про DDL: в PostgreSQL (наша БД) DDL — CREATE/ALTER/DROP TABLE и + // т.п. — полностью транзакционен и откатывается вместе с остальными + // изменениями пачки, поэтому здесь для Postgres проблемы нет. Но + // Nextcloud работает и с MySQL/MariaDB через тот же IDBConnection, а + // там DDL делает неявный COMMIT — если эта пачка выполнится на + // MySQL-инстансе, commit()/rollBack() после такого DDL получат + // исключение "no active transaction". Оборачиваем begin/commit/ + // rollBack в try/catch, чтобы это не превращалось в необработанное + // исключение и HTTP 500 вместо аккуратного JSON-ответа. $results = []; $rolledBack = false; + $rollbackFailed = false; + $transactionStarted = false; - $this->db->beginTransaction(); + try { + $this->db->beginTransaction(); + $transactionStarted = true; + } catch (\Exception $e) { + $this->logger->warning('[occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [ + 'app' => 'occweb', + 'error' => $e->getMessage(), + ]); + } foreach ($queries as $query) { $normalized = $this->stripLeadingComments($query); @@ -321,20 +341,54 @@ class DbController extends Controller // Откатываем всю пачку и останавливаемся: не продолжаем // выполнять оставшиеся запросы (например, серию DELETE), // если один из предыдущих шагов не выполнился. - $this->db->rollBack(); - $rolledBack = true; + if ($transactionStarted) { + try { + $this->db->rollBack(); + $rolledBack = true; + } catch (\Exception $rollbackError) { + // Транзакция уже закрыта не нами — например, неявным + // COMMIT-ом от DDL-запроса на MySQL/MariaDB. Всё, что + // выполнилось ДО этой точки в пачке, могло остаться + // применённым навсегда — намеренно НЕ ставим + // rolledBack в true, это было бы неправдой. + $rollbackFailed = true; + $this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [ + 'app' => 'occweb', + 'error' => $rollbackError->getMessage(), + ]); + } + } break; } } - if (!$rolledBack) { - $this->db->commit(); + if (!$rolledBack && !$rollbackFailed && $transactionStarted) { + try { + $this->db->commit(); + } catch (\Exception $e) { + // Транзакция уже закоммичена неявно (например, DDL-запросом + // на MySQL/MariaDB) — эффекты уже сохранены, это не ошибка + // выполнения самой пачки. + $this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [ + 'app' => 'occweb', + 'error' => $e->getMessage(), + ]); + } } - return new JSONResponse([ + $response = [ 'success' => true, 'rolledBack' => $rolledBack, 'results' => $results - ]); + ]; + + if ($rollbackFailed) { + $response['rollbackFailed'] = true; + $response['warning'] = 'The batch failed partway through and the rollback itself failed (this can happen if an earlier ' + . 'statement, e.g. a DDL statement, implicitly committed on this database backend). Statements executed before the ' + . 'failure may have been permanently applied — check the results above and verify manually.'; + } + + return new JSONResponse($response); } }