Compare commits
11 Commits
9e15c3b96c
...
v0.3.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 97ceaf7c37 | |||
| 292b8980cb | |||
| faa389c4b1 | |||
| 2af32728f7 | |||
| ad0ce047e5 | |||
| 7d05643ee6 | |||
| 040743d351 | |||
| 85890e12db | |||
| cd01a2fa17 | |||
| a4a8f9a4eb | |||
| f681caca35 |
@@ -1,15 +1,34 @@
|
||||
# ⚠️ Deprecated ⚠️ OCCWeb terminal
|
||||
|
||||
*Читать на [русском](README.ru.md).*
|
||||
# OCCWeb terminal (maintained fork)
|
||||
|
||||
### A web terminal for admins to launch Nextcloud's occ commands
|
||||
|
||||

|
||||
|
||||
## About this fork
|
||||
|
||||
## ⚠️ Deprecated ⚠️
|
||||
As nextcloudd has no native support for asynchronous operations, due to the use of php, this aplication is deprecated, and will no longer support the Nextcloud' future versions (19+). I did not find a way to implemement true support for interactive and long running occ tasks in a web terminal whitout introducing addtional dependencies (through websockets, for example), the lack of true asynchronous occ operations can lead to serious alterations of voluminous instances.
|
||||
[This issue](https://github.com/nextcloud/server/issues/16726) may give some hints on why I decided to not support this application anymore.
|
||||
The original [Adphi/occweb](https://github.com/Adphi/occweb) was marked deprecated by its
|
||||
author and is unmaintained. This fork tracks [fanategorius/occweb](https://github.com/fanategorius/occweb),
|
||||
which picked up compatibility maintenance where Adphi left off, and adds SQL query mode,
|
||||
security hardening (admin checks, CSRF/confirmation flows, transactional batches), and PHP
|
||||
8.1+ compatibility fixes on top.
|
||||
|
||||
### Credits
|
||||
|
||||
- [Adphi/occweb](https://github.com/Adphi/occweb) — original author/project.
|
||||
- [fanategorius/occweb](https://github.com/fanategorius/occweb) — the actively maintained
|
||||
fork this repo is based on and tracks.
|
||||
- [Git-Usr123/occweb](https://github.com/Git-Usr123/occweb) — independent fork; the
|
||||
`OC::$server->getConfig()` → `OC::$server->get(\OCP\IConfig::class)` fix in
|
||||
`OccController.php` was spotted there and backported here.
|
||||
|
||||
**This tool is still not the right place for big/risky operations.** It has no support for
|
||||
asynchronous or long-running tasks — every command runs synchronously inside a single PHP
|
||||
web request, so anything that takes a while (`occ files:scan` on a large instance, a Nextcloud
|
||||
major-version `occ upgrade`, `occ maintenance:mode --on` workflows) can time out mid-operation
|
||||
with no way to recover cleanly. Use a real shell (SSH + `occ` directly, or the guest-agent/
|
||||
console) for major upgrades and other long-running or high-blast-radius operations; reserve
|
||||
this terminal for routine day-to-day occ/SQL admin tasks. [This issue](https://github.com/nextcloud/server/issues/16726)
|
||||
has background on why Nextcloud has no native async support for occ tasks.
|
||||
|
||||
|
||||
## Install
|
||||
@@ -110,4 +129,4 @@ This does not apply on a fresh `occ app:enable` install — that command sets
|
||||
`installed_version` correctly on its own.
|
||||
|
||||
## TODOs:
|
||||
See [open issues](https://github.com/Adphi/occweb/issues)
|
||||
See [open issues](https://github.com/dillardblom/occweb/issues)
|
||||
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
# ⚠️ Устарело ⚠️ OCCWeb terminal
|
||||
|
||||
*Read in [English](README.md).*
|
||||
|
||||
### Веб-терминал для администраторов для запуска occ-команд Nextcloud
|
||||
|
||||

|
||||
|
||||
|
||||
## ⚠️ Устарело ⚠️
|
||||
|
||||
Поскольку Nextcloud не имеет встроенной поддержки асинхронных операций
|
||||
(из-за использования PHP), это приложение считается устаревшим и больше не
|
||||
будет поддерживать будущие версии Nextcloud (19+). Автору не удалось найти
|
||||
способ реализовать полноценную поддержку интерактивных и долгих occ-задач в
|
||||
веб-терминале без введения дополнительных зависимостей (например, через
|
||||
websockets), а отсутствие настоящей асинхронности может привести к серьёзным
|
||||
проблемам на больших инсталляциях.
|
||||
[Этот issue](https://github.com/nextcloud/server/issues/16726) объясняет,
|
||||
почему разработчик решил больше не поддерживать это приложение.
|
||||
|
||||
|
||||
## Установка
|
||||
|
||||
Сборка не нужна (чистый PHP + vanilla JS). Клонируйте прямо в `apps/`
|
||||
целевого сервера и запустите установочный скрипт:
|
||||
|
||||
```bash
|
||||
cd /var/www/nextcloud/apps
|
||||
git clone https://github.com/fanategorius/occweb.git
|
||||
bash occweb/install.sh
|
||||
```
|
||||
|
||||
`install.sh` удаляет dev/CI-файлы, не нужные для работающей установки
|
||||
(`tests/`, `.travis.yml`, `phpunit*.xml`, `composer.json`, `composer.lock`,
|
||||
`Makefile`), выставляет `chown -R` на пользователя веб-сервера и выполняет
|
||||
`occ app:enable`. По умолчанию считает, что Nextcloud лежит в
|
||||
`/var/www/nextcloud`, а пользователь веб-сервера — `www-data`; если у вас
|
||||
иначе, передайте своими аргументами:
|
||||
|
||||
```bash
|
||||
bash occweb/install.sh /path/to/nextcloud custom-web-user
|
||||
```
|
||||
|
||||
`occ app:enable` сам корректно проставит версию приложения в базе, поэтому
|
||||
оговорки из раздела «Обновление кода на сервере» ниже к свежей установке не
|
||||
относятся — они актуальны только когда приложение уже включено и вы
|
||||
обновляете его на месте.
|
||||
|
||||
Перед установкой сверьте версию вашего Nextcloud с диапазоном в
|
||||
`appinfo/info.xml` (`dependencies/nextcloud`, `min-version`/`max-version`)
|
||||
— `occ app:enable` откажется включать приложение вне этого диапазона.
|
||||
|
||||
Учтите: `install.sh` удаляет файлы, отслеживаемые в git. Если планируете
|
||||
дальше обновлять эту установку через `git pull`, а не пере-клонированием —
|
||||
имейте в виду, что будущее изменение одного из удалённых файлов в апстриме
|
||||
может привести к отказу `git pull` смёржить изменения. Git сам сообщит об
|
||||
этом, и файл можно вернуть командой `git checkout -- <file>`.
|
||||
|
||||
## Режим SQL-запросов
|
||||
|
||||
Введите `sql` в терминале, чтобы переключиться в режим SQL-запросов и
|
||||
выполнять произвольные SQL-запросы напрямую к базе данных Nextcloud (только
|
||||
для администраторов). Запросы разделяются `;`. Используйте **Shift+Enter**
|
||||
для перехода на новую строку и **Enter**, чтобы отправить весь блок одним
|
||||
запросом — это важно для скриптов вида
|
||||
`SET vars.x = 'value'; SELECT current_setting('vars.x');`, так как все
|
||||
запросы, отправленные одним блоком, выполняются в рамках одного соединения
|
||||
(сессии) с базой данных. Введите `occ`, чтобы вернуться в обычный режим
|
||||
occ-команд.
|
||||
|
||||
⚠️ Для `DELETE`/`UPDATE` нет отмены — внимательно проверяйте, что вы
|
||||
собираетесь выполнить, желательно сначала на некритичной записи/пользователе.
|
||||
|
||||
## ⚠️ Предупреждения ⚠️
|
||||
|
||||
- Это приложение не является настоящим интерактивным терминалом и не
|
||||
поддерживает долгие задачи.
|
||||
Поэтому на крупных инсталляциях команды вроде `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`. Сначала сделайте бэкап базы и
|
||||
протестируйте на некритичном аккаунте.
|
||||
|
||||
## Обновление кода на сервере
|
||||
|
||||
После того как новый код скопирован/спуллен в `nextcloud/apps/occweb/`,
|
||||
обязательно перезапустите PHP, иначе изменения не применятся. `occ
|
||||
app:disable`/`app:enable` **не помогает** — эта команда не сбрасывает
|
||||
opcode-кэш PHP, из-за чего может продолжать работать старая закэшированная
|
||||
версия кода (характерный симптом — маршруты внезапно отвечают 404).
|
||||
Перезапустите сам процесс PHP, например:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart php8.3-fpm # укажите свою версию PHP
|
||||
sudo systemctl restart apache2 # если PHP работает как модуль Apache
|
||||
```
|
||||
|
||||
### Если меняете `<version>` в `appinfo/info.xml`
|
||||
|
||||
Nextcloud хранит установленную версию каждого приложения в базе
|
||||
(`installed_version` в `oc_appconfig`) отдельно от `<version>` в
|
||||
`info.xml`. Если вы правите файлы на месте (копированием/`git pull`), а не
|
||||
через App Store или `occ upgrade`, эти два значения расходятся — и
|
||||
Nextcloud считает, что «инстансу нужен апгрейд», блокируя большинство
|
||||
`occ`-команд за CLI-визардом обновления, хотя само ядро Nextcloud никак не
|
||||
менялось. После бампа версии синхронизируйте её вручную:
|
||||
|
||||
```bash
|
||||
sudo -u www-data php /var/www/nextcloud/occ config:app:set occweb installed_version --value="X.Y.Z"
|
||||
```
|
||||
|
||||
(то же значение `X.Y.Z`, что и в новом `<version>`), затем перезапустите
|
||||
PHP, как описано выше. На свежую установку через `occ app:enable` это не
|
||||
распространяется — там версия проставляется корректно автоматически.
|
||||
|
||||
## Планы (TODO):
|
||||
См. [открытые issues](https://github.com/Adphi/occweb/issues)
|
||||
+7
-7
@@ -5,17 +5,17 @@
|
||||
<name>Extended OCC Web</name>
|
||||
<summary>OCC Commands in a web terminal</summary>
|
||||
<description><![CDATA[Run OCC Commands in a web terminal]]></description>
|
||||
<version>0.3.3</version>
|
||||
<version>0.3.4</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="ebugaev@itkey.com" >Egor</author>
|
||||
<author mail="dillard.blom@ensembia.com" >Dillard Blom</author>
|
||||
<namespace>OCCWeb</namespace>
|
||||
<category>tools</category>
|
||||
<website>https://github.com/fanategorius/occweb</website>
|
||||
<bugs>https://github.com/fanategorius/occweb/issues</bugs>
|
||||
<repository>https://github.com/fanategorius/occweb</repository>
|
||||
<screenshot>https://github.com/fanategorius/occweb/raw/main/appinfo/screenshot.png</screenshot>
|
||||
<website>https://github.com/dillardblom/occweb</website>
|
||||
<bugs>https://github.com/dillardblom/occweb/issues</bugs>
|
||||
<repository>https://github.com/dillardblom/occweb</repository>
|
||||
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png</screenshot>
|
||||
<dependencies>
|
||||
<nextcloud min-version="30" max-version="35"/>
|
||||
<nextcloud min-version="30" max-version="33"/>
|
||||
</dependencies>
|
||||
<navigations>
|
||||
<navigation role="admin">
|
||||
|
||||
+33
-31
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -159,12 +160,9 @@ class DbController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
// Проверка прав администратора
|
||||
// Check admin privileges
|
||||
$user = $this->userSession->getUser();
|
||||
if (!$user) {
|
||||
return new JSONResponse(['error' => 'Not authenticated'], 401);
|
||||
@@ -188,25 +186,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 +220,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 +251,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 +295,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 +338,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 +367,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(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use OC\MemoryInfo;
|
||||
use OCP\IRequest;
|
||||
use OCP\AppFramework\Http\TemplateResponse;
|
||||
use OCP\AppFramework\Http\DataResponse;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Controller;
|
||||
use Symfony\Component\Console\Input\StringInput;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -31,7 +32,7 @@ class OccController extends Controller
|
||||
|
||||
$this->application = new Application(
|
||||
OC::$server->get(\OCP\ServerVersion::class),
|
||||
OC::$server->getConfig(),
|
||||
OC::$server->get(\OCP\IConfig::class),
|
||||
OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class),
|
||||
new FakeRequest(),
|
||||
$this->logger,
|
||||
@@ -47,11 +48,28 @@ class OccController extends Controller
|
||||
$this->symphonyApplication = $reflectionProperty->getValue($this->application);
|
||||
}
|
||||
|
||||
private function requireAdmin(): ?JSONResponse
|
||||
{
|
||||
// Services via OC::$server zodat de constructor-signatuur ongewijzigd blijft
|
||||
// en NC's DI-container (zonder application.php) de class kan resolven.
|
||||
$userSession = OC::$server->get(\OCP\IUserSession::class);
|
||||
$user = $userSession->getUser();
|
||||
if ($user === null) {
|
||||
return new JSONResponse(['error' => 'Not authenticated'], 401);
|
||||
}
|
||||
$groupManager = OC::$server->get(\OCP\IGroupManager::class);
|
||||
if (!$groupManager->isAdmin($user->getUID())) {
|
||||
return new JSONResponse(['error' => 'Admin privileges required'], 403);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($err = $this->requireAdmin()) return $err;
|
||||
return new TemplateResponse('occweb', 'index');
|
||||
}
|
||||
|
||||
@@ -76,6 +94,7 @@ class OccController extends Controller
|
||||
*/
|
||||
public function cmd($command)
|
||||
{
|
||||
if ($err = $this->requireAdmin()) return $err;
|
||||
$this->logger->debug($command);
|
||||
$input = new StringInput($command);
|
||||
$response = $this->run($input);
|
||||
@@ -84,6 +103,7 @@ class OccController extends Controller
|
||||
}
|
||||
|
||||
public function list() {
|
||||
if ($err = $this->requireAdmin()) return $err;
|
||||
$defs = $this->symphonyApplication->all();
|
||||
$cmds = array();
|
||||
foreach ($defs as $d) {
|
||||
|
||||
@@ -35,19 +35,19 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
|
||||
);
|
||||
}
|
||||
|
||||
public function fetch()
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user