13 Commits

Author SHA1 Message Date
dillard f091cc8c0b Add NC-version compatibility matrix to README 2026-09-26 12:27:49 +02:00
dillard 2872d70653 Fix NC34 compatibility: vendor jQuery, send CSRF token explicitly on ajax calls
Live-tested against nc01 after its auto-update jumped to NC 34.0.4:

- Nextcloud's global jQuery has been deprecated since NC19 and is no
  longer reliably present/first-loaded on NC34 (other apps like
  serverinfo hit the same issue). occweb's index.js and the vendored
  jquery.terminal (term.js) both assume a global jQuery, so ship our
  own copy (js/jquery.js, 3.7.1 from code.jquery.com) and load it
  before them in templates/index.php. Fixes a hard "jQuery is not
  defined" crash that left the terminal blank (black screen).

- With that fixed, every ajax call started failing with HTTP 412
  "CSRF check failed": core only auto-attaches the requesttoken
  header to requests made through ITS OWN globally-loaded jQuery, and
  with occweb now vendoring a separate jQuery instance that no longer
  happens. A global $.ajaxSetup() call turned out unreliable (another
  app's later jQuery load can replace window.jQuery afterwards), so
  each of the three ajax calls (SQL query, initial completion list,
  occ command execution) now sets the requesttoken header explicitly
  via OC.requestToken.

Bump max-version to 34 - actually verified working on NC 34.0.4 now,
not inherited/guessed (see the policy comment in this same file).
Version bump to 0.3.5.
2026-09-26 12:23:28 +02:00
dillard 1a333d161b Replace INSTALL-AIO.md with a generic, English, host-agnostic version
The previous version was in Dutch and hardcoded internal deploy details
(hostname alias, personal local path, private Gitea mirror domain) that
had no business being in a public repo. Replaced with a generic version
using <docker-host> placeholders; the internal nc01-specific copy now
lives at notes/deploy-nc01.md, which is gitignored and stays local-only.

Not rewriting prior history for this (confirmed with user: nothing
credential-level was exposed, just a hostname alias and a path).
2026-09-26 03:59:00 +02:00
dillard 827b2d10dd Revert max-version to 33: only claim what we've actually verified
35 was fanategorius's own tested value, but this is now a modified
fork (CSRF fix, admin checks, translated internals) - inheriting their
compatibility claim isn't the same as having verified it ourselves.
33 is the only version this fork has actually run on (nc01).

An untested max-version that's too high is worse than one that's
accurate: on the next core bump, Nextcloud's own compat check would
keep the app enabled on an unverified major instead of cleanly
disabling it, so a real incompatibility would surface as a runtime
error instead of a safe, expected "needs testing" disable.

Policy going forward (documented inline in info.xml): test against
the next major's beta ahead of time and bump max-version before that
major goes final, so the app is never caught by surprise - never bump
it reactively/speculatively.
2026-09-26 03:53:16 +02:00
dillard 20bdb7dcd6 Fix install/clone instructions to point at our fork, revert max-version to 35
README.md and install.sh's install instructions still told new users to
git clone the abandoned upstream (fanategorius/occweb), which lacks all
of this fork's fixes. Point those at dillardblom/occweb; leave the
Credits section's links to Adphi/fanategorius as-is since those are
deliberate attribution, not install instructions. Also repoint the
README screenshot badge, which was hotlinking Adphi's repo.

composer.json still listed only the original author; add ourselves
alongside, matching the info.xml author field.

Revert max-version 40->35: 35 was fanategorius's own tested release
value (commit 7c09dd2), not a guess. Bumping to 40 was our own
speculative headroom into an untested future major - exactly the
pattern this project's own history already reverted twice, for the
same stated reason ("hasn't been tested against newer Nextcloud
releases"). Getting auto-disabled by the compat check on an untested
major is the correct, safe failure mode; claiming compatibility we
haven't verified is not. 35 still satisfies the Codex finding (no
narrowing below a previously-validated ceiling) without guessing ahead.
2026-09-26 03:50:37 +02:00
dillard c2186ce2ea Give max-version real headroom instead of pinning to nc01's exact core version
Codex review flagged the previous 35->33 change as an unjustified
compatibility regression: nothing in this app's code is actually
incompatible with NC 34/35, so narrowing the ceiling only reflects
nc01's current version, not a real limitation. Worse, nc01's AIO now
auto-updates its core nightly (watchtower-aio), so pinning max-version
to today's exact version would very likely repeat the 2026-08-09
incident (occweb auto-disabled by the compat check) on the very next
core bump. This app only calls a handful of stable, long-lived OCP
interfaces, so a generous ceiling carries little real compatibility
risk compared to that recurring outage.
2026-09-26 03:38:48 +02:00
dillard f4395d8dda Add INSTALL-AIO.md to the repo, rewrite it for the actual AIO/custom_apps workflow
Was untracked locally and stale (referenced apps/ instead of custom_apps/,
git clone inside the container instead of our tar-based deploy). Documents
the two things that just caused a production incident on nc01: files
extracted from a tarball keep the source uid/gid and need an explicit
chown to www-data, and skipping the installed_version sync after bumping
<version> puts the whole instance in "needs upgrade" state, which blocks
not just occ commands but the web login page too.

Link it from README.md's install section.
2026-09-26 03:27:27 +02:00
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
11 changed files with 266 additions and 120 deletions
+3
View File
@@ -30,5 +30,8 @@ js/*.log
.env .env
*.local *.local
# Internal deploy notes (hostnames, paths) - never publish these
notes/
# Misc logs # Misc logs
*.log *.log
+95
View File
@@ -0,0 +1,95 @@
# Installing/updating occweb on Nextcloud AIO (Docker)
Nextcloud's official All-in-One (AIO) distribution runs core in its own container
(`nextcloud-aio-nextcloud` by convention), so the generic [install instructions](README.md)
(`git clone` straight into the server) don't apply as-is. This covers the differences.
## Where the app belongs
`/var/www/html/custom_apps/` inside the core container — **not** `/var/www/html/apps/`.
AIO's `config.php` sets `apps_paths` with `apps/` as `writable: false` (managed by
Nextcloud's own core/appstore sync, which can overwrite custom code placed there) and
`custom_apps/` as `writable: true` — the intended location for third-party apps like
this one.
## Deploy/update procedure
The AIO container has no working git access to GitHub, so deploy a tarball instead of
`git clone`/`git pull` inside it:
```bash
# 1. On your machine: clean checkout of the branch/tag you want, dev files stripped
STAGE=$(mktemp -d)
git archive main | tar -x -C "$STAGE"
rm -rf "$STAGE"/{tests,.travis.yml,phpunit.xml,phpunit.integration.xml,composer.json,composer.lock,Makefile}
tar -C "$STAGE" -cf /tmp/occweb-deploy.tar .
# 2. Copy it onto the Docker host, then into the container
scp /tmp/occweb-deploy.tar <docker-host>:/tmp/occweb-deploy.tar
ssh <docker-host> "docker cp /tmp/occweb-deploy.tar nextcloud-aio-nextcloud:/tmp/occweb-deploy.tar && \
docker exec nextcloud-aio-nextcloud sh -c 'mkdir -p /var/www/html/custom_apps/occweb && \
tar -xf /tmp/occweb-deploy.tar -C /var/www/html/custom_apps/occweb && rm /tmp/occweb-deploy.tar'"
# 3. Fix ownership - tar preserves the source uid/gid, not www-data
ssh <docker-host> "docker exec nextcloud-aio-nextcloud chown -R www-data:www-data /var/www/html/custom_apps/occweb"
# 4. Fresh install only (skip when updating an already-enabled app):
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ app:enable occweb"
```
> **Gotcha**: never copy a file into a container with `ssh host "docker exec container bash -c 'cat > file'"`
> piped from local stdin — that produces an empty file. Always `scp` to the host, then
> `docker cp` into the container, as above.
## ⚠️ Critical after bumping `<version>` in `appinfo/info.xml`
This step is **not optional** — skipping it doesn't just block occ commands, it can
block **logging into the web UI entirely** (Nextcloud shows an upgrade screen to every
user instead of the login page).
Nextcloud tracks each app's installed version in the database (`installed_version` in
`oc_appconfig`), separately from `<version>` in `info.xml`. If you replace files in place
(instead of going through the app store or `occ upgrade`), those two fall out of sync,
and Nextcloud puts the whole instance into a "needs upgrade" state until you sync it
manually:
```bash
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ config:app:set occweb installed_version --value='X.Y.Z'"
```
(use the same `X.Y.Z` as the new `<version>`). Verify with `occ status` that
`needsDbUpgrade: false` again.
## Restart PHP-FPM after every deploy
`occ app:disable`/`app:enable` does not clear PHP's opcache — an update can silently
fail to take effect without a restart (routes 404ing is the typical symptom). Prefer a
graceful reload over a hard restart (keeps in-flight requests alive):
```bash
ssh <docker-host> "docker exec nextcloud-aio-nextcloud ps aux | grep 'php-fpm: master'" # find the PID
ssh <docker-host> "docker exec nextcloud-aio-nextcloud kill -USR2 <master-pid>"
```
`supervisorctl` won't work directly inside this container (its socket isn't at the
default path) — hence the manual PID lookup.
## Removing the app
```bash
ssh <docker-host> "docker exec -u www-data nextcloud-aio-nextcloud php occ app:disable occweb && \
docker exec nextcloud-aio-nextcloud rm -rf /var/www/html/custom_apps/occweb"
```
## `max-version` policy
See the comment next to `<dependencies>` in `appinfo/info.xml`: it must always equal the
highest Nextcloud core version this fork has actually been verified against — never a
value inherited from upstream, and never a guess ahead of what's been tested. Test
against the next major's beta ahead of time and bump it before that major goes final,
rather than reacting after the fact.
## SQL query mode
Type `sql` in the OCCWeb terminal to run raw SQL directly against the Nextcloud
database. Separate statements with `;`, use Shift+Enter for a new line and Enter to run.
+21 -2
View File
@@ -2,7 +2,7 @@
### 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/dillardblom/occweb/raw/main/appinfo/screenshot.png)
## About this fork ## About this fork
@@ -33,12 +33,15 @@ has background on why Nextcloud has no native async support for occ tasks.
## Install ## Install
Running Nextcloud AIO (Docker)? See [INSTALL-AIO.md](INSTALL-AIO.md) instead — the app
path and deploy steps differ from the generic instructions below.
No build step required (plain PHP + vanilla JS). Clone straight into the No build step required (plain PHP + vanilla JS). Clone straight into the
target server's `apps/` directory and run the install script: target server's `apps/` directory and run the install script:
```bash ```bash
cd /var/www/nextcloud/apps cd /var/www/nextcloud/apps
git clone https://github.com/fanategorius/occweb.git git clone https://github.com/dillardblom/occweb.git
bash occweb/install.sh bash occweb/install.sh
``` ```
@@ -128,5 +131,21 @@ sudo -u www-data php /var/www/nextcloud/occ config:app:set occweb installed_vers
This does not apply on a fresh `occ app:enable` install — that command sets 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.
## Compatibility matrix
`max-version` in `appinfo/info.xml` only ever reflects the highest version we've actually
tested (see the policy comment right above it) - this table is the actual history of what
was verified, so a rollback target is obvious if a newer major causes problems.
| occweb version | Tag | NC version tested | Result |
|---|---|---|---|
| 0.3.4 | `v0.3.4` | 33.0.8 | ✅ Verified (nc01, 2026-09-25) |
| 0.3.4 | `v0.3.4` | 34.0.4 | ❌ Blank terminal - global jQuery gone on NC34 (`jQuery is not defined`), then a 412 "CSRF check failed" on every ajax call once jQuery was vendored. Fixed in 0.3.5. |
| 0.3.5 | `v0.3.5-nc34` | 34.0.4 | ✅ Verified (nc01, 2026-09-26) - vendors its own jQuery + sends the CSRF `requesttoken` header explicitly on every ajax call |
| 0.3.5 | `v0.3.5-nc34` | 33.0.8 | ⚠️ Believed compatible (the 0.3.5 change is additive-only, nothing removed that NC30-33 depended on) but not re-verified - nc01 no longer runs NC33 to test against |
If a future major breaks something, `git checkout v0.3.4` is the last version confirmed
working on NC33 and older.
## TODOs: ## TODOs:
See [open issues](https://github.com/dillardblom/occweb/issues) See [open issues](https://github.com/dillardblom/occweb/issues)
+12 -7
View File
@@ -5,17 +5,22 @@
<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.5</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"/> <!-- max-version must equal the highest NC core version this fork has
actually been verified on - never a value inherited from upstream
or guessed ahead. Test against the next major's beta and bump
this BEFORE it goes final, so occ:upgrade never has to fall back
to auto-disabling the app. -->
<nextcloud min-version="30" max-version="34"/>
</dependencies> </dependencies>
<navigations> <navigations>
<navigation role="admin"> <navigation role="admin">
+4
View File
@@ -6,6 +6,10 @@
"authors": [ "authors": [
{ {
"name": "Adphi" "name": "Adphi"
},
{
"name": "Dillard Blom",
"email": "dillard.blom@ensembia.com"
} }
], ],
"require": {}, "require": {},
+1 -1
View File
@@ -3,7 +3,7 @@
# Post-clone install script for occweb. # Post-clone install script for occweb.
# #
# Usage (from nextcloud/apps/): # Usage (from nextcloud/apps/):
# git clone https://github.com/fanategorius/occweb.git # git clone https://github.com/dillardblom/occweb.git
# bash occweb/install.sh [nextcloud-root] [web-user] # bash occweb/install.sh [nextcloud-root] [web-user]
# #
# Defaults: nextcloud-root=/var/www/nextcloud, web-user=www-data # Defaults: nextcloud-root=/var/www/nextcloud, web-user=www-data
+43 -32
View File
@@ -1,23 +1,30 @@
(function (OC, window, $, undefined) { (function (OC, window, $, undefined) {
'use strict'; 'use strict';
$(function() { $(function() {
// Nextcloud core only auto-attaches the CSRF requesttoken header to ajax
// calls made through its own globally-loaded jQuery. Since NC34 that
// global jQuery is no longer guaranteed to be the one still active by
// the time our own calls fire (other apps loading their own jQuery can
// replace window.jQuery afterwards - see js/jquery.js), ajaxSetup() here
// isn't reliable; every $.ajax() call below sets the header explicitly.
function scrollToBottom(){ function scrollToBottom(){
var html = $('html'); var html = $('html');
html.scrollTop(html.prop('scrollHeight')); html.scrollTop(html.prop('scrollHeight'));
} }
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 +61,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 +75,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 +100,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 +113,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 +177,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 +186,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 +282,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) {
@@ -285,6 +294,7 @@
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
timeout: SQL_REQUEST_TIMEOUT_MS, timeout: SQL_REQUEST_TIMEOUT_MS,
headers: { requesttoken: OC.requestToken },
data: JSON.stringify({ sql: sql, confirm: !!confirmed }) data: JSON.stringify({ sql: sql, confirm: !!confirmed })
}).done(function (response) { }).done(function (response) {
if (response && response.requiresConfirmation) { if (response && response.requiresConfirmation) {
@@ -317,7 +327,7 @@
}); });
} }
$.get(baseUrl + '/cmd', function(response){ $.ajax({ url: baseUrl + '/cmd', headers: { requesttoken: OC.requestToken } }).done(function(response){
$('#app-content').terminal(function(command, term) { $('#app-content').terminal(function(command, term) {
if (mode === 'sql') { if (mode === 'sql') {
var trimmed = command.trim(); var trimmed = command.trim();
@@ -373,6 +383,7 @@
url: baseUrl + '/cmd', url: baseUrl + '/cmd',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
headers: { requesttoken: OC.requestToken },
data: JSON.stringify(occCommand) data: JSON.stringify(occCommand)
}).done(function (response) { }).done(function (response) {
term.echo('\n' + response).resume(); term.echo('\n' + response).resume();
@@ -388,8 +399,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;
+2
View File
File diff suppressed because one or more lines are too long
+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);
} }
+5
View File
@@ -1,4 +1,9 @@
<?php <?php
// Nextcloud's global jQuery has been deprecated since NC19 and is no longer
// reliably present on NC34+ (see e.g. serverinfo needing to bundle its own) -
// term.js (jquery.terminal) and index.js both require jQuery to be loaded
// first, so ship and load our own copy rather than relying on core.
script('occweb', 'jquery');
script('occweb', 'index'); script('occweb', 'index');
script('occweb', 'term'); script('occweb', 'term');
script('occweb', 'unix_formatting'); script('occweb', 'unix_formatting');