Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04f4ec5869 | |||
| 3f8a6b0213 | |||
| 93221b2905 | |||
| 839572f1fb | |||
| 266b5a6985 | |||
| 3b9fbf42a0 | |||
| ec4b4a4ece | |||
| 6dbf2f9675 | |||
| d349aad2e6 | |||
| d25580239e | |||
| 5c7fbcbbfc | |||
| d7b093812d | |||
| 284e75ddb3 | |||
| c1fd53845f | |||
| 2b57349519 | |||
| 45c97653ca | |||
| 363eb0de11 | |||
| bea30b94c9 | |||
| 9861313961 | |||
| f89ced651b | |||
| 10055c9358 | |||
| f4b7484e2f | |||
| f68b1658be | |||
| 3fc8601344 | |||
| 635cdd4b24 | |||
| 76368f154e | |||
| a607426a9c | |||
| 35e1f5df32 | |||
| 4b84be3cb7 | |||
| 93244a0d4d | |||
| 8fc4942517 | |||
| 95cdd06426 | |||
| 6016d0916c | |||
| 422748cace | |||
| a5f5c5cdb7 | |||
| 221ea8a249 | |||
| 138ee88486 | |||
| f091cc8c0b | |||
| 2872d70653 | |||
| 1a333d161b | |||
| 827b2d10dd | |||
| 20bdb7dcd6 | |||
| c2186ce2ea | |||
| f4395d8dda |
@@ -0,0 +1,12 @@
|
|||||||
|
# Files excluded from `git archive` output - used by `make appstore` (and by
|
||||||
|
# our own nc01 deploy tarballs) to strip dev/CI-only files that don't belong
|
||||||
|
# in a running install, without needing them repeated as tar --exclude flags.
|
||||||
|
tests export-ignore
|
||||||
|
.travis.yml export-ignore
|
||||||
|
phpunit.xml export-ignore
|
||||||
|
phpunit.integration.xml export-ignore
|
||||||
|
composer.json export-ignore
|
||||||
|
composer.lock export-ignore
|
||||||
|
Makefile export-ignore
|
||||||
|
.gitattributes export-ignore
|
||||||
|
appinfo/screenshot*.png export-ignore
|
||||||
@@ -30,5 +30,11 @@ js/*.log
|
|||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Internal deploy notes (hostnames, paths) - never publish these
|
||||||
|
notes/
|
||||||
|
|
||||||
|
# Local compat-test docker rigs (throwaway, not part of the app release)
|
||||||
|
testbak/
|
||||||
|
|
||||||
# Misc logs
|
# Misc logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 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 HEAD | 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/extended_occweb && \
|
||||||
|
tar -xf /tmp/occweb-deploy.tar -C /var/www/html/custom_apps/extended_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/extended_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 extended_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 extended_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 extended_occweb && \
|
||||||
|
docker exec nextcloud-aio-nextcloud rm -rf /var/www/html/custom_apps/extended_occweb"
|
||||||
|
```
|
||||||
|
|
||||||
|
## `max-version` policy
|
||||||
|
|
||||||
|
See the comment next to `<dependencies>` in `appinfo/info.xml`: it must always equal the
|
||||||
|
highest Nextcloud core version this app 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.
|
||||||
|
|
||||||
|
Before giving anyone access, read the Security section in the README: only
|
||||||
|
admins can use the app, and the database user should have limited rights.
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
# "build": "node node_modules/gulp-cli/bin/gulp.js"
|
# "build": "node node_modules/gulp-cli/bin/gulp.js"
|
||||||
# },
|
# },
|
||||||
|
|
||||||
app_name=$(notdir $(CURDIR))
|
app_name=$(shell xpath -q -e "//info/id/text()" appinfo/info.xml)
|
||||||
build_tools_directory=$(CURDIR)/build/tools
|
build_tools_directory=$(CURDIR)/build/tools
|
||||||
source_build_directory=$(CURDIR)/build/artifacts/source
|
source_build_directory=$(CURDIR)/build/artifacts/source
|
||||||
source_package_name=$(source_build_directory)/$(app_name)
|
source_package_name=$(source_build_directory)/$(app_name)
|
||||||
@@ -110,48 +110,41 @@ dist:
|
|||||||
make source
|
make source
|
||||||
make appstore
|
make appstore
|
||||||
|
|
||||||
# Builds the source package
|
# Both targets package via `git archive` rather than tarring the checkout
|
||||||
|
# directory directly: it only ever includes tracked files (so untracked/
|
||||||
|
# gitignored content - build/, notes/, any stray .env - can never leak into
|
||||||
|
# a release archive), it sets the correct top-level folder name via
|
||||||
|
# --prefix regardless of what this checkout is actually called (this repo
|
||||||
|
# is "occweb", the app id is "extended_occweb" - no symlink trick needed),
|
||||||
|
# and dev/CI-only files (tests/, Makefile, composer.*, phpunit*.xml,
|
||||||
|
# .travis.yml) are stripped via the `export-ignore` entries in
|
||||||
|
# .gitattributes instead of a long, easy-to-miss list of tar --exclude
|
||||||
|
# flags repeated per target. That also means source and appstore now
|
||||||
|
# produce the same archive; both targets are kept for compatibility with
|
||||||
|
# anything that invokes one of them by name.
|
||||||
|
#
|
||||||
|
# app_name comes from `xpath` (see its definition above) - if that binary
|
||||||
|
# is missing, $(shell ...) silently returns an empty string rather than
|
||||||
|
# failing the build, which would turn --prefix=$(app_name)/ below into
|
||||||
|
# --prefix=/ and produce a broken, unusably-rooted archive with no
|
||||||
|
# indication anything went wrong. Fail loudly instead.
|
||||||
|
.PHONY: check-app-name
|
||||||
|
check-app-name:
|
||||||
|
@test -n "$(app_name)" || \
|
||||||
|
(echo "app_name is empty - is 'xpath' (libxml2-utils/perl-XML-XPath) installed?" >&2; exit 1)
|
||||||
|
|
||||||
.PHONY: source
|
.PHONY: source
|
||||||
source:
|
source: check-app-name check-clean-tree check-on-branch
|
||||||
rm -rf $(source_build_directory)
|
rm -rf $(source_build_directory)
|
||||||
mkdir -p $(source_build_directory)
|
mkdir -p $(source_build_directory)
|
||||||
tar \
|
git archive --format=tar --prefix=$(app_name)/ HEAD | gzip > $(source_package_name).tar.gz
|
||||||
--exclude-vcs \
|
|
||||||
--exclude="../$(app_name)/build" \
|
|
||||||
--exclude="../$(app_name)/js/node_modules" \
|
|
||||||
--exclude="../$(app_name)/node_modules" \
|
|
||||||
--exclude="../$(app_name)/*.log" \
|
|
||||||
--exclude="../$(app_name)/js/*.log" \
|
|
||||||
-cvzf $(source_package_name).tar.gz ../$(app_name)
|
|
||||||
|
|
||||||
# Builds the source package for the app store, ignores php and js tests
|
# Builds the source package for the app store, ignores php and js tests
|
||||||
.PHONY: appstore
|
.PHONY: appstore
|
||||||
appstore:
|
appstore: check-app-name check-clean-tree check-on-branch
|
||||||
rm -rf $(appstore_build_directory)
|
rm -rf $(appstore_build_directory)
|
||||||
mkdir -p $(appstore_build_directory)
|
mkdir -p $(appstore_build_directory)
|
||||||
tar \
|
git archive --format=tar --prefix=$(app_name)/ HEAD | gzip > $(appstore_package_name).tar.gz
|
||||||
--exclude-vcs \
|
|
||||||
--exclude="../$(app_name)/build" \
|
|
||||||
--exclude="../$(app_name)/tests" \
|
|
||||||
--exclude="../$(app_name)/Makefile" \
|
|
||||||
--exclude="../$(app_name)/*.log" \
|
|
||||||
--exclude="../$(app_name)/phpunit*xml" \
|
|
||||||
--exclude="../$(app_name)/composer.*" \
|
|
||||||
--exclude="../$(app_name)/js/node_modules" \
|
|
||||||
--exclude="../$(app_name)/js/tests" \
|
|
||||||
--exclude="../$(app_name)/js/test" \
|
|
||||||
--exclude="../$(app_name)/js/*.log" \
|
|
||||||
--exclude="../$(app_name)/js/package.json" \
|
|
||||||
--exclude="../$(app_name)/js/bower.json" \
|
|
||||||
--exclude="../$(app_name)/js/karma.*" \
|
|
||||||
--exclude="../$(app_name)/js/protractor.*" \
|
|
||||||
--exclude="../$(app_name)/package.json" \
|
|
||||||
--exclude="../$(app_name)/bower.json" \
|
|
||||||
--exclude="../$(app_name)/karma.*" \
|
|
||||||
--exclude="../$(app_name)/protractor\.*" \
|
|
||||||
--exclude="../$(app_name)/.*" \
|
|
||||||
--exclude="../$(app_name)/js/.*" \
|
|
||||||
-cvzf $(appstore_package_name).tar.gz ../$(app_name)
|
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: composer
|
test: composer
|
||||||
@@ -159,8 +152,8 @@ test: composer
|
|||||||
$(CURDIR)/vendor/phpunit/phpunit/phpunit -c phpunit.integration.xml
|
$(CURDIR)/vendor/phpunit/phpunit/phpunit -c phpunit.integration.xml
|
||||||
|
|
||||||
.PHONY: sign
|
.PHONY: sign
|
||||||
sign:
|
sign: check-app-name
|
||||||
@openssl dgst -sha512 -sign ~/.nextcloud/certificates/occweb.key build/artifacts/appstore/occweb.tar.gz |openssl base64
|
@openssl dgst -sha512 -sign ~/.nextcloud/certificates/$(app_name).key $(appstore_package_name).tar.gz |openssl base64
|
||||||
|
|
||||||
VERSION := $(shell xpath -q -e "//info/version/text()" appinfo/info.xml)
|
VERSION := $(shell xpath -q -e "//info/version/text()" appinfo/info.xml)
|
||||||
|
|
||||||
@@ -168,11 +161,41 @@ VERSION := $(shell xpath -q -e "//info/version/text()" appinfo/info.xml)
|
|||||||
show-version:
|
show-version:
|
||||||
@echo $(VERSION)
|
@echo $(VERSION)
|
||||||
|
|
||||||
|
# Defaults to "origin" - correct for a plain clone of this repo, where
|
||||||
|
# "origin" is dillardblom/occweb. Override for a checkout where "origin"
|
||||||
|
# means something else (e.g. this maintainer's own machine, where the repo
|
||||||
|
# was originally forked from upstream and "origin" still points there):
|
||||||
|
# make version RELEASE_REMOTE=fork
|
||||||
|
RELEASE_REMOTE ?= origin
|
||||||
|
|
||||||
|
# VERSION/app_name are read live from the working tree's appinfo/info.xml,
|
||||||
|
# but the archive this tags/packages/signs is git archive HEAD - i.e. the
|
||||||
|
# last COMMITTED state. Without this check, bumping <version> without
|
||||||
|
# committing first would tag and sign a package named/keyed after the new
|
||||||
|
# version while its actual contents are still the previous commit's.
|
||||||
|
.PHONY: check-clean-tree
|
||||||
|
check-clean-tree:
|
||||||
|
@git diff --quiet HEAD -- || \
|
||||||
|
(echo "Uncommitted changes to tracked files - commit before releasing (see 'git status')." >&2; exit 1)
|
||||||
|
|
||||||
|
# `git archive HEAD` packages whatever commit the checkout currently points
|
||||||
|
# to, regardless of branch - check-clean-tree only guards against *uncommitted*
|
||||||
|
# changes, not against HEAD being the wrong branch entirely (a feature branch,
|
||||||
|
# an old tag, a PR checkout, a detached HEAD). Override for the rare
|
||||||
|
# intentional case (e.g. releasing from a branch on purpose):
|
||||||
|
# make appstore RELEASE_BRANCH=some-branch
|
||||||
|
RELEASE_BRANCH ?= main
|
||||||
|
|
||||||
|
.PHONY: check-on-branch
|
||||||
|
check-on-branch:
|
||||||
|
@test "$$(git rev-parse --abbrev-ref HEAD)" = "$(RELEASE_BRANCH)" || \
|
||||||
|
(echo "HEAD is on '$$(git rev-parse --abbrev-ref HEAD)', not '$(RELEASE_BRANCH)' - pass RELEASE_BRANCH=<branch> to override if this is intentional." >&2; exit 1)
|
||||||
|
|
||||||
.PHONY: version
|
.PHONY: version
|
||||||
version:
|
version: check-clean-tree check-on-branch
|
||||||
@echo "Creating version v$(VERSION)"
|
@echo "Creating version v$(VERSION)"
|
||||||
@git tag v$(VERSION)
|
@git tag v$(VERSION)
|
||||||
@git push origin v$(VERSION)
|
@git push $(RELEASE_REMOTE) v$(VERSION)
|
||||||
@make dist
|
@make dist
|
||||||
@echo "\nRelease Signature: \n"
|
@echo "\nRelease Signature: \n"
|
||||||
@make sign
|
@make sign
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
# OCCWeb terminal (maintained fork)
|
# Extended OCC Web (occ & SQL terminal)
|
||||||
|
|
||||||
### A web terminal for admins to launch Nextcloud's occ commands
|
### A web terminal for Nextcloud admins to run occ commands and SQL queries
|
||||||
|
|
||||||

|
Available in the [Nextcloud App Store](https://apps.nextcloud.com/apps/extended_occweb) as `extended_occweb`.
|
||||||
|
|
||||||
## About this fork
|

|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
The original [Adphi/occweb](https://github.com/Adphi/occweb) was marked deprecated by its
|
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),
|
author and is unmaintained. This project started as a fork of
|
||||||
which picked up compatibility maintenance where Adphi left off, and adds SQL query mode,
|
[fanategorius/occweb](https://github.com/fanategorius/occweb), which had picked up
|
||||||
|
compatibility maintenance where Adphi left off, and tracked it while adding SQL query mode,
|
||||||
security hardening (admin checks, CSRF/confirmation flows, transactional batches), and PHP
|
security hardening (admin checks, CSRF/confirmation flows, transactional batches), and PHP
|
||||||
8.1+ compatibility fixes on top.
|
8.1+ compatibility fixes. Since 0.4.3 it is published under its own app id,
|
||||||
|
`extended_occweb`, and it is now maintained as an independent project.
|
||||||
|
|
||||||
### Credits
|
### Credits
|
||||||
|
|
||||||
- [Adphi/occweb](https://github.com/Adphi/occweb) — original author/project.
|
- [Adphi/occweb](https://github.com/Adphi/occweb) — original author/project.
|
||||||
- [fanategorius/occweb](https://github.com/fanategorius/occweb) — the actively maintained
|
- [fanategorius/occweb](https://github.com/fanategorius/occweb) — the maintained fork this
|
||||||
fork this repo is based on and tracks.
|
project was based on.
|
||||||
- [Git-Usr123/occweb](https://github.com/Git-Usr123/occweb) — independent fork; the
|
- [Git-Usr123/occweb](https://github.com/Git-Usr123/occweb) — independent fork; the
|
||||||
`OC::$server->getConfig()` → `OC::$server->get(\OCP\IConfig::class)` fix in
|
`OC::$server->getConfig()` → `OC::$server->get(\OCP\IConfig::class)` fix in
|
||||||
`OccController.php` was spotted there and backported here.
|
`OccController.php` was spotted there and backported here.
|
||||||
@@ -33,13 +37,19 @@ 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:
|
||||||
|
|
||||||
|
Nextcloud requires the app's directory name to match its id (`extended_occweb`), so
|
||||||
|
clone into that name explicitly rather than the repo's own name:
|
||||||
|
|
||||||
```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 extended_occweb
|
||||||
bash occweb/install.sh
|
bash extended_occweb/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
`install.sh` removes the dev/CI-only files that don't belong in a running
|
`install.sh` removes the dev/CI-only files that don't belong in a running
|
||||||
@@ -50,7 +60,7 @@ server user, and runs `occ app:enable`. It assumes Nextcloud lives at
|
|||||||
values as arguments if that's not the case:
|
values as arguments if that's not the case:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash occweb/install.sh /path/to/nextcloud custom-web-user
|
bash extended_occweb/install.sh /path/to/nextcloud custom-web-user
|
||||||
```
|
```
|
||||||
|
|
||||||
`occ app:enable` takes care of registering the app's version correctly, so
|
`occ app:enable` takes care of registering the app's version correctly, so
|
||||||
@@ -79,8 +89,40 @@ run the whole block in a single request — this matters for scripts like
|
|||||||
statement in one submission runs on the same database connection/session.
|
statement in one submission runs on the same database connection/session.
|
||||||
Type `occ` to switch back to the normal occ-command mode.
|
Type `occ` to switch back to the normal occ-command mode.
|
||||||
|
|
||||||
⚠️ There is no undo for `DELETE`/`UPDATE` statements — double check what
|
Long values in result tables are cut to fit the terminal. Add `--full` to the
|
||||||
you are about to run, ideally against a non-critical row/user first.
|
query (it is an SQL comment, so the database ignores it) to see them in full,
|
||||||
|
e.g. `SELECT configvalue FROM oc_appconfig WHERE configkey = 'x'; --full`.
|
||||||
|
|
||||||
|
Every statement that changes data or the schema (`DELETE`, `UPDATE`,
|
||||||
|
`INSERT`, `DROP`, a data-modifying `WITH`, ...) asks for confirmation before
|
||||||
|
the batch runs. There is still no undo — double check what you are about to
|
||||||
|
run, ideally against a non-critical row/user first. Statements that access the
|
||||||
|
server's filesystem or start programs are blocked.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
OCC Web is a powerful tool: it gives whoever can open it the same control over
|
||||||
|
Nextcloud as `occ` on the command line, plus direct access to the database.
|
||||||
|
Treat access to it like shell access to the server.
|
||||||
|
|
||||||
|
- **Only admins can use it.** Every endpoint checks that the user is a member
|
||||||
|
of the Nextcloud `admin` group, and the endpoints that run commands or SQL
|
||||||
|
also require a valid CSRF token; other users get HTTP 403, anonymous
|
||||||
|
requests HTTP 401. This is the security boundary of the app.
|
||||||
|
- **Be careful who you make an admin.** Anyone in the `admin` group can use OCC
|
||||||
|
Web, and an admin can do everything this app does in other ways as well.
|
||||||
|
Deciding who gets admin rights is up to the instance operator, not this app.
|
||||||
|
- **Run the database as a scoped user with limited rights.** The SQL mode runs
|
||||||
|
with the rights of Nextcloud's own database user. Make sure that user is not
|
||||||
|
a database superuser and cannot read or write files on the database server
|
||||||
|
or start programs there (on PostgreSQL: no superuser, no
|
||||||
|
`pg_execute_server_program`, `pg_read_server_files` or
|
||||||
|
`pg_write_server_files`). The checks in the SQL mode are an extra layer, not
|
||||||
|
a replacement for this.
|
||||||
|
- **No blocklist of occ commands, on purpose.** We don't block commands that
|
||||||
|
show sensitive settings. It would make the app less useful, and an admin
|
||||||
|
can run such commands in a slightly more roundabout way anyway, so it would
|
||||||
|
only give a false sense of security.
|
||||||
|
|
||||||
## ⚠️ Warnings ⚠️
|
## ⚠️ Warnings ⚠️
|
||||||
|
|
||||||
@@ -99,7 +141,7 @@ So if your instance is pretty big, commands like `occ files:scan` will time out
|
|||||||
|
|
||||||
## Deploying updates
|
## Deploying updates
|
||||||
|
|
||||||
After pulling/copying new code into `nextcloud/apps/occweb/`, restart PHP so
|
After pulling/copying new code into `nextcloud/apps/extended_occweb/`, restart PHP so
|
||||||
the changes actually take effect. `occ app:disable`/`app:enable` is **not**
|
the changes actually take effect. `occ app:disable`/`app:enable` is **not**
|
||||||
enough — it does not clear PHP's opcode cache, so a stale, cached version of
|
enough — it does not clear PHP's opcode cache, so a stale, cached version of
|
||||||
the code can keep running (routes silently 404ing is a typical symptom).
|
the code can keep running (routes silently 404ing is a typical symptom).
|
||||||
@@ -121,12 +163,40 @@ CLI-upgrade wizard, even though nothing about Nextcloud core actually
|
|||||||
changed. After bumping the version, sync it manually:
|
changed. After bumping the version, sync it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u www-data php /var/www/nextcloud/occ config:app:set occweb installed_version --value="X.Y.Z"
|
sudo -u www-data php /var/www/nextcloud/occ config:app:set extended_occweb installed_version --value="X.Y.Z"
|
||||||
```
|
```
|
||||||
|
|
||||||
(use the same `X.Y.Z` as the new `<version>`), then restart PHP as above.
|
(use the same `X.Y.Z` as the new `<version>`), then restart PHP as above.
|
||||||
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 (2026-09-25). Last release under the old `occweb` id; does not work on NC34 (missing jQuery, CSRF), fixed from 0.3.5 on. |
|
||||||
|
| 0.4.3 | `v0.4.3` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-27). First release under `extended_occweb`: NC35 fatal in `OccOutput` fixed, vendored jQuery only loaded on NC34+. |
|
||||||
|
| 0.4.4 | (untagged) | 34.0.4 | ✅ Verified (2026-09-30). Brackets in occ output escaped before rendering. |
|
||||||
|
| 0.4.5 | `v0.4.5` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-30). SQL mode hardening, occ output sanitized, first release code-signed with the Nextcloud certificate. |
|
||||||
|
| 0.4.6 | `v0.4.6` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-09-30). occ commands logged for the audit trail, console built from the container, more SQL guard gaps closed, long values in result tables cut to fit (`--full` shows them). |
|
||||||
|
| 0.4.7 | `v0.4.7` | 32.0.15, 33.0.9, 34.0.4, 35.0.1 | ✅ Verified (`testbak/`, 2026-10-01). Dark app icon for the app list and updates page, screenshots no longer in the package. |
|
||||||
|
|
||||||
|
Older entries (0.3.5-0.4.2) are in the git history of this file.
|
||||||
|
|
||||||
|
`min-version`/`max-version` in `appinfo/info.xml` are `32`/`35` since 2026-09-27: 30/31 were never tested and are past Nextcloud's supported range, and 35 is verified rather than guessed.
|
||||||
|
|
||||||
|
If a future major breaks something, `v0.3.4` is the last version confirmed working on NC33
|
||||||
|
and older - but a plain `git checkout v0.3.4` is **not** a working rollback by itself. That
|
||||||
|
tag predates the app id rename (it still declares `<id>occweb</id>`), so checking it out
|
||||||
|
into an `extended_occweb`-named install just produces a broken id/directory mismatch. A
|
||||||
|
real rollback means disabling/removing `extended_occweb` and deploying `v0.3.4` fresh under
|
||||||
|
an `occweb`-named directory instead, same as the original install steps above. This is one
|
||||||
|
of the items still deferred to when a proper test instance exists (see the code review
|
||||||
|
notes in git history around 0.4.1) - not yet written up in full.
|
||||||
|
|
||||||
## TODOs:
|
## TODOs:
|
||||||
See [open issues](https://github.com/dillardblom/occweb/issues)
|
See [open issues](https://github.com/dillardblom/occweb/issues)
|
||||||
|
|||||||
+39
-6
@@ -1,11 +1,32 @@
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
<info xmlns:xsi= "http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
||||||
<id>occweb</id>
|
<id>extended_occweb</id>
|
||||||
<name>Extended OCC Web</name>
|
<name>Extended OCC Web</name>
|
||||||
<summary>OCC Commands in a web terminal</summary>
|
<summary>Run occ commands and SQL queries from a web terminal (admins only)</summary>
|
||||||
<description><![CDATA[Run OCC Commands in a web terminal]]></description>
|
<description><![CDATA[A terminal in the browser for Nextcloud administrators: run occ commands and query the database without shell access to the server. Handy on Nextcloud All-in-One (AIO) and other container setups, where occ otherwise needs `docker exec`.
|
||||||
<version>0.3.4</version>
|
|
||||||
|
**occ mode**
|
||||||
|
|
||||||
|
- Run any occ command, with tab completion of command names and colored output.
|
||||||
|
- Every command is logged with the user who ran it, in the Nextcloud log and, when enabled, in the admin_audit log.
|
||||||
|
|
||||||
|
**SQL mode** (type `sql`)
|
||||||
|
|
||||||
|
- Run SQL against the Nextcloud database. Statements separated by `;` run as one batch on one connection, and the batch is rolled back if a statement fails.
|
||||||
|
- Results are shown as tables (up to 1000 rows per query).
|
||||||
|
- Statements that change data or the schema ask for confirmation before the batch runs. Reading or writing files on the database server and starting programs through SQL are blocked.
|
||||||
|
- Ready-made scripts: type `templates`, for example to look up or completely delete a local user. A template only fills the command line; nothing runs until you review it and press Enter.
|
||||||
|
|
||||||
|
**Security**
|
||||||
|
|
||||||
|
- Only members of the Nextcloud admin group can use the app; every request is checked and CSRF-protected.
|
||||||
|
- Treat access to it like shell access to the server, and run the database as a user without superuser or server file/program rights. See the Security section in the README.
|
||||||
|
|
||||||
|
**Limitations**
|
||||||
|
|
||||||
|
- Not an interactive terminal: long-running commands (like `files:scan` on a large instance) time out, and commands that ask questions cannot be answered (use `-n`).]]></description>
|
||||||
|
<version>0.4.7</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author mail="dillard.blom@ensembia.com" >Dillard Blom</author>
|
<author mail="dillard.blom@ensembia.com" >Dillard Blom</author>
|
||||||
<namespace>OCCWeb</namespace>
|
<namespace>OCCWeb</namespace>
|
||||||
@@ -14,13 +35,25 @@
|
|||||||
<bugs>https://github.com/dillardblom/occweb/issues</bugs>
|
<bugs>https://github.com/dillardblom/occweb/issues</bugs>
|
||||||
<repository>https://github.com/dillardblom/occweb</repository>
|
<repository>https://github.com/dillardblom/occweb</repository>
|
||||||
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png</screenshot>
|
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot.png</screenshot>
|
||||||
|
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-status.png</screenshot>
|
||||||
|
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql-switch.png</screenshot>
|
||||||
|
<screenshot>https://github.com/dillardblom/occweb/raw/main/appinfo/screenshot-sql.png</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<nextcloud min-version="30" max-version="33"/>
|
<!-- min-version and max-version must equal the lowest/highest NC core
|
||||||
|
versions this app has actually been verified on (via testbak/,
|
||||||
|
see its README) - never inherited from upstream or guessed at
|
||||||
|
either end. min-version was 30 pre-testbak, unverified since the
|
||||||
|
app-id rename/jQuery fixes - narrowed to 32 (2026-09-27) once 30/31
|
||||||
|
turned out to never have been re-tested and 32 was confirmed
|
||||||
|
working. Test against the next major's beta and bump max-version
|
||||||
|
BEFORE it goes final, so occ:upgrade never has to fall back to
|
||||||
|
auto-disabling the app. -->
|
||||||
|
<nextcloud min-version="32" max-version="35"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<navigations>
|
<navigations>
|
||||||
<navigation role="admin">
|
<navigation role="admin">
|
||||||
<name>OCC Web</name>
|
<name>OCC Web</name>
|
||||||
<route>occweb.occ.index</route>
|
<route>extended_occweb.occ.index</route>
|
||||||
<icon>app.svg</icon>
|
<icon>app.svg</icon>
|
||||||
<type>link</type>
|
<type>link</type>
|
||||||
</navigation>
|
</navigation>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 101 KiB |
@@ -6,6 +6,10 @@
|
|||||||
"authors": [
|
"authors": [
|
||||||
{
|
{
|
||||||
"name": "Adphi"
|
"name": "Adphi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dillard Blom",
|
||||||
|
"email": "dillard.blom@ensembia.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"require": {},
|
"require": {},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg height="1024" width="896" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path style="fill:#000000;" d="M831 127H63c-35.35 0-64 28.65-64 64v640c0 35.35 28.65 64 64 64h768c35.35 0 64-28.65 64-64V191C895 155.64999999999998 866.35 127 831 127zM127 575l128-128L127 319l64-64 192 192L191 639 127 575zM639 639H383v-64h256V639z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 328 B |
+5
-4
@@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Post-clone install script for occweb.
|
# Post-clone install script for occweb (Nextcloud app id: extended_occweb).
|
||||||
#
|
#
|
||||||
# Usage (from nextcloud/apps/):
|
# Usage (from nextcloud/apps/) - clone into "extended_occweb", not the repo's
|
||||||
# git clone https://github.com/fanategorius/occweb.git
|
# own name, since Nextcloud requires the app directory to match its id:
|
||||||
# bash occweb/install.sh [nextcloud-root] [web-user]
|
# git clone https://github.com/dillardblom/occweb.git extended_occweb
|
||||||
|
# bash extended_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
|
||||||
#
|
#
|
||||||
|
|||||||
+96
-13
@@ -1,11 +1,42 @@
|
|||||||
(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/extended_occweb');
|
||||||
|
|
||||||
|
// occ output is plain text with ANSI colors, but jQuery Terminal reads
|
||||||
|
// "[" and "]" as its own formatting syntax. Escape the brackets in the
|
||||||
|
// text (e.g. Symfony's "[alias]" in `occ list`) and leave the ANSI
|
||||||
|
// sequences alone, so from_ansi can still turn them into colors. This
|
||||||
|
// only works together with unixFormattingEscapeBrackets (see below).
|
||||||
|
//
|
||||||
|
// Output can contain text from users (display names, file names), so
|
||||||
|
// keep only color codes (SGR) and drop other escape sequences,
|
||||||
|
// backspaces and control characters, which could rewrite or hide text
|
||||||
|
// on screen. A lone CR becomes a newline.
|
||||||
|
function sanitizeOccOutput(output) {
|
||||||
|
return String(output)
|
||||||
|
.replace(/\x1B\][^\x07\x1B]*(\x07|\x1B\\)?/g, '')
|
||||||
|
.replace(/(\x1B\[[0-9;]*m)|\x1B\[[0-?]*[ -\/]*[@-~]|\x1B[@-_]?/g, function (match, sgr) {
|
||||||
|
return sgr || '';
|
||||||
|
})
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F\x7F]/g, '')
|
||||||
|
.replace(/(\x1B\[[0-9;]*m)|[\[\]]/g, function (match, sgr) {
|
||||||
|
return sgr ? sgr : (match === '[' ? '[' : ']');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Current terminal mode: 'occ' (regular occ commands) or 'sql'
|
// Current terminal mode: 'occ' (regular occ commands) or 'sql'
|
||||||
// (running arbitrary SQL queries via /db/query).
|
// (running arbitrary SQL queries via /db/query).
|
||||||
@@ -56,7 +87,8 @@
|
|||||||
|
|
||||||
// Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
|
// Quick client-side check for DELETE/UPDATE — purely for UX (to avoid
|
||||||
// an extra round trip to the server). The backend always makes the
|
// an extra round trip to the server). The backend always makes the
|
||||||
// final call (requiresConfirmation); this is just a hint.
|
// final call (requiresConfirmation) and also covers every other
|
||||||
|
// statement that changes something; 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*/, '');
|
||||||
@@ -171,7 +203,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tabular rendering of SELECT results, psql-style.
|
// Tabular rendering of SELECT results, psql-style.
|
||||||
function renderTable(term, rows) {
|
// Unless full is set, the widest columns are narrowed until the table
|
||||||
|
// fits the terminal, so one long value doesn't wrap the whole table.
|
||||||
|
// Returns true when a value was cut.
|
||||||
|
function renderTable(term, rows, full) {
|
||||||
if (!rows || !rows.length) {
|
if (!rows || !rows.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -202,9 +237,34 @@
|
|||||||
}, col.length);
|
}, col.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var MIN_WIDTH = 10;
|
||||||
|
var cut = false;
|
||||||
|
if (!full) {
|
||||||
|
var available = term.cols() - 1;
|
||||||
|
var total = function () {
|
||||||
|
return widths.reduce(function (sum, w) { return sum + w + 3; }, -1);
|
||||||
|
};
|
||||||
|
while (total() > available) {
|
||||||
|
var widest = widths.indexOf(Math.max.apply(null, widths));
|
||||||
|
if (widths[widest] <= MIN_WIDTH) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
widths[widest] = Math.max(MIN_WIDTH, widths[widest] - (total() - available));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fit(str, width) {
|
||||||
|
str = String(str);
|
||||||
|
if (str.length <= width) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
cut = true;
|
||||||
|
return str.slice(0, width - 1) + '\u2026';
|
||||||
|
}
|
||||||
|
|
||||||
function formatRow(cells) {
|
function formatRow(cells) {
|
||||||
return cells.map(function (cell, i) {
|
return cells.map(function (cell, i) {
|
||||||
return ' ' + pad(cell, widths[i]) + ' ';
|
return ' ' + pad(fit(cell, widths[i]), widths[i]) + ' ';
|
||||||
}).join('|');
|
}).join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +276,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
term.echo($.terminal.escape_formatting(lines.join('\n')));
|
term.echo($.terminal.escape_formatting(lines.join('\n')));
|
||||||
|
return cut;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSqlResponse(term, response) {
|
function renderSqlResponse(term, response, full) {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
term.echo('[[;#ff5555;]Empty response from server]');
|
term.echo('[[;#ff5555;]Empty response from server]');
|
||||||
return;
|
return;
|
||||||
@@ -238,8 +299,8 @@
|
|||||||
term.echo('[[;#ff5555;] Error: ]' + $.terminal.escape_formatting(r.error || 'unknown error'));
|
term.echo('[[;#ff5555;] Error: ]' + $.terminal.escape_formatting(r.error || 'unknown error'));
|
||||||
} else if (r.type === 'select') {
|
} else if (r.type === 'select') {
|
||||||
term.echo('[[;gray;] ' + r.count + ' row(s)]');
|
term.echo('[[;gray;] ' + r.count + ' row(s)]');
|
||||||
if (r.count > 0) {
|
if (r.count > 0 && renderTable(term, r.data, full)) {
|
||||||
renderTable(term, r.data);
|
term.echo('[[;gray;] Long values are cut to fit the terminal. Add --full to the query to see them in full.]');
|
||||||
}
|
}
|
||||||
if (r.truncated) {
|
if (r.truncated) {
|
||||||
term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]');
|
term.echo('[[;yellow;] Result truncated — showing only the first ' + r.count + ' rows, add LIMIT to see more precisely.]');
|
||||||
@@ -287,6 +348,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) {
|
||||||
@@ -294,7 +356,10 @@
|
|||||||
askDeleteConfirmation(term, sql, response.error);
|
askDeleteConfirmation(term, sql, response.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderSqlResponse(term, response);
|
// "--full" is a plain SQL comment, so the database ignores it; string
|
||||||
|
// literals are left out so a value containing it doesn't count
|
||||||
|
var withoutStrings = sql.replace(/'(?:[^']|'')*'/g, "''").replace(/"(?:[^"]|"")*"/g, '""');
|
||||||
|
renderSqlResponse(term, response, /(^|\s)-- ?full\b/.test(withoutStrings));
|
||||||
term.resume();
|
term.resume();
|
||||||
}).fail(function (xhr, status) {
|
}).fail(function (xhr, status) {
|
||||||
if (status === 'timeout') {
|
if (status === 'timeout') {
|
||||||
@@ -307,7 +372,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function askDeleteConfirmation(term, sql, message) {
|
function askDeleteConfirmation(term, sql, message) {
|
||||||
var prompt = '[[;#ff5555;]' + (message || 'This script contains DELETE/UPDATE statement(s).') + ' Type "yes" to run it: ]';
|
var text = message || 'This script contains statement(s) that change data or the schema.';
|
||||||
|
var prompt = '[[;#ff5555;]' + $.terminal.escape_brackets(text) + ' Type "yes" to run it: ]';
|
||||||
term.read(prompt).then(function (answer) {
|
term.read(prompt).then(function (answer) {
|
||||||
if ((answer || '').trim().toLowerCase() === 'yes') {
|
if ((answer || '').trim().toLowerCase() === 'yes') {
|
||||||
sendSqlQuery(term, sql, true);
|
sendSqlQuery(term, sql, true);
|
||||||
@@ -319,7 +385,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();
|
||||||
@@ -375,11 +441,12 @@
|
|||||||
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' + sanitizeOccOutput(response)).resume();
|
||||||
}).fail(function (response, code) {
|
}).fail(function (xhr, status) {
|
||||||
term.echo('\n' + response).resume();
|
term.echo('\n[[;#ff5555;]Request failed: ]' + $.terminal.escape_formatting(xhr.status + ' ' + xhr.statusText)).resume();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -389,6 +456,12 @@
|
|||||||
name: 'occ',
|
name: 'occ',
|
||||||
prompt: OCC_PROMPT,
|
prompt: OCC_PROMPT,
|
||||||
completion: response,
|
completion: response,
|
||||||
|
// The overtyping and from_ansi formatters unescape brackets before
|
||||||
|
// parsing; this makes them escape the text again afterwards.
|
||||||
|
unixFormattingEscapeBrackets: true,
|
||||||
|
// Output can contain user-controlled text; don't turn URLs in it
|
||||||
|
// into links an admin might click by mistake.
|
||||||
|
convertLinks: false,
|
||||||
keydown: function (e) {
|
keydown: function (e) {
|
||||||
// Shift+Enter inserts a newline instead of running the command,
|
// Shift+Enter inserts a newline instead of running the command,
|
||||||
// letting you type multi-line SQL scripts in sql mode.
|
// letting you type multi-line SQL scripts in sql mode.
|
||||||
@@ -401,6 +474,16 @@
|
|||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}).fail(function (xhr, status) {
|
||||||
|
// Without this, a failure here (expired session/CSRF token, network
|
||||||
|
// error, a rejected admin check) leaves #app-content permanently
|
||||||
|
// empty with no visible error and nothing in the console pointing
|
||||||
|
// at the cause - exactly what happened during NC34 testing before
|
||||||
|
// this handler existed.
|
||||||
|
$('#app-content').text(
|
||||||
|
'Failed to load the terminal: ' + xhr.status + ' ' + xhr.statusText +
|
||||||
|
'. Try reloading the page; if that keeps happening, check the server logs.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
$('html').keypress(function(){
|
$('html').keypress(function(){
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
|
|||||||
Vendored
+2
File diff suppressed because one or more lines are too long
+38
-158
@@ -3,6 +3,7 @@
|
|||||||
namespace OCA\OCCWeb\Controller;
|
namespace OCA\OCCWeb\Controller;
|
||||||
|
|
||||||
use OC;
|
use OC;
|
||||||
|
use OCA\OCCWeb\Service\SqlGuard;
|
||||||
use OCP\AppFramework\Controller;
|
use OCP\AppFramework\Controller;
|
||||||
use OCP\IRequest;
|
use OCP\IRequest;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
@@ -19,26 +20,6 @@ class DbController extends Controller
|
|||||||
/** Maximum size of submitted SQL text in bytes (protection against 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
|
|
||||||
* 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 (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() (connects to arbitrary databases)',
|
|
||||||
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
|
|
||||||
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
|
|
||||||
];
|
|
||||||
|
|
||||||
private $db;
|
private $db;
|
||||||
private $groupManager;
|
private $groupManager;
|
||||||
private $userSession;
|
private $userSession;
|
||||||
@@ -60,109 +41,14 @@ class DbController extends Controller
|
|||||||
$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
|
|
||||||
* next query and interfere with detecting its type (SELECT/SET/DELETE).
|
|
||||||
*/
|
|
||||||
private function stripLeadingComments($query)
|
|
||||||
{
|
|
||||||
$query = ltrim($query);
|
|
||||||
while (preg_match('/^--[^\n]*\n/', $query)) {
|
|
||||||
$query = ltrim(preg_replace('/^--[^\n]*\n/', '', $query, 1));
|
|
||||||
}
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
{
|
|
||||||
$query = preg_replace('/--[^\n]*/', '', $query);
|
|
||||||
$query = preg_replace('/\/\*[\s\S]*?\*\//', '', $query);
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
{
|
|
||||||
$statements = [];
|
|
||||||
$current = '';
|
|
||||||
$len = strlen($sql);
|
|
||||||
$quoteChar = null;
|
|
||||||
|
|
||||||
for ($i = 0; $i < $len; $i++) {
|
|
||||||
$ch = $sql[$i];
|
|
||||||
|
|
||||||
if ($quoteChar !== null) {
|
|
||||||
if ($ch === $quoteChar) {
|
|
||||||
if ($i + 1 < $len && $sql[$i + 1] === $quoteChar) {
|
|
||||||
$current .= $quoteChar . $quoteChar;
|
|
||||||
$i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$quoteChar = null;
|
|
||||||
}
|
|
||||||
$current .= $ch;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ch === "'" || $ch === '"') {
|
|
||||||
$quoteChar = $ch;
|
|
||||||
$current .= $ch;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ch === ';') {
|
|
||||||
$statements[] = trim($current);
|
|
||||||
$current = '';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$current .= $ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($current) !== '') {
|
|
||||||
$statements[] = trim($current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($statements, function ($s) {
|
|
||||||
return $s !== '';
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
{
|
|
||||||
$clean = $this->removeAllComments($query);
|
|
||||||
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
|
|
||||||
if (preg_match($pattern, $clean)) {
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function query()
|
public function query()
|
||||||
{
|
{
|
||||||
// Check admin privileges
|
// Deliberate defense-in-depth (see the equivalent check in
|
||||||
|
// OccController::requireAdmin() for why it's kept even though
|
||||||
|
// Nextcloud's SecurityMiddleware already enforces this by default
|
||||||
|
// absent @NoAdminRequired) - this one is also the direct fix for
|
||||||
|
// this controller having shipped with @PublicPage originally,
|
||||||
|
// which did bypass that default protection entirely.
|
||||||
$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);
|
||||||
@@ -189,8 +75,8 @@ class DbController extends Controller
|
|||||||
// Audit: log the fact that execution was attempted BEFORE any checks,
|
// Audit: log the fact that execution was attempted BEFORE any checks,
|
||||||
// so blocked/rejected queries are recorded in the log too, not just
|
// so blocked/rejected queries are recorded in the log too, not just
|
||||||
// the ones that succeeded.
|
// the ones that succeeded.
|
||||||
$this->logger->warning('[occweb] SQL submitted by {user}: {sql}', [
|
$this->logger->warning('[extended_occweb] SQL submitted by {user}: {sql}', [
|
||||||
'app' => 'occweb',
|
'app' => 'extended_occweb',
|
||||||
'user' => $user->getUID(),
|
'user' => $user->getUID(),
|
||||||
'sql' => $sql,
|
'sql' => $sql,
|
||||||
]);
|
]);
|
||||||
@@ -199,16 +85,16 @@ class DbController extends Controller
|
|||||||
// All queries run sequentially on the same DB connection (within a
|
// All queries run sequentially on the same DB connection (within a
|
||||||
// single HTTP request), so SET retains its value for
|
// single HTTP request), so SET retains its value for
|
||||||
// current_setting() in the following queries of the same batch.
|
// current_setting() in the following queries of the same batch.
|
||||||
$queries = $this->splitStatements($sql);
|
$queries = SqlGuard::splitStatements($sql);
|
||||||
|
|
||||||
// Access to the server's filesystem / running programs via SQL is
|
// Access to the server's filesystem / running programs via SQL is
|
||||||
// blocked entirely — this isn't about data loss (like DELETE), it's
|
// blocked entirely — this isn't about data loss (like DELETE), it's
|
||||||
// about potential server takeover, and confirmation doesn't bypass it.
|
// about potential server takeover, and confirmation doesn't bypass it.
|
||||||
foreach ($queries as $query) {
|
foreach ($queries as $query) {
|
||||||
$forbidden = $this->findForbiddenConstruct($query);
|
$forbidden = SqlGuard::findForbiddenConstruct($query);
|
||||||
if ($forbidden !== null) {
|
if ($forbidden !== null) {
|
||||||
$this->logger->error('[occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
|
$this->logger->error('[extended_occweb] Blocked forbidden construct ({construct}) from {user}: {sql}', [
|
||||||
'app' => 'occweb',
|
'app' => 'extended_occweb',
|
||||||
'construct' => $forbidden,
|
'construct' => $forbidden,
|
||||||
'user' => $user->getUID(),
|
'user' => $user->getUID(),
|
||||||
'sql' => $sql,
|
'sql' => $sql,
|
||||||
@@ -220,34 +106,29 @@ class DbController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE and UPDATE are irreversible (or hard to reverse), so we
|
// Anything that isn't a plain read (DELETE, UPDATE, INSERT, DROP,
|
||||||
// require explicit confirmation from the client (confirm=true) before
|
// a data-modifying CTE, ...) needs explicit confirmation from the
|
||||||
// executing a single query from the batch.
|
// client (confirm=true) before a single query of the batch runs.
|
||||||
$deleteCount = 0;
|
$writeTypes = [];
|
||||||
$updateCount = 0;
|
|
||||||
foreach ($queries as $query) {
|
foreach ($queries as $query) {
|
||||||
$normalized = $this->stripLeadingComments($query);
|
if (!SqlGuard::isReadOnly($query)) {
|
||||||
if (stripos($normalized, 'DELETE') === 0) {
|
$type = SqlGuard::statementType($query) ?: 'UNKNOWN';
|
||||||
$deleteCount++;
|
$writeTypes[$type] = ($writeTypes[$type] ?? 0) + 1;
|
||||||
} elseif (stripos($normalized, 'UPDATE') === 0) {
|
|
||||||
$updateCount++;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($deleteCount > 0 || $updateCount > 0) && !$confirmed) {
|
if ($writeTypes !== [] && !$confirmed) {
|
||||||
$parts = [];
|
$parts = [];
|
||||||
if ($deleteCount > 0) {
|
foreach ($writeTypes as $type => $count) {
|
||||||
$parts[] = "{$deleteCount} DELETE";
|
$parts[] = "{$count} {$type}";
|
||||||
}
|
|
||||||
if ($updateCount > 0) {
|
|
||||||
$parts[] = "{$updateCount} UPDATE";
|
|
||||||
}
|
}
|
||||||
return new JSONResponse([
|
return new JSONResponse([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'requiresConfirmation' => true,
|
'requiresConfirmation' => true,
|
||||||
'deleteCount' => $deleteCount,
|
'deleteCount' => $writeTypes['DELETE'] ?? 0,
|
||||||
'updateCount' => $updateCount,
|
'updateCount' => $writeTypes['UPDATE'] ?? 0,
|
||||||
'error' => 'Batch contains ' . implode(' and ', $parts) . ' statement(s) and was not executed. Resend with confirm=true to proceed.'
|
'writeCount' => array_sum($writeTypes),
|
||||||
|
'error' => 'Batch contains statement(s) that change data or the schema (' . implode(', ', $parts) . ') and was not executed. Resend with confirm=true to proceed.'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,24 +158,23 @@ class DbController extends Controller
|
|||||||
$this->db->beginTransaction();
|
$this->db->beginTransaction();
|
||||||
$transactionStarted = true;
|
$transactionStarted = true;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->warning('[occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
|
$this->logger->warning('[extended_occweb] beginTransaction() failed, proceeding without an explicit transaction: {error}', [
|
||||||
'app' => 'occweb',
|
'app' => 'extended_occweb',
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($queries as $query) {
|
foreach ($queries as $query) {
|
||||||
$normalized = $this->stripLeadingComments($query);
|
$statementType = SqlGuard::statementType($query);
|
||||||
$isSelect = stripos($normalized, 'SELECT') === 0;
|
$isSet = $statementType === 'SET';
|
||||||
$isSet = stripos($normalized, 'SET ') === 0;
|
$isDelete = $statementType === 'DELETE';
|
||||||
$isDelete = stripos($normalized, 'DELETE') === 0;
|
$isUpdate = $statementType === 'UPDATE';
|
||||||
$isUpdate = stripos($normalized, 'UPDATE') === 0;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$stmt = $this->db->prepare($query);
|
$stmt = $this->db->prepare($query);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|
||||||
if ($isSelect) {
|
if ($statementType === 'SELECT' || ($statementType !== 'SET' && SqlGuard::isReadOnly($query))) {
|
||||||
// Read row by row and stop at MAX_ROWS, instead of
|
// Read row by row and stop at MAX_ROWS, instead of
|
||||||
// fetchAll() + array_slice — otherwise a SELECT without
|
// fetchAll() + array_slice — otherwise a SELECT without
|
||||||
// LIMIT on a huge table would still pull everything into
|
// LIMIT on a huge table would still pull everything into
|
||||||
@@ -353,8 +233,8 @@ class DbController extends Controller
|
|||||||
// permanently applied — we deliberately do NOT set
|
// permanently applied — we deliberately do NOT set
|
||||||
// rolledBack to true, since that would be untrue.
|
// 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('[extended_occweb] rollBack() failed after an error — earlier statements in this batch may already be permanently applied: {error}', [
|
||||||
'app' => 'occweb',
|
'app' => 'extended_occweb',
|
||||||
'error' => $rollbackError->getMessage(),
|
'error' => $rollbackError->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -371,8 +251,8 @@ class DbController extends Controller
|
|||||||
// a DDL statement on MySQL/MariaDB) — the effects are
|
// a DDL statement on MySQL/MariaDB) — the effects are
|
||||||
// already saved, this isn't an error in executing the batch
|
// already saved, this isn't an error in executing the batch
|
||||||
// itself.
|
// itself.
|
||||||
$this->logger->info('[occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
|
$this->logger->info('[extended_occweb] commit() had nothing to commit (likely auto-committed by a DDL statement): {error}', [
|
||||||
'app' => 'occweb',
|
'app' => 'extended_occweb',
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace OCA\OCCWeb\Controller;
|
|||||||
use Exception;
|
use Exception;
|
||||||
use OC;
|
use OC;
|
||||||
use OC\Console\Application;
|
use OC\Console\Application;
|
||||||
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;
|
||||||
@@ -21,6 +20,7 @@ class OccController extends Controller
|
|||||||
private $userId;
|
private $userId;
|
||||||
|
|
||||||
private $application;
|
private $application;
|
||||||
|
private $fakeRequest;
|
||||||
private $symphonyApplication;
|
private $symphonyApplication;
|
||||||
private $output;
|
private $output;
|
||||||
|
|
||||||
@@ -30,16 +30,8 @@ class OccController extends Controller
|
|||||||
$this->logger = OC::$server->get(LoggerInterface::class);
|
$this->logger = OC::$server->get(LoggerInterface::class);
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
|
|
||||||
$this->application = new Application(
|
$this->fakeRequest = new FakeRequest();
|
||||||
OC::$server->get(\OCP\ServerVersion::class),
|
$this->application = $this->createConsoleApplication($this->fakeRequest);
|
||||||
OC::$server->get(\OCP\IConfig::class),
|
|
||||||
OC::$server->get(\OCP\EventDispatcher\IEventDispatcher::class),
|
|
||||||
new FakeRequest(),
|
|
||||||
$this->logger,
|
|
||||||
OC::$server->query(MemoryInfo::class),
|
|
||||||
OC::$server->get(\OCP\App\IAppManager::class), // Obtain the IAppManager
|
|
||||||
OC::$server->get(\OCP\Defaults::class)
|
|
||||||
);
|
|
||||||
$this->application->setAutoExit(false);
|
$this->application->setAutoExit(false);
|
||||||
$this->output = new OccOutput(OutputInterface::VERBOSITY_NORMAL, true);
|
$this->output = new OccOutput(OutputInterface::VERBOSITY_NORMAL, true);
|
||||||
$this->application->loadCommands(new StringInput(""), $this->output);
|
$this->application->loadCommands(new StringInput(""), $this->output);
|
||||||
@@ -50,8 +42,17 @@ class OccController extends Controller
|
|||||||
|
|
||||||
private function requireAdmin(): ?JSONResponse
|
private function requireAdmin(): ?JSONResponse
|
||||||
{
|
{
|
||||||
// Services via OC::$server zodat de constructor-signatuur ongewijzigd blijft
|
// Deliberate defense-in-depth, not a workaround for a missing framework
|
||||||
// en NC's DI-container (zonder application.php) de class kan resolven.
|
// check: Nextcloud's own SecurityMiddleware already rejects non-admins
|
||||||
|
// here by default (no method below has @NoAdminRequired), so this is
|
||||||
|
// redundant today. Kept anyway given what this app can do (arbitrary
|
||||||
|
// occ/SQL execution) - if the framework check ever regresses, this
|
||||||
|
// still holds. If you ever want to intentionally open one of these
|
||||||
|
// routes to non-admins, remove the call below AND add
|
||||||
|
// @NoAdminRequired, or this will keep silently blocking it.
|
||||||
|
//
|
||||||
|
// Services via OC::$server so the constructor signature stays unchanged
|
||||||
|
// and NC's DI container (without an application.php) can resolve the class.
|
||||||
$userSession = OC::$server->get(\OCP\IUserSession::class);
|
$userSession = OC::$server->get(\OCP\IUserSession::class);
|
||||||
$user = $userSession->getUser();
|
$user = $userSession->getUser();
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
@@ -70,7 +71,25 @@ class OccController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
if ($err = $this->requireAdmin()) return $err;
|
if ($err = $this->requireAdmin()) return $err;
|
||||||
return new TemplateResponse('occweb', 'index');
|
return new TemplateResponse('extended_occweb', 'index');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds Nextcloud's console application with its dependencies from the
|
||||||
|
* container, except the request: the console reads argv from it for the
|
||||||
|
* ConsoleEvent (used by admin_audit), which a web request doesn't have.
|
||||||
|
* Resolving the arguments by type keeps this working when Nextcloud
|
||||||
|
* changes the constructor, which it has done several times.
|
||||||
|
*/
|
||||||
|
private function createConsoleApplication(IRequest $request): Application
|
||||||
|
{
|
||||||
|
$arguments = [];
|
||||||
|
foreach ((new \ReflectionClass(Application::class))->getConstructor()->getParameters() as $parameter) {
|
||||||
|
$type = $parameter->getType();
|
||||||
|
$class = $type instanceof \ReflectionNamedType ? $type->getName() : null;
|
||||||
|
$arguments[] = $class === IRequest::class ? $request : OC::$server->get($class);
|
||||||
|
}
|
||||||
|
return new Application(...$arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +101,7 @@ class OccController extends Controller
|
|||||||
try {
|
try {
|
||||||
$this->application->run($input, $this->output);
|
$this->application->run($input, $this->output);
|
||||||
return $this->output->fetch();
|
return $this->output->fetch();
|
||||||
} catch (Exception $ex) {
|
} catch (\Throwable $ex) {
|
||||||
$this->logger->error($ex->getMessage(), ['exception' => $ex]);
|
$this->logger->error($ex->getMessage(), ['exception' => $ex]);
|
||||||
return "error: " . $ex->getMessage();
|
return "error: " . $ex->getMessage();
|
||||||
}
|
}
|
||||||
@@ -95,7 +114,15 @@ class OccController extends Controller
|
|||||||
public function cmd($command)
|
public function cmd($command)
|
||||||
{
|
{
|
||||||
if ($err = $this->requireAdmin()) return $err;
|
if ($err = $this->requireAdmin()) return $err;
|
||||||
$this->logger->debug($command);
|
// The regular log gets only the command name: arguments can hold
|
||||||
|
// secrets (config:system:set --value=...). The full command goes to
|
||||||
|
// admin_audit through the ConsoleEvent, like occ on the CLI.
|
||||||
|
$this->logger->warning('[extended_occweb] occ command by {user}: {command}', [
|
||||||
|
'app' => 'extended_occweb',
|
||||||
|
'user' => $this->userId,
|
||||||
|
'command' => strtok(trim((string) $command), " \t\n") ?: '(empty)',
|
||||||
|
]);
|
||||||
|
$this->fakeRequest->server['argv'] = ['occ', $command];
|
||||||
$input = new StringInput($command);
|
$input = new StringInput($command);
|
||||||
$response = $this->run($input);
|
$response = $this->run($input);
|
||||||
$this->logger->debug($response);
|
$this->logger->debug($response);
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ class OccOutput extends BufferedOutput implements ConsoleOutputInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setErrorOutput(OutputInterface $error)
|
// NC35 bundles a Symfony Console version whose ConsoleOutputInterface
|
||||||
|
// declares this method `: void` - PHP 8 fatals ("Declaration must be
|
||||||
|
// compatible") without a matching return type here. Declaring `: void`
|
||||||
|
// is a valid narrowing of the untyped signature older Symfony/NC
|
||||||
|
// versions have, so this stays compatible with NC30-34 too.
|
||||||
|
public function setErrorOutput(OutputInterface $error): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\OCCWeb\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks run on SQL submitted through the SQL mode before it is executed.
|
||||||
|
*
|
||||||
|
* This is defense in depth, not a sandbox: dynamic SQL (EXECUTE with a
|
||||||
|
* built-up string, functions) can always get around a text-based check.
|
||||||
|
* The real protection is a database role without superuser rights,
|
||||||
|
* pg_execute_server_program or pg_read/write_server_files.
|
||||||
|
*/
|
||||||
|
class SqlGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
private const FORBIDDEN_PATTERNS = [
|
||||||
|
'/\bCOPY\b[\s\S]*\bPROGRAM\b/i' => 'COPY ... PROGRAM (runs external commands)',
|
||||||
|
// A COPY file name is a string literal: 'x', E'x', U&'x' or $$x$$
|
||||||
|
'/\bCOPY\b[\s\S]*\b(FROM|TO)\s+(E|U&)?(\'|\$)/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\w*\s*\(/i' => 'dblink functions (connect to arbitrary databases)',
|
||||||
|
'/\bLOAD_FILE\s*\(/i' => 'LOAD_FILE()',
|
||||||
|
'/\bINTO\s+(OUTFILE|DUMPFILE)\b/i' => 'INTO OUTFILE/DUMPFILE',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statements that start with one of these only read, unless
|
||||||
|
* WRITES_INSIDE_READ matches. SQLite's PRAGMA is left out on purpose:
|
||||||
|
* it can also change settings, so it asks for confirmation.
|
||||||
|
*/
|
||||||
|
private const READ_ONLY_START = '/^(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC|SET|VALUES|TABLE)\b/i';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What makes a statement that starts as a read change something:
|
||||||
|
* a data-modifying CTE (WITH x AS (DELETE ...)), SELECT ... INTO,
|
||||||
|
* SELECT ... FOR UPDATE, EXPLAIN ANALYZE (runs the statement),
|
||||||
|
* SET ROLE / SET SESSION AUTHORIZATION, MySQL's SET PASSWORD /
|
||||||
|
* GLOBAL / PERSIST, and functions with side
|
||||||
|
* effects. Statements that don't start as a read always need
|
||||||
|
* confirmation, so INSERT, DROP, CREATE etc. don't need to be here.
|
||||||
|
*/
|
||||||
|
private const WRITES_INSIDE_READ = [
|
||||||
|
'/\b(INSERT|UPDATE|DELETE|MERGE|INTO|ANALYZE)\b/i',
|
||||||
|
'/^SET\s+(SESSION\s+|LOCAL\s+)?(ROLE|SESSION\s+AUTHORIZATION)\b/i',
|
||||||
|
'/^SET\s+(PASSWORD|GLOBAL|PERSIST|PERSIST_ONLY)\b|^SET\s+@@(GLOBAL|PERSIST|PERSIST_ONLY)\./i',
|
||||||
|
'/\b(pg_terminate_backend|pg_cancel_backend|pg_reload_conf|set_config|setval|nextval|lo_create|lo_put|lo_unlink|pg_switch_wal|pg_create_restore_point|pg_advisory_lock)\s*\(/i',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a batch on ";" outside of 'strings', "identifiers",
|
||||||
|
* $tag$dollar-quoted bodies$tag$ and comments, so a semicolon in a
|
||||||
|
* DO block or a quote in a comment doesn't break up the split.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public static function splitStatements(string $sql): array
|
||||||
|
{
|
||||||
|
$statements = [];
|
||||||
|
$current = '';
|
||||||
|
$len = strlen($sql);
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
while ($i < $len) {
|
||||||
|
$end = self::literalEnd($sql, $i) ?? self::commentEnd($sql, $i);
|
||||||
|
if ($end !== null) {
|
||||||
|
$current .= substr($sql, $i, $end - $i);
|
||||||
|
$i = $end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($sql[$i] === ';') {
|
||||||
|
$statements[] = trim($current);
|
||||||
|
$current = '';
|
||||||
|
} else {
|
||||||
|
$current .= $sql[$i];
|
||||||
|
}
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$statements[] = trim($current);
|
||||||
|
|
||||||
|
// A trailing comment ("SELECT 1; -- note") is not a statement
|
||||||
|
return array_values(array_filter($statements, function ($s) {
|
||||||
|
return trim(self::removeComments($s)) !== '';
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces comments with a space, outside of literals only. A comment
|
||||||
|
* separates tokens, so dropping it without a space would glue
|
||||||
|
* "TO/ ** /PROGRAM" into one word; and a comment marker inside a
|
||||||
|
* literal ('/*') must not swallow the code after it.
|
||||||
|
*/
|
||||||
|
public static function removeComments(string $query): string
|
||||||
|
{
|
||||||
|
return self::scan($query, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also replaces string literals and quoted identifiers with a
|
||||||
|
* placeholder, so words inside them ('deleted', "update") don't count
|
||||||
|
* as keywords.
|
||||||
|
*/
|
||||||
|
public static function removeLiterals(string $query): string
|
||||||
|
{
|
||||||
|
return self::scan($query, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function scan(string $sql, bool $replaceLiterals): string
|
||||||
|
{
|
||||||
|
$result = '';
|
||||||
|
$len = strlen($sql);
|
||||||
|
$i = 0;
|
||||||
|
|
||||||
|
while ($i < $len) {
|
||||||
|
$end = self::literalEnd($sql, $i);
|
||||||
|
if ($end !== null) {
|
||||||
|
$literal = substr($sql, $i, $end - $i);
|
||||||
|
if (preg_match('/^"([A-Za-z_][A-Za-z0-9_$]*)"$/', $literal, $m) && preg_match('/\G(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*\(/', $sql, $unused, 0, $end)) {
|
||||||
|
// A quoted function name ("setval"(...)) calls the same
|
||||||
|
// function as the bare word, so check it as one
|
||||||
|
$result .= $m[1];
|
||||||
|
} else {
|
||||||
|
$result .= $replaceLiterals ? ($sql[$i] === '"' ? '""' : "''") : $literal;
|
||||||
|
}
|
||||||
|
$i = $end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$end = self::commentEnd($sql, $i);
|
||||||
|
if ($end !== null) {
|
||||||
|
$result .= ' ';
|
||||||
|
$i = $end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$result .= $sql[$i];
|
||||||
|
$i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End offset of the 'string', E'string', "identifier" or $tag$body$tag$
|
||||||
|
* starting at $i, or null if none starts there. An unclosed literal
|
||||||
|
* runs to the end.
|
||||||
|
*/
|
||||||
|
private static function literalEnd(string $sql, int $i): ?int
|
||||||
|
{
|
||||||
|
$len = strlen($sql);
|
||||||
|
$ch = $sql[$i];
|
||||||
|
|
||||||
|
if ($ch === "'" || $ch === '"') {
|
||||||
|
$backslashEscapes = $ch === "'" && $i > 0 && ($sql[$i - 1] === 'E' || $sql[$i - 1] === 'e')
|
||||||
|
&& ($i === 1 || !preg_match('/[A-Za-z0-9_]/', $sql[$i - 2]));
|
||||||
|
$end = $i + 1;
|
||||||
|
while ($end < $len) {
|
||||||
|
if ($backslashEscapes && $sql[$end] === '\\') {
|
||||||
|
$end += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($sql[$end] === $ch) {
|
||||||
|
if ($end + 1 < $len && $sql[$end + 1] === $ch) {
|
||||||
|
$end += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return $end + 1;
|
||||||
|
}
|
||||||
|
$end++;
|
||||||
|
}
|
||||||
|
return $len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ch === '$' && preg_match('/\G\$([A-Za-z_][A-Za-z0-9_]*)?\$/', $sql, $m, 0, $i)) {
|
||||||
|
$close = strpos($sql, $m[0], $i + strlen($m[0]));
|
||||||
|
return $close === false ? $len : $close + strlen($m[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End offset of the -- or block comment starting at $i, or null.
|
||||||
|
*/
|
||||||
|
private static function commentEnd(string $sql, int $i): ?int
|
||||||
|
{
|
||||||
|
if (substr($sql, $i, 2) === '--') {
|
||||||
|
$newline = strpos($sql, "\n", $i);
|
||||||
|
return $newline === false ? strlen($sql) : $newline;
|
||||||
|
}
|
||||||
|
if (substr($sql, $i, 2) === '/*') {
|
||||||
|
$close = strpos($sql, '*/', $i + 2);
|
||||||
|
return $close === false ? strlen($sql) : $close + 2;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a description of the forbidden construct found, or null.
|
||||||
|
* Literals are kept here on purpose: a file name is a literal.
|
||||||
|
*/
|
||||||
|
public static function findForbiddenConstruct(string $query): ?string
|
||||||
|
{
|
||||||
|
$clean = self::removeComments($query);
|
||||||
|
foreach (self::FORBIDDEN_PATTERNS as $pattern => $label) {
|
||||||
|
if (preg_match($pattern, $clean)) {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first keyword of a statement, upper case, e.g. SELECT or DELETE.
|
||||||
|
*/
|
||||||
|
public static function statementType(string $query): string
|
||||||
|
{
|
||||||
|
$clean = ltrim(self::removeComments($query), " \t\n\r\0\x0B(");
|
||||||
|
return preg_match('/^([A-Za-z]+)/', $clean, $m) ? strtoupper($m[1]) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a statement only reads. Anything else needs confirmation.
|
||||||
|
*/
|
||||||
|
public static function isReadOnly(string $query): bool
|
||||||
|
{
|
||||||
|
$clean = ltrim(self::removeLiterals(self::removeComments($query)), " \t\n\r\0\x0B(");
|
||||||
|
if (preg_match(self::READ_ONLY_START, $clean) !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
foreach (self::WRITES_INSIDE_READ as $pattern) {
|
||||||
|
if (preg_match($pattern, $clean) === 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-4
@@ -1,8 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
script('occweb', 'index');
|
// Nextcloud's global jQuery has been deprecated since NC19 and is no longer
|
||||||
script('occweb', 'term');
|
// reliably present on NC34+ (see e.g. serverinfo needing to bundle its own) -
|
||||||
script('occweb', 'unix_formatting');
|
// term.js (jquery.terminal) and index.js both require jQuery to be loaded
|
||||||
style('occweb', 'style');
|
// first, so ship and load our own copy where core no longer provides one.
|
||||||
|
//
|
||||||
|
// Only do this on NC34+: on NC32/33 core still exposes a working jQuery, but
|
||||||
|
// via a read-only `window.$` getter - loading our vendored copy there too
|
||||||
|
// makes its UMD wrapper throw trying to reassign `window.$` (confirmed via
|
||||||
|
// testbak/, see the README compat matrix). Harmless in practice (this app
|
||||||
|
// attaches via window.jQuery, not window.$) but a real, avoidable console
|
||||||
|
// exception, so skip the extra load where core's own jQuery already works.
|
||||||
|
if (\OCP\Server::get(\OCP\ServerVersion::class)->getMajorVersion() >= 34) {
|
||||||
|
script('extended_occweb', 'jquery');
|
||||||
|
}
|
||||||
|
script('extended_occweb', 'index');
|
||||||
|
script('extended_occweb', 'term');
|
||||||
|
script('extended_occweb', 'unix_formatting');
|
||||||
|
style('extended_occweb', 'style');
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div id="app" class="full-width">
|
<div id="app" class="full-width">
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace OCA\OCCWeb\Tests\Unit\Service;
|
||||||
|
|
||||||
|
use OCA\OCCWeb\Service\SqlGuard;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SqlGuardTest extends TestCase
|
||||||
|
{
|
||||||
|
public static function forbiddenProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["COPY (SELECT 1) TO PROGRAM 'sh'"],
|
||||||
|
["COPY (SELECT 'id') TO/**/PROGRAM 'sh'"],
|
||||||
|
["COPY (SELECT 1) TO--x\nPROGRAM 'sh'"],
|
||||||
|
["COPY (SELECT 1) TO '/tmp/x'"],
|
||||||
|
["COPY (SELECT current_user) TO/**/'/tmp/x'"],
|
||||||
|
["COPY (SELECT 1) TO E'/tmp/x'"],
|
||||||
|
["COPY (SELECT 1) TO \$\$/tmp/x\$\$"],
|
||||||
|
["COPY (SELECT 1) TO \$f\$/tmp/x\$f\$"],
|
||||||
|
["COPY oc_users FROM '/etc/passwd'"],
|
||||||
|
["SELECT pg_read_file/**/('/etc/passwd')"],
|
||||||
|
["SELECT pg_ls_dir('/')"],
|
||||||
|
["SELECT lo_import('/etc/passwd')"],
|
||||||
|
["SELECT dblink_connect('host=x')"],
|
||||||
|
["SELECT dblink_exec('host=x', 'DROP TABLE t')"],
|
||||||
|
["SELECT \"pg_read_file\"('/etc/passwd')"],
|
||||||
|
["SELECT pg_catalog.\"pg_read_file\"('/etc/passwd')"],
|
||||||
|
["SELECT \"pg_read_file\" /* x */ ('/etc/passwd')"],
|
||||||
|
["SELECT \"pg_read_file\"-- x\n('/etc/passwd')"],
|
||||||
|
["SELECT dblink_send_query('c', 'SELECT 1')"],
|
||||||
|
["SELECT * FROM dblink('host=x', 'SELECT 1') AS t(a int)"],
|
||||||
|
["SELECT * FROM t INTO OUTFILE '/tmp/x'"],
|
||||||
|
["COPY (SELECT '/*') TO PROGRAM 'sh' --*/"],
|
||||||
|
["COPY (SELECT '--') TO PROGRAM 'sh'"],
|
||||||
|
["COPY (SELECT \$\$/*\$\$) TO PROGRAM 'sh' --*/"],
|
||||||
|
["COPY (SELECT \"/*\") TO PROGRAM 'sh' --*/"],
|
||||||
|
["COPY (SELECT E'\\'/*') TO PROGRAM 'sh' --*/"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider forbiddenProvider
|
||||||
|
*/
|
||||||
|
public function testForbidden(string $sql): void
|
||||||
|
{
|
||||||
|
$this->assertNotNull(SqlGuard::findForbiddenConstruct($sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allowedProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['SELECT * FROM oc_users'],
|
||||||
|
['COPY (SELECT 1) TO STDOUT'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider allowedProvider
|
||||||
|
*/
|
||||||
|
public function testAllowed(string $sql): void
|
||||||
|
{
|
||||||
|
$this->assertNull(SqlGuard::findForbiddenConstruct($sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function readOnlyProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['SELECT * FROM oc_users'],
|
||||||
|
[' -- comment' . "\n" . 'SELECT 1'],
|
||||||
|
['/* comment */ SELECT 1'],
|
||||||
|
['(SELECT 1) UNION (SELECT 2)'],
|
||||||
|
['WITH x AS (SELECT 1) SELECT * FROM x'],
|
||||||
|
["SELECT * FROM oc_jobs WHERE class = 'OCA\\\\Mail\\\\DeleteJob'"],
|
||||||
|
["SELECT * FROM t WHERE status = 'deleted' OR note = 'update into'"],
|
||||||
|
['SELECT "update" FROM t'],
|
||||||
|
["SELECT replace(name, 'a', 'b') FROM t"],
|
||||||
|
['SHOW search_path'],
|
||||||
|
['EXPLAIN SELECT 1'],
|
||||||
|
["SET myvar.uid = 'alice'"],
|
||||||
|
['SET @uid = 5'],
|
||||||
|
['SET SESSION sql_mode = \'\''],
|
||||||
|
["SELECT \$\$DELETE FROM x\$\$"],
|
||||||
|
["SELECT E'it\\'s delete' FROM t"],
|
||||||
|
['VALUES (1)'],
|
||||||
|
['DESCRIBE oc_users'],
|
||||||
|
['DESC oc_users'],
|
||||||
|
["SELECT '/* not a comment */' AS a, '-- nor this' AS b"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider readOnlyProvider
|
||||||
|
*/
|
||||||
|
public function testReadOnly(string $sql): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(SqlGuard::isReadOnly($sql), $sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function writeProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['DELETE FROM oc_preferences'],
|
||||||
|
['/* not a leading -- comment */ DELETE FROM oc_preferences'],
|
||||||
|
["-- x\nUPDATE t SET a = 1"],
|
||||||
|
['WITH removed AS (DELETE FROM oc_preferences RETURNING *) SELECT count(*) FROM removed'],
|
||||||
|
['WITH x AS (UPDATE t SET a = 1 RETURNING *) SELECT 1'],
|
||||||
|
['INSERT INTO t VALUES (1)'],
|
||||||
|
['TRUNCATE oc_preferences'],
|
||||||
|
['DROP TABLE t'],
|
||||||
|
['ALTER TABLE t ADD c int'],
|
||||||
|
['CREATE TABLE t (a int)'],
|
||||||
|
['GRANT ALL ON t TO x'],
|
||||||
|
["DO \$\$BEGIN PERFORM 1; END\$\$"],
|
||||||
|
['CALL p()'],
|
||||||
|
['SELECT * INTO newtable FROM t'],
|
||||||
|
['SELECT * FROM t FOR UPDATE'],
|
||||||
|
['EXPLAIN ANALYZE DELETE FROM t'],
|
||||||
|
['SET ROLE postgres'],
|
||||||
|
['SET SESSION AUTHORIZATION postgres'],
|
||||||
|
["SET PASSWORD = 'x'"],
|
||||||
|
['SET GLOBAL max_connections = 10'],
|
||||||
|
['SET PERSIST max_connections = 10'],
|
||||||
|
['SET @@GLOBAL.max_connections = 10'],
|
||||||
|
['SELECT pg_terminate_backend(123)'],
|
||||||
|
["SELECT set_config('x', 'y', false)"],
|
||||||
|
["SELECT setval('seq', 1)"],
|
||||||
|
["SELECT nextval('seq')"],
|
||||||
|
["SELECT \"setval\"('seq', 1)"],
|
||||||
|
["SELECT pg_catalog.\"setval\"('seq', 1)"],
|
||||||
|
["SELECT pg_catalog.setval('seq', 1)"],
|
||||||
|
["SELECT \"setval\" /* x */ ('seq', 1)"],
|
||||||
|
['MERGE INTO t USING s ON true WHEN MATCHED THEN DELETE'],
|
||||||
|
['VACUUM'],
|
||||||
|
['PRAGMA journal_mode = WAL'],
|
||||||
|
["WITH a AS (SELECT '/*'), b AS (DELETE FROM t RETURNING 1) SELECT '*/'"],
|
||||||
|
["SELECT '--' AS x FROM t WHERE a IN (SELECT 1) FOR UPDATE"],
|
||||||
|
["WITH a AS (SELECT \$\$/*\$\$), b AS (DELETE FROM t RETURNING 1) SELECT '*/'"],
|
||||||
|
['', ],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider writeProvider
|
||||||
|
*/
|
||||||
|
public function testNeedsConfirmation(string $sql): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(SqlGuard::isReadOnly($sql), $sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatementType(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('DELETE', SqlGuard::statementType('/* x */ delete from t'));
|
||||||
|
$this->assertSame('WITH', SqlGuard::statementType("-- x\nWITH a AS (SELECT 1) SELECT 1"));
|
||||||
|
$this->assertSame('SELECT', SqlGuard::statementType('(SELECT 1)'));
|
||||||
|
$this->assertSame('', SqlGuard::statementType(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function splitProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['SELECT 1; SELECT 2', ['SELECT 1', 'SELECT 2']],
|
||||||
|
["SELECT ';'; SELECT 2", ["SELECT ';'", 'SELECT 2']],
|
||||||
|
["SELECT 'it''s; x'", ["SELECT 'it''s; x'"]],
|
||||||
|
['SELECT "a;b" FROM t', ['SELECT "a;b" FROM t']],
|
||||||
|
["SELECT E'it\\'s; x'; SELECT 2", ["SELECT E'it\\'s; x'", 'SELECT 2']],
|
||||||
|
["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$; SELECT 3", ["DO \$\$BEGIN PERFORM 1; PERFORM 2; END\$\$", 'SELECT 3']],
|
||||||
|
["SELECT \$f\$a;\$\$;b\$f\$; SELECT 2", ["SELECT \$f\$a;\$\$;b\$f\$", 'SELECT 2']],
|
||||||
|
["-- don't; split\nSELECT 1; SELECT 2", ["-- don't; split\nSELECT 1", 'SELECT 2']],
|
||||||
|
["/* it's; here */ SELECT 1; SELECT 2", ["/* it's; here */ SELECT 1", 'SELECT 2']],
|
||||||
|
['SELECT \$1; SELECT 2', ['SELECT \$1', 'SELECT 2']],
|
||||||
|
[' ; ;SELECT 1;', ['SELECT 1']],
|
||||||
|
["SELECT 1; --full", ['SELECT 1']],
|
||||||
|
["SELECT 1; -- note\nSELECT 2", ['SELECT 1', "-- note\nSELECT 2"]],
|
||||||
|
["-- only a comment", []],
|
||||||
|
["SELECT 1; /* note */", ['SELECT 1']],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider splitProvider
|
||||||
|
*/
|
||||||
|
public function testSplitStatements(string $sql, array $expected): void
|
||||||
|
{
|
||||||
|
$this->assertSame($expected, SqlGuard::splitStatements($sql));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user