9e15c3b96c
- 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.
408 lines
17 KiB
JavaScript
408 lines
17 KiB
JavaScript
(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);
|