From 0930947fe46b0290296b29d4446bde3510547f6e Mon Sep 17 00:00:00 2001 From: sam-19 <26563023+sam-19@users.noreply.github.com> Date: Sun, 11 Oct 2020 16:08:01 +0300 Subject: [PATCH 01/10] Add contentRating property Signed-off-by: sam-19 <26563023+sam-19@users.noreply.github.com> --- src/components/RecipeEdit.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/RecipeEdit.vue b/src/components/RecipeEdit.vue index 1991de642..2d363814b 100644 --- a/src/components/RecipeEdit.vue +++ b/src/components/RecipeEdit.vue @@ -38,6 +38,7 @@ export default { id: 0, name: null, description: '', + contentRating: 0, url: '', image: '', prepTime: '', From fa89d3035b8fb6bab2d6484c27194abed8b5a1e8 Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Thu, 22 Oct 2020 13:25:32 +0200 Subject: [PATCH 02/10] Added routes for rating system Signed-off-by: Christian Wolf --- appinfo/routes.php | 3 +++ lib/Controller/RatingController.php | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 lib/Controller/RatingController.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 42b9236c5..67affc0cb 100755 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -28,6 +28,9 @@ ['name' => 'main#category', 'url' => '/api/category/{category}', 'verb' => 'GET'], ['name' => 'main#tags', 'url' => '/api/tags/{keywords}', 'verb' => 'GET'], ['name' => 'main#search', 'url' => '/api/search/{query}', 'verb' => 'GET'], + /* Rating routes */ + ['name' => 'rating#save', 'url' => '/api/recipes/{id}/rating', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], + ['name' => 'rating#remove', 'url' => '/api/recipes/{id}/rating', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], ], /* API resources */ diff --git a/lib/Controller/RatingController.php b/lib/Controller/RatingController.php new file mode 100644 index 000000000..63ea8dc74 --- /dev/null +++ b/lib/Controller/RatingController.php @@ -0,0 +1,17 @@ + Date: Sat, 24 Oct 2020 14:33:54 +0200 Subject: [PATCH 03/10] Corrected namespace of Controller class Signed-off-by: Christian Wolf --- lib/Controller/RatingController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/Controller/RatingController.php b/lib/Controller/RatingController.php index 63ea8dc74..d688b9df2 100644 --- a/lib/Controller/RatingController.php +++ b/lib/Controller/RatingController.php @@ -1,5 +1,7 @@ Date: Sun, 1 Nov 2020 15:41:41 +0100 Subject: [PATCH 04/10] Updating new routes to be versioned. Signed-off-by: Christian Wolf --- appinfo/routes.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index 67affc0cb..e26662901 100755 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -29,8 +29,8 @@ ['name' => 'main#tags', 'url' => '/api/tags/{keywords}', 'verb' => 'GET'], ['name' => 'main#search', 'url' => '/api/search/{query}', 'verb' => 'GET'], /* Rating routes */ - ['name' => 'rating#save', 'url' => '/api/recipes/{id}/rating', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], - ['name' => 'rating#remove', 'url' => '/api/recipes/{id}/rating', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], + ['name' => 'rating#save', 'url' => '/api/v1/recipes/{id}/rating', 'verb' => 'POST', 'requirements' => ['id' => '\d+']], + ['name' => 'rating#remove', 'url' => '/api/v1/recipes/{id}/rating', 'verb' => 'DELETE', 'requirements' => ['id' => '\d+']], ], /* API resources */ From 9a06126a46320aa7110bff4236966214f6f85776 Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Sun, 1 Nov 2020 15:43:03 +0100 Subject: [PATCH 05/10] Added a basic CHANGELOG entry Signed-off-by: Christian Wolf --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e41ea0196..dce48a4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ [#383](https://github.com/nextcloud/cookbook/pull/383/) @christianlupus - Unit tests for JSON object service [#387](https://github.com/nextcloud/cookbook/pull/387) @TobiasMie +- Rating recipes for nextcloud users + [#342](https://github.com/nextcloud/cookbook/pull/342/) @sam-19 ### Changed - Switch of project ownership to neextcloud organization in GitHub From 456ec3389e7487e0a85adbfff07afb6840922579 Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Thu, 12 Nov 2020 14:44:16 +0100 Subject: [PATCH 06/10] Avoid changes to the ratings through normal interface Signed-off-by: Christian Wolf --- lib/Service/RecipeService.php | 46 +++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/lib/Service/RecipeService.php b/lib/Service/RecipeService.php index 4fa47f83d..134ec250c 100755 --- a/lib/Service/RecipeService.php +++ b/lib/Service/RecipeService.php @@ -664,6 +664,8 @@ public function addRecipe($json) // Create/move recipe folder $user_folder = $this->getFolderForUser(); $recipe_folder = null; + + $this->dropRatingChange($json, $user_folder); // Recipe already has an id, update it if (isset($json['id']) && $json['id']) { @@ -1171,4 +1173,48 @@ private function cleanUpString($str, $preserve_newlines = false, $remove_slashes return $str; } + + /** + * Drop any changes of the ratings of the JSON data in favor of the currently stored ones. + * This avoid unintended or malicious changes to the rating data. + * + * @param string $json The JSON data to be checked + * @param Folder $userFolder The folder of the cookbook to look for the recipe + */ + private function dropRatingChange(string &$json, Folder $userFolder) : void + { + if(isset($json['id'])) + { + // We have an existing recipe. Copy the ratings from there to make sure, + // no change has been made. + $recipeFolder = $userFolder->getById((int) $json['id'])[0]; + $recipeFile = $this->getRecipeFileByFolderId($recipeFolder->getId()); + $oldJson = json_decode($recipeFile->getContent()); + + if(isset($oldJson['contentRating'])) + { + $json['contentRating'] = $oldJson['contentRating']; + } + + if(isset($oldJson['aggregateRating'])) + { + $json['aggregateRating'] = $oldJson['aggregateRating']; + } + } + + // Ensure the rating fields do exist in all cases. + if(!isset($json['contentRating'])) + { + $json['contentRating'] = []; + } + + if(!isset($json['aggreegateRating'])) + { + $json['aggregateRating'] = array( + '@type' => 'AggregateRating', + 'ratingCount' => 0 + ); + } + } + } From 3c42b975874714db41a5c13dfb19f480cef74936 Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Thu, 12 Nov 2020 19:27:14 +0100 Subject: [PATCH 07/10] Adding a Rating controller that allow to store the rating in the JSON file Signed-off-by: Christian Wolf --- lib/Controller/RatingController.php | 64 +++++- lib/Service/RatingService.php | 311 ++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 lib/Service/RatingService.php diff --git a/lib/Controller/RatingController.php b/lib/Controller/RatingController.php index d688b9df2..e0aced2b5 100644 --- a/lib/Controller/RatingController.php +++ b/lib/Controller/RatingController.php @@ -3,17 +3,69 @@ namespace OCA\Cookbook\Controller; use OCP\AppFramework\Controller; +use OCA\Cookbook\Service\DbCacheService; +use OCA\Cookbook\Service\RatingService; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http; +/** + * A controller that allows altering of the ratings of a recipe + * + * @author Christian Wolf + * + */ class RatingController extends Controller { - public function __construct(?string $UserId) - {} + /** + * @var DbCacheService + */ + private $dbCacheService; - public function save() - {} + /** + * @var RatingService + */ + private $ratingService; - public function remove() - {} + /** + * @var string + */ + private $userId; + + public function __construct( + ?string $UserId, DbCacheService $dbCacheService, RatingService $ratingService) + { + $this->dbCacheService = $dbCacheService; + $this->ratingService = $ratingService; + $this->userId = $UserId; + } + + /** + * Add or update a rating for the current user to a recipe + * @param int $id The id of the recipe to be rated + */ + public function save($id) + { + $this->dbCacheService->triggerCheck(); + + $data = []; + parse_str(file_get_contents('php://input'), $data); + + $this->ratingService->addRating((int) $id, $this->userId, $data); + + return new DataResponse([], Http::STATUS_OK, ['Content-Type' => 'application/json']); + } + + /** + * Remove a rating from a recipe for the currently logged in user + * @param int $id The id of the recipe to be altered + */ + public function remove($id) + { + $this->dbCacheService->triggerCheck(); + $this->ratingService->removeRating($id, $this->userId); + + return new DataResponse([], Http::STATUS_OK, ['Content-Type' => 'application/json']); + } } diff --git a/lib/Service/RatingService.php b/lib/Service/RatingService.php new file mode 100644 index 000000000..7d83576b6 --- /dev/null +++ b/lib/Service/RatingService.php @@ -0,0 +1,311 @@ +recipeService = $recipeService; + $this->jsonService = $jsonService; + $this->l = $l10n; + } + + /** + * Remove a rating from a recipe + * @param int $recipeId The recipe to remove the rating from + * @param string $userId The user of the rating that should be removed + * @throws \Exception If either the JSON is invalid or the recipe was not found. + */ + public function removeRating(int $recipeId, string $userId) + { + $json = $this->recipeService->getRecipeById($recipeId); + + if($json === null) + { + throw new \Exception($this->l->t('No matching recipe was found.')); + } + + $json = $this->canonicalizeRatings($json); + + $idx = $this->searchForRating($json, $userId); + + if($idx >= 0) + { + // We found the rating + unset($json[self::CONTENT_RATING][$idx]); + + $json = $this->updateAggregateRating($json); + + // Undo our canonicalization + $json = $this->uncanonicalizeRatings($json); + + // Save the file on disk + $recipeFile = $this->recipeService->getRecipeFileByFolderId($recipeId); + $recipeFile->putContent(json_encode($json)); + + $recipeFile->getParent()->touch(); + } + } + + /** + * Add a rating to a recipe + * @param int $recipeId The recipe to add the rating to + * @param string $userId The user who gave the rating + * @param int $rating The rating value + * @throws \Exception If either the JSON is invalid or the recipe was not found. + */ + public function addRating(int $recipeId, string $userId, int $rating) : void + { + $json = $this->recipeService->getRecipeById($recipeId); + + if($json === null) + { + throw new \Exception($this->l->t('No matching recipe was found.')); + } + + $json = $this->canonicalizeRatings($json); + + $idx = $this->searchForRating($json, $userId); + + if($idx == -1) + { + // Add a new rating + $json[self::CONTENT_RATING][] = $this->createRating($rating, $userId); + } + else + { + // Replace the corresponding rating + $json[self::CONTENT_RATING][$idx] = $this->createRating($rating, $userId); + } + + $this->updateAggregateRating($json); + + // Undo our canonicalization + $json = $this->uncanonicalizeRatings($json); + + // Save the file on disk + $recipeFile = $this->recipeService->getRecipeFileByFolderId($recipeId); + $recipeFile->putContent(json_encode($json)); + + $recipeFile->getParent()->touch(); + } + + /** + * Create a schema.org [Rating object](https://schema.org/Rating) + * + * @param int $rating The numerical rating value + * @param string $userId The user id of the rating + * @return array The Rating object generated as an array + */ + private function createRating(int $rating, string $userId) : array + { + $ret = []; + + $ret['@type'] = 'Rating'; + $ret['ratingValue'] = $rating; + + $ret['author'] = array( + '@type' => 'Person', + 'identifier' => $userId + ); + + return $ret; + } + + /** + * Update the aggregate rating of the structure. + * + * The ratings **must** be canonicalized. + * + * @param array $json The object to parse + * @return array The updated structure + */ + private function updateAggregateRating(array $json) : array + { + $count = count($json[self::CONTENT_RATING]); + + $json['aggregateRating'] = array( + '@type' => 'AggregateRating', + 'ratingCount' => $count + ); + + if($count > 0) + { + // We have some ratings + $min = $max = -1; + $sum = 0; + + foreach($json[self::CONTENT_RATING] as $rating) + { + if($this->jsonService->isSchemaObject($rating, 'Rating') && + $this->jsonService->hasProperty($rating, 'ratingValue')) + { + // Shortcut for the value of the rating + $rv = $rating['ratingValue']; + + $min = ($min == -1) ? $rv : min([ $min, $rv ]); + $max = ($max == -1) ? $rv : max([ $max, $rv ]); + $sum += $rv; + } + } + + $json['aggregateRating']['bestRating'] = $max; + $json['aggregateRating']['worstRating'] = $min; + $json['aggregateRating']['ratingValue'] = (float) $sum / $count; + } + + return $json; + } + + /** + * Ensure that the contentRating property exists and is an array. + * + * By the schema.org standard, the rating might be either + * - non-existent + * - an empty array + * - a single string + * - a single Rating object + * - an array consisting of strings and/or Rating objects. + * + * For simpler processing this method eensures that the property + * exists and is always an array. + * The individual ratings are entries in this recipe. + * So, the number of entries in the property represent the count of ratings. + * + * @param string $json The JSON string to be canonicalized + * @return string The canonical JSON as string + */ + private function canonicalizeRatings(string $json) : string + { + if(! isset($json[self::CONTENT_RATING])) + { + // Ensure there is at leasst an empty array + $json[self::CONTENT_RATING] = []; + } + + if(is_string($json[self::CONTENT_RATING])) + { + // If there is only a single rating in form of text, put it into an array + $json[self::CONTENT_RATING] = array($json[self::CONTENT_RATING]); + } + + // We have for sure an array now. + + if($this->jsonService->isSchemaObject($json[self::CONTENT_RATING])) + { + // We do have an object as rating. Put it into a nested array for iterating + $json[self::CONTENT_RATING] = array( + $json[self::CONTENT_RATING] + ); + } + + return $json; + } + + /** + * Undo the changes by canonicalizeRatings + * + * This makes the JSON again compatible with the schema.org standard. + * + * @param string $json The JSON in canconical form + * @return string The standard conforming JSON as string + */ + private function uncanonicalizeRatings(string $json) : string + { + if(count($json[self::CONTENT_RATING]) == 0) + { + unset($json[self::CONTENT_RATING]); + } + else if(count($json[self::CONTENT_RATING]) == 1) + { + // Move the only entry to the top level. + $json[self::CONTENT_RATING] = $json[self::CONTENT_RATING][0]; + } + + return $json; + } + + /** + * Search all ratings for a rating with the given user id. + * + * Please note that the JSON data to be searched **must** be canonicalized! + * + * @param string $json The data of the recipe to look for ratings + * @param string $userId The user to look for + * @throws \Exception If an invalid type of rating was found in the JSON + * @return int The index of the rating in the array or -1 if no rating for the user was found. + */ + private function searchForRating(string $json, string $userId) : int + { + foreach($json[self::CONTENT_RATING] as $key => $val) + { + if(is_string($val)) + { + // Simple text rating found. We have no knowledge who wrote it. Skipping here + // XXX Do something useful? + continue; + } + + if(! $this->jsonService->isSchemaObject($val, 'Rating')) + { + // Some illegal object was found in the array. + throw new \Exception($this->l->t('Invalid type for rating found.')); + } + + if(! $this->jsonService->hasProperty($val, 'author') || + ! $this->jsonService->isSchemaObject($val['author'], 'Person')) + { + // We have no clue about the author. Skipping here. + // XXX Do something useful? + continue; + } + + if(! $this->jsonService->hasProperty($val['author'], 'identifier')) + { + // Without id of the author we cannot match it + continue; + } + + if($val['author']['identifier'] === $userId) + { + return $key; + } + } + + // Nothing was found + return -1; + } + +} From 6b83b6943882252b05c148ee138aa4f14602f17f Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Sat, 14 Nov 2020 18:46:25 +0100 Subject: [PATCH 08/10] Adding a basic structure to database Signed-off-by: Christian Wolf --- lib/Controller/RatingController.php | 4 +- .../Version000000Date20201114165448.php | 53 +++++++++++++++++ lib/Service/RatingService.php | 59 ++++++++++++++++++- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 lib/Migration/Version000000Date20201114165448.php diff --git a/lib/Controller/RatingController.php b/lib/Controller/RatingController.php index e0aced2b5..99a2798a4 100644 --- a/lib/Controller/RatingController.php +++ b/lib/Controller/RatingController.php @@ -2,11 +2,11 @@ namespace OCA\Cookbook\Controller; -use OCP\AppFramework\Controller; use OCA\Cookbook\Service\DbCacheService; use OCA\Cookbook\Service\RatingService; -use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; /** * A controller that allows altering of the ratings of a recipe diff --git a/lib/Migration/Version000000Date20201114165448.php b/lib/Migration/Version000000Date20201114165448.php new file mode 100644 index 000000000..ff56fb6af --- /dev/null +++ b/lib/Migration/Version000000Date20201114165448.php @@ -0,0 +1,53 @@ +createTable(self::RATINGS); + + // Set up columns + $tableRatings->addColumn('recipe_id', 'integer', [ + 'notnull' => true + ]); + $tableRatings->addColumn('user_id', 'string', [ + 'notnull' => true, + 'length' => 64 + ]); + $tableRatings->addColumn('rating', 'float', [ + 'notnull' => true + ]); + + // Set up indices + $tableRatings->addIndex(['recipe_id', 'user_id']); + // XXX ForeignKeyConstraints + + return $schema; + } + +} diff --git a/lib/Service/RatingService.php b/lib/Service/RatingService.php index 7d83576b6..9c65c8ac8 100644 --- a/lib/Service/RatingService.php +++ b/lib/Service/RatingService.php @@ -4,6 +4,8 @@ use OCP\IL10N; use OCA\Cookbook\JsonService; +use OCP\IDBConnection; +use OCP\DB\QueryBuilder\IQueryBuilder; class RatingService { @@ -23,6 +25,11 @@ class RatingService */ private $jsonService; + /** + * @var IDBConnection + */ + private $db; + /** * The name of the rating field */ @@ -34,12 +41,15 @@ class RatingService * @param RecipeService $recipeService * @param JsonService $jsonService * @param IL10N $l10n + * @param IDBConnection $db */ - public function __construct(RecipeService $recipeService, JsonService $jsonService, IL10N $l10n) + public function __construct(RecipeService $recipeService, JsonService $jsonService, + IL10N $l10n, IDBConnection $db) { $this->recipeService = $recipeService; $this->jsonService = $jsonService; $this->l = $l10n; + $this->db = $db; } /** @@ -67,6 +77,7 @@ public function removeRating(int $recipeId, string $userId) unset($json[self::CONTENT_RATING][$idx]); $json = $this->updateAggregateRating($json); + $this->updateDatabase($userId, $recipeId, $json['aggregateRating']); // Undo our canonicalization $json = $this->uncanonicalizeRatings($json); @@ -111,6 +122,7 @@ public function addRating(int $recipeId, string $userId, int $rating) : void } $this->updateAggregateRating($json); + $this->updateDatabase($userId, $recipeId, $json['aggregateRating']); // Undo our canonicalization $json = $this->uncanonicalizeRatings($json); @@ -189,6 +201,51 @@ private function updateAggregateRating(array $json) : array return $json; } + /** + * Update the ratings in the database + * @param string $userId The owner of the recipe + * @param int $recipeId The id or the recipe + * @param array $rating The aggregate rating array as a AggregareRating schema.org object + * @deprecated This should be moved to a DB helper class + */ + private function updateDatabase(string $userId, int $recipeId, array $rating) + { + + $qb = $this->db->getQueryBuilder(); + + $qb->delete('cookbook_ratings') + ->where(['recipe_id = :rid', 'user_id = :uid']); + + $qb->setParameter('uid', $userId, IQueryBuilder::PARAM_STR); + $qb->setParameter('rid', $recipeId, IQueryBuilder::PARAM_INT); + + $qb->execute(); + + if($rating['ratingCount'] > 0) + { + $qb = $this->db->getQueryBuilder(); + + $qb ->insert('cookbook_ratings') + ->values([ + 'recipe_id' => ':rid', + 'user_id' => ':uid', + 'rating' => ':rating' + ]); + + $qb->setParameters([ + 'rid' => $recipeId, + 'uid' => $userId, + 'rating' => $rating['ratingValue'] + ], [ + IQueryBuilder::PARAM_INT, + IQueryBuilder::PARAM_STR, + IQueryBuilder::PARAM_STR + ]); + + $qb->execute(); + } + } + /** * Ensure that the contentRating property exists and is an array. * From 8b53da8ff5341219ecd13264d0cbbf94ff244a9e Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Sat, 14 Nov 2020 21:51:07 +0100 Subject: [PATCH 09/10] Removed bug in migration Signed-off-by: Christian Wolf --- lib/Migration/Version000000Date20201114165448.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Migration/Version000000Date20201114165448.php b/lib/Migration/Version000000Date20201114165448.php index ff56fb6af..ee3d5e90e 100644 --- a/lib/Migration/Version000000Date20201114165448.php +++ b/lib/Migration/Version000000Date20201114165448.php @@ -27,7 +27,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** * @var ISchemaWrapper $schema */ - $schema = $schemaClosure; + $schema = $schemaClosure(); $tableRatings = $schema->createTable(self::RATINGS); From de4e25163963e4d1bb73dfd84f35d338889aa1d4 Mon Sep 17 00:00:00 2001 From: Christian Wolf Date: Sun, 15 Nov 2020 18:01:20 +0100 Subject: [PATCH 10/10] Removed bug regarding namespaces found in #387 Signed-off-by: Christian Wolf --- lib/Service/RatingService.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Service/RatingService.php b/lib/Service/RatingService.php index 9c65c8ac8..6b0a3e2d8 100644 --- a/lib/Service/RatingService.php +++ b/lib/Service/RatingService.php @@ -3,7 +3,6 @@ namespace OCA\Cookbook\Service; use OCP\IL10N; -use OCA\Cookbook\JsonService; use OCP\IDBConnection; use OCP\DB\QueryBuilder\IQueryBuilder;