diff --git a/.gitignore b/.gitignore index 58498d6ad..203714a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ cypress/screenshots cypress/snapshots .php-cs-fixer.cache + +tests/.phpunit.result.cache diff --git a/appinfo/info.xml b/appinfo/info.xml index 3e20a5baf..88cf88d86 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -77,4 +77,9 @@ OCA\Social\Providers\ContactsMenuProvider + + + OCA\Social\Settings\Personal + OCA\Social\Settings\PersonalSection + diff --git a/appinfo/routes.php b/appinfo/routes.php index 43315413e..3ef5ea911 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -67,8 +67,8 @@ ['name' => 'OStatus#getLink', 'url' => '/api/v1/ostatus/link/{local}/{account}', 'verb' => 'GET'], // OAuth - ['name' => 'OAuth#nodeinfo', 'url' => '/.well-known/nodeinfo', 'verb' => 'GET'], - ['name' => 'OAuth#nodeinfo2', 'url' => '/.well-known/nodeinfo/2.0', 'verb' => 'GET'], + ['name' => 'OAuth#index', 'url' => '/.well-known/nodeinfo', 'verb' => 'GET'], + ['name' => 'OAuth#show', 'url' => '/.well-known/nodeinfo/2.{version}{extension}', 'verb' => 'GET'], ['name' => 'OAuth#apps', 'url' => '/api/v1/apps', 'verb' => 'POST'], ['name' => 'OAuth#authorize', 'url' => '/oauth/authorize', 'verb' => 'GET'], ['name' => 'OAuth#token', 'url' => '/oauth/token', 'verb' => 'POST'], @@ -82,6 +82,16 @@ ['name' => 'Api#timelines', 'url' => '/api/v1/timelines/{timeline}/', 'verb' => 'GET'], ['name' => 'Api#notifications', 'url' => '/api/v1/notifications', 'verb' => 'GET'], + ['name' => 'MediaApi#uploadMedia', 'url' => '/api/v1/media', 'verb' => 'POST'], + ['name' => 'MediaApi#updateMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'PUT'], + ['name' => 'MediaApi#deleteMedia', 'url' => '/api/v1/media/{id}', 'verb' => 'DELETE'], + + ['name' => 'StatusApi#publishStatus', 'url' => '/api/v1/statuses', 'verb' => 'POST'], + ['name' => 'StatusApi#getStatus', 'url' => '/api/v1/statuses/{id}', 'verb' => 'GET'], + ['name' => 'StatusApi#deleteStatus', 'url' => '/api/v1/statuses/{id}', 'verb' => 'DELETE'], + ['name' => 'StatusApi#contextStatus', 'url' => '/api/v1/statuses/{id}/context', 'verb' => 'GET'], + ['name' => 'StatusApi#reblogedBy', 'url' => '/api/v1/statuses/{id}/reblogged_by', 'verb' => 'GET'], + // Api for local front-end // TODO: front-end should be using the new ApiController ['name' => 'Local#streamHome', 'url' => '/api/v1/stream/home', 'verb' => 'GET'], diff --git a/composer.json b/composer.json index b2e48943e..b029b7a25 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ }, "require": { "gumlet/php-image-resize": "2.0.*", - "friendica/json-ld": "^1.0" + "friendica/json-ld": "^1.0", + "landrok/activitypub": "^0.5.8" }, "require-dev": { "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index 5d400544b..b5567caaa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "27094b58c53da78123011af974a4cfdd", + "content-hash": "bcc84820727b59f399bdb39802db821a", "packages": [ { "name": "friendica/json-ld", @@ -108,6 +108,1776 @@ "source": "https://github.com/gumlet/php-image-resize/tree/2.0.3" }, "time": "2022-06-21T16:20:34+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.4.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", + "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5", + "guzzlehttp/psr7": "^1.9 || ^2.4", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "ext-curl": "*", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^8.5.5 || ^9.3.5", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.4-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.4.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2022-06-20T22:16:13+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "1.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "reference": "fe752aedc9fd8fcca3fe7ad05d419d32998a06da", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4 || ^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2021-10-22T20:56:57+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "13388f00956b1503577598873fffb5ae994b5737" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/13388f00956b1503577598873fffb5ae994b5737", + "reference": "13388f00956b1503577598873fffb5ae994b5737", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^8.5.8 || ^9.3.10" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.4.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2022-06-20T21:43:11+00:00" + }, + { + "name": "landrok/activitypub", + "version": "0.5.8", + "source": { + "type": "git", + "url": "https://github.com/landrok/activitypub.git", + "reference": "f30b8f726cf1a196337ec065536eba2d66a4b329" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/landrok/activitypub/zipball/f30b8f726cf1a196337ec065536eba2d66a4b329", + "reference": "f30b8f726cf1a196337ec065536eba2d66a4b329", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": ">=6.3", + "monolog/monolog": "^1.12|^2.0|^3.0", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0.7", + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/cache": ">=4.0", + "symfony/http-foundation": ">=3.4" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "ActivityPhp\\": "src/ActivityPhp/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP implementation of ActivityPub protocol based upon the ActivityStreams 2.0 data format.", + "homepage": "https://github.com/landrok/activitypub", + "keywords": [ + "Federation", + "activitypub", + "activitystreams" + ], + "support": { + "issues": "https://github.com/landrok/activitypub/issues", + "source": "https://github.com/landrok/activitypub/tree/0.5.8" + }, + "time": "2022-06-07T11:22:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "5579edf28aee1190a798bfa5be8bc16c563bd524" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5579edf28aee1190a798bfa5be8bc16c563bd524", + "reference": "5579edf28aee1190a798bfa5be8bc16c563bd524", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.3", + "phpspec/prophecy": "^1.15", + "phpstan/phpstan": "^0.12.91", + "phpunit/phpunit": "^8.5.14", + "predis/predis": "^1.1", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2022-06-09T08:59:12+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.14", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "reference": "2f0b7af658cbea265cbb4a791d6c29a6613f98ef", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.14" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2022-04-04T05:15:45+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/container", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.2" + }, + "time": "2021-11-05T16:50:12+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/master" + }, + "time": "2020-06-29T06:28:15+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/master" + }, + "time": "2019-04-30T12:38:16+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/master" + }, + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/cache", + "version": "v5.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "c4e387b739022fd4b20abd8edb2143c44c5daa14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/c4e387b739022fd4b20abd8edb2143c44c5daa14", + "reference": "c4e387b739022fd4b20abd8edb2143c44c5daa14", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/var-exporter": "^4.4|^5.0|^6.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<4.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0|2.0", + "psr/simple-cache-implementation": "1.0|2.0", + "symfony/cache-implementation": "1.0|2.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "^1.6|^2.0", + "doctrine/dbal": "^2.13.1|^3.0", + "predis/predis": "^1.1", + "psr/simple-cache": "^1.0|^2.0", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/filesystem": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0", + "symfony/messenger": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an extended PSR-6, PSR-16 (and tags) implementation", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v5.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-19T12:03:50+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0|^3.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v5.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "e7793b7906f72a8cc51054fbca9dcff7a8af1c1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/e7793b7906f72a8cc51054fbca9dcff7a8af1c1e", + "reference": "e7793b7906f72a8cc51054fbca9dcff7a8af1c1e", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "predis/predis": "~1.0", + "symfony/cache": "^4.4|^5.0|^6.0", + "symfony/expression-language": "^4.4|^5.0|^6.0", + "symfony/mime": "^4.4|^5.0|^6.0" + }, + "suggest": { + "symfony/mime": "To use the file extension guesser" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v5.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-19T13:13:40+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85", + "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-24T11:49:31+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.26-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-10T07:21:04+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.1", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-30T19:17:29+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v5.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "8fc03ee75eeece3d9be1ef47d26d79bea1afb340" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/8fc03ee75eeece3d9be1ef47d26d79bea1afb340", + "reference": "8fc03ee75eeece3d9be1ef47d26d79bea1afb340", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "symfony/var-dumper": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v5.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-27T12:56:18+00:00" } ], "packages-dev": [ @@ -2127,103 +3897,6 @@ ], "time": "2022-06-19T12:14:25+00:00" }, - { - "name": "psr/cache", - "version": "1.0.1", - "source": { - "type": "git", - "url": "https://github.com/php-fig/cache.git", - "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", - "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Cache\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Common interface for caching libraries", - "keywords": [ - "cache", - "psr", - "psr-6" - ], - "support": { - "source": "https://github.com/php-fig/cache/tree/master" - }, - "time": "2016-08-06T20:24:11+00:00" - }, - { - "name": "psr/container", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" - }, - "time": "2021-11-05T16:50:12+00:00" - }, { "name": "psr/event-dispatcher", "version": "1.0.0", @@ -2274,56 +3947,6 @@ }, "time": "2019-01-08T18:20:26+00:00" }, - { - "name": "psr/log", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" - }, - "time": "2021-05-03T11:20:27+00:00" - }, { "name": "sebastian/cli-parser", "version": "1.0.1", @@ -3334,89 +4957,16 @@ "suggest": { "psr/log": "For using the console logger", "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command line", - "console", - "terminal" - ], - "support": { - "source": "https://github.com/symfony/console/tree/v5.4.11" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-07-22T10:42:43+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v2.5.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } + "symfony/lock": "", + "symfony/process": "" }, + "type": "library", "autoload": { - "files": [ - "function.php" + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -3425,18 +4975,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command line", + "console", + "terminal" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + "source": "https://github.com/symfony/console/tree/v5.4.11" }, "funding": [ { @@ -3452,7 +5008,7 @@ "type": "tidelift" } ], - "time": "2022-01-02T09:53:40+00:00" + "time": "2022-07-22T10:42:43+00:00" }, { "name": "symfony/event-dispatcher", @@ -3696,243 +5252,19 @@ "reference": "231313534dded84c7ecaa79d14bc5da4ccb69b7d", "shasum": "" }, - "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Finder\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Finds files and directories via an intuitive fluent interface", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.3" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-01-26T16:34:36+00:00" - }, - { - "name": "symfony/options-resolver", - "version": "v5.4.3", - "source": { - "type": "git", - "url": "https://github.com/symfony/options-resolver.git", - "reference": "cc1147cb11af1b43f503ac18f31aa3bec213aba8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/cc1147cb11af1b43f503ac18f31aa3bec213aba8", - "reference": "cc1147cb11af1b43f503ac18f31aa3bec213aba8", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php73": "~1.0", - "symfony/polyfill-php80": "^1.16" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\OptionsResolver\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an improved replacement for the array_replace PHP function", - "homepage": "https://symfony.com", - "keywords": [ - "config", - "configuration", - "options" - ], - "support": { - "source": "https://github.com/symfony/options-resolver/tree/v5.4.3" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-01-02T09:53:40+00:00" - }, - { - "name": "symfony/polyfill-ctype", - "version": "v1.26.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", - "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "provide": { - "ext-ctype": "*" - }, - "suggest": { - "ext-ctype": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.26-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for ctype functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" - ], - "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-05-24T11:49:31+00:00" - }, - { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.26.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "433d05519ce6990bf3530fba6957499d327395c2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/433d05519ce6990bf3530fba6957499d327395c2", - "reference": "433d05519ce6990bf3530fba6957499d327395c2", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.26-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3940,26 +5272,18 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.26.0" + "source": "https://github.com/symfony/finder/tree/v5.4.3" }, "funding": [ { @@ -3975,47 +5299,35 @@ "type": "tidelift" } ], - "time": "2022-05-24T11:49:31+00:00" + "time": "2022-01-26T16:34:36+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.26.0", + "name": "symfony/options-resolver", + "version": "v5.4.3", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "219aa369ceff116e673852dce47c3a41794c14bd" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "cc1147cb11af1b43f503ac18f31aa3bec213aba8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", - "reference": "219aa369ceff116e673852dce47c3a41794c14bd", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/cc1147cb11af1b43f503ac18f31aa3bec213aba8", + "reference": "cc1147cb11af1b43f503ac18f31aa3bec213aba8", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php73": "~1.0", + "symfony/polyfill-php80": "^1.16" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.26-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Component\\OptionsResolver\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -4024,26 +5336,23 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Provides an improved replacement for the array_replace PHP function", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" + "config", + "configuration", + "options" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" + "source": "https://github.com/symfony/options-resolver/tree/v5.4.3" }, "funding": [ { @@ -4059,30 +5368,30 @@ "type": "tidelift" } ], - "time": "2022-05-24T11:49:31+00:00" + "time": "2022-01-02T09:53:40+00:00" }, { - "name": "symfony/polyfill-mbstring", + "name": "symfony/polyfill-ctype", "version": "v1.26.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", - "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", "shasum": "" }, "require": { "php": ">=7.1" }, "provide": { - "ext-mbstring": "*" + "ext-ctype": "*" }, "suggest": { - "ext-mbstring": "For best performance" + "ext-ctype": "For best performance" }, "type": "library", "extra": { @@ -4099,7 +5408,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" + "Symfony\\Polyfill\\Ctype\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -4108,25 +5417,24 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill for ctype functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", + "ctype", "polyfill", - "portable", - "shim" + "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" }, "funding": [ { @@ -4145,22 +5453,25 @@ "time": "2022-05-24T11:49:31+00:00" }, { - "name": "symfony/polyfill-php73", + "name": "symfony/polyfill-intl-grapheme", "version": "v1.26.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "433d05519ce6990bf3530fba6957499d327395c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/e440d35fa0286f77fb45b79a03fedbeda9307e85", - "reference": "e440d35fa0286f77fb45b79a03fedbeda9307e85", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/433d05519ce6990bf3530fba6957499d327395c2", + "reference": "433d05519ce6990bf3530fba6957499d327395c2", "shasum": "" }, "require": { "php": ">=7.1" }, + "suggest": { + "ext-intl": "For best performance" + }, "type": "library", "extra": { "branch-alias": { @@ -4176,11 +5487,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4196,16 +5504,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "grapheme", + "intl", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.26.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.26.0" }, "funding": [ { @@ -4224,22 +5534,25 @@ "time": "2022-05-24T11:49:31+00:00" }, { - "name": "symfony/polyfill-php80", + "name": "symfony/polyfill-intl-normalizer", "version": "v1.26.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", - "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd", "shasum": "" }, "require": { "php": ">=7.1" }, + "suggest": { + "ext-intl": "For best performance" + }, "type": "library", "extra": { "branch-alias": { @@ -4255,7 +5568,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, "classmap": [ "Resources/stubs" @@ -4266,10 +5579,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -4279,16 +5588,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "intl", + "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" }, "funding": [ { @@ -4304,7 +5615,7 @@ "type": "tidelift" } ], - "time": "2022-05-10T07:21:04+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-php81", @@ -4447,89 +5758,6 @@ ], "time": "2022-03-18T16:18:52+00:00" }, - { - "name": "symfony/service-contracts", - "version": "v2.5.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "suggest": { - "symfony/service-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-05-30T19:17:29+00:00" - }, { "name": "symfony/stopwatch", "version": "v5.4.5", diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 664798c00..ed8c904c1 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -31,22 +31,19 @@ namespace OCA\Social\AppInfo; -use Closure; +use OCA\Social\Entity\Account; use OCA\Social\Notification\Notifier; use OCA\Social\Search\UnifiedSearchProvider; -use OCA\Social\Service\ConfigService; -use OCA\Social\Service\UpdateService; +use OCA\Social\Serializer\AccountSerializer; +use OCA\Social\Serializer\SerializerFactory; +use OCA\Social\Service\Feed\RedisFeedProvider; +use OCA\Social\Service\Feed\IFeedProvider; use OCA\Social\WellKnown\WebfingerHandler; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; -use OCP\AppFramework\QueryException; -use OCP\IDBConnection; -use OCP\IServerContainer; -use OC\DB\SchemaWrapper; -use OCP\DB\ISchemaWrapper; -use Throwable; +use Psr\Container\ContainerInterface; require_once __DIR__ . '/../../vendor/autoload.php'; @@ -69,50 +66,21 @@ public function __construct(array $params = []) { public function register(IRegistrationContext $context): void { $context->registerSearchProvider(UnifiedSearchProvider::class); $context->registerWellKnownHandler(WebfingerHandler::class); - } - + $context->registerNotifierService(Notifier::class); - /** - * @param IBootContext $context - */ - public function boot(IBootContext $context): void { - $manager = $context->getServerContainer() - ->getNotificationManager(); - $manager->registerNotifierService(Notifier::class); + /** @var SerializerFactory $serializerFactory */ + $serializerFactory = $this->getContainer()->get(SerializerFactory::class); + $serializerFactory->registerSerializer(Account::class, AccountSerializer::class); - try { - $context->injectFn(Closure::fromCallable([$this, 'checkUpgradeStatus'])); - } catch (Throwable $e) { - } + $context->registerService(IFeedProvider::class, function (ContainerInterface $container): IFeedProvider { + return $container->get(RedisFeedProvider::class); + }); } /** - * Register Navigation Tab - * - * @param IServerContainer $container + * @param IBootContext $context */ - protected function checkUpgradeStatus(IServerContainer $container) { - $upgradeChecked = $container->getConfig() - ->getAppValue(Application::APP_NAME, 'update_checked', ''); - - if ($upgradeChecked === '0.3') { - return; - } - - try { - $configService = $container->query(ConfigService::class); - $updateService = $container->query(UpdateService::class); - } catch (QueryException $e) { - return; - } - - /** @var ISchemaWrapper $schema */ - $schema = new SchemaWrapper($container->get(IDBConnection::class)); - if ($schema->hasTable('social_a2_stream')) { - $updateService->checkUpdateStatus(); - } - - $configService->setAppValue('update_checked', '0.3'); + public function boot(IBootContext $context): void { } } diff --git a/lib/Command/AccountFollowing.php b/lib/Command/AccountFollowing.php index 66fbb4cfa..b95cf25ef 100644 --- a/lib/Command/AccountFollowing.php +++ b/lib/Command/AccountFollowing.php @@ -33,9 +33,11 @@ use Exception; use OC\Core\Command\Base; +use OCA\Social\Service\AccountFinder; use OCA\Social\Service\AccountService; use OCA\Social\Service\CacheActorService; use OCA\Social\Service\ConfigService; +use OCA\Social\Service\FollowOption; use OCA\Social\Service\FollowService; use OCA\Social\Service\MiscService; use Symfony\Component\Console\Input\InputArgument; @@ -44,23 +46,18 @@ use Symfony\Component\Console\Output\OutputInterface; class AccountFollowing extends Base { - private AccountService $accountService; + private AccountFinder $accountFinder; private CacheActorService $cacheActorService; private FollowService $followService; - private ConfigService $configService; - private MiscService $miscService; public function __construct( - AccountService $accountService, CacheActorService $cacheActorService, - FollowService $followService, ConfigService $configService, MiscService $miscService + AccountFinder $accountFinder, FollowService $followService, ConfigService $configService ) { parent::__construct(); - $this->accountService = $accountService; - $this->cacheActorService = $cacheActorService; + $this->accountFinder = $accountFinder; $this->followService = $followService; $this->configService = $configService; - $this->miscService = $miscService; } protected function configure() { @@ -80,16 +77,16 @@ protected function execute(InputInterface $input, OutputInterface $output) { $userId = $input->getArgument('userId'); $account = $input->getArgument('account'); - $actor = $this->accountService->getActor($userId); + $sourceAccount = $this->accountFinder->getAccountByNextcloudId($userId); + if ($input->getOption('local')) { - $local = $this->cacheActorService->getFromLocalAccount($account); - $account = $local->getAccount(); + $targetAccount = $this->accountFinder->getAccountByNextcloudId($account); } if ($input->getOption('unfollow')) { - $this->followService->unfollowAccount($actor, $account); + $this->followService->unfollow($sourceAccount, $targetAccount, FollowOption::default()); } else { - $this->followService->followAccount($actor, $account); + $this->followService->follow($sourceAccount, $targetAccount, FollowOption::default()); } } } diff --git a/lib/Controller/ActivityPubController.php b/lib/Controller/ActivityPubController.php index 61a400df4..2b59c215e 100644 --- a/lib/Controller/ActivityPubController.php +++ b/lib/Controller/ActivityPubController.php @@ -30,6 +30,8 @@ namespace OCA\Social\Controller; +use OCA\Social\Entity\Account; +use OCA\Social\Serializer\SerializerFactory; use OCA\Social\Tools\Traits\TNCLogger; use OCA\Social\Tools\Traits\TNCDataResponse; use OCA\Social\Tools\Traits\TAsync; @@ -57,6 +59,8 @@ use OCP\AppFramework\Controller; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\Response; +use OCP\DB\ORM\IEntityManager; +use OCP\DB\ORM\IEntityRepository; use OCP\IRequest; class ActivityPubController extends Controller { @@ -76,13 +80,14 @@ class ActivityPubController extends Controller { private StreamService $streamService; private ConfigService $configService; private MiscService $miscService; + private IEntityManager $entityManager; public function __construct( IRequest $request, SocialPubController $socialPubController, FediverseService $fediverseService, CacheActorService $cacheActorService, SignatureService $signatureService, StreamQueueService $streamQueueService, ImportService $importService, AccountService $accountService, FollowService $followService, StreamService $streamService, ConfigService $configService, - MiscService $miscService + MiscService $miscService, IEntityManager $entityManager, SerializerFactory $serializerFactory ) { parent::__construct(Application::APP_NAME, $request); @@ -97,11 +102,12 @@ public function __construct( $this->streamService = $streamService; $this->configService = $configService; $this->miscService = $miscService; + $this->entityManager = $entityManager; } /** - * returns information about an Actor, based on the username. + * Returns the actor information * * This method should be called when a remote ActivityPub server require information * about a local Social account @@ -111,9 +117,6 @@ public function __construct( * @NoCSRFRequired * @PublicPage * - * @param string $username - * - * @return Response * @throws UrlCloudException * @throws SocialAppConfigException */ @@ -122,15 +125,17 @@ public function actor(string $username): Response { return $this->socialPubController->actor($username); } - try { - $actor = $this->cacheActorService->getFromLocalAccount($username); - $actor->setDisplayW3ContextSecurity(true); - - return $this->directSuccess($actor); - } catch (Exception $e) { + /** @var IEntityRepository $accountRepository */ + $accountRepository = $this->entityManager->getRepository(Account::class); + $account = $accountRepository->findOneBy([ + 'userName' => $username, + ]); + if ($account === null || !$account->isLocal()) { http_response_code(404); exit(); } + + return $account->toJsonLd($this->request); } diff --git a/lib/Controller/ApiController.php b/lib/Controller/ApiController.php index a6dc9fba0..873e1ae9f 100644 --- a/lib/Controller/ApiController.php +++ b/lib/Controller/ApiController.php @@ -43,7 +43,7 @@ use OCA\Social\Model\Client\SocialClient; use OCA\Social\Service\AccountService; use OCA\Social\Service\CacheActorService; -use OCA\Social\Service\ClientService; +use OCA\Social\Service\ApplicationService; use OCA\Social\Service\ConfigService; use OCA\Social\Service\FollowService; use OCA\Social\Service\InstanceService; @@ -65,7 +65,7 @@ class ApiController extends Controller { private IUserSession $userSession; private InstanceService $instanceService; - private ClientService $clientService; + private ApplicationService $clientService; private AccountService $accountService; private CacheActorService $cacheActorService; private FollowService $followService; @@ -77,10 +77,10 @@ class ApiController extends Controller { private ?Person $viewer = null; public function __construct( - IRequest $request, IUserSession $userSession, InstanceService $instanceService, - ClientService $clientService, AccountService $accountService, CacheActorService $cacheActorService, - FollowService $followService, StreamService $streamService, ConfigService $configService, - MiscService $miscService + IRequest $request, IUserSession $userSession, InstanceService $instanceService, + ApplicationService $clientService, AccountService $accountService, CacheActorService $cacheActorService, + FollowService $followService, StreamService $streamService, ConfigService $configService, + MiscService $miscService ) { parent::__construct(Application::APP_NAME, $request); diff --git a/lib/Controller/MediaApiController.php b/lib/Controller/MediaApiController.php index 717d35976..75be9185b 100644 --- a/lib/Controller/MediaApiController.php +++ b/lib/Controller/MediaApiController.php @@ -7,11 +7,37 @@ namespace OCA\Social\Controller; -use OCP\AppFramework\Controller; +use OCA\Social\Entity\MediaAttachment; +use OCA\Social\Service\AccountFinder; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\DB\ORM\IEntityManager; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\AppFramework\Controller; use OCP\Files\IMimeTypeDetector; +use OCP\Image; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Util; +use Psr\Log\LoggerInterface; class MediaApiController extends Controller { + + private IL10N $l10n; + private IMimeTypeDetector $mimeTypeDetector; + private IAppData $appData; + private IUserSession $userSession; + private AccountFinder $accountFinder; + private IEntityManager $entityManager; + private IURLGenerator $generator; + private LoggerInterface $logger; + public const IMAGE_MIME_TYPES = [ 'image/png', 'image/jpeg', @@ -24,22 +50,208 @@ class MediaApiController extends Controller { 'image/webp', ]; - private IMimeTypeDetector $mimeTypeDetector; + public function __construct( + string $appName, + IRequest $request, + IL10N $l10n, + IMimeTypeDetector $mimeTypeDetector, + IAppData $appData, + IUserSession $userSession, + AccountFinder $accountFinder, + IEntityManager $entityManager, + IURLGenerator $generator, + LoggerInterface $logger + ) { + parent::__construct($appName, $request); + $this->l10n = $l10n; + $this->mimeTypeDetector = $mimeTypeDetector; + $this->appData = $appData; + $this->userSession = $userSession; + $this->accountFinder = $accountFinder; + $this->entityManager = $entityManager; + $this->generator = $generator; + $this->logger = $logger; + } /** * Creates an attachment to be used with a new status. * * @NoAdminRequired */ - public function uploadMedia(): DataResponse { - // TODO - return new DataResponse([ - 'id' => 1, - 'url' => '', - 'preview_url' => '', - 'remote_url' => null, - 'text_url' => '', - 'description' => '', + public function uploadMedia(?string $description, ?string $focus = ''): DataResponse { + try { + $file = $this->getUploadedFile('file'); + if (!isset($file['tmp_name'], $file['name'], $file['type'])) { + return new DataResponse(['error' => 'No uploaded file'], Http::STATUS_BAD_REQUEST); + } + + if (!in_array($file['type'], MediaAttachment::IMAGE_MIME_TYPES, true)) { + return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST); + } + + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + + $meta = []; + $this->processFocus($focus, $meta); + + $newFileResource = fopen($file['tmp_name'], 'rb'); + if (!is_resource($newFileResource)) { + return new DataResponse(['error' => 'Image type not supported'], Http::STATUS_BAD_REQUEST); + } + + $image = new Image(); + $image->loadFromFileHandle($newFileResource); + $meta['original'] = [ + "width" => $image->width(), + "height" => $image->height(), + "size" => $image->width() . "x" . $image->height(), + "aspect" => $image->width() / $image->height(), + ]; + + $attachment = MediaAttachment::create(); + $attachment->setMimetype($file['type']); + $attachment->setAccount($account); + $attachment->setDescription($description ?? ''); + $attachment->setMeta($meta); + $this->entityManager->persist($attachment); + $this->entityManager->flush(); + + try { + $folder = $this->appData->getFolder('media-attachments'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('media-attachments'); + } + assert($attachment->getId() !== ''); + $folder->newFile($attachment->getId(), $image->data()); + + return new DataResponse($attachment->toMastodonApi($this->generator)); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new DataResponse([ + "error" => "Validation failed: File content type is invalid, File is invalid", + ], 500); + } + } + + /** + * @NoAdminRequired + */ + public function updateMedia(string $id, ?string $description, ?string $focus = ''): Response { + try { + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'id' => $id, + ]); + if ($attachement->getAccount()->getId() !== $account->getId()) { + throw new NotFoundResponse(); + } + + $attachement->setDescription($description ?? ''); + $this->entityManager->persist($attachement); + $this->entityManager->flush(); + + return new DataResponse($attachement->toMastodonApi($this->generator)); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new DataResponse([ + "error" => "Validation failed: File content type is invalid, File is invalid", + ], 500); + } + } + + private function processFocus(string $focus, array &$meta): void { + if ($focus === '') { + return; + } + + try { + [$x, $y] = explode(',', $focus); + $meta['focus'] = ['x' => $x, 'y' => $y]; + } catch (\Exception $e) { + return; + } + } + + private function getUploadedFile(string $key): array { + $file = $this->request->getUploadedFile($key); + $error = null; + $phpFileUploadErrors = [ + UPLOAD_ERR_OK => $this->l10n->t('The file was uploaded'), + UPLOAD_ERR_INI_SIZE => $this->l10n->t('The uploaded file exceeds the upload_max_filesize directive in php.ini'), + UPLOAD_ERR_FORM_SIZE => $this->l10n->t('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form'), + UPLOAD_ERR_PARTIAL => $this->l10n->t('The file was only partially uploaded'), + UPLOAD_ERR_NO_FILE => $this->l10n->t('No file was uploaded'), + UPLOAD_ERR_NO_TMP_DIR => $this->l10n->t('Missing a temporary folder'), + UPLOAD_ERR_CANT_WRITE => $this->l10n->t('Could not write file to disk'), + UPLOAD_ERR_EXTENSION => $this->l10n->t('A PHP extension stopped the file upload'), + ]; + + if (empty($file)) { + $error = $this->l10n->t('No file uploaded or file size exceeds maximum of %s', [Util::humanFileSize(Util::uploadLimit())]); + } + if (!empty($file) && array_key_exists('error', $file) && $file['error'] !== UPLOAD_ERR_OK) { + $error = $phpFileUploadErrors[$file['error']]; + } + if ($error !== null) { + throw new \Exception($error); + } + return $file; + } + + /** + * @NoAdminRequired + * @NoCSRFRequired + */ + public function getMedia(string $shortcode, string $extension): DataDownloadResponse { + try { + $folder = $this->appData->getFolder('media-attachments'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('media-attachments'); + } + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'shortcode' => $shortcode, ]); + $file = $folder->getFile($attachement->getId()); + return new DataDownloadResponse( + $file->getContent(), + (string) Http::STATUS_OK, + $this->getSecureMimeType($file->getMimeType()) + ); + } + + /** + * @NoAdminRequired + */ + public function deleteMedia(string $id): DataResponse { + try { + $folder = $this->appData->getFolder('media-attachments'); + } catch (NotFoundException $e) { + $folder = $this->appData->newFolder('media-attachments'); + } + $attachementRepository = $this->entityManager->getRepository(MediaAttachment::class); + $attachement = $attachementRepository->findOneBy([ + 'id' => $id, + ]); + $file = $folder->getFile($attachement->getId()); + $file->delete(); + $this->entityManager->remove($attachement); + $this->entityManager->flush(); + return new DataResponse(['removed']); + } + + /** + * Allow all supported mimetypes + * Use mimetype detector for the other ones + * + * @param string $mimetype + * @return string + */ + private function getSecureMimeType(string $mimetype): string { + if (in_array($mimetype, self::IMAGE_MIME_TYPES)) { + return $mimetype; + } + return $this->mimeTypeDetector->getSecureMimeType($mimetype); } } diff --git a/lib/Controller/NavigationController.php b/lib/Controller/NavigationController.php index 1d4bbbca6..6d2d17309 100644 --- a/lib/Controller/NavigationController.php +++ b/lib/Controller/NavigationController.php @@ -31,6 +31,7 @@ namespace OCA\Social\Controller; +use OCA\Social\Service\AccountFinder; use OCA\Social\Tools\Traits\TNCDataResponse; use OCA\Social\Tools\Traits\TArrayTools; use Exception; @@ -49,13 +50,13 @@ use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\Response; use OCP\AppFramework\Http\TemplateResponse; +use OCP\AppFramework\Services\IInitialState; +use OCP\DB\ORM\IEntityManager; use OCP\IConfig; -use OCP\IInitialStateService; use OCP\IL10N; use OCP\IRequest; use OCP\IURLGenerator; -use OCP\IGroupManager; -use OCP\Server; +use OCP\IUserSession; /** * Class NavigationController @@ -69,40 +70,43 @@ class NavigationController extends Controller { private ?string $userId = null; private IConfig $config; private IURLGenerator $urlGenerator; - private AccountService $accountService; + private AccountFinder $accountFinder; private DocumentService $documentService; private ConfigService $configService; private MiscService $miscService; private IL10N $l10n; private CheckService $checkService; - private IInitialStateService $initialStateService; + private IInitialState $initialState; + private IUserSession $userSession; public function __construct( IL10N $l10n, IRequest $request, ?string $userId, IConfig $config, - IInitialStateService $initialStateService, + IInitialState $initialState, IURLGenerator $urlGenerator, - AccountService $accountService, + AccountFinder $accountFinder, DocumentService $documentService, ConfigService $configService, CheckService $checkService, - MiscService $miscService + MiscService $miscService, + IUserSession $userSession ) { parent::__construct(Application::APP_NAME, $request); $this->userId = $userId; $this->l10n = $l10n; $this->config = $config; - $this->initialStateService = $initialStateService; + $this->initialState = $initialState; $this->urlGenerator = $urlGenerator; $this->checkService = $checkService; - $this->accountService = $accountService; + $this->accountFinder = $accountFinder; $this->documentService = $documentService; $this->configService = $configService; $this->miscService = $miscService; + $this->userSession = $userSession; } @@ -118,33 +122,31 @@ public function __construct( public function navigate(string $path = ''): TemplateResponse { $serverData = [ 'public' => false, - 'firstrun' => false, 'setup' => false, - 'isAdmin' => Server::get(IGroupManager::class) - ->isAdmin($this->userId), 'cliUrl' => $this->getCliUrl() ]; try { $serverData['cloudAddress'] = $this->configService->getCloudUrl(); } catch (SocialAppConfigException $e) { - $this->checkService->checkInstallationStatus(true); - $cloudAddress = $this->setupCloudAddress(); - if ($cloudAddress !== '') { - $serverData['cloudAddress'] = $cloudAddress; - } else { - $serverData['setup'] = true; - - if ($serverData['isAdmin']) { - $cloudAddress = $this->request->getParam('cloudAddress'); - if ($cloudAddress !== null) { - $this->configService->setCloudUrl($cloudAddress); - } else { - $this->initialStateService->provideInitialState(Application::APP_NAME, 'serverData', $serverData); - return new TemplateResponse(Application::APP_NAME, 'main'); - } - } - } + // TODO redirect to admin page + //$this->checkService->checkInstallationStatus(true); + //$cloudAddress = $this->setupCloudAddress(); + //if ($cloudAddress !== '') { + // $serverData['cloudAddress'] = $cloudAddress; + //} else { + // $serverData['setup'] = true; + + // if ($serverData['isAdmin']) { + // $cloudAddress = $this->request->getParam('cloudAddress'); + // if ($cloudAddress !== null) { + // $this->configService->setCloudUrl($cloudAddress); + // } else { + // $this->initialState->provideInitialState( 'serverData', $serverData); + // return new TemplateResponse(Application::APP_NAME, 'main'); + // } + // } + //} } try { @@ -153,26 +155,9 @@ public function navigate(string $path = ''): TemplateResponse { $this->configService->setSocialUrl(); } - if ($serverData['isAdmin']) { - $checks = $this->checkService->checkDefault(); - $serverData['checks'] = $checks; - } - - /* - * Create social user account if it doesn't exist yet - */ - try { - $this->accountService->createActor($this->userId, $this->userId); - $serverData['firstrun'] = true; - } catch (AccountAlreadyExistsException $e) { - // we do nothing - } catch (NoUserException $e) { - // well, should not happens - } catch (SocialAppConfigException $e) { - // neither. - } + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); - $this->initialStateService->provideInitialState(Application::APP_NAME, 'serverData', $serverData); + $this->initialState->provideInitialState('serverData', $serverData); return new TemplateResponse(Application::APP_NAME, 'main'); } @@ -194,7 +179,7 @@ private function setupCloudAddress(): string { return ''; } - private function getCliUrl() { + private function getCliUrl(): string { $url = rtrim($this->urlGenerator->getBaseUrl(), '/'); $frontControllerActive = ($this->config->getSystemValue('htaccess.IgnoreFrontController', false) === true diff --git a/lib/Controller/OAuthController.php b/lib/Controller/OAuthController.php index b87bb9662..21f82885b 100644 --- a/lib/Controller/OAuthController.php +++ b/lib/Controller/OAuthController.php @@ -30,23 +30,24 @@ namespace OCA\Social\Controller; +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Instance; +use OCA\Social\Repository\InstanceRepository; use OCA\Social\Tools\Traits\TNCDataResponse; use Exception; -use OCA\Social\AppInfo\Application; +use OCA\Social\Entity\Application; use OCA\Social\Exceptions\ClientException; use OCA\Social\Exceptions\ClientNotFoundException; -use OCA\Social\Exceptions\InstanceDoesNotExistException; -use OCA\Social\Model\Client\SocialClient; use OCA\Social\Service\AccountService; use OCA\Social\Service\CacheActorService; -use OCA\Social\Service\ClientService; +use OCA\Social\Service\ApplicationService; use OCA\Social\Service\ConfigService; use OCA\Social\Service\InstanceService; use OCA\Social\Service\MiscService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\Response; +use OCP\DB\ORM\IEntityManager; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserSession; @@ -59,17 +60,24 @@ class OAuthController extends Controller { private InstanceService $instanceService; private AccountService $accountService; private CacheActorService $cacheActorService; - private ClientService $clientService; + private ApplicationService $clientService; private ConfigService $configService; private MiscService $miscService; + private IEntityManager $entityManager; public function __construct( - IRequest $request, IUserSession $userSession, IURLGenerator $urlGenerator, - InstanceService $instanceService, AccountService $accountService, - CacheActorService $cacheActorService, ClientService $clientService, ConfigService $configService, - MiscService $miscService + IRequest $request, + IUserSession $userSession, + IURLGenerator $urlGenerator, + InstanceService $instanceService, + AccountService $accountService, + CacheActorService $cacheActorService, + ApplicationService $clientService, + ConfigService $configService, + MiscService $miscService, + IEntityManager $entityManager ) { - parent::__construct(Application::APP_NAME, $request); + parent::__construct('social', $request); $this->userSession = $userSession; $this->urlGenerator = $urlGenerator; @@ -79,9 +87,7 @@ public function __construct( $this->clientService = $clientService; $this->configService = $configService; $this->miscService = $miscService; - - $body = file_get_contents('php://input'); - $this->miscService->log('[OAuthController] input: ' . $body, 0); + $this->entityManager = $entityManager; } @@ -89,11 +95,11 @@ public function __construct( * @NoCSRFRequired * @PublicPage */ - public function nodeinfo(): DataResponse { + public function index(): DataResponse { $nodeInfo = [ 'links' => [ 'rel' => 'http://nodeinfo.diaspora.software/ns/schema/2.0', - 'href' => $this->urlGenerator->linkToRouteAbsolute('social.OAuth.nodeinfo2') + 'href' => $this->urlGenerator->linkToRouteAbsolute('social.OAuth.show') ] ]; @@ -105,32 +111,24 @@ public function nodeinfo(): DataResponse { * @NoCSRFRequired * @PublicPage */ - public function nodeinfo2(): Response { - try { - $local = $this->instanceService->getLocal(); - $name = $local->getTitle(); - - $version = $local->getVersion(); - $usage = $local->getUsage(); - $openReg = $local->isRegistrations(); - } catch (InstanceDoesNotExistException $e) { - $name = 'Nextcloud Social'; - $version = $this->configService->getAppValue('installed_version'); - $usage = []; - $openReg = false; - } + public function show(): DataResponse { + $query = $this->entityManager->createQuery('SELECT COUNT(a) FROM \OCA\Social\Entity\Account a'); + $query->setCacheable(true); + $countUser = $query->getSingleScalarResult(); $nodeInfo = [ "version" => "2.0", "software" => [ - "name" => $name, - "version" => $version + "name" => 'Nextcloud Social', + "version" => $this->configService->getAppValue('installed_version'), ], "protocols" => [ "activitypub" ], - "usage" => $usage, - "openRegistrations" => $openReg + "usage" => [ + "total" => (int)$countUser, + ], + "openRegistrations" => false, ]; return new DataResponse($nodeInfo, Http::STATUS_OK); @@ -151,10 +149,10 @@ public function apps( $redirect_uris = [$redirect_uris]; } - $client = new SocialClient(); + $client = new Application(); $client->setAppWebsite($website); $client->setAppRedirectUris($redirect_uris); - $client->setAppScopes($client->getScopesFromString($scopes)); + $client->setAppScopes(Application::getScopesFromString($scopes)); $client->setAppName($client_name); $this->clientService->createApp($client); @@ -181,6 +179,11 @@ public function authorize( ): DataResponse { try { $user = $this->userSession->getUser(); + $accountRepository = $this->entityManager->getRepository(Account::class); + $accountRepository->findBy([ + '' + ]); + $account = $this->accountService->getActorFromUserId($user->getUID()); if ($response_type !== 'code') { diff --git a/lib/Controller/SetupController.php b/lib/Controller/SetupController.php new file mode 100644 index 000000000..4c7031ee5 --- /dev/null +++ b/lib/Controller/SetupController.php @@ -0,0 +1,64 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Controller; + +use OCA\Social\Entity\Account; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\RedirectResponse; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\TemplateResponse; +use OCA\Social\AppInfo\Application as App; +use OCP\DB\ORM\IEntityManager; +use OCP\DB\ORM\IEntityRepository; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\IUserSession; + +/** + * Controller responsible to set up social + */ +class SetupController extends Controller { + private IUserSession $userSession; + private IEntityManager $entityManager; + private IEntityRepository $accountRepository; + private IURLGenerator $generator; + + public function __construct(IRequest $request, IUserSession $userSession, IEntityManager $entityManager, IURLGenerator $generator) { + parent::__construct(App::APP_NAME, $request); + $this->userSession = $userSession; + $this->entityManager = $entityManager; + $this->accountRepository = $entityManager->getRepository(Account::class); + $this->generator = $generator; + } + + /** + * Display the account creation page + * + * @NoAdminRequired + * @NoCSRFRequired + */ + public function setupUser(): Response { + $account = $this->accountRepository->findOneBy([ + 'userId' => $this->userSession->getUser()->getUID(), + ]); + if ($account !== null) { + return new RedirectResponse($this->generator->linkToRoute('social.Navigation.timeline')); + } + return new TemplateResponse(App::APP_NAME, 'setup-user'); + } + + /** + * @NoAdminRequired + */ + public function createAccount(string $userName): DataResponse { + + } +} diff --git a/lib/Controller/StatusApiController.php b/lib/Controller/StatusApiController.php new file mode 100644 index 000000000..863a9c90c --- /dev/null +++ b/lib/Controller/StatusApiController.php @@ -0,0 +1,174 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Controller; + +use OCA\Social\Entity\MediaAttachment; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Service\PostServiceStatus; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\DB\ORM\IEntityManager; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\AppFramework\Controller; +use OCP\Files\IMimeTypeDetector; +use OCP\Image; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Util; +use Psr\Log\LoggerInterface; + +class StatusApiController extends Controller { + + private IL10N $l10n; + private IMimeTypeDetector $mimeTypeDetector; + private IAppData $appData; + private IUserSession $userSession; + private AccountFinder $accountFinder; + private IEntityManager $entityManager; + private IURLGenerator $generator; + private LoggerInterface $logger; + private PostServiceStatus $postServiceStatus; + + public function __construct( + string $appName, + IRequest $request, + IL10N $l10n, + IUserSession $userSession, + AccountFinder $accountFinder, + IEntityManager $entityManager, + IURLGenerator $generator, + LoggerInterface $logger, + PostServiceStatus $postServiceStatus + ) { + parent::__construct($appName, $request); + $this->l10n = $l10n; + $this->userSession = $userSession; + $this->accountFinder = $accountFinder; + $this->entityManager = $entityManager; + $this->generator = $generator; + $this->logger = $logger; + $this->postServiceStatus = $postServiceStatus; + } + + /** + * Publish new status + * @NoAdminRequired + */ + public function publishStatus( + ?string $status, + array $media_ids, + ?bool $sensitive, + ?string $spoiler_text + ): DataResponse { + if ($sensitive === null) { + $sensitive = false; + } + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + $status = $this->postServiceStatus->create($account, [ + 'text' => $status, + 'spoilerText' => $spoiler_text, + 'sensitive' => $sensitive, + ]); + return new DataResponse($status->toMastodonApi()); + } + + /** + * View specific status + * @NoAdminRequired + */ + public function getStatus(string $id): DataResponse { + $statusRepository = $this->entityManager->getRepository(Status::class); + $status = $statusRepository->findOneBy([ + 'id' => $id, + ]); + if ($status === null) { + return new DataResponse(["error" => "Record not found"]); + } + + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + if (!$this->canRead($account, $status)) { + return new DataResponse(["error" => "Record not found"]); + } + + return new DataResponse($status->toMastodonApi()); + } + + /** + * Delete specific status + * @NoAdminRequired + */ + public function deleteStatus(string $id): DataResponse { + $statusRepository = $this->entityManager->getRepository(Status::class); + $status = $statusRepository->findOneBy([ + 'id' => $id, + ]); + if ($status === null) { + return new DataResponse(["error" => "Record not found"]); + } + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + + if ($status->getAccount()->getId() !== $account->getId()) { + return new DataResponse(["error" => "Record not found"]); + } + $this->entityManager->delete($status); + $this->entityManager->flush(); + + return new DataResponse($status->toMastodonApi()); + } + + /** + * Context of a specific status + * @NoAdminRequired + */ + public function contextStatus(string $id): DataResponse { + $statusRepository = $this->entityManager->getRepository(Status::class); + $status = $statusRepository->findOneBy([ + 'id' => $id, + ]); + if ($status === null) { + return new DataResponse(["error" => "Record not found"]); + } + + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + if (!$this->canRead($account, $status)) { + return new DataResponse(["error" => "Record not found"]); + } + + return new DataResponse([ + 'ancestors' => [], + 'descendants' => [], + ]); + } + + public function reblogedBy(string $id): DataResponse { + $statusRepository = $this->entityManager->getRepository(Status::class); + $status = $statusRepository->findOneBy([ + 'id' => $id, + ]); + if ($status === null) { + return new DataResponse(["error" => "Record not found"]); + } + + $account = $this->accountFinder->getCurrentAccount($this->userSession->getUser()); + if (!$this->canRead($account, $status)) { + return new DataResponse(["error" => "Record not found"]); + } + + return new DataResponse([]); + } + + private function canRead(Account $accout, Status $status): bool { + return true; + } +} \ No newline at end of file diff --git a/lib/Controller/TimelineApiController.php b/lib/Controller/TimelineApiController.php new file mode 100644 index 000000000..118af3299 --- /dev/null +++ b/lib/Controller/TimelineApiController.php @@ -0,0 +1,98 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Controller; + +use OCA\Social\Entity\MediaAttachment; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Service\PostServiceStatus; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\DataDownloadResponse; +use OCP\AppFramework\Http\NotFoundResponse; +use OCP\DB\ORM\IEntityManager; +use OCP\Files\IAppData; +use OCP\Files\NotFoundException; +use OCP\IL10N; +use OCP\AppFramework\Controller; +use OCP\Files\IMimeTypeDetector; +use OCP\Image; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\Util; +use Psr\Log\LoggerInterface; + +class TimelineApiController extends Controller { + + private IL10N $l10n; + private IMimeTypeDetector $mimeTypeDetector; + private IAppData $appData; + private IUserSession $userSession; + private AccountFinder $accountFinder; + private IEntityManager $entityManager; + private IURLGenerator $generator; + private LoggerInterface $logger; + private PostServiceStatus $postServiceStatus; + + public function __construct( + string $appName, + IRequest $request, + IL10N $l10n, + IUserSession $userSession, + AccountFinder $accountFinder, + IEntityManager $entityManager, + IURLGenerator $generator, + LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + $this->l10n = $l10n; + $this->userSession = $userSession; + $this->accountFinder = $accountFinder; + $this->entityManager = $entityManager; + $this->generator = $generator; + $this->logger = $logger; + } + + /** + * Public timeline + * + * @params bool $local Show only local statuses? Defaults to false. + * @params bool $remote Show only remote statuses? Defaults to false. + * @params bool $only_media Show only statuses with media attached? Defaults to false. + * @params string $max_id Return results older than this id + * @params string $since_id Return results newer than this id + * @params string $min_id Return results immediately newer than this id + * @params int $limit Maximum number of results to return. Defaults to 20. + */ + public function publicTimeline( + bool $local = null, + bool $remote = null, + bool $only_media = null, + string $max_id = null, + string $since_id = null, + string $min_id = null, + int $limit = null, + ): DataResponse { + if ($local === null) { + $local = false; + } + if ($remote === null) { + $remote = false; + } + if ($only_media === null) { + $only_media = false; + } + if ($limit === null || $limit > 100) { + $limit = 20; + } + + $statusRepository = $this->entityManager->getRepository(Status::class); + $statusRepository->createQuery('SELECT s FROM \OCA\Social\Entity\Status s WHERE s.visibility = :visibility'); + } +} diff --git a/lib/Db/ClientRequest.php b/lib/Db/ClientRequest.php deleted file mode 100644 index ad8b0a124..000000000 --- a/lib/Db/ClientRequest.php +++ /dev/null @@ -1,164 +0,0 @@ - - * @copyright 2018, Maxence Lange - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - - -namespace OCA\Social\Db; - -use OCA\Social\Tools\Traits\TArrayTools; -use DateTime; -use Exception; -use OCA\Social\Exceptions\ClientNotFoundException; -use OCA\Social\Model\Client\SocialClient; -use OCA\Social\Service\ClientService; -use OCP\DB\QueryBuilder\IQueryBuilder; - -/** - * Class ClientAppRequest - * - * @package OCA\Social\Db - */ -class ClientRequest extends ClientRequestBuilder { - use TArrayTools; - - /** - * Insert a new OAuth client in the database. - * @throws \OCP\DB\Exception - */ - public function saveApp(SocialClient $client): void { - $qb = $this->getClientInsertSql(); - $qb->setValue('app_name', $qb->createNamedParameter($client->getAppName())) - ->setValue('app_website', $qb->createNamedParameter($client->getAppWebsite())) - ->setValue( - 'app_redirect_uris', $qb->createNamedParameter(json_encode($client->getAppRedirectUris())) - ) - ->setValue('app_client_id', $qb->createNamedParameter($client->getAppClientId())) - ->setValue('app_client_secret', $qb->createNamedParameter($client->getAppClientSecret())) - ->setValue('app_scopes', $qb->createNamedParameter(json_encode($client->getAppScopes()))); - - try { - $dt = new DateTime('now'); - $qb->setValue('last_update', $qb->createNamedParameter($dt, IQueryBuilder::PARAM_DATE)); - $qb->setValue('creation', $qb->createNamedParameter($dt, IQueryBuilder::PARAM_DATE)); - } catch (Exception $e) { - } - - $qb->executeStatement(); - - $client->setId($qb->getLastInsertId()); - } - - - /** - * @param SocialClient $client - */ - public function authClient(SocialClient $client): void { - $qb = $this->getClientUpdateSql(); - $qb->set('auth_code', $qb->createNamedParameter($client->getAuthCode())); - $qb->set('auth_scopes', $qb->createNamedParameter(json_encode($client->getAuthScopes()))); - $qb->set('auth_account', $qb->createNamedParameter($client->getAuthAccount())); - $qb->set('auth_user_id', $qb->createNamedParameter($client->getAuthUserId())); - - $qb->limitToId($client->getId()); - - $qb->executeStatement(); - } - - - /** - * @param SocialClient $client - */ - public function updateToken(SocialClient $client): void { - $qb = $this->getClientUpdateSql(); - $qb->set('token', $qb->createNamedParameter($client->getToken())); - $qb->set('auth_code', $qb->createNamedParameter('')); - - $qb->limitToId($client->getId()); - - $qb->execute(); - } - - - /** - * @param SocialClient $client - */ - public function updateTime(SocialClient $client): void { - $now = new DateTime('now'); - $client->setLastUpdate($now->getTimestamp()); - - $qb = $this->getClientUpdateSql(); - $qb->set('last_update', $qb->createNamedParameter($now, IQueryBuilder::PARAM_DATE)); - - $qb->limitToId($client->getId()); - - $qb->execute(); - } - - - /** - * @param string $clientId - * - * @return SocialClient - * @throws ClientNotFoundException - */ - public function getFromClientId(string $clientId): SocialClient { - $qb = $this->getClientSelectSql(); - $qb->limitToAppClientId($clientId); - - return $this->getClientFromRequest($qb); - } - - - /** - * @param string $token - * - * @return SocialClient - * @throws ClientNotFoundException - */ - public function getFromToken(string $token): SocialClient { - $qb = $this->getClientSelectSql(); - $qb->limitToToken($token); - - return $this->getClientFromRequest($qb); - } - - - /** - * @throws Exception - */ - public function deprecateToken() { - $qb = $this->getClientDeleteSql(); - - $date = new DateTime(); - $date->setTimestamp(time() - ClientService::TIME_TOKEN_TTL); - $qb->limitToDBFieldDateTime('last_update', $date, true); - - $qb->execute(); - } -} diff --git a/lib/Db/ClientRequestBuilder.php b/lib/Db/ClientRequestBuilder.php deleted file mode 100644 index ec8b32b93..000000000 --- a/lib/Db/ClientRequestBuilder.php +++ /dev/null @@ -1,154 +0,0 @@ - - * @copyright 2018, Maxence Lange - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - - -namespace OCA\Social\Db; - -use OCA\Social\Tools\Exceptions\RowNotFoundException; -use OCA\Social\Tools\Traits\TArrayTools; -use Exception; -use OCA\Social\Exceptions\ClientNotFoundException; -use OCA\Social\Model\Client\SocialClient; - -/** - * Class ClientRequestBuilder - * - * @package OCA\Social\Db - */ -class ClientRequestBuilder extends CoreRequestBuilder { - use TArrayTools; - - - /** - * Base of the Sql Insert request - * - * @return SocialQueryBuilder - */ - protected function getClientInsertSql(): SocialQueryBuilder { - $qb = $this->getQueryBuilder(); - $qb->insert(self::TABLE_CLIENT); - - return $qb; - } - - - /** - * Base of the Sql Update request - * - * @return SocialQueryBuilder - */ - protected function getClientUpdateSql(): SocialQueryBuilder { - $qb = $this->getQueryBuilder(); - $qb->update(self::TABLE_CLIENT); - - return $qb; - } - - - /** - * Base of the Sql Select request for Shares - * - * @return SocialQueryBuilder - */ - protected function getClientSelectSql(): SocialQueryBuilder { - $qb = $this->getQueryBuilder(); - - /** @noinspection PhpMethodParametersCountMismatchInspection */ - $qb->select( - 'cl.id', 'cl.app_name', 'cl.app_website', 'cl.app_redirect_uris', 'cl.app_client_id', - 'cl.app_client_secret', 'cl.app_scopes', 'cl.auth_scopes', 'cl.auth_account', 'cl.auth_user_id', - 'cl.auth_code', 'cl.token', 'cl.last_update', 'cl.creation' - ) - ->from(self::TABLE_CLIENT, 'cl'); - - $this->defaultSelectAlias = 'cl'; - $qb->setDefaultSelectAlias('cl'); - - return $qb; - } - - - /** - * Base of the Sql Delete request - * - * @return SocialQueryBuilder - */ - protected function getClientDeleteSql(): SocialQueryBuilder { - $qb = $this->getQueryBuilder(); - $qb->delete(self::TABLE_CLIENT); - - return $qb; - } - - - /** - * @param SocialQueryBuilder $qb - * - * @return SocialClient - * @throws ClientNotFoundException - */ - public function getClientFromRequest(SocialQueryBuilder $qb): SocialClient { - /** @var SocialClient $result */ - try { - $result = $qb->getRow([$this, 'parseClientSelectSql']); - } catch (RowNotFoundException $e) { - throw new ClientNotFoundException($e->getMessage()); - } - - return $result; - } - - - /** - * @param SocialQueryBuilder $qb - * - * @return SocialClient[] - */ - public function getClientsFromRequest(SocialQueryBuilder $qb): array { - /** @var SocialClient[] $result */ - $result = $qb->getRows([$this, 'parseClientSelectSql']); - - return $result; - } - - - /** - * @param array $data - * - * @return SocialClient - * @throws Exception - */ - public function parseClientSelectSql(array $data): SocialClient { - $item = new SocialClient(); - $item->importFromDatabase($data); - - return $item; - } -} diff --git a/lib/Db/StatusMapper.php b/lib/Db/StatusMapper.php new file mode 100644 index 000000000..b51a7d53d --- /dev/null +++ b/lib/Db/StatusMapper.php @@ -0,0 +1,14 @@ + + */ +class StatusMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'status', Status::class); + } +} diff --git a/lib/Entity/Account.php b/lib/Entity/Account.php new file mode 100644 index 000000000..c984af17d --- /dev/null +++ b/lib/Entity/Account.php @@ -0,0 +1,580 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; +use Doctrine\ORM\Mapping as ORM; +use OCA\Social\Service\FollowOption; +use OCA\Social\InstanceUtils; +use OCP\IRequest; + +/** + * @ORM\Entity + * @ORM\Table(name="social_account") + */ +class Account { + const REPRESENTATIVE_ID = '-99'; + + const TYPE_APPLICATION = 'Application'; + const TYPE_PERSON = 'Person'; + const TYPE_GROUP = 'Group'; + const TYPE_ORGANIZATION = 'Organization'; + const TYPE_SERVICE = 'Service'; + + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private ?string $id = null; + + /** + * Username of the user e.g. alice from alice@cloud.social + * + * @ORM\Column(name="user_name", nullable=false) + */ + private string $userName = ""; + + /** + * Internal userId of the user + * + * Only set for local users. + * + * @ORM\Column(name="user_id", nullable=true, unique=true) + */ + private ?string $userId = null; + + /** + * Display name: e.g. "Alice Müller" + * @ORM\Column(nullable=true) + */ + private ?string $name = null; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(name="domain", referencedColumnName="domain", nullable=true) + */ + private ?Instance $instance = null; + + /** + * @ORM\Column(name="private_key", type="text", nullable=false) + */ + private string $privateKey = ""; + + /** + * @ORM\Column(name="public_key", type="text", nullable=false) + */ + private string $publicKey = ""; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTime $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTime $updatedAt; + + /** + * @ORM\Column(type="string", nullable=false) + */ + private string $uri = ""; + + /** + * @ORM\Column(type="string", nullable=false) + */ + private string $url = ""; + + /** + * @ORM\Column(type="boolean", nullable=false) + */ + private bool $locked = false; + + /** + * @ORM\Column(name="avatar_remote_url", type="string", nullable=false) + */ + private string $avatarRemoteUrl = ""; + + /** + * @ORM\Column(name="header_remote_url", type="string", nullable=false) + */ + private string $headerRemoteUrl = ""; + + /** + * @ORM\Column(name="last_webfingered_at", type="datetime", nullable=true) + */ + private ?\DateTimeInterface $lastWebfingeredAt = null; + + /** + * @ORM\Column(name="inbox_url", type="string", nullable=false) + */ + private string $inboxUrl = ""; + + /** + * @ORM\Column(name="outbox_url", type="string", nullable=false) + */ + private string $outboxUrl = ""; + + /** + * @ORM\Column(name="shared_inbox_url", type="string", nullable=false) + */ + private string $sharedInboxUrl = ""; + + /** + * @ORM\Column(name="followers_url", type="string", nullable=false) + */ + private string $followersUrl = ""; + + /** + * @ORM\Column(name="protocol", type="string", nullable=false) + */ + private string $protocol = "ostatus"; + + /** + * @ORM\Column(type="boolean", nullable=false) + */ + private bool $memorial = false; + + /** + * @ORM\Column(type="json", nullable=false) + */ + private array $fields = []; + + /** + * @ORM\Column(type="string", nullable=false) + */ + private string $actorType = self::TYPE_PERSON; + + /** + * @ORM\Column(nullable=false) + */ + private bool $discoverable = true; + + /** + * @ORM\OneToMany(targetEntity="Follow", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $follow; + + /** + * @ORM\OneToMany(targetEntity="Follow", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $followedBy; + + /** + * @ORM\OneToMany(targetEntity="FollowRequest", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $followRequest; + + /** + * @ORM\OneToMany(targetEntity="FollowRequest", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $followRequestFrom; + + /** + * @ORM\OneToMany(targetEntity="Follow", mappedBy="account", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $block; + + /** + * @ORM\OneToMany(targetEntity="Follow", mappedBy="targetAccount", fetch="EXTRA_LAZY", cascade={"persist", "remove"}) + * @var Collection + */ + private Collection $blockedBy; + + public function __construct() { + $this->block = new ArrayCollection(); + $this->blockedBy = new ArrayCollection(); + $this->follow = new ArrayCollection(); + $this->followRequest = new ArrayCollection(); + $this->followRequestFrom = new ArrayCollection(); + $this->followedBy = new ArrayCollection(); + $this->updatedAt = new \DateTime(); + $this->createdAt = new \DateTime(); + } + + static public function newLocal(string $userId = null, string $userName = null, string $displayName = null): self { + $account = new Account(); + if ($userId !== null) { + $account->setUserId($userId); + if ($userName !== null) { + $account->setUserName($userName); + } else { + $account->setUserName($userId); + } + if ($displayName !== null) { + $account->setName($displayName); + } else { + $account->setName($account->getUserName()); + } + } + $account->generateKeys(); + return $account; + } + + public function generateKeys(): self { + if (!$this->isLocal() || ($this->publicKey !== '' && $this->privateKey !== '')) { + return $this; + } + + $res = openssl_pkey_new([ + "digest_alg" => "rsa", + "private_key_bits" => 2048, + "private_key_type" => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($res, $privateKey); + $publicKey = openssl_pkey_get_details($res)['key']; + + $this->setPublicKey($publicKey); + $this->setPrivateKey($privateKey); + + return $this; + } + + public function getId(): string { + return $this->id; + } + + public function setRepresentative(): self { + $this->userId = '__self'; + return $this; + } + + public function getUserId(): ?string { + return $this->userId; + } + + public function setUserId(string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getUserName(): string { + return $this->userName; + } + + public function setUserName(string $userName): self { + $this->userName = $userName; + return $this; + } + + public function getName(): string { + return $this->name; + } + + public function setName(string $displayName): self { + $this->name = $displayName; + return $this; + } + + public function getInstance(): ?Instance { + return $this->instance; + } + + public function setInstance(Instance $instance): self { + $this->instance = $instance; + return $this; + } + + public function getPrivateKey(): string { + return $this->privateKey; + } + + public function setPrivateKey(string $privateKey): self { + $this->privateKey = $privateKey; + return $this; + } + + public function getPublicKey(): string { + return $this->publicKey; + } + + public function setPublicKey(string $publicKey): self { + $this->publicKey = $publicKey; + return $this; + } + + public function getCreatedAt(): \DateTime { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): self { + $this->createdAt = $createdAt; + return $this; + } + + public function getUpdatedAt(): \DateTime { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): self { + $this->updatedAt = $updatedAt; + return $this; + } + + public function getUri(): string { + return $this->uri; + } + + public function setUri(string $uri): self { + $this->uri = $uri; + return $this; + } + + public function getUrl(): string { + return $this->url; + } + + public function setUrl(string $url): self { + $this->url = $url; + return $this; + } + + public function isLocked(): bool { + return $this->locked; + } + + public function setLocked(bool $locked): self { + $this->locked = $locked; + return $this; + } + + public function getAvatarRemoteUrl(): string { + return $this->avatarRemoteUrl; + } + + public function setAvatarRemoteUrl(string $avatarRemoteUrl): self { + $this->avatarRemoteUrl = $avatarRemoteUrl; + return $this; + } + + public function getHeaderRemoteUrl(): string { + return $this->headerRemoteUrl; + } + + public function setHeaderRemoteUrl(string $headerRemoteUrl): self { + $this->headerRemoteUrl = $headerRemoteUrl; + return $this; + } + + public function getLastWebfingeredAt(): ?\DateTimeInterface { + return $this->lastWebfingeredAt; + } + + public function setLastWebfingeredAt(?\DateTimeInterface $lastWebfingeredAt): self { + $this->lastWebfingeredAt = $lastWebfingeredAt; + return $this; + } + + public function getInboxUrl(): string { + return $this->inboxUrl; + } + + public function setInboxUrl(string $inboxUrl): self { + $this->inboxUrl = $inboxUrl; + return $this; + } + + public function getOutboxUrl(): string { + return $this->outboxUrl; + } + + public function setOutboxUrl(string $outboxUrl): self { + $this->outboxUrl = $outboxUrl; + return $this; + } + + public function getSharedInboxUrl(): string { + return $this->sharedInboxUrl; + } + + public function setSharedInboxUrl(string $sharedInboxUrl): self { + $this->sharedInboxUrl = $sharedInboxUrl; + return $this; + } + + public function getFollowersUrl(): string { + return $this->followersUrl; + } + + public function setFollowersUrl(string $followersUrl): self { + $this->followersUrl = $followersUrl; + return $this; + } + + public function getProtocol(): string { + return $this->protocol; + } + + public function setProtocol(string $protocol): self { + $this->protocol = $protocol; + return $this; + } + + public function isMemorial(): bool { + return $this->memorial; + } + + public function setMemorial(bool $memorial): self { + $this->memorial = $memorial; + return $this; + } + + public function getFields(): array { + return $this->fields; + } + + public function setFields(array $fields): self { + $this->fields = $fields; + return $this; + } + + public function getActorType(): string { + return $this->actorType; + } + + public function setActorType(string $actorType): self { + $this->actorType = $actorType; + return $this; + } + + public function isDiscoverable(): bool { + return $this->discoverable; + } + + public function setDiscoverable(bool $discoverable): self { + $this->discoverable = $discoverable; + return $this; + } + + public function getFollow(): Collection { + return $this->follow; + } + + public function setFollow(Collection $follow): self { + $this->follow = $follow; + return $this; + } + + public function getFollowedBy(): Collection { + return $this->followedBy; + } + + public function setFollowedBy(Collection $followedBy): void { + $this->followedBy = $followedBy; + } + + public function getBlock(): Collection { + return $this->block; + } + + public function setBlock(Collection $block): self { + $this->block = $block; + return $this; + } + + public function getBlockedBy(): Collection { + return $this->blockedBy; + } + + public function setBlockedBy(Collection $blockedBy): void { + $this->blockedBy = $blockedBy; + } + + public function isLocal(): bool { + return $this->getInstance() === null; + } + + public function getDomain(): ?string { + return $this->getInstance() !== null ? $this->getInstance()->getDomain() : null; + } + + public function getAccountName(): string { + return $this->isLocal() ? $this->getUserName() : $this->getUserName() . '@' . $this->getDomain(); + } + + public function possiblyStale(): bool { + return $this->lastWebfingeredAt === null || $this->lastWebfingeredAt->diff((new \DateTime('now')))->days > 1; + } + + /** + * Check whether this account follow the $targetAccount + */ + public function following(Account $targetAccount): bool { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('account', $targetAccount)); + return !$this->follow->matching($criteria)->isEmpty(); + } + + /** + * Check whether this account created a follow request to $targetAccount + */ + public function followRequested(Account $targetAccount): bool { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('account', $targetAccount)); + return !$this->followRequest->matching($criteria)->isEmpty(); + } + + /** + * Add a new follower to this account + */ + public function follow(Account $account, bool $notify = false, bool $showReblogs = true): Follow { + $follow = new Follow(); + $follow->setTargetAccount($account); + $follow->setAccount($this); + $follow->setNotify($notify); + $follow->setShowReblogs($showReblogs); + $this->followedBy->add($follow); + return $follow; + } + + public function getFollowRequest() { + return $this->followRequest; + } + + public function setFollowRequest($followRequest): void { + $this->followRequest = $followRequest; + } + + public function getFollowRequestFrom(): Collection { + return $this->followRequestFrom; + } + + public function setFollowRequestFrom(Collection $followRequestFrom): self { + $this->followRequestFrom = $followRequestFrom; + return $this; + } + + public function requestFollow(Account $targetAccount, bool $notify = false, bool $showReblogs = true): FollowRequest { + $followRequest = new Follow(); + $followRequest->setTargetAccount($targetAccount); + $followRequest->setAccount($this); + $followRequest->setNotify($notify); + $followRequest->setShowReblogs($showReblogs); + $this->followRequest->add($followRequest); + return $followRequest; + } + + public function toMastodonApi(): array { + return [ + 'id' => $this->id, + 'username' => $this->userName, + 'acct' => $this->userName, + 'display_name' => $this->name ?? $this->userName, + // TODO more + ]; + } +} diff --git a/lib/Entity/Application.php b/lib/Entity/Application.php new file mode 100644 index 000000000..fb6e6fbeb --- /dev/null +++ b/lib/Entity/Application.php @@ -0,0 +1,209 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use DateTimeInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_application") + */ +class Application { + /** + * @ORM\Id + * @ORM\Column + * @ORM\GeneratedValue + */ + private int $id = 0; + + /** + * @ORM\Column(name="app_name") + */ + private string $appName = ''; + + /** + * @ORM\Column(name="app_website") + */ + private string $appWebsite = ''; + + /** + * @ORM\Column(name="app_redirect_uris") + */ + private array $appRedirectUris = []; + + /** + * @ORM\Column(name="app_client_id") + */ + private string $appClientId = ''; + + /** + * @ORM\Column(name="app_client_secret") + */ + private string $appClientSecret = ''; + + /** + * @ORM\Column(name="app_scopes") + */ + private array $appScopes = []; + + /** + * @ORM\Column(name="auth_scopes") + */ + private array $authScopes = []; + + /** + * @ORM\Column(name="auth_account") + */ + private string $authAccount = ''; + + /** + * @ORM\Column(name="auth_user_id") + */ + private string $authUserId = ''; + + /** + * @ORM\Column(name="auth_code") + */ + private string $authCode = ''; + + /** + * @ORM\Column(name="last_update") + */ + private int $lastUpdate = -1; + + /** + * @ORM\Column + */ + private string $token = ''; + + /** + * @ORM\Column + */ + private DateTimeInterface $creation; + + public function __construct() { + $this->lastUpdate = (new \DateTime('now'))->getTimestamp(); + $this->creation = new \DateTime('now'); + } + + /** + * @return list + */ + static public function getScopesFromString(string $scopes): array { + return explode(' ', $scopes); + } + + public function getId(): int { + return $this->id; + } + + public function getAppName(): string { + return $this->appName; + } + + public function setAppName(string $appName): void { + $this->appName = $appName; + } + + public function getAppWebsite(): string { + return $this->appWebsite; + } + + public function setAppWebsite(string $appWebsite): void { + $this->appWebsite = $appWebsite; + } + + public function getAppRedirectUris(): array { + return $this->appRedirectUris; + } + + public function setAppRedirectUris(array $appRedirectUris): void { + $this->appRedirectUris = $appRedirectUris; + } + + public function getAppClientId(): string { + return $this->appClientId; + } + + public function setAppClientId(string $appClientId): void { + $this->appClientId = $appClientId; + } + + public function getAppClientSecret(): string { + return $this->appClientSecret; + } + + public function setAppClientSecret(string $appClientSecret): void { + $this->appClientSecret = $appClientSecret; + } + + public function getAppScopes(): array { + return $this->appScopes; + } + + public function setAppScopes(array $appScopes): void { + $this->appScopes = $appScopes; + } + + public function getAuthScopes(): array { + return $this->authScopes; + } + + public function setAuthScopes(array $authScopes): void { + $this->authScopes = $authScopes; + } + + public function getAuthAccount(): string { + return $this->authAccount; + } + + public function setAuthAccount(string $authAccount): void { + $this->authAccount = $authAccount; + } + + public function getAuthUserId(): string { + return $this->authUserId; + } + + public function setAuthUserId(string $authUserId): void { + $this->authUserId = $authUserId; + } + + public function getAuthCode(): string { + return $this->authCode; + } + + public function setAuthCode(string $authCode): void { + $this->authCode = $authCode; + } + + public function getLastUpdate(): int { + return $this->lastUpdate; + } + + public function setLastUpdate(int $lastUpdate): void { + $this->lastUpdate = $lastUpdate; + } + + public function getToken(): string { + return $this->token; + } + + public function setToken(string $token): void { + $this->token = $token; + } + + public function getCreation(): DateTimeInterface { + return $this->creation; + } + + public function setCreation(DateTimeInterface $creation): void { + $this->creation = $creation; + } +} diff --git a/lib/Entity/Block.php b/lib/Entity/Block.php new file mode 100644 index 000000000..bb0ee9628 --- /dev/null +++ b/lib/Entity/Block.php @@ -0,0 +1,59 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use DateTimeInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_block") + */ +class Block { + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private string $id = "-1"; + + /** + * @ORM\Column(name="created_at") + */ + private DateTimeInterface $createdAt; + + /** + * @ORM\Column(name="updated_at") + */ + private DateTimeInterface $updatedAt; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(nullable=false) + */ + private Account $account; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(nullable=false) + */ + private Account $targetAccount; + + /** + * @ORM\Column + */ + private string $uri = ""; + + public function __construct() { + $this->updatedAt = new \DateTime(); + $this->createdAt = new \DateTime(); + $this->account = new Account(); + $this->targetAccount = new Account(); + } +} diff --git a/lib/Entity/Follow.php b/lib/Entity/Follow.php new file mode 100644 index 000000000..f26121ea3 --- /dev/null +++ b/lib/Entity/Follow.php @@ -0,0 +1,129 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use DateTimeInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_follow") + */ +class Follow { + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private ?string $id = null; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTime $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTime $updatedAt; + + /** + * @ORM\ManyToOne(cascade={"persist", "remove"}) + * @ORM\JoinColumn(nullable=false) + */ + private ?Account $account = null; + + /** + * @ORM\ManyToOne(cascade={"persist", "remove"}) + * @ORM\JoinColumn(nullable=false) + */ + private ?Account $targetAccount = null; + + /** + * @ORM\Column + */ + private bool $showReblogs = true; + + /** + * @ORM\Column + */ + private string $uri = ""; + + /** + * @ORM\Column + */ + private bool $notify = false; + + public function __construct() { + $this->updatedAt = new \DateTime(); + $this->createdAt = new \DateTime(); + $this->account = new Account(); + $this->targetAccount = new Account(); + } + + public function getId(): string { + return $this->id; + } + + public function getCreatedAt():\DateTimeInterface { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): void { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): \DateTimeInterface { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): void { + $this->updatedAt = $updatedAt; + } + + public function getAccount(): Account { + return $this->account; + } + + public function setAccount(Account $account): void { + $this->account = $account; + } + + public function getTargetAccount(): Account { + return $this->targetAccount; + } + + public function setTargetAccount(Account $targetAccount): void { + $this->targetAccount = $targetAccount; + } + + public function isShowReblogs(): bool { + return $this->showReblogs; + } + + public function setShowReblogs(bool $showReblogs): void { + $this->showReblogs = $showReblogs; + } + + public function getUri(): string { + return $this->uri; + } + + public function setUri(string $uri): void { + $this->uri = $uri; + } + + public function isNotify(): bool { + return $this->notify; + } + + public function setNotify(bool $notify): void { + $this->notify = $notify; + } +} diff --git a/lib/Entity/FollowRequest.php b/lib/Entity/FollowRequest.php new file mode 100644 index 000000000..fd54395f9 --- /dev/null +++ b/lib/Entity/FollowRequest.php @@ -0,0 +1,129 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use DateTimeInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_request") + */ +class FollowRequest { + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private ?string $id = null; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTime $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTime $updatedAt; + + /** + * @ORM\ManyToOne(cascade={"persist", "remove"}) + * @ORM\JoinColumn(nullable=false) + */ + private ?Account $account = null; + + /** + * @ORM\ManyToOne(cascade={"persist", "remove"}) + * @ORM\JoinColumn(nullable=false) + */ + private ?Account $targetAccount = null; + + /** + * @ORM\Column + */ + private bool $showReblogs = true; + + /** + * @ORM\Column + */ + private string $uri = ""; + + /** + * @ORM\Column + */ + private bool $notify = false; + + public function __construct() { + $this->updatedAt = new \DateTime(); + $this->createdAt = new \DateTime(); + $this->account = new Account(); + $this->targetAccount = new Account(); + } + + public function getId(): string { + return $this->id; + } + + public function getCreatedAt():\DateTimeInterface { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): void { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): \DateTimeInterface { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): void { + $this->updatedAt = $updatedAt; + } + + public function getAccount(): Account { + return $this->account; + } + + public function setAccount(Account $account): void { + $this->account = $account; + } + + public function getTargetAccount(): Account { + return $this->targetAccount; + } + + public function setTargetAccount(Account $targetAccount): void { + $this->targetAccount = $targetAccount; + } + + public function isShowReblogs(): bool { + return $this->showReblogs; + } + + public function setShowReblogs(bool $showReblogs): void { + $this->showReblogs = $showReblogs; + } + + public function getUri(): string { + return $this->uri; + } + + public function setUri(string $uri): void { + $this->uri = $uri; + } + + public function isNotify(): bool { + return $this->notify; + } + + public function setNotify(bool $notify): void { + $this->notify = $notify; + } +} diff --git a/lib/Entity/Instance.php b/lib/Entity/Instance.php new file mode 100644 index 000000000..937419d87 --- /dev/null +++ b/lib/Entity/Instance.php @@ -0,0 +1,43 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_instance") + */ +class Instance { + /** + * @ORM\Id + * @ORM\Column + */ + private string $domain = ""; + + /** + * @ORM\Column(type="integer") + */ + private int $accountsCount = -1; + + public function getDomain(): string { + return $this->domain; + } + + public function setDomain(string $domain): void { + $this->domain = $domain; + } + + public function getAccountsCount(): int { + return $this->accountsCount; + } + + public function setAccountsCount(int $accountsCount): void { + $this->accountsCount = $accountsCount; + } +} diff --git a/lib/Entity/MediaAttachment.php b/lib/Entity/MediaAttachment.php new file mode 100644 index 000000000..cdb67ede5 --- /dev/null +++ b/lib/Entity/MediaAttachment.php @@ -0,0 +1,227 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use Doctrine\ORM\Mapping as ORM; +use OCP\IURLGenerator; + +/** + * @ORM\Entity + * @ORM\Table(name="social_media_attachment") + */ +class MediaAttachment { + const TYPE_IMAGE = 1; + + public const IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/x-xbitmap', + 'image/x-ms-bmp', + 'image/bmp', + 'image/svg+xml', + 'image/webp', + ]; + + public const IMAGE_MIME_TYPES_CONVERSATION = [ + 'image/png' => 'png', + 'image/jpeg' => 'jpg', + 'image/jpg' => 'jpg', + 'image/gif' => 'gif', + 'image/x-xbitmap' => 'bmp', + 'image/x-ms-bmp' => 'bmp', + 'image/bmp' => 'bmp', + 'image/svg+xml' => 'svg', + 'image/webp' => 'webp', + ]; + + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private ?string $id = '-1'; + + /** + * @ORM\ManyToOne + */ + private ?Status $status = null; + + /** + * @ORM\Column(name="remote_url", nullable=false) + */ + private string $remoteUrl = ""; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTime $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTime $updatedAt; + + /** + * @ORM\Column + */ + private ?string $shortcode = null; + + /** + * @ORM\Column + */ + private string $mimetype = 'image/png'; + + /** + * @ORM\Column(type="text") + */ + private string $description = ''; + + /** + * @ORM\Column + */ + private int $type = self::TYPE_IMAGE; + + /** + * @ORM\Column(type="json") + */ + private ?array $meta; + + /** + * @ORM\ManyToOne + */ + private ?Account $account = null; + + /** + * @ORM\Column + */ + private string $blurhash = ''; + + public function __construct() { + $this->updatedAt = new \DateTime(); + $this->createdAt = new \DateTime(); + $this->meta = []; + } + + static public function create(): self { + $attachement = new MediaAttachment(); + $length = 14; + $length = ($length < 4) ? 4 : $length; + $attachement->setShortcode(bin2hex(random_bytes(($length - ($length % 2)) / 2))); + return $attachement; + } + + public function getId(): string { + return $this->id; + } + + public function setId(?string $id): void { + $this->id = $id; + } + + public function getStatus(): ?Status { + return $this->status; + } + + public function setStatus(?Status $status): void { + $this->status = $status; + } + + public function getRemoteUrl(): string { + return $this->remoteUrl; + } + + public function setRemoteUrl(string $remoteUrl): void { + $this->remoteUrl = $remoteUrl; + } + + public function getCreatedAt(): \DateTime { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): void { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): \DateTime { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): void { + $this->updatedAt = $updatedAt; + } + + public function getShortcode(): ?string { + return $this->shortcode; + } + + public function setShortcode(?string $shortcode): void { + $this->shortcode = $shortcode; + } + + public function getType(): int { + return $this->type; + } + + public function setType(int $type): void { + $this->type = $type; + } + + public function getMeta(): ?array { + return $this->meta; + } + + public function setMeta(?array $meta): void { + $this->meta = $meta; + } + + public function getAccount(): ?Account { + return $this->account; + } + + public function setAccount(?Account $account): void { + $this->account = $account; + } + + public function getBlurhash(): ?string { + return $this->blurhash; + } + + public function setBlurhash(?string $blurhash): void { + $this->blurhash = $blurhash; + } + + public function getDescription(): ?string { + return $this->description; + } + + public function setDescription(?string $description): void { + $this->description = $description; + } + + public function getMimetype(): string { + return $this->mimetype; + } + + public function setMimetype(string $mimetype): void { + $this->mimetype = $mimetype; + } + + function toMastodonApi(IURLGenerator $generator) { + return [ + 'id' => $this->getId(), + 'url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode() . '.'), + 'preview_url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode() . '.' . self::IMAGE_MIME_TYPES_CONVERSATION[$this->getMimetype()]), + 'remote_url' => null, + 'text_url' => $generator->getAbsoluteURL('/apps/social/media/' . $this->getShortcode()), + 'description' => $this->getDescription(), + 'meta' => $this->getMeta(), + ]; + } +} diff --git a/lib/Entity/Mention.php b/lib/Entity/Mention.php new file mode 100644 index 000000000..11498aaa0 --- /dev/null +++ b/lib/Entity/Mention.php @@ -0,0 +1,105 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + * @ORM\Table(name="social_mention") + */ +class Mention { + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private string $id = ""; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn() + */ + private ?Status $status = null; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn() + */ + private ?Account $account = null; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTimeInterface $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTimeInterface $updatedAt; + + /** + * @ORM\Column + */ + private bool $silent = false; + + public function __construct() { + $this->createdAt = new \DateTime(); + $this->updatedAt = new \DateTime(); + } + + public function getId(): string { + return $this->id; + } + + public function getStatus(): ?Status { + return $this->status; + } + + public function setStatus(?Status $status): self { + $this->status = $status; + return $this; + } + + public function getAccount(): ?Account { + return $this->account; + } + + public function setAccount(?Account $account): self { + $this->account = $account; + return $this; + } + + public function getCreatedAt() { + return $this->createdAt; + } + + public function setCreatedAt($createdAt): self { + $this->createdAt = $createdAt; + return $this; + } + + public function getUpdatedAt() { + return $this->updatedAt; + } + + public function setUpdatedAt($updatedAt): self { + $this->updatedAt = $updatedAt; + return $this; + } + + public function isSilent(): bool { + return $this->silent; + } + + public function setSilent(bool $silent): self { + $this->silent = $silent; + return $this; + } +} diff --git a/lib/Entity/Status.php b/lib/Entity/Status.php new file mode 100644 index 000000000..ee9019e10 --- /dev/null +++ b/lib/Entity/Status.php @@ -0,0 +1,401 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; +use Doctrine\ORM\Mapping as ORM; +use OCA\Social\Service\ActivityPub; + +/** + * @ORM\Entity + * @ORM\Table(name="social_status") + * @ORM\HasLifecycleCallbacks + */ +class Status { + const STATUS_PUBLIC = "public"; + const STATUS_UNLISTED = "unlisted"; + const STATUS_PRIVATE = "private"; + const STATUS_DIRECT = "direct"; + + /** + * @ORM\Id + * @ORM\Column(type="bigint") + * @ORM\GeneratedValue + */ + private string $id = ""; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private ?string $uri = null; + + /** + * @ORM\Column(type="text", nullable=false) + */ + private string $text = ""; + + /** + * @ORM\Column(name="created_at", type="datetime", nullable=false) + */ + private \DateTime $createdAt; + + /** + * @ORM\Column(name="updated_at", type="datetime", nullable=false) + */ + private \DateTime $updatedAt; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(name="in_reply_to_id", referencedColumnName="id", nullable=true) + */ + private ?Status $inReplyTo = null; + + /** + * @ORM\Column(type="string", nullable=true) + */ + private ?string $url = null; + + /** + * @ORM\Column(name="`sensitive`") + */ + private bool $sensitive = false; + + /** + * @ORM\Column + */ + private int $visibility = 0; + + /** + * @ORM\Column(name="spoiler_text", type="text", nullable=false) + */ + private string $spoilerText = ""; + + /** + * @ORM\Column(type="boolean", nullable=false) + */ + private bool $reply = false; + + /** + * @ORM\Column(type="string", nullable=false) + */ + private string $language = "en"; + + /** + * @ORM\Column(name="conversation_id", type="bigint", nullable=true) + */ + private ?string $conversationId = null; + + /** + * @ORM\Column(name="local", type="boolean", nullable=false) + */ + private bool $local = false; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(nullable=false) + */ + private ?Account $account = null; + + /** + * @ORM\ManyToOne + * @ORM\JoinColumn(name="application_id", referencedColumnName="id", nullable=true) + */ + private ?Application $application = null; + + /** + * @ORM\Column(name="in_reply_to_account_id", type="bigint", nullable=true) + */ + private ?string $inReplyToAccountId = null; + + /** + * @ORM\Column(name="poll_id", type="bigint", nullable=true) + */ + private ?string $pollId = null; + + /** + * @ORM\Column(name="deleted_at", type="datetime", nullable=true) + */ + private ?\DateTime $deletedAt = null; + + /** + * @ORM\Column(name="edited_at", type="datetime", nullable=true) + */ + private ?\DateTime $editedAt = null; + + /** + * @ORM\Column(nullable=false) + */ + private bool $trendable = false; + + /** + * @var list + * @ORM\Column(name="ordered_media_attachment_ids", type="array", nullable=false) + */ + private array $orderedMediaAttachmentIds = []; + + /** + * @ORM\OneToMany(targetEntity="Mention", mappedBy="status") + */ + private Collection $mentions; + + public function __construct() { + $this->mentions = new ArrayCollection(); + $this->createdAt = new \DateTime(); + $this->updatedAt = new \DateTime(); + } + + /** + * @ORM\PostPersist + */ + public function generateUri(): void { + if ($this->uri !== null) { + return; + } + + $this->uri = ActivityPub\TagManager::getInstance()->uriFor($this); + } + + public function getId(): string { + return $this->id; + } + + public function setId(string $id): void { + $this->id = $id; + } + + public function getUri(): ?string { + return $this->uri; + } + + public function setUri(?string $uri): void { + $this->uri = $uri; + } + + public function getText(): string { + return $this->text; + } + + public function setText(string $text): void { + $this->text = $text; + } + + public function getCreatedAt(): \DateTime { + return $this->createdAt; + } + + public function setCreatedAt(\DateTime $createdAt): void { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): \DateTime { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTime $updatedAt): void { + $this->updatedAt = $updatedAt; + } + + public function getInReplyTo(): ?Status { + return $this->inReplyTo; + } + + public function setInReplyTo(?Status $inReplyTo): void { + $this->inReplyTo = $inReplyTo; + } + + public function getReblogOf(): ?Status { + return $this->reblogOf; + } + + public function setReblogOf(?Status $reblogOf): void { + $this->reblogOf = $reblogOf; + } + + public function getUrl(): ?string { + return $this->url; + } + + public function setUrl(?string $url): void { + $this->url = $url; + } + + public function isSensitive(): bool { + return $this->sensitive; + } + + public function setSensitive(bool $sensitive): void { + $this->sensitive = $sensitive; + } + + public function getVisibility(): int { + return $this->visibility; + } + + public function setVisibility(int $visibility): void { + $this->visibility = $visibility; + } + + public function getSpoilerText(): string { + return $this->spoilerText; + } + + public function setSpoilerText(string $spoilerText): void { + $this->spoilerText = $spoilerText; + } + + public function isReply(): bool { + return $this->reply; + } + + public function setReply(bool $reply): void { + $this->reply = $reply; + } + + public function getLanguage(): string { + return $this->language; + } + + public function setLanguage(string $language): void { + $this->language = $language; + } + + public function getConversationId(): ?string { + return $this->conversationId; + } + + public function setConversationId(?string $conversationId): void { + $this->conversationId = $conversationId; + } + + public function isLocal(): bool { + return $this->local; + } + + public function setLocal(bool $local): void { + $this->local = $local; + } + + public function getAccount(): Account { + return $this->account; + } + + public function setAccount(Account $account): void { + $this->account = $account; + } + + public function getApplication(): ?Application { + return $this->application; + } + + public function setApplication(?Application $application): void { + $this->application = $application; + } + + public function getInReplyToAccountId(): ?string { + return $this->inReplyToAccountId; + } + + public function setInReplyToAccountId(?string $inReplyToAccountId): void { + $this->inReplyToAccountId = $inReplyToAccountId; + } + + public function getPollId(): ?string { + return $this->pollId; + } + + public function setPollId(?string $pollId): void { + $this->pollId = $pollId; + } + + public function getDeletedAt(): ?\DateTime { + return $this->deletedAt; + } + + public function setDeletedAt(?\DateTime $deletedAt): void { + $this->deletedAt = $deletedAt; + } + + public function getEditedAt(): ?\DateTime { + return $this->editedAt; + } + + public function setEditedAt(?\DateTime $editedAt): void { + $this->editedAt = $editedAt; + } + + public function isTrendable(): bool { + return $this->trendable; + } + + public function setTrendable(bool $trendable): void { + $this->trendable = $trendable; + } + + /** + * @return int[] + */ + public function getOrderedMediaAttachmentIds(): array { + return $this->orderedMediaAttachmentIds; + } + + /** + * @param int[] $orderedMediaAttachmentIds + */ + public function setOrderedMediaAttachmentIds(array $orderedMediaAttachmentIds): void { + $this->orderedMediaAttachmentIds = $orderedMediaAttachmentIds; + } + + public function getMentions(): Collection { + return $this->mentions; + } + + /** + * @return Collection + */ + public function getActiveMentions(): Collection { + $criteria = Criteria::create(); + $criteria->where(Criteria::expr()->eq('silent', false)); + return $this->mentions->matching($criteria); + } + + public function setMentions(Collection $mentions): void { + $this->mentions = $mentions; + } + + public function isReblog(): bool { + return $this->reblogOf !== null; + } + + public function toMastodonApi(): array { + return [ + 'id' => $this->id, + 'created_at' => $this->createdAt->format(\DateTimeInterface::ISO8601), + 'in_reply_to_id' => $this->inReplyTo ? $this->inReplyTo->getId() : null, + 'in_reply_to_account_id' => $this->inReplyTo ? $this->inReplyTo->getAccount()->getId() : null, + 'sensitive' => $this->sensitive, + 'spoiler_text' => $this->spoilerText, + 'visibility' => $this->visibility, + 'language' => $this->language, + 'uri' => $this->uri, + 'url' => $this->url, + 'replies_count' => 0, + 'reblogs_count' => 0, + 'favourites_count' => 0, + 'favourited' => false, + 'reblogged' => false, + 'muted' => false, + 'bookmarked' => false, + 'content' => $this->text, + 'reblog' => $this->reblogOf, + 'application' => $this->application ? $this->application->toMastodonApi() : null, + 'account' => $this->account->toMastodonApi(), + ]; + } +} diff --git a/lib/InstanceUtils.php b/lib/InstanceUtils.php new file mode 100644 index 000000000..975180b66 --- /dev/null +++ b/lib/InstanceUtils.php @@ -0,0 +1,29 @@ +generator = $generator; + } + /** + * Return the url of the instance: e.g. https://hello.social + */ + public function getLocalInstanceUrl(): string { + $url = $this->generator->getAbsoluteURL('/'); + return rtrim($url, '/'); + } + + /** + * Return the name of the instance: e.g. hello.social + */ + public function getLocalInstanceName(): string { + $url = $this->generator->getAbsoluteURL('/'); + $url = rtrim($url, '/'); + return substr($url, 8); + } +} diff --git a/lib/Model/ActivityPub/ACore.php b/lib/Model/ActivityPub/ACore.php index 00feb456f..6f980bac5 100644 --- a/lib/Model/ActivityPub/ACore.php +++ b/lib/Model/ActivityPub/ACore.php @@ -283,7 +283,7 @@ public function setSignature(LinkedDataSignature $signature): Acore { * * @throws UrlCloudException */ - public function generateUniqueId(string $base = '', bool $root = true) { + public function generateUniqueId(string $base = '', bool $root = true): void { $url = ''; if ($root) { $url = $this->getUrlCloud(); diff --git a/lib/Model/ActivityPub/Object/Follow.php b/lib/Model/ActivityPub/Object/Follow.php index 3b779311e..3c3c85a9b 100644 --- a/lib/Model/ActivityPub/Object/Follow.php +++ b/lib/Model/ActivityPub/Object/Follow.php @@ -31,34 +31,27 @@ namespace OCA\Social\Model\ActivityPub\Object; -use OCA\Social\Tools\IQueryRow; +use OCA\Social\Entity\Follow as FollowEntitiy; use JsonSerializable; use OCA\Social\Model\ActivityPub\ACore; /** + * Virtual rep * Class Follow * - * @package OCA\Social\Model\ActivityPub\Object */ -class Follow extends ACore implements JsonSerializable, IQueryRow { +class Follow extends ACore implements JsonSerializable { public const TYPE = 'Follow'; - - private string $followId = ''; - - private string $followIdPrim = ''; - - private bool $accepted = false; - - - /** - * Follow constructor. - * - * @param ACore $parent - */ + static public function create(FollowEntitiy $follow): self { + $followActivity = new Follow(); + $followActivity->setId($follow->getUri() ?: $follow->getAccount()->getUri() . '#follows/' . $follow->getId()); + $followActivity->setActor($follow->getAccount()); + $followActivity->setVirtualObject(); + return $followActivity + } public function __construct($parent = null) { parent::__construct($parent); - $this->setType(self::TYPE); } @@ -119,27 +112,6 @@ public function setAccepted(bool $accepted): Follow { return $this; } - - /** - * @param array $data - */ - public function import(array $data) { - parent::import($data); - } - - - /** - * @param array $data - */ - public function importFromDatabase(array $data) { - parent::importFromDatabase($data); - - $this->setAccepted(($this->getInt('accepted', $data, 0) === 1) ? true : false); - $this->setFollowId($this->get('follow_id', $data, '')); - $this->setFollowIdPrim($this->get('follow_id_prim', $data, '')); - } - - /** * @return array */ @@ -150,9 +122,6 @@ public function jsonSerialize(): array { $result = array_merge( $result, [ - 'follow_id' => $this->getFollowId(), - 'follow_id_prim' => $this->getFollowIdPrim(), - 'accepted' => $this->isAccepted() ] ); } diff --git a/lib/Repository/InstanceRepository.php b/lib/Repository/InstanceRepository.php new file mode 100644 index 000000000..0e63ab3fb --- /dev/null +++ b/lib/Repository/InstanceRepository.php @@ -0,0 +1,12 @@ +findOneBy(['local' => true]); + } +} diff --git a/lib/Serializer/AccountSerializer.php b/lib/Serializer/AccountSerializer.php new file mode 100644 index 000000000..ad73c1591 --- /dev/null +++ b/lib/Serializer/AccountSerializer.php @@ -0,0 +1,48 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Serializer; + +use OCA\Social\Entity\Account; +use OCA\Social\InstanceUtils; +use OCP\IRequest; +use OCP\IUserManager; + +class AccountSerializer extends ActivityPubSerializer { + private IUserManager $userManager; + private InstanceUtils $instanceUtils; + + public function __construct(IUserManager $userManager, InstanceUtils $instanceUtils) { + $this->userManager = $userManager; + $this->instanceUtils = $instanceUtils; + } + + public function toJsonLd(object $account): array { + assert($account instanceof Account && $account->isLocal()); + + $user = $this->userManager->get($account->getUserId()); + + $baseUrl = $this->instanceUtils->getLocalInstanceUrl() . '/'; + $baseUserUrl = $baseUrl . "/users/" . $account->getUserName() . '/'; + + return array_merge($this->getContext(), [ + "id" => $baseUrl . $account->getUserName(), + "type" => $account->getActorType(), + "following" => $baseUserUrl . "following", + "followers" => $baseUserUrl . "followers", + "inbox" => $baseUserUrl . "inbox", + "outbox" => $baseUserUrl . "outbox", + "preferredUsername" => $account->getUserName(), + "name" => $user->getDisplayName(), + "publicKey" => [ + "id" => $baseUrl . $account->getUserName() . "#main-key", + "owner" => $baseUrl . $account->getUserName(), + "publicKeyPem" => $account->getPublicKey(), + ] + ]); + } +} diff --git a/lib/Serializer/ActivityPubSerializer.php b/lib/Serializer/ActivityPubSerializer.php new file mode 100644 index 000000000..8c885fab4 --- /dev/null +++ b/lib/Serializer/ActivityPubSerializer.php @@ -0,0 +1,85 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Serializer; + +/** + * @template T + */ +abstract class ActivityPubSerializer { + /** + * @param T $account + * @return array + */ + abstract public function toJsonLd(object $account): array; + + protected function getContext(): array { + // Provide namespace information + return [ + "@context" => [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + [ + "manuallyApprovesFollowers" => "as:manuallyApprovesFollowers", + "toot" => "http://joinmastodon.org/ns#", + "featured" => [ + "@id" => "toot:featured", + "@type" => "@id", + ], + "featuredTags" => [ + "@id" => "toot:featuredTags", + "@type" => "@id", + ], + "alsoKnownAs" => [ + "@id" => "as:alsoKnownAs", + "@type" => "@id", + ], + "movedTo" => [ + "@id" => "as=>movedTo", + "@type" => "@id" + ], + "schema" => "http=>//schema.org#", + "PropertyValue" => "schema:PropertyValue", + "value" => "schema:value", + "discoverable" => "toot:discoverable", + "Device" => "toot:Device", + "Ed25519Signature" => "toot:Ed25519Signature", + "Ed25519Key" => "toot:Ed25519Key", + "Curve25519Key" => "toot:Curve25519Key", + "EncryptedMessage" => "toot:EncryptedMessage", + "publicKeyBase64" => "toot:publicKeyBase64", + "deviceId" => "toot:deviceId", + "claim" => [ + "@type" => "@id", + "@id" => "toot:claim" + ], + "fingerprintKey" => [ + "@type" => "@id", + "@id" => "toot:fingerprintKey" + ], + "identityKey" => [ + "@type" => "@id", + "@id" => "toot:identityKey" + ], + "devices" => [ + "@type" => "@id", + "@id" => "toot:devices" + ], + "messageFranking" => "toot:messageFranking", + "messageType" => "toot:messageType", + "cipherText" => "toot:cipherText", + "suspended" => "toot:suspended", + "focalPoint" => [ + "@container" => "@list", + "@id" => "toot:focalPoint" + ] + ] + ] + ]; + } +} diff --git a/lib/Serializer/SerializerFactory.php b/lib/Serializer/SerializerFactory.php new file mode 100644 index 000000000..cbef8f88e --- /dev/null +++ b/lib/Serializer/SerializerFactory.php @@ -0,0 +1,42 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Serializer; + +use Psr\Container\ContainerInterface; + +class SerializerFactory { + /** + * @template T + * @var array, class-string>> + */ + private array $serializers = []; + private ContainerInterface $container; + + public function __construct(ContainerInterface $container) { + $this->container = $container; + } + + /** + * @template T + * @param class-string $className + * @param class-string> $serializerName + */ + public function registerSerializer(string $className, string $serializerName): void { + $this->serializers[$className] = $serializerName; + } + + /** + * @template T + * @param T $object + * @return ActivityPubSerializer + */ + public function getSerializerFor(object $object): ActivityPubSerializer { + return $this->container->get($this->serializers[get_class($object)]); + } +} diff --git a/lib/Service/AccountFinder.php b/lib/Service/AccountFinder.php new file mode 100644 index 000000000..384f64cc4 --- /dev/null +++ b/lib/Service/AccountFinder.php @@ -0,0 +1,107 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service; + +use Doctrine\Common\Collections\Collection; +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Follow; +use OCA\Social\Entity\Instance; +use OCP\DB\ORM\IEntityManager; +use OCP\DB\ORM\IEntityRepository; +use OCP\DB\ORM\NoResultException; +use OCP\IRequest; +use OCP\IUser; + +class AccountFinder { + private IEntityManager $entityManager; + private IEntityRepository $repository; + private IRequest $request; + private ?Account $representative = null; + + public function __construct(IEntityManager $entityManager, IRequest $request) { + $this->entityManager = $entityManager; + $this->repository = $this->entityManager->getRepository(Account::class); + $this->request = $request; + } + + public function findRemote(string $userName, ?string $domain): ?Account { + if ($domain !== null) { + $instance = new Instance(); + $instance->setDomain($domain); + return $this->entityManager->createQuery('SELECT a FROM \OCA\Social\Entity\Account a WHERE a.instance = :instance AND a.userName = :userName') + ->setParameters([ + 'instance' => $instance, + 'userName' => $userName, + ])->getOneOrNullResult(); + } else { + return $this->entityManager->createQuery('SELECT a FROM \OCA\Social\Entity\Account a WHERE a.instance is NULL AND a.userName = :userName') + ->setParameters([ + 'userName' => $userName, + ])->getOneOrNullResult(); + } + } + + public function findLocal(string $userName): ?Account { + return $this->findRemote($userName, null); + } + + public function getAccountByNextcloudId(string $userId): ?Account { + return $this->repository->findOneBy([ + 'userId' => $userId, + ]); + } + + public function getCurrentAccount(IUser $user): Account { + $account = $this->getAccountByNextcloudId($user->getUID()); + if ($account) { + return $account; + } + $account = Account::newLocal(); + $account->setUserName($user->getUID()); + $account->setUserId($user->getUID()); + $account->setName($user->getDisplayName()); + $account->generateKeys(); + $this->entityManager->persist($account); + $this->entityManager->flush(); + return $account; + } + + public function getRepresentative(): Account { + if ($this->representative !== null) { + return $this->representative; + } + $account = $this->repository->findOneBy([ + 'userId' => '__self', + ]); + if ($account) { + $this->representative = $account; + return $account; + } + $account = Account::newLocal(); + $account->setRepresentative() + ->setActorType(Account::TYPE_APPLICATION) + ->setUserName($this->request->getServerHost()) + ->setUserId('__self') + ->setLocked(true) + ->generateKeys(); + $this->entityManager->persist($account); + $this->entityManager->flush(); + $this->representative = $account; + return $account; + } + + /** + * @param Account $account + * @return array + */ + public function getLocalFollowersOf(Account $account): array { + return $this->entityManager + ->createQuery('SELECT f,a FROM \OCA\Social\Entity\Follow f LEFT JOIN f.account a WHERE f.targetAccount = :target') + ->setParameters(['target' => $account]) + ->getResult(); + } +} diff --git a/lib/Service/ActivityPub/RemoteAccountFetcher.php b/lib/Service/ActivityPub/RemoteAccountFetcher.php new file mode 100644 index 000000000..06a995706 --- /dev/null +++ b/lib/Service/ActivityPub/RemoteAccountFetcher.php @@ -0,0 +1,49 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\ActivityPub; + +use OCA\Social\Entity\Account; +use OCP\IRequest; + +class RemoteAccountFetchOption { + public bool $id = true; + public ?string $prefetchedBody = null; + public bool $breakOnRedirect = false; + public bool $onlyKey = false; + + static public function default(): self { + return new self(); + } +} + +class RemoteAccountFetcher { + private IRequest $request; + private TagManager $tagManager; + + public function __construct(IRequest $request) { + $this->request = $request; + $this->tagManager = TagManager::getInstance(); + } + + public function fetch(?string $uri, RemoteAccountFetchOption $fetchOption): ?Account { + if ($this->tagManager->isLocalUri($uri)) { + return $this->tagManager->uriToResource($uri, Account::class); + } + + if ($fetchOption->prefetchedBody !== null) { + $json = json_decode($fetchOption->prefetchedBody); + } else { + $json = $this->fetchResource($uri, $fetchOption->id); + } + + return null; + } + + public function fetchResource() { + + } +} diff --git a/lib/Service/ActivityPub/SignedRequest.php b/lib/Service/ActivityPub/SignedRequest.php new file mode 100644 index 000000000..1c54e8895 --- /dev/null +++ b/lib/Service/ActivityPub/SignedRequest.php @@ -0,0 +1,93 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\ActivityPub; + +use OCA\Social\Entity\Account; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Tools\Model\NCRequest; +use OCA\Social\Tools\Model\Request; +use OCA\Social\Tools\Model\Uri; + +class SignedRequest extends Request { + private ?Account $account = null; + + const FORMAT_URI = 'URI'; + const FORMAT_ACCOUNT = 'ACCOUNT'; + + public const DATE_HEADER = 'D, d M Y H:i:s T'; + public const DATE_OBJECT = 'Y-m-d\TH:i:s\Z'; + + private bool $alreadySigned = false; + + public function __construct(Uri $url, int $type = 0, bool $binary = false) { + parent::__construct($url, $type, $binary); + } + + /** + * @param self::FORMAT_* $keyIdFormat + */ + public function setOnBehalfOf(Account $onBehalfOf): self { + $this->account = $onBehalfOf; + return $this; + } + + public function setKeyIdFormat(string $keyIdFormat = self::FORMAT_URI): self { + $this->format = $keyIdFormat; + return $this; + } + + public function sign() { + if ($this->alreadySigned) { + throw new \RuntimeException('Trying to sign a request two times'); + } + $date = gmdate(self::DATE_HEADER); + + $headersElements = ['(request-target)', 'content-length', 'date', 'host', 'digest']; + $allElements = [ + '(request-target)' => Request::method($this->getType()) . ' ' . $this->getPath(), + 'date' => $date, + 'host' => $this->getHost(), + 'digest' => $this->generateDigest($this->getDataBody()), + 'content-length' => strlen($this->getDataBody()) + ]; + + $signing = $this->generateHeaders($headersElements, $allElements); + openssl_sign($signing, $signed, $this->account->getPrivateKey(), OPENSSL_ALGO_SHA256); + + $signed = base64_encode($signed); + $signature = $this->generateSignature($headersElements, $this->account->getUserName(), $signed); + + $this->addHeader('Signature', $signature); + } + + private function generateHeaders(array $elements, array $data): string { + $signingElements = []; + foreach ($elements as $element) { + $signingElements[] = $element . ': ' . $data[$element]; + $this->addHeader($element, $data[$element]); + } + + return implode("\n", $signingElements); + } + + private function generateSignature(array $elements, string $actorId, string $signed): string { + $signatureElements[] = 'keyId="' . $actorId . '#main-key"'; + $signatureElements[] = 'algorithm="rsa-sha256"'; + $signatureElements[] = 'headers="' . implode(' ', $elements) . '"'; + $signatureElements[] = 'signature="' . $signed . '"'; + + return implode(',', $signatureElements); + } + + private function generateDigest(string $data): string { + $encoded = hash("sha256", utf8_encode($data), true); + + return 'SHA-256=' . base64_encode($encoded); + } +} diff --git a/lib/Service/ActivityPub/TJsonLdUtils.php b/lib/Service/ActivityPub/TJsonLdUtils.php new file mode 100644 index 000000000..2ba35ee8c --- /dev/null +++ b/lib/Service/ActivityPub/TJsonLdUtils.php @@ -0,0 +1,49 @@ +client = $clientService->newClient(); + $this->jsonLdCache = $cacheFactory->createLocal('social.jsonld'); + } + + public function fetchResource(string $uri, bool $id, ?Account $onBehalfOf = null) { + if (!$id) { + $json = $this->fetchResourceWithoutIdValidation($uri, $onBehalfOf); + } + } + + private function fetchResourceWithoutIdValidation(string $uri, ?Account $onBehalfOf): array { + + $this->client->get($uri, [ + 'header' => [ + 'Accept' => 'application/activity+json, application/ld+json', + ], + ]); + } + + public function onBehalfOf(Account $account, $keyIdFormat, string $signWith): array { + return []; + } + + private function buildRequest(?Account $onBehalfOf): SignedRequest { + $request = new SignedRequest(); + $request->setOnBehalfOf($onBehalfOf); + $request->addHeader('Accept', 'application/activity+json, application/ld+json'); + return $request; + } +} diff --git a/lib/Service/ActivityPub/TagManager.php b/lib/Service/ActivityPub/TagManager.php new file mode 100644 index 000000000..d12dbc11e --- /dev/null +++ b/lib/Service/ActivityPub/TagManager.php @@ -0,0 +1,100 @@ +request = $request; + } + + private function __clone() { + } + + /** + * @template T + * @param class-string $className + * @return ?T + */ + public function uriToResource(string $uri, string $className): object { + if ($this->isLocalUri($uri)) { + // Find resource but from the DB + switch ($className) { + case Account::class: + return null; // TODO + + case Status::class: + return null; // TODO + + return null; + } + } else { + // Find remote resource + } + } + + public function isLocalUri(?string $uri) { + if ($uri === null) { + return false; + } + + $parsedUrl = parse_url($uri); + if (!isset($parsedUrl['host'])) { + return false; + } + $host = $parsedUrl['host']; + if (isset($parsedUrl['port'])) { + $host = $host . ':' . $parsedUrl['port']; + } + return $host === $this->request->getServerHost(); + } + + public function uriFor(object $target): string { + if ($target->getUri()) { + return $target->getUri(); + } + + $instanceUtils = \OCP\Server::get(InstanceUtils::class); + + if ($target instanceof Status) { + if ($target->isReblog()) { + // todo + } + return $instanceUtils->getLocalInstanceUrl() . '/users/' . $target->getAccount()->getUserName() . '/statues/' . $target->getId(); + } + } + + public function urlFor(object $target): string { + if ($target->getUrl()) { + return $target->getUrl(); + } + + $instanceUtils = \OCP\Server::get(InstanceUtils::class); + + if ($target instanceof Status) { + if ($target->isReblog()) { + // todo + } + return $instanceUtils->getLocalInstanceUrl() . '/@' . $target->account->getUserName() . '/' . $target->getId(); + } + } + + public function __wakeup() { + throw new \Exception("Cannot unserialize singleton"); + } +} diff --git a/lib/Service/ApiException.php b/lib/Service/ApiException.php new file mode 100644 index 000000000..9c726f8b2 --- /dev/null +++ b/lib/Service/ApiException.php @@ -0,0 +1,6 @@ + +// SPDX-FileCopyrightText: 2022 Carl Schwan +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service; + +use OCA\Social\Entity\Application; +use Exception; +use OCA\Social\Exceptions\ClientException; +use OCA\Social\Exceptions\ClientNotFoundException; +use OCA\Social\Tools\Traits\TStringTools; +use OCP\DB\ORM\IEntityManager; +use OCP\DB\ORM\IEntityRepository; + +class ApplicationService { + public const TIME_TOKEN_REFRESH = 300; // 5m +// const TIME_TOKEN_TTL = 21600; // 6h +// const TIME_AUTH_TTL = 30672000; // 1y + + // looks like there is no token refresh. token must have been updated in the last year. + public const TIME_TOKEN_TTL = 30672000; // 1y + + use TStringTools; + + private IEntityManager $em; + /** @var IEntityRepository */ + private IEntityRepository $applicationRepository; + + public function __construct(IEntityManager $em) { + $this->em = $em; + $this->applicationRepository = $em->getRepository(Application::class); + } + + + /** + * @throws ClientException + */ + public function createApp(Application $application): void { + if ($application->getAppName() === '') { + throw new ClientException('missing client_name'); + } + + if (empty($application->getAppRedirectUris())) { + throw new ClientException('missing redirect_uris'); + } + + $application->setAppClientId($this->token(40)); + $application->setAppClientSecret($this->token(40)); + + $this->em->persist($application); + $this->em->flush(); + } + + public function authClient(Application $client) { + $client->setAuthCode($this->token(60)); + $this->em->flush(); + } + + public function generateToken(Application $client): void { + $client->setToken($this->token(80)); + $this->em->flush(); + } + + public function getFromClientId(string $clientId): Application { + return $this->applicationRepository->findOneBy([ + 'appClientId' => $clientId, + ]); + } + + /** + * @throws ClientNotFoundException + */ + public function getFromToken(string $token): Application { + /** @var Application $application */ + $application = $this->applicationRepository->findOneBy(['token' => $token]); + + if ($application->getLastUpdate() + self::TIME_TOKEN_TTL < time()) { + try { + $this->em->remove($application); + $this->em->flush(); + } catch (Exception $e) { + } + + throw new ClientNotFoundException(); + } + + if ($application->getLastUpdate() + self::TIME_TOKEN_REFRESH > time()) { + $application->setLastUpdate((new \DateTime('now'))->getTimestamp()); + $this->em->flush(); + } + + return $application; + } + + /** + * @throws ClientException + */ + public function confirmData(Application $client, array $data): void { + if (array_key_exists('redirect_uri', $data) + && !in_array($data['redirect_uri'], $client->getAppRedirectUris())) { + throw new ClientException('unknown redirect_uri'); + } + + if (array_key_exists('client_secret', $data) + && $data['client_secret'] !== $client->getAppClientSecret()) { + throw new ClientException('wrong client_secret'); + } + + if (array_key_exists('app_scopes', $data)) { + $scopes = $data['app_scopes']; + if (!is_array($scopes)) { + $scopes = $client->getScopesFromString($scopes); + } + + foreach ($scopes as $scope) { + if (!in_array($scope, $client->getAppScopes())) { + throw new ClientException('invalid scope'); + } + } + } + + if (array_key_exists('auth_scopes', $data)) { + $scopes = $data['auth_scopes']; + if (!is_array($scopes)) { + $scopes = $client->getScopesFromString($scopes); + } + + foreach ($scopes as $scope) { + if (!in_array($scope, $client->getAuthScopes())) { + throw new ClientException('invalid scope'); + } + } + } + + if (array_key_exists('code', $data) && $data['code'] !== $client->getAuthCode()) { + throw new ClientException('unknown code'); + } + } +} diff --git a/lib/Service/CheckService.php b/lib/Service/CheckService.php index 763d4eb55..730e9707b 100644 --- a/lib/Service/CheckService.php +++ b/lib/Service/CheckService.php @@ -57,7 +57,6 @@ class CheckService { use TArrayTools; use TStringTools; - public const CACHE_PREFIX = 'social_check_'; private IUserManager $userManager; diff --git a/lib/Service/ClientService.php b/lib/Service/ClientService.php deleted file mode 100644 index e181cb41b..000000000 --- a/lib/Service/ClientService.php +++ /dev/null @@ -1,200 +0,0 @@ - - * @copyright 2018, Maxence Lange - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -namespace OCA\Social\Service; - -use OCA\Social\Tools\Traits\TStringTools; -use Exception; -use OCA\Social\Db\ClientRequest; -use OCA\Social\Exceptions\ClientException; -use OCA\Social\Exceptions\ClientNotFoundException; -use OCA\Social\Model\Client\SocialClient; - -/** - * Class ClientService - * - * @package OCA\Social\Service - */ -class ClientService { - public const TIME_TOKEN_REFRESH = 300; // 5m -// const TIME_TOKEN_TTL = 21600; // 6h -// const TIME_AUTH_TTL = 30672000; // 1y - - // looks like there is no token refresh. token must have been updated in the last year. - public const TIME_TOKEN_TTL = 30672000; // 1y - - - use TStringTools; - - - private ClientRequest $clientRequest; - - private MiscService $miscService; - - - /** - * ClientService constructor. - * - * @param ClientRequest $clientRequest - * @param MiscService $miscService - */ - public function __construct(ClientRequest $clientRequest, MiscService $miscService) { - $this->clientRequest = $clientRequest; - $this->miscService = $miscService; - } - - - /** - * @param SocialClient $client - * - * @throws ClientException - */ - public function createApp(SocialClient $client): void { - if ($client->getAppName() === '') { - throw new ClientException('missing client_name'); - } - - if (empty($client->getAppRedirectUris())) { - throw new ClientException('missing redirect_uris'); - } - - $client->setAppClientId($this->token(40)); - $client->setAppClientSecret($this->token(40)); - - $this->clientRequest->saveApp($client); - } - - - /** - * @param SocialClient $client - */ - public function authClient(SocialClient $client) { - $client->setAuthCode($this->token(60)); -// $clientAuth->setClientId($client->getId()); - - $this->clientRequest->authClient($client); - } - - - /** - * @param SocialClient $client - */ - public function generateToken(SocialClient $client): void { - $client->setToken($this->token(80)); - - $this->clientRequest->updateToken($client); - } - - - /** - * @param string $clientId - * - * @return SocialClient - * @throws ClientNotFoundException - */ - public function getFromClientId(string $clientId): SocialClient { - return $this->clientRequest->getFromClientId($clientId); - } - - - /** - * @param string $token - * - * @return SocialClient - * @throws ClientNotFoundException - */ - public function getFromToken(string $token): SocialClient { - $client = $this->clientRequest->getFromToken($token); - - if ($client->getLastUpdate() + self::TIME_TOKEN_TTL < time()) { - try { - $this->clientRequest->deprecateToken(); - } catch (Exception $e) { - } - - throw new ClientNotFoundException(); - } - - if ($client->getLastUpdate() + self::TIME_TOKEN_REFRESH > time()) { - $this->clientRequest->updateTime($client); - } - - return $client; - } - - - /** - * @param SocialClient $client - * @param array $data - * - * @throws ClientException - */ - public function confirmData(SocialClient $client, array $data) { - if (array_key_exists('redirect_uri', $data) - && !in_array($data['redirect_uri'], $client->getAppRedirectUris())) { - throw new ClientException('unknown redirect_uri'); - } - - if (array_key_exists('client_secret', $data) - && $data['client_secret'] !== $client->getAppClientSecret()) { - throw new ClientException('wrong client_secret'); - } - - if (array_key_exists('app_scopes', $data)) { - $scopes = $data['app_scopes']; - if (!is_array($scopes)) { - $scopes = $client->getScopesFromString($scopes); - } - - foreach ($scopes as $scope) { - if (!in_array($scope, $client->getAppScopes())) { - throw new ClientException('invalid scope'); - } - } - } - - if (array_key_exists('auth_scopes', $data)) { - $scopes = $data['auth_scopes']; - if (!is_array($scopes)) { - $scopes = $client->getScopesFromString($scopes); - } - - foreach ($scopes as $scope) { - if (!in_array($scope, $client->getAuthScopes())) { - throw new ClientException('invalid scope'); - } - } - } - - if (array_key_exists('code', $data) && $data['code'] !== $client->getAuthCode()) { - throw new ClientException('unknown code'); - } - } -} diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 1c2a852f6..db4a5dba7 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -307,19 +307,8 @@ public function getCloudHost(): string { * @throws SocialAppConfigException */ public function getCloudUrl(bool $noPhp = false) { - $address = $this->getAppValue(self::CLOUD_URL); - if ($address === '') { - throw new SocialAppConfigException(); - } - - if ($noPhp) { - $pos = strpos($address, '/index.php'); - if ($pos) { - $address = substr($address, 0, $pos); - } - } - - return $this->withoutEndSlash($address, false, false); + $url = $this->urlGenerator->getAbsoluteURL('/'); + return rtrim($url, '/'); } /** diff --git a/lib/Service/Feed/FeedManager.php b/lib/Service/Feed/FeedManager.php new file mode 100644 index 000000000..0b6447796 --- /dev/null +++ b/lib/Service/Feed/FeedManager.php @@ -0,0 +1,53 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\Feed; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; + +/** + * This service handle storing and retrieving the feeds from Redis + */ +class FeedManager { + /** + * Number of items in the feed since last reblog of status + * before the new reblog will be inserted. Must be <= MAX_ITEMS + * or the tracking sets will grow forever + */ + const REBLOG_FALLOFF = 40; + + const HOME_FEED = "home"; + const MAX_ITEM = 400; + + private IFeedProvider $feedProvider; + + public function __construct(IFeedProvider $feedProvider) { + $this->feedProvider = $feedProvider; + } + + public function addToHome(string $accountId, Status $status): bool { + return $this->addToFeed(self::HOME_FEED, $accountId, $status); + } + + public function removeFromHome(string $accountId, Status $status): bool { + return $this->removeFromFeed(self::HOME_FEED, $accountId, $status); + } + + public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool { + return $this->feedProvider->addToFeed($timelineType, $accountId, $status, $aggregateReblog); + } + + public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool { + return $this->feedProvider->removeFromFeed($timelineType, $accountId, $status, $aggregateReblog); + } + + public function mergeIntoHome(Account $fromAccount, Account $toAccount): void { + $this->feedProvider->mergeIntoHome($fromAccount, $toAccount); + } +} diff --git a/lib/Service/Feed/IFeedProvider.php b/lib/Service/Feed/IFeedProvider.php new file mode 100644 index 000000000..3145adc50 --- /dev/null +++ b/lib/Service/Feed/IFeedProvider.php @@ -0,0 +1,33 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\Feed; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; + +/** + * Interface abstracting the feed. Currently, there is only one implementation + * relying on Redis. + */ +interface IFeedProvider { + /** + * Add a status from a feed + */ + public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool; + + /** + * Remove a status from a feed + */ + public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool; + + /** + * Fill a home feed with an account's status + */ + public function mergeIntoHome(Account $fromAccount, Account $toAccount): void; +} diff --git a/lib/Service/Feed/PostDeliveryService.php b/lib/Service/Feed/PostDeliveryService.php new file mode 100644 index 000000000..ff62e122e --- /dev/null +++ b/lib/Service/Feed/PostDeliveryService.php @@ -0,0 +1,53 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\Feed; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Mention; +use OCA\Social\Entity\Status; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Service\Feed\FeedManager; + +class PostDeliveryService { + private FeedManager $feedManager; + private AccountFinder $accountFinder; + + public function __construct(FeedManager $feedManager, AccountFinder $accountFinder) { + $this->feedManager = $feedManager; + $this->accountFinder = $accountFinder; + } + + public function run(Status $status): void { + $author = $status->getAccount(); + // deliver to self + if ($status->isLocal()) { + $this->feedManager->addToHome($author->getId(), $status); + } + + // deliver to mentioned accounts + $status->getActiveMentions()->forAll(function ($mention) use ($status): void{ + if ($mention && $mention->getAccount()->isLocal()) { + $this->deliverLocalAccount($status, $mention->getAccount()); + } + }); + + // deliver to local followers + $localFollowers = $this->accountFinder->getLocalFollowersOf($author); + foreach ($localFollowers as $follower) { + $this->deliverLocalAccount($status, $follower->getAccount()); + }; + } + + public function deliverLocalAccount(Status $status, Account $account) { + assert($account->isLocal()); + + // TODO create notification + + $this->feedManager->addToHome($account->getId(), $status); + } +} diff --git a/lib/Service/Feed/RedisFeedProvider.php b/lib/Service/Feed/RedisFeedProvider.php new file mode 100644 index 000000000..e110743df --- /dev/null +++ b/lib/Service/Feed/RedisFeedProvider.php @@ -0,0 +1,67 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service\Feed; + +use OC\RedisFactory; +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; + +class RedisFeedProvider implements IFeedProvider { + private \Redis $redis; + + public function __construct(RedisFactory $redisFactory) { + $this->redis = $redisFactory->getInstance(); + } + + private function key(string $feedName, string $accountId, ?string $subType = null) { + if ($subType === null) { + return 'feed:' . $feedName . ':' . $accountId; + } + return 'feed:' . $feedName . ':' . $accountId . ':' . $subType; + } + + public function addToFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool { + $timelineKey = $this->key($timelineType, $accountId); + $reblogKey = $this->key($timelineType, $accountId, 'reblogs'); + + if ($status->isReblog() && $aggregateReblog) { + $rank = $this->redis->zRevRank($timelineKey, $status->getReblogOf()->getId()); + if ($rank !== null && $rank < FeedManager::REBLOG_FALLOFF) { + return false; + } + + if ($this->redis->zAdd($reblogKey, ['NX'], $status->getId(), $status->getReblogOf()->getId())) { + $this->redis->zAdd($timelineKey, $status->getId(), $status->getReblogOf()->getId()); + } else { + $reblogSetKey = $this->key($timelineType, $accountId, 'reblogs:' . $status->getReblogOf()->getId()); + $this->redis->sAdd($reblogSetKey, $status->getId()); + return false; + } + } else { + if ($this->redis->zScore($reblogKey, $status->getId()) === false) { + return false; + } + $this->redis->zAdd($timelineKey, $status->getId(), $status->getId()); + } + + return true; + } + + public function removeFromFeed(string $timelineType, string $accountId, Status $status, bool $aggregateReblog = true): bool { + return false; + } + + public function mergeIntoHome(Account $fromAccount, Account $intoAccount): void { + $timelineKey = $this->key(FeedManager::HOME_FEED, $intoAccount->getId()); + $aggregate = true; // TODO make configurable + + if ($this->redis->zCard($timelineKey) > (FeedManager::MAX_ITEM / 4)) { + $oldestHomeScore = $this->redis->zRange($timelineKey, 0, 0, true); + } + } +} diff --git a/lib/Service/FollowService.php b/lib/Service/FollowService.php index 47e1ce59b..503b532c0 100644 --- a/lib/Service/FollowService.php +++ b/lib/Service/FollowService.php @@ -30,79 +30,126 @@ namespace OCA\Social\Service; -use OCA\Social\Tools\Exceptions\MalformedArrayException; -use OCA\Social\Tools\Traits\TArrayTools; +use ActivityPhp\Type; use OCA\Social\AP; -use OCA\Social\Db\FollowsRequest; -use OCA\Social\Exceptions\CacheActorDoesNotExistException; -use OCA\Social\Exceptions\FollowNotFoundException; -use OCA\Social\Exceptions\FollowSameAccountException; -use OCA\Social\Exceptions\InvalidOriginException; -use OCA\Social\Exceptions\InvalidResourceException; -use OCA\Social\Exceptions\ItemUnknownException; -use OCA\Social\Exceptions\RedundancyLimitException; -use OCA\Social\Tools\Exceptions\RequestContentException; -use OCA\Social\Tools\Exceptions\RequestNetworkException; -use OCA\Social\Tools\Exceptions\RequestResultNotJsonException; -use OCA\Social\Tools\Exceptions\RequestResultSizeException; -use OCA\Social\Tools\Exceptions\RequestServerException; -use OCA\Social\Exceptions\RetrieveAccountFormatException; -use OCA\Social\Exceptions\SocialAppConfigException; -use OCA\Social\Exceptions\UnauthorizedFediverseException; -use OCA\Social\Exceptions\UrlCloudException; -use OCA\Social\Model\ActivityPub\Activity\Undo; -use OCA\Social\Model\ActivityPub\Actor\Person; -use OCA\Social\Model\ActivityPub\Object\Follow; -use OCA\Social\Model\ActivityPub\OrderedCollection; -use OCA\Social\Model\InstancePath; +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Follow; +use OCA\Social\Entity\FollowRequest; +use OCA\Social\Service\Feed\FeedManager; +use OCP\DB\ORM\IEntityManager; +use OCP\DB\ORM\IEntityRepository; + +class FollowOption { + /** + * Show reblog of the account + */ + public bool $showReblogs = true; -class FollowService { - use TArrayTools; + /** + * Notify about new posts + */ + public bool $notify = false; + static public function default(): self { + return new FollowOption(); + } +} - private FollowsRequest $followsRequest; +class FollowService { + private IEntityManager $entityManager; + /** @var IEntityRepository $followRepository */ + private IEntityRepository $followRepository; + /** @var IEntityRepository $followRepository */ + private IEntityRepository $followRequestRepository; + private FeedManager $feedManager; + + public function __construct(IEntityManager $entityManager, FeedManager $feedManager) { + $this->entityManager = $entityManager; + $this->followRepository = $entityManager->getRepository(Follow::class); + $this->followRepository = $entityManager->getRepository(FollowRequest::class); + $this->feedManager = $feedManager; + } - private ActivityService $activityService; + public function follow(Account $sourceAccount, Account $targetAccount, FollowOption $option): void { + if ($sourceAccount->following($targetAccount)) { + $this->updateFollow($sourceAccount, $targetAccount, $option->notify, $option->showReblogs); + return; + } elseif ($sourceAccount->followRequested($targetAccount)) { + $this->updateFollowRequest($sourceAccount, $targetAccount, $option->notify, $option->showReblogs); + return; + } - private CacheActorService $cacheActorService; + if ($targetAccount->isLocked() || !$targetAccount->isLocal()) { + $this->requestFollow($sourceAccount, $targetAccount); + } else { + $this->directFollow($sourceAccount, $targetAccount); + } + } - private ConfigService $configService; + private function updateFollow(Account $sourceAccount, Account $targetAccount, bool $notify, bool $showReblogs): void { + /** @var Follow $follow */ + $follow = $this->followRepository->findOneBy([ + 'account' => $sourceAccount, + 'targetAccount' => $targetAccount, + ]); + assert($follow); + + $follow->setNotify($notify); + $follow->setShowReblogs($showReblogs); + $this->entityManager->persist($follow); + $this->entityManager->flush(); + } - private MiscService $miscService; + private function updateFollowRequest(Account $sourceAccount, Account $targetAccount, bool $notify, bool $showReblogs): void { + /** @var Follow $follow */ + $followRequest = $this->followRequestRepository->findOneBy([ + 'account' => $sourceAccount, + 'targetAccount' => $targetAccount, + ]); + assert($followRequest); + + $followRequest->setNotify($notify); + $followRequest->setShowReblogs($showReblogs); + $this->entityManager->persist($followRequest); + $this->entityManager->flush(); + } + private function directFollow(Account $sourceAccount, Account $targetAccount): Follow { + $follow = $sourceAccount->follow($targetAccount); + $this->entityManager->persist($follow); + $this->entityManager->flush(); - private ?Person $viewer = null; + // TODO Notify target account they got a new follower + // Add statues of target user into source user timeline + $this->feedManager->mergeIntoHome($targetAccount, $sourceAccount); - /** - * FollowService constructor. - * - * @param FollowsRequest $followsRequest - * @param ActivityService $activityService - * @param CacheActorService $cacheActorService - * @param ConfigService $configService - * @param MiscService $miscService - */ - public function __construct( - FollowsRequest $followsRequest, ActivityService $activityService, - CacheActorService $cacheActorService, ConfigService $configService, MiscService $miscService - ) { - $this->followsRequest = $followsRequest; - $this->activityService = $activityService; - $this->cacheActorService = $cacheActorService; - $this->configService = $configService; - $this->miscService = $miscService; + return $follow; } + private function requestFollow(Account $sourceAccount, Account $targetAccount) { + if ($targetAccount->isLocal()) { + // Just create an internal follow request + $followRequest = $sourceAccount->requestFollow($targetAccount); + $this->entityManager->persist($followRequest); + $this->entityManager->flush(); - /** - * @param Person $viewer - */ - public function setViewer(Person $viewer) { - $this->viewer = $viewer; - $this->followsRequest->setViewer($viewer); + // TODO Notify target account they got a new follow request + } else { + $this->createRemoteFollowRequest($sourceAccount, $targetAccount); + } } + private function createRemoteFollowRequest(Account $sourceAccount, Account $targetAccount): void { + /** @var Type\Extended\Activity\Follow $follow */ + $follow = Type::create('Follow', [ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'actor' => $sourceAccount->getUri(), + 'object' => $targetAccount->getUri(), + ]); + + // TODO send follow request + } /** * @param Person $actor @@ -131,12 +178,6 @@ public function followAccount(Person $actor, string $account) { throw new FollowSameAccountException("Don't follow yourself, be your own lead"); } - /** @var Follow $follow */ - $follow = AP::$activityPub->getItemFromType(Follow::TYPE); - $follow->generateUniqueId(); - $follow->setActorId($actor->getId()); - $follow->setObjectId($remoteActor->getId()); - $follow->setFollowId($remoteActor->getFollowers()); try { $this->followsRequest->getByPersons($actor->getId(), $remoteActor->getId()); diff --git a/lib/Service/PostServiceStatus.php b/lib/Service/PostServiceStatus.php new file mode 100644 index 000000000..f111016d6 --- /dev/null +++ b/lib/Service/PostServiceStatus.php @@ -0,0 +1,95 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; +use OCA\Social\Service\Feed\PostDeliveryService; +use OCP\DB\ORM\IEntityManager; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; + +class PostServiceStatus { + private ICache $idempotenceCache; + private IConfig $config; + private IEntityManager $entityManager; + private ProcessMentionsService $mentionsService; + private PostDeliveryService $deliveryService; + + public function __construct( + ICacheFactory $cacheFactory, + IConfig $config, + IEntityManager $entityManager, + ProcessMentionsService $mentionsService, + PostDeliveryService $deliveryService + ) { + $this->idempotenceCache = $cacheFactory->createDistributed('social.idempotence'); + $this->config = $config; + $this->entityManager = $entityManager; + $this->mentionsService = $mentionsService; + $this->deliveryService = $deliveryService; + } + + /** + * @psalm-param array{?text: string, ?spoilerText: string, ?sensitive: bool, ?visibility: Status::STATUS_*} $options + */ + public function create(Account $account, array $options): Status { + $this->checkIdempotenceDuplicate($account, $options); + + $status = new Status(); + $status->setText($options['text'] ?? ''); + $status->setSensitive(isset($options['spoilerText']) + || ($options['sensitive'] ?? $this->config->getUserValue($account->getUserId(), 'social', 'default_sensitivity', 'no') === 'yes')); + $status->setAccount($account); + $status->setLocal(true); + + if (isset($options['inReplyToId'])) { + $status->setInReplyToId($options['inReplyToId']); + } + + $visibility = $options['visibility'] ?? $this->config->getUserValue($account->getUserId(), 'social', 'default_privacy', Status::STATUS_PUBLIC); + if (!in_array($visibility, [Status::STATUS_DIRECT, Status::STATUS_PRIVATE, Status::STATUS_PUBLIC, Status::STATUS_UNLISTED])) { + throw new ApiException('Invalid visibility'); + } + + // Add mentioned user to CC + $this->mentionsService->run($status); + + // Save status + $this->entityManager->persist($status); + $this->entityManager->persist($account); + $this->entityManager->flush(); + + $this->deliveryService->run($status); + + $this->updateIdempotency($account, $status); + return $status; + } + + private function idempotencyKey(Account $account, string $idempotency): string { + return $account->getUserId() . '-' . $idempotency; + } + + private function checkIdempotenceDuplicate(Account $account, array $options): void { + if (!isset($options['idempotency'])) { + return; + } + + if ($this->idempotenceCache->get($this->idempotencyKey($account, $options['idempotency'])) !== null) { + throw new ApiException('Same message already sent'); + } + } + + private function updateIdempotency(Account $account, Status $status): void { + if (!isset($options['idempotency'])) { + return; + } + + $this->idempotenceCache->set($this->idempotencyKey($account, $options['idempotency']), $status->getId(), 3600); + } +} diff --git a/lib/Service/ProcessMentionsService.php b/lib/Service/ProcessMentionsService.php new file mode 100644 index 000000000..8eea6f15e --- /dev/null +++ b/lib/Service/ProcessMentionsService.php @@ -0,0 +1,80 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Service; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use OCA\Social\Entity\Mention; +use OCA\Social\Entity\Status; +use OCP\IRequest; + +class ProcessMentionsService { + private Collection $previousMentions; + private Collection $currentMentions; + private IRequest $request; + private AccountFinder $accountFinder; + private ResolveAccountService $resolveAccountService; + + public function __construct(IRequest $request, AccountFinder $accountFinder, ResolveAccountService $resolveAccountService) { + $this->previousMentions = new ArrayCollection(); + $this->currentMentions = new ArrayCollection(); + $this->request = $request; + $this->accountFinder = $accountFinder; + $this->resolveAccountService = $resolveAccountService; + } + + public function run(Status $status) { + if (!$status->isLocal()) { + return; + } + + $this->previousMentions = $status->getActiveMentions(); + $this->currentMentions = new ArrayCollection(); + + if (preg_match_all('/@(([a-z0-9_]([a-z0-9_\.-]+[a-z0-9_]+)+)(@[[:word:]\.\-]+[[:word:]]+)?)/i', $status->getText(), $matches)) { + $host = $this->request->getServerHost(); + for ($i = 0; $i < count($matches[0]); $i++) { + $completeMatch = $matches[0][$i]; + $userName = $matches[2][$i]; + $domain = $matches[4][$i] === '' ? '' : substr($matches[4][$i], 1); + + $isLocal = $domain === '' || $host === $domain; + if ($isLocal) { + $domain = null; + } else { + // normalize domain name + $domain = parse_url('https://' . $domain, PHP_URL_HOST); + } + + $mentionnedAccount = $this->accountFinder->findRemote($userName, $domain); + assert($mentionnedAccount !== null); + + if (!$mentionnedAccount) { + // try to resolve it + $mentionnedAccount = $this->resolveAccountService->resolveMention($userName, $domain, AccountResolverOption::default()); + } + + if (!$mentionnedAccount) { + // give up + continue; + } + $mentions = $this->previousMentions->filter(fn (Mention $mention) => $mention->getAccount()->getId() === $mentionnedAccount->getId()); + if ($mentions->isEmpty()) { + $mention = new Mention(); + $mention->setStatus($status); + $mention->setAccount($mentionnedAccount); + } else { + $mention = $mentions->first(); + } + $this->currentMentions->add($mention); + str_replace($completeMatch, $mentionnedAccount->getAccountName(), $status->getText()); + } + } + + $status->setMentions($this->currentMentions); + } +} diff --git a/lib/Service/ResolveAccountService.php b/lib/Service/ResolveAccountService.php new file mode 100644 index 000000000..fcdfc7030 --- /dev/null +++ b/lib/Service/ResolveAccountService.php @@ -0,0 +1,143 @@ +clientService = $clientService; + $this->request = $request; + $this->trustedDomainChecker = $trustedDomainChecker; + $this->accountFinder = $accountFinder; + $this->remoteAccountFetcher = $remoteAccountFetcher; + } + + /** + * @param string $userName The username of the user + * @param string $domain The domain of the user + * @return Account|null + */ + public function resolveMention(string $userName, string $domain, AccountResolverOption $option): ?Account { + $webFinger = $this->requestWebfinger($userName, $domain); + if ($webFinger === null) { + return null; + } + + [$confirmedUserName, $confirmedDomain] = $webFinger->getSubject(); + + if ($confirmedDomain !== $domain || $confirmedUserName !== $userName) { + if (!$option->followWebfingerRedirection) { + return null; + } + + $webFinger = $this->requestWebfinger($confirmedUserName, $confirmedDomain); + if ($webFinger === null) { + return null; + } + + [$newConfirmedUserName, $newConfirmedDomain] = $webFinger->getSubject(); + if ($confirmedDomain !== $newConfirmedDomain || $confirmedUserName !== $newConfirmedUserName) { + // Hijack attempt + return null; + } + $confirmedDomain = $newConfirmedDomain; + $confirmedUserName = $newConfirmedUserName; + } + + if ($confirmedDomain === $this->request->getServerHost()) { + $confirmedDomain = null; + } + + if ($this->trustedDomainChecker->check($confirmedDomain)) { + return null; // blocked + } + + $account = $this->accountFinder->findRemote($userName, $domain); + + if ($account !== null && ($account->isLocal() || !$account->possiblyStale())) { + return $account; + } + + return $this->fetchAccount($webFinger); + } + + private function requestWebfinger($userName, $domain): ?NCWebfinger { + $client = $this->clientService->newClient(); + + $uri = 'acct:' . $userName . '@' . $domain; + if (str_ends_with($domain, '.onion')) { + $url = 'http://' . $domain . '/.well-known/webfinger?resource=' . $uri; + } else { + $url = 'https://' . $domain . '/.well-known/webfinger?resource=' . $uri; + } + $response = $client->get($url, [ + 'headers' => [ + 'Accept' => 'application/jrd+json, application/json', + ], + ]); + + if ($response->getStatusCode() !== 200) { + // TODO mark server as unavailable + return null; + } + + try { + $webFinger = new NCWebfinger(json_decode($response->getBody())); + } catch (\Exception $e) { + return null; + } + return $webFinger; + } + + public function resolveAccount(Account $account, AccountResolverOption $option): ?Account { + if (!$account->isLocal() && $option->queryWebfinger && $account->possiblyStale()) { + return $this->resolveMention($account->getUserName(), $account->getDomain(), $option); + } + return $account; + } + + public function fetchAccount(NCWebfinger $webfinger): ?Account { + // TODO lock + + $actorUrl = $webfinger->getLink('self'); + if (!$actorUrl) { + return null; + } + + return $this->remoteAccountFetcher->fetch($actorUrl, RemoteAccountFetchOption::default()); + } +} diff --git a/lib/Service/SignatureService.php b/lib/Service/SignatureService.php index 021f4e062..7a8ad6c92 100644 --- a/lib/Service/SignatureService.php +++ b/lib/Service/SignatureService.php @@ -30,6 +30,7 @@ namespace OCA\Social\Service; +use OCA\Social\Entity\Account; use OCA\Social\Tools\Exceptions\DateTimeException; use OCA\Social\Tools\Exceptions\MalformedArrayException; use OCA\Social\Tools\Exceptions\RequestContentException; @@ -43,8 +44,6 @@ use Exception; use JsonLdException; use OCA\Social\AppInfo\Application; -use OCA\Social\Db\ActorsRequest; -use OCA\Social\Exceptions\ActorDoesNotExistException; use OCA\Social\Exceptions\InvalidOriginException; use OCA\Social\Exceptions\InvalidResourceException; use OCA\Social\Exceptions\ItemUnknownException; @@ -77,31 +76,7 @@ class SignatureService { public const DATE_HEADER = 'D, d M Y H:i:s T'; public const DATE_OBJECT = 'Y-m-d\TH:i:s\Z'; - public const DATE_DELAY = 300; - - private CacheActorService $cacheActorService; - private ActorsRequest $actorsRequest; - private CurlService $curlService; - private ConfigService $configService; - private MiscService $miscService; - - public function __construct( - ActorsRequest $actorsRequest, CacheActorService $cacheActorService, - CurlService $curlService, - ConfigService $configService, MiscService $miscService - ) { - $this->actorsRequest = $actorsRequest; - $this->cacheActorService = $cacheActorService; - $this->curlService = $curlService; - $this->configService = $configService; - $this->miscService = $miscService; - } - - - /** - * @param Person $actor - */ - public function generateKeys(Person &$actor) { + public function generateKeys(Account $account): void { $res = openssl_pkey_new( [ "digest_alg" => "rsa", @@ -113,19 +88,11 @@ public function generateKeys(Person &$actor) { openssl_pkey_export($res, $privateKey); $publicKey = openssl_pkey_get_details($res)['key']; - $actor->setPublicKey($publicKey); - $actor->setPrivateKey($privateKey); + $account->setPublicKey($publicKey); + $account->setPrivateKey($privateKey); } - - /** - * @param NCRequest $request - * @param RequestQueue $queue - * - * @throws ActorDoesNotExistException - * @throws SocialAppConfigException // TODO: implement in TNCRequest ? - */ - public function signRequest(NCRequest $request, RequestQueue $queue): void { + public function signRequest(SignedRequest $request): void { $date = gmdate(self::DATE_HEADER); $path = $queue->getInstance(); @@ -133,7 +100,7 @@ public function signRequest(NCRequest $request, RequestQueue $queue): void { $headersElements = ['(request-target)', 'content-length', 'date', 'host', 'digest']; $allElements = [ - '(request-target)' => 'post ' . $path->getPath(), + '(request-target)' => 'path' . $request->getPath(), 'date' => $date, 'host' => $path->getAddress(), 'digest' => $this->generateDigest($request->getDataBody()), diff --git a/lib/Service/TrustedDomainChecker.php b/lib/Service/TrustedDomainChecker.php new file mode 100644 index 000000000..56d515068 --- /dev/null +++ b/lib/Service/TrustedDomainChecker.php @@ -0,0 +1,11 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Settings; + +use OCP\AppFramework\Http\TemplateResponse; +use OCP\Settings\ISettings; + +class Personal implements ISettings { + public function getForm(): TemplateResponse { + return new TemplateResponse('social', 'settings-personal'); + } + + public function getSection(): string { + return 'social'; + } + + public function getPriority(): int { + return 99; + } +} diff --git a/lib/Settings/PersonalSection.php b/lib/Settings/PersonalSection.php new file mode 100644 index 000000000..99b956cf4 --- /dev/null +++ b/lib/Settings/PersonalSection.php @@ -0,0 +1,40 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Settings; + +use OCP\IL10N; +use OCP\IURLGenerator; +use OCP\Settings\IIconSection; + +class PersonalSection implements IIconSection { + + private IURLGenerator $url; + private IL10N $l; + + public function __construct(IURLGenerator $generator, IL10N $l) { + $this->url = $generator; + $this->l = $l; + } + + public function getID() { + return 'social'; + } + + public function getName() { + return $this->l->t('Social'); + } + + public function getPriority() { + return 99; + } + + public function getIcon(): string { + return $this->url->imagePath('social', 'social-dark.svg'); + } +} diff --git a/lib/Tools/Exceptions/MalformedUriException.php b/lib/Tools/Exceptions/MalformedUriException.php new file mode 100644 index 000000000..0e12a15c9 --- /dev/null +++ b/lib/Tools/Exceptions/MalformedUriException.php @@ -0,0 +1,6 @@ +client = $client; - return $this; + public function getOnBehalfOf(): ?Account { + return $this->onBehalfOf; } - public function getClient(): IClient { - return $this->client; + public function setOnBehalfOf(?Account $onBehalfOf): void { + $this->onBehalfOf = $onBehalfOf; } - public function getClientOptions(): array { - return $this->clientOptions; - } - - public function setClientOptions(array $clientOptions): self { - $this->clientOptions = $clientOptions; - - return $this; - } - - public function isLocalAddressAllowed(): bool { - return $this->localAddressAllowed; - } - - public function setLocalAddressAllowed(bool $allowed): self { - $this->localAddressAllowed = $allowed; - - return $this; - } - - public function jsonSerialize(): array { - return array_merge( - parent::jsonSerialize(), - [ - 'clientOptions' => $this->getClientOptions(), - 'localAddressAllowed' => $this->isLocalAddressAllowed(), - ] - ); - } } diff --git a/lib/Tools/Model/Request.php b/lib/Tools/Model/Request.php index 1ee6763fc..915c458df 100644 --- a/lib/Tools/Model/Request.php +++ b/lib/Tools/Model/Request.php @@ -33,6 +33,7 @@ use OCA\Social\Tools\Traits\TArrayTools; use JsonSerializable; +use OCP\Http\Client\IClient; /** * Class Request @@ -42,211 +43,96 @@ class Request implements JsonSerializable { use TArrayTools; - public const TYPE_GET = 0; public const TYPE_POST = 1; public const TYPE_PUT = 2; public const TYPE_DELETE = 3; - public const QS_VAR_DUPLICATE = 1; public const QS_VAR_ARRAY = 2; - - /** @var string */ - private $protocol = ''; - - /** @var array */ - private $protocols = ['https']; - - /** @var string */ - private $host = ''; - - /** @var int */ - private $port = 0; - - /** @var string */ - private $url = ''; - - /** @var string */ - private $baseUrl = ''; - - /** @var int */ - private $type = 0; - - /** @var bool */ - private $binary = false; - - /** @var bool */ - private $verifyPeer = true; - - /** @var bool */ - private $httpErrorsAllowed = false; - - /** @var bool */ - private $followLocation = true; - - /** @var array */ - private $headers = []; - - /** @var array */ - private $cookies = []; - - /** @var array */ - private $params = []; - - /** @var array */ - private $data = []; - - /** @var int */ - private $queryStringType = self::QS_VAR_DUPLICATE; - - /** @var int */ - private $timeout = 10; - - /** @var string */ - private $userAgent = ''; - - /** @var int */ - private $resultCode = 0; - - /** @var string */ - private $contentType = ''; - - - /** - * Request constructor. - * - * @param string $url - * @param int $type - * @param bool $binary - */ - public function __construct(string $url = '', int $type = 0, bool $binary = false) { + private string $protocol = ''; + private array $protocols = ['https']; + private string $host = ''; + private int $port = 0; + private ?Uri $url; + private string $baseUrl = ''; + private int $type = 0; + private bool $binary = false; + private bool $verifyPeer = true; + private bool $httpErrorsAllowed = false; + private bool $followLocation = true; + private array $headers = []; + private array $cookies = []; + private array $params = []; + private array $data = []; + private int $queryStringType = self::QS_VAR_DUPLICATE; + private int $timeout = 10; + private string $userAgent = ''; + private int $resultCode = 0; + private string $contentType = ''; + private IClient $client; + + public function __construct(?Uri $url = null, int $type = 0, bool $binary = false) { $this->url = $url; $this->type = $type; $this->binary = $binary; } - /** - * @param string $protocol - * - * @return Request - */ - public function setProtocol(string $protocol): Request { - $this->protocols = [$protocol]; - - return $this; - } - - /** - * @param array $protocols - * - * @return Request - */ - public function setProtocols(array $protocols): Request { - $this->protocols = $protocols; - + public function setClient(IClient $client): self { + $this->client = $client; return $this; } - /** - * @return string[] - */ - public function getProtocols(): array { - return $this->protocols; - } - - /** - * @return string - */ - public function getUsedProtocol(): string { - return $this->protocol; + public function getClient(): IClient { + return $this->client; } - /** - * @param string $protocol - * - * @return Request - */ - public function setUsedProtocol(string $protocol): Request { + public function setProtocol(string $protocol): self { $this->protocol = $protocol; - return $this; } - - /** - * @return string - * @deprecated - 19 - use getHost(); - */ - public function getAddress(): string { - return $this->getHost(); - } - - /** - * @param string $address - * - * @return Request - * @deprecated - 19 - use setHost(); - */ - public function setAddress(string $address): Request { - $this->setHost($address); - - return $this; + public function getProtocol(): string { + return $this->protocol; } - /** - * @return string - */ public function getHost(): string { - return $this->host; + return $this->url->getHost(); } - /** - * @param string $host - * - * @return Request - */ - public function setHost(string $host): Request { - $this->host = $host; + public function setHost(string $host): self { + $this->url->setHost($host); return $this; } - - /** - * @return int - */ - public function getPort(): int { - return $this->port; + public function getPort(): ?int { + return $this->url->getPort(); } - /** - * @param int $port - * - * @return Request - */ - public function setPort(int $port): Request { - $this->port = $port; + public function setPort(?int $port): self { + $this->url->setPort($port); return $this; } - /** - * @param string $instance + * Set the instance * - * @return Request + * @param string $instance The instance for example floss.social, cloud.com:442, 4u3849u3.onion + * @return $this */ - public function setInstance(string $instance): Request { + public function setInstance(string $instance): self { + $this->setPort(null); if (strpos($instance, ':') === false) { $this->setHost($instance); return $this; } - list($host, $port) = explode(':', $instance, 2); + [$host, $port] = explode(':', $instance, 2); $this->setHost($host); if ($port !== '') { $this->setPort((int)$port); @@ -255,63 +141,20 @@ public function setInstance(string $instance): Request { return $this; } - - /** - * @return string - */ public function getInstance(): string { $instance = $this->getHost(); - if ($this->getPort() > 0) { + if ($this->getPort() !== null) { $instance .= ':' . $this->getPort(); } return $instance; } - - /** - * @param string $url - * - * @deprecated - 19 - use basedOnUrl(); - */ - public function setAddressFromUrl(string $url) { - $this->basedOnUrl($url); - } - - /** - * @param string $url - */ - public function basedOnUrl(string $url) { - $protocol = parse_url($url, PHP_URL_SCHEME); - if ($protocol === null) { - if (strpos($url, '/') > -1) { - list($address, $baseUrl) = explode('/', $url, 2); - $this->setBaseUrl('/' . $baseUrl); - } else { - $address = $url; - } - if (strpos($address, ':') > -1) { - list($address, $port) = explode(':', $address, 2); - $this->setPort((int)$port); - } - $this->setHost($address); - } else { - $this->setProtocols([$protocol]); - $this->setUsedProtocol($protocol); - $this->setHost(parse_url($url, PHP_URL_HOST)); - $this->setBaseUrl(parse_url($url, PHP_URL_PATH)); - if (is_numeric($port = parse_url($url, PHP_URL_PORT))) { - $this->setPort($port); - } - } + public function parse(string $url): void { + $this->url = new Uri($url); } - /** - * @param string|null $baseUrl - * - * @return Request - */ - public function setBaseUrl(?string $baseUrl): Request { + public function setBaseUrl(?string $baseUrl): self { if ($baseUrl !== null) { $this->baseUrl = $baseUrl; } @@ -319,92 +162,40 @@ public function setBaseUrl(?string $baseUrl): Request { return $this; } - /** - * @return bool - */ public function isBinary(): bool { return $this->binary; } - - /** - * @param bool $verifyPeer - * - * @return $this - */ - public function setVerifyPeer(bool $verifyPeer): Request { + public function setVerifyPeer(bool $verifyPeer): self { $this->verifyPeer = $verifyPeer; return $this; } - /** - * @return bool - */ public function isVerifyPeer(): bool { return $this->verifyPeer; } - - /** - * @param bool $httpErrorsAllowed - * - * @return Request - */ - public function setHttpErrorsAllowed(bool $httpErrorsAllowed): Request { + public function setHttpErrorsAllowed(bool $httpErrorsAllowed): self { $this->httpErrorsAllowed = $httpErrorsAllowed; return $this; } - /** - * @return bool - */ public function isHttpErrorsAllowed(): bool { return $this->httpErrorsAllowed; } - - /** - * @param bool $followLocation - * - * @return $this - */ - public function setFollowLocation(bool $followLocation): Request { + public function setFollowLocation(bool $followLocation): self { $this->followLocation = $followLocation; return $this; } - /** - * @return bool - */ public function isFollowLocation(): bool { return $this->followLocation; } - - /** - * @return string - * @deprecated - 19 - use getParametersUrl() + addParam() - */ - public function getParsedUrl(): string { - $url = $this->getPath(); - $ak = array_keys($this->getData()); - foreach ($ak as $k) { - if (!is_string($this->data[$k])) { - continue; - } - - $url = str_replace(':' . $k, $this->data[$k], $url); - } - - return $url; - } - - /** - * @return string - */ public function getParametersUrl(): string { $url = $this->getPath(); $ak = array_keys($this->getParams()); @@ -419,43 +210,19 @@ public function getParametersUrl(): string { return $url; } - - /** - * @return string - */ public function getPath(): string { return $this->baseUrl . $this->url; } - - /** - * @return string - * @deprecated - 19 - use getPath() - */ - public function getUrl(): string { - return $this->getPath(); - } - - - /** - * @return string - */ public function getCompleteUrl(): string { - $port = ($this->getPort() > 0) ? ':' . $this->getPort() : ''; - - return $this->getUsedProtocol() . '://' . $this->getHost() . $port . $this->getParametersUrl(); + return (string)$this->url; } - - /** - * @return int - */ public function getType(): int { return $this->type; } - - public function addHeader($key, $value): Request { + public function addHeader($key, $value): self { $header = $this->get($key, $this->headers); if ($header !== '') { $header .= ', ' . $value; @@ -474,213 +241,92 @@ public function addHeader($key, $value): Request { public function getHeaders(): array { return array_merge(['User-Agent' => $this->getUserAgent()], $this->headers); } - - /** - * @param array $headers - * - * @return Request - */ - public function setHeaders(array $headers): Request { + public function setHeaders(array $headers): self { $this->headers = $headers; return $this; } - - /** - * @return array - */ public function getCookies(): array { return $this->cookies; } - /** - * @param array $cookies - * - * @return Request - */ - public function setCookies(array $cookies): Request { + public function setCookies(array $cookies): self { $this->cookies = $cookies; return $this; } - - /** - * @param int $queryStringType - * - * @return Request - */ public function setQueryStringType(int $queryStringType): self { $this->queryStringType = $queryStringType; return $this; } - /** - * @return int - */ public function getQueryStringType(): int { return $this->queryStringType; } - - /** - * @return array - */ public function getData(): array { return $this->data; } - - /** - * @param array $data - * - * @return Request - */ - public function setData(array $data): Request { + public function setData(array $data): self { $this->data = $data; return $this; } - - /** - * @param string $data - * - * @return Request - */ - public function setDataJson(string $data): Request { + public function setDataJson(string $data): self { $this->setData(json_decode($data, true)); return $this; } - - /** - * @param JsonSerializable $data - * - * @return Request - */ - public function setDataSerialize(JsonSerializable $data): Request { + public function setDataSerialize(JsonSerializable $data): self { $this->setDataJson(json_encode($data)); return $this; } - - /** - * @return array - */ public function getParams(): array { return $this->params; } - /** - * @param array $params - * - * @return Request - */ - public function setParams(array $params): Request { + public function setParams(array $params): self { $this->params = $params; return $this; } - - /** - * @param string $k - * @param string $v - * - * @return Request - */ - public function addParam(string $k, string $v): Request { + public function addParam(string $k, string $v): self { $this->params[$k] = $v; return $this; } - - /** - * @param string $k - * @param int $v - * - * @return Request - */ - public function addParamInt(string $k, int $v): Request { + public function addParamInt(string $k, int $v): self { $this->params[$k] = $v; return $this; } - - /** - * @param string $k - * @param string $v - * - * @return Request - */ - public function addData(string $k, string $v): Request { + public function addData(string $k, string $v): self { $this->data[$k] = $v; return $this; } - - /** - * @param string $k - * @param int $v - * - * @return Request - */ - public function addDataInt(string $k, int $v): Request { + public function addDataInt(string $k, int $v): self { $this->data[$k] = $v; return $this; } - - /** - * @return string - */ public function getDataBody(): string { return json_encode($this->getData()); } - /** - * @return string - * @deprecated - 19 - use getUrlParams(); - */ - public function getUrlData(): string { - if ($this->getData() === []) { - return ''; - } - - return preg_replace( - '/([(%5B)]{1})[0-9]+([(%5D)]{1})/', '$1$2', http_build_query($this->getData()) - ); - } - - /** - * @return string - * @deprecated - 21 - use getQueryString(); - */ - public function getUrlParams(): string { - if ($this->getParams() === []) { - return ''; - } - - return preg_replace( - '/([(%5B)]{1})[0-9]+([(%5D)]{1})/', '$1$2', http_build_query($this->getParams()) - ); - } - - - /** - * @param int $type - * - * @return string - */ public function getQueryString(): string { if (empty($this->getParams())) { return ''; @@ -698,90 +344,48 @@ public function getQueryString(): string { } } - - /** - * @return int - */ public function getTimeout(): int { return $this->timeout; } - /** - * @param int $timeout - * - * @return Request - */ - public function setTimeout(int $timeout): Request { + public function setTimeout(int $timeout): self { $this->timeout = $timeout; return $this; } - - /** - * @return string - */ public function getUserAgent(): string { return $this->userAgent; } - /** - * @param string $userAgent - * - * @return Request - */ - public function setUserAgent(string $userAgent): Request { + public function setUserAgent(string $userAgent): self { $this->userAgent = $userAgent; return $this; } - - /** - * @return int - */ public function getResultCode(): int { return $this->resultCode; } - /** - * @param int $resultCode - * - * @return Request - */ - public function setResultCode(int $resultCode): Request { + public function setResultCode(int $resultCode): self { $this->resultCode = $resultCode; return $this; } - - /** - * @return string - */ public function getContentType(): string { return $this->contentType; } - /** - * @param string $contentType - * - * @return Request - */ - public function setContentType(string $contentType): Request { + public function setContentType(string $contentType): self { $this->contentType = $contentType; return $this; } - - /** - * @return array - */ public function jsonSerialize(): array { return [ - 'protocols' => $this->getProtocols(), - 'used_protocol' => $this->getUsedProtocol(), 'port' => $this->getPort(), 'host' => $this->getHost(), 'url' => $this->getPath(), @@ -798,12 +402,6 @@ public function jsonSerialize(): array { ]; } - - /** - * @param string $type - * - * @return int - */ public static function type(string $type): int { switch (strtoupper($type)) { case 'GET': @@ -819,7 +417,6 @@ public static function type(string $type): int { return 0; } - public static function method(int $type): string { switch ($type) { case self::TYPE_GET: diff --git a/lib/Tools/Model/Uri.php b/lib/Tools/Model/Uri.php new file mode 100644 index 000000000..95d8c6089 --- /dev/null +++ b/lib/Tools/Model/Uri.php @@ -0,0 +1,910 @@ +getScheme() != '') { + return $rel->withPath(self::removeDotSegments($rel->getPath())); + } + + if ($rel->getAuthority() != '') { + $targetAuthority = $rel->getAuthority(); + $targetPath = self::removeDotSegments($rel->getPath()); + $targetQuery = $rel->getQuery(); + } else { + $targetAuthority = $base->getAuthority(); + if ($rel->getPath() === '') { + $targetPath = $base->getPath(); + $targetQuery = $rel->getQuery() != '' ? $rel->getQuery() : $base->getQuery(); + } else { + if ($rel->getPath()[0] === '/') { + $targetPath = $rel->getPath(); + } else { + if ($targetAuthority != '' && $base->getPath() === '') { + $targetPath = '/' . $rel->getPath(); + } else { + $lastSlashPos = strrpos($base->getPath(), '/'); + if ($lastSlashPos === false) { + $targetPath = $rel->getPath(); + } else { + $targetPath = substr($base->getPath(), 0, $lastSlashPos + 1) . $rel->getPath(); + } + } + } + $targetPath = self::removeDotSegments($targetPath); + $targetQuery = $rel->getQuery(); + } + } + + return new Uri(Uri::composeComponents( + $base->getScheme(), + $targetAuthority, + $targetPath, + $targetQuery, + $rel->getFragment() + )); + } + + /** + * Returns the target URI as a relative reference from the base URI. + * + * This method is the counterpart to resolve(): + * + * (string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target)) + * + * One use-case is to use the current request URI as base URI and then generate relative links in your documents + * to reduce the document size or offer self-contained downloadable document archives. + * + * $base = new Uri('http://example.com/a/b/'); + * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'. + * echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'. + * echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'. + * echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'. + * + * This method also accepts a target that is already relative and will try to relativize it further. Only a + * relative-path reference will be returned as-is. + * + * echo UriResolver::relativize($base, new Uri('/a/b/c')); // prints 'c' as well + */ + public static function relativize(UriInterface $base, UriInterface $target): UriInterface + { + if ($target->getScheme() !== '' && + ($base->getScheme() !== $target->getScheme() || $target->getAuthority() === '' && $base->getAuthority() !== '') + ) { + return $target; + } + + if (Uri::isRelativePathReference($target)) { + // As the target is already highly relative we return it as-is. It would be possible to resolve + // the target with `$target = self::resolve($base, $target);` and then try make it more relative + // by removing a duplicate query. But let's not do that automatically. + return $target; + } + + if ($target->getAuthority() !== '' && $base->getAuthority() !== $target->getAuthority()) { + return $target->withScheme(''); + } + + // We must remove the path before removing the authority because if the path starts with two slashes, the URI + // would turn invalid. And we also cannot set a relative path before removing the authority, as that is also + // invalid. + $emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost(''); + + if ($base->getPath() !== $target->getPath()) { + return $emptyPathUri->withPath(self::getRelativePath($base, $target)); + } + + if ($base->getQuery() === $target->getQuery()) { + // Only the target fragment is left. And it must be returned even if base and target fragment are the same. + return $emptyPathUri->withQuery(''); + } + + // If the base URI has a query but the target has none, we cannot return an empty path reference as it would + // inherit the base query component when resolving. + if ($target->getQuery() === '') { + $segments = explode('/', $target->getPath()); + /** @var string $lastSegment */ + $lastSegment = end($segments); + + return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment); + } + + return $emptyPathUri; + } + + private static function getRelativePath(UriInterface $base, UriInterface $target): string + { + $sourceSegments = explode('/', $base->getPath()); + $targetSegments = explode('/', $target->getPath()); + array_pop($sourceSegments); + $targetLastSegment = array_pop($targetSegments); + foreach ($sourceSegments as $i => $segment) { + if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) { + unset($sourceSegments[$i], $targetSegments[$i]); + } else { + break; + } + } + $targetSegments[] = $targetLastSegment; + $relativePath = str_repeat('../', count($sourceSegments)) . implode('/', $targetSegments); + + // A reference to am empty last segment or an empty first sub-segment must be prefixed with "./". + // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used + // as the first segment of a relative-path reference, as it would be mistaken for a scheme name. + if ('' === $relativePath || false !== strpos(explode('/', $relativePath, 2)[0], ':')) { + $relativePath = "./$relativePath"; + } elseif ('/' === $relativePath[0]) { + if ($base->getAuthority() != '' && $base->getPath() === '') { + // In this case an extra slash is added by resolve() automatically. So we must not add one here. + $relativePath = ".$relativePath"; + } else { + $relativePath = "./$relativePath"; + } + } + + return $relativePath; + } + + private function __construct() + { + // cannot be instantiated + } +} + +/** + * This class provides an abstraction over an uri + * + * @since 25.0.0 + */ +class Uri implements UriInterface, \JsonSerializable { + + /** + * Absolute http and https URIs require a host per RFC 7230 Section 2.7 + * but in generic URIs the host can be empty. So for http(s) URIs + * we apply this default host when no host is given yet to form a + * valid URI. + */ + private const HTTP_DEFAULT_HOST = 'localhost'; + + private const DEFAULT_PORTS = [ + 'http' => 80, + 'https' => 443, + 'ftp' => 21, + 'gopher' => 70, + 'nntp' => 119, + 'news' => 119, + 'telnet' => 23, + 'tn3270' => 23, + 'imap' => 143, + 'pop' => 110, + 'ldap' => 389, + ]; + + /** + * Unreserved characters for use in a regex. + * + * @link https://tools.ietf.org/html/rfc3986#section-2.3 + */ + private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~'; + + /** + * Sub-delims for use in a regex. + * + * @link https://tools.ietf.org/html/rfc3986#section-2.2 + */ + private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;='; + private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26']; + + private string $scheme = ''; + private string $userInfo = ''; + private string $host = ''; + private ?int $port = null; + private string $path = ''; + private string $query = ''; + private string $fragment = ''; + private ?string $composedComponents = null; + + public function __construct(string $uri = '') + { + if ($uri !== '') { + $parts = self::parse($uri); + if ($parts === false) { + throw new \Exception("Unable to parse URI: $uri"); + } + $this->applyParts($parts); + } + } + + /** + * UTF-8 aware parse_url replacement + * @param string $url + * @return array|false + */ + private function parse(string $url) { + // If IPv6 + $prefix = ''; + if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) { + /** @var array{0:string, 1:string, 2:string} $matches */ + $prefix = $matches[1]; + $url = $matches[2]; + } + + /** @var string */ + $encodedUrl = preg_replace_callback( + '%[^:/@?&=#]+%usD', + static function ($matches) { + return urlencode($matches[0]); + }, + $url + ); + + $result = parse_url($prefix . $encodedUrl); + + if ($result === false) { + return false; + } + + return array_map('urldecode', $result); + } + + public function __toString(): string + { + if ($this->composedComponents === null) { + $this->composedComponents = self::composeComponents( + $this->scheme, + $this->getAuthority(), + $this->path, + $this->query, + $this->fragment + ); + } + + return $this->composedComponents; + } + + /** + * Composes a URI reference string from its various components. + * + * Usually this method does not need to be called manually but instead is used indirectly via + * `Psr\Http\Message\UriInterface::__toString`. + * + * PSR-7 UriInterface treats an empty component the same as a missing component as + * getQuery(), getFragment() etc. always return a string. This explains the slight + * difference to RFC 3986 Section 5.3. + * + * Another adjustment is that the authority separator is added even when the authority is missing/empty + * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with + * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But + * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to + * that format). + * + * @link https://tools.ietf.org/html/rfc3986#section-5.3 + */ + public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string + { + $uri = ''; + + // weak type checks to also accept null until we can add scalar type hints + if ($scheme != '') { + $uri .= $scheme . ':'; + } + + if ($authority != ''|| $scheme === 'file') { + $uri .= '//' . $authority; + } + + $uri .= $path; + + if ($query != '') { + $uri .= '?' . $query; + } + + if ($fragment != '') { + $uri .= '#' . $fragment; + } + + return $uri; + } + + /** + * Whether the URI has the default port of the current scheme. + * + * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used + * independently of the implementation. + */ + public static function isDefaultPort(UriInterface $uri): bool + { + return $uri->getPort() === null + || (isset(self::DEFAULT_PORTS[$uri->getScheme()]) && $uri->getPort() === self::DEFAULT_PORTS[$uri->getScheme()]); + } + + /** + * Whether the URI is absolute, i.e. it has a scheme. + * + * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true + * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative + * to another URI, the base URI. Relative references can be divided into several forms: + * - network-path references, e.g. '//example.com/path' + * - absolute-path references, e.g. '/path' + * - relative-path references, e.g. 'subpath' + * + * @see Uri::isNetworkPathReference + * @see Uri::isAbsolutePathReference + * @see Uri::isRelativePathReference + * @link https://tools.ietf.org/html/rfc3986#section-4 + */ + public static function isAbsolute(UriInterface $uri): bool + { + return $uri->getScheme() !== ''; + } + + /** + * Whether the URI is a network-path reference. + * + * A relative reference that begins with two slash characters is termed an network-path reference. + * + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isNetworkPathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' && $uri->getAuthority() !== ''; + } + + /** + * Whether the URI is a absolute-path reference. + * + * A relative reference that begins with a single slash character is termed an absolute-path reference. + * + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isAbsolutePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && $uri->getAuthority() === '' + && isset($uri->getPath()[0]) + && $uri->getPath()[0] === '/'; + } + + /** + * Whether the URI is a relative-path reference. + * + * A relative reference that does not begin with a slash character is termed a relative-path reference. + * + * @link https://tools.ietf.org/html/rfc3986#section-4.2 + */ + public static function isRelativePathReference(UriInterface $uri): bool + { + return $uri->getScheme() === '' + && $uri->getAuthority() === '' + && (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/'); + } + + /** + * Whether the URI is a same-document reference. + * + * A same-document reference refers to a URI that is, aside from its fragment + * component, identical to the base URI. When no base URI is given, only an empty + * URI reference (apart from its fragment) is considered a same-document reference. + * + * @param UriInterface $uri The URI to check + * @param UriInterface|null $base An optional base URI to compare against + * + * @link https://tools.ietf.org/html/rfc3986#section-4.4 + */ + public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool + { + if ($base !== null) { + $uri = UriResolver::resolve($base, $uri); + + return ($uri->getScheme() === $base->getScheme()) + && ($uri->getAuthority() === $base->getAuthority()) + && ($uri->getPath() === $base->getPath()) + && ($uri->getQuery() === $base->getQuery()); + } + + return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === ''; + } + + /** + * Creates a new URI with a specific query string value removed. + * + * Any existing query string values that exactly match the provided key are + * removed. + * + * @param UriInterface $uri URI to use as a base. + * @param string $key Query string key to remove. + */ + public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface + { + $result = self::getFilteredQueryString($uri, [$key]); + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a new URI with a specific query string value. + * + * Any existing query string values that exactly match the provided key are + * removed and replaced with the given key value pair. + * + * A value of null will set the query string key without a value, e.g. "key" + * instead of "key=value". + * + * @param UriInterface $uri URI to use as a base. + * @param string $key Key to set. + * @param string|null $value Value to set + */ + public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface + { + $result = self::getFilteredQueryString($uri, [$key]); + + $result[] = self::generateQueryString($key, $value); + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a new URI with multiple specific query string values. + * + * It has the same behavior as withQueryValue() but for an associative array of key => value. + * + * @param UriInterface $uri URI to use as a base. + * @param array $keyValueArray Associative array of key and values + */ + public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface + { + $result = self::getFilteredQueryString($uri, array_keys($keyValueArray)); + + foreach ($keyValueArray as $key => $value) { + $result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null); + } + + return $uri->withQuery(implode('&', $result)); + } + + /** + * Creates a URI from a hash of `parse_url` components. + * + * @link http://php.net/manual/en/function.parse-url.php + * + * @throws MalformedUriException If the components do not form a valid URI. + */ + public static function fromParts(array $parts): UriInterface + { + $uri = new self(); + $uri->applyParts($parts); + $uri->validateState(); + + return $uri; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getAuthority(): string + { + $authority = $this->host; + if ($this->userInfo !== '') { + $authority = $this->userInfo . '@' . $authority; + } + + if ($this->port !== null) { + $authority .= ':' . $this->port; + } + + return $authority; + } + + public function getUserInfo(): string { + return $this->userInfo; + } + + public function getHost(): string { + return $this->host; + } + + public function setHost(string $host): self { + $this->host = $host; + } + + public function getPort(): ?int { + return $this->port; + } + + public function setPort(?int $port): self { + $this->port = $port !== null ? $this->filterPort($port) : null; + return $this; + } + + public function getPath(): string + { + return $this->path; + } + + public function getQuery(): string + { + return $this->query; + } + + public function getFragment(): string + { + return $this->fragment; + } + + public function withScheme($scheme): UriInterface + { + $scheme = $this->filterScheme($scheme); + + if ($this->scheme === $scheme) { + return $this; + } + + $new = clone $this; + $new->scheme = $scheme; + $new->composedComponents = null; + $new->removeDefaultPort(); + $new->validateState(); + + return $new; + } + + public function withUserInfo($user, $password = null): UriInterface + { + $info = $this->filterUserInfoComponent($user); + if ($password !== null) { + $info .= ':' . $this->filterUserInfoComponent($password); + } + + if ($this->userInfo === $info) { + return $this; + } + + $new = clone $this; + $new->userInfo = $info; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withHost($host): UriInterface + { + $host = $this->filterHost($host); + + if ($this->host === $host) { + return $this; + } + + $new = clone $this; + $new->host = $host; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withPort($port): UriInterface + { + $port = $this->filterPort($port); + + if ($this->port === $port) { + return $this; + } + + $new = clone $this; + $new->port = $port; + $new->composedComponents = null; + $new->removeDefaultPort(); + $new->validateState(); + + return $new; + } + + public function withPath($path): UriInterface + { + $path = $this->filterPath($path); + + if ($this->path === $path) { + return $this; + } + + $new = clone $this; + $new->path = $path; + $new->composedComponents = null; + $new->validateState(); + + return $new; + } + + public function withQuery($query): UriInterface + { + $query = $this->filterQueryAndFragment($query); + + if ($this->query === $query) { + return $this; + } + + $new = clone $this; + $new->query = $query; + $new->composedComponents = null; + + return $new; + } + + public function withFragment($fragment): UriInterface + { + $fragment = $this->filterQueryAndFragment($fragment); + + if ($this->fragment === $fragment) { + return $this; + } + + $new = clone $this; + $new->fragment = $fragment; + $new->composedComponents = null; + + return $new; + } + + public function jsonSerialize(): string + { + return $this->__toString(); + } + + /** + * Apply parse_url parts to a URI. + * + * @param array $parts Array of parse_url parts to apply. + */ + private function applyParts(array $parts): void + { + $this->scheme = isset($parts['scheme']) + ? $this->filterScheme($parts['scheme']) + : ''; + $this->userInfo = isset($parts['user']) + ? $this->filterUserInfoComponent($parts['user']) + : ''; + $this->host = isset($parts['host']) + ? $this->filterHost($parts['host']) + : ''; + $this->port = isset($parts['port']) + ? $this->filterPort($parts['port']) + : null; + $this->path = isset($parts['path']) + ? $this->filterPath($parts['path']) + : ''; + $this->query = isset($parts['query']) + ? $this->filterQueryAndFragment($parts['query']) + : ''; + $this->fragment = isset($parts['fragment']) + ? $this->filterQueryAndFragment($parts['fragment']) + : ''; + if (isset($parts['pass'])) { + $this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']); + } + + $this->removeDefaultPort(); + } + + /** + * @param mixed $scheme + * + * @throws \InvalidArgumentException If the scheme is invalid. + */ + private function filterScheme($scheme): string + { + if (!is_string($scheme)) { + throw new \InvalidArgumentException('Scheme must be a string'); + } + + return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + + /** + * @param mixed $component + * + * @throws \InvalidArgumentException If the user info is invalid. + */ + private function filterUserInfoComponent($component): string + { + if (!is_string($component)) { + throw new \InvalidArgumentException('User info must be a string'); + } + + return preg_replace_callback( + '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $component + ); + } + + /** + * @param mixed $host + * + * @throws \InvalidArgumentException If the host is invalid. + */ + private function filterHost($host): string + { + if (!is_string($host)) { + throw new \InvalidArgumentException('Host must be a string'); + } + + return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + + /** + * @param mixed $port + * + * @throws \InvalidArgumentException If the port is invalid. + */ + private function filterPort($port): ?int + { + if ($port === null) { + return null; + } + + $port = (int) $port; + if (0 > $port || 0xffff < $port) { + throw new \InvalidArgumentException( + sprintf('Invalid port: %d. Must be between 0 and 65535', $port) + ); + } + + return $port; + } + + /** + * @param string[] $keys + * + * @return string[] + */ + private static function getFilteredQueryString(UriInterface $uri, array $keys): array + { + $current = $uri->getQuery(); + + if ($current === '') { + return []; + } + + $decodedKeys = array_map('rawurldecode', $keys); + + return array_filter(explode('&', $current), function ($part) use ($decodedKeys) { + return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true); + }); + } + + private static function generateQueryString(string $key, ?string $value): string + { + // Query string separators ("=", "&") within the key or value need to be encoded + // (while preventing double-encoding) before setting the query string. All other + // chars that need percent-encoding will be encoded by withQuery(). + $queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT); + + if ($value !== null) { + $queryString .= '=' . strtr($value, self::QUERY_SEPARATORS_REPLACEMENT); + } + + return $queryString; + } + + private function removeDefaultPort(): void + { + if ($this->port !== null && self::isDefaultPort($this)) { + $this->port = null; + } + } + + /** + * Filters the path of a URI + * + * @param mixed $path + * + * @throws \InvalidArgumentException If the path is invalid. + */ + private function filterPath($path): string + { + if (!is_string($path)) { + throw new \InvalidArgumentException('Path must be a string'); + } + + return preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $path + ); + } + + /** + * Filters the query string or fragment of a URI. + * + * @param mixed $str + * + * @throws \InvalidArgumentException If the query or fragment is invalid. + */ + private function filterQueryAndFragment($str): string + { + if (!is_string($str)) { + throw new \InvalidArgumentException('Query and fragment must be a string'); + } + + return preg_replace_callback( + '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', + [$this, 'rawurlencodeMatchZero'], + $str + ); + } + + private function rawurlencodeMatchZero(array $match): string + { + return rawurlencode($match[0]); + } + + private function validateState(): void + { + if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { + $this->host = self::HTTP_DEFAULT_HOST; + } + + if ($this->getAuthority() === '') { + if (0 === strpos($this->path, '//')) { + throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"'); + } + if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) { + throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon'); + } + } elseif (isset($this->path[0]) && $this->path[0] !== '/') { + throw new MalformedUriException('The path of a URI with an authority must start with a slash "/" or be empty'); + } + } +} diff --git a/src/App.vue b/src/App.vue index b9f4a5b3b..377c7af67 100644 --- a/src/App.vue +++ b/src/App.vue @@ -118,7 +118,7 @@ export default { AppContent, AppNavigation, AppNavigationItem, - Search, + Search }, mixins: [currentuserMixin], data: function() { diff --git a/src/components/Composer/Composer.vue b/src/components/Composer/Composer.vue index 7d5685b30..31121762a 100644 --- a/src/components/Composer/Composer.vue +++ b/src/components/Composer/Composer.vue @@ -25,12 +25,12 @@ + class="hidden-visually" + @change="handleFileChange($event)"> @@ -62,13 +62,13 @@ @tribute-replaced="updatePostFromTribute" /> - + - + @click.prevent="clickImportInput"> @@ -78,10 +78,10 @@ - + :aria-label="t('social', 'Add emoji')"> @@ -90,10 +90,10 @@ - + @@ -142,10 +142,10 @@ export default { EmoticonOutline, Button, Send, - PreviewGrid, + PreviewGrid }, directives: { - FocusOnCreate, + FocusOnCreate }, mixins: [CurrentUserMixin], props: {}, @@ -256,13 +256,13 @@ export default { computed: { postTo() { switch (this.type) { - case 'public': - case 'unlisted': - return t('social', 'Post') - case 'followers': - return t('social', 'Post to followers') - case 'direct': - return t('social', 'Post to mentioned users') + case 'public': + case 'unlisted': + return t('social', 'Post') + case 'followers': + return t('social', 'Post to followers') + case 'direct': + return t('social', 'Post to mentioned users') } }, currentVisibilityIconClass() { @@ -288,6 +288,14 @@ export default { currentVisibilityPostLabel() { return this.visibilityPostLabel(this.type) }, + message: { + get() { + return this.$store.state.obj.message + }, + set(value) { + this.$store.commit('updateStatus', value) + } + }, visibilityPostLabel() { return (type) => { if (typeof type === 'undefined') { @@ -362,7 +370,7 @@ export default { }, canPost() { if (this.previewUrls.length > 0) { - return true; + return true } return this.post.length !== 0 && this.post !== '' } @@ -378,12 +386,9 @@ export default { this.$refs.fileUploadInput.click() }, handleFileChange(event) { - const previewUrl = URL.createObjectURL(event.target.files[0]) - this.previewUrls.push({ - description: '', - url: previewUrl, - result: event.target.files[0], - }) + const formData = new FormData() + formData.append('file', event.target.files[0]) + this.$store.dispatch('uploadAttachement', formData) }, removeAttachment(idx) { this.previewUrls.splice(idx, 1) @@ -410,13 +415,17 @@ export default { this.menuOpened = false localStorage.setItem('social.lastPostType', type) }, - getPostData() { + keyup(event) { + if (event.shiftKey || event.ctrlKey) { + this.createPost(event) + } + }, + updatePostFromTribute(event) { + // Trick to let vue-contenteditable know that tribute replaced a mention or hashtag + this.$refs.composerInput.oninput(event) + }, + createPost: async function(event) { let element = this.$refs.composerInput.cloneNode(true) - Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => { - var em = document.createTextNode(emoji.getAttribute('alt')) - emoji.replaceWith(em) - }) - let contentHtml = element.innerHTML // Extract mentions from content and create an array out of them @@ -430,67 +439,26 @@ export default { } } while (match) - // Add author of original post in case of reply - if (this.replyTo !== null) { - to.push(this.replyTo.actor_info.account) - } - - // Extract hashtags from content and create an array ot of them - const hashtagRegex = />#([^<]+) (wich we turn in newlines) and decode the remaining html entities let content = contentHtml.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim() content = he.decode(content) - let data = { - content: content, - to: to, - hashtags: hashtags, - type: this.type, - attachments: this.previewUrls.map(preview => preview.result), // TODO send the summary and other props too - } - - if (this.replyTo) { - data.replyTo = this.replyTo.id - } - - return data - }, - keyup(event) { - if (event.shiftKey || event.ctrlKey) { - this.createPost(event) - } - }, - updatePostFromTribute(event) { - // Trick to let vue-contenteditable know that tribute replaced a mention or hashtag - this.$refs.composerInput.oninput(event) - }, - createPost: async function(event) { - - let postData = this.getPostData() - - // Trick to validate last mention when the user directly clicks on the "post" button without validating it. - let regex = /@([-\w]+)$/ - let lastMention = postData.content.match(regex) - if (lastMention) { - - // Ask the server for matching accounts, and wait for the results - let result = await this.remoteSearchAccounts(lastMention[1]) - - // Validate the last mention only when it matches a single account - if (result.data.result.accounts.length === 1) { - postData.content = postData.content.replace(regex, '@' + result.data.result.accounts[0].account) - postData.to.push(result.data.result.accounts[0].account) - } - } + console.debug(content) + this.$store.dispatch('postStatus', content) + // + // // Trick to validate last mention when the user directly clicks on the "post" button without validating it. + // let regex = /@([-\w]+)$/ + // let lastMention = postData.content.match(regex) + // if (lastMention) { + // + // // Ask the server for matching accounts, and wait for the results + // let result = await this.remoteSearchAccounts(lastMention[1]) + // + // // Validate the last mention only when it matches a single account + // if (result.data.result.accounts.length === 1) { + // postData.content = postData.content.replace(regex, '@' + result.data.result.accounts[0].account) + // postData.to.push(result.data.result.accounts[0].account) + // } + // } // Abort if the post is a direct message and no valid mentions were found // if (this.type === 'direct' && postData.to.length === 0) { @@ -498,17 +466,6 @@ export default { // return // } - // Post message - this.loading = true - this.$store.dispatch('post', postData).then((response) => { - this.loading = false - this.replyTo = null - this.post = '' - this.$refs.composerInput.innerText = this.post - this.previewUrls = [] - this.$store.dispatch('refreshTimeline') - }) - }, closeReply() { this.replyTo = null diff --git a/src/components/Composer/PreviewGrid.vue b/src/components/Composer/PreviewGrid.vue index 77e134c91..c218e3065 100644 --- a/src/components/Composer/PreviewGrid.vue +++ b/src/components/Composer/PreviewGrid.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later - + @@ -19,7 +19,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later - + @@ -27,33 +28,33 @@ SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/components/Composer/PreviewGridItem.vue b/src/components/Composer/PreviewGridItem.vue index 942330591..48f8fbaa2 100644 --- a/src/components/Composer/PreviewGridItem.vue +++ b/src/components/Composer/PreviewGridItem.vue @@ -2,32 +2,33 @@ - + - + {{ t('social', 'Delete') }} - + {{ t('social', 'Edit') }} - + {{ t('social', 'No description added') }} - + {{ t('social', 'Describe for the visually impaired') }} - - - {{ t('social', 'Close') }} + + + {{ t('social', 'Close') }} + @@ -46,38 +47,51 @@ export default { Close, Edit, Button, - Modal, - }, - data() { - return { - modal: false, - } - }, - methods: { - showModal() { - this.modal = true - }, - closeModal() { - this.modal = false - } + Modal }, props: { preview: { type: Object, - required: true, + required: true }, index: { type: Number, - required: true, - }, + required: true + } + }, + data() { + return { + modal: false, + internalDescription: '' + } }, computed: { backgroundStyle() { return { - backgroundImage: `url("${this.preview.url}")`, + backgroundImage: `url("${this.preview.preview_url}")` } - }, + } }, + mounted() { + this.internalDescription = this.preview.description + }, + methods: { + deletePreview() { + this.$store.dispatch('deleteAttachement', { + id: this.preview.id + }) + }, + showModal() { + this.modal = true + }, + closeModal() { + this.modal = false + this.$store.dispatch('updateAttachement', { + id: this.preview.id, + description: this.internalDescription + }) + } + } } diff --git a/src/components/TimelineAvatar.vue b/src/components/TimelineAvatar.vue index 70938266b..9a7301754 100644 --- a/src/components/TimelineAvatar.vue +++ b/src/components/TimelineAvatar.vue @@ -19,13 +19,13 @@ import Avatar from '@nextcloud/vue/dist/Components/Avatar' export default { name: 'TimelineAvatar', components: { - Avatar, + Avatar }, props: { item: { type: Object, - default: () => {}, - }, + default: () => {} + } }, computed: { userTest() { @@ -33,8 +33,8 @@ export default { }, avatarUrl() { return OC.generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo) - }, - }, + } + } } diff --git a/src/components/TimelinePost.vue b/src/components/TimelinePost.vue index 764e8c029..75f619922 100644 --- a/src/components/TimelinePost.vue +++ b/src/components/TimelinePost.vue @@ -33,31 +33,31 @@ - - @@ -65,8 +65,8 @@ + icon="icon-delete" + @click="remove()"> {{ t('social', 'Delete') }} @@ -105,7 +105,7 @@ export default { Repeat, Reply, Heart, - HeartOutline, + HeartOutline }, mixins: [currentUser], props: { diff --git a/src/settings-personal.js b/src/settings-personal.js new file mode 100644 index 000000000..07722c65e --- /dev/null +++ b/src/settings-personal.js @@ -0,0 +1,26 @@ +// Nextcloud - Social Support +// SPDX-FileCopyrightText: 2022 Carl Schwan +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Vue from 'vue' + +import App from './views/SettingsPersonal.vue' + +// CSP config for webpack dynamic chunk loading +// eslint-disable-next-line +__webpack_nonce__ = btoa(OC.requestToken) + +// Correct the root of the app for chunk loading +// OC.linkTo matches the apps folders +// eslint-disable-next-line +__webpack_public_path__ = OC.linkTo('social', 'js/') + +Vue.prototype.t = t +Vue.prototype.n = n +Vue.prototype.OC = OC +Vue.prototype.OCA = OCA + +/* eslint-disable-next-line no-new */ +new Vue({ + render: h => h(App) +}).$mount('#settings-personal') diff --git a/src/store/composer.js b/src/store/composer.js new file mode 100644 index 000000000..62e9b75b2 --- /dev/null +++ b/src/store/composer.js @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2022 Carl Schwan +// SPDX-License-Identifier: AGPL-3.0-or-later + +const state = { + attachements: [], + status: '', + sensitive: false +} + +const mutations = { + addAttachement(state, { id, description, url, preview_url }) { + state.attachements.push({ id, description, url, preview_url }) + }, + updateAttachement(state, { id, description, url, preview_url }) { + const index = state.attachements.findIndex(item => { + return id === item.id + }) + state.attachements.splice(index, 1, { id, description, url, preview_url }) + }, + deleteAttachement(state, { id }) { + const index = state.attachements.findIndex(item => { + return id === item.id + }) + state.attachements.splice(index, 1) + }, + clearAttachements(state) { + state.attachements.splice(0) + }, + updateSensitive(sensitive, status) { + state.sensitive = sensitive + } +} + +const actions = { + async uploadAttachement(context, formData) { + const res = await axios.post(generateUrl('apps/social/api/v1/media'), formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + context.commit('addAttachement', { + id: res.data.id, + description: res.data.description, + url: res.data.url, + preview_url: res.data.preview_url + }) + }, + async updateAttachement(context, { id, description }) { + const res = await axios.put(generateUrl('apps/social/api/v1/media/' + id), { + description + }) + context.commit('updateAttachement', { + id: res.data.id, + description: res.data.description, + url: res.data.url, + preview_url: res.data.preview_url + }) + }, + async deleteAttachement(context, { id }) { + const res = await axios.delete(generateUrl('apps/social/api/v1/media/' + id)) + context.commit('deleteAttachement', { + id: res.data.id + }) + }, + async postStatus({ commit, state }, text) { + const data = { + status: text, + media_ids: state.attachements.map(attachement => attachement.id), + sensitive: state.sensitive + } + try { + const response = await axios.post(generateUrl('apps/social/api/v1/statuses'), data) + } catch (error) { + OC.Notification.showTemporary('Failed to create a post') + Logger.error('Failed to create a post', { 'error': error.response }) + } + commit('clearAttachements') + } +} diff --git a/src/store/index.js b/src/store/index.js index 4076bff72..34bd54e23 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -26,6 +26,7 @@ import Vuex from 'vuex' import timeline from './timeline' import account from './account' import settings from './settings' +import composer from './composer' Vue.use(Vuex) @@ -35,7 +36,8 @@ export default new Vuex.Store({ modules: { timeline, account, - settings + settings, + composer }, strict: debug }) diff --git a/src/store/timeline.js b/src/store/timeline.js index 031f3306d..741fc2a44 100644 --- a/src/store/timeline.js +++ b/src/store/timeline.js @@ -52,7 +52,10 @@ const state = { * It's up to the view to honor this status or not. * @member {boolean} */ - composerDisplayStatus: false + composerDisplayStatus: false, + draft: { + attachements: [] + } } const mutations = { addToTimeline(state, data) { @@ -111,6 +114,24 @@ const mutations = { if (typeof parentAnnounce.id !== 'undefined') { Vue.set(state.timeline[parentAnnounce.id].cache[parentAnnounce.object].object.action.values, 'boosted', false) } + }, + addAttachement(state, { id, description, url, preview_url }) { + state.draft.attachements.push({ id, description, url, preview_url }) + }, + updateAttachement(state, { id, description, url, preview_url }) { + const index = state.draft.attachements.findIndex(item => { + return id === item.id + }) + state.draft.attachements.splice(index, 1, { id, description, url, preview_url }) + }, + deleteAttachement(state, { id }) { + const index = state.draft.attachements.findIndex(item => { + return id === item.id + }) + state.draft.attachements.splice(index, 1) + }, + clearAttachements(state) { + state.draft.attachements.splice(0) } } const getters = { @@ -144,18 +165,49 @@ const actions = { context.commit('setTimelineType', 'account') context.commit('setAccount', account) }, - post(context, post) { - return new Promise((resolve, reject) => { - axios.post(generateUrl('apps/social/api/v1/post'), { data: post }).then((response) => { - Logger.info('Post created with token ' + response.data.result.token) - resolve(response) - }).catch((error) => { - OC.Notification.showTemporary('Failed to create a post') - Logger.error('Failed to create a post', { 'error': error.response }) - reject(error) - }) + async uploadAttachement(context, formData) { + const res = await axios.post(generateUrl('apps/social/api/v1/media'), formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + context.commit('addAttachement', { + id: res.data.id, + description: res.data.description, + url: res.data.url, + preview_url: res.data.preview_url + }) + }, + async updateAttachement(context, { id, description }) { + const res = await axios.put(generateUrl('apps/social/api/v1/media/' + id), { + description + }) + context.commit('updateAttachement', { + id: res.data.id, + description: res.data.description, + url: res.data.url, + preview_url: res.data.preview_url }) }, + async deleteAttachement(context, { id }) { + const res = await axios.delete(generateUrl('apps/social/api/v1/media/' + id)) + context.commit('deleteAttachement', { + id: res.data.id + }) + }, + async postStatus({ commit, state }, text) { + const data = { + status: text, + media_ids: state.draft.attachements.map(attachement => attachement.id) + } + try { + const response = axios.post(generateUrl('apps/social/api/v1/statuses'), data) + } catch (error) { + OC.Notification.showTemporary('Failed to create a post') + Logger.error('Failed to create a post', { 'error': error.response }) + } + commit('clearAttachements') + }, postDelete(context, post) { return axios.delete(generateUrl(`apps/social/api/v1/post?id=${post.id}`)).then((response) => { context.commit('removePost', post) diff --git a/src/views/SettingsPersonal.vue b/src/views/SettingsPersonal.vue new file mode 100644 index 000000000..07209a933 --- /dev/null +++ b/src/views/SettingsPersonal.vue @@ -0,0 +1,85 @@ + + + + {{ t('social', 'Default post visibility:') }} + + {{ t('social', 'Public') }} + + + {{ t('social', 'Make your post publicly visible') }} + + + + {{ t('social', 'Instance Only') }} + + + {{ t('social', 'Make your post visible only to the user of this instance') }} + + + + {{ t('social', 'Followers only') }} + + + {{ t('social', 'Make your post visible only to your follower') }} + + + + + + {{ t('social', 'Require follow requests') }} + + + {{ t('social', 'Manually control who can follow you by approving follow requests') }} + + + + + + + + diff --git a/templates/settings-personal.php b/templates/settings-personal.php new file mode 100644 index 000000000..8a4e25789 --- /dev/null +++ b/templates/settings-personal.php @@ -0,0 +1,12 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +\OCP\Util::addScript('social', 'settings-personal'); +?> + + diff --git a/tests/Serializer/AccountSerializerTest.php b/tests/Serializer/AccountSerializerTest.php new file mode 100644 index 000000000..b7e7f79a7 --- /dev/null +++ b/tests/Serializer/AccountSerializerTest.php @@ -0,0 +1,47 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Entitiy; + +use OCA\Social\Entity\Account; +use OCA\Social\InstanceUtils; +use OCA\Social\Serializer\AccountSerializer; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserManager; +use Test\TestCase; + +class AccountSerializerTest extends TestCase { + public function testJsonLd(): void { + $localDomain = "helloworld.social"; + $instanceUtil = $this->createMock(InstanceUtils::class); + $instanceUtil->expects($this->any()) + ->method('getLocalInstanceUrl') + ->willReturn('https://' . $localDomain); + + $alice = $this->createMock(IUser::class); + $alice->expects($this->atLeastOnce()) + ->method('getDisplayName') + ->willReturn('Alice Alice'); + + $userManager = $this->createMock(IUserManager::class); + $userManager->expects($this->once()) + ->method('get') + ->with('alice_id') + ->willReturn($alice); + + $account = Account::newLocal(); + $account->setUserName('alice'); + $account->setUserId('alice_id'); + + $accountSerializer = new AccountSerializer($userManager, $instanceUtil); + $jsonLd = $accountSerializer->toJsonLd($account); + $this->assertSame('https://' . $localDomain . '/alice', $jsonLd['id']); + $this->assertSame('Alice Alice', $jsonLd['name']); + } +} diff --git a/tests/Service/AccountFinderTest.php b/tests/Service/AccountFinderTest.php new file mode 100644 index 000000000..7c7e9fbc6 --- /dev/null +++ b/tests/Service/AccountFinderTest.php @@ -0,0 +1,63 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Service; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Follow; +use OCA\Social\Service\AccountFinder; +use OCP\DB\ORM\IEntityManager; +use OCP\Server; +use Test\TestCase; + +/** + * @group DB + */ +class AccountFinderTest extends TestCase { + private ?Account $account1 = null; + private ?Account $account2 = null; + private ?AccountFinder $accountFinder = null; + + public function setUp(): void { + parent::setUp(); + + $em = Server::get(IEntityManager::class); + + $this->account1 = Account::newLocal('user1', 'user1', 'User1'); + $this->account2 = Account::newLocal('user2', 'user2', 'User2'); + $this->account2->follow($this->account1); + + $em->persist($this->account1); + $em->persist($this->account2); + $em->flush(); + + $this->accountFinder = Server::get(AccountFinder::class); + } + + public function tearDown(): void { + $em = Server::get(IEntityManager::class); + $em->remove($this->account1); + $em->remove($this->account2); + $em->flush(); + + parent::tearDown(); + } + + public function testGetLocalFollower(): void { + $accounts = $this->accountFinder->getLocalFollowersOf($this->account1); + $this->assertSame(1, count($accounts)); + $this->assertSame($accounts[0]->getAccount()->getId(), $this->account2->getId()); + } + + public function testGetRepresentive(): void { + $account = $this->accountFinder->getRepresentative(); + $account1 = $this->accountFinder->getRepresentative(); + + // Caching works + $this->assertSame($account, $account1); + } +} diff --git a/tests/Service/ActivityPub/TagManagerTest.php b/tests/Service/ActivityPub/TagManagerTest.php new file mode 100644 index 000000000..838ccf2a3 --- /dev/null +++ b/tests/Service/ActivityPub/TagManagerTest.php @@ -0,0 +1,38 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Service\ActivityPub; + +use OCA\Social\Service\ActivityPub\TagManager; +use OCP\IRequest; +use Test\TestCase; + +class TagManagerTest extends TestCase { + + public function localUriProvider(): array { + return [ + [null, 'helloworld.com', false], + ['https://helloworld.com', 'helloworld.com', true], + ['https://helloworld.com/rehie', 'helloworld.com', true], + ['https://helloworld.com:3000/rehie', 'helloworld.com', false], + ['https://helloworld1.com', 'helloworld.com', false], + ['https://floss.social/@carlschwan', 'helloworld.com', false], + ]; + } + + /** + * @dataProvider localUriProvider + */ + public function testIsLocalUri(?string $url, string $localDomain, bool $result): void { + $request = $this->createMock(IRequest::class); + $request->expects($this->any()) + ->method('getServerHost') + ->willReturn($localDomain); + $tagManager = new TagManager($request); + $this->assertSame($tagManager->isLocalUri($url), $result); + } +} diff --git a/tests/Service/Feed/PostDeliveryServiceTest.php b/tests/Service/Feed/PostDeliveryServiceTest.php new file mode 100644 index 000000000..2559d0622 --- /dev/null +++ b/tests/Service/Feed/PostDeliveryServiceTest.php @@ -0,0 +1,90 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Service\Feed; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Service\Feed\PostDeliveryService; +use OCA\Social\Service\Feed\FeedManager; +use OCA\Social\Service\ProcessMentionsService; +use OCP\DB\ORM\IEntityManager; +use OCP\Server; +use PHPUnit\Framework\MockObject\MockClass; +use Test\TestCase; + +class PostDeliveryServiceTest extends TestCase { + private ?Account $account1 = null; + private ?Account $account2 = null; + private ?Account $account3 = null; + private ?AccountFinder $accountFinder = null; + private ?PostDeliveryService $postDeliveryService = null; + /** @var MockClass&FeedManager */ + private $feedManager; + + public function setUp(): void { + parent::setUp(); + + $em = Server::get(IEntityManager::class); + + $this->account1 = Account::newLocal('user1', 'user1', 'User1'); + $this->account2 = Account::newLocal('user2', 'user2', 'User2'); + $this->account3 = Account::newLocal('user3', 'user3', 'User3'); + $this->account2->follow($this->account1); + + $em->persist($this->account1); + $em->persist($this->account2); + $em->persist($this->account3); + $em->flush(); + + $this->accountFinder = Server::get(AccountFinder::class); + $this->feedManager = $this->createMock(FeedManager::class); + $this->postDeliveryService = new PostDeliveryService($this->feedManager, $this->accountFinder); + } + + public function tearDown(): void { + $em = Server::get(IEntityManager::class); + $em->remove($this->account1); + $em->remove($this->account2); + $em->remove($this->account3); + $em->flush(); + + parent::tearDown(); + } + + public function testCreateBasicStatus(): void { + $status = new Status(); + $status->setAccount($this->account1); + $status->setText('Hello world!'); + $status->setLocal(true); + $this->feedManager->expects($this->exactly(2)) + ->method('addToHome') + ->withConsecutive( + [$this->account1->getId(), $status], // self + [$this->account2->getId(), $status] // follower + ); + $this->postDeliveryService->run($status); + } + + public function testCreateBasicStatusWithLocalMention(): void { + $status = new Status(); + $status->setAccount($this->account1); + $status->setText('Hello world @user3!'); + $status->setLocal(true); + $mentionService = \OCP\Server::get(ProcessMentionsService::class); + $mentionService->run($status); + $this->feedManager->expects($this->exactly(2)) + ->method('addToHome') + ->withConsecutive( + [$this->account1->getId(), $status], // self + [$this->account2->getId(), $status], // follower + [$this->account3->getId(), $status] // mentioned user + ); + $this->postDeliveryService->run($status); + } +} diff --git a/tests/Service/FeedManagerTest.php b/tests/Service/FeedManagerTest.php new file mode 100644 index 000000000..88e05a9bd --- /dev/null +++ b/tests/Service/FeedManagerTest.php @@ -0,0 +1,84 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Service; + +use OCA\Social\Entity\Account; +use OCA\Social\Entity\Status; +use OCA\Social\Service\AccountFinder; +use OCA\Social\Service\Feed\FeedManager; +use OCA\Social\Service\Feed\PostDeliveryService; +use OCP\DB\ORM\IEntityManager; +use OCP\IDBConnection; +use OCP\Server; +use phpseclib3\Exception\FileNotFoundException; +use PHPUnit\Framework\MockObject\MockClass; +use Test\TestCase; + +class FeedManagerTest extends TestCase { + + private ?Account $account1 = null; + private ?Account $account2 = null; + private ?Account $account3 = null; + private ?AccountFinder $accountFinder = null; + private ?PostDeliveryService $postDeliveryService = null; + /** @var MockClass&FeedManager */ + private $feedManager; + private IEntityManager $em; + + public function setUp(): void { + parent::setUp(); + + $this->em = Server::get(IEntityManager::class); + + $this->account1 = Account::newLocal('user1', 'user1', 'User1'); + $this->account2 = Account::newLocal('user2', 'user2', 'User2'); + $this->account3 = Account::newLocal('user3', 'user3', 'User3'); + + $this->em->persist($this->account1); + $this->em->persist($this->account2); + $this->em->persist($this->account3); + $this->em->flush(); + + $this->accountFinder = Server::get(AccountFinder::class); + $this->feedManager = $this->createMock(FeedManager::class); + $this->postDeliveryService = new PostDeliveryService($this->feedManager, $this->accountFinder); + } + + public function tearDown(): void { + Server::get(IDBConnection::class)->executeStatement('DELETE from **PREFIX**social_status'); + parent::tearDown(); + } + + public function testFollowAccont(): void { + $feedManager = Server::get(FeedManager::class); + + for ($i = 0; $i < 100; $i++) { + $status = new Status(); + $status->setAccount($this->account2); + $status->setText('Hello world!'); + $this->em->persist($status); + $this->em->flush(); + $feedManager->addToHome($this->account2->getId(), $status); + + $status1 = new Status(); + $status1->setAccount($this->account3); + $status1->setText('Hello world!'); + $this->em->persist($status1); + $this->em->flush(); + $feedManager->addToHome($this->account3->getId(), $status1); + } + + $status->setLocal(true); + $this->account1->follow($this->account2); + $feedManager->mergeIntoHome($this->account2, $this->account1); + + $status->setLocal(true); + $this->account1->follow($this->account3); + $feedManager->mergeIntoHome($this->account3, $this->account1); + } +} diff --git a/tests/Service/InstanceUtilsTest.php b/tests/Service/InstanceUtilsTest.php new file mode 100644 index 000000000..dda107824 --- /dev/null +++ b/tests/Service/InstanceUtilsTest.php @@ -0,0 +1,33 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Social\Tests\Service; + +use OCA\Social\InstanceUtils; +use OCP\IURLGenerator; +use Test\TestCase; + +class InstanceUtilsTest extends TestCase { + private InstanceUtils $instanceUtils; + + public function setUp(): void { + parent::setUp(); + $generator = $this->createMock(IURLGenerator::class); + $generator->expects($this->once()) + ->method('getAbsoluteUrl') + ->willReturn('https://hello.world.social/'); + $this->instanceUtils = new InstanceUtils($generator); + } + + public function testInstanceName(): void { + $this->assertSame('hello.world.social', $this->instanceUtils->getLocalInstanceName('/')); + } + + public function testInstanceUrl(): void { + $this->assertSame('https://hello.world.social', $this->instanceUtils->getLocalInstanceUrl('/')); + } +} diff --git a/webpack.common.js b/webpack.common.js index 3f7ba387d..d810fe386 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -6,6 +6,7 @@ module.exports = { entry: { social: path.join(__dirname, 'src', 'main.js'), ostatus: path.join(__dirname, 'src', 'ostatus.js'), + 'settings-personal': path.join(__dirname, 'src', 'settings-personal.js'), }, output: { path: path.resolve(__dirname, './js'),
+ {{ t('social', 'Make your post publicly visible') }} +
+ {{ t('social', 'Make your post visible only to the user of this instance') }} +
+ {{ t('social', 'Make your post visible only to your follower') }} +
+ {{ t('social', 'Manually control who can follow you by approving follow requests') }} +