diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ed43d86 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +NEXTCLOUD_URL=http://nextcloud.local +PORT=3002 +TLS=false +TLS_KEY= +TLS_CERT= +JWT_SECRET_KEY=your_secret_key diff --git a/.gitignore b/.gitignore index adde509..4416880 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ .php-cs-fixer.cache .phpunit.result.cache +.env diff --git a/appinfo/routes.php b/appinfo/routes.php index 70f607c..b122b8b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,6 +11,8 @@ return [ 'routes' => [ + /** @see JWTController::getJWT() */ + ['name' => 'JWT#getJWT', 'url' => '{fileId}/token', 'verb' => 'GET'], /** @see WhiteboardController::update() */ ['name' => 'Whiteboard#update', 'url' => '{fileId}', 'verb' => 'PUT'], /** @see WhiteboardController::show() */ diff --git a/composer.json b/composer.json index 84ccf61..061ff1e 100644 --- a/composer.json +++ b/composer.json @@ -1,31 +1,31 @@ { - "name": "nextcloud/whiteboard", - "require-dev": { - "phpunit/phpunit": "^9.5", - "nextcloud/coding-standard": "^1.0", - "psalm/phar": "^5.2", - "sabre/dav": "^4.3", - "nextcloud/ocp": "dev-master" - }, + "name": "nextcloud/whiteboard", "config": { + "autoloader-suffix": "Whiteboard", + "optimize-autoloader": true, "platform": { - "php": "8.0" - } + "php": "8.1" + }, + "sort-packages": true }, - "license": "AGPL", - "require": { - "php": "^8.0" - }, - "scripts": { - "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", - "cs:check": "php-cs-fixer fix --dry-run --diff", - "cs:fix": "php-cs-fixer fix", - "psalm": "psalm.phar", - "test:unit": "phpunit -c tests/phpunit.xml" - }, - "autoload-dev": { - "psr-4": { - "OCP\\": "vendor/nextcloud/ocp/OCP" - } + "license": "AGPL", + "require": { + "php": "^8.1", + "firebase/php-jwt": "^6.10" + }, + "require-dev": { + "roave/security-advisories": "dev-latest", + "phpunit/phpunit": "^9.5", + "nextcloud/coding-standard": "^1.0", + "psalm/phar": "^5.2", + "sabre/dav": "^4.3", + "nextcloud/ocp": "dev-master" + }, + "scripts": { + "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", + "cs:check": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix", + "psalm": "psalm.phar", + "test:unit": "phpunit -c tests/phpunit.xml" } } diff --git a/composer.lock b/composer.lock index af99115..29b6ff4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,35 +4,99 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d1bbda0b0d66fda4775cdc253df34276", - "packages": [], + "content-hash": "f9aa485359b3d941f620301f2c931748", + "packages": [ + { + "name": "firebase/php-jwt", + "version": "v6.10.1", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "500501c2ce893c824c801da135d02661199f60c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5", + "reference": "500501c2ce893c824c801da135d02661199f60c5", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v6.10.1" + }, + "time": "2024-05-18T18:05:11+00:00" + } + ], "packages-dev": [ { "name": "doctrine/instantiator", - "version": "1.5.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", + "doctrine/coding-standard": "^11", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" }, "type": "library", "autoload": { @@ -59,7 +123,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" }, "funding": [ { @@ -75,7 +139,7 @@ "type": "tidelift" } ], - "time": "2022-12-30T00:15:36+00:00" + "time": "2022-12-30T00:23:10+00:00" }, { "name": "myclabs/deep-copy", @@ -184,12 +248,12 @@ "source": { "type": "git", "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "ad39a84bcc13c8bcae5b160cbb8a115b26f6b8b1" + "reference": "bdeabb2fbb4691ac3d72dc27f56dd52aa6c61725" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/ad39a84bcc13c8bcae5b160cbb8a115b26f6b8b1", - "reference": "ad39a84bcc13c8bcae5b160cbb8a115b26f6b8b1", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/bdeabb2fbb4691ac3d72dc27f56dd52aa6c61725", + "reference": "bdeabb2fbb4691ac3d72dc27f56dd52aa6c61725", "shasum": "" }, "require": { @@ -221,7 +285,7 @@ "issues": "https://github.com/nextcloud-deps/ocp/issues", "source": "https://github.com/nextcloud-deps/ocp/tree/master" }, - "time": "2024-06-12T00:36:06+00:00" + "time": "2024-06-18T00:36:38+00:00" }, { "name": "nikic/php-parser", @@ -401,16 +465,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.58.1", + "version": "v3.59.3", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "a6af3df8516033b450a220003c673b9393d68f55" + "reference": "c855876e64de3431bc9279f27af7be40d11d7613" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/a6af3df8516033b450a220003c673b9393d68f55", - "reference": "a6af3df8516033b450a220003c673b9393d68f55", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/c855876e64de3431bc9279f27af7be40d11d7613", + "reference": "c855876e64de3431bc9279f27af7be40d11d7613", "shasum": "" }, "require": { @@ -447,9 +511,9 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.58.1" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.59.3" }, - "time": "2024-05-29T16:39:49+00:00" + "time": "2024-06-16T14:17:34+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1109,6 +1173,809 @@ }, "time": "2021-05-03T11:20:27+00:00" }, + { + "name": "roave/security-advisories", + "version": "dev-latest", + "source": { + "type": "git", + "url": "https://github.com/Roave/SecurityAdvisories.git", + "reference": "a36c08c63614a0da558615424f8b37b3216416b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a36c08c63614a0da558615424f8b37b3216416b6", + "reference": "a36c08c63614a0da558615424f8b37b3216416b6", + "shasum": "" + }, + "conflict": { + "3f/pygmentize": "<1.2", + "admidio/admidio": "<4.2.13", + "adodb/adodb-php": "<=5.20.20|>=5.21,<=5.21.3", + "aheinze/cockpit": "<2.2", + "aimeos/ai-client-html": ">=2020.04.1,<2020.10.27|>=2021.04.1,<2021.10.21|>=2022.04.1,<2022.10.12|>=2023.04.1,<2023.10.14|>=2024.04.1,<2024.04.4", + "aimeos/aimeos-core": "<2024.04.7", + "aimeos/aimeos-typo3": "<19.10.12|>=20,<20.10.5", + "airesvsg/acf-to-rest-api": "<=3.1", + "akaunting/akaunting": "<2.1.13", + "akeneo/pim-community-dev": "<5.0.119|>=6,<6.0.53", + "alextselegidis/easyappointments": "<1.5", + "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1", + "amazing/media2click": ">=1,<1.3.3", + "amphp/artax": "<1.0.6|>=2,<2.0.6", + "amphp/http": "<=1.7.2|>=2,<=2.1", + "amphp/http-client": ">=4,<4.4", + "anchorcms/anchor-cms": "<=0.12.7", + "andreapollastri/cipi": "<=3.1.15", + "andrewhaine/silverstripe-form-capture": ">=0.2,<=0.2.3|>=1,<1.0.2|>=2,<2.2.5", + "apache-solr-for-typo3/solr": "<2.8.3", + "apereo/phpcas": "<1.6", + "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6|>=2.6,<2.7.10|>=3,<3.0.12|>=3.1,<3.1.3", + "appwrite/server-ce": "<=1.2.1", + "arc/web": "<3", + "area17/twill": "<1.2.5|>=2,<2.5.3", + "artesaos/seotools": "<0.17.2", + "asymmetricrypt/asymmetricrypt": "<9.9.99", + "athlon1600/php-proxy": "<=5.1", + "athlon1600/php-proxy-app": "<=3", + "austintoddj/canvas": "<=3.4.2", + "automad/automad": "<=1.10.9", + "automattic/jetpack": "<9.8", + "awesome-support/awesome-support": "<=6.0.7", + "aws/aws-sdk-php": "<3.288.1", + "azuracast/azuracast": "<0.18.3", + "backdrop/backdrop": "<1.24.2", + "backpack/crud": "<3.4.9", + "bacula-web/bacula-web": "<8.0.0.0-RC2-dev", + "badaso/core": "<2.7", + "bagisto/bagisto": "<2.1", + "barrelstrength/sprout-base-email": "<1.2.7", + "barrelstrength/sprout-forms": "<3.9", + "barryvdh/laravel-translation-manager": "<0.6.2", + "barzahlen/barzahlen-php": "<2.0.1", + "baserproject/basercms": "<5.0.9", + "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bbpress/bbpress": "<2.6.5", + "bcosca/fatfree": "<3.7.2", + "bedita/bedita": "<4", + "bigfork/silverstripe-form-capture": ">=3,<3.1.1", + "billz/raspap-webgui": "<2.9.5", + "bk2k/bootstrap-package": ">=7.1,<7.1.2|>=8,<8.0.8|>=9,<9.0.4|>=9.1,<9.1.3|>=10,<10.0.10|>=11,<11.0.3", + "blueimp/jquery-file-upload": "==6.4.4", + "bmarshall511/wordpress_zero_spam": "<5.2.13", + "bolt/bolt": "<3.7.2", + "bolt/core": "<=4.2", + "born05/craft-twofactorauthentication": "<3.3.4", + "bottelet/flarepoint": "<2.2.1", + "bref/bref": "<2.1.17", + "brightlocal/phpwhois": "<=4.2.5", + "brotkrueml/codehighlight": "<2.7", + "brotkrueml/schema": "<1.13.1|>=2,<2.5.1", + "brotkrueml/typo3-matomo-integration": "<1.3.2", + "buddypress/buddypress": "<7.2.1", + "bugsnag/bugsnag-laravel": ">=2,<2.0.2", + "bytefury/crater": "<6.0.2", + "cachethq/cachet": "<2.5.1", + "cakephp/cakephp": "<3.10.3|>=4,<4.0.10|>=4.1,<4.1.4|>=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", + "cakephp/database": ">=4.2,<4.2.12|>=4.3,<4.3.11|>=4.4,<4.4.10", + "cardgate/magento2": "<2.0.33", + "cardgate/woocommerce": "<=3.1.15", + "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cart2quote/module-quotation-encoded": ">=4.1.6,<=4.4.5|>=5,<5.4.4", + "cartalyst/sentry": "<=2.1.6", + "catfan/medoo": "<1.7.5", + "causal/oidc": "<2.1", + "cecil/cecil": "<7.47.1", + "centreon/centreon": "<22.10.15", + "cesnet/simplesamlphp-module-proxystatistics": "<3.1", + "chriskacerguis/codeigniter-restserver": "<=2.7.1", + "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", + "ckeditor/ckeditor": "<4.24", + "cockpit-hq/cockpit": "<2.7|==2.7", + "codeception/codeception": "<3.1.3|>=4,<4.1.22", + "codeigniter/framework": "<3.1.9", + "codeigniter4/framework": "<4.4.7", + "codeigniter4/shield": "<1.0.0.0-beta8", + "codiad/codiad": "<=2.8.4", + "composer/composer": "<1.10.27|>=2,<2.2.24|>=2.3,<2.7.7", + "concrete5/concrete5": "<9.2.8", + "concrete5/core": "<8.5.8|>=9,<9.1", + "contao-components/mediaelement": ">=2.14.2,<2.21.1", + "contao/comments-bundle": ">=2,<4.13.40|>=5.0.0.0-RC1-dev,<5.3.4", + "contao/contao": ">=3,<3.5.37|>=4,<4.4.56|>=4.5,<4.9.40|>=4.10,<4.11.7|>=4.13,<4.13.21|>=5.1,<5.1.4", + "contao/core": "<3.5.39", + "contao/core-bundle": "<4.13.40|>=5,<5.3.4", + "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", + "contao/managed-edition": "<=1.5", + "corveda/phpsandbox": "<1.3.5", + "cosenary/instagram": "<=2.3", + "craftcms/cms": "<4.6.2", + "croogo/croogo": "<4", + "cuyz/valinor": "<0.12", + "czproject/git-php": "<4.0.3", + "dapphp/securimage": "<3.6.6", + "darylldoyle/safe-svg": "<1.9.10", + "datadog/dd-trace": ">=0.30,<0.30.2", + "datatables/datatables": "<1.10.10", + "david-garcia/phpwhois": "<=4.3.1", + "dbrisinajumi/d2files": "<1", + "dcat/laravel-admin": "<=2.1.3.0-beta", + "derhansen/fe_change_pwd": "<2.0.5|>=3,<3.0.3", + "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1|>=7,<7.4", + "desperado/xml-bundle": "<=0.1.7", + "devgroup/dotplant": "<2020.09.14-dev", + "directmailteam/direct-mail": "<6.0.3|>=7,<7.0.3|>=8,<9.5.2", + "doctrine/annotations": "<1.2.7", + "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2", + "doctrine/common": "<2.4.3|>=2.5,<2.5.1", + "doctrine/dbal": ">=2,<2.0.8|>=2.1,<2.1.2|>=3,<3.1.4", + "doctrine/doctrine-bundle": "<1.5.2", + "doctrine/doctrine-module": "<0.7.2", + "doctrine/mongodb-odm": "<1.0.2", + "doctrine/mongodb-odm-bundle": "<3.0.1", + "doctrine/orm": ">=1,<1.2.4|>=2,<2.4.8|>=2.5,<2.5.1|>=2.8.3,<2.8.4", + "dolibarr/dolibarr": "<19.0.2", + "dompdf/dompdf": "<2.0.4", + "doublethreedigital/guest-entries": "<3.1.2", + "drupal/core": ">=6,<6.38|>=7,<7.96|>=8,<10.1.8|>=10.2,<10.2.2", + "drupal/drupal": ">=5,<5.11|>=6,<6.38|>=7,<7.80|>=8,<8.9.16|>=9,<9.1.12|>=9.2,<9.2.4", + "duncanmcclean/guest-entries": "<3.1.2", + "dweeves/magmi": "<=0.7.24", + "ec-cube/ec-cube": "<2.4.4|>=2.11,<=2.17.1|>=3,<=3.0.18.0-patch4|>=4,<=4.1.2", + "ecodev/newsletter": "<=4", + "ectouch/ectouch": "<=2.7.2", + "egroupware/egroupware": "<16.1.20170922", + "elefant/cms": "<2.0.7", + "elgg/elgg": "<3.3.24|>=4,<4.0.5", + "elijaa/phpmemcacheadmin": "<=1.3", + "encore/laravel-admin": "<=1.8.19", + "endroid/qr-code-bundle": "<3.4.2", + "enhavo/enhavo-app": "<=0.13.1", + "enshrined/svg-sanitize": "<0.15", + "erusev/parsedown": "<1.7.2", + "ether/logs": "<3.0.4", + "evolutioncms/evolution": "<=3.2.3", + "exceedone/exment": "<4.4.3|>=5,<5.0.3", + "exceedone/laravel-admin": "<2.2.3|==3", + "ezsystems/demobundle": ">=5.4,<5.4.6.1-dev", + "ezsystems/ez-support-tools": ">=2.2,<2.2.3", + "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1-dev", + "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1-dev|>=5.4,<5.4.11.1-dev|>=2017.12,<2017.12.0.1-dev", + "ezsystems/ezplatform": "<=1.13.6|>=2,<=2.5.24", + "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6|>=1.5,<1.5.29|>=2.3,<2.3.26", + "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1", + "ezsystems/ezplatform-graphql": ">=1.0.0.0-RC1-dev,<1.0.13|>=2.0.0.0-beta1,<2.3.12", + "ezsystems/ezplatform-kernel": "<1.2.5.1-dev|>=1.3,<1.3.35", + "ezsystems/ezplatform-rest": ">=1.2,<=1.2.2|>=1.3,<1.3.8", + "ezsystems/ezplatform-richtext": ">=2.3,<2.3.7.1-dev", + "ezsystems/ezplatform-solr-search-engine": ">=1.7,<1.7.12|>=2,<2.0.2|>=3.3,<3.3.15", + "ezsystems/ezplatform-user": ">=1,<1.0.1", + "ezsystems/ezpublish-kernel": "<6.13.8.2-dev|>=7,<7.5.31", + "ezsystems/ezpublish-legacy": "<=2017.12.7.3|>=2018.6,<=2019.03.5.1", + "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", + "ezsystems/repository-forms": ">=2.3,<2.3.2.1-dev|>=2.5,<2.5.15", + "ezyang/htmlpurifier": "<4.1.1", + "facade/ignition": "<1.16.15|>=2,<2.4.2|>=2.5,<2.5.2", + "facturascripts/facturascripts": "<=2022.08", + "fastly/magento2": "<1.2.26", + "feehi/cms": "<=2.1.1", + "feehi/feehicms": "<=2.1.1", + "fenom/fenom": "<=2.12.1", + "filegator/filegator": "<7.8", + "filp/whoops": "<2.1.13", + "fineuploader/php-traditional-server": "<=1.2.2", + "firebase/php-jwt": "<6", + "fixpunkt/fp-masterquiz": "<2.2.1|>=3,<3.5.2", + "fixpunkt/fp-newsletter": "<1.1.1|>=2,<2.1.2|>=2.2,<3.2.6", + "flarum/core": "<1.8.5", + "flarum/flarum": "<0.1.0.0-beta8", + "flarum/framework": "<1.8.5", + "flarum/mentions": "<1.6.3", + "flarum/sticky": ">=0.1.0.0-beta14,<=0.1.0.0-beta15", + "flarum/tags": "<=0.1.0.0-beta13", + "floriangaerber/magnesium": "<0.3.1", + "fluidtypo3/vhs": "<5.1.1", + "fof/byobu": ">=0.3.0.0-beta2,<1.1.7", + "fof/upload": "<1.2.3", + "foodcoopshop/foodcoopshop": ">=3.2,<3.6.1", + "fooman/tcpdf": "<6.2.22", + "forkcms/forkcms": "<5.11.1", + "fossar/tcpdf-parser": "<6.2.22", + "francoisjacquet/rosariosis": "<=11.5.1", + "frappant/frp-form-answers": "<3.1.2|>=4,<4.0.2", + "friendsofsymfony/oauth2-php": "<1.3", + "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2", + "friendsofsymfony/user-bundle": ">=1,<1.3.5", + "friendsofsymfony1/swiftmailer": ">=4,<5.4.13|>=6,<6.2.5", + "friendsofsymfony1/symfony1": ">=1.1,<1.15.19", + "friendsoftypo3/mediace": ">=7.6.2,<7.6.5", + "friendsoftypo3/openid": ">=4.5,<4.5.31|>=4.7,<4.7.16|>=6,<6.0.11|>=6.1,<6.1.6", + "froala/wysiwyg-editor": "<3.2.7|>=4.0.1,<=4.1.3", + "froxlor/froxlor": "<2.1.9", + "frozennode/administrator": "<=5.0.12", + "fuel/core": "<1.8.1", + "funadmin/funadmin": "<=3.2|>=3.3.2,<=3.3.3", + "gaoming13/wechat-php-sdk": "<=1.10.2", + "genix/cms": "<=1.1.11", + "getformwork/formwork": "<1.13.1|==2.0.0.0-beta1", + "getgrav/grav": "<1.7.46", + "getkirby/cms": "<4.1.1", + "getkirby/kirby": "<=2.5.12", + "getkirby/panel": "<2.5.14", + "getkirby/starterkit": "<=3.7.0.2", + "gilacms/gila": "<=1.15.4", + "gleez/cms": "<=1.3|==2", + "globalpayments/php-sdk": "<2", + "gogentooss/samlbase": "<1.2.7", + "google/protobuf": "<3.15", + "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3", + "gree/jose": "<2.2.1", + "gregwar/rst": "<1.0.3", + "grumpydictator/firefly-iii": "<6.1.17", + "gugoan/economizzer": "<=0.9.0.0-beta1", + "guzzlehttp/guzzle": "<6.5.8|>=7,<7.4.5", + "guzzlehttp/psr7": "<1.9.1|>=2,<2.4.5", + "haffner/jh_captcha": "<=2.1.3|>=3,<=3.0.2", + "harvesthq/chosen": "<1.8.7", + "helloxz/imgurl": "<=2.31", + "hhxsv5/laravel-s": "<3.7.36", + "hillelcoren/invoice-ninja": "<5.3.35", + "himiklab/yii2-jqgrid-widget": "<1.0.8", + "hjue/justwriting": "<=1", + "hov/jobfair": "<1.0.13|>=2,<2.0.2", + "httpsoft/http-message": "<1.0.12", + "hyn/multi-tenant": ">=5.6,<5.7.2", + "ibexa/admin-ui": ">=4.2,<4.2.3", + "ibexa/core": ">=4,<4.0.7|>=4.1,<4.1.4|>=4.2,<4.2.3|>=4.5,<4.5.6|>=4.6,<4.6.2", + "ibexa/graphql": ">=2.5,<2.5.31|>=3.3,<3.3.28|>=4.2,<4.2.3", + "ibexa/post-install": "<=1.0.4", + "ibexa/solr": ">=4.5,<4.5.4", + "ibexa/user": ">=4,<4.4.3", + "icecoder/icecoder": "<=8.1", + "idno/known": "<=1.3.1", + "ilicmiljan/secure-props": ">=1.2,<1.2.2", + "illuminate/auth": "<5.5.10", + "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<6.18.31|>=7,<7.22.4", + "illuminate/database": "<6.20.26|>=7,<7.30.5|>=8,<8.40", + "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15", + "illuminate/view": "<6.20.42|>=7,<7.30.6|>=8,<8.75", + "imdbphp/imdbphp": "<=5.1.1", + "impresscms/impresscms": "<=1.4.5", + "impresspages/impresspages": "<=1.0.12", + "in2code/femanager": "<5.5.3|>=6,<6.3.4|>=7,<7.2.3", + "in2code/ipandlanguageredirect": "<5.1.2", + "in2code/lux": "<17.6.1|>=18,<24.0.2", + "innologi/typo3-appointments": "<2.0.6", + "intelliants/subrion": "<4.2.2", + "inter-mediator/inter-mediator": "==5.5", + "islandora/islandora": ">=2,<2.4.1", + "ivankristianto/phpwhois": "<=4.3", + "jackalope/jackalope-doctrine-dbal": "<1.7.4", + "james-heinrich/getid3": "<1.9.21", + "james-heinrich/phpthumb": "<1.7.12", + "jasig/phpcas": "<1.3.3", + "jcbrand/converse.js": "<3.3.3", + "johnbillion/wp-crontrol": "<1.16.2", + "joomla/application": "<1.0.13", + "joomla/archive": "<1.1.12|>=2,<2.0.1", + "joomla/filesystem": "<1.6.2|>=2,<2.0.1", + "joomla/filter": "<1.4.4|>=2,<2.0.1", + "joomla/framework": "<1.5.7|>=2.5.4,<=3.8.12", + "joomla/input": ">=2,<2.0.2", + "joomla/joomla-cms": ">=2.5,<3.9.12", + "joomla/session": "<1.3.1", + "joyqi/hyper-down": "<=2.4.27", + "jsdecena/laracom": "<2.0.9", + "jsmitty12/phpwhois": "<5.1", + "juzaweb/cms": "<=3.4", + "kazist/phpwhois": "<=4.2.6", + "kelvinmo/simplexrd": "<3.1.1", + "kevinpapst/kimai2": "<1.16.7", + "khodakhah/nodcms": "<=3", + "kimai/kimai": "<2.16", + "kitodo/presentation": "<3.2.3|>=3.3,<3.3.4", + "klaviyo/magento2-extension": ">=1,<3", + "knplabs/knp-snappy": "<=1.4.2", + "kohana/core": "<3.3.3", + "krayin/laravel-crm": "<1.2.2", + "kreait/firebase-php": ">=3.2,<3.8.1", + "kumbiaphp/kumbiapp": "<=1.1.1", + "la-haute-societe/tcpdf": "<6.2.22", + "laminas/laminas-diactoros": "<2.18.1|==2.19|==2.20|==2.21|==2.22|==2.23|>=2.24,<2.24.2|>=2.25,<2.25.2", + "laminas/laminas-form": "<2.17.1|>=3,<3.0.2|>=3.1,<3.1.1", + "laminas/laminas-http": "<2.14.2", + "laravel/fortify": "<1.11.1", + "laravel/framework": "<6.20.44|>=7,<7.30.6|>=8,<8.75", + "laravel/laravel": ">=5.4,<5.4.22", + "laravel/socialite": ">=1,<2.0.10", + "latte/latte": "<2.10.8", + "lavalite/cms": "<=9|==10.1", + "lcobucci/jwt": ">=3.4,<3.4.6|>=4,<4.0.4|>=4.1,<4.1.5", + "league/commonmark": "<0.18.3", + "league/flysystem": "<1.1.4|>=2,<2.1.1", + "league/oauth2-server": ">=8.3.2,<8.4.2|>=8.5,<8.5.3", + "lexik/jwt-authentication-bundle": "<2.10.7|>=2.11,<2.11.3", + "libreform/libreform": ">=2,<=2.0.8", + "librenms/librenms": "<2017.08.18", + "liftkit/database": "<2.13.2", + "lightsaml/lightsaml": "<1.3.5", + "limesurvey/limesurvey": "<3.27.19", + "livehelperchat/livehelperchat": "<=3.91", + "livewire/livewire": ">2.2.4,<2.2.6|>=3.3.5,<3.4.9", + "lms/routes": "<2.1.1", + "localizationteam/l10nmgr": "<7.4|>=8,<8.7|>=9,<9.2", + "luyadev/yii-helpers": "<1.2.1", + "magento/community-edition": "<2.4.5|==2.4.5|>=2.4.5.0-patch1,<2.4.5.0-patch8|==2.4.6|>=2.4.6.0-patch1,<2.4.6.0-patch6|==2.4.7", + "magento/core": "<=1.9.4.5", + "magento/magento1ce": "<1.9.4.3-dev", + "magento/magento1ee": ">=1,<1.14.4.3-dev", + "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2.0-patch2", + "magneto/core": "<1.9.4.4-dev", + "maikuolan/phpmussel": ">=1,<1.6", + "mainwp/mainwp": "<=4.4.3.3", + "mantisbt/mantisbt": "<2.26.2", + "marcwillmann/turn": "<0.3.3", + "matyhtf/framework": "<3.0.6", + "mautic/core": "<4.4.12|>=5.0.0.0-alpha,<5.0.4", + "mdanter/ecc": "<2", + "mediawiki/core": "<1.36.2", + "mediawiki/matomo": "<2.4.3", + "mediawiki/semantic-media-wiki": "<4.0.2", + "melisplatform/melis-asset-manager": "<5.0.1", + "melisplatform/melis-cms": "<5.0.1", + "melisplatform/melis-front": "<5.0.1", + "mezzio/mezzio-swoole": "<3.7|>=4,<4.3", + "mgallegos/laravel-jqgrid": "<=1.3", + "microsoft/microsoft-graph": ">=1.16,<1.109.1|>=2,<2.0.1", + "microsoft/microsoft-graph-beta": "<2.0.1", + "microsoft/microsoft-graph-core": "<2.0.2", + "microweber/microweber": "<=2.0.4", + "mikehaertl/php-shellcommand": "<1.6.1", + "miniorange/miniorange-saml": "<1.4.3", + "mittwald/typo3_forum": "<1.2.1", + "mobiledetect/mobiledetectlib": "<2.8.32", + "modx/revolution": "<=2.8.3.0-patch", + "mojo42/jirafeau": "<4.4", + "mongodb/mongodb": ">=1,<1.9.2", + "monolog/monolog": ">=1.8,<1.12", + "moodle/moodle": "<4.3.4", + "mos/cimage": "<0.7.19", + "movim/moxl": ">=0.8,<=0.10", + "movingbytes/social-network": "<=1.2.1", + "mpdf/mpdf": "<=7.1.7", + "munkireport/comment": "<4.1", + "munkireport/managedinstalls": "<2.6", + "munkireport/munki_facts": "<1.5", + "munkireport/munkireport": ">=2.5.3,<5.6.3", + "munkireport/reportdata": "<3.5", + "munkireport/softwareupdate": "<1.6", + "mustache/mustache": ">=2,<2.14.1", + "namshi/jose": "<2.2", + "neoan3-apps/template": "<1.1.1", + "neorazorx/facturascripts": "<2022.04", + "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", + "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", + "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", + "neos/swiftmailer": "<5.4.5", + "netgen/tagsbundle": ">=3.4,<3.4.11|>=4,<4.0.15", + "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", + "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", + "nilsteampassnet/teampass": "<3.0.10", + "nonfiction/nterchange": "<4.1.1", + "notrinos/notrinos-erp": "<=0.7", + "noumo/easyii": "<=0.9", + "novaksolutions/infusionsoft-php-sdk": "<1", + "nukeviet/nukeviet": "<4.5.02", + "nyholm/psr7": "<1.6.1", + "nystudio107/craft-seomatic": "<3.4.12", + "nzedb/nzedb": "<0.8", + "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", + "october/backend": "<1.1.2", + "october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1", + "october/october": "<=3.4.4", + "october/rain": "<1.0.472|>=1.1,<1.1.2", + "october/system": "<1.0.476|>=1.1,<1.1.12|>=2,<2.2.34|>=3,<3.5.2", + "omeka/omeka-s": "<4.0.3", + "onelogin/php-saml": "<2.10.4", + "oneup/uploader-bundle": ">=1,<1.9.3|>=2,<2.1.5", + "open-web-analytics/open-web-analytics": "<1.7.4", + "opencart/opencart": "<=3.0.3.7|>=4,<4.0.2.3-dev", + "openid/php-openid": "<2.3", + "openmage/magento-lts": "<20.5", + "opensolutions/vimbadmin": "<=3.0.15", + "opensource-workshop/connect-cms": "<1.7.2|>=2,<2.3.2", + "orchid/platform": ">=9,<9.4.4|>=14.0.0.0-alpha4,<14.5", + "oro/calendar-bundle": ">=4.2,<=4.2.6|>=5,<=5.0.6|>=5.1,<5.1.1", + "oro/commerce": ">=4.1,<5.0.11|>=5.1,<5.1.1", + "oro/crm": ">=1.7,<1.7.4|>=3.1,<4.1.17|>=4.2,<4.2.7", + "oro/crm-call-bundle": ">=4.2,<=4.2.5|>=5,<5.0.4|>=5.1,<5.1.1", + "oro/customer-portal": ">=4.1,<=4.1.13|>=4.2,<=4.2.10|>=5,<=5.0.11|>=5.1,<=5.1.3", + "oro/platform": ">=1.7,<1.7.4|>=3.1,<3.1.29|>=4.1,<4.1.17|>=4.2,<=4.2.10|>=5,<=5.0.12|>=5.1,<=5.1.3", + "oxid-esales/oxideshop-ce": "<4.5", + "oxid-esales/paymorrow-module": ">=1,<1.0.2|>=2,<2.0.1", + "packbackbooks/lti-1-3-php-library": "<5", + "padraic/humbug_get_contents": "<1.1.2", + "pagarme/pagarme-php": "<3", + "pagekit/pagekit": "<=1.0.18", + "paragonie/ecc": "<2.0.1", + "paragonie/random_compat": "<2", + "passbolt/passbolt_api": "<4.6.2", + "paypal/adaptivepayments-sdk-php": "<=3.9.2", + "paypal/invoice-sdk-php": "<=3.9", + "paypal/merchant-sdk-php": "<3.12", + "paypal/permissions-sdk-php": "<=3.9.1", + "pear/archive_tar": "<1.4.14", + "pear/auth": "<1.2.4", + "pear/crypt_gpg": "<1.6.7", + "pear/pear": "<=1.10.1", + "pegasus/google-for-jobs": "<1.5.1|>=2,<2.1.1", + "personnummer/personnummer": "<3.0.2", + "phanan/koel": "<5.1.4", + "phenx/php-svg-lib": "<0.5.2", + "php-censor/php-censor": "<2.0.13|>=2.1,<2.1.5", + "php-mod/curl": "<2.3.2", + "phpbb/phpbb": "<3.2.10|>=3.3,<3.3.1", + "phpems/phpems": ">=6,<=6.1.3", + "phpfastcache/phpfastcache": "<6.1.5|>=7,<7.1.2|>=8,<8.0.7", + "phpmailer/phpmailer": "<6.5", + "phpmussel/phpmussel": ">=1,<1.6", + "phpmyadmin/phpmyadmin": "<5.2.1", + "phpmyfaq/phpmyfaq": "<3.2.5|==3.2.5", + "phpoffice/common": "<0.2.9", + "phpoffice/phpexcel": "<1.8", + "phpoffice/phpspreadsheet": "<1.16", + "phpseclib/phpseclib": "<2.0.47|>=3,<3.0.36", + "phpservermon/phpservermon": "<3.6", + "phpsysinfo/phpsysinfo": "<3.4.3", + "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3", + "phpwhois/phpwhois": "<=4.2.5", + "phpxmlrpc/extras": "<0.6.1", + "phpxmlrpc/phpxmlrpc": "<4.9.2", + "pi/pi": "<=2.5", + "pimcore/admin-ui-classic-bundle": "<=1.4.2", + "pimcore/customer-management-framework-bundle": "<4.0.6", + "pimcore/data-hub": "<1.2.4", + "pimcore/demo": "<10.3", + "pimcore/ecommerce-framework-bundle": "<1.0.10", + "pimcore/perspective-editor": "<1.5.1", + "pimcore/pimcore": "<11.2.4", + "pixelfed/pixelfed": "<0.11.11", + "plotly/plotly.js": "<2.25.2", + "pocketmine/bedrock-protocol": "<8.0.2", + "pocketmine/pocketmine-mp": "<5.11.2", + "pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1", + "pressbooks/pressbooks": "<5.18", + "prestashop/autoupgrade": ">=4,<4.10.1", + "prestashop/blockreassurance": "<=5.1.3", + "prestashop/blockwishlist": ">=2,<2.1.1", + "prestashop/contactform": ">=1.0.1,<4.3", + "prestashop/gamification": "<2.3.2", + "prestashop/prestashop": "<8.1.6", + "prestashop/productcomments": "<5.0.2", + "prestashop/ps_emailsubscription": "<2.6.1", + "prestashop/ps_facetedsearch": "<3.4.1", + "prestashop/ps_linklist": "<3.1", + "privatebin/privatebin": "<1.4", + "processwire/processwire": "<=3.0.210", + "propel/propel": ">=2.0.0.0-alpha1,<=2.0.0.0-alpha7", + "propel/propel1": ">=1,<=1.7.1", + "pterodactyl/panel": "<1.11.6", + "ptheofan/yii2-statemachine": ">=2.0.0.0-RC1-dev,<=2", + "ptrofimov/beanstalk_console": "<1.7.14", + "pubnub/pubnub": "<6.1", + "pusher/pusher-php-server": "<2.2.1", + "pwweb/laravel-core": "<=0.3.6.0-beta", + "pyrocms/pyrocms": "<=3.9.1", + "qcubed/qcubed": "<=3.1.1", + "quickapps/cms": "<=2.0.0.0-beta2", + "rainlab/blog-plugin": "<1.4.1", + "rainlab/debugbar-plugin": "<3.1", + "rainlab/user-plugin": "<=1.4.5", + "rankmath/seo-by-rank-math": "<=1.0.95", + "rap2hpoutre/laravel-log-viewer": "<0.13", + "react/http": ">=0.7,<1.9", + "really-simple-plugins/complianz-gdpr": "<6.4.2", + "redaxo/source": "<=5.15.1", + "remdex/livehelperchat": "<4.29", + "reportico-web/reportico": "<=8.1", + "rhukster/dom-sanitizer": "<1.0.7", + "rmccue/requests": ">=1.6,<1.8", + "robrichards/xmlseclibs": ">=1,<3.0.4", + "roots/soil": "<4.1", + "rudloff/alltube": "<3.0.3", + "s-cart/core": "<6.9", + "s-cart/s-cart": "<6.9", + "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1", + "sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9", + "scheb/two-factor-bundle": "<3.26|>=4,<4.11", + "sensiolabs/connect": "<4.2.3", + "serluck/phpwhois": "<=4.2.6", + "sfroemken/url_redirect": "<=1.2.1", + "sheng/yiicms": "<=1.2", + "shopware/core": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", + "shopware/platform": "<6.5.8.8-dev|>=6.6.0.0-RC1-dev,<6.6.1", + "shopware/production": "<=6.3.5.2", + "shopware/shopware": "<6.2.3", + "shopware/storefront": "<=6.4.8.1|>=6.5.8,<6.5.8.7-dev", + "shopxo/shopxo": "<2.2.6", + "showdoc/showdoc": "<2.10.4", + "silverstripe-australia/advancedreports": ">=1,<=2", + "silverstripe/admin": "<1.13.19|>=2,<2.1.8", + "silverstripe/assets": ">=1,<1.11.1", + "silverstripe/cms": "<4.11.3", + "silverstripe/comments": ">=1.3,<3.1.1", + "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3", + "silverstripe/framework": "<4.13.39|>=5,<5.1.11", + "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.8.2|>=4,<4.3.7|>=5,<5.1.3", + "silverstripe/hybridsessions": ">=1,<2.4.1|>=2.5,<2.5.1", + "silverstripe/recipe-cms": ">=4.5,<4.5.3", + "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1", + "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4|>=2.1,<2.1.2", + "silverstripe/silverstripe-omnipay": "<2.5.2|>=3,<3.0.2|>=3.1,<3.1.4|>=3.2,<3.2.1", + "silverstripe/subsites": ">=2,<2.6.1", + "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1", + "silverstripe/userforms": "<3|>=5,<5.4.2", + "silverstripe/versioned-admin": ">=1,<1.11.1", + "simple-updates/phpwhois": "<=1", + "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4|==5.0.0.0-alpha12", + "simplesamlphp/simplesamlphp": "<1.18.6", + "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1", + "simplesamlphp/simplesamlphp-module-openid": "<1", + "simplesamlphp/simplesamlphp-module-openidprovider": "<0.9", + "simplesamlphp/xml-security": "==1.6.11", + "simplito/elliptic-php": "<1.0.6", + "sitegeist/fluid-components": "<3.5", + "sjbr/sr-freecap": "<2.4.6|>=2.5,<2.5.3", + "slim/psr7": "<1.4.1|>=1.5,<1.5.1|>=1.6,<1.6.1", + "slim/slim": "<2.6", + "slub/slub-events": "<3.0.3", + "smarty/smarty": "<4.5.3|>=5,<5.1.1", + "snipe/snipe-it": "<6.4.2", + "socalnick/scn-social-auth": "<1.15.2", + "socialiteproviders/steam": "<1.1", + "spatie/browsershot": "<3.57.4", + "spatie/image-optimizer": "<1.7.3", + "spipu/html2pdf": "<5.2.8", + "spoon/library": "<1.4.1", + "spoonity/tcpdf": "<6.2.22", + "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1", + "ssddanbrown/bookstack": "<22.02.3", + "statamic/cms": "<4.46|>=5.3,<5.6.2", + "stormpath/sdk": "<9.9.99", + "studio-42/elfinder": "<2.1.62", + "subhh/libconnect": "<7.0.8|>=8,<8.1", + "sukohi/surpass": "<1", + "sulu/form-bundle": ">=2,<2.5.3", + "sulu/sulu": "<1.6.44|>=2,<2.4.17|>=2.5,<2.5.13", + "sumocoders/framework-user-bundle": "<1.4", + "superbig/craft-audit": "<3.0.2", + "swag/paypal": "<5.4.4", + "swiftmailer/swiftmailer": "<6.2.5", + "swiftyedit/swiftyedit": "<1.2", + "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2", + "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", + "sylius/grid-bundle": "<1.10.1", + "sylius/paypal-plugin": ">=1,<1.2.4|>=1.3,<1.3.1", + "sylius/resource-bundle": ">=1,<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", + "sylius/sylius": "<1.9.10|>=1.10,<1.10.11|>=1.11,<1.11.2|>=1.12.0.0-alpha1,<1.12.16|>=1.13.0.0-alpha1,<1.13.1", + "symbiote/silverstripe-multivaluefield": ">=3,<3.1", + "symbiote/silverstripe-queuedjobs": ">=3,<3.0.2|>=3.1,<3.1.4|>=4,<4.0.7|>=4.1,<4.1.2|>=4.2,<4.2.4|>=4.3,<4.3.3|>=4.4,<4.4.3|>=4.5,<4.5.1|>=4.6,<4.6.4", + "symbiote/silverstripe-seed": "<6.0.3", + "symbiote/silverstripe-versionedfiles": "<=2.0.3", + "symfont/process": ">=0", + "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", + "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4", + "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1", + "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=5.3.14,<5.3.15|>=5.4.3,<5.4.4|>=6.0.3,<6.0.4", + "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7", + "symfony/http-kernel": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", + "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13", + "symfony/maker-bundle": ">=1.27,<1.29.2|>=1.30,<1.31.1", + "symfony/mime": ">=4.3,<4.3.8", + "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/polyfill": ">=1,<1.10", + "symfony/polyfill-php55": ">=1,<1.10", + "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7", + "symfony/routing": ">=2,<2.0.19", + "symfony/security": ">=2,<2.7.51|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.8", + "symfony/security-bundle": ">=2,<4.4.50|>=5,<5.4.20|>=6,<6.0.20|>=6.1,<6.1.12|>=6.2,<6.2.6", + "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<3.4.49|>=4,<4.4.24|>=5,<5.2.9", + "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11", + "symfony/security-guard": ">=2.8,<3.4.48|>=4,<4.4.23|>=5,<5.2.8", + "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7|>=5.1,<5.2.8|>=5.3,<5.3.2|>=5.4,<5.4.31|>=6,<6.3.8", + "symfony/serializer": ">=2,<2.0.11|>=4.1,<4.4.35|>=5,<5.3.12", + "symfony/symfony": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8", + "symfony/translation": ">=2,<2.0.17", + "symfony/twig-bridge": ">=2,<4.4.51|>=5,<5.4.31|>=6,<6.3.8", + "symfony/ux-autocomplete": "<2.11.2", + "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3", + "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8", + "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4", + "symfony/webhook": ">=6.3,<6.3.8", + "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7|>=2.2.0.0-beta1,<2.2.0.0-beta2", + "symphonycms/symphony-2": "<2.6.4", + "t3/dce": "<0.11.5|>=2.2,<2.6.2", + "t3g/svg-sanitizer": "<1.0.3", + "t3s/content-consent": "<1.0.3|>=2,<2.0.2", + "tastyigniter/tastyigniter": "<3.3", + "tcg/voyager": "<=1.4", + "tecnickcom/tcpdf": "<=6.7.4", + "terminal42/contao-tablelookupwizard": "<3.3.5", + "thelia/backoffice-default-template": ">=2.1,<2.1.2", + "thelia/thelia": ">=2.1,<2.1.3", + "theonedemon/phpwhois": "<=4.2.5", + "thinkcmf/thinkcmf": "<6.0.8", + "thorsten/phpmyfaq": "<3.2.2", + "tikiwiki/tiki-manager": "<=17.1", + "timber/timber": ">=0.16.6,<1.23.1|>=1.24,<1.24.1|>=2,<2.1", + "tinymce/tinymce": "<7", + "tinymighty/wiki-seo": "<1.2.2", + "titon/framework": "<9.9.99", + "tobiasbg/tablepress": "<=2.0.0.0-RC1", + "topthink/framework": "<6.0.17|>=6.1,<6.1.5|>=8,<8.0.4", + "topthink/think": "<=6.1.1", + "topthink/thinkphp": "<=3.2.3", + "torrentpier/torrentpier": "<=2.4.1", + "tpwd/ke_search": "<4.0.3|>=4.1,<4.6.6|>=5,<5.0.2", + "tribalsystems/zenario": "<9.5.60602", + "truckersmp/phpwhois": "<=4.3.1", + "ttskch/pagination-service-provider": "<1", + "twig/twig": "<1.44.7|>=2,<2.15.3|>=3,<3.4.3", + "typo3/cms": "<9.5.29|>=10,<10.4.35|>=11,<11.5.23|>=12,<12.2", + "typo3/cms-backend": "<4.1.14|>=4.2,<4.2.15|>=4.3,<4.3.7|>=4.4,<4.4.4|>=7,<=7.6.50|>=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", + "typo3/cms-core": "<=8.7.56|>=9,<=9.5.47|>=10,<=10.4.44|>=11,<=11.5.36|>=12,<=12.4.14|>=13,<=13.1", + "typo3/cms-extbase": "<6.2.24|>=7,<7.6.8|==8.1.1", + "typo3/cms-fluid": "<4.3.4|>=4.4,<4.4.1", + "typo3/cms-form": ">=8,<=8.7.39|>=9,<=9.5.24|>=10,<=10.4.13|>=11,<=11.1", + "typo3/cms-frontend": "<4.3.9|>=4.4,<4.4.5", + "typo3/cms-install": "<4.1.14|>=4.2,<4.2.16|>=4.3,<4.3.9|>=4.4,<4.4.5|>=12.2,<12.4.8", + "typo3/cms-rte-ckeditor": ">=9.5,<9.5.42|>=10,<10.4.39|>=11,<11.5.30", + "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "typo3/html-sanitizer": ">=1,<=1.5.2|>=2,<=2.1.3", + "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<2.3.99|>=3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<3.3.23|>=4,<4.0.17|>=4.1,<4.1.16|>=4.2,<4.2.12|>=4.3,<4.3.3", + "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", + "typo3/swiftmailer": ">=4.1,<4.1.99|>=5.4,<5.4.5", + "typo3fluid/fluid": ">=2,<2.0.8|>=2.1,<2.1.7|>=2.2,<2.2.4|>=2.3,<2.3.7|>=2.4,<2.4.4|>=2.5,<2.5.11|>=2.6,<2.6.10", + "ua-parser/uap-php": "<3.8", + "uasoft-indonesia/badaso": "<=2.9.7", + "unisharp/laravel-filemanager": "<2.6.4", + "userfrosting/userfrosting": ">=0.3.1,<4.6.3", + "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", + "uvdesk/community-skeleton": "<=1.1.1", + "uvdesk/core-framework": "<=1.1.1", + "vanilla/safecurl": "<0.9.2", + "verbb/comments": "<1.5.5", + "verbb/formie": "<2.1.6", + "verbb/image-resizer": "<2.0.9", + "verbb/knock-knock": "<1.2.8", + "verot/class.upload.php": "<=2.1.6", + "villagedefrance/opencart-overclocked": "<=1.11.1", + "vova07/yii2-fileapi-widget": "<0.1.9", + "vrana/adminer": "<4.8.1", + "vufind/vufind": ">=2,<9.1.1", + "waldhacker/hcaptcha": "<2.1.2", + "wallabag/tcpdf": "<6.2.22", + "wallabag/wallabag": "<2.6.7", + "wanglelecc/laracms": "<=1.0.3", + "web-auth/webauthn-framework": ">=3.3,<3.3.4", + "web-feet/coastercms": "==5.5", + "webbuilders-group/silverstripe-kapost-bridge": "<0.4", + "webcoast/deferred-image-processing": "<1.0.2", + "webklex/laravel-imap": "<5.3", + "webklex/php-imap": "<5.3", + "webpa/webpa": "<3.1.2", + "wikibase/wikibase": "<=1.39.3", + "wikimedia/parsoid": "<0.12.2", + "willdurand/js-translation-bundle": "<2.1.1", + "winter/wn-backend-module": "<1.2.4", + "winter/wn-dusk-plugin": "<2.1", + "winter/wn-system-module": "<1.2.4", + "wintercms/winter": "<=1.2.3", + "woocommerce/woocommerce": "<6.6|>=8.8,<8.8.5|>=8.9,<8.9.3", + "wp-cli/wp-cli": ">=0.12,<2.5", + "wp-graphql/wp-graphql": "<=1.14.5", + "wp-premium/gravityforms": "<2.4.21", + "wpanel/wpanel4-cms": "<=4.3.1", + "wpcloud/wp-stateless": "<3.2", + "wpglobus/wpglobus": "<=1.9.6", + "wwbn/avideo": "<14.3", + "xataface/xataface": "<3", + "xpressengine/xpressengine": "<3.0.15", + "yab/quarx": "<2.4.5", + "yeswiki/yeswiki": "<4.1", + "yetiforce/yetiforce-crm": "<=6.4", + "yidashi/yii2cmf": "<=2", + "yii2mod/yii2-cms": "<1.9.2", + "yiisoft/yii": "<1.1.29", + "yiisoft/yii2": "<2.0.50", + "yiisoft/yii2-authclient": "<2.2.15", + "yiisoft/yii2-bootstrap": "<2.0.4", + "yiisoft/yii2-dev": "<2.0.43", + "yiisoft/yii2-elasticsearch": "<2.0.5", + "yiisoft/yii2-gii": "<=2.2.4", + "yiisoft/yii2-jui": "<2.0.4", + "yiisoft/yii2-redis": "<2.0.8", + "yikesinc/yikes-inc-easy-mailchimp-extender": "<6.8.6", + "yoast-seo-for-typo3/yoast_seo": "<7.2.3", + "yourls/yourls": "<=1.8.2", + "yuan1994/tpadmin": "<=1.3.12", + "zencart/zencart": "<=1.5.7.0-beta", + "zendesk/zendesk_api_client_php": "<2.2.11", + "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3", + "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2", + "zendframework/zend-db": "<2.2.10|>=2.3,<2.3.5", + "zendframework/zend-developer-tools": ">=1.2.2,<1.2.3", + "zendframework/zend-diactoros": "<1.8.4", + "zendframework/zend-feed": "<2.10.3", + "zendframework/zend-form": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-http": "<2.8.1", + "zendframework/zend-json": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zend-ldap": ">=2,<2.0.99|>=2.1,<2.1.99|>=2.2,<2.2.8|>=2.3,<2.3.3", + "zendframework/zend-mail": "<2.4.11|>=2.5,<2.7.2", + "zendframework/zend-navigation": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-session": ">=2,<2.2.9|>=2.3,<2.3.4", + "zendframework/zend-validator": ">=2.3,<2.3.6", + "zendframework/zend-view": ">=2,<2.2.7|>=2.3,<2.3.1", + "zendframework/zend-xmlrpc": ">=2.1,<2.1.6|>=2.2,<2.2.6", + "zendframework/zendframework": "<=3", + "zendframework/zendframework1": "<1.12.20", + "zendframework/zendopenid": "<2.0.2", + "zendframework/zendrest": "<2.0.2", + "zendframework/zendservice-amazon": "<2.0.3", + "zendframework/zendservice-api": "<1", + "zendframework/zendservice-audioscrobbler": "<2.0.2", + "zendframework/zendservice-nirvanix": "<2.0.2", + "zendframework/zendservice-slideshare": "<2.0.2", + "zendframework/zendservice-technorati": "<2.0.2", + "zendframework/zendservice-windowsazure": "<2.0.2", + "zendframework/zendxml": ">=1,<1.0.1", + "zenstruck/collection": "<0.2.1", + "zetacomponents/mail": "<1.8.2", + "zf-commons/zfc-user": "<1.2.2", + "zfcampus/zf-apigility-doctrine": ">=1,<1.0.3", + "zfr/zfr-oauth2-server-module": "<0.1.2", + "zoujingli/thinkadmin": "<=6.1.53" + }, + "default-branch": true, + "type": "metapackage", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "role": "maintainer" + }, + { + "name": "Ilya Tribusean", + "email": "slash3b@gmail.com", + "role": "maintainer" + } + ], + "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it", + "keywords": [ + "dev" + ], + "support": { + "issues": "https://github.com/Roave/SecurityAdvisories/issues", + "source": "https://github.com/Roave/SecurityAdvisories/tree/latest" + }, + "funding": [ + { + "url": "https://github.com/Ocramius", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories", + "type": "tidelift" + } + ], + "time": "2024-06-17T23:04:35+00:00" + }, { "name": "sabre/dav", "version": "4.6.0", @@ -2571,16 +3438,17 @@ "aliases": [], "minimum-stability": "stable", "stability-flags": { + "roave/security-advisories": 20, "nextcloud/ocp": 20 }, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.0" + "php": "^8.1" }, "platform-dev": [], "platform-overrides": { - "php": "8.0" + "php": "8.1" }, "plugin-api-version": "2.6.0" } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 1a817ea..ebc939e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -29,6 +29,8 @@ public function __construct(array $params = []) { } public function register(IRegistrationContext $context): void { + include_once __DIR__ . '/../../vendor/autoload.php'; + $context->registerEventListener(AddContentSecurityPolicyEvent::class, AddContentSecurityPolicyListener::class); $context->registerEventListener(LoadViewer::class, LoadViewerListener::class); $context->registerEventListener(RegisterTemplateCreatorEvent::class, RegisterTemplateCreatorListener::class); diff --git a/lib/Controller/JWTController.php b/lib/Controller/JWTController.php new file mode 100644 index 0000000..017af10 --- /dev/null +++ b/lib/Controller/JWTController.php @@ -0,0 +1,111 @@ +userSession->isLoggedIn()) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $user = $this->userSession->getUser(); + + if ($user === null) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $userId = $user->getUID(); + try { + $folder = $this->rootFolder->getUserFolder($userId); + } catch (NotPermittedException $e) { + return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); + } catch (NoUserException $e) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $file = $folder->getById($fileId)[0] ?? null; + + if ($file === null) { + return new DataResponse(['message' => 'File not found or access denied'], Http::STATUS_FORBIDDEN); + } + + try { + $readable = $file->isReadable(); + } catch (InvalidPathException|NotFoundException $e) { + return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); + } + + if (!$readable) { + return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); + } + + try { + $permissions = $file->getPermissions(); + } catch (InvalidPathException $e) { + return new DataResponse(['message' => 'Access denied'], Http::STATUS_FORBIDDEN); + } catch (NotFoundException $e) { + return new DataResponse(['message' => 'File not found'], Http::STATUS_NOT_FOUND); + } + + $key = $this->config->getSystemValueString(self::JWT_CONFIG_KEY); + $issuedAt = time(); + $expirationTime = $issuedAt + self::EXPIRATION_TIME; + $payload = [ + 'userid' => $userId, + 'fileId' => $fileId, + 'permissions' => $permissions, + 'user' => [ + 'id' => $userId, + 'name' => $user->getDisplayName() + ], + 'iat' => $issuedAt, + 'exp' => $expirationTime + ]; + + $jwt = JWT::encode($payload, $key, self::JWT_ALGORITHM); + + return new DataResponse(['token' => $jwt]); + } +} diff --git a/lib/Controller/WhiteboardController.php b/lib/Controller/WhiteboardController.php index 773b33e..a968f6d 100644 --- a/lib/Controller/WhiteboardController.php +++ b/lib/Controller/WhiteboardController.php @@ -5,39 +5,95 @@ * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Whiteboard\Controller; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use OC\User\NoUserException; use OCP\AppFramework\ApiController; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\Files\IRootFolder; +use OCP\Files\NotPermittedException; +use OCP\IConfig; use OCP\IRequest; use OCP\IUserSession; +/** + * @psalm-suppress UndefinedClass + * @psalm-suppress UndefinedDocblockClass + */ final class WhiteboardController extends ApiController { - - public function __construct($appName, IRequest $request, private IUserSession $userSession, private IRootFolder $rootFolder) { + public function __construct( + $appName, + IRequest $request, + private IUserSession $userSession, + private IRootFolder $rootFolder, + private IConfig $config + ) { parent::__construct($appName, $request); } + /** + * @throws NotPermittedException + * @throws NoUserException + * @throws \JsonException + */ #[NoAdminRequired] #[NoCSRFRequired] + #[PublicPage] public function update(int $fileId, array $data): DataResponse { $user = $this->userSession->getUser(); $userFolder = $this->rootFolder->getUserFolder($user?->getUID()); $file = $userFolder->getById($fileId)[0]; + if (empty($data)) { + $data = ['elements' => [], 'scrollToContent' => true]; + } + $file->putContent(json_encode($data, JSON_THROW_ON_ERROR)); return new DataResponse(['status' => 'success']); } + /** + * @throws NotPermittedException + * @throws NoUserException + * @throws \JsonException + */ #[NoAdminRequired] #[NoCSRFRequired] + #[PublicPage] public function show(int $fileId): DataResponse { - $user = $this->userSession->getUser(); - $userFolder = $this->rootFolder->getUserFolder($user?->getUID()); + $authHeader = $this->request->getHeader('Authorization'); + + if (!$authHeader) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $assignedValues = sscanf($authHeader, 'Bearer %s', $jwt); + + if (!$assignedValues) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + if (!$jwt || !is_string($jwt)) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + try { + $key = $this->config->getSystemValueString('jwt_secret_key'); + $decoded = JWT::decode($jwt, new Key($key, 'HS256')); + $userId = $decoded->userid; + } catch (\Exception $e) { + return new DataResponse(['message' => 'Unauthorized'], Http::STATUS_UNAUTHORIZED); + } + + $userFolder = $this->rootFolder->getUserFolder($userId); $file = $userFolder->getById($fileId)[0]; $fileContent = $file->getContent(); diff --git a/package-lock.json b/package-lock.json index eb05ec6..7e951da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "@nextcloud/initial-state": "^2.2.0", "@nextcloud/l10n": "^3.1.0", "@nextcloud/router": "^3.0.1", + "dotenv": "^16.4.5", "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "node-fetch": "^3.3.2", "react": "^18.3.1", @@ -35,6 +37,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "@vue/tsconfig": "^0.5.1", + "nodemon": "^3.1.3", "prettier": "^3.3.2", "stylelint-config-css-modules": "^4.4.0", "typescript": "^5.4.5", @@ -3293,6 +3296,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -3664,6 +3673,7 @@ "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", "dependencies": { "object-assign": "^4", "vary": "^1" @@ -4127,7 +4137,7 @@ "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -4135,6 +4145,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6265,6 +6284,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/image-size": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", @@ -6874,6 +6900,61 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6999,12 +7080,48 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", "dev": true }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7012,6 +7129,12 @@ "dev": true, "peer": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.truncate": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", @@ -7441,6 +7564,72 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "dev": true }, + "node_modules/nodemon": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.3.tgz", + "integrity": "sha512-m4Vqs+APdKzDFpuaL9F9EVOF85+h070FnkHVEoU4+rmT6Vw0bmNl7s61VEkY/cJkL7RCv1p4urnUDUMrS5rk2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8118,6 +8307,13 @@ "dev": true, "optional": true }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/public-encrypt": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", @@ -8831,6 +9027,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9724,6 +9946,16 @@ "node": ">=0.6" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -10005,6 +10237,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index db03dd5..f796acd 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "lint:fix": "eslint --ext .js,.ts,.vue src websocket_server --fix", "stylelint": "stylelint 'src/**/*.{css,scss,sass}'", "stylelint:fix": "stylelint 'src/**/*.{css,scss,sass}' --fix", - "server:start": "node websocket_server/index.js" + "server:start": "node websocket_server/server.js", + "server:watch": "nodemon websocket_server/server.js" }, "dependencies": { "@excalidraw/excalidraw": "^0.17.6", @@ -24,7 +25,9 @@ "@nextcloud/initial-state": "^2.2.0", "@nextcloud/l10n": "^3.1.0", "@nextcloud/router": "^3.0.1", + "dotenv": "^16.4.5", "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "node-fetch": "^3.3.2", "react": "^18.3.1", @@ -41,6 +44,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "@vue/tsconfig": "^0.5.1", + "nodemon": "^3.1.3", "prettier": "^3.3.2", "stylelint-config-css-modules": "^4.4.0", "typescript": "^5.4.5", diff --git a/src/App.tsx b/src/App.tsx index 35182d8..0b600f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,32 +6,27 @@ // https://github.com/excalidraw/excalidraw/blob/4dc4590f247a0a0d9c3f5d39fe09c00c5cef87bf/examples/excalidraw /* eslint-disable no-console */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { useCallback, useEffect, useRef, useState } from 'react' import { Excalidraw, - exportToClipboard, LiveCollaborationTrigger, MainMenu, - restoreElements, sceneCoordsToViewportCoords, useHandleLibrary, viewportCoordsToSceneCoords } from '@excalidraw/excalidraw' - import './App.scss' -import initialData from './initialData' - -import { nanoid } from 'nanoid' import { distance2d, resolvablePromise, withBatchedUpdates, withBatchedUpdatesThrottled } from './utils' - -import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types' +import type { + AppState, + ExcalidrawImperativeAPI, + ExcalidrawInitialDataState, + PointerDownState +} from '@excalidraw/excalidraw/types/types' import { Collab } from './collaboration/collab' - -declare global { - interface Window { - ExcalidrawLib: any; - } -} +import type { ResolvablePromise } from '@excalidraw/excalidraw/types/utils' +import type { NonDeletedExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' type Comment = { x: number; @@ -40,23 +35,7 @@ type Comment = { id?: string; }; -type PointerDownState = { - x: number; - y: number; - hitElement: Comment; - onMove: any; - onUp: any; - hitElementOffsets: { - x: number; - y: number; - }; -}; -// This is so that we use the bundled excalidraw.development.js file instead -// of the actual source code - const COMMENT_ICON_DIMENSION = 32 -const COMMENT_INPUT_HEIGHT = 50 -const COMMENT_INPUT_WIDTH = 150 interface WhiteboardAppProps { fileId: number; @@ -76,27 +55,31 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { const [theme, setTheme] = useState(darkMode ? 'dark' : 'light') const [isCollaborating, setIsCollaborating] = useState(false) const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>( - {}, + {} ) const [comment, setComment] = useState(null) + const initialData = { + elements: [], + scrollToContent: true + } const initialStatePromiseRef = useRef<{ promise: ResolvablePromise; - }>({promise: null!}) + }>({ promise: null! }) if (!initialStatePromiseRef.current.promise) { - initialStatePromiseRef.current.promise = resolvablePromise() + initialStatePromiseRef.current.promise = resolvablePromise() } const [ excalidrawAPI, - setExcalidrawAPI, + setExcalidrawAPI ] = useState(null) const [collab, setCollab] = useState(null) if (excalidrawAPI && !collab) setCollab(new Collab(excalidrawAPI, fileId)) if (collab && !collab.portal.socket) collab.startCollab() - useHandleLibrary({excalidrawAPI}) + useHandleLibrary({ excalidrawAPI }) useEffect(() => { if (!excalidrawAPI) { @@ -105,8 +88,8 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { const fetchData = async () => { initialStatePromiseRef.current.promise.resolve(initialData) } - fetchData() + fetchData().then() }, [excalidrawAPI]) const renderTopRightUI = (isMobile: boolean) => { @@ -124,58 +107,15 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { ) } - const updateScene = () => { - const sceneData = { - elements: restoreElements( - [ - { - type: 'rectangle', - version: 141, - versionNonce: 361174001, - isDeleted: false, - id: 'oDVXy8D6rom3H1-LLH2-f', - fillStyle: 'hachure', - strokeWidth: 1, - strokeStyle: 'solid', - roughness: 1, - opacity: 100, - angle: 0, - x: 100.50390625, - y: 93.67578125, - strokeColor: '#c92a2a', - backgroundColor: 'transparent', - width: 186.47265625, - height: 141.9765625, - seed: 1968410350, - groupIds: [], - boundElements: null, - locked: false, - link: null, - updated: 1, - roundness: { - type: 3, - value: 32, - }, - }, - ], - null, - ), - appState: { - viewBackgroundColor: '#edf2ff', - }, - } - excalidrawAPI?.updateScene(sceneData) - } - const onLinkOpen = useCallback( ( element: NonDeletedExcalidrawElement, event: CustomEvent<{ nativeEvent: MouseEvent | React.PointerEvent; - }>, + }> ) => { const link = element.link! - const {nativeEvent} = event.detail + const { nativeEvent } = event.detail const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey const isNewWindow = nativeEvent.shiftKey const isInternalLink @@ -187,35 +127,16 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { // ... } }, - [], + [] ) - const onCopy = async (type: 'png' | 'svg' | 'json') => { - if (!excalidrawAPI) { - return false - } - await exportToClipboard({ - elements: excalidrawAPI.getSceneElements(), - appState: excalidrawAPI.getAppState(), - files: excalidrawAPI.getFiles(), - type, - }) - window.alert(`Copied to clipboard as ${type} successfully`) - } - - const [pointerData, setPointerData] = useState<{ - pointer: { x: number; y: number }; - button: 'down' | 'up'; - pointersMap: Gesture['pointers']; - } | null>(null) - const onPointerDown = ( activeTool: AppState['activeTool'], - pointerDownState: ExcalidrawPointerDownState, + pointerDownState: any ) => { if (activeTool.type === 'custom' && activeTool.customType === 'comment') { - const {x, y} = pointerDownState.origin - setComment({x, y, value: ''}) + const { x, y } = pointerDownState.origin + setComment({ x, y, value: '' }) } } @@ -224,14 +145,14 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { return false } const commentIconsElements = appRef.current.querySelectorAll( - '.comment-icon', + '.comment-icon' ) as HTMLElement[] commentIconsElements.forEach((ele) => { const id = ele.id const appstate = excalidrawAPI.getAppState() - const {x, y} = sceneCoordsToViewportCoords( - {sceneX: commentIcons[id].x, sceneY: commentIcons[id].y}, - appstate, + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: commentIcons[id].x, sceneY: commentIcons[id].y }, + appstate ) ele.style.left = `${ x - COMMENT_ICON_DIMENSION / 2 - appstate!.offsetLeft @@ -243,41 +164,41 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { } const onPointerMoveFromPointerDownHandler = ( - pointerDownState: PointerDownState, + pointerDownState: PointerDownState ) => { return withBatchedUpdatesThrottled((event) => { if (!excalidrawAPI) { return false } - const {x, y} = viewportCoordsToSceneCoords( + const { x, y } = viewportCoordsToSceneCoords( { clientX: event.clientX - pointerDownState.hitElementOffsets.x, - clientY: event.clientY - pointerDownState.hitElementOffsets.y, + clientY: event.clientY - pointerDownState.hitElementOffsets.y }, - excalidrawAPI.getAppState(), + excalidrawAPI.getAppState() ) setCommentIcons({ ...commentIcons, [pointerDownState.hitElement.id!]: { ...commentIcons[pointerDownState.hitElement.id!], x, - y, - }, + y + } }) }) } const onPointerUpFromPointerDownHandler = ( - pointerDownState: PointerDownState, + pointerDownState: PointerDownState ) => { return withBatchedUpdates((event) => { window.removeEventListener('pointermove', pointerDownState.onMove) window.removeEventListener('pointerup', pointerDownState.onUp) - excalidrawAPI?.setActiveTool({type: 'selection'}) + excalidrawAPI?.setActiveTool({ type: 'selection' }) const distance = distance2d( pointerDownState.x, pointerDownState.y, event.clientX, - event.clientY, + event.clientY ) if (distance === 0) { if (!comment) { @@ -285,7 +206,7 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { x: pointerDownState.hitElement.x + 60, y: pointerDownState.hitElement.y, value: pointerDownState.hitElement.value, - id: pointerDownState.hitElement.id, + id: pointerDownState.hitElement.id }) } else { setComment(null) @@ -308,8 +229,8 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { x: comment.id ? comment.x - 60 : comment.x, y: comment.y, id, - value: comment.value, - }, + value: comment.value + } }) setComment(null) } @@ -320,9 +241,9 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { return false } const appState = excalidrawAPI.getAppState() - const {x, y} = sceneCoordsToViewportCoords( - {sceneX: commentIcon.x, sceneY: commentIcon.y}, - excalidrawAPI.getAppState(), + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: commentIcon.x, sceneY: commentIcon.y }, + excalidrawAPI.getAppState() ) return (
{ @@ -352,13 +273,13 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { hitElementOffsets: { x: event.clientX - x, y: event.clientY - y - }, + } } const onPointerMove = onPointerMoveFromPointerDownHandler( - pointerDownState, + pointerDownState ) const onPointerUp = onPointerUpFromPointerDownHandler( - pointerDownState, + pointerDownState ) window.addEventListener('pointermove', onPointerMove) window.addEventListener('pointerup', onPointerUp) @@ -368,12 +289,12 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { excalidrawAPI?.setActiveTool({ type: 'custom', - customType: 'comment', + customType: 'comment' }) }} >
- doremon + doremon
) @@ -385,9 +306,9 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { return null } const appState = excalidrawAPI?.getAppState()! - const {x, y} = sceneCoordsToViewportCoords( - {sceneX: comment.x, sceneY: comment.y}, - appState, + const { x, y } = sceneCoordsToViewportCoords( + { sceneX: comment.x, sceneY: comment.y }, + appState ) let top = y - COMMENT_ICON_DIMENSION / 2 - appState.offsetTop let left = x - COMMENT_ICON_DIMENSION / 2 - appState.offsetLeft @@ -420,7 +341,7 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { position: 'absolute', zIndex: 1, height: `${COMMENT_INPUT_HEIGHT}px`, - width: `${COMMENT_INPUT_WIDTH}px`, + width: `${COMMENT_INPUT_WIDTH}px` }} ref={(ref) => { setTimeout(() => ref?.focus()) @@ -428,7 +349,7 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { placeholder={comment.value ? 'Reply' : 'Comment'} value={comment.value} onChange={(event) => { - setComment({...comment, value: event.target.value}) + setComment({ ...comment, value: event.target.value }) }} onBlur={saveComment} onKeyDown={(event) => { @@ -444,25 +365,8 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { const renderMenu = () => { return ( - - - - window.alert('You clicked on collab button')} - /> - - - - - - - + + ) } @@ -478,7 +382,7 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { }} initialData={initialStatePromiseRef.current.promise} onChange={(elements, state) => { - console.info('Elements :', elements, 'State : ', state) + }} onPointerUpdate={collab?.onPointerUpdate} viewModeEnabled={viewModeEnabled} @@ -488,8 +392,8 @@ export default function App({ fileId, isEmbedded }: WhiteboardAppProps) { name="Custom name of drawing" UIOptions={{ canvasActions: { - loadScene: false, - }, + loadScene: false + } }} renderTopRightUI={renderTopRightUI} onLinkOpen={onLinkOpen} diff --git a/src/CustomFooter.tsx b/src/CustomFooter.tsx deleted file mode 100644 index ed14701..0000000 --- a/src/CustomFooter.tsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Excalidraw - * SPDX-License-Identifier: MIT - */ - -// https://github.com/excalidraw/excalidraw/blob/4dc4590f247a0a0d9c3f5d39fe09c00c5cef87bf/examples/excalidraw - -import { Button, MIME_TYPES } from "@excalidraw/excalidraw"; - -const COMMENT_SVG = ( - - - -); -const CustomFooter = ({ - excalidrawAPI -}: { - excalidrawAPI: ExcalidrawImperativeAPI; -}) => { - return ( - <> - - - - - ); -}; - -export default CustomFooter; diff --git a/src/MobileFooter.tsx b/src/MobileFooter.tsx deleted file mode 100644 index 07fec12..0000000 --- a/src/MobileFooter.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2020 Excalidraw - * SPDX-License-Identifier: MIT - */ - -// https://github.com/excalidraw/excalidraw/blob/4dc4590f247a0a0d9c3f5d39fe09c00c5cef87bf/examples/excalidraw - -import { useDevice, Footer } from "@excalidraw/excalidraw"; -import CustomFooter from "./CustomFooter"; - -const MobileFooter = ({ - excalidrawAPI -}: { - excalidrawAPI: ExcalidrawImperativeAPI; -}) => { - const device = useDevice(); - if (device.isMobile) { - return ( -
- -
- ); - } - return null; -}; -export default MobileFooter; diff --git a/src/collaboration/Portal.ts b/src/collaboration/Portal.ts index 1a22194..cc81b17 100644 --- a/src/collaboration/Portal.ts +++ b/src/collaboration/Portal.ts @@ -5,9 +5,16 @@ /* eslint-disable no-console */ import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' -import type { Socket } from 'socket.io-client' +import { io, type Socket } from 'socket.io-client' import type { Collab } from './collab' -import type { Gesture } from '@excalidraw/excalidraw/types/types' +import type { AppState, Gesture } from '@excalidraw/excalidraw/types/types' +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' + +enum BroadcastType { + SceneInit = 'SCENE_INIT', + MouseLocation = 'MOUSE_LOCATION', +} export class Portal { @@ -22,108 +29,131 @@ export class Portal { this.collab = collab } - open(socket: Socket) { - this.socket = socket - - this.socket.on('init-room', () => { - console.log('room initialized') - if (this.socket) { - console.log(`joined room ${this.roomId}`) - this.socket.emit('join-room', this.roomId) + connectSocket = () => { + const collabBackendUrl = loadState('whiteboard', 'collabBackendUrl', 'nextcloud.local:3002') - this.socket.on('joined-data', (data) => { - const remoteElements = JSON.parse(new TextDecoder().decode(data)) + const token = localStorage.getItem(`jwt-${this.roomId}`) || '' - console.log(`JOINED DATA ${new TextDecoder().decode(data)}`) + const socket = io(collabBackendUrl, { + withCredentials: true, + auth: { + token, + }, + }) - const reconciledElements = this.collab._reconcileElements(remoteElements) + this.open(socket) + } - this.collab.handleRemoteSceneUpdate(reconciledElements) + open(socket: Socket) { + this.socket = socket - const elements = this.collab.excalidrawAPI.getSceneElements() + const eventsNeedingTokenRefresh = ['connect_error'] + eventsNeedingTokenRefresh.forEach((event) => + this.socket?.on(event, async () => { + await this.handleTokenRefresh() + }), + ) - this.collab.excalidrawAPI.scrollToContent(elements, { - fitToContent: true, - animate: true, - duration: 500, - }) - }) - } - }) + this.socket.on('read-only', () => this.handleReadOnlySocket()) + this.socket.on('init-room', () => this.handleInitRoom()) + this.socket.on('room-user-change', (users: { + user: { + id: string, + name: string + }, + socketId: string, + pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, + button: 'down' | 'up', + selectedElementIds: AppState['selectedElementIds'] + }[]) => this.collab.updateCollaborators(users)) + this.socket.on('client-broadcast', (data) => this.handleClientBroadcast(data)) + } - this.socket.on('new-user', async (_socketId: string) => { - console.log(`NEW USER ${_socketId}`) + async handleReadOnlySocket() { + this.collab.makeBoardReadOnly() + } - this.broadcastScene('SCENE_INIT', this.collab.getSceneElementsIncludingDeleted()) - }) + async handleTokenRefresh() { + const newToken = await this.refreshJWT() + if (this.socket && newToken) { + this.socket.auth.token = newToken + this.socket?.connect() + } + } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.socket.on('room-user-change', (clients: any) => { - console.log(`ROOM USER CHANGE ${clients}`) + handleInitRoom() { + this.socket?.emit('join-room', this.roomId) + this.socket?.on('joined-data', (data) => { + const remoteElements = JSON.parse(new TextDecoder().decode(data)) + const reconciledElements = this.collab._reconcileElements(remoteElements) + this.collab.handleRemoteSceneUpdate(reconciledElements) + this.collab.scrollToContent() }) + } - this.socket.on('client-broadcast', (data) => { - const decoded = JSON.parse(new TextDecoder().decode(data)) - console.log(decoded) - console.log(data) - - switch (decoded.type) { - case 'SCENE_INIT': { - const remoteElements = decoded.payload.elements - const reconciledElements = this.collab._reconcileElements(remoteElements) - this.collab.handleRemoteSceneUpdate(reconciledElements) - break - } - case 'MOUSE_LOCATION': { - this.collab.updateCollaborator(decoded.payload.socketId, decoded.payload) - } - } - }) + handleClientBroadcast(data: ArrayBuffer) { + const decoded = JSON.parse(new TextDecoder().decode(data)) + switch (decoded.type) { + case BroadcastType.SceneInit: + this.handleSceneInit(decoded.payload.elements) + break + case BroadcastType.MouseLocation: + this.collab.updateCursor(decoded.payload) + break + } + } - return this.socket + handleSceneInit(elements: readonly ExcalidrawElement[]) { + const reconciledElements = this.collab._reconcileElements(elements) + this.collab.handleRemoteSceneUpdate(reconciledElements) } - isOpen() { - return true + async refreshJWT(): Promise { + try { + const response = await axios.get(`/index.php/apps/whiteboard/${this.roomId}/token`, { withCredentials: true }) + const token = response.data.token + if (!token) throw new Error('No token received') + + localStorage.setItem(`jwt-${this.roomId}`, token) + + return token + } catch (error) { + console.error('Error refreshing JWT:', error) + window.location.href = '/index.php/apps/files/files' + return null + } } - async _broadcastSocketData( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any, - volatile: boolean = false, - roomId?: string, - ) { - const json = JSON.stringify(data) + async _broadcastSocketData(data: { + type: string; + payload: { + elements?: readonly ExcalidrawElement[]; + socketId?: string; + pointer?: { x: number; y: number; tool: 'pointer' | 'laser' }; + button?: 'down' | 'up'; + selectedElementIds?: AppState['selectedElementIds']; + username?: string; + }; + }, volatile: boolean = false, roomId?: string) { + const json = JSON.stringify(data) const encryptedBuffer = new TextEncoder().encode(json) + this.socket?.emit(volatile ? 'server-volatile-broadcast' : 'server-broadcast', roomId ?? this.roomId, encryptedBuffer, []) - this.socket?.emit( - volatile ? 'server-volatile-broadcast' : 'server-broadcast', - roomId ?? this.roomId, - encryptedBuffer, - [], - ) } - async broadcastScene( - updateType: string, - elements: readonly ExcalidrawElement[]) { - const data = { - type: updateType, - payload: { - elements, - }, - } - await this._broadcastSocketData(data) + async broadcastScene(updateType: string, elements: readonly ExcalidrawElement[]) { + await this._broadcastSocketData({ type: updateType, payload: { elements } }) } async broadcastMouseLocation(payload: { - pointer: { x: number, y: number, tool: 'pointer' | 'laser' }; + pointer: { x: number; y: number; tool: 'pointer' | 'laser' }; button: 'down' | 'up'; pointersMap: Gesture['pointers']; }) { + const data = { - type: 'MOUSE_LOCATION', + type: BroadcastType.MouseLocation, payload: { socketId: this.socket?.id, pointer: payload.pointer, @@ -132,10 +162,9 @@ export class Portal { username: this.socket?.id, }, } - return this._broadcastSocketData( - data, - true, // volatile - ) + + await this._broadcastSocketData(data, true) + } } diff --git a/src/collaboration/collab.ts b/src/collaboration/collab.ts index f4c30a8..bcefdda 100644 --- a/src/collaboration/collab.ts +++ b/src/collaboration/collab.ts @@ -6,11 +6,9 @@ import type { ExcalidrawElement } from '@excalidraw/excalidraw/types/element/types' import type { AppState, Collaborator, ExcalidrawImperativeAPI, Gesture } from '@excalidraw/excalidraw/types/types' import { Portal } from './Portal' -import { io } from 'socket.io-client' import { restoreElements } from '@excalidraw/excalidraw' import { throttle } from 'lodash' import { hashElementsVersion, reconcileElements } from './util' -import { loadState } from '@nextcloud/initial-state' export class Collab { @@ -24,11 +22,11 @@ export class Collab { this.portal = new Portal(String(fileId), '1', this) } - startCollab() { + async startCollab() { if (this.portal.socket) return - const collabBackendUrl = loadState('whiteboard', 'collabBackendUrl', 'nextcloud.local:3002') - this.portal.open(io(collabBackendUrl)) + this.portal.connectSocket() + this.excalidrawAPI.onChange(this.onChange) } @@ -41,9 +39,7 @@ export class Collab { const localElements = this.getSceneElementsIncludingDeleted() const appState = this.excalidrawAPI.getAppState() - const reconciledElements = reconcileElements(localElements, restoredRemoteElements, appState) - - return reconciledElements + return reconcileElements(localElements, restoredRemoteElements, appState) } handleRemoteSceneUpdate = (elements: ExcalidrawElement[]) => { @@ -75,26 +71,64 @@ export class Collab { payload.pointersMap.size < 2 && this.portal.socket && this.portal.broadcastMouseLocation(payload) } - setCollaborators(socketIds: string[]) { - const collaborators = new Map() - for (const socketId of socketIds) { - collaborators.set(socketId, Object.assign({}, this.collaborators.get(socketId), { - isCurrentUser: socketId === this.portal.socket?.id, - })) - } + updateCollaborators = (users: { + user: { + id: string, + name: string + }, + socketId: string, + pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, + button: 'down' | 'up', + selectedElementIds: AppState['selectedElementIds'] + }[]) => { + const collaborators = new Map() + + users.forEach((payload) => { + collaborators.set(payload.user.id, { + username: payload.user.name, + ...payload, + }) + }) - this.collaborators = collaborators this.excalidrawAPI.updateScene({ collaborators }) - } - updateCollaborator = (socketId: string, updates: Partial) => { - const collaborators = new Map(this.collaborators) - const user = Object.assign({}, collaborators.get(socketId), updates, { isCurrentUser: socketId === this.portal.socket?.id }) - collaborators.set(socketId, user) this.collaborators = collaborators + } + + updateCursor = (payload: { + socketId: string, + pointer: { x: number, y: number, tool: 'pointer' | 'laser' }, + button: 'down' | 'up', + selectedElementIds: AppState['selectedElementIds'], + user: { + id: string, + name: string + } + }) => { + this.excalidrawAPI.updateScene({ + collaborators: this.collaborators.set(payload.user.id, { + ...this.collaborators.get(payload.user.id), + ...payload, + username: payload.user.name, + }), + }) + } + + scrollToContent = () => { + const elements = this.excalidrawAPI.getSceneElements() + + this.excalidrawAPI.scrollToContent(elements, { + fitToContent: true, + animate: true, + duration: 500, + }) + } + makeBoardReadOnly = () => { this.excalidrawAPI.updateScene({ - collaborators, + appState: { + viewModeEnabled: true, + }, }) } diff --git a/src/initialData.js b/src/initialData.js deleted file mode 100644 index 2f12117..0000000 --- a/src/initialData.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -export default { - elements: [], - scrollToContent: true, -} diff --git a/websocket_server/app.js b/websocket_server/app.js new file mode 100644 index 0000000..9666b9f --- /dev/null +++ b/websocket_server/app.js @@ -0,0 +1,14 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import express from 'express' + +const app = express() + +app.get('/', (req, res) => { + res.send('Excalidraw collaboration server is up :)') +}) + +export default app diff --git a/websocket_server/index.js b/websocket_server/index.js deleted file mode 100644 index 410fdad..0000000 --- a/websocket_server/index.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import express from 'express' -import http from 'http' -import https from 'https' -import { Server as SocketIO } from 'socket.io' -import fetch from 'node-fetch' -import * as fs from 'node:fs' - -const nextcloudUrl = process.env.NEXTCLOUD_URL || 'http://nextcloud.local' -const port = process.env.PORT || 3002 - -const tls = process.env.TLS || false -const key = process.env.TLS_KEY || undefined -const cert = process.env.TLS_CERT || undefined - -const app = express() - -app.get('/', (req, res) => { - res.send('Excalidraw collaboration server is up :)') -}) - -const server = (tls ? https : http).createServer({ - key: key ? fs.readFileSync(key) : undefined, - cert: cert ? fs.readFileSync(cert) : undefined, -}, app) - -let roomDataStore = {} - -const getRoomDataFromFile = async (roomID) => { - const response = await fetch(`${nextcloudUrl}/index.php/apps/whiteboard/${roomID}`, { - headers: { - Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), - }, - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - const data = await response.json() - const roomData = data.data - - return JSON.stringify(roomData.elements) -} - -const convertStringToArrayBuffer = (string) => { - return new TextEncoder().encode(string).buffer -} - -const convertArrayBufferToString = (arrayBuffer) => { - return new TextDecoder().decode(arrayBuffer) -} - -const saveRoomDataToFile = async (roomID, data) => { - console.info(`Saving room data to file: ${roomID}`) - - const body = JSON.stringify({ data: { elements: data } }) - - try { - await fetch(`${nextcloudUrl}/index.php/apps/whiteboard/${roomID}`, { - method: 'PUT', - headers: { - Authorization: 'Basic ' + Buffer.from('admin:admin').toString('base64'), - 'Content-Type': 'application/json', - }, - body, - }) - } catch (error) { - console.error(error) - } -} - -const saveAllRoomsData = async () => { - for (const roomID in roomDataStore) { - if (roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) - } - } -} - -const io = new SocketIO(server, { - transports: ['websocket', 'polling'], - cors: { - allowedHeaders: ['X-Requested-With', 'Content-Type', 'Authorization'], - origin: '*', - methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - }, - allowEIO3: true, -}) - -io.on('connection', async (socket) => { - io.to(`${socket.id}`).emit('init-room') - - socket.on('join-room', async (roomID) => { - console.debug(`${socket.id} has joined ${roomID}`) - await socket.join(roomID) - - if (!roomDataStore[roomID]) { - roomDataStore[roomID] = await getRoomDataFromFile(roomID) - } - - socket.emit('joined-data', convertStringToArrayBuffer(roomDataStore[roomID]), []) - - const sockets = await io.in(roomID).fetchSockets() - - if (sockets.length <= 1) { - io.to(`${socket.id}`).emit('first-in-room') - } else { - console.debug(`${socket.id} new-user emitted to room ${roomID}`) - socket.broadcast.to(roomID).emit('new-user', socket.id) - } - - io.in(roomID).emit('room-user-change', sockets.map((socket) => socket.id)) - }) - - socket.on('server-broadcast', (roomID, encryptedData, iv) => { - console.debug(`Broadcasting to room ${roomID}`) - - socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) - - const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) - - setTimeout(() => { - roomDataStore[roomID] = decryptedData.payload.elements - }) - }) - - socket.on('server-volatile-broadcast', (roomID, encryptedData, iv) => { - console.debug(`Volatile broadcasting to room ${roomID}`) - - socket.volatile.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) - - const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) - - console.debug(decryptedData.payload) - - // setTimeout(() => { - // roomDataStore[roomID] = decryptedData.payload.elements - // }) - }) - - socket.on('user-follow', async (payload) => { - console.debug(`User follow action: ${JSON.stringify(payload)}`) - const roomID = `follow@${payload.userToFollow.socketId}` - - switch (payload.action) { - case 'FOLLOW': { - await socket.join(roomID) - - const sockets = await io.in(roomID).fetchSockets() - const followedBy = sockets.map((socket) => socket.id) - - io.to(payload.userToFollow.socketId).emit('user-follow-room-change', followedBy) - - break - } - case 'UNFOLLOW': { - await socket.leave(roomID) - - const sockets = await io.in(roomID).fetchSockets() - const followedBy = sockets.map((socket) => socket.id) - - io.to(payload.userToFollow.socketId).emit('user-follow-room-change', followedBy) - - break - } - } - }) - - socket.on('disconnecting', async () => { - console.debug(`${socket.id} has disconnected`) - - for (const roomID of Array.from(socket.rooms)) { - if (roomID === socket.id) continue - - console.debug(`${socket.id} has left ${roomID}`) - - const otherClients = (await io.in(roomID).fetchSockets()).filter((_socket) => _socket.id !== socket.id) - - // Save room data if no one is in the room - if (otherClients.length === 0 && roomDataStore[roomID]) { - await saveRoomDataToFile(roomID, roomDataStore[roomID]) - - // Flush room data if no one is in the room - delete roomDataStore[roomID] - } - - const isFollowRoom = roomID.startsWith('follow@') - - if (!isFollowRoom && otherClients.length > 0) { - socket.broadcast.to(roomID).emit('room-user-change', otherClients.map((socket) => socket.id)) - } - - if (isFollowRoom && otherClients.length === 0) { - const socketId = roomID.replace('follow@', '') - io.to(socketId).emit('broadcast-unfollow') - } - } - }) - - socket.on('disconnect', async () => { - socket.removeAllListeners() - socket.disconnect() - }) -}) - -// Save all rooms data every 1 hour -const interval = setInterval(saveAllRoomsData, 60 * 60 * 1000) - -// Graceful Shutdown -const gracefulShutdown = async () => { - console.debug('Received shutdown signal, saving all data...') - await saveAllRoomsData() - console.debug('All data saved, shutting down server...') - clearInterval(interval) - roomDataStore = {} - - // Close the server gracefully - server.close(() => { - console.debug('HTTP server closed.') - - // eslint-disable-next-line n/no-process-exit - process.exit(0) - }) - - // Force close the server if it doesn't close within 1 minute - setTimeout(() => { - console.error('Force closing server after 1 minute.') - - // eslint-disable-next-line n/no-process-exit - process.exit(1) - }, 60 * 1000) - - io.close(() => { - console.debug('Socket server closed.') - }) -} - -// Handle shutdown signals -process.on('SIGTERM', gracefulShutdown) -process.on('SIGINT', gracefulShutdown) - -server.listen(port, () => { - console.debug(`listening on port: ${port}`) -}) diff --git a/websocket_server/roomData.js b/websocket_server/roomData.js new file mode 100644 index 0000000..6354fdd --- /dev/null +++ b/websocket_server/roomData.js @@ -0,0 +1,86 @@ +/* eslint-disable no-console */ + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import fetch from 'node-fetch' +import dotenv from 'dotenv' + +dotenv.config() + +const { + NEXTCLOUD_URL = 'http://nextcloud.local', + ADMIN_USER = 'admin', + ADMIN_PASS = 'admin', +} = process.env + +export const roomDataStore = {} + +const fetchOptions = (method, token, body = null) => { + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + } + + if (method === 'PUT') { + headers.Authorization = 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64') + } + + return { + method, + headers, + ...(body && { body: JSON.stringify(body) }), + } +} + +const fetchData = async (url, options, socket = null, roomID = '') => { + try { + const response = await fetch(url, options) + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + + return response.json() + } catch (error) { + console.error(error) + if (socket) { + socket.emit('error', { message: 'Failed to get room data' }) + socket.leave(roomID) + } + return null + } +} + +export const getRoomDataFromFile = async (roomID, socket) => { + const token = socket.handshake.auth.token + const url = `${NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` + const options = fetchOptions('GET', token) + + const result = await fetchData(url, options, socket, roomID) + return result ? result.data.elements : null +} + +// Called when there's nobody in the room (No one keeping the latest data), BE to BE communication +export const saveRoomDataToFile = async (roomID, data) => { + console.log(`Saving room data to file: ${roomID}`) + const url = `${NEXTCLOUD_URL}/index.php/apps/whiteboard/${roomID}` + const body = { data: { elements: data } } + const options = fetchOptions('PUT', '', body) + + await fetchData(url, options) +} + +// TODO: Should be called when the server is shutting down and a should be a BE to BE (or OS) communication +// in batch operation, run in background and check if it's necessary to save for each room. +// Should be called periodically and saved somewhere else for preventing data loss (memory loss, server crash, electricity cut, etc.) +export const saveAllRoomsData = async () => { +} + +export const removeAllRoomData = async () => { + for (const roomID in roomDataStore) { + if (Object.prototype.hasOwnProperty.call(roomDataStore, roomID)) { + delete roomDataStore[roomID] + } + } +} diff --git a/websocket_server/server.js b/websocket_server/server.js new file mode 100644 index 0000000..1ac4c40 --- /dev/null +++ b/websocket_server/server.js @@ -0,0 +1,74 @@ +/* eslint-disable no-console */ +/* eslint-disable n/no-process-exit */ + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import http from 'http' +import https from 'https' +import fs from 'fs' +import app from './app.js' +import { initSocket } from './socket.js' +import { removeAllRoomData, saveAllRoomsData } from './roomData.js' +import dotenv from 'dotenv' +import { parseBooleanFromEnv } from './utils.js' + +dotenv.config() + +const { + PORT = 3002, + TLS, + TLS_KEY: keyPath, + TLS_CERT: certPath, +} = process.env + +const FORCE_CLOSE_TIMEOUT = 60 * 60 * 1000 + +const readTlsCredentials = (keyPath, certPath) => ({ + key: keyPath ? fs.readFileSync(keyPath) : undefined, + cert: certPath ? fs.readFileSync(certPath) : undefined, +}) + +const createConfiguredServer = (app) => { + const useTls = parseBooleanFromEnv(TLS) + const serverType = useTls ? https : http + const serverOptions = useTls ? readTlsCredentials(keyPath, certPath) : {} + + return serverType.createServer(serverOptions, app) +} + +const server = createConfiguredServer(app) + +initSocket(server) + +server.listen(PORT, () => { + console.log(`Listening on port: ${PORT}`) +}) + +export const gracefulShutdown = async (server) => { + console.log('Received shutdown signal, saving all data...') + await saveAllRoomsData() + + console.log('Clear all room data...') + await removeAllRoomData() + + console.log('Closing server...') + server.close(() => { + console.log('HTTP server closed.') + process.exit(0) + }) + + setTimeout(() => { + console.error('Force closing server after 1 hour') + process.exit(1) + }, FORCE_CLOSE_TIMEOUT) +} + +const shutdown = async () => { + await gracefulShutdown(server) // Perform graceful shutdown tasks +} + +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) diff --git a/websocket_server/socket.js b/websocket_server/socket.js new file mode 100644 index 0000000..4c2d7bc --- /dev/null +++ b/websocket_server/socket.js @@ -0,0 +1,153 @@ +/* eslint-disable no-console */ + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { Server as SocketIO } from 'socket.io' +import jwt from 'jsonwebtoken' +import { getRoomDataFromFile, roomDataStore, saveRoomDataToFile } from './roomData.js' +import { convertArrayBufferToString, convertStringToArrayBuffer } from './utils.js' +import dotenv from 'dotenv' + +dotenv.config() + +const { + NEXTCLOUD_URL = 'http://nextcloud.local', + JWT_SECRET_KEY, +} = process.env + +const verifyToken = (token) => new Promise((resolve, reject) => { + jwt.verify(token, JWT_SECRET_KEY, (err, decoded) => { + if (err) { + console.log(err.name === 'TokenExpiredError' ? 'Token expired' : 'Token verification failed') + + return reject(new Error('Authentication error')) + } + + resolve(decoded) + }) +}) + +export const initSocket = (server) => { + const io = new SocketIO(server, { + transports: ['websocket', 'polling'], + cors: { + origin: NEXTCLOUD_URL, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + credentials: true, + }, + }) + + io.use(socketAuthenticateHandler) + + io.on('connection', (socket) => { + setupSocketEvents(socket, io) + }) +} + +const setupSocketEvents = (socket, io) => { + socket.emit('init-room') + socket.on('join-room', (roomID) => joinRoomHandler(socket, io, roomID)) + socket.on('server-broadcast', (roomID, encryptedData, iv) => serverBroadcastHandler(socket, io, roomID, encryptedData, iv)) + socket.on('server-volatile-broadcast', (roomID, encryptedData) => serverVolatileBroadcastHandler(socket, roomID, encryptedData)) + socket.on('disconnecting', () => disconnectingHandler(socket, io)) + socket.on('disconnect', () => socket.removeAllListeners()) +} + +const socketAuthenticateHandler = async (socket, next) => { + try { + const token = socket.handshake.auth.token || null + if (!token) { + console.error('No token provided') + next(new Error('Authentication error')) + } + + socket.decodedData = await verifyToken(token) + + console.log(`User ${socket.decodedData.user.name} with permission ${socket.decodedData.permissions} connected`) + + if (isSocketReadOnly(socket)) { + socket.emit('read-only') + } + + next() + } catch (error) { + console.error(error.message) + + next(new Error('Authentication error')) + } +} + +const joinRoomHandler = async (socket, io, roomID) => { + console.log(`${socket.decodedData.user.name} has joined ${roomID}`) + await socket.join(roomID) + + if (!roomDataStore[roomID]) { + console.log(`Data for room ${roomID} is not available, fetching from file ...`) + roomDataStore[roomID] = await getRoomDataFromFile(roomID, socket) + } + + socket.emit('joined-data', convertStringToArrayBuffer(JSON.stringify(roomDataStore[roomID])), []) + + const sockets = await io.in(roomID).fetchSockets() + + io.in(roomID).emit('room-user-change', sockets.map((s) => ({ + socketId: s.id, + user: s.decodedData.user, + }))) +} + +const serverBroadcastHandler = (socket, io, roomID, encryptedData, iv) => { + if (isSocketReadOnly(socket)) return + + setTimeout(() => { + const decryptedData = JSON.parse(convertArrayBufferToString(encryptedData)) + + roomDataStore[roomID] = decryptedData.payload.elements + }) + + socket.broadcast.to(roomID).emit('client-broadcast', encryptedData, iv) +} + +const serverVolatileBroadcastHandler = (socket, roomID, encryptedData) => { + const payload = JSON.parse(convertArrayBufferToString(encryptedData)) + + if (payload.type === 'MOUSE_LOCATION') { + const eventData = { + type: 'MOUSE_LOCATION', + payload: { + ...payload.payload, + user: socket.decodedData.user, + }, + } + + const encodedEventData = convertStringToArrayBuffer(JSON.stringify(eventData)) + + socket.volatile.broadcast.to(roomID).emit('client-broadcast', encodedEventData) + } +} + +const disconnectingHandler = async (socket, io) => { + console.log(`${socket.decodedData.user.name} has disconnected`) + for (const roomID of Array.from(socket.rooms)) { + if (roomID === socket.id) continue + console.log(`${socket.decodedData.user.name} has left ${roomID}`) + const otherClients = (await io.in(roomID).fetchSockets()).filter((s) => s.id !== socket.id) + + if (otherClients.length === 0 && roomDataStore[roomID]) { + await saveRoomDataToFile(roomID, roomDataStore[roomID]) + // delete roomDataStore[roomID] + } + + if (otherClients.length > 0) { + socket.broadcast.to(roomID).emit('room-user-change', otherClients.map((s) => ({ + socketId: s.id, + user: s.decodedData.user, + }))) + } + } +} + +const isSocketReadOnly = (socket) => socket.decodedData.permissions === 1 diff --git a/websocket_server/utils.js b/websocket_server/utils.js new file mode 100644 index 0000000..31abcd7 --- /dev/null +++ b/websocket_server/utils.js @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export const convertStringToArrayBuffer = (string) => new TextEncoder().encode(string).buffer +export const convertArrayBufferToString = (arrayBuffer) => new TextDecoder().decode(arrayBuffer) +export const parseBooleanFromEnv = (value) => value === 'true'