6 Commits

Author SHA1 Message Date
dillard 97ceaf7c37 Point website/bugs/repository/screenshot at our own fork
Upstream (fanategorius/occweb) is abandoned; this fork is the
maintained one now, so app metadata should point here instead.
2026-09-26 02:01:29 +02:00
dillard 292b8980cb Update app author name to maintainer's own name 2026-09-26 02:00:01 +02:00
dillard faa389c4b1 Update app author email to maintainer's own address
Upstream (fanategorius/occweb) is abandoned; this fork is maintained
independently, so the contact address in info.xml should point here.
2026-09-26 01:59:27 +02:00
dillard 2af32728f7 Correct max-version to 33 (actual nc01 core version, not 36)
occ status on nc01 reports 33.0.8.2, not 36 as assumed — match the
repo's established convention of pinning max-version to the actually
tested/installed core version rather than a guess.
2026-09-26 01:55:56 +02:00
dillard ad0ce047e5 Remove unjustified @NoCSRFRequired from DbController::query(), bump max-version to 36
query() runs arbitrary admin SQL (including DELETE/UPDATE) and was
exempted from CSRF protection — a leftover from the original upstream
code, which had this alongside @PublicPage (no auth at all) before our
admin-check hardening. The client already goes through the standard
$.ajax() flow that auto-attaches Nextcloud's CSRF token, exactly like
OccController::cmd() does without any such exemption, so removing it
has no functional cost.

Also bump nextcloud max-version to 36 to match nc01's current core
version, now that AIO auto-updates the core nightly.

