11 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
dillard 040743d351 Credit fanategorius and Git-Usr123 forks in README 2026-09-11 17:52:59 +02:00
dillard 85890e12db Update README to reflect active fork status, drop stale Russian translation
Also backport OccController's getConfig() -> IConfig::class fix (deprecated
internal API -> stable OCP interface), spotted while comparing against
Git-Usr123/occweb.
2026-09-11 17:40:12 +02:00
dillard cd01a2fa17 Merge branch 'fix/fetch-return-type-php81' into deploy-nc01 2026-08-09 13:06:32 +02:00
dillard a4a8f9a4eb Fix missing admin check on OccController routes (security)
index(), cmd() and list() were accessible to any logged-in user.
The navigation menu already restricts visibility to admins, but the
routes themselves were unprotected — any authenticated user who knows
the URL could execute occ commands or list all available commands.

Fix: inject IGroupManager + IUserSession, add requireAdmin() helper,
call it at the start of all three public methods. Matches the pattern
already used in DbController.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:04:05 +02:00
dillard f681caca35 Fix OccOutput::fetch() return type for PHP 8.1+ compatibility
BufferedOutput::fetch() declares a string return type in Symfony 5+.
PHP 8.1 enforces return type compatibility with parent methods, causing
a fatal 500 error when the occweb page loads.

Fix: add ': string' return type to OccOutput::fetch().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:25:56 +02:00
7 changed files with 168 additions and 251 deletions
+26 -7
View File
@@ -1,15 +1,34 @@
# ⚠️ Deprecated ⚠️ OCCWeb terminal # OCCWeb terminal (maintained fork)
*Читать на [русском](README.ru.md).*
### A web terminal for admins to launch Nextcloud's occ commands ### A web terminal for admins to launch Nextcloud's occ commands
![occweb](https://github.com/Adphi/OCCWeb/raw/main/appinfo/screenshot.png) ![occweb](https://github.com/Adphi/OCCWeb/raw/main/appinfo/screenshot.png)
## About this fork
## ⚠️ Deprecated ⚠️ The original [Adphi/occweb](https://github.com/Adphi/occweb) was marked deprecated by its
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. author and is unmaintained. This fork tracks [fanategorius/occweb](https://github.com/fanategorius/occweb),
[This issue](https://github.com/nextcloud/server/issues/16726) may give some hints on why I decided to not support this application anymore. 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 ## 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. `installed_version` correctly on its own.
## TODOs: ## TODOs:
See [open issues](https://github.com/Adphi/occweb/issues) See [open issues](https://github.com/dillardblom/occweb/issues)
-126
View File
@@ -1,126 +0,0 @@
# ⚠️ Устарело ⚠️ OCCWeb terminal
*Read in [English](README.md).*
### Веб-терминал для администраторов для запуска occ-команд Nextcloud
![occweb](https://github.com/Adphi/OCCWeb/raw/main/appinfo/screenshot.png)
## ⚠️ Устарело ⚠️
Поскольку 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
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(),
+21 -1
View File
@@ -9,6 +9,7 @@ use OC\MemoryInfo;
use OCP\IRequest; use OCP\IRequest;
use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Controller; use OCP\AppFramework\Controller;
use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -31,7 +32,7 @@ class OccController extends Controller
$this->application = new Application( $this->application = new Application(
OC::$server->get(\OCP\ServerVersion::class), OC::$server->get(\OCP\ServerVersion::class),
OC::$server->getConfig(), OC::$server->get(\OCP\IConfig::class),
OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class), OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class),
new FakeRequest(), new FakeRequest(),
$this->logger, $this->logger,
@@ -47,11 +48,28 @@ class OccController extends Controller
$this->symphonyApplication = $reflectionProperty->getValue($this->application); $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 * @NoCSRFRequired
*/ */
public function index() public function index()
{ {
if ($err = $this->requireAdmin()) return $err;
return new TemplateResponse('occweb', 'index'); return new TemplateResponse('occweb', 'index');
} }
@@ -76,6 +94,7 @@ class OccController extends Controller
*/ */
public function cmd($command) public function cmd($command)
{ {
if ($err = $this->requireAdmin()) return $err;
$this->logger->debug($command); $this->logger->debug($command);
$input = new StringInput($command); $input = new StringInput($command);
$response = $this->run($input); $response = $this->run($input);
@@ -84,6 +103,7 @@ class OccController extends Controller
} }
public function list() { public function list() {
if ($err = $this->requireAdmin()) return $err;
$defs = $this->symphonyApplication->all(); $defs = $this->symphonyApplication->all();
$cmds = array(); $cmds = array();
foreach ($defs as $d) { foreach ($defs as $d) {
+5 -5
View File
@@ -35,19 +35,19 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
); );
} }
public function fetch() 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);
} }