Add rename-user SQL template; make batch transaction handling robust against non-Postgres backends

- New 'rename-user' template (templates / template rename-user <old> <new>):
  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.
This commit is contained in:
Egor Bugaev
2026-07-06 19:52:05 +03:00
parent ca0605706f
commit 9e15c3b96c
4 changed files with 118 additions and 7 deletions
+9
View File
@@ -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/<old> -> data/<new>`) 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
+10
View File
@@ -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`. Сначала сделайте бэкап базы и
протестируйте на некритичном аккаунте.
## Обновление кода на сервере
+38
View File
@@ -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.') + ']');
}
}
+61 -7
View File
@@ -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;
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),
// если один из предыдущих шагов не выполнился.
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) {
if (!$rolledBack && !$rollbackFailed && $transactionStarted) {
try {
$this->db->commit();
}
return new JSONResponse([
'success' => true,
'rolledBack' => $rolledBack,
'results' => $results
} 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(),
]);
}
}
$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);
}
}