Translate Russian comments and user-facing strings to English

Covers OccOutput.php, DbController.php and js/index.js: docblocks,
inline comments, the forbidden-construct labels shown to admins in
error responses, and the SQL template descriptions shown in the
terminal (delete-user, list-user, rename-user).
This commit is contained in:
2026-09-26 00:13:03 +02:00
parent 040743d351
commit 7d05643ee6
3 changed files with 113 additions and 106 deletions
+33 -31
View File
@@ -7,17 +7,17 @@
}
var baseUrl = OC.generateUrl('/apps/occweb');
// Текущий режим терминала: 'occ' (обычные occ-команды) или 'sql'
// (выполнение произвольных SQL-запросов через /db/query).
// Current terminal mode: 'occ' (regular occ commands) or 'sql'
// (running arbitrary SQL queries via /db/query).
var mode = 'occ';
var OCC_PROMPT = 'occ $ ';
var SQL_PROMPT = '[[;#ff5555;]sql]# ';
// Разбивает пачку запросов по ";" с учётом одинарных И двойных кавычек
// (в т.ч. '' / "" как экранированной кавычки внутри строки/идентификатора),
// чтобы ";" в строковом литерале или "квотированном идентификаторе" не
// ломал разбиение. Зеркалит splitStatements() на бэкенде.
// Splits a batch of queries on ";" while respecting single AND double
// quotes (incl. '' / "" as an escaped quote inside a string/identifier),
// so that a ";" inside a string literal or "quoted identifier" doesn't
// break the split. Mirrors splitStatements() on the backend.
function splitStatements(sql) {
var statements = [];
var current = '';
@@ -54,9 +54,9 @@
return statements.filter(function (s) { return s !== ''; });
}
// Быстрая клиентская проверка на DELETE/UPDATE — только для UX (чтобы не
// делать лишний запрос к серверу). Итоговое решение всё равно
// принимает бэкенд (requiresConfirmation), это лишь подсказка.
// Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
// an extra round trip to the server). The backend always makes the
// final call (requiresConfirmation); this is just a hint.
function scriptNeedsConfirmation(sql) {
return splitStatements(sql).some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
@@ -68,18 +68,18 @@
return String(value).replace(/'/g, "''");
}
// Готовые шаблоны SQL-скриптов. build() возвращает текст скрипта,
// который вставляется в командную строку через term.set_command() —
// ничего не выполняется автоматически, пользователь сам проверяет
// и жмёт Enter (после чего срабатывает обычное подтверждение DELETE).
// Ready-made SQL script templates. build() returns the script text,
// which is inserted into the command line via term.set_command() —
// nothing runs automatically, the user reviews it themselves and
// presses Enter (which then triggers the normal DELETE confirmation).
var TEMPLATES = {
'delete-user': {
args: ['uid'],
description: 'Полностью удалить локального пользователя (oc_preferences, oc_group_user, oc_ldap_user_mapping, oc_users)',
description: 'Completely delete a local user (oc_preferences, oc_group_user, oc_ldap_user_mapping, oc_users)',
build: function (uid) {
// Значение подставляется напрямую в каждый запрос (а не через
// SET+current_setting) — так скрипт остаётся рабочим, даже если
// пользователь скопирует/выполнит только часть строк по отдельности.
// The value is substituted directly into each query (rather than
// via SET+current_setting) — this keeps the script working even
// if the user copies/runs only some of the lines individually.
var v = escapeSqlString(uid);
return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'",
@@ -93,7 +93,7 @@
},
'list-user': {
args: ['uid'],
description: 'Только посмотреть данные пользователя, без удаления (oc_users, oc_preferences, oc_group_user, oc_ldap_user_mapping)',
description: "View a user's data only, without deleting anything (oc_users, oc_preferences, oc_group_user, oc_ldap_user_mapping)",
build: function (uid) {
var v = escapeSqlString(uid);
return [
@@ -106,14 +106,15 @@
},
'rename-user': {
args: ['old_uid', 'new_uid'],
description: 'ВНИМАНИЕ: не официальная операция Nextcloud. Переименовывает uid только в основных таблицах ядра — покрывает не всё, требует ручных доп. шагов (см. предупреждение в самом скрипте)',
description: "WARNING: not an official Nextcloud operation. Renames the uid only in the core tables — doesn't cover everything, requires manual extra steps (see the warning in the script itself)",
build: function (oldUid, newUid) {
var o = escapeSqlString(oldUid);
var n = escapeSqlString(newUid);
// Предупреждение — только однострочные "--"-комментарии без ";" внутри,
// поэтому splitStatements() (парный на бэкенде и здесь) не разобьёт их
// как отдельные запросы, а stripLeadingComments() на бэкенде уберёт
// этот блок перед определением типа самого первого запроса (SELECT).
// The warning consists only of single-line "--" comments with no
// ";" inside them, so splitStatements() (mirrored on the backend
// and here) won't split them into separate statements, and
// stripLeadingComments() on the backend will strip this block
// before determining the type of the very first query (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" +
@@ -169,7 +170,7 @@
term.echo('[[;yellow;]Template inserted into the command line — review it, then press Enter to run.]');
}
// Табличный вывод результатов SELECT в стиле psql.
// Tabular rendering of SELECT results, psql-style.
function renderTable(term, rows) {
if (!rows || !rows.length) {
return;
@@ -178,8 +179,9 @@
function formatCell(v) {
if (v === null || v === undefined) {
// Явная метка, а не пустая строка — иначе NULL неотличим от
// настоящей пустой строки '' в выводе таблицы.
// An explicit label rather than an empty string — otherwise NULL
// would be indistinguishable from an actual empty string '' in
// the table output.
return '[NULL]';
}
if (typeof v === 'object') {
@@ -273,9 +275,9 @@
term.echo('[[;yellow;]Switched back to OCC mode.]');
}
// Таймаут для больших/долгих пачек (например, DELETE по большой таблице
// без индекса): без него зависший запрос молча оставит терминал
// заблокированным (term.pause()) навсегда, если сервер не ответит.
// Timeout for large/long-running batches (e.g. a DELETE on a big table
// without an index): without it, a hung query would silently leave the
// terminal blocked (term.pause()) forever if the server never responds.
var SQL_REQUEST_TIMEOUT_MS = 120000;
function sendSqlQuery(term, sql, confirmed) {
@@ -388,8 +390,8 @@
prompt: OCC_PROMPT,
completion: response,
keydown: function (e) {
// Shift+Enter вставляет перевод строки вместо выполнения команды,
// это позволяет набирать многострочные SQL-скрипты в режиме sql.
// Shift+Enter inserts a newline instead of running the command,
// letting you type multi-line SQL scripts in sql mode.
if (e.shiftKey && e.key === 'Enter') {
this.insert('\n');
return false;
+76 -71
View File
@@ -13,28 +13,28 @@ use Psr\Log\LoggerInterface;
class DbController extends Controller
{
/** Максимум строк, возвращаемых на один SELECT (защита от OOM/DoS). */
/** Maximum rows returned per SELECT (protection against OOM/DoS). */
private const MAX_ROWS = 1000;
/** Максимальный размер присланного SQL-текста в байтах (защита от OOM/DoS). */
/** Maximum size of submitted SQL text in bytes (protection against OOM/DoS). */
private const MAX_SQL_BYTES = 1048576; // 1 MB
/**
* Конструкции, дающие доступ к файловой системе сервера или запуску
* внешних программ через SQL. Блокируются полностью, без возможности
* подтверждения — в отличие от DELETE, это не про потерю данных,
* а про потенциальный захват сервера.
* Constructs that give access to the server's filesystem or allow
* launching external programs via SQL. These are blocked entirely,
* with no way to confirm past them — unlike DELETE, this isn't
* about data loss, it's about potential server takeover.
*/
private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (запуск внешних команд)',
'/\bCOPY\b[\s\S]*\b(FROM|TO)\b\s*\'/i' => "COPY ... FROM/TO 'file' (доступ к файловой системе сервера)",
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (runs external commands)',
'/\bCOPY\b[\s\S]*\b(FROM|TO)\b\s*\'/i' => "COPY ... FROM/TO 'file' (access to the server's filesystem)",
'/\bpg_read_binary_file\s*\(/i' => 'pg_read_binary_file()',
'/\bpg_read_file\s*\(/i' => 'pg_read_file()',
'/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()',
'/\bpg_stat_file\s*\(/i' => 'pg_stat_file()',
'/\blo_import\s*\(/i' => 'lo_import()',
'/\blo_export\s*\(/i' => 'lo_export()',
'/\bdblink(_connect)?\s*\(/i' => 'dblink() (подключение к произвольным БД)',
'/\bdblink(_connect)?\s*\(/i' => 'dblink() (connects to arbitrary databases)',
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
];
@@ -55,15 +55,15 @@ class DbController extends Controller
$this->db = $db;
$this->groupManager = $groupManager;
$this->userSession = $userSession;
// Через OC::$server, как и в OccController — не добавляем LoggerInterface
// в конструктор, чтобы не менять сигнатуру, резолвящуюся DI-контейнером.
// Via OC::$server, same as in OccController — we don't add LoggerInterface
// to the constructor so as not to change the signature resolved by the DI container.
$this->logger = OC::$server->get(LoggerInterface::class);
}
/**
* Убирает ведущие однострочные комментарии ("-- ...") перед запросом.
* После разбиения пачки такой комментарий может "приклеиться"
* к следующему запросу и помешать определить его тип (SELECT/SET/DELETE).
* Strips leading single-line comments ("-- ...") before a query.
* After splitting a batch, such a comment can end up stuck to the
* next query and interfere with detecting its type (SELECT/SET/DELETE).
*/
private function stripLeadingComments($query)
{
@@ -75,12 +75,12 @@ class DbController extends Controller
}
/**
* Убирает ВСЕ однострочные (-- ...) и блочные C-style комментарии
* из запроса, включая те, что стоят внутри выражения (например, между
* именем функции и открывающей скобкой — иначе так можно спрятать
* запрещённую конструкцию от findForbiddenConstruct). Используется
* только для проверки на запрещённые конструкции — сам запрос на
* выполнение идёт без изменений.
* Strips ALL single-line (-- ...) and block-style C comments from a
* query, including ones sitting inside an expression (e.g. between a
* function name and its opening parenthesis — otherwise a forbidden
* construct could be hidden from findForbiddenConstruct that way).
* Used only for checking against forbidden constructs — the query
* that actually gets executed is left unmodified.
*/
private function removeAllComments($query)
{
@@ -90,10 +90,10 @@ class DbController extends Controller
}
/**
* Разбивает пачку запросов по ";" с учётом одинарных и двойных
* кавычек (строки и экранированные идентификаторы Postgres), чтобы
* точка с запятой внутри 'строки' или "идентификатора" (в том числе
* с '' / "" как экранированной кавычкой) не ломала разбиение.
* Splits a batch of queries on ";" while respecting single and double
* quotes (strings and Postgres-escaped identifiers), so that a
* semicolon inside a 'string' or "identifier" (including '' / "" as
* an escaped quote) doesn't break up the split.
*/
private function splitStatements($sql)
{
@@ -143,10 +143,11 @@ class DbController extends Controller
}
/**
* Возвращает описание найденной запрещённой конструкции (доступ к ФС,
* запуск программ) или null, если запрос безопасен в этом плане.
* Проверяется версия запроса без комментариев — иначе конструкцию
* можно спрятать, вставив комментарий между именем функции и "(".
* Returns a description of the forbidden construct found (filesystem
* access, running programs), or null if the query is safe in that
* regard. The comment-stripped version of the query is checked —
* otherwise the construct could be hidden by inserting a comment
* between the function name and "(".
*/
private function findForbiddenConstruct($query)
{
@@ -164,7 +165,7 @@ class DbController extends Controller
*/
public function query()
{
// Проверка прав администратора
// Check admin privileges
$user = $this->userSession->getUser();
if (!$user) {
return new JSONResponse(['error' => 'Not authenticated'], 401);
@@ -188,25 +189,24 @@ class DbController extends Controller
], 413);
}
// Аудит: логируем сам факт попытки выполнения ДО всех проверок,
// чтобы в логе остались и заблокированные/отклонённые запросы,
// а не только успешно выполненные.
// Audit: log the fact that execution was attempted BEFORE any checks,
// so blocked/rejected queries are recorded in the log too, not just
// the ones that succeeded.
$this->logger->warning('[occweb] SQL submitted by {user}: {sql}', [
'app' => 'occweb',
'user' => $user->getUID(),
'sql' => $sql,
]);
// Разбиваем пачку по ";" с учётом кавычек (см. splitStatements).
// Все запросы выполняются последовательно на одном и том же
// соединении с БД (в рамках одного HTTP-запроса), поэтому SET
// сохраняет своё значение для current_setting() в следующих
// запросах этой же пачки.
// Split the batch on ";" respecting quotes (see splitStatements).
// All queries run sequentially on the same DB connection (within a
// single HTTP request), so SET retains its value for
// current_setting() in the following queries of the same batch.
$queries = $this->splitStatements($sql);
// Доступ к файловой системе сервера / запуск программ через SQL
// блокируется полностью — это не про потерю данных (как DELETE),
// а про потенциальный захват сервера, подтверждением не обходится.
// Access to the server's filesystem / running programs via SQL is
// blocked entirely — this isn't about data loss (like DELETE), it's
// about potential server takeover, and confirmation doesn't bypass it.
foreach ($queries as $query) {
$forbidden = $this->findForbiddenConstruct($query);
if ($forbidden !== null) {
@@ -223,9 +223,9 @@ class DbController extends Controller
}
}
// DELETE и UPDATE необратимы (или трудно обратимы), поэтому
// требуем явное подтверждение с клиента (confirm=true), прежде
// чем выполнять хоть один запрос из пачки.
// DELETE and UPDATE are irreversible (or hard to reverse), so we
// require explicit confirmation from the client (confirm=true) before
// executing a single query from the batch.
$deleteCount = 0;
$updateCount = 0;
foreach ($queries as $query) {
@@ -254,21 +254,23 @@ class DbController extends Controller
]);
}
// Пачка выполняется в одной транзакции: если один из запросов
// упадёт (например, DELETE на середине серии из-за FK), все уже
// выполненные в этой же пачке изменения откатываются, а не
// остаются частично применёнными. SET (без LOCAL) не транзакционен
// в PostgreSQL, поэтому откат не затрагивает current_setting().
// The batch runs inside a single transaction: if one of the queries
// fails (e.g. a DELETE partway through a series due to an FK), all
// changes already made within this same batch are rolled back instead
// of staying partially applied. SET (without LOCAL) isn't
// transactional in PostgreSQL, so the rollback doesn't affect
// 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-ответа.
// About DDL: in PostgreSQL (our database) DDL — CREATE/ALTER/DROP
// TABLE etc. — is fully transactional and rolls back together with
// the rest of the batch's changes, so there's no problem here for
// Postgres. But Nextcloud also works with MySQL/MariaDB through the
// same IDBConnection, and there DDL causes an implicit COMMIT — if
// this batch runs on a MySQL instance, commit()/rollBack() after such
// a DDL statement will get a "no active transaction" exception. We
// wrap begin/commit/rollBack in try/catch so this doesn't turn into
// an unhandled exception and an HTTP 500 instead of a clean JSON
// response.
$results = [];
$rolledBack = false;
$rollbackFailed = false;
@@ -296,9 +298,10 @@ class DbController extends Controller
$stmt->execute();
if ($isSelect) {
// Читаем построчно и останавливаемся на MAX_ROWS, а не
// fetchAll() + array_slice — иначе SELECT без LIMIT на
// огромной таблице всё равно утащит всё в память PHP.
// Read row by row and stop at MAX_ROWS, instead of
// fetchAll() + array_slice — otherwise a SELECT without
// LIMIT on a huge table would still pull everything into
// PHP memory.
$rows = [];
$truncated = false;
while (($row = $stmt->fetch()) !== false) {
@@ -338,19 +341,20 @@ class DbController extends Controller
'type' => 'error',
'error' => $e->getMessage()
];
// Откатываем всю пачку и останавливаемся: не продолжаем
// выполнять оставшиеся запросы (например, серию DELETE),
// если один из предыдущих шагов не выполнился.
// Roll back the whole batch and stop: don't keep executing
// the remaining queries (e.g. a series of DELETEs) if one of
// the preceding steps failed.
if ($transactionStarted) {
try {
$this->db->rollBack();
$rolledBack = true;
} catch (\Exception $rollbackError) {
// Транзакция уже закрыта не нами — например, неявным
// COMMIT-ом от DDL-запроса на MySQL/MariaDB. Всё, что
// выполнилось ДО этой точки в пачке, могло остаться
// применённым навсегда — намеренно НЕ ставим
// rolledBack в true, это было бы неправдой.
// The transaction was already closed by something
// other than us — e.g. an implicit COMMIT from a DDL
// statement on MySQL/MariaDB. Anything that executed
// BEFORE this point in the batch may remain
// permanently applied — we deliberately do NOT set
// rolledBack to true, since that would be untrue.
$rollbackFailed = true;
$this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'occweb',
@@ -366,9 +370,10 @@ class DbController extends Controller
try {
$this->db->commit();
} catch (\Exception $e) {
// Транзакция уже закоммичена неявно (например, DDL-запросом
// на MySQL/MariaDB) — эффекты уже сохранены, это не ошибка
// выполнения самой пачки.
// The transaction was already committed implicitly (e.g. by
// a DDL statement on MySQL/MariaDB) — the effects are
// already saved, this isn't an error in executing the batch
// itself.
$this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'occweb',
'error' => $e->getMessage(),
+4 -4
View File
@@ -37,17 +37,17 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
public function fetch(): string
{
// Получаем основной буфер
// Get the main buffer
$content = parent::fetch();
// Если есть stream от секций, читаем его тоже
// If there's a stream from sections, read that too
if ($this->stream !== null) {
rewind($this->stream);
$streamContent = stream_get_contents($this->stream);
if ($streamContent !== false) {
$content .= $streamContent;
}
// Очищаем stream для следующего вызова
// Clear the stream for the next call
ftruncate($this->stream, 0);
rewind($this->stream);
}