Version bump to 0.3.4.
2026-09-26 00:36:16 +02:00
dillard 7d05643ee6 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).
2026-09-26 00:13:03 +02:00
4 changed files with 120 additions and 116 deletions
+7 -7
View File
@@ -5,17 +5,17 @@
<name>Extended OCC Web</name> <name>Extended OCC Web</name>
<summary>OCC Commands in a web terminal</summary> <summary>OCC Commands in a web terminal</summary>
<description><![CDATA[Run OCC Commands in a web terminal]]></description> <description><![CDATA[Run OCC Commands in a web terminal]]></description>
<version>0.3.3</version> <version>0.3.4</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="ebugaev@itkey.com" >Egor</author> <author mail="dillard.blom@ensembia.com" >Dillard Blom</author>
<namespace>OCCWeb</namespace> <namespace>OCCWeb</namespace>
<category>tools</category> <category>tools</category>
<website>https://github.com/fanategorius/occweb</website> <website>https://github.com/dillardblom/occweb</website>
<bugs>https://github.com/fanategorius/occweb/issues</bugs> <bugs>https://github.com/dillardblom/occweb/issues</bugs>
<repository>https://github.com/fanategorius/occweb</repository> <repository>https://github.com/dillardblom/occweb</repository>
<screenshot>https://github.com/fanategorius/occweb/raw/main/appinfo/screenshot.png</screenshot> <screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png</screenshot>
<dependencies> <dependencies>
<nextcloud min-version="30" max-version="35"/> <nextcloud min-version="30" max-version="33"/>
</dependencies> </dependencies>
<navigations> <navigations>
<navigation role="admin"> <navigation role="admin">
+33 -31
View File
@@ -7,17 +7,17 @@
} }
var baseUrl = OC.generateUrl('/apps/occweb'); var baseUrl = OC.generateUrl('/apps/occweb');
// Текущий режим терминала: 'occ' (обычные occ-команды) или 'sql' // Current terminal mode: 'occ' (regular occ commands) or 'sql'
// (выполнение произвольных SQL-запросов через /db/query). // (running arbitrary SQL queries via /db/query).
var mode = 'occ'; var mode = 'occ';
var OCC_PROMPT = 'occ $ '; var OCC_PROMPT = 'occ $ ';
var SQL_PROMPT = '[[;#ff5555;]sql]# '; var SQL_PROMPT = '[[;#ff5555;]sql]# ';
// Разбивает пачку запросов по ";" с учётом одинарных И двойных кавычек // 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
// ломал разбиение. Зеркалит splitStatements() на бэкенде. // break the split. Mirrors splitStatements() on the backend.
function splitStatements(sql) { function splitStatements(sql) {
var statements = []; var statements = [];
var current = ''; var current = '';
@@ -54,9 +54,9 @@
return statements.filter(function (s) { return s !== ''; }); return statements.filter(function (s) { return s !== ''; });
} }
// Быстрая клиентская проверка на DELETE/UPDATE — только для UX (чтобы не // Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
// делать лишний запрос к серверу). Итоговое решение всё равно // an extra round trip to the server). The backend always makes the
// принимает бэкенд (requiresConfirmation), это лишь подсказка. // final call (requiresConfirmation); this is just a hint.
function scriptNeedsConfirmation(sql) { function scriptNeedsConfirmation(sql) {
return splitStatements(sql).some(function (part) { return splitStatements(sql).some(function (part) {
var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, ''); var normalized = part.replace(/^(\s*--[^\n]*\n)*\s*/, '');
@@ -68,18 +68,18 @@
return String(value).replace(/'/g, "''"); return String(value).replace(/'/g, "''");
} }
// Готовые шаблоны SQL-скриптов. build() возвращает текст скрипта, // Ready-made SQL script templates. build() returns the script text,
// который вставляется в командную строку через term.set_command() — // which is inserted into the command line via term.set_command() —
// ничего не выполняется автоматически, пользователь сам проверяет // nothing runs automatically, the user reviews it themselves and
// и жмёт Enter (после чего срабатывает обычное подтверждение DELETE). // presses Enter (which then triggers the normal DELETE confirmation).
var TEMPLATES = { var TEMPLATES = {
'delete-user': { 'delete-user': {
args: ['uid'], 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) { build: function (uid) {
// Значение подставляется напрямую в каждый запрос (а не через // The value is substituted directly into each query (rather than
// SET+current_setting) — так скрипт остаётся рабочим, даже если // 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); var v = escapeSqlString(uid);
return [ return [
"SELECT * FROM oc_users WHERE uid = '" + v + "'", "SELECT * FROM oc_users WHERE uid = '" + v + "'",
@@ -93,7 +93,7 @@
}, },
'list-user': { 'list-user': {
args: ['uid'], 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) { build: function (uid) {
var v = escapeSqlString(uid); var v = escapeSqlString(uid);
return [ return [
@@ -106,14 +106,15 @@
}, },
'rename-user': { 'rename-user': {
args: ['old_uid', 'new_uid'], 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) { build: function (oldUid, newUid) {
var o = escapeSqlString(oldUid); var o = escapeSqlString(oldUid);
var n = escapeSqlString(newUid); var n = escapeSqlString(newUid);
// Предупреждение — только однострочные "--"-комментарии без ";" внутри, // The warning consists only of single-line "--" comments with no
// поэтому splitStatements() (парный на бэкенде и здесь) не разобьёт их // ";" inside them, so splitStatements() (mirrored on the backend
// как отдельные запросы, а stripLeadingComments() на бэкенде уберёт // and here) won't split them into separate statements, and
// этот блок перед определением типа самого первого запроса (SELECT). // stripLeadingComments() on the backend will strip this block
// before determining the type of the very first query (SELECT).
var warning = var warning =
"-- WARNING rename is NOT an officially supported Nextcloud operation\n" + "-- WARNING rename is NOT an officially supported Nextcloud operation\n" +
"-- This only updates core tables below - it does NOT cover app-specific\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.]'); 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) { function renderTable(term, rows) {
if (!rows || !rows.length) { if (!rows || !rows.length) {
return; return;
@@ -178,8 +179,9 @@
function formatCell(v) { function formatCell(v) {
if (v === null || v === undefined) { 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]'; return '[NULL]';
} }
if (typeof v === 'object') { if (typeof v === 'object') {
@@ -273,9 +275,9 @@
term.echo('[[;yellow;]Switched back to OCC mode.]'); term.echo('[[;yellow;]Switched back to OCC mode.]');
} }
// Таймаут для больших/долгих пачек (например, DELETE по большой таблице // 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
// заблокированным (term.pause()) навсегда, если сервер не ответит. // terminal blocked (term.pause()) forever if the server never responds.
var SQL_REQUEST_TIMEOUT_MS = 120000; var SQL_REQUEST_TIMEOUT_MS = 120000;
function sendSqlQuery(term, sql, confirmed) { function sendSqlQuery(term, sql, confirmed) {
@@ -388,8 +390,8 @@
prompt: OCC_PROMPT, prompt: OCC_PROMPT,
completion: response, completion: response,
keydown: function (e) { keydown: function (e) {
// Shift+Enter вставляет перевод строки вместо выполнения команды, // Shift+Enter inserts a newline instead of running the command,
// это позволяет набирать многострочные SQL-скрипты в режиме sql. // letting you type multi-line SQL scripts in sql mode.
if (e.shiftKey && e.key === 'Enter') { if (e.shiftKey && e.key === 'Enter') {
this.insert('\n'); this.insert('\n');
return false; return false;
+76 -74
View File
@@ -13,28 +13,28 @@ use Psr\Log\LoggerInterface;
class DbController extends Controller class DbController extends Controller
{ {
/** Максимум строк, возвращаемых на один SELECT (защита от OOM/DoS). */ /** Maximum rows returned per SELECT (protection against OOM/DoS). */
private const MAX_ROWS = 1000; 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 private const MAX_SQL_BYTES = 1048576; // 1 MB
/** /**
* Конструкции, дающие доступ к файловой системе сервера или запуску * Constructs that give access to the server's filesystem or allow
* внешних программ через SQL. Блокируются полностью, без возможности * launching external programs via SQL. These are blocked entirely,
* подтверждения — в отличие от DELETE, это не про потерю данных, * 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 = [ private const FORBIDDEN_PATTERNS = [
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (запуск внешних команд)', '/\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' (доступ к файловой системе сервера)", '/\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_binary_file\s*\(/i' => 'pg_read_binary_file()',
'/\bpg_read_file\s*\(/i' => 'pg_read_file()', '/\bpg_read_file\s*\(/i' => 'pg_read_file()',
'/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()', '/\bpg_ls_dir\s*\(/i' => 'pg_ls_dir()',
'/\bpg_stat_file\s*\(/i' => 'pg_stat_file()', '/\bpg_stat_file\s*\(/i' => 'pg_stat_file()',
'/\blo_import\s*\(/i' => 'lo_import()', '/\blo_import\s*\(/i' => 'lo_import()',
'/\blo_export\s*\(/i' => 'lo_export()', '/\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()', '/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE', '/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
]; ];
@@ -55,15 +55,15 @@ class DbController extends Controller
$this->db = $db; $this->db = $db;
$this->groupManager = $groupManager; $this->groupManager = $groupManager;
$this->userSession = $userSession; $this->userSession = $userSession;
// Через OC::$server, как и в OccController — не добавляем LoggerInterface // Via OC::$server, same as in OccController — we don't add LoggerInterface
// в конструктор, чтобы не менять сигнатуру, резолвящуюся DI-контейнером. // to the constructor so as not to change the signature resolved by the DI container.
$this->logger = OC::$server->get(LoggerInterface::class); $this->logger = OC::$server->get(LoggerInterface::class);
} }
/** /**
* Убирает ведущие однострочные комментарии ("-- ...") перед запросом. * Strips leading single-line comments ("-- ...") before a query.
* После разбиения пачки такой комментарий может "приклеиться" * After splitting a batch, such a comment can end up stuck to the
* к следующему запросу и помешать определить его тип (SELECT/SET/DELETE). * next query and interfere with detecting its type (SELECT/SET/DELETE).
*/ */
private function stripLeadingComments($query) private function stripLeadingComments($query)
{ {
@@ -75,12 +75,12 @@ class DbController extends Controller
} }
/** /**
* Убирает ВСЕ однострочные (-- ...) и блочные C-style комментарии * 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
* запрещённую конструкцию от findForbiddenConstruct). Используется * 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) private function removeAllComments($query)
{ {
@@ -90,10 +90,10 @@ class DbController extends Controller
} }
/** /**
* Разбивает пачку запросов по ";" с учётом одинарных и двойных * Splits a batch of queries on ";" while respecting single and double
* кавычек (строки и экранированные идентификаторы Postgres), чтобы * 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) private function splitStatements($sql)
{ {
@@ -143,10 +143,11 @@ class DbController extends Controller
} }
/** /**
* Возвращает описание найденной запрещённой конструкции (доступ к ФС, * Returns a description of the forbidden construct found (filesystem
* запуск программ) или null, если запрос безопасен в этом плане. * 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) private function findForbiddenConstruct($query)
{ {
@@ -159,12 +160,9 @@ class DbController extends Controller
return null; return null;
} }
/**
* @NoCSRFRequired
*/
public function query() public function query()
{ {
// Проверка прав администратора // Check admin privileges
$user = $this->userSession->getUser(); $user = $this->userSession->getUser();
if (!$user) { if (!$user) {
return new JSONResponse(['error' => 'Not authenticated'], 401); return new JSONResponse(['error' => 'Not authenticated'], 401);
@@ -188,25 +186,24 @@ class DbController extends Controller
], 413); ], 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}', [ $this->logger->warning('[occweb] SQL submitted by {user}: {sql}', [
'app' => 'occweb', 'app' => 'occweb',
'user' => $user->getUID(), 'user' => $user->getUID(),
'sql' => $sql, 'sql' => $sql,
]); ]);
// Разбиваем пачку по ";" с учётом кавычек (см. splitStatements). // Split the batch on ";" respecting quotes (see splitStatements).
// Все запросы выполняются последовательно на одном и том же // All queries run sequentially on the same DB connection (within a
// соединении с БД (в рамках одного HTTP-запроса), поэтому SET // single HTTP request), so SET retains its value for
// сохраняет своё значение для current_setting() в следующих // current_setting() in the following queries of the same batch.
// запросах этой же пачки.
$queries = $this->splitStatements($sql); $queries = $this->splitStatements($sql);
// Доступ к файловой системе сервера / запуск программ через SQL // Access to the server's filesystem / running programs via SQL is
// блокируется полностью — это не про потерю данных (как DELETE), // 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) { foreach ($queries as $query) {
$forbidden = $this->findForbiddenConstruct($query); $forbidden = $this->findForbiddenConstruct($query);
if ($forbidden !== null) { if ($forbidden !== null) {
@@ -223,9 +220,9 @@ class DbController extends Controller
} }
} }
// DELETE и UPDATE необратимы (или трудно обратимы), поэтому // DELETE and UPDATE are irreversible (or hard to reverse), so we
// требуем явное подтверждение с клиента (confirm=true), прежде // require explicit confirmation from the client (confirm=true) before
// чем выполнять хоть один запрос из пачки. // executing a single query from the batch.
$deleteCount = 0; $deleteCount = 0;
$updateCount = 0; $updateCount = 0;
foreach ($queries as $query) { foreach ($queries as $query) {
@@ -254,21 +251,23 @@ class DbController extends Controller
]); ]);
} }
// Пачка выполняется в одной транзакции: если один из запросов // The batch runs inside a single transaction: if one of the queries
// упадёт (например, DELETE на середине серии из-за FK), все уже // 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
// остаются частично применёнными. SET (без LOCAL) не транзакционен // of staying partially applied. SET (without LOCAL) isn't
// в PostgreSQL, поэтому откат не затрагивает current_setting(). // transactional in PostgreSQL, so the rollback doesn't affect
// current_setting().
// //
// Про DDL: в PostgreSQL (наша БД) DDL — CREATE/ALTER/DROP TABLE и // About DDL: in PostgreSQL (our database) DDL — CREATE/ALTER/DROP
// т.п. — полностью транзакционен и откатывается вместе с остальными // TABLE etc. — is fully transactional and rolls back together with
// изменениями пачки, поэтому здесь для Postgres проблемы нет. Но // the rest of the batch's changes, so there's no problem here for
// Nextcloud работает и с MySQL/MariaDB через тот же IDBConnection, а // Postgres. But Nextcloud also works with MySQL/MariaDB through the
// там DDL делает неявный COMMIT — если эта пачка выполнится на // same IDBConnection, and there DDL causes an implicit COMMIT — if
// MySQL-инстансе, commit()/rollBack() после такого DDL получат // this batch runs on a MySQL instance, commit()/rollBack() after such
// исключение "no active transaction". Оборачиваем begin/commit/ // a DDL statement will get a "no active transaction" exception. We
// rollBack в try/catch, чтобы это не превращалось в необработанное // wrap begin/commit/rollBack in try/catch so this doesn't turn into
// исключение и HTTP 500 вместо аккуратного JSON-ответа. // an unhandled exception and an HTTP 500 instead of a clean JSON
// response.
$results = []; $results = [];
$rolledBack = false; $rolledBack = false;
$rollbackFailed = false; $rollbackFailed = false;
@@ -296,9 +295,10 @@ class DbController extends Controller
$stmt->execute(); $stmt->execute();
if ($isSelect) { if ($isSelect) {
// Читаем построчно и останавливаемся на MAX_ROWS, а не // Read row by row and stop at MAX_ROWS, instead of
// fetchAll() + array_slice — иначе SELECT без LIMIT на // fetchAll() + array_slice — otherwise a SELECT without
// огромной таблице всё равно утащит всё в память PHP. // LIMIT on a huge table would still pull everything into
// PHP memory.
$rows = []; $rows = [];
$truncated = false; $truncated = false;
while (($row = $stmt->fetch()) !== false) { while (($row = $stmt->fetch()) !== false) {
@@ -338,19 +338,20 @@ class DbController extends Controller
'type' => 'error', 'type' => 'error',
'error' => $e->getMessage() 'error' => $e->getMessage()
]; ];
// Откатываем всю пачку и останавливаемся: не продолжаем // Roll back the whole batch and stop: don't keep executing
// выполнять оставшиеся запросы (например, серию DELETE), // the remaining queries (e.g. a series of DELETEs) if one of
// если один из предыдущих шагов не выполнился. // the preceding steps failed.
if ($transactionStarted) { if ($transactionStarted) {
try { try {
$this->db->rollBack(); $this->db->rollBack();
$rolledBack = true; $rolledBack = true;
} catch (\Exception $rollbackError) { } catch (\Exception $rollbackError) {
// Транзакция уже закрыта не нами — например, неявным // The transaction was already closed by something
// COMMIT-ом от DDL-запроса на MySQL/MariaDB. Всё, что // 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
// rolledBack в true, это было бы неправдой. // permanently applied — we deliberately do NOT set
// rolledBack to true, since that would be untrue.
$rollbackFailed = true; $rollbackFailed = true;
$this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [ $this->logger->warning('[occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
'app' => 'occweb', 'app' => 'occweb',
@@ -366,9 +367,10 @@ class DbController extends Controller
try { try {
$this->db->commit(); $this->db->commit();
} catch (\Exception $e) { } catch (\Exception $e) {
// Транзакция уже закоммичена неявно (например, DDL-запросом // The transaction was already committed implicitly (e.g. by
// на MySQL/MariaDB) — эффекты уже сохранены, это не ошибка // 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}', [ $this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
'app' => 'occweb', 'app' => 'occweb',
'error' => $e->getMessage(), 'error' => $e->getMessage(),
+4 -4
View File
@@ -37,17 +37,17 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
public function fetch(): string public function fetch(): string
{ {
// Получаем основной буфер // Get the main buffer
$content = parent::fetch(); $content = parent::fetch();
// Если есть stream от секций, читаем его тоже // If there's a stream from sections, read that too
if ($this->stream !== null) { if ($this->stream !== null) {
rewind($this->stream); rewind($this->stream);
$streamContent = stream_get_contents($this->stream); $streamContent = stream_get_contents($this->stream);
if ($streamContent !== false) { if ($streamContent !== false) {
$content .= $streamContent; $content .= $streamContent;
} }
// Очищаем stream для следующего вызова // Clear the stream for the next call
ftruncate($this->stream, 0); ftruncate($this->stream, 0);
rewind($this->stream); rewind($this->stream);
} }