Files
Egor Bugaev 9e15c3b96c 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.
2026-07-06 19:52:05 +03:00

408 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function (OC, window, $, undefined) {
'use strict';
$(function() {
function scrollToBottom(){
var html = $('html');
html.scrollTop(html.prop('scrollHeight'));
}
var baseUrl = OC.generateUrl('/apps/occweb');
// Текущий режим терминала: 'occ' (обычные occ-команды) или 'sql'
// (выполнение произвольных SQL-запросов через /db/query).
var mode = 'occ';
var OCC_PROMPT = 'occ $ ';
var SQL_PROMPT = '[[;#ff5555;]sql]# ';
// Разбивает пачку запросов по ";" с учётом одинарных И двойных кавычек
// (в т.ч. '' / "" как экранированной кавычки внутри строки/идентификатора),
// чтобы ";" в строковом литерале или "квотированном идентификаторе" не
// ломал разбиение. Зеркалит splitStatements() на бэкенде.
function splitStatements(sql) {
var statements = [];
var current = '';
var quoteChar = null;
for (var i = 0; i < sql.length; i++) {
var ch = sql[i];
if (quoteChar !== null) {
if (ch === quoteChar) {
if (sql[i + 1] === quoteChar) {
current += quoteChar + quoteChar;
i++;
continue;
}
quoteChar = null;
}
current += ch;
continue;
}
if (ch === "'" || ch === '"') {
quoteChar = ch;
current += ch;
continue;
}
if (ch === ';') {
statements.push(current.trim());
current = '';
continue;
}
current += ch;
}
if (current.trim() !== '') {
statements.push(current.trim());
}
return statements.filter(function (s) { return s !== ''; });
}
// Быстрая клиентская проверка на DELETE/UPDATE — только для UX (чтобы не
// делать лишний запрос к серверу). Итоговое решение всё равно
// принимает бэкенд (requiresConfirmation), это лишь подсказка.
function scriptNeedsConfirmation(sql) {
return splitStatements(sql).some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
return /^(DELETE|UPDATE)\b/i.test(normalized);
});
}
function escapeSqlString(value) {
return String(value).replace(/'/g, "''");
}
// Готовые шаблоны SQL-скриптов. build() возвращает текст скрипта,
// который вставляется в командную строку через term.set_command() —
// ничего не выполняется автоматически, пользователь сам проверяет
// и жмёт Enter (после чего срабатывает обычное подтверждение DELETE).
var TEMPLATES = {
'delete-user': {
args: ['uid'],
description: 'Полностью удалить локального пользователя (oc_preferences, oc_group_user, oc_ldap_user_mapping, oc_users)',
build: function (uid) {
// Значение подставляется напрямую в каждый запрос (а не через
// SET+current_setting) — так скрипт остаётся рабочим, даже если
// пользователь скопирует/выполнит только часть строк по отдельности.
var v = escapeSqlString(uid);
return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'",
"SELECT * FROM oc_preferences WHERE userid = '" + v + "'",
"DELETE FROM oc_preferences WHERE userid = '" + v + "'",
"DELETE FROM oc_group_user WHERE uid = '" + v + "'",
"DELETE FROM oc_ldap_user_mapping WHERE owncloud_name = '" + v + "'",
"DELETE FROM oc_users WHERE uid = '" + v + "'"
].join(';\n') + ';';
}
},
'list-user': {
args: ['uid'],
description: 'Только посмотреть данные пользователя, без удаления (oc_users, oc_preferences, oc_group_user, oc_ldap_user_mapping)',
build: function (uid) {
var v = escapeSqlString(uid);
return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'",
"SELECT * FROM oc_preferences WHERE userid = '" + v + "'",
"SELECT * FROM oc_group_user WHERE uid = '" + v + "'",
"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') + ';';
}
}
};
function listTemplates(term) {
term.echo('[[;yellow;]Available templates:]');
Object.keys(TEMPLATES).forEach(function (name) {
var t = TEMPLATES[name];
var usage = name + ' ' + t.args.map(function (a) { return '<' + a + '>'; }).join(' ');
term.echo('[[;#009ae3;] template ' + usage + ']');
term.echo(' ' + t.description);
});
term.echo('[[;gray;]Fills the command line — review it, then press Enter to run.]');
}
function useTemplate(term, name, args) {
var t = TEMPLATES[name];
if (!t) {
term.echo('[[;#ff5555;]Unknown template: ]' + $.terminal.escape_formatting(name || '') + '. Type "templates" to list available ones.');
return;
}
if (args.length < t.args.length) {
var usage = name + ' ' + t.args.map(function (a) { return '<' + a + '>'; }).join(' ');
term.echo('[[;#ff5555;]Missing arguments. Usage: ]template ' + usage);
return;
}
var sql = t.build.apply(null, args);
term.set_command(sql);
term.echo('[[;yellow;]Template inserted into the command line — review it, then press Enter to run.]');
}
// Табличный вывод результатов SELECT в стиле psql.
function renderTable(term, rows) {
if (!rows || !rows.length) {
return;
}
var columns = Object.keys(rows[0]);
function formatCell(v) {
if (v === null || v === undefined) {
// Явная метка, а не пустая строка — иначе NULL неотличим от
// настоящей пустой строки '' в выводе таблицы.
return '[NULL]';
}
if (typeof v === 'object') {
return JSON.stringify(v);
}
return String(v).replace(/\r?\n/g, '\\n');
}
function pad(str, width) {
str = String(str);
var diff = width - str.length;
return diff > 0 ? str + new Array(diff + 1).join(' ') : str;
}
var widths = columns.map(function (col) {
return rows.reduce(function (max, row) {
return Math.max(max, formatCell(row[col]).length);
}, col.length);
});
function formatRow(cells) {
return cells.map(function (cell, i) {
return ' ' + pad(cell, widths[i]) + ' ';
}).join('|');
}
var lines = [];
lines.push(formatRow(columns));
lines.push(widths.map(function (w) { return new Array(w + 3).join('-'); }).join('+'));
rows.forEach(function (row) {
lines.push(formatRow(columns.map(function (col) { return formatCell(row[col]); })));
});
term.echo($.terminal.escape_formatting(lines.join('\n')));
}
function renderSqlResponse(term, response) {
if (!response) {
term.echo('[[;#ff5555;]Empty response from server]');
return;
}
if (response.success === false) {
term.echo('[[;#ff5555;]Error: ]' + $.terminal.escape_formatting(response.error || 'unknown error'));
return;
}
var results = response.results || [];
if (!results.length) {
term.echo('[[;yellow;]No statements were executed]');
return;
}
results.forEach(function (r) {
term.echo('[[;#009ae3;]> ]' + $.terminal.escape_formatting(r.query || ''));
if (r.type === 'error') {
term.echo('[[;#ff5555;] Error: ]' + $.terminal.escape_formatting(r.error || 'unknown error'));
} else if (r.type === 'select') {
term.echo('[[;gray;] ' + r.count + ' row(s)]');
if (r.count > 0) {
renderTable(term, r.data);
}
if (r.truncated) {
term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]');
}
} else if (r.type === 'set') {
term.echo('[[;green;] OK (session variable set)]');
} else if (r.type === 'delete') {
term.echo('[[;#ff9900;] DELETED ' + r.affected_rows + ' row(s)]');
} else if (r.type === 'update') {
term.echo('[[;#ff9900;] UPDATED ' + r.affected_rows + ' row(s)]');
} else {
term.echo('[[;green;] OK, ' + r.affected_rows + ' row(s) affected]');
}
});
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.') + ']');
}
}
function enterSqlMode(term) {
mode = 'sql';
term.set_prompt(SQL_PROMPT);
term.echo('[[;yellow;]Switched to SQL mode. Admin only — statements run directly against the database.]');
term.echo('[[;gray;]Separate statements with ";". Shift+Enter for a new line, Enter to run. Type "occ" to go back.]');
term.echo('[[;gray;]Type "templates" to list ready-made scripts (e.g. deleting a user).]');
}
function exitSqlMode(term) {
mode = 'occ';
term.set_prompt(OCC_PROMPT);
term.echo('[[;yellow;]Switched back to OCC mode.]');
}
// Таймаут для больших/долгих пачек (например, DELETE по большой таблице
// без индекса): без него зависший запрос молча оставит терминал
// заблокированным (term.pause()) навсегда, если сервер не ответит.
var SQL_REQUEST_TIMEOUT_MS = 120000;
function sendSqlQuery(term, sql, confirmed) {
term.pause();
$.ajax({
url: baseUrl + '/db/query',
type: 'POST',
contentType: 'application/json',
timeout: SQL_REQUEST_TIMEOUT_MS,
data: JSON.stringify({ sql: sql, confirm: !!confirmed })
}).done(function (response) {
if (response && response.requiresConfirmation) {
term.resume();
askDeleteConfirmation(term, sql, response.error);
return;
}
renderSqlResponse(term, response);
term.resume();
}).fail(function (xhr, status) {
if (status === 'timeout') {
term.echo('[[;#ff5555;]Request timed out after ' + (SQL_REQUEST_TIMEOUT_MS / 1000) + 's — the query may still be running on the server, check occ/DB logs before retrying.]');
} else {
term.echo('[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText));
}
term.resume();
});
}
function askDeleteConfirmation(term, sql, message) {
var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE/UPDATE statement(s).') + ' Type "yes" to run it: ]';
term.read(prompt).then(function (answer) {
if ((answer || '').trim().toLowerCase() === 'yes') {
sendSqlQuery(term, sql, true);
} else {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
}
}, function () {
term.echo('[[;yellow;]Cancelled — nothing was executed.]');
});
}
$.get(baseUrl + '/cmd', function(response){
$('#app-content').terminal(function(command, term) {
if (mode === 'sql') {
var trimmed = command.trim();
if (trimmed === 'occ') {
exitSqlMode(term);
return;
}
if (trimmed === 'c') {
term.clear();
return;
}
if (trimmed === 'exit') {
exitSqlMode(term);
term.reset();
return;
}
if (trimmed === 'templates') {
listTemplates(term);
return;
}
if (/^template(\s|$)/i.test(trimmed)) {
var parts = trimmed.split(/\s+/);
useTemplate(term, parts[1], parts.slice(2));
return;
}
if (!trimmed) {
return;
}
if (scriptNeedsConfirmation(command)) {
askDeleteConfirmation(term, command);
} else {
sendSqlQuery(term, command, false);
}
return;
}
switch (command) {
case "c":
this.clear();
break;
case "exit":
this.reset();
break;
case "sql":
enterSqlMode(term);
break;
default:
var occCommand = {
command: command
};
term.pause();
$.ajax({
url: baseUrl + '/cmd',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(occCommand)
}).done(function (response) {
term.echo('\n' + response).resume();
}).fail(function (response, code) {
term.echo('\n' + response).resume();
});
}
}, {
greetings: function (callback) {
callback('[[;green;]' + new Date().toString().slice(0, 24) + "]\n\nPress [[;#ff5e99;]Enter] for more information on [[;#009ae3;]occ] commands.\nType [[;#ff5e99;]sql] to switch to SQL query mode.\n")
},
name: 'occ',
prompt: OCC_PROMPT,
completion: response,
keydown: function (e) {
// Shift+Enter вставляет перевод строки вместо выполнения команды,
// это позволяет набирать многострочные SQL-скрипты в режиме sql.
if (e.shiftKey && e.key === 'Enter') {
this.insert('\n');
return false;
}
},
onResize: function(){
scrollToBottom()
}
});
});
$('html').keypress(function(){
scrollToBottom()
})
});
})(OC, window, jQuery);