diff --git a/README.md b/README.md index a0539781..afd6c060 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ This app allows generating reports of shares on the system. ## Usage -### Command +### Commands + +#### List ```sh ./occ sharing:list [-u|--user [USER]] [-p|--path [PATH]] [-t|--token [TOKEN]] [-f|--filter [FILTER]] [-o|--output FORMAT] @@ -13,7 +15,7 @@ This app allows generating reports of shares on the system. Without options, the command yields the unfiltered list of all shares.\ With options, the list is narrowed down using the filters set. -### Options +##### Options * `-u [USER]` or `--user [USER]`\ List only shares of the given user. @@ -27,6 +29,33 @@ With options, the list is narrowed down using the filters set. * `-o FORMAT` or `--output FORMAT`\ Set the output format (json or csv, default is json). +#### Send + +```sh +./occ sharing:send [-u|--user USER] [-p|--path PATH] [-t|--token TOKEN] [-f|--filter FILTER] [-o|--output FORMAT] +``` + +Without options, the command yields the unfiltered list of all shares.\ +With options, the list is narrowed down using the filters set. + +##### Options + +* `-r` or `--recipients`\ + Recipients users of generated reports. +* `-x` or `--target-path`\ + Generated reports will be stored on this path. +* `-d` or `--diff`\ + Create a differential report in json format from the last available report. +* `-u [USER]` or `--user [USER]`\ + List only shares of the given user. +* `-p [PATH]` or `--path [PATH]`\ + List only shares within the given path. +* `-t [TOKEN]` or `--token [TOKEN]`\ + List only shares that use a token that (at least partly) matches the argument. +* `-f [FILTER]` or `--filter [FILTER]`\ + List only shares where the TYPE matches the argument.\ + Possible values for the filter argument: {owner, initiator, recipient} + ## Examples To better illustrate how the app work see the examples below: diff --git a/appinfo/info.xml b/appinfo/info.xml index 959027ba..fcba62d4 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -17,5 +17,6 @@ OCA\ShareListing\Command\ListShares + OCA\ShareListing\Command\SendShares diff --git a/composer.json b/composer.json index ecc07ef2..0a2fe6e5 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "license": "MIT", "require": { "nikic/iter": "^2.2", - "symfony/serializer": "^5.4" + "symfony/serializer": "^5.4", + "swaggest/json-diff": "^3.9" }, "require-dev": { "phpunit/phpunit": "^9", diff --git a/composer.lock b/composer.lock index c9c367d4..a0876f4f 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": "280059a3e5c545fce72e8e3f92894660", + "content-hash": "2e26a4680f38483a82654971170dc65d", "packages": [ { "name": "nikic/iter", @@ -56,6 +56,49 @@ }, "time": "2021-08-02T15:04:32+00:00" }, + { + "name": "swaggest/json-diff", + "version": "v3.9.0", + "source": { + "type": "git", + "url": "https://github.com/swaggest/json-diff.git", + "reference": "ff3a7921e9f1aa096067eb541fcfd0e7611c558c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swaggest/json-diff/zipball/ff3a7921e9f1aa096067eb541fcfd0e7611c558c", + "reference": "ff3a7921e9f1aa096067eb541fcfd0e7611c558c", + "shasum": "" + }, + "require": { + "ext-json": "*" + }, + "require-dev": { + "phperf/phpunit": "4.8.37" + }, + "type": "library", + "autoload": { + "psr-4": { + "Swaggest\\JsonDiff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Viacheslav Poturaev", + "email": "vearutop@gmail.com" + } + ], + "description": "JSON diff/rearrange/patch/pointer library for PHP", + "support": { + "issues": "https://github.com/swaggest/json-diff/issues", + "source": "https://github.com/swaggest/json-diff/tree/v3.9.0" + }, + "time": "2022-08-29T15:04:08+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v2.5.1", diff --git a/lib/Command/AbstractCommand.php b/lib/Command/AbstractCommand.php new file mode 100644 index 00000000..7a3bf562 --- /dev/null +++ b/lib/Command/AbstractCommand.php @@ -0,0 +1,78 @@ + + * + * @author Florent Poinsaut + * @author Roeland Jago Douma + * @author John Molakvoæ + * + * @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\ShareListing\Command; + +use OCA\ShareListing\Service\SharesList; +use OC\Core\Command\Base; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +abstract class AbstractCommand extends Base { + /** @var SharesList */ + protected $sharesList; + + public function __construct(SharesList $sharesList) { + parent::__construct(); + + $this->sharesList = $sharesList; + } + + public function configure() { + $this->addOption( + 'user', + 'u', + InputOption::VALUE_OPTIONAL, + 'Will list shares of the given user' + ) + ->addOption( + 'path', + 'p', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given path' + )->addOption( + 'token', + 't', + InputOption::VALUE_OPTIONAL, + 'Will only consider the given token' + )->addOption( + 'filter', + 'f', + InputOption::VALUE_OPTIONAL, + 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' + ); + } + + protected function getOptions(InputInterface $input): array { + $user = $input->getOption('user'); + $path = $input->getOption('path'); + $token = $input->getOption('token'); + $filter = $this->sharesList->filterStringToInt($input->getOption('filter')); + + return [$user, $path, $token, $filter]; + } +} diff --git a/lib/Command/ListShares.php b/lib/Command/ListShares.php index d2407c5f..6e94d604 100644 --- a/lib/Command/ListShares.php +++ b/lib/Command/ListShares.php @@ -27,69 +27,16 @@ namespace OCA\ShareListing\Command; use iter; -use OCA\ShareListing\Service\SharesList; -use OCP\Files\IRootFolder; -use OCP\IUser; -use OCP\IUserManager; -use OCP\Share\IManager as ShareManager; -use OC\Core\Command\Base; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class ListShares extends Base { - - /** @var ShareManager */ - private $shareManager; - - /** @var IUserManager */ - private $userManager; - - /** @var IRootFolder */ - private $rootFolder; - - /** @var SharesList */ - private $sharesList; - - public function __construct(ShareManager $shareManager, - IUserManager $userManager, - IRootFolder $rootFolder, - SharesList $sharesList) { - parent::__construct(); - - $this->shareManager = $shareManager; - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; - $this->sharesList = $sharesList; - - } - +class ListShares extends AbstractCommand { public function configure() { + parent::configure(); + $this->setName('sharing:list') ->setDescription('List who has access to shares by owner') - ->addOption( - 'user', - 'u', - InputOption::VALUE_OPTIONAL, - 'Will list shares of the given user' - ) - ->addOption( - 'path', - 'p', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given path' - )->addOption( - 'token', - 't', - InputOption::VALUE_OPTIONAL, - 'Will only consider the given token' - )->addOption( - 'filter', - 'f', - InputOption::VALUE_OPTIONAL, - 'Filter shares, possible values: owner, initiator, recipient, token, has-expiration, no-expiration' - ) ->addOption( 'output', 'o', @@ -98,12 +45,9 @@ public function configure() { 'json' ); } - + protected function execute(InputInterface $input, OutputInterface $output): int { - $user = $input->getOption('user'); - $path = $input->getOption('path'); - $token = $input->getOption('token'); - $filter = $this->sharesList->filterStringToInt($input->getOption('filter')); + [$user, $path, $token, $filter] = $this->getOptions($input); $outputOpt = $input->getOption('output'); $shares = iter\toArray($this->sharesList->getFormattedShares($user, $filter, $path, $token)); diff --git a/lib/Command/SendShares.php b/lib/Command/SendShares.php new file mode 100644 index 00000000..5127f4fe --- /dev/null +++ b/lib/Command/SendShares.php @@ -0,0 +1,140 @@ + + * + * @author Florent Poinsaut + * @author Roeland Jago Douma + * @author John Molakvoæ + * + * @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\ShareListing\Command; + +use OCA\ShareListing\Service\ReportSender; +use OCA\ShareListing\Service\SharesList; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SendShares extends AbstractCommand { + /** @var IUserManager */ + private $userManager; + + /** @var ReportSender */ + private $reportSender; + + public function __construct( + IUserManager $userManager, + ReportSender $reportSender, + SharesList $sharesList + ) { + parent::__construct($sharesList); + + $this->userManager = $userManager; + $this->reportSender = $reportSender; + } + + public function configure() { + parent::configure(); + + $this->setName('sharing:send') + ->setDescription('Send list who has access to shares by owner') + ->addOption( + 'diff', + 'd', + InputOption::VALUE_NONE, + 'Create a differential report in json format from the last available report' + ) + ->addOption( + 'recipients', + 'r', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Recipients users of generated reports' + ) + ->addOption( + 'target-path', + 'x', + InputOption::VALUE_REQUIRED, + 'Generated reports will be stored on this path' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $this->checkAllRequiredOptionsAreNotEmpty($input); + + [$user, $path, $token, $filter] = $this->getOptions($input); + $diff = $input->getOption('diff'); + $recipients = $input->getOption('recipients'); + $targetPath = $input->getOption('target-path'); + + $dateTime = new \DateTimeImmutable(); + + foreach ($recipients as $recipient) { + $this->reportSender->createReport( + $recipient, + $targetPath, + $dateTime, + $user, + $filter, + $path, + $token + ); + + if ($diff) { + $this->reportSender->diff($recipient, $targetPath); + } + + $this->reportSender->sendReport($recipient, $dateTime); + } + return 0; + } + + protected function checkAllRequiredOptionsAreNotEmpty(InputInterface $input) + { + $errors = []; + + if (!$input->getOption('target-path')) { + $errors[] = 'The required option --target-path is not set or is empty.'; + } + + $recipients = $this->getDefinition()->getOption('recipients'); + + /** @var InputOption $recipient */ + foreach ([$recipients] as $recipient) { + $name = $recipient->getName(); + $values = $input->getOption($name); + + if ($values === null || $values === '' || ($recipient->isArray() && empty($values))) { + $errors[] = sprintf('The required option --%s is not set or is empty.', $name); + } + + foreach ($values as $value) { + if (!$this->userManager->userExists($value)) { + $errors[] = sprintf('The recipient user %s does not exist.', $value); + } + } + } + + if (count($errors)) { + throw new \InvalidArgumentException(implode("\n\n", $errors)); + } + } +} diff --git a/lib/Service/ReportSender.php b/lib/Service/ReportSender.php new file mode 100644 index 00000000..3c47ac00 --- /dev/null +++ b/lib/Service/ReportSender.php @@ -0,0 +1,310 @@ + + * + * @author Florent Poinsaut + * @author Robin Appelman + * + * @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\ShareListing\Service; + +use iter; +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OC\Files\Search\SearchOrder; +use OC\Files\Search\SearchQuery; +use OCA\ShareListing\Service\SharesList; +use OCP\Defaults; +use OCP\Files\FileInfo; +use OCP\Files\Folder; +use OCP\Files\InvalidDirectoryException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOrder; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Mail\IMailer; +use OCP\Util; +use Psr\Log\LoggerInterface; +use Swaggest\JsonDiff\JsonDiff; + +class ReportSender +{ + protected const REPORT_NAME = ' - Shares report.'; + + /** @var string */ + private $appName; + /** @var Iconfig */ + private $config; + /** @var ?array */ + protected $diffReport = null; + + private $mailer; + private $userManager; + private $defaults; + private $l10nFactory; + private $logger; + + /** @var array */ + protected $reports = []; + /** @var SharesList */ + private $sharesList; + /** @var IRootFolder */ + private $root; + /** @var IURLGenerator */ + protected $url; + + public function __construct( + string $appName, + IConfig $config, + IMailer $mailer, + IUserManager $userManager, + Defaults $defaults, + IFactory $l10nFactory, + LoggerInterface $logger, + SharesList $sharesList, + IRootFolder $root, + IURLGenerator $url + ) { + $this->appName = $appName; + $this->config = $config; + $this->mailer = $mailer; + $this->userManager = $userManager; + $this->defaults = $defaults; + $this->l10nFactory = $l10nFactory; + $this->logger = $logger; + $this->sharesList = $sharesList; + $this->root = $root; + $this->url = $url; + } + + public function createReport( + string $recipient, + string $targetPath, + \DateTimeImmutable $dateTime, + ?string $userId = '', + int $filter = SharesList::FILTER_NONE, + string $path = null, + string $token = null + ) { + $userFolder = $this->root->getUserFolder($recipient); + + if ($userFolder->nodeExists($targetPath)) { + /** @var Folder $folder */ + $folder = $userFolder->get($targetPath); + if ($folder->getType() !== FileInfo::TYPE_FOLDER) { + $this->logger->warning( + 'Target path ' . $targetPath . ' is not a folder', + ['app' => $this->appName] + ); + } + } else { + $folder = $userFolder->newFolder($targetPath); + } + + $formats = ['json', 'csv']; + $formatedDateTime = $dateTime->format('YmdHi'); + foreach ($formats as $key => $format) { + $fileName = $formatedDateTime . self::REPORT_NAME . $format; + if (!array_key_exists($fileName, $this->reports)) { + if ($key === array_key_first($formats)) { + $shares = iter\toArray($this->sharesList->getFormattedShares($userId, $filter, $path, $token)); + } + $reportFile = $folder->newFile($fileName); + $data = $this->sharesList->getSerializedShares($shares, $format); + $reportFile->putContent($data); + $this->reports[$reportFile->getName()] = [ + 'url' => $this->url->linkToRouteAbsolute( + 'files.View.showFile', + ['fileid' => $reportFile->getId()] + ), + 'data' => $data + ]; + } + } + } + + public function sendReport(string $recipient, \DateTimeImmutable $dateTime) + { + $defaultLanguage = $this->config->getSystemValue('default_language', 'en'); + $userLanguages = $this->config->getUserValue($recipient, 'core', 'lang'); + $language = (!empty($userLanguages)) ? $userLanguages : $defaultLanguage; + + $l10n = $this->l10nFactory->get('shareslist', $language); + + $template = $this->mailer->createEMailTemplate('shareslist.Notification', [ + 'date-time' => $dateTime, + ]); + + $formatedDateTime = $dateTime->format(\DateTimeInterface::COOKIE); + $template->setSubject($l10n->t('Shares reports generated on %s', $formatedDateTime)); + $template->addHeader(); + $template->addBodyText('You can find the list of shares reports generated on ' . $formatedDateTime . ':'); + + foreach ($this->reports as $name => $value) { + $template->addBodyListItem( + '' . $name . '', + '', + '', + $name . ': ' . $value['url'] + ); + } + if ($this->diffReport !== null) { + $template->addBodyText('You can also find the differential between this report and the previous one:'); + $template->addBodyListItem( + '' . $this->diffReport['fileName'] . '', + '', + '', + $this->diffReport['fileName'] . ': ' . $this->diffReport['url'] + ); + } + + $template->addBodyText('Permissions mapping:'); + $template->addBodyListItem('1 = read'); + $template->addBodyListItem('2 = update'); + $template->addBodyListItem('4 = create'); + $template->addBodyListItem('8 = delete'); + $template->addBodyListItem('16 = share'); + $template->addBodyListItem('31 = all (default: 31, for public shares: 1)'); + + $template->addFooter('', $language); + + $message = $this->mailer->createMessage(); + $message->setTo([$this->getEmailAdressFromUserId($recipient)]); + $message->useTemplate($template); + $message->setFrom([Util::getDefaultEmailAddress('no-reply') => $this->defaults->getName()]); + + try { + $this->mailer->send($message); + } catch (\Exception $e) { + $this->logger->error($e->getMessage()); + return; + } + } + + protected function getEmailAdressFromUserId(string $userId): ?string + { + $user = $this->userManager->get($userId); + if ($user === null) { + $this->logger->warning( + 'ShareList error, the user "' . $userId . '" does not exist.', + ['app' => $this->appName] + ); + return null; + } + + $email = $user->getEMailAddress(); + if ($email === null || $email === '') { + $this->logger->warning( + 'ShareList error, the user "' . $userId . '" does not have an email set up.', + ['app' => $this->appName] + ); + return null; + } + + return $email; + } + + public function diff( + string $userId, + string $dir + ) { + $userFolder = $this->root->getUserFolder($userId); + + if ($userFolder->nodeExists($dir)) { + /** @var Folder $folder */ + $folder = $userFolder->get($dir); + if ($folder->getType() !== FileInfo::TYPE_FOLDER) { + throw new InvalidDirectoryException('Invalid directory, "' . $dir . '" not a folder'); + } + } else { + throw new InvalidDirectoryException('Invalid directory, "' . $dir . '" does not exist'); + } + + $search = $userFolder->search( + new SearchQuery( + new SearchBinaryOperator( + ISearchBinaryOperator::OPERATOR_OR, + [ + new SearchComparison( + ISearchComparison::COMPARE_LIKE, + 'name', + '%' . self::REPORT_NAME . 'json' + ) + ] + ), + 1, + 1, + [new SearchOrder(ISearchOrder::DIRECTION_DESCENDING, 'mtime')] + ) + ); + + if (empty($search)) { + throw new NotFoundException('No previous report found on this folder.'); + } + + $previousFile = $search[0]; + $previousFilename = $previousFile->getName(); + $previousDateTime = substr($previousFilename, 0, 12); + $previousContent = json_decode($previousFile->getContent()); + $previousContentWithId = []; + foreach ($previousContent as $value) { + $previousContentWithId[$value->id] = $value; + } + + $newFilename = array_keys($this->reports)[0]; + $newDateTime = substr($newFilename, 0, 12); + $newContent = json_decode(array_values($this->reports)[0]['data']); + $newContentWithId = []; + foreach ($newContent as $value) { + $newContentWithId[$value->id] = $value; + } + + $jsonDiff = new JsonDiff( + $previousContentWithId, + $newContentWithId, + JsonDiff::REARRANGE_ARRAYS + JsonDiff::COLLECT_MODIFIED_DIFF + ); + + $reportFilename = $previousDateTime . ' - ' . $newDateTime . ' - Shares report diff.json'; + $reportFile = $folder->newFile($reportFilename); + $res = [ + 'added' => $jsonDiff->getAdded(), + 'removed' => $jsonDiff->getRemoved(), + 'modified' => $jsonDiff->getModifiedDiff() + ]; + + $reportFile->putContent(json_encode($res, JSON_PRETTY_PRINT)); + + $this->diffReport = [ + 'url' => $this->url->linkToRouteAbsolute( + 'files.View.showFile', + ['fileid' => $reportFile->getId()] + ), + 'fileName' => $reportFilename + ]; + } +